From 306e1163512edf8ef5cd0121c79963f3ae39c637 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Tue, 17 Mar 2026 09:31:01 +0000
Subject: [PATCH 01/14] feat: add classic preseed plugin
---
imagecraft/plugins/_setup.py | 4 +-
imagecraft/plugins/classic_preseed_plugin.py | 67 +++++++++++++++++++
.../plugins/classic-preseed/imagecraft.yaml | 35 ++++++++++
.../spread/plugins/classic-preseed/task.yaml | 0
4 files changed, 105 insertions(+), 1 deletion(-)
create mode 100644 imagecraft/plugins/classic_preseed_plugin.py
create mode 100644 tests/spread/plugins/classic-preseed/imagecraft.yaml
create mode 100644 tests/spread/plugins/classic-preseed/task.yaml
diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py
index 62629029..a72e5334 100644
--- a/imagecraft/plugins/_setup.py
+++ b/imagecraft/plugins/_setup.py
@@ -17,13 +17,15 @@
from craft_parts.plugins import register
from craft_parts.plugins.plugins import PluginType
+from .classic_preseed_plugin import ClassicPreseedPlugin
+
def get_app_plugins() -> dict[str, PluginType]:
"""Get Imagecraft-specific craft-parts plugins.
:returns: A dict mapping plugin names to plugins
"""
- return {}
+ return {"classic-preseed": ClassicPreseedPlugin}
def setup_plugins() -> None:
diff --git a/imagecraft/plugins/classic_preseed_plugin.py b/imagecraft/plugins/classic_preseed_plugin.py
new file mode 100644
index 00000000..b25f479c
--- /dev/null
+++ b/imagecraft/plugins/classic_preseed_plugin.py
@@ -0,0 +1,67 @@
+import shlex
+from typing import Literal, cast, override
+from craft_parts.plugins import Plugin, PluginProperties
+
+
+class ClassicPreseedPluginProperties(PluginProperties, frozen=True):
+ """Properties for the 'classic-preseed' plugin."""
+
+ plugin: Literal["classic-preseed"] = "classic-preseed"
+
+ classic_preseed_snaps: list[str]
+ classic_preseed_channel: str | None = None
+ classic_preseed_model_assert: str = ""
+ classic_preseed_validation: Literal["ignore", "enforce"] = "ignore"
+ classic_preseed_assertions: list[str] = []
+ classic_preseed_revisions: str | None = None
+
+
+class ClassicPreseedPlugin(Plugin):
+
+ properties_class = ClassicPreseedPluginProperties
+
+ @override
+ def get_build_snaps(self) -> set[str]:
+ return set()
+
+ @override
+ def get_build_packages(self) -> set[str]:
+ return set()
+
+ @override
+ def get_build_environment(self) -> dict[str, str]:
+ return {}
+
+ @override
+ def get_build_commands(self) -> list[str]:
+ options = cast(ClassicPreseedPluginProperties, self._options)
+ cmd = [
+ "snap",
+ "prepare-image",
+ "--classic",
+ f"--arch={self._part_info.target_arch}",
+ f"--validation={options.classic_preseed_validation}",
+ ]
+ if options.classic_preseed_channel:
+ cmd.append(f"--channel={shlex.quote(options.classic_preseed_channel)}")
+
+ if options.classic_preseed_revisions:
+ cmd.append(f"--revisions={shlex.quote(options.classic_preseed_revisions)}")
+
+ for assertion in options.classic_preseed_assertions:
+ cmd.append(f"--assert={shlex.quote(assertion)}")
+
+ for snap in options.classic_preseed_snaps:
+ cmd.append(f"--snap={self._resolve_snap(snap)}")
+
+ cmd.append(
+ f'"{options.classic_preseed_model_assert}" {self._part_info.part_install_dir}'
+ )
+ return [" ".join(cmd)]
+
+ def _resolve_snap(self, snap: str) -> str:
+ snap = snap.strip()
+ if "/" in snap and not snap.endswith(".snap"):
+ name, channel = snap.split("/", 1)
+ snap = f"{name}={channel}"
+ return shlex.quote(snap)
diff --git a/tests/spread/plugins/classic-preseed/imagecraft.yaml b/tests/spread/plugins/classic-preseed/imagecraft.yaml
new file mode 100644
index 00000000..a7b3e5e9
--- /dev/null
+++ b/tests/spread/plugins/classic-preseed/imagecraft.yaml
@@ -0,0 +1,35 @@
+name: classic-preseed-test
+version: "0.1"
+summary: Test classic-preseed plugin
+description: Test classic-preseed plugin
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ amd64:
+
+volumes:
+ disk:
+ schema: gpt
+ structure:
+ - name: rootfs
+ role: system-data
+ type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
+ filesystem: ext4
+ filesystem-label: writable
+ size: 2G
+
+filesystems:
+ default:
+ - mount: /
+ device: (volume/disk/rootfs)
+
+parts:
+ snaps:
+ plugin: classic-preseed
+ classic-preseed-snaps:
+ - yq/latest/stable
+ - core24
+ - hello-world=edge
+ organize:
+ "*": (overlay)/
diff --git a/tests/spread/plugins/classic-preseed/task.yaml b/tests/spread/plugins/classic-preseed/task.yaml
new file mode 100644
index 00000000..e69de29b
From 6e4c0f293b952d65486df02959ff12cb1e382914 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Thu, 19 Mar 2026 15:12:22 +0000
Subject: [PATCH 02/14] feat: add uc preseed plugin
---
imagecraft/models/volume.py | 5 ++
imagecraft/plugins/_setup.py | 3 +-
imagecraft/plugins/uc_preseed_plugin.py | 85 +++++++++++++++++++
.../spread/plugins/uc-preseed/imagecraft.yaml | 48 +++++++++++
tests/spread/plugins/uc-preseed/model.assert | 48 +++++++++++
5 files changed, 188 insertions(+), 1 deletion(-)
create mode 100644 imagecraft/plugins/uc_preseed_plugin.py
create mode 100644 tests/spread/plugins/uc-preseed/imagecraft.yaml
create mode 100755 tests/spread/plugins/uc-preseed/model.assert
diff --git a/imagecraft/models/volume.py b/imagecraft/models/volume.py
index 2c7c196c..11025abf 100644
--- a/imagecraft/models/volume.py
+++ b/imagecraft/models/volume.py
@@ -155,6 +155,11 @@ class Role(str, enum.Enum):
SYSTEM_BOOT = "system-boot"
"""The partition stores the image's boot assets."""
+ SYSTEM_SEED = "system-seed"
+ """The partition stores the image's initial seed data used during first boot."""
+
+ SYSTEM_SAVE = "system-save"
+ """The partition stores persistent system state."""
class StructureItem(CraftBaseModel):
"""Structure item of the image."""
diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py
index a72e5334..5ac456d9 100644
--- a/imagecraft/plugins/_setup.py
+++ b/imagecraft/plugins/_setup.py
@@ -17,6 +17,7 @@
from craft_parts.plugins import register
from craft_parts.plugins.plugins import PluginType
+from .uc_preseed_plugin import UcPreseedPlugin
from .classic_preseed_plugin import ClassicPreseedPlugin
@@ -25,7 +26,7 @@ def get_app_plugins() -> dict[str, PluginType]:
:returns: A dict mapping plugin names to plugins
"""
- return {"classic-preseed": ClassicPreseedPlugin}
+ return {"uc-preseed": UcPreseedPlugin, "classic-preseed": ClassicPreseedPlugin}
def setup_plugins() -> None:
diff --git a/imagecraft/plugins/uc_preseed_plugin.py b/imagecraft/plugins/uc_preseed_plugin.py
new file mode 100644
index 00000000..8e2c3b46
--- /dev/null
+++ b/imagecraft/plugins/uc_preseed_plugin.py
@@ -0,0 +1,85 @@
+from typing import Literal, cast, override
+import shlex
+from craft_parts.plugins import Plugin, PluginProperties
+
+
+class UcPreseedPluginProperties(PluginProperties, frozen=True):
+ """Properties for the uc-preseed plugin."""
+
+ plugin: Literal["uc-preseed"] = "uc-preseed"
+
+ uc_preseed_model_assert: str
+ uc_preseed_snaps: list[str] = []
+ uc_preseed_channel: str | None = None
+ uc_preseed_validation: Literal["ignore", "enforce"] = "ignore"
+ uc_preseed_assertions: list[str] = []
+ uc_preseed_revisions: str | None = None
+ uc_preseed_preseed: bool = False
+ uc_preseed_preseed_sign_key: str | None = None
+ uc_preseed_apparmor_features_dir: str | None = None
+ uc_preseed_sysfs_overlay: str | None = None
+
+
+class UcPreseedPlugin(Plugin):
+ """Prepare snaps for Ubuntu Core using 'snap prepare-image'."""
+
+ properties_class = UcPreseedPluginProperties
+
+ @override
+ def get_build_snaps(self) -> set[str]:
+ return set()
+
+ @override
+ def get_build_packages(self) -> set[str]:
+ return set()
+
+ @override
+ def get_build_environment(self) -> dict[str, str]:
+ return {}
+
+ @override
+ def get_build_commands(self) -> list[str]:
+ options = cast(UcPreseedPluginProperties, self._options)
+
+ cmd = ["snap", "prepare-image"]
+
+ if options.uc_preseed_preseed:
+ cmd.append("--preseed")
+
+ if options.uc_preseed_preseed_sign_key:
+ cmd.append(f"--preseed-sign-key={options.uc_preseed_preseed_sign_key}")
+
+ if options.uc_preseed_apparmor_features_dir:
+ cmd.append(
+ f"--apparmor-features-dir={options.uc_preseed_apparmor_features_dir}"
+ )
+
+ if options.uc_preseed_sysfs_overlay:
+ cmd.append(f"--sysfs-overlay={options.uc_preseed_sysfs_overlay}")
+
+ cmd.append(f"--validation={options.uc_preseed_validation}")
+
+ if options.uc_preseed_channel:
+ cmd.append(f"--channel={options.uc_preseed_channel}")
+
+ if options.uc_preseed_revisions:
+ cmd.append(f"--revisions={options.uc_preseed_revisions}")
+
+ for assertion in options.uc_preseed_assertions:
+ cmd.append(f"--assert={assertion}")
+
+ for snap in options.uc_preseed_snaps:
+ cmd.append(f"--snap={self._resolve_snap(snap)}")
+
+ cmd.append(
+ f"{options.uc_preseed_model_assert} {self._part_info.part_install_dir}"
+ )
+
+ return [" ".join(cmd)]
+
+ def _resolve_snap(self, snap: str) -> str:
+ snap = snap.strip()
+ if "/" in snap and not snap.endswith(".snap"):
+ name, channel = snap.split("/", 1)
+ snap = f"{name}={channel}"
+ return shlex.quote(snap)
diff --git a/tests/spread/plugins/uc-preseed/imagecraft.yaml b/tests/spread/plugins/uc-preseed/imagecraft.yaml
new file mode 100644
index 00000000..13695460
--- /dev/null
+++ b/tests/spread/plugins/uc-preseed/imagecraft.yaml
@@ -0,0 +1,48 @@
+name: uc-full-test
+version: "1.0"
+summary: Test uc-preseed plugin
+description: Test uc-preseed plugin
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ amd64:
+
+filesystems:
+ default:
+ - mount: /
+ device: (volume/disk/ubuntu-data)
+
+parts:
+ uc-seed:
+ plugin: uc-preseed
+ source: .
+ uc-preseed-model-assert: model.assert
+ uc-preseed-preseed: true
+ organize:
+ "system-seed/*": (volume/disk/ubuntu-seed)/
+
+volumes:
+ disk:
+ schema: gpt
+ structure:
+ - name: ubuntu-seed
+ role: system-seed
+ type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
+ filesystem: vfat
+ size: 1500M
+ - name: ubuntu-boot
+ role: system-boot
+ type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
+ filesystem: ext4
+ size: 750M
+ - name: ubuntu-save
+ role: system-save
+ type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
+ filesystem: ext4
+ size: 32M
+ - name: ubuntu-data
+ role: system-data
+ type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
+ filesystem: ext4
+ size: 1G
diff --git a/tests/spread/plugins/uc-preseed/model.assert b/tests/spread/plugins/uc-preseed/model.assert
new file mode 100755
index 00000000..411b8f85
--- /dev/null
+++ b/tests/spread/plugins/uc-preseed/model.assert
@@ -0,0 +1,48 @@
+type: model
+authority-id: canonical
+series: 16
+brand-id: canonical
+model: ubuntu-core-24-amd64
+architecture: amd64
+base: core24
+grade: signed
+snaps:
+ -
+ default-channel: 24/stable
+ id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH
+ name: pc
+ type: gadget
+ -
+ default-channel: 24/stable
+ id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza
+ name: pc-kernel
+ type: kernel
+ -
+ default-channel: latest/stable
+ id: dwTAh7MZZ01zyriOZErqd1JynQLiOGvM
+ name: core24
+ type: base
+ -
+ default-channel: latest/stable
+ id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4
+ name: snapd
+ type: snapd
+ -
+ default-channel: 24/stable
+ id: ASctKBEHzVt3f1pbZLoekCvcigRjtuqw
+ name: console-conf
+ presence: optional
+ type: app
+timestamp: 2024-04-19T08:42:32+00:00
+sign-key-sha3-384: 9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn
+
+AcLBXAQAAQoABgUCZiLKhgAKCRDgT5vottzAEmmwD/9xNRSiN7yoPMsERG2aSOLr+2ZTJddBhdj3
+bEuS6ifCb1K/t7CpNhmJt1iMasdcUl7q1DJCUr/uunLgoVKODxE0Vx4k3UCtoEH2v2Mj6oqjPdAh
+d90gsEBP0nVhf+LjoS34zF3O7ScrJU69mjUPhcDx0mgOslSY5aHbFTIKsT9InJ3tKgg41+RFxvwZ
+V1Ksf4ITkuy9ap7CjqhczFyNMSMn0cmF9OKK39jvmtfpXRVmjTDCoF0mLXmJ+RDyKFbjMuQU1O3S
+opv6gJPE//hWj9Hn5S3DIVgjcpWWOqGk+wREFGgMs/g9aT5DoLMp/FvcMVPH8sZNLiZF3wrVJCjZ
+Tm+fD3SGBlht5GJGKWL5eAlGRVOUNh0cmX1WTYuZKw+OOnNuQyPykfkEFE2OKwSQwXP+ZICuGIcX
+KlO56GB8OLs8hdQQrOZH8ysO5qM3Ee0Iq/keJiQZIYIv63ODLn4cPkTa2Es33alVf59xiFvUFAfd
+2hWOAFDbxSJSETNmCReSMNXR01U8YTKpQMB9CA5tlX7RlrHCeLrPld8ytSCLUf29ofsJE3yavgTK
+2wt5eeX6M5okmq2TR78iH6xbX3qeK8J6Ld1ElhGIikivnXMPyA5BApdQa6aoHtHVx9jHRlgWNOZs
+X5uwovE8pMriAGI4l+AWYFG6SeVyY90qqlAmzaybsw==
From 44d37bb1be8514443a884814d48dc91f07849751 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Fri, 17 Apr 2026 14:28:14 +0100
Subject: [PATCH 03/14] refactor: update plugins + add spread tasks
---
imagecraft/models/volume.py | 2 -
imagecraft/plugins/_setup.py | 8 +-
imagecraft/plugins/classic_preseed_plugin.py | 67 -----------
imagecraft/plugins/snap_preseed_plugin.py | 90 +++++++++++++++
imagecraft/plugins/uc_prepare_plugin.py | 107 ++++++++++++++++++
imagecraft/plugins/uc_preseed_plugin.py | 85 --------------
.../spread/plugins/classic-preseed/task.yaml | 0
.../imagecraft.yaml | 16 ++-
tests/spread/plugins/snap-preseed/task.yaml | 16 +++
.../spread/plugins/uc-prepare/imagecraft.yaml | 32 ++++++
.../{uc-preseed => uc-prepare}/model.assert | 0
tests/spread/plugins/uc-prepare/task.yaml | 18 +++
.../spread/plugins/uc-preseed/imagecraft.yaml | 48 --------
13 files changed, 277 insertions(+), 212 deletions(-)
delete mode 100644 imagecraft/plugins/classic_preseed_plugin.py
create mode 100644 imagecraft/plugins/snap_preseed_plugin.py
create mode 100644 imagecraft/plugins/uc_prepare_plugin.py
delete mode 100644 imagecraft/plugins/uc_preseed_plugin.py
delete mode 100644 tests/spread/plugins/classic-preseed/task.yaml
rename tests/spread/plugins/{classic-preseed => snap-preseed}/imagecraft.yaml (61%)
create mode 100644 tests/spread/plugins/snap-preseed/task.yaml
create mode 100644 tests/spread/plugins/uc-prepare/imagecraft.yaml
rename tests/spread/plugins/{uc-preseed => uc-prepare}/model.assert (100%)
create mode 100644 tests/spread/plugins/uc-prepare/task.yaml
delete mode 100644 tests/spread/plugins/uc-preseed/imagecraft.yaml
diff --git a/imagecraft/models/volume.py b/imagecraft/models/volume.py
index b932c93d..a432e5e6 100644
--- a/imagecraft/models/volume.py
+++ b/imagecraft/models/volume.py
@@ -164,8 +164,6 @@ class Role(str, enum.Enum):
SYSTEM_SEED = "system-seed"
"""The partition stores the image's initial seed data used during first boot."""
- SYSTEM_SAVE = "system-save"
- """The partition stores persistent system state."""
class StructureItem(CraftBaseModel):
"""Structure item of the image."""
diff --git a/imagecraft/plugins/_setup.py b/imagecraft/plugins/_setup.py
index ff0c2aab..cdeb0e13 100644
--- a/imagecraft/plugins/_setup.py
+++ b/imagecraft/plugins/_setup.py
@@ -18,6 +18,8 @@
from craft_parts.plugins.plugins import PluginType
from .mmdebstrap_plugin import MmdebstrapPlugin
+from .snap_preseed_plugin import SnapPreseedPlugin
+from .uc_prepare_plugin import UcPreparePlugin
def get_app_plugins() -> dict[str, PluginType]:
@@ -25,7 +27,11 @@ def get_app_plugins() -> dict[str, PluginType]:
:returns: A dict mapping plugin names to plugins
"""
- return {"mmdebstrap": MmdebstrapPlugin}
+ return {
+ "mmdebstrap": MmdebstrapPlugin,
+ "snap-preseed": SnapPreseedPlugin,
+ "uc-prepare": UcPreparePlugin,
+ }
def setup_plugins() -> None:
diff --git a/imagecraft/plugins/classic_preseed_plugin.py b/imagecraft/plugins/classic_preseed_plugin.py
deleted file mode 100644
index b25f479c..00000000
--- a/imagecraft/plugins/classic_preseed_plugin.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import shlex
-from typing import Literal, cast, override
-from craft_parts.plugins import Plugin, PluginProperties
-
-
-class ClassicPreseedPluginProperties(PluginProperties, frozen=True):
- """Properties for the 'classic-preseed' plugin."""
-
- plugin: Literal["classic-preseed"] = "classic-preseed"
-
- classic_preseed_snaps: list[str]
- classic_preseed_channel: str | None = None
- classic_preseed_model_assert: str = ""
- classic_preseed_validation: Literal["ignore", "enforce"] = "ignore"
- classic_preseed_assertions: list[str] = []
- classic_preseed_revisions: str | None = None
-
-
-class ClassicPreseedPlugin(Plugin):
-
- properties_class = ClassicPreseedPluginProperties
-
- @override
- def get_build_snaps(self) -> set[str]:
- return set()
-
- @override
- def get_build_packages(self) -> set[str]:
- return set()
-
- @override
- def get_build_environment(self) -> dict[str, str]:
- return {}
-
- @override
- def get_build_commands(self) -> list[str]:
- options = cast(ClassicPreseedPluginProperties, self._options)
- cmd = [
- "snap",
- "prepare-image",
- "--classic",
- f"--arch={self._part_info.target_arch}",
- f"--validation={options.classic_preseed_validation}",
- ]
- if options.classic_preseed_channel:
- cmd.append(f"--channel={shlex.quote(options.classic_preseed_channel)}")
-
- if options.classic_preseed_revisions:
- cmd.append(f"--revisions={shlex.quote(options.classic_preseed_revisions)}")
-
- for assertion in options.classic_preseed_assertions:
- cmd.append(f"--assert={shlex.quote(assertion)}")
-
- for snap in options.classic_preseed_snaps:
- cmd.append(f"--snap={self._resolve_snap(snap)}")
-
- cmd.append(
- f'"{options.classic_preseed_model_assert}" {self._part_info.part_install_dir}'
- )
- return [" ".join(cmd)]
-
- def _resolve_snap(self, snap: str) -> str:
- snap = snap.strip()
- if "/" in snap and not snap.endswith(".snap"):
- name, channel = snap.split("/", 1)
- snap = f"{name}={channel}"
- return shlex.quote(snap)
diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py
new file mode 100644
index 00000000..db1a5315
--- /dev/null
+++ b/imagecraft/plugins/snap_preseed_plugin.py
@@ -0,0 +1,90 @@
+# This file is part of imagecraft.
+#
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+
+"""The snap-preseed plugin."""
+
+from typing import Literal, cast, override
+
+from craft_parts.plugins import Plugin, PluginProperties
+
+
+class SnapPreseedPluginProperties(PluginProperties, frozen=True):
+ """Properties for the 'snap-preseed' plugin."""
+
+ plugin: Literal["snap-preseed"] = "snap-preseed"
+
+ snap_preseed_snaps: list[str]
+ snap_preseed_channel: str | None = None
+ snap_preseed_model_assert: str = ""
+ snap_preseed_validation: Literal["ignore", "enforce"] = "ignore"
+ snap_preseed_assertions: list[str] = []
+ snap_preseed_revisions: str | None = None
+
+
+class SnapPreseedPlugin(Plugin):
+ """Prepare snaps for Ubuntu Classic images using 'snap prepare-image'."""
+
+ properties_class = SnapPreseedPluginProperties
+
+ @override
+ def get_build_snaps(self) -> set[str]:
+ """Return a set of required snaps to install in the build environment."""
+ return set()
+
+ @override
+ def get_build_packages(self) -> set[str]:
+ """Return a set of required packages to install in the build environment."""
+ return set()
+
+ @override
+ def get_build_environment(self) -> dict[str, str]:
+ """Return a dictionary with the environment to use in the build step."""
+ return {}
+
+ @override
+ def get_build_commands(self) -> list[str]:
+ """Return a list of commands to run during the build step."""
+ options = cast(SnapPreseedPluginProperties, self._options)
+ cmd = [
+ "snap",
+ "prepare-image",
+ "--classic",
+ f"--arch={self._part_info.target_arch}",
+ f"--validation={options.snap_preseed_validation}",
+ ]
+ if options.snap_preseed_channel:
+ cmd.append(f"--channel={options.snap_preseed_channel}")
+
+ if options.snap_preseed_revisions:
+ cmd.append(f"--revisions={options.snap_preseed_revisions}")
+
+ for assertion in options.snap_preseed_assertions:
+ cmd.append(f"--assert={assertion}")
+
+ for snap in options.snap_preseed_snaps:
+ cmd.append(f"--snap={self._resolve_snap(snap)}")
+
+ cmd.append(
+ f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}'
+ )
+ return [" ".join(cmd)]
+
+ def _resolve_snap(self, snap: str) -> str:
+ snap = snap.strip()
+ if "/" in snap and not snap.endswith(".snap"):
+ name, channel = snap.split("/", 1)
+ snap = f"{name}={channel}"
+ return snap
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
new file mode 100644
index 00000000..bc626696
--- /dev/null
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -0,0 +1,107 @@
+# This file is part of imagecraft.
+#
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+
+"""The uc-prepare plugin."""
+
+from typing import Literal, cast, override
+
+from craft_parts.plugins import Plugin, PluginProperties
+
+
+class UcPreparePluginProperties(PluginProperties, frozen=True):
+ """Properties for the uc-prepare plugin."""
+
+ plugin: Literal["uc-prepare"] = "uc-prepare"
+
+ uc_prepare_model_assert: str
+ uc_prepare_snaps: list[str] = []
+ uc_prepare_channel: str | None = None
+ uc_prepare_validation: Literal["ignore", "enforce"] = "ignore"
+ uc_prepare_assertions: list[str] = []
+ uc_prepare_revisions: str | None = None
+ uc_prepare_preseed: bool = False
+ uc_prepare_preseed_sign_key: str | None = None
+ uc_prepare_apparmor_features_dir: str | None = None
+ uc_prepare_sysfs_overlay: str | None = None
+
+
+class UcPreparePlugin(Plugin):
+ """Prepare snaps for Ubuntu Core using 'snap prepare-image'."""
+
+ properties_class = UcPreparePluginProperties
+
+ @override
+ def get_build_snaps(self) -> set[str]:
+ """Return a set of required snaps to install in the build environment."""
+ return set()
+
+ @override
+ def get_build_packages(self) -> set[str]:
+ """Return a set of required packages to install in the build environment."""
+ return set()
+
+ @override
+ def get_build_environment(self) -> dict[str, str]:
+ """Return a dictionary with the environment to use in the build step."""
+ return {}
+
+ @override
+ def get_build_commands(self) -> list[str]:
+ """Return a list of commands to run during the build step."""
+ options = cast(UcPreparePluginProperties, self._options)
+
+ cmd = ["snap", "prepare-image"]
+
+ if options.uc_prepare_preseed:
+ cmd.append("--preseed")
+
+ if options.uc_prepare_preseed_sign_key:
+ cmd.append(f"--preseed-sign-key={options.uc_prepare_preseed_sign_key}")
+
+ if options.uc_prepare_apparmor_features_dir:
+ cmd.append(
+ f"--apparmor-features-dir={options.uc_prepare_apparmor_features_dir}"
+ )
+
+ if options.uc_prepare_sysfs_overlay:
+ cmd.append(f"--sysfs-overlay={options.uc_prepare_sysfs_overlay}")
+
+ cmd.append(f"--validation={options.uc_prepare_validation}")
+
+ if options.uc_prepare_channel:
+ cmd.append(f"--channel={options.uc_prepare_channel}")
+
+ if options.uc_prepare_revisions:
+ cmd.append(f"--revisions={options.uc_prepare_revisions}")
+
+ for assertion in options.uc_prepare_assertions:
+ cmd.append(f"--assert={assertion}")
+
+ for snap in options.uc_prepare_snaps:
+ cmd.append(f"--snap={self._resolve_snap(snap)}")
+
+ cmd.append(
+ f"{options.uc_prepare_model_assert} {self._part_info.part_install_dir}"
+ )
+
+ return [" ".join(cmd)]
+
+ def _resolve_snap(self, snap: str) -> str:
+ snap = snap.strip()
+ if "/" in snap and not snap.endswith(".snap"):
+ name, channel = snap.split("/", 1)
+ snap = f"{name}={channel}"
+ return snap
diff --git a/imagecraft/plugins/uc_preseed_plugin.py b/imagecraft/plugins/uc_preseed_plugin.py
deleted file mode 100644
index 8e2c3b46..00000000
--- a/imagecraft/plugins/uc_preseed_plugin.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from typing import Literal, cast, override
-import shlex
-from craft_parts.plugins import Plugin, PluginProperties
-
-
-class UcPreseedPluginProperties(PluginProperties, frozen=True):
- """Properties for the uc-preseed plugin."""
-
- plugin: Literal["uc-preseed"] = "uc-preseed"
-
- uc_preseed_model_assert: str
- uc_preseed_snaps: list[str] = []
- uc_preseed_channel: str | None = None
- uc_preseed_validation: Literal["ignore", "enforce"] = "ignore"
- uc_preseed_assertions: list[str] = []
- uc_preseed_revisions: str | None = None
- uc_preseed_preseed: bool = False
- uc_preseed_preseed_sign_key: str | None = None
- uc_preseed_apparmor_features_dir: str | None = None
- uc_preseed_sysfs_overlay: str | None = None
-
-
-class UcPreseedPlugin(Plugin):
- """Prepare snaps for Ubuntu Core using 'snap prepare-image'."""
-
- properties_class = UcPreseedPluginProperties
-
- @override
- def get_build_snaps(self) -> set[str]:
- return set()
-
- @override
- def get_build_packages(self) -> set[str]:
- return set()
-
- @override
- def get_build_environment(self) -> dict[str, str]:
- return {}
-
- @override
- def get_build_commands(self) -> list[str]:
- options = cast(UcPreseedPluginProperties, self._options)
-
- cmd = ["snap", "prepare-image"]
-
- if options.uc_preseed_preseed:
- cmd.append("--preseed")
-
- if options.uc_preseed_preseed_sign_key:
- cmd.append(f"--preseed-sign-key={options.uc_preseed_preseed_sign_key}")
-
- if options.uc_preseed_apparmor_features_dir:
- cmd.append(
- f"--apparmor-features-dir={options.uc_preseed_apparmor_features_dir}"
- )
-
- if options.uc_preseed_sysfs_overlay:
- cmd.append(f"--sysfs-overlay={options.uc_preseed_sysfs_overlay}")
-
- cmd.append(f"--validation={options.uc_preseed_validation}")
-
- if options.uc_preseed_channel:
- cmd.append(f"--channel={options.uc_preseed_channel}")
-
- if options.uc_preseed_revisions:
- cmd.append(f"--revisions={options.uc_preseed_revisions}")
-
- for assertion in options.uc_preseed_assertions:
- cmd.append(f"--assert={assertion}")
-
- for snap in options.uc_preseed_snaps:
- cmd.append(f"--snap={self._resolve_snap(snap)}")
-
- cmd.append(
- f"{options.uc_preseed_model_assert} {self._part_info.part_install_dir}"
- )
-
- return [" ".join(cmd)]
-
- def _resolve_snap(self, snap: str) -> str:
- snap = snap.strip()
- if "/" in snap and not snap.endswith(".snap"):
- name, channel = snap.split("/", 1)
- snap = f"{name}={channel}"
- return shlex.quote(snap)
diff --git a/tests/spread/plugins/classic-preseed/task.yaml b/tests/spread/plugins/classic-preseed/task.yaml
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/spread/plugins/classic-preseed/imagecraft.yaml b/tests/spread/plugins/snap-preseed/imagecraft.yaml
similarity index 61%
rename from tests/spread/plugins/classic-preseed/imagecraft.yaml
rename to tests/spread/plugins/snap-preseed/imagecraft.yaml
index a7b3e5e9..ebd8198e 100644
--- a/tests/spread/plugins/classic-preseed/imagecraft.yaml
+++ b/tests/spread/plugins/snap-preseed/imagecraft.yaml
@@ -1,7 +1,7 @@
-name: classic-preseed-test
+name: snap-preseed-test
version: "0.1"
-summary: Test classic-preseed plugin
-description: Test classic-preseed plugin
+summary: Test snap-preseed plugin
+description: Test snap-preseed plugin
base: bare
build-base: ubuntu@24.04
@@ -26,10 +26,8 @@ filesystems:
parts:
snaps:
- plugin: classic-preseed
- classic-preseed-snaps:
- - yq/latest/stable
- - core24
- - hello-world=edge
+ plugin: snap-preseed
+ snap-preseed-snaps:
+ - hello-world/latest/stable
organize:
- "*": (overlay)/
+ "var/*": (overlay)/var/
diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml
new file mode 100644
index 00000000..c9400847
--- /dev/null
+++ b/tests/spread/plugins/snap-preseed/task.yaml
@@ -0,0 +1,16 @@
+summary: Test snap-preseed plugin
+
+systems: [ubuntu-24.04-64]
+
+execute: |
+ imagecraft pack --verbose --destructive-mode
+
+ test -f disk.img
+
+ test -d prime/var/lib/snapd/seed/
+ test -f prime/var/lib/snapd/seed/snaps/hello-world_*.snap
+
+restore: |
+ imagecraft clean --destructive-mode
+ rm -rf disk.img || true
+
diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml
new file mode 100644
index 00000000..495e12df
--- /dev/null
+++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml
@@ -0,0 +1,32 @@
+name: uc-prepare-test
+version: "1.0"
+summary: Test uc-prepare plugin
+description: Test uc-prepare plugin
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ amd64:
+
+filesystems:
+ default:
+ - mount: /
+ device: (volume/disk/ubuntu-seed)
+
+parts:
+ uc-seed:
+ plugin: uc-prepare
+ source: .
+ uc-prepare-model-assert: model.assert
+ organize:
+ "system-seed/*": (volume/disk/ubuntu-seed)/
+
+volumes:
+ disk:
+ schema: gpt
+ structure:
+ - name: ubuntu-seed
+ role: system-seed
+ type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
+ filesystem: vfat
+ size: 1500M
diff --git a/tests/spread/plugins/uc-preseed/model.assert b/tests/spread/plugins/uc-prepare/model.assert
similarity index 100%
rename from tests/spread/plugins/uc-preseed/model.assert
rename to tests/spread/plugins/uc-prepare/model.assert
diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml
new file mode 100644
index 00000000..9e5479e6
--- /dev/null
+++ b/tests/spread/plugins/uc-prepare/task.yaml
@@ -0,0 +1,18 @@
+summary: Test uc-prepare plugin
+
+systems: [ubuntu-24.04-64]
+
+execute: |
+ imagecraft pack --verbose --destructive-mode
+
+ test -f disk.img
+
+ test -f prime/snaps/pc_*.snap
+ test -f prime/snaps/pc-kernel_*.snap
+ test -f prime/snaps/core24_*.snap
+ test -f prime/snaps/snapd_*.snap
+
+restore: |
+ imagecraft clean --destructive-mode
+ rm -rf disk.img || true
+
diff --git a/tests/spread/plugins/uc-preseed/imagecraft.yaml b/tests/spread/plugins/uc-preseed/imagecraft.yaml
deleted file mode 100644
index 13695460..00000000
--- a/tests/spread/plugins/uc-preseed/imagecraft.yaml
+++ /dev/null
@@ -1,48 +0,0 @@
-name: uc-full-test
-version: "1.0"
-summary: Test uc-preseed plugin
-description: Test uc-preseed plugin
-base: bare
-build-base: ubuntu@24.04
-
-platforms:
- amd64:
-
-filesystems:
- default:
- - mount: /
- device: (volume/disk/ubuntu-data)
-
-parts:
- uc-seed:
- plugin: uc-preseed
- source: .
- uc-preseed-model-assert: model.assert
- uc-preseed-preseed: true
- organize:
- "system-seed/*": (volume/disk/ubuntu-seed)/
-
-volumes:
- disk:
- schema: gpt
- structure:
- - name: ubuntu-seed
- role: system-seed
- type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
- filesystem: vfat
- size: 1500M
- - name: ubuntu-boot
- role: system-boot
- type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
- filesystem: ext4
- size: 750M
- - name: ubuntu-save
- role: system-save
- type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
- filesystem: ext4
- size: 32M
- - name: ubuntu-data
- role: system-data
- type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
- filesystem: ext4
- size: 1G
From eb115118eeabae1ae5f59f4902bf6bd7f8347ebb Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 11:33:21 +0100
Subject: [PATCH 04/14] test: add unit tests
---
.../unit/plugins/test_snap_preseed_plugin.py | 102 +++++++++++++++
tests/unit/plugins/test_uc_prepare_plugin.py | 118 ++++++++++++++++++
2 files changed, 220 insertions(+)
create mode 100644 tests/unit/plugins/test_snap_preseed_plugin.py
create mode 100644 tests/unit/plugins/test_uc_prepare_plugin.py
diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py
new file mode 100644
index 00000000..ebfcfe58
--- /dev/null
+++ b/tests/unit/plugins/test_snap_preseed_plugin.py
@@ -0,0 +1,102 @@
+# This file is part of imagecraft.
+#
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+
+import pytest
+from craft_parts import PartInfo, ProjectInfo
+from craft_parts.parts import Part
+from imagecraft.plugins.snap_preseed_plugin import (
+ SnapPreseedPlugin,
+ SnapPreseedPluginProperties,
+)
+from pydantic import ValidationError
+
+
+@pytest.fixture
+def part_info(new_dir):
+ project_info = ProjectInfo(application_name="test_snap_preseed", cache_dir=new_dir)
+ return PartInfo(project_info=project_info, part=Part("my-part", {}))
+
+
+def test_missing_snaps_key():
+ with pytest.raises(ValidationError, match="snap-preseed-snaps"):
+ SnapPreseedPluginProperties.unmarshal({})
+
+
+@pytest.fixture
+def cmd_prefix(part_info):
+ return f"snap prepare-image --classic --arch={part_info.target_arch} --validation=ignore"
+
+
+def test_get_build_commands(part_info, cmd_prefix):
+ properties = SnapPreseedPluginProperties.unmarshal(
+ {"snap-preseed-snaps": ["core24", "hello-world/latest/stable"]}
+ )
+
+ plugin = SnapPreseedPlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f'{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable "" {part_info.part_install_dir}'
+ )
+
+
+def test_get_build_commands_with_model_assertion(part_info, cmd_prefix):
+ properties = SnapPreseedPluginProperties.unmarshal(
+ {
+ "snap-preseed-snaps": ["core24"],
+ "snap-preseed-model-assert": "model.assert",
+ }
+ )
+
+ plugin = SnapPreseedPlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f'{cmd_prefix} --snap=core24 "model.assert" {part_info.part_install_dir}'
+ )
+
+
+def test_get_build_commands_with_assertions(part_info, cmd_prefix):
+ properties = SnapPreseedPluginProperties.unmarshal(
+ {
+ "snap-preseed-snaps": ["core24"],
+ "snap-preseed-assertions": ["system-user.assert", "account.assert"],
+ }
+ )
+
+ plugin = SnapPreseedPlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f'{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 "" {part_info.part_install_dir}'
+ )
+
+
+def test_get_build_commands_with_revisions(part_info, cmd_prefix):
+ properties = SnapPreseedPluginProperties.unmarshal(
+ {
+ "snap-preseed-snaps": ["core24"],
+ "snap-preseed-revisions": "./revisions.txt",
+ }
+ )
+
+ plugin = SnapPreseedPlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f'{cmd_prefix} --revisions=./revisions.txt --snap=core24 "" {part_info.part_install_dir}'
+ )
diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py
new file mode 100644
index 00000000..75738e70
--- /dev/null
+++ b/tests/unit/plugins/test_uc_prepare_plugin.py
@@ -0,0 +1,118 @@
+# This file is part of imagecraft.
+#
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+
+import pytest
+from craft_parts import PartInfo, ProjectInfo
+from craft_parts.parts import Part
+from imagecraft.plugins.uc_prepare_plugin import (
+ UcPreparePlugin,
+ UcPreparePluginProperties,
+)
+from pydantic import ValidationError
+
+
+@pytest.fixture
+def part_info(new_dir):
+ project_info = ProjectInfo(application_name="test_uc_prepare", cache_dir=new_dir)
+ return PartInfo(project_info=project_info, part=Part("my-part", {}))
+
+
+def test_missing_model_assertion():
+ with pytest.raises(ValidationError, match="uc-prepare-model-assert"):
+ UcPreparePluginProperties.unmarshal({})
+
+
+def test_get_build_commands(part_info):
+ properties = UcPreparePluginProperties.unmarshal(
+ {"uc-prepare-model-assert": "model.assert"}
+ )
+
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f"snap prepare-image --validation=ignore model.assert {part_info.part_install_dir}"
+ )
+
+
+def test_get_build_commands_with_preseed(part_info):
+ properties = UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed": True,
+ }
+ )
+
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f"snap prepare-image --preseed --validation=ignore model.assert {part_info.part_install_dir}"
+ )
+
+
+def test_get_build_commands_with_preseed_sign_key(part_info):
+ properties = UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed": True,
+ "uc-prepare-preseed-sign-key": "sign-key",
+ }
+ )
+
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f"snap prepare-image --preseed --preseed-sign-key=sign-key --validation=ignore model.assert {part_info.part_install_dir}"
+ )
+
+
+def test_get_build_commands_with_apparmor_dir(part_info):
+ apparmor_features_dir = "/sys/kernel/secutiry/somewhere"
+ properties = UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed": True,
+ "uc-prepare-apparmor-features-dir": apparmor_features_dir,
+ }
+ )
+
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f"snap prepare-image --preseed --apparmor-features-dir={apparmor_features_dir} --validation=ignore model.assert {part_info.part_install_dir}"
+ )
+
+
+def test_get_build_commands_with_sysfs_overlay(part_info):
+ sysfs_overlay = "./sysfs-overlay"
+ properties = UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed": True,
+ "uc-prepare-sysfs-overlay": sysfs_overlay,
+ }
+ )
+
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert (
+ plugin.get_build_commands()[0]
+ == f"snap prepare-image --preseed --sysfs-overlay={sysfs_overlay} --validation=ignore model.assert {part_info.part_install_dir}"
+ )
From ae490101e19049cfcdd3ba96a8dbd58f9342e8c8 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 11:41:54 +0100
Subject: [PATCH 05/14] fix: linting
---
imagecraft/plugins/snap_preseed_plugin.py | 10 ++++++----
imagecraft/plugins/uc_prepare_plugin.py | 10 ++++++----
tests/spread/plugins/snap-preseed/task.yaml | 1 -
tests/spread/plugins/uc-prepare/task.yaml | 1 -
4 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py
index db1a5315..6d60fd97 100644
--- a/imagecraft/plugins/snap_preseed_plugin.py
+++ b/imagecraft/plugins/snap_preseed_plugin.py
@@ -71,11 +71,13 @@ def get_build_commands(self) -> list[str]:
if options.snap_preseed_revisions:
cmd.append(f"--revisions={options.snap_preseed_revisions}")
- for assertion in options.snap_preseed_assertions:
- cmd.append(f"--assert={assertion}")
+ cmd.extend(
+ f"--assert={assertion}" for assertion in options.snap_preseed_assertions
+ )
- for snap in options.snap_preseed_snaps:
- cmd.append(f"--snap={self._resolve_snap(snap)}")
+ cmd.extend(
+ f"--snap={self._resolve_snap(snap)}" for snap in options.snap_preseed_snaps
+ )
cmd.append(
f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}'
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
index bc626696..49a4c63b 100644
--- a/imagecraft/plugins/uc_prepare_plugin.py
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -87,11 +87,13 @@ def get_build_commands(self) -> list[str]:
if options.uc_prepare_revisions:
cmd.append(f"--revisions={options.uc_prepare_revisions}")
- for assertion in options.uc_prepare_assertions:
- cmd.append(f"--assert={assertion}")
+ cmd.extend(
+ f"--assert={assertion}" for assertion in options.uc_prepare_assertions
+ )
- for snap in options.uc_prepare_snaps:
- cmd.append(f"--snap={self._resolve_snap(snap)}")
+ cmd.extend(
+ f"--snap={self._resolve_snap(snap)}" for snap in options.uc_prepare_snaps
+ )
cmd.append(
f"{options.uc_prepare_model_assert} {self._part_info.part_install_dir}"
diff --git a/tests/spread/plugins/snap-preseed/task.yaml b/tests/spread/plugins/snap-preseed/task.yaml
index c9400847..e80ef227 100644
--- a/tests/spread/plugins/snap-preseed/task.yaml
+++ b/tests/spread/plugins/snap-preseed/task.yaml
@@ -13,4 +13,3 @@ execute: |
restore: |
imagecraft clean --destructive-mode
rm -rf disk.img || true
-
diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml
index 9e5479e6..e2dc8be1 100644
--- a/tests/spread/plugins/uc-prepare/task.yaml
+++ b/tests/spread/plugins/uc-prepare/task.yaml
@@ -15,4 +15,3 @@ execute: |
restore: |
imagecraft clean --destructive-mode
rm -rf disk.img || true
-
From dcc030b5e53c5b36f0882683fd8fbfbbd3ffd808 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 11:56:46 +0100
Subject: [PATCH 06/14] fix: add model validators
---
imagecraft/plugins/uc_prepare_plugin.py | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
index 49a4c63b..d308369d 100644
--- a/imagecraft/plugins/uc_prepare_plugin.py
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -19,6 +19,8 @@
from typing import Literal, cast, override
from craft_parts.plugins import Plugin, PluginProperties
+from pydantic import model_validator
+from typing_extensions import Self
class UcPreparePluginProperties(PluginProperties, frozen=True):
@@ -37,6 +39,24 @@ class UcPreparePluginProperties(PluginProperties, frozen=True):
uc_prepare_apparmor_features_dir: str | None = None
uc_prepare_sysfs_overlay: str | None = None
+ @model_validator(mode="after")
+ def sign_key_requires_preseed(self) -> Self:
+ """preseed-sign-key requires preseed to be enabled."""
+ if self.uc_prepare_preseed_sign_key and not self.uc_prepare_preseed:
+ raise ValueError(
+ "uc-prepare-preseed-sign-key cannot be used without uc-prepare-preseed"
+ )
+ return self
+
+ @model_validator(mode="after")
+ def sysfs_overlay_requires_preseed(self) -> Self:
+ """sysfs-overlay requires preseed to be enabled."""
+ if self.uc_prepare_sysfs_overlay and not self.uc_prepare_preseed:
+ raise ValueError(
+ "uc-prepare-sysfs-overlay cannot be used without uc-prepare-preseed"
+ )
+ return self
+
class UcPreparePlugin(Plugin):
"""Prepare snaps for Ubuntu Core using 'snap prepare-image'."""
From 683a3b0e61ae190ec440da47155a68dd0e8d9bf7 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:02:36 +0100
Subject: [PATCH 07/14] fix: add build-package for cross-building
---
imagecraft/plugins/uc_prepare_plugin.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
index d308369d..3e66fbb4 100644
--- a/imagecraft/plugins/uc_prepare_plugin.py
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -71,6 +71,8 @@ def get_build_snaps(self) -> set[str]:
@override
def get_build_packages(self) -> set[str]:
"""Return a set of required packages to install in the build environment."""
+ if self._part_info.host_arch != self._part_info.target_arch:
+ return {"qemu-user-static"}
return set()
@override
From 83a8c7b86c3e59b4b5188b95b836d008e3e930a4 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:14:52 +0100
Subject: [PATCH 08/14] fix: condition build-package on preseed key
---
imagecraft/plugins/uc_prepare_plugin.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
index 3e66fbb4..9f65f8ea 100644
--- a/imagecraft/plugins/uc_prepare_plugin.py
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -71,7 +71,11 @@ def get_build_snaps(self) -> set[str]:
@override
def get_build_packages(self) -> set[str]:
"""Return a set of required packages to install in the build environment."""
- if self._part_info.host_arch != self._part_info.target_arch:
+ options = cast(UcPreparePluginProperties, self._options)
+ if (
+ options.uc_prepare_preseed
+ and self._part_info.host_arch != self._part_info.target_arch
+ ):
return {"qemu-user-static"}
return set()
From 6300f793161612518bff087194621bbd08793953 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:22:02 +0100
Subject: [PATCH 09/14] fix: update test_volume.py
---
tests/unit/models/test_volume.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/unit/models/test_volume.py b/tests/unit/models/test_volume.py
index cc2eee3f..b94ef49f 100644
--- a/tests/unit/models/test_volume.py
+++ b/tests/unit/models/test_volume.py
@@ -140,7 +140,7 @@ def test_volume_valid():
},
),
(
- "1 validation error for Volume\nstructure.0.role\n Input should be 'system-data' or 'system-boot'",
+ "1 validation error for Volume\nstructure.0.role\n Input should be 'system-data', 'system-boot' or 'system-seed'",
ValidationError,
{
"schema": "gpt",
From 72b08d9a07e5659f7f2b70c936f0d63ea3a8b6d4 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 13:31:47 +0100
Subject: [PATCH 10/14] test: filter out gadget, kernel + resolved-content
directories
---
tests/spread/plugins/uc-prepare/imagecraft.yaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml
index 495e12df..fc7f6b7b 100644
--- a/tests/spread/plugins/uc-prepare/imagecraft.yaml
+++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml
@@ -20,6 +20,8 @@ parts:
uc-prepare-model-assert: model.assert
organize:
"system-seed/*": (volume/disk/ubuntu-seed)/
+ prime:
+ - (volume/disk/ubuntu-seed)/*
volumes:
disk:
From 86a6b0266f4fc41920bf3ba98349da76300d9d58 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Mon, 20 Apr 2026 14:25:50 +0100
Subject: [PATCH 11/14] test: fix filter
---
tests/spread/plugins/uc-prepare/imagecraft.yaml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml
index fc7f6b7b..676723cb 100644
--- a/tests/spread/plugins/uc-prepare/imagecraft.yaml
+++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml
@@ -21,7 +21,9 @@ parts:
organize:
"system-seed/*": (volume/disk/ubuntu-seed)/
prime:
- - (volume/disk/ubuntu-seed)/*
+ - -kernel
+ - -gadget
+ - -resolved-content
volumes:
disk:
From 4c517a06f7281b48f39e0d0bf9b0e3c1b9f9ed39 Mon Sep 17 00:00:00 2001
From: smethnani <82812166+smethnani@users.noreply.github.com>
Date: Tue, 21 Apr 2026 06:37:48 +0100
Subject: [PATCH 12/14] fix: pr feedback
---
imagecraft/plugins/snap_preseed_plugin.py | 3 +-
imagecraft/plugins/uc_prepare_plugin.py | 4 +-
tests/unit/plugins/test_uc_prepare_plugin.py | 57 ++++++++++++++++++--
3 files changed, 56 insertions(+), 8 deletions(-)
diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py
index 6d60fd97..19b67b32 100644
--- a/imagecraft/plugins/snap_preseed_plugin.py
+++ b/imagecraft/plugins/snap_preseed_plugin.py
@@ -16,9 +16,10 @@
"""The snap-preseed plugin."""
-from typing import Literal, cast, override
+from typing import Literal, cast
from craft_parts.plugins import Plugin, PluginProperties
+from typing_extensions import override
class SnapPreseedPluginProperties(PluginProperties, frozen=True):
diff --git a/imagecraft/plugins/uc_prepare_plugin.py b/imagecraft/plugins/uc_prepare_plugin.py
index 9f65f8ea..4cd15022 100644
--- a/imagecraft/plugins/uc_prepare_plugin.py
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -16,11 +16,11 @@
"""The uc-prepare plugin."""
-from typing import Literal, cast, override
+from typing import Literal, cast
from craft_parts.plugins import Plugin, PluginProperties
from pydantic import model_validator
-from typing_extensions import Self
+from typing_extensions import Self, override
class UcPreparePluginProperties(PluginProperties, frozen=True):
diff --git a/tests/unit/plugins/test_uc_prepare_plugin.py b/tests/unit/plugins/test_uc_prepare_plugin.py
index 75738e70..fda7baf1 100644
--- a/tests/unit/plugins/test_uc_prepare_plugin.py
+++ b/tests/unit/plugins/test_uc_prepare_plugin.py
@@ -36,6 +36,57 @@ def test_missing_model_assertion():
UcPreparePluginProperties.unmarshal({})
+def test_preseed_sign_key_without_preseed():
+ with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"):
+ UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed-sign-key": "sign-key",
+ }
+ )
+
+
+def test_sysfs_overlay_without_preseed():
+ with pytest.raises(ValueError, match="cannot be used without uc-prepare-preseed"):
+ UcPreparePluginProperties.unmarshal(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-sysfs-overlay": "./sysfs",
+ }
+ )
+
+
+def test_get_build_packages_without_preseed(part_info):
+ properties = UcPreparePluginProperties.unmarshal(
+ {"uc-prepare-model-assert": "model.assert"}
+ )
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert plugin.get_build_packages() == set()
+
+
+def test_get_build_packages_with_preseed_same_arch(part_info, mocker):
+ mocker.patch.object(part_info, "host_arch", "amd64")
+ mocker.patch.object(part_info, "target_arch", "amd64")
+ properties = UcPreparePluginProperties.unmarshal(
+ {"uc-prepare-model-assert": "model.assert", "uc-prepare-preseed": True}
+ )
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert plugin.get_build_packages() == set()
+
+
+def test_get_build_packages_with_preseed_different_arch(part_info, mocker):
+ mocker.patch.object(part_info, "host_arch", "amd64")
+ mocker.patch.object(part_info, "target_arch", "arm64")
+ properties = UcPreparePluginProperties.unmarshal(
+ {"uc-prepare-model-assert": "model.assert", "uc-prepare-preseed": True}
+ )
+ plugin = UcPreparePlugin(properties=properties, part_info=part_info)
+
+ assert plugin.get_build_packages() == {"qemu-user-static"}
+
+
def test_get_build_commands(part_info):
properties = UcPreparePluginProperties.unmarshal(
{"uc-prepare-model-assert": "model.assert"}
@@ -56,7 +107,6 @@ def test_get_build_commands_with_preseed(part_info):
"uc-prepare-preseed": True,
}
)
-
plugin = UcPreparePlugin(properties=properties, part_info=part_info)
assert (
@@ -73,7 +123,6 @@ def test_get_build_commands_with_preseed_sign_key(part_info):
"uc-prepare-preseed-sign-key": "sign-key",
}
)
-
plugin = UcPreparePlugin(properties=properties, part_info=part_info)
assert (
@@ -83,7 +132,7 @@ def test_get_build_commands_with_preseed_sign_key(part_info):
def test_get_build_commands_with_apparmor_dir(part_info):
- apparmor_features_dir = "/sys/kernel/secutiry/somewhere"
+ apparmor_features_dir = "/sys/kernel/security/somewhere"
properties = UcPreparePluginProperties.unmarshal(
{
"uc-prepare-model-assert": "model.assert",
@@ -91,7 +140,6 @@ def test_get_build_commands_with_apparmor_dir(part_info):
"uc-prepare-apparmor-features-dir": apparmor_features_dir,
}
)
-
plugin = UcPreparePlugin(properties=properties, part_info=part_info)
assert (
@@ -109,7 +157,6 @@ def test_get_build_commands_with_sysfs_overlay(part_info):
"uc-prepare-sysfs-overlay": sysfs_overlay,
}
)
-
plugin = UcPreparePlugin(properties=properties, part_info=part_info)
assert (
From 39af7925b50086dcb9e4b4f578ef9a5a51610016 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 21 Apr 2026 20:11:19 +0000
Subject: [PATCH 13/14] Initial plan
From ebe02311254d1fd864e0fe9c1260a47c00a28735 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 21 Apr 2026 20:14:18 +0000
Subject: [PATCH 14/14] fix: use shlex.join for safe command construction in
snap-preseed plugin
Agent-Logs-Url: https://github.com/canonical/imagecraft/sessions/2390df9f-e724-4191-8188-e2ec39b78dd0
Co-authored-by: lengau <4305943+lengau@users.noreply.github.com>
---
imagecraft/plugins/snap_preseed_plugin.py | 8 ++++----
tests/unit/plugins/test_snap_preseed_plugin.py | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/imagecraft/plugins/snap_preseed_plugin.py b/imagecraft/plugins/snap_preseed_plugin.py
index 19b67b32..2de23eb4 100644
--- a/imagecraft/plugins/snap_preseed_plugin.py
+++ b/imagecraft/plugins/snap_preseed_plugin.py
@@ -16,6 +16,7 @@
"""The snap-preseed plugin."""
+import shlex
from typing import Literal, cast
from craft_parts.plugins import Plugin, PluginProperties
@@ -80,10 +81,9 @@ def get_build_commands(self) -> list[str]:
f"--snap={self._resolve_snap(snap)}" for snap in options.snap_preseed_snaps
)
- cmd.append(
- f'"{options.snap_preseed_model_assert}" {self._part_info.part_install_dir}'
- )
- return [" ".join(cmd)]
+ cmd.append(options.snap_preseed_model_assert)
+ cmd.append(str(self._part_info.part_install_dir))
+ return [shlex.join(cmd)]
def _resolve_snap(self, snap: str) -> str:
snap = snap.strip()
diff --git a/tests/unit/plugins/test_snap_preseed_plugin.py b/tests/unit/plugins/test_snap_preseed_plugin.py
index ebfcfe58..93c98375 100644
--- a/tests/unit/plugins/test_snap_preseed_plugin.py
+++ b/tests/unit/plugins/test_snap_preseed_plugin.py
@@ -50,7 +50,7 @@ def test_get_build_commands(part_info, cmd_prefix):
assert (
plugin.get_build_commands()[0]
- == f'{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable "" {part_info.part_install_dir}'
+ == f"{cmd_prefix} --snap=core24 --snap=hello-world=latest/stable '' {part_info.part_install_dir}"
)
@@ -66,7 +66,7 @@ def test_get_build_commands_with_model_assertion(part_info, cmd_prefix):
assert (
plugin.get_build_commands()[0]
- == f'{cmd_prefix} --snap=core24 "model.assert" {part_info.part_install_dir}'
+ == f"{cmd_prefix} --snap=core24 model.assert {part_info.part_install_dir}"
)
@@ -82,7 +82,7 @@ def test_get_build_commands_with_assertions(part_info, cmd_prefix):
assert (
plugin.get_build_commands()[0]
- == f'{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 "" {part_info.part_install_dir}'
+ == f"{cmd_prefix} --assert=system-user.assert --assert=account.assert --snap=core24 '' {part_info.part_install_dir}"
)
@@ -98,5 +98,5 @@ def test_get_build_commands_with_revisions(part_info, cmd_prefix):
assert (
plugin.get_build_commands()[0]
- == f'{cmd_prefix} --revisions=./revisions.txt --snap=core24 "" {part_info.part_install_dir}'
+ == f"{cmd_prefix} --revisions=./revisions.txt --snap=core24 '' {part_info.part_install_dir}"
)