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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user