From cfebdf5cd43b5b208b5577b441f16c441b9cb3eb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 11 Jun 2026 14:56:57 +0200 Subject: [PATCH] v0.20.0: golden stacks-dir bind + per-guest hostname/CT + bake base-infra images build-golden.sh: same-path /opt/docker/stacks host bind (Section-G fix, breaks all bind-mounted stacks without it) + --hostname from bootstrap.json (portable sed parse, no jq) + bake the 3 pinned public base-infra images with a manifest-inspect hard gate. Provision --selftest defaults -hostname to the DNS-safe-sanitized customer-id so the CT/LXC is named meaningfully. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++++++++++++++ cmd/felhom-agent/main.go | 38 ++++++++++++++++++++++++++++++++--- configs/build-golden.sh | 43 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5990bb0..ef15651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.20.0 — golden: stacks-dir bind + per-guest hostname/CT name + bake base-infra images (2026-06-11) + +Lockstep with `felhom-controller` v0.41.0 + a golden rebake. Changes in `configs/build-golden.sh` and +the provision path; no change to the proxmox/authz/token fences. + +- **Section-G mount fix (the load-bearing one):** the in-guest controller writes app/infra compose + stacks under `/opt/docker/stacks` *inside its container*, but the baked controller-bootstrap `docker run` + never bind-mounted that path. So `docker compose up` (run by the GUEST daemon over the shared socket) + resolved every relative bind source on the guest filesystem — silently creating empty dirs — which + broke **every** bind-mounted stack (base infra AND customer apps like immich/nextcloud). The bootstrap + unit now `mkdir -p /opt/docker/stacks` and adds a **same-path host bind** + `-v /opt/docker/stacks:/opt/docker/stacks` (a named volume would NOT fix this). Empirically confirmed on + guest 9201 before writing the fix. +- **Per-guest container hostname (3A):** the bootstrap unit derives `customer.id` from + `/etc/felhom-bootstrap/bootstrap.json` with a portable `sed` parse (NO jq in the golden) and passes + `--hostname ` to `docker run`, so the controller's `os.Hostname()` (its hub-reported + hostname) is the customer id, not the Docker container ID. Fail-safe: no parse → no `--hostname`. +- **Per-guest CT/LXC name (3B):** `--selftest=provision` now defaults `-hostname` to the (DNS-safe + sanitized) `-customer-id` when not given, so the bring-up's existing `SetConfig hostname` step + (`bringup.go`) names the CT meaningfully (e.g. `demo-felhom`) instead of inheriting the golden's + `felhom-golden`. New `sanitizeHostname` (lowercase, collapse invalid → `-`, trim, ≤63). +- **Bake base-infra images:** the golden now also pulls the three PINNED, PUBLIC base-infra images + (`traefik:v3.6.7`, `cloudflare/cloudflared:2026.6.0`, `gtstef/filebrowser:1.3.3-stable`) into its Docker + storage so the controller's first-boot bring-up is OFFLINE-capable. A hard gate (`docker manifest + inspect`) fails the bake early on a bad pin. Tags MUST match the controller's `internal/infra` constants. + ## v0.19.0 — bootstrap contract v2: agent relays the hub retrieval passphrase (no host key in the guest) (2026-06-11) Lockstep with `felhom-controller` v0.40.0. Fixes the onboarding 401: a freshly provisioned guest's diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 882a0d0..a8eccc8 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -42,7 +42,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.19.0" +var version = "0.20.0" func main() { var ( @@ -966,6 +966,30 @@ type provisionArgs struct { hubPassword string // the customer's hub retrieval passphrase (SECRET) — baked into bootstrap } +// sanitizeHostname makes s a DNS-safe LXC hostname (RFC 1123 label-ish): lowercase, any run of +// invalid characters collapses to a single '-', leading/trailing '-' stripped, capped at 63 chars. +// Returns "" if nothing usable remains (caller then sets no hostname). PVE itself validates, but a +// customer id can legitimately contain characters (e.g. '_' or spaces) that a hostname cannot. +func sanitizeHostname(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + prevDash := false + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + } else if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 63 { + out = strings.Trim(out[:63], "-") + } + return out +} + // runSelftestProvision runs the FULL slice-8A provisioning chain on-demand: the slice-7 bring-up // FRONT half (provision mode, golden) + the slice-8A BACK half (mint per-guest token → render the // stable bootstrap.json → write 0600 → chown to the mapped guest-root → attach the read-only bind @@ -1033,12 +1057,20 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger, }) - fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s) ===\n", version, a.vmid, a.customerID) + // Default the guest hostname to the customer id (DNS-safe-sanitized) when not explicitly given, + // so the CT/LXC is named meaningfully (e.g. "demo-felhom") instead of inheriting the golden's + // baked "felhom-golden". An explicit -hostname always wins. + hostname := a.hostname + if hostname == "" { + hostname = sanitizeHostname(a.customerID) + } + + fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s hostname=%s) ===\n", version, a.vmid, a.customerID, hostname) engine.Recover(ctx) fmt.Printf(" --- front half: bring-up (provision) %s → vmid %d ---\n", a.archive, a.vmid) res := engine.RunBringUp(ctx, reconcile.BringUpSpec{ Mode: reconcile.ModeProvision, Archive: a.archive, VMID: a.vmid, - RestoreStorage: cfg.Backup.RestoreStorage, Hostname: a.hostname, + RestoreStorage: cfg.Backup.RestoreStorage, Hostname: hostname, }) if res.Err != nil || !res.Pass { fmt.Fprintf(os.Stderr, " [FAIL] front-half bring-up (vmid %d): %v\n", a.vmid, res.Err) diff --git a/configs/build-golden.sh b/configs/build-golden.sh index 77abf84..7bfb696 100644 --- a/configs/build-golden.sh +++ b/configs/build-golden.sh @@ -73,6 +73,25 @@ pct exec "$VMID" -- bash -c "docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || t # Record the baked image ref for the bootstrap unit (so the unit needs no login/pull). pct exec "$VMID" -- bash -c "printf '%s\n' '$CONTROLLER_IMAGE' > /etc/felhom-controller-image" +# Bake the base-infrastructure images (traefik, cloudflared, filebrowser) so the controller's +# first-boot bring-up (EnsureBaseStack) is OFFLINE-capable — no registry pull at deploy. These are +# PUBLIC Docker Hub images (no cred needed). The PINNED tags MUST match the controller's +# internal/infra constants (TraefikImage / CloudflaredImage / FileBrowserImage); a drift means the +# golden bakes one image and the controller requests another (→ a pull at deploy, defeating the goal). +INFRA_IMAGES=( + "traefik:v3.6.7" + "cloudflare/cloudflared:2026.6.0" + "gtstef/filebrowser:1.3.3-stable" +) +echo "[golden] baking base-infra images: ${INFRA_IMAGES[*]} …" +for img in "${INFRA_IMAGES[@]}"; do + # Hard gate: fail loudly BEFORE pulling if a pinned tag doesn't resolve (a bad pin otherwise fails + # mid-bake with a confusing error). + pct exec "$VMID" -- bash -c "docker manifest inspect '$img' >/dev/null 2>&1" \ + || { echo "[golden] FATAL: pinned base-infra image does not resolve: $img"; exit 1; } + pct exec "$VMID" -- bash -c "docker pull '$img'" +done + echo "[golden] baking the controller-bootstrap unit (deploys the BAKED controller from the config mount) …" pct push "$VMID" /dev/stdin /usr/local/sbin/felhom-controller-bootstrap.sh --perms 700 <<'BOOTSH' #!/bin/bash @@ -85,16 +104,32 @@ CFG=/etc/felhom-bootstrap/bootstrap.json [ -r "$CFG" ] || { echo "[ctrl-bootstrap] no $CFG — not provisioned, nothing to do"; exit 0; } IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true) [ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: /etc/felhom-controller-image missing"; exit 1; } -echo "[ctrl-bootstrap] deploying $IMAGE from $CFG" +# Per-guest container hostname (slice base-infra/3A): derive from the bootstrap's customer.id so the +# controller's os.Hostname() (its hub-reported hostname) is the customer id, not the Docker container +# ID. Portable, dependency-free parse (NO jq in the golden) — bootstrap.json has exactly one "id" key +# (customer.id). Falls back to no --hostname if the parse yields nothing (fail-safe). +CUSTOMER_ID=$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CFG" | head -1) +HOSTNAME_ARG="" +[ -n "$CUSTOMER_ID" ] && HOSTNAME_ARG="--hostname $CUSTOMER_ID" +echo "[ctrl-bootstrap] deploying $IMAGE from $CFG (hostname=${CUSTOMER_ID:-})" docker rm -f felhom-controller >/dev/null 2>&1 || true +# Section-G fix (base-infra slice): the controller writes app/infra compose stacks under +# /opt/docker/stacks INSIDE the container, but `docker compose up` is executed by the GUEST daemon +# (shared socket), which resolves every relative bind source on the GUEST filesystem. Without a +# SAME-PATH host bind for /opt/docker/stacks, the daemon can't see those bind sources and silently +# creates empty dirs → every bind-mounted stack (base infra AND customer apps) breaks. A named volume +# would NOT fix this (it resolves to /var/lib/docker/volumes/...). Pre-create the dir, then same-path bind. +mkdir -p /opt/docker/stacks # slice 8C: the controller is DE-PRIVILEGED — disk execution (scan/format/mount/migrate) is the # host agent's job now, so this run grants NO disk privileges: no --privileged, no /dev, no -# /etc/fstab, no rshared /mnt. Only the bootstrap config (ro), the data volume, and the docker -# socket (app/stack management). The controller reaches the agent's local API for disk management. -docker run -d --name felhom-controller --restart unless-stopped \ +# /etc/fstab, no rshared /mnt. Only the bootstrap config (ro), the data volume, the stacks dir +# (same-path host bind), and the docker socket (app/stack management). The controller reaches the +# agent's local API for disk management. +docker run -d --name felhom-controller --restart unless-stopped $HOSTNAME_ARG \ -e FELHOM_BOOTSTRAP_PATH=/etc/felhom-bootstrap/bootstrap.json \ -v /etc/felhom-bootstrap:/etc/felhom-bootstrap:ro \ -v felhom-controller-data:/opt/docker/felhom-controller \ + -v /opt/docker/stacks:/opt/docker/stacks \ -v /var/run/docker.sock:/var/run/docker.sock \ "$IMAGE" echo "[ctrl-bootstrap] controller started"