v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted
Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before release. A box whose first ingestion failed now serves one bilingual waiting page and retries every minute instead of the wizard. Own commit so the lead can hold it back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped
|
||||
// hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the
|
||||
// only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes
|
||||
// it, and comes up CONFIGURED — skipping the setup wizard.
|
||||
// it, and comes up CONFIGURED (an unconfigured box waits and retries — the setup wizard is deleted, R-554).
|
||||
//
|
||||
// This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the
|
||||
// controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key).
|
||||
@@ -33,7 +33,7 @@ const DefaultMountPath = "/etc/felhom-bootstrap/bootstrap.json"
|
||||
// SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the
|
||||
// contract's MEANING (the controller pulls its config from the hub rather than seeding it from the
|
||||
// agent's host key) and its field set, so it is a clean version bump with no v1 back-compat
|
||||
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → setup mode.
|
||||
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → the box waits (R-554).
|
||||
const SchemaV2 = "felhom.bootstrap/v2"
|
||||
|
||||
// ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub).
|
||||
@@ -95,7 +95,7 @@ func Path() string {
|
||||
// - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is
|
||||
// NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged.
|
||||
// - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub
|
||||
// pull that ultimately fails leaves cfg unchanged (the caller proceeds to setup mode). It logs
|
||||
// pull that ultimately fails leaves cfg unchanged (the caller serves the waiting page and retries, R-554). It logs
|
||||
// and NEVER crashes — a hub outage at first boot must not brick the guest.
|
||||
// - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg.
|
||||
func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config {
|
||||
@@ -111,37 +111,37 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
|
||||
data, err := os.ReadFile(bpath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
logger.Printf("[WARN] bootstrap: cannot read %s: %v — staying in setup", bpath, err)
|
||||
logger.Printf("[WARN] bootstrap: cannot read %s: %v — waiting for the configuration", bpath, err)
|
||||
}
|
||||
return cfg // no bootstrap → normal setup
|
||||
return cfg // no bootstrap → the caller waits (WaitForConfig)
|
||||
}
|
||||
|
||||
var b Bootstrap
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — staying in setup", bpath, err)
|
||||
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — waiting for the configuration", bpath, err)
|
||||
return cfg
|
||||
}
|
||||
if b.Schema != SchemaV2 {
|
||||
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — staying in setup", b.Schema, SchemaV2)
|
||||
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — waiting for the configuration", b.Schema, SchemaV2)
|
||||
return cfg
|
||||
}
|
||||
if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" {
|
||||
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — staying in setup", bpath)
|
||||
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — waiting for the configuration", bpath)
|
||||
return cfg
|
||||
}
|
||||
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
|
||||
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — staying in setup", bpath)
|
||||
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — waiting for the configuration", bpath)
|
||||
return cfg
|
||||
}
|
||||
if pull == nil {
|
||||
logger.Printf("[WARN] bootstrap: no pull function wired — staying in setup")
|
||||
logger.Printf("[WARN] bootstrap: no pull function wired — waiting for the configuration")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. ---
|
||||
pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — staying in setup (manual setup wizard remains the fallback)",
|
||||
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — waiting for the configuration (the waiting page retries every minute)",
|
||||
b.Customer.ID, b.Hub.URL, err)
|
||||
return cfg
|
||||
}
|
||||
@@ -150,16 +150,16 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
|
||||
// every field the hub emits is preserved (forward-compat with hub template changes). ---
|
||||
merged, err := mergeLocalAPI(pulled, b.LocalAPI)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — staying in setup", err)
|
||||
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — waiting for the configuration", err)
|
||||
return cfg
|
||||
}
|
||||
if err := writeFileAtomic(configPath, merged); err != nil {
|
||||
logger.Printf("[WARN] bootstrap: could not write %s: %v — staying in setup", configPath, err)
|
||||
logger.Printf("[WARN] bootstrap: could not write %s: %v — waiting for the configuration", configPath, err)
|
||||
return cfg
|
||||
}
|
||||
reloaded, err := config.LoadPermissive(configPath)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — staying in setup", configPath, err)
|
||||
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — waiting for the configuration", configPath, err)
|
||||
return cfg
|
||||
}
|
||||
logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// ── Not configured yet: wait for the hub (R-554, v0.308.0, `09` §3 decision 222) ───────────────
|
||||
//
|
||||
// Until v0.308.0 a box whose first boot could not ingest its configuration (no or bad bootstrap.json, a
|
||||
// hub it could not reach, a failed write) entered the first-boot SETUP WIZARD — eight pages built for the
|
||||
// retired docker-setup.sh install. The agent provisions every box now; the wizard is deleted. Such a box
|
||||
// shows ONE page instead — „Felhom has not reached the central service yet", in both languages — and
|
||||
// retries the ingestion every minute. When it succeeds the page goes away and the controller starts
|
||||
// normally on the same listener. Pinned by TestWaitForConfig_ServesWaitingPageThenStarts.
|
||||
|
||||
// WaitRetryInterval is how often an unconfigured box retries the ingestion.
|
||||
const WaitRetryInterval = time.Minute
|
||||
|
||||
// Unconfigured reports whether this controller has no customer id — the only state that waits.
|
||||
func Unconfigured(cfg *config.Config) bool {
|
||||
return cfg == nil || cfg.Customer.ID == ""
|
||||
}
|
||||
|
||||
// WaitingHandler serves the waiting page on every path, and a health answer on /api/health (the
|
||||
// container's health check must keep passing while the box waits — it is not broken, it is early).
|
||||
func WaitingHandler(version string) http.Handler {
|
||||
page := waitingPage()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/health" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true, "message": "felhom-controller is waiting for its configuration",
|
||||
"waiting_for_hub": true, "setup_mode": true, "version": version,
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
}
|
||||
|
||||
// waitingPage is the page in BOTH languages: an unconfigured box has no household setting to choose by.
|
||||
func waitingPage() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html>\n<html lang=\"hu\">\n<head>\n<meta charset=\"UTF-8\">\n")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
|
||||
b.WriteString("<meta http-equiv=\"refresh\" content=\"60\">\n<title>Felhom</title>\n")
|
||||
b.WriteString("<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;line-height:1.5}section{margin-bottom:2rem}</style>\n")
|
||||
b.WriteString("</head>\n<body>\n")
|
||||
bundle, err := i18n.Shared()
|
||||
for _, lang := range i18n.Supported {
|
||||
title, body := "Felhom", ""
|
||||
if err == nil {
|
||||
title, body = bundle.Msg(lang, "waiting.title"), bundle.Msg(lang, "waiting.body")
|
||||
}
|
||||
b.WriteString("<section lang=\"" + lang + "\">\n<h1>" + html.EscapeString(title) + "</h1>\n<p>" + html.EscapeString(body) + "</p>\n</section>\n")
|
||||
}
|
||||
b.WriteString("</body>\n</html>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WaitForConfig serves the waiting page on ln and retries MaybeIngest every `every` until the box is
|
||||
// configured, then closes the page and returns the configured config. It returns nil when ctx ends first
|
||||
// (the process is stopping). ln may be nil (the port could not be bound): the retries still run.
|
||||
func WaitForConfig(ctx context.Context, ln net.Listener, configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc, every time.Duration, version string) *config.Config {
|
||||
var srv *http.Server
|
||||
if ln != nil {
|
||||
srv = &http.Server{Handler: WaitingHandler(version), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
logger.Printf("[ERROR] bootstrap: waiting page server: %v", err)
|
||||
}
|
||||
}()
|
||||
logger.Printf("[INFO] bootstrap: not configured yet — serving the waiting page on %s, retrying every %s", ln.Addr(), every)
|
||||
}
|
||||
stop := func() {
|
||||
if srv == nil {
|
||||
return
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sctx)
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for attempt := 1; ; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return nil
|
||||
case <-t.C:
|
||||
}
|
||||
next := MaybeIngest(configPath, cfg, logger, pull)
|
||||
if !Unconfigured(next) {
|
||||
logger.Printf("[INFO] bootstrap: configured after %d retries — closing the waiting page and starting", attempt)
|
||||
stop()
|
||||
return next
|
||||
}
|
||||
cfg = next
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-554 (`09` §3 decision 222): the setup wizard is deleted. A box whose first ingestion FAILED serves the
|
||||
// waiting page — in both languages, on every path, with a passing health answer — and not a 404 or a
|
||||
// wizard; it retries, and once the hub answers it returns the configured config and closes the page.
|
||||
//
|
||||
// RED-PROOF (REPORT): make WaitingHandler answer http.NotFound for "/" → "GET / = 404"; make the loop
|
||||
// return the unchanged cfg on the first tick → "the box did not come up configured".
|
||||
func TestWaitForConfig_ServesWaitingPageThenStarts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
|
||||
|
||||
var calls atomic.Int32
|
||||
pull := func(_, _, _ string) (string, error) {
|
||||
if calls.Add(1) <= 5 {
|
||||
return "", errors.New("hub answered 503") // permanent: no in-call retry, the loop retries
|
||||
}
|
||||
return hubYAML, nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := "http://" + ln.Addr().String()
|
||||
|
||||
// The first ingestion failed: the caller holds an unconfigured config.
|
||||
start := MaybeIngest(cfgPath, config.Default(), testLogger(), pull)
|
||||
if !Unconfigured(start) {
|
||||
t.Fatal("setup: the first ingestion should have failed")
|
||||
}
|
||||
|
||||
done := make(chan *config.Config, 1)
|
||||
go func() {
|
||||
done <- WaitForConfig(context.Background(), ln, cfgPath, start, testLogger(), pull, 150*time.Millisecond, "0.308.0")
|
||||
}()
|
||||
|
||||
get := func(path string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(addr + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
code, body := get("/")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET / = %d — an unconfigured box must show the waiting page, not a 404", code)
|
||||
}
|
||||
for _, want := range []string{`lang="hu"`, "nem érte el a központot", `lang="en"`, "has not reached the central service yet", "support@felhom.eu"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the waiting page does not carry %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(body), "varázsló") || strings.Contains(body, "8081") {
|
||||
t.Error("the waiting page still points at the setup wizard")
|
||||
}
|
||||
if code, _ := get("/backups/restore"); code != http.StatusOK {
|
||||
t.Errorf("GET /backups/restore = %d — every path shows the waiting page", code)
|
||||
}
|
||||
if code, body := get("/api/health"); code != http.StatusOK || !strings.Contains(body, `"waiting_for_hub":true`) {
|
||||
t.Errorf("health while waiting: %d %s", code, body)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got == nil || Unconfigured(got) || got.Customer.ID != "cust-8200" {
|
||||
t.Fatalf("the box did not come up configured: %+v", got)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("WaitForConfig never returned although the hub answered")
|
||||
}
|
||||
if _, err := http.Get(addr + "/"); err == nil {
|
||||
t.Error("the waiting page is still served after the box was configured")
|
||||
}
|
||||
}
|
||||
|
||||
// A stop signal while waiting returns nil (the process exits); it never pretends to be configured.
|
||||
func TestWaitForConfig_StopReturnsNil(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if got := WaitForConfig(ctx, nil, "", config.Default(), testLogger(), nil, time.Hour, "x"); got != nil {
|
||||
t.Fatalf("a stopped wait returned a config: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user