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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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