Skip to content

Commit ce138f4

Browse files
cmatsuokaupils
andcommitted
feat: reconcile overlays and partition features
Allow usage of the overlays and partition features simultaneously, using `overlay-organize` to move overlay files to other partitions. Co-authored-by: Paul Mars <paul.mars@canonical.com> Signed-off-by: Claudio Matsuoka <claudio.matsuoka@canonical.com>
1 parent 841a79a commit ce138f4

15 files changed

+246
-141
lines changed

craft_parts/dirs.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
22
#
3-
# Copyright 2021,2024 Canonical Ltd.
3+
# Copyright 2021-2025 Canonical Ltd.
44
#
55
# This program is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public

craft_parts/executor/organize.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
22
#
3-
# Copyright 2015-2021,2024 Canonical Ltd.
3+
# Copyright 2015,2021-2025 Canonical Ltd.
44
#
55
# This program is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public

craft_parts/executor/part_handler.py

+121-60
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
22
#
3-
# Copyright 2017-2024 Canonical Ltd.
3+
# Copyright 2017-2025 Canonical Ltd.
44
#
55
# This program is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -35,6 +35,7 @@
3535
from craft_parts.packages.base import read_origin_stage_package
3636
from craft_parts.packages.platform import is_deb_based
3737
from craft_parts.parts import Part, get_parts_with_overlay, has_overlay_visibility
38+
from craft_parts.permissions import Permissions
3839
from craft_parts.plugins import Plugin
3940
from craft_parts.state_manager import MigrationState, StepState, states
4041
from craft_parts.steps import Step
@@ -71,6 +72,34 @@ def __call__(
7172
) -> None: ...
7273

7374

75+
class _Squasher:
76+
def __init__(self) -> None:
77+
self.migrated_files: set[str] = set()
78+
self.migrated_dirs: set[str] = set()
79+
80+
def migrate(
81+
self,
82+
refdir: Path,
83+
srcdir: Path,
84+
destdir: Path,
85+
permissions: list[Permissions] | None = None,
86+
) -> None:
87+
visible_files, visible_dirs = overlays.visible_in_layer(refdir, destdir)
88+
layer_files, layer_dirs = migration.migrate_files(
89+
files=visible_files,
90+
dirs=visible_dirs,
91+
srcdir=srcdir,
92+
destdir=destdir,
93+
oci_translation=True,
94+
permissions=permissions,
95+
)
96+
self.migrated_files |= layer_files
97+
self.migrated_dirs |= layer_dirs
98+
99+
def get_state(self) -> MigrationState:
100+
return MigrationState(files=self.migrated_files, directories=self.migrated_dirs)
101+
102+
74103
class PartHandler:
75104
"""Handle lifecycle steps for a part.
76105
@@ -246,6 +275,14 @@ def _run_overlay(
246275
stderr=stderr,
247276
)
248277

278+
# apply overlay-organize
279+
organize_files(
280+
part_name=self._part.name,
281+
file_map=self._part.spec.overlay_organize_files,
282+
install_dir_map=self._part.part_layer_dirs,
283+
overwrite=False,
284+
)
285+
249286
# apply overlay filter
250287
overlay_fileset = filesets.Fileset(
251288
self._part.spec.overlay_files, name="overlay"
@@ -330,7 +367,12 @@ def _run_build(
330367
# time around. We can be confident that this won't overwrite anything else,
331368
# because to do so would require changing the `organize` keyword, which will
332369
# make the build step dirty and require a clean instead of an update.
333-
self._organize(overwrite=update)
370+
organize_files(
371+
part_name=self._part.name,
372+
file_map=self._part.spec.organize_files,
373+
install_dir_map=self._part.part_install_dirs,
374+
overwrite=update,
375+
)
334376

335377
assets = {
336378
"build-packages": self.build_packages,
@@ -664,6 +706,13 @@ def _reapply_overlay(
664706
) -> None:
665707
"""Clean and repopulate the current part's layer, keeping its state."""
666708
shutil.rmtree(self._part.part_layer_dir)
709+
710+
# delete partition layer dirs, if any
711+
for partition in self._part_info.partitions or (None,):
712+
layer_dir = self._part.part_layer_dirs[partition]
713+
if layer_dir.exists():
714+
shutil.rmtree(self._part.part_layer_dirs[partition])
715+
667716
self._run_overlay(step_info, stdout=stdout, stderr=stderr)
668717

