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

@@ -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;