From 306e1163512edf8ef5cd0121c79963f3ae39c637 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:31:01 +0000 Subject: [PATCH 01/26] feat: add classic preseed plugin --- imagecraft/plugins/_setup.py | 4 +- imagecraft/plugins/classic_preseed_plugin.py | 67 +++++++++++++++++++ .../plugins/classic-preseed/imagecraft.yaml | 35 ++++++++++ .../spread/plugins/classic-preseed/task.yaml | 0 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 imagecraft/plugins/classic_preseed_plugin.py create mode 100644 tests/spread/plugins/classic-preseed/imagecraft.yaml create mode 100644 tests/spread/plugins/classic-preseed/task.yaml diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py index 62629029..a72e5334 100644 --- a/imagecraft/plugins/_setup.py +++ b/imagecraft/plugins/_setup.py @@ -17,13 +17,15 @@ from craft_parts.plugins import register from craft_parts.plugins.plugins import PluginType +from .classic_preseed_plugin import ClassicPreseedPlugin + def get_app_plugins() -> dict[str, PluginType]: """Get Imagecraft-specific craft-parts plugins. :returns: A dict mapping plugin names to plugins """ - return {} + return {"classic-preseed": ClassicPreseedPlugin} def setup_plugins() -> None: diff --git a/imagecraft/plugins/classic_preseed_plugin.py b/imagecraft/plugins/classic_preseed_plugin.py new file mode 100644 index 00000000..b25f479c --- /dev/null +++ b/imagecraft/plugins/classic_preseed_plugin.py @@ -0,0 +1,67 @@ +import shlex +from typing import Literal, cast, override +from craft_parts.plugins import Plugin, PluginProperties + + +class ClassicPreseedPluginProperties(PluginProperties, frozen=True): + """Properties for the 'classic-preseed' plugin.""" + + plugin: Literal["classic-preseed"] = "classic-preseed" + + classic_preseed_snaps: list[str] + classic_preseed_channel: str | None = None + classic_preseed_model_assert: str = "" + classic_preseed_validation: Literal["ignore", "enforce"] = "ignore" + classic_preseed_assertions: list[str] = [] + classic_preseed_revisions: str | None = None + + +class ClassicPreseedPlugin(Plugin): + + properties_class = ClassicPreseedPluginProperties + + @override + def get_build_snaps(self) -> set[str]: + return set() + + @override + def get_build_packages(self) -> set[str]: + return set() + + @override + def get_build_environment(self) -> dict[str, str]: + return {} + + @override + def get_build_commands(self) -> list[str]: + options = cast(ClassicPreseedPluginProperties, self._options) + cmd = [ + "snap", + "prepare-image", + "--classic", + f"--arch={self._part_info.target_arch}", + f"--validation={options.classic_preseed_validation}", + ] + if options.classic_preseed_channel: + cmd.append(f"--channel={shlex.quote(options.classic_preseed_channel)}") + + if options.classic_preseed_revisions: + cmd.append(f"--revisions={shlex.quote(options.classic_preseed_revisions)}") + + for assertion in options.classic_preseed_assertions: + cmd.append(f"--assert={shlex.quote(assertion)}") + + for snap in options.classic_preseed_snaps: + cmd.append(f"--snap={self._resolve_snap(snap)}") + + cmd.append( + f'"{options.classic_preseed_model_assert}" {self._part_info.part_install_dir}' + ) + return [" ".join(cmd)] + + def _resolve_snap(self, snap: str) -> str: + snap = snap.strip() + if "/" in snap and not snap.endswith(".snap"): + name, channel = snap.split("/", 1) + snap = f"{name}={channel}" + return shlex.quote(snap) diff --git a/tests/spread/plugins/classic-preseed/imagecraft.yaml b/tests/spread/plugins/classic-preseed/imagecraft.yaml new file mode 100644 index 00000000..a7b3e5e9 --- /dev/null +++ b/tests/spread/plugins/classic-preseed/imagecraft.yaml @@ -0,0 +1,35 @@ +name: classic-preseed-test +version: "0.1" +summary: Test classic-preseed plugin +description: Test classic-preseed plugin +base: bare +build-base: ubuntu@24.04 + +platforms: + amd64: + +volumes: + disk: + schema: gpt + structure: + - name: rootfs + role: system-data + type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 + filesystem: ext4 + filesystem-label: writable + size: 2G + +filesystems: + default: + - mount: / + device: (volume/disk/rootfs) + +parts: + snaps: + plugin: classic-preseed + classic-preseed-snaps: + - yq/latest/stable + - core24 + - hello-world=edge + organize: + "*": (overlay)/ diff --git a/tests/spread/plugins/classic-preseed/task.yaml b/tests/spread/plugins/classic-preseed/task.yaml new file mode 100644 index 00000000..e69de29b From 6e4c0f293b952d65486df02959ff12cb1e382914 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:12:22 +0000 Subject: [PATCH 02/26] feat: add uc preseed plugin --- imagecraft/models/volume.py | 5 ++ imagecraft/plugins/_setup.py | 3 +- imagecraft/plugins/uc_preseed_plugin.py | 85 +++++++++++++++++++ .../spread/plugins/uc-preseed/imagecraft.yaml | 48 +++++++++++ tests/spread/plugins/uc-preseed/model.assert | 48 +++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 imagecraft/plugins/uc_preseed_plugin.py create mode 100644 tests/spread/plugins/uc-preseed/imagecraft.yaml create mode 100755 tests/spread/plugins/uc-preseed/model.assert diff --git a/imagecraft/models/volume.py b/imagecraft/models/volume.py index 2c7c196c..11025abf 100644 --- a/imagecraft/models/volume.py +++ b/imagecraft/models/volume.py @@ -155,6 +155,11 @@ class Role(str, enum.Enum): SYSTEM_BOOT = "system-boot" """The partition stores the image's boot assets.""" + SYSTEM_SEED = "system-seed" + """The partition stores the image's initial seed data used during first boot.""" + + SYSTEM_SAVE = "system-save" + """The partition stores persistent system state.""" class StructureItem(CraftBaseModel): """Structure item of the image.""" diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py index a72e5334..5ac456d9 100644 --- a/imagecraft/plugins/_setup.py +++ b/imagecraft/plugins/_setup.py @@ -17,6 +17,7 @@ from craft_parts.plugins import register from craft_parts.plugins.plugins import PluginType +from .uc_preseed_plugin import UcPreseedPlugin from .classic_preseed_plugin import ClassicPreseedPlugin @@ -25,7 +26,7 @@ def get_app_plugins() -> dict[str, PluginType]: :returns: A dict mapping plugin names to plugins """ - return {"classic-preseed": ClassicPreseedPlugin} + return {"uc-preseed": UcPreseedPlugin, "classic-preseed": ClassicPreseedPlugin} def setup_plugins() -> None: diff --git a/imagecraft/plugins/uc_preseed_plugin.py b/imagecraft/plugins/uc_preseed_plugin.py new file mode 100644 index 00000000..8e2c3b46 --- /dev/null +++ b/imagecraft/plugins/uc_preseed_plugin.py @@ -0,0 +1,85 @@ +from typing import Literal, cast, override +import shlex +from craft_parts.plugins import Plugin, PluginProperties + + +class UcPreseedPluginProperties(PluginProperties, frozen=True): + """Properties for the uc-preseed plugin.""" + + plugin: Literal["uc-preseed"] = "uc-preseed" + + uc_preseed_model_assert: str + uc_preseed_snaps: list[str] = [] + uc_preseed_channel: str | None = None + uc_preseed_validation: Literal["ignore", "enforce"] = "ignore" + uc_preseed_assertions: list[str] = [] + uc_preseed_revisions: str | None = None + uc_preseed_preseed: bool = False + uc_preseed_preseed_sign_key: str | None = None + uc_preseed_apparmor_features_dir: str | None = None + uc_preseed_sysfs_overlay: str | None = None + + +class UcPreseedPlugin(Plugin): + """Prepare snaps for Ubuntu Core using 'snap prepare-image'.""" + + properties_class = UcPreseedPluginProperties + + @override + def get_build_snaps(self) -> set[str]: + return set() + + @override + def get_build_packages(self) -> set[str]: + return set() + + @override + def get_build_environment(self) -> dict[str, str]: + return {} + + @override + def get_build_commands(self) -> list[str]: + options = cast(UcPreseedPluginProperties, self._options) + + cmd = ["snap", "prepare-image"] + + if options.uc_preseed_preseed: + cmd.append("--preseed") + + if options.uc_preseed_preseed_sign_key: + cmd.append(f"--preseed-sign-key={options.uc_preseed_preseed_sign_key}") + + if options.uc_preseed_apparmor_features_dir: + cmd.append( + f"--apparmor-features-dir={options.uc_preseed_apparmor_features_dir}" + ) + + if options.uc_preseed_sysfs_overlay: + cmd.append(f"--sysfs-overlay={options.uc_preseed_sysfs_overlay}") + + cmd.append(f"--validation={options.uc_preseed_validation}") + + if options.uc_preseed_channel: + cmd.append(f"--channel={options.uc_preseed_channel}") + + if options.uc_preseed_revisions: + cmd.append(f"--revisions={options.uc_preseed_revisions}") + + for assertion in options.uc_preseed_assertions: + cmd.append(f"--assert={assertion}") + + for snap in options.uc_preseed_snaps: + cmd.append(f"--snap={self._resolve_snap(snap)}") + + cmd.append( + f"{options.uc_preseed_model_assert} {self._part_info.part_install_dir}" + ) + + return [" ".join(cmd)] + + def _resolve_snap(self, snap: str) -> str: + snap = snap.strip() + if "/" in snap and not snap.endswith(".snap"): + name, channel = snap.split("/", 1) + snap = f"{name}={channel}" + return shlex.quote(snap) diff --git a/tests/spread/plugins/uc-preseed/imagecraft.yaml b/tests/spread/plugins/uc-preseed/imagecraft.yaml new file mode 100644 index 00000000..13695460 --- /dev/null +++ b/tests/spread/plugins/uc-preseed/imagecraft.yaml @@ -0,0 +1,48 @@ +name: uc-full-test +version: "1.0" +summary: Test uc-preseed plugin +description: Test uc-preseed plugin +base: bare +build-base: ubuntu@24.04 + +platforms: + amd64: + +filesystems: + default: + - mount: / + device: (volume/disk/ubuntu-data) + +parts: + uc-seed: + plugin: uc-preseed + source: . + uc-preseed-model-assert: model.assert + uc-preseed-preseed: true + organize: + "system-seed/*": (volume/disk/ubuntu-seed)/ + +volumes: + disk: + schema: gpt + structure: + - name: ubuntu-seed + role: system-seed + type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B + filesystem: vfat + size: 1500M + - name: ubuntu-boot + role: system-boot + type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 + filesystem: ext4 + size: 750M + - name: ubuntu-save + role: system-save + type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 + filesystem: ext4 + size: 32M + - name: ubuntu-data + role: system-data + type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 + filesystem: ext4 + size: 1G diff --git a/tests/spread/plugins/uc-preseed/model.assert b/tests/spread/plugins/uc-preseed/model.assert new file mode 100755 index 00000000..411b8f85 --- /dev/null +++ b/tests/spread/plugins/uc-preseed/model.assert @@ -0,0 +1,48 @@ +type: model +authority-id: canonical +series: 16 +brand-id: canonical +model: ubuntu-core-24-amd64 +architecture: amd64 +base: core24 +grade: signed +snaps: + - + default-channel: 24/stable + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + type: gadget + - + default-channel: 24/stable + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + type: kernel + - + default-channel: latest/stable + id: dwTAh7MZZ01zyriOZErqd1JynQLiOGvM + name: core24 + type: base + - + default-channel: latest/stable + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + name: snapd + type: snapd + - + default-channel: 24/stable + id: ASctKBEHzVt3f1pbZLoekCvcigRjtuqw + name: console-conf + presence: optional + type: app +timestamp: 2024-04-19T08:42:32+00:00 +sign-key-sha3-384: 9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn + +AcLBXAQAAQoABgUCZiLKhgAKCRDgT5vottzAEmmwD/9xNRSiN7yoPMsERG2aSOLr+2ZTJddBhdj3 +bEuS6ifCb1K/t7CpNhmJt1iMasdcUl7q1DJCUr/uunLgoVKODxE0Vx4k3UCtoEH2v2Mj6oqjPdAh +d90gsEBP0nVhf+LjoS34zF3O7ScrJU69mjUPhcDx0mgOslSY5aHbFTIKsT9InJ3tKgg41+RFxvwZ +V1Ksf4ITkuy9ap7CjqhczFyNMSMn0cmF9OKK39jvmtfpXRVmjTDCoF0mLXmJ+RDyKFbjMuQU1O3S +opv6gJPE//hWj9Hn5S3DIVgjcpWWOqGk+wREFGgMs/g9aT5DoLMp/FvcMVPH8sZNLiZF3wrVJCjZ +Tm+fD3SGBlht5GJGKWL5eAlGRVOUNh0cmX1WTYuZKw+OOnNuQyPykfkEFE2OKwSQwXP+ZICuGIcX +KlO56GB8OLs8hdQQrOZH8ysO5qM3Ee0Iq/keJiQZIYIv63ODLn4cPkTa2Es33alVf59xiFvUFAfd +2hWOAFDbxSJSETNmCReSMNXR01U8YTKpQMB9CA5tlX7RlrHCeLrPld8ytSCLUf29ofsJE3yavgTK +2wt5eeX6M5okmq2TR78iH6xbX3qeK8J6Ld1ElhGIikivnXMPyA5BApdQa6aoHtHVx9jHRlgWNOZs +X5uwovE8pMriAGI4l+AWYFG6SeVyY90qqlAmzaybsw== From 44d37bb1be8514443a884814d48dc91f07849751 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:28:14 +0100 Subject: [PATCH 03/26] refactor: update plugins + add spread tasks --- imagecraft/models/volume.py | 2 - imagecraft/plugins/_setup.py | 8 +- imagecraft/plugins/classic_preseed_plugin.py | 67 ----------- imagecraft/plugins/snap_preseed_plugin.py | 90 +++++++++++++++ imagecraft/plugins/uc_prepare_plugin.py | 107 ++++++++++++++++++ imagecraft/plugins/uc_preseed_plugin.py | 85 -------------- .../spread/plugins/classic-preseed/task.yaml | 0 .../imagecraft.yaml | 16 ++- tests/spread/plugins/snap-preseed/task.yaml | 16 +++ .../spread/plugins/uc-prepare/imagecraft.yaml | 32 ++++++ .../{uc-preseed => uc-prepare}/model.assert | 0 tests/spread/plugins/uc-prepare/task.yaml | 18 +++ .../spread/plugins/uc-preseed/imagecraft.yaml | 48 -------- 13 files changed, 277 insertions(+), 212 deletions(-) delete mode 100644 imagecraft/plugins/classic_preseed_plugin.py create mode 100644 imagecraft/plugins/snap_preseed_plugin.py create mode 100644 imagecraft/plugins/uc_prepare_plugin.py delete mode 100644 imagecraft/plugins/uc_preseed_plugin.py delete mode 100644 tests/spread/plugins/classic-preseed/task.yaml rename tests/spread/plugins/{classic-preseed => snap-preseed}/imagecraft.yaml (61%) create mode 100644 tests/spread/plugins/snap-preseed/task.yaml create mode 100644 tests/spread/plugins/uc-prepare/imagecraft.yaml rename tests/spread/plugins/{uc-preseed => uc-prepare}/model.assert (100%) create mode 100644 tests/spread/plugins/uc-prepare/task.yaml delete mode 100644 tests/spread/plugins/uc-preseed/imagecraft.yaml diff --git a/imagecraft/models/volume.py b/imagecraft/models/volume.py index b932c93d..a432e5e6 100644 --- a/imagecraft/models/volume.py +++ b/imagecraft/models/volume.py @@ -164,8 +164,6 @@ class Role(str, enum.Enum): SYSTEM_SEED = "system-seed" """The partition stores the image's initial seed data used during first boot.""" - SYSTEM_SAVE = "system-save" - """The partition stores persistent system state.""" class StructureItem(CraftBaseModel): """Structure item of the image.""" diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py index ff0c2aab..cdeb0e13 100644 --- a/imagecraft/plugins/_setup.py +++ b/imagecraft/plugins/_setup.py @@ -18,6 +18,8 @@ from craft_parts.plugins.plugins import PluginType from .mmdebstrap_plugin import MmdebstrapPlugin +from .snap_preseed_plugin import SnapPreseedPlugin +from .uc_prepare_plugin import UcPreparePlugin def get_app_plugins() -> dict[str, PluginType]: @@ -25,7 +27,11 @@ def get_app_plugins() -> dict[str, PluginType]: :returns: A dict mapping plugin names to plugins """ - return {"mmdebstrap": MmdebstrapPlugin} + return { + "mmdebstrap": MmdebstrapPlugin, + "snap-preseed": SnapPreseedPlugin, + "uc-prepare": UcPreparePlugin, + } def setup_plugins() -> None: diff --git a/imagecraft/plugins/classic_preseed_plugin.py b/imagecraft/plugins/classic_preseed_plugin.py deleted file mode 100644 index b25f479c..00000000 --- a/imagecraft/plugins/classic_preseed_plugin.py +++ /dev/null @@ -1,67 +0,0 @@ -import shlex -from typing import Literal, cast, override -from craft_parts.plugins import Plugin, PluginProperties - - -class ClassicPreseedPluginProperties(PluginProperties, frozen=True): - """Properties for the 'classic-preseed' plugin.""" - - plugin: Literal["classic-preseed"] = "classic-preseed" - - classic_preseed_snaps: list[str] - classic_preseed_channel: str | None = None - classic_preseed_model_assert: str = "" - classic_preseed_validation: Literal["ignore", "enforce"] = "ignore" - classic_preseed_assertions: list[str] = [] - classic_preseed_revisions: str | None = None - - -class ClassicPreseedPlugin(Plugin): - - properties_class = ClassicPreseedPluginProperties - - @override - def get_build_snaps(self) -> set[str]: - return set() - - @override - def get_build_packages(self) -> set[str]: - return set() - - @override - def get_build_environment(self) -> dict[str, str]: - return {} - - @override - def get_build_commands(self) -> list[str]: - options = cast(ClassicPreseedPluginProperties, self._options) - cmd = [ - "snap", - "prepare-image", - "--classic", - f"--arch={self._part_info.target_arch}", - f"--validation={options.classic_preseed_validation}", - ] - if options.classic_preseed_channel: - cmd.append(f"--channel={shlex.quote(options.classic_preseed_channel)}") - - if options.classic_preseed_revisions: - cmd.append(f"--revisions={shlex.quote(options.classic_preseed_revisions)}") - - for assertion in options.classic_preseed_assertions: - cmd.append(f"--assert={shlex.quote(assertion)}") - - for snap in options.classic_preseed_snaps: - cmd.append(f"--snap={self._resolve_snap(snap)}") - - cmd.append( - f'"{options.classic_preseed_model_assert}" {self._part_info.part_install_dir}' - ) - return [" ".join(cmd)] - - def _resolve_snap(self, snap: str) -> str: - snap = snap.strip() - if "/" in snap and not snap.endswith(".snap"): - name, channel = snap.split("/", 1) - snap = f"{name}={channel}" - return shlex.quote(snap) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py new file mode 100644 index 00000000..db1a5315 --- /dev/null +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -0,0 +1,90 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU 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 +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +"""The snap-preseed plugin.""" + +from typing import Literal, cast, override + +from craft_parts.plugins import Plugin, PluginProperties + + +class SnapPreseedPluginProperties(PluginProperties, frozen=True): + """Properties for the 'snap-preseed' plugin.""" + + plugin: Literal["snap-preseed"] = "snap-preseed" + + snap_preseed_snaps: list[str] + snap_preseed_channel: str | None = None + snap_preseed_model_assert: str = "" + snap_preseed_validation: Literal["ignore", "enforce"] = "ignore" + snap_preseed_assertions: list[str] = [] + snap_preseed_revisions: str | None = None + + +class SnapPreseedPlugin(Plugin): + """Prepare snaps for Ubuntu Classic images using 'snap prepare-image'.""" + + properties_class = SnapPreseedPluginProperties + + @override + def get_build_snaps(self) -> set[str]: + """Return a set of required snaps to install in the build environment.""" + return set() + + @override + def get_build_packages(self) -> set[str]: + """Return a set of required packages to install in the build environment.""" + return set() + + @override + def get_build_environment(self) -> dict[str, str]: + """Return a dictionary with the environment to use in the build step.""" + return {} + + @override + def get_build_commands(self) -> list[str]: + """Return a list of commands to run during the build step.""" + options = cast(SnapPreseedPluginProperties, self._options) + cmd = [ + "snap", + "prepare-image", + "--classic", + f"--arch={self._part_info.target_arch}", + f"--validation={options.snap_preseed_validation}", + ] + if options.snap_preseed_channel: + cmd.append(f"--channel={options.snap_preseed_channel}") + + if options.snap_preseed_revisions: + cmd.append(f"--revisions={options.snap_preseed_revisions}") + + for assertion in options.snap_preseed_assertions: + cmd.append(f"--assert={assertion}") + + for snap in options.snap_preseed_snaps: + cmd.append(f"--snap={self._resolve_snap(snap)}") + + cmd.append( + f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}' + ) + return [" ".join(cmd)] + + def _resolve_snap(self, snap: str) -> str: + snap = snap.strip() + if "/" in snap and not snap.endswith(".snap"): + name, channel = snap.split("/", 1) + snap = f"{name}={channel}" + return snap diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py new file mode 100644 index 00000000..bc626696 --- /dev/null +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -0,0 +1,107 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU 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 +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +"""The uc-prepare plugin.""" + +from typing import Literal, cast, override + +from craft_parts.plugins import Plugin, PluginProperties + + +class UcPreparePluginProperties(PluginProperties, frozen=True): + """Properties for the uc-prepare plugin.""" + + plugin: Literal["uc-prepare"] = "uc-prepare" + + uc_prepare_model_assert: str + uc_prepare_snaps: list[str] = [] + uc_prepare_channel: str | None = None + uc_prepare_validation: Literal["ignore", "enforce"] = "ignore" + uc_prepare_assertions: list[str] = [] + uc_prepare_revisions: str | None = None + uc_prepare_preseed: bool = False + uc_prepare_preseed_sign_key: str | None = None + uc_prepare_apparmor_features_dir: str | None = None + uc_prepare_sysfs_overlay: str | None = None + + +class UcPreparePlugin(Plugin): + """Prepare snaps for Ubuntu Core using 'snap prepare-image'.""" + + properties_class = UcPreparePluginProperties + + @override + def get_build_snaps(self) -> set[str]: + """Return a set of required snaps to install in the build environment.""" + return set() + + @override + def get_build_packages(self) -> set[str]: + """Return a set of required packages to install in the build environment.""" + return set() + + @override + def get_build_environment(self) -> dict[str, str]: + """Return a dictionary with the environment to use in the build step.""" + return {} + + @override + def get_build_commands(self) -> list[str]: + """Return a list of commands to run during the build step.""" + options = cast(UcPreparePluginProperties, self._options) + + cmd = ["snap", "prepare-image"] + + if options.uc_prepare_preseed: + cmd.append("--preseed") + + if options.uc_prepare_preseed_sign_key: + cmd.append(f"--preseed-sign-key={options.uc_prepare_preseed_sign_key}") + + if options.uc_prepare_apparmor_features_dir: + cmd.append( + f"--apparmor-features-dir={options.uc_prepare_apparmor_features_dir}" + ) + + if options.uc_prepare_sysfs_overlay: + cmd.append(f"--sysfs-overlay={options.uc_prepare_sysfs_overlay}") + + cmd.append(f"--validation={options.uc_prepare_validation}") + + if options.uc_prepare_channel: + cmd.append(f"--channel={options.uc_prepare_channel}") + + if options.uc_prepare_revisions: + cmd.append(f"--revisions={options.uc_prepare_revisions}") + + for assertion in options.uc_prepare_assertions: + cmd.append(f"--assert={assertion}") + + for snap in options.uc_prepare_snaps: + cmd.append(f"--snap={self._resolve_snap(snap)}") + + cmd.append( + f"{options.uc_prepare_model_assert} {self._part_info.part_install_dir}" + ) + + return [" ".join(cmd)] + + def _resolve_snap(self, snap: str) -> str: + snap = snap.strip() + if "/" in snap and not snap.endswith(".snap"): + name, channel = snap.split("/", 1) + snap = f"{name}={channel}" + return snap diff --git a/imagecraft/plugins/uc_preseed_plugin.py b/imagecraft/plugins/uc_preseed_plugin.py deleted file mode 100644 index 8e2c3b46..00000000 --- a/imagecraft/plugins/uc_preseed_plugin.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Literal, cast, override -import shlex -from craft_parts.plugins import Plugin, PluginProperties - - -class UcPreseedPluginProperties(PluginProperties, frozen=True): - """Properties for the uc-preseed plugin.""" - - plugin: Literal["uc-preseed"] = "uc-preseed" - - uc_preseed_model_assert: str - uc_preseed_snaps: list[str] = [] - uc_preseed_channel: str | None = None - uc_preseed_validation: Literal["ignore", "enforce"] = "ignore" - uc_preseed_assertions: list[str] = [] - uc_preseed_revisions: str | None = None - uc_preseed_preseed: bool = False - uc_preseed_preseed_sign_key: str | None = None - uc_preseed_apparmor_features_dir: str | None = None - uc_preseed_sysfs_overlay: str | None = None - - -class UcPreseedPlugin(Plugin): - """Prepare snaps for Ubuntu Core using 'snap prepare-image'.""" - - properties_class = UcPreseedPluginProperties - - @override - def get_build_snaps(self) -> set[str]: - return set() - - @override - def get_build_packages(self) -> set[str]: - return set() - - @override - def get_build_environment(self) -> dict[str, str]: - return {} - - @override - def get_build_commands(self) -> list[str]: - options = cast(UcPreseedPluginProperties, self._options) - - cmd = ["snap", "prepare-image"] - - if options.uc_preseed_preseed: - cmd.append("--preseed") - - if options.uc_preseed_preseed_sign_key: - cmd.append(f"--preseed-sign-key={options.uc_preseed_preseed_sign_key}") - - if options.uc_preseed_apparmor_features_dir: - cmd.append( - f"--apparmor-features-dir={options.uc_preseed_apparmor_features_dir}" - ) - - if options.uc_preseed_sysfs_overlay: - cmd.append(f"--sysfs-overlay={options.uc_preseed_sysfs_overlay}") - - cmd.append(f"--validation={options.uc_preseed_validation}") - - if options.uc_preseed_channel: - cmd.append(f"--channel={options.uc_preseed_channel}") - - if options.uc_preseed_revisions: - cmd.append(f"--revisions={options.uc_preseed_revisions}") - - for assertion in options.uc_preseed_assertions: - cmd.append(f"--assert={assertion}") - - for snap in options.uc_preseed_snaps: - cmd.append(f"--snap={self._resolve_snap(snap)}") - - cmd.append( - f"{options.uc_preseed_model_assert} {self._part_info.part_install_dir}" - ) - - return [" ".join(cmd)] - - def _resolve_snap(self, snap: str) -> str: - snap = snap.strip() - if "/" in snap and not snap.endswith(".snap"): - name, channel = snap.split("/", 1) - snap = f"{name}={channel}" - return shlex.quote(snap) diff --git a/tests/spread/plugins/classic-preseed/task.yaml b/tests/spread/plugins/classic-preseed/task.yaml deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/spread/plugins/classic-preseed/imagecraft.yaml b/tests/spread/plugins/snap-preseed/imagecraft.yaml similarity index 61% rename from tests/spread/plugins/classic-preseed/imagecraft.yaml rename to tests/spread/plugins/snap-preseed/imagecraft.yaml index a7b3e5e9..ebd8198e 100644 --- a/tests/spread/plugins/classic-preseed/imagecraft.yaml +++ b/tests/spread/plugins/snap-preseed/imagecraft.yaml @@ -1,7 +1,7 @@ -name: classic-preseed-test +name: snap-preseed-test version: "0.1" -summary: Test classic-preseed plugin -description: Test classic-preseed plugin +summary: Test snap-preseed plugin +description: Test snap-preseed plugin base: bare build-base: ubuntu@24.04 @@ -26,10 +26,8 @@ filesystems: parts: snaps: - plugin: classic-preseed - classic-preseed-snaps: - - yq/latest/stable - - core24 - - hello-world=edge + plugin: snap-preseed + snap-preseed-snaps: + - hello-world/latest/stable organize: - "*": (overlay)/ + "var/*": (overlay)/var/ diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml new file mode 100644 index 00000000..c9400847 --- /dev/null +++ b/tests/spread/plugins/snap-preseed/task.yaml @@ -0,0 +1,16 @@ +summary: Test snap-preseed plugin + +systems: [ubuntu-24.04-64] + +execute: | + imagecraft pack --verbose --destructive-mode + + test -f disk.img + + test -d prime/var/lib/snapd/seed/ + test -f prime/var/lib/snapd/seed/snaps/hello-world_*.snap + +restore: | + imagecraft clean --destructive-mode + rm -rf disk.img || true + diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml new file mode 100644 index 00000000..495e12df --- /dev/null +++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml @@ -0,0 +1,32 @@ +name: uc-prepare-test +version: "1.0" +summary: Test uc-prepare plugin +description: Test uc-prepare plugin +base: bare +build-base: ubuntu@24.04 + +platforms: + amd64: + +filesystems: + default: + - mount: / + device: (volume/disk/ubuntu-seed) + +parts: + uc-seed: + plugin: uc-prepare + source: . + uc-prepare-model-assert: model.assert + organize: + "system-seed/*": (volume/disk/ubuntu-seed)/ + +volumes: + disk: + schema: gpt + structure: + - name: ubuntu-seed + role: system-seed + type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B + filesystem: vfat + size: 1500M diff --git a/tests/spread/plugins/uc-preseed/model.assert b/tests/spread/plugins/uc-prepare/model.assert similarity index 100% rename from tests/spread/plugins/uc-preseed/model.assert rename to tests/spread/plugins/uc-prepare/model.assert diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml new file mode 100644 index 00000000..9e5479e6 --- /dev/null +++ b/tests/spread/plugins/uc-prepare/task.yaml @@ -0,0 +1,18 @@ +summary: Test uc-prepare plugin + +systems: [ubuntu-24.04-64] + +execute: | + imagecraft pack --verbose --destructive-mode + + test -f disk.img + + test -f prime/snaps/pc_*.snap + test -f prime/snaps/pc-kernel_*.snap + test -f prime/snaps/core24_*.snap + test -f prime/snaps/snapd_*.snap + +restore: | + imagecraft clean --destructive-mode + rm -rf disk.img || true + diff --git a/tests/spread/plugins/uc-preseed/imagecraft.yaml b/tests/spread/plugins/uc-preseed/imagecraft.yaml deleted file mode 100644 index 13695460..00000000 --- a/tests/spread/plugins/uc-preseed/imagecraft.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: uc-full-test -version: "1.0" -summary: Test uc-preseed plugin -description: Test uc-preseed plugin -base: bare -build-base: ubuntu@24.04 - -platforms: - amd64: - -filesystems: - default: - - mount: / - device: (volume/disk/ubuntu-data) - -parts: - uc-seed: - plugin: uc-preseed - source: . - uc-preseed-model-assert: model.assert - uc-preseed-preseed: true - organize: - "system-seed/*": (volume/disk/ubuntu-seed)/ - -volumes: - disk: - schema: gpt - structure: - - name: ubuntu-seed - role: system-seed - type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B - filesystem: vfat - size: 1500M - - name: ubuntu-boot - role: system-boot - type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 - filesystem: ext4 - size: 750M - - name: ubuntu-save - role: system-save - type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 - filesystem: ext4 - size: 32M - - name: ubuntu-data - role: system-data - type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4 - filesystem: ext4 - size: 1G From eb115118eeabae1ae5f59f4902bf6bd7f8347ebb Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:33:21 +0100 Subject: [PATCH 04/26] test: add unit tests --- .../unit/plugins/test_snap_preseed_plugin.py | 102 +++++++++++++++ tests/unit/plugins/test_uc_prepare_plugin.py | 118 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 tests/unit/plugins/test_snap_preseed_plugin.py create mode 100644 tests/unit/plugins/test_uc_prepare_plugin.py diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py new file mode 100644 index 00000000..ebfcfe58 --- /dev/null +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -0,0 +1,102 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest +from craft_parts import PartInfo, ProjectInfo +from craft_parts.parts import Part +from imagecraft.plugins.snap_preseed_plugin import ( + SnapPreseedPlugin, + SnapPreseedPluginProperties, +) +from pydantic import ValidationError + + +@pytest.fixture +def part_info(new_dir): + project_info = ProjectInfo(application_name="test_snap_preseed", cache_dir=new_dir) + return PartInfo(project_info=project_info, part=Part("my-part", {})) + + +def test_missing_snaps_key(): + with pytest.raises(ValidationError, match="snap-preseed-snaps"): + SnapPreseedPluginProperties.unmarshal({}) + + +@pytest.fixture +def cmd_prefix(part_info): + return f"snap prepare-image --classic --arch={part_info.target_arch} --validation=ignore" + + +def test_get_build_commands(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + {"snap-preseed-snaps": ["core24", "hello-world/latest/stable"]} + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable "" {part_info.part_install_dir}' + ) + + +def test_get_build_commands_with_model_assertion(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-model-assert": "model.assert", + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'{cmd_prefix} --snap=core24 "model.assert" {part_info.part_install_dir}' + ) + + +def test_get_build_commands_with_assertions(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-assertions": ["system-user.assert", "account.assert"], + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 "" {part_info.part_install_dir}' + ) + + +def test_get_build_commands_with_revisions(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-revisions": "./revisions.txt", + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'{cmd_prefix} --revisions=./revisions.txt --snap=core24 "" {part_info.part_install_dir}' + ) diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py new file mode 100644 index 00000000..75738e70 --- /dev/null +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -0,0 +1,118 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest +from craft_parts import PartInfo, ProjectInfo +from craft_parts.parts import Part +from imagecraft.plugins.uc_prepare_plugin import ( + UcPreparePlugin, + UcPreparePluginProperties, +) +from pydantic import ValidationError + + +@pytest.fixture +def part_info(new_dir): + project_info = ProjectInfo(application_name="test_uc_prepare", cache_dir=new_dir) + return PartInfo(project_info=project_info, part=Part("my-part", {})) + + +def test_missing_model_assertion(): + with pytest.raises(ValidationError, match="uc-prepare-model-assert"): + UcPreparePluginProperties.unmarshal({}) + + +def test_get_build_commands(part_info): + properties = UcPreparePluginProperties.unmarshal( + {"uc-prepare-model-assert": "model.assert"} + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_preseed(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-preseed": True, + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --preseed --validation=ignore model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_preseed_sign_key(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-preseed": True, + "uc-prepare-preseed-sign-key": "sign-key", + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --preseed --preseed-sign-key=sign-key --validation=ignore model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_apparmor_dir(part_info): + apparmor_features_dir = "/sys/kernel/secutiry/somewhere" + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-preseed": True, + "uc-prepare-apparmor-features-dir": apparmor_features_dir, + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --preseed --apparmor-features-dir={apparmor_features_dir} --validation=ignore model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_sysfs_overlay(part_info): + sysfs_overlay = "./sysfs-overlay" + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-preseed": True, + "uc-prepare-sysfs-overlay": sysfs_overlay, + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --preseed --sysfs-overlay={sysfs_overlay} --validation=ignore model.assert {part_info.part_install_dir}" + ) From ae490101e19049cfcdd3ba96a8dbd58f9342e8c8 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:41:54 +0100 Subject: [PATCH 05/26] fix: linting --- imagecraft/plugins/snap_preseed_plugin.py | 10 ++++++---- imagecraft/plugins/uc_prepare_plugin.py | 10 ++++++---- tests/spread/plugins/snap-preseed/task.yaml | 1 - tests/spread/plugins/uc-prepare/task.yaml | 1 - 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index db1a5315..6d60fd97 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -71,11 +71,13 @@ def get_build_commands(self) -> list[str]: if options.snap_preseed_revisions: cmd.append(f"--revisions={options.snap_preseed_revisions}") - for assertion in options.snap_preseed_assertions: - cmd.append(f"--assert={assertion}") + cmd.extend( + f"--assert={assertion}" for assertion in options.snap_preseed_assertions + ) - for snap in options.snap_preseed_snaps: - cmd.append(f"--snap={self._resolve_snap(snap)}") + cmd.extend( + f"--snap={self._resolve_snap(snap)}" for snap in options.snap_preseed_snaps + ) cmd.append( f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}' diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index bc626696..49a4c63b 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -87,11 +87,13 @@ def get_build_commands(self) -> list[str]: if options.uc_prepare_revisions: cmd.append(f"--revisions={options.uc_prepare_revisions}") - for assertion in options.uc_prepare_assertions: - cmd.append(f"--assert={assertion}") + cmd.extend( + f"--assert={assertion}" for assertion in options.uc_prepare_assertions + ) - for snap in options.uc_prepare_snaps: - cmd.append(f"--snap={self._resolve_snap(snap)}") + cmd.extend( + f"--snap={self._resolve_snap(snap)}" for snap in options.uc_prepare_snaps + ) cmd.append( f"{options.uc_prepare_model_assert} {self._part_info.part_install_dir}" diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml index c9400847..e80ef227 100644 --- a/tests/spread/plugins/snap-preseed/task.yaml +++ b/tests/spread/plugins/snap-preseed/task.yaml @@ -13,4 +13,3 @@ execute: | restore: | imagecraft clean --destructive-mode rm -rf disk.img || true - diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml index 9e5479e6..e2dc8be1 100644 --- a/tests/spread/plugins/uc-prepare/task.yaml +++ b/tests/spread/plugins/uc-prepare/task.yaml @@ -15,4 +15,3 @@ execute: | restore: | imagecraft clean --destructive-mode rm -rf disk.img || true - From dcc030b5e53c5b36f0882683fd8fbfbbd3ffd808 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:56:46 +0100 Subject: [PATCH 06/26] fix: add model validators --- imagecraft/plugins/uc_prepare_plugin.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 49a4c63b..d308369d 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -19,6 +19,8 @@ from typing import Literal, cast, override from craft_parts.plugins import Plugin, PluginProperties +from pydantic import model_validator +from typing_extensions import Self class UcPreparePluginProperties(PluginProperties, frozen=True): @@ -37,6 +39,24 @@ class UcPreparePluginProperties(PluginProperties, frozen=True): uc_prepare_apparmor_features_dir: str | None = None uc_prepare_sysfs_overlay: str | None = None + @model_validator(mode="after") + def sign_key_requires_preseed(self) -> Self: + """preseed-sign-key requires preseed to be enabled.""" + if self.uc_prepare_preseed_sign_key and not self.uc_prepare_preseed: + raise ValueError( + "uc-prepare-preseed-sign-key cannot be used without uc-prepare-preseed" + ) + return self + + @model_validator(mode="after") + def sysfs_overlay_requires_preseed(self) -> Self: + """sysfs-overlay requires preseed to be enabled.""" + if self.uc_prepare_sysfs_overlay and not self.uc_prepare_preseed: + raise ValueError( + "uc-prepare-sysfs-overlay cannot be used without uc-prepare-preseed" + ) + return self + class UcPreparePlugin(Plugin): """Prepare snaps for Ubuntu Core using 'snap prepare-image'.""" From 683a3b0e61ae190ec440da47155a68dd0e8d9bf7 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:02:36 +0100 Subject: [PATCH 07/26] fix: add build-package for cross-building --- imagecraft/plugins/uc_prepare_plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index d308369d..3e66fbb4 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -71,6 +71,8 @@ def get_build_snaps(self) -> set[str]: @override def get_build_packages(self) -> set[str]: """Return a set of required packages to install in the build environment.""" + if self._part_info.host_arch != self._part_info.target_arch: + return {"qemu-user-static"} return set() @override From 83a8c7b86c3e59b4b5188b95b836d008e3e930a4 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:14:52 +0100 Subject: [PATCH 08/26] fix: condition build-package on preseed key --- imagecraft/plugins/uc_prepare_plugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 3e66fbb4..9f65f8ea 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -71,7 +71,11 @@ def get_build_snaps(self) -> set[str]: @override def get_build_packages(self) -> set[str]: """Return a set of required packages to install in the build environment.""" - if self._part_info.host_arch != self._part_info.target_arch: + options = cast(UcPreparePluginProperties, self._options) + if ( + options.uc_prepare_preseed + and self._part_info.host_arch != self._part_info.target_arch + ): return {"qemu-user-static"} return set() From 6300f793161612518bff087194621bbd08793953 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:22:02 +0100 Subject: [PATCH 09/26] fix: update test_volume.py --- tests/unit/models/test_volume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/models/test_volume.py b/tests/unit/models/test_volume.py index cc2eee3f..b94ef49f 100644 --- a/tests/unit/models/test_volume.py +++ b/tests/unit/models/test_volume.py @@ -140,7 +140,7 @@ def test_volume_valid(): }, ), ( - "1 validation error for Volume\nstructure.0.role\n Input should be 'system-data' or 'system-boot'", + "1 validation error for Volume\nstructure.0.role\n Input should be 'system-data', 'system-boot' or 'system-seed'", ValidationError, { "schema": "gpt", From 72b08d9a07e5659f7f2b70c936f0d63ea3a8b6d4 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:31:47 +0100 Subject: [PATCH 10/26] test: filter out gadget, kernel + resolved-content directories --- tests/spread/plugins/uc-prepare/imagecraft.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml index 495e12df..fc7f6b7b 100644 --- a/tests/spread/plugins/uc-prepare/imagecraft.yaml +++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml @@ -20,6 +20,8 @@ parts: uc-prepare-model-assert: model.assert organize: "system-seed/*": (volume/disk/ubuntu-seed)/ + prime: + - (volume/disk/ubuntu-seed)/* volumes: disk: From 86a6b0266f4fc41920bf3ba98349da76300d9d58 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:25:50 +0100 Subject: [PATCH 11/26] test: fix filter --- tests/spread/plugins/uc-prepare/imagecraft.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml index fc7f6b7b..676723cb 100644 --- a/tests/spread/plugins/uc-prepare/imagecraft.yaml +++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml @@ -21,7 +21,9 @@ parts: organize: "system-seed/*": (volume/disk/ubuntu-seed)/ prime: - - (volume/disk/ubuntu-seed)/* + - -kernel + - -gadget + - -resolved-content volumes: disk: From 4c517a06f7281b48f39e0d0bf9b0e3c1b9f9ed39 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Tue, 21 Apr 2026 06:37:48 +0100 Subject: [PATCH 12/26] fix: pr feedback --- imagecraft/plugins/snap_preseed_plugin.py | 3 +- imagecraft/plugins/uc_prepare_plugin.py | 4 +- tests/unit/plugins/test_uc_prepare_plugin.py | 57 ++++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index 6d60fd97..19b67b32 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -16,9 +16,10 @@ """The snap-preseed plugin.""" -from typing import Literal, cast, override +from typing import Literal, cast from craft_parts.plugins import Plugin, PluginProperties +from typing_extensions import override class SnapPreseedPluginProperties(PluginProperties, frozen=True): diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 9f65f8ea..4cd15022 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -16,11 +16,11 @@ """The uc-prepare plugin.""" -from typing import Literal, cast, override +from typing import Literal, cast from craft_parts.plugins import Plugin, PluginProperties from pydantic import model_validator -from typing_extensions import Self +from typing_extensions import Self, override class UcPreparePluginProperties(PluginProperties, frozen=True): diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index 75738e70..fda7baf1 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -36,6 +36,57 @@ def test_missing_model_assertion(): UcPreparePluginProperties.unmarshal({}) +def test_preseed_sign_key_without_preseed(): + with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"): + UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-preseed-sign-key": "sign-key", + } + ) + + +def test_sysfs_overlay_without_preseed(): + with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"): + UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-sysfs-overlay": "./sysfs", + } + ) + + +def test_get_build_packages_without_preseed(part_info): + properties = UcPreparePluginProperties.unmarshal( + {"uc-prepare-model-assert": "model.assert"} + ) + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_packages() == set() + + +def test_get_build_packages_with_preseed_same_arch(part_info, mocker): + mocker.patch.object(part_info, "host_arch", "amd64") + mocker.patch.object(part_info, "target_arch", "amd64") + properties = UcPreparePluginProperties.unmarshal( + {"uc-prepare-model-assert": "model.assert", "uc-prepare-preseed": True} + ) + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_packages() == set() + + +def test_get_build_packages_with_preseed_different_arch(part_info, mocker): + mocker.patch.object(part_info, "host_arch", "amd64") + mocker.patch.object(part_info, "target_arch", "arm64") + properties = UcPreparePluginProperties.unmarshal( + {"uc-prepare-model-assert": "model.assert", "uc-prepare-preseed": True} + ) + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_packages() == {"qemu-user-static"} + + def test_get_build_commands(part_info): properties = UcPreparePluginProperties.unmarshal( {"uc-prepare-model-assert": "model.assert"} @@ -56,7 +107,6 @@ def test_get_build_commands_with_preseed(part_info): "uc-prepare-preseed": True, } ) - plugin = UcPreparePlugin(properties=properties, part_info=part_info) assert ( @@ -73,7 +123,6 @@ def test_get_build_commands_with_preseed_sign_key(part_info): "uc-prepare-preseed-sign-key": "sign-key", } ) - plugin = UcPreparePlugin(properties=properties, part_info=part_info) assert ( @@ -83,7 +132,7 @@ def test_get_build_commands_with_preseed_sign_key(part_info): def test_get_build_commands_with_apparmor_dir(part_info): - apparmor_features_dir = "/sys/kernel/secutiry/somewhere" + apparmor_features_dir = "/sys/kernel/security/somewhere" properties = UcPreparePluginProperties.unmarshal( { "uc-prepare-model-assert": "model.assert", @@ -91,7 +140,6 @@ def test_get_build_commands_with_apparmor_dir(part_info): "uc-prepare-apparmor-features-dir": apparmor_features_dir, } ) - plugin = UcPreparePlugin(properties=properties, part_info=part_info) assert ( @@ -109,7 +157,6 @@ def test_get_build_commands_with_sysfs_overlay(part_info): "uc-prepare-sysfs-overlay": sysfs_overlay, } ) - plugin = UcPreparePlugin(properties=properties, part_info=part_info) assert ( From 86190fa3333058e6da2dfdbbca24b83b0edcc3f9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:13:36 +0400 Subject: [PATCH 13/26] fix: extract shared helper fn (#324) * Initial plan * Extract _resolve_snap to shared helper in imagecraft/plugins/_utils.py Agent-Logs-Url: https://github.com/canonical/imagecraft/sessions/4615f0f5-ebf0-4d3b-86ea-03f5a729dd29 Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --- imagecraft/plugins/_utils.py | 32 +++++++++++++++++++ imagecraft/plugins/snap_preseed_plugin.py | 11 ++----- imagecraft/plugins/uc_prepare_plugin.py | 11 ++----- tests/unit/plugins/test_utils.py | 38 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 imagecraft/plugins/_utils.py create mode 100644 tests/unit/plugins/test_utils.py diff --git a/imagecraft/plugins/_utils.py b/imagecraft/plugins/_utils.py new file mode 100644 index 00000000..4ad23cdf --- /dev/null +++ b/imagecraft/plugins/_utils.py @@ -0,0 +1,32 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU 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 +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +"""Shared plugin utilities.""" + + +def resolve_snap(snap: str) -> str: + """Resolve a snap reference to the format expected by snap prepare-image. + + If the snap reference contains a channel (e.g. ``name/track/risk``), it is + converted to the ``name=track/risk`` form that ``snap prepare-image`` + expects. Plain snap names and local ``.snap`` file paths are returned + unchanged. + """ + snap = snap.strip() + if "/" in snap and not snap.endswith(".snap"): + name, channel = snap.split("/", 1) + snap = f"{name}={channel}" + return snap diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index 19b67b32..ede1715d 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -21,6 +21,8 @@ from craft_parts.plugins import Plugin, PluginProperties from typing_extensions import override +from ._utils import resolve_snap + class SnapPreseedPluginProperties(PluginProperties, frozen=True): """Properties for the 'snap-preseed' plugin.""" @@ -77,17 +79,10 @@ def get_build_commands(self) -> list[str]: ) cmd.extend( - f"--snap={self._resolve_snap(snap)}" for snap in options.snap_preseed_snaps + f"--snap={resolve_snap(snap)}" for snap in options.snap_preseed_snaps ) cmd.append( f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}' ) return [" ".join(cmd)] - - def _resolve_snap(self, snap: str) -> str: - snap = snap.strip() - if "/" in snap and not snap.endswith(".snap"): - name, channel = snap.split("/", 1) - snap = f"{name}={channel}" - return snap diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 4cd15022..71adf7d4 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -22,6 +22,8 @@ from pydantic import model_validator from typing_extensions import Self, override +from ._utils import resolve_snap + class UcPreparePluginProperties(PluginProperties, frozen=True): """Properties for the uc-prepare plugin.""" @@ -118,7 +120,7 @@ def get_build_commands(self) -> list[str]: ) cmd.extend( - f"--snap={self._resolve_snap(snap)}" for snap in options.uc_prepare_snaps + f"--snap={resolve_snap(snap)}" for snap in options.uc_prepare_snaps ) cmd.append( @@ -126,10 +128,3 @@ def get_build_commands(self) -> list[str]: ) return [" ".join(cmd)] - - def _resolve_snap(self, snap: str) -> str: - snap = snap.strip() - if "/" in snap and not snap.endswith(".snap"): - name, channel = snap.split("/", 1) - snap = f"{name}={channel}" - return snap diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py new file mode 100644 index 00000000..75709c3d --- /dev/null +++ b/tests/unit/plugins/test_utils.py @@ -0,0 +1,38 @@ +# This file is part of imagecraft. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +from imagecraft.plugins._utils import resolve_snap + + +@pytest.mark.parametrize( + ("snap", "expected"), + [ + # Plain snap name – unchanged + ("core24", "core24"), + # Leading/trailing whitespace is stripped + (" core24 ", "core24"), + # Local .snap file path with a slash – unchanged (not a channel) + ("./my-snap_1.0_amd64.snap", "./my-snap_1.0_amd64.snap"), + # snap name with channel – converted to name=channel + ("hello-world/latest/stable", "hello-world=latest/stable"), + ("core24/stable", "core24=stable"), + # snap name with channel and leading whitespace + (" hello-world/latest/stable ", "hello-world=latest/stable"), + ], +) +def test_resolve_snap(snap, expected): + assert resolve_snap(snap) == expected From 60e09f8e3a7f4a502dbce612341074ce5f6b45a3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:18:53 +0400 Subject: [PATCH 14/26] test: add unit tests for snap-preseed-channel and snap-preseed-validation plugin options (#323) * Initial plan * Add tests for snap-preseed-channel and snap-preseed-validation options Agent-Logs-Url: https://github.com/canonical/imagecraft/sessions/34e3782d-fe61-42fb-8630-aac0f0238e49 Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --- .../unit/plugins/test_snap_preseed_plugin.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index ebfcfe58..18ce4ea4 100644 --- a/tests/unit/plugins/test_snap_preseed_plugin.py +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -100,3 +100,35 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix): plugin.get_build_commands()[0] == f'{cmd_prefix} --revisions=./revisions.txt --snap=core24 "" {part_info.part_install_dir}' ) + + +def test_get_build_commands_with_channel(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-channel": "latest/stable", + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'{cmd_prefix} --channel=latest/stable --snap=core24 "" {part_info.part_install_dir}' + ) + + +def test_get_build_commands_with_validation_enforce(part_info): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-validation": "enforce", + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f'snap prepare-image --classic --arch={part_info.target_arch} --validation=enforce --snap=core24 "" {part_info.part_install_dir}' + ) From cbd661fe92d4e8ba3733877e9770c0d25488d044 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:37:01 +0100 Subject: [PATCH 15/26] fix: pr feedback --- imagecraft/plugins/snap_preseed_plugin.py | 14 +++++++------- imagecraft/plugins/uc_prepare_plugin.py | 14 ++++++-------- tests/spread/plugins/snap-preseed/imagecraft.yaml | 2 +- tests/spread/plugins/snap-preseed/task.yaml | 5 ++++- tests/spread/plugins/uc-prepare/task.yaml | 2 +- tests/unit/plugins/test_snap_preseed_plugin.py | 10 +++++----- tests/unit/plugins/test_utils.py | 5 ----- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index ede1715d..b27cbfbd 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -16,6 +16,7 @@ """The snap-preseed plugin.""" +import shlex from typing import Literal, cast from craft_parts.plugins import Plugin, PluginProperties @@ -34,7 +35,7 @@ class SnapPreseedPluginProperties(PluginProperties, frozen=True): snap_preseed_model_assert: str = "" snap_preseed_validation: Literal["ignore", "enforce"] = "ignore" snap_preseed_assertions: list[str] = [] - snap_preseed_revisions: str | None = None + snap_preseed_write_revisions: str | None = None class SnapPreseedPlugin(Plugin): @@ -71,8 +72,8 @@ def get_build_commands(self) -> list[str]: if options.snap_preseed_channel: cmd.append(f"--channel={options.snap_preseed_channel}") - if options.snap_preseed_revisions: - cmd.append(f"--revisions={options.snap_preseed_revisions}") + if options.snap_preseed_write_revisions: + cmd.append(f"--revisions={options.snap_preseed_write_revisions}") cmd.extend( f"--assert={assertion}" for assertion in options.snap_preseed_assertions @@ -82,7 +83,6 @@ def get_build_commands(self) -> list[str]: f"--snap={resolve_snap(snap)}" for snap in options.snap_preseed_snaps ) - cmd.append( - f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}' - ) - return [" ".join(cmd)] + cmd.append(options.snap_preseed_model_assert) + cmd.append(str(self._part_info.part_install_dir)) + return [shlex.join(cmd)] diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 71adf7d4..82dd4ac5 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -35,7 +35,7 @@ class UcPreparePluginProperties(PluginProperties, frozen=True): uc_prepare_channel: str | None = None uc_prepare_validation: Literal["ignore", "enforce"] = "ignore" uc_prepare_assertions: list[str] = [] - uc_prepare_revisions: str | None = None + uc_prepare_write_revisions: str | None = None uc_prepare_preseed: bool = False uc_prepare_preseed_sign_key: str | None = None uc_prepare_apparmor_features_dir: str | None = None @@ -46,7 +46,7 @@ def sign_key_requires_preseed(self) -> Self: """preseed-sign-key requires preseed to be enabled.""" if self.uc_prepare_preseed_sign_key and not self.uc_prepare_preseed: raise ValueError( - "uc-prepare-preseed-sign-key cannot be used without uc-prepare-preseed" + "uc-prepare-preseed-sign-key requires uc-prepare-preseed to be set" ) return self @@ -55,7 +55,7 @@ def sysfs_overlay_requires_preseed(self) -> Self: """sysfs-overlay requires preseed to be enabled.""" if self.uc_prepare_sysfs_overlay and not self.uc_prepare_preseed: raise ValueError( - "uc-prepare-sysfs-overlay cannot be used without uc-prepare-preseed" + "uc-prepare-sysfs-overlay requires uc-prepare-preseed to be set" ) return self @@ -112,16 +112,14 @@ def get_build_commands(self) -> list[str]: if options.uc_prepare_channel: cmd.append(f"--channel={options.uc_prepare_channel}") - if options.uc_prepare_revisions: - cmd.append(f"--revisions={options.uc_prepare_revisions}") + if options.uc_prepare_write_revisions: + cmd.append(f"--revisions={options.uc_prepare_write_revisions}") cmd.extend( f"--assert={assertion}" for assertion in options.uc_prepare_assertions ) - cmd.extend( - f"--snap={resolve_snap(snap)}" for snap in options.uc_prepare_snaps - ) + cmd.extend(f"--snap={resolve_snap(snap)}" for snap in options.uc_prepare_snaps) cmd.append( f"{options.uc_prepare_model_assert} {self._part_info.part_install_dir}" diff --git a/tests/spread/plugins/snap-preseed/imagecraft.yaml b/tests/spread/plugins/snap-preseed/imagecraft.yaml index ebd8198e..915882c4 100644 --- a/tests/spread/plugins/snap-preseed/imagecraft.yaml +++ b/tests/spread/plugins/snap-preseed/imagecraft.yaml @@ -6,7 +6,7 @@ base: bare build-base: ubuntu@24.04 platforms: - amd64: + armhf: volumes: disk: diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml index e80ef227..25c357b0 100644 --- a/tests/spread/plugins/snap-preseed/task.yaml +++ b/tests/spread/plugins/snap-preseed/task.yaml @@ -10,6 +10,9 @@ execute: | test -d prime/var/lib/snapd/seed/ test -f prime/var/lib/snapd/seed/snaps/hello-world_*.snap + unsquashfs -d ./hello-snap prime/var/lib/snapd/seed/snaps/hello-world_*.snap + grep -q "armhf" ./hello-snap/meta/snap.yaml + restore: | + rm -rf disk.img ./hello-snap imagecraft clean --destructive-mode - rm -rf disk.img || true diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml index e2dc8be1..9b71f5a4 100644 --- a/tests/spread/plugins/uc-prepare/task.yaml +++ b/tests/spread/plugins/uc-prepare/task.yaml @@ -13,5 +13,5 @@ execute: | test -f prime/snaps/snapd_*.snap restore: | + rm -rf disk.img imagecraft clean --destructive-mode - rm -rf disk.img || true diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index 18ce4ea4..55cba252 100644 --- a/tests/unit/plugins/test_snap_preseed_plugin.py +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -50,7 +50,7 @@ def test_get_build_commands(part_info, cmd_prefix): assert ( plugin.get_build_commands()[0] - == f'{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable "" {part_info.part_install_dir}' + == f"{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable '' {part_info.part_install_dir}" ) @@ -66,7 +66,7 @@ def test_get_build_commands_with_model_assertion(part_info, cmd_prefix): assert ( plugin.get_build_commands()[0] - == f'{cmd_prefix} --snap=core24 "model.assert" {part_info.part_install_dir}' + == f"{cmd_prefix} --snap=core24 model.assert {part_info.part_install_dir}" ) @@ -82,7 +82,7 @@ def test_get_build_commands_with_assertions(part_info, cmd_prefix): assert ( plugin.get_build_commands()[0] - == f'{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 "" {part_info.part_install_dir}' + == f"{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 '' {part_info.part_install_dir}" ) @@ -90,7 +90,7 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix): properties = SnapPreseedPluginProperties.unmarshal( { "snap-preseed-snaps": ["core24"], - "snap-preseed-revisions": "./revisions.txt", + "snap-preseed-write-revisions": "./revisions.txt", } ) @@ -98,7 +98,7 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix): assert ( plugin.get_build_commands()[0] - == f'{cmd_prefix} --revisions=./revisions.txt --snap=core24 "" {part_info.part_install_dir}' + == f"{cmd_prefix} --revisions=./revisions.txt --snap=core24 '' {part_info.part_install_dir}" ) diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py index 75709c3d..0c20bc1f 100644 --- a/tests/unit/plugins/test_utils.py +++ b/tests/unit/plugins/test_utils.py @@ -21,16 +21,11 @@ @pytest.mark.parametrize( ("snap", "expected"), [ - # Plain snap name – unchanged ("core24", "core24"), - # Leading/trailing whitespace is stripped (" core24 ", "core24"), - # Local .snap file path with a slash – unchanged (not a channel) ("./my-snap_1.0_amd64.snap", "./my-snap_1.0_amd64.snap"), - # snap name with channel – converted to name=channel ("hello-world/latest/stable", "hello-world=latest/stable"), ("core24/stable", "core24=stable"), - # snap name with channel and leading whitespace (" hello-world/latest/stable ", "hello-world=latest/stable"), ], ) From 71f47d061f7170edcdb5fbaa17fe39605e80d9d1 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:40:38 +0100 Subject: [PATCH 16/26] fix: validation message --- tests/unit/plugins/test_uc_prepare_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index fda7baf1..eb22a324 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -37,7 +37,7 @@ def test_missing_model_assertion(): def test_preseed_sign_key_without_preseed(): - with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"): + with pytest.raises(ValueError, match="requires uc-prepare-preseed to be set"): UcPreparePluginProperties.unmarshal( { "uc-prepare-model-assert": "model.assert", @@ -47,7 +47,7 @@ def test_preseed_sign_key_without_preseed(): def test_sysfs_overlay_without_preseed(): - with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"): + with pytest.raises(ValueError, match="requires uc-prepare-preseed to be set"): UcPreparePluginProperties.unmarshal( { "uc-prepare-model-assert": "model.assert", From efaa248b81833df94cca7fc02b7c5f7b34a78fc6 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:44:22 +0100 Subject: [PATCH 17/26] test: fix asserts --- tests/unit/plugins/test_snap_preseed_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index 55cba252..ca8cbc4d 100644 --- a/tests/unit/plugins/test_snap_preseed_plugin.py +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -114,7 +114,7 @@ def test_get_build_commands_with_channel(part_info, cmd_prefix): assert ( plugin.get_build_commands()[0] - == f'{cmd_prefix} --channel=latest/stable --snap=core24 "" {part_info.part_install_dir}' + == f"{cmd_prefix} --channel=latest/stable --snap=core24 '' {part_info.part_install_dir}" ) @@ -130,5 +130,5 @@ def test_get_build_commands_with_validation_enforce(part_info): assert ( plugin.get_build_commands()[0] - == f'snap prepare-image --classic --arch={part_info.target_arch} --validation=enforce --snap=core24 "" {part_info.part_install_dir}' + == f"snap prepare-image --classic --arch={part_info.target_arch} --validation=enforce --snap=core24 '' {part_info.part_install_dir}" ) From 07df7d2eebbe0d84d274aaeff5b8632c4dd92e34 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:52:15 +0100 Subject: [PATCH 18/26] test: add channel and validation option tests --- tests/unit/plugins/test_uc_prepare_plugin.py | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index eb22a324..8c9ef307 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -163,3 +163,35 @@ def test_get_build_commands_with_sysfs_overlay(part_info): plugin.get_build_commands()[0] == f"snap prepare-image --preseed --sysfs-overlay={sysfs_overlay} --validation=ignore model.assert {part_info.part_install_dir}" ) + + +def test_get_build_commands_with_channel(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-channel": "latest/stable", + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --channel=latest/stable model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_validation_enforce(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-validation": "enforce", + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=enforce model.assert {part_info.part_install_dir}" + ) From 7841aa6b5d0e34ef8e5542efd3069c7e31105b2b Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:02:00 +0100 Subject: [PATCH 19/26] test: add uc-prepare tests for assertions and snaps options --- tests/unit/plugins/test_uc_prepare_plugin.py | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index 8c9ef307..7136b42c 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -165,6 +165,38 @@ def test_get_build_commands_with_sysfs_overlay(part_info): ) +def test_get_build_commands_with_snaps(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-snaps": ["core24", "hello-world/latest/stable"], + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --snap=core24 --snap=hello-world=latest/stable model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_assertions(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-assertions": ["system-user.assert", "account.assert"], + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --assert=system-user.assert --assert=account.assert model.assert {part_info.part_install_dir}" + ) + + def test_get_build_commands_with_channel(part_info): properties = UcPreparePluginProperties.unmarshal( { From 565781a58b0dcb81d891e9b7fbdebbc3117d3e3b Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:42:50 +0100 Subject: [PATCH 20/26] fix: add build-for --- tests/spread/plugins/snap-preseed/imagecraft.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/spread/plugins/snap-preseed/imagecraft.yaml b/tests/spread/plugins/snap-preseed/imagecraft.yaml index 915882c4..9b83d6ca 100644 --- a/tests/spread/plugins/snap-preseed/imagecraft.yaml +++ b/tests/spread/plugins/snap-preseed/imagecraft.yaml @@ -7,6 +7,8 @@ build-base: ubuntu@24.04 platforms: armhf: + build-on: [amd64] + build-for: [armhf] volumes: disk: From 52273308be83979b88f380a1e93492c1816d87f2 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:34:52 +0100 Subject: [PATCH 21/26] test: check arch of installed core snap --- tests/spread/plugins/snap-preseed/task.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml index 25c357b0..919d9f6b 100644 --- a/tests/spread/plugins/snap-preseed/task.yaml +++ b/tests/spread/plugins/snap-preseed/task.yaml @@ -10,9 +10,9 @@ execute: | test -d prime/var/lib/snapd/seed/ test -f prime/var/lib/snapd/seed/snaps/hello-world_*.snap - unsquashfs -d ./hello-snap prime/var/lib/snapd/seed/snaps/hello-world_*.snap - grep -q "armhf" ./hello-snap/meta/snap.yaml + unsquashfs -d core-snap prime/var/lib/snapd/seed/snaps/core_*.snap + grep -q "armhf" core-snap/meta/snap.yaml restore: | - rm -rf disk.img ./hello-snap + rm -rf disk.img core-snap imagecraft clean --destructive-mode From d05c876aa73b8a1503af70826ea949c9826ca7b6 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:09:47 +0100 Subject: [PATCH 22/26] fix: add snap ref validator --- imagecraft/plugins/_utils.py | 23 +++++++++++++++++ imagecraft/plugins/snap_preseed_plugin.py | 8 +++++- imagecraft/plugins/uc_prepare_plugin.py | 9 +++++-- .../unit/plugins/test_snap_preseed_plugin.py | 9 +++++++ tests/unit/plugins/test_uc_prepare_plugin.py | 9 +++++++ tests/unit/plugins/test_utils.py | 25 ++++++++++++++++++- 6 files changed, 79 insertions(+), 4 deletions(-) diff --git a/imagecraft/plugins/_utils.py b/imagecraft/plugins/_utils.py index 4ad23cdf..ff115418 100644 --- a/imagecraft/plugins/_utils.py +++ b/imagecraft/plugins/_utils.py @@ -16,6 +16,29 @@ """Shared plugin utilities.""" +from craft_application.models.constraints import PROJECT_NAME_COMPILED_REGEX + +VALID_RISKS = ["stable", "candidate", "beta", "edge"] + + +def validate_snap_refs(snaps: list[str]) -> list[str]: + for snap in snaps: + if snap.endswith(".snap"): + continue + + parts = snap.split("/") + + name = parts[0] + if not PROJECT_NAME_COMPILED_REGEX.match(name): + raise ValueError(f"Invalid snap reference {snap}") + + if (channel_parts := parts[1:]) and ( + len(channel_parts) > 1 and not any(p in VALID_RISKS for p in channel_parts) + ): + raise ValueError(f"Invalid snap reference {snap}") + + return snaps + def resolve_snap(snap: str) -> str: """Resolve a snap reference to the format expected by snap prepare-image. diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index b27cbfbd..58a2aaec 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -20,9 +20,10 @@ from typing import Literal, cast from craft_parts.plugins import Plugin, PluginProperties +from pydantic import field_validator from typing_extensions import override -from ._utils import resolve_snap +from ._utils import resolve_snap, validate_snap_refs class SnapPreseedPluginProperties(PluginProperties, frozen=True): @@ -37,6 +38,11 @@ class SnapPreseedPluginProperties(PluginProperties, frozen=True): snap_preseed_assertions: list[str] = [] snap_preseed_write_revisions: str | None = None + @field_validator("snap_preseed_snaps") + @classmethod + def _validate_snap_refs(cls, snaps: list[str]) -> list[str]: + return validate_snap_refs(snaps) + class SnapPreseedPlugin(Plugin): """Prepare snaps for Ubuntu Classic images using 'snap prepare-image'.""" diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 82dd4ac5..335f8822 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -19,10 +19,10 @@ from typing import Literal, cast from craft_parts.plugins import Plugin, PluginProperties -from pydantic import model_validator +from pydantic import field_validator, model_validator from typing_extensions import Self, override -from ._utils import resolve_snap +from ._utils import resolve_snap, validate_snap_refs class UcPreparePluginProperties(PluginProperties, frozen=True): @@ -59,6 +59,11 @@ def sysfs_overlay_requires_preseed(self) -> Self: ) return self + @field_validator("uc_prepare_snaps") + @classmethod + def _validate_snap_refs(cls, snaps: list[str]) -> list[str]: + return validate_snap_refs(snaps) + class UcPreparePlugin(Plugin): """Prepare snaps for Ubuntu Core using 'snap prepare-image'.""" diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index ca8cbc4d..f4269c7a 100644 --- a/tests/unit/plugins/test_snap_preseed_plugin.py +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -41,6 +41,15 @@ def cmd_prefix(part_info): return f"snap prepare-image --classic --arch={part_info.target_arch} --validation=ignore" +def test_snap_preseed_snaps_validation(): + with pytest.raises(ValidationError, match="Invalid snap reference"): + SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["invalid--snap"], + } + ) + + def test_get_build_commands(part_info, cmd_prefix): properties = SnapPreseedPluginProperties.unmarshal( {"snap-preseed-snaps": ["core24", "hello-world/latest/stable"]} diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index 7136b42c..fae3e1eb 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -36,6 +36,15 @@ def test_missing_model_assertion(): UcPreparePluginProperties.unmarshal({}) +def test_snap_preseed_snaps_validation(): + with pytest.raises(ValidationError, match="Invalid snap reference"): + UcPreparePluginProperties.unmarshal( + { + "uc-prepare-snaps": ["invalid--snap"], + } + ) + + def test_preseed_sign_key_without_preseed(): with pytest.raises(ValueError, match="requires uc-prepare-preseed to be set"): UcPreparePluginProperties.unmarshal( diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py index 0c20bc1f..cb126cb3 100644 --- a/tests/unit/plugins/test_utils.py +++ b/tests/unit/plugins/test_utils.py @@ -15,7 +15,7 @@ # along with this program. If not, see . import pytest -from imagecraft.plugins._utils import resolve_snap +from imagecraft.plugins._utils import resolve_snap, validate_snap_refs @pytest.mark.parametrize( @@ -31,3 +31,26 @@ ) def test_resolve_snap(snap, expected): assert resolve_snap(snap) == expected + + +def test_validate_snap_ref_valid(): + valid_refs = [ + "hello-world", + "hello-world/edge", + "hello-world/24", + "hello-world/stable/hotfix", + "hello-world/24/beta/hotfix", + "my-snap-123", + "/path/to/local.snap", + ] + + assert validate_snap_refs(valid_refs) == valid_refs + + +@pytest.mark.parametrize( + "invalid_ref", + ["123", "has--double-dash", "hello-world/track/notarisk"], +) +def test_validate_snap_ref_invalid(invalid_ref): + with pytest.raises(ValueError, match="Invalid snap reference"): + validate_snap_refs([invalid_ref]) From e8f1268853b69e3d413a1d73b6ab6af1e60fb359 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:46:29 +0100 Subject: [PATCH 23/26] fix: add max length contraint --- imagecraft/plugins/_utils.py | 3 ++- tests/unit/plugins/test_utils.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/imagecraft/plugins/_utils.py b/imagecraft/plugins/_utils.py index ff115418..972c4110 100644 --- a/imagecraft/plugins/_utils.py +++ b/imagecraft/plugins/_utils.py @@ -19,6 +19,7 @@ from craft_application.models.constraints import PROJECT_NAME_COMPILED_REGEX VALID_RISKS = ["stable", "candidate", "beta", "edge"] +MAX_LEN = 40 def validate_snap_refs(snaps: list[str]) -> list[str]: @@ -29,7 +30,7 @@ def validate_snap_refs(snaps: list[str]) -> list[str]: parts = snap.split("/") name = parts[0] - if not PROJECT_NAME_COMPILED_REGEX.match(name): + if not (len(name) <= MAX_LEN and PROJECT_NAME_COMPILED_REGEX.match(name)): raise ValueError(f"Invalid snap reference {snap}") if (channel_parts := parts[1:]) and ( diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py index cb126cb3..30da6430 100644 --- a/tests/unit/plugins/test_utils.py +++ b/tests/unit/plugins/test_utils.py @@ -49,7 +49,12 @@ def test_validate_snap_ref_valid(): @pytest.mark.parametrize( "invalid_ref", - ["123", "has--double-dash", "hello-world/track/notarisk"], + [ + "123", + "has--double-dash", + "hello-world/track/notarisk", + "this-is-too-long-123456789234567891234567", + ], ) def test_validate_snap_ref_invalid(invalid_ref): with pytest.raises(ValueError, match="Invalid snap reference"): From e1dfa3ba0a8a9eed63d7759a16ca02dc9eac1145 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:12:41 +0100 Subject: [PATCH 24/26] test: extract snap.yaml file --- tests/spread/plugins/snap-preseed/task.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml index 919d9f6b..558161d8 100644 --- a/tests/spread/plugins/snap-preseed/task.yaml +++ b/tests/spread/plugins/snap-preseed/task.yaml @@ -9,8 +9,9 @@ execute: | test -d prime/var/lib/snapd/seed/ test -f prime/var/lib/snapd/seed/snaps/hello-world_*.snap + test -f prime/var/lib/snapd/seed/snaps/core_*.snap - unsquashfs -d core-snap prime/var/lib/snapd/seed/snaps/core_*.snap + unsquashfs -d core-snap prime/var/lib/snapd/seed/snaps/core_*.snap --extract-file meta/snap.yaml grep -q "armhf" core-snap/meta/snap.yaml restore: | From 081c72ed3e95ed5cd65629b27790623c1b8d1162 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:56:48 +0100 Subject: [PATCH 25/26] fix: add --write-revisions key --- imagecraft/plugins/snap_preseed_plugin.py | 15 +++++- imagecraft/plugins/uc_prepare_plugin.py | 15 +++++- .../spread/plugins/uc-prepare/imagecraft.yaml | 1 + .../unit/plugins/test_snap_preseed_plugin.py | 34 ++++++++++++- tests/unit/plugins/test_uc_prepare_plugin.py | 48 +++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index 58a2aaec..4d0faafb 100644 --- a/imagecraft/plugins/snap_preseed_plugin.py +++ b/imagecraft/plugins/snap_preseed_plugin.py @@ -36,7 +36,8 @@ class SnapPreseedPluginProperties(PluginProperties, frozen=True): snap_preseed_model_assert: str = "" snap_preseed_validation: Literal["ignore", "enforce"] = "ignore" snap_preseed_assertions: list[str] = [] - snap_preseed_write_revisions: str | None = None + snap_preseed_revisions: str | None = None + snap_preseed_write_revisions: str | bool = False @field_validator("snap_preseed_snaps") @classmethod @@ -78,8 +79,18 @@ def get_build_commands(self) -> list[str]: if options.snap_preseed_channel: cmd.append(f"--channel={options.snap_preseed_channel}") + if options.snap_preseed_revisions: + cmd.append(f"--revisions={options.snap_preseed_revisions}") + if options.snap_preseed_write_revisions: - cmd.append(f"--revisions={options.snap_preseed_write_revisions}") + revisions_path = self._part_info.part_install_dir / ( + "seed.manifest" + if isinstance(options.snap_preseed_write_revisions, bool) + else options.snap_preseed_write_revisions.lstrip("/") + ) + + revisions_path.parent.mkdir(parents=True, exist_ok=True) + cmd.append(f"--write-revisions={revisions_path}") cmd.extend( f"--assert={assertion}" for assertion in options.snap_preseed_assertions diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py index 335f8822..310daa0c 100644 --- a/imagecraft/plugins/uc_prepare_plugin.py +++ b/imagecraft/plugins/uc_prepare_plugin.py @@ -35,7 +35,8 @@ class UcPreparePluginProperties(PluginProperties, frozen=True): uc_prepare_channel: str | None = None uc_prepare_validation: Literal["ignore", "enforce"] = "ignore" uc_prepare_assertions: list[str] = [] - uc_prepare_write_revisions: str | None = None + uc_prepare_revisions: str | None = None + uc_prepare_write_revisions: str | bool = False uc_prepare_preseed: bool = False uc_prepare_preseed_sign_key: str | None = None uc_prepare_apparmor_features_dir: str | None = None @@ -117,8 +118,18 @@ def get_build_commands(self) -> list[str]: if options.uc_prepare_channel: cmd.append(f"--channel={options.uc_prepare_channel}") + if options.uc_prepare_revisions: + cmd.append(f"--revisions={options.uc_prepare_revisions}") + if options.uc_prepare_write_revisions: - cmd.append(f"--revisions={options.uc_prepare_write_revisions}") + revisions_path = self._part_info.part_install_dir / ( + "seed.manifest" + if isinstance(options.uc_prepare_write_revisions, bool) + else options.uc_prepare_write_revisions.lstrip("/") + ) + + revisions_path.parent.mkdir(parents=True, exist_ok=True) + cmd.append(f"--write-revisions={revisions_path}") cmd.extend( f"--assert={assertion}" for assertion in options.uc_prepare_assertions diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml index 676723cb..ec0c61f9 100644 --- a/tests/spread/plugins/uc-prepare/imagecraft.yaml +++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml @@ -24,6 +24,7 @@ parts: - -kernel - -gadget - -resolved-content + - -system-seed volumes: disk: diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index f4269c7a..eedc57f0 100644 --- a/tests/unit/plugins/test_snap_preseed_plugin.py +++ b/tests/unit/plugins/test_snap_preseed_plugin.py @@ -99,7 +99,7 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix): properties = SnapPreseedPluginProperties.unmarshal( { "snap-preseed-snaps": ["core24"], - "snap-preseed-write-revisions": "./revisions.txt", + "snap-preseed-revisions": "./revisions.txt", } ) @@ -111,6 +111,38 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix): ) +def test_get_build_commands_with_write_revisions(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-write-revisions": True, + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"{cmd_prefix} --write-revisions={part_info.part_install_dir}/seed.manifest --snap=core24 '' {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_write_revisions_path(part_info, cmd_prefix): + properties = SnapPreseedPluginProperties.unmarshal( + { + "snap-preseed-snaps": ["core24"], + "snap-preseed-write-revisions": "./revisions.txt", + } + ) + + plugin = SnapPreseedPlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"{cmd_prefix} --write-revisions={part_info.part_install_dir}/revisions.txt --snap=core24 '' {part_info.part_install_dir}" + ) + + def test_get_build_commands_with_channel(part_info, cmd_prefix): properties = SnapPreseedPluginProperties.unmarshal( { diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py index fae3e1eb..f5bab9ee 100644 --- a/tests/unit/plugins/test_uc_prepare_plugin.py +++ b/tests/unit/plugins/test_uc_prepare_plugin.py @@ -236,3 +236,51 @@ def test_get_build_commands_with_validation_enforce(part_info): plugin.get_build_commands()[0] == f"snap prepare-image --validation=enforce model.assert {part_info.part_install_dir}" ) + + +def test_get_build_commands_with_revisions(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-revisions": "./revisions.txt", + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --revisions=./revisions.txt model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_write_revisions(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-write-revisions": True, + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --write-revisions={part_info.part_install_dir}/seed.manifest model.assert {part_info.part_install_dir}" + ) + + +def test_get_build_commands_with_write_revisions_path(part_info): + properties = UcPreparePluginProperties.unmarshal( + { + "uc-prepare-model-assert": "model.assert", + "uc-prepare-write-revisions": "./revisions.txt", + } + ) + + plugin = UcPreparePlugin(properties=properties, part_info=part_info) + + assert ( + plugin.get_build_commands()[0] + == f"snap prepare-image --validation=ignore --write-revisions={part_info.part_install_dir}/revisions.txt model.assert {part_info.part_install_dir}" + ) From c1565d0befcd813f61148a36345c0520ae293ad8 Mon Sep 17 00:00:00 2001 From: smethnani <82812166+smethnani@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:08:31 +0100 Subject: [PATCH 26/26] fix: add max / in channel constraint --- imagecraft/plugins/_utils.py | 6 +++++- tests/unit/plugins/test_utils.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/imagecraft/plugins/_utils.py b/imagecraft/plugins/_utils.py index 972c4110..24dd3fe7 100644 --- a/imagecraft/plugins/_utils.py +++ b/imagecraft/plugins/_utils.py @@ -20,9 +20,11 @@ VALID_RISKS = ["stable", "candidate", "beta", "edge"] MAX_LEN = 40 +MAX_CHANNEL_PARTS = 3 def validate_snap_refs(snaps: list[str]) -> list[str]: + """Validate a list of snap references.""" for snap in snaps: if snap.endswith(".snap"): continue @@ -34,7 +36,9 @@ def validate_snap_refs(snaps: list[str]) -> list[str]: raise ValueError(f"Invalid snap reference {snap}") if (channel_parts := parts[1:]) and ( - len(channel_parts) > 1 and not any(p in VALID_RISKS for p in channel_parts) + len(channel_parts) > 1 + and not any(p in VALID_RISKS for p in channel_parts) + or len(channel_parts) > MAX_CHANNEL_PARTS ): raise ValueError(f"Invalid snap reference {snap}") diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py index 30da6430..6505ffbf 100644 --- a/tests/unit/plugins/test_utils.py +++ b/tests/unit/plugins/test_utils.py @@ -54,6 +54,7 @@ def test_validate_snap_ref_valid(): "has--double-dash", "hello-world/track/notarisk", "this-is-too-long-123456789234567891234567", + "hello-world/track/stable/branch/notallowed", ], ) def test_validate_snap_ref_invalid(invalid_ref):