Source code for ironic_python_agent.hardware_managers.container

# 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 ironic_python_agent import errors
from ironic_python_agent import hardware
from ironic_python_agent import utils
from oslo_config import cfg
from oslo_log import log

from functools import partial
import yaml

CONF = cfg.CONF
LOG = log.getLogger(__name__)

# Transports naming an image in a remote registry, and so the only ones it is
# safe to look through. oci:// is ironic's own spelling for a registry
# reference and carries no trademark. Stripping a local transport instead
# would let containers-storage:example.com/tool:1 match an allowlist entry
# written for the registry image while running whatever happens to sit in
# local storage under that name. A transport absent from here fails closed:
# the reference does not normalize, so it matches nothing and is refused.
TRANSPORT_PREFIXES = ('docker://', 'oci://')


def _normalize_image_ref(value):
    """Strip the transport prefix from a container image reference.

    ``docker://example.com/tool:1`` and ``example.com/tool:1`` name the same
    image, so the allowlist must not treat them as different.
    """
    for prefix in TRANSPORT_PREFIXES:
        if value.startswith(prefix):
            return value[len(prefix):]
    return value


def _as_options(value):
    """Normalize configured container options into argv elements.

    pull_options and run_options are ListOpts, and lists split on commas
    rather than whitespace. The conductor sends them as plain strings and the
    build element writes them space separated, so without this a whole option
    string reaches the runtime as a single argument. An option value
    containing a literal space cannot be expressed this way.
    """
    if value is None:
        return []
    if isinstance(value, str):
        value = [value]
    return [word for item in value for word in str(item).split()]