669718
def _migrate_overlay_files_to_stage(self) -> None:
@@ -690,27 +739,25 @@ def _migrate_overlay_files_to_stage(self) -> None:
690739
return
691740

692741
logger.debug("staging overlay files")
693-
migrated_files: set[str] = set()
694-
migrated_dirs: set[str] = set()
695-
696-
# process layers from top to bottom (reversed)
697-
for part in reversed(parts_with_overlay):
698-
logger.debug("migrate part %r layer to stage", part.name)
699-
visible_files, visible_dirs = overlays.visible_in_layer(
700-
part.part_layer_dir, part.stage_dir
701-
)
702-
layer_files, layer_dirs = migration.migrate_files(
703-
files=visible_files,
704-
dirs=visible_dirs,
705-
srcdir=part.part_layer_dir,
706-
destdir=part.stage_dir,
707-
oci_translation=True,
708-
)
709-
migrated_files |= layer_files
710-
migrated_dirs |= layer_dirs
711742

712-
state = MigrationState(files=migrated_files, directories=migrated_dirs)
713-
state.write(stage_overlay_state_path)
743+
# process parts in each partition
744+
for partition in self._part_info.partitions or (None,):
745+
squasher = _Squasher()
746+
for part in reversed(parts_with_overlay):
747+
logger.debug(
748+
"migrate %s partition part %r layer to stage",
749+
partition,
750+
part.name,
751+
)
752+
squasher.migrate(
753+
refdir=part.part_layer_dirs[partition],
754+
srcdir=part.part_layer_dirs[partition],
755+
destdir=part.stage_dirs[partition],
756+
)
757+
758+
if partition in ("default", None):
759+
state = squasher.get_state()
760+
state.write(stage_overlay_state_path)
714761

