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
@@ -0,0 +1,76 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and
// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST
// check fails; make the helper skip the delete → the copy is still on disk.
func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
_ = os.MkdirAll(cfg.Paths.DataDir, 0o755)
_ = os.MkdirAll(drive, 0o755)
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp")
if mine == "" || other == "" {
t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other)
}
for _, p := range []string{mine, other} {
_ = os.MkdirAll(p, 0o755)
_ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644)
}
r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)}
got := r.removeVerificationCopy("cloudapp")
if len(got) != 1 || !strings.HasPrefix(got[0], mine) {
t.Fatalf("reported %v, want the copy at %s", got, mine)
}
if _, err := os.Stat(mine); !os.IsNotExist(err) {
t.Fatalf("the verification copy is still on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil {
t.Fatalf("another app's verification copy was touched: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" {
called = true
}
return true
})
}
}
if !called {
t.Fatal("removeStack does not call removeVerificationCopy (R-706)")
}
}
+26
View File
@@ -929,6 +929,29 @@ func (r *Router) dropLeftoverHold(name, why string) {
}
}
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
func (r *Router) removeVerificationCopy(name string) []string {
if r.backupMgr == nil {
return nil
}
var out []string
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
if c.Stack != name {
continue
}
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
continue
}
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
}
return out
}
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
@@ -1008,6 +1031,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
}
if body.RemoveBackups {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
}
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
+5
View File
@@ -160,6 +160,9 @@ type Manager struct {
// INIT/TEST ONLY.
keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error)
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
hollow hollowWatch
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
@@ -667,6 +670,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run:
// the capture folds the stamps the legs above just wrote (v0.275.0).
m.captureAllRecoveryUnits(true)
// Part D (v0.279.0): a RUNNING app whose unit still holds no data after this run is an alarm.
m.checkLocalCopies()
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
+137
View File
@@ -0,0 +1,137 @@
package backup
import (
"sort"
"sync"
"time"
)
// ── Part D (v0.279.0): a RUNNING app whose newest copy holds no data is an alarm ─────────────────────
//
// Measured 2026-09-28 on demo-hp (controller 0.277.0): a freshly installed nextcloud carried a leftover
// hold (R-704), so the dump leg skipped its volumes and its unit was never captured; the off-site run then
// pushed a snapshot that "carried NO database dump and NO volume tar". The ONLY trace was one WARN line in
// the container log (R-412 leg 1 made it honest wording, nothing more): no event, no page sentence. The app
// was running and unprotected, and nobody could know.
//
// R-704 closed that cause. This closes the CLASS: at the end of each data leg, every installed app that is
// RUNNING and has named volumes (the data a unit must carry — a database lives in a volume) must have a
// copy that carries data (unitCarriesData — the manifest lists a dump or a tar). If not:
// - the operator hears it once per app, per tier, per day (the existing operator-only backup_run_failures
// digest, wired in main.go — no new event type);
// - the household sees one sentence per app on the backup page, until a later check finds data.
// A HELD app that is really stopped is not flagged — a hold is a deliberate stop, and its copy is the one
// the hold names. A held app that RUNS (yesterday's shape) is flagged.
// Pinned by internal/backup/r_partd_hollow_watch_test.go.
// Hollow-copy tiers.
const (
HollowTierLocal = "local" // the app's own recovery unit (Tier 1), checked at the end of the dump leg
HollowTierOffsite = "offsite" // the unit the off-site run just pushed (Tier 3)
)
// HollowCopy is one running app whose newest copy on a tier holds no data.
type HollowCopy struct {
App string
Tier string
At time.Time // when the check found it
}
type hollowWatch struct {
mu sync.Mutex
current map[string]HollowCopy // key app|tier
notified map[string]string // key app|tier → the day (YYYY-MM-DD) the operator was told
notify func(app, tier string)
now func() time.Time
}
// SetHollowCopyNotify wires the operator signal (main.go → notifier). INIT-ONLY.
func (m *Manager) SetHollowCopyNotify(fn func(app, tier string)) {
m.hollow.mu.Lock()
m.hollow.notify = fn
m.hollow.mu.Unlock()
}
// HollowCopies returns the flagged apps, sorted, for the backup page.
func (m *Manager) HollowCopies() []HollowCopy {
m.hollow.mu.Lock()
defer m.hollow.mu.Unlock()
out := make([]HollowCopy, 0, len(m.hollow.current))
for _, h := range m.hollow.current {
out = append(out, h)
}
sort.Slice(out, func(i, j int) bool {
if out[i].App != out[j].App {
return out[i].App < out[j].App
}
return out[i].Tier < out[j].Tier
})
return out
}
// watchesForData: a deployed app that RUNS and has named volumes. A held app that is stopped is skipped.
func (m *Manager) watchesForData(app string) bool {
if m.stackProvider == nil || m.cfg != nil && m.cfg.IsProtectedStack(app) {
return false
}
if len(m.stackProvider.GetDockerVolumes(app)) == 0 {
return false
}
return m.stackProvider.RefreshAndIsRunning(app)
}
// judgeCopy records the verdict for one app's copy on a tier: flags (and tells the operator, once a day) a
// running app's copy with no data, clears the flag when the copy carries data.
func (m *Manager) judgeCopy(app, tier, unitDir string) {
key := app + "|" + tier
hollow := m.watchesForData(app) && !unitCarriesData(unitDir)
m.hollow.mu.Lock()
if m.hollow.current == nil {
m.hollow.current = map[string]HollowCopy{}
m.hollow.notified = map[string]string{}
}
now := time.Now
if m.hollow.now != nil {
now = m.hollow.now
}
if !hollow {
delete(m.hollow.current, key)
m.hollow.mu.Unlock()
return
}
t := now()
m.hollow.current[key] = HollowCopy{App: app, Tier: tier, At: t}
day := t.Format("2006-01-02")
tell := m.hollow.notify != nil && m.hollow.notified[key] != day
if tell {
m.hollow.notified[key] = day
}
fn := m.hollow.notify
m.hollow.mu.Unlock()
m.logger.Printf("[ERROR] [backup] %s is RUNNING but its newest %s copy (%s) holds NO database dump and NO volume tar — it is not protected (Part D)", app, tier, unitDir)
if tell {
fn(app, tier)
}
}
// checkLocalCopies judges every deployed app's own unit at the end of the dump leg.
func (m *Manager) checkLocalCopies() {
if m.stackProvider == nil {
return
}
for _, s := range m.stackProvider.ListDeployedStacks() {
m.judgeCopy(s.Name, HollowTierLocal, m.primaryUnitDirFor(s.Name))
}
}
// FlagHollowCopyForTest records a flagged copy without a backup run (the web package's render test).
// Test-only by name: only judgeCopy may decide a copy is hollow.
func (m *Manager) FlagHollowCopyForTest(app, tier string) {
m.hollow.mu.Lock()
defer m.hollow.mu.Unlock()
if m.hollow.current == nil {
m.hollow.current = map[string]HollowCopy{}
m.hollow.notified = map[string]string{}
}
m.hollow.current[app+"|"+tier] = HollowCopy{App: app, Tier: tier, At: time.Now()}
}
+1
View File
@@ -1384,6 +1384,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// WORDING ONLY. This adds no guard and does NOT touch the capture — `07` §8.2 records why
// guarding the capture would make the manifest lie. The reader of a log gets the fact; whether
// the push should refuse is R-412 leg 2 and is still open.
m.judgeCopy(stack, HollowTierOffsite, src) // Part D (v0.279.0): a running app's empty snapshot is an alarm
if unitIsHollow(src) {
m.logger.Printf("[WARN] [offbox] backed up %s (%s, %d mandatory path(s)) — but the recovery unit carried NO database dump and NO volume tar, so this snapshot holds none of the app's data; the next run with a dump leg will replace it",
stack, src, len(extra))
@@ -0,0 +1,91 @@
package backup
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// hollowProvider: per-app volumes and running state; everything else from floorProvider.
type hollowProvider struct {
floorProvider
volumes map[string][]string
running map[string]bool
}
func (p *hollowProvider) GetDockerVolumes(n string) []string { return p.volumes[n] }
func (p *hollowProvider) RefreshAndIsRunning(n string) bool { return p.running[n] }
// Part D (v0.279.0) — the CONSEQUENCE: a running app whose newest copy holds no data reaches the operator
// (once per app per tier per day) and the backup page; a copy with data clears it; a stopped (held) app
// and an app with no volumes are never flagged.
// COMPANION RED-PROOF: make judgeCopy ignore unitCarriesData (the pre-0.279.0 shape: nothing but a log) →
// no notification and the first assertion fails.
func TestPartD_ARunningAppWithAnEmptyCopyIsAnAlarm(t *testing.T) {
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.SystemDataPath = t.TempDir()
m := NewManager(cfg, sett, log.New(io.Discard, "", 0))
p := &hollowProvider{floorProvider: floorProvider{stacks: []string{"cloud", "held", "novol"}, dir: t.TempDir()},
volumes: map[string][]string{"cloud": {"cloud_db"}, "held": {"held_db"}},
running: map[string]bool{"cloud": true, "novol": true}}
m.SetStackProvider(p)
day := time.Date(2026, 9, 28, 15, 20, 0, 0, time.UTC)
m.hollow.now = func() time.Time { return day }
var told []string
m.SetHollowCopyNotify(func(app, tier string) { told = append(told, app+"/"+tier) })
unit := t.TempDir()
writeMan := func(withData bool) {
man := RecoveryManifest{SchemaVersion: 2, AppName: "cloud"}
if withData {
man.DBDumps = []string{"cloud.sql"}
}
b, _ := json.Marshal(man)
_ = os.WriteFile(UnitManifestFile(unit), b, 0o644)
}
writeMan(false) // yesterday's shape: a manifest listing nothing
for _, app := range []string{"cloud", "held", "novol"} {
m.judgeCopy(app, HollowTierOffsite, unit)
}
if len(told) != 1 || told[0] != "cloud/offsite" {
t.Fatalf("operator told %v, want exactly [cloud/offsite] (held+stopped and volume-less apps are not flagged)", told)
}
if h := m.HollowCopies(); len(h) != 1 || h[0].App != "cloud" || h[0].Tier != HollowTierOffsite {
t.Fatalf("page list %+v, want the one running app", h)
}
// Same day again: still flagged, NOT told twice.
m.judgeCopy("cloud", HollowTierOffsite, unit)
if len(told) != 1 {
t.Fatalf("told twice in one day: %v", told)
}
// A missing unit (no manifest) is also no data — fail closed.
m.judgeCopy("cloud", HollowTierLocal, filepath.Join(unit, "absent"))
if len(told) != 2 || told[1] != "cloud/local" {
t.Fatalf("an absent local unit was not flagged: %v", told)
}
// The next day: told again.
m.hollow.now = func() time.Time { return day.Add(24 * time.Hour) }
m.judgeCopy("cloud", HollowTierOffsite, unit)
if len(told) != 3 {
t.Fatalf("not told again the next day: %v", told)
}
// The copy carries data again: cleared from the page.
writeMan(true)
m.judgeCopy("cloud", HollowTierOffsite, unit)
m.judgeCopy("cloud", HollowTierLocal, unit)
if h := m.HollowCopies(); len(h) != 0 {
t.Fatalf("a copy with data is still listed: %+v", h)
}
}
@@ -26,6 +26,11 @@ const (
// gate — one constant, so the two can never disagree (TestLegDeadlineAndGateShareW5h).
const UpdateLegStopOffsetMin = 300
// UpdateLegLengthMin is how long the leg may start steps on a normal night: from the off-site leg (W+105m),
// after which it is chained, to W+5h. A MANUAL run of the night's chain (R-705) gives the leg this same
// length, counted from when it starts.
const UpdateLegLengthMin = UpdateLegStopOffsetMin - offboxOffsetMin
// ParseHHMM parses "HH:MM" (24h) into minutes-since-midnight. It rejects anything but a valid
// hour:minute — the same contract as the scheduler's parseDailyTime, kept here so this package is
// dependency-free and reusable by the quiesce gate.
+4
View File
@@ -111,6 +111,7 @@
"app_import.tarolo": "Storage",
"app_import.titkos": "Encrypted",
"app_import.visszaallitas_inditasa": "Start restore",
"app_info.known_login": "This app starts with a known, shared password: %s. Change it right after the install.",
"app_info.a_kezdeti_jelszo_beolvasasa_nem": "The first password could not be read.",
"app_info.a_regi_adatok_erintetlenek": "The old data is untouched.",
"app_info.adatait_ide": "data to:",
@@ -233,6 +234,8 @@
"backups.utolso_sikeres_mentes": "&#10003; Last successful backup: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
"backups.utolso_teljes_mentes": "Last full backup",
"backups.visszaallitas_ellenorizve": "Restore checked",
"backups_apps.hollow_local": "%s is running, but its newest local backup holds no data (no database, no data volume). So it is not protected now. Felhom knows about it.",
"backups_apps.hollow_offsite": "%s is running, but its newest off-site copy holds no data (no database, no data volume). So it is not protected off-site now. Felhom knows about it.",
"backups_apps.1_mentes": "Backup 1",
"backups_apps.1_mentes_auto": "Backup 1 auto",
"backups_apps.2_mentes": "Backup 2",
@@ -899,6 +902,7 @@
"datapath.consequence.import_excluded": "Copy the files to be processed in here. The app reads them and then deletes them from here — this folder is temporary and is not backed up.",
"datapath.consequence.kept": "This is where your files are kept. It is backed up.",
"datapath.free_space": "%.1f GB free",
"debug.night_chain": "Tonight's chain now (dump → second drive → off-site → update)",
"debug.aktiv": "Active",
"debug.aktiv_feladat": "Active job",
"debug.alkalmazas": "App",
+4
View File
@@ -107,6 +107,7 @@
"app_import.tarolo": "Tároló",
"app_import.titkos": "Titkos",
"app_import.visszaallitas_inditasa": "Visszaállítás indítása",
"app_info.known_login": "Ez az alkalmazás egy ismert, közös jelszóval indul: %s. Telepítés után azonnal változtasd meg.",
"app_info.a_kezdeti_jelszo_beolvasasa_nem": "A kezdeti jelszó beolvasása nem sikerült.",
"app_info.a_regi_adatok_erintetlenek": "A régi adatok érintetlenek.",
"app_info.adatait_ide": "adatait ide:",
@@ -229,6 +230,8 @@
"backups.utolso_sikeres_mentes": "&#10003; Utolsó sikeres mentés: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
"backups.utolso_teljes_mentes": "Utolsó teljes mentés",
"backups.visszaallitas_ellenorizve": "Visszaállítás ellenőrizve",
"backups_apps.hollow_local": "A(z) %s fut, de a legutóbbi helyi mentése nem tartalmaz adatot (se adatbázist, se adatkötetet). Így most nincs védve. A Felhom tud róla.",
"backups_apps.hollow_offsite": "A(z) %s fut, de a legutóbbi távoli mentése nem tartalmaz adatot (se adatbázist, se adatkötetet). Így most nincs védve távol. A Felhom tud róla.",
"backups_apps.1_mentes": "1. mentés",
"backups_apps.1_mentes_auto": "1. mentés auto",
"backups_apps.2_mentes": "2. mentés",
@@ -894,6 +897,7 @@
"datapath.consequence.import_excluded": "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés.",
"datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.",
"datapath.free_space": "%.1f GB szabad",
"debug.night_chain": "Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)",
"debug.aktiv": "Aktív",
"debug.aktiv_feladat": "Aktív feladat",
"debug.alkalmazas": "Alkalmazás",
+154
View File
@@ -0,0 +1,154 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ── after_install (v0.279.0, `09` §3 decision 45): no app is published with a login a stranger knows ──
//
// Some apps start with a known, shared admin password (claper seeds admin@claper.co / claper at every first
// start — measured on 9202 2026-09-28, R-702). The box publishes every app on the household's domain. So
// where the app's OWN CLI or API can change it, the template declares ONE command the box runs once, in the
// app's own container, right after a FRESH install:
//
// after_install:
// service: claper
// env: [ADMIN_PASSWORD] # deploy values filled into the command; nothing else is
// command: ["/app/bin/claper", "rpc", "... ${ADMIN_PASSWORD} ..."]
// success: FELHOM_AFTER_INSTALL_OK # the command's output must carry this, or it failed
//
// The value is a generated `type: password` field, so the household sees it on the app page as the first
// password (the grafana/code-server pattern).
//
// ONLY after a fresh install (the deploy-done hook, ok=true). NEVER after a restore or "use my kept data":
// there the login comes back with the data, and changing it would lock the household out (R-694). A failed
// command is retried while the app boots, then recorded (`after_install` in app.yaml) and shown on the app
// page; the app stays installed and running — never half-installed. The expanded command is never logged
// (it carries the password); the log names the template.
// Pinned by internal/stacks/after_install_test.go.
// AfterInstallCommand is `.felhom.yml`'s `after_install:`.
type AfterInstallCommand struct {
Service string `yaml:"service" json:"service"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
Env []string `yaml:"env,omitempty" json:"env,omitempty"`
Command []string `yaml:"command" json:"command"`
Success string `yaml:"success" json:"success"`
}
// AfterInstallRecord is app.yaml's `after_install:` — what the one-time command did.
type AfterInstallRecord struct {
At string `yaml:"at" json:"at"`
OK bool `yaml:"ok" json:"ok"`
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"`
}
// afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done.
var (
afterInstallTries = 6
afterInstallGap = 20 * time.Second
)
// expandAfterInstall fills ${NAME} for the declared env names only, from the app's env. An undeclared or
// empty name refuses — a command with a hole must never run (it could set an EMPTY password).
func expandAfterInstall(cmd []string, allowed []string, env map[string]string) ([]string, error) {
ok := map[string]bool{}
for _, n := range allowed {
ok[n] = true
}
var missing []string
out := make([]string, len(cmd))
for i, a := range cmd {
out[i] = os.Expand(a, func(k string) string {
if !ok[k] || env[k] == "" {
missing = append(missing, k)
return ""
}
return env[k]
})
}
if len(missing) > 0 {
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
}
return out, nil
}
// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook).
// Returns (ran, error). Records the outcome in app.yaml either way.
func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) {
st, ok := m.GetStack(name)
if !ok {
return false, fmt.Errorf("stack %q not found", name)
}
ai := st.Meta.AfterInstall
if ai == nil || ai.Service == "" || len(ai.Command) == 0 || ai.Success == "" {
return false, nil
}
dir := filepath.Dir(st.ComposePath)
record := func(ok bool, detail string) {
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)}
m.mutateAppConfig(name, dir, "after_install", func(cfg *AppConfig) bool { cfg.AfterInstall = rec; return true })
}
cfg := LoadAppConfigDecrypted(dir, m.encKey)
if cfg == nil {
record(false, "the app's settings could not be read")
return true, fmt.Errorf("after_install %s: app.yaml unreadable", name)
}
cmd, err := expandAfterInstall(ai.Command, ai.Env, cfg.Env)
if err != nil {
m.logger.Printf("[ERROR] [stacks] %s: %v", name, err)
record(false, err.Error())
return true, err
}
deadline := time.Now().Add(wait)
for {
_ = m.RefreshStatus()
if s, ok := m.GetStack(name); ok && (s.State == StateRunning || s.State == StateUnhealthy) {
break
}
if time.Now().After(deadline) {
record(false, "the app did not start in time")
return true, fmt.Errorf("after_install %s: the app did not start within %s — not run", name, wait)
}
time.Sleep(5 * time.Second)
}
return true, m.runAfterInstallNow(name, ai, cmd, record)
}
// runAfterInstallNow runs the expanded command (RunAfterInstall has waited for the app): retries while the
// output lacks the success marker, then records the outcome.
func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd []string, record func(ok bool, detail string)) error {
dir := ""
if st, ok := m.GetStack(name); ok {
dir = filepath.Dir(st.ComposePath)
}
args := []string{"exec", "-T"}
if ai.User != "" {
args = append(args, "-u", ai.User)
}
args = append(args, ai.Service)
args = append(args, cmd...)
var last string
for try := 1; try <= afterInstallTries; try++ {
t0 := time.Now()
out, err := m.afterLoadExec(dir, args...)
if err == nil && strings.Contains(out, ai.Success) {
m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one",
name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try)
record(true, "")
return nil
}
last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success)
m.logger.Printf("[WARN] [stacks] after_install %s: %s %v %s", name, ai.Service, ai.Command, last)
if try < afterInstallTries {
time.Sleep(afterInstallGap)
}
}
m.logger.Printf("[ERROR] [stacks] after_install %s FAILED after %d tries — the app runs with its KNOWN default login; the app page says so", name, afterInstallTries)
record(false, last)
return fmt.Errorf("after_install %s failed: %s", name, last)
}
@@ -0,0 +1,91 @@
package stacks
import (
"bytes"
"errors"
"log"
"path/filepath"
"strings"
"testing"
"time"
)
// v0.279.0 (decision 45) — after_install: the one command after a FRESH install that replaces a known default
// login with the generated one. The exec is the seam; nothing reaches Docker.
// COMPANION RED-PROOFS: (1) drop the success-marker check in runAfterInstallNow → a failing command is
// recorded ok and the second case fails; (2) make expandAfterInstall allow an undeclared/empty name → the
// command runs with a hole and the third case fails.
func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
m, _ := keptManager(t)
var logBuf bytes.Buffer
m.logger = log.New(&logBuf, "", 0)
afterInstallTries, afterInstallGap = 3, 0
t.Cleanup(func() { afterInstallTries, afterInstallGap = 6, 20*time.Second })
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}}, m.encKey, nil))
ai := &AfterInstallCommand{Service: "cloudapp", Env: []string{"ADMIN_PASSWORD"},
Command: []string{"/app/bin/tool", "set-admin", "${ADMIN_PASSWORD}"}, Success: "FELHOM_OK"}
m.mu.Lock()
m.stacks["cloudapp"].Meta.AfterInstall = ai
m.mu.Unlock()
record := func(ok bool, d string) {
m.mutateAppConfig("cloudapp", dir, "after_install", func(c *AppConfig) bool {
c.AfterInstall = &AfterInstallRecord{At: "t", OK: ok, Detail: d}
return true
})
}
// 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it.
var calls [][]string
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil }
cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"})
must(t, err)
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil {
t.Fatal(err)
}
if len(calls) != 1 || strings.Join(calls[0], " ") != "exec -T cloudapp /app/bin/tool set-admin Gen3r4tedValue" {
t.Fatalf("ran %v", calls)
}
if c := LoadAppConfig(dir); c.AfterInstall == nil || !c.AfterInstall.OK {
t.Fatalf("record: %+v", c.AfterInstall)
}
if strings.Contains(logBuf.String(), "Gen3r4tedValue") {
t.Fatal("the generated password reached the log")
}
// 2. No success marker: retried, then recorded as FAILED — never recorded ok.
calls = nil
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") }
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
t.Fatal("a failing command reported success")
}
if len(calls) != 3 {
t.Fatalf("tries %d, want 3", len(calls))
}
if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK {
t.Fatalf("a failure was recorded as ok: %+v", c.AfterInstall)
}
// 2b. Exit 0 WITHOUT the marker (claper's `rpc` exits 0 on an Elixir error): still a failure.
calls = nil
m.afterLoadFn = func(_ string, args ...string) (string, error) {
calls = append(calls, args)
return "FELHOM_AFTER_INSTALL_FAILED {:error, changeset}", nil
}
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
t.Fatal("an exit-0 command without the success marker reported success")
}
if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK {
t.Fatalf("an exit-0 failure was recorded as ok: %+v", c.AfterInstall)
}
// 3. A value that is not declared, or empty, refuses BEFORE anything runs (never an empty password).
for _, env := range []map[string]string{{}, {"ADMIN_PASSWORD": ""}} {
if _, err := expandAfterInstall(ai.Command, ai.Env, env); err == nil {
t.Fatalf("expanded with env %v", env)
}
}
if _, err := expandAfterInstall([]string{"x ${OTHER}"}, ai.Env, map[string]string{"OTHER": "v"}); err == nil {
t.Fatal("an undeclared name was filled in")
}
}
+2
View File
@@ -179,6 +179,8 @@ type AppConfig struct {
// removed app, a rebuilt guest) while the app's own login came back with its data. The page then shows
// no value for them and says the old password is the one that works (restoredLoginFields).
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
// AfterInstall (v0.279.0, decision 45) is what the template's one-time after_install command did.
AfterInstall *AfterInstallRecord `yaml:"after_install,omitempty" json:"after_install,omitempty"`
}
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
@@ -31,6 +31,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
cfg.FailedStep = prior.FailedStep
cfg.LastUpdateUndone = prior.LastUpdateUndone
cfg.LastAutoUpdate = prior.LastAutoUpdate
cfg.AfterInstall = prior.AfterInstall // v0.279.0: what the install's one-time command did stays true after a restore
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
cur := ""
if prior.ConversionCopy != nil {
+5 -2
View File
@@ -52,8 +52,11 @@ type Metadata struct {
// app's kept data (a database from a backup under files kept on the drive) — for an app whose own
// index of its files must be rebuilt. Measured on nextcloud: a file written after the backup is on
// the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs.
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
// AfterInstall (v0.279.0, `09` §3 decision 45) is ONE command the box runs once after a FRESH install —
// to replace a known default login with the generated one. See after_install.go.
AfterInstall *AfterInstallCommand `yaml:"after_install,omitempty" json:"after_install,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
+14
View File
@@ -217,6 +217,17 @@ func LegDeadline(now time.Time, window string, loc *time.Location) time.Time {
// RunUpdateLeg runs one night's leg and returns its summary (nil when another leg is running or the leg
// is not wired). trigger names the caller for the log ("after-offsite").
func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSummary {
return m.runUpdateLeg(ctx, trigger, false)
}
// RunUpdateLegNow is the leg for a MANUAL run of the night's chain (R-705, the debug action): the same
// leg, but its step deadline is its start + the leg's normal length (backupwindow.UpdateLegLengthMin),
// not W+5h of the last window — which, by day, has passed and would make the leg start nothing.
func (m *Manager) RunUpdateLegNow(ctx context.Context, trigger string) *UpdateLegSummary {
return m.runUpdateLeg(ctx, trigger, true)
}
func (m *Manager) runUpdateLeg(ctx context.Context, trigger string, manual bool) *UpdateLegSummary {
if !m.leg.run.TryLock() {
m.logger.Printf("[WARN] [update-leg] a leg is already running — this call (%s) does nothing", trigger)
return nil
@@ -249,6 +260,9 @@ func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSu
window = o.WindowStart()
}
sum.Deadline = LegDeadline(sum.StartedAt, window, o.Location)
if manual {
sum.Deadline = sum.StartedAt.Add(time.Duration(backupwindow.UpdateLegLengthMin) * time.Minute)
}
m.setLegFlags(true, false)
defer m.setLegFlags(false, false)
m.logger.Printf("[INFO] [update-leg] started (%s): window %s, no step starts at or after %s", trigger, window, sum.Deadline.In(o.Location).Format("15:04"))
@@ -473,3 +473,23 @@ func TestR687_EmptyLegReportsEmptySteps(t *testing.T) {
t.Fatalf("an empty leg must report steps as [], got %s", b)
}
}
// R-705 (v0.279.0) — the MANUAL chain's leg runs by day: at 18:00 the night's W+5h (07:30) has passed, so the
// night leg starts nothing (window_end), while RunUpdateLegNow gives the leg its normal length from its own
// start and presses the step. COMPANION RED-PROOF: drop the `if manual` deadline in runUpdateLeg → the manual
// run also reports window_end and starts nothing.
func TestR705_TheManualLegRunsByDay(t *testing.T) {
day := time.Date(2026, 9, 25, 18, 0, 0, 0, time.UTC)
m, _, _, ups, _ := legManager(t)
legOpts(m, func(o *UpdateLegOptions) { o.Now = func() time.Time { return day } })
if s := m.RunUpdateLeg(context.Background(), "night"); s == nil || s.Stopped != LegSkipWindowEnd || len(*ups) != 0 {
t.Fatalf("by day the NIGHT leg must start nothing: ups=%v summary=%+v", *ups, s)
}
s := m.RunUpdateLegNow(context.Background(), "manual-chain")
if s == nil || !s.Deadline.Equal(day.Add(195*time.Minute)) {
t.Fatalf("the manual leg's deadline %v, want %v", s.Deadline, day.Add(195*time.Minute))
}
if len(*ups) == 0 || legStepFor(s, "nextcloud").Reason == LegSkipWindowEnd {
t.Fatalf("the manual leg pressed nothing by day: ups=%v summary=%+v", *ups, s)
}
}
+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>