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