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 <customer-id> 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:56:57 +02:00
parent de34d170d2
commit cfebdf5cd4
3 changed files with 100 additions and 7 deletions
+26
View File
@@ -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 <customer-id>` 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
+35 -3
View File
@@ -42,7 +42,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; 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)
+39 -4
View File
@@ -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:-<unset>})"
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"