slice 10A: hub desired-state serving + signed-jobs queue (Down channel) (hub v0.9.0)
Serve operator intent to authenticated hosts: PUT /admin/hosts/{id}/desired-state
(global key) bumps desired_generation; GET /hosts/{id}/desired-state + /jobs are
per-host self-scoped; the host-report envelope now carries the real generation +
has_signed_ops. New signed_jobs table + store methods. Desired-state stored/served
opaquely (agent owns the schema). Cross-repo golden (envelope + desired-state)
byte-identical with felhom-agent; doc 03 §4/§9 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -282,6 +282,20 @@ func (s *Store) migrate() error {
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- signed_jobs (slice 10A): the per-host queue of OPAQUE operator-signed destructive-op
|
||||
-- blobs. The hub STORES + SERVES them; it never forges one (there is no signing key
|
||||
-- hub-side) and never executes them (execution + signature verification is slice 10B).
|
||||
-- HasSignedOps on the control envelope is "this host has >=1 pending job". A job is opaque
|
||||
-- bytes (the signed-op envelope the agent verifies); the hub treats it as a blob.
|
||||
CREATE TABLE IF NOT EXISTS signed_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
blob BLOB NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_jobs_host ON signed_jobs(host_id, id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1439,6 +1453,70 @@ func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) {
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// SetHostDesired sets a host's desired-state JSON and ATOMICALLY bumps its desired_generation
|
||||
// (slice 10A — the operator "admin-set" write). Returns the NEW generation. The generation is
|
||||
// the cheap change-signal carried on every heartbeat envelope; the agent re-fetches the full
|
||||
// desired-state only when it advances. Errors with sql.ErrNoRows if the host does not exist.
|
||||
func (s *Store) SetHostDesired(hostID string, desiredJSON []byte) (int64, error) {
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE hosts SET desired_json = ?, desired_generation = desired_generation + 1,
|
||||
updated_at = datetime('now')
|
||||
WHERE host_id = ?`, string(desiredJSON), hostID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return 0, sql.ErrNoRows // unknown host
|
||||
}
|
||||
var gen int64
|
||||
if err := s.db.QueryRow(`SELECT desired_generation FROM hosts WHERE host_id = ?`, hostID).Scan(&gen); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return gen, nil
|
||||
}
|
||||
|
||||
// SignedJob is one OPAQUE operator-signed destructive-op blob queued for a host (slice 10A). The
|
||||
// hub stores + serves the bytes; it never forges, opens, or executes them (10B owns verify+run).
|
||||
type SignedJob struct {
|
||||
JobID string
|
||||
Blob []byte
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// EnqueueSignedJob appends an opaque signed-op blob to a host's queue (slice 10A). Operator-side;
|
||||
// the hub holds no signing key — the blob arrives pre-signed.
|
||||
func (s *Store) EnqueueSignedJob(hostID, jobID string, blob []byte) error {
|
||||
_, err := s.db.Exec(`INSERT INTO signed_jobs (host_id, job_id, blob) VALUES (?, ?, ?)`,
|
||||
hostID, jobID, blob)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSignedJobs returns a host's pending signed-op blobs, oldest first (slice 10A serving).
|
||||
func (s *Store) GetSignedJobs(hostID string) ([]SignedJob, error) {
|
||||
rows, err := s.db.Query(`SELECT job_id, blob, created_at FROM signed_jobs WHERE host_id = ? ORDER BY id ASC`, hostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var jobs []SignedJob
|
||||
for rows.Next() {
|
||||
var j SignedJob
|
||||
if err := rows.Scan(&j.JobID, &j.Blob, &j.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
// CountSignedJobs returns the number of pending signed-op blobs for a host (drives the envelope's
|
||||
// has_signed_ops flag — the cheap "fetch your jobs" notification).
|
||||
func (s *Store) CountSignedJobs(hostID string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM signed_jobs WHERE host_id = ?`, hostID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SaveHostReport inserts a host_reports row and bumps the host's reality columns
|
||||
// (agent_version/last_report_at/updated_at) — never the inert intent columns.
|
||||
func (s *Store) SaveHostReport(hostID, customerID string, reportJSON []byte, d HostReportDenorm) error {
|
||||
|
||||
Reference in New Issue
Block a user