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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 39af7925b50086dcb9e4b4f578ef9a5a51610016 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:11:19 +0000 Subject: [PATCH 13/14] Initial plan From ebe02311254d1fd864e0fe9c1260a47c00a28735 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:14:18 +0000 Subject: [PATCH 14/14] fix: use shlex.join for safe command construction in snap-preseed plugin Agent-Logs-Url: https://github.com/canonical/imagecraft/sessions/2390df9f-e724-4191-8188-e2ec39b78dd0 Co-authored-by: lengau <4305943+lengau@users.noreply.github.com> --- imagecraft/plugins/snap_preseed_plugin.py | 8 ++++---- tests/unit/plugins/test_snap_preseed_plugin.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py index 19b67b32..2de23eb4 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 @@ -80,10 +81,9 @@ def get_build_commands(self) -> list[str]: 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}' - ) - return [" ".join(cmd)] + cmd.append(options.snap_preseed_model_assert) + cmd.append(str(self._part_info.part_install_dir)) + return [shlex.join(cmd)] def _resolve_snap(self, snap: str) -> str: snap = snap.strip() diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py index ebfcfe58..93c98375 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}" ) @@ -98,5 +98,5 @@ 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}" )