From 6db68f4e45c35519907aa950075cbde8cc8042a4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 1 Jul 2026 07:47:34 +0200 Subject: [PATCH] host-install v1.3.0: --uninstall (clean revert) + pre-flight guards Colleague-safety batch #1+#2 (script-only). Adds a guarded, dry-run-aware --uninstall local host teardown (guest -> agent -> pveum(ACL,token,user,role) -> golden(opt-in) -> state file), with ours-check on the /etc/felhom-bootstrap mount, typed vmid confirm, other-Felhom-guests safe-skip, and idempotent tolerate-absent. Records customer_id + provisioned_vmid into the install state (_state_put/_state_get) so uninstall resolves its target automatically. Adds three provision pre-flight guards: multi-node (--node required), archive-storage exists, and a RAM-floor WARN. Validated dry-run-only on felhom-pve. Co-Authored-By: Claude Opus 4.8 (1M context) --- REPORT.md | 151 +++++++++---------- scripts/CHANGELOG.md | 44 ++++++ scripts/felhom-host-install.sh | 268 ++++++++++++++++++++++++++++++++- 3 files changed, 377 insertions(+), 86 deletions(-) diff --git a/REPORT.md b/REPORT.md index aeaaaa8..728c0e8 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,104 +1,99 @@ # felhom.eu — task reports -> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md). +> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md). --- -# REPORT — Configuration-tab move + online setup command + vmid auto-detect (hub v0.28.0 / host-install v1.2.0) +# REPORT — `felhom-host-install.sh --uninstall` (clean revert) + pre-flight guards (host-install v1.3.0) -**Date:** 2026-07-01 · **Repo:** `felhom.eu` (`hub/` + `scripts/` + `manifests/`) +**Date:** 2026-07-01 · **Repo:** `felhom.eu` (`scripts/` only) -Three operator-requested improvements. +Colleague-safety batch **#1 (`--uninstall`) + #2 (pre-flight guards)**. Script-only. A first-class, +guarded local host-teardown mode so an operator can cleanly back out of a trial install, plus three +provision pre-flight guards. Specced, built, and validated **dry-run-only** (the live `--uninstall` is a +supervised step and was deliberately NOT run — see the STOP list). -## Baselines +## 1. Confirmed baseline -hub `v0.27.0` @ `54daaf9` → **`v0.28.0`**; `scripts/felhom-host-install.sh` `v1.1.0` → **`v1.2.0`**. -Trunk-based `main`. Commits: `562c0df` (Parts A/B/C code), `629196c` (hub image manifest), `b77df3c` -(script header version). +| Repo | `main` @ commit | Current | → Target | +|------|-----------------|---------|----------| +| felhom.eu (`scripts/felhom-host-install.sh`) | `83c4f7f7` | script **v1.2.0** | **v1.3.0** | -## Part A — global settings moved to the Configuration tab +No agent/controller/hub change in this task. -The two **global** cards ("Managed updates — global floor", "Day-0 artifacts — agent & golden") were on -the Customers page; they now live on Configuration. +## 2. Files changed -- `internal/web/server.go` — `handleConfiguration` supplies `GlobalFloor` + `Artifacts` + `CSRFField`; - the two POST routes moved to `/configuration/global-floor` + `/configuration/artifacts`. -- `internal/web/configs.go` — `handleSetGlobalFloor`/`handleSetArtifacts` redirects `/configs?flash=…` - → `/configuration?flash=…` (5 redirects); `handleConfigList` data trimmed to `{Customers, ActiveNav, - Flash}` (dropped the now-unused `GlobalFloor`/`Artifacts`/`CSRFField`; `globalFloor` still computed for - per-customer effective-floor). -- `templates/configuration.html` — the two cards + their 5 flash banners added. `templates/configs.html` - — cards + flashes removed; back to customer list + "Add Customer". -- `internal/web/render_test.go` — updated: asserts the global cards are **gone** from configs.html and - **present** (with `/configuration/*` actions + a flash) on configuration.html. +- `scripts/felhom-host-install.sh` (v1.2.0 → **v1.3.0**) — the only code file. +- `scripts/CHANGELOG.md` — v1.3.0 entry (newest on top; this is where the v1.2.0 entry lives). +- `REPORT.md` — this file (overwritten). -## Part B — online setup command + serving fix + passphrase hardening +## 3. Commit hash(es) -- **B1 (`scripts/…`):** `read_passphrase` now reads `read -rsp … < /dev/tty` — the no-echo prompt works - whether the script is a local file or piped to bash. `--passphrase-file` unchanged; passphrase still - never on argv/logs/state. -- **B2 (serving):** `https://felhom.eu/scripts/felhom-host-install.sh` was **404** — the webpage nginx - doc-root is `.../current/website` and git-sync only synced `/website/`. Fixed in - `manifests/webpage.yaml`: added `/scripts/` to the git-sync sparse-checkout + an nginx - `location /scripts/ { root .../current; default_type text/plain; }`. Single source of truth (the repo's - `/scripts`), no duplicated copy. **Now 200** (verified live, serves `SCRIPT_VERSION="1.2.0"`). -- **B3 (`customer_unified.html`):** new **"Option 1: Online install (recommended)"** — download-then-run - (`curl -fsSL … -o … && sudo bash … --customer-id {{.CustomerID}}`) with a copy button; the passphrase is - **not** templated (entered at the prompt). Former local-file command demoted to Option 2, debug curl to - Option 3. Rationale note: fetched over TLS, inspect-before-run — not `curl | sudo bash`. +- `` on `main` (filled in after push; see §Served-copy check). -## Part C — vmid auto-detect (`scripts/…`) +## 4. What was implemented -- New `VMID_EXPLICIT` flag (set by `--vmid`). The pre-flight guard now determines "in use" against the - **`pct list` + `qm list`** id-set (LXC + VMs share the space) via new helpers `used_vmids` / - `_vmid_in_use` / `next_free_vmid`. -- **explicit `--vmid` + in use** → deterministic: die unless `--force`. -- **default 9201 + in use + no `--force`** → auto-pick the next free id (scan upward) + **confirm** from - `/dev/tty` (`[y/N]`); `die "no free vmid confirmed"` on decline. Never silent. -- **default 9201 + `--force`** → destructive over-provision, no prompt. -- `--vmid` help + `usage()` range updated (and usage now reaches `-h, --help`). +**Part 1 — install state records vmid + customer.** New `_state_put KEY VALUE` / `_state_get KEY` +helpers (python3, dry-run-guarded like `_state_mark`, `completed[]` shape untouched). `step_preflight` +now records `customer_id`; `step_provision` records `provisioned_vmid` on success (dry-run logs only). -## Green gates +**Part 2 — `--uninstall` mode** (early top-level dispatch, root-required, no `--customer-id`, no hub +contact, no passphrase). `run_uninstall` reverses install order: **guest → agent(unit+.bak / sudoers / +binary+.bak / state dir / user) → pveum(ACL user, ACL token, token, user, role) → golden(opt-in) → +state file.** Every mutation via `run()` (dry-run prints, executes nothing). Guards: ours-check on the +`/etc/felhom-bootstrap` bind mount (matched by the constant guest *path*, not a slot), typed vmid +confirmation (`/dev/tty`, skipped only in dry-run), other-Felhom-guests safe-skip (keeps host-level in +place unless `--force`), state-mismatch `--force` gate, and idempotent tolerate-absent on every object. +`--remove-golden` opt-in deletes the golden vzdump (`pvesm free`). Never removes the `sudo` package; +never contacts the hub. New helper `felhom_guests()` enumerates Felhom guests by the bootstrap mount. -- Hub: `go build ./... && go vet ./... && go test ./...` — all `ok` (incl. the updated - `TestTemplates_FloorRender`). -- Script: `bash -n` clean. `shellcheck` (koalaman container on the build server): my additions clean; - the only output is **2 pre-existing** SC2015 *info* notices (lines 833/843, unrelated verify - functions — present in HEAD, not touched). Local unit test of `next_free_vmid` (3 cases) passes. +**Part 3 — pre-flight guards** (all inside `step_preflight`, before any mutation → fire under dry-run): +multi-node guard (`die` unless `--node` explicit via new `NODE_EXPLICIT`; no-op single-node / under +`--skip-provision`), archive-storage-exists guard (`pvesm status --storage`; no-op under +`--skip-provision`), RAM floor WARN (`MemAvailable < 2048 MiB`, never fatal). -## Deploy +Confirmed the PVE 9 ACL-delete syntax live before wiring it: +`pveum acl delete --users|--tokens --roles `. -hub image `:0.28.0` built+pushed on 180; `manifests/hub.yaml` bumped; ArgoCD `felhom` synced → -**Synced/Healthy**; `deploy/hub` on `:0.28.0`. `felhom-webpage` rollout-restarted so it re-clones with -the new sparse-checkout (`/scripts/`) + new nginx config. +## 5. Green gate — `bash -n` + `shellcheck` -## Live proofs +- `bash -n scripts/felhom-host-install.sh` → **OK** (locally + on felhom-pve). +- `shellcheck v0.10.0` (fetched static binary on the build server; shellcheck isn't installed on any + box): **HEAD v1.2.0 baseline = 2× SC2015 (info)**; **v1.3.0 = 2× SC2015 (info)** at lines 1128 & 1138 + — both in the *pre-existing* `step_verify` (`A && B || C`), untouched by this task. **0 new warnings.** -- **Part A (claude-in-chrome, operator-authed):** Configuration tab shows both cards with current values - (global floor v0.87.0; agent 0.43.0 / golden 0.85.1); "Save global floor" → `/configuration?flash=floor_set` - with the green "Controller-version floor saved." banner (persist + flash on Configuration). The Customers - page shows only the customer list + "Add Customer" (cards gone; footer `0.28.0`). -- **Part B (curl + browser):** `curl https://felhom.eu/scripts/felhom-host-install.sh` → **HTTP 200**, - serves `SCRIPT_VERSION="1.2.0"`. The customer page (`demo-felhom`) shows Option 1 online install with the - id filled + copy button, Option 2 local, Option 3 debug — passphrase not in Option 1. -- **Part C (SSH, felhom-pve):** the deployed script's own vmid functions against real `pct`+`qm`: - used-set `{9000,9001,9100,9201,9999}`, 9201 in use → `next_free_vmid`=**9202** (verified genuinely free). - The actual guard block (extracted from the served script): explicit+no-force → deterministic die; - default+no-force → logs "next free vmid is 9202" + prompts on `/dev/tty` → clean `die "no free vmid - confirmed"` on no-confirm; default+`--force` → destructive-over-provision warning, no prompt. (A fully - interactive real run isn't automatable now that both prompts read `/dev/tty`; the guard was proven with - real host state + the real code path.) +## 6. Tests → §7 mapping (dry-run transcripts / read-only live checks on felhom-pve, single-node, live guest 9201) -## Observations +| # | Scenario | Result | +|---|----------|--------| +| T-A | full clean-uninstall plan (`--uninstall --vmid 9201 --dry-run`) | **PASS** — ours-check passes, prints `pct config 9201`, would-prompt for typed confirm, then the full plan: `pct stop/destroy 9201` → agent removal (unit + all `.bak-*`, sudoers, binary + all `.bak-*`, `rm -rf /var/lib/felhom-agent`, `userdel`) → `pveum acl delete` (user, then token) → token remove → user delete → **role delete last** → golden skipped → state file rm. All `[DRY-RUN]`. `sudo` package NOT touched; hub note printed. | +| T-B | other-guests detector | **PASS** — `felhom_guests()` enumeration prints only `9201` (only Felhom guest on the host). Skip-branch code-reviewed (2nd Felhom guest not available live). | +| T-C | not-ours refusal (**red-proof**) | **PASS** — `--uninstall --vmid 9001 --dry-run` (9001 exists, no bootstrap mount) → `die "vmid 9001 does not look like a Felhom-provisioned guest … Refusing to destroy. Pass --force to override."`, exit 1, dies before any `run()`. Nonexistent id `987654` → safe refusal ("absent and is not the recorded provisioned_vmid … --force"). | +| T-D | multi-node guard | **PASS (single-node)** — `node: demo-felhom (auto)`, guard is a no-op on the 1-node host; multi-node `die` code-reviewed. Live multi-node validation deferred (see STOP). | +| T-E | storage / RAM guards | **PASS** — `--dry-run --archive-storage nonesuch` → `die "archive storage 'nonesuch' not found (pvesm status)…"` before the passphrase prompt; normal dry-run prints the RAM line (`free RAM: ~12251 MiB`; the `<2048` WARN branch is code-reviewed — the box has 12 GiB free). | +| T-F | dry-run executes nothing | **PASS** — after all dry-runs: `pct status 9201` = running, `felhom-agent` active, binary present, `pveum` user + role present, `state.json` unchanged (no `provisioned_vmid` added — dry-run writes nothing). | +| Part 1 | `_state_put`/`_state_get` round-trip | **PASS** — isolated temp-file test: writes top-level `customer_id`/`provisioned_vmid`, preserves `completed[]`, reads both back. | -- The `/dev/tty` reads make the script prompts unattended-proof by design — so an automated run past the - prompts isn't possible; Part C was proven via the real functions/guard against live `pct`+`qm`. -- shellcheck shows 2 pre-existing SC2015 *info* notices unrelated to this change (left untouched — fixing - them would alter unrelated verify logic). -- The customer page still renders the retrieval passphrase in the debug Option 3 + the credential box - (pre-existing); the new Option 1 deliberately does not. +## 7. Served-copy version check (after push) -## Remaining audit follow-ups (unchanged) +``` +curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh | grep SCRIPT_VERSION +# expect: SCRIPT_VERSION="1.3.0" +``` +→ `` -Controller-side geo intent sync; a read-only reported-vs-desired "Show Diff"; the cosmetic `controllerURL` -cleanup in `configs.go`. +## 8. NOT yet live-validated — awaiting a supervised run + +- A **real live `--uninstall`** (actual guest destroy + `pveum` role/user/token/ACL removal + `userdel` + + state wipe). Deliberately not run per the TASK STOP — do it on the colleague's box or a throwaway + scratch guest, never the demo guest 9201. +- The **multi-node guard** on an actual 2+-node cluster (felhom-pve is single-node). +- The other-Felhom-guests safe-skip branch with a second live Felhom guest present. + +## 9. Observations (noticed, NOT acted on) + +- In `--dry-run`, `run_uninstall` prints `[OK] guest destroyed` after the `[DRY-RUN] pct destroy` + lines; the trailing `[DRY-RUN]: nothing above was actually executed.` clarifies it, and this mirrors + the existing dry-run success-logging pattern — left as-is (minimal change). +- Colleague-safety batch items #3 (CPU/mem cap, agent-side) and #4 (pool-scoped ACL) are separate specs; + not touched here. diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 36941e9..823cbd0 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,5 +1,49 @@ # Felhom scripts — Changelog +## felhom-host-install.sh v1.3.0 — `--uninstall` (clean revert) + pre-flight guards (2026-07-01) + +Colleague-safety batch #1+#2. Adds a first-class, guarded **`--uninstall`** teardown so an operator can +cleanly back out of a trial install, plus three provision pre-flight guards that stop common footguns. +Script-only; no agent/hub/controller change. + +- **`--uninstall` (local host teardown — no hub contact, no passphrase).** Reverses an install in the + install-order's reverse: **guest → agent(unit/sudoers/binary/state/user) → pveum(ACL,token,user,role) + → golden(opt-in) → state file.** Every mutation goes through `run()` so `--dry-run` prints the full + plan and executes nothing. Safety: + - **Ours-check:** refuses to destroy a guest that lacks the `/etc/felhom-bootstrap` bind mount (matched + by the constant guest *path*, not a hardcoded `mpN` slot — on the demo host it's `mp9`), unless + `--force`. + - **Typed confirmation:** must type the vmid to confirm PERMANENT destruction (read from `/dev/tty`; + skipped only under `--dry-run`, where nothing is destroyed). + - **Other-guests guard:** if any OTHER Felhom guest remains, destroys only the target and **leaves the + agent + PVE token + state in place** (re-run with `--force` to remove host-level anyway — orphans the + others). + - **Never removes the `sudo` package**; never contacts the hub (the host record intentionally persists). + - Presence-checked + idempotent: an already-absent guest/unit/sudoers/binary/user/ACL/token/role is a + tolerated skip, not an error. The `pveum role delete` runs only after its ACL grants are gone (PVE + refuses to delete a referenced role). Confirmed PVE 9 ACL-delete form: + `pveum acl delete / --users|--tokens --roles FelhomAgent`. + - Target vmid resolves from `--vmid`, else the recorded `provisioned_vmid` (else dies). A `--vmid` that + disagrees with the recorded one needs `--force`. + - **`--remove-golden`:** with `--uninstall`, also delete the golden vzdump from the archive storage + (`pvesm free`); otherwise it is left in place. +- **Install state now records `customer_id` + `provisioned_vmid`** (new `_state_put`/`_state_get` helpers, + dry-run-guarded like `_state_mark`; the `completed[]` shape is untouched) so a later `--uninstall` + resolves its target automatically and safely. +- **Pre-flight guards (provision mode):** + - **Multi-node guard** — on a 2+-node cluster, `die` (naming the nodes) unless `--node` is explicit + (new `NODE_EXPLICIT`); single-node keeps the current auto-pick. No-op under `--skip-provision`. + - **Archive-storage-exists guard** — verify `--archive-storage` appears in `pvesm status` (else `die`); + no-op under `--skip-provision`. + - **RAM floor (WARN, never fatal)** — warn when `MemAvailable < 2048 MiB`. + All three run inside `step_preflight` (before any mutation) so they also fire under `--dry-run`. +- **Validated dry-run-only on felhom-pve** (single-node, live guest 9201): T-A full uninstall plan, T-C + not-ours refusal (red-proof), archive-missing `die`, RAM line, other-guests detector, state round-trip; + confirmed 9201 + agent + pveum + state untouched after all dry-runs. `bash -n` + `shellcheck` clean + (0 new warnings vs. baseline; the 2 pre-existing SC2015 in `step_verify` are unchanged). **NOT yet + live-validated (awaiting a supervised run):** a real live `--uninstall` (guest destroy + pveum removal) + and the multi-node guard on an actual cluster. + ## felhom-host-install.sh v1.2.0 — /dev/tty passphrase read + vmid auto-detect (2026-07-01) Two operator-experience fixes so a colleague can install online (via the hub's new "Option 1: Online diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 3b8d85a..7e5a52b 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -1,6 +1,6 @@ #!/bin/bash #=============================================================================== -# felhom-host-install.sh v1.2.0 +# felhom-host-install.sh v1.3.0 # Day-0 host-bootstrap for a Felhom Proxmox host (operator-deploy model). # # Run by the operator on a FRESHLY-PVE-INSTALLED box (after a manual PVE install @@ -64,6 +64,16 @@ # --resume skip steps already recorded in the state file # -h, --help this help # +# Uninstall (local host teardown — no hub contact, no passphrase): +# --uninstall cleanly revert an install: destroy the Felhom guest, remove the agent +# (unit/sudoers/binary/state/user), the pveum role/user/token/ACL, and the +# install state file. Guarded: refuses a non-Felhom guest (no +# /etc/felhom-bootstrap mount) and skips host-level removal if OTHER Felhom +# guests remain (both overridable with --force). Typed vmid confirmation +# required. Reuses --vmid (else the recorded provisioned_vmid), --force, +# --archive-storage, --golden-vmid, --dry-run. +# --remove-golden with --uninstall, also delete the golden vzdump from the archive storage +# # State (idempotent/resumable): /var/lib/felhom-install/state.json # Agent config written 0600 to the systemd unit's -config path # (auto-detected; else /etc/felhom-agent/agent.json). @@ -75,7 +85,7 @@ set -euo pipefail -SCRIPT_VERSION="1.2.0" +SCRIPT_VERSION="1.3.0" #------------------------------------------------------------------------------- # Logging (mirrors felhom-controller/scripts/docker-setup.sh) @@ -103,6 +113,7 @@ GOLDEN_VOLID="" GOLDEN_VMID="9100" ARCHIVE_STORAGE="local" NODE="" +NODE_EXPLICIT=false # set true when --node is given; gates the multi-node wrong-node guard BRIDGE_ADDR="" ROOTFS_GROW="" DATAVOL_GROW="" @@ -116,6 +127,8 @@ FORCE_GITEA_GOLDEN=false SKIP_PROVISION=false DRY_RUN=false RESUME=false +UNINSTALL=false # --uninstall: local host teardown (destroy guest + remove agent/pveum/state) +REMOVE_GOLDEN=false # --remove-golden: also delete the golden vzdump during --uninstall # --- Gitea (artifact source) + agent install model (BUNDLE slice) --- GITEA_BASE="https://gitea.dooplex.hu" @@ -153,7 +166,7 @@ ART_GOLDEN_SHA="" #------------------------------------------------------------------------------- # Helpers #------------------------------------------------------------------------------- -usage() { sed -n '2,65p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } +usage() { sed -n '2,75p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } run() { # simple (no pipes/redirects) mutating command if $DRY_RUN; then log_dry "$*"; else "$@"; fi @@ -196,6 +209,18 @@ should_skip() { # returns 0 (skip) if --resume AND step already done if $RESUME && _state_has "$1"; then log_skip "step '$1' already completed"; return 0; fi return 1 } +# _state_put KEY VALUE — set a top-level string key in state.json (creates the file if absent). +# Mirrors _state_mark: dry-run no-ops (writes nothing), robust JSON via python3. +_state_put() { + $DRY_RUN && return 0 + mkdir -p "$STATE_DIR" + STATE_FILE="$STATE_FILE" python3 -c "import json,os,sys;f=os.environ['STATE_FILE'];d=json.load(open(f)) if os.path.exists(f) else {'completed':[]};d[sys.argv[1]]=sys.argv[2];json.dump(d,open(f,'w'),indent=2)" "$1" "$2" +} +# _state_get KEY — print the top-level string value for KEY (empty if the file/key is absent). +_state_get() { + [[ -f "$STATE_FILE" ]] || return 0 + STATE_FILE="$STATE_FILE" python3 -c "import json,os,sys;d=json.load(open(os.environ['STATE_FILE']));print(d.get(sys.argv[1],''))" "$1" 2>/dev/null +} http_code() { # GET, prints status code only (read-only preflight) curl -fsS -o /dev/null -w '%{http_code}' "$@" 2>/dev/null || curl -sS -o /dev/null -w '%{http_code}' "$@" 2>/dev/null @@ -258,6 +283,184 @@ fetch_raw() { [[ -s "$dest" ]] || die "raw fetch empty: $path" } +#------------------------------------------------------------------------------- +# Uninstall (local host teardown) — reverse of install; no hub contact, no passphrase +#------------------------------------------------------------------------------- +# felhom_guests — every vmid on this host that carries the /etc/felhom-bootstrap bind mount (the +# read-only bootstrap mount an agent-provisioned guest always has). Matched by the CONSTANT guest +# PATH, not a hardcoded mpN slot (the slot drifts; on the demo host it's mp9). +felhom_guests() { + local id + for id in $(used_vmids); do + pct config "$id" 2>/dev/null | grep -q 'mp=/etc/felhom-bootstrap' && echo "$id" + done +} + +# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it +# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user, +# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8. +run_uninstall() { + log_step "UNINSTALL — local host teardown" + + # 1. Resolve the target vmid: --vmid, else the recorded provisioned_vmid, else die. + local state_vmid vmid + state_vmid=$(_state_get provisioned_vmid) + if $VMID_EXPLICIT; then + vmid="$VMID" + elif [[ -n "$state_vmid" ]]; then + vmid="$state_vmid" + log_info " no --vmid given; using recorded provisioned_vmid=$vmid from $STATE_FILE" + else + die "pass --vmid N (state has no recorded vmid)" + fi + + # state-mismatch: an explicit --vmid that disagrees with the recorded one needs --force. + if $VMID_EXPLICIT && [[ -n "$state_vmid" && "$state_vmid" != "$vmid" ]]; then + if $FORCE; then + log_warn " --vmid $vmid differs from the recorded provisioned_vmid=$state_vmid — --force given, proceeding" + else + die "--vmid $vmid differs from the recorded provisioned_vmid=$state_vmid. Pass --force to override." + fi + fi + + # 2. Guest teardown (guarded: ours-check + typed confirm). + if _vmid_in_use "$vmid"; then + # ours-check: a Felhom guest carries the /etc/felhom-bootstrap bind mount (constant guest path). + if pct config "$vmid" 2>/dev/null | grep -q 'mp=/etc/felhom-bootstrap'; then + log_info " vmid $vmid looks like a Felhom guest (has the /etc/felhom-bootstrap mount)" + elif $FORCE; then + log_warn " vmid $vmid has NO /etc/felhom-bootstrap mount — --force given, destroying anyway" + else + die "vmid $vmid does not look like a Felhom-provisioned guest (no /etc/felhom-bootstrap mount). + Refusing to destroy. Pass --force to override." + fi + + # show the config so the operator can eyeball what is about to be destroyed + log_info " pct config $vmid:" + pct config "$vmid" 2>/dev/null | sed 's/^/ /' + + # typed confirmation — mandatory, never skipped except in --dry-run (nothing is destroyed there). + if $DRY_RUN; then + log_dry "would prompt: Type the vmid ($vmid) to confirm PERMANENT destruction" + else + local ans + read -rp "Type the vmid ($vmid) to confirm PERMANENT destruction: " ans < /dev/tty + [[ "$ans" == "$vmid" ]] || die "confirmation mismatch (got '$ans', expected '$vmid') — aborting, nothing destroyed" + fi + + # stop (tolerate already-stopped) then destroy + local gstat; gstat=$(pct status "$vmid" 2>/dev/null | awk '{print $2}') + if [[ "$gstat" == "running" ]]; then + run pct stop "$vmid" + else + log_skip " guest $vmid not running (status: ${gstat:-unknown}) — skip stop" + fi + run pct destroy "$vmid" + log_success " guest $vmid destroyed" + else + log_skip " guest $vmid already absent — skipping guest teardown" + # host-level removal is still ours-gated: allowed if state's provisioned_vmid matches; else --force. + if [[ -n "$state_vmid" && "$state_vmid" == "$vmid" ]]; then + log_info " recorded provisioned_vmid matches $vmid — host-level removal permitted" + elif ! $FORCE; then + die "guest $vmid is absent and is not the recorded provisioned_vmid ('${state_vmid:-none}') — refusing host-level removal without --force." + fi + fi + + # 3. Other-Felhom-guests detector — the safe default. If any OTHER Felhom guest remains and no + # --force, stop after the guest teardown and leave every host-level component in place. + local others others_csv + others=$(felhom_guests | grep -vx "$vmid" || true) + if [[ -n "$others" ]] && ! $FORCE; then + others_csv=$(echo "$others" | tr '\n' ' ' | sed 's/ */ /g;s/^ //;s/ $//;s/ /, /g') + echo "" + log_warn "Other Felhom guests remain (${others_csv}); leaving the agent + PVE token + state in place." + log_warn "Re-run --uninstall --force to remove host-level components anyway (this orphans ${others_csv})." + log_success "UNINSTALL (guest-only) complete — removed guest $vmid; host-level components preserved." + log_info " NOTE: the host record still exists in the hub — remove it there if desired." + $DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed." + return 0 + fi + + # ── host-level removal (reverse of install) ────────────────────────────────────────────────── + log_step "host-level removal" + + # 4. Agent removal — service, unit(+.bak), sudoers, binary(+.bak), state dir, user. NEVER `sudo`. + if systemctl list-unit-files felhom-agent.service >/dev/null 2>&1; then + systemctl is-active --quiet felhom-agent 2>/dev/null && run systemctl stop felhom-agent + systemctl is-enabled --quiet felhom-agent 2>/dev/null && run systemctl disable felhom-agent + else + log_skip " felhom-agent unit not loaded — skip stop/disable" + fi + if [[ -f "$AGENT_UNIT" ]]; then run rm -f "$AGENT_UNIT"; else log_skip " $AGENT_UNIT already absent"; fi + local bak + for bak in "${AGENT_UNIT}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done + run systemctl daemon-reload + if [[ -f "$AGENT_SUDOERS" ]]; then run rm -f "$AGENT_SUDOERS"; else log_skip " $AGENT_SUDOERS already absent"; fi + if [[ -f "$AGENT_BIN" ]]; then run rm -f "$AGENT_BIN"; else log_skip " $AGENT_BIN already absent"; fi + for bak in "${AGENT_BIN}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done + if [[ -d "$AGENT_STATE_DIR" ]]; then run rm -rf "$AGENT_STATE_DIR"; else log_skip " $AGENT_STATE_DIR already absent"; fi + if id "$AGENT_USER" >/dev/null 2>&1; then run userdel "$AGENT_USER"; else log_skip " service user $AGENT_USER already absent"; fi + + # 5. pveum removal (reverse order; presence-checked; tolerate-absent). The role delete must come + # AFTER its ACL grants are gone — PVE refuses to delete a referenced role. + local acls; acls=$(pveum acl list --output-format json 2>/dev/null || echo '[]') + if echo "$acls" | python3 -c "import json,sys;sys.exit(0 if any(e.get('path')=='/' and e.get('type')=='user' and e.get('ugid')=='$PVE_USER' and e.get('roleid')=='$PVE_ROLE' for e in json.load(sys.stdin)) else 1)" 2>/dev/null; then + run pveum acl delete / --users "$PVE_USER" --roles "$PVE_ROLE" + else + log_skip " ACL user grant ($PVE_USER @ /) already absent" + fi + if echo "$acls" | python3 -c "import json,sys;sys.exit(0 if any(e.get('path')=='/' and e.get('type')=='token' and e.get('ugid')=='${PVE_USER}!${PVE_TOKENID}' and e.get('roleid')=='$PVE_ROLE' for e in json.load(sys.stdin)) else 1)" 2>/dev/null; then + run pveum acl delete / --tokens "${PVE_USER}!${PVE_TOKENID}" --roles "$PVE_ROLE" + else + log_skip " ACL token grant (${PVE_USER}!${PVE_TOKENID} @ /) already absent" + fi + if pveum user token list "$PVE_USER" --output-format json 2>/dev/null | python3 -c "import json,sys;sys.exit(0 if any(t['tokenid']=='$PVE_TOKENID' for t in json.load(sys.stdin)) else 1)" 2>/dev/null; then + run pveum user token remove "$PVE_USER" "$PVE_TOKENID" + else + log_skip " token ${PVE_USER}!${PVE_TOKENID} already absent" + fi + if pveum user list --output-format json 2>/dev/null | python3 -c "import json,sys;sys.exit(0 if any(u['userid']=='$PVE_USER' for u in json.load(sys.stdin)) else 1)" 2>/dev/null; then + run pveum user delete "$PVE_USER" + else + log_skip " user $PVE_USER already absent" + fi + if pveum role list --output-format json 2>/dev/null | python3 -c "import json,sys;sys.exit(0 if any(r['roleid']=='$PVE_ROLE' for r in json.load(sys.stdin)) else 1)" 2>/dev/null; then + run pveum role delete "$PVE_ROLE" + else + log_skip " role $PVE_ROLE already absent" + fi + + # 6. Golden vzdump (opt-in via --remove-golden; else left in place). + if $REMOVE_GOLDEN; then + local gvols gv + gvols=$(pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | awk -v v="$GOLDEN_VMID" '$0 ~ ("vzdump-lxc-" v "-"){print $1}') + if [[ -n "$gvols" ]]; then + while IFS= read -r gv; do + [[ -n "$gv" ]] || continue + run pvesm free "$gv" + done <<<"$gvols" + log_success " removed golden vzdump(s) from $ARCHIVE_STORAGE" + else + log_skip " no golden vzdump (vzdump-lxc-${GOLDEN_VMID}-*) on $ARCHIVE_STORAGE" + fi + else + log_skip " golden vzdump left in place (pass --remove-golden to remove)" + fi + + # 7. Install state file (only reached when host-level removal ran — safe-skip returned earlier). + if [[ -f "$STATE_FILE" ]]; then run rm -f "$STATE_FILE"; else log_skip " $STATE_FILE already absent"; fi + run rmdir "$STATE_DIR" 2>/dev/null || true + + # 8. Summary. + echo "" + log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/user), the pveum role/user/token/ACL, and $STATE_FILE." + if $REMOVE_GOLDEN; then log_info " golden vzdump: removed."; else log_info " golden vzdump: left in place (--remove-golden to remove)."; fi + log_info " NOTE: the 'sudo' package was left installed (system package); the host record still exists in the hub — remove it there if desired." + $DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed." + return 0 +} + #------------------------------------------------------------------------------- # Arg parse #------------------------------------------------------------------------------- @@ -270,7 +473,7 @@ while [[ $# -gt 0 ]]; do --golden) GOLDEN_VOLID="$2"; shift 2 ;; --golden-vmid) GOLDEN_VMID="$2"; shift 2 ;; --archive-storage) ARCHIVE_STORAGE="$2"; shift 2 ;; - --node) NODE="$2"; shift 2 ;; + --node) NODE="$2"; NODE_EXPLICIT=true; shift 2 ;; --bridge-ip) BRIDGE_ADDR="$2"; shift 2 ;; --rootfs-grow) ROOTFS_GROW="$2"; shift 2 ;; --datavol-grow) DATAVOL_GROW="$2"; shift 2 ;; @@ -282,6 +485,8 @@ while [[ $# -gt 0 ]]; do --force) FORCE=true; shift ;; --force-gitea-golden) FORCE_GITEA_GOLDEN=true; shift ;; --skip-provision) SKIP_PROVISION=true; shift ;; + --uninstall) UNINSTALL=true; shift ;; + --remove-golden) REMOVE_GOLDEN=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --resume) RESUME=true; shift ;; -h|--help) usage ;; @@ -289,6 +494,20 @@ while [[ $# -gt 0 ]]; do esac done +#=============================================================================== +# UNINSTALL MODE — local host teardown (no hub contact, no passphrase). Dispatched early, +# before any provision/DR logic, and does not require --customer-id. +#=============================================================================== +if $UNINSTALL; then + [[ $EUID -eq 0 ]] || die "must run as root" + echo "" + log_info "felhom-host-install v${SCRIPT_VERSION} — mode=uninstall" + $DRY_RUN && log_warn "DRY-RUN: no mutations will be performed" + echo "" + run_uninstall + exit 0 +fi + #=============================================================================== # DR MODE — documented seam only (10D). NOT implemented. #=============================================================================== @@ -353,11 +572,19 @@ step_preflight() { [[ "$pvever" == *"/9."* ]] || log_warn "expected PVE 9.x, got: $pvever" log_info " $pvever" - # node - if [[ -z "$NODE" ]]; then - NODE=$(pvesh get /nodes --output-format json 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin)[0]['node'])" 2>/dev/null || hostname) + # node — on a MULTI-NODE cluster, auto-selecting nodes[0] is a wrong-node footgun. Require an + # explicit --node unless there is exactly one node (or no guest will be provisioned). + local nodes_json node_count node_names + nodes_json=$(pvesh get /nodes --output-format json 2>/dev/null || echo "[]") + node_count=$(python3 -c "import json,sys;print(len(json.loads(sys.argv[1])))" "$nodes_json" 2>/dev/null || echo 0) + if [[ "${node_count:-0}" -gt 1 ]] && ! $NODE_EXPLICIT && ! $SKIP_PROVISION; then + node_names=$(python3 -c "import json,sys;print(', '.join(n['node'] for n in json.loads(sys.argv[1])))" "$nodes_json" 2>/dev/null || echo "?") + die "this is a ${node_count}-node cluster (${node_names}); pass --node explicitly — auto-selecting nodes[0] risks provisioning on the wrong node." fi - log_info " node: $NODE" + if [[ -z "$NODE" ]]; then + NODE=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])[0]['node'])" "$nodes_json" 2>/dev/null || hostname) + fi + if $NODE_EXPLICIT; then log_info " node: $NODE (explicit)"; else log_info " node: $NODE (auto)"; fi # agent config path: honor the existing systemd unit's -config, else default if systemctl cat felhom-agent >/dev/null 2>&1; then @@ -383,6 +610,26 @@ step_preflight() { log_warn " could not read local-lvm free space (continuing)" fi + # RAM floor (soft): a big appliance guest can pressure existing guests on a small box. WARN only. + local mem_avail_mib + mem_avail_mib=$(awk '/^MemAvailable:/{print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0) + if [[ "${mem_avail_mib:-0}" -gt 0 ]]; then + if [[ "$mem_avail_mib" -lt 2048 ]]; then + log_warn " low free RAM (~${mem_avail_mib} MiB); the appliance guest may pressure existing guests — consider the CPU/mem cap." + else + log_info " free RAM: ~${mem_avail_mib} MiB" + fi + fi + + # archive-storage-exists guard (provision only — the golden lives there + the restore reads it). + if ! $SKIP_PROVISION; then + if pvesm status --storage "$ARCHIVE_STORAGE" >/dev/null 2>&1; then + log_info " archive storage '$ARCHIVE_STORAGE' present" + else + die "archive storage '$ARCHIVE_STORAGE' not found (pvesm status). Pass --archive-storage NAME." + fi + fi + # hub reachable local hc; hc=$(http_code "$HUB_URL/api/v1/config/$CUSTOMER_ID" -H "X-Retrieval-Password: preflight-no-op" || echo 000) [[ "$hc" != "000" ]] || die "hub unreachable at $HUB_URL" @@ -435,6 +682,8 @@ step_preflight() { log_success " using auto-selected vmid $VMID" fi fi + # Record the customer into the install state (foundation for a later automatic --uninstall). + _state_put customer_id "$CUSTOMER_ID" _state_mark preflight log_success "pre-flight passed" } @@ -853,6 +1102,7 @@ step_provision() { # visible in ps. Tracked as an Observation (candidate: env/stdin in the agent). if $DRY_RUN; then log_dry "felhom-agent --config $AGENT_CONFIG --selftest=provision -archive $GOLDEN_VOLID -vmid $VMID -customer-id $CUSTOMER_ID -hub-password -rootfs-grow $ROOTFS_GROW -datavol-grow $DATAVOL_GROW -sysdata-grow $SYSDATA_GROW" + log_dry "record provisioned_vmid=$VMID in $STATE_FILE (for a later automatic --uninstall)" _state_mark provision; return 0 fi if ! felhom-agent --config "$AGENT_CONFIG" --selftest=provision \ @@ -863,6 +1113,8 @@ step_provision() { fi log_success " provision completed" _state_mark provision + # Record the provisioned vmid so a later --uninstall resolves the target automatically + safely. + _state_put provisioned_vmid "$VMID" } #-------------------------------------------------------------------------------