715762
def _migrate_overlay_files_to_prime(self) -> None:
716763
"""Prime overlay files and create state.
@@ -736,43 +783,51 @@ def _migrate_overlay_files_to_prime(self) -> None:
736783
return
737784

738785
logger.debug("priming overlay files")
739-
migrated_files: set[str] = set()
740-
migrated_dirs: set[str] = set()
741-
742-
# process layers from top to bottom (reversed)
743-
for part in reversed(parts_with_overlay):
744-
logger.debug("migrate part %r layer to prime", part.name)
745-
visible_files, visible_dirs = overlays.visible_in_layer(
746-
part.part_layer_dir, part.prime_dir
747-
)
748-
layer_files, layer_dirs = migration.migrate_files(
749-
files=visible_files,
750-
dirs=visible_dirs,
751-
srcdir=part.stage_dir,
752-
destdir=part.prime_dir,
753-
oci_translation=True,
754-
permissions=part.spec.permissions,
755-
)
756-
migrated_files |= layer_files
757-
migrated_dirs |= layer_dirs
758786

759-
# Clean up dangling whiteout files with no backing files to white out
787+
# Process parts in each partition.
788+
for partition in self._part_info.partitions or (None,):
789+
squasher = _Squasher()
790+
for part in reversed(parts_with_overlay):
791+
logger.debug(
792+
"migrate %s partition part %r layer to prime",
793+
partition,
794+
part.name,
795+
)
796+
squasher.migrate(
797+
refdir=part.part_layer_dirs[partition],
798+
srcdir=part.stage_dirs[partition],
799+
destdir=part.prime_dirs[partition],
800+
permissions=part.spec.permissions,
801+
)
802+
803+
if partition in ("default", None):
804+
# Non-default partitions have no dangling whiteout files
805+
# because content was copied from an assembled overlay stack.
806+
self._clean_dangling_whiteouts(
807+
self._part_info.prime_dir,
808+
squasher.migrated_files,
809+
squasher.migrated_dirs,
810+
)
811+
812+
state = squasher.get_state()
813+
state.write(prime_overlay_state_path)
814+
815+
def _clean_dangling_whiteouts(
816+
self, prime_dir: Path, migrated_files: set[str], migrated_dirs: set[str]
817+
) -> None:
818+
"""Clean up dangling whiteout files with no backing files to white out."""
760819
dangling_whiteouts = migration.filter_dangling_whiteouts(
761820
migrated_files, migrated_dirs, base_dir=self._overlay_manager.base_layer_dir
762821
)
763822
for whiteout in dangling_whiteouts:
764-
primed_whiteout = self._part_info.prime_dir / whiteout
823+
primed_whiteout = prime_dir / whiteout
765824
try:
766825
primed_whiteout.unlink()
767826
logger.debug("unlinked '%s'", str(primed_whiteout))
768827
except OSError as err:
769828
# XXX: fuse-overlayfs creates a .wh..opq file in part layer dir?
770829
logger.debug("error unlinking '%s': %s", str(primed_whiteout), err)
771830

772-
# Create overlay migration state file
773-
state = MigrationState(files=migrated_files, directories=migrated_dirs)
774-
state.write(prime_overlay_state_path)
775-
776831
def clean_step(self, step: Step) -> None:
777832
"""Remove the work files and the state of the given step.
778833
@@ -824,18 +879,21 @@ def _clean_stage(self) -> None:
824879
"""Remove the current part's stage step files and state."""
825880
for stage_dir in self._part.stage_dirs.values():
826881
self._clean_shared(Step.STAGE, shared_dir=stage_dir)
882+
self._clean_shared_overlay(Step.STAGE, shared_dir=self._part.stage_dir)
827883

828884
def _clean_prime(self) -> None:
829885
"""Remove the current part's prime step files and state."""
830886
for prime_dir in self._part.prime_dirs.values():
831887
self._clean_shared(Step.PRIME, shared_dir=prime_dir)
888+
self._clean_shared_overlay(Step.PRIME, shared_dir=self._part.prime_dir)
832889

833890
def _clean_shared(self, step: Step, *, shared_dir: Path) -> None:
834891
"""Remove the current part's shared files from the given directory.
835892
836893
:param step: The step corresponding to the shared directory.
837894
:param shared_dir: The shared directory to clean.
838895
"""
896+
logger.info(f"clean shared dir: {shared_dir} for step: {step}")
839897
part_states = _load_part_states(step, self._part_list)
840898
overlay_migration_state = states.load_overlay_migration_state(
841899
self._part.overlay_dir, step
@@ -848,11 +906,20 @@ def _clean_shared(self, step: Step, *, shared_dir: Path) -> None:
848906
overlay_migration_state=overlay_migration_state,
849907
)
850908

