commit ef98e3f1316477f77b55e399e7566d5e2bf03e78 Author: Maarten Date: Wed Feb 18 14:04:05 2026 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..095a2a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build artifacts +*.iso +*.img +*.gz +*.tar +*.fat + +# Seed working directories +seed-workdir-*/ + +# Temporary files +*.tmp +*.bak +*~ + +# OS files +.DS_Store +Thumbs.db + +# Editor files +*.swp +*.swo +.vscode/ +.idea/ + +# Go binaries (flasher-tool) +flasher-tool diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0f2f0b --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# incu-contrib + +Peripheral tools, snippets, and scripts for working with [Incus](https://linuxcontainers.org/incus/), +[IncusOS](https://linuxcontainers.org/incus-os/), and the broader ecosystem. + +Primarily aimed at home lab environments but useful in production as well. + +## Contents + +### [`incusos/`](incusos/) + +Scripts for building IncusOS installation media and seed configurations: + +- **`incusos-iso`** -- Build customized IncusOS ISO/IMG images using the official flasher-tool. + Supports both architectures (x86_64, aarch64), multiple applications (Incus, Operations Center), + automatic client certificate injection, and batch generation. + +- **`incusos-seed`** -- Generate IncusOS seed archives (tar or FAT image) for installation + customization. Covers all seed file types: install, applications, Incus preseed, + Operations Center, network, and update configuration. + +See [`incusos/README.md`](incusos/README.md) for full documentation. + +### [`notes/`](notes/) + +Research notes and reference material. + +## Quick Start + +```bash +# Install the flasher-tool (requires Go 1.21+) +go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest + +# Build a default IncusOS ISO with your client cert injected +./incusos/incusos-iso + +# Build for a specific architecture and application +./incusos/incusos-iso --arch aarch64 --app operations-center --defaults + +# Generate all common ISO variants at once +./incusos/incusos-iso --batch + +# Generate a standalone seed archive +./incusos/incusos-seed --app incus --defaults --output my-seed.tar + +# Generate a SEED_DATA FAT image for external boot media +./incusos/incusos-seed --format fat --app incus --defaults --output seed.img +``` + +## Requirements + +- **Go 1.21+** -- for installing the flasher-tool +- **curl** -- for downloading images from the CDN +- **tar**, **gzip** -- for seed archive creation and image decompression +- **mtools** (optional) -- for creating FAT seed images +- **incus** (optional) -- for automatic client certificate detection + +## License + +MIT diff --git a/incusos/README.md b/incusos/README.md new file mode 100644 index 0000000..aca2d32 --- /dev/null +++ b/incusos/README.md @@ -0,0 +1,261 @@ +# IncusOS Tools + +Scripts for building IncusOS installation media and seed configurations. + +## Overview + +| Script | Purpose | +|--------|---------| +| [`incusos-iso`](#incusos-iso) | Build customized IncusOS ISO/IMG images using the flasher-tool | +| [`incusos-seed`](#incusos-seed) | Generate seed archives (tar or FAT) for installation customization | + +Both scripts support automatic client certificate injection from the local Incus +installation, so the freshly installed system is immediately manageable. + +## Prerequisites + +- **Go 1.21+** -- for installing the flasher-tool +- **flasher-tool** -- the official IncusOS image builder + +```bash +go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest +export PATH="${GOPATH:-$HOME/go}/bin:$PATH" +``` + +Optional: +- **incus** -- for automatic client certificate detection +- **mtools** / **dosfstools** -- for creating FAT seed images +- **jq** or **python3** -- for parsing the CDN index (cross-architecture builds) +- **curl** -- for downloading images + +## incusos-iso + +Wrapper around the official flasher-tool that adds: + +- **Cross-architecture builds** -- build aarch64 images on x86_64 and vice versa +- **Automatic seed generation** -- no need to manually create seed files +- **Client certificate injection** -- auto-detects your local Incus certificate +- **Batch mode** -- generate all common ISO variants in one command +- **Sensible defaults** -- minimal flags needed for common use cases + +### Quick Start + +```bash +# Build a default IncusOS ISO for the current architecture +./incusos-iso + +# Incus with default configuration (ZFS pool, bridge, port 8443) +./incusos-iso --defaults + +# Operations Center for aarch64 +./incusos-iso --arch aarch64 --app operations-center --defaults + +# Build a USB image instead of ISO +./incusos-iso --format img --defaults + +# Generate all variants (2 arches x 2 apps x 2 default modes = 8 images) +./incusos-iso --batch + +# Preview what would be built +./incusos-iso --batch --dry-run +``` + +### Options Reference + +``` +-a, --arch ARCH x86_64 or aarch64 (default: auto-detect) +-A, --app APP incus or operations-center (default: incus) +-d, --defaults Apply default config (ZFS pool, bridge, port 8443) +-c, --cert FILE Client certificate PEM file (default: auto-detect) + --no-cert Skip certificate injection +-F, --format FORMAT iso or img (default: iso) +-o, --output FILE Output filename (default: auto-generated) +-C, --channel CHANNEL stable or testing (default: stable) +-s, --seed FILE Use pre-built seed tar (skip seed generation) +-i, --image FILE Use local base image (skip CDN download) +-b, --batch Generate all common variants + --batch-arches LIST Architectures for batch (default: x86_64,aarch64) + --batch-apps LIST Apps for batch (default: incus,operations-center) + --output-dir DIR Output directory for batch (default: .) + --disk-target ID Target disk pattern (e.g. "virtio-*") + --force-install Overwrite existing installation + --force-reboot Auto-reboot after installation + --missing-tpm Allow install without TPM 2.0 + --missing-secureboot Allow install without Secure Boot + --hostname NAME Set system hostname + --dry-run Show what would happen +-q, --quiet Suppress info output +-h, --help Full help text +-V, --version Show version +``` + +### Output Naming Convention + +Auto-generated filenames follow the pattern: + +``` +incusos-{app}-{defaults|bare}-{arch}.{iso|img} +``` + +Examples: +- `incusos-incus-defaults-x86_64.iso` +- `incusos-ops-center-bare-aarch64.iso` +- `incusos-incus-defaults-x86_64.img` + +### Batch Mode Matrix + +With `--batch`, the following variants are generated: + +| # | Architecture | Application | Defaults | Filename | +|---|-------------|-------------|----------|----------| +| 1 | x86_64 | Incus | yes | `incusos-incus-defaults-x86_64.iso` | +| 2 | x86_64 | Incus | no | `incusos-incus-bare-x86_64.iso` | +| 3 | x86_64 | Ops Center | yes | `incusos-ops-center-defaults-x86_64.iso` | +| 4 | x86_64 | Ops Center | no | `incusos-ops-center-bare-x86_64.iso` | +| 5 | aarch64 | Incus | yes | `incusos-incus-defaults-aarch64.iso` | +| 6 | aarch64 | Incus | no | `incusos-incus-bare-aarch64.iso` | +| 7 | aarch64 | Ops Center | yes | `incusos-ops-center-defaults-aarch64.iso` | +| 8 | aarch64 | Ops Center | no | `incusos-ops-center-bare-aarch64.iso` | + +Customize with `--batch-arches` and `--batch-apps`: + +```bash +# Only x86_64 Incus variants +./incusos-iso --batch --batch-arches x86_64 --batch-apps incus +``` + +## incusos-seed + +Generates IncusOS seed archives containing the YAML configuration files +that customize an IncusOS installation. Output can be: + +- **tar archive** (default) -- for use with `flasher-tool --seed-tar` +- **FAT image** -- for use as an external SEED_DATA partition/disk + +### Quick Start + +```bash +# Standalone Incus with defaults and auto-detected certificate +./incusos-seed --defaults + +# Operations Center seed +./incusos-seed --app operations-center --defaults --cert ~/.config/incus/client.crt + +# Cluster node (no defaults) targeting virtio disks +./incusos-seed --disk-target 'virtio-*' --force-install --force-reboot + +# FAT image for external boot media +./incusos-seed --format fat --defaults --output seed.img + +# Preview generated YAML without creating files +./incusos-seed --defaults --dry-run +``` + +### Options Reference + +``` +-A, --app APP incus or operations-center (default: incus) +-d, --defaults Apply default configuration +-c, --cert FILE Client certificate PEM file (default: auto-detect) + --no-cert Skip certificate injection +-o, --output FILE Output file (default: incusos-seed.tar) +-f, --format FORMAT tar or fat (default: tar) + --fat-size SIZE FAT image size in MiB (default: 10) + --disk-target ID Target disk pattern + --force-install Overwrite existing installation + --force-reboot Auto-reboot after installation + --missing-tpm Allow install without TPM 2.0 + --missing-secureboot Allow install without Secure Boot + --hostname NAME Set hostname + --domain NAME Set domain + --dns SERVERS Comma-separated DNS servers + --channel CHANNEL stable or testing (default: stable) + --auto-reboot Auto-reboot for updates + --dry-run Preview YAML output +-q, --quiet Suppress info output +-h, --help Full help text +-V, --version Show version +``` + +### Seed Files Reference + +The following YAML files are generated inside the archive: + +| File | Always | Description | +|------|--------|-------------| +| `install.yaml` | yes | Installation target, security, reboot settings | +| `applications.yaml` | yes | Application selection | +| `incus.yaml` | when app=incus | Incus preseed (defaults, certificates) | +| `operations-center.yaml` | when app=ops-center | Operations Center config | +| `network.yaml` | when network opts set | Hostname, DNS, static IP | +| `update.yaml` | when non-default | Update channel, auto-reboot | + +### Using the FAT Image (SEED_DATA) + +The FAT output format creates a small disk image labeled `SEED_DATA` that can be +attached to a VM as a secondary disk. IncusOS detects this label at boot and +reads the seed configuration from it. + +This is useful for the "pristine image" workflow: + +1. Download one uncustomized IncusOS ISO +2. Generate per-node seed images with `incusos-seed --format fat` +3. Boot each VM with both the ISO and the node-specific seed image + +```bash +# Generate seeds for three cluster nodes +for i in 1 2 3; do + ./incusos-seed --format fat \ + --hostname "node-${i}" \ + --disk-target 'virtio-*' \ + --force-install \ + --output "seed-node-${i}.img" +done +``` + +## Client Certificate Auto-Detection + +Both scripts automatically look for a client certificate in this order: + +1. `incus remote get-client-certificate` (if the `incus` CLI is installed) +2. `~/.config/incus/client.crt` +3. `~/snap/incus/common/config/client.crt` +4. `~/.config/lxc/client.crt` + +Override with `--cert FILE` or disable with `--no-cert`. + +### After Installation + +With an injected certificate, the remote is already trusted: + +```bash +incus remote add my-server +incus remote list +``` + +For web UI access, export a PKCS#12 bundle and import it into your browser: + +```bash +openssl pkcs12 -export \ + -inkey ~/.config/incus/client.key \ + -in ~/.config/incus/client.crt \ + -out ~/.config/incus/client.pfx +``` + +## Examples + +See the [`examples/`](examples/) directory for sample seed configurations: + +- [`incus-standalone.yaml`](examples/incus-standalone.yaml) -- Single Incus server with defaults +- [`incus-cluster-node.yaml`](examples/incus-cluster-node.yaml) -- Cluster node (no defaults) +- [`ops-center.yaml`](examples/ops-center.yaml) -- Operations Center +- [`network-static.yaml`](examples/network-static.yaml) -- Static network configuration + +## Security Notes + +- **TPM 2.0** and **Secure Boot** are required by default. Use `--missing-tpm` and + `--missing-secureboot` only for lab environments or VMs that lack these features. +- All storage pools are **automatically encrypted** by IncusOS. +- Retrieve encryption recovery keys after installation with: + `incus admin os system security show` +- Keep recovery keys safe -- they are needed if TPM state changes. diff --git a/incusos/examples/incus-cluster-node.yaml b/incusos/examples/incus-cluster-node.yaml new file mode 100644 index 0000000..d61c2b9 --- /dev/null +++ b/incusos/examples/incus-cluster-node.yaml @@ -0,0 +1,38 @@ +# Example: Incus cluster node (no defaults) +# +# For cluster nodes, apply_defaults should be false to avoid +# creating conflicting storage pools and network bridges. +# The cluster leader will push configuration to joining nodes. +# +# Usage with incusos-seed: +# incusos-seed --cert ~/.config/incus/client.crt --hostname node-02 +# +# After installation, join the cluster with: +# incus admin init (on the node, choosing "join existing cluster") + +# --- incus.yaml --- +apply_defaults: false +preseed: + server: + certificates: + - name: "my-workstation" + type: "client" + certificate: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + +# --- network.yaml --- +# dns: +# hostname: "node-02" +# domain: "lab.local" +# nameservers: +# - "192.168.1.1" +# interfaces: +# - name: "mgmt" +# hwaddr: "enp5s0" +# addresses: +# - "dhcp4" +# roles: +# - management +# - cluster diff --git a/incusos/examples/incus-standalone.yaml b/incusos/examples/incus-standalone.yaml new file mode 100644 index 0000000..5265bf9 --- /dev/null +++ b/incusos/examples/incus-standalone.yaml @@ -0,0 +1,38 @@ +# Example: Standalone Incus server with defaults +# +# This seed configures a single Incus server with: +# - ZFS storage pool "local" on remaining disk space +# - Network bridge "incusbr0" +# - Listening on port 8443 on all interfaces +# - Client certificate pre-trusted +# +# Usage with incusos-seed: +# incusos-seed --defaults --cert ~/.config/incus/client.crt +# +# Usage with flasher-tool (place files in a tar archive): +# tar -cf seed.tar install.yaml applications.yaml incus.yaml +# flasher-tool --seed-tar seed.tar + +# --- install.yaml --- +# force_install: true +# force_reboot: true +# target: +# id: "virtio-*" +# security: +# missing_tpm: true + +# --- applications.yaml --- +# applications: +# - name: incus + +# --- incus.yaml --- +apply_defaults: true +preseed: + server: + certificates: + - name: "my-workstation" + type: "client" + certificate: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- diff --git a/incusos/examples/network-static.yaml b/incusos/examples/network-static.yaml new file mode 100644 index 0000000..2adbd1a --- /dev/null +++ b/incusos/examples/network-static.yaml @@ -0,0 +1,55 @@ +# Example: Static network configuration +# +# IncusOS defaults to DHCP/SLAAC when no network seed is provided. +# Use this template for static IP configuration. +# +# This file goes into the seed archive as "network.yaml" + +dns: + hostname: "incus-01" + domain: "lab.local" + nameservers: + - "192.168.1.1" + - "1.1.1.1" + search_domains: + - "lab.local" + +time: + ntp_servers: + - "pool.ntp.org" + timezone: "America/New_York" + +interfaces: + - name: "mgmt" + hwaddr: "enp5s0" # interface name or MAC address + addresses: + - "192.168.1.100/24" + roles: + - management + - cluster + mtu: 1500 + routes: + - to: "0.0.0.0/0" + via: "192.168.1.1" + +# --- Bonding example (uncomment to use) --- +# bonds: +# - name: "bond0" +# members: +# - "enp5s0" +# - "enp6s0" +# mode: "802.3ad" +# addresses: +# - "dhcp4" +# roles: +# - management + +# --- VLAN example (uncomment to use) --- +# vlans: +# - name: "vlan100" +# parent: "enp5s0" +# id: 100 +# addresses: +# - "10.100.0.10/24" +# roles: +# - instances diff --git a/incusos/examples/ops-center.yaml b/incusos/examples/ops-center.yaml new file mode 100644 index 0000000..3719963 --- /dev/null +++ b/incusos/examples/ops-center.yaml @@ -0,0 +1,31 @@ +# Example: Operations Center +# +# Operations Center provides centralized management for multiple IncusOS nodes. +# It can also serve as an update provider for air-gapped environments. +# +# IMPORTANT: At least one trusted client certificate must be provided, +# otherwise you will be locked out of the Operations Center after installation. +# +# Usage with incusos-seed: +# incusos-seed --app operations-center --defaults --cert ~/.config/incus/client.crt +# +# Usage with incusos-iso: +# incusos-iso --app operations-center --defaults + +# --- operations-center.yaml --- +apply_defaults: true +trusted_client_certificates: + - | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + +# --- network.yaml (optional) --- +# dns: +# hostname: "ops-center" +# domain: "lab.local" + +# --- update.yaml (optional) --- +# channel: "stable" +# auto_reboot: false +# check_frequency: "6h" diff --git a/incusos/incusos-iso b/incusos/incusos-iso new file mode 100755 index 0000000..2205d58 --- /dev/null +++ b/incusos/incusos-iso @@ -0,0 +1,946 @@ +#!/usr/bin/env bash +# incusos-iso - Build IncusOS ISO/IMG images using the official flasher-tool +# +# Wrapper around the IncusOS flasher-tool that adds cross-architecture support, +# automatic seed generation, client certificate injection, and batch builds. +# +# Usage: incusos-iso [OPTIONS] +# Run 'incusos-iso --help' for full usage information. + +set -euo pipefail + +readonly VERSION="0.1.0" +readonly SCRIPT_NAME="incusos-iso" +readonly CDN_INDEX="https://images.linuxcontainers.org/os/index.json" +readonly CDN_BASE="https://images.linuxcontainers.org/os" + +# --------------------------------------------------------------------------- +# Color and formatting +# --------------------------------------------------------------------------- + +setup_colors() { + if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + DIM='\033[2m' + RESET='\033[0m' + else + RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET='' + fi +} + +info() { echo -e "${BLUE}[info]${RESET} $*"; } +success() { echo -e "${GREEN}[ok]${RESET} $*"; } +warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; } +error() { echo -e "${RED}[error]${RESET} $*" >&2; } +step() { echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; } +detail() { echo -e "${DIM} $*${RESET}"; } + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +usage() { + cat <&2 + exit 1 + ;; + *) + error "Unexpected argument: $1" + echo "Run '${SCRIPT_NAME} --help' for usage information." >&2 + exit 1 + ;; + esac + done +} + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +detect_arch() { + local machine + machine=$(uname -m) + case "$machine" in + x86_64) echo "x86_64" ;; + aarch64) echo "aarch64" ;; + arm64) echo "aarch64" ;; + *) + error "Unsupported architecture: ${machine}" + error "IncusOS supports x86_64 and aarch64 only" + exit 1 + ;; + esac +} + +validate_args() { + # Auto-detect architecture if not specified + if [[ -z "$ARCH" ]]; then + ARCH=$(detect_arch) + [[ "$QUIET" != true ]] && info "Auto-detected architecture: ${ARCH}" + fi + + case "$ARCH" in + x86_64|aarch64) ;; + *) + error "Invalid architecture: ${ARCH}" + error "Supported: x86_64, aarch64" + exit 1 + ;; + esac + + case "$APP" in + incus|operations-center) ;; + *) + error "Invalid application: ${APP}" + error "Supported: incus, operations-center" + exit 1 + ;; + esac + + case "$IMG_FORMAT" in + iso|img) ;; + *) + error "Invalid image format: ${IMG_FORMAT}" + error "Supported: iso, img" + exit 1 + ;; + esac + + case "$CHANNEL" in + stable|testing) ;; + *) + error "Invalid channel: ${CHANNEL}" + error "Supported: stable, testing" + exit 1 + ;; + esac + + if [[ -n "$CERT_FILE" ]] && [[ "$NO_CERT" == true ]]; then + error "Cannot use both --cert and --no-cert" + exit 1 + fi + + if [[ -n "$CERT_FILE" ]] && [[ ! -f "$CERT_FILE" ]]; then + error "Certificate file not found: ${CERT_FILE}" + exit 1 + fi + + if [[ -n "$SEED_FILE" ]] && [[ ! -f "$SEED_FILE" ]]; then + error "Seed file not found: ${SEED_FILE}" + exit 1 + fi + + if [[ -n "$LOCAL_IMAGE" ]] && [[ ! -f "$LOCAL_IMAGE" ]]; then + error "Local image not found: ${LOCAL_IMAGE}" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Dependency checks +# --------------------------------------------------------------------------- + +FLASHER_TOOL="" + +find_flasher_tool() { + # Check common locations + local candidates=( + "flasher-tool" + "${GOPATH:-${HOME}/go}/bin/flasher-tool" + "${HOME}/go/bin/flasher-tool" + "/usr/local/bin/flasher-tool" + ) + + for candidate in "${candidates[@]}"; do + if command -v "$candidate" &>/dev/null; then + FLASHER_TOOL="$candidate" + return + fi + if [[ -x "$candidate" ]]; then + FLASHER_TOOL="$candidate" + return + fi + done +} + +check_dependencies() { + step "Checking dependencies" + + # flasher-tool + find_flasher_tool + if [[ -z "$FLASHER_TOOL" ]]; then + error "flasher-tool not found" + echo "" >&2 + echo " Install the flasher-tool with:" >&2 + echo " go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest" >&2 + echo "" >&2 + echo " Make sure \$GOPATH/bin is in your PATH:" >&2 + echo " export PATH=\"\${GOPATH:-\$HOME/go}/bin:\$PATH\"" >&2 + echo "" >&2 + + # Offer to install if Go is available + if command -v go &>/dev/null; then + echo -n " Go is installed. Install flasher-tool now? [Y/n] " >&2 + local reply + read -r reply + if [[ "$reply" =~ ^[Yy]?$ ]]; then + install_flasher_tool + else + exit 1 + fi + else + error "Go is not installed. Install Go 1.21+ first, then the flasher-tool." + exit 1 + fi + else + success "flasher-tool: ${FLASHER_TOOL}" + fi + + # curl (needed for cross-arch downloads) + if ! command -v curl &>/dev/null; then + warn "curl not found -- cross-architecture downloads will not be available" + else + detail "curl: $(command -v curl)" + fi + + # gzip + if ! command -v gzip &>/dev/null; then + warn "gzip not found -- image decompression may fail" + fi + + # tar + if ! command -v tar &>/dev/null; then + error "tar is required but not found" + exit 1 + fi +} + +install_flasher_tool() { + step "Installing flasher-tool" + info "Running: go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest" + + if go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest; then + find_flasher_tool + if [[ -n "$FLASHER_TOOL" ]]; then + success "flasher-tool installed: ${FLASHER_TOOL}" + else + # Try adding GOPATH/bin to PATH for this session + export PATH="${GOPATH:-${HOME}/go}/bin:${PATH}" + find_flasher_tool + if [[ -n "$FLASHER_TOOL" ]]; then + success "flasher-tool installed: ${FLASHER_TOOL}" + else + error "flasher-tool was installed but could not be found in PATH" + exit 1 + fi + fi + else + error "Failed to install flasher-tool" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Certificate detection +# --------------------------------------------------------------------------- + +CERT_CONTENT="" + +detect_client_cert() { + if [[ "$NO_CERT" == true ]]; then + [[ "$QUIET" != true ]] && info "Skipping certificate injection (--no-cert)" + return + fi + + if [[ -n "$CERT_FILE" ]]; then + step "Loading client certificate from ${CERT_FILE}" + CERT_CONTENT=$(<"$CERT_FILE") + if [[ "$CERT_CONTENT" != *"BEGIN CERTIFICATE"* ]]; then + error "File does not appear to be a PEM certificate: ${CERT_FILE}" + exit 1 + fi + success "Certificate loaded" + return + fi + + # Auto-detection + step "Auto-detecting client certificate" + + # Try incus CLI + if command -v incus &>/dev/null; then + local cert + if cert=$(incus remote get-client-certificate 2>/dev/null); then + CERT_CONTENT="$cert" + success "Certificate obtained from Incus CLI" + return + fi + fi + + # Try common file locations + local cert_paths=( + "${HOME}/.config/incus/client.crt" + "${HOME}/snap/incus/common/config/client.crt" + "${HOME}/.config/lxc/client.crt" + ) + + for path in "${cert_paths[@]}"; do + if [[ -f "$path" ]]; then + CERT_CONTENT=$(<"$path") + success "Certificate loaded from ${path}" + return + fi + done + + warn "No client certificate found -- image will require manual certificate setup" +} + +# --------------------------------------------------------------------------- +# CDN operations +# --------------------------------------------------------------------------- + +fetch_latest_version() { + local channel="$1" + + step "Fetching latest IncusOS version (channel: ${channel})" + + local index_file + index_file=$(mktemp "${TMPDIR:-/tmp}/incusos-index-XXXXXX.json") + + if ! curl -fsSL "$CDN_INDEX" -o "$index_file" 2>/dev/null; then + error "Failed to fetch CDN index from ${CDN_INDEX}" + rm -f "$index_file" + exit 1 + fi + + local version="" + + # Parse with jq if available, otherwise use python3 + if command -v jq &>/dev/null; then + version=$(jq -r \ + --arg ch "$channel" \ + '[.updates[] | select(.channels[] == $ch)] | sort_by(.version) | last | .version // empty' \ + "$index_file") + elif command -v python3 &>/dev/null; then + version=$(python3 -c " +import json, sys +data = json.load(open('$index_file')) +versions = [u['version'] for u in data.get('updates', []) if '$channel' in u.get('channels', [])] +if versions: + print(sorted(versions)[-1]) +" 2>/dev/null) + else + error "Either jq or python3 is required to parse the CDN index" + rm -f "$index_file" + exit 1 + fi + + rm -f "$index_file" + + if [[ -z "$version" ]]; then + error "No version found for channel '${channel}'" + exit 1 + fi + + success "Latest version: ${version}" + echo "$version" +} + +download_image() { + local version="$1" + local arch="$2" + local format="$3" + local output_path="$4" + + local url="${CDN_BASE}/${version}/${arch}/IncusOS_${version}.${format}.gz" + local gz_path="${output_path}.gz" + + step "Downloading IncusOS image" + detail "URL: ${url}" + detail "Target: ${output_path}" + + if ! curl -fL --progress-bar "$url" -o "$gz_path"; then + error "Failed to download image from ${url}" + rm -f "$gz_path" + exit 1 + fi + + step "Decompressing image" + if ! gzip -d "$gz_path"; then + error "Failed to decompress image" + rm -f "$gz_path" + exit 1 + fi + + success "Image ready: ${output_path}" +} + +# --------------------------------------------------------------------------- +# Seed generation (inline, for when incusos-seed is not used separately) +# --------------------------------------------------------------------------- + +generate_seed_tar() { + local output_tar="$1" + local workdir + workdir=$(mktemp -d "${TMPDIR:-/tmp}/incusos-seed-XXXXXX") + + step "Generating seed archive" + + # install.yaml + { + echo "# Generated by ${SCRIPT_NAME} v${VERSION}" + [[ "$FORCE_INSTALL" == true ]] && echo "force_install: true" + [[ "$FORCE_REBOOT" == true ]] && echo "force_reboot: true" + if [[ -n "$DISK_TARGET" ]]; then + echo "target:" + echo " id: \"${DISK_TARGET}\"" + fi + if [[ "$MISSING_TPM" == true ]] || [[ "$MISSING_SECUREBOOT" == true ]]; then + echo "security:" + [[ "$MISSING_TPM" == true ]] && echo " missing_tpm: true" + [[ "$MISSING_SECUREBOOT" == true ]] && echo " missing_secure_boot: true" + fi + } > "${workdir}/install.yaml" + detail "install.yaml" + + # applications.yaml + { + echo "applications:" + echo " - name: ${APP}" + } > "${workdir}/applications.yaml" + detail "applications.yaml (${APP})" + + # App-specific seed + case "$APP" in + incus) + { + echo "apply_defaults: ${APPLY_DEFAULTS}" + if [[ -n "$CERT_CONTENT" ]]; then + echo "preseed:" + echo " server:" + echo " certificates:" + echo " - name: \"auto-injected-client\"" + echo " type: \"client\"" + echo " certificate: |" + while IFS= read -r line; do + echo " ${line}" + done <<< "$CERT_CONTENT" + fi + } > "${workdir}/incus.yaml" + detail "incus.yaml (defaults=${APPLY_DEFAULTS})" + ;; + operations-center) + { + echo "apply_defaults: ${APPLY_DEFAULTS}" + if [[ -n "$CERT_CONTENT" ]]; then + echo "trusted_client_certificates:" + echo " - |" + while IFS= read -r line; do + echo " ${line}" + done <<< "$CERT_CONTENT" + fi + } > "${workdir}/operations-center.yaml" + detail "operations-center.yaml (defaults=${APPLY_DEFAULTS})" + ;; + esac + + # network.yaml (optional) + if [[ -n "$HOSTNAME_OPT" ]]; then + { + echo "dns:" + echo " hostname: \"${HOSTNAME_OPT}\"" + } > "${workdir}/network.yaml" + detail "network.yaml (hostname=${HOSTNAME_OPT})" + fi + + # update.yaml (optional) + if [[ "$CHANNEL" != "stable" ]]; then + { + echo "channel: \"${CHANNEL}\"" + echo "auto_reboot: false" + echo "check_frequency: \"6h\"" + } > "${workdir}/update.yaml" + detail "update.yaml (channel=${CHANNEL})" + fi + + # Create tar + tar -cf "$output_tar" -C "$workdir" . + rm -rf "$workdir" + + success "Seed archive: ${output_tar}" +} + +# --------------------------------------------------------------------------- +# ISO/IMG building +# --------------------------------------------------------------------------- + +generate_output_name() { + local arch="$1" + local app="$2" + local defaults="$3" + local format="$4" + local dir="$5" + + local defaults_tag + if [[ "$defaults" == true ]]; then + defaults_tag="defaults" + else + defaults_tag="bare" + fi + + local app_tag + case "$app" in + incus) app_tag="incus" ;; + operations-center) app_tag="ops-center" ;; + esac + + echo "${dir}/incusos-${app_tag}-${defaults_tag}-${arch}.${format}" +} + +build_single() { + local arch="$1" + local app="$2" + local defaults="$3" + local format="$4" + local output="$5" + local seed="$6" + local image="$7" + + echo "" + echo -e "${BOLD}Building: ${CYAN}${output}${RESET}" + echo -e " Arch: ${arch} App: ${app} Defaults: ${defaults} Format: ${format}" + echo "" + + if [[ "$DRY_RUN" == true ]]; then + info "Dry run -- skipping build" + return + fi + + local tmpdir + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/incusos-build-XXXXXX") + trap 'rm -rf "$tmpdir"' RETURN + + # Generate seed if not provided + local seed_tar="$seed" + if [[ -z "$seed_tar" ]]; then + seed_tar="${tmpdir}/seed.tar" + + # Save and set state for seed generation + local save_app="$APP" save_defaults="$APPLY_DEFAULTS" + APP="$app" + APPLY_DEFAULTS="$defaults" + + generate_seed_tar "$seed_tar" + + APP="$save_app" + APPLY_DEFAULTS="$save_defaults" + fi + + # Determine image path + local image_path="$image" + local needs_download=false + + if [[ -z "$image_path" ]]; then + # Check if we need a cross-architecture download + local current_arch + current_arch=$(detect_arch) + + if [[ "$arch" != "$current_arch" ]]; then + # Cross-architecture: must download manually + needs_download=true + fi + # Same architecture: flasher-tool can download itself + fi + + # Build flasher-tool command + local cmd=("$FLASHER_TOOL") + cmd+=(--format "$format") + cmd+=(--seed-tar "$seed_tar") + cmd+=(--channel "$CHANNEL") + + if [[ "$needs_download" == true ]]; then + # Download cross-arch image + local version + version=$(fetch_latest_version "$CHANNEL" | tail -1) + image_path="${tmpdir}/IncusOS_${version}.${format}" + download_image "$version" "$arch" "$format" "$image_path" + cmd+=(--image "$image_path") + elif [[ -n "$image_path" ]]; then + cmd+=(--image "$image_path") + fi + + step "Running flasher-tool" + detail "${cmd[*]}" + + # The flasher-tool writes to the image in place when --image is given, + # or downloads and writes when --image is not given. + # We need to handle the output file. + if [[ -n "$image_path" ]]; then + # flasher-tool modifies the image in place + "${cmd[@]}" + + # Move to final output location + if [[ "$image_path" != "$output" ]]; then + mv "$image_path" "$output" + fi + else + # flasher-tool will download and create output interactively + # We run it and let it produce the file, then rename + "${cmd[@]}" + + # Find the produced file (flasher-tool outputs to current directory) + local produced + produced=$(ls -t IncusOS_*.${format} 2>/dev/null | head -1) + if [[ -n "$produced" ]] && [[ "$produced" != "$output" ]]; then + mv "$produced" "$output" + fi + fi + + echo "" + success "Image built: ${output}" + + local size + if [[ -f "$output" ]]; then + size=$(du -h "$output" | cut -f1) + detail "Size: ${size}" + fi +} + +# --------------------------------------------------------------------------- +# Batch mode +# --------------------------------------------------------------------------- + +batch_build() { + IFS=',' read -ra arches <<< "$BATCH_ARCHES" + IFS=',' read -ra apps <<< "$BATCH_APPS" + + local total=0 + local variants=() + + # Build matrix: arch x app x defaults(true/false) + for arch in "${arches[@]}"; do + for app in "${apps[@]}"; do + for defaults in true false; do + local outfile + outfile=$(generate_output_name "$arch" "$app" "$defaults" "$IMG_FORMAT" "$OUTPUT_DIR") + variants+=("${arch}|${app}|${defaults}|${outfile}") + total=$((total + 1)) + done + done + done + + echo -e "${BOLD}Batch build: ${total} variants${RESET}" + echo "" + + local i=0 + for variant in "${variants[@]}"; do + IFS='|' read -r arch app defaults outfile <<< "$variant" + i=$((i + 1)) + echo -e "${BOLD}[${i}/${total}]${RESET} ${outfile}" + + if [[ "$DRY_RUN" == true ]]; then + detail "arch=${arch} app=${app} defaults=${defaults} format=${IMG_FORMAT}" + continue + fi + + build_single "$arch" "$app" "$defaults" "$IMG_FORMAT" "$outfile" "$SEED_FILE" "$LOCAL_IMAGE" + done + + echo "" + if [[ "$DRY_RUN" == true ]]; then + info "Dry run complete -- no images were built" + else + success "Batch build complete: ${total} images generated" + fi +} + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +print_summary() { + echo "" + echo -e "${BOLD}Build summary${RESET}" + echo -e " Architecture: ${CYAN}${ARCH}${RESET}" + echo -e " Application: ${CYAN}${APP}${RESET}" + echo -e " Defaults: $(if [[ "$APPLY_DEFAULTS" == true ]]; then echo "${GREEN}yes${RESET}"; else echo "${YELLOW}no${RESET}"; fi)" + echo -e " Certificate: $(if [[ -n "$CERT_CONTENT" ]]; then echo "${GREEN}injected${RESET}"; else echo "${DIM}none${RESET}"; fi)" + echo -e " Format: ${IMG_FORMAT}" + echo -e " Channel: ${CHANNEL}" + echo -e " Output: ${BOLD}${OUTPUT}${RESET}" + + if [[ "$MISSING_TPM" == true ]]; then + echo -e " Security: ${YELLOW}TPM not required${RESET}" + fi + + echo "" + + if [[ -n "$CERT_CONTENT" ]]; then + echo -e "${DIM} The client certificate has been injected into the image.${RESET}" + echo -e "${DIM} After installation, add the remote with:${RESET}" + echo -e " incus remote add " + else + echo -e "${DIM} No certificate was injected. After installation:${RESET}" + echo -e " incus remote add --accept-certificate" + fi + echo "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + setup_colors + parse_args "$@" + validate_args + + if [[ "$QUIET" != true ]]; then + echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION}" + echo "" + fi + + if [[ "$DRY_RUN" != true ]]; then + check_dependencies + else + find_flasher_tool + if [[ -z "$FLASHER_TOOL" ]]; then + FLASHER_TOOL="flasher-tool (not installed)" + fi + fi + echo "" + + if [[ "$BATCH" == true ]]; then + # In batch mode, cert detection applies to all builds + if [[ -z "$SEED_FILE" ]]; then + detect_client_cert + fi + echo "" + batch_build + return + fi + + # Single build mode + if [[ -z "$SEED_FILE" ]]; then + detect_client_cert + fi + + # Set default output name + if [[ -z "$OUTPUT" ]]; then + OUTPUT=$(generate_output_name "$ARCH" "$APP" "$APPLY_DEFAULTS" "$IMG_FORMAT" "$OUTPUT_DIR") + fi + + build_single "$ARCH" "$APP" "$APPLY_DEFAULTS" "$IMG_FORMAT" "$OUTPUT" "$SEED_FILE" "$LOCAL_IMAGE" + + if [[ "$QUIET" != true ]] && [[ "$DRY_RUN" != true ]]; then + print_summary + fi +} + +main "$@" diff --git a/incusos/incusos-seed b/incusos/incusos-seed new file mode 100755 index 0000000..654dae9 --- /dev/null +++ b/incusos/incusos-seed @@ -0,0 +1,706 @@ +#!/usr/bin/env bash +# incusos-seed - Generate IncusOS seed archives for installation customization +# +# Creates tar archives or FAT images containing seed configuration files +# for IncusOS installations. Supports all seed file types including install, +# applications, Incus preseed, Operations Center, network, and update config. +# +# Usage: incusos-seed [OPTIONS] +# Run 'incusos-seed --help' for full usage information. + +set -euo pipefail + +readonly VERSION="0.1.0" +readonly SCRIPT_NAME="incusos-seed" + +# --------------------------------------------------------------------------- +# Color and formatting +# --------------------------------------------------------------------------- + +setup_colors() { + if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + DIM='\033[2m' + RESET='\033[0m' + else + RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET='' + fi +} + +info() { echo -e "${BLUE}[info]${RESET} $*"; } +success() { echo -e "${GREEN}[ok]${RESET} $*"; } +warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; } +error() { echo -e "${RED}[error]${RESET} $*" >&2; } +step() { echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; } +detail() { echo -e "${DIM} $*${RESET}"; } + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +usage() { + cat <&2 + exit 1 + ;; + *) + error "Unexpected argument: $1" + echo "Run '${SCRIPT_NAME} --help' for usage information." >&2 + exit 1 + ;; + esac + done +} + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +validate_args() { + case "$APP" in + incus|operations-center) ;; + *) + error "Invalid application: ${APP}" + error "Supported: incus, operations-center" + exit 1 + ;; + esac + + case "$FORMAT" in + tar|fat) ;; + *) + error "Invalid output format: ${FORMAT}" + error "Supported: tar, fat" + exit 1 + ;; + esac + + case "$CHANNEL" in + stable|testing) ;; + *) + error "Invalid channel: ${CHANNEL}" + error "Supported: stable, testing" + exit 1 + ;; + esac + + if [[ -n "$CERT_FILE" ]] && [[ "$NO_CERT" == true ]]; then + error "Cannot use both --cert and --no-cert" + exit 1 + fi + + if [[ -n "$CERT_FILE" ]] && [[ ! -f "$CERT_FILE" ]]; then + error "Certificate file not found: ${CERT_FILE}" + exit 1 + fi + + if [[ "$FORMAT" == "fat" ]]; then + if ! command -v mcopy &>/dev/null && ! command -v mkfs.fat &>/dev/null; then + error "FAT image creation requires mtools (mcopy) or dosfstools (mkfs.fat)" + error "Install with: sudo apt install mtools dosfstools" + exit 1 + fi + fi + + # Set default output filename + if [[ -z "$OUTPUT" ]]; then + case "$FORMAT" in + tar) OUTPUT="incusos-seed.tar" ;; + fat) OUTPUT="incusos-seed.img" ;; + esac + fi +} + +# --------------------------------------------------------------------------- +# Certificate detection +# --------------------------------------------------------------------------- + +CERT_CONTENT="" + +detect_client_cert() { + if [[ "$NO_CERT" == true ]]; then + [[ "$QUIET" != true ]] && info "Skipping certificate injection (--no-cert)" + return + fi + + if [[ -n "$CERT_FILE" ]]; then + step "Loading client certificate from ${CERT_FILE}" + CERT_CONTENT=$(<"$CERT_FILE") + if [[ "$CERT_CONTENT" != *"BEGIN CERTIFICATE"* ]]; then + error "File does not appear to be a PEM certificate: ${CERT_FILE}" + exit 1 + fi + success "Certificate loaded" + return + fi + + # Auto-detection + step "Auto-detecting client certificate" + + # Try incus CLI first + if command -v incus &>/dev/null; then + local cert + if cert=$(incus remote get-client-certificate 2>/dev/null); then + CERT_CONTENT="$cert" + success "Certificate obtained from Incus CLI" + return + fi + detail "Incus CLI available but could not retrieve certificate" + fi + + # Try common file locations + local cert_paths=( + "${HOME}/.config/incus/client.crt" + "${HOME}/snap/incus/common/config/client.crt" + "${HOME}/.config/lxc/client.crt" + ) + + for path in "${cert_paths[@]}"; do + if [[ -f "$path" ]]; then + CERT_CONTENT=$(<"$path") + success "Certificate loaded from ${path}" + return + fi + done + + warn "No client certificate found" + warn "The installed system will require manual certificate trust setup" + warn "Use --cert FILE to specify a certificate, or --no-cert to suppress this warning" +} + +# --------------------------------------------------------------------------- +# Seed file generation +# --------------------------------------------------------------------------- + +WORKDIR="" + +setup_workdir() { + WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}-XXXXXX") + trap 'rm -rf "$WORKDIR"' EXIT +} + +generate_install_yaml() { + local file="${WORKDIR}/install.yaml" + + step "Generating install.yaml" + + { + echo "# IncusOS installation configuration" + echo "# Generated by ${SCRIPT_NAME} v${VERSION} on $(date -Iseconds)" + + if [[ "$FORCE_INSTALL" == true ]]; then + echo "force_install: true" + fi + + if [[ "$FORCE_REBOOT" == true ]]; then + echo "force_reboot: true" + fi + + if [[ -n "$DISK_TARGET" ]]; then + echo "target:" + echo " id: \"${DISK_TARGET}\"" + fi + + if [[ "$MISSING_TPM" == true ]] || [[ "$MISSING_SECUREBOOT" == true ]]; then + echo "security:" + if [[ "$MISSING_TPM" == true ]]; then + echo " missing_tpm: true" + fi + if [[ "$MISSING_SECUREBOOT" == true ]]; then + echo " missing_secure_boot: true" + fi + fi + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- install.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + success "install.yaml" + fi +} + +generate_applications_yaml() { + local file="${WORKDIR}/applications.yaml" + + step "Generating applications.yaml" + + { + echo "# IncusOS application selection" + echo "applications:" + echo " - name: ${APP}" + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- applications.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + success "applications.yaml (${APP})" + fi +} + +generate_incus_yaml() { + local file="${WORKDIR}/incus.yaml" + + step "Generating incus.yaml" + + { + echo "# Incus preseed configuration" + echo "apply_defaults: ${APPLY_DEFAULTS}" + + if [[ -n "$CERT_CONTENT" ]]; then + echo "preseed:" + echo " server:" + echo " certificates:" + echo " - name: \"auto-injected-client\"" + echo " type: \"client\"" + echo " certificate: |" + # Indent each line of the certificate + while IFS= read -r line; do + echo " ${line}" + done <<< "$CERT_CONTENT" + fi + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- incus.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + local desc="defaults=$(bool_str $APPLY_DEFAULTS)" + [[ -n "$CERT_CONTENT" ]] && desc+=", certificate=injected" + success "incus.yaml (${desc})" + fi +} + +generate_ops_center_yaml() { + local file="${WORKDIR}/operations-center.yaml" + + step "Generating operations-center.yaml" + + { + echo "# Operations Center configuration" + echo "apply_defaults: ${APPLY_DEFAULTS}" + + if [[ -n "$CERT_CONTENT" ]]; then + echo "trusted_client_certificates:" + echo " - |" + while IFS= read -r line; do + echo " ${line}" + done <<< "$CERT_CONTENT" + fi + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- operations-center.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + local desc="defaults=$(bool_str $APPLY_DEFAULTS)" + [[ -n "$CERT_CONTENT" ]] && desc+=", certificate=injected" + success "operations-center.yaml (${desc})" + fi +} + +generate_network_yaml() { + # Only generate if network options were specified + if [[ -z "$HOSTNAME_OPT" ]] && [[ -z "$DOMAIN_OPT" ]] && [[ -z "$DNS_SERVERS" ]]; then + return + fi + + local file="${WORKDIR}/network.yaml" + + step "Generating network.yaml" + + { + echo "# Network configuration" + + if [[ -n "$HOSTNAME_OPT" ]] || [[ -n "$DOMAIN_OPT" ]] || [[ -n "$DNS_SERVERS" ]]; then + echo "dns:" + [[ -n "$HOSTNAME_OPT" ]] && echo " hostname: \"${HOSTNAME_OPT}\"" + [[ -n "$DOMAIN_OPT" ]] && echo " domain: \"${DOMAIN_OPT}\"" + + if [[ -n "$DNS_SERVERS" ]]; then + echo " nameservers:" + IFS=',' read -ra servers <<< "$DNS_SERVERS" + for srv in "${servers[@]}"; do + echo " - \"$(echo "$srv" | xargs)\"" + done + fi + fi + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- network.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + local desc="" + [[ -n "$HOSTNAME_OPT" ]] && desc+="hostname=${HOSTNAME_OPT}" + [[ -n "$DNS_SERVERS" ]] && desc+=", dns=${DNS_SERVERS}" + success "network.yaml (${desc})" + fi +} + +generate_update_yaml() { + # Only generate if non-default values were specified + if [[ "$CHANNEL" == "stable" ]] && [[ "$AUTO_REBOOT" == false ]]; then + return + fi + + local file="${WORKDIR}/update.yaml" + + step "Generating update.yaml" + + { + echo "# Update configuration" + echo "channel: \"${CHANNEL}\"" + echo "auto_reboot: ${AUTO_REBOOT}" + echo "check_frequency: \"6h\"" + } > "$file" + + if [[ "$DRY_RUN" == true ]]; then + detail "--- update.yaml ---" + while IFS= read -r line; do detail "$line"; done < "$file" + else + success "update.yaml (channel=${CHANNEL}, auto_reboot=$(bool_str $AUTO_REBOOT))" + fi +} + +# --------------------------------------------------------------------------- +# Output creation +# --------------------------------------------------------------------------- + +create_tar_output() { + step "Creating tar archive: ${OUTPUT}" + + tar -cf "$OUTPUT" -C "$WORKDIR" . + + local size + size=$(du -h "$OUTPUT" | cut -f1) + success "Seed archive created: ${OUTPUT} (${size})" +} + +create_fat_output() { + step "Creating FAT image: ${OUTPUT} (${FAT_SIZE} MiB)" + + # Create empty image + dd if=/dev/zero of="$OUTPUT" bs=1M count="$FAT_SIZE" status=none + + if command -v mkfs.fat &>/dev/null; then + mkfs.fat -n SEED_DATA "$OUTPUT" >/dev/null + + if command -v mcopy &>/dev/null; then + # Use mtools to copy files without mounting + for f in "${WORKDIR}"/*.yaml; do + [[ -f "$f" ]] && mcopy -i "$OUTPUT" "$f" "::/$(basename "$f")" + done + else + warn "mtools not installed -- FAT image created but files not copied" + warn "Install mtools and re-run, or manually mount and copy seed files" + warn "Files are in: ${WORKDIR}/" + # Keep workdir around + trap - EXIT + return + fi + elif command -v mcopy &>/dev/null; then + # mtools can format too + mformat -i "$OUTPUT" -v SEED_DATA :: + for f in "${WORKDIR}"/*.yaml; do + [[ -f "$f" ]] && mcopy -i "$OUTPUT" "$f" "::/$(basename "$f")" + done + fi + + local size + size=$(du -h "$OUTPUT" | cut -f1) + success "FAT seed image created: ${OUTPUT} (${size})" +} + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + +bool_str() { + if [[ "$1" == true ]]; then echo "yes"; else echo "no"; fi +} + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +print_summary() { + echo "" + echo -e "${BOLD}Seed archive summary${RESET}" + echo -e " Application: ${CYAN}${APP}${RESET}" + echo -e " Defaults: $(if [[ "$APPLY_DEFAULTS" == true ]]; then echo "${GREEN}yes${RESET}"; else echo "${YELLOW}no${RESET}"; fi)" + echo -e " Certificate: $(if [[ -n "$CERT_CONTENT" ]]; then echo "${GREEN}injected${RESET}"; else echo "${DIM}none${RESET}"; fi)" + echo -e " Format: ${FORMAT}" + echo -e " Output: ${BOLD}${OUTPUT}${RESET}" + + if [[ -n "$DISK_TARGET" ]]; then + echo -e " Disk target: ${DISK_TARGET}" + fi + if [[ "$MISSING_TPM" == true ]]; then + echo -e " Security: ${YELLOW}TPM not required${RESET}" + fi + if [[ "$MISSING_SECUREBOOT" == true ]]; then + echo -e " Security: ${YELLOW}Secure Boot not required${RESET}" + fi + + echo "" + + if [[ "$FORMAT" == "tar" ]]; then + echo -e "${DIM} Use with flasher-tool:${RESET}" + echo -e " flasher-tool --seed-tar ${OUTPUT}" + elif [[ "$FORMAT" == "fat" ]]; then + echo -e "${DIM} Attach as secondary disk/ISO to the VM.${RESET}" + echo -e "${DIM} IncusOS will detect the SEED_DATA label at boot.${RESET}" + fi + echo "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + setup_colors + parse_args "$@" + validate_args + + if [[ "$QUIET" != true ]] && [[ "$DRY_RUN" != true ]]; then + echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION}" + echo "" + fi + + if [[ "$DRY_RUN" == true ]]; then + echo -e "${BOLD}Dry run -- showing generated seed files${RESET}" + echo "" + fi + + setup_workdir + detect_client_cert + + echo "" + + # Generate all seed files + generate_install_yaml + generate_applications_yaml + + case "$APP" in + incus) + generate_incus_yaml + ;; + operations-center) + generate_ops_center_yaml + ;; + esac + + generate_network_yaml + generate_update_yaml + + echo "" + + # Create output + if [[ "$DRY_RUN" == true ]]; then + info "Dry run complete -- no files were created" + else + case "$FORMAT" in + tar) create_tar_output ;; + fat) create_fat_output ;; + esac + + if [[ "$QUIET" != true ]]; then + print_summary + fi + fi +} + +main "$@" diff --git a/notes/iso-download-methods.md b/notes/iso-download-methods.md new file mode 100644 index 0000000..5bd9b62 --- /dev/null +++ b/notes/iso-download-methods.md @@ -0,0 +1,110 @@ +# IncusOS ISO Download & Customization Methods + +> **Note:** This document was the original research note that started this repository. +> For the actual automation scripts, see [`../incusos/README.md`](../incusos/README.md). + +--- + +To automate the generation and download of customized IncusOS ISO files, you have two technical paths: using the official **flasher-tool** (the recommended CLI method for automation) or scripting the **REST API** used by the web customizer. + +### Method 1: The flasher-tool (Recommended for Scripts) + +The Incus team provides a dedicated command-line utility specifically for automated image generation. It connects to the Linux Containers CDN to fetch the latest builds and applies your customizations locally. + +**Installation:** + +```bash +go install github.com/lxc/incus-os/incus-osd/cmd/flasher-tool@latest +``` + +**Sample Automation Script:** + +This script generates a customized ISO for an x86_64 cluster node with your local client certificate already trusted. + +```bash +#!/bin/bash +# automated-incusos-build.sh + +# 1. Fetch your local Incus client certificate +CLIENT_CERT=$(incus remote get-client-certificate) + +# 2. Define your customized seed in JSON format +# This example preps a node to join a cluster (apply_defaults: false) +cat < cluster-node-seed.json +{ + "apply_defaults": false, + "preseed": { + "certificates": [] + } +} +EOF + +# 3. Run the flasher tool to build the ISO +# -f: format (iso or img) +# -s: path to your seed file +# -o: output filename +flasher-tool -f iso -s cluster-node-seed.json -o my-custom-node.iso +``` + +### Method 2: Scripting the Web Customizer API + +If you prefer to use the web backend directly via curl, you must POST a configuration payload to the generator endpoint. The web customizer is an SPA that communicates with a backend to stream a compressed image. + +**Technical Logic:** + +The backend accepts a JSON object that mimics the "Seed" structures. One critical detail is that the web customizer streams **gzipped** data which the browser typically decompresses on the fly; when using curl, you must ensure you handle the output stream correctly. + +**Sample curl Download Script:** + +```bash +#!/bin/bash + +# Configuration +ARCH="x86_64" # x86_64 or aarch64 +APP="incus" # incus, operations-center, or migration-manager +USAGE="install" # install (on disk) or live (run from media) +CERT=$(incus remote get-client-certificate | sed ':a;N;$!ba;s/\n/\\n/g') + +# Generate the JSON payload for the web customizer +PAYLOAD=$(cat <