diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/conf_mode/container.py | 395 | ||||
-rwxr-xr-x | src/migration-scripts/container/0-to-1 | 77 | ||||
-rwxr-xr-x | src/op_mode/container.py | 84 |
3 files changed, 556 insertions, 0 deletions
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py new file mode 100755 index 000000000..7567444db --- /dev/null +++ b/src/conf_mode/container.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2022 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 ipaddress import ip_address +from ipaddress import ip_network +from time import sleep +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 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'] + 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'] + 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 = defaults(base + ['name', 'port']) + container['name'][name]['port'][port] = dict_merge( + default_values, container['name'][name]['port'][port]) + + # 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}"!') + + 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" -e \"{k}={v['value']}\"" + + # 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' -p {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'] + volume += f' -v {svol}:{dvol}' + + 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} {device} {port} {volume} {env_opt}' + + if 'allow_host_networks' in container_config: + return f'{container_base_cmd} --net host {image}' + + 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} {image}' + +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)) + + 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}) + + 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) diff --git a/src/migration-scripts/container/0-to-1 b/src/migration-scripts/container/0-to-1 new file mode 100755 index 000000000..d0461389b --- /dev/null +++ b/src/migration-scripts/container/0-to-1 @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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/>. + +# T4870: change underlaying container filesystem from vfs to overlay + +import os +import shutil +import sys + +from vyos.configtree import ConfigTree +from vyos.util import call + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['container', 'name'] +config = ConfigTree(config_file) + +# Check if containers exist and we need to perform image manipulation +if config.exists(base): + for container in config.list_nodes(base): + # Stop any given container first + call(f'systemctl stop vyos-container-{container}.service') + # Export container image for later re-import to new filesystem. We store + # the backup on a real disk as a tmpfs (like /tmp) could probably lack + # memory if a host has too many containers stored. + image_name = config.return_value(base + [container, 'image']) + call(f'podman image save --quiet --output /root/{container}.tar --format oci-archive {image_name}') + +# No need to adjust the strage driver online (this is only used for testing and +# debugging on a live system) - it is already overlay2 when the migration script +# is run during system update. But the specified driver in the image is actually +# overwritten by the still present VFS filesystem on disk. Thus podman still +# thinks it uses VFS until we delete the libpod directory under: +# /usr/lib/live/mount/persistence/container/storage +#call('sed -i "s/vfs/overlay2/g" /etc/containers/storage.conf /usr/share/vyos/templates/container/storage.conf.j2') + +base_path = '/usr/lib/live/mount/persistence/container/storage' +for dir in ['libpod', 'vfs', 'vfs-containers', 'vfs-images', 'vfs-layers']: + if os.path.exists(f'{base_path}/{dir}'): + shutil.rmtree(f'{base_path}/{dir}') + +# Now all remaining information about VFS is gone and we operate in overlayfs2 +# filesystem mode. Time to re-import the images. +if config.exists(base): + for container in config.list_nodes(base): + # Export container image for later re-import to new filesystem + image_name = config.return_value(base + [container, 'image']) + image_path = f'/root/{container}.tar' + call(f'podman image load --quiet --input {image_path}') + + # Start any given container first + call(f'systemctl start vyos-container-{container}.service') + + # Delete temporary container image + if os.path.exists(image_path): + os.unlink(image_path) + diff --git a/src/op_mode/container.py b/src/op_mode/container.py new file mode 100755 index 000000000..ecefc556e --- /dev/null +++ b/src/op_mode/container.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 json +import sys + +from sys import exit + +from vyos.util import cmd + +import vyos.opmode + +def _get_json_data(command: str) -> list: + """ + Get container command format JSON + """ + return cmd(f'{command} --format json') + + +def _get_raw_data(command: str) -> list: + json_data = _get_json_data(command) + data = json.loads(json_data) + return data + + +def show_container(raw: bool): + command = 'podman ps --all' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + + +def show_image(raw: bool): + command = 'podman image ls' + container_data = _get_raw_data('podman image ls') + if raw: + return container_data + else: + return cmd(command) + + +def show_network(raw: bool): + command = 'podman network ls' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + + +def restart(name: str): + from vyos.util import rc_cmd + + rc, output = rc_cmd(f'systemctl restart vyos-container-{name}.service') + if rc != 0: + print(output) + return None + print(f'Container name "{name}" restarted!') + return output + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) |