summaryrefslogtreecommitdiff
path: root/src/conf_mode/container.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/conf_mode/container.py')
-rwxr-xr-xsrc/conf_mode/container.py278
1 files changed, 192 insertions, 86 deletions
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py
index 8efeaed54..46eb10714 100755
--- a/src/conf_mode/container.py
+++ b/src/conf_mode/container.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2021-2022 VyOS maintainers and contributors
+# 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
@@ -16,30 +16,36 @@
import os
+from hashlib import sha256
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.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 import defaults
+from vyos.xml_ref import default_value
from vyos import ConfigError
from vyos import airbag
airbag.enable()
-config_containers_registry = '/etc/containers/registries.conf'
-config_containers_storage = '/etc/containers/storage.conf'
+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):
@@ -61,20 +67,28 @@ def get_config(config=None):
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'])
- for name in container['name']:
- container['name'][name] = dict_merge(default_values, container['name'][name])
+ 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'])
@@ -123,21 +137,29 @@ def verify(container):
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!')
+ cnt_ipv4 = 0
+ cnt_ipv6 = 0
+ for address in 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]
+ cnt_ipv4 += 1
+ elif is_ipv6(address):
+ network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0]
+ cnt_ipv6 += 1
+
+ # 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():
@@ -156,6 +178,11 @@ def verify(container):
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:
@@ -168,6 +195,11 @@ def verify(container):
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}"!')
@@ -194,6 +226,8 @@ def verify(container):
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):
@@ -202,6 +236,13 @@ def verify(container):
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:
+ 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):
@@ -230,21 +271,36 @@ def generate_run_arguments(name, container_config):
env_opt = ''
if 'environment' in container_config:
for k, v in container_config['environment'].items():
- env_opt += f" -e \"{k}={v['value']}\""
+ env_opt += f" --env \"{k}={v['value']}\""
+
+ # Check/set label options "--label foo=bar"
+ env_opt = ''
+ if 'label' in container_config:
+ for k, v in container_config['label'].items():
+ env_opt += 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']:
- if 'protocol' in container_config['port'][portmap]:
- protocol = container_config['port'][portmap]['protocol']
- protocol = f'/{protocol}'
- else:
- protocol = '/tcp'
+ protocol = container_config['port'][portmap]['protocol']
sport = container_config['port'][portmap]['source']
dport = container_config['port'][portmap]['destination']
- port += f' -p {sport}:{dport}{protocol}'
+ 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}'
# Bind volume
volume = ''
@@ -252,66 +308,102 @@ def generate_run_arguments(name, container_config):
for vol, vol_config in container_config['volume'].items():
svol = vol_config['source']
dvol = vol_config['destination']
- volume += f' -v {svol}:{dvol}'
+ 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} {device} {port} {volume} {env_opt}'
+ 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('"', "&quot;")
+ entrypoint = f'--entrypoint &apos;{entrypoint}&apos;'
+
+ 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 {image}'
+ 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}'
+ 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} --net {networks} {ip_param} {image}'
+ 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)
+ 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 = {
- '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' : [],
- },
- }]
+ '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 = [{'gateway' : inc_ip(prefix, 1), 'subnet' : prefix}]
- tmp['plugins'][0]['ipam']['ranges'].append(net)
+ net = {'subnet' : prefix, 'gateway' : inc_ip(prefix, 1)}
+ tmp['subnets'].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})
+ tmp['ipv6_enabled'] = True
+
+ write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2))
- 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)
- render(config_containers_registry, 'container/registries.conf.j2', container)
- render(config_containers_storage, 'container/storage.conf.j2', container)
+ 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():
@@ -320,7 +412,8 @@ def generate(container):
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})
+ render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args,},
+ formater=lambda _: _.replace("&quot;", '"').replace("&apos;", "'"))
return None
@@ -339,10 +432,7 @@ def apply(container):
# 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)
+ call(f'podman network rm {network} >/dev/null 2>&1')
# Add container
disabled_new = False
@@ -366,11 +456,27 @@ def apply(container):
os.unlink(file_path)
continue
- cmd(f'systemctl restart vyos-container-{name}.service')
+ 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__':