909+
def _clean_shared_overlay(self, step: Step, *, shared_dir: Path) -> None:
910+
"""Remove shared files originating from overlay."""
911+
part_states = _load_part_states(step, self._part_list)
912+
parts_with_overlay_in_step = _parts_with_overlay_in_step(
913+
step, part_list=self._part_list
914+
)
915+
overlay_migration_state = states.load_overlay_migration_state(
916+
self._part.overlay_dir, step
917+
)
918+
919+
logger.info(f"parts_with_overlay_in_step: {parts_with_overlay_in_step}")
920+
851921
# remove overlay data if this is the last part with overlay
852-
if (
853-
self._part.has_overlay
854-
and len(_parts_with_overlay_in_step(step, part_list=self._part_list)) == 1
855-
):
922+
if self._part.has_overlay and len(parts_with_overlay_in_step) == 1:
856923
migration.clean_shared_overlay(
857924
shared_dir=shared_dir,
858925
part_states=part_states,
@@ -861,6 +928,9 @@ def _clean_shared(self, step: Step, *, shared_dir: Path) -> None:
861928
overlay_migration_state_path = states.get_overlay_migration_state_path(
862929
self._part.overlay_dir, step
863930
)
931+
logger.info(
932+
f"remove overlay migration state file for part {self._part.name}, step {step}"
933+
)
864934
overlay_migration_state_path.unlink()
865935

866936
def _make_dirs(self) -> None:
@@ -877,15 +947,6 @@ def _make_dirs(self) -> None:
877947
for dir_name in dirs:
878948
os.makedirs(dir_name, exist_ok=True)
879949

880-
def _organize(self, *, overwrite: bool = False) -> None:
881-
mapping = self._part.spec.organize_files
882-
organize_files(
883-
part_name=self._part.name,
884-
file_map=mapping,
885-
install_dir_map=self._part.part_install_dirs,
886-
overwrite=overwrite,
887-
)
888-
889950
def _fetch_stage_packages(self, *, step_info: StepInfo) -> list[str] | None:
890951
"""Download stage packages to the part's package directory.
891952

craft_parts/features.py

+1-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
22
#
3-
# Copyright 2023-2024 Canonical Ltd.
3+
# Copyright 2023-2025 Canonical Ltd.
44
#
55
# This program is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -20,7 +20,6 @@
2020
import dataclasses
2121
import logging
2222

23-
from craft_parts.errors import FeatureError
2423
from craft_parts.utils import Singleton
2524

2625
logger = logging.getLogger()
@@ -37,17 +36,6 @@ class Features(metaclass=Singleton):
3736
enable_overlay: bool = False
3837
enable_partitions: bool = False
3938

40-
def __post_init__(self) -> None:
41-
"""Validate feature set.
42-
43-
:raises FeatureError: If mutually exclusive features are enabled.
44-
"""
45-
if self.enable_overlay and self.enable_partitions:
46-
raise FeatureError(
47-
message="Cannot enable overlay and partition features.",
48-
details="Overlay and partition features are mutually exclusive.",
49-
)
50-
5139
@classmethod
5240
def reset(cls) -> None:
5341
"""Delete stored class instance."""

craft_parts/main.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
22
#
3-
# Copyright 2021 Canonical Ltd.
3+
# Copyright 2021-2025 Canonical Ltd.
44
#
55
# This program is free software; you can redistribute it and/or
66
# modify it under the terms of the GNU Lesser General Public
@@ -49,7 +49,11 @@ def main() -> None:
4949

5050
logging.basicConfig(level=log_level)
5151

52-
craft_parts.Features(enable_overlay=True)
52+
features = {"enable_overlay": True}
53+
if options.partitions:
54+
features["enable_partitions"] = True
55+
56+
craft_parts.Features(**features)
5357

5458
try:
5559
_process_parts(options)
@@ -92,6 +96,8 @@ def _process_parts(options: argparse.Namespace) -> None:
9296
base_layer_hash = b""
9397
overlay_base = None
9498

99+
partitions = options.partitions.split(",") if options.partitions else None
100+
95101
lcm = craft_parts.LifecycleManager(
96102
part_data,
97103
application_name=options.application_name,
@@ -101,6 +107,7 @@ def _process_parts(options: argparse.Namespace) -> None:
101107
base=options.base,
102108
base_layer_dir=overlay_base,
103109
base_layer_hash=base_layer_hash,
110+
partitions=partitions,
104111
)
105112

106113
command = options.command if options.command else "prime"
@@ -273,6 +280,12 @@ def _parse_arguments() -> argparse.Namespace:
273280
default="",
274281
help="Set an alternate cache directory location.",
275282
)
283+
parser.add_argument(
284+
"--partitions",
285+
metavar="name",
286+
default="",
287+
help="List of partitions to create.",
288+
)
276289
parser.add_argument(
277290
"-v",
278291
"--verbose",

0 commit comments

Comments
 (0)