#!/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 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 # Determine how serious the missing cert is if [[ "$APP" == "operations-center" ]]; then error "No client certificate found" error "Operations Center requires at least one trusted certificate for access" error "" error "Options:" error " --cert FILE Provide a certificate file" error " Install the Incus client: sudo apt install incus-client" error " --no-cert Skip certificate (you will be locked out!)" exit 1 fi warn "No client certificate found" if ! command -v incus &>/dev/null; then warn "The Incus client is not installed on this machine" warn "Install it to enable auto-detection: sudo apt install incus-client" fi warn "The installed system will require manual certificate trust setup" warn "Use --cert FILE to provide a certificate, or --no-cert to suppress this warning" } # --------------------------------------------------------------------------- # 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 "$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 "$@"