ADR: Declarative Live ISO Installer#
Status: Proposed
Date: 2026-04-17
Updated: N/A
Authors: OS Image Composer Team
Technical Area: Provisioning / Live Installer / Security
Summary#
This ADR proposes extending the existing Live ISO installer to become a declarative provisioning engine that supports automated disk selection, Full Disk Encryption (FDE), dm-verity root integrity, SELinux enforcement, and network configuration - all driven by the image template.
The goal is to eliminate the need for a separate interim OS provisioning environment by closing the implementation gaps in the current Live ISO installer, resulting in a single, maintainable provisioning path for all use cases.
Context#
Problem Statement#
The OS Image Composer (ICT) generates fully qualified images with all required packages and tools, but is not a complete edge node provisioning and installation solution. Several capabilities required for production edge deployments are not available through the current Live ISO installer:
Manual disk selection - The unattended installer requires a hardcoded
disk.path(e.g.,/dev/sda) in the template. There is no automatic disk discovery or policy-based selection.No Full Disk Encryption - The boot parameter template contains
{{.LuksUUID}}and{{.EncryptionBootUUID}}placeholders, but they are replaced with empty strings. No LUKS provisioning logic exists.No SELinux automation - The boot parameter template contains a
{{.SELinux}}placeholder, also replaced with an empty string. SELinux packages can be installed manually, but there is no automated mode configuration, policy selection, or filesystem relabeling.No declarative network configuration - Network setup for the installed OS is not part of the template schema. Post-install networking relies on cloud-init or manual
configurationscommands.No install manifest separation - The ISO builder and live installer are tightly coupled to the image template. There is no distinct concept of an “install manifest” that separates what to install from how to lay out the disk.
Background#
An alternative approach was proposed: introducing an interim OS (based on LinuxKit or a minimal provisioning environment) that would boot first, perform disk provisioning and security setup, then deploy the target OS. While this approach is technically viable, it introduces significant concerns:
Duplicated provisioning logic across two environments
Increased maintenance burden for two boot paths
Risk of script drift between the interim OS and the ISO installer
Multiple provisioning paths to test, validate, and support
The architect’s position - and the recommendation of this ADR - is that these capabilities should be implemented directly in the Live ISO installer, which already has substantial infrastructure in place.
Existing Infrastructure#
The current codebase provides a strong foundation:
Capability |
Status |
Location |
|---|---|---|
Disk enumeration via |
Implemented (attended TUI only) |
|
ISO media exclusion |
Implemented |
|
GPT/MBR partition creation |
Implemented |
|
dm-verity (hash partition + root hash) |
Implemented |
|
Overlay filesystem (read-only root) |
Implemented |
|
UKI (Unified Kernel Image) |
Implemented |
|
Secure Boot signing |
Implemented |
|
Unattended install mode |
Implemented |
|
Attended install TUI |
Implemented |
|
Boot parameter template |
Implemented (with unused placeholders) |
|
|
Present |
|
Cloud-init as optional package |
Present |
Various templates |
Decision / Recommendation#
Extend the Live ISO installer to support all required provisioning capabilities natively through the existing template schema. Do not introduce a separate interim OS provisioning environment.
Core Design Principles#
Single provisioning path - All provisioning flows (BKC, Robotics, Edge, etc.) use the same Live ISO installer.
Declarative configuration - All provisioning behavior is driven by the image template YAML. No imperative scripts or manual intervention.
Backward compatibility - Existing templates continue to work without modification. New fields are optional with safe defaults.
Separation of responsibilities - The installer handles low-level hardware provisioning; cloud-init handles high-level OS customization.
Incremental delivery - Each capability is independently valuable and can be shipped without waiting for the others.
Separation of Responsibilities#
The target architecture cleanly separates concerns across three layers:
OS Image Composer (os-image-composer build)#
Produces installable artifacts:
Root filesystem payload (packages installed into chroot)
Kernel, initrd, or Unified Kernel Image (UKI)
Install manifest (declarative provisioning instructions)
Does not produce fixed disk layouts or partition tables.
Live ISO Installer (live-installer)#
Declarative provisioning engine responsible for:
Hardware detection (disk enumeration, interface discovery)
Disk selection (policy-based or explicit)
Partition table creation (GPT/MBR)
Filesystem creation and formatting
Full Disk Encryption (LUKS2 provisioning)
dm-verity setup (hash partition, root hash injection)
SELinux base configuration (mode, policy, relabeling)
Bootloader installation (GRUB2, systemd-boot, UKI)
Root filesystem deployment
Cloud-init (post-first-boot)#
Handles customer customization:
User accounts and SSH keys
Hostname
Network overrides
Package installation
Service configuration
Application deployment
For air-gapped or offline edge deployments where network connectivity cannot
be assumed after installation, cloud-init configuration files (user-data,
meta-data, network-config) can be injected at build time via the
template. The composer embeds them into the image as a NoCloud seed
directory (/etc/cloud/seed/nocloud/), so cloud-init runs locally on first
boot without requiring a network datasource.
systemConfig:
cloudInit:
userDataFile: /path/to/user-data
metaDataFile: /path/to/meta-data # optional
networkConfigFile: /path/to/network-config # optional
Phased Implementation#
Phase 1: Unattended Provisioning#
Phase 1 delivers a fully functional unattended installer that can provision an edge node without manual intervention. It covers automated disk selection, declarative network configuration, dynamic inputs via cloud-init injection, and comprehensive documentation.
1.1 Automated Disk Selection#
Problem: The unattended installer requires disk.path: /dev/sdX to be
hardcoded in the template - a value that varies across hardware.
Solution: Add a selectionPolicy field to DiskConfig that allows the
installer to discover and select a disk automatically at install time.
Template Schema#
disk:
# Explicit path (existing behavior, still supported):
# path: /dev/sda
# New: policy-based selection for unattended installs
selectionPolicy:
strategy: largest # largest | fastest
excludeRemovable: true # skip USB/removable media (default: true)
partitionTableType: gpt
partitions:
- id: esp
# ...
When disk.path is empty, the installer resolves the disk using the policy.
When disk.path is set, the policy is ignored (backward compatible).
Approach#
Add a
DiskSelectionPolicystruct to the config and a new disk selection module ininternal/image/imagedisc/Reuse the existing
SystemBlockDevices()enumeration, extending thelsblkquery to includeTRAN(transport),ROTA(rotational), andRM(removable) fieldsImplement strategy-based selection:
largest— select the disk with the most capacityfastest— prefer NVMe over SATA SSD over HDD (based on transport type and rotational flag)
Filter removable devices and ISO installer media (existing logic)
When
disk.pathis empty in the live installer, fall back to policy-based selection before proceeding to partition creation
1.2 Declarative Network Configuration#
Problem: Network configuration for the installed OS is not part of the
template schema. Users must add it via configurations commands or rely
entirely on cloud-init.
Solution: Add a network section to the template that generates the
appropriate network configuration files for the target OS.
Template Schema#
systemConfig:
network:
backend: netplan # netplan | networkmanager | systemd-networkd
# NIC selection policy (when interface names vary across hardware)
nicPolicy: link-up # all | link-up | by-name (default: all)
interfaces:
- name: eth0
dhcp4: true
- name: eth1
addresses:
- "192.168.1.10/24"
gateway4: "192.168.1.1"
nameservers:
- "8.8.8.8"
- "8.8.4.4"
proxy:
httpProxy: "http://proxy.corp.example.com:8080"
httpsProxy: "http://proxy.corp.example.com:8080"
noProxy: "localhost,127.0.0.1,.corp.example.com"
# Future: WPAD/DHCP-based automatic proxy discovery
When nicPolicy is all, every listed interface is configured. When
link-up, only interfaces with an active link at install time are
configured (useful when interface names are unpredictable). When by-name,
only explicitly named interfaces are configured (same as current behavior).
WiFi support is out of scope for the initial implementation and will be addressed in a future iteration.
Approach#
Add
NetworkConfig,NetworkInterface, andProxyConfigstructs toSystemConfigCreate a new
internal/image/imagenetwork/package that generates the appropriate config files based on the selected backend:netplan →
/etc/netplan/01-installer-config.yamlsystemd-networkd →
/etc/systemd/network/10-<name>.networknetworkmanager →
/etc/NetworkManager/system-connections/<name>.nmconnection
Implement NIC discovery using
ip link showto enumerate interfaces and filter by link state whennicPolicy: link-upGenerate proxy configuration in
/etc/environmentand backend-specific proxy settingsCall network configuration from the OS installation flow after package installation
1.3 Dynamic Inputs (Cloud-init Injection)#
Problem: Unattended deployments — especially air-gapped or offline edge nodes — need per-node customization (hostname, users, SSH keys, services) without requiring network connectivity after installation.
Solution: Allow customer-provided cloud-init configuration files to be injected into the image at build time via the template. The composer embeds them as a NoCloud seed directory so cloud-init runs locally on first boot.
Template Schema#
systemConfig:
cloudInit:
userDataFile: /path/to/user-data
metaDataFile: /path/to/meta-data # optional
networkConfigFile: /path/to/network-config # optional
Approach#
Add a
CloudInitConfigstruct toSystemConfigDuring image build, copy referenced files into the rootfs at
/etc/cloud/seed/nocloud/(user-data, meta-data, network-config)Validate that referenced files exist during template validation
Cloud-init auto-detects the NoCloud seed on first boot
1.4 Documentation#
All Phase 1 features must include corresponding documentation updates:
Template specification: Update
docs/architecture/os-image-composer-templates.mdwithselectionPolicy,network, andcloudInitschema definitionsUsage guide: Update
docs/tutorial/usage-guide.mdwith unattended provisioning examplesJSON schema: Update
os-image-template.schema.jsonwith new fieldsExample templates: Add example ISO templates demonstrating unattended provisioning with auto disk selection and network config
Phase 2: Security Hardening#
Phase 2 adds security capabilities to the provisioning flow: Full Disk Encryption, SELinux enforcement, and the install manifest architectural refactor that cleanly separates artifact production from provisioning logic.
2.1 Full Disk Encryption (FDE)#
Problem: Production edge deployments require encrypted root filesystems.
The boot parameter template has {{.LuksUUID}} and {{.EncryptionBootUUID}}
placeholders but they are never populated.
Solution: Add LUKS2 encryption support to the live installer, triggered by
a new encryption section in the template.
Template Schema#
systemConfig:
encryption:
enabled: true
type: luks2 # luks2 (default, only supported type)
tpmEnroll: true # enroll TPM2 for auto-unlock (optional)
recoveryKey: true # generate recovery key (optional)
partitions: # partition IDs to encrypt
- root
Approach#
Add an
EncryptionConfigstruct toSystemConfigand a newinternal/image/imageencrypt/packageInsert encryption into the install flow after partition creation but before rootfs installation:
Format target partitions with LUKS2 via
cryptsetupOpen the LUKS container and update the disk path map to use
/dev/mapper/...devicesInstall the OS into the opened container
Generate
/etc/crypttabin the installed rootfsOptionally enroll TPM2 via
systemd-cryptenrollOptionally generate a recovery key saved to the ESP
Populate the existing
{{.LuksUUID}}boot parameter placeholder withrd.luks.uuid=<uuid>instead of the current empty stringAdd
systemd-cryptenrollto the shell command allowlist (cryptsetupis already present)
Unlock Modes#
The encryption configuration supports three unlock modes, determined by the
combination of tpmEnroll and recoveryKey flags:
Mode |
Config |
Boot Behavior |
|---|---|---|
TPM auto-unlock |
|
Passphrase sealed in TPM2 PCRs; disk decrypted automatically at boot with no user interaction. This is the primary production mode for unattended edge nodes. |
Recovery key |
|
A one-time recovery key is generated and saved to the ESP. Used as fallback if TPM unlock fails (e.g., hardware change, firmware update). Requires manual entry. |
Interactive passphrase |
|
User must type a passphrase at the boot prompt. Suitable for development, testing, or systems without TPM2 hardware. |
These modes are composable — a production deployment would typically use
tpmEnroll: true + recoveryKey: true to get automatic unlock with a
recovery fallback. A dev/test environment might use only interactive
passphrase.
Security Considerations#
Passphrase for non-TPM scenarios must be provided via template or secure input mechanism (never logged)
Recovery keys are written only to the ESP, not to the root filesystem
TPM enrollment happens after OS installation is complete
TPM PCR policy binds the key to specific boot measurements, preventing offline disk extraction attacks
2.2 SELinux Enforcement#
Problem: Edge deployments with security requirements need SELinux in
enforcing mode. The boot parameter template has a {{.SELinux}} placeholder
but it is never populated, and there is no automated SELinux configuration.
Solution: Add an selinux section to the template that configures SELinux
mode, policy type, and filesystem relabeling strategy.
Template Schema#
systemConfig:
selinux:
mode: enforcing # enforcing | permissive | disabled
policy: targeted # targeted (default) | mls | minimum
relabel: first-boot # first-boot (default) | install-time
policyFiles: # optional: customer-provided policy modules
- /path/to/custom.pp
ICT treats .pp files as opaque artifacts, copies them into the chroot and installs them via semodule -i.
The .pp format is the industry-standard SELinux module format used across
all major Linux distributions.
Approach#
Add a
SELinuxConfigstruct toSystemConfigAdd SELinux configuration logic to the OS installation flow (post-package-install phase in
imageos):Write
/etc/selinux/configwith the desired mode and policy typeIf
relabel: first-boot→ create/.autorelabelmarkerIf
relabel: install-time→ runsetfilesin the chrootAuto-inject required SELinux packages for the target OS
If
policyFilesare specified, copy each.ppfile into the chroot and install viasemodule -i
Populate the existing
{{.SELinux}}boot parameter placeholder:enforcing→security=selinux selinux=1 enforcing=1permissive→security=selinux selinux=1 enforcing=0disabled→ empty string (current behavior)
Add
setfiles,restorecon, andsemoduleto the shell command allowlist
2.3 Install Manifest and Architectural Separation#
Problem: The ISO builder and live installer are tightly coupled to the full image template. The ISO carries a complete package cache and the installer replays the entire build. This conflates artifact production with provisioning logic.
Solution: Introduce an install manifest - a declarative YAML document that describes how to provision a system using pre-built artifacts. The ISO carries the manifest alongside a rootfs payload, kernel, and initrd/UKI.
Install Manifest Structure#
version: "1.0"
payloads:
rootfs: /payloads/rootfs.tar.zst
kernel: /payloads/vmlinuz
initrd: /payloads/initrd.img # or UKI path
diskPolicy:
strategy: largest
excludeRemovable: true
partitions:
- id: esp
type: esp
fsType: fat32
start: 1MiB
end: 512MiB
mountPoint: /boot/efi
- id: root
type: linux-root-amd64
fsType: ext4
start: 512MiB
end: "0"
mountPoint: /
security:
encryption:
enabled: true
type: luks2
tpmEnroll: true
partitions: [root]
immutability:
enabled: true
selinux:
mode: enforcing
policy: targeted
relabel: first-boot
network:
backend: netplan
interfaces:
- name: eth0
dhcp4: true
bootloader:
bootType: efi
provider: systemd-boot
cloudInit:
userDataFile: /payloads/cloud-init/user-data
metaDataFile: /payloads/cloud-init/meta-data
ISO Builder Changes#
The ISO builder (isomaker) currently creates an initrd-based rootfs, copies
the package cache, and assembles the ISO. The modified flow:
Build rootfs as a compressed tarball (instead of copying raw packages)
Generate
install-manifest.ymlfrom the templateEmbed both under
/payloads/and/manifest/on the ISO
Live Installer Flow (manifest-driven)#
Boot ISO
↓
Read /manifest/install-manifest.yml
↓
Discover hardware → select disk via diskPolicy
↓
Create partitions per manifest
↓
Encrypt partitions if security.encryption.enabled
↓
Extract rootfs tarball to mounted partitions
↓
Configure SELinux if security.selinux.mode != ""
↓
Install bootloader (GRUB2 / systemd-boot / UKI)
↓
Apply dm-verity if security.immutability.enabled
↓
Write network configuration
↓
Reboot → cloud-init handles post-install customization
Example Template: Full Declarative ISO#
This example demonstrates all new capabilities in a single template:
metadata:
description: Ubuntu 24.04 edge node with FDE, SELinux, and auto disk selection
use_cases:
- Secure edge node provisioning
- Zero-touch bare metal deployment
- BKC qualified image installation
keywords:
- iso
- fde
- selinux
- dm-verity
- unattended
- edge
image:
name: edge-node-ubuntu
version: "24.04"
target:
os: ubuntu
dist: ubuntu24
arch: x86_64
imageType: iso
disk:
selectionPolicy:
strategy: largest
excludeRemovable: true
partitionTableType: gpt
partitions:
- id: esp
name: EFI System Partition
type: esp
fsType: fat32
start: 1MiB
end: 512MiB
mountPoint: /boot/efi
flags: [boot]
- id: boot
name: Boot
type: linux-root-amd64
fsType: ext4
start: 512MiB
end: 1GiB
mountPoint: /boot
- id: root
name: Root
type: linux-root-amd64
fsType: ext4
start: 1GiB
end: "0"
mountPoint: /
systemConfig:
name: edge-node
description: Secure edge node configuration
hostname: edge-node
bootloader:
bootType: efi
provider: systemd-boot
kernel:
version: "6.8"
uki: true
packages:
- linux-image-generic
immutability:
enabled: true
encryption:
enabled: true
type: luks2
tpmEnroll: true
recoveryKey: true
partitions:
- root
selinux:
mode: enforcing
policy: targeted
relabel: first-boot
network:
backend: netplan
interfaces:
- name: eth0
dhcp4: true
cloudInit:
userDataFile: /path/to/user-data
metaDataFile: /path/to/meta-data
packages:
- cloud-init
- openssh-server
- policycoreutils
- selinux-basics
- selinux-policy-default
users:
- name: admin
sudo: true
shell: /bin/bash
Delivery Milestones#
Milestone |
Deliverable |
Dependencies |
Parallelizable |
|---|---|---|---|
M1.1 |
Disk auto-selection (Phase 1.1) |
None |
Yes |
M1.2 |
Network configuration (Phase 1.2) |
None |
Yes (parallel with M1.1) |
M1.3 |
Cloud-init injection (Phase 1.3) |
None |
Yes (parallel with M1.1, M1.2) |
M1.4 |
Documentation (Phase 1.4) |
M1.1 + M1.2 + M1.3 |
After implementation |
M2.1 |
Full Disk Encryption (Phase 2.1) |
M1.1 (needs resolved disk path) |
After M1.1 |
M2.2 |
SELinux enforcement (Phase 2.2) |
None |
Yes (parallel with M2.1) |
M2.3 |
Install manifest v1 (Phase 2.3) |
M2.1 + M2.2 |
Last (integrates all) |
Phase 1 milestones (M1.1, M1.2, M1.3) can be developed in parallel by different engineers. M1.4 (documentation) follows after all Phase 1 implementation is complete. Phase 2 starts after Phase 1 is delivered, with M2.1 and M2.2 parallelizable.
Risks and Mitigations#
Risk |
Impact |
Mitigation |
|---|---|---|
FDE adds complexity to boot failure debugging |
Medium |
Recovery key generation; clear error messages; fallback to unencrypted mode |
SELinux relabeling at install time is slow for large rootfs |
Low |
Default to |
Disk auto-selection picks the wrong disk |
High |
Conservative defaults ( |
Install manifest refactor is invasive |
Medium |
Deliver as last milestone in Phase 2; Phase 1 works with existing template structure |
TPM2 not available on all target hardware |
Low |
|
Testing Strategy#
Each phase includes dedicated tests:
Unit tests: Table-driven tests for each new function (disk selection strategies, encryption config generation, SELinux config writing, network config rendering)
Integration tests: Build ISO with new template fields, verify installer behavior in QEMU/KVM
Security tests: Verify LUKS UUID appears in boot params, verify dm-verity root hash is correct, verify SELinux mode in installed OS
Backward compatibility tests: Existing templates without new fields continue to build and install correctly
Alternatives Considered#
Alternative 1: Interim OS (LinuxKit-based)#
Boot a minimal provisioning OS, perform disk setup and security configuration, then deploy the target OS.
Rejected because:
Duplicates provisioning logic between two environments
Two boot paths to maintain and test
Risk of script drift
Higher long-term maintenance cost
References#
Boot parameter template:
config/general/image/efi/bootParams.confDisk enumeration:
internal/image/imagedisc/imagedisc.go-SystemBlockDevices()dm-verity setup:
internal/image/imageos/imageos.go-prepareVeritySetup()Immutability/overlay:
internal/image/imagesecure/imagesecure.go-ConfigImageSecurity()Live installer:
cmd/live-installer/install.go-install()Image template schema:
internal/config/schema/os-image-template.schema.jsonShell command allowlist:
internal/utils/shell/shell.go-commandMapSecurity objectives:
docs/architecture/image-composition-tool-security-objectives.md