diff --git a/imagecraft/models/volume.py b/imagecraft/models/volume.py
index bb4ed3c0..80bdf7a3 100644
--- a/imagecraft/models/volume.py
+++ b/imagecraft/models/volume.py
@@ -161,6 +161,9 @@ 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."""
+
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/_utils.py b/imagecraft/plugins/_utils.py
new file mode 100644
index 00000000..24dd3fe7
--- /dev/null
+++ b/imagecraft/plugins/_utils.py
@@ -0,0 +1,60 @@
+# 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."""
+
+from craft_application.models.constraints import PROJECT_NAME_COMPILED_REGEX
+
+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
+
+ parts = snap.split("/")
+
+ name = parts[0]
+ 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 (
+ 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}")
+
+ return snaps
+
+
+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
new file mode 100644
index 00000000..4d0faafb
--- /dev/null
+++ b/imagecraft/plugins/snap_preseed_plugin.py
@@ -0,0 +1,105 @@
+# 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."""
+
+import shlex
+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, validate_snap_refs
+
+
+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
+ snap_preseed_write_revisions: str | bool = False
+
+ @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'."""
+
+ 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}")
+
+ if 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
+ )
+
+ cmd.extend(
+ f"--snap={resolve_snap(snap)}" for snap in options.snap_preseed_snaps
+ )
+
+ 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
new file mode 100644
index 00000000..310daa0c
--- /dev/null
+++ b/imagecraft/plugins/uc_prepare_plugin.py
@@ -0,0 +1,144 @@
+# 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
+
+from craft_parts.plugins import Plugin, PluginProperties
+from pydantic import field_validator, model_validator
+from typing_extensions import Self, override
+
+from ._utils import resolve_snap, validate_snap_refs
+
+
+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_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
+ 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 requires uc-prepare-preseed to be set"
+ )
+ 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 requires uc-prepare-preseed to be set"
+ )
+ 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'."""
+
+ 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."""
+ 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()
+
+ @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}")
+
+ if 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
+ )
+
+ 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}"
+ )
+
+ return [" ".join(cmd)]
diff --git a/tests/spread/plugins/snap-preseed/imagecraft.yaml b/tests/spread/plugins/snap-preseed/imagecraft.yaml
new file mode 100644
index 00000000..9b83d6ca
--- /dev/null
+++ b/tests/spread/plugins/snap-preseed/imagecraft.yaml
@@ -0,0 +1,35 @@
+name: snap-preseed-test
+version: "0.1"
+summary: Test snap-preseed plugin
+description: Test snap-preseed plugin
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ armhf:
+ build-on: [amd64]
+ build-for: [armhf]
+
+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: snap-preseed
+ snap-preseed-snaps:
+ - hello-world/latest/stable
+ organize:
+ "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..558161d8
--- /dev/null
+++ b/tests/spread/plugins/snap-preseed/task.yaml
@@ -0,0 +1,19 @@
+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
+ test -f 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: |
+ rm -rf disk.img core-snap
+ imagecraft clean --destructive-mode
diff --git a/tests/spread/plugins/uc-prepare/imagecraft.yaml b/tests/spread/plugins/uc-prepare/imagecraft.yaml
new file mode 100644
index 00000000..ec0c61f9
--- /dev/null
+++ b/tests/spread/plugins/uc-prepare/imagecraft.yaml
@@ -0,0 +1,37 @@
+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)/
+ prime:
+ - -kernel
+ - -gadget
+ - -resolved-content
+ - -system-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-prepare/model.assert b/tests/spread/plugins/uc-prepare/model.assert
new file mode 100755
index 00000000..411b8f85
--- /dev/null
+++ b/tests/spread/plugins/uc-prepare/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==
diff --git a/tests/spread/plugins/uc-prepare/task.yaml b/tests/spread/plugins/uc-prepare/task.yaml
new file mode 100644
index 00000000..9b71f5a4
--- /dev/null
+++ b/tests/spread/plugins/uc-prepare/task.yaml
@@ -0,0 +1,17 @@
+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: |
+ rm -rf disk.img
+ imagecraft clean --destructive-mode
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",
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..eedc57f0
--- /dev/null
+++ b/tests/unit/plugins/test_snap_preseed_plugin.py
@@ -0,0 +1,175 @@
+# 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_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"]}
+ )
+
+ 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}"
+ )
+
+
+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(
+ {
+ "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}"
+ )
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..f5bab9ee
--- /dev/null
+++ b/tests/unit/plugins/test_uc_prepare_plugin.py
@@ -0,0 +1,286 @@
+# 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_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(
+ {
+ "uc-prepare-model-assert": "model.assert",
+ "uc-prepare-preseed-sign-key": "sign-key",
+ }
+ )
+
+
+def test_sysfs_overlay_without_preseed():
+ with pytest.raises(ValueError, match="requires uc-prepare-preseed to be set"):
+ 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"}
+ )
+
+ 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/security/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}"
+ )
+
+
+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(
+ {
+ "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}"
+ )
+
+
+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}"
+ )
diff --git a/tests/unit/plugins/test_utils.py b/tests/unit/plugins/test_utils.py
new file mode 100644
index 00000000..6505ffbf
--- /dev/null
+++ b/tests/unit/plugins/test_utils.py
@@ -0,0 +1,62 @@
+# 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, validate_snap_refs
+
+
+@pytest.mark.parametrize(
+ ("snap", "expected"),
+ [
+ ("core24", "core24"),
+ (" core24 ", "core24"),
+ ("./my-snap_1.0_amd64.snap", "./my-snap_1.0_amd64.snap"),
+ ("hello-world/latest/stable", "hello-world=latest/stable"),
+ ("core24/stable", "core24=stable"),
+ (" hello-world/latest/stable ", "hello-world=latest/stable"),
+ ],
+)
+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",
+ "this-is-too-long-123456789234567891234567",
+ "hello-world/track/stable/branch/notallowed",
+ ],
+)
+def test_validate_snap_ref_invalid(invalid_ref):
+ with pytest.raises(ValueError, match="Invalid snap reference"):
+ validate_snap_refs([invalid_ref])