#!/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 . import os 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.util import call from vyos.util import cmd from vyos.util import run from vyos.util import rc_cmd from vyos.util import write_file 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 import defaults from vyos import ConfigError from vyos import airbag airbag.enable() config_containers_registry = '/etc/containers/registries.conf' config_containers_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=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = defaults(base) # container base default values can not be merged here - remove and add them later if 'name' in default_values: del default_values['name'] # registry will be handled below if 'registry' in default_values: del default_values['registry'] container = dict_merge(default_values, container) # Merge per-container default values if 'name' in container: default_values = defaults(base + ['name']) if 'port' in default_values: del default_values['port'] if 'volume' in default_values: del default_values['volume'] for name in container['name']: container['name'][name] = dict_merge(default_values, container['name'][name]) # XXX: T2665: we can not safely rely on the defaults() when there are # tagNodes in place, it is better to blend in the defaults manually. if 'port' in container['name'][name]: for port in container['name'][name]['port']: default_values_port = defaults(base + ['name', 'port']) container['name'][name]['port'][port] = dict_merge( default_values_port, container['name'][name]['port'][port]) # XXX: T2665: we can not safely rely on the defaults() when there are # tagNodes in place, it is better to blend in the defaults manually. if 'volume' in container['name'][name]: for volume in container['name'][name]['volume']: default_values_volume = defaults(base + ['name', 'volume']) container['name'][name]['volume'][volume] = dict_merge( default_values_volume, container['name'][name]['volume'][volume]) # 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 = defaults(base) for registry in default_values['registry'].split(): 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]: address = container_config['network'][network_name]['address'] network = None if is_ipv4(address): network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] elif is_ipv6(address): network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] # 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 '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 '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!') # 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}"!') # A network attached to a container can not be deleted if {'network_remove', 'name'} <= set(container): for network in container['network_remove']: for container, container_config in container['name'].items(): if 'network' in container_config and network in container_config['network']: raise ConfigError(f'Can not remove network "{network}", used by container "{container}"!') if 'registry' in container and 'authentication' in container['registry']: for registry, registry_config in container['registry']['authentication'].items(): if not {'username', 'password'} <= set(registry_config): 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']}\"" 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'] port += f' --publish {sport}:{dport}/{protocol}' # 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'] volume += f' --volume {svol}:{dvol}:{mode}' 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}' 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' in container_config['network'][network]: address = container_config['network'][network]['address'] ip_param = f'--ip {address}' return f'{container_base_cmd} --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: if os.path.exists(config_containers_registry): os.unlink(config_containers_registry) if os.path.exists(config_containers_storage): os.unlink(config_containers_storage) return None if 'network' in container: for network, network_config in container['network'].items(): tmp = { 'cniVersion' : '0.4.0', 'name' : network, 'plugins' : [{ 'type': 'bridge', 'bridge': f'cni-{network}', 'isGateway': True, 'ipMasq': False, 'hairpinMode': False, 'ipam' : { 'type': 'host-local', 'routes': [], 'ranges' : [], }, }] } for prefix in network_config['prefix']: net = [{'gateway' : inc_ip(prefix, 1), 'subnet' : prefix}] tmp['plugins'][0]['ipam']['ranges'].append(net) # install per address-family default orutes default_route = '0.0.0.0/0' if is_ipv6(prefix): default_route = '::/0' tmp['plugins'][0]['ipam']['routes'].append({'dst': default_route}) write_file(f'/etc/cni/net.d/{network}.conflist', 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_registry, 'container/registries.conf.j2', container) render(config_containers_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}') tmp = f'/etc/cni/net.d/{network}.conflist' if os.path.exists(tmp): os.unlink(tmp) # 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 cmd(f'systemctl restart vyos-container-{name}.service') if disabled_new: call('systemctl daemon-reload') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)