Image Template Reference#
Templates are YAML files that define what goes into a custom OS image, the target platform, packages, disk layout, users, and build-time customizations. This document is the authoritative field-by-field reference for the template format.
For a conceptual overview of how templates fit into the build pipeline, see Understanding the Build Process.
Table of Contents#
What Are Templates and How Do They Work?#
Templates are predefined build specifications that serve as a foundation for building operating system images. Here’s what templates empower you to do:
Create standardized baseline configurations.
Impose consistency across multiple images.
Reduce duplication of effort.
Share and reuse common configurations with your team.
The ICT provides default image templates on a per-distribution basis and image type (RAW vs. ISO) that can be used directly to build an operating system from those defaults. You can override these default templates by providing your own template and configure or override the settings and values you want. The tool will internally merge the two to create the final template used for image composition.
How Templates Work#
ICT ships default templates for each distribution and image type (raw, ISO, initrd). When you provide a user template, the tool merges it with the matching default; your values override or extend the defaults. The merged result is validated against a JSON schema before the build begins.
Default templates live at:
config/osv/<target.os>/<target.dist>/imageconfigs/defaultconfigs/default-<imageType>-<arch>.yml
Note:
imageType: imgmaps todefault-initrd-<arch>.yml(there is nodefault-img-filename).
You do not need to edit the defaults. You can start from one of the examples in
image-templates/ and override only what you need.
Quick Start Example#
A minimal user template only needs image, target, and optionally
systemConfig with extra packages:
image:
name: my-edge-device
version: "1.0.0"
target:
os: edge-microvisor-toolkit
dist: emt3
arch: x86_64
imageType: raw
systemConfig:
name: edge
packages:
- cloud-init
- rsyslog
Everything else (disk layout, bootloader, kernel, default packages) comes from
the default template for emt3 / raw / x86_64.
Top-Level Structure#
A template file has up to five top-level sections plus an optional metadata
block:
metadata: # Optional - AI-searchable discovery metadata
...
image: # Required - image name and version
...
target: # Required - OS, distribution, architecture, image type
...
baseline: # Optional - "create" (default) or "overlay" an existing image
...
overlayPolicy: # Optional - overlay-mode policy (only with baseline.mode: overlay)
...
disk: # Optional - disk layout, partitions, output artifacts
...
packageRepositories: # Optional - additional package repositories
- ...
systemConfig: # Required in merged template - packages, kernel, users, etc.
...
Note: User templates require only
imageandtarget. The remaining sections are merged from the default template if omitted.
Field Reference#
metadata#
Optional block for AI-powered template discovery. Ignored by the build engine.
Field |
Type |
Description |
|---|---|---|
|
string |
Human-readable description of the template |
|
string[] |
Use cases this template targets |
|
string[] |
Keywords for search and discovery |
metadata:
description: "Edge device image with container runtime"
use_cases: ["edge computing", "IoT gateway"]
keywords: [edge, docker, emt3]
image (required)#
Image identification. Both fields are required.
Field |
Type |
Required |
Validation |
Description |
|---|---|---|---|---|
|
string |
Yes |
|
Image name (alphanumeric, hyphens, underscores) |
|
string |
Yes |
Semver-like: |
Version string |
image:
name: my-edge-device
version: "1.0.0"
target (required)#
Target platform. All four fields are required.
Field |
Type |
Required |
Valid Values |
Description |
|---|---|---|---|---|
|
string |
Yes |
|
Target operating system |
|
string |
Yes |
See OS constraints below |
Distribution identifier |
|
string |
Yes |
|
Target CPU architecture |
|
string |
Yes |
|
Output image format |
OS → dist constraints:
OS |
Valid |
|---|---|
|
|
|
|
|
|
|
|
|
Any (e.g., |
target:
os: ubuntu
dist: ubuntu24
arch: x86_64
imageType: raw
WSL-Compatible Images#
Set target.imageType: wsl2 to compose a WSL-compatible root filesystem.
target:
os: ubuntu
dist: ubuntu24
arch: x86_64
imageType: wsl2
For a complete Ubuntu 24 WSL example, see
image-templates/ubuntu24/ubuntu24-x86_64-agentic-wsl2.yml.
baseline#
Selects how the image is assembled. If omitted, the build defaults to create
mode (build the image from scratch, the behavior described everywhere else in
this reference). Set mode: overlay to instead layer packages onto an existing
baseline disk image without rebuilding it. The baseline may be a RAW image or a
qcow2/vhd/vhdx image, which is converted to RAW before the overlay runs.
Field |
Type |
Required |
Valid Values |
Description |
|---|---|---|---|---|
|
string |
No |
|
Assembly mode |
|
object |
Yes when |
— |
The baseline image to overlay |
baseline.source#
Identifies the baseline image. Exactly one of path or url must be set.
The source is copied into the build workspace first and is never modified in
place.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
one of |
Local filesystem path to the baseline image (no URI scheme) |
|
string |
one of |
|
|
string |
No |
Baseline image format: |
|
string |
No |
Local filesystem path (no URI scheme) to an externally-supplied SPDX SBOM (JSON) describing the baseline image. Defaults to unset. See SBOM generation below |
baseline:
mode: overlay
source:
path: /path/to/ubuntu-24.04-base.img
format: raw
# Optional: combine this base-image SBOM with the overlay delta to emit a
# full SBOM. Omit it to use the SBOM embedded in the baseline image, if any.
sbomPath: /path/to/ubuntu-24.04-base.spdx.json
SBOM generation (overlay mode)#
An overlay build emits SPDX SBOM sidecar files into the build output directory,
alongside the emitted image (<image-name>-<version>.raw):
Artifact |
Name |
Contents |
|---|---|---|
Delta SBOM |
|
Always emitted. Only the overlay-induced package changes — the packages the overlay added and, in |
Complete SBOM |
|
Emitted only when a base SBOM is available (see below). The full final image inventory — the baseline packages plus the overlay result. When no base SBOM exists the complete document would be identical to the delta, so this sidecar is skipped. |
Both are SPDX 2.3 JSON, the same format the standalone SBOM/inspect tooling uses.
The complete SBOM is also embedded inside the image at /usr/share/sbom
(replacing the SBOM the image inherited from the baseline, so the in-image
manifest reflects the full final inventory).
The complete SBOM is the union of a base SBOM and the overlay delta. Which base is used follows this order:
baseline.source.sbomPathset and valid — that externally-supplied base SBOM is combined with the delta.sbomPathunset — the SBOM the baseline image itself carries at/usr/share/sbomis used as the base and combined with the delta.No base SBOM available —
sbomPathis unset or the file is absent/unreadable/malformed and the baseline image embeds no SBOM — then there is no full inventory to build, so the complete sidecar is skipped (it would be identical to the delta) while the in-image/usr/share/sbommanifest and the delta sidecar are still written. This is never an error: the build succeeds.
A missing or malformed sbomPath falls back to the baseline-embedded SBOM (then
to delta-only); it does not fail the build. Omitting sbomPath entirely
preserves the pre-existing base-resolution behavior. This applies to every
overlay-capable provider (Ubuntu and Debian).
To diff these artifacts against the baseline — the overlay RAW vs the baseline RAW, or the complete SBOM vs the baseline SBOM — see Comparing Overlay Outputs.
Note: Overlay mode is currently wired end-to-end for the Ubuntu and Debian providers. Targeting a provider without overlay support fails the build immediately with a clear message rather than silently falling back to a create-mode build. The overlay build is additive by default: packages (and their transitive dependencies not already present in the baseline) are installed into the baseline root, the initramfs is regenerated for the added packages, the template’s
systemConfig.configurationscommands andsystemConfig.additionalFilesare applied, and a grow-only resize — unconditionally expanding todisk.sizefirst, then auto-sizing further from the resolved packages’ installed-size metadata, capped atdisk.maxSizewhen set — can enlarge the image to fit them. Existing baseline packages are not removed unlessoverlayPolicy.allowPackageRemovalis enabled, which permits a conflict-driven removal of a baseline package a to-install package conflicts with. The installed bootloader binary and the ESP are never modified regardless of policy. The bootable kernel is likewise immutable unlessoverlayPolicy.replaceKernelis set, which swaps the kernel by installing a new one and removing the baseline kernel family (the GRUB config on the writable root is regenerated to boot it; the bootloader binary and ESP still stay untouched).OS defaults do not apply. Unlike a create-mode build, an overlay template is not merged with the target’s create-mode OS default configuration (
default-raw-*.yml). Those defaults describe how to build an image from scratch — disk size and partition table, bootloader, kernel, and the base OS package set — all of which the baseline image already provides. The overlay pipeline reads disk/bootloader/kernel geometry from the detected baseline, not from the template, so the effective overlay template is exactly what you declare (folded through anyextends:chain) with nothing inherited from the OS default. In particular, an overlay template that omitsdisk.sizedoes not pick up the default’sdisk.size. Resize always expands todisk.sizefirst (when it is set and larger than the baseline) with no separate opt-in, then auto-sizes further from the resolved packages’ installed-size needs — but only whendisk.maxSizeis also set; without it, growth does not extend beyonddisk.size(or the raw baseline, ifdisk.sizeis also unset); see the Sizing note below.Unsupported systemConfig sections. Because overlay mode does not re-run the boot/system-provisioning stages, the following
systemConfigsections cannot be applied to an overlay build:hostname,network,initramfs,kernel,immutability,fde, andbootloader. Previously these were silently ignored; now setting any of them in an overlay template fails the build up front with a message naming every offending section. Configure them in the baseline image (acreate-mode build) instead. The overlay-supportedsystemConfiginputs arepackages,users,configurations, andadditionalFiles.Users in overlay.
systemConfig.usersis provisioned onto the baseline. A requested user that already exists in the baseline image fails the build up front (an overlay cannot redefine a baseline account); the check is re-run immediately before creation, so a name that a package’s maintainer script adds during install also fails rather than being silently modified. A user’sstartupScriptmust reference a path already present when users are created — i.e. shipped by the baseline or installed by an overlaypackagesentry — not a file delivered viaadditionalFiles, which are copied later in the overlay pipeline.
home,shell,passwordMaxAge, andsshAuthorizedKeysare applied in overlay builds exactly as in create mode (seesystemConfig.users[]), so a service account can be pinned to/usr/sbin/nologin.Sizing: Growing the image needs no opt-in —
disk.size/disk.maxSizeare themselves the explicit consent. Whendisk.sizeis set and larger than the baseline, the resize unconditionally expands to it first (adisk.sizeat or below the baseline is already satisfied and is a no-op, never an error). Package-driven growth beyond that floor requiresdisk.maxSizeto be set: without it, growth does not extend pastdisk.size(or the raw baseline, ifdisk.sizeis also unset), regardless of how much room the packages being installed need. Whendisk.maxSizeis set, the tool auto-sizes the further growth: it estimates the space the packages need — summing each package’s reported installed size (DebianInstalled-Size, RPM<size installed=…>) with a conservative overhead factor and margin — measures the baseline’s real free space (crediting the headroom thedisk.sizeexpand itself adds), and grows the image only by the shortfall (rounded up), capped atdisk.maxSize.disk.maxSizemust always be greater thandisk.size, and requiresdisk.sizeto also be set — both are rejected up front as an invalid configuration otherwise. A configureddisk.maxSizesmaller than the resultingdisk.sizefloor is likewise rejected up front as an impossible constraint. If the computed need exceedsdisk.maxSize, the tool caps at it and logs a warning (the install may then hit “no space left on device”) — the same outcome as whendisk.maxSizeis unset entirely and the packages need more than the floor provides.If no package reports a size (missing metadata), the tool falls back to
disk.maxSize(or, with no ceiling, stops at thedisk.sizefloor). Resize is grow-only and never shrinks the image.Partial metadata is not treated as complete. When some (but not all) resolved packages report a size, summing only the known ones would understate the real need. The tool does not auto-size from that partial estimate: with
disk.maxSizeset, it grows straight to that ceiling instead (skipping the free-space measurement) and logs a warning that the estimate was incomplete; withdisk.maxSizeunset, the build fails up front with an actionable error asking you to set one. The measured-shortfall auto-sizing described above, capped atdisk.maxSize, only applies when every resolved package reports a size (or none does) — and, as always, only whendisk.maxSizeis set at all.Resize constraints. The grow-only resize extends the last partition on the disk and its filesystem in place. It is rejected (before any disk mutation, with an actionable error) when the root is not the last partition, when the root sits on LVM, and when the root is LUKS-encrypted or dm-verity protected.
ext4/ext3/ext2roots are the supported and CI-covered target;xfsroots use the same code path (xfs_growfs) but are best-effort: the grow sequence has unit-test coverage, but no shipping baseline exercises it against a real xfs filesystem in CI/e2e, so treat xfs resize as unverified end-to-end. The resize shells out togrowpart(cloud-guest-utils),sgdisk(gdisk, GPT only),resize2fs(e2fsprogs) orxfs_growfs(xfsprogs), andlosetup/partx(util-linux); these must be present on the build host, and the build fails early with a clear message if any is missing. Resize also reads partition start sectors vialsblk -o PATH,START,TYPE, which requires util-linux >= 2.38; Ubuntu 22.04’s stocklsblk/resize2fsare too old and not upgradable viaapt— see the util-linux/e2fsprogs build-from-source instructions.Output formats. Overlay honors
disk.artifactsexactly as a create-mode build does: every supported output format (raw,qcow2,vhd,vhdx,vmdk,vdi,tar) and compression build mode is available. The overlay always produces a RAW image internally and then converts it to the requested formats after the post-build inspection; a template whosedisk.artifactsomitsrawdeletes the intermediate RAW during conversion. Whendisk.artifactsis empty or lists onlyraw, a plain RAW image is emitted and no conversion runs.
overlayPolicy#
Optional policy controls for overlay-mode preflight and install. It is a
top-level peer of baseline and may only be set when baseline.mode is
overlay. If omitted, the defaults below apply.
Field |
Type |
Required |
Valid Values |
Description |
|---|---|---|---|---|
|
string |
No |
|
Permitted package operations. |
|
string |
No |
|
How a package conflict detected during preflight is handled. |
|
string |
No |
— |
Optional kernel command-line override applied to the overlaid image (full-line replacement of |
|
string |
No |
— |
Optional |
|
boolean |
No |
|
Permit removing a baseline package that a to-install package conflicts with (e.g. installing |
|
object |
No |
|
Replace the baseline kernel: install the named kernel package (plus any |
additive-and-upgradescope. Upgrades apply only to the package set: a package already installed in the baseline may be replaced by a newer version when the resolved overlay closure requires it. Downgrades are still rejected at preflight. The bootloader binary remains immutable in all cases, and the kernel is immutable unless you explicitly opt into a swap withreplaceKernel(see below) — an overlay never upgrades a kernel image in place or reinstalls the bootloader, regardless ofpackageOperation. Chooseadditive-only(the default) to fail the build on any version bump to a baseline package.
Package removal (
allowPackageRemoval). Opt-in, and permitted only underpackageOperation: additive-and-upgrade— removal is more invasive than an in-place upgrade, so pairing it with the defaultadditive-onlyis rejected at validation. By default an overlay never removes a baseline package: a to-install package thatConflicts:/Breaks:a present baseline package fails the build. SetallowPackageRemoval: true(withadditive-and-upgrade) to let the overlay remove the conflicting baseline package before installing its replacement — the case that makesdracut(which conflicts withinitramfs-tools) installable on a stock baseline. Bootloader and bootable-kernel packages are still never removed, so the flag cannot break the boot path.Cascade removal of orphaned reverse-dependencies. A conflict-driven removal can leave an unrelated baseline package that only
Depends:on the removed one with an unmet dependency (for example, the Debian cloud image’scloud-initramfs-growrootdepends oninitramfs-tools). WhenallowPackageRemovalis enabled, the post-install dependency audit turns into a bounded cascade: each baseline package that is broken after a removal but was whole before it is itself removed, transitively, until the package manager’s own check (apt-get check/dnf check) reports the dependency tree is whole again. A dependency that an alternative still satisfies is never treated as broken, so nothing is over-removed. The cascade still fails closed: if resolving the breakage would require removing a bootloader/kernel-image package or a package the overlay is installing, the build fails instead. Cascade removals are folded into the package statistics and the complete SBOM so the final inventory is accurate. This behavior is entirely gated byallowPackageRemoval; when it isfalse, a removal that would orphan another package fails the build as before.
baseline:
mode: overlay
source:
path: /path/to/ubuntu-24.04-base.img
overlayPolicy:
# Removal requires additive-and-upgrade (not the default additive-only).
packageOperation: additive-and-upgrade
conflictPolicy: fail
# Opt in to removing a baseline package a new package conflicts with
# (e.g. remove initramfs-tools so dracut can install). Default: false.
allowPackageRemoval: true
A complete additive-only starter template (the default policy, without package
removal) lives at
image-templates/ubuntu24/ubuntu24-x86_64-overlay-raw.yml;
to enable removal, add the overlayPolicy block shown above (additive-and-upgrade
plus allowPackageRemoval: true) to it.
Kernel replacement (
replaceKernel). By default the baseline kernel is immutable: an overlay may add a new kernel alongside the existing one (a version-qualifiedlinux-image-*package is an addition, and GRUB gains a menu entry for it), but it never upgrades or removes an installed kernel image. SetoverlayPolicy.replaceKernel.package: <kernel-package>to perform a full swap instead:
the named replacement kernel is resolved from the configured repositories and installed (like any other overlay package). The
packagevalue accepts the same glob wildcards as an ordinarysystemConfigpackage (*,?,[...]), so you can writelinux-image-*-oeminstead of pinning an exact version. The pattern must resolve to exactly one bootable kernel image: a value that matches no kernel (a typo, or a userspace-only package) or several kernels (an over-broad glob) fails preflight, which lists what it matched so you can narrow it;any
additionalPackages(e.g. the matchinglinux-headers-*package) are resolved and installed alongsidepackage, following the same rules aspackageitself;the baseline kernel family — the bootable image plus its meta-package, modules, and headers (e.g.
linux-image-6.8.0-40-generic,linux-image-generic,linux-modules-*,linux-headers-*) — is auto-detected and removed, so the emitted image ships only the new kernel and whateveradditionalPackagesyou named;the GRUB config is regenerated so the removed kernel’s menu entry is dropped and
GRUB_DEFAULTpoints at the new kernel (auto-pinned to"0"when you do not setgrubDefaultexplicitly).
enableExtraModules(space-separated driver modules, e.g."intel_vpu uas") mirrorssystemConfig.kernel.enableExtraModulesbut is scoped to the replacement kernel: the named modules are forced into its regenerated initramfs (dracut --add-drivers, or an/etc/initramfs-tools/modulesentry on aninitramfs-toolsbaseline).versionis descriptive only — it is never used to resolve or install packages, only surfaced for reporting (e.g. the compose summary).Only the GRUB config on the writable root is updated — the ESP and the bootloader binary are never touched (no
grub-install), matching the overlay read-only-ESP contract. On a Secure Boot baseline the newly installed kernel may be unsigned; overlay does not re-sign it, so sign it out of band if the image must boot under Secure Boot.replaceKernelrequirespackageOperation: additive-and-upgradebut does not requireallowPackageRemoval— the two are orthogonal (allowPackageRemovalgoverns conflict-driven removal of non-kernel baseline packages). Because the swap self-authorizes only its own kernel-family removals, if it orphans an unrelated non-kernel baseline package (for example a DKMS module bound to the removed kernel’s modules/headers) the build fails closed unless you also enableallowPackageRemoval. AreplaceKernelset on a non-GRUB2 baseline (including a UKI baseline) is a hard error, raised at preflight before any package is installed or removed. Userspace kernel-adjacent packages (linux-libc-dev,linux-tools-common, rpmkernel-headers/kernel-devel) are not removed.
baseline:
mode: overlay
source:
path: /path/to/ubuntu-24.04-base.img
overlayPolicy:
# A kernel swap requires additive-and-upgrade (not the default additive-only).
packageOperation: additive-and-upgrade
conflictPolicy: fail
# Install this kernel and remove the baseline kernel family. GRUB_DEFAULT is
# auto-pinned to the new kernel unless grubDefault is set below.
replaceKernel:
package: linux-image-6.11.0-1004-oem
# Optional: further kernel-family packages to install alongside package.
additionalPackages:
- linux-headers-6.11.0-1004-oem
# Optional: driver modules forced into the replacement kernel's initramfs.
enableExtraModules: "intel_vpu uas"
# Optional: descriptive only, never used to resolve/install packages.
version: "6.11.0-1004-oem"
# Optional: the exact command line the new kernel boots with.
# kernelCmdline: "quiet splash"
# Optional: pin a specific GRUB entry instead of the auto "0" default.
# grubDefault: "Advanced options for Ubuntu>Ubuntu, with Linux 6.11.0-1004-oem"
A complete kernel-replacement example lives at
image-templates/ubuntu24/ubuntu24-x86_64-overlay-replace-kernel-raw.yml.
disk#
Disk layout, partition scheme, and output artifact formats. If omitted, the default template provides sensible values (typically 4–6 GiB GPT disk with EFI boot and ext4 root partitions).
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes (schema) |
Disk configuration name (e.g., |
|
string |
No |
Disk device path used by the live installer (e.g., |
|
string |
No |
Disk size. Accepts: |
|
string |
No |
Overlay-mode-only ceiling on further, package-driven growth beyond |
|
string |
No |
|
|
artifact[] |
No |
Output formats and optional compression |
|
partition[] |
No |
Partition layout definitions |
disk.artifacts[]#
Each entry defines one output format:
Field |
Type |
Required |
Valid Values |
Description |
|---|---|---|---|---|
|
string |
Yes |
|
Output image format |
|
string |
No |
|
Compression to apply |
disk.partitions[]#
Each entry defines one partition:
Field |
Type |
Description |
|---|---|---|
|
string |
Partition identifier (e.g., |
|
string |
Partition label |
|
string |
Partition type (e.g., |
|
string |
GPT type GUID (e.g., |
|
string |
Filesystem type: |
|
string |
Filesystem label |
|
string |
Non-zero offset from disk start, or a negative offset measured back from disk end |
|
string |
Offset using the same convention, or |
|
string |
Mount point (e.g., |
|
string |
Mount options (e.g., |
|
string[] |
Partition flags (e.g., |
Offsets are non-zero whole numbers with one of these suffixes: KiB, MiB, GiB, K, M, G, KB, MB, or GB.
Partitions may contain gaps, but they must be listed in disk order and must not overlap. Only the final partition may use
end: "0", so a layout cannot contain more than one rest-of-disk partition.
Sizing partitions for disks of unknown size. An unattended ISO installs
onto whatever disk the selection policy picks, so absolute offsets cannot make
root fill the disk when a fixed-size partition follows it. Use offsets measured
from the end of the disk instead: the partition before the fixed-size one ends
at -<size>, and the fixed-size one starts at the same -<size>. Both are
aligned down to 1 MiB. The installer’s disk-size check reserves the largest
negative offset, plus 1 MiB, in addition to the largest absolute offset, and the
installer checks the selected disk against that minimum before it wipes or
partitions anything, and that every partition, resolved against the selected
disk, starts before it ends and does not overlap the one before it.
partitions:
- id: rootfs # fills the disk except the last 20 GiB
type: linux-root-amd64
start: 513MiB
end: "-20GiB"
fsType: ext4
mountPoint: /
- id: data # fixed 16 GiB
type: linux
start: "-20GiB"
end: "-4GiB"
fsType: ext4
mountPoint: /data
- id: swap # fixed 4 GiB at the end of the disk
type: linux-swap
start: "-4GiB"
end: "0"
fsType: linux-swap
Negative offsets are resolved after the target disk is selected. Mixed positive/negative overlap checks therefore occur
when its size is known, but before the disk is wiped. An explicit disk.path bypasses policy-based selection but not this
pre-wipe layout check. The same partitioning path serves raw builds and the ISO live installer. Offset resolution is
position-based and does not depend on partition IDs or roles.
Every partition with a mountPoint (and every swap partition) gets an
/etc/fstab entry keyed by PARTUUID, in template order. A non-swap partition
without a mountPoint (or with none) is created and formatted but not added
to fstab.
Example - raw disk with two partitions and two output formats:
disk:
name: Edge_Raw
size: 4GiB
partitionTableType: gpt
artifacts:
- type: raw
compression: gz
- type: vhdx
partitions:
- id: boot
type: esp
flags: [esp, boot]
start: 1MiB
end: 513MiB
fsType: fat32
mountPoint: /boot/efi
mountOptions: umask=0077
- id: rootfs
type: linux-root-amd64
start: 513MiB
end: "0"
fsType: ext4
mountPoint: /
mountOptions: defaults
packageRepositories#
Optional list of additional package repositories beyond the OS base repos.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes |
Repository identifier (e.g., |
|
string |
Yes |
Repository base URL (must be a valid URI) |
|
string |
Yes |
GPG key URL, absolute file path, or |
|
string |
No |
Repository component (e.g., |
|
int |
No |
Priority from |
|
string[] |
No |
Specific packages to include from this repo (package pinning) |
packageRepositories:
- codename: "company-internal"
url: "https://packages.example.com/repo"
pkey: "https://packages.example.com/gpg.key"
component: "main"
priority: 100
- codename: "dev-tools"
url: "https://dev.example.com/repo"
pkey: "[trusted=yes]"
For Debian-based (APT) repositories, the tool auto-detects whether the repo
publishes a classic split Release + detached Release.gpg pair or a
combined InRelease file (Release content with the signature embedded, as
published by aptly-based repositories that don’t also serve a separate
Release.gpg). No extra template configuration is needed for either case.
See Multiple Package Repository Support for detailed configuration guidance.
systemConfig#
System configuration - packages, kernel, users, bootloader, build-time commands, and more. Required in the final merged template, but optional in user templates (as defaults already provide a complete base).
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
No |
Configuration name |
|
string |
No |
Human-readable description |
|
string |
No |
System hostname (RFC 1123: letters, digits, inner hyphens, dot-separated) |
|
string[] |
No |
Packages to install (additive with defaults) |
|
object |
No |
Kernel configuration |
|
object |
No |
Bootloader configuration |
|
object |
No |
dm-verity / Secure Boot configuration |
|
user[] |
No |
User account definitions |
|
object |
No |
Initramfs config (ISO/initrd builds) |
|
file[] |
No |
Extra files to copy into the image |
|
cmd[] |
No |
Shell commands to run during build |
|
object |
No |
Proxy settings persisted into the deployed system |
|
object |
No |
Boot-time provisioning scripts |
|
object |
No |
Install and seed cloud-init |
|
object |
No |
Restrict which repositories may upgrade packages |
|
object |
No |
Compose OEP Installer profiles or modules into the image |
Package names must match: ^[A-Za-z0-9](?:[A-Za-z0-9+_.:~-]*[A-Za-z0-9+])?$
and must be unique within the list.
systemConfig.kernel#
Field |
Type |
Description |
|---|---|---|
|
string |
Kernel version (e.g., |
|
string |
Kernel boot command line |
|
string[] |
Kernel packages (e.g., |
|
string |
Additional kernel modules to load |
|
bool |
Enable Unified Kernel Image (typically set by defaults) |
systemConfig:
kernel:
version: "6.14"
cmdline: "console=ttyS0,115200 console=tty0 loglevel=7"
packages:
- linux-image-generic-hwe-24.04
# Optional additional repositories
packageRepositories:
- codename: emtNext
url: https://example.com/rpms/next/base
pkey: https://example.com/RPM-GPG-KEY
priority: 1001
allowPackages:
- kernel-6.17.11
- kernel-drivers-gpu-6.17.11
- libva*
- codename: edgeai
url: https://example2.com/edgeai/
pkey: https://example2.com/edgeai/GPG-PUB-KEY.gpg
priority: 500
systemConfig.bootloader#
Field |
Type |
Valid Values |
Description |
|---|---|---|---|
|
string |
|
Boot firmware type |
|
string |
|
Bootloader software |
|
string |
|
Live installer only: |
Typical defaults: raw images use efi / systemd-boot; ISO images use
efi / grub.
After an EFI install, the live installer creates a firmware boot entry labelled
ICT for the installed disk and sets BootOrder explicitly so the installed
disk boots first. With preserve, the existing BootOrder (other operating
systems, network boot, vendor tools) follows it unchanged; entries that were not
in BootOrder stay out of it. With exclusive, BootOrder contains only the
installed OS. No other boot entry is deleted, so every existing entry stays
selectable from the firmware menu; only entries labelled exactly ICT (left by
a previous install) are removed.
systemConfig.network#
Declarative network configuration for the installed OS. This is a minimal explicit-interface implementation.
Field |
Type |
Required |
Valid Values |
Description |
|---|---|---|---|---|
|
string |
Yes (when section present) |
|
Network configuration backend |
|
object[] |
No |
See below |
List of interface configurations |
interfaces[] fields:
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes |
Interface name (for example, |
|
bool |
No |
Enable DHCPv4 |
|
bool |
No |
Enable DHCPv6 |
|
string[] |
No |
Static addresses in CIDR format |
|
object[] |
No |
Static routes (see below) |
|
string[] |
No |
DNS server addresses |
routes[] fields:
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes |
Destination ( |
|
string |
Yes |
Gateway address |
Note: Interface names are explicit and user-provided. The current implementation does not auto-discover or auto-select NICs at install time.
When
backendissystemd-networkd, the builder enables thesystemd-networkdservice in the image. Whenbackendisnetplan,systemd-networkdis not forcibly enabled — netplan manages its own renderer.
systemConfig:
network:
backend: systemd-networkd
interfaces:
- name: enp1s0
dhcp4: true
- name: enp2s0
addresses:
- "10.0.0.100/24"
routes:
- to: default
via: "10.0.0.1"
nameservers:
- "8.8.8.8"
- "8.8.4.4"
systemConfig:
network:
backend: netplan
interfaces:
- name: enp1s0
dhcp4: true
- name: enp2s0
addresses:
- "192.168.1.10/24"
routes:
- to: default
via: "192.168.1.1"
nameservers:
- "1.1.1.1"
systemConfig.immutability#
Configures dm-verity immutable root filesystem and optional UEFI Secure Boot signing.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
Yes (when section present) |
Enable dm-verity immutable root |
|
string |
Conditional |
Private key file ( |
|
string |
Conditional |
Certificate in PEM format ( |
|
string |
Conditional |
Certificate in DER format ( |
Note: If any Secure Boot field is provided, all three must be provided and
enabledmust betrue.
systemConfig:
immutability:
enabled: true
secureBootDBKey: /path/to/db.key
secureBootDBCrt: /path/to/db.crt
secureBootDBCer: /path/to/db.cer
systemConfig.fde#
Configures LUKS2 full-disk encryption for selected partitions.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
Yes |
Enable full-disk encryption |
|
string |
Yes (when |
Absolute or template-relative local file path containing passphrase material |
|
string[] |
No |
Disk partition IDs to encrypt (defaults to the root partition) |
|
string |
No |
Boot unlock mode: |
systemConfig:
fde:
enabled: true
passphraseFile: "/run/secrets/fde-passphrase.txt"
unlock: auto
partitions:
- rootfs
See Configure Full-Disk Encryption for a complete guide, including usage with dm-verity immutability.
systemConfig.dkms#
Builds and verifies vendor DKMS kernel modules against the image’s installed
target kernel during composition — always with an explicit -k <kernel-version>,
since a package’s own postinst/dkms.conf trigger would otherwise key off
uname -r, which inside the build chroot reports the build host’s kernel, not
the one just installed. ICT verifies both that every requested module reports
installed in dkms status, and that every module the source’s own dkms.conf
declares (BUILT_MODULE_NAME) actually compiled — a source can report installed
while one of its modules silently failed to build.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
Yes |
Whether ICT builds/verifies DKMS modules during composition |
|
string[] |
No |
Optional |
|
object |
No |
Sign built modules for UEFI Secure Boot; see below |
systemConfig.dkms.secureBoot
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
Yes (when section present) |
Sign built modules for UEFI Secure Boot. Requires |
|
string |
Conditional |
Private key ( |
|
string |
Conditional |
Certificate ( |
|
bool |
No |
Copy the signing key/cert into the image so a future on-target DKMS rebuild (e.g. after an HWE kernel update) can still produce Secure-Boot-trusted modules. Requires |
|
string |
Conditional |
In-image path the signing key is copied to. Required when |
|
string |
Conditional |
In-image path the signing certificate is copied to. Required when |
Note:
secureBoot.enabledrequiresdkms.enabled: true, andretainSigningIdentity: truerequiressecureBoot.enabled: true— both are rejected by schema and semantic validation otherwise. When retained, ICT also writesmok_signing_key/mok_certificateinto the image’s/etc/dkms/framework.conf, the standard mechanismdkmsitself consults to auto-sign future builds — not just inert key material.Per the EdgePack ADR, ICT does not attempt per-device key uniqueness: a shared signing key’s fleet-wide blast radius is treated as an OS-level file permission/authorization concern, not something this schema solves.
systemConfig:
dkms:
enabled: true
secureBoot:
enabled: true
signingKeyPath: /path/to/signing.key
signingCertPath: /path/to/signing.crt
retainSigningIdentity: true
targetKeyPath: /etc/dkms/keys/signing.key
targetCertPath: /etc/dkms/keys/signing.crt
Merge semantics: enabled and secureBoot.enabled/retainSigningIdentity
are full overwrites (the user value always wins, even to turn a default true
back to false); modules is a full replace when non-empty (mirrors
systemConfig.kernel.packages), not an append; the other secureBoot string
fields (signingKeyPath, signingCertPath, targetKeyPath, targetCertPath)
are only overwritten when the user supplies a non-empty value, otherwise the
default is kept.
systemConfig.users[]#
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes |
Username |
|
string |
No |
Password (plain text or pre-hashed with |
|
string |
No |
Hash algorithm: |
|
int |
No |
Max password age in days. |
|
string |
No |
Script to run on login |
|
string[] |
No |
Additional groups |
|
bool |
No |
Grant sudo permissions |
|
string |
No |
Custom home directory for a new account (absolute path). Not applied to an account that already exists in the image, such as |
|
string |
No |
Login shell (absolute path, default |
|
string[] |
No |
Public key lines ( |
|
string[] |
No |
Host files (absolute or template-relative) whose key lines are read at build time and added to |
A value in password that is already a crypt(3) hash ($6$…, $y$…, …) is
always set as-is, even without hash_algo. SSH keys are installed with the
modes sshd requires: ~/.ssh is 0700 and authorized_keys is 0600, both
owned by the user. Key-based login also needs openssh-server in
systemConfig.packages; validation warns when it is missing.
systemConfig:
users:
- name: admin
password: "$6$<salt>$<hash>" # from: openssl passwd -6
sudo: true
groups: [docker, wheel]
shell: /bin/bash
sshAuthorizedKeys:
- ssh-ed25519 AAAAC3Nza... admin@laptop
sshAuthorizedKeysFiles:
- keys/admin.pub
- name: service-account
shell: /usr/sbin/nologin
systemConfig.initramfs#
Used for ISO and initrd builds. Points to the initramfs configuration template.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes (when section present) |
Path to the initramfs config template file |
systemConfig.additionalFiles[]#
Copy host files into the image at build time.
Field |
Type |
Description |
|---|---|---|
|
string |
Source path on the host (absolute, or relative to template directory) |
|
string |
Destination path inside the image |
|
string |
Overlay-only. When to copy the file relative to initramfs/boot regeneration: |
systemConfig:
additionalFiles:
- local: files/dhcp.network
final: /etc/systemd/network/dhcp.network
- local: files/motd
final: /etc/motd
# A dracut module must be in place BEFORE the initramfs is (re)built, or the
# already-built initramfs would ignore it — mark it pre-initramfs.
- local: files/90-custom.conf
final: /usr/lib/dracut/dracut.conf.d/90-custom.conf
stage: pre-initramfs
Generated files (create mode): a build fails if an entry installs to a file that an enabled provisioning setting writes after the files are copied, since the payload would be silently replaced: the proxy’s apt and systemd files, the immutable-image apt policy files, the cloud-init datasource file, the key-only sudoers file, and the provisioning unit files and stamp directory.
/etc/environmentis exempt because the proxy settings are merged into it.
Overlay mode:
additionalFilesare honored in overlay builds, and each entry’s copy timing is controlled by itsstagemarker:
stage: ""(default) — copied as the last build step, after both initramfs and GRUB regeneration. This is the historical behavior, so existing templates are unaffected. It is deliberate: a prebuilt boot artifact (for example a custom/boot/initrd.img-*) dropped here survives, whereas a file placed before regeneration would be overwritten byupdate-initramfs.
stage: pre-initramfs— copied before boot/initramfs regeneration, so content the generator consumes (a dracut module under/usr/lib/dracut, an initramfs-tools hook under/etc/initramfs-tools/) is in place when the initramfs is rebuilt. Without this, such a file dropped at the end would be ignored by the already-built initramfs.A file only needs
pre-initramfswhen the initramfs build must see it; a file that must instead run a generator itself still belongs in asystemConfig.configurationscommand.
systemConfig.configurations[]#
Shell commands executed inside the chroot during the configuration stage.
Field |
Type |
Description |
|---|---|---|
|
string |
Shell command to execute |
systemConfig:
configurations:
- cmd: systemctl enable docker
- cmd: echo "BuildDate=$(date)" >> /etc/image-info
configurations run after the provisioning, cloudInit, and aptPolicy
sections are applied, so a command can still adjust their result. The proxy
section is written after configurations, so build-time commands such as
apt-get install use the build host’s network, not the deployment-site proxy.
systemConfig.proxy#
Proxy settings persisted into the deployed system. The build itself uses
the build host’s http_proxy, https_proxy, ftp_proxy, and no_proxy
environment; the unattended ISO installs offline from the package repository
bundled on the ISO, so this section only affects the running system.
Field |
Type |
Description |
|---|---|---|
|
string |
|
|
string |
|
|
string |
|
|
string |
|
A layer that extends another replaces an inherited proxy section as a whole. An empty proxy: {} clears the inherited proxy.
The values are written to:
/etc/environment— upper- and lower-case variables for login sessions; existing proxy lines are replaced, other lines (such asPATH) are kept/etc/apt/apt.conf.d/95ict-proxy—Acquire::{http,https,ftp}::Proxy; plain host names innoProxybecomeDIRECTentries for http, https, and (whenftpProxyis set) ftp; apt cannot express domain suffixes or CIDRs/etc/systemd/system.conf.d/90-ict-proxy.conf—DefaultEnvironment=for every systemd service, including cloud-init and the provisioning units
Proxy URLs must not contain credentials (http://user:password@proxy:3128 is
rejected) or a query string or fragment (http://proxy/?token=x): the values land in world-readable files such as /etc/environment
and in the template carried on the ISO. Use a proxy that authenticates the
host, or supply credentials at deployment time (for example from a
provisioning script or cloud-init).
systemConfig:
proxy:
httpProxy: http://proxy.example.com:3128
httpsProxy: http://proxy.example.com:3128
noProxy: localhost,127.0.0.1,.example.com
The CLI flags --http-proxy, --https-proxy, --ftp-proxy, and --no-proxy
override these fields.
systemConfig.provisioning#
User scripts copied into the image and run by generated systemd units at boot.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
bool |
No |
Run the scripts before the login prompt: console and SSH logins are unavailable until they finish, except for root. Default |
|
string |
Yes |
Identifier ( |
|
string |
Yes |
Script on the host (absolute, or relative to the template) |
|
string |
Yes |
Absolute in-image file path of letters, digits, and |
|
string |
No |
|
|
int |
No |
Execution order, lower first; ties keep template order |
|
list of string |
No |
Command-line arguments passed to the script. Each is 1-256 characters of letters, digits, and |
|
map of string |
No |
Environment variables set for the script. Names match |
|
int |
No |
Fail the script’s unit if it runs longer than this many seconds (systemd |
|
bool |
No |
Reboot once the script has succeeded and its stamp is written ( |
Each script gets ict-provision-<NNN>-<name>.service (NNN is its position in
execution order), started at boot by ict-provision-start.service. Units run after
network-online.target and, when cloud-init is installed, after
cloud-final.service, so a script can rely on the packages, users, and files
cloud-init applies. Each unit is also after and requires the previous one, so a
failed script stops the scripts after it. Output goes to the journal and console. A first-boot unit records success in
/var/lib/image-composer-tool/provisioned/<name>; a failed script leaves no
stamp and is retried on the next boot.
systemConfig:
provisioning:
scripts:
- name: platform-bootstrap
local: files/bootstrap.sh
final: /usr/local/sbin/bootstrap.sh
order: 10
- name: health-report
local: files/health.sh
final: /usr/local/sbin/health.sh
stage: every-boot
order: 20
env:
REPORT_LEVEL: summary
Do not put secrets in args or env. They are written into the generated unit
file, which is world-readable inside the image, and args are also visible in
the process list. The composition manifest records args as written and only
the names of the env variables, never their values.
With beforeLogin: true the units are ordered before systemd-user-sessions.service
instead of after cloud-final.service, so a script cannot rely on what cloud-init
applies in its final stage (packages, runcmd); cloud-config.service still runs
first. systemd shows “A start job is running for ICT provisioning script …” on the
console. A script whose timeoutSec is unset or 0 is limited to 3600 s so a hung
script cannot lock the system out (a script cannot opt out of the limit in this mode); a failed or timed-out script releases the login and, if
first-boot, is retried on the next boot. A layer that sets beforeLogin replaces
the inherited value, so beforeLogin: false turns an inherited true off.
The login block is /run/nologin, which pam_nologin and sshd do not apply to root,
so root can still log in on the console, or over SSH where root login is permitted,
while the scripts run. With rebootAfter such a session is cut off by the reboot.
Scripts from every layer of an extends chain accumulate, and a script that
reuses an inherited name replaces it as a whole, including its args and env,
so a child layer that overrides a script restates them. The other sections are replaced by the
most specific layer that sets them.
systemConfig.cloudInit#
Installs cloud-init and seeds it through the NoCloud datasource, which reads
/var/lib/cloud/seed/nocloud/ — no network metadata service or attached
media is needed.
Field |
Type |
Description |
|---|---|---|
|
bool |
Install and configure cloud-init (adds the |
|
string |
Host path of the user-data; must start with |
|
string |
Host path of the meta-data. When omitted, a first-boot unit generates meta-data with a unique |
|
string |
Host path of a network-config file |
|
string[] |
Host paths copied into |
The datasource is pinned with /etc/cloud/cloud.cfg.d/90_ict_datasource.cfg
(datasource_list: [ NoCloud, None ]), so first boot does not wait for cloud
metadata services. Setting both networkConfigFile and systemConfig.network
produces a validation warning, because the two may conflict.
systemConfig:
cloudInit:
enabled: true
userDataFile: files/user-data
systemConfig.aptPolicy#
Restricts which of the template’s packageRepositories may upgrade installed
packages on the deployed system (ubuntu and debian targets).
Field |
Type |
Description |
|---|---|---|
|
string[] |
|
The distribution archives (noble, noble-updates, noble-security, …) are
not restricted by this section, so the deployed system keeps receiving OS
and security updates. They stop upgrading only when
systemConfig.immutability is enabled: then every
archive and repository is pinned so nothing upgrades, and unattended-upgrades is
given no origins.
APT pins repositories by origin (host), so an allowed and a not-allowed
repository on the same host (for example two PPAs on
ppa.launchpadcontent.net) cannot be told apart; such a policy is rejected at
validation. List both repositories, or neither.
The policy is applied through the per-repository files ICT already writes to
/etc/apt/preferences.d/ (see Priority Behavior): a
repository that may not upgrade gets Pin-Priority: 50 there instead of its
priority, unless its priority is already lower (a negative priority keeps
the repository blocked). The priority still governs package selection at build time. On an
immutable image, /etc/apt/preferences.d/00-ict-apt-policy pins the
distribution archive and /etc/apt/apt.conf.d/52ict-unattended-upgrades clears
the unattended-upgrades origins.
packageRepositories:
- codename: platform-stack
url: https://ppa.example.com/platform/ubuntu
pkey: https://ppa.example.com/platform/key.gpg
systemConfig:
aptPolicy:
upgradeAllowedRepos:
- platform-stack
Check the result on a deployed system with apt-cache policy <package> and
apt-get -s upgrade.
systemConfig.oepInstaller#
Composes OEP Installer (openedge-cli) profiles or modules into the image
(ubuntu targets). The OEP Installer publishes, next to the CLI, the
openedge-cli list --sbom output for each supported configuration, generated
on a host running the target OS and kernel. At build time ICT installs the
Debian packages and apt repositories that output lists; on first boot
openedge-cli install completes the remaining installation. The interface is
described in the
OEP Installer interface specification.
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
Yes |
|
|
string |
Yes |
Hex SHA-256 digest of the file at |
|
string |
Yes |
|
|
string |
Yes |
Hex SHA-256 digest of the file at |
|
string[] |
Yes |
Profiles or modules to install, as accepted by |
|
string |
Yes |
A |
|
string[] |
No |
Extra arguments (for example |
At build time ICT downloads the file at sbomUrl, checks it against
sbomSha256, and then:
Adds the packages it lists to
systemConfig.packages. Versions written asname=versionorname=version*(the newest version starting withversion) are installed at exactly the version selected. Artifacts listed by URL are left toopenedge-clion first boot.Adds the apt repositories it lists, writing their sources list, keyring, and preferences files at the paths
openedge-cliuses. ApackageRepositoriesentry with the same URL and suite is replaced by the one from the file.Fails the build when the file’s
module-listdiffers frommodules, a listed package conflicts with the template’s version of it, a package the file requires not to be installed is insystemConfig.packages, or a kernel parameter it lists is missing fromsystemConfig.kernel.cmdline.Fails the build when a kernel package it lists does not match the template’s kernel: a kernel image must be one of the template’s kernel packages, a meta-package such as
linux-image-generic-7.0must name the template kernel’s series, and headers, modules, or tools must be for the template kernel’s release.
On first boot, a generated provisioning script
(/usr/local/sbin/ict-oep-installer.sh, ordered after the template’s own
provisioning.scripts) runs as user: the CLI’s setup command, which saves
it as ~/.local/bin/openedge-cli, and then openedge-cli install with
modules and args. The user has passwordless sudo only while the script
runs; the grant is kept in /run, so it is removed when the script exits and is gone after any reboot, even on an immutable image. Like other
first-boot scripts, it runs again on each boot until it succeeds, including
after openedge-cli reboots the system to finish an installation step. ICT
copies the CLI to /usr/local/share/oep-installer/ and adds sudo to the
image’s packages. Samples that run on top of a blueprint are not part of the
image; install them afterwards with openedge-cli install <sample>.
To download from a private GitHub repository, set ICT_OEP_INSTALLER_TOKEN
to a GitHub token with read access, for example the one the GitHub CLI stores
(gh config get -h github.com oauth_token, or gh auth token on newer
versions). ICT sends the token only to GitHub hosts. Behind a proxy that
inspects TLS traffic, the build host must trust the proxy’s root certificate
(for example in /usr/local/share/ca-certificates/, followed by
update-ca-certificates), or the download fails with a certificate error.
A child template’s oepInstaller section replaces an inherited one as a
whole. The section is rejected in overlay mode.
systemConfig:
users:
- name: edge
oepInstaller:
url: https://raw.githubusercontent.com/<org>/oep-installer-cli/<commit>/rendered/openedge-cli-federal-aerospace
sha256: <sha256 of the CLI>
sbomUrl: https://raw.githubusercontent.com/<org>/oep-installer-cli/<commit>/rendered/sbom-handheld-blueprint-ubuntu-24.04.5.yaml
sbomSha256: <sha256 of the output file>
modules:
- handheld_blueprint
user: edge
args: ["--edgepack"]
The output file pins the kernel of the host it was generated on (for example
linux-headers-7.0.0-38-generic), so the template must install that exact
kernel.
Package Repositories#
Use packageRepositories to add extra Debian or RPM repositories to a build.
Each entry defines where to fetch package metadata and how candidates are
selected when the same package exists in multiple repositories.
Repository Fields#
codename: repository identifier.url: repository base URL.component: optional Debian component (for example,main,universe) for multi-component repositories.pkey: GPG key reference; supportshttp:///https://URLs,file://URLs, absolute local paths, or[trusted=yes]for supported Debian flows.priority: numeric repository preference used in conflict resolution.allowPackages: optional package white list for metadata filtering.
Priority Behavior#
priority is evaluated during package candidate selection across repositories.
Higher numeric values are preferred.
Debian resolver also supports APT-like behavior:
< 0: block packages from that repository990: prefer over default repositories1000: install even if lower version> 1000: force preference
When candidates have equivalent priority, version constraints and dependency context determine the final package choice.
AllowPackages White List#
allowPackages limits which package names are indexed from a specific
repository.
If omitted or empty, all repository packages are eligible.
If present, only matching package names are indexed.
Supported matching modes:
exact name (for example
spice-server)prefix/version pin (for example
kernel-6.17.11)glob patterns (for example
libva*,wayland*)
Filtering happens at metadata-parse time, before dependency resolution.
Template Merge Behavior#
When your user template is merged with the default template, different sections follow different strategies:
Section |
Strategy |
|---|---|
|
User overrides default if non-empty |
|
User value used entirely |
|
User replaces entire default if non-empty |
|
Additive - user packages appended to defaults (deduplicated) |
|
User overrides |
|
User overrides individual fields if non-empty |
|
User overrides |
|
Merged by |
|
Merged by |
|
Additive - user commands appended after defaults |
|
Merged only if user explicitly provides the section |
|
Merged only if user explicitly provides the section |
|
Merged by |
Template Extends (Inheritance)#
User templates can inherit from another template with the optional
extends: field. This lets you keep minimal delta templates that pick up
updates from their parent automatically, while still overriding anything
you need to change. Each template inherits from at most one parent
(single inheritance), so a chain is always a simple linear sequence
root → ... → leaf — no diamond problem.
Syntax#
extends: is a top-level string field. Its value is a path to a parent
template file, resolved relative to the child template’s directory.
.yml and .yaml are both accepted; symbolic links are not.
The canonical two-level example ships in the repo at
image-templates/ubuntu24/ubuntu24-x86_64-extends-example-raw.yml:
# Inherit everything (disk layout, systemConfig, immutability, ...) from the
# parent template, resolved relative to this file's directory. The parent's
# target must match this template's target. Values below override or extend the
# parent using the standard merge strategies (packages are additive).
extends: "ubuntu24-x86_64-minimal-raw.yml"
image:
name: ubuntu24-x86_64-extends-example
version: "24.04"
target:
os: ubuntu # Must match the parent template's target
dist: ubuntu24
arch: x86_64
imageType: raw
systemConfig:
name: Extends_Example
# These packages are merged additively on top of the parent's package list.
packages:
- htop
- curl
Merge Behavior in a Chain#
The same per-section rules that govern the user↔default merge
above apply at every level of an extends
chain. Because MergeConfigurations(child, parent) is a pure function of
two inputs, applying it iteratively as a fold produces the same set of
merged fields at any depth: if the merge is deterministic for two
layers, it is deterministic for N. Note that the ordering of slice
entries merged by key (systemConfig.users by name, and
systemConfig.additionalFiles by destination path) is not guaranteed to
be stable across runs — the merge implementation uses a map to
deduplicate keys, so the emitted slice order for those two fields may
vary. Every other field’s ordering is stable. The table below lists the
full behavior across a chain:
Section |
Rule (chain semantics) |
|---|---|
|
Non-empty child value overrides; last non-empty level in the chain wins |
|
Replaced wholesale by the child’s value (all four sub-fields together) |
|
Non-nil child pointer replaces the parent value |
|
Wholesale replacement when the child provides a non-empty disk config |
|
Non-empty child overrides |
|
Additive union, deduplicated; each level’s packages are appended in chain order and de-duplicated. Deduplication is by exact string, so a child pinning |
|
Merged by |
|
Merged by |
|
Additive concat: appended in chain order (root first, leaf last), no deduplication |
|
Per-field override: |
|
Per-field override: |
|
Per-field: |
|
Merged only when the child provides some immutability configuration |
|
Merged by |
|
Stripped from the final merged output — it is a build-time directive, not part of the built template |
The rules quoted above are the exact per-field strategies enforced by
MergeConfigurations in internal/config/merge.go.
Overlay mode caveat: when the leaf template uses
baseline.mode: overlay, the additive-packages rule is deliberately overridden. The merged package list becomes exactly the user’s declared packages — the baseline already ships the create-mode toolchain, and unioning it back in would drag in bootloader packages whose strict version pins the frozen baseline cannot satisfy.
Chain Resolution#
At load time, the resolver walks the chain from leaf to root and then folds it back in root-to-leaf order so leaf values have the highest precedence. Once the chain is folded, the OS defaults for the target distribution are applied underneath, producing the effective layering:
OS defaults → root template → intermediate levels → leaf template
Each successive level takes precedence over everything below it. The
merge is applied pairwise: fold(MergeConfigurations, defaults, [root, level1, ..., leaf]).
The build command logs the resolved chain at info level so you can see the inheritance hierarchy directly in build output:
Extends chain: root.yml -> child.yml -> leaf.yml
Worked example. Consider three templates where the leaf declares
packages: [my-custom-app], the middle level declares packages: [prometheus-node-exporter, grafana-agent], the root declares packages: [docker-cli, containerd], and the OS defaults declare
packages: [openssh-server]. The final merged template’s package list
is the union: openssh-server, docker-cli, containerd, prometheus-node-exporter, grafana-agent, my-custom-app (deduplicated,
default order preserved). If any two levels also set kernel.version,
the last non-empty value in the chain wins.
Limitations and Validation Rules#
Single inheritance. Each template may reference at most one parent via
extends:. Multiple inheritance and diamond-shaped graphs are not supported.Cycle detection. The resolver walks the chain with a visited-set keyed on the symlink-resolved canonical absolute path of each template. Any repeat is rejected with
circular extends detected: A -> B -> A. Canonicalizing the path ensures a directory symlink cannot alias two textual paths to the same file and evade the check.Target match. Every template in the chain must share the same
target.os,target.dist,target.arch, andtarget.imageType. A mismatch at any level is rejected withextends target mismatch at level N: child targets os/dist/arch/imageType but parent targets ....Depth warning. Chains that exceed 4 levels emit
extends chain depth N exceeds recommended maximum of 4as a warning. This is a soft cap intended to keep hierarchies maintainable — the build still succeeds.Path containment. The parent path is resolved relative to the child template’s directory. Both a lexical guard and a symlink-resolved guard reject any path that escapes that directory (
extends path escapes child template's directory: ...), so a parent cannot pull a template in from an unrelated location on disk.Symlink rejection. Parent templates that are themselves symbolic links are rejected at load time, matching the same policy the CLI uses for the leaf template.
File extension. Only
.ymland.yamlextensions are accepted; other extensions are rejected.Schema.
extends:is defined as a plain optional string inos-image-template.schema.jsonwith nopatternorformatconstraint — all validation happens in Go at load time so the errors above carry more context than a schema violation would.Output stripping. The
extends:field is stripped from the final merged template because it is a build-time directive, not part of the built image’s declarative state.
Debugging an Extends Chain#
The resolve subcommand renders a template exactly as the build system
sees it after the chain is folded:
# Chain-only merge, without OS defaults (useful for verifying inheritance)
image-composer-tool resolve image-templates/ubuntu24/ubuntu24-x86_64-extends-example-raw.yml
# Full build-time view: OS defaults as base, extends chain folded on top (leaf wins)
image-composer-tool resolve image-templates/ubuntu24/ubuntu24-x86_64-extends-example-raw.yml --full
The output includes the merged systemConfig.packages union and every
other field as it would be used at build time. Sensitive fields (user
passwords, hash_algo values, and secure-boot key/cert paths) are
redacted so the output is safe to paste into an issue or a code review.
See Resolve Command
for the full CLI reference.
Advanced Mode’s Use of Extends#
The web UI’s Advanced tab applies its overrides — the Image Name, the selected
packages, and the enabled package repositories — by generating a small
extends delta over the matched curated template, rather than rewriting the
curated file or inventing a separate override mechanism. For a selection
with an override, POST /templates/compose and POST /builds both:
Look up the matched curated template via the manifest, exactly as Basic mode does.
Generate a delta (
extends: <curated template>, restatingimage/targetwith the override applied, plussystemConfig.packagesandpackageRepositoriesfor whatever the Packages step selected) and write it into the templates directory under a server-generated name (.ict-adv-<uuid>.yml, gitignored).Resolve or build that delta instead of the curated file.
Remove the generated delta once it is no longer needed — immediately after a compose response, or once the build’s real child process has actually exited. A self-contained, merged copy of the resolved template is archived to the build’s directory before the build is even started — not just at the end — so the per-build template download always serves the full template from the moment the build is accepted, through every stage of composing, and for every outcome (success, failure, cancellation, or a cancel that misses its hard deadline). The delta itself is left on disk until the real process is later confirmed to have exited, since a still-running process may still have it open; only the archived copy, not the live delta, backs the download in the meantime.
The delta must live in the templates directory (or an ancestor of it) because of the path containment rule above — a parent must resolve at or below the child’s own directory, so a generated child cannot reference a curated parent from anywhere else, such as a per-build work directory.
Consequently, POST /templates/compose’s yaml field is not the curated
template’s bytes verbatim (as it is for Basic mode’s own preview) — it is the
fully resolved template, equivalent to running resolve --full against
whichever file (curated or delta) was actually resolved. This guarantees the
template shown in the Review step and the template a build runs are always the
same file, merged the same way.
What the Packages Step Contributes#
A package the Packages step selects is emitted as a systemConfig.packages
entry. A pinned version uses the name_version form the package resolvers
match (curl_8.5.0-2ubuntu10.13, epoch included where the repository publishes
one); an unpinned pick is the bare name and floats to whatever the repository
publishes at build time. Entries are emitted sorted, so the same set of picks
always renders the same delta regardless of the order they were clicked in.
Each enabled repository is emitted as a packageRepositories entry, so a
package picked from a repository the curated template does not already
configure can still be resolved. The entry’s codename, url and component
come from the catalog’s index metadata and its pkey from the catalog’s
pkey field, which holds the same signing-key URL the authored templates use
for that repository. Repositories the catalog marks enabledByDefault are
not emitted: they are each target’s base repository, already supplied by
the per-OS providerconfigs/, so re-declaring them would duplicate
configuration the build already has.
Pinning a package the curated template already lists. Per the merge table above,
systemConfig.packagesis an additive union — a child layer cannot remove a parent’s entry. So pinning a version for a package the curated template already lists unpinned leaves both entries in the resolved template (curlandcurl_8.5.0-2ubuntu10.13), and the resolver may install either.POST /templates/composereports these onpinConflictsand the Review step names them, rather than presenting the resolved template as unambiguous. To let the curated template’s entry decide, choose “Latest” for that package.
Because the delta is a template in its own right, the Review step can show it
directly: POST /templates/compose returns deltaYaml (the generated delta
verbatim), baseYaml (the curated template resolved without the overrides)
and yaml (the two merged — what the build runs), which the Review step
presents as “Your changes”, “Base template” and “Resolved”. A selection with no
overrides generates no delta, so only yaml is returned.
See ADR: Advanced mode template modification via extends for the full design rationale, including why an image-type override is not supported the same way.
Advanced Mode’s Repository Catalog#
The Advanced tab’s repository picker is driven by a catalog at
internal/api/service/data/package-repos.yaml, not by the selected template.
The catalog mirrors the two places repositories are really declared — each
template’s own packageRepositories: block, and the per-OS
config/osv/<os>/<dist>/providerconfigs/*_repo.yml — so the picker only offers
repositories that a build of that target could actually install from.
Alongside the picker’s presentation fields, each entry carries an index: list
naming the package indexes a search reads. One catalog entry can need several
indexes: a Debian-style base repository spans a release, -security and
-updates suite, and Ubuntu serves those from two different hosts plus a third
for aarch64. Each index entry may therefore override the repository’s base
url and restrict itself to particular arch values. Where a template omits
component, the catalog records main, matching the default that
generateAptSourcesContent applies when writing a build’s apt sources — so a
search reads the same component a build installs from.
Each optional entry also carries a pkey: the signing-key URL written into the
packageRepositories entry a generated delta emits when the user enables that
repository (see What the Packages Step
Contributes). The value is the same key
URL the authored templates already use for that repository, so an
Advanced-mode build verifies against exactly what a hand-written one would. It
is distinct from gpgKeyPath, which is a local keyring file a package
search reads. GET /package-repos publishes only a hasSigningKey flag, not
the URL; a repository with no key is still selectable, but the picker states
that its packages are fetched without verifying the repository’s signature.
Because the catalog duplicates information, unit tests assert the mirroring
holds in both directions: every repository a manifest-reachable template or
providerconfig declares must be covered by a catalog index. Adding a repository
to a template therefore fails the build until it is catalogued too, rather than
silently producing a package picker that cannot find that repository’s packages.
A further test requires every optional entry to carry a pkey, so a new
repository cannot quietly start producing unverified builds.
Repositories for target OSes the manifest does not offer are deliberately absent; the picker can never query them. They should be added alongside the manifest combination that makes them reachable.
See Also#
ADR: template
extends— design rationale and full validation matrixADR: package picker UX — the Advanced tab’s repository and package selection design
ADR: Advanced mode template modification via extends — how the web UI applies overrides through a generated delta
Resolve Command — CLI reference for
image-composer-tool resolveTemplate Merge Behavior — the two-layer user↔default merge rules an extends chain reuses at every level
Variable Substitution#
Templates support variable substitution using ${variable_name} syntax. You
can provide variable values via a separate YAML file or command-line flags at
build time.
To learn how variables interact with each build stage, see Build Stages in Detail.
WSL Required Fields#
To compose a WSL-compatible image, set target.imageType: wsl2 and include a
WSL-compatible disk artifact definition.
Field |
Required for WSL |
Requirement |
|---|---|---|
|
Yes |
Standard image identifier |
|
Yes |
Standard image version |
|
Yes |
Any supported OS/distribution with a WSL2 default template |
|
Yes |
Distribution for the selected OS (for example, |
|
Yes |
Use |
|
Yes |
Must be |
|
Yes |
Required when |
|
Yes |
Must be |
|
Yes |
Must be |
Additional notes for WSL builds:
The default Ubuntu WSL template seeds the standard Ubuntu apt sources via
systemConfig.additionalFiles(for example,ubuntu-noble.list), which is the same mechanism used by the raw and initrd defaults.disk.partitionTableTypeanddisk.partitionsare not used forwsl2templates.systemConfig.kernelis not allowed forwsl2templates.
Example disk block for WSL:
disk:
name: ubuntu24-x86_64-agentic
artifacts:
- type: tar
compression: gz
See the full end-to-end example at
image-templates/ubuntu24/ubuntu24-x86_64-agentic-wsl2.yml.
Best Practices#
Start from examples - copy a template from
image-templates/and modify only the fields you need. Let defaults handle the rest.Keep templates minimal - override only what differs from the default. Smaller templates are easier to maintain and review.
Use descriptive names - name images and configs after their purpose (e.g.,
factory-floor-edge, nottest-image-3).Version control your templates - store them in Git alongside your deployment code.
Validate before building - run
image-composer-tool validate template.ymlto catch errors early.Prefer
additionalFilesoverconfigurations- copying config files is more reproducible than running arbitrary shell commands.