From 48557942acb0b5d7f0fbe2e68552245692c7890a Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Fri, 14 Feb 2025 13:53:03 +1300 Subject: [PATCH] Add systemd provider for console containers A new entry point ``ironic.console.container`` is added to determine how console containers are orchestrated when ``ironic.conf`` ``[vnc]enabled=True``. By default the ``fake`` provider is specified by ``[vnc]container_provider`` which performs no orchestration. The only functional implementation included is ``systemd`` which manages containers as Systemd Quadlet containers. These containers run as user services and rootless podman containers. Having ``podman`` installed is also a dependency for this provider. See ``ironic.conf`` ``[vnc]`` options to see how this provider can be configured. The ``systemd`` provider is opinionated and will not be appropriate for some Ironic deployment methods, especially those which run Ironic inside containers. External implementations of ``ironic.console.container`` are encouraged to integrate with other deployment / management methods. Related-Bug: 2086715 Change-Id: Ib890c3c7be91ddd78a43b9c5261dd1d8c1054c04 --- bindep.txt | 4 + devstack/lib/ironic | 27 +- .../include/configure-ironic-novncproxy.inc | 6 + ironic/cmd/singleprocess.py | 1 - ironic/common/console_factory.py | 67 ++++ ironic/common/exception.py | 5 + ironic/conductor/rpc_service.py | 15 + ironic/conf/vnc.py | 47 +++ ironic/console/container/__init__.py | 0 ironic/console/container/base.py | 55 +++ ironic/console/container/fake.py | 31 ++ .../ironic-console.container.template | 11 + ironic/console/container/systemd.py | 324 ++++++++++++++++++ .../tests/unit/console/container/__init__.py | 0 .../container/test_console_container.py | 320 +++++++++++++++++ ...le_container_systemd-9aba9a603e3fa94c.yaml | 17 + setup.cfg | 4 + 17 files changed, 932 insertions(+), 2 deletions(-) create mode 100644 ironic/common/console_factory.py create mode 100644 ironic/console/container/__init__.py create mode 100644 ironic/console/container/base.py create mode 100644 ironic/console/container/fake.py create mode 100644 ironic/console/container/ironic-console.container.template create mode 100644 ironic/console/container/systemd.py create mode 100644 ironic/tests/unit/console/container/__init__.py create mode 100644 ironic/tests/unit/console/container/test_console_container.py create mode 100644 releasenotes/notes/console_container_systemd-9aba9a603e3fa94c.yaml diff --git a/bindep.txt b/bindep.txt index 2752e2dc82..13f7a9dcae 100644 --- a/bindep.txt +++ b/bindep.txt @@ -96,3 +96,7 @@ gawk [imagebuild] mtools [imagebuild] # For automatic artifact decompression zstd [devstack] + +# For graphical console support +podman [devstack] +systemd-container [devstack] \ No newline at end of file diff --git a/devstack/lib/ironic b/devstack/lib/ironic index 05f4ca0f0a..1356226f32 100644 --- a/devstack/lib/ironic +++ b/devstack/lib/ironic @@ -1303,6 +1303,8 @@ function install_ironic { NOVNC_WEB_DIR=$DEST/novnc git_clone $NOVNC_REPO $NOVNC_WEB_DIR $NOVNC_BRANCH fi + # podman, systemd-container required by the systemd container provider + install_package podman systemd-container fi } @@ -2046,12 +2048,17 @@ function configure_ironic_novnc { # TODO(stevebaker) handle configuring tls-proxy local service_protocol=http + # TODO(stevebaker) check for existence of vnc_lite.html vs vnc_auto.html + # from older NoVNC releases novnc_url=$service_protocol://$SERVICE_HOST:$service_port/vnc_lite.html iniset $IRONIC_CONF_FILE vnc enabled True iniset $IRONIC_CONF_FILE vnc public_url $novnc_url iniset $IRONIC_CONF_FILE vnc host_ip $SERVICE_HOST iniset $IRONIC_CONF_FILE vnc port $service_port iniset $IRONIC_CONF_FILE vnc novnc_web $NOVNC_WEB_DIR + iniset $IRONIC_CONF_FILE vnc container_provider systemd + # TODO(stevebaker) build this locally during the devstack run + # iniset $IRONIC_CONF_FILE vnc console_image localhost/ironic-vnc-container } @@ -2066,6 +2073,19 @@ function create_ironic_cache_dir { rm -f $IRONIC_AUTH_CACHE_DIR/registry/* } +# create_systemd_container_dir() - Part of the init_ironic() process +function create_systemd_container_dir { + + local uid=$(id -u) + local user_dir=/etc/containers/systemd/users/$uid + if [ ! -d "$user_dir" ]; then + sudo mkdir -p $user_dir + sudo chown $STACK_USER $user_dir + # container files have BMC credentials, limit non stack user + sudo chmod 0750 $user_dir + fi +} + # create_ironic_accounts() - Set up common required ironic accounts # Project User Roles @@ -2118,6 +2138,9 @@ function init_ironic { if [ $ret_val -gt 1 ] ; then die $LINENO "The `ironic-status upgrade check` command returned an error. Cannot proceed." fi + if is_service_enabled ir-novnc; then + create_systemd_container_dir + fi } # _ironic_bm_vm_names() - Generates list of names for baremetal VMs. @@ -2182,7 +2205,9 @@ function start_ironic_api { # start_ironic_conductor() - Used by start_ironic(). # Starts Ironic conductor. function start_ironic_conductor { - run_process ir-cond "$IRONIC_BIN_DIR/ironic-conductor --config-file=$IRONIC_CONF_FILE" + # NOTE(stevebaker) set DBUS_SESSION_BUS_ADDRESS so that systemd calls can be made + # for the systemd console container provider. + run_process ir-cond "$IRONIC_BIN_DIR/ironic-conductor --config-file=$IRONIC_CONF_FILE" "" "" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$UID/bus" # Unset variables which we shouldn't have... Grenade resets these :\ # in grenade/projects/60_nova/resources.sh as part of the resource diff --git a/doc/source/install/include/configure-ironic-novncproxy.inc b/doc/source/install/include/configure-ironic-novncproxy.inc index a4bd0b3918..f324b57e5e 100644 --- a/doc/source/install/include/configure-ironic-novncproxy.inc +++ b/doc/source/install/include/configure-ironic-novncproxy.inc @@ -38,6 +38,12 @@ Configuring ironic-novncproxy service # proxy is used the protocol, IP, and port needs to match how users will access the service public_url=http://PUBLIC_IP:6090/vnc_auto.html + # The only functional container provider included is the systemd provider which manages + # containers as Systemd Quadlet containers. This provider is appropriate to use when the + # Ironic services themselves are not containerised, otherwise a custom external provider + # may be required + container_provider=systemd + #. Restart the ironic-novncproxy service: diff --git a/ironic/cmd/singleprocess.py b/ironic/cmd/singleprocess.py index d18ddd6a62..7a8fbcd114 100644 --- a/ironic/cmd/singleprocess.py +++ b/ironic/cmd/singleprocess.py @@ -57,7 +57,6 @@ def main(): if CONF.vnc.enabled: # Build and start the websocket proxy - launcher = ironic_service.process_launcher() novncproxy = novncproxy_service.NoVNCProxyService() launcher.launch_service(novncproxy) diff --git a/ironic/common/console_factory.py b/ironic/common/console_factory.py new file mode 100644 index 0000000000..addb5ce63d --- /dev/null +++ b/ironic/common/console_factory.py @@ -0,0 +1,67 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_concurrency import lockutils +from oslo_log import log as logging +import stevedore + +from ironic.common import exception +from ironic.conf import CONF + +EM_SEMAPHORE = 'console_container_provider' + +LOG = logging.getLogger(__name__) + + +class ConsoleContainerFactory(object): + + _provider = None + + def __init__(self, **kwargs): + if not ConsoleContainerFactory._provider: + ConsoleContainerFactory._set_provider(**kwargs) + + # Use lockutils to avoid a potential race in eventlet + # that might try to create two factories. + @classmethod + @lockutils.synchronized(EM_SEMAPHORE) + def _set_provider(cls, **kwargs): + """Initialize the provider + + :raises: ConsoleContainerError if the provider cannot be loaded. + """ + + # In case multiple greenthreads queue up on + # this lock before _provider is initialized, + # prevent creation of multiple DriverManager. + if cls._provider: + return + + provider_name = CONF.vnc.container_provider + try: + _extension_manager = stevedore.driver.DriverManager( + 'ironic.console.container', + provider_name, + invoke_kwds=kwargs, + invoke_on_load=True) + except Exception as e: + LOG.exception('Could not create console container provider') + raise exception.ConsoleContainerError( + provider=provider_name, reason=e + ) + + cls._provider = _extension_manager.driver + + @property + def provider(self): + return self._provider diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 7f28af3335..2b122262ca 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -1078,6 +1078,11 @@ class RFBAuthNoAvailableScheme(IronicException): "desired types: '%(desired_types)s'") +class ConsoleContainerError(IronicException): + _msg_fmt = _("Console container error with provider '%(provider)s', " + "reason: %(reason)s") + + class ImageHostRateLimitFailure(TemporaryFailure): _msg_fmt = _("The image registry has indicates the rate limit has been " "exceeded for url %(image_ref)s. Please try again later or " diff --git a/ironic/conductor/rpc_service.py b/ironic/conductor/rpc_service.py index d0163c4934..a64c9b0425 100644 --- a/ironic/conductor/rpc_service.py +++ b/ironic/conductor/rpc_service.py @@ -18,6 +18,7 @@ from oslo_config import cfg from oslo_log import log from oslo_utils import timeutils +from ironic.common import console_factory from ironic.common import rpc from ironic.common import rpc_service @@ -36,6 +37,11 @@ class RPCService(rpc_service.BaseRPCService): super()._real_start() rpc.set_global_manager(self.manager) + # Start in a known state of no console containers running. + # Any enabled console managed by this conductor will be started + # after this + self._stop_console_containers() + def stop(self): initial_time = timeutils.utcnow() extend_time = initial_time + datetime.timedelta( @@ -71,6 +77,9 @@ class RPCService(rpc_service.BaseRPCService): '%(host)s.', {'service': self.topic, 'host': self.host}) + # Stop all running console containers + self._stop_console_containers() + # Wait for reservation locks held by this conductor. # The conductor process will end when one of the following occurs: # - All reservations for this conductor are released @@ -88,6 +97,12 @@ class RPCService(rpc_service.BaseRPCService): rpc.set_global_manager(None) + def _stop_console_containers(self): + # the default provider is fake, so this can be called even when + # CONF.vnc.enabled is false + provider = console_factory.ConsoleContainerFactory().provider + provider.stop_all_containers() + def _shutdown_timeout_reached(self, initial_time): if self.draining: shutdown_timeout = CONF.drain_shutdown_timeout diff --git a/ironic/conf/vnc.py b/ironic/conf/vnc.py index 8c6172a1ee..47954ba08a 100644 --- a/ironic/conf/vnc.py +++ b/ironic/conf/vnc.py @@ -12,6 +12,8 @@ # License for the specific language governing permissions and limitations # under the License. +import os + from oslo_config import cfg from oslo_config import types @@ -74,6 +76,51 @@ opts = [ default=600, min=10, help='The lifetime of a console auth token (in seconds).'), + cfg.IntOpt( + 'expire_console_session_interval', + default=120, + min=1, + help='Interval (in seconds) between periodic checks to determine ' + 'whether active console sessions have expired and need to be ' + 'closed.'), + cfg.StrOpt( + 'container_provider', + default='fake', + help='Console container provider which manages the containers that ' + 'expose a VNC service to ironic-novncproxy or nova-novncproxy. ' + 'Each container runs an X11 session and a browser showing the ' + 'actual BMC console. ' + '"systemd" manages containers as systemd units via podman ' + 'Quadlet support. The default is "fake" which returns an ' + 'unusable VNC host and port. This needs to be changed if enabled ' + 'is True'), + cfg.StrOpt( + 'console_image', + mutable=True, + help='Container image reference for the "systemd" console container ' + 'provider, and any other out-of-tree provider which requires a ' + 'configurable image reference.'), + cfg.StrOpt( + 'systemd_container_template', + default=os.path.join( + '$pybasedir', + 'console/container/ironic-console.container.template'), + mutable=True, + help='For the systemd provider, path to the template for defining a ' + 'console container. The default template requires that ' + '"console_image" be set.'), + cfg.StrOpt( + 'systemd_container_publish_port', + default='$my_ip::5900', + help='Equivalent to the podman run --port argument for the ' + 'mapping of VNC port 5900 to the host. An IP address is ' + 'required to bind to, defaulting to $my_ip. The VNC port ' + 'exposed on the host will be a randomly allocated high port. ' + 'These containers expose VNC servers which must be accessible ' + 'by ironic-novncproxy and/or nova-novncproxy. The VNC servers ' + 'have no authentication or encryption so they also should not ' + 'be exposed to public access. Additionally, the containers ' + 'need to be able to access BMC management endpoints. '), ] diff --git a/ironic/console/container/__init__.py b/ironic/console/container/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/console/container/base.py b/ironic/console/container/base.py new file mode 100644 index 0000000000..13e0edc07a --- /dev/null +++ b/ironic/console/container/base.py @@ -0,0 +1,55 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Abstract base class for console container providers. +""" + +import abc + + +class BaseConsoleContainer(object, metaclass=abc.ABCMeta): + """Base class for console container provider APIs.""" + + @abc.abstractmethod + def start_container(self, task, app_name, app_info): + """Start a console container for a node. + + Calling this will block until a consumable container host and port can + be returned. + + :param task: A TaskManager instance. + :param app_name: Name of app to run in the container + :param app_info: Dict of app-specific info + :returns: Tuple of host IP address and published port + :raises: ConsoleContainerError + """ + + @abc.abstractmethod + def stop_container(self, task): + """Stop a console container for a node. + + Any existing running container for this node will be stopped. + + :param task: A TaskManager instance. + :raises: ConsoleContainerError + """ + + @abc.abstractmethod + def stop_all_containers(self): + """Stops all running console containers + + This is run on conductor startup and graceful shutdown to ensure + no console containers are running. + :raises: ConsoleContainerError + """ diff --git a/ironic/console/container/fake.py b/ironic/console/container/fake.py new file mode 100644 index 0000000000..cbb9ad252e --- /dev/null +++ b/ironic/console/container/fake.py @@ -0,0 +1,31 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Fake console container provider for disabling . +""" + +from ironic.console.container import base + + +class FakeConsoleContainer(base.BaseConsoleContainer): + + def start_container(self, task, app_name, app_info): + # return a test-net-1 address + return '192.0.2.1', 5900 + + def stop_container(self, task): + pass + + def stop_all_containers(self): + pass diff --git a/ironic/console/container/ironic-console.container.template b/ironic/console/container/ironic-console.container.template new file mode 100644 index 0000000000..32dc0ddcbe --- /dev/null +++ b/ironic/console/container/ironic-console.container.template @@ -0,0 +1,11 @@ +[Unit] +Description={{ description }} + +[Container] +Image={{ image }} +PublishPort={{ port }} +Environment=APP={{ app }} +Environment=APP_INFO='{{ app_info }}' + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/ironic/console/container/systemd.py b/ironic/console/container/systemd.py new file mode 100644 index 0000000000..ed78c938a5 --- /dev/null +++ b/ironic/console/container/systemd.py @@ -0,0 +1,324 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Systemd Quadlet console container provider. +""" +import json +import os +import re + +from oslo_concurrency import processutils +from oslo_log import log as logging + +from ironic.common import exception +from ironic.common import utils +from ironic.conf import CONF +from ironic.console.container import base + +LOG = logging.getLogger(__name__) + +TEMPLATE_PREFIX = 'ironic-console' + +# "podman port" output is of the format +# 5900/tcp -> 127.0.0.1:12345 +# ^^^^^^^^^ ^^^^^ +PORT_RE = re.compile('^5900/tcp -> (.*):([0-9]+)$') + + +class SystemdConsoleContainer(base.BaseConsoleContainer): + """Console container provider which uses Systemd Quadlets.""" + + unit_dir = None + + def __init__(self): + + # confirm podman and systemctl are available + try: + utils.execute('systemctl', '--version') + except processutils.ProcessExecutionError as e: + LOG.exception('systemctl not available, ' + 'this provider cannot be used.') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + try: + utils.execute('podman', '--version') + except processutils.ProcessExecutionError as e: + LOG.exception('podman not available, ' + 'it is mandatory to use this provider.') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _init_unit_dir(self, unit_dir=None): + + if unit_dir: + self.unit_dir = unit_dir + + elif not self.unit_dir: + + # Write container files to + # /etc/containers/systemd/users/{uid}/containers/systemd + # Containers are stateless and can be run rootless as user + # containers. + uid = str(os.getuid()) + user_dir = os.path.join('/etc/containers/systemd/users', uid) + if not os.path.isdir(user_dir): + reason = (f'Directory {user_dir} must exist and be writable ' + f'by user {uid}') + raise exception.ConsoleContainerError( + provider='systemd', reason=reason) + + self.unit_dir = os.path.join( + '/etc/containers/systemd/users', uid, 'containers/systemd') + + if not os.path.exists(self.unit_dir): + try: + os.makedirs(self.unit_dir) + except OSError as e: + LOG.exception( + 'Could not initialize console containers') + raise exception.ConsoleContainerError( + provider='systemd', reason=e) + + def _container_path(self, identifier): + """Build a container path. + + :param identifier: Optional identifier to include in the path + :returns: A quadlet .container file path + """ + return os.path.join( + self.unit_dir, f'{TEMPLATE_PREFIX}-{identifier}.container') + + def _unit_name(self, identifier): + """Build a unit name. + + :param identifier: Optional identifier to include in the name + :returns: Unit service name which corresponds to a .container file + """ + return f'{TEMPLATE_PREFIX}-{identifier}.service' + + def _container_name(self, identifier): + """Build a container name. + + :param identifier: Optional identifier to include in the name + :returns: The name of the podman container created by systemd + quadlet container + """ + return f'systemd-{TEMPLATE_PREFIX}-{identifier}' + + def _reload(self): + """Call systemctl --user daemon-reload + + :raises: ConsoleContainerError + """ + try: + utils.execute('systemctl', '--user', 'daemon-reload') + except processutils.ProcessExecutionError as e: + LOG.exception('Problem calling systemctl daemon-reload') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _start(self, unit): + """Call systemctl --user start. + + :param unit: Name of the unit to start + :raises: ConsoleContainerError + """ + try: + utils.execute('systemctl', '--user', 'start', unit) + except processutils.ProcessExecutionError as e: + LOG.exception('Problem calling systemctl start') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _stop(self, unit): + """Call systemctl --user stop. + + :param unit: Name of the unit to stop + :raises: ConsoleContainerError + """ + try: + utils.execute('systemctl', '--user', 'stop', unit) + except processutils.ProcessExecutionError as e: + LOG.exception('Problem calling systemctl stop') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _host_port(self, container): + """Extract running host and port from a container. + + Calls 'podman port' and parses the result. + + :param container: container name + :returns: Tuple of host IP address and published port + :raises: ConsoleContainerError + """ + try: + out, _ = utils.execute('podman', 'port', container) + match = PORT_RE.match(out) + if match: + return match.group(1), int(match.group(2)) + raise exception.ConsoleContainerError( + provider='systemd', + reason=f'Could not detect port in the output: {out}') + + except processutils.ProcessExecutionError as e: + LOG.exception('Problem calling podman port %s', container) + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _write_container_file(self, identifier, app_name, app_info): + """Create quadlet container file. + + :param identifier: Unique container identifier + :param app_name: Sets container environment APP value + :param app_info: Sets container environment APP_INFO value + :raises: ConsoleContainerError + """ + try: + container_file = self._container_path(identifier) + + # TODO(stevebaker) Support bind-mounting certificate files to + # handle verified BMC certificates + + params = { + 'description': 'A VNC server which displays a console ' + f'for node {identifier}', + 'image': CONF.vnc.console_image, + 'port': CONF.vnc.systemd_container_publish_port, + 'app': app_name, + 'app_info': json.dumps(app_info), + } + + LOG.debug('Writing %s', container_file) + with open(container_file, 'wt') as fp: + fp.write(utils.render_template( + CONF.vnc.systemd_container_template, params=params)) + + except OSError as e: + LOG.exception('Could not start console container') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def _delete_container_file(self, identifier): + """Delete container file. + + :param identifier: Unique container identifier + :raises: ConsoleContainerError + """ + container_file = self._container_path(identifier) + + try: + if os.path.exists(container_file): + LOG.debug('Removing file %s', container_file) + os.remove(container_file) + except OSError as e: + LOG.exception('Could not stop console containers') + raise exception.ConsoleContainerError(provider='systemd', reason=e) + + def start_container(self, task, app_name, app_info): + """Stop a console container for a node. + + Any existing running container for this node will be stopped. + + :param task: A TaskManager instance. + :raises: ConsoleContainerError + """ + self._init_unit_dir() + node = task.node + uuid = node.uuid + + LOG.debug('Starting console container for node %s', uuid) + + self._write_container_file( + identifier=uuid, app_name=app_name, app_info=app_info) + + # notify systemd to changed file + self._reload() + + # start the container + unit = self._unit_name(uuid) + try: + self._start(unit) + except Exception as e: + try: + self._delete_container_file(uuid) + pass + except Exception: + pass + raise e + + container = self._container_name(uuid) + + return self._host_port(container) + + def _stop_container(self, identifier): + """Stop a console container for a node. + + Any existing running container for this node will be stopped. + + :param identifier: Unique container identifier + :raises: ConsoleContainerError + """ + unit = self._unit_name(identifier) + try: + # stop any running container + self._stop(unit) + except Exception: + pass + + self._delete_container_file(identifier) + + def stop_container(self, task): + """Stop a console container for a node. + + Any existing running container for this node will be stopped. + + :param task: A TaskManager instance. + :raises: ConsoleContainerError + """ + self._init_unit_dir() + node = task.node + uuid = node.uuid + LOG.debug('Stopping console container for node %s', uuid) + self._stop_container(uuid) + # notify systemd to changed file + self._reload() + + def stop_all_containers(self): + """Stops all running console containers + + This is run on conductor startup and graceful shutdown to ensure + no console containers are running. + :raises: ConsoleContainerError + """ + LOG.debug('Stopping all console containers') + self._init_unit_dir() + stop_count = 0 + if not os.path.exists(self.unit_dir): + # No unit state, so assume no containers are running + return + + for filename in os.listdir(self.unit_dir): + if not filename.startswith(TEMPLATE_PREFIX): + # ignore containers this isn't managing + continue + + stop_count = stop_count + 1 + try: + # get the identifier from the filename and stop the container + identifier = filename.split( + f'{TEMPLATE_PREFIX}-')[1].split('.container')[0] + self._stop_container(identifier) + except Exception: + pass + + if stop_count > 0: + try: + # notify systemd to changed file + self._reload() + except Exception: + pass diff --git a/ironic/tests/unit/console/container/__init__.py b/ironic/tests/unit/console/container/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/console/container/test_console_container.py b/ironic/tests/unit/console/container/test_console_container.py new file mode 100644 index 0000000000..67f3995276 --- /dev/null +++ b/ironic/tests/unit/console/container/test_console_container.py @@ -0,0 +1,320 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import tempfile +from unittest import mock + +from oslo_concurrency import processutils +from oslo_config import cfg + +from ironic.common import console_factory +from ironic.common import exception +from ironic.common import utils +from ironic.console.container import fake +from ironic.tests import base + +CONF = cfg.CONF + + +def _reset_provider(provider_name): + CONF.set_override('container_provider', provider_name, 'vnc') + console_factory.ConsoleContainerFactory._provider = None + + +class TestConsoleContainerFactory(base.TestCase): + + def setUp(self): + super(TestConsoleContainerFactory, self).setUp() + _reset_provider('fake') + + def test_factory(self): + provider = console_factory.ConsoleContainerFactory().provider + + self.assertIsInstance(provider, fake.FakeConsoleContainer) + + provider2 = console_factory.ConsoleContainerFactory().provider + self.assertEqual(provider, provider2) + + +class TestSystemdConsoleContainer(base.TestCase): + + def setUp(self): + super(TestSystemdConsoleContainer, self).setUp() + _reset_provider('systemd') + self.addCleanup(_reset_provider, 'fake') + self.tempdir = tempfile.mkdtemp() + self.addCleanup(lambda: utils.rmtree_without_raise(self.tempdir)) + os.environ['XDG_RUNTIME_DIR'] = self.tempdir + with mock.patch.object(utils, 'execute', autospec=True) as mock_exec: + self.provider = console_factory.ConsoleContainerFactory().provider + mock_exec.assert_has_calls([ + mock.call('systemctl', '--version'), + mock.call('podman', '--version'), + ]) + # Override unit directory with tempdir + self.provider._init_unit_dir(self.tempdir) + + def test__container_path(self): + self.assertEqual( + f'{self.tempdir}/ironic-console-1234.container', + self.provider._container_path('1234')) + + def test__unit_name(self): + self.assertEqual( + 'ironic-console-1234.service', + self.provider._unit_name('1234') + ) + + def test__container_name(self): + self.assertEqual( + 'systemd-ironic-console-1234', + self.provider._container_name('1234') + ) + + @mock.patch.object(utils, 'execute', autospec=True) + def test__reload(self, mock_exec): + + mock_exec.return_value = (None, None) + self.provider._reload() + + # assert successful call + mock_exec.assert_called_once_with( + 'systemctl', '--user', 'daemon-reload') + + mock_exec.side_effect = [ + processutils.ProcessExecutionError( + stderr='ouch' + ), + (None, None) + ] + # assert failed call + self.assertRaisesRegex(exception.ConsoleContainerError, 'ouch', + self.provider._reload) + + @mock.patch.object(utils, 'execute', autospec=True) + def test__start(self, mock_exec): + + mock_exec.return_value = (None, None) + unit = self.provider._unit_name('1234') + self.provider._start(unit) + + # assert successful call + mock_exec.assert_called_once_with( + 'systemctl', '--user', 'start', unit) + + mock_exec.side_effect = [ + processutils.ProcessExecutionError( + stderr='ouch' + ), + (None, None) + ] + # assert failed call + self.assertRaisesRegex(exception.ConsoleContainerError, 'ouch', + self.provider._start, unit) + + @mock.patch.object(utils, 'execute', autospec=True) + def test__stop(self, mock_exec): + + mock_exec.return_value = (None, None) + unit = self.provider._unit_name('1234') + self.provider._stop(unit) + + # assert successful call + mock_exec.assert_called_once_with('systemctl', '--user', 'stop', unit) + + mock_exec.side_effect = [ + processutils.ProcessExecutionError( + stderr='ouch' + ), + (None, None) + ] + # assert failed call + self.assertRaisesRegex(exception.ConsoleContainerError, 'ouch', + self.provider._stop, unit) + + @mock.patch.object(utils, 'execute', autospec=True) + def test__host_port(self, mock_exec): + + mock_exec.return_value = ('5900/tcp -> 192.0.2.1:33819', None) + container = self.provider._container_name('1234') + self.assertEqual( + ('192.0.2.1', 33819), + self.provider._host_port(container) + ) + + # assert successful call + mock_exec.assert_called_once_with('podman', 'port', container) + + # assert failed parsing response + mock_exec.return_value = ('5900/tcp -> asdkljffo872', None) + self.assertRaisesRegex(exception.ConsoleContainerError, + 'Could not detect port', + self.provider._host_port, container) + + mock_exec.side_effect = [ + processutils.ProcessExecutionError( + stderr=f'Error: no container with name or ID "{container}" ' + 'found: no such container' + ), + (None, None) + ] + # assert failed call + self.assertRaisesRegex(exception.ConsoleContainerError, + 'no such container', + self.provider._host_port, container) + + def test__write_container_file(self): + CONF.set_override( + 'systemd_container_publish_port', + '192.0.2.2::5900', + group='vnc') + CONF.set_override( + 'console_image', + 'localhost/ironic-vnc-container', + group='vnc') + + uuid = '1234' + container_path = self.provider._container_path(uuid) + self.provider._write_container_file( + identifier=uuid, app_name='fake', app_info={}) + + # assert the file is correct + with open(container_path, "r") as f: + self.assertEqual( + """[Unit] +Description=A VNC server which displays a console for node 1234 + +[Container] +Image=localhost/ironic-vnc-container +PublishPort=192.0.2.2::5900 +Environment=APP=fake +Environment=APP_INFO='{}' + +[Install] +WantedBy=default.target""", f.read()) + + def test_delete_container_file(self): + uuid = '1234' + self.provider._write_container_file( + uuid, app_name='fake', app_info={}) + + container_path = self.provider._container_path(uuid) + + # initial state file exists + self.assertTrue(os.path.isfile(container_path)) + + self.provider._delete_container_file(uuid) + + # assert file was deleted + self.assertFalse(os.path.exists(container_path)) + + @mock.patch.object(utils, 'execute', autospec=True) + def test_start_stop_container(self, mock_exec): + uuid = '1234' + task = mock.Mock(node=mock.Mock(uuid=uuid)) + + container_path = self.provider._container_path(uuid) + + mock_exec.side_effect = [ + (None, None), + (None, None), + ('5900/tcp -> 192.0.2.1:33819', None) + ] + + # start the container and assert the host / port + self.assertEqual( + ('192.0.2.1', 33819), + self.provider.start_container(task, 'fake', {}) + ) + # assert the created file + self.assertTrue(os.path.isfile(container_path)) + + # assert all the expected calls + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'daemon-reload'), + mock.call('systemctl', '--user', 'start', + 'ironic-console-1234.service'), + mock.call('podman', 'port', 'systemd-ironic-console-1234') + ]) + + mock_exec.reset_mock() + mock_exec.side_effect = [ + (None, None), + (None, None), + ] + # stop the container + self.provider.stop_container(task) + + # assert the container file is deleted + self.assertFalse(os.path.exists(container_path)) + + # assert expected stop calls + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'stop', + 'ironic-console-1234.service'), + mock.call('systemctl', '--user', 'daemon-reload'), + ]) + + @mock.patch.object(utils, 'execute', autospec=True) + def test_stop_all_containers(self, mock_exec): + # set up initial state with 3 running containers + t1 = mock.Mock(node=mock.Mock(uuid='1234')) + t2 = mock.Mock(node=mock.Mock(uuid='asdf')) + t3 = mock.Mock(node=mock.Mock(uuid='foobar')) + mock_exec.side_effect = [ + (None, None), + (None, None), + ('5900/tcp -> 192.0.2.1:33819', None), + (None, None), + (None, None), + ('5900/tcp -> 192.0.2.1:33820', None), + (None, None), + (None, None), + ('5900/tcp -> 192.0.2.1:33821', None), + ] + self.provider.start_container(t1, 'fake', {}) + self.provider.start_container(t2, 'fake', {}) + self.provider.start_container(t3, 'fake', {}) + + mock_exec.reset_mock() + mock_exec.side_effect = [ + (None, None), + (None, None), + (None, None), + (None, None), + ] + + self.provider.stop_all_containers() + # assert all containers stopped + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'stop', + 'ironic-console-1234.service'), + ]) + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'stop', + 'ironic-console-asdf.service'), + ]) + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'stop', + 'ironic-console-foobar.service'), + ]) + mock_exec.assert_has_calls([ + mock.call('systemctl', '--user', 'daemon-reload') + ]) + + # stop all containers again and confirm nothing was stopped because + # all of the files are deleted + mock_exec.reset_mock() + self.provider.stop_all_containers() + mock_exec.assert_not_called() diff --git a/releasenotes/notes/console_container_systemd-9aba9a603e3fa94c.yaml b/releasenotes/notes/console_container_systemd-9aba9a603e3fa94c.yaml new file mode 100644 index 0000000000..0e20ab7605 --- /dev/null +++ b/releasenotes/notes/console_container_systemd-9aba9a603e3fa94c.yaml @@ -0,0 +1,17 @@ +--- +features: + - | + A new entry point ``ironic.console.container`` is added to determine how + console containers are orchestrated when ``ironic.conf`` + ``[vnc]enabled=True``. By default the ``fake`` provider is specified by + ``[vnc]container_provider`` which performs no orchestration. The only + functional implementation included is ``systemd`` which manages containers + as Systemd Quadlet containers. These containers run as user services and + rootless podman containers. Having ``podman`` installed is also a + dependency for this provider. See ``ironic.conf`` ``[vnc]`` options + to see how this provider can be configured. + + The ``systemd`` provider is opinionated and will not be appropriate for + some Ironic deployment methods, especially those which run Ironic inside + containers. External implementations of ``ironic.console.container`` are + encouraged to integrate with other deployment / management methods. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3ef47ac99a..08bf58b8a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -203,6 +203,10 @@ ironic.inspection.hooks = local-link-connection = ironic.drivers.modules.inspector.hooks.local_link_connection:LocalLinkConnectionHook parse-lldp = ironic.drivers.modules.inspector.hooks.parse_lldp:ParseLLDPHook +ironic.console.container = + systemd = ironic.console.container.systemd:SystemdConsoleContainer + fake = ironic.console.container.fake:FakeConsoleContainer + [extras] guru_meditation_reports = oslo.reports>=1.18.0 # Apache-2.0