Merge "Add systemd provider for console containers"

This commit is contained in:
Zuul 2025-03-05 04:22:08 +00:00 committed by Gerrit Code Review
commit eddcfc93ee
17 changed files with 932 additions and 2 deletions

View File

@ -96,3 +96,7 @@ gawk [imagebuild]
mtools [imagebuild]
# For automatic artifact decompression
zstd [devstack]
# For graphical console support
podman [devstack]
systemd-container [devstack]

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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 "

View File

@ -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

View File

@ -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. '),
]

View File

View File

@ -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
"""

View File

@ -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

View File

@ -0,0 +1,11 @@
[Unit]
Description={{ description }}
[Container]
Image={{ image }}
PublishPort={{ port }}
Environment=APP={{ app }}
Environment=APP_INFO='{{ app_info }}'
[Install]
WantedBy=default.target

View File

@ -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

View File

@ -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()

View File

@ -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.

View File

@ -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