v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s

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:
2026-09-28 18:38:30 +02:00
parent 2e9a948cbd
commit 0c702f834a
35 changed files with 1119 additions and 16 deletions
+3
View File
@@ -75,6 +75,9 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
// it. Only the debug route was absent, so this is IMPLEMENTED rather than deleted.
case subpath == "backup/crossdrive" && r.Method == http.MethodPost:
s.debugRunCrossDrive(w, r)
// R-705 (v0.279.0): the night's four legs, in order, now.
case subpath == "backup/night-chain" && r.Method == http.MethodPost:
s.debugRunNightChain(w, r)
// R-397 — the button at debug.html:83 has posted here since it was added and NOTHING answered.
// Verified 2026-08-30: this dispatch had no such case, so pressing „Restic integritás" did
// nothing at all. Seventh instance of built-but-never-wired in this project; filed as R-400 in its
+28
View File
@@ -460,6 +460,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["Meta"] = meta
data["AppConfig"] = appCfg
data["AlreadyDeployed"] = alreadyDeployed
// v0.279.0 (decision 45): the default login, before the install when nothing will replace it, after it
// while it is still in effect.
data["KnownLoginLine"] = s.knownLoginLine(lang, meta, appCfg, alreadyDeployed)
data["DefaultLoginReplaced"] = meta.AfterInstall != nil && !defaultLoginInEffect(meta, appCfg, alreadyDeployed)
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
@@ -756,6 +760,9 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["Stack"] = found
data["Meta"] = found.Meta
data["AppInfo"] = found.Meta.AppInfo
// v0.279.0 (decision 45): the default-login card only while that login is in effect.
data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed)
data["DefaultLoginReplaced"] = found.Meta.AfterInstall != nil && !defaultLoginInEffect(&found.Meta, found.AppConfig, found.Deployed)
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["EffectiveSubdomain"] = effectiveSubdomain
@@ -1124,6 +1131,7 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r)
data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders
s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status
data["HollowCopyLines"] = s.hollowCopyLines(s.langFor(r)) // Part D: running apps whose copy holds no data
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
// Enrich AppDataInfo with storage labels
@@ -1151,6 +1159,26 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "backups_apps", data)
}
// hollowCopyLines (Part D, v0.279.0) — one sentence per running app whose newest copy holds no data.
func (s *Server) hollowCopyLines(lang string) []string {
if s.backupMgr == nil {
return nil
}
var out []string
for _, h := range s.backupMgr.HollowCopies() {
name := h.App
if st, ok := s.stackMgr.GetStack(h.App); ok && st.Meta.DisplayName != "" {
name = st.Meta.DisplayName
}
key := "backups_apps.hollow_local"
if h.Tier == backup.HollowTierOffsite {
key = "backups_apps.hollow_offsite"
}
out = append(out, s.msgLang(lang, key, name))
}
return out
}
// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox
// restore-to-verify list and the .fab export/import loop.
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
+41
View File
@@ -0,0 +1,41 @@
package web
import (
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── Known default logins (v0.279.0, `09` §3 decision 45) ────────────────────────────────────────────
//
// "An app is never published with a login a stranger knows." Where the template's after_install replaces
// the default with a generated password, the default is gone once that command succeeded — the page must
// then NOT show it (it would send the household to a login that no longer works). Where it cannot (no
// after_install) or the command failed, the page and the install dialog say plainly what the default is and
// to change it at once. Pinned by internal/web/known_login_test.go.
// defaultLoginInEffect: the template has a default login and nothing has replaced it on this install.
// installed=false is the install dialog, before the install: a declared after_install WILL replace it.
func defaultLoginInEffect(meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) bool {
if meta == nil || strings.TrimSpace(meta.AppInfo.DefaultCreds) == "" {
return false
}
if meta.AfterInstall == nil {
return true
}
if !installed {
return false
}
// Installed with an after_install: in effect only when the command FAILED (absent = not run yet — the
// deploy-done hook runs it within minutes; the page does not warn about a default it is replacing).
return cfg != nil && cfg.AfterInstall != nil && !cfg.AfterInstall.OK
}
// knownLoginLine is the sentence, in lang, or "" when no default login is in effect.
func (s *Server) knownLoginLine(lang string, meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) string {
if !defaultLoginInEffect(meta, cfg, installed) {
return ""
}
creds := strings.ReplaceAll(meta.AppInfo.DefaultCreds, "DOMAIN", s.cfg.Customer.Domain)
return s.msgLang(lang, "app_info.known_login", creds)
}
@@ -0,0 +1,44 @@
package web
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.279.0 (decision 45) — when the default login is IN EFFECT, and the sentence the household reads.
// COMPANION RED-PROOF: make defaultLoginInEffect ignore after_install (the pre-0.279.0 page: the default
// card always shown) → the "replaced" rows fail — the page would send the household to a dead login.
func TestKnownLogin_InEffectOnlyUntilReplaced(t *testing.T) {
withCreds := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin"}}
withFix := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin@claper.co / claper"},
AfterInstall: &stacks.AfterInstallCommand{Service: "claper", Command: []string{"x"}, Success: "OK"}}
ok := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: true}}
failed := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: false}}
for _, c := range []struct {
name string
meta *stacks.Metadata
cfg *stacks.AppConfig
installed bool
want bool
}{
{"no default login", &stacks.Metadata{}, nil, false, false},
{"default, nothing replaces it: before install", withCreds, nil, false, true},
{"default, nothing replaces it: installed", withCreds, &stacks.AppConfig{}, true, true},
{"after_install declared: before install", withFix, nil, false, false},
{"after_install succeeded", withFix, ok, true, false},
{"after_install not run yet", withFix, &stacks.AppConfig{}, true, false},
{"after_install FAILED", withFix, failed, true, true},
} {
if got := defaultLoginInEffect(c.meta, c.cfg, c.installed); got != c.want {
t.Errorf("%s: in effect = %v, want %v", c.name, got, c.want)
}
}
s := testServer(t)
if got, want := s.knownLoginLine("hu", withCreds, nil, false), "Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin. Telepítés után azonnal változtasd meg."; got != want {
t.Fatalf("hu sentence %q, want %q", got, want)
}
if got, want := s.knownLoginLine("en", withFix, failed, true), "This app starts with a known, shared password: admin@claper.co / claper. Change it right after the install."; got != want {
t.Fatalf("en sentence %q, want %q", got, want)
}
}
+110
View File
@@ -0,0 +1,110 @@
package web
import (
"context"
"net/http"
"sync/atomic"
"time"
)
// ── R-705 (v0.279.0): "run tonight's chain now" — a debug action ─────────────────────────────────────
//
// The night runs four legs in ONE order (07 §6.1, 09 §6.4.2): the database/volume dump at W, the
// second-drive copy at W+60m, the off-site copy at W+105m, and the automatic update leg chained after the
// off-site one. Until now the only way to see that chain by day was to move the backup window and wait
// two hours. This runs the same four legs, in the same order, one at a time, NOW. The whole-guest backup
// (the agent's) is not part of it — it has no controller trigger (R-705 keeps that half open).
//
// Refused while any backup/restore op or guarded update runs, and while a chain is already running.
// nightChain is the four legs; each field is a seam so the order and the refusal are testable without
// Docker or restic. production: newNightChain(s).
type nightChain struct {
dump func(ctx context.Context) error
tier2 func()
offsite func(ctx context.Context) error // nil: no off-site target on this box
leg func(ctx context.Context)
busy func() (bool, string)
logf func(format string, args ...interface{})
}
var nightChainRunning atomic.Bool
func (s *Server) newNightChain() nightChain {
c := nightChain{
dump: s.backupMgr.RunDBDumps,
tier2: s.backupMgr.RunAllTier2,
leg: func(ctx context.Context) { s.stackMgr.RunUpdateLegNow(ctx, "manual-chain") },
logf: s.logger.Printf,
busy: func() (bool, string) {
if s.backupMgr.IsRunning() || s.backupMgr.RestoreStatus().Running {
return true, "a backup or restore is running"
}
if s.stackMgr.AnyUpdating() {
return true, "a guarded update is running"
}
return false, ""
},
}
if s.backupMgr.OffboxRunnable() {
c.offsite = s.backupMgr.RunOffboxBackup
}
return c
}
// start refuses or launches; it reports which legs will run.
func (c nightChain) start() (bool, string, []string) {
if busy, why := c.busy(); busy {
return false, why, nil
}
if !nightChainRunning.CompareAndSwap(false, true) {
return false, "the night's chain is already running", nil
}
legs := []string{"db-dump", "tier2", "offsite", "update-leg"}
if c.offsite == nil {
legs = []string{"db-dump", "tier2", "update-leg"}
}
go c.run()
return true, "", legs
}
func (c nightChain) run() {
defer nightChainRunning.Store(false)
ctx := context.Background()
t0 := time.Now()
step := func(name string, fn func() error) {
s := time.Now()
c.logf("[INFO] [night-chain] %s: started", name)
if err := fn(); err != nil {
c.logf("[WARN] [night-chain] %s: ended with an error after %s: %v — the chain goes on, as the night does", name, time.Since(s).Round(time.Second), err)
return
}
c.logf("[INFO] [night-chain] %s: done in %s", name, time.Since(s).Round(time.Second))
}
c.logf("[INFO] [night-chain] manual run of tonight's chain: dump → second drive → off-site → update leg")
step("db-dump", func() error { return c.dump(ctx) })
step("tier2", func() error { c.tier2(); return nil })
if c.offsite != nil {
step("offsite", func() error { return c.offsite(ctx) })
} else {
c.logf("[INFO] [night-chain] offsite: no off-site target on this box — skipped, as at night")
}
step("update-leg", func() error { c.leg(ctx); return nil })
c.logf("[INFO] [night-chain] finished in %s", time.Since(t0).Round(time.Second))
}
func (s *Server) debugRunNightChain(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || s.stackMgr == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil)
return
}
s.backupMgr.MarkManualRun()
ok, why, legs := s.newNightChain().start()
if !ok {
s.logger.Printf("[WARN] [night-chain] manual run REFUSED: %s", why)
writeDebugJSON(w, http.StatusConflict, false, "refused: "+why, nil)
return
}
s.logger.Printf("[INFO] [night-chain] manual run started from %s: %v", r.RemoteAddr, legs)
writeDebugJSON(w, http.StatusAccepted, true, "started", map[string]interface{}{"legs": legs})
}
@@ -0,0 +1,77 @@
package web
import (
"context"
"reflect"
"sync"
"testing"
"time"
)
// R-705 (v0.279.0) — the manual night chain runs dump → Tier 2 → off-site → update leg, IN ORDER, one at a
// time; it is refused while anything else runs and while a chain runs; with no off-site target it skips
// only that leg. COMPANION RED-PROOF: swap two step calls in run() → the order assertion fails; drop the
// busy() check in start() → the refusal assertion fails.
func TestR705_NightChainRunsTheLegsInOrderAndRefusesWhenBusy(t *testing.T) {
var mu sync.Mutex
var order []string
rec := func(n string) { mu.Lock(); order = append(order, n); mu.Unlock() }
release := make(chan struct{})
done := make(chan struct{})
c := nightChain{
dump: func(context.Context) error { rec("db-dump"); <-release; return nil },
tier2: func() { rec("tier2") },
offsite: func(context.Context) error { rec("offsite"); return nil },
leg: func(context.Context) { rec("update-leg"); close(done) },
busy: func() (bool, string) { return false, "" },
logf: func(string, ...interface{}) {},
}
ok, why, legs := c.start()
if !ok || !reflect.DeepEqual(legs, []string{"db-dump", "tier2", "offsite", "update-leg"}) {
t.Fatalf("start: ok=%v why=%q legs=%v", ok, why, legs)
}
// A second press while the first chain runs is refused.
if ok, why, _ := c.start(); ok || why != "the night's chain is already running" {
t.Fatalf("second start while running: ok=%v why=%q", ok, why)
}
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the chain never reached the update leg")
}
for i := 0; i < 100 && nightChainRunning.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
if want := []string{"db-dump", "tier2", "offsite", "update-leg"}; !reflect.DeepEqual(order, want) {
t.Fatalf("order %v, want %v", order, want)
}
// Busy: refused, nothing runs.
order = nil
c.busy = func() (bool, string) { return true, "a guarded update is running" }
if ok, why, _ := c.start(); ok || why != "a guarded update is running" {
t.Fatalf("busy start: ok=%v why=%q", ok, why)
}
time.Sleep(50 * time.Millisecond)
if len(order) != 0 {
t.Fatalf("a refused chain ran legs: %v", order)
}
// No off-site target: that leg alone is skipped.
c.busy = func() (bool, string) { return false, "" }
c.offsite = nil
c.dump = func(context.Context) error { rec("db-dump"); return nil }
done2 := make(chan struct{})
c.leg = func(context.Context) { rec("update-leg"); close(done2) }
if ok, _, legs := c.start(); !ok || !reflect.DeepEqual(legs, []string{"db-dump", "tier2", "update-leg"}) {
t.Fatalf("no-offsite start: ok=%v legs=%v", ok, legs)
}
<-done2
for i := 0; i < 100 && nightChainRunning.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
if want := []string{"db-dump", "tier2", "update-leg"}; !reflect.DeepEqual(order, want) {
t.Fatalf("no-offsite order %v, want %v", order, want)
}
}
@@ -0,0 +1,29 @@
package web
import (
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// Part D (v0.279.0) — the household sees the sentence on the REAL backup page (ServeHTTP → template), in
// Hungarian by default, and the page is silent when nothing is flagged (negative control).
// COMPANION RED-PROOF: drop `data["HollowCopyLines"]` from backupsAppsHandler → the sentence is absent.
func TestPartD_TheBackupPageSaysARunningAppHasNoData(t *testing.T) {
s := newDashboardServer(t, time.Time{})
if s.backupMgr == nil {
t.Fatal("fixture has no backup manager")
}
quiet := getPage(t, s, "/backups/apps")
if quiet.Code != 200 || strings.Contains(quiet.Body.String(), "nem tartalmaz adatot") {
t.Fatalf("with nothing flagged the page must be silent (code %d)", quiet.Code)
}
s.backupMgr.FlagHollowCopyForTest("cloudapp", backup.HollowTierOffsite)
rec := getPage(t, s, "/backups/apps")
body := rec.Body.String()
if rec.Code != 200 || !strings.Contains(body, "A(z) cloudapp fut, de a legutóbbi távoli mentése nem tartalmaz adatot") {
t.Fatalf("the flagged app's sentence is not on the page (code %d)", rec.Code)
}
}
@@ -217,11 +217,11 @@ function appMigrate(btn,app,label){
</div>
{{end}}
{{if .AppInfo.DefaultCreds}}
{{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}
<div class="app-info-card">
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>
{{if .KnownLoginLine}}<p class="app-info-creds-warn" id="known-login-line">{{.KnownLoginLine}}</p>{{else}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>{{end}}
</div>
{{end}}
@@ -8,6 +8,11 @@
{{template "backups_flash" .}}
{{template "restore_banner" .}}
{{- if .HollowCopyLines}}
<div class="alert alert-error" style="margin-bottom:1.5rem">
{{range .HollowCopyLines}}<div>{{.}}</div>{{end}}
</div>
{{- end}}
{{if not .Backup}}
{{template "backups_empty" .}}
@@ -79,6 +79,8 @@
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="{{T "debug.csak_cross_drive"}}" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">{{T "debug.csak_cross_drive"}}</button>
<span class="debug-result" id="btn-crossdrive-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-nightchain" data-label="{{T "debug.night_chain"}}" onclick="triggerAction('btn-nightchain','/api/debug/backup/night-chain','POST')">{{T "debug.night_chain"}}</button>
<span class="debug-result" id="btn-nightchain-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="{{T "debug.restic_integritas"}}" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">{{T "debug.restic_integritas"}}</button>
<span class="debug-result" id="btn-integrity-result"></span>
@@ -25,6 +25,7 @@
<div class="deploy-container">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
{{- if .KnownLoginLine}}<div class="flash flash-error" id="known-login-line">{{.KnownLoginLine}}</div>{{end}}
<div class="deploy-info">
<img class="deploy-logo" src="{{.LogoURL}}" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{.LogoPNGURL}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
@@ -674,7 +675,7 @@
<script>
var postDeployInfo = {
firstSteps: {{json .Meta.AppInfo.FirstSteps}},
defaultCreds: {{json .Meta.AppInfo.DefaultCreds}},
defaultCreds: {{if .Meta.AfterInstall}}""{{else}}{{json .Meta.AppInfo.DefaultCreds}}{{end}},
docsURL: {{json .Meta.AppInfo.DocsURL}},
domain: {{json .Domain}},
displayName: {{json .Meta.DisplayName}},
@@ -245,6 +245,8 @@
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="Csak cross-drive" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">Csak cross-drive</button>
<span class="debug-result" id="btn-crossdrive-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-nightchain" data-label="Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)" onclick="triggerAction('btn-nightchain','/api/debug/backup/night-chain','POST')">Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)</button>
<span class="debug-result" id="btn-nightchain-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="Restic integritás" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">Restic integritás</button>
<span class="debug-result" id="btn-integrity-result"></span>