aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKumar Damani <me@kumardamani.net>2026-09-15 20:29:29 +0000
committerKumar Damani <me@kumardamani.net>2026-09-15 21:09:51 +0000
commit276c9bd2a751f90911689a37e3a86f4a26056e23 (patch)
tree626dd69d29a2a4f57a5dcdbc6738c8c12a98b606
parent0b47dfe796edd63cf121c64e53de95dfc17c580d (diff)
automate adding new host flow
-rwxr-xr-xREADME.md55
-rwxr-xr-xmanage.sh531
-rw-r--r--nix/README.md17
-rwxr-xr-xnix/generate-keys.sh3
4 files changed, 583 insertions, 23 deletions
diff --git a/README.md b/README.md
index 9a0f1bf..884097a 100755
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
# My Home-Lab as Code
-> BEWARE! This project is in MEGA flux atm. Check the `nix/` for the latest stuff as I
-migrate most things there.
+> BEWARE! This project is in MEGA flux atm as I migrate most things to `nix/`.
Welcome to my home-lab!
@@ -13,21 +12,65 @@ of my hosts die, and I can use to use this repo to be able to re-create it all w
The flow is simple. First provision a VM with `tofu`.
Then deploy the configuration with `nix`.
+The `manage.sh` script can do the full flow for you while prompting you to verify
+prior to applying changes to real infra.
+
This is a monorepo with the following structure:
```tree
# Definitions for my Proxmox VMs, and LXC (legacy)
├── terraform/
-# Configurations for my services
+# Configurations for my VMs
├── nix/
-# Configurations for my services (legacy)
+# (DEPRECATED) Configurations for my VMs
├── ansible/
...
```
-# Architecture
+Example run:
+```
+❯ ./manage.sh
-tbd
+=== home-lab manager ===
+ 1) add a new host
+ 2) update an existing host
+ 3) update all hosts
+ 4) update base image + template
+ q) quit
+> 2
+Hosts:
+ 1) caldav
+ 2) git
+ 3) monitoring-2
+ 4) photos
+ 5) rproxy-2
+ 6) s3
+ 7) torrents-2
+Host name/number: 3
+Deploy action [switch/boot, default switch]:
+→ [monitoring-2] Host keys match, no action needed
+→ [monitoring-2] Deploying with nixos-rebuild...
+warning: Git tree '/home/kdam0/projects/home-lab' is dirty
+building the system configuration...
+warning: Git tree '/home/kdam0/projects/home-lab' is dirty
+copying 0 paths...
+Checking switch inhibitors... done
+updating GRUB 2 menu...
+activating the configuration...
+setting up /etc...
+reloading user units for jefe...
+restarting the following user units: nixos-activation.service
+restarting sysinit-reactivation.target
+the following new units were started: sysinit-reactivation.target, systemd-tmpfiles-resetup.service
+Done. The new configuration is /nix/store/2s6jq4jhvrhxzv5h73qvb0z1qyi05nkr-nixos-system-monitoring-2-26.11.20260910.8ce4ef6
+=== home-lab manager ===
+ 1) add a new host
+ 2) update an existing host
+ 3) update all hosts
+ 4) update base image + template
+ q) quit
+> q
+```
# What is NOT covered in this repo?
diff --git a/manage.sh b/manage.sh
new file mode 100755
index 0000000..f89f11a
--- /dev/null
+++ b/manage.sh
@@ -0,0 +1,531 @@
+#!/usr/bin/env bash
+# Top-level entry point for management.
+# Run without args for the interactive menu.
+set -euo pipefail
+
+# Resolve paths relative to this script, not the caller's CWD
+cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")"
+
+die() { echo "❌ $*" >&2; exit 1; }
+
+usage() {
+ cat <<'EOF'
+usage: manage.sh # interactive menu
+ manage.sh add <HOST> [flags]
+ manage.sh update <HOST> [switch|boot]
+ manage.sh update-all
+ manage.sh base # rebuild base image, restore + template on proxmox
+
+add flags:
+ --cpu N cores (default 1)
+ --mem MB memory in MB (default 1024)
+ --disk SIZE e.g. 10G (default 10G)
+ --vlan TAG network tag (default 30)
+ --tf show plan, ask for approval, then tofu/terraform apply
+ --ssh wait for ssh to come up
+ --deploy nix/deploy.sh <HOST> jefe
+ --all shorthand for --tf --ssh --deploy
+ --yes non-interactive: auto-approve the plan, skip prompts
+EOF
+}
+
+hosts() { ls nix/per-host | grep -v '^base$'; }
+
+confirm() {
+ local r=""
+ read -rp "$1 [y/N]: " r || r=""
+ [[ $r =~ ^[Yy]$ ]]
+}
+
+# Map a numeric selection (or a literal name) to a host name
+resolve_host() {
+ local input="$1" list=()
+ readarray -t list < <(hosts)
+ if [[ $input =~ ^[0-9]+$ ]]; then
+ (( input >= 1 && input <= ${#list[@]} )) || die "invalid selection: $input (pick 1..${#list[@]})"
+ printf '%s' "${list[input-1]}"
+ else
+ printf '%s' "$input"
+ fi
+}
+
+pick_host() {
+ local host="${1:-}"
+ if [[ -z $host ]]; then
+ local list=() i
+ readarray -t list < <(hosts)
+ {
+ echo "Hosts:"
+ for i in "${!list[@]}"; do printf ' %2d) %s\n' "$((i+1))" "${list[i]}"; done
+ } >&2
+ read -rp "Host name/number: " input || die "aborted"
+ [[ -n $input ]] || die "aborted"
+ fi
+ host=$(resolve_host "${host:-$input}")
+ [[ -d "nix/per-host/$host" ]] || die "no such host: '$host' (expected nix/per-host/$host)"
+ printf '%s' "$host"
+}
+
+# ---- actions ----
+
+cmd_update_host() {
+ local host target
+ host=$(pick_host "${1:-}")
+ target="${2:-}"
+ if [[ -z $target ]]; then
+ read -rp "Deploy action [switch/boot, default switch]: " target || target=""
+ target="${target:-switch}"
+ fi
+ [[ $target == switch || $target == boot ]] || die "action must be 'switch' or 'boot'"
+ nix/deploy.sh "$host" jefe "$target"
+}
+
+cmd_update_all() {
+ echo "→ updating flake inputs ..."
+ (cd nix && nix flake update)
+ local host
+ for host in $(hosts); do
+ if ! ssh -o ConnectTimeout=5 "$host" true 2>/dev/null; then
+ echo "→ [$host] offline, skipping"
+ continue
+ fi
+ # `boot` avoids the auto-switch restriction; the reboot activates it.
+ if nix/deploy.sh "$host" jefe boot; then
+ ssh "$host" sudo reboot || true
+ else
+ echo "→ [$host] deploy failed, not rebooting"
+ fi
+ done
+}
+
+cmd_update_base() {
+ echo "→ building base image ..."
+ (cd nix && nix flake update && nixos-rebuild build-image --image-variant proxmox --flake .#base)
+ local img
+ img=$(ls -t nix/result/*.vma.zst 2>/dev/null | head -n1) || die "no image found in nix/result/"
+ echo "→ image: $(basename "$img")"
+ echo "→ copying to lan.hesh:/var/lib/vz/dump/ ..."
+ scp "$img" lan.hesh:/var/lib/vz/dump/
+
+ # 104 is the clone source in TF (full clones, so no linked-clone breakage).
+ confirm "→ Destroy template 104 and restore from this image?" || {
+ echo "→ stopped after copy; restore manually"
+ return 0
+ }
+ ssh lan.hesh 'qm destroy 104 --purge'
+ ssh lan.hesh "qmrestore /var/lib/vz/dump/$(basename "$img") 104 --storage local-lvm"
+ ssh lan.hesh 'qm template 104'
+ echo "✅ template 104 updated"
+}
+
+# ---- add a new host (scaffold + optional full flow) ----
+
+cmd_add() {
+ local HOST="" CPU=1 MEM=1024 DISK="10G" VLAN=30
+ local CPU_GIVEN=0 MEM_GIVEN=0 DISK_GIVEN=0 VLAN_GIVEN=0
+ local RUN_TF=0 RUN_SSH=0 RUN_DEPLOY=0 AUTO_YES=0
+
+ # Interactive menu: offer the full flow up front
+ if (( $# == 0 )) && [[ -t 0 ]]; then
+ local r=""
+ read -rp "→ Run full flow (tofu apply, ssh wait, deploy)? [Y/n]: " r || r=""
+ [[ $r =~ ^[Nn]$ ]] || { RUN_TF=1; RUN_SSH=1; RUN_DEPLOY=1; }
+ fi
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) usage; return 0 ;;
+ --tf) RUN_TF=1 ;;
+ --ssh) RUN_SSH=1 ;;
+ --deploy) RUN_DEPLOY=1 ;;
+ --all) RUN_TF=1; RUN_SSH=1; RUN_DEPLOY=1 ;;
+ -y|--yes) AUTO_YES=1 ;;
+ --cpu) [[ $# -ge 2 ]] || die "--cpu needs a value"; CPU=$2; CPU_GIVEN=1; shift ;;
+ --mem) [[ $# -ge 2 ]] || die "--mem needs a value"; MEM=$2; MEM_GIVEN=1; shift ;;
+ --disk) [[ $# -ge 2 ]] || die "--disk needs a value"; DISK=$2; DISK_GIVEN=1; shift ;;
+ --vlan) [[ $# -ge 2 ]] || die "--vlan needs a value"; VLAN=$2; VLAN_GIVEN=1; shift ;;
+ -*) usage >&2; die "unknown flag: $1" ;;
+ *) [[ -z $HOST ]] || die "unexpected argument: $1"; HOST=$1 ;;
+ esac
+ shift
+ done
+
+ if [[ -z $HOST ]]; then
+ read -rp "Host name: " HOST
+ fi
+ [[ $HOST =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || die "invalid host name: '$HOST'"
+ [[ -d nix/per-host/$HOST && $HOST != base ]] && echo "→ note: nix/per-host/$HOST already exists"
+
+ [[ $CPU =~ ^[0-9]+$ ]] || die "--cpu must be an integer"
+ [[ $MEM =~ ^[0-9]+$ ]] || die "--mem must be an integer (MB)"
+ [[ $VLAN =~ ^[0-9]+$ && $VLAN -le 4094 ]] || die "--vlan must be 0..4094"
+ [[ $DISK =~ ^[0-9]+[KMGT]B?$ ]] || die "--disk must look like 10G / 100G / 512M"
+
+ # Prompt (interactive only) for anything not given; defaults come from monitoring-2.
+ if [[ -t 0 ]] && ! (( AUTO_YES )); then
+ (( CPU_GIVEN )) || { read -rp "CPU cores [$CPU]: " r || r=""; CPU=${r:-$CPU}; }
+ (( MEM_GIVEN )) || { read -rp "Memory MB [$MEM]: " r || r=""; MEM=${r:-$MEM}; }
+ (( DISK_GIVEN )) || { read -rp "Disk size [$DISK]: " r || r=""; DISK=${r:-$DISK}; }
+ (( VLAN_GIVEN )) || { read -rp "VLAN tag [$VLAN]: " r || r=""; VLAN=${r:-$VLAN}; }
+ fi
+
+ local SNAKE="${HOST//-/_}"
+ local SOPS_PREFIX
+ SOPS_PREFIX=$(sed -E 's/-[0-9]+$//' <<<"$HOST") # torrents-2 -> torrents (for path_regex)
+
+ # ---- host keys + age key ----
+ if [[ -f "nix/host-keys/$HOST/ssh_host_ed25519_key.pub" ]]; then
+ echo "→ [$HOST] host keys exist, skipping generation"
+ else
+ nix/generate-keys.sh "$HOST" >/dev/null
+ echo "→ [$HOST] generated host keys"
+ fi
+
+ if ! command -v ssh-to-age >/dev/null; then
+ die "ssh-to-age not found (direnv should provide it via nix/.envrc)"
+ fi
+ local AGE_KEY
+ AGE_KEY=$(ssh-to-age < "nix/host-keys/$HOST/ssh_host_ed25519_key.pub")
+ [[ $AGE_KEY == age1* ]] || die "failed to derive age key for $HOST"
+
+ # ---- .sops.yaml: key anchor ----
+ if grep -q "&server_${SNAKE} " nix/.sops.yaml; then
+ echo "→ [$HOST] .sops.yaml anchor exists, skipping"
+ else
+ awk -v line=" - &server_${SNAKE} ${AGE_KEY}" '
+ !done && /^creation_rules:/ { print line; done=1 }
+ { print }' nix/.sops.yaml > nix/.sops.yaml.tmp && mv nix/.sops.yaml.tmp nix/.sops.yaml
+ echo "→ [$HOST] added age key to .sops.yaml"
+ fi
+
+ # ---- .sops.yaml: creation rule (extend an existing prefix rule if present) ----
+ if grep -qE "^ +- \*server_${SNAKE}\$" nix/.sops.yaml; then
+ echo "→ [$HOST] .sops.yaml creation rule exists, skipping"
+ elif grep -q "path_regex: .secrets/${SOPS_PREFIX}\." nix/.sops.yaml; then
+ awk -v prefix="$SOPS_PREFIX" -v newline=" - *server_${SNAKE}" '
+ /^ - path_regex/ { inrule=0 }
+ $0 ~ (" - path_regex: .secrets/" prefix) { inrule=1 }
+ inrule && /^ - / { last=NR }
+ /^$/ { inrule=0 }
+ { lines[NR]=$0 }
+ END {
+ for (i=1; i<=NR; i++) {
+ print lines[i]
+ if (i == last) print newline
+ }
+ }' nix/.sops.yaml > nix/.sops.yaml.tmp && mv nix/.sops.yaml.tmp nix/.sops.yaml
+ echo "→ [$HOST] extended existing creation rule for 'secrets/${SOPS_PREFIX}.*'"
+ else
+ cat >> nix/.sops.yaml <<EOF
+
+ - path_regex: 'secrets/${SOPS_PREFIX}.*\.yaml$'
+ key_groups:
+ - age:
+ - *admin_me
+ - *admin_me_2
+ - *server_${SNAKE}
+EOF
+ echo "→ [$HOST] added creation rule for 'secrets/${SOPS_PREFIX}.*'"
+ fi
+
+ # ---- per-host config ----
+ if [[ -f "nix/per-host/$HOST/configuration.nix" ]]; then
+ echo "→ [$HOST] per-host config exists, skipping"
+ else
+ mkdir -p "nix/per-host/$HOST"
+ cat > "nix/per-host/$HOST/configuration.nix" <<EOF
+{ config, pkgs, modulesPath, lib, system, ... }:
+
+{
+ config = {
+ sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+ sops.defaultSopsFile = ../../secrets/${HOST}.yaml;
+
+ networking.hostName = "${HOST}";
+ };
+}
+EOF
+ echo "→ [$HOST] created nix/per-host/$HOST/configuration.nix"
+ fi
+
+ # ---- flake.nix: nixosConfiguration ----
+ if grep -q "\"${HOST}\" = nixpkgs.lib.nixosSystem" nix/flake.nix; then
+ echo "→ [$HOST] flake.nix entry exists, skipping"
+ else
+ local FLAKE_BLOCK
+ FLAKE_BLOCK=$(cat <<EOF
+ "${HOST}" = nixpkgs.lib.nixosSystem {
+ system = "x86_64-linux";
+ modules = [
+ ./per-host/base/configuration.nix
+ ./per-host/${HOST}/configuration.nix
+ sops-nix.nixosModules.sops
+ ];
+ };
+EOF
+)
+ awk -v block="$FLAKE_BLOCK" '
+ { lines[NR]=$0 }
+ END {
+ # insert before the close of nixosConfigurations (" };" + " };"),
+ # falling back to the last " };"
+ ins=0
+ for (i=NR; i>=1; i--) {
+ if (lines[i] == " };" && (i == NR || lines[i+1] == " };")) { ins=i; break }
+ }
+ for (i=1; i<=NR; i++) {
+ if (i == ins) { print block; print "" }
+ print lines[i]
+ }
+ }' nix/flake.nix > nix/flake.nix.tmp && mv nix/flake.nix.tmp nix/flake.nix
+ echo "→ [$HOST] added nixosConfigurations entry to flake.nix"
+ fi
+
+ # ---- secrets ----
+ if [[ -f "nix/secrets/$HOST.yaml" ]]; then
+ echo "→ [$HOST] nix/secrets/$HOST.yaml exists, skipping"
+ else
+ command -v sops >/dev/null || die "sops not found (direnv should provide it via nix/.envrc)"
+ printf 'foo: bar\n' > "nix/secrets/$HOST.yaml"
+ (cd nix && sops -e -i "secrets/$HOST.yaml") || die "sops encrypt failed"
+ echo "→ [$HOST] created nix/secrets/$HOST.yaml (foo: bar)"
+ fi
+
+ # ---- Terraform VM (only if it doesn't exist) ----
+ local TF_CREATED=0
+ if grep -q "proxmox_vm_qemu\" \"${SNAKE}\"" terraform/main.tf; then
+ echo "→ [$HOST] TF resource proxmox_vm_qemu.${SNAKE} exists, skipping"
+ else
+ local ORDER
+ ORDER=$(grep -E '^\s*order\s*=' terraform/main.tf \
+ | grep -oE '[0-9]+' | sort -n | tail -n1)
+ ORDER=$(( ${ORDER:-0} + 1 ))
+ cat >> terraform/main.tf <<EOF
+
+resource "proxmox_vm_qemu" "${SNAKE}" {
+ target_node = "hesh"
+ name = "${HOST}"
+ memory = ${MEM}
+ start_at_node_boot = true
+ boot = "order=virtio0;net0"
+ clone_id = 104
+ full_clone = true
+ scsihw = "virtio-scsi-single"
+
+ startup_shutdown {
+ order = ${ORDER}
+ }
+
+ cpu {
+ cores = ${CPU}
+ }
+
+ disk {
+ type = "disk"
+ slot = "virtio0"
+ size = "${DISK}"
+ storage = "local-lvm"
+ format = "raw"
+ }
+
+ network {
+ id = 0
+ model = "virtio"
+ bridge = "vmbr1"
+ tag = ${VLAN}
+ mtu = 1420
+ }
+}
+EOF
+ TF_CREATED=1
+ echo "→ [$HOST] added proxmox_vm_qemu.${SNAKE} to terraform/main.tf (startup order ${ORDER})"
+ fi
+
+ # ---- ~/.ssh/config ----
+ local SSH_CONFIG="${HOME}/.ssh/config"
+ if [[ -f $SSH_CONFIG ]] && grep -qE "^Host ${HOST}\$" "$SSH_CONFIG"; then
+ echo "→ [$HOST] ~/.ssh/config block exists, skipping"
+ else
+ mkdir -p "${HOME}/.ssh"
+ touch "$SSH_CONFIG"
+ cat >> "$SSH_CONFIG" <<EOF
+
+Host ${HOST}
+ User jefe
+ ProxyJump lan.labj
+ # required for first ssh
+ # remove after ./deploy.sh has run
+ HostName nixos
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+EOF
+ echo "→ [$HOST] added ~/.ssh/config block"
+ fi
+
+ # ---- README Hosts section ----
+ if grep -q "^### ${HOST}\$" nix/README.md; then
+ echo "→ [$HOST] README section exists, skipping"
+ else
+ local PURPOSE=""
+ if [[ -t 0 ]] && ! (( AUTO_YES )); then
+ read -rp "Host purpose for README (blank = skip): " PURPOSE || PURPOSE=""
+ fi
+ cat >> nix/README.md <<EOF
+
+### ${HOST}
+EOF
+ if [[ -n ${PURPOSE// /} ]]; then
+ printf '\n%s\n' "$PURPOSE" >> nix/README.md
+ fi
+ echo "→ [$HOST] added README Hosts section"
+ fi
+
+ # ---- Phase: tofu plan/approve/apply (idempotent; plan is shown before apply) ----
+ local TF_APPLIED=0 TF_CLEAN=0 TF_BLOCKED=0
+ if (( RUN_TF )); then
+ local TOFU=()
+ if command -v tofu >/dev/null; then TOFU=(tofu)
+ elif command -v terraform >/dev/null; then TOFU=(terraform)
+ else
+ die "tofu/terraform not on PATH (direnv provides it via nix/.envrc)"
+ fi
+ echo "→ [$HOST] running ${TOFU[0]} plan ..."
+ rm -f terraform/tfplan # clear any stale plan
+ local PLAN_OUT
+ PLAN_OUT=$( cd terraform &&
+ # shellcheck disable=SC1091
+ source con.env && "${TOFU[@]}" plan -out=tfplan ) || die "tofu plan failed"
+ printf '%s\n' "$PLAN_OUT"
+
+ if grep -q "No changes." <<<"$PLAN_OUT"; then
+ echo "→ [$HOST] no infrastructure changes; apply not needed"
+ TF_CLEAN=1
+ else
+ local approved=0
+ if (( AUTO_YES )); then
+ approved=1
+ elif [[ -t 0 ]]; then
+ read -rp "→ Apply the above plan? [y/N]: " r || r=""
+ if [[ $r =~ ^[Yy]$ ]]; then approved=1; fi
+ else
+ echo "→ [$HOST] no tty available for approval; use --yes to auto-approve"
+ fi
+ if (( approved )); then
+ echo "→ [$HOST] applying ..."
+ ( cd terraform &&
+ # shellcheck disable=SC1091
+ source con.env && "${TOFU[@]}" apply tfplan ) || die "tofu apply failed"
+ echo "→ [$HOST] apply done"
+ TF_APPLIED=1
+ else
+ echo "→ [$HOST] apply declined"
+ TF_BLOCKED=1
+ fi
+ fi
+ rm -f terraform/tfplan
+ fi
+
+ # ---- Phase: wait for ssh (VM first boots as 'nixos' via the ~/.ssh/config workaround) ----
+ if (( RUN_SSH && TF_BLOCKED )); then
+ echo "→ [$HOST] skipping ssh phase (apply was declined)"
+ elif (( RUN_SSH )); then
+ local SSH_TRIES=30 SSH_WAIT=5 ok=0
+ echo "→ [$HOST] waiting for ssh (up to $((SSH_TRIES * SSH_WAIT))s) ..."
+ local i
+ for i in $(seq 1 "$SSH_TRIES"); do
+ if ssh -o ConnectTimeout=5 "$HOST" true 2>/dev/null; then ok=1; break; fi
+ sleep "$SSH_WAIT"
+ done
+ (( ok )) || die "ssh to $HOST failed after $SSH_TRIES attempts"
+ echo "→ [$HOST] ssh ok"
+ fi
+
+ # ---- Phase: deploy (nixos-rebuild switch is idempotent) ----
+ if (( RUN_DEPLOY && TF_BLOCKED )); then
+ echo "→ [$HOST] skipping deploy phase (apply was declined)"
+ elif (( RUN_DEPLOY )); then
+ echo "→ [$HOST] deploying ..."
+ nix/deploy.sh "$HOST" jefe
+ echo "→ [$HOST] deploy done"
+ fi
+
+ # ---- Summary ----
+ echo ""
+ echo "================ ${HOST} ================"
+ echo " age key: ${AGE_KEY}"
+ echo " sops prefix: secrets/${SOPS_PREFIX}.*.yaml"
+ echo " flake target: .#${HOST}"
+ echo " per-host config: nix/per-host/${HOST}/configuration.nix"
+ echo " secrets: nix/secrets/${HOST}.yaml (foo: bar)"
+ if (( TF_CREATED )); then
+ echo " TF resource: proxmox_vm_qemu.${SNAKE} (created: cpu=${CPU} mem=${MEM} disk=${DISK} vlan=${VLAN})"
+ else
+ echo " TF resource: proxmox_vm_qemu.${SNAKE} (already in TF)"
+ fi
+ echo ""
+ echo "Next steps:"
+ if (( TF_CREATED && ! RUN_TF )) || (( RUN_TF && ! TF_APPLIED && ! TF_CLEAN )); then
+ cat <<EOF
+
+ cd terraform
+ source con.env
+ tofu plan -out=tfplan
+ tofu apply tfplan
+EOF
+ fi
+ if (( ! RUN_SSH )); then
+ cat <<EOF
+
+ # test ssh (first boot uses the base image hostname 'nixos')
+ ssh ${HOST} true && echo ok
+EOF
+ fi
+ if (( ! RUN_DEPLOY )); then
+ cat <<EOF
+
+ # deploy
+ nix/deploy.sh ${HOST} jefe
+EOF
+ fi
+ cat <<EOF
+
+ # then remove the StrictHostKeyChecking/UserKnownHostsFile workaround
+ # from ~/.ssh/config for ${HOST}
+EOF
+}
+
+# ---- menu ----
+
+menu() {
+ while true; do
+ echo ""
+ echo "=== home-lab manager ==="
+ echo " 1) add a new host"
+ echo " 2) update an existing host"
+ echo " 3) update all hosts"
+ echo " 4) update base image + template"
+ echo " q) quit"
+ local choice=""
+ read -rp "> " choice || exit 0
+ case "$choice" in
+ # subshells: a die/validation failure inside an action must not kill the menu
+ 1) ( cmd_add ) || echo "✗ add failed" ;;
+ 2) ( cmd_update_host ) || echo "✗ update failed" ;;
+ 3) ( cmd_update_all ) || echo "✗ update-all failed" ;;
+ 4) ( cmd_update_base ) || echo "✗ base update failed" ;;
+ q|quit) exit 0 ;;
+ "") ;;
+ *) echo "? unknown choice: $choice" ;;
+ esac
+ done
+}
+
+case "${1:-}" in
+ "" ) menu ;;
+ -h|--help|help) usage ;;
+ add) shift; cmd_add "$@" ;;
+ update) shift; cmd_update_host "$@" ;;
+ update-all) cmd_update_all ;;
+ base) cmd_update_base ;;
+ *) usage >&2; die "unknown command: $1" ;;
+esac
diff --git a/nix/README.md b/nix/README.md
index 95cd4ac..920e9d3 100644
--- a/nix/README.md
+++ b/nix/README.md
@@ -28,25 +28,8 @@ Convert to Proxmox Template:
ssh lan.hesh 'qm template 104'
```
-Run TF as usual using clone against the Template.
-```sh
-cd ../terraform
-source con.env
-tofu plan -out=tfplan
-tofu apply tfplan
-```
-
## Add Host
-Scaffold a new host (host keys, `.sops.yaml`, `flake.nix`, `per-host/`, secrets, TF VM if absent, `~/.ssh/config`, Hosts section) and print the exact cmds to create the VM and deploy:
-```sh
-./add.sh <HOST_NAME> # interactive for cpu/mem/disk/vlan
-./add.sh <HOST_NAME> --cpu 1 --mem 1024 --disk 10G --vlan 30
-```
-
-Defaults for cpu/mem/disk/vlan are taken from `monitoring-2`. If the VM already exists in TF it is left alone.
-
-The manual equivalent, if needed:
```sh
./generate-keys.sh <HOST_NAME>
diff --git a/nix/generate-keys.sh b/nix/generate-keys.sh
index 611d931..89484e7 100755
--- a/nix/generate-keys.sh
+++ b/nix/generate-keys.sh
@@ -2,6 +2,9 @@
# Generate a host key for a specific host
set -euo pipefail
+# Resolve paths relative to this script, not the caller's CWD
+cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")"
+
HOST="$1"
mkdir -p "./host-keys/${HOST}"
ssh-keygen -t ed25519 -f "./host-keys/${HOST}/ssh_host_ed25519_key" -N ""