Merge "Add systemd provider for console containers"
This commit is contained in:
commit
eddcfc93ee
@ -96,3 +96,7 @@ gawk [imagebuild]
|
||||
mtools [imagebuild]
|
||||
# For automatic artifact decompression
|
||||
zstd [devstack]
|
||||
|
||||
# For graphical console support
|
||||
podman [devstack]
|
||||
systemd-container [devstack]
|
@ -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
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
67
ironic/common/console_factory.py
Normal file
67
ironic/common/console_factory.py
Normal 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
|
@ -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 "
|
||||
|
@ -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
|
||||
|
@ -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. '),
|
||||
]
|
||||
|
||||
|
||||
|
0
ironic/console/container/__init__.py
Normal file
0
ironic/console/container/__init__.py
Normal file
55
ironic/console/container/base.py
Normal file
55
ironic/console/container/base.py
Normal 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
|
||||
"""
|
31
ironic/console/container/fake.py
Normal file
31
ironic/console/container/fake.py
Normal 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
|
11
ironic/console/container/ironic-console.container.template
Normal file
11
ironic/console/container/ironic-console.container.template
Normal 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
|
324
ironic/console/container/systemd.py
Normal file
324
ironic/console/container/systemd.py
Normal 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
|
0
ironic/tests/unit/console/container/__init__.py
Normal file
0
ironic/tests/unit/console/container/__init__.py
Normal file
320
ironic/tests/unit/console/container/test_console_container.py
Normal file
320
ironic/tests/unit/console/container/test_console_container.py
Normal 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()
|
@ -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.
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user