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
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:
1 parent
c7bb776e00
commit
5b3b541602
25 files changed
+1809
-83
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user