#!/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: prefer reading cert files directly (works with all # Incus versions including 6.0 LTS which lacks 'get-client-certificate'). step "Auto-detecting client certificate" # 1) Check common on-disk certificate locations first 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") if [[ "$CERT_CONTENT" == *"BEGIN CERTIFICATE"* ]]; then success "Certificate loaded from ${path}" return fi detail "File exists but does not contain a valid PEM certificate: ${path}" fi done # 2) Try the incus CLI (only works with Incus >= 6.3) 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 via Incus CLI" return fi local incus_version incus_version=$(incus version 2>/dev/null | grep -oP 'Client version: \K[0-9.]+' || echo "unknown") detail "Incus CLI v${incus_version} found but no certificate available" detail "Generate one with: incus remote list (triggers auto-generation)" fi # 3) Nothing found -- report based on context if [[ "$APP" == "operations-center" ]]; then error "No client certificate found" error "Operations Center requires at least one trusted certificate for access" echo "" >&2 error "Options:" error " --cert FILE Provide a certificate file directly" error " Generate one: incus remote list (creates ~/.config/incus/client.crt)" error " --no-cert Skip certificate injection (you will be locked out!)" exit 1 fi warn "No client certificate found" if command -v incus &>/dev/null; then warn "Run 'incus remote list' to generate a client certificate, then re-run this script" else warn "The Incus client is not installed on this machine" warn "Install it to enable auto-detection (see: https://github.com/lxc/incus)" fi warn "Use --cert FILE to provide 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 ${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 "$@"