87af859fc3
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
248 lines
9.0 KiB
Go
248 lines
9.0 KiB
Go
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()
|
|
}
|