Source code for craft_parts.lifecycle_manager

# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2021-2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""The parts lifecycle manager."""

import os
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any, cast

from pydantic import ValidationError

from craft_parts import errors, executor, packages, plugins, sequencer
from craft_parts.actions import Action
from craft_parts.dirs import ProjectDirs
from craft_parts.features import Features
from craft_parts.infos import ProjectInfo
from craft_parts.overlays import LayerHash
from craft_parts.parts import Part, part_by_name
from craft_parts.state_manager import states
from craft_parts.steps import Step
from craft_parts.utils.partition_utils import validate_partition_names


[docs] class LifecycleManager: """Coordinate the planning and execution of the parts lifecycle. The lifecycle manager determines the list of actions that needs be executed in order to obtain a tree of installed files from the specification on how to process its parts, and provides a mechanism to execute each of these actions. :param all_parts: A dictionary containing the parts specification according to the :ref:`parts schema<part_properties>`. The format is compatible with the output generated by PyYAML's ``yaml.load``. :param application_name: A unique non-empty identifier for the application using Craft Parts. Valid application names contain upper and lower case letters, underscores or numbers, and must start with a letter. :param project_name: name of the project being built. :param cache_dir: The path to store cached packages and files. If not specified, a directory under the application name entry in the XDG base directory will be used. :param work_dir: The toplevel directory for work directories. The current directory will be used if none is specified. :param arch: The architecture to build for. Defaults to the host system architecture. :param base: [deprecated] The system base the project being processed will run on. Defaults to the system where Craft Parts is being executed. :param parallel_build_count: The maximum number of concurrent jobs to be used to build each part of this project. :param application_package_name: The name of the application package, if required by the package manager used by the platform. Defaults to the application name. :param ignore_local_sources: A list of local source patterns to ignore. :param extra_build_packages: A list of additional build packages to install. :param extra_build_snaps: A list of additional build snaps to install. :param track_stage_packages: Add primed stage packages to the prime state. :param strict_mode: Only allow plugins capable of building in strict mode. :param base_layer_dir: The path to the overlay base layer, if using overlays. :param base_layer_hash: The validation hash of the overlay base image, if using overlays. The validation hash should be constant for a given image, and should change if a different base image is used. :param project_vars_part_name: Project variables can only be set in the part matching this name. :param project_vars: A dictionary containing project variables. :param partitions: A list of partitions to use when the partitions feature is enabled. The first partition must be "default". Partitions may have an optional namespace prefix separated by a forward slash. Partition names must contain one or more lowercase alphanumeric characters or hyphens ("-"), and may not begin or end with a hyphen. Namespace names must consist of only lowercase alphanumeric characters. :param custom_args: Any additional arguments that will be passed directly to callbacks. """ def __init__( # noqa: PLR0913 self, all_parts: dict[str, Any], *, application_name: str, cache_dir: Path | str, work_dir: Path | str = ".", arch: str = "", base: str = "", project_name: str | None = None, parallel_build_count: int = 1, application_package_name: str | None = None, ignore_local_sources: list[str] | None = None, extra_build_packages: list[str] | None = None, extra_build_snaps: list[str] | None = None, track_stage_packages: bool = False, strict_mode: bool = False, base_layer_dir: Path | None = None, base_layer_hash: bytes | None = None, project_vars_part_name: str | None = None, project_vars: dict[str, str] | None = None, partitions: list[str] | None = None, **custom_args: Any, # custom passthrough args ) -> None: # pylint: disable=too-many-locals if not re.match("^[A-Za-z][0-9A-Za-z_]*$", application_name): raise errors.InvalidApplicationName(application_name) if not isinstance(all_parts, dict): raise TypeError("parts definition must be a dictionary") if not application_package_name: application_package_name = application_name if "parts" not in all_parts: raise ValueError("parts definition is missing") validate_partition_names(partitions) packages.Repository.configure(application_package_name) project_dirs = ProjectDirs(work_dir=work_dir, partitions=partitions) project_info = ProjectInfo( application_name=application_name, cache_dir=Path(cache_dir), arch=arch, base=base, parallel_build_count=parallel_build_count, strict_mode=strict_mode, project_name=project_name, project_dirs=project_dirs, project_vars_part_name=project_vars_part_name, project_vars=project_vars, partitions=partitions, base_layer_dir=base_layer_dir, base_layer_hash=base_layer_hash, **custom_args, ) parts_data = all_parts.get("parts", {}) executor.expand_environment(parts_data, info=project_info) part_list = [] for name, spec in parts_data.items(): part = _build_part(name, spec, project_dirs, strict_mode, partitions) _validate_part_dependencies(part, parts_data) part_list.append(part) self._has_overlay = any(p.has_overlay for p in part_list) # a base layer is mandatory if overlays are in use if self._has_overlay: _ensure_overlay_supported() if not base_layer_dir: raise ValueError("base_layer_dir must be specified if using overlays") if not base_layer_hash: raise ValueError("base_layer_hash must be specified if using overlays") else: base_layer_dir = None if base_layer_hash: layer_hash: LayerHash | None = LayerHash(base_layer_hash) else: layer_hash = None self._part_list = part_list self._application_name = application_name self._target_arch = project_info.target_arch self._sequencer = sequencer.Sequencer( part_list=self._part_list, project_info=project_info, ignore_outdated=ignore_local_sources, base_layer_hash=layer_hash, ) self._executor = executor.Executor( part_list=self._part_list, project_info=project_info, ignore_patterns=ignore_local_sources, extra_build_packages=extra_build_packages, extra_build_snaps=extra_build_snaps, track_stage_packages=track_stage_packages, base_layer_dir=base_layer_dir, base_layer_hash=layer_hash, ) self._project_info = project_info # pylint: enable=too-many-locals @property def project_info(self) -> ProjectInfo: """Obtain information about this project.""" return self._project_info
[docs] def clean( self, step: Step = Step.PULL, *, part_names: list[str] | None = None ) -> None: """Clean the specified step and parts. Cleaning a step removes its state and all artifacts generated in that step and subsequent steps for the specified parts. :param step: The step to clean. If not specified, all steps will be cleaned. :param part_names: The list of part names to clean. If not specified, all parts will be cleaned and work directories will be removed. """ self._executor.clean(initial_step=step, part_names=part_names)
[docs] def refresh_packages_list(self) -> None: """Update the available packages list. The list of available packages should be updated before planning the sequence of actions to take. To ensure consistency between the scenarios, it shouldn't be updated between planning and execution. """ packages.Repository.refresh_packages_list()
[docs] def plan( self, target_step: Step, part_names: Sequence[str] | None = None, *, rerun: bool = False, ) -> list[Action]: """Obtain the list of actions to be executed given the target step and parts. :param target_step: The final step we want to reach. :param part_names: The list of parts to process. If not specified, all parts will be processed. :return: The list of :class:`Action` objects that should be executed in order to reach the target step for the specified parts. """ return self._sequencer.plan(target_step, part_names, rerun=rerun)
[docs] def reload_state(self) -> None: """Reload the ephemeral state from disk.""" self._sequencer.reload_state()
[docs] def action_executor(self) -> executor.ExecutionContext: """Return a context manager for action execution.""" return executor.ExecutionContext(executor=self._executor)
[docs] def get_pull_assets(self, *, part_name: str) -> dict[str, Any] | None: """Return the part's pull state assets. :param part_name: The name of the part to get assets from. :return: The dictionary of the part's pull assets, or None if no state found. """ part = part_by_name(part_name, self._part_list) state = cast(states.PullState, states.load_step_state(part, Step.PULL)) return state.assets if state else None
[docs] def get_primed_stage_packages(self, *, part_name: str) -> list[str] | None: """Return the list of primed stage packages. :param part_name: The name of the part to get primed stage packages from. :return: The sorted list of primed stage packages, or None if no state found. """ part = part_by_name(part_name, self._part_list) state = cast(states.PrimeState, states.load_step_state(part, Step.PRIME)) if not state: return None return sorted(state.primed_stage_packages)
def _ensure_overlay_supported() -> None: """Overlay is only supported in Linux and requires superuser privileges.""" if not Features().enable_overlay: raise errors.FeatureError("Overlays are not supported.") if sys.platform != "linux": raise errors.OverlayPlatformError if os.geteuid() != 0: raise errors.OverlayPermissionError def _build_part( name: str, spec: dict[str, Any], project_dirs: ProjectDirs, strict_plugins: bool, # noqa: FBT001 partitions: list[str] | None, ) -> Part: """Create and populate a :class:`Part` object based on part specification data. :param spec: A dictionary containing the part specification. :param project_dirs: The project's work directories. :return: A :class:`Part` object corresponding to the given part specification. """ if not isinstance(spec, dict): raise errors.PartSpecificationError( part_name=name, message="part definition is malformed" ) plugin_name = spec.get("plugin", "") # If the plugin was not specified, use the part name as the plugin name. part_name_as_plugin_name = not plugin_name if part_name_as_plugin_name: plugin_name = name try: plugin_class = plugins.get_plugin_class(plugin_name) except ValueError as err: if part_name_as_plugin_name: # If plugin was not specified, avoid raising an exception telling # that part name is an invalid plugin. raise errors.UndefinedPlugin(part_name=name) from err raise errors.InvalidPlugin(plugin_name, part_name=name) from err if strict_plugins and not plugin_class.supports_strict_mode: raise errors.PluginNotStrict(plugin_name, part_name=name) # validate and unmarshal plugin properties try: properties = plugin_class.properties_class.unmarshal(spec) except ValidationError as err: raise errors.PartSpecificationError.from_validation_error( part_name=name, error_list=err.errors() ) from err except ValueError as err: raise errors.PartSpecificationError(part_name=name, message=str(err)) from err part_spec = plugins.extract_part_properties(spec, plugin_name=plugin_name) # initialize part and unmarshal part specs return Part( name, part_spec, project_dirs=project_dirs, plugin_properties=properties, partitions=partitions, ) def _validate_part_dependencies(part: Part, parts_data: dict[str, Any]) -> None: for name in part.dependencies: if name not in parts_data: raise errors.InvalidPartName(name)