#!/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 } # --------------------------------------------------------------------------- # Platform detection # --------------------------------------------------------------------------- # ISO_TOOL is set by find_iso_tool() and used by create_iso_output() ISO_TOOL="" find_iso_tool() { # Prefer genisoimage (Linux), fall back to mkisofs (macOS cdrtools) if command -v genisoimage &>/dev/null; then ISO_TOOL="genisoimage" return 0 elif command -v mkisofs &>/dev/null; then ISO_TOOL="mkisofs" return 0 fi return 1 } # MKFS_FAT_TOOL is set by find_mkfs_fat_tool() MKFS_FAT_TOOL="" find_mkfs_fat_tool() { if command -v mkfs.fat &>/dev/null; then MKFS_FAT_TOOL="mkfs.fat" return 0 elif [[ -x /usr/sbin/mkfs.fat ]]; then MKFS_FAT_TOOL="/usr/sbin/mkfs.fat" return 0 elif [[ -x /sbin/mkfs.fat ]]; then MKFS_FAT_TOOL="/sbin/mkfs.fat" return 0 elif command -v newfs_msdos &>/dev/null; then MKFS_FAT_TOOL="newfs_msdos" return 0 fi return 1 } # --------------------------------------------------------------------------- # 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|iso|fat) ;; *) error "Invalid output format: ${FORMAT}" error "Supported: tar, iso, 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 "$STATIC_IP" ]] && [[ -z "$GATEWAY" ]]; then error "--ip requires --gateway" exit 1 fi if [[ -n "$STATIC_IP" ]] && [[ "$STATIC_IP" != */* ]]; then error "--ip must include prefix length (e.g. 192.168.102.100/22)" exit 1 fi if [[ -n "$CERT_FILE" ]] && [[ ! -f "$CERT_FILE" ]]; then error "Certificate file not found: ${CERT_FILE}" exit 1 fi if [[ "$FORMAT" == "iso" ]]; then if ! find_iso_tool; then error "ISO 9660 image creation requires genisoimage (Linux) or mkisofs (macOS)" case "$(uname -s)" in Darwin) error "Install with: brew install cdrtools" ;; *) error "Install with: sudo apt install genisoimage" ;; esac exit 1 fi fi if [[ "$FORMAT" == "fat" ]]; then local has_mkfs=false if find_mkfs_fat_tool; then has_mkfs=true fi if ! command -v mcopy &>/dev/null && [[ "$has_mkfs" != true ]]; then error "FAT image creation requires mtools (mcopy) or dosfstools (mkfs.fat)" case "$(uname -s)" in Darwin) error "Install with: brew install mtools" ;; *) error "Install with: sudo apt install mtools dosfstools" ;; esac exit 1 fi fi # Set default output filename if [[ -z "$OUTPUT" ]]; then case "$FORMAT" in tar) OUTPUT="incusos-seed.tar" ;; iso) OUTPUT="incusos-seed.iso" ;; 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 | sed -n 's/.*Client version: \([0-9.]*\).*/\1/p' || echo "unknown") [[ -z "$incus_version" ]] && incus_version="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 '+%Y-%m-%dT%H:%M:%S%z')" 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 " 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" ]] && [[ -z "$STATIC_IP" ]]; 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 # Static IP configuration if [[ -n "$STATIC_IP" ]]; then echo "" echo "interfaces:" echo " - name: \"mgmt\"" echo " hwaddr: \"${IFACE}\"" echo " addresses:" echo " - \"${STATIC_IP}\"" echo " roles:" echo " - management" echo " routes:" echo " - to: \"0.0.0.0/0\"" echo " via: \"${GATEWAY}\"" 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 "$STATIC_IP" ]] && desc+=", ip=${STATIC_IP}" [[ -n "$DNS_SERVERS" ]] && desc+=", dns=${DNS_SERVERS}" success "network.yaml (${desc})" fi } generate_update_yaml() { local file="${WORKDIR}/update.yaml" step "Generating update.yaml" { echo "# Update configuration" echo "# Always generated to ensure explicit check_frequency on first boot" 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_iso_output() { step "Creating ISO 9660 image: ${OUTPUT}" # ISO_TOOL was set by find_iso_tool() during validation # Both genisoimage and mkisofs accept the same core flags "$ISO_TOOL" -o "$OUTPUT" -V SEED_DATA -J -r "$WORKDIR"/ 2>/dev/null local size size=$(du -h "$OUTPUT" | cut -f1) success "ISO 9660 seed image created: ${OUTPUT} (${size})" } create_fat_output() { step "Creating FAT image: ${OUTPUT} (${FAT_SIZE} MiB)" # Create empty image dd if=/dev/zero of="$OUTPUT" bs=1048576 count="$FAT_SIZE" 2>/dev/null # MKFS_FAT_TOOL was set by find_mkfs_fat_tool() during validation if [[ -n "$MKFS_FAT_TOOL" ]]; then if [[ "$MKFS_FAT_TOOL" == "newfs_msdos" ]]; then # macOS built-in: -F 12 for FAT12, -v for volume label newfs_msdos -F 12 -v SEED_DATA "$OUTPUT" >/dev/null else # Linux mkfs.fat "$MKFS_FAT_TOOL" -n SEED_DATA "$OUTPUT" >/dev/null fi if command -v mcopy &>/dev/null; then 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}/" 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" == "iso" ]]; then echo -e "${DIM} Attach as CD-ROM to the VM.${RESET}" echo -e "${DIM} IncusOS will detect the SEED_DATA volume label at boot.${RESET}" 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 ;; iso) create_iso_output ;; fat) create_fat_output ;; esac if [[ "$QUIET" != true ]]; then print_summary fi fi } main "$@"