D65 = A: the busy file the agent reads before an operator reboot, and the household's 'Felhom support restarted your box' line; R-1350: a hold clear records the wanted stop so no app_start_failed follows it
gates / gates (push) Successful in 32s

This commit is contained in:
admin committed 2026-10-29 16:24:40 +01:00
1 parent a28db2d17f
commit b69e90bdad
22 files changed
+662 -11

No files matched your search

+25
View File
@@ -1,3 +1,28 @@
## Unreleased — the household line after an operator reboot and the busy file for it (D65 = A); a hold clear records the wanted stop (R-1350)
### 2026-10-29 (evening)
**MinAgent: 0.158.1** (unchanged). The new `GET /guest/operator-reboot` is asked once at start; an older agent answers
404, and such an agent cannot reboot from the hub either — nothing to show, nothing broken. The busy file is read BY the
agent (≥ the reboot_guest commit); an older agent never reads it.
- **D65 = A — the busy file (`internal/busymark`).** `<data dir>/ctl/busy`, rewritten every 15 s while a backup, a
restore, an off-site run, the night catch-up, an app update or an app export/import runs; removed when idle and at
start. The agent refuses the operator's „Reboot guest" while it is fresh. Tests `TestBusyFileFollowsTheWork`,
`TestBusyMarkPathIsTheOneTheAgentReads` (the path is the agent's guest path), `TestBusyMarkIsWiredAtStartup`.
- **D65 = A — „Felhom support restarted your box at <time>."** The dashboard's one neutral line for a day after an
operator reboot, HU/EN (`dashboard.operator_reboot`); the idle dashboard is byte-identical (the parity fixtures pass
unchanged). Tests `TestOperatorRebootNotice_*`, `TestWatchOperatorReboot_RetriesAndKeepsTheTime`.
- **R-1350 — no „installed app is not running" after the operator's hold clear.** Measured 2026-10-29 14:57 on Tester 1:
gokapi's crash-loop hold cleared from the hub, the app stayed stopped, and 30 s later `app_start_failed` went out —
the hold had kept it out of the dead-app scan while the household's recorded wish was still „running". The clear now
records the stop as wanted (desired state „stopped") for an app that is not up, once the hold is found and before it
is lifted; a running app's wish is left alone; a refused clear records nothing; the household's Start ends it.
Tests `TestR1350_*` (the consequence through `classifyRunStates`, the order against the lift, the refusal, the up
check, the wiring).
- Every new test seen failing by a mutation (17 mutants; two first-run mutants that did not compile were re-made so they
compile, and then failed).
## v0.348.0 (2026-10-29) — clear_hold from the hub (D64 = B); the box command clears through the running controller (R-1343); the held-app mail and line name the real cause (R-1340); the OOM line once per kill (R-1341); the restore copy safe during the stable-start watch (R-1342) — built from the commit carrying this heading
### 2026-10-29 (afternoon)
+13
View File
@@ -3663,6 +3663,19 @@ starts a countdown or shortens one (`TestOpActions_ClosedList`). The box's log r
dropped from the app-stop marker too; its copy stays on disk. The update's in-memory „held" flag
(`stacks.Manager.ForgetUpdateHeld`) goes with it; the hold reporter's next tick (≤1 min) sends `app_hold_cleared`.
Refused, nothing changed: a bad name, or an app with no hold. No new event type: the hub records the result.
**R-1350 (2026-10-29):** for an app that is not up, the clear records the stop as WANTED (desired state „stopped",
`stacks.Manager.RecordStopWantedIfDown`) once the hold is found and BEFORE it is lifted
(`AppStopGuard.ClearHoldByOperatorThen`), so the dead-app scan sends no „Telepített alkalmazás nem fut" for it and the
boot reconciler does not start it; the household's Start records „running" and ends it.
- **The busy file for the host agent (D65 = A, 2026-10-29; `internal/busymark`).** While a backup, a restore, an
off-site run, the night catch-up, an app update or an app export/import runs, the controller rewrites
`<data dir>/ctl/busy` every 15 s (line 1 the UTC time, line 2 what runs, 0600) and removes it when idle. The agent reads
it before the operator's „Reboot guest" and refuses while it is fresh (≤ 90 s); a controller that stopped writing it
does not block the reboot.
- **„Felhom support restarted your box" (D65 = A).** At start the controller asks the agent `GET /guest/operator-reboot`
(retrying for up to 10 minutes; a 404 from an older agent ends it) and the dashboard shows one neutral line,
„A Felhom ügyfélszolgálata HH:MM-kor újraindította a dobozt." / „Felhom support restarted your box at HH:MM.", until a
day has passed since the reboot.
- **The box command `--clear-restore-hold <app>`** (`pct exec <vmid> -- docker exec felhom-controller felhom-controller
--clear-restore-hold <app>`) asks the running controller over a unix socket, `<data dir>/ctl/hold.sock` (0600 in a
0700 dir, peer uid checked, nothing on TCP — not reachable through traefik, the LAN or an app), and says no restart is
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/busymark"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// D65 = A: the agent reads ONE fixed guest path — the controller-data volume + data/ctl/busy
// (felhom-agent ControllerBusyMarkerGuestPath, and its sudoers line). The volume is mounted at
// /opt/docker/felhom-controller, so the controller must write <default data dir>/ctl/busy.
// RED-PROOF: rename holdCtlDir or busymark.FileName → the path moves → FAILS.
func TestBusyMarkPathIsTheOneTheAgentReads(t *testing.T) {
cfg, err := config.LoadFromBytes([]byte("customer:\n id: t\n domain: t.example\n"))
if err != nil {
t.Fatal(err)
}
got := filepath.Join(cfg.Paths.DataDir, holdCtlDir, busymark.FileName)
want := "/opt/docker/felhom-controller/data/ctl/busy" // = the agent's /var/lib/docker/volumes/felhom-controller-data/_data/data/ctl/busy
if got != want {
t.Fatalf("the busy file is %s, the agent reads %s — an operator reboot would never be refused for a backup", got, want)
}
}
// Every kind of work has its probe and the writer is started — a seam built and never wired is this project's commonest
// defect. RED-PROOF: delete any AddProbe line or the Run → FAILS.
func TestBusyMarkIsWiredAtStartup(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, want := range []string{
`busyMark.AddProbe("an app update is running", stackMgr.AnyUpdating)`,
`return backupMgr.IsRunning() || backupMgr.RestoreStatus().Running`,
`busyMark.AddProbe("the night backup catch-up is running", catchUp.Running)`,
`busyMark.AddProbe("an app export or import is running", appExporter.IsRunning)`,
`go busyMark.Run(ctx)`,
`go webServer.WatchOperatorReboot(ctx)`, // the household line after an operator reboot
} {
if !strings.Contains(src, want) {
t.Fatalf("main.go does not contain %q", want)
}
}
}
+19 -4
View File
@@ -31,9 +31,12 @@ import (
// holdClearDeps are clearHoldNow's seams (production: holdClearDepsFor).
type holdClearDeps struct {
clear func(stack string) (settings.RestoreHold, error) // backup.AppStopGuard.ClearHoldByOperator
forgetUpdateHeld func(stack string) bool // stacks.Manager.ForgetUpdateHeld (nil = none)
logger *log.Logger
clear func(stack string, beforeLift func()) (settings.RestoreHold, error) // backup.AppStopGuard.ClearHoldByOperatorThen
forgetUpdateHeld func(stack string) bool // stacks.Manager.ForgetUpdateHeld (nil = none)
// keepStopped (R-1350) records that the stop is wanted for an app that is not up — run once the hold is found and
// BEFORE it is lifted: stacks.Manager.RecordStopWantedIfDown (nil = none).
keepStopped func(stack string) (bool, error)
logger *log.Logger
}
// clearHoldNow lifts stack's hold and returns the result sentence (English, operator-facing). who names the person
@@ -43,7 +46,19 @@ func clearHoldNow(d holdClearDeps, stack, who string) (string, error) {
if d.clear == nil {
return "", fmt.Errorf("%w: clearing a hold is not available on this box", report.ErrActionRefused)
}
h, err := d.clear(stack)
var before func()
if d.keepStopped != nil {
before = func() {
rec, kerr := d.keepStopped(stack)
switch {
case kerr != nil && d.logger != nil:
d.logger.Printf("[WARN] [hold] %s: the stop could not be recorded as wanted (%v) — the dead-app alarm may follow the clear (R-1350)", stack, kerr)
case rec && d.logger != nil:
d.logger.Printf("[INFO] [hold] %s: the stop is recorded as wanted (desired state: stopped) — no dead-app alarm; the household's Start ends it (R-1350)", stack)
}
}
}
h, err := d.clear(stack, before)
switch {
case errors.Is(err, backup.ErrHoldBadStack):
return "", fmt.Errorf("%w: %q is not a valid app name — nothing was changed", report.ErrActionRefused, stack)
+1 -1
View File
@@ -76,7 +76,7 @@ func newD64Rig(t *testing.T) *d64Rig {
r.guard = backup.NewAppStopGuard(filepath.Join(r.dir, "appstop-state.json"), r.logger)
r.guard.SetHolds(sett)
r.holdDeps = holdClearDeps{
clear: r.guard.ClearHoldByOperator,
clear: r.guard.ClearHoldByOperatorThen,
forgetUpdateHeld: func(s string) bool { r.forgot = append(r.forgot, s); return true },
logger: r.logger,
}
+15 -1
View File
@@ -34,6 +34,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
"gitea.dooplex.hu/admin/felhom-controller/internal/bootrecon"
"gitea.dooplex.hu/admin/felhom-controller/internal/bootstrap"
"gitea.dooplex.hu/admin/felhom-controller/internal/busymark"
"gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
@@ -467,7 +468,8 @@ func main() {
// process so memory and settings.json agree (holdclear.go). The box command reaches it over a unix socket in the
// data dir (0600, nothing on TCP). A socket that cannot be bound is logged; the command then says plainly that no
// running controller answered.
holdDeps := holdClearDeps{clear: appStopGuard.ClearHoldByOperator, forgetUpdateHeld: stackMgr.ForgetUpdateHeld, logger: logger}
holdDeps := holdClearDeps{clear: appStopGuard.ClearHoldByOperatorThen, forgetUpdateHeld: stackMgr.ForgetUpdateHeld,
keepStopped: stackMgr.RecordStopWantedIfDown, logger: logger}
if holdLn, herr := listenHoldCtl(holdCtlPath(cfg.Paths.DataDir), func(stack, who string) (string, error) {
return clearHoldNow(holdDeps, stack, who)
}, logger); herr != nil {
@@ -577,6 +579,11 @@ func main() {
logger.Println("[INFO] Healthchecks ping UUIDs configured but no longer used — monitoring is now handled by the Hub")
}
// D65 = A (2026-10-29): the busy file the host agent reads before an operator reboot of the guest (internal/busymark).
// Probes are added where each component is built; the writer starts once the last one exists (after the exporter).
busyMark := &busymark.Writer{Path: filepath.Join(cfg.Paths.DataDir, holdCtlDir, busymark.FileName), Logger: logger}
busyMark.AddProbe("an app update is running", stackMgr.AnyUpdating)
// --- Initialize backup manager (app-data only: DB dumps + Docker-volume tars) ---
var backupMgr *backup.Manager
// R-550: the restore the last stop interrupted, found when the record loads; raised once the notifier exists.
@@ -588,6 +595,9 @@ func main() {
}
if cfg.Backup.Enabled {
backupMgr = backup.NewManager(cfg, sett, logger)
busyMark.AddProbe("a backup, a restore or an off-site run is running", func() bool {
return backupMgr.IsRunning() || backupMgr.RestoreStatus().Running
})
// The stray-copy fix: a second-drive copy container a previous controller process lost (restart, self-update)
// still runs `rsync --delete`. Remove every one BEFORE the scheduler or the web server can start a backup or a
// restore; until that succeeds, restores are refused. Pinned by TestStrayCopy_ClearIsWiredBeforeTheScheduler.
@@ -1661,6 +1671,7 @@ func main() {
if quiesceLoop != nil {
quiesceLoop.SetCatchUpFn(catchUp.Running)
}
busyMark.AddProbe("the night backup catch-up is running", catchUp.Running)
catchUp.Evaluate(ctx, "controller start")
resumeTick := time.NewTicker(time.Minute)
go nightchain.ResumeWatch(ctx, resumeTick.C,
@@ -2119,6 +2130,8 @@ func main() {
// this the export path would be the uncovered sibling of two covered ones, which is how a reader
// concludes the whole class is handled (§2.2).
appExporter.SetStopGuard(exportStopGuard{g: appStopGuard})
busyMark.AddProbe("an app export or import is running", appExporter.IsRunning)
go busyMark.Run(ctx)
// R-1279 (D48 for the import): the import downloads first, with the restores' helper and sentences.
if backupMgr != nil {
appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)
@@ -2127,6 +2140,7 @@ func main() {
// --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
go webServer.WatchOperatorReboot(ctx) // D65: „Felhom support restarted your box at …" after an operator reboot
// v0.316.0: which BUILD this is, not just which version it calls itself. Every shell's stylesheet
// URL is derived from it, so two builds of one version can never share a cache entry. Init-only,
// before the HTTP server starts.
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"os"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1350 (measured 2026-10-29 14:57 on Tester 1): the operator cleared gokapi's crash-loop hold from the hub, the app
// stayed stopped (the clear starts nothing), and 30 s later the dead-app scan sent „Telepített alkalmazás nem fut:
// Gokapi" — the hold had kept the app out of the scan, and the household's recorded wish was still „running".
// The fix: the clear records that the stop is WANTED (desired state „stopped") for an app that is not up, BEFORE the
// hold is lifted; a later Start records „running" and ends it.
// The consequence, through the scan's own derivation: a stopped app whose wish is „running" is DOWN (the alarm); the
// same app with the wish „stopped" is not.
func TestR1350_AStopWantedIsNotDown(t *testing.T) {
st := func(desired string) []stacks.Stack {
return []stacks.Stack{{Name: "gokapi", Deployed: true, State: stacks.StateStopped, AppConfig: &stacks.AppConfig{DesiredState: desired}}}
}
if _, states := classifyRunStates(st(stacks.DesiredStateRunning), nil, nil, time.Now()); len(states) != 1 || !states[0].Down {
t.Fatalf("fixture: a stopped app wished running must be down: %+v", states)
}
if _, states := classifyRunStates(st(stacks.DesiredStateStopped), nil, nil, time.Now()); len(states) != 1 || states[0].Down {
t.Fatalf("a stopped app wished stopped is down — the alarm fires: %+v", states)
}
}
// The clear records the wish BEFORE the hold is lifted (a dead-app scan between the two would read „no hold, wished
// running" and mail), and only for an app that IS held. RED-PROOF: drop the keepStopped call from the clear → FAILS
// („not recorded"); call it after the lift → FAILS („after the hold was lifted").
func TestR1350_TheClearRecordsTheStopIsWanted(t *testing.T) {
r := newD64Rig(t)
r.hold(t, settings.RestoreHold{Stack: "gokapi", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop", Trip: 1})
var recorded []string
var heldWhenRecorded bool
r.holdDeps.keepStopped = func(stack string) (bool, error) {
recorded = append(recorded, stack)
_, heldWhenRecorded = r.sett.GetRestoreHold(stack)
return true, nil
}
o := r.actions()
o.Reconcile([]report.OperatorAction{{ID: 21, Action: report.OpClearHold, Arg: "gokapi"}})
if res := onlyResult(t, o, 21); res.Outcome != report.OutcomeDone {
t.Fatalf("result %+v", res)
}
if len(recorded) != 1 || recorded[0] != "gokapi" {
t.Fatalf("the stop was not recorded as wanted: %v", recorded)
}
if !heldWhenRecorded {
t.Fatal("the wish was recorded after the hold was lifted — a scan in between mails app_start_failed")
}
if lg := r.logBuf.String(); !strings.Contains(lg, "gokapi: the stop is recorded as wanted") {
t.Fatalf("the box's log must say it:\n%s", lg)
}
// a refusal (no hold) records nothing — a down app with no hold is a real fault the alarm must keep
o.Reconcile([]report.OperatorAction{{ID: 22, Action: report.OpClearHold, Arg: "kimai"}})
if res := onlyResult(t, o, 22); res.Outcome != report.OutcomeRefused {
t.Fatalf("result %+v", res)
}
if len(recorded) != 1 {
t.Fatalf("a refused clear recorded a wish: %v", recorded)
}
}
// The running controller's clear is wired with the record. RED-PROOF: drop keepStopped from main.go's holdDeps → FAILS.
func TestR1350_MainWiresTheRecord(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "keepStopped: stackMgr.RecordStopWantedIfDown") || !strings.Contains(string(raw), "clear: appStopGuard.ClearHoldByOperatorThen") {
t.Fatal("main.go's hold clear does not record the wanted stop (R-1350)")
}
}
@@ -0,0 +1,33 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
"time"
)
// OperatorReboot calls GET /guest/operator-reboot (D65 = A, agent ≥ the reboot_guest commit): the time the operator
// rebooted THIS guest from the hub, when it is less than a day old; the zero time when there is none. An agent that
// predates the route answers 404 (a *StatusError) — such an agent cannot reboot from the hub either, so there is
// nothing to show and no MinAgent is needed.
func (c *Client) OperatorReboot(ctx context.Context) (time.Time, error) {
body, err := c.get(ctx, "/guest/operator-reboot")
if err != nil {
return time.Time{}, err
}
var out struct {
At string `json:"at"`
}
if err := json.Unmarshal(body, &out); err != nil {
return time.Time{}, fmt.Errorf("agentapi: decode /guest/operator-reboot: %w", err)
}
if out.At == "" {
return time.Time{}, nil
}
at, err := time.Parse(time.RFC3339, out.At)
if err != nil {
return time.Time{}, fmt.Errorf("agentapi: /guest/operator-reboot: bad time %q", out.At)
}
return at, nil
}
+10
View File
@@ -39,6 +39,13 @@ func IsValidHoldStack(s string) bool { return isSafeStackName(s) }
// returns the hold it lifted. A waiting put-back is dropped from the app-stop marker too (see above). Refusals
// (ErrHoldBadStack, ErrNotHeld) change nothing.
func (g *AppStopGuard) ClearHoldByOperator(stack string) (settings.RestoreHold, error) {
return g.ClearHoldByOperatorThen(stack, nil)
}
// ClearHoldByOperatorThen is ClearHoldByOperator with beforeLift run once the hold is FOUND and before it is lifted,
// under the same lock (R-1350: the household's „the stop is wanted" is recorded before the hold stops keeping the app out
// of the dead-app scan). beforeLift is not run on a refusal.
func (g *AppStopGuard) ClearHoldByOperatorThen(stack string, beforeLift func()) (settings.RestoreHold, error) {
if !isSafeStackName(stack) {
return settings.RestoreHold{}, ErrHoldBadStack
}
@@ -51,6 +58,9 @@ func (g *AppStopGuard) ClearHoldByOperator(stack string) (settings.RestoreHold,
if !ok {
return settings.RestoreHold{}, ErrNotHeld
}
if beforeLift != nil {
beforeLift()
}
cleared, err := g.holds.ClearRestoreHold(stack)
if err != nil {
return h, err
+132
View File
@@ -0,0 +1,132 @@
// Package busymark writes the controller's „busy" file for the host agent (D65 = A, operator ruling 2026-10-29 15:50;
// felhom.eu `03`, `04` §6.1).
//
// WHY: the operator can reboot the guest from the hub. The agent refuses while a backup, a restore, an off-site run or
// an app update runs — but the controller's own work is invisible to the agent (it runs inside this container). So
// while any of it runs, this package keeps `<data>/ctl/busy` fresh: line 1 the time (RFC 3339, UTC), line 2 what runs.
// The agent reads it over one exact read-only grant (`pct exec <vmid> -- cat <the guest path>`), without Docker.
//
// FRESHNESS IS THE SIGNAL, NOT PRESENCE: the file is rewritten every Every (15 s) while busy and removed when idle.
// A controller that is killed or stuck mid-backup stops rewriting it, and the agent (90 s) reads the old file as „not
// busy" — a stuck controller is exactly the box a reboot is for. A file left by a previous run is removed at start.
// Pinned by busymark_test.go.
package busymark
import (
"context"
"errors"
"log"
"os"
"path/filepath"
"sync"
"time"
)
// FileName is the busy file inside the ctl directory (the directory of the hold-clear socket, 0700).
const FileName = "busy"
// DefaultEvery is the rewrite cadence while busy.
const DefaultEvery = 15 * time.Second
// Writer keeps the file in step with its probes.
type Writer struct {
Path string // <data>/ctl/busy
Every time.Duration
Now func() time.Time
Logger *log.Logger
mu sync.Mutex
probes []probe
last string // what the last tick saw ("" = idle)
}
type probe struct {
what string
busy func() bool
}
// AddProbe adds one kind of work: what is the sentence the agent shows the operator, busy says whether it runs now.
// A nil busy is ignored (a box without that component).
func (w *Writer) AddProbe(what string, busy func() bool) {
if busy == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
w.probes = append(w.probes, probe{what: what, busy: busy})
}
// Busy returns the first running work's sentence, or "" when nothing runs.
func (w *Writer) Busy() string {
w.mu.Lock()
ps := append([]probe(nil), w.probes...)
w.mu.Unlock()
for _, p := range ps {
if p.busy() {
return p.what
}
}
return ""
}
func (w *Writer) now() time.Time {
if w.Now != nil {
return w.Now()
}
return time.Now()
}
func (w *Writer) logf(format string, args ...any) {
if w.Logger != nil {
w.Logger.Printf(format, args...)
}
}
// Tick runs one evaluation: write the file when busy, remove it when idle. Exported for the test and the start.
func (w *Writer) Tick() {
what := w.Busy()
if what == "" {
if err := os.Remove(w.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
w.logf("[WARN] [busymark] the busy file could not be removed: %v — the agent reads it as busy for up to 90 s more", err)
}
if w.last != "" {
w.logf("[INFO] [busymark] idle again (was: %s) — an operator reboot of the box is no longer refused for it", w.last)
}
w.last = ""
return
}
body := w.now().UTC().Format(time.RFC3339) + "\n" + what + "\n"
if err := os.MkdirAll(filepath.Dir(w.Path), 0o700); err == nil {
tmp := w.Path + ".tmp"
if err = os.WriteFile(tmp, []byte(body), 0o600); err == nil {
err = os.Rename(tmp, w.Path)
}
if err != nil {
w.logf("[WARN] [busymark] the busy file could not be written: %v — an operator reboot is not refused for %s", err, what)
}
}
if w.last != what {
w.logf("[INFO] [busymark] busy: %s — an operator reboot of the box is refused until it ends", what)
}
w.last = what
}
// Run removes a file left by an earlier run, then ticks every Every until ctx ends (and removes the file then).
func (w *Writer) Run(ctx context.Context) {
every := w.Every
if every <= 0 {
every = DefaultEvery
}
w.Tick()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
_ = os.Remove(w.Path)
return
case <-t.C:
w.Tick()
}
}
}
@@ -0,0 +1,65 @@
package busymark
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// D65 = A: the agent refuses an operator reboot while the controller's busy file is fresh.
func newW(t *testing.T) (*Writer, *bool, *time.Time) {
t.Helper()
busy := false
now := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC)
w := &Writer{Path: filepath.Join(t.TempDir(), "ctl", FileName), Now: func() time.Time { return now }}
w.AddProbe("a backup or a restore is running", func() bool { return busy })
return w, &busy, &now
}
// Busy → the file holds the time and the sentence; idle → gone. RED-PROOF: skip the write → no file → FAILS;
// skip the remove → the file stays → FAILS.
func TestBusyFileFollowsTheWork(t *testing.T) {
w, busy, now := newW(t)
w.Tick()
if _, err := os.Stat(w.Path); !os.IsNotExist(err) {
t.Fatalf("idle: a busy file exists (%v)", err)
}
*busy = true
w.Tick()
raw, err := os.ReadFile(w.Path)
if err != nil {
t.Fatalf("busy: no file: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 2 || lines[0] != now.Format(time.RFC3339) || lines[1] != "a backup or a restore is running" {
t.Fatalf("file %q", raw)
}
if fi, _ := os.Stat(w.Path); fi.Mode().Perm() != 0o600 {
t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
}
// it is REWRITTEN while busy: freshness is the agent's signal
*now = now.Add(15 * time.Second)
w.Tick()
raw, _ = os.ReadFile(w.Path)
if !strings.HasPrefix(string(raw), now.Format(time.RFC3339)) {
t.Fatalf("not rewritten: %q", raw)
}
*busy = false
w.Tick()
if _, err := os.Stat(w.Path); !os.IsNotExist(err) {
t.Fatalf("idle again: the busy file is still there (%v) — the agent would refuse a reboot for 90 s more", err)
}
}
// The first probe that runs names the work; a nil probe is ignored.
func TestBusyNamesTheRunningWork(t *testing.T) {
w, _, _ := newW(t)
w.AddProbe("an app update is running", func() bool { return true })
w.AddProbe("ignored", nil)
if got := w.Busy(); got != "an app update is running" {
t.Fatalf("Busy() = %q", got)
}
}
+1
View File
@@ -953,6 +953,7 @@
"dashboard.tunnel_offline_title": "The secure internet connection is not connected now, so your apps cannot be reached from outside. Check the internet connection.",
"dashboard.utolso_mentes": "Last backup:",
"dashboard.vedett": "Protected",
"dashboard.operator_reboot": "Felhom support restarted your box at %s.",
"dashboard.vezerlopult": "Dashboard",
"datapath.consequence.excluded": "This folder is temporary and is not backed up.",
"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.",
+1
View File
@@ -948,6 +948,7 @@
"dashboard.tunnel_offline_title": "A biztonságos internetkapcsolat most nem kapcsolódik, ezért az alkalmazásaid kívülről nem érhetők el. Ellenőrizd az internetkapcsolatot.",
"dashboard.utolso_mentes": "Utolsó mentés:",
"dashboard.vedett": "Védett",
"dashboard.operator_reboot": "A Felhom ügyfélszolgálata %s-kor újraindította a dobozt.",
"dashboard.vezerlopult": "Vezérlőpult",
"datapath.consequence.excluded": "Ez a mappa átmeneti, és nem készül róla biztonsági mentés.",
"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.",
@@ -160,6 +160,26 @@ func isObservedUp(s Stack) bool {
}
}
// RecordStopWantedIfDown (R-1350) is the operator's hold clear (D64 = B) speaking for the household: the clear starts
// nothing, so an app that is NOT up stays stopped — and that stop is now WANTED, recorded as „stopped" so the dead-app
// scan does not mail „Telepített alkalmazás nem fut" 30 s after the clear (measured 2026-10-29 on Tester 1), and the
// boot reconciler does not start it at the next boot. A later Start records „running" and ends it. A running app's wish
// is left alone (recorded=false). The clear is a PERSON's act (R-379: „a person — on the box, or the operator on the
// hub"), so this is the ownership rule's own exception, not a machine writing intent.
func (m *Manager) RecordStopWantedIfDown(name string) (bool, error) {
st, ok := m.GetStack(name)
if !ok {
return false, nil
}
if isObservedUp(*st) {
return false, nil
}
if err := m.SetDesiredState(name, DesiredStateStopped); err != nil {
return false, err
}
return true, nil
}
// WantsStopped (R-721, v0.283.0) reports whether the household's own recorded intent for the stack is
// „stopped". The machines that stop an app for their own purposes and start it again afterwards — the
// whole-guest backup's quiesce, the nightly volume dump — ask this before their restart, so a Stop the
@@ -336,3 +336,27 @@ func TestDesiredStateOf_NilAppConfigIsUnknown(t *testing.T) {
t.Fatalf("a stack with no AppConfig reported desired state %q — absent must read as unknown", got)
}
}
// R-1350: the operator's hold clear records „stopped" for an app that is NOT up, and leaves a running app's wish alone
// (a clear never stops anything). RED-PROOF: write „stopped" without the up check → the running case FAILS.
func TestR1350_RecordStopWantedIfDown(t *testing.T) {
m, root := newDSManager(t, "gokapi", "immich")
for _, n := range []string{"gokapi", "immich"} {
writeAppYAML(t, stackDirOf(root, n), "deployed: true\ndesired_state: running\n")
m.stacks[n].AppConfig = LoadAppConfig(stackDirOf(root, n))
}
m.stacks["gokapi"].State = StateStopped
m.stacks["immich"].State = StateRunning
if rec, err := m.RecordStopWantedIfDown("gokapi"); err != nil || !rec {
t.Fatalf("gokapi: recorded=%v err=%v", rec, err)
}
if got := LoadAppConfig(stackDirOf(root, "gokapi")).DesiredState; got != DesiredStateStopped {
t.Fatalf("gokapi desired_state on disk = %q, want stopped", got)
}
if rec, err := m.RecordStopWantedIfDown("immich"); err != nil || rec {
t.Fatalf("immich (running): recorded=%v err=%v", rec, err)
}
if got := LoadAppConfig(stackDirOf(root, "immich")).DesiredState; got != DesiredStateRunning {
t.Fatalf("a running app's wish was changed to %q", got)
}
}
+4 -3
View File
@@ -183,9 +183,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["OperatorRebootNotice"] = s.operatorRebootNotice(s.langFor(r), time.Now()) // D65: „Felhom support restarted your box"
// R-560: the app rows carry catalog copy (each row's `.Meta.Description`), so the list the
// TEMPLATE sees is the localised view. The lists the warning helpers below see are the
// originals — they key on stack NAMES, and handing them a translated copy would only widen
@@ -0,0 +1,81 @@
package web
import (
"context"
"errors"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// „Felhom support restarted your box at <time>" (D65 = A, operator ruling 2026-10-29 15:50; felhom.eu `04` §6.1).
//
// The operator can reboot the guest from the hub; the household must see afterwards that Felhom did it. The agent
// writes its record BEFORE the reboot, so this controller — which starts with the guest — asks for it once at start
// (GET /guest/operator-reboot) and the dashboard shows one neutral line until a day has passed since the reboot.
// Pinned by operator_reboot_notice_test.go.
// operatorRebootNoticeFor is how long the line shows (the agent also stops answering after a day).
const operatorRebootNoticeFor = 24 * time.Hour
// SetOperatorReboot records the last operator reboot (the zero time = none).
func (s *Server) SetOperatorReboot(at time.Time) {
s.opRebootMu.Lock()
defer s.opRebootMu.Unlock()
s.opRebootAt = at
}
// operatorRebootNotice is the dashboard's line for lang at now ("" = none).
func (s *Server) operatorRebootNotice(lang string, now time.Time) string {
s.opRebootMu.Lock()
at := s.opRebootAt
s.opRebootMu.Unlock()
if at.IsZero() || now.Sub(at) >= operatorRebootNoticeFor || now.Before(at) {
return ""
}
return s.i18n.Msgf(lang, "dashboard.operator_reboot", at.In(budapestLocation()).Format("15:04"))
}
// WatchOperatorReboot asks the agent once at start, retrying while the agent does not answer (it may start after us),
// at most for opRebootAskFor. A 404 (an older agent) ends it at once.
func (s *Server) WatchOperatorReboot(ctx context.Context) {
fetch := s.opRebootFetch
if fetch == nil {
fetch = func(ctx context.Context) (time.Time, error) {
c, err := s.agentClient()
if err != nil {
return time.Time{}, err
}
return c.OperatorReboot(ctx)
}
}
every := s.opRebootRetry
if every <= 0 {
every = 30 * time.Second
}
for deadline := time.Now().Add(10 * time.Minute); ; {
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
at, err := fetch(cctx)
cancel()
var se *agentapi.StatusError
switch {
case err == nil:
if !at.IsZero() {
s.SetOperatorReboot(at)
s.logger.Printf("[INFO] [web] the operator rebooted this box from the hub at %s — the dashboard says so for a day", at.UTC().Format(time.RFC3339))
}
return
case errors.As(err, &se) && se.Code == 404:
return // an agent older than the route cannot reboot from the hub
}
if !time.Now().Before(deadline) {
s.logger.Printf("[WARN] [web] could not ask the agent about an operator reboot: %v — no notice on the dashboard", err)
return
}
select {
case <-ctx.Done():
return
case <-time.After(every):
}
}
}
@@ -0,0 +1,79 @@
package web
import (
"context"
"errors"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// D65 = A: after the operator rebooted the box from the hub, the household's dashboard says so — one neutral line,
// for one day, then gone.
// The real dashboard, through ServeHTTP: the line is there after an operator reboot, in Hungarian, with the Budapest
// time; it is gone a day later. RED-PROOF: drop the data["OperatorRebootNotice"] line from dashboardHandler → FAILS;
// drop the 24-hour check → the day-later page still shows it → FAILS.
func TestOperatorRebootNotice_OnTheDashboardForADay(t *testing.T) {
s := newDashboardServer(t, time.Time{})
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
t.Fatal("no reboot yet, but the dashboard shows the line")
}
at := time.Now().Add(-2 * time.Hour)
s.SetOperatorReboot(at)
body := getPage(t, s, "/dashboard").Body.String()
want := "A Felhom ügyfélszolgálata " + at.In(budapestLocation()).Format("15:04") + "-kor újraindította a dobozt."
if !strings.Contains(body, want) {
t.Fatalf("the dashboard lacks %q", want)
}
s.SetOperatorReboot(time.Now().Add(-25 * time.Hour))
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
t.Fatal("a day later the line must be gone")
}
}
// English: the reader's language.
func TestOperatorRebootNotice_English(t *testing.T) {
s := newDashboardServer(t, time.Time{})
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
s.SetOperatorReboot(at)
got := s.operatorRebootNotice("en", at.Add(time.Hour))
if got != "Felhom support restarted your box at "+at.In(budapestLocation()).Format("15:04")+"." {
t.Fatalf("en: %q", got)
}
}
// The start-up ask: it retries while the agent does not answer, keeps the time it gets, and stops at once on a 404
// (an agent older than the route cannot reboot from the hub). RED-PROOF: drop SetOperatorReboot from the success
// branch → FAILS; return on the first error → the retried answer is lost → FAILS.
func TestWatchOperatorReboot_RetriesAndKeepsTheTime(t *testing.T) {
s := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
calls := 0
s.opRebootFetch = func(context.Context) (time.Time, error) {
calls++
if calls < 3 {
return time.Time{}, errors.New("agentapi: GET /guest/operator-reboot: connection refused")
}
return at, nil
}
s.WatchOperatorReboot(context.Background())
if calls != 3 || !s.opRebootAt.Equal(at) {
t.Fatalf("calls=%d at=%v, want 3 and %v", calls, s.opRebootAt, at)
}
s2 := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
calls = 0
s2.opRebootFetch = func(context.Context) (time.Time, error) {
calls++
return time.Time{}, &agentapi.StatusError{Path: "/guest/operator-reboot", Code: 404}
}
s2.WatchOperatorReboot(context.Background())
if calls != 1 || !s2.opRebootAt.IsZero() {
t.Fatalf("a 404 must end the ask at once: calls=%d at=%v", calls, s2.opRebootAt)
}
}
+5
View File
@@ -136,6 +136,11 @@ type Server struct {
agentCli *agentapi.Client
agentCliErr error
agentCliOnce sync.Once
// D65 = A: the last operator reboot of this guest (operator_reboot_notice.go); the fetch and retry are test seams.
opRebootMu sync.Mutex
opRebootAt time.Time
opRebootFetch func(ctx context.Context) (time.Time, error)
opRebootRetry time.Duration
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
@@ -5,7 +5,7 @@
<h2>{{T "dashboard.vezerlopult"}}</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{template "media_notice" .}}
{{template "media_notice" .}}{{if .OperatorRebootNotice}}<div class="operator-reboot-notice" data-operator-reboot>{{.OperatorRebootNotice}}</div>{{end}}
<div class="stats-grid">
<a href="/stacks?filter=running" class="stat-card">
@@ -911,6 +911,8 @@ select.form-control option { background: var(--bg-2); color: var(--text-1); }
/* Inline storage warnings (under storage bars on dashboard and monitoring) */
.inline-warnings { margin-top: 0.75rem; }
/* D65: „Felhom support restarted your box" — neutral (a deliberate act, not a fault: the exception-colour rule). */
.operator-reboot-notice { margin: 0 0 1rem; padding: 0.6rem 0.9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); color: var(--text-2); }
.inline-warning {
display: flex;
align-items: center;
+2 -1
View File
@@ -344,7 +344,8 @@
"api.escrow.recovery_offer_open": "BORN AS A KEY (R-1219, brief 2026-10-26 night Part 3) -- the escrow start handler refuses during a recovery offer. Pinned by TestR1219_NoCeremonyDuringARecoveryOffer.",
"err.appexport.kulonleges_fajl": "BORN AS A KEY (R-1187 a, brief 2026-10-26 night Part 4) -- the export refuses a FIFO, socket or device, naming the path. Pinned by TestR1187a_AFifoFailsTheExportNamingThePath.",
"err.appexport.import_foglalt": "BORN AS A KEY (R-1187 d, brief 2026-10-26 night Part 4) -- an import is refused while another operation holds the box. Pinned by TestR1187d_AnImportWaitsForARestore.",
"err.appexport.hdd_path_hibas": "BORN AS A KEY (R-1187 f, brief 2026-10-26 night Part 4) -- a bundle with an empty or relative HDD_PATH is refused before anything is touched. Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused."
"err.appexport.hdd_path_hibas": "BORN AS A KEY (R-1187 f, brief 2026-10-26 night Part 4) -- a bundle with an empty or relative HDD_PATH is refused before anything is touched. Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused.",
"dashboard.operator_reboot": "BORN AS A KEY (D65 = A, 2026-10-29) -- the household's line after the operator rebooted the box from the hub; no Go literal ever said it"
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",