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
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:
@@ -1916,6 +1916,10 @@ that folder is never a dead end, and an install never runs into it silently (R-6
|
||||
- **Where it lives.** `<drive>/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser
|
||||
userdata source, Samba share or backup leg reads it. It is in `ProtectedHDDPaths`.
|
||||
- **The drive-full warning** ends with the kept folders on that drive and their sizes (`fillwatch.SetExtra`).
|
||||
- **`after_install:` (v0.279.0, decision 45)** — `{service, user?, env: [NAMES], command: [...], success: MARKER}`: one
|
||||
command in the app's own container after a FRESH install (never after a restore or a kept-data load), with the named
|
||||
deploy values filled into `${NAME}`; the output must carry `success`. Recorded in `app.yaml` `after_install`; the app
|
||||
page hides the default-login card once it succeeded and warns while a default login is in effect.
|
||||
- **R-704 (v0.278.0).** A new install (plain or "use my kept data") drops an update or crash-loop hold left by an EARLIER
|
||||
install of the app, and a removal clears both kinds; a restore hold (R-379) stays operator-cleared.
|
||||
- **R-690 (fixed here).** The removed-app restore (R-487) never found a unit on a DATA drive — it asked
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
|
||||
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
|
||||
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
|
||||
// drop the RunAfterInstall call → the first fails.
|
||||
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hook *ast.CallExpr
|
||||
var insideNotifierIf bool
|
||||
var walk func(n ast.Node, underNotifier bool)
|
||||
walk = func(n ast.Node, underNotifier bool) {
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if ifs, ok := x.(*ast.IfStmt); ok {
|
||||
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
|
||||
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
|
||||
walk(ifs.Body, true)
|
||||
if ifs.Else != nil {
|
||||
walk(ifs.Else, underNotifier)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if c, ok := x.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
|
||||
hook, insideNotifierIf = c, underNotifier
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
walk(f, false)
|
||||
if hook == nil {
|
||||
t.Fatal("SetDeployDoneHook is never called")
|
||||
}
|
||||
calls := false
|
||||
ast.Inspect(hook, func(x ast.Node) bool {
|
||||
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
|
||||
calls = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !calls {
|
||||
t.Fatal("the deploy-done hook does not run RunAfterInstall")
|
||||
}
|
||||
if insideNotifierIf {
|
||||
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1191,18 @@ func main() {
|
||||
// It rides the EXISTING per-run digest (backup_run_failures) rather than a new event type: a
|
||||
// new type is a two-repo change (the hub's allowedEventTypes drops anything unlisted), and the
|
||||
// digest is already operator-only and already means "this run did not fully do its job".
|
||||
// Part D (v0.279.0): a RUNNING app whose newest copy holds no database dump and no volume tar. Rides
|
||||
// the same operator-only digest as R-203 (no new event type — the hub drops unlisted ones), once per
|
||||
// app per tier per day (the backup package dedupes).
|
||||
backupMgr.SetHollowCopyNotify(func(app, tier string) {
|
||||
d := notify.BackupRunFailuresDetails{RunKind: "data-check", Attempted: 1, Failed: 1,
|
||||
Apps: []notify.RunFailureDetail{{App: app, Leg: "hollow-" + tier,
|
||||
Reason: "the app is running but its newest " + tier + " copy holds no database dump and no volume tar"}}}
|
||||
notifier.NotifyBackupRunFailures(fmt.Sprintf(
|
||||
"Backup holds NO DATA: %s is running, but its newest %s copy carries no database dump and no volume tar — "+
|
||||
"the app is not protected on that tier. Check for a hold on a running app (R-704's shape) or a skipped dump.",
|
||||
app, tier), d)
|
||||
})
|
||||
backupMgr.SetOffboxGapNotify(func(gaps map[string][]string) {
|
||||
apps := make([]string, 0, len(gaps))
|
||||
for app := range gaps {
|
||||
@@ -1749,19 +1761,31 @@ func main() {
|
||||
// R-536: „Alkalmazás telepítve" is sent when the async deploy ACTUALLY finishes, and a deploy
|
||||
// that ends badly now says so instead of being silence. The accept-time event is
|
||||
// `app_deploy_started`, emitted by the API router beside its 202.
|
||||
if notifier != nil {
|
||||
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
|
||||
display := name
|
||||
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
|
||||
display = s.Meta.DisplayName
|
||||
}
|
||||
if ok {
|
||||
// v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too;
|
||||
// only the events need the notifier.
|
||||
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
|
||||
display := name
|
||||
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
|
||||
display = s.Meta.DisplayName
|
||||
}
|
||||
if ok {
|
||||
if notifier != nil {
|
||||
notifier.NotifyAppDeployed(name, display)
|
||||
return
|
||||
}
|
||||
// v0.279.0 (decision 45): a FRESH install replaces a known default login with its generated one,
|
||||
// when the template declares after_install. Never on a restore or a kept-data load — those do not
|
||||
// pass through the deploy-done hook.
|
||||
go func() {
|
||||
if ran, err := stackMgr.RunAfterInstall(name, 10*time.Minute); ran && err != nil {
|
||||
log.Printf("[WARN] [stacks] after_install %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
if notifier != nil {
|
||||
notifier.NotifyAppDeployFailed(name, display, detail)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
// R-681 (v0.270.0): an install a restart cut off is finished through the failure path and reported —
|
||||
// AFTER the deploy-done hook exists, so its event is sent.
|
||||
if names := stackMgr.RecoverInterruptedInstalls(); len(names) > 0 {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "✓ 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",
|
||||
|
||||
@@ -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": "✓ 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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
"kept.backup.none": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||
"kept.backup.own": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||
"kept.backup.second": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||
"app_info.known_login": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||
"backups_apps.hollow_local": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||
"backups_apps.hollow_offsite": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||
"err.kept.offsite_not_usable": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||
"err.kept.offsite_version_unknown": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||
"kept.backup.offsite": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||
|
||||
Reference in New Issue
Block a user