controller: config-apply self-restart + manual restart button (A)

POST /api/config/apply now takes effect via a graceful SELF-RESTART instead of
logging "restart needed" and leaving stale in-process singletons (the CF client
is built once at startup, so a rotated Cloudflare token never applied until a
manual LXC restart). Container is restart:unless-stopped, so a clean os.Exit(0)
auto-restarts with fresh config.

- New gracefulSelfRestart helper behind an injectable Restarter seam (Router.restart
  + SetRestarter) so the exit is unit-testable.
- configApply: no-op guard (byte-identical re-push → no write, no restart), else
  write → 200 (flushed) → restart. Removed stale "restart needed" wording.
- Removed the dead OnConfigApplied hook (Phase-1-retired infra-backup push; the
  self-restart reloads everything and a fresh report is pushed on startup).
- New POST /api/selfrestart (auth+CSRF via /api/ mount) + "Vezérlő újraindítása"
  settings button: confirm → POST → poll GET / every 2s → reload.
- Tests: changed→restart once; identical→not called (companion); invalid→not called;
  selfrestart→restart once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:56:51 +02:00
parent 2a2514255b
commit ba87412508
5 changed files with 228 additions and 15 deletions
+49 -9
View File
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -41,8 +42,14 @@ type Router struct {
notifier *notify.Notifier
logger *log.Logger
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
OnConfigApplied func()
// restart triggers a graceful self-restart (config-apply + the manual restart button).
// Defaults to a real exit-after-flush so Docker's restart:unless-stopped brings the
// process back with fresh config; tests inject a recorder via SetRestarter.
restart func()
// triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a
// geo settings change so the hub reflects the new state immediately). Nil = no-op.
triggerReportPush func()
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
OnGeoRelevantChange func()
@@ -86,7 +93,25 @@ func (r *Router) SetIntegrationManager(im *integrations.Manager) {
}
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
r.restart = func() { gracefulSelfRestart(r.logger) }
return r
}
// SetRestarter overrides the graceful-restart action. Tests inject a recorder so the
// process is not actually killed.
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
// The provided func MUST be non-blocking (it is called from request handlers).
func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn }
// reportPushNow fires the report-push trigger if wired. Called after a state change the
// hub should reflect immediately (geo settings/sync) instead of waiting for the next cycle.
func (r *Router) reportPushNow() {
if r.triggerReportPush != nil {
r.triggerReportPush()
}
}
type apiResponse struct {
@@ -146,6 +171,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req)
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost:
r.selfRestart(w, req)
// --- Integration routes (must be before hasSuffix-based stack cases) ---
// GET /api/integrations/{provider} — list integrations for a provider
@@ -1058,6 +1087,15 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
return
}
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
// nothing — don't rewrite, don't restart. The hub may re-push the same config
// idempotently, and a self-restart on every push would be a needless flap.
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
return
}
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
@@ -1067,12 +1105,14 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
return
}
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
// Push updated infra backup so Hub has fresh config data immediately
if r.OnConfigApplied != nil {
go r.OnConfigApplied()
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
// effect on restart — singletons such as the Cloudflare client are built once at
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
flushResponse(w)
if r.restart != nil {
r.restart()
}
}