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
+27
View File
@@ -244,6 +244,33 @@ func TestGetJobs_SelfScopedAndServesBlobs(t *testing.T) {
}
}
// DELETE /hosts/{id}/jobs/{job_id} clears a processed job (slice 10B), self-scoped + idempotent.
func TestDeleteJob_SelfScopedAndIdempotent(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
seedHost(t, st, "h2", "c2", "HKEY2")
st.EnqueueSignedJob("h1", "jobA", []byte("blob"))
// h2 cannot clear h1's job (self-scope).
if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY2", ""); rr.Code != http.StatusForbidden {
t.Errorf("h2 clearing h1's job = %d, want 403", rr.Code)
}
if n, _ := st.CountSignedJobs("h1"); n != 1 {
t.Errorf("job was removed by an unauthorized delete (depth=%d)", n)
}
// h1 clears its own job → 200, queue empties.
if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK {
t.Fatalf("h1 clearing own job = %d, want 200", rr.Code)
}
if n, _ := st.CountSignedJobs("h1"); n != 0 {
t.Errorf("queue depth after delete = %d, want 0", n)
}
// Idempotent: deleting an absent job is a clean 200.
if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK {
t.Errorf("idempotent delete = %d, want 200", rr.Code)
}
}
// The admin enqueue-job endpoint (global key only) seeds the queue, reflected in has_signed_ops.
func TestAdminEnqueueJob_GlobalKeyOnly(t *testing.T) {
h, st, _ := newTestHandler(t)
+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).