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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user