summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/templates/container/registries.conf.j227
-rw-r--r--data/templates/container/storage.conf.j24
-rw-r--r--data/templates/container/systemd-unit.j217
-rw-r--r--debian/control2
-rwxr-xr-xdebian/vyos-1x-smoketest.postinst10
-rw-r--r--interface-definitions/container.xml.in324
-rw-r--r--op-mode-definitions/container.xml.in176
-rwxr-xr-xsmoketest/scripts/cli/test_container.py114
-rwxr-xr-xsrc/conf_mode/container.py395
-rwxr-xr-xsrc/migration-scripts/container/0-to-177
-rwxr-xr-xsrc/op_mode/container.py84
11 files changed, 1230 insertions, 0 deletions
diff --git a/data/templates/container/registries.conf.j2 b/data/templates/container/registries.conf.j2
new file mode 100644
index 000000000..c583e0ad5
--- /dev/null
+++ b/data/templates/container/registries.conf.j2
@@ -0,0 +1,27 @@
+### Autogenerated by container.py ###
+
+# For more information on this configuration file, see containers-registries.conf(5).
+#
+# NOTE: RISK OF USING UNQUALIFIED IMAGE NAMES
+# We recommend always using fully qualified image names including the registry
+# server (full dns name), namespace, image name, and tag
+# (e.g., registry.redhat.io/ubi8/ubi:latest). Pulling by digest (i.e.,
+# quay.io/repository/name@digest) further eliminates the ambiguity of tags.
+# When using short names, there is always an inherent risk that the image being
+# pulled could be spoofed. For example, a user wants to pull an image named
+# `foobar` from a registry and expects it to come from myregistry.com. If
+# myregistry.com is not first in the search list, an attacker could place a
+# different `foobar` image at a registry earlier in the search list. The user
+# would accidentally pull and run the attacker's image and code rather than the
+# intended content. We recommend only adding registries which are completely
+# trusted (i.e., registries which don't allow unknown or anonymous users to
+# create accounts with arbitrary names). This will prevent an image from being
+# spoofed, squatted or otherwise made insecure. If it is necessary to use one
+# of these registries, it should be added at the end of the list.
+#
+# An array of host[:port] registries to try when pulling an unqualified image, in order.
+# unqualified-search-registries = ["example.com"]
+
+{% if registry is defined and registry is not none %}
+unqualified-search-registries = {{ registry }}
+{% endif %}
diff --git a/data/templates/container/storage.conf.j2 b/data/templates/container/storage.conf.j2
new file mode 100644
index 000000000..39a072c70
--- /dev/null
+++ b/data/templates/container/storage.conf.j2
@@ -0,0 +1,4 @@
+### Autogenerated by container.py ###
+[storage]
+ driver = "overlay2"
+ graphroot = "/usr/lib/live/mount/persistence/container/storage"
diff --git a/data/templates/container/systemd-unit.j2 b/data/templates/container/systemd-unit.j2
new file mode 100644
index 000000000..fa48384ab
--- /dev/null
+++ b/data/templates/container/systemd-unit.j2
@@ -0,0 +1,17 @@
+### Autogenerated by container.py ###
+[Unit]
+Description=VyOS Container {{ name }}
+
+[Service]
+Environment=PODMAN_SYSTEMD_UNIT=%n
+Restart=on-failure
+ExecStartPre=/bin/rm -f %t/%n.pid %t/%n.cid
+ExecStart=/usr/bin/podman run \
+ --conmon-pidfile %t/%n.pid --cidfile %t/%n.cid --cgroups=no-conmon \
+ {{ run_args }}
+ExecStop=/usr/bin/podman stop --ignore --cidfile %t/%n.cid -t 5
+ExecStopPost=/usr/bin/podman rm --ignore -f --cidfile %t/%n.cid
+ExecStopPost=/bin/rm -f %t/%n.cid
+PIDFile=%t/%n.pid
+KillMode=none
+Type=forking
diff --git a/debian/control b/debian/control
index 5100f326a..16cab3a88 100644
--- a/debian/control
+++ b/debian/control
@@ -93,6 +93,7 @@ Depends:
pciutils,
pdns-recursor,
pmacct (>= 1.6.0),
+ podman,
pppoe,
procps,
python3,
@@ -138,6 +139,7 @@ Depends:
traceroute,
tuned,
udp-broadcast-relay,
+ uidmap,
usb-modeswitch,
usbutils,
vyatta-bash,
diff --git a/debian/vyos-1x-smoketest.postinst b/debian/vyos-1x-smoketest.postinst
new file mode 100755
index 000000000..18612804c
--- /dev/null
+++ b/debian/vyos-1x-smoketest.postinst
@@ -0,0 +1,10 @@
+#!/bin/sh -e
+
+BUSYBOX_TAG="docker.io/library/busybox:stable"
+OUTPUT_PATH="/usr/share/vyos/busybox-stable.tar"
+
+if [[ -f $OUTPUT_PATH ]]; then
+ rm -f $OUTPUT_PATH
+fi
+
+skopeo copy --additional-tag "$BUSYBOX_TAG" "docker://$BUSYBOX_TAG" "docker-archive:/$OUTPUT_PATH"
diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in
new file mode 100644
index 000000000..4bac305d1
--- /dev/null
+++ b/interface-definitions/container.xml.in
@@ -0,0 +1,324 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="container" owner="${vyos_conf_scripts_dir}/container.py">
+ <properties>
+ <help>Container applications</help>
+ <priority>1280</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-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-time</format>
+ <description>Permission to set system clock</description>
+ </valueHelp>
+ <constraint>
+ <regex>(net-admin|net-bind-service|net-raw|setpcap|sys-admin|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="image">
+ <properties>
+ <help>Image name in the hub-registry</help>
+ </properties>
+ </leafNode>
+ <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>
+ <!-- PODMAN currently does not support more then one IPv4 or IPv6 address assignments to a container -->
+ <help>Assign static IP address to container</help>
+ <valueHelp>
+ <format>ipv4</format>
+ <description>IPv4 address</description>
+ </valueHelp>
+ <constraint>
+ <validator name="ipv4-address"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ </children>
+ </tagNode>
+ <tagNode name="port">
+ <properties>
+ <help>Publish port to the container</help>
+ </properties>
+ <children>
+ <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>
+ <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>
+ </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>
+ <leafNode name="description">
+ <properties>
+ <help>Network description</help>
+ </properties>
+ </leafNode>
+ <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>
+ </children>
+ </tagNode>
+ <leafNode name="registry">
+ <properties>
+ <help>Registry Name</help>
+ <multi/>
+ </properties>
+ <defaultValue>docker.io quay.io</defaultValue>
+ </leafNode>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/op-mode-definitions/container.xml.in b/op-mode-definitions/container.xml.in
new file mode 100644
index 000000000..786bd66d3
--- /dev/null
+++ b/op-mode-definitions/container.xml.in
@@ -0,0 +1,176 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="add">
+ <children>
+ <node name="container">
+ <properties>
+ <help>Add container image</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>Pull a new image for container</help>
+ </properties>
+ <command>sudo podman image pull "${4}"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="connect">
+ <children>
+ <tagNode name="container">
+ <properties>
+ <help>Attach to a running container</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo podman exec --interactive --tty "$3" /bin/sh</command>
+ </tagNode>
+ </children>
+ </node>
+ <node name="delete">
+ <children>
+ <node name="container">
+ <properties>
+ <help>Delete container image</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>Delete container image</help>
+ <completionHelp>
+ <script>sudo podman image ls -q</script>
+ </completionHelp>
+ </properties>
+ <command>sudo podman image rm --force "${4}"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="generate">
+ <children>
+ <node name="container">
+ <properties>
+ <help>Generate Container Image</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>Name of container image (tag)</help>
+ </properties>
+ <children>
+ <tagNode name="path">
+ <properties>
+ <help>Path to Dockerfile</help>
+ <completionHelp>
+ <list>&lt;filename&gt;</list>
+ </completionHelp>
+ </properties>
+ <command>sudo podman build --net host --layers --force-rm --tag "$4" $6</command>
+ </tagNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="monitor">
+ <children>
+ <node name="log">
+ <children>
+ <tagNode name="container">
+ <properties>
+ <help>Monitor last lines of container logs</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo podman logs --follow --names "$4"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="show">
+ <children>
+ <node name="container">
+ <properties>
+ <help>Show containers</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/container.py show_container</command>
+ <children>
+ <leafNode name="image">
+ <properties>
+ <help>Show container image</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/container.py show_image</command>
+ </leafNode>
+ <tagNode name="log">
+ <properties>
+ <help>Show logs from a given container</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo podman logs --names "$4"</command>
+ </tagNode>
+ <leafNode name="network">
+ <properties>
+ <help>Show available container networks</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/container.py show_network</command>
+ </leafNode>
+ </children>
+ </node>
+ <node name="log">
+ <children>
+ <tagNode name="container">
+ <properties>
+ <help>Show logs from a given container</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo podman logs --names "$4"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="restart">
+ <children>
+ <tagNode name="container">
+ <properties>
+ <help>Restart a given container</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/container.py restart --name="$3"</command>
+ </tagNode>
+ </children>
+ </node>
+ <node name="update">
+ <children>
+ <node name="container">
+ <properties>
+ <help>Update a container image</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>Update container image</help>
+ <completionHelp>
+ <path>container name</path>
+ </completionHelp>
+ </properties>
+ <command>if cli-shell-api existsActive container name "$4"; then sudo podman pull $(cli-shell-api returnActiveValue container name "$4" image); else echo "Container $4 does not exist"; fi</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py
new file mode 100755
index 000000000..902156ee6
--- /dev/null
+++ b/smoketest/scripts/cli/test_container.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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 vyos.configsession import ConfigSessionError
+from vyos.util import cmd
+from vyos.util import process_named_running
+from vyos.util import read_file
+
+base_path = ['container']
+cont_image = 'busybox:stable' # busybox is included in vyos-build
+prefix = '192.168.205.0/24'
+net_name = 'NET01'
+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')
+
+ @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_01_basic_container(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_02_container_network(self):
+ cont_name = 'c2'
+ cont_ip = '192.168.205.25'
+ self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
+ self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'network', net_name, 'address', cont_ip])
+
+ # commit changes
+ self.cli_commit()
+
+ n = cmd_to_json(f'sudo podman network inspect {net_name}')
+ json_subnet = n['subnets'][0]['subnet']
+
+ c = cmd_to_json(f'sudo podman container inspect {cont_name}')
+ json_ip = c['NetworkSettings']['Networks'][net_name]['IPAddress']
+
+ self.assertEqual(json_subnet, prefix)
+ self.assertEqual(json_ip, cont_ip)
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)
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)