Operator actions in the report reply (D1, R-314/R-279/R-177, decision 185)
The hub may ask the running controller for a CLOSED list of actions, carried in the report ACK (operator_actions) and answered on the next report (operator_action_results): offsite_backup_now, abandon_stop, abandon_extend (1-30 days), run_job (fill-watch, offsite-integrity, offsite-proof, disk-health-check). Unknown action/job/argument -> refused, nothing called. Once per id (in memory; every action is safe to repeat). - internal/report/opactions.go: the executor; results re-sent until the hub stops listing the id. - scheduler.RunNow: refuses unknown / already-running jobs; OnDemand(ctx) makes an operator's offsite-integrity run even when not due. - ExtendAbandon never shortens the countdown and refuses in the hub phase; StopAbandon reports failure when the hub cancel failed (it said success). - Report ACK read cap 4 KiB -> 64 KiB (an ACK over the cap dropped every field in it). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1): the operator's actions. See opactions.go's header.
|
||||
|
||||
// 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).
|
||||
func TestOpActions_ClosedList(t *testing.T) {
|
||||
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "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) {
|
||||
t.Fatalf("run_job names = %v, want exactly %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// calls records every handler call.
|
||||
type calls struct {
|
||||
mu sync.Mutex
|
||||
list []string
|
||||
}
|
||||
|
||||
func (c *calls) add(s string) { c.mu.Lock(); c.list = append(c.list, s); c.mu.Unlock() }
|
||||
func (c *calls) get() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.list...)
|
||||
}
|
||||
|
||||
func recordingHandlers(c *calls) OperatorActionHandlers {
|
||||
return OperatorActionHandlers{
|
||||
OffsiteBackupNow: func() (string, func(context.Context) error) {
|
||||
c.add("offsite-check")
|
||||
return "", func(context.Context) error { c.add("offsite-run"); return nil }
|
||||
},
|
||||
AbandonStop: func() error { c.add("stop"); return nil },
|
||||
AbandonExtend: func(d int) (time.Time, error) {
|
||||
c.add("extend")
|
||||
return time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), nil
|
||||
},
|
||||
RunJob: func(name string) (<-chan error, error) {
|
||||
c.add("job:" + name)
|
||||
ch := make(chan error, 1)
|
||||
ch <- nil
|
||||
close(ch)
|
||||
return ch, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resultFor(t *testing.T, o *OperatorActions, id int64) OperatorActionResult {
|
||||
t.Helper()
|
||||
for _, r := range o.Results() {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no result for #%d in %+v", id, o.Results())
|
||||
return OperatorActionResult{}
|
||||
}
|
||||
|
||||
// Red test (3) of the design: an unknown action, an unknown job, an argument out of range → refused,
|
||||
// and NOTHING is called.
|
||||
func TestOpActions_UnknownIsRefusedAndCallsNothing(t *testing.T) {
|
||||
c := &calls{}
|
||||
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
|
||||
list := []OperatorAction{
|
||||
{ID: 1, Action: "delete_everything"},
|
||||
{ID: 2, Action: OpRunJob, Arg: "offsite-abandon-sweep"}, // a real job, NOT on the list: it deletes
|
||||
{ID: 3, Action: OpRunJob, Arg: ""},
|
||||
{ID: 4, Action: OpAbandonExtend, Arg: "0"},
|
||||
{ID: 5, Action: OpAbandonExtend, Arg: "31"},
|
||||
{ID: 6, Action: OpAbandonExtend, Arg: "-3"},
|
||||
{ID: 7, Action: OpAbandonExtend, Arg: "7 "},
|
||||
{ID: 8, Action: OpAbandonStop, Arg: "x"},
|
||||
{ID: 9, Action: OpOffsiteBackupNow, Arg: "x"},
|
||||
}
|
||||
o.Reconcile(list)
|
||||
o.running.Wait()
|
||||
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 || r.Message == "" {
|
||||
t.Errorf("#%d %s(%q): %+v, want refused with a reason", a.ID, a.Action, a.Arg, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Red test (2): the same id delivered twice → the action runs ONCE; its result is re-sent while listed.
|
||||
func TestOpActions_OncePerID(t *testing.T) {
|
||||
c := &calls{}
|
||||
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
|
||||
a := OperatorAction{ID: 42, Action: OpAbandonStop}
|
||||
o.Reconcile([]OperatorAction{a})
|
||||
o.Reconcile([]OperatorAction{a})
|
||||
o.Reconcile([]OperatorAction{a})
|
||||
if got := c.get(); len(got) != 1 || got[0] != "stop" {
|
||||
t.Fatalf("calls = %v, want exactly one stop", got)
|
||||
}
|
||||
if r := resultFor(t, o, 42); r.Outcome != OutcomeDone {
|
||||
t.Fatalf("result = %+v", r)
|
||||
}
|
||||
// The hub has the result and stops listing the id → it is no longer sent, and is not run again.
|
||||
o.Reconcile(nil)
|
||||
if rs := o.Results(); len(rs) != 0 {
|
||||
t.Fatalf("a result the hub no longer waits for is still sent: %+v", rs)
|
||||
}
|
||||
o.Reconcile([]OperatorAction{a})
|
||||
if got := c.get(); len(got) != 1 {
|
||||
t.Fatalf("re-listed id ran again: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpActions_EachDoorAndItsOutcome(t *testing.T) {
|
||||
c := &calls{}
|
||||
h := recordingHandlers(c)
|
||||
fired := 0
|
||||
var fmu sync.Mutex
|
||||
h.ResultReady = func() { fmu.Lock(); fired++; fmu.Unlock() }
|
||||
o := NewOperatorActions(context.Background(), h, nil)
|
||||
o.Reconcile([]OperatorAction{
|
||||
{ID: 1, Action: OpOffsiteBackupNow},
|
||||
{ID: 2, Action: OpAbandonExtend, Arg: "30"},
|
||||
{ID: 3, Action: OpRunJob, Arg: "fill-watch"},
|
||||
})
|
||||
o.running.Wait()
|
||||
for id := int64(1); id <= 3; id++ {
|
||||
if r := resultFor(t, o, id); r.Outcome != OutcomeDone {
|
||||
t.Errorf("#%d: %+v", id, r)
|
||||
}
|
||||
}
|
||||
if r := resultFor(t, o, 2); r.Message != "the set-aside history is now deleted on 2026-11-01" {
|
||||
t.Errorf("extend message = %q", r.Message)
|
||||
}
|
||||
fmu.Lock()
|
||||
defer fmu.Unlock()
|
||||
if fired != 3 {
|
||||
t.Errorf("ResultReady fired %d times, want 3 (one per finished action)", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpActions_RefusalsAndFailuresAreDistinct(t *testing.T) {
|
||||
h := OperatorActionHandlers{
|
||||
OffsiteBackupNow: func() (string, func(context.Context) error) { return "a backup run is already in flight", nil },
|
||||
AbandonStop: func() error { return errors.New("no abandonment countdown is running on this box") },
|
||||
RunJob: func(string) (<-chan error, error) { return nil, errors.New("the job is already running") },
|
||||
}
|
||||
o := NewOperatorActions(context.Background(), h, nil)
|
||||
o.Reconcile([]OperatorAction{{ID: 1, Action: OpOffsiteBackupNow}, {ID: 2, Action: OpAbandonStop}, {ID: 3, Action: OpRunJob, Arg: "offsite-proof"}, {ID: 4, Action: OpAbandonExtend, Arg: "5"}})
|
||||
o.running.Wait()
|
||||
want := map[int64]string{1: OutcomeRefused, 2: OutcomeFailed, 3: OutcomeRefused, 4: OutcomeRefused /* nil handler */}
|
||||
for id, w := range want {
|
||||
if r := resultFor(t, o, id); r.Outcome != w {
|
||||
t.Errorf("#%d: outcome %q, want %q (%s)", id, r.Outcome, w, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wire: the reply's operator_actions decodes into PushResponse, and the report's
|
||||
// operator_action_results is what BuildReport attaches.
|
||||
func TestOpActions_WireShapes(t *testing.T) {
|
||||
var pr PushResponse
|
||||
if err := json.Unmarshal([]byte(`{"status":"ok","operator_actions":[{"id":7,"action":"run_job","arg":"fill-watch"}]}`), &pr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pr.OperatorActions) != 1 || pr.OperatorActions[0] != (OperatorAction{ID: 7, Action: "run_job", Arg: "fill-watch"}) {
|
||||
t.Fatalf("decoded %+v", pr.OperatorActions)
|
||||
}
|
||||
c := &calls{}
|
||||
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
|
||||
SetOperatorActions(o)
|
||||
defer SetOperatorActions(nil)
|
||||
o.Reconcile(pr.OperatorActions)
|
||||
o.running.Wait()
|
||||
b, _ := json.Marshal(Report{OperatorActionResults: pendingOperatorActionResults()})
|
||||
var back struct {
|
||||
Results []map[string]interface{} `json:"operator_action_results"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &back); err != nil || len(back.Results) != 1 || back.Results[0]["outcome"] != "done" || back.Results[0]["id"].(float64) != 7 {
|
||||
t.Fatalf("report carried %s", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user