Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
306e116
feat: add classic preseed plugin
smethnani Mar 17, 2026
6e4c0f2
feat: add uc preseed plugin
smethnani Mar 19, 2026
45b35c6
chore(merge): 'main' into work/classic-snap-prepare-image-plugin
smethnani Apr 17, 2026
44d37bb
refactor: update plugins + add spread tasks
smethnani Apr 17, 2026
eb11511
test: add unit tests
smethnani Apr 20, 2026
ae49010
fix: linting
smethnani Apr 20, 2026
dcc030b
fix: add model validators
smethnani Apr 20, 2026
683a3b0
fix: add build-package for cross-building
smethnani Apr 20, 2026
83a8c7b
fix: condition build-package on preseed key
smethnani Apr 20, 2026
6300f79
fix: update test_volume.py
smethnani Apr 20, 2026
72b08d9
test: filter out gadget, kernel + resolved-content directories
smethnani Apr 20, 2026
86a6b02
test: fix filter
smethnani Apr 20, 2026
aa1ce0e
chore(merge): 'main' into work/classic-snap-prepare-image-plugin
smethnani Apr 21, 2026
4c517a0
fix: pr feedback
smethnani Apr 21, 2026
86190fa
fix: extract shared helper fn (#324)
Copilot Apr 22, 2026
60e09f8
test: add unit tests for snap-preseed-channel and snap-preseed-valida…
Copilot Apr 22, 2026
cbd661f
fix: pr feedback
smethnani Apr 22, 2026
71f47d0
fix: validation message
smethnani Apr 22, 2026
efaa248
test: fix asserts
smethnani Apr 22, 2026
07df7d2
test: add channel and validation option tests
smethnani Apr 22, 2026
7841aa6
test: add uc-prepare tests for assertions and snaps options
smethnani Apr 22, 2026
565781a
fix: add build-for
smethnani Apr 22, 2026
5227330
test: check arch of installed core snap
smethnani Apr 22, 2026
d05c876
fix: add snap ref validator
smethnani Apr 22, 2026
51af373
chore(merge): 'main' into work/classic-snap-prepare-image-plugin
smethnani Apr 23, 2026
e8f1268
fix: add max length contraint
smethnani Apr 23, 2026
e1dfa3b
test: extract snap.yaml file
smethnani Apr 23, 2026
081c72e
fix: add --write-revisions key
smethnani Apr 23, 2026
c1565d0
fix: add max / in channel constraint
smethnani Apr 23, 2026
0daf2bd
chore(merge): 'main' into work/classic-snap-prepare-image-plugin
smethnani Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions imagecraft/models/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Comment thread
lengau marked this conversation as resolved.


class StructureItem(CraftBaseModel):
"""Structure item of the image."""
Expand Down
8 changes: 7 additions & 1 deletion imagecraft/plugins/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,20 @@
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]:
"""Get Imagecraft-specific craft-parts plugins.

:returns: A dict mapping plugin names to plugins
"""
return {"mmdebstrap": MmdebstrapPlugin}
return {
"mmdebstrap": MmdebstrapPlugin,
"snap-preseed": SnapPreseedPlugin,
"uc-prepare": UcPreparePlugin,
}


def setup_plugins() -> None:
Expand Down
60 changes: 60 additions & 0 deletions imagecraft/plugins/_utils.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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
105 changes: 105 additions & 0 deletions imagecraft/plugins/snap_preseed_plugin.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""The snap-preseed plugin."""

import shlex
from typing import Literal, cast

from craft_parts.plugins import Plugin, PluginProperties
Comment thread
lengau marked this conversation as resolved.
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]
Comment thread
lengau marked this conversation as resolved.
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)

Comment thread
smethnani marked this conversation as resolved.

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)]
144 changes: 144 additions & 0 deletions imagecraft/plugins/uc_prepare_plugin.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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
Comment thread
smethnani marked this conversation as resolved.

@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
Comment thread
smethnani marked this conversation as resolved.

@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()
Comment thread
smethnani marked this conversation as resolved.

@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}"
)
Comment thread
smethnani marked this conversation as resolved.

return [" ".join(cmd)]
35 changes: 35 additions & 0 deletions tests/spread/plugins/snap-preseed/imagecraft.yaml
Original file line number Diff line number Diff line change
@@ -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/
19 changes: 19 additions & 0 deletions tests/spread/plugins/snap-preseed/task.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
lengau marked this conversation as resolved.
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
Loading
Loading