// 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 `/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 -- cat `), 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 // /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() } } }