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):