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
gates / gates (push) Successful in 32s
This commit is contained in:
1 parent
a28db2d17f
commit
b69e90bdad
22 files changed
+662
-11
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user