slice 10B: signed-op job completion (DELETE clear-job) (hub v0.10.0)

Add DELETE /hosts/{id}/jobs/{job_id} (per-host self-scoped, idempotent) so the
agent clears a job after executing or terminally rejecting it. The hub stores
the operator-signed blobs opaquely (no signing key — cannot forge or open);
the agent verifies + executes. Doc 03 §4/§6/§9 updated (operator-signed path
live; 8C wipe completes; 10B done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:14:32 +02:00
parent 8c54775b6f
commit 0c843286a2
6 changed files with 134 additions and 35 deletions
+35
View File
@@ -136,6 +136,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/jobs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/jobs")
h.handleGetJobs(w, r, hostID)
// Job completion (slice 10B) — per-host-key, self-scoped: DELETE /hosts/{id}/jobs/{job_id}.
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/jobs/"):
rest := strings.TrimPrefix(path, "/hosts/")
if i := strings.Index(rest, "/jobs/"); i > 0 {
h.handleDeleteJob(w, r, rest[:i], rest[i+len("/jobs/"):])
} else {
http.NotFound(w, r)
}
// Admin-set (slice 10A) — global/operator key only; bumps the generation.
case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/desired-state"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/desired-state")
@@ -739,6 +747,33 @@ func (h *Handler) handleGetJobs(w http.ResponseWriter, r *http.Request, pathHost
json.NewEncoder(w).Encode(map[string]interface{}{"jobs": out})
}
// handleDeleteJob clears a processed job from a host's queue (slice 10B). Per-host key,
// SELF-SCOPED (a host clears only its own jobs; the global key may clear any). Idempotent.
func (h *Handler) handleDeleteJob(w http.ResponseWriter, r *http.Request, pathHostID, jobID string) {
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if pathHostID == "" || jobID == "" {
http.Error(w, "Missing host_id or job_id", http.StatusBadRequest)
return
}
if !isGlobal && authHostID != pathHostID {
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
return
}
if err := h.store.DeleteSignedJob(pathHostID, jobID); err != nil {
h.logger.Printf("[ERROR] delete job %s for %s: %v", jobID, pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] host %s cleared signed-op job %s (executed or rejected)", pathHostID, jobID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// handleAdminSetDesiredState sets a host's desired-state (slice 10A). GLOBAL/operator key ONLY —
// a per-host key cannot author its own intent. The body is the desired-state JSON (opaque to the
// hub: it stores + serves bytes, never validates/interprets the schema — the agent/CLI owns it).