Files
2026-07-27 20:21:10 +02:00

1104 lines
42 KiB
Bash
Executable File

#!/usr/bin/env bash
# pve-build.sh — build a NixOS configuration on a disposable LXC container
# running on a Proxmox VE node, then apply the result to the requester
# (= the machine invoking this script, by default).
#
# The flow has five stages; each can be invoked on its own, or chained via
# the composite commands (build / test / switch / boot).
#
# Stage Step command Effect
# ------ ------------------------- --------------------------------------
# 1 check-image Read-only: is a NixOS LXC ostemplate on PVE?
# deploy-image Idempotent: build+upload if missing, no-op if present.
# update-image Force: rebuild locally + replace on PVE.
# 2 start-builder deploy-image + clone a fresh CT under a free
# VMID + start it + wait for DHCP.
# 3 build-on [VMID] Run `nixos-rebuild build --build-host builder@<hostname>`
# against an existing disposable. VMID is
# discovered from PVE if not given.
# 4 activate <test|switch|boot>
# Run `sudo nixos-rebuild <cmd> --flake .#<requester>`
# LOCALLY (no SSH). Closure must already be
# in the local store from stage 3.
# 5 destroy-builder [VMID] pct destroy --purge --force. VMID is
# discovered from PVE if not given.
#
# Composite commands (chain stages 1-5 for the common cases):
# build = 1(deploy) + 2 + 3 (CT left running)
# test = 1(deploy) + 2 + 3 + 4(test) (CT left running)
# switch = 1(deploy) + 2 + 3 + 4(switch) + 5 (CT destroyed on success)
# boot = 1(deploy) + 2 + 3 + 4(boot) + 5 (CT destroyed on success)
#
# Auxiliary:
# info [VMID] Print VMID/IP/SSH/destroy hints for a disposable.
#
# STATELESS DESIGN
# The script writes no state files. Each step discovers prior steps'
# artifacts dynamically:
# - Disposables are identified by hostname pattern "<prefix>-<VMID>"
# on PVE. `discover_vmid` queries pvesh for LXC containers matching
# the prefix; if exactly one exists, it is used. Zero or multiple
# -> the user must pass a VMID explicitly.
# - The built closure lives in the local Nix store; nixos-rebuild finds
# it naturally on activate.
# This makes the script safe to re-run, interrupt, or split across shell
# sessions without leftover state.
#
# HOSTNAME-BASED SSH
# All SSH to disposables targets their hostname (`<prefix>-<VMID>`, e.g.
# `nixos-builder-9999`), never their IP. The caller's resolver must be
# able to look up PVE container hostnames — typically via dnsmasq or a
# split-DNS that reads from PVE. The script never resolves IPs for SSH;
# `get_ct_ip` is used only for `info` display and diagnostic logging.
#
# The split between build (user-owned SSH, no sudo) and activate (sudo, no
# SSH) avoids the need for the requester's root user to have SSH keys that
# reach the disposable — only the calling user does.
set -euo pipefail
# Global toggles referenced by the logging helpers above; defaulted here so
# the helpers can be called before the argument parser runs.
DEBUG=false
## ---------------------------------------------------------------------------
# Paths
## ---------------------------------------------------------------------------
# Walk up from $PWD looking for flake.nix. Override with FLAKE_ROOT=/path.
find_flake_root() {
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
[[ -f "$dir/flake.nix" ]] && { echo "$dir"; return 0; }
dir=$(dirname "$dir")
done
return 1
}
if [[ -z "${FLAKE_ROOT:-}" ]]; then
if ! FLAKE_ROOT=$(find_flake_root); then
die "No flake.nix found in \$PWD or any parent. Run from your flake root or set FLAKE_ROOT."
fi
fi
## ---------------------------------------------------------------------------
# Defaults — override via env vars or CLI flags
# ---------------------------------------------------------------------------
PVE_HOST="${PVE_HOST:-pve}" # SSH alias for the PVE node (defined in ~/.ssh/config)
PVE_STORAGE="${PVE_STORAGE:-local}" # ostemplate storage (holds vztmpl/)
PVE_ROOTFS_STORAGE="${PVE_ROOTFS_STORAGE:-local-lvm}" # rootfs storage for new CTs
PVE_BRIDGE="${PVE_BRIDGE:-vmbr0}" # network bridge
VMID_START="${VMID_START:-9999}" # scan downward from here for a free VMID
VMID_FLOOR="${VMID_FLOOR:-100}" # lowest VMID to consider (PVE minimum)
TEMPLATE_OUTPUT="${TEMPLATE_OUTPUT:-nixos-lxc}" # flake output to build
CT_HOSTNAME_PREFIX="${CT_HOSTNAME_PREFIX:-nixos-builder}" # disposables' hostname prefix
BUILD_SSH_USER="${BUILD_SSH_USER:-builder}" # user nixos-rebuild SSHes as on the CT
CT_BOOT_TIMEOUT="${CT_BOOT_TIMEOUT:-120}" # seconds to wait for DHCP lease
CT_ROOTFS_GIB="${CT_ROOTFS_GIB:-150}" # rootfs size in GiB (nix store fills fast)
CT_MEMORY="${CT_MEMORY:-16384}" # container memory limit in MB (nix builds are hungry)
CT_SWAP="${CT_SWAP:-16384}" # container swap in MB
## ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
# Levels / channels:
# log / warn / err plain status, warnings, errors (always on)
# phase major stage banner — what we are about to do overall
# step sub-step within a phase
# ok success marker
# phase_done phase completion with elapsed seconds
# run echo a local command, then execute it
# debug verbose internals — only with --debug
#
# All output goes to stderr so stdout stays clean for callers that pipe us.
LOG_INDENT=""
log() { printf '\033[1;34m[pve-build]\033[0m %s%s\n' "$LOG_INDENT" "$*" >&2; }
warn() { printf '\033[1;33m[pve-build WARN]\033[0m %s%s\n' "$LOG_INDENT" "$*" >&2; }
err() { printf '\033[1;31m[pve-build ERROR]\033[0m %s%s\n' "$LOG_INDENT" "$*" >&2; }
die() { err "$*"; exit 1; }
debug() { $DEBUG && printf '\033[2;37m[pve-build debug]\033[0m %s%s\n' "$LOG_INDENT" "$*" >&2 || true; }
# Major stage banner. Also starts the phase timer used by phase_done.
phase() {
printf '\n\033[1;36m━━━ %s ━━━\033[0m\n' "$*" >&2
_PHASE_START=$SECONDS
}
step() { printf '\033[1;36m▸\033[0m %s\n' "$*" >&2; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
# Phase completion with elapsed time.
phase_done() {
local label="$1"
local elapsed=$(( SECONDS - ${_PHASE_START:-$SECONDS} ))
printf '\033[1;32m✓\033[0m %s \033[2m(%ds)\033[0m\n' "$label" "$elapsed" >&2
}
# Echo a local command (dim green "$ cmd..."), then execute it.
run() {
printf '\033[2;32m$\033[0m %s\n' "$*" >&2
"$@"
}
# SSH options for reaching the PVE node. Connection multiplexing keeps a
# single master connection alive for the whole script invocation so the
# user authenticates once (passphrase, password, MFA) instead of per call.
# The mux socket lives in a private mktemp directory so concurrent runs
# don't collide. ControlPersist=yes keeps the master alive indefinitely
# (until the EXIT trap closes it via `ssh -O exit`) — this is deliberate,
# because long-running local work between PVE calls (e.g. `nix build` of
# a 350M+ image) would otherwise let a timed ControlPersist expire and
# force re-auth on the next pve/pve_scp call.
SSH_MUX_DIR=$(mktemp -d -t pve-build-mux.XXXXXX)
SSH_MUX_SOCKET="$SSH_MUX_DIR/sock"
PVE_SSH_OPTS=(
-o ConnectTimeout=10
-o ControlMaster=auto
-o ControlPath="$SSH_MUX_SOCKET"
-o ControlPersist=yes
)
# Tear down the SSH master and the temp dir on exit. `ssh -O exit` talks
# to the master via the ControlPath socket (no network call, no prompt);
# the `[[ -S ... ]]` guard skips it if no master was ever established.
cleanup_ssh_master() {
if [[ -S "$SSH_MUX_SOCKET" ]]; then
ssh "${PVE_SSH_OPTS[@]}" -O exit "$PVE_HOST" 2>/dev/null || true
fi
if [[ -S "${DISPOSABLE_MUX_SOCKET:-}" ]]; then
ssh "${DISPOSABLE_SSH_OPTS[@]}" -O exit builder@placeholder 2>/dev/null || true
fi
rm -rf "$SSH_MUX_DIR" "${DISPOSABLE_MUX_DIR:-}"
}
trap cleanup_ssh_master EXIT
# Default pve() wrapper — runs a command on the PVE node over SSH, echoing
# the full invocation first. Every pve/pvesh/pct call in this script goes
# through here, so all PVE traffic is visible in the log and reuses the
# same authenticated master connection.
pve() {
printf '\033[2;32m$\033[0m ssh %s %s\n' "$PVE_HOST" "$*" >&2
ssh "${PVE_SSH_OPTS[@]}" "$PVE_HOST" "$@"
}
# scp wrapper that reuses the same master connection as pve(). Times the
# transfer and logs the average speed on completion. Without the master
# reuse, scp would establish its own fresh SSH session and re-prompt for
# auth.
pve_scp() {
printf '\033[2;32m$\033[0m scp %s\n' "$*" >&2
local start=$SECONDS rc elapsed
set +e
scp "${PVE_SSH_OPTS[@]}" "$@"
rc=$?
set -e
elapsed=$((SECONDS - start))
if [[ $rc -eq 0 ]]; then
local src="$1" size_bytes size_human speed
if [[ -f "$src" ]]; then
size_bytes=$(wc -c < "$src" 2>/dev/null || echo 0)
size_human=$(du -h "$src" | cut -f1)
speed=$(awk -v s="$size_bytes" -v t="$elapsed" \
'BEGIN{ if(t>0) printf "%.1f", s/1024/1024/t; else print "?" }')
ok "Transferred $size_human in ${elapsed}s (${speed} MB/s)"
else
ok "Transferred in ${elapsed}s"
fi
else
fail "scp failed (exit $rc) after ${elapsed}s"
fi
return $rc
}
# SSH options for reaching the disposable — its host key is fresh every run.
# Same multiplexing pattern as PVE: one master per script invocation so the
# user authenticates once even though wait_for_ready polls every 2s and
# probe_ssh / nixos-rebuild each make their own connections.
DISPOSABLE_MUX_DIR=$(mktemp -d -t pve-build-disposable-mux.XXXXXX)
DISPOSABLE_MUX_SOCKET="$DISPOSABLE_MUX_DIR/sock"
DISPOSABLE_SSH_OPTS=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=10
-o ControlMaster=auto
-o ControlPath="$DISPOSABLE_MUX_SOCKET"
-o ControlPersist=yes
)
## ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $0 <command> [options]
Build a NixOS configuration on a disposable LXC container on a Proxmox VE
node, then apply it to the requester (current machine by default).
Composite commands (chain all stages):
build deploy-image + start-builder + build-on.
test deploy-image + start-builder + build-on + activate test.
switch deploy-image + start-builder + build-on + activate switch
+ destroy-builder. Disposable destroyed on success.
boot deploy-image + start-builder + build-on + activate boot
+ destroy-builder. Disposable destroyed on success.
Step commands (run one stage — VMID auto-discovered from PVE if omitted):
check-image Read-only: exit 0 if a NixOS LXC ostemplate is on PVE, 1 if not.
deploy-image Idempotent: build+upload if missing, no-op if present.
update-image Force: rebuild .$TEMPLATE_OUTPUT locally + replace on PVE.
start-builder deploy-image + clone a fresh CT + start.
build-on [VMID] Build closure on an existing disposable.
activate <test|switch|boot>
Activate LOCALLY (closure must already be built).
destroy-builder [VMID] pct destroy --purge --force.
Auxiliary:
info [VMID] Show VMID / IP / SSH / destroy hints (or list all).
Disposables are identified by hostname pattern "$CT_HOSTNAME_PREFIX-<VMID>"
on PVE. If exactly one exists, it is used implicitly; if zero or multiple
exist, you must pass a VMID explicitly. The script writes no state files.
Options:
--machine NAME Requester machine name (default: current hostname).
--pve-host HOST PVE SSH target (default: $PVE_HOST; env: PVE_HOST).
--pve-storage NAME Ostemplate storage (default: $PVE_STORAGE).
--rootfs-storage NAME Rootfs storage (default: $PVE_ROOTFS_STORAGE).
--bridge NAME Bridge (default: $PVE_BRIDGE).
--vmid-start N Highest VMID to consider (default: $VMID_START). Scanned downward.
--vmid-floor N Lowest VMID to consider (default: $VMID_FLOOR).
--rootfs-gib N Rootfs size in GiB (default: $CT_ROOTFS_GIB).
--keep Keep the disposable even on successful switch/boot.
--local-build Skip PVE entirely; run nixos-rebuild locally.
--show-trace Pass --show-trace to nixos-rebuild.
--verbose Pass --verbose to nixos-rebuild.
--debug Verbose script internals (commands, decisions).
-h, --help Show this help.
Environment:
PVE_HOST, PVE_STORAGE, PVE_ROOTFS_STORAGE, PVE_BRIDGE,
VMID_START, VMID_FLOOR, BUILD_SSH_USER,
CT_BOOT_TIMEOUT, CT_ROOTFS_GIB, TEMPLATE_OUTPUT, CT_HOSTNAME_PREFIX
Examples:
# Full happy path — build + activate susano permanently, auto-destroy CT.
$0 switch --machine susano
# Step-by-step (each step discovers the prior step's artifacts from PVE):
$0 deploy-image # build + upload the LXC ostemplate (no-op if present)
$0 start-builder # clones a fresh disposable CT
$0 build-on # discovers the CT on PVE, builds on it
$0 activate switch # activates locally (no SSH)
$0 destroy-builder # discovers the CT on PVE, destroys it
# Rebuild the LXC image after editing machines/builder/default.nix.
$0 update-image
# Operate on a specific CT (skips discovery, useful when multiple exist).
$0 build-on 305
$0 destroy-builder 305
# See what disposables exist on PVE.
$0 info
Any unrecognized options are passed through to nixos-rebuild.
EOF
}
## ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
COMMAND=""
MACHINE_NAME=""
POSITIONAL_VMID="" # used by build-on / destroy-builder / info
ACTIVATE_CMD="" # activate <test|switch|boot>
KEEP=false
LOCAL_BUILD=false
SHOW_TRACE=false
VERBOSE=false
PASSTHROUGH=()
while [[ $# -gt 0 ]]; do
case "$1" in
# Composite commands
build|test|switch|boot)
[[ -n "$COMMAND" ]] && die "Only one command may be specified."
COMMAND="$1"; shift ;;
# Step commands
check-image|deploy-image|update-image|start-builder|ensure-template|create)
[[ -n "$COMMAND" ]] && die "Only one command may be specified."
# Backwards-compat aliases:
case "$1" in
ensure-template) COMMAND="deploy-image" ;;
create) COMMAND="start-builder" ;;
*) COMMAND="$1" ;;
esac
shift ;;
build-on|destroy-builder|info)
[[ -n "$COMMAND" ]] && die "Only one command may be specified."
COMMAND="$1"; shift
if [[ $# -gt 0 && "$1" != --* ]]; then POSITIONAL_VMID="$1"; shift; fi ;;
activate)
[[ -n "$COMMAND" ]] && die "Only one command may be specified."
COMMAND="$1"; shift
if [[ $# -gt 0 && "$1" != --* ]]; then ACTIVATE_CMD="$1"; shift; fi ;;
# Flags
--machine) MACHINE_NAME="${2:?--machine requires a value}"; shift 2 ;;
--pve-host) PVE_HOST="${2:?--pve-host requires a value}"; shift 2 ;;
--pve-storage) PVE_STORAGE="${2:?--pve-storage requires a value}"; shift 2 ;;
--rootfs-storage) PVE_ROOTFS_STORAGE="${2:?--rootfs-storage requires a value}"; shift 2 ;;
--bridge) PVE_BRIDGE="${2:?--bridge requires a value}"; shift 2 ;;
--vmid-start) VMID_START="${2:?--vmid-start requires a value}"; shift 2 ;;
--vmid-floor) VMID_FLOOR="${2:?--vmid-floor requires a value}"; shift 2 ;;
--rootfs-gib) CT_ROOTFS_GIB="${2:?--rootfs-gib requires a value}"; shift 2 ;;
--keep) KEEP=true; shift ;;
--local-build) LOCAL_BUILD=true; shift ;;
--show-trace) SHOW_TRACE=true; shift ;;
--verbose) VERBOSE=true; shift ;;
--debug) DEBUG=true; shift ;;
-h|--help) usage; exit 0 ;;
--*) die "Unknown option: $1" ;;
*)
# A non-flag token before any command is set is an unknown command.
[[ -z "$COMMAND" ]] && die "Unknown command: $1 (try --help)"
PASSTHROUGH+=("$1"); shift ;;
esac
done
[[ -n "$COMMAND" ]] || { usage; exit 1; }
# Requester auto-detection: hostname, override with --machine.
# Relevant for composite, build-on, activate (anything that calls nixos-rebuild).
if [[ -z "$MACHINE_NAME" ]]; then
MACHINE_NAME=$(hostname 2>/dev/null || echo "$USER")
fi
phase "Configuration"
log "Requester machine: $MACHINE_NAME"
log "Flake: $FLAKE_ROOT#$MACHINE_NAME"
log "PVE host: $PVE_HOST"
log "PVE storage: $PVE_STORAGE (ostemplate) / $PVE_ROOTFS_STORAGE (rootfs)"
log "Bridge: $PVE_BRIDGE"
log "VMID start: $VMID_START (scanning down to $VMID_FLOOR)"
log "Disposable prefix: $CT_HOSTNAME_PREFIX-<VMID>"
log "Command: $COMMAND"
debug "DEBUG: enabled"
debug "KEEP=$KEEP LOCAL_BUILD=$LOCAL_BUILD SHOW_TRACE=$SHOW_TRACE VERBOSE=$VERBOSE"
debug "PASSTHROUGH (${#PASSTHROUGH[@]}): ${PASSTHROUGH[*]:-}"
## ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
require() {
command -v "$1" >/dev/null || die "$1 is required on the caller ($2)."
}
# Verify SSH connectivity to the PVE node before any PVE-touching step.
# Without this, silent SSH failures inside pvesh / pveam / pct calls (which
# use `2>/dev/null || true` to tolerate empty results) would be reported to
# the user as "no matches" or "no disposables" — a misleading success.
require_pve() {
step "Probing SSH to PVE node ($PVE_HOST) ..."
if ! pve true; then
fail "Cannot SSH to PVE node via alias '$PVE_HOST'."
warn " - is the PVE node up?"
warn " - does '$PVE_HOST' resolve via ~/.ssh/config (Host, User, IdentityFile)?"
warn " - is your SSH key authorized on the PVE node?"
die "PVE unreachable."
fi
ok "PVE reachable: $PVE_HOST"
}
## ---------------------------------------------------------------------------
# VMID discovery — query PVE for LXC containers whose name matches the
# disposable hostname prefix. Used by every step that needs a target VMID
# but wasn't given one explicitly.
# ---------------------------------------------------------------------------
#
# Output of `pvesh get /cluster/resources --type vm --output-format json`:
# [{"vmid":100,"name":"nixos-builder-300","type":"lxc","status":"running",...}, ...]
discover_disposables() {
# Prints "<vmid> <status> <name>" lines for every LXC container whose name
# starts with "$CT_HOSTNAME_PREFIX-". Empty output means pvesh failed OR
# no matches; the caller decides based on context.
step "Querying PVE for LXC containers matching '${CT_HOSTNAME_PREFIX}-*' ..."
local raw
raw=$(pve pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq -r --arg prefix "$CT_HOSTNAME_PREFIX" '
map(select(
.type == "lxc" and (.name | startswith($prefix + "-"))
))
| sort_by(.vmid)
| .[]
| "\(.vmid) \(.status) \(.name)"
' 2>/dev/null || true)
local count=0
[[ -n "$raw" ]] && count=$(grep -c '^' <<<"$raw" || true)
debug "discover_disposables: pvesh returned $count match(es)"
printf '%s' "$raw"
}
# Echo the VMID of the single disposable matching the prefix on PVE. Dies if
# there are zero or multiple matches (caller should pass an explicit VMID).
discover_vmid() {
local matches
matches=$(discover_disposables)
if [[ -z "$matches" ]]; then
fail "No disposable container on $PVE_HOST matches '${CT_HOSTNAME_PREFIX}-*'."
die "Run '$0 start-builder' first, or pass a VMID explicitly."
fi
local count
count=$(grep -c '^' <<<"$matches" || true)
if [[ "$count" -eq 1 ]]; then
local vmid status name
read -r vmid status name <<<"$matches"
ok "Found disposable: VMID=$vmid status=$status name=$name"
echo "$vmid"
return
fi
fail "Multiple disposable containers found on $PVE_HOST:"
while IFS= read -r line; do
warn " $line"
done <<<"$matches"
die "Ambiguous: pass a VMID explicitly (e.g. '$0 $COMMAND <VMID>')."
}
# Resolve a VMID: explicit argument wins, else discover from PVE.
resolve_vmid() {
local explicit="${1:-}"
if [[ -n "$explicit" ]]; then
echo "$explicit"
else
discover_vmid
fi
}
## ---------------------------------------------------------------------------
# Stage 1: image management (check / deploy / update).
# All three operate on the NixOS LXC ostemplate stored in PVE's
# /var/lib/vz/template/cache/. The "latest" template is whichever
# nixos-lxc-*.tar.xz sorts last alphabetically (matches the helpers below).
# ---------------------------------------------------------------------------
# Echo the storage-relative path of the newest nixos-lxc template on PVE, or
# empty if none. Read-only.
find_template_on_pve() {
debug "find_template_on_pve: pveam list $PVE_STORAGE"
pve pveam list "$PVE_STORAGE" 2>/dev/null \
| awk '/nixos.*lxc.*\.tar\.xz/ {print $1}' \
| sort | tail -n1 || true
}
# Build .$TEMPLATE_OUTPUT locally and scp the tarball to PVE's template cache.
# Sets OSTEMPLATE on success. Used by both deploy-image (when missing) and
# update-image (always).
build_and_upload_image() {
step "Building .$TEMPLATE_OUTPUT locally via nix ..."
local result_dir
result_dir=$(cd "$FLAKE_ROOT" && run nix build ".#$TEMPLATE_OUTPUT" --no-link --print-out-paths)
[[ -n "$result_dir" && -d "$result_dir" ]] \
|| die "nix build .$TEMPLATE_OUTPUT produced no output path."
log " nix output: $result_dir"
# nixos-generators proxmox-lxc format lays the file under result/tarball/*.tar.xz
local tarball
tarball=$(find "$result_dir/tarball" -maxdepth 1 -type f -name '*.tar.xz' -print -quit | head -n1)
[[ -n "$tarball" ]] \
|| die "No .tar.xz found under $result_dir/tarball/. Did nixos-generators output layout change?"
local tname; tname=$(basename "$tarball")
debug "tarball path: $tarball"
debug "tarball name: $tname"
ok "Built image: $tname"
step "Uploading to $PVE_HOST:/var/lib/vz/template/cache/ ..."
local size_human
size_human=$(du -h "$tarball" | cut -f1)
log " scp $tarball ($size_human) -> $PVE_HOST:/var/lib/vz/template/cache/$tname"
pve_scp "$tarball" "$PVE_HOST:/var/lib/vz/template/cache/$tname"
OSTEMPLATE="$PVE_STORAGE:vztmpl/$tname"
}
# Remove old nixos-lxc templates from PVE, keeping $1. Must be called after
# the new image is uploaded so a build/upload failure leaves the previous
# template intact.
purge_old_templates_on_pve() {
local keep="${1:-}"
step "Purging old nixos-lxc templates from $PVE_HOST (keeping ${keep:-none}) ..."
local all
all=$(pve pveam list "$PVE_STORAGE" 2>/dev/null \
| awk '/nixos.*lxc.*\.tar\.xz/ {print $1}' || true)
local removed=0
while IFS= read -r t; do
[[ -n "$t" ]] || continue
if [[ -n "$keep" && "$t" == "$keep" ]]; then
debug "keeping: $t"
continue
fi
local filename="${t##*/}"
log " removing: $t"
pve rm -f "/var/lib/vz/template/cache/$filename" 2>/dev/null || true
removed=$((removed + 1))
done <<<"$all"
ok "Purged $removed old template(s)"
}
# Read-only check. Sets OSTEMPLATE and returns 0 if a template exists;
# clears OSTEMPLATE and returns 1 if missing.
check_image() {
step "Checking for nixos-lxc ostemplate on $PVE_HOST (storage: $PVE_STORAGE) ..."
local existing
existing=$(find_template_on_pve)
if [[ -n "$existing" ]]; then
OSTEMPLATE="$existing"
ok "Image present: $OSTEMPLATE"
return 0
fi
OSTEMPLATE=""
warn "No nixos-lxc ostemplate found."
return 1
}
# Idempotent deploy: build + upload only if no template exists.
deploy_image() {
if check_image; then
log "Nothing to do — image already on PVE."
return
fi
build_and_upload_image
ok "Image deployed: $OSTEMPLATE"
}
# Force update: build + upload first, then purge old. If the build or upload
# fails, the previous template remains on PVE.
update_image() {
build_and_upload_image
purge_old_templates_on_pve "$OSTEMPLATE"
ok "Image updated: $OSTEMPLATE"
}
## ---------------------------------------------------------------------------
# Stage 2 helpers: VMID + container lifecycle
# ---------------------------------------------------------------------------
find_free_vmid() {
step "Finding a free VMID (scanning downward from $VMID_START to $VMID_FLOOR) ..."
# pvesh returns JSON cluster-wide; jq extracts vmids on the caller side.
local used
used=$(pve pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq -r '.[].vmid' 2>/dev/null || true)
local used_count
used_count=$(grep -c '^' <<<"$used" || true)
debug "pvesh reports $used_count VMID(s) in use cluster-wide"
local id
for (( id=VMID_START; id>=VMID_FLOOR; id-- )); do
if ! grep -qx "$id" <<<"$used"; then
ok "Free VMID selected: $id"
echo "$id"
return
fi
done
fail "No free VMID in [$VMID_FLOOR, $VMID_START]."
die "Raise --vmid-start, lower --vmid-floor, or clean up old disposables."
}
# Create + start a container. The container is identifiable afterwards purely
# by its hostname pattern on PVE — no state file is written.
create_and_start_ct() {
local vmid="$1"
local hostname="$CT_HOSTNAME_PREFIX-$vmid"
step "Creating container $vmid (hostname=$hostname) ..."
debug "ostemplate: $OSTEMPLATE"
debug "rootfs: $PVE_ROOTFS_STORAGE:$CT_ROOTFS_GIB"
debug "net0: name=eth0,bridge=$PVE_BRIDGE,ip=dhcp"
pve pct create "$vmid" "$OSTEMPLATE" \
--hostname "$hostname" \
--arch amd64 \
--ostype unmanaged \
--unprivileged 1 \
--features keyctl=1,nesting=1 \
--rootfs "$PVE_ROOTFS_STORAGE:$CT_ROOTFS_GIB" \
--memory "$CT_MEMORY" \
--swap "$CT_SWAP" \
--net0 "name=eth0,bridge=$PVE_BRIDGE,ip=dhcp" \
--onboot 0 \
--start 0
ok "Container $vmid created"
step "Starting container $vmid ..."
pve pct start "$vmid"
ok "Container $vmid started"
}
# Echo the current IPv4 of a running container's eth0, or empty if unavailable.
get_ct_ip() {
local vmid="$1"
pve pct exec "$vmid" -- ip -j -4 addr show eth0 2>/dev/null \
| jq -r '.[0].addr_info[]? | select(.scope=="global") | .local' 2>/dev/null \
| head -n1 || true
}
# Wait for a disposable to become SSH-reachable via its hostname. Echos the
# hostname on success (so the caller can pass it to do_build / probe_ssh).
# We deliberately do NOT use the IP here — the script's contract is that
# disposable hostnames resolve on the caller (see HOSTNAME-BASED SSH above).
wait_for_ready() {
local vmid="$1"
local hostname="$CT_HOSTNAME_PREFIX-$vmid"
local deadline=$(( $(date +%s) + CT_BOOT_TIMEOUT ))
step "Waiting for SSH to $BUILD_SSH_USER@$hostname (timeout ${CT_BOOT_TIMEOUT}s) ..."
local elapsed=0
while [[ $(date +%s) -lt $deadline ]]; do
if ssh "${DISPOSABLE_SSH_OPTS[@]}" "$BUILD_SSH_USER@$hostname" true 2>/dev/null; then
# Best-effort IP lookup for diagnostic logging only.
local maybe_ip; maybe_ip=$(get_ct_ip "$vmid" 2>/dev/null || true)
ok "SSH up: $BUILD_SSH_USER@$hostname${maybe_ip:+ (IP: $maybe_ip)} (after ${elapsed}s)"
echo "$hostname"
return
fi
debug "ssh to $hostname not ready yet (t=${elapsed}s); retrying in 2s"
sleep 2
elapsed=$(( elapsed + 2 ))
done
fail "Timeout waiting for SSH to $BUILD_SSH_USER@$hostname."
warn "Is '$hostname' resolvable on this host? The script SSHes by hostname, never IP."
die "Check PVE container start state and your DNS/hosts setup."
}
# Probe SSH to the disposable. Returns nonzero on failure.
probe_ssh() {
local target="$1"
step "Probing SSH to $BUILD_SSH_USER@$target ..."
if ! ssh "${DISPOSABLE_SSH_OPTS[@]}" "$BUILD_SSH_USER@$target" true 2>/dev/null; then
fail "Cannot SSH to $BUILD_SSH_USER@$target."
warn "The disposable's '$BUILD_SSH_USER' user must trust your SSH key. The baked-in admin"
warn "key is alexander0derevianko@gmail.com — edit machines/builder/default.nix (adminKeys) to add yours."
return 1
fi
ok "SSH OK: $BUILD_SSH_USER@$target"
}
## ---------------------------------------------------------------------------
# Stages 3 + 4: build on disposable / activate locally
# ---------------------------------------------------------------------------
# Build phase: build the requester's closure on the disposable, copy it back
# into the caller's local Nix store. Returns the nixos-rebuild exit code.
# Targets the disposable by hostname (nixos-rebuild will SSH to it).
do_build() {
local vmid="$1" hostname="$2"
local build_args=(
nixos-rebuild build
--flake "$FLAKE_ROOT#$MACHINE_NAME"
--build-host "$BUILD_SSH_USER@$hostname"
)
$SHOW_TRACE && build_args+=(--show-trace)
$VERBOSE && build_args+=(--verbose)
[[ ${#PASSTHROUGH[@]} -gt 0 ]] && build_args+=("${PASSTHROUGH[@]}")
# nix / nixos-rebuild honour NIX_SSHOPTS when SSHing to --build-host.
export NIX_SSHOPTS="${NIX_SSHOPTS:-} ${DISPOSABLE_SSH_OPTS[*]}"
step "Build phase (CT $vmid via $hostname): building requester closure on disposable ..."
debug "NIX_SSHOPTS=$NIX_SSHOPTS"
printf '\033[2;32m$\033[0m %s\n' "${build_args[*]}" >&2
set +e
"${build_args[@]}"
local rc=$?
set -e
return $rc
}
# Activate phase: runs LOCALLY with sudo, no --build-host. The closure must
# already be in the local store (from a prior do_build). Returns exit code.
do_activate() {
local cmd="$1"
local activate_args=(sudo nixos-rebuild "$cmd" --flake "$FLAKE_ROOT#$MACHINE_NAME")
$SHOW_TRACE && activate_args+=(--show-trace)
$VERBOSE && activate_args+=(--verbose)
[[ ${#PASSTHROUGH[@]} -gt 0 ]] && activate_args+=("${PASSTHROUGH[@]}")
step "Activate phase (local): $cmd — applying closure already in local store ..."
printf '\033[2;32m$\033[0m %s\n' "${activate_args[*]}" >&2
set +e
"${activate_args[@]}"
local rc=$?
set -e
return $rc
}
## ---------------------------------------------------------------------------
# Stage 5: destroy
# ---------------------------------------------------------------------------
destroy_ct() {
local vmid="$1"
step "Destroying container $vmid on $PVE_HOST (pct destroy --purge --force) ..."
pve pct destroy "$vmid" --purge --force \
|| die "Failed to destroy container $vmid (it may already be gone)."
ok "Container $vmid destroyed"
}
# Print debug hints for a container that's been left in place.
print_debug_info() {
local vmid="$1" hostname="${2:-}" ip="${3:-}"
warn "Container $vmid is left on PVE."
warn " pct enter: ssh $PVE_HOST -t pct enter $vmid"
[[ -n "$hostname" ]] && warn " SSH: ssh ${DISPOSABLE_SSH_OPTS[*]} $BUILD_SSH_USER@$hostname"
[[ -n "$ip" ]] && warn " raw IP: $ip (not used by this script — SSH is hostname-based)"
warn " destroy: $0 destroy-builder $vmid"
warn " info: $0 info $vmid"
}
## ---------------------------------------------------------------------------
# Local build: skip PVE entirely, run nixos-rebuild on this machine.
# ---------------------------------------------------------------------------
cmd_local_build() {
require nix "run nixos-rebuild"
phase "Local build: $COMMAND (no PVE disposable)"
local args=(nixos-rebuild "$COMMAND" --flake "$FLAKE_ROOT#$MACHINE_NAME")
$SHOW_TRACE && args+=(--show-trace)
$VERBOSE && args+=(--verbose)
[[ ${#PASSTHROUGH[@]} -gt 0 ]] && args+=("${PASSTHROUGH[@]}")
if [[ "$COMMAND" == "test" || "$COMMAND" == "switch" || "$COMMAND" == "boot" ]]; then
args=(sudo "${args[@]}")
fi
printf '\033[2;32m$\033[0m %s\n' "${args[*]}" >&2
set +e
"${args[@]}"
local rc=$?
set -e
if [[ $rc -ne 0 ]]; then
fail "Local build FAILED (exit $rc)."
exit "$rc"
fi
ok "Local build SUCCEEDED."
phase_done "Local build: $COMMAND"
}
## ---------------------------------------------------------------------------
# Composite command: full pipeline for build / test / switch / boot.
# ---------------------------------------------------------------------------
cmd_composite() {
if $LOCAL_BUILD; then
cmd_local_build
return
fi
require ssh "drive PVE"
require_pve
require scp "upload the template"
require nix "build the template + run nixos-rebuild"
require jq "find free VMID + read container IP"
phase "Composite: $COMMAND (deploy-image + start-builder + build-on$([[ "$COMMAND" != build ]] && echo " + activate $COMMAND")$({ [[ "$COMMAND" == switch || "$COMMAND" == boot ]] && ! $KEEP; } && echo " + destroy-builder"))"
deploy_image
local vmid hostname rc matches count
matches=$(discover_disposables)
if [[ -z "$matches" ]]; then
vmid=$(find_free_vmid)
hostname="$CT_HOSTNAME_PREFIX-$vmid"
log "No existing disposable — creating $hostname"
create_and_start_ct "$vmid"
else
count=$(grep -c '^' <<<"$matches" || true)
if [[ "$count" -ne 1 ]]; then
warn "Multiple disposable containers found:"
while IFS= read -r line; do warn " $line"; done <<<"$matches"
die "Ambiguous: clean up with '$0 destroy-builder <VMID>' first."
fi
read -r vmid _ _ <<<"$matches"
hostname="$CT_HOSTNAME_PREFIX-$vmid"
log "Reusing existing disposable: $hostname"
local status
status=$(pve pct status "$vmid" 2>/dev/null | awk '{print $2}' || true)
if [[ "$status" != "running" ]]; then
step "Container $vmid is stopped — starting ..."
pve pct start "$vmid"
fi
fi
hostname=$(wait_for_ready "$vmid")
probe_ssh "$hostname" || { print_debug_info "$vmid" "$hostname"; die "Aborting composite pipeline."; }
do_build "$vmid" "$hostname" && rc=0 || rc=$?
if [[ $rc -eq 0 ]]; then ok "Build phase succeeded (exit 0)."; else fail "Build phase failed (exit $rc)."; fi
if [[ $rc -eq 0 ]] && [[ "$COMMAND" == "test" || "$COMMAND" == "switch" || "$COMMAND" == "boot" ]]; then
do_activate "$COMMAND" && rc=0 || rc=$?
if [[ $rc -eq 0 ]]; then ok "Activate phase succeeded (exit 0)."; else fail "Activate phase failed (exit $rc)."; fi
fi
if [[ $rc -ne 0 ]]; then
fail "Pipeline FAILED (exit $rc)."
print_debug_info "$vmid" "$hostname"
exit "$rc"
fi
if { [[ "$COMMAND" == "switch" || "$COMMAND" == "boot" ]]; } && ! $KEEP; then
destroy_ct "$vmid"
else
log "Command was '$COMMAND'${KEEP:+ (--keep)} — container $vmid is left on PVE."
log " destroy with: $0 destroy-builder $vmid"
fi
phase_done "Composite: $COMMAND"
}
## ---------------------------------------------------------------------------
# Step commands
# ---------------------------------------------------------------------------
cmd_check_image() {
require ssh "drive PVE"
require_pve
phase "check-image — read-only probe of ostemplate on PVE"
if check_image; then
log "Image present on PVE: $OSTEMPLATE"
phase_done "check-image"
exit 0
fi
fail "No NixOS LXC image found on $PVE_HOST."
log "Run '$0 deploy-image' to build+upload."
phase_done "check-image"
exit 1
}
cmd_deploy_image() {
require ssh "drive PVE"
require_pve
require scp "upload the template"
require nix "build the template"
phase "deploy-image — ensure ostemplate is on PVE (idempotent)"
deploy_image
log "Ostemplate ready: $OSTEMPLATE"
phase_done "deploy-image"
}
cmd_update_image() {
require ssh "drive PVE"
require_pve
require scp "upload the template"
require nix "build the template"
phase "update-image — purge + rebuild + upload ostemplate"
update_image
log "Ostemplate ready: $OSTEMPLATE"
phase_done "update-image"
}
cmd_start_builder() {
require ssh "drive PVE"
require_pve
require scp "upload the template"
require nix "build the template"
require jq "find free VMID"
phase "start-builder — clone a fresh disposable CT"
deploy_image
local vmid hostname
vmid=$(find_free_vmid)
hostname="$CT_HOSTNAME_PREFIX-$vmid"
log "Hostname will be: $hostname"
create_and_start_ct "$vmid"
hostname=$(wait_for_ready "$vmid")
if ! probe_ssh "$hostname"; then
print_debug_info "$vmid" "$hostname"
die "SSH probe failed."
fi
local ip; ip=$(get_ct_ip "$vmid")
cat >&2 <<EOF
$(ok "Disposable ready.")
VMID: $vmid
Hostname: $hostname
IP (eth0): ${ip:-(unknown)}
PVE host: $PVE_HOST
Rootfs: $PVE_ROOTFS_STORAGE ($CT_ROOTFS_GIB GiB)
Bridge: $PVE_BRIDGE
SSH: ssh $BUILD_SSH_USER@$hostname
pct enter: ssh $PVE_HOST -t pct enter $vmid
Next steps:
build: $0 build-on $vmid
destroy: $0 destroy-builder $vmid
inspect: $0 info $vmid
EOF
phase_done "start-builder"
}
cmd_build_on() {
require ssh "drive PVE"
require_pve
require nix "run nixos-rebuild"
require jq "discover + read container IP"
phase "build-on — build requester closure on disposable"
local vmid hostname rc
vmid=$(resolve_vmid "${POSITIONAL_VMID:-}")
hostname="$CT_HOSTNAME_PREFIX-$vmid"
step "Target disposable: VMID=$vmid hostname=$hostname"
# Sanity: confirm the container is running on PVE before trusting DNS.
local status
status=$(pve pct status "$vmid" 2>/dev/null | awk '{print $2}' || true)
debug "pct status $vmid -> ${status:-(unknown)}"
if [[ "$status" != "running" ]]; then
fail "Container $vmid is not running (status: ${status:-(unknown)})."
die "Start it on PVE first ('pct start $vmid') or pick another VMID."
fi
probe_ssh "$hostname" || { print_debug_info "$vmid" "$hostname"; die "Aborting build-on."; }
do_build "$vmid" "$hostname" && rc=0 || rc=$?
if [[ $rc -ne 0 ]]; then
fail "Build FAILED (exit $rc)."
print_debug_info "$vmid" "$hostname"
exit "$rc"
fi
ok "Build SUCCEEDED."
log " activate with: $0 activate <test|switch|boot>"
phase_done "build-on"
}
cmd_activate() {
require nix "run nixos-rebuild"
case "${ACTIVATE_CMD:-}" in
test|switch|boot) ;;
*) die "activate requires <test|switch|boot> as its argument." ;;
esac
phase "activate $ACTIVATE_CMD — apply closure LOCALLY (no SSH to disposable)"
local rc
do_activate "$ACTIVATE_CMD" && rc=0 || rc=$?
if [[ $rc -ne 0 ]]; then
fail "Activate FAILED (exit $rc)."
exit "$rc"
fi
ok "Activate SUCCEEDED."
# Auto-destroy the disposable only for switch/boot without --keep. The
# disposable (if any) is discovered from PVE; if multiple exist, skip the
# auto-destroy and tell the user to clean up explicitly.
if { [[ "$ACTIVATE_CMD" == "switch" || "$ACTIVATE_CMD" == "boot" ]]; } && ! $KEEP; then
step "Auto-destroying disposable (command was $ACTIVATE_CMD, no --keep) ..."
local matches count
matches=$(discover_disposables)
count=$(grep -c '^' <<<"$matches" || true)
case "$count" in
0)
log "No disposable left on PVE — nothing to auto-destroy." ;;
1)
local vmid; read -r vmid _ _ <<<"$matches"
destroy_ct "$vmid" ;;
*)
warn "Multiple disposables on PVE — skipping auto-destroy. Clean up explicitly:"
while IFS= read -r line; do warn " $line"; done <<<"$matches"
warn " e.g. $0 destroy-builder <VMID>"
;;
esac
fi
phase_done "activate $ACTIVATE_CMD"
}
cmd_destroy_builder() {
require ssh "drive PVE"
require_pve
phase "destroy-builder — pct destroy --purge --force"
local vmid
vmid=$(resolve_vmid "${POSITIONAL_VMID:-}")
destroy_ct "$vmid"
phase_done "destroy-builder"
}
cmd_info() {
require ssh "drive PVE"
require_pve
require jq "discover + read container IP"
phase "info — disposables on $PVE_HOST"
# No explicit VMID: list every disposable.
if [[ -z "$POSITIONAL_VMID" ]]; then
local matches count
matches=$(discover_disposables)
if [[ -z "$matches" ]]; then
log "No disposable containers on $PVE_HOST match '${CT_HOSTNAME_PREFIX}-*'."
log "Start one with: $0 start-builder"
phase_done "info"
return
fi
count=$(grep -c '^' <<<"$matches" || true)
log "Found $count disposable(s) on $PVE_HOST:"
while IFS= read -r line; do
local v s n maybe_ip
read -r v s n <<<"$line"
maybe_ip=$(get_ct_ip "$v" 2>/dev/null || true)
printf ' VMID=%-6s status=%-10s name=%-30s ip=%s\n' \
"$v" "$s" "$n" "${maybe_ip:-(no ip)}" >&2
done <<<"$matches"
log "Inspect one in detail with: $0 info <VMID>"
phase_done "info"
return
fi
# Explicit VMID: detail view.
local vmid hostname ip
vmid="$POSITIONAL_VMID"
hostname="$CT_HOSTNAME_PREFIX-$vmid"
ip=$(get_ct_ip "$vmid")
cat >&2 <<EOF
[pve-build] Disposable info
VMID: $vmid
Hostname: $hostname
IP (eth0): ${ip:-(unavailable — not running?)}
PVE host: $PVE_HOST
SSH (hostname): ssh ${DISPOSABLE_SSH_OPTS[*]} $BUILD_SSH_USER@$hostname
pct enter: ssh $PVE_HOST -t pct enter $vmid
destroy: $0 destroy-builder $vmid
EOF
phase_done "info"
}
## ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
phase_done "Configuration"
case "$COMMAND" in
build|test|switch|boot) cmd_composite ;;
check-image) cmd_check_image ;;
deploy-image) cmd_deploy_image ;;
update-image) cmd_update_image ;;
start-builder) cmd_start_builder ;;
build-on) cmd_build_on ;;
activate) cmd_activate ;;
destroy-builder) cmd_destroy_builder ;;
info) cmd_info ;;
*) die "Unknown command: $COMMAND" ;;
esac