Operator actions in the report reply (D1, R-314/R-279/R-177, decision 185)

The hub may ask the running controller for a CLOSED list of actions,
carried in the report ACK (operator_actions) and answered on the next
report (operator_action_results): offsite_backup_now, abandon_stop,
abandon_extend (1-30 days), run_job (fill-watch, offsite-integrity,
offsite-proof, disk-health-check). Unknown action/job/argument -> refused,
nothing called. Once per id (in memory; every action is safe to repeat).

- internal/report/opactions.go: the executor; results re-sent until the
  hub stops listing the id.
- scheduler.RunNow: refuses unknown / already-running jobs; OnDemand(ctx)
  makes an operator's offsite-integrity run even when not due.
- ExtendAbandon never shortens the countdown and refuses in the hub phase;
  StopAbandon reports failure when the hub cancel failed (it said success).
- Report ACK read cap 4 KiB -> 64 KiB (an ACK over the cap dropped every
  field in it).

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:
2026-10-08 14:38:39 +02:00
parent 63441f0a69
commit e06680b797
12 changed files with 1055 additions and 5 deletions
+10 -2
View File
@@ -51,8 +51,14 @@ type PushResponse struct {
// Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for
// the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is.
Claim *ClaimStatus `json:"claim"`
// OperatorActions (R-314/R-279/R-177, `09` §3 decision 185) — the operator's pending actions for
// this box, listed until their result arrives (opactions.go). Absent on an old hub = none.
OperatorActions []OperatorAction `json:"operator_actions"`
}
// maxPushResponseBytes bounds the report ACK read (see Push).
const maxPushResponseBytes = 64 << 10
// Pusher sends reports to the central hub.
type Pusher struct {
hubURL string
@@ -126,8 +132,10 @@ func (p *Pusher) Push(report *Report) error {
continue
}
// Read response body to parse customer_blocked field
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
// Read response body to parse customer_blocked field. The cap was 4 KiB; an ACK over it fails to
// parse and EVERY field in it is dropped (floor, config version, escrow, operator actions), so it
// is 64 KiB now — far above any real ACK, still a bound.
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxPushResponseBytes))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {