Files
felhom.eu/hub/internal/store/opactions.go
T
admin 87af859fc3 hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)
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
2026-10-08 15:17:06 +02:00

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()
}