diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index b35ba8d1c..f0db8a6f2 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -1,445 +1,469 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="container" owner="${vyos_conf_scripts_dir}/container.py"> <properties> <help>Container applications</help> <priority>450</priority> </properties> <children> <tagNode name="name"> <properties> <help>Container name</help> <constraint> <regex>[-a-zA-Z0-9]+</regex> </constraint> <constraintErrorMessage>Container name must be alphanumeric and can contain hyphens</constraintErrorMessage> </properties> <children> <leafNode name="allow-host-networks"> <properties> <help>Allow host networks in container</help> <valueless/> </properties> </leafNode> <leafNode name="cap-add"> <properties> <help>Container capabilities/permissions</help> <completionHelp> <list>net-admin net-bind-service net-raw setpcap sys-admin sys-module sys-time</list> </completionHelp> <valueHelp> <format>net-admin</format> <description>Network operations (interface, firewall, routing tables)</description> </valueHelp> <valueHelp> <format>net-bind-service</format> <description>Bind a socket to privileged ports (port numbers less than 1024)</description> </valueHelp> <valueHelp> <format>net-raw</format> <description>Permission to create raw network sockets</description> </valueHelp> <valueHelp> <format>setpcap</format> <description>Capability sets (from bounded or inherited set)</description> </valueHelp> <valueHelp> <format>sys-admin</format> <description>Administation operations (quotactl, mount, sethostname, setdomainame)</description> </valueHelp> <valueHelp> <format>sys-module</format> <description>Load, unload and delete kernel modules</description> </valueHelp> <valueHelp> <format>sys-time</format> <description>Permission to set system clock</description> </valueHelp> <constraint> <regex>(net-admin|net-bind-service|net-raw|setpcap|sys-admin|sys-module|sys-time)</regex> </constraint> <multi/> </properties> </leafNode> #include <include/generic-description.xml.i> <tagNode name="device"> <properties> <help>Add a host device to the container</help> </properties> <children> <leafNode name="source"> <properties> <help>Source device (Example: "/dev/x")</help> <valueHelp> <format>txt</format> <description>Source device</description> </valueHelp> </properties> </leafNode> <leafNode name="destination"> <properties> <help>Destination container device (Example: "/dev/x")</help> <valueHelp> <format>txt</format> <description>Destination container device</description> </valueHelp> </properties> </leafNode> </children> </tagNode> #include <include/generic-disable-node.xml.i> <tagNode name="environment"> <properties> <help>Add custom environment variables</help> <constraint> <regex>[-_a-zA-Z0-9]+</regex> </constraint> <constraintErrorMessage>Environment variable name must be alphanumeric and can contain hyphen and underscores</constraintErrorMessage> </properties> <children> <leafNode name="value"> <properties> <help>Set environment option value</help> <valueHelp> <format>txt</format> <description>Set environment option value</description> </valueHelp> </properties> </leafNode> </children> </tagNode> <leafNode name="entrypoint"> <properties> <help>Override the default ENTRYPOINT from the image</help> <constraint> <regex>[ !#-%&(-~]+</regex> </constraint> <constraintErrorMessage>Entrypoint must be ASCII characters, use &quot; and &apos for double and single quotes respectively</constraintErrorMessage> </properties> </leafNode> <leafNode name="host-name"> <properties> <help>Container host name</help> <constraint> #include <include/constraint/host-name.xml.i> </constraint> <constraintErrorMessage>Host-name must be alphanumeric and can contain hyphens</constraintErrorMessage> </properties> </leafNode> <leafNode name="image"> <properties> <help>Image name in the hub-registry</help> </properties> </leafNode> <leafNode name="command"> <properties> <help>Override the default CMD from the image</help> <constraint> <regex>[ !#-%&(-~]+</regex> </constraint> <constraintErrorMessage>Command must be ASCII characters, use &quot; and &apos for double and single quotes respectively</constraintErrorMessage> </properties> </leafNode> <leafNode name="arguments"> <properties> <help>The command's arguments for this container</help> <constraint> <regex>[ !#-%&(-~]+</regex> </constraint> <constraintErrorMessage>The command's arguments must be ASCII characters, use &quot; and &apos for double and single quotes respectively</constraintErrorMessage> </properties> </leafNode> <tagNode name="label"> <properties> <help>Add label variables</help> <constraint> <regex>[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?</regex> </constraint> <constraintErrorMessage>Label variable name must be alphanumeric and can contain hyphen, dots and underscores</constraintErrorMessage> </properties> <children> <leafNode name="value"> <properties> <help>Set label option value</help> <valueHelp> <format>txt</format> <description>Set label option value</description> </valueHelp> </properties> </leafNode> </children> </tagNode> <leafNode name="memory"> <properties> <help>Memory (RAM) available to this container</help> <valueHelp> <format>u32:0</format> <description>Unlimited</description> </valueHelp> <valueHelp> <format>u32:1-16384</format> <description>Container memory in megabytes (MB)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 0-16384"/> </constraint> <constraintErrorMessage>Container memory must be in range 0 to 16384 MB</constraintErrorMessage> </properties> <defaultValue>512</defaultValue> </leafNode> <leafNode name="shared-memory"> <properties> <help>Shared memory available to this container</help> <valueHelp> <format>u32:0</format> <description>Unlimited</description> </valueHelp> <valueHelp> <format>u32:1-8192</format> <description>Container memory in megabytes (MB)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 0-8192"/> </constraint> <constraintErrorMessage>Container memory must be in range 0 to 8192 MB</constraintErrorMessage> </properties> <defaultValue>64</defaultValue> </leafNode> <tagNode name="network"> <properties> <help>Attach user defined network to container</help> <completionHelp> <path>container network</path> </completionHelp> </properties> <children> <leafNode name="address"> <properties> <help>Assign static IP address to container</help> <valueHelp> <format>ipv4</format> <description>IPv4 address</description> </valueHelp> <valueHelp> <format>ipv6</format> <description>IPv6 address</description> </valueHelp> <constraint> <validator name="ip-address"/> </constraint> <multi/> </properties> </leafNode> </children> </tagNode> <tagNode name="port"> <properties> <help>Publish port to the container</help> </properties> <children> #include <include/listen-address.xml.i> <leafNode name="source"> <properties> <help>Source host port</help> <valueHelp> <format>u32:1-65535</format> <description>Source host port</description> </valueHelp> <valueHelp> <format>start-end</format> <description>Source host port range (e.g. 10025-10030)</description> </valueHelp> <constraint> <validator name="port-range"/> </constraint> </properties> </leafNode> <leafNode name="destination"> <properties> <help>Destination container port</help> <valueHelp> <format>u32:1-65535</format> <description>Destination container port</description> </valueHelp> <valueHelp> <format>start-end</format> <description>Destination container port range (e.g. 10025-10030)</description> </valueHelp> <constraint> <validator name="port-range"/> </constraint> </properties> </leafNode> <leafNode name="protocol"> <properties> <help>Transport protocol used for port mapping</help> <completionHelp> <list>tcp udp</list> </completionHelp> <valueHelp> <format>tcp</format> <description>Use Transmission Control Protocol for given port</description> </valueHelp> <valueHelp> <format>udp</format> <description>Use User Datagram Protocol for given port</description> </valueHelp> <constraint> <regex>(tcp|udp)</regex> </constraint> </properties> <defaultValue>tcp</defaultValue> </leafNode> </children> </tagNode> <leafNode name="restart"> <properties> <help>Restart options for container</help> <completionHelp> <list>no on-failure always</list> </completionHelp> <valueHelp> <format>no</format> <description>Do not restart containers on exit</description> </valueHelp> <valueHelp> <format>on-failure</format> <description>Restart containers when they exit with a non-zero exit code, retrying indefinitely</description> </valueHelp> <valueHelp> <format>always</format> <description>Restart containers when they exit, regardless of status, retrying indefinitely</description> </valueHelp> <constraint> <regex>(no|on-failure|always)</regex> </constraint> </properties> <defaultValue>on-failure</defaultValue> </leafNode> + <leafNode name="uid"> + <properties> + <help>User ID this container will run as</help> + <valueHelp> + <format>u32:0-65535</format> + <description>User ID this container will run as</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 0-65535"/> + </constraint> + </properties> + </leafNode> + <leafNode name="gid"> + <properties> + <help>Group ID this container will run as</help> + <valueHelp> + <format>u32:0-65535</format> + <description>Group ID this container will run as</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 0-65535"/> + </constraint> + </properties> + </leafNode> <tagNode name="volume"> <properties> <help>Mount a volume into the container</help> </properties> <children> <leafNode name="source"> <properties> <help>Source host directory</help> <valueHelp> <format>txt</format> <description>Source host directory</description> </valueHelp> </properties> </leafNode> <leafNode name="destination"> <properties> <help>Destination container directory</help> <valueHelp> <format>txt</format> <description>Destination container directory</description> </valueHelp> </properties> </leafNode> <leafNode name="mode"> <properties> <help>Volume access mode ro/rw</help> <completionHelp> <list>ro rw</list> </completionHelp> <valueHelp> <format>ro</format> <description>Volume mounted into the container as read-only</description> </valueHelp> <valueHelp> <format>rw</format> <description>Volume mounted into the container as read-write</description> </valueHelp> <constraint> <regex>(ro|rw)</regex> </constraint> </properties> <defaultValue>rw</defaultValue> </leafNode> <leafNode name="propagation"> <properties> <help>Volume bind propagation</help> <completionHelp> <list>shared slave private rshared rslave rprivate</list> </completionHelp> <valueHelp> <format>shared</format> <description>Sub-mounts of the original mount are exposed to replica mounts</description> </valueHelp> <valueHelp> <format>slave</format> <description>Allow replica mount to see sub-mount from the original mount but not vice versa</description> </valueHelp> <valueHelp> <format>private</format> <description>Sub-mounts within a mount are not visible to replica mounts or the original mount</description> </valueHelp> <valueHelp> <format>rshared</format> <description>Allows sharing of mount points and their nested mount points between both the original and replica mounts</description> </valueHelp> <valueHelp> <format>rslave</format> <description>Allows mount point and their nested mount points between original an replica mounts</description> </valueHelp> <valueHelp> <format>rprivate</format> <description>No mount points within original or replica mounts in any direction</description> </valueHelp> <constraint> <regex>(shared|slave|private|rshared|rslave|rprivate)</regex> </constraint> </properties> <defaultValue>rprivate</defaultValue> </leafNode> </children> </tagNode> </children> </tagNode> <tagNode name="network"> <properties> <help>Network name</help> <constraint> <regex>[-_a-zA-Z0-9]{1,11}</regex> </constraint> <constraintErrorMessage>Network name cannot be longer than 11 characters</constraintErrorMessage> </properties> <children> #include <include/generic-description.xml.i> <leafNode name="prefix"> <properties> <help>Prefix which allocated to that network</help> <valueHelp> <format>ipv4net</format> <description>IPv4 network prefix</description> </valueHelp> <valueHelp> <format>ipv6net</format> <description>IPv6 network prefix</description> </valueHelp> <constraint> <validator name="ipv4-prefix"/> <validator name="ipv6-prefix"/> </constraint> <multi/> </properties> </leafNode> #include <include/interface/vrf.xml.i> </children> </tagNode> <tagNode name="registry"> <properties> <help>Registry Name</help> </properties> <defaultValue>docker.io quay.io</defaultValue> <children> #include <include/interface/authentication.xml.i> #include <include/generic-disable-node.xml.i> </children> </tagNode> </children> </node> </interfaceDefinition> diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py index cdf46a6e1..9094e27dd 100755 --- a/smoketest/scripts/cli/test_container.py +++ b/smoketest/scripts/cli/test_container.py @@ -1,192 +1,214 @@ #!/usr/bin/env python3 # # Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import unittest import glob import json from base_vyostest_shim import VyOSUnitTestSHIM from ipaddress import ip_interface from vyos.configsession import ConfigSessionError from vyos.utils.process import cmd from vyos.utils.process import process_named_running from vyos.utils.file import read_file base_path = ['container'] cont_image = 'busybox:stable' # busybox is included in vyos-build PROCESS_NAME = 'conmon' PROCESS_PIDFILE = '/run/vyos-container-{0}.service.pid' busybox_image_path = '/usr/share/vyos/busybox-stable.tar' def cmd_to_json(command): c = cmd(command + ' --format=json') data = json.loads(c)[0] return data class TestContainer(VyOSUnitTestSHIM.TestCase): @classmethod def setUpClass(cls): super(TestContainer, cls).setUpClass() # Load image for smoketest provided in vyos-build try: cmd(f'cat {busybox_image_path} | sudo podman load') except: cls.skipTest(cls, reason='busybox image not available') # ensure we can also run this test on a live system - so lets clean # out the current configuration :) cls.cli_delete(cls, base_path) @classmethod def tearDownClass(cls): super(TestContainer, cls).tearDownClass() # Cleanup podman image cmd(f'sudo podman image rm -f {cont_image}') def tearDown(self): self.cli_delete(base_path) self.cli_commit() # Ensure no container process remains self.assertIsNone(process_named_running(PROCESS_NAME)) # Ensure systemd units are removed units = glob.glob('/run/systemd/system/vyos-container-*') self.assertEqual(units, []) def test_basic(self): cont_name = 'c1' self.cli_set(['interfaces', 'ethernet', 'eth0', 'address', '10.0.2.15/24']) self.cli_set(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop', '10.0.2.2']) self.cli_set(['system', 'name-server', '1.1.1.1']) self.cli_set(['system', 'name-server', '8.8.8.8']) self.cli_set(base_path + ['name', cont_name, 'image', cont_image]) self.cli_set(base_path + ['name', cont_name, 'allow-host-networks']) # commit changes self.cli_commit() pid = 0 with open(PROCESS_PIDFILE.format(cont_name), 'r') as f: pid = int(f.read()) # Check for running process self.assertEqual(process_named_running(PROCESS_NAME), pid) def test_ipv4_network(self): prefix = '192.0.2.0/24' base_name = 'ipv4' net_name = 'NET01' self.cli_set(base_path + ['network', net_name, 'prefix', prefix]) for ii in range(1, 6): name = f'{base_name}-{ii}' self.cli_set(base_path + ['name', name, 'image', cont_image]) self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + ii)]) # verify() - first IP address of a prefix can not be used by a container with self.assertRaises(ConfigSessionError): self.cli_commit() tmp = f'{base_name}-1' self.cli_delete(base_path + ['name', tmp]) self.cli_commit() n = cmd_to_json(f'sudo podman network inspect {net_name}') self.assertEqual(n['subnets'][0]['subnet'], prefix) # skipt first container, it was never created for ii in range(2, 6): name = f'{base_name}-{ii}' c = cmd_to_json(f'sudo podman container inspect {name}') self.assertEqual(c['NetworkSettings']['Networks'][net_name]['Gateway'] , str(ip_interface(prefix).ip + 1)) self.assertEqual(c['NetworkSettings']['Networks'][net_name]['IPAddress'], str(ip_interface(prefix).ip + ii)) def test_ipv6_network(self): prefix = '2001:db8::/64' base_name = 'ipv6' net_name = 'NET02' self.cli_set(base_path + ['network', net_name, 'prefix', prefix]) for ii in range(1, 6): name = f'{base_name}-{ii}' self.cli_set(base_path + ['name', name, 'image', cont_image]) self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + ii)]) # verify() - first IP address of a prefix can not be used by a container with self.assertRaises(ConfigSessionError): self.cli_commit() tmp = f'{base_name}-1' self.cli_delete(base_path + ['name', tmp]) self.cli_commit() n = cmd_to_json(f'sudo podman network inspect {net_name}') self.assertEqual(n['subnets'][0]['subnet'], prefix) # skipt first container, it was never created for ii in range(2, 6): name = f'{base_name}-{ii}' c = cmd_to_json(f'sudo podman container inspect {name}') self.assertEqual(c['NetworkSettings']['Networks'][net_name]['IPv6Gateway'] , str(ip_interface(prefix).ip + 1)) self.assertEqual(c['NetworkSettings']['Networks'][net_name]['GlobalIPv6Address'], str(ip_interface(prefix).ip + ii)) def test_dual_stack_network(self): prefix4 = '192.0.2.0/24' prefix6 = '2001:db8::/64' base_name = 'dual-stack' net_name = 'net-4-6' self.cli_set(base_path + ['network', net_name, 'prefix', prefix4]) self.cli_set(base_path + ['network', net_name, 'prefix', prefix6]) for ii in range(1, 6): name = f'{base_name}-{ii}' self.cli_set(base_path + ['name', name, 'image', cont_image]) self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix4).ip + ii)]) self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix6).ip + ii)]) # verify() - first IP address of a prefix can not be used by a container with self.assertRaises(ConfigSessionError): self.cli_commit() tmp = f'{base_name}-1' self.cli_delete(base_path + ['name', tmp]) self.cli_commit() n = cmd_to_json(f'sudo podman network inspect {net_name}') self.assertEqual(n['subnets'][0]['subnet'], prefix4) self.assertEqual(n['subnets'][1]['subnet'], prefix6) # skipt first container, it was never created for ii in range(2, 6): name = f'{base_name}-{ii}' c = cmd_to_json(f'sudo podman container inspect {name}') self.assertEqual(c['NetworkSettings']['Networks'][net_name]['IPv6Gateway'] , str(ip_interface(prefix6).ip + 1)) self.assertEqual(c['NetworkSettings']['Networks'][net_name]['GlobalIPv6Address'], str(ip_interface(prefix6).ip + ii)) self.assertEqual(c['NetworkSettings']['Networks'][net_name]['Gateway'] , str(ip_interface(prefix4).ip + 1)) self.assertEqual(c['NetworkSettings']['Networks'][net_name]['IPAddress'] , str(ip_interface(prefix4).ip + ii)) + def test_uid_gid(self): + cont_name = 'uid-test' + gid = '100' + uid = '1001' + + self.cli_set(base_path + ['name', cont_name, 'allow-host-networks']) + self.cli_set(base_path + ['name', cont_name, 'image', cont_image]) + self.cli_set(base_path + ['name', cont_name, 'gid', gid]) + + # verify() - GID can only be set if UID is set + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['name', cont_name, 'uid', uid]) + + self.cli_commit() + + # verify + tmp = cmd(f'sudo podman exec -it {cont_name} id -u') + self.assertEqual(tmp, uid) + tmp = cmd(f'sudo podman exec -it {cont_name} id -g') + self.assertEqual(tmp, gid) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 59d11c5a3..321d00abf 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -1,496 +1,508 @@ #!/usr/bin/env python3 # # Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from hashlib import sha256 from ipaddress import ip_address from ipaddress import ip_network from json import dumps as json_write from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.ifconfig import Interface from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.process import rc_cmd from vyos.template import bracketize_ipv6 from vyos.template import inc_ip from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.template import render from vyos.xml_ref import default_value from vyos import ConfigError from vyos import airbag airbag.enable() config_containers = '/etc/containers/containers.conf' config_registry = '/etc/containers/registries.conf' config_storage = '/etc/containers/storage.conf' systemd_unit_path = '/run/systemd/system' def _cmd(command): if os.path.exists('/tmp/vyos.container.debug'): print(command) return cmd(command) def network_exists(name): # Check explicit name for network, returns True if network exists c = _cmd(f'podman network ls --quiet --filter name=^{name}$') return bool(c) # Common functions def get_config(config=None): if config: conf = config else: conf = Config() base = ['container'] container = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, with_recursive_defaults=True) for name in container.get('name', []): # T5047: Any container related configuration changed? We only # wan't to restart the required containers and not all of them ... tmp = is_node_changed(conf, base + ['name', name]) if tmp: if 'container_restart' not in container: container['container_restart'] = [name] else: container['container_restart'].append(name) # registry is a tagNode with default values - merge the list from # default_values['registry'] into the tagNode variables if 'registry' not in container: container.update({'registry' : {}}) default_values = default_value(base + ['registry']) for registry in default_values: tmp = {registry : {}} container['registry'] = dict_merge(tmp, container['registry']) # Delete container network, delete containers tmp = node_changed(conf, base + ['network']) if tmp: container.update({'network_remove' : tmp}) tmp = node_changed(conf, base + ['name']) if tmp: container.update({'container_remove' : tmp}) return container def verify(container): # bail out early - looks like removal from running config if not container: return None # Add new container if 'name' in container: for name, container_config in container['name'].items(): # Container image is a mandatory option if 'image' not in container_config: raise ConfigError(f'Container image for "{name}" is mandatory!') # Check if requested container image exists locally. If it does not # exist locally - inform the user. This is required as there is a # shared container image storage accross all VyOS images. A user can # delete a container image from the system, boot into another version # of VyOS and then it would fail to boot. This is to prevent any # configuration error when container images are deleted from the # global storage. A per image local storage would be a super waste # of diskspace as there will be a full copy (up tu several GB/image) # on upgrade. This is the "cheapest" and fastest solution in terms # of image upgrade and deletion. image = container_config['image'] if run(f'podman image exists {image}') != 0: Warning(f'Image "{image}" used in container "{name}" does not exist '\ f'locally. Please use "add container image {image}" to add it '\ f'to the system! Container "{name}" will not be started!') if 'network' in container_config: if len(container_config['network']) > 1: raise ConfigError(f'Only one network can be specified for container "{name}"!') # Check if the specified container network exists network_name = list(container_config['network'])[0] if network_name not in container.get('network', {}): raise ConfigError(f'Container network "{network_name}" does not exist!') if 'address' in container_config['network'][network_name]: cnt_ipv4 = 0 cnt_ipv6 = 0 for address in container_config['network'][network_name]['address']: network = None if is_ipv4(address): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] cnt_ipv4 += 1 except: raise ConfigError(f'Network "{network_name}" does not contain an IPv4 prefix!') elif is_ipv6(address): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] cnt_ipv6 += 1 except: raise ConfigError(f'Network "{network_name}" does not contain an IPv6 prefix!') # Specified container IP address must belong to network prefix if ip_address(address) not in ip_network(network): raise ConfigError(f'Used container address "{address}" not in network "{network}"!') # We can not use the first IP address of a network prefix as this is used by podman if ip_address(address) == ip_network(network)[1]: raise ConfigError(f'IP address "{address}" can not be used for a container, '\ 'reserved for the container engine!') if cnt_ipv4 > 1 or cnt_ipv6 > 1: raise ConfigError(f'Only one IP address per address family can be used for '\ f'container "{name}". {cnt_ipv4} IPv4 and {cnt_ipv6} IPv6 address(es)!') if 'device' in container_config: for dev, dev_config in container_config['device'].items(): if 'source' not in dev_config: raise ConfigError(f'Device "{dev}" has no source path configured!') if 'destination' not in dev_config: raise ConfigError(f'Device "{dev}" has no destination path configured!') source = dev_config['source'] if not os.path.exists(source): raise ConfigError(f'Device "{dev}" source path "{source}" does not exist!') if 'environment' in container_config: for var, cfg in container_config['environment'].items(): if 'value' not in cfg: raise ConfigError(f'Environment variable {var} has no value assigned!') if 'label' in container_config: for var, cfg in container_config['label'].items(): if 'value' not in cfg: raise ConfigError(f'Label variable {var} has no value assigned!') if 'volume' in container_config: for volume, volume_config in container_config['volume'].items(): if 'source' not in volume_config: raise ConfigError(f'Volume "{volume}" has no source path configured!') if 'destination' not in volume_config: raise ConfigError(f'Volume "{volume}" has no destination path configured!') source = volume_config['source'] if not os.path.exists(source): raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') if 'port' in container_config: for tmp in container_config['port']: if not {'source', 'destination'} <= set(container_config['port'][tmp]): raise ConfigError(f'Both "source" and "destination" must be specified for a port mapping!') # If 'allow-host-networks' or 'network' not set. if 'allow_host_networks' not in container_config and 'network' not in container_config: raise ConfigError(f'Must either set "network" or "allow-host-networks" for container "{name}"!') # Can not set both allow-host-networks and network at the same time if {'allow_host_networks', 'network'} <= set(container_config): raise ConfigError(f'"allow-host-networks" and "network" for "{name}" cannot be both configured at the same time!') + # gid cannot be set without uid + if 'gid' in container_config and 'uid' not in container_config: + raise ConfigError(f'Cannot set "gid" without "uid" for container') + # Add new network if 'network' in container: for network, network_config in container['network'].items(): v4_prefix = 0 v6_prefix = 0 # If ipv4-prefix not defined for user-defined network if 'prefix' not in network_config: raise ConfigError(f'prefix for network "{network}" must be defined!') for prefix in network_config['prefix']: if is_ipv4(prefix): v4_prefix += 1 elif is_ipv6(prefix): v6_prefix += 1 if v4_prefix > 1: raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!') if v6_prefix > 1: raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!') # Verify VRF exists verify_vrf(network_config) # A network attached to a container can not be deleted if {'network_remove', 'name'} <= set(container): for network in container['network_remove']: for c, c_config in container['name'].items(): if 'network' in c_config and network in c_config['network']: raise ConfigError(f'Can not remove network "{network}", used by container "{c}"!') if 'registry' in container: for registry, registry_config in container['registry'].items(): if 'authentication' not in registry_config: continue if not {'username', 'password'} <= set(registry_config['authentication']): raise ConfigError('If registry username or or password is defined, so must be the other!') return None def generate_run_arguments(name, container_config): image = container_config['image'] memory = container_config['memory'] shared_memory = container_config['shared_memory'] restart = container_config['restart'] # Add capability options. Should be in uppercase cap_add = '' if 'cap_add' in container_config: for c in container_config['cap_add']: c = c.upper() c = c.replace('-', '_') cap_add += f' --cap-add={c}' # Add a host device to the container /dev/x:/dev/x device = '' if 'device' in container_config: for dev, dev_config in container_config['device'].items(): source_dev = dev_config['source'] dest_dev = dev_config['destination'] device += f' --device={source_dev}:{dest_dev}' # Check/set environment options "-e foo=bar" env_opt = '' if 'environment' in container_config: for k, v in container_config['environment'].items(): env_opt += f" --env \"{k}={v['value']}\"" # Check/set label options "--label foo=bar" label = '' if 'label' in container_config: for k, v in container_config['label'].items(): label += f" --label \"{k}={v['value']}\"" hostname = '' if 'host_name' in container_config: hostname = container_config['host_name'] hostname = f'--hostname {hostname}' # Publish ports port = '' if 'port' in container_config: protocol = '' for portmap in container_config['port']: protocol = container_config['port'][portmap]['protocol'] sport = container_config['port'][portmap]['source'] dport = container_config['port'][portmap]['destination'] listen_addresses = container_config['port'][portmap].get('listen_address', []) # If listen_addresses is not empty, include them in the publish command if listen_addresses: for listen_address in listen_addresses: port += f' --publish {bracketize_ipv6(listen_address)}:{sport}:{dport}/{protocol}' else: # If listen_addresses is empty, just include the standard publish command port += f' --publish {sport}:{dport}/{protocol}' + # Set uid and gid + uid = '' + if 'uid' in container_config: + uid = container_config['uid'] + if 'gid' in container_config: + uid += ':' + container_config['gid'] + uid = f'--user {uid}' + # Bind volume volume = '' if 'volume' in container_config: for vol, vol_config in container_config['volume'].items(): svol = vol_config['source'] dvol = vol_config['destination'] mode = vol_config['mode'] prop = vol_config['propagation'] volume += f' --volume {svol}:{dvol}:{mode},{prop}' container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \ f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ - f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label}' + f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid}' entrypoint = '' if 'entrypoint' in container_config: # it needs to be json-formatted with single quote on the outside entrypoint = json_write(container_config['entrypoint'].split()).replace('"', """) entrypoint = f'--entrypoint '{entrypoint}'' hostname = '' if 'host_name' in container_config: hostname = container_config['host_name'] hostname = f'--hostname {hostname}' command = '' if 'command' in container_config: command = container_config['command'].strip() command_arguments = '' if 'arguments' in container_config: command_arguments = container_config['arguments'].strip() if 'allow_host_networks' in container_config: return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip() ip_param = '' networks = ",".join(container_config['network']) for network in container_config['network']: if 'address' not in container_config['network'][network]: continue for address in container_config['network'][network]['address']: if is_ipv6(address): ip_param += f' --ip6 {address}' else: ip_param += f' --ip {address}' return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() def generate(container): # bail out early - looks like removal from running config if not container: for file in [config_containers, config_registry, config_storage]: if os.path.exists(file): os.unlink(file) return None if 'network' in container: for network, network_config in container['network'].items(): tmp = { 'name': network, 'id' : sha256(f'{network}'.encode()).hexdigest(), 'driver': 'bridge', 'network_interface': f'pod-{network}', 'subnets': [], 'ipv6_enabled': False, 'internal': False, 'dns_enabled': True, 'ipam_options': { 'driver': 'host-local' } } for prefix in network_config['prefix']: net = {'subnet' : prefix, 'gateway' : inc_ip(prefix, 1)} tmp['subnets'].append(net) if is_ipv6(prefix): tmp['ipv6_enabled'] = True write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2)) if 'registry' in container: cmd = f'podman logout --all' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigError(out) for registry, registry_config in container['registry'].items(): if 'disable' in registry_config: continue if 'authentication' in registry_config: if {'username', 'password'} <= set(registry_config['authentication']): username = registry_config['authentication']['username'] password = registry_config['authentication']['password'] cmd = f'podman login --username {username} --password {password} {registry}' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigError(out) render(config_containers, 'container/containers.conf.j2', container) render(config_registry, 'container/registries.conf.j2', container) render(config_storage, 'container/storage.conf.j2', container) if 'name' in container: for name, container_config in container['name'].items(): if 'disable' in container_config: continue file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') run_args = generate_run_arguments(name, container_config) render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args,}, formater=lambda _: _.replace(""", '"').replace("'", "'")) return None def apply(container): # Delete old containers if needed. We can't delete running container # Option "--force" allows to delete containers with any status if 'container_remove' in container: for name in container['container_remove']: file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') call(f'systemctl stop vyos-container-{name}.service') if os.path.exists(file_path): os.unlink(file_path) call('systemctl daemon-reload') # Delete old networks if needed if 'network_remove' in container: for network in container['network_remove']: call(f'podman network rm {network} >/dev/null 2>&1') # Add container disabled_new = False if 'name' in container: for name, container_config in container['name'].items(): image = container_config['image'] if run(f'podman image exists {image}') != 0: # container image does not exist locally - user already got # informed by a WARNING in verfiy() - bail out early continue if 'disable' in container_config: # check if there is a container by that name running tmp = _cmd('podman ps -a --format "{{.Names}}"') if name in tmp: file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') call(f'systemctl stop vyos-container-{name}.service') if os.path.exists(file_path): disabled_new = True os.unlink(file_path) continue if 'container_restart' in container and name in container['container_restart']: cmd(f'systemctl restart vyos-container-{name}.service') if disabled_new: call('systemctl daemon-reload') # Start network and assign it to given VRF if requested. this can only be done # after the containers got started as the podman network interface will # only be enabled by the first container and yet I do not know how to enable # the network interface in advance if 'network' in container: for network, network_config in container['network'].items(): network_name = f'pod-{network}' # T5147: Networks are started only as soon as there is a consumer. # If only a network is created in the first place, no need to assign # it to a VRF as there's no consumer, yet. if os.path.exists(f'/sys/class/net/{network_name}'): tmp = Interface(network_name) tmp.add_ipv6_eui64_address('fe80::/64') tmp.set_vrf(network_config.get('vrf', '')) return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)