faef8f8a91
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
113 lines
4.5 KiB
Go
113 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|