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

+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)
}
}