[docs] class ContainerHardwareManager(hardware.HardwareManager): """Hardware manager for container-based cleanup.""" HARDWARE_MANAGER_NAME = "ContainerHardwareManager" # 2: container_clean_step renamed, allowlist enforced on untrusted steps. HARDWARE_MANAGER_VERSION = "2" # Runtimes support detection scans for; [container]runner picks one. CONTAINER_RUNNERS = ("podman", "docker") DEPRECATED_STEP_NAME = "container_clean_step" _RESERVED_NAMES = None def __init__(self): self.STEPS = None def _load_steps_from_yaml(self, file_path): """Load container steps from the configured YAML file. A missing file means the ramdisk defines no container steps, which is normal. A file that exists but cannot be read or parsed is an operator error and is reported rather than treated as "no steps". :raises HardwareManagerConfigurationError: if the file exists but cannot be read, parsed, or does not match the expected schema. """ try: with open(file_path, 'r') as file: data = yaml.safe_load(file) except FileNotFoundError: LOG.debug("No container steps file at %s", file_path) return [] except (OSError, yaml.YAMLError) as e: raise errors.HardwareManagerConfigurationError( f"Could not read container steps from {file_path}: {e}" ) if data is None: return [] if not isinstance(data, dict): raise errors.HardwareManagerConfigurationError( f"Container steps file {file_path} must contain a mapping " f"with a 'steps' key, got {type(data).__name__}." ) steps = data.get('steps') or [] if not isinstance(steps, list): raise errors.HardwareManagerConfigurationError( f"'steps' in {file_path} must be a list, got " f"{type(steps).__name__}." ) for step in steps: self._validate_step(step, file_path) return steps @staticmethod def _validate_step(step, file_path): """Check a single YAML step has what execution will need. :raises HardwareManagerConfigurationError: if the step is unusable. """ if not isinstance(step, dict): raise errors.HardwareManagerConfigurationError( f"Each container step in {file_path} must be a mapping, got " f"{type(step).__name__}." ) for key in ('name', 'image'): if not step.get(key): raise errors.HardwareManagerConfigurationError( f"Container step '{step.get('name', '<unnamed>')}' in " f"{file_path} is missing the required '{key}' key." ) def _apply_container_policy(self, container_url, pull_options, run_options, trusted): """Apply container policy and return the options to execute with. Steps baked into the ramdisk are trusted: an operator authored them at build time and they cannot be altered at run time. Steps carrying an image in their arguments, such as from a runbook or deploy template, are not, and are held to [container]allowed_containers. Step arguments are validated by name but not by type, so container_url arrives as whatever JSON the runbook carried and is type checked here. :param trusted: whether the step came from the ramdisk itself. :returns: the pull and run options to execute with. :raises InvalidCommandParamsError: if container_url is not a string. :raises ContainerNotPermittedError: if the image is not permitted. """ if not isinstance(container_url, str) or not container_url: raise errors.InvalidCommandParamsError( "container_url must be a non-empty string, got " f"{type(container_url).__name__}.") if trusted or CONF.container.allow_arbitrary_containers: return (_as_options(pull_options or CONF.container.pull_options), _as_options(run_options or CONF.container.run_options)) allowed = {_normalize_image_ref(image) for image in CONF.container.allowed_containers} if _normalize_image_ref(container_url) not in allowed: # Matching is exact and operators are expected to pin by digest, # so a refusal is usually two long strings differing in one # place. Logging both saves a trip back into the ramdisk to find # out what the allowlist actually said. LOG.error("Refusing to run container %s: it is not listed in " "[container]allowed_containers, which permits %s", container_url, CONF.container.allowed_containers) raise errors.ContainerNotPermittedError(container_url) # NOTE(cid): options alone defeat an image allowlist (--privileged, # -v /:/host, --entrypoint), so a caller who may not choose the image # may not choose the flags either. return (_as_options(CONF.container.pull_options), _as_options(CONF.container.run_options)) def _run_container(self, container_url, pull_options=None, run_options=None, trusted=False): """Pull and run a container image. The only place container images are executed. Every entry point routes through here so the policy check cannot be reached around. """ pull_options, run_options = self._apply_container_policy( container_url, pull_options, run_options, trusted) self._check_runner_available() utils.execute(CONF.container.runner, "pull", *pull_options, container_url) utils.execute(CONF.container.runner, "run", *run_options, container_url) def _container_step(self, node, ports, container_url, pull_options=None, run_options=None, trusted=False): try: self._run_container(container_url, pull_options=pull_options, run_options=run_options, trusted=trusted) LOG.info("Container step completed for image: %s", container_url) except Exception as e: LOG.exception("Error during container operation: %s", e) raise
[docs] def generic_container_step(self, node, ports, container_url, pull_options=None, run_options=None): """Run a container image supplied through step arguments. trusted is hardcoded and deliberately absent from this signature. Step arguments are passed straight through, so making it a parameter would let a runbook assert its own trust. """ return self._container_step(node, ports, container_url, pull_options=pull_options, run_options=run_options, trusted=False)
[docs] def container_clean_step(self, node, ports, container_url, pull_options=None, run_options=None): """Deprecated alias for generic_container_step. Kept working because runbooks are rows in operators' databases, not something they can re-spell on upgrade. """ LOG.warning("The 'container_clean_step' step is deprecated, use " "'generic_container_step' instead.") return self.generic_container_step(node, ports, container_url, pull_options=pull_options, run_options=run_options)
def _create_step_method(self, container_url, pull_options=None, run_options=None): """Build the callable invoked for a ramdisk-baked step.""" return partial(self._container_step, container_url=container_url, pull_options=pull_options, run_options=run_options, trusted=True) def _create_container_step(self, step_name=None): return { "step": step_name or "generic_container_step", "priority": 0, # run only manual cleaning "interface": "deploy", "reboot_requested": False, "abortable": True, "argsinfo": { "container_url": {"description": "Container image URL", "required": True}, "pull_options": {"description": "Pull options", "required": False}, "run_options": {"description": "Run options", "required": False}, }, }
[docs] def evaluate_hardware_support(self): """Determine if a container runner exists and return support level. [container]runner is not consulted here. Managers are evaluated before the lookup response is applied, so it would test a value the conductor is about to replace. _check_runner_available() checks the configured runtime at execution time instead. The runtime is asked for its version rather than merely located, so a binary that is present but cannot run does not read as support. """ for runner in self.CONTAINER_RUNNERS: try: utils.execute(runner, "--version") LOG.debug("Found %s, returning MAINLINE", runner) return hardware.HardwareSupport.MAINLINE except Exception as e: LOG.debug("Error checking container runner: %s", e) LOG.info("No container runtime (%s) is usable in this ramdisk, " "container based steps are unavailable", ", ".join(self.CONTAINER_RUNNERS)) return hardware.HardwareSupport.NONE
def _check_runner_available(self): """Verify the configured container runner runs in this ramdisk. :raises HardwareManagerConfigurationError: if it is not usable. """ runner = CONF.container.runner try: utils.execute(runner, "--version") except Exception as e: raise errors.HardwareManagerConfigurationError( f"Configured container runner '{runner}' " f"([container]runner) is not usable in this ramdisk: {e}" ) def _get_steps(self, automatic): """Build the advertised step list. :param automatic: whether a step's declared priority is honored. A priority in the steps file describes automated cleaning; other phases expose the same steps for explicit invocation only. """ self.STEPS = self._load_steps_from_yaml( CONF.container['container_steps_file']) steps = [self._create_container_step(), self._create_container_step(self.DEPRECATED_STEP_NAME)] for step in self.STEPS: # A step advertised here but refused by __getattr__ would be # scheduled and then fail with a confusing "method not found". name = step.get("name") if name in self._reserved_names(): raise errors.HardwareManagerConfigurationError( f"Container step '{name}' collides with an existing " f"hardware manager method and would shadow it. Rename it " f"in {CONF.container['container_steps_file']}." ) try: # Read priority even when it is about to be discarded, so a # steps file missing it fails the same way in every phase. priority = step["priority"] steps.append( { "step": step["name"], "priority": priority if automatic else 0, "interface": step['interface'], "reboot_requested": step['reboot_requested'], "abortable": step["abortable"], } ) except KeyError as e: missing_key = str(e) step_name = step.get("name", "unknown") LOG.exception("Missing key '%s' in cleaning step: %s", missing_key, step_name) raise errors.HardwareManagerConfigurationError( f"Missing required key {missing_key} in cleaning step: " f"{step_name}" ) return steps
[docs] def get_clean_steps(self, node, ports): """Dynamically generate cleaning steps.""" return self._get_steps(automatic=True)
[docs] def get_service_steps(self, node, ports): """Generate service steps, none of which run on their own. Priorities in the steps file describe automated cleaning. Honoring them here would run every such container on every service operation. """ return self._get_steps(automatic=False)
[docs] def get_deploy_steps(self, node, ports): """Generate deploy steps, none of which run on their own. A deploy step with a priority above zero runs automatically, so honoring the steps file priority here would fire a container meant for cleaning on every deployment. """ return self._get_steps(automatic=False)
@classmethod def _reserved_names(cls): """Names a YAML step is not allowed to claim. This manager reports MAINLINE, so it is consulted ahead of every other manager for every dispatch. Without this, a step named erase_devices_metadata would run a container instead of erasing the disk, and the step would be reported as successful. An operator's own hardware manager is as shadowable as the generic one, so every loaded manager contributes the names it answers to. """ if cls._RESERVED_NAMES is not None: return cls._RESERVED_NAMES names = (frozenset(dir(cls)) | frozenset(dir(hardware.GenericHardwareManager))) # NOTE(cid): the loaded managers are read from the cache instead of # being requested with get_managers(). Managers are probed and # initialized inside get_managers_detail(), so asking it for them # from __getattr__ while it is still running re-enters it with the # cache empty and recurses. managers = hardware._global_managers if not managers: # Nothing is loaded yet, so the generic manager is every name # that can be known here. Deliberately left uncached: the full # set is available once loading finishes. return names cls._RESERVED_NAMES = names.union( *(frozenset(dir(hwm['manager'])) for hwm in managers)) return cls._RESERVED_NAMES def __getattr__(self, name): """Resolve a steps file entry to the callable that runs it. Container policy is not applied here. Resolving a name is not running it, and enforcing at resolution time has let the guard be skipped silently before; policy lives in _apply_container_policy(). Private names are never synthesized, because doing so sends copy, pickle and hasattr probing into a YAML read. """ if name.startswith('_') or name in self._reserved_names(): raise AttributeError( '%s object has no attribute %s' % (self.__class__.__name__, name)) # Not self.STEPS: that recurses here when __init__ has not run. steps = self.__dict__.get('STEPS') if steps is None: try: steps = self._load_steps_from_yaml( CONF.container['container_steps_file']) except errors.HardwareManagerConfigurationError as e: # NOTE(cid): raising here would break dispatch for unrelated # methods, since this manager is consulted first for all of # them. get_clean_steps() reports the same problem loudly. LOG.error("Cannot resolve container step %s: %s", name, e) steps = [] self.STEPS = steps for step in steps: if step.get('name') == name: return self._create_step_method( container_url=step.get('image'), pull_options=step.get('pull_options'), run_options=step.get('run_options')) raise AttributeError( '%s object has no attribute %s' % (self.__class__.__name__, name))