From 87af859fc3d31632574e3baafa2f09b8a19e11d0 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 14:38:53 +0200 Subject: [PATCH] hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host page "Operator Actions" card: run off-site backup now, run a check now (fixed job list), stop / extend (1-30 days) a deletion countdown. POST /hosts/{id}/operator-action validates against the CLOSED list before storing (unknown -> 400, no row), stores operator_actions(id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message), logs who pressed (channel + address) and bumps the box's intent. The report ACK lists pending rows as operator_actions until the box's operator_action_results closes them (matched on id AND reporting customer); each closed row becomes a hub-minted operator_action event (stored, never dispatched). Unanswered after 24 h: expired. A customer RESET cancels pending rows. Wire gate: new root + field-by-field mirror (controller report.OperatorAction) — needs the controller commit first. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/api/handler.go | 16 ++ hub/internal/api/opactions.go | 57 ++++ hub/internal/api/opactions_test.go | 70 +++++ hub/internal/store/customer_reset.go | 7 + hub/internal/store/opactions.go | 247 ++++++++++++++++++ hub/internal/store/opactions_test.go | 110 ++++++++ hub/internal/store/store.go | 4 + hub/internal/web/hosts.go | 4 + hub/internal/web/hosts_test.go | 10 +- hub/internal/web/opactions.go | 82 ++++++ hub/internal/web/opactions_test.go | 158 +++++++++++ hub/internal/web/server.go | 8 + .../web/templates/host_detail_body.html | 74 ++++++ scripts/wire_contract_gate.py | 5 + 14 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 hub/internal/api/opactions.go create mode 100644 hub/internal/api/opactions_test.go create mode 100644 hub/internal/store/opactions.go create mode 100644 hub/internal/store/opactions_test.go create mode 100644 hub/internal/web/opactions.go create mode 100644 hub/internal/web/opactions_test.go diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 9907dc31..54080d9a 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -481,6 +481,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { } } + // Operator actions (R-314/R-279/R-177, `09` §3 decision 185): the box's results for the actions + // the ACK listed. Each closes its row — matched on the id AND this report's customer, so a result + // naming another customer's action changes nothing — and becomes a hub-minted event (stored only, + // never dispatched: an operator record, not a customer mail). Old controllers never send this. + h.ingestOperatorActionResults(payload.CustomerID, body) + // DR recipe — persist the controller's secret-free customer/apps half (preserving any host half). // Backward-compatible (old controllers won't have this field); a failure must not drop the report. var drPayload struct { @@ -609,6 +615,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { resp["controller_log_requested"] = true } + // Decision 185: the operator's pending actions for this box, listed until each result arrives. + // Read AFTER the results above were recorded, so an action answered in this report is not + // listed back in the same reply. Omitted when none (an old controller ignores the field). + if acts, err := h.store.PendingOperatorActions(payload.CustomerID); err != nil { + h.logger.Printf("[WARN] operator actions for %s could not be read (not listed this cycle): %v", payload.CustomerID, err) + } else if len(acts) > 0 { + resp["operator_actions"] = acts + h.logger.Printf("[DEBUG] operator actions listed for %s: %d", payload.CustomerID, len(acts)) + } + // Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override // else global default) and the latest available version. The controller compares its current // version against the floor and auto-updates when below it (latest stays the customer's opt-in diff --git a/hub/internal/api/opactions.go b/hub/internal/api/opactions.go new file mode 100644 index 00000000..a22cc409 --- /dev/null +++ b/hub/internal/api/opactions.go @@ -0,0 +1,57 @@ +package api + +import ( + "encoding/json" + "fmt" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// operatorActionResultsPayload is the controller report's operator_action_results (decision 185; +// controller internal/report OperatorActionResult). +type operatorActionResultsPayload struct { + OperatorActionResults []struct { + ID int64 `json:"id"` + Outcome string `json:"outcome"` + Message string `json:"message"` + } `json:"operator_action_results"` +} + +// ingestOperatorActionResults closes each answered row and saves one hub-minted event per closed row. +// The box re-sends a result until the ACK stops listing its id; a re-send finds the row closed and +// does nothing (RecordOperatorActionResult matches only open rows of THIS customer). +func (h *Handler) ingestOperatorActionResults(customerID string, body []byte) { + var p operatorActionResultsPayload + if err := json.Unmarshal(body, &p); err != nil || len(p.OperatorActionResults) == 0 { + return + } + for _, res := range p.OperatorActionResults { + row, err := h.store.RecordOperatorActionResult(customerID, res.ID, res.Outcome, res.Message) + switch { + case err != nil: + h.logger.Printf("[WARN] operator action #%d result from %s not recorded: %v", res.ID, customerID, err) + continue + case row == nil: + h.logger.Printf("[DEBUG] operator action #%d result from %s ignored (already closed, or not this customer's)", res.ID, customerID) + continue + } + h.logger.Printf("[INFO] operator action #%d %s%s for %s (pressed by %s): %s — %s", + row.ID, row.Action, argSuffix(row.Arg), customerID, row.RequestedBy, row.Outcome, row.Message) + severity := "info" + if row.Outcome != store.OperatorActionDone { + severity = "warning" + } + if _, eerr := h.store.SaveEvent(customerID, "operator_action", severity, + fmt.Sprintf("Operator action %s%s (pressed by %s): %s — %s", row.Action, argSuffix(row.Arg), row.RequestedBy, row.Outcome, row.Message), + "", "hub"); eerr != nil { + h.logger.Printf("[WARN] operator action #%d recorded, but its event could not be saved: %v", row.ID, eerr) + } + } +} + +func argSuffix(arg string) string { + if arg == "" { + return "" + } + return " " + arg +} diff --git a/hub/internal/api/opactions_test.go b/hub/internal/api/opactions_test.go new file mode 100644 index 00000000..d73ec33f --- /dev/null +++ b/hub/internal/api/opactions_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// `09` §3 decision 185 (D1), the design's red test (4), wire half: the report reply lists a pending +// operator action until a result arrives, then not; a result naming another customer's id is ignored; +// a closed result becomes a hub-minted event. + +func ackActions(t *testing.T, h *Handler, customerID, extra string) []store.OperatorActionDirective { + t.Helper() + body := `{"customer_id":"` + customerID + `"` + extra + `}` + rr := do(h, http.MethodPost, "/report", globalKey, body) + if rr.Code != http.StatusOK { + t.Fatalf("report: %d %s", rr.Code, rr.Body.String()) + } + var ack struct { + OperatorActions []store.OperatorActionDirective `json:"operator_actions"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil { + t.Fatal(err) + } + return ack.OperatorActions +} + +func TestReportACK_OperatorActionsUntilResult(t *testing.T) { + h, st, _ := newTestHandler(t) + id, err := st.CreateOperatorAction("c1", "offsite_backup_now", "", "operator browser session from 10.0.0.1") + if err != nil { + t.Fatal(err) + } + // Listed to c1, not to c2. + if got := ackActions(t, h, "c1", ""); len(got) != 1 || got[0].ID != id || got[0].Action != "offsite_backup_now" { + t.Fatalf("c1 ACK = %+v", got) + } + if got := ackActions(t, h, "c2", ""); len(got) != 0 { + t.Fatalf("c2 sees c1's action: %+v", got) + } + // c2 reports a result for c1's id: ignored — c1's ACK still lists it, and no event is saved. + res := fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"forged"}]`, id) + ackActions(t, h, "c2", res) + if got := ackActions(t, h, "c1", ""); len(got) != 1 { + t.Fatalf("a cross-customer result closed c1's action: %+v", got) + } + if evs, _ := st.GetRecentEvents("c2", 10); len(evs) != 0 { + t.Fatalf("a cross-customer result minted an event: %+v", evs) + } + // c1's own result: closed, and THE SAME reply no longer lists it. + res = fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"the off-site backup finished"}]`, id) + if got := ackActions(t, h, "c1", res); len(got) != 0 { + t.Fatalf("still listed in the reply to its own result: %+v", got) + } + evs, _ := st.GetRecentEvents("c1", 10) + if len(evs) != 1 || evs[0].EventType != "operator_action" || evs[0].Source != "hub" || + !strings.Contains(evs[0].Message, "offsite_backup_now") || !strings.Contains(evs[0].Message, "10.0.0.1") { + t.Fatalf("events = %+v", evs) + } + // The box re-sends until it sees the id gone: a no-op, no second event. + ackActions(t, h, "c1", res) + if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 1 { + t.Fatalf("a re-sent result minted %d events", len(evs)) + } +} diff --git a/hub/internal/store/customer_reset.go b/hub/internal/store/customer_reset.go index 6d73c312..a5447273 100644 --- a/hub/internal/store/customer_reset.go +++ b/hub/internal/store/customer_reset.go @@ -169,6 +169,13 @@ func (s *Store) PurgeCustomerResetDBState(customerID string, escrowAcked bool) e return fmt.Errorf("reset purge %q: %w", q, err) } } + // Decision 185: an operator action still pending for the OLD box must never reach the box that + // takes this customer id next. Closed, not deleted — the row is the record of who pressed what. + if _, err := tx.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ? + WHERE customer_id = ? AND done_at IS NULL`, + time.Now().UTC(), OperatorActionCancelled, "cancelled by the customer RESET", customerID); err != nil { + return fmt.Errorf("reset: cancel pending operator actions: %w", err) + } return tx.Commit() } diff --git a/hub/internal/store/opactions.go b/hub/internal/store/opactions.go new file mode 100644 index 00000000..e77b5d33 --- /dev/null +++ b/hub/internal/store/opactions.go @@ -0,0 +1,247 @@ +package store + +import ( + "database/sql" + "fmt" + "sort" + "strconv" + "time" +) + +// Operator actions (R-314 / R-279 / R-177, `09` §3 decision 185 — D1, design option A of +// documentation/audits/day-2026-10-08/design-R-314-279-177.md). +// +// The operator presses a button on the host page; a row is stored here and the box's intent generation +// is bumped, so its wait channel wakes; the report ACK lists every pending row as +// `operator_actions: [{id, action, arg}]`; the controller acts once per id and sends +// `operator_action_results: [{id, outcome, message}]` on its next report; the row is closed and stops +// being listed. The hub never connects into the box. +// +// THE LIST IS CLOSED, and the hub refuses an unknown action, job or argument BEFORE storing anything — +// the controller refuses them again on its side. Nothing on the list deletes data, starts a countdown or +// shortens one; `03` §4 asks for a signing key only to destroy or overwrite the only copy. The list is +// pinned on both sides (TestOperatorActions_ClosedList here, TestOpActions_ClosedList in the controller) +// so a new entry is an operator decision, not an edit. + +const ( + OperatorActionOffsiteBackupNow = "offsite_backup_now" + OperatorActionAbandonStop = "abandon_stop" + OperatorActionAbandonExtend = "abandon_extend" + OperatorActionRunJob = "run_job" + + // Outcomes: the box's three, plus two the hub sets itself. + OperatorActionDone = "done" + OperatorActionRefused = "refused" + OperatorActionFailed = "failed" + OperatorActionExpired = "expired" // the box did not answer within OperatorActionTTL + OperatorActionCancelled = "cancelled" // the customer was RESET while it was pending + + OperatorActionExtendMinDays = 1 + OperatorActionExtendMaxDays = 30 + + // OperatorActionTTL: a pending action the box has not answered in a day is closed as expired and + // no longer listed — an off-site run pressed for a box that was off for a week must not start the + // moment it comes back, unasked. + OperatorActionTTL = 24 * time.Hour + + // operatorActionMessageMax bounds the box's message stored per row. + operatorActionMessageMax = 500 + // operatorActionsListed caps how many pending rows one ACK carries. + operatorActionsListed = 10 +) + +var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActionAbandonStop, OperatorActionAbandonExtend, OperatorActionRunJob} + +// operatorJobNames is the fixed set run_job may name — the controller's scheduler job names. +var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"} + +// OperatorActionNames returns the closed action list (sorted copy). +func OperatorActionNames() []string { return sortedStrings(operatorActionNames) } + +// OperatorJobNames returns the fixed run_job set (sorted copy). +func OperatorJobNames() []string { return sortedStrings(operatorJobNames) } + +func sortedStrings(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +func inList(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// ValidateOperatorAction refuses anything outside the closed list. A nil error is the only way a row +// is stored. +func ValidateOperatorAction(action, arg string) error { + switch action { + case OperatorActionOffsiteBackupNow, OperatorActionAbandonStop: + if arg != "" { + return fmt.Errorf("%s takes no argument", action) + } + case OperatorActionAbandonExtend: + d, err := strconv.Atoi(arg) + if err != nil || d < OperatorActionExtendMinDays || d > OperatorActionExtendMaxDays { + return fmt.Errorf("the number of days must be %d-%d", OperatorActionExtendMinDays, OperatorActionExtendMaxDays) + } + case OperatorActionRunJob: + if !inList(operatorJobNames, arg) { + return fmt.Errorf("unknown job %q", arg) + } + default: + return fmt.Errorf("unknown action %q", action) + } + return nil +} + +// OperatorActionDirective is ONE entry of the report ACK's operator_actions list — the wire type, +// named so scripts/wire_contract_gate.py can check it field by field against the controller's +// report.OperatorAction. +type OperatorActionDirective struct { + ID int64 `json:"id"` + Action string `json:"action"` + Arg string `json:"arg,omitempty"` +} + +// OperatorActionRow is one stored row, for the host page. +type OperatorActionRow struct { + ID int64 + CustomerID string + Action string + Arg string + RequestedAt time.Time + RequestedBy string + DoneAt *time.Time + Outcome string + Message string +} + +func (s *Store) migrateOperatorActions() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS operator_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + action TEXT NOT NULL, + arg TEXT NOT NULL DEFAULT '', + requested_at DATETIME NOT NULL, + requested_by TEXT NOT NULL DEFAULT '', + done_at DATETIME, + outcome TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_operator_actions_customer ON operator_actions(customer_id, done_at);`) + return err +} + +// CreateOperatorAction validates against the closed list and stores a pending row. by names who +// pressed (the operator's channel and address — never a credential). +func (s *Store) CreateOperatorAction(customerID, action, arg, by string) (int64, error) { + if customerID == "" { + return 0, fmt.Errorf("operator action: empty customer id") + } + if err := ValidateOperatorAction(action, arg); err != nil { + return 0, err + } + res, err := s.db.Exec(`INSERT INTO operator_actions (customer_id, action, arg, requested_at, requested_by) + VALUES (?, ?, ?, ?, ?)`, customerID, action, arg, time.Now().UTC(), by) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// PendingOperatorActions returns what the next ACK lists for this customer, oldest first. Rows older +// than OperatorActionTTL are closed as expired first and are not listed. +func (s *Store) PendingOperatorActions(customerID string) ([]OperatorActionDirective, error) { + now := time.Now().UTC() + if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ? + WHERE customer_id = ? AND done_at IS NULL AND requested_at < ?`, + now, OperatorActionExpired, "the box did not answer within a day", customerID, now.Add(-OperatorActionTTL)); err != nil { + return nil, err + } + rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions + WHERE customer_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, customerID, operatorActionsListed) + if err != nil { + return nil, err + } + defer rows.Close() + var out []OperatorActionDirective + for rows.Next() { + var d OperatorActionDirective + if err := rows.Scan(&d.ID, &d.Action, &d.Arg); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// RecordOperatorActionResult closes a pending row with the box's result. It matches on BOTH the id and +// the reporting customer, so a result naming another customer's action changes nothing (recorded=false). +// An outcome outside the box's three is refused. Returns the closed row when recorded. +func (s *Store) RecordOperatorActionResult(customerID string, id int64, outcome, message string) (*OperatorActionRow, error) { + switch outcome { + case OperatorActionDone, OperatorActionRefused, OperatorActionFailed: + default: + return nil, fmt.Errorf("unknown outcome %q", outcome) + } + if r := []rune(message); len(r) > operatorActionMessageMax { + message = string(r[:operatorActionMessageMax]) + } + res, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ? + WHERE id = ? AND customer_id = ? AND done_at IS NULL`, time.Now().UTC(), outcome, message, id, customerID) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, nil + } + return s.getOperatorAction(id) +} + +func (s *Store) getOperatorAction(id int64) (*OperatorActionRow, error) { + rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message + FROM operator_actions WHERE id = ?`, id) + if err != nil { + return nil, err + } + defer rows.Close() + list, err := scanOperatorActions(rows) + if err != nil || len(list) == 0 { + return nil, err + } + return &list[0], nil +} + +// ListOperatorActions returns the customer's newest rows (pending and closed), newest first. +func (s *Store) ListOperatorActions(customerID string, limit int) ([]OperatorActionRow, error) { + rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message + FROM operator_actions WHERE customer_id = ? ORDER BY id DESC LIMIT ?`, customerID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanOperatorActions(rows) +} + +func scanOperatorActions(rows *sql.Rows) ([]OperatorActionRow, error) { + var out []OperatorActionRow + for rows.Next() { + var r OperatorActionRow + var done sql.NullTime + if err := rows.Scan(&r.ID, &r.CustomerID, &r.Action, &r.Arg, &r.RequestedAt, &r.RequestedBy, &done, &r.Outcome, &r.Message); err != nil { + return nil, err + } + if done.Valid { + t := done.Time + r.DoneAt = &t + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/hub/internal/store/opactions_test.go b/hub/internal/store/opactions_test.go new file mode 100644 index 00000000..4bd21323 --- /dev/null +++ b/hub/internal/store/opactions_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "reflect" + "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) { + 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) + } +} + +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) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 564c62d9..40f9cca0 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -878,6 +878,10 @@ func (s *Store) migrate() error { if err := s.backfillAPIKeyHashes(); err != nil && s.logger != nil { s.logger.Printf("[ERROR] api_key_hash backfill: %v (unhashed rows still match on their plaintext key)", err) } + // R-314/R-279/R-177 (`09` §3 decision 185): the operator's actions for a box (opactions.go). + if err := s.migrateOperatorActions(); err != nil { + return fmt.Errorf("operator_actions: %w", err) + } return nil } diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index eba66fe9..e2903497 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -865,6 +865,10 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in // v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL). "LogBundles": s.hostLogBundleRows(host), "CSRFToken": s.getCSRFToken(r), + // Decision 185 (D1): the operator's closed list of actions for this host's controller. + "OperatorActions": s.hostOperatorActionRows(host), + "OperatorJobNames": store.OperatorJobNames(), + "OperatorExtendMaxDay": store.OperatorActionExtendMaxDays, // v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online // hosts — an ONLINE host is never deletable (no override exists). // R-30 slice 2 (D2): an online-by-report host whose box has been unreachable for diff --git a/hub/internal/web/hosts_test.go b/hub/internal/web/hosts_test.go index bc968248..ae885535 100644 --- a/hub/internal/web/hosts_test.go +++ b/hub/internal/web/hosts_test.go @@ -171,8 +171,14 @@ func TestHandleHostDetail(t *testing.T) { // v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host // danger-zone card must NOT render for it — this pin now doubles as the // "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly). - if got := strings.Count(strings.ToLower(body), "`, `max="30"`, "No operator actions for this box yet."} { + if !strings.Contains(body, want) { + t.Errorf("customer host, no rows: missing %q", want) + } + } + + id, _ := st.CreateOperatorAction("c1", "run_job", "offsite-proof", "operator browser session from 10.0.0.1") + _, _ = st.CreateOperatorAction("c1", "abandon_stop", "", "operator browser session from 10.0.0.1") + if _, err := st.RecordOperatorActionResult("c1", id, "refused", "the job offsite-proof was not started"); err != nil { + t.Fatal(err) + } + body = renderHost(t, s, "h1") + for _, want := range []string{"run_job offsite-proof", ">refused<", "the job offsite-proof was not started", ">pending<", "from 10.0.0.1"} { + if !strings.Contains(body, want) { + t.Errorf("customer host, rows: missing %q", want) + } + } + if strings.Contains(body, "No operator actions for this box yet.") { + t.Error("the empty line rendered beside rows") + } + + if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil { + t.Fatal(err) + } + body = renderHost(t, s, "lonely") + if strings.Contains(body, "/operator-action") { + t.Error("a host with no customer rendered operator-action buttons") + } + if !strings.Contains(body, "there is no controller to act") { + t.Error("the no-customer branch did not say why there are no buttons") + } +} + +func TestOperatorAction_NoCSRFNoRow(t *testing.T) { + s, st := newTestServer(t) + if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/hosts/h1/operator-action", strings.NewReader("action=abandon_stop")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.handleOperatorAction(rr, req, "h1") + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rr.Code) + } + if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 { + t.Fatal("a press without CSRF stored a row") + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 436cfc95..4f5ec022 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -537,6 +537,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + // Decision 185 (D1): the operator's actions — suffix route BEFORE the bare /hosts/ catch-all. + case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/operator-action"): + hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/operator-action") + if r.Method == http.MethodPost { + s.handleOperatorAction(w, r, hostID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"): hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs") if r.Method == http.MethodPost { diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index 2ea0f6b1..c5225cc4 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -283,6 +283,80 @@ {{end}} + +
+

Operator Actions

+ {{if .CustomerID}} +

+ The box acts on its next report reply (usually seconds, at most one report interval) and answers on the + report after that. Each press runs once. Recorded in the box's own log and in Events. Unanswered after a day: expired. +

+
+
+ + + +
+
+ + + + +
+
+ + + +
+
+ + + + +
+
+ {{if .OperatorActions}} + + + + + + + + + + + + {{range .OperatorActions}} + + + + + + + + {{end}} + +
#ActionRequestedByOutcome
{{.ID}}{{.Action}}{{if .Arg}} {{.Arg}}{{end}}{{timeAgo .RequestedAt}}{{.RequestedBy}} + {{if not .DoneAt}}pending + {{else if eq .Outcome "done"}}done + {{else if eq .Outcome "failed"}}failed + {{else}}{{.Outcome}}{{end}} + {{if .Message}}{{.Message}}{{end}} +
+ {{else}} +
+

No operator actions for this box yet.

+
+ {{end}} + {{else}} +
+

This host has no customer, so there is no controller to act.

+
+ {{end}} +
+