hub: Restart controller (the agent's closed list on the host-report reply) and Clear hold (D64 = B, the controller's list) on the host page, each behind a confirm; controller_unresponsive (R-1344, alarm only); 04 §6.1 + 09 §3 decision 283; wire gate declares both new wires; R-1344 filed — tests red first
gates / gates (push) Successful in 3m23s
gates / gates (push) Successful in 3m23s
This commit is contained in:
1 parent
70862fa7b3
commit
3d0c907b56
20 files changed
+663
-20
No files matched your search
@@ -809,6 +809,16 @@ type hostReportPayload struct {
|
||||
DeliberateOff *struct {
|
||||
RequestedAt string `json:"requested_at"`
|
||||
} `json:"deliberate_off"`
|
||||
// OperatorActionResults (2026-10-29, the agent's operator actions): the answers to the previous replies'
|
||||
// operator_actions — the controller's shape. Absent on an older agent.
|
||||
OperatorActionResults []hostOperatorActionResult `json:"operator_action_results"`
|
||||
}
|
||||
|
||||
// hostOperatorActionResult mirrors the agent's hub.OperatorActionResult.
|
||||
type hostOperatorActionResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// hostForeignKeyArchives mirrors the agent's hub.ForeignKeyArchives (R-366 slice 2).
|
||||
@@ -1133,6 +1143,14 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
|
||||
if pending, err := h.store.PendingLogBundleRequest(hostID, store.LogBundleComponentAgent); err == nil && pending {
|
||||
resp["log_tail_requested"] = true
|
||||
}
|
||||
// The agent's operator actions (2026-10-29): the results this report carries close their rows (one event each);
|
||||
// what is still pending rides this reply. A store error drops nothing from the heartbeat.
|
||||
h.ingestHostOperatorActionResults(hostID, custID, rep.OperatorActionResults)
|
||||
if acts, err := h.store.PendingHostOperatorActions(hostID); err != nil {
|
||||
h.logger.Printf("[WARN] host operator actions for %s: %v", hostID, err)
|
||||
} else if len(acts) > 0 {
|
||||
resp["operator_actions"] = acts
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
@@ -2400,6 +2418,8 @@ var allowedEventTypes = map[string]bool{
|
||||
"controller_crashloop": true,
|
||||
// R-539 (hub v0.117.0, agent v0.132.0) — hub-generated from slow_crashloop_since; operator-only.
|
||||
"controller_slow_crashloop": true,
|
||||
// R-1344 (2026-10-29): hub-generated from the stanza's unresponsive_since; operator-only (notify.operatorOnlyEvents).
|
||||
"controller_unresponsive": true,
|
||||
// R-550 (hub v0.117.0, controller v0.246.0) — pushed by the controller at startup when it finds a
|
||||
// restore that was in flight when the box stopped. For the household: they can run it again.
|
||||
"restore_interrupted": true,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Operator brief 2026-10-29, Part 1: the AGENT's operator actions ride the host-report reply as `operator_actions`
|
||||
// (the controller's shape) and its answer rides the next host report as `operator_action_results`; the row closes and
|
||||
// one `operator_action` event is stored. A controller row never reaches the agent. RED-PROOF: seen failing before the
|
||||
// reply listed the agent rows (no `operator_actions` key).
|
||||
func TestHostReport_AgentOperatorActionRoundTrip(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
if _, err := st.CreateOperatorAction("c1", "clear_hold", "radicale", "operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := st.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator browser session from 192.168.0.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
|
||||
var env struct {
|
||||
OperatorActions []store.OperatorActionDirective `json:"operator_actions"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(env.OperatorActions) != 1 || env.OperatorActions[0].ID != id || env.OperatorActions[0].Action != "restart_controller" ||
|
||||
env.OperatorActions[0].Arg != "9201" {
|
||||
t.Fatalf("the agent's reply lists %+v, want only the restart #%d (never the controller's clear_hold)", env.OperatorActions, id)
|
||||
}
|
||||
body := strings.Replace(validReportBody("h1"), `"audit_tail":[]`,
|
||||
`"audit_tail":[],"operator_action_results":[{"id":`+i64s(id)+`,"outcome":"done","message":"the controller answered after 14 s"}]`, 1)
|
||||
if rr := do(h, http.MethodPost, "/host-report", "HKEY", body); rr.Code != http.StatusOK {
|
||||
t.Fatalf("result heartbeat = %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if left, _ := st.PendingHostOperatorActions("h1"); len(left) != 0 {
|
||||
t.Fatalf("the row is still pending: %+v", left)
|
||||
}
|
||||
rows, _ := st.ListOperatorActions("c1", 10)
|
||||
var got *store.OperatorActionRow
|
||||
for i := range rows {
|
||||
if rows[i].ID == id {
|
||||
got = &rows[i]
|
||||
}
|
||||
}
|
||||
if got == nil || got.Outcome != "done" || got.Message != "the controller answered after 14 s" {
|
||||
t.Fatalf("row = %+v", got)
|
||||
}
|
||||
evs, _ := st.GetEventsByType("c1", "operator_action", time.Time{})
|
||||
n := 0
|
||||
for _, e := range evs {
|
||||
if e.EventType == "operator_action" && strings.Contains(e.Message, "restart_controller 9201") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("operator_action events for the restart = %d, want 1: %+v", n, evs)
|
||||
}
|
||||
// once answered, the reply stops listing it
|
||||
rr = do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
|
||||
env.OperatorActions = nil
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &env)
|
||||
if len(env.OperatorActions) != 0 {
|
||||
t.Fatalf("still listed after its result: %+v", env.OperatorActions)
|
||||
}
|
||||
}
|
||||
|
||||
func i64s(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
// R-1344: the hub-generated controller_unresponsive is listed (the crash-loop pair's rule) and operator-only.
|
||||
func TestControllerUnresponsive_RegisteredAndOperatorOnly(t *testing.T) {
|
||||
if !allowedEventTypes["controller_unresponsive"] {
|
||||
t.Error("controller_unresponsive is not in allowedEventTypes")
|
||||
}
|
||||
if !notify.IsOperatorOnly("controller_unresponsive") {
|
||||
t.Error("controller_unresponsive is not operator-only")
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,33 @@ func argSuffix(arg string) string {
|
||||
}
|
||||
return " " + arg
|
||||
}
|
||||
|
||||
// ingestHostOperatorActionResults closes each answered AGENT row (matched on the id AND this host) and saves one
|
||||
// hub-minted operator_action event per closed row — the controller's rule, on the agent's channel.
|
||||
func (h *Handler) ingestHostOperatorActionResults(hostID, customerID string, results []hostOperatorActionResult) {
|
||||
for _, res := range results {
|
||||
row, err := h.store.RecordHostOperatorActionResult(hostID, res.ID, res.Outcome, res.Message)
|
||||
switch {
|
||||
case err != nil:
|
||||
h.logger.Printf("[WARN] host operator action #%d result from %s not recorded: %v", res.ID, hostID, err)
|
||||
continue
|
||||
case row == nil:
|
||||
h.logger.Printf("[DEBUG] host operator action #%d result from %s ignored (already closed, or not this host's)", res.ID, hostID)
|
||||
continue
|
||||
}
|
||||
h.logger.Printf("[INFO] host operator action #%d %s%s on %s (pressed by %s): %s — %s",
|
||||
row.ID, row.Action, argSuffix(row.Arg), hostID, row.RequestedBy, row.Outcome, row.Message)
|
||||
if customerID == "" {
|
||||
continue
|
||||
}
|
||||
severity := "info"
|
||||
if row.Outcome != store.OperatorActionDone {
|
||||
severity = "warning"
|
||||
}
|
||||
if _, eerr := h.store.SaveEvent(customerID, "operator_action", severity,
|
||||
fmt.Sprintf("Operator action %s%s on host %s (pressed by %s): %s — %s", row.Action, argSuffix(row.Arg), hostID, row.RequestedBy, row.Outcome, row.Message),
|
||||
"", "hub"); eerr != nil {
|
||||
h.logger.Printf("[WARN] host operator action #%d recorded, but its event could not be saved: %v", row.ID, eerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user