clear_hold (D64 = B) + the box command through the running controller (R-1343), R-1340, R-1341 — tests red first; commit only
gates / gates (push) Successful in 31s

- clear_hold: a fifth verb in the closed operator-action list; lifts a hold of any reason in the RUNNING
  controller (memory + settings.json together), forgets the update's held flag, drops a waiting put-back
  from the app-stop marker (copy kept). Refused on a bad name or no hold. No new event type.
- --clear-restore-hold asks the running controller over <data dir>/ctl/hold.sock (unix, 0600, peer uid
  checked, no TCP); edits settings.json only when no controller answers, and says so (in-guest restart).
- R-1340: the operator's HELD STOPPED mail is built from the hold record (restore kind, mixed cause,
  failed put-back step, database replay or not, kept copy) — never the option-C database story by default.
- R-1341: the OOM WARN line once per new kill (container, start time, kill count); event unchanged.
This commit is contained in:
admin committed 2026-10-29 12:49:46 +01:00
1 parent c7bb776e00
commit 5b3b541602
25 files changed
+1809 -83

No files matched your search

+42 -4
View File
@@ -21,9 +21,11 @@ import (
// problem, main.go). The result rides the next report as `operator_action_results: [{id, outcome,
// message}]`; the hub lists an action until its result has arrived, then stops.
//
// THE LIST IS CLOSED. Four actions, and run_job names one of four jobs. Nothing here deletes data,
// THE LIST IS CLOSED. Five actions, and run_job names one of four jobs. Nothing here deletes data,
// starts a countdown or shortens one — `03` §4 asks for a signing key only to destroy or overwrite the
// only copy, and none of these does. An unknown action, an unknown job or an argument out of range is
// only copy, and none of these does. clear_hold (D64 = B, operator ruling 2026-10-29 12:10) lifts an app's
// hold of ANY reason with no operator signature: it deletes nothing and starts nothing — the app stays
// stopped until the household (or the operator) starts it, and a waiting put-back's copy stays on disk. An unknown action, an unknown job or an argument out of range is
// answered `refused` and NOTHING is called. Pinned by TestOpActions_ClosedList (which fails when an
// entry is added, so a new action is a decision, not an edit) and TestOpActions_UnknownIsRefused.
//
@@ -38,6 +40,7 @@ const (
OpAbandonStop = "abandon_stop"
OpAbandonExtend = "abandon_extend"
OpRunJob = "run_job"
OpClearHold = "clear_hold"
OutcomeDone = "done"
OutcomeRefused = "refused"
@@ -57,7 +60,7 @@ const (
)
// operatorActionNames is THE closed list. Adding an entry fails TestOpActions_ClosedList by design.
var operatorActionNames = []string{OpOffsiteBackupNow, OpAbandonStop, OpAbandonExtend, OpRunJob}
var operatorActionNames = []string{OpOffsiteBackupNow, OpAbandonStop, OpAbandonExtend, OpRunJob, OpClearHold}
// operatorJobNames is the fixed set run_job may name — the scheduler's own job names (main.go).
var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"}
@@ -83,6 +86,21 @@ func contains(list []string, v string) bool {
return false
}
// validHoldStack is clear_hold's argument check: the same alphabet as backup.isSafeStackName (report cannot import
// backup; REUSE.md „Safe-name validators").
func validHoldStack(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
continue
}
return false
}
return true
}
// ErrActionRefused, wrapped by a handler, turns its error into a `refused` outcome (the action could
// not START — e.g. an off-site run already in flight) instead of `failed` (it started and went wrong).
var ErrActionRefused = errors.New("refused")
@@ -101,7 +119,7 @@ type OperatorActionResult struct {
Message string `json:"message"`
}
// OperatorActionHandlers are the four doors, wired by main.go. A nil handler answers `refused`
// OperatorActionHandlers are the five doors, wired by main.go. A nil handler answers `refused`
// („not available on this box").
type OperatorActionHandlers struct {
// OffsiteBackupNow returns a refusal sentence when the run may not start (the household's
@@ -111,6 +129,9 @@ type OperatorActionHandlers struct {
AbandonExtend func(days int) (time.Time, error)
// RunJob is Scheduler.RunNow: an error = not started (unknown/running); the channel = the job's end.
RunJob func(name string) (<-chan error, error)
// ClearHold lifts the app's hold in the RUNNING controller (D64 = B, R-1343) and returns the result
// sentence. actionID names the action in the box's log. An error wrapping ErrActionRefused = nothing changed.
ClearHold func(stack string, actionID int64) (string, error)
// ResultReady sends an out-of-cycle report so a result reaches the hub in seconds (optional).
ResultReady func()
}
@@ -317,6 +338,23 @@ func (o *OperatorActions) dispatch(a OperatorAction) {
}
o.finish(a, outcomeOf(jerr), msg)
}()
case OpClearHold:
// The argument is checked HERE, before any handler is touched (the closed list's rule): one app name,
// the backup package's strict alphabet (letters, digits, '-' and '_', at most 64).
if !validHoldStack(a.Arg) {
o.finish(a, OutcomeRefused, "the argument must be one app name (letters, digits, '-' and '_')")
return
}
if o.h.ClearHold == nil {
o.finish(a, OutcomeRefused, "not available on this box")
return
}
msg, err := o.h.ClearHold(a.Arg, a.ID)
if err != nil {
o.finish(a, outcomeOf(err), err.Error())
return
}
o.finish(a, OutcomeDone, msg)
default:
o.finish(a, OutcomeRefused, "unknown action")
}
+79 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"sync"
@@ -15,9 +16,11 @@ import (
// The list is CLOSED. This test fails when an action or a job is added, by design: a new entry is an
// operator decision (no action may delete data, start a countdown or shorten one), not an edit. The
// hub pins the same four in its own test (hub internal/store opactions_test.go).
// hub pins the same list in its own test (hub internal/store opactions_test.go).
// 2026-10-29: clear_hold added deliberately — operator ruling D64 = B (12:10): the hub's „Clear hold" works for
// every hold reason with no operator signature. It deletes nothing and starts nothing.
func TestOpActions_ClosedList(t *testing.T) {
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) {
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "clear_hold", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) {
t.Fatalf("operator actions = %v, want exactly %v — a new action needs the operator's word (decision 185)", got, want)
}
if got, want := OperatorJobNames(), []string{"disk-health-check", "fill-watch", "offsite-integrity", "offsite-proof"}; !reflect.DeepEqual(got, want) {
@@ -56,6 +59,10 @@ func recordingHandlers(c *calls) OperatorActionHandlers {
close(ch)
return ch, nil
},
ClearHold: func(stack string, id int64) (string, error) {
c.add("clear:" + stack)
return "the hold on " + stack + " is cleared", nil
},
}
}
@@ -197,3 +204,73 @@ func TestOpActions_WireShapes(t *testing.T) {
t.Fatalf("report carried %s", b)
}
}
// clear_hold (D64 = B): the argument is one app name in the strict alphabet — never a path, never empty, never two
// names. A bad one is refused and the handler is NOT called (the closed list's rule: validate before any door).
// RED-PROOF (2026-10-29): with validHoldStack returning true, this fails on „a refused action called [clear:…]".
func TestOpActions_ClearHoldBadArgumentIsRefusedAndCallsNothing(t *testing.T) {
c := &calls{}
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
list := []OperatorAction{
{ID: 1, Action: OpClearHold, Arg: ""},
{ID: 2, Action: OpClearHold, Arg: "../settings"},
{ID: 3, Action: OpClearHold, Arg: "immich paperless"},
{ID: 4, Action: OpClearHold, Arg: "immich/x"},
{ID: 5, Action: OpClearHold, Arg: strings.Repeat("a", 65)},
{ID: 6, Action: OpClearHold, Arg: "immich\n"},
{ID: 7, Action: OpClearHold, Arg: "."},
}
o.Reconcile(list)
if got := c.get(); len(got) != 0 {
t.Fatalf("a refused action called %v", got)
}
for _, a := range list {
if r := resultFor(t, o, a.ID); r.Outcome != OutcomeRefused || !strings.Contains(r.Message, "one app name") {
t.Errorf("#%d clear_hold(%q): %+v, want refused naming the rule", a.ID, a.Arg, r)
}
}
// control: a good name does reach the handler
o.Reconcile([]OperatorAction{{ID: 8, Action: OpClearHold, Arg: "paperless-ngx_2"}})
if got := c.get(); len(got) != 1 || got[0] != "clear:paperless-ngx_2" {
t.Fatalf("a valid name must reach the handler, calls %v", got)
}
}
// clear_hold (D64 = B): a valid name reaches the handler with the action's id, and its sentence is the result; a
// handler refusal (the app has no hold) is answered refused, an error that is not a refusal failed.
// RED-PROOF (2026-10-29): with the OpClearHold case removed from dispatch, this fails on „unknown action".
func TestOpActions_ClearHoldDoneRefusedFailed(t *testing.T) {
var gotStack string
var gotID int64
h := OperatorActionHandlers{ClearHold: func(stack string, id int64) (string, error) {
gotStack, gotID = stack, id
switch stack {
case "notheld":
return "", fmt.Errorf("%w: notheld has no hold — nothing was changed", ErrActionRefused)
case "broken":
return "", errors.New("saving settings: disk full")
}
return "the hold on " + stack + " is cleared (it was: held after a failed update)", nil
}}
o := NewOperatorActions(context.Background(), h, nil)
o.Reconcile([]OperatorAction{{ID: 7, Action: OpClearHold, Arg: "kimai"}})
if gotStack != "kimai" || gotID != 7 {
t.Fatalf("handler called with (%q, %d), want (kimai, 7)", gotStack, gotID)
}
if r := resultFor(t, o, 7); r.Outcome != OutcomeDone || r.Message != "the hold on kimai is cleared (it was: held after a failed update)" {
t.Fatalf("result %+v", r)
}
o.Reconcile([]OperatorAction{{ID: 8, Action: OpClearHold, Arg: "notheld"}, {ID: 9, Action: OpClearHold, Arg: "broken"}})
if r := resultFor(t, o, 8); r.Outcome != OutcomeRefused || !strings.Contains(r.Message, "has no hold") {
t.Errorf("not held: %+v", r)
}
if r := resultFor(t, o, 9); r.Outcome != OutcomeFailed {
t.Errorf("a save error must be failed, not refused: %+v", r)
}
// nil handler → refused, not a crash
o2 := NewOperatorActions(context.Background(), OperatorActionHandlers{}, nil)
o2.Reconcile([]OperatorAction{{ID: 1, Action: OpClearHold, Arg: "kimai"}})
if r := resultFor(t, o2, 1); r.Outcome != OutcomeRefused {
t.Errorf("nil handler: %+v", r)
}
}