Source code for craft_application.services.lifecycle

# This file is part of craft-application.
#
# Copyright 2023-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 warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, 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/>.
"""craft-parts lifecycle integration."""

from __future__ import annotations

import contextlib
import types
from pathlib import Path
from typing import TYPE_CHECKING, Any

import craft_platforms
import distro
from craft_cli import CraftError, emit
from craft_parts import (
    Action,
    ActionType,
    Features,
    LifecycleManager,
    PartsError,
    ProjectInfo,
    Step,
    StepInfo,
    callbacks,
)
from craft_parts.errors import CallbackRegistrationError
from craft_parts.plugins.plugins import set_plugin_group
from typing_extensions import override

from craft_application import errors, util
from craft_application.errors import EmptyBuildPlanError
from craft_application.services import base
from craft_application.util import repositories

if TYPE_CHECKING:  # pragma: no cover
    from craft_parts.plugins import Plugin

    from craft_application.application import AppMetadata
    from craft_application.services import ServiceFactory


ACTION_MESSAGES = types.MappingProxyType(
    {
        Step.PULL: types.MappingProxyType(
            {
                ActionType.RUN: "Pulling",
                ActionType.RERUN: "Repulling",
                ActionType.SKIP: "Skipping pull for",
                ActionType.UPDATE: "Updating sources for",
            }
        ),
        Step.OVERLAY: types.MappingProxyType(
            {
                ActionType.RUN: "Overlaying",
                ActionType.RERUN: "Re-overlaying",
                ActionType.SKIP: "Skipping overlay for",
                ActionType.UPDATE: "Updating overlay for",
                ActionType.REAPPLY: "Reapplying",
            }
        ),
        Step.BUILD: types.MappingProxyType(
            {
                ActionType.RUN: "Building",
                ActionType.RERUN: "Rebuilding",
                ActionType.SKIP: "Skipping build for",
                ActionType.UPDATE: "Updating build for",
            }
        ),
        Step.STAGE: types.MappingProxyType(
            {
                ActionType.RUN: "Staging",
                ActionType.RERUN: "Restaging",
                ActionType.SKIP: "Skipping stage for",
            }
        ),
        Step.PRIME: types.MappingProxyType(
            {
                ActionType.RUN: "Priming",
                ActionType.RERUN: "Repriming",
                ActionType.SKIP: "Skipping prime for",
            }
        ),
    }
)


def _get_parts_action_message(action: Action) -> str:
    """Get a user-readable message for a particular craft-parts action."""
    message = f"{ACTION_MESSAGES[action.step][action.action_type]} {action.part_name}"
    if action.reason:
        return message + f" ({action.reason})"
    return message


def _get_step(step_name: str) -> Step:
    """Get a lifecycle step by name."""
    if step_name.lower() == "overlay" and not Features().enable_overlay:
        raise RuntimeError("Invalid target step 'overlay'")
    steps = Step.__members__
    try:
        return steps[step_name.upper()]
    except KeyError:
        raise RuntimeError(f"Invalid target step {step_name!r}") from None


[docs] class LifecycleService(base.AppService): """Create and manage the parts lifecycle. :param app: An AppMetadata object containing metadata about the application. :param project: The Project object that describes this project. :param work_dir: The working directory for parts processing. :param cache_dir: The cache directory for parts processing. :param build_plan: The filtered build plan of platforms that are valid for the running host. :param lifecycle_kwargs: Additional keyword arguments are passed through to the LifecycleManager on initialisation. """
[docs] def __init__( self, app: AppMetadata, services: ServiceFactory, *, work_dir: Path | str, cache_dir: Path | str, **lifecycle_kwargs: Any, ) -> None: super().__init__(app, services) self._work_dir = work_dir self._cache_dir = cache_dir self._manager_kwargs = lifecycle_kwargs self._lcm: LifecycleManager = None # type: ignore[assignment]
[docs] @override def setup(self) -> None: """Initialize the LifecycleManager with previously-set arguments.""" # By default we can get the regular build. However, if there is no possible # build on the current machine, we get any build we can possibly do. # If the exhaustive build plan is still empty, error out. try: build = self._get_build() except EmptyBuildPlanError: build_plan = self._services.get("build_plan").create_build_plan( platforms=None, build_for=None, build_on=None, ) if not build_plan: raise EmptyBuildPlanError build = build_plan[0] plugin_group = self.get_plugin_group(build) if plugin_group is not None: emit.debug( "A plugin group has been specified for the current build. " "This overrides any previous plugin configurations.\n" f"Build: {self._get_build()}\n" f"Plugins: {plugin_group}" ) set_plugin_group(plugin_group) self._lcm = self._init_lifecycle_manager() callbacks.register_post_step(self.post_prime, step_list=[Step.PRIME]) callbacks.register_configure_overlay(repositories.enable_overlay_eol)
[docs] @staticmethod def get_plugin_group( build_info: craft_platforms.BuildInfo, # noqa: ARG004 (used by overrides) ) -> dict[str, type[Plugin]] | None: """Get the plugin group for a given build. If this returns a non-``None`` value, the LifecycleService sets the plugin group to that group when running the given build. If ``None`` is returned, the plugin groups feature is not used and an application must manually handle its plugin groups. The default implementation simply returns ``None``, as this is designed for an application to override in order to get the relevant plugin groups. :param build_info: The BuildInfo for the build. :returns: A dictionary that is an appropriate plugin group or ``None``. """ return None
[docs] def _get_build(self) -> craft_platforms.BuildInfo: """Get the build for this run.""" plan = self._services.get("build_plan").plan() if not plan: raise errors.EmptyBuildPlanError return plan[0]
[docs] def _validate_build_plan(self) -> None: """Validate that the build plan is usable for a lifecycle run.""" build = self._build_info host_base = craft_platforms.DistroBase.from_linux_distribution( distro.LinuxDistribution() ) if build.build_base.series == "devel": # If the build base is "devel", we don't try to match the specific # version as that is a moving target; Just ensure the systems are the # same. if build.build_base.distribution != host_base.distribution: raise errors.IncompatibleBaseError( host_base, build.build_base, artifact_type=self._app.artifact_type ) elif build.build_base != host_base: raise errors.IncompatibleBaseError( host_base, build.build_base, artifact_type=self._app.artifact_type )
[docs] def _get_build_for(self) -> str: """Get the ``build_for`` architecture for craft-parts. The default behaviour is as follows: 1. If the build plan's ``build_for`` is ``all``, use the host architecture. 2. If it's anything else, use that. 3. If it's undefined, use the host architecture. """ # Note: we fallback to the host's architecture here if the build plan # is empty just to be able to create the LifecycleManager; this will # correctly fail later on when run() is called (but not necessarily when # something else like clean() is called). # We also use the host arch if the build-for is 'all' try: build = self._get_build() except errors.EmptyBuildPlanError: return util.get_host_architecture() if build.build_for != "all": return str(build.build_for) return util.get_host_architecture()
[docs] def _init_lifecycle_manager(self) -> LifecycleManager: """Create and return the Lifecycle manager. An application may override this method if needed if the lifecycle manager needs to be called differently. """ emit.debug(f"Initializing lifecycle manager in {self._work_dir}") emit.trace(f"Lifecycle: {repr(self)}") project_service = self._services.get("project") build_for = self._get_build_for() if self._project.package_repositories: self._manager_kwargs["package_repositories"] = ( self._project.package_repositories ) source_ignore_patterns = [ ".craft", # in case of unmanaged lifecycle run *self._app.source_ignore_patterns, ] # Ignore spread.yaml and spread to prevent repulling sources # when test files are changed. ignore_outdated = source_ignore_patterns + ( ["spread.yaml", "spread"] if Path("spread/.extension").exists() else [] ) try: return LifecycleManager( {"parts": self._project.parts}, application_name=self._app.name, arch=build_for, cache_dir=self._cache_dir, work_dir=self._work_dir, ignore_local_sources=source_ignore_patterns, ignore_outdated=ignore_outdated, parallel_build_count=util.get_parallel_build_count(self._app.name), project_vars=project_service.project_vars, track_stage_packages=True, partitions=project_service.partitions, **self._manager_kwargs, ) except PartsError as err: raise errors.PartsLifecycleError.from_parts_error(err) from err
@property def prime_state_timestamp(self) -> float | None: """The timestamp of the most recently primed part's prime state file.""" return self._lcm.get_prime_state_timestamp() @property def prime_dir(self) -> Path: """The path to the prime directory.""" return self._lcm.project_info.dirs.prime_dir @property def project_info(self) -> ProjectInfo: """The lifecycle's ProjectInfo.""" return self._lcm.project_info
[docs] def get_pull_assets(self, *, part_name: str) -> dict[str, Any] | None: """Obtain 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. """ return self._lcm.get_pull_assets(part_name=part_name)
[docs] def get_primed_stage_packages(self, *, part_name: str) -> list[str] | None: """Obtain 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. """ return self._lcm.get_primed_stage_packages(part_name=part_name)
[docs] def run( self, step_name: str | None, part_names: list[str] | None = None, ) -> None: """Run the lifecycle manager for the parts. :param step_name: The name of the target step (defaults to running the entire lifecycle) :param part_names: Which parts to build (defaults to all parts) """ target_step = _get_step(step_name) if step_name else None self._validate_build_plan() try: if self._project.package_repositories: emit.trace("Installing package repositories") repositories.install_package_repositories( self._project.package_repositories, self._lcm, local_keys_path=self._get_local_keys_path(), ) with contextlib.suppress(CallbackRegistrationError): callbacks.register_configure_overlay( repositories.install_overlay_repositories ) if target_step: emit.trace(f"Planning {step_name} for {part_names or 'all parts'}") actions = self._lcm.plan(target_step, part_names=part_names) else: actions = [] emit.progress("Initializing lifecycle") self._exec(actions) except PartsError as err: raise errors.PartsLifecycleError.from_parts_error(err) from err except CraftError: # CraftError passthrough to be handled by the application. raise except RuntimeError as err: raise RuntimeError(f"Parts processing internal error: {err}") from err except OSError as err: raise errors.PartsLifecycleError.from_os_error(err) from err except Exception as err: raise errors.PartsLifecycleError(f"Unknown error: {str(err)}") from err
def _exec(self, actions: list[Action]) -> None: """Execute actions of the lifecycle. Applications must override this method to handle errors before craft-application. """ with self._lcm.action_executor() as aex: for action in actions: message = _get_parts_action_message(action) emit.progress(message) with emit.open_stream() as stream: aex.execute(action, stdout=stream, stderr=stream)
[docs] def post_prime(self, step_info: StepInfo) -> bool: """Perform any necessary post-lifecycle modifications to the prime directory. This method should be idempotent and meet the requirements for a craft-parts callback. It is added as a post-prime callback during the setup phase. NOTE: This is not guaranteed to run in any particular order if other callbacks are added to the prime step. """ if step_info.step != Step.PRIME: raise RuntimeError(f"Post-prime hook called after step: {step_info.step}") return False
[docs] def clean(self, part_names: list[str] | None = None) -> None: """Remove lifecycle artifacts. :param part_names: The names of the parts to clean. If unspecified, all parts will be cleaned. """ if part_names: message = "Cleaning parts: " + ", ".join(part_names) else: message = "Cleaning all parts" emit.progress(message) self._lcm.clean(part_names=part_names)
[docs] @staticmethod def previous_step_name(step_name: str) -> str | None: """Get the name of the step immediately previous to `step_name`. Returns None if `step_name` is the first one (pull). """ step = _get_step(step_name) previous_steps = step.previous_steps() return previous_steps[-1].name.lower() if previous_steps else None
def __repr__(self) -> str: work_dir = self._work_dir cache_dir = self._cache_dir return ( f"{self.__class__.__name__}({self._app!r}, " f"{work_dir=}, {cache_dir=}, **{self._manager_kwargs!r})" ) def _get_local_keys_path(self) -> Path | None: """Return a directory with public keys for package-repositories. This default implementation does not support local keys; it should be overridden by subclasses that do. """ return None