Source code for craft_parts.overlays.overlay_manager

# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2021-2025 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/>.

"""Overlay mount operations and package installation helpers."""

import logging
import os
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any, Literal, TypeVar, cast

from craft_parts import packages
from craft_parts.infos import ProjectInfo
from craft_parts.parts import Part

from . import chroot
from .overlay_fs import OverlayFS

logger = logging.getLogger(__name__)

_T = TypeVar("_T")


def _defer_evaluation(method: Callable[..., _T]) -> Callable[..., _T]:
    """Wrap methods to defer evaluation.

    Defer evaluation of proxied class methods to happen at execution time.
    Used to pass repositories to chroot environments in a way that the
    repository type will only be evaluated inside the chroot environment.
    """
    method_name = getattr(method, "__name__", None)
    instance = getattr(method, "__self__", None)

    if instance is None or method_name is None:
        raise TypeError("Only bound methods can be deferred")

    def _thunk(*args: Any, **kwargs: Any) -> _T:
        method = cast(Callable[..., _T], getattr(instance, method_name))
        return method(*args, **kwargs)

    return _thunk


[docs] class OverlayManager: """Execution time overlay mounting and package installation. :param project_info: The project information. :param part_list: A list of all parts in the project. :param base_layer_dir: The directory containing the overlay base, or None if the project doesn't use overlay parameters. :param cache_level: The number of part layers to be mounted before the package cache. """ def __init__( self, *, project_info: ProjectInfo, part_list: list[Part], base_layer_dir: Path | None, cache_level: int, ) -> None: self._project_info = project_info self._part_list = part_list self._layer_dirs = [p.part_layer_dir for p in part_list] self._overlay_fs: OverlayFS | None = None self._base_layer_dir = base_layer_dir self._cache_level = cache_level @property def base_layer_dir(self) -> Path | None: """Return the path to the base layer, if any.""" return self._base_layer_dir @property def cache_level(self) -> int: """The cache layer index above the base layer.""" return self._cache_level
[docs] def mount_layer(self, part: Part, *, pkg_cache: bool = False) -> None: """Mount the overlay step layer stack up to the given part. :param part: The part corresponding to the topmost layer to mount. :param pkg cache: Whether the package cache layer is enabled. """ if not self._base_layer_dir: raise RuntimeError("request to mount overlay without a base layer") # The top layer index. index = self._part_list.index(part) # Lower layers without the cache layer. lowers = [self._base_layer_dir] lowers.extend(self._layer_dirs[0:index]) # Insert the cache layer at the appropriate level. If the layer is 0, # it will be placed immediately above the base layer. if pkg_cache and index >= self._cache_level: level = self._cache_level + 1 lowers = [ *lowers[0:level], self._project_info.overlay_packages_dir, *lowers[level : index + 1], ] # The top layer. upper = self._layer_dirs[index] # lower dirs are stacked from right to left lowers.reverse() self._overlay_fs = OverlayFS( lower_dirs=lowers, upper_dir=upper, work_dir=self._project_info.overlay_work_dir, ) self._overlay_fs.mount(self._project_info.overlay_mount_dir)
[docs] def mount_pkg_cache(self) -> None: """Mount the overlay step package cache layer.""" if not self._base_layer_dir: raise RuntimeError( "request to mount the overlay package cache without a base layer" ) lowers = [self._base_layer_dir] lowers.extend(self._layer_dirs[0 : self._cache_level]) # Lower dirs are stacked from right to left. lowers.reverse() self._overlay_fs = OverlayFS( lower_dirs=lowers, upper_dir=self._project_info.overlay_packages_dir, work_dir=self._project_info.overlay_work_dir, ) logger.debug("Mount cache layer %d", self._cache_level) self._overlay_fs.mount(self._project_info.overlay_mount_dir)
[docs] def unmount(self) -> None: """Unmount the overlay step layer stack.""" if not self._overlay_fs: raise RuntimeError("filesystem is not mounted") self._overlay_fs.unmount() self._overlay_fs = None
[docs] def mkdirs(self) -> None: """Create overlay directories and mountpoints.""" for overlay_dir in [ self._project_info.overlay_mount_dir, self._project_info.overlay_packages_dir, self._project_info.overlay_work_dir, ]: overlay_dir.mkdir(parents=True, exist_ok=True)
[docs] def refresh_packages_list(self) -> None: """Update the list of available packages in the overlay system.""" if not self._overlay_fs: raise RuntimeError("overlay filesystem not mounted") logger.debug("Refreshing packages list in overlay") mount_dir = self._project_info.overlay_mount_dir # Ensure we always run refresh_packages_list by resetting the cache packages.Repository.refresh_packages_list.cache_clear() # type: ignore[attr-defined] chroot.chroot( mount_dir, _defer_evaluation(packages.Repository.refresh_packages_list) )
[docs] def download_packages(self, package_names: list[str]) -> None: """Download packages and populate the overlay package cache. :param package_names: The list of packages to download. """ if not self._overlay_fs: raise RuntimeError("overlay filesystem not mounted") mount_dir = self._project_info.overlay_mount_dir chroot.chroot( mount_dir, _defer_evaluation(packages.Repository.download_packages), package_names, )
[docs] def install_packages(self, package_names: list[str]) -> None: """Install packages on the overlay area using chroot. :param package_names: The list of packages to install. """ if not self._overlay_fs: raise RuntimeError("overlay filesystem not mounted") mount_dir = self._project_info.overlay_mount_dir chroot.chroot( mount_dir, _defer_evaluation(packages.Repository.install_packages), package_names, refresh_package_cache=False, )
[docs] class LayerMount: """Mount the overlay layer stack for step processing. :param overlay_manager: The overlay manager. :param top_part: The topmost part to mount. :param pkg_cache: Whether to mount the overlay package cache. """ def __init__( self, overlay_manager: OverlayManager, top_part: Part, pkg_cache: bool = True, # noqa: FBT001, FBT002 ) -> None: self._overlay_manager = overlay_manager self._overlay_manager.mkdirs() self._top_part = top_part self._pkg_cache = pkg_cache self._pid = os.getpid() def __enter__(self) -> "LayerMount": logger.debug("---- Enter layer mount context ----") self._overlay_manager.mount_layer( self._top_part, pkg_cache=self._pkg_cache, ) return self def __exit__(self, *exc: object) -> Literal[False]: # prevent pychroot process leak if os.getpid() != self._pid: sys.exit() self._overlay_manager.unmount() logger.debug("---- Exit layer mount context ----") return False
[docs] def install_packages(self, package_names: list[str]) -> None: """Install the specified packages on the local system. :param package_names: The list of packages to install. """ self._overlay_manager.install_packages(package_names)
[docs] class PackageCacheMount: """Mount and umount the overlay package cache. :param overlay_manager: The overlay manager. """ def __init__(self, overlay_manager: OverlayManager) -> None: self._overlay_manager = overlay_manager self._overlay_manager.mkdirs() self._pid = os.getpid() def __enter__(self) -> "PackageCacheMount": logger.debug("---- Enter package cache mount context ----") self._overlay_manager.mount_pkg_cache() return self def __exit__(self, *exc: object) -> Literal[False]: # prevent pychroot process leak if os.getpid() != self._pid: sys.exit() self._overlay_manager.unmount() logger.debug("---- Exit package cache mount context ----") return False
[docs] def refresh_packages_list(self) -> None: """Update the list of available packages in the overlay system.""" self._overlay_manager.refresh_packages_list()
[docs] def download_packages(self, package_names: list[str]) -> None: """Download the specified packages to the local system. :param package_names: The list of packages to download. """ self._overlay_manager.download_packages(package_names)