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:
@@ -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
|
||||
|
||||
@@ -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), "<button"); got != 2 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
|
||||
// Decision 185 (2026-10-08): plus the four operator-action buttons — a closed list, none of which
|
||||
// deletes or mutates the host (TestOperatorActions_* pin what they may do). Every OTHER button
|
||||
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 6 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action buttons", got)
|
||||
}
|
||||
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 4 {
|
||||
t.Errorf("operator-action forms = %d, want 4", got)
|
||||
}
|
||||
if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
|
||||
t.Error("the request-logs forms are missing — every button must be a log-bundle request")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185 — D1). The host page's buttons store a
|
||||
// pending row (store/opactions.go) and wake the box's wait channel; the box acts on its next report
|
||||
// reply and answers on the report after that. The list is CLOSED and validated here, before anything
|
||||
// is stored — an unknown action, job or argument is a 400 and no row.
|
||||
|
||||
// opActionsShown is how many rows the host page lists.
|
||||
const opActionsShown = 10
|
||||
|
||||
// operatorActor names who pressed, for the log and the row: the channel and the address. The hub has
|
||||
// one operator password and no user names, so this is the most it can say — never a credential.
|
||||
func operatorActor(r *http.Request) string {
|
||||
channel := "operator CLI (basic auth)"
|
||||
if _, err := r.Cookie(SessionCookieName); err == nil {
|
||||
channel = "operator browser session"
|
||||
}
|
||||
return channel + " from " + bindClientIP(r)
|
||||
}
|
||||
|
||||
// handleOperatorAction — POST /hosts/{id}/operator-action (form: action, arg).
|
||||
func (s *Server) handleOperatorAction(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
if !s.validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil || host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if host.CustomerID == "" {
|
||||
http.Error(w, "This host has no customer — there is no controller to act", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
action := strings.TrimSpace(r.FormValue("action"))
|
||||
arg := strings.TrimSpace(r.FormValue("arg"))
|
||||
if err := store.ValidateOperatorAction(action, arg); err != nil {
|
||||
s.logger.Printf("[INFO] operator action refused for host %s: %v", hostID, err)
|
||||
http.Error(w, "Refused: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
by := operatorActor(r)
|
||||
id, err := s.store.CreateOperatorAction(host.CustomerID, action, arg, by)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] operator action %s for %s: %v", action, host.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] operator action #%d %s%s requested for %s (host %s) by %s — the box acts on its next report",
|
||||
id, action, opArgSuffix(arg), host.CustomerID, hostID, by)
|
||||
// Direction-2: wake the controller's wait channel so the reply carrying the action comes in seconds.
|
||||
s.bumpIntent(host.CustomerID)
|
||||
http.Redirect(w, r, "/hosts/"+hostID+"#operator-actions", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func opArgSuffix(arg string) string {
|
||||
if arg == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + arg
|
||||
}
|
||||
|
||||
// hostOperatorActionRows is the host page's list (newest first). nil for a host with no customer.
|
||||
func (s *Server) hostOperatorActionRows(host *store.Host) []store.OperatorActionRow {
|
||||
if host.CustomerID == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.store.ListOperatorActions(host.CustomerID, opActionsShown)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] operator actions for %s: %v", host.CustomerID, err)
|
||||
return nil
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1). Red test (4) of the design, web half: a host-page POST stores a row and
|
||||
// bumps the box's intent; an unknown action is refused with no row. Plus a render test per branch of
|
||||
// the card's template gate (the seam-built-but-never-wired trap covers templates).
|
||||
|
||||
func postOperatorAction(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/operator-action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Forwarded-For", "10.9.8.7")
|
||||
// The CLI channel's CSRF pass (validateCSRF): Basic auth + the operator header.
|
||||
req.SetBasicAuth("", "pw")
|
||||
req.Header.Set(OperatorCLIHeader, "confirm")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleOperatorAction(rr, req, hostID)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestOperatorAction_PostStoresAndBumpsIntent(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
hub := intent.New()
|
||||
s.SetIntentHub(hub)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := hub.Generation("c1")
|
||||
rr := postOperatorAction(t, s, "h1", url.Values{"action": {"abandon_extend"}, "arg": {"14"}})
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rows, _ := st.ListOperatorActions("c1", 10)
|
||||
if len(rows) != 1 || rows[0].Action != "abandon_extend" || rows[0].Arg != "14" || rows[0].DoneAt != nil {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
if !strings.Contains(rows[0].RequestedBy, "10.9.8.7") {
|
||||
t.Errorf("requested_by = %q, want the operator's address", rows[0].RequestedBy)
|
||||
}
|
||||
if hub.Generation("c1") == before {
|
||||
t.Error("the box's intent was not bumped — its wait channel would not wake")
|
||||
}
|
||||
if p, _ := st.PendingOperatorActions("c1"); len(p) != 1 {
|
||||
t.Fatalf("the next ACK would list %d action(s), want 1", len(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorAction_UnknownRefusedNothingStored(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
hub := intent.New()
|
||||
s.SetIntentHub(hub)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := hub.Generation("c1")
|
||||
for _, f := range []url.Values{
|
||||
{"action": {"delete_offsite"}},
|
||||
{"action": {"run_job"}, "arg": {"offsite-abandon-sweep"}},
|
||||
{"action": {"abandon_extend"}, "arg": {"45"}},
|
||||
} {
|
||||
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("%v: status = %d, want 400", f, rr.Code)
|
||||
}
|
||||
}
|
||||
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
|
||||
t.Fatalf("a refused press stored %+v", rows)
|
||||
}
|
||||
if hub.Generation("c1") != before {
|
||||
t.Error("a refused press woke the box")
|
||||
}
|
||||
// A host with no customer has no controller to act.
|
||||
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr := postOperatorAction(t, s, "lonely", url.Values{"action": {"abandon_stop"}}); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("no-customer host: status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func renderHost(t *testing.T, s *Server, hostID string) string {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil), hostID)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
// One render per branch: customer + no rows, customer + rows (pending and closed), no customer.
|
||||
func TestOperatorAction_CardRendersPerBranch(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := renderHost(t, s, "h1")
|
||||
if got := strings.Count(body, `action="/hosts/h1/operator-action"`); got != 4 {
|
||||
t.Errorf("customer host: %d operator-action forms, want 4", got)
|
||||
}
|
||||
for _, want := range []string{`value="offsite_backup_now"`, `value="abandon_stop"`, `value="abandon_extend"`, `value="run_job"`, `<option value="fill-watch">`, `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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -283,6 +283,80 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Operator actions (decision 185, R-314/R-279/R-177): a CLOSED list. None deletes data, starts a
|
||||
countdown or shortens one; the box acts on its next report reply (seconds, at most one cycle). -->
|
||||
<section class="card" id="operator-actions">
|
||||
<h2>Operator Actions</h2>
|
||||
{{if .CustomerID}}
|
||||
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
||||
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.
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0;">
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="offsite_backup_now">
|
||||
<button type="submit" class="btn btn-sm">Run off-site backup now</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="run_job">
|
||||
<select name="arg">{{range .OperatorJobNames}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<button type="submit" class="btn btn-sm">Run check now</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="abandon_stop">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Stop deletion countdown</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="abandon_extend">
|
||||
<input type="number" name="arg" min="1" max="{{.OperatorExtendMaxDay}}" value="7" style="width: 4.5em; padding: 0.3em 0.5em;" aria-label="days">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Extend countdown (days from now)</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .OperatorActions}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Action</th>
|
||||
<th>Requested</th>
|
||||
<th>By</th>
|
||||
<th>Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .OperatorActions}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Action}}{{if .Arg}} {{.Arg}}{{end}}</td>
|
||||
<td>{{timeAgo .RequestedAt}}</td>
|
||||
<td>{{.RequestedBy}}</td>
|
||||
<td>
|
||||
{{if not .DoneAt}}<span class="badge badge-neutral">pending</span>
|
||||
{{else if eq .Outcome "done"}}<span class="badge badge-ok">done</span>
|
||||
{{else if eq .Outcome "failed"}}<span class="badge badge-error">failed</span>
|
||||
{{else}}<span class="badge badge-warn">{{.Outcome}}</span>{{end}}
|
||||
{{if .Message}}<span style="color: var(--text-muted); font-size: 0.85rem;">{{.Message}}</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>No operator actions for this box yet.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>This host has no customer, so there is no controller to act.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
|
||||
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
|
||||
whether the box confirms holding it, because an allocation alone cannot tell a live
|
||||
|
||||
Reference in New Issue
Block a user