220 lines
9.8 KiB
Go
220 lines
9.8 KiB
Go
package store
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// `09` §3 decision 185 (D1). See opactions.go.
|
|
|
|
// The list is CLOSED, on the hub side too — this fails when an action or a job is added, by design. The
|
|
// controller pins the same four (internal/report TestOpActions_ClosedList).
|
|
func TestOperatorActions_ClosedList(t *testing.T) {
|
|
// D64 = B (operator 2026-10-29 12:10): clear_hold joins the controller's list.
|
|
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) {
|
|
t.Fatalf("run_job names = %v, want exactly %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOperatorActions_RefusedBeforeStoring(t *testing.T) {
|
|
s := newTestStore(t)
|
|
bad := []struct{ action, arg string }{
|
|
{"delete_everything", ""},
|
|
{"run_job", "offsite-abandon-sweep"},
|
|
{"run_job", ""},
|
|
{"abandon_extend", "0"},
|
|
{"abandon_extend", "31"},
|
|
{"abandon_extend", "x"},
|
|
{"abandon_stop", "1"},
|
|
{"offsite_backup_now", "now"},
|
|
}
|
|
for _, b := range bad {
|
|
if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil {
|
|
t.Errorf("%s(%q) was accepted", b.action, b.arg)
|
|
}
|
|
}
|
|
if rows, _ := s.ListOperatorActions("c1", 50); len(rows) != 0 {
|
|
t.Fatalf("a refused action stored %d row(s)", len(rows))
|
|
}
|
|
for _, ok := range []struct{ action, arg string }{{"offsite_backup_now", ""}, {"abandon_stop", ""}, {"abandon_extend", "1"}, {"abandon_extend", "30"}, {"run_job", "fill-watch"}} {
|
|
if _, err := s.CreateOperatorAction("c1", ok.action, ok.arg, "operator"); err != nil {
|
|
t.Errorf("%s(%q) refused: %v", ok.action, ok.arg, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Red test (4), store half: listed until a result arrives, then not; a result naming another
|
|
// customer's id changes nothing.
|
|
func TestOperatorActions_ListedUntilResult_OtherCustomerIgnored(t *testing.T) {
|
|
s := newTestStore(t)
|
|
id, err := s.CreateOperatorAction("c1", "run_job", "fill-watch", "operator session from 10.0.0.1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := s.PendingOperatorActions("c1")
|
|
if len(p) != 1 || p[0] != (OperatorActionDirective{ID: id, Action: "run_job", Arg: "fill-watch"}) {
|
|
t.Fatalf("pending = %+v", p)
|
|
}
|
|
if p2, _ := s.PendingOperatorActions("c2"); len(p2) != 0 {
|
|
t.Fatalf("another customer sees c1's action: %+v", p2)
|
|
}
|
|
// c2 reports a result for c1's id → ignored.
|
|
if row, err := s.RecordOperatorActionResult("c2", id, "done", "x"); err != nil || row != nil {
|
|
t.Fatalf("cross-customer result recorded: row=%+v err=%v", row, err)
|
|
}
|
|
if p, _ := s.PendingOperatorActions("c1"); len(p) != 1 {
|
|
t.Fatal("a cross-customer result closed the row")
|
|
}
|
|
if _, err := s.RecordOperatorActionResult("c1", id, "exploded", "x"); err == nil {
|
|
t.Fatal("an unknown outcome was accepted")
|
|
}
|
|
row, err := s.RecordOperatorActionResult("c1", id, "done", "the job fill-watch ran")
|
|
if err != nil || row == nil || row.Outcome != "done" || row.DoneAt == nil || row.RequestedBy != "operator session from 10.0.0.1" {
|
|
t.Fatalf("record: row=%+v err=%v", row, err)
|
|
}
|
|
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
|
|
t.Fatalf("still listed after its result: %+v", p)
|
|
}
|
|
// A repeated result (the box re-sends until it sees the id gone) is a no-op.
|
|
if row, _ := s.RecordOperatorActionResult("c1", id, "failed", "late"); row != nil {
|
|
t.Fatal("a second result overwrote the first")
|
|
}
|
|
}
|
|
|
|
func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
|
|
s := newTestStore(t)
|
|
old, _ := s.CreateOperatorAction("c1", "offsite_backup_now", "", "operator")
|
|
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id = ?`, time.Now().UTC().Add(-25*time.Hour), old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh, _ := s.CreateOperatorAction("c1", "abandon_stop", "", "operator")
|
|
p, _ := s.PendingOperatorActions("c1")
|
|
if len(p) != 1 || p[0].ID != fresh {
|
|
t.Fatalf("a day-old action is still listed: %+v", p)
|
|
}
|
|
if r, _ := s.getOperatorAction(old); r == nil || r.Outcome != OperatorActionExpired {
|
|
t.Fatalf("old row = %+v, want expired", r)
|
|
}
|
|
if err := s.PurgeCustomerResetDBState("c1", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
|
|
t.Fatalf("a RESET left a pending action for the next box: %+v", p)
|
|
}
|
|
if r, _ := s.getOperatorAction(fresh); r == nil || r.Outcome != OperatorActionCancelled {
|
|
t.Fatalf("fresh row after reset = %+v, want cancelled (kept as a record)", r)
|
|
}
|
|
}
|
|
|
|
// The AGENT's closed list (operator brief 2026-10-29, Part 1; D65 = A adds reboot_guest). RED-PROOF: seen failing before
|
|
// the list existed, and again before reboot_guest joined it.
|
|
func TestHostOperatorActions_ClosedList(t *testing.T) {
|
|
if got, want := HostOperatorActionNames(), []string{"reboot_guest", "restart_controller"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("host operator actions = %v, want exactly %v — a new verb needs the operator's word", got, want)
|
|
}
|
|
for _, a := range OperatorActionNames() {
|
|
if IsHostOperatorAction(a) {
|
|
t.Errorf("%s is on BOTH lists — a controller action must never reach the agent", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Each list refuses the other's verbs and every bad argument BEFORE storing.
|
|
func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) {
|
|
s := newTestStore(t)
|
|
bad := []struct{ action, arg string }{
|
|
{"restart_controller", ""}, {"restart_controller", "abc"}, {"restart_controller", "99"}, {"restart_controller", "9201 "},
|
|
{"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_host", "9201"}, {"clear_hold", "radicale"},
|
|
{"offsite_backup_now", ""}, {"reboot_guest", ""}, {"reboot_guest", "abc"}, {"reboot_guest", "99"},
|
|
}
|
|
for _, b := range bad {
|
|
if _, err := s.CreateHostOperatorAction("c1", "h1", b.action, b.arg, "operator"); err == nil {
|
|
t.Errorf("host action %s(%q) was accepted", b.action, b.arg)
|
|
}
|
|
}
|
|
for _, b := range []struct{ action, arg string }{{"clear_hold", ""}, {"clear_hold", "../x"}, {"clear_hold", "Rad icale"},
|
|
{"clear_hold", "a/b"}, {"restart_controller", "9201"}} {
|
|
if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil {
|
|
t.Errorf("controller action %s(%q) was accepted", b.action, b.arg)
|
|
}
|
|
}
|
|
if _, err := s.CreateOperatorAction("c1", "clear_hold", "radicale", "operator"); err != nil {
|
|
t.Fatalf("clear_hold radicale refused: %v", err)
|
|
}
|
|
if _, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator"); err != nil {
|
|
t.Fatalf("restart_controller 9201 refused: %v", err)
|
|
}
|
|
if _, err := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator"); err != nil {
|
|
t.Fatalf("reboot_guest 9201 refused: %v", err)
|
|
}
|
|
if _, err := s.CreateOperatorAction("c1", "reboot_guest", "9201", "operator"); err == nil {
|
|
t.Error("reboot_guest reached the controller's list")
|
|
}
|
|
}
|
|
|
|
// D65 = A: a reboot the agent has not picked up in 30 minutes expires — a box that was off must not reboot by itself
|
|
// when it comes back hours later. A restart keeps the day. RED-PROOF: drop the reboot TTL → the 31-minute-old reboot is
|
|
// still listed → FAILS.
|
|
func TestHostOperatorActions_ARebootExpiresAfter30Minutes(t *testing.T) {
|
|
s := newTestStore(t)
|
|
rb, _ := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator")
|
|
rs, _ := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator")
|
|
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id IN (?, ?)`, time.Now().UTC().Add(-31*time.Minute), rb, rs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := s.PendingHostOperatorActions("h1")
|
|
if len(p) != 1 || p[0].ID != rs {
|
|
t.Fatalf("pending = %+v, want only the restart #%d", p, rs)
|
|
}
|
|
if r, _ := s.getOperatorAction(rb); r == nil || r.Outcome != OperatorActionExpired || !strings.Contains(r.Message, "30 minutes") {
|
|
t.Fatalf("the old reboot = %+v, want expired, naming 30 minutes", r)
|
|
}
|
|
}
|
|
|
|
// An agent row reaches only its host's envelope, a controller row only the customer's ACK; a result closes only a row of
|
|
// the reporting host. RED-PROOF: drop `AND host_id = ”` from PendingOperatorActions → the restart reaches the controller.
|
|
func TestHostOperatorActions_EachSideSeesOnlyItsOwn(t *testing.T) {
|
|
s := newTestStore(t)
|
|
cid, err := s.CreateOperatorAction("c1", "clear_hold", "radicale", "operator")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hid, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctl, _ := s.PendingOperatorActions("c1")
|
|
if len(ctl) != 1 || ctl[0].ID != cid {
|
|
t.Fatalf("the controller ACK lists %+v, want only #%d", ctl, cid)
|
|
}
|
|
agent, _ := s.PendingHostOperatorActions("h1")
|
|
if len(agent) != 1 || agent[0].ID != hid || agent[0].Action != "restart_controller" || agent[0].Arg != "9201" {
|
|
t.Fatalf("the agent envelope lists %+v, want only #%d", agent, hid)
|
|
}
|
|
if other, _ := s.PendingHostOperatorActions("h2"); len(other) != 0 {
|
|
t.Fatalf("another host sees %+v", other)
|
|
}
|
|
if row, _ := s.RecordHostOperatorActionResult("h2", hid, "done", "x"); row != nil {
|
|
t.Fatal("another host closed the row")
|
|
}
|
|
if row, _ := s.RecordOperatorActionResult("c1", hid, "done", "x"); row != nil {
|
|
t.Fatal("the controller's result channel closed an agent row")
|
|
}
|
|
row, err := s.RecordHostOperatorActionResult("h1", hid, "done", "the controller answered after 14 s")
|
|
if err != nil || row == nil || row.Outcome != "done" || row.HostID != "h1" {
|
|
t.Fatalf("result: %+v %v", row, err)
|
|
}
|
|
if left, _ := s.PendingHostOperatorActions("h1"); len(left) != 0 {
|
|
t.Fatalf("a closed row is still listed: %+v", left)
|
|
}
|
|
list, _ := s.ListOperatorActions("c1", 10)
|
|
if len(list) != 2 {
|
|
t.Fatalf("the host page lists %d rows, want both", len(list))
|
|
}
|
|
}
|