docs: Q1c GREEN — reboot survival automatic since agent 0.84.0 (feature doc + audit §7 + CONTEXT + REPORT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 21:25:14 +02:00
parent ae950e5933
commit 146d165c26
11 changed files with 743 additions and 40 deletions
+200
View File
@@ -0,0 +1,200 @@
package store
// Group C (hub v0.47.0 stale host removal) — DeleteHost cascade + CountHostArtifacts.
// Effect assertions throughout: rows counted per table before/after; refusals prove the
// NON-effect (zero deletions), never just "an error came back".
import (
"errors"
"testing"
"time"
)
// seedHostWithArtifacts creates a host plus one row in EVERY host-scoped table, and one
// CUSTOMER-scoped log bundle (the controller channel) that must survive a host delete.
func seedHostWithArtifacts(t *testing.T, s *Store, hostID, customerID string) {
t.Helper()
if err := s.UpsertHost(&Host{HostID: hostID, CustomerID: customerID, APIKey: "key-" + hostID}); err != nil {
t.Fatal(err)
}
for _, g := range []int{100, 101} {
if err := s.UpsertGuestFromReport(&Guest{GuestID: GuestID(hostID, g), CustomerID: customerID,
HostID: hostID, VMID: g, Status: "stopped"}); err != nil {
t.Fatal(err)
}
}
// One host_reports row via raw insert — SaveHostReport would set hosts.last_report_at
// (→ online), and the fixture host must stay never-reported for the handler tests.
if _, err := s.db.Exec(`INSERT INTO host_reports (host_id, customer_id, report_json) VALUES (?, ?, '{}')`,
hostID, customerID); err != nil {
t.Fatal(err)
}
if err := s.EnqueueSignedJob(hostID, "job-1", []byte("opaque")); err != nil {
t.Fatal(err)
}
if err := s.SaveHostRecoveryCredential(hostID, "root@pam", "recovery-secret"); err != nil {
t.Fatal(err)
}
if err := s.SaveHostPBSSecret(hostID, "pbs-secret"); err != nil {
t.Fatal(err)
}
// Agent-scoped log bundle (scope_id = host_id) + a pending request. Order matters:
// SaveLogBundle CONSUMES the matching pending request, so the request is re-issued
// after the bundle lands to leave one row in each table.
if _, err := s.SaveLogBundle(hostID, LogBundleComponentAgent, time.Now(), []string{"line"}); err != nil {
t.Fatal(err)
}
if err := s.RequestLogBundle(hostID, LogBundleComponentAgent); err != nil {
t.Fatal(err)
}
// Customer-scoped log bundle (controller channel) — must be UNTOUCHED by a host delete.
if _, err := s.SaveLogBundle(customerID, LogBundleComponentController, time.Now(), []string{"ctl line"}); err != nil {
t.Fatal(err)
}
// Bound wg peer (raw insert — no endpoint needed for the cascade test; assigned_ip is
// TEXT with a UNIQUE constraint, so any distinct value serves).
if _, err := s.db.Exec(`INSERT INTO wg_peers (pubkey, assigned_ip, host_id, note) VALUES (?, ?, ?, '')`,
"PK-"+hostID, "ip-"+hostID, hostID); err != nil {
t.Fatal(err)
}
if err := s.SaveHostEscrow(hostID, []byte("opaque-escrow"), "fp", "posture", "2026-07-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
}
func countRows(t *testing.T, s *Store, query string, arg string) int {
t.Helper()
var n int
if err := s.db.QueryRow(query, arg).Scan(&n); err != nil {
t.Fatalf("count %q: %v", query, err)
}
return n
}
// hostRowCounts snapshots every table the cascade touches, keyed for exact comparison.
func hostRowCounts(t *testing.T, s *Store, hostID string) map[string]int {
t.Helper()
return map[string]int{
"hosts": countRows(t, s, `SELECT COUNT(*) FROM hosts WHERE host_id = ?`, hostID),
"guests": countRows(t, s, `SELECT COUNT(*) FROM guests WHERE host_id = ?`, hostID),
"host_reports": countRows(t, s, `SELECT COUNT(*) FROM host_reports WHERE host_id = ?`, hostID),
"signed_jobs": countRows(t, s, `SELECT COUNT(*) FROM signed_jobs WHERE host_id = ?`, hostID),
"host_recovery": countRows(t, s, `SELECT COUNT(*) FROM host_recovery WHERE host_id = ?`, hostID),
"host_pbs_secrets": countRows(t, s, `SELECT COUNT(*) FROM host_pbs_secrets WHERE host_id = ?`, hostID),
"log_bundle_requests": countRows(t, s, `SELECT COUNT(*) FROM log_bundle_requests WHERE scope_id = ?`, hostID),
"log_bundles": countRows(t, s, `SELECT COUNT(*) FROM log_bundles WHERE scope_id = ?`, hostID),
"wg_peers": countRows(t, s, `SELECT COUNT(*) FROM wg_peers WHERE host_id = ?`, hostID),
"host_escrow": countRows(t, s, `SELECT COUNT(*) FROM host_escrow WHERE host_id = ?`, hostID),
}
}
func TestDeleteHost_CascadeAndIsolation(t *testing.T) {
s := newTestStore(t)
seedHostWithArtifacts(t, s, "dr-drill-host", "cust-a")
seedHostWithArtifacts(t, s, "other-host", "cust-b") // must be fully intact afterwards
before := hostRowCounts(t, s, "dr-drill-host")
for table, n := range before {
if n == 0 {
t.Fatalf("fixture gap: %s has no row for dr-drill-host", table)
}
}
otherBefore := hostRowCounts(t, s, "other-host")
if err := s.DeleteHost("dr-drill-host", true); err != nil {
t.Fatalf("DeleteHost: %v", err)
}
// Every host-scoped row is gone.
for table, n := range hostRowCounts(t, s, "dr-drill-host") {
if n != 0 {
t.Errorf("%s: %d row(s) survived the cascade", table, n)
}
}
// The unrelated host's rows are EXACTLY intact.
otherAfter := hostRowCounts(t, s, "other-host")
for table, n := range otherBefore {
if otherAfter[table] != n {
t.Errorf("unrelated host %s: %d → %d rows (must be untouched)", table, n, otherAfter[table])
}
}
// Customer-scoped log bundles (controller channel) are UNTOUCHED — only scope_id ==
// host_id rows die. RED-PROOF 5: widening the delete to all scopes fails this.
if n := countRows(t, s, `SELECT COUNT(*) FROM log_bundles WHERE scope_id = ?`, "cust-a"); n != 1 {
t.Errorf("customer-scoped log bundle count = %d, want 1 (must survive a host delete)", n)
}
}
// Escrow refusal is fail-safe: the typed error comes back and the transaction NEVER ran —
// zero rows deleted anywhere. RED-PROOF 2 (handler-side) models on the same store guard.
func TestDeleteHost_EscrowRefusalIsNonEffect(t *testing.T) {
s := newTestStore(t)
seedHostWithArtifacts(t, s, "escrow-host", "cust-c")
before := hostRowCounts(t, s, "escrow-host")
err := s.DeleteHost("escrow-host", false)
if !errors.Is(err, ErrHostEscrowPresent) {
t.Fatalf("DeleteHost without escrow ack = %v, want ErrHostEscrowPresent", err)
}
after := hostRowCounts(t, s, "escrow-host")
for table, n := range before {
if after[table] != n {
t.Errorf("refusal deleted from %s: %d → %d (must be a non-effect)", table, n, after[table])
}
}
}
// Without an escrow row, deleteEscrow=false succeeds; with one, deleteEscrow=true removes it.
func TestDeleteHost_EscrowFlagSemantics(t *testing.T) {
s := newTestStore(t)
// No escrow → plain delete works without the ack.
if err := s.UpsertHost(&Host{HostID: "plain", CustomerID: "c", APIKey: "k"}); err != nil {
t.Fatal(err)
}
if err := s.DeleteHost("plain", false); err != nil {
t.Fatalf("DeleteHost without escrow: %v", err)
}
if n := countRows(t, s, `SELECT COUNT(*) FROM hosts WHERE host_id = ?`, "plain"); n != 0 {
t.Error("plain host not deleted")
}
// Escrow + ack → escrow row removed with the host.
seedHostWithArtifacts(t, s, "ack-host", "cust-d")
if err := s.DeleteHost("ack-host", true); err != nil {
t.Fatalf("DeleteHost with ack: %v", err)
}
if n := countRows(t, s, `SELECT COUNT(*) FROM host_escrow WHERE host_id = ?`, "ack-host"); n != 0 {
t.Error("escrow row survived deleteEscrow=true")
}
// Empty host id → refused before touching anything.
if err := s.DeleteHost("", true); err == nil {
t.Error("DeleteHost(\"\") must be refused")
}
}
func TestCountHostArtifacts(t *testing.T) {
s := newTestStore(t)
seedHostWithArtifacts(t, s, "impact-host", "cust-e")
a, err := s.CountHostArtifacts("impact-host")
if err != nil {
t.Fatalf("CountHostArtifacts: %v", err)
}
if a.Guests != 2 || a.Reports != 1 || a.LogBundles != 1 {
t.Errorf("counts = %+v, want guests=2 reports=1 log_bundles=1", a)
}
if !a.EscrowPresent || !a.WGPeerBound || !a.PBSSecretPresent || !a.RecoveryPresent {
t.Errorf("presence flags = %+v, want all true", a)
}
// The customer-scoped bundle must NOT be counted (host scope only).
empty, err := s.CountHostArtifacts("no-such-host")
if err != nil {
t.Fatalf("CountHostArtifacts(empty): %v", err)
}
if empty != (HostArtifacts{}) {
t.Errorf("unknown host artifacts = %+v, want zero value", empty)
}
}
+108
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
@@ -1636,6 +1637,113 @@ func (s *Store) ListHosts() ([]Host, error) {
return hosts, rows.Err()
}
// ErrHostEscrowPresent is returned by DeleteHost when the host still has a key-escrow row
// and the caller did not explicitly acknowledge deleting it (fail-safe-to-refuse — an escrow
// blob may be the ONLY remaining path to a customer's backup keys).
var ErrHostEscrowPresent = errors.New("host has key escrow; deletion requires the explicit escrow acknowledgement")
// HostArtifacts summarizes what a host deletion would remove — counts/booleans ONLY (the
// impact preview must never carry a secret or blob).
type HostArtifacts struct {
Guests int
Reports int
LogBundles int // log_bundles rows with scope_id == host_id (the agent channel ONLY)
EscrowPresent bool
WGPeerBound bool
PBSSecretPresent bool
RecoveryPresent bool
}
// CountHostArtifacts reports the per-table blast radius of deleting a host (v0.47.0 stale
// host removal). LogBundles counts ONLY host-scoped rows — customer-scoped bundles (the
// controller channel, scope_id == customer_id) belong to the customer and are never touched.
func (s *Store) CountHostArtifacts(hostID string) (HostArtifacts, error) {
var a HostArtifacts
counts := []struct {
dst *int
query string
}{
{&a.Guests, `SELECT COUNT(*) FROM guests WHERE host_id = ?`},
{&a.Reports, `SELECT COUNT(*) FROM host_reports WHERE host_id = ?`},
{&a.LogBundles, `SELECT COUNT(*) FROM log_bundles WHERE scope_id = ?`},
}
for _, c := range counts {
if err := s.db.QueryRow(c.query, hostID).Scan(c.dst); err != nil {
return a, err
}
}
flags := []struct {
dst *bool
query string
}{
{&a.EscrowPresent, `SELECT EXISTS(SELECT 1 FROM host_escrow WHERE host_id = ?)`},
{&a.WGPeerBound, `SELECT EXISTS(SELECT 1 FROM wg_peers WHERE host_id = ?)`},
{&a.PBSSecretPresent, `SELECT EXISTS(SELECT 1 FROM host_pbs_secrets WHERE host_id = ?)`},
{&a.RecoveryPresent, `SELECT EXISTS(SELECT 1 FROM host_recovery WHERE host_id = ?)`},
}
for _, f := range flags {
var n int
if err := s.db.QueryRow(f.query, hostID).Scan(&n); err != nil {
return a, err
}
*f.dst = n != 0
}
return a, nil
}
// DeleteHost removes a host and every host-scoped artifact in ONE transaction (v0.47.0
// stale host removal). The online-gate lives in the web handler — the store deletes what
// it is told to. Guards:
// - empty hostID → refused (would DELETE the '' scope rows);
// - escrow present without deleteEscrow → ErrHostEscrowPresent, the tx never starts.
//
// The wg_peers delete is INSIDE the tx on purpose — a crash between a host delete and a
// separate peer delete would strand a bound peer the reconciler keeps pushing. The wgsync
// reconciler's 5-minute declarative full-list push converges the endpoint after the row
// disappears — no bump, no reconciler change. log_bundle rows die by scope_id == host_id
// (agent channel); customer-scoped bundles (scope_id == customer_id) are NOT touched.
func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
if hostID == "" {
return fmt.Errorf("DeleteHost: empty host_id")
}
if !deleteEscrow {
var n int
if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM host_escrow WHERE host_id = ?)`, hostID).Scan(&n); err != nil {
return fmt.Errorf("DeleteHost %s: escrow check: %w", hostID, err)
}
if n != 0 {
return ErrHostEscrowPresent
}
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("DeleteHost %s: begin: %w", hostID, err)
}
defer tx.Rollback()
stmts := []string{
`DELETE FROM guests WHERE host_id = ?`,
`DELETE FROM host_reports WHERE host_id = ?`,
`DELETE FROM signed_jobs WHERE host_id = ?`,
`DELETE FROM host_recovery WHERE host_id = ?`,
`DELETE FROM host_pbs_secrets WHERE host_id = ?`,
`DELETE FROM log_bundle_requests WHERE scope_id = ?`,
`DELETE FROM log_bundles WHERE scope_id = ?`,
`DELETE FROM wg_peers WHERE host_id = ?`,
}
if deleteEscrow {
stmts = append(stmts, `DELETE FROM host_escrow WHERE host_id = ?`)
}
stmts = append(stmts, `DELETE FROM hosts WHERE host_id = ?`)
for _, q := range stmts {
if _, err := tx.Exec(q, hostID); err != nil {
return fmt.Errorf("DeleteHost %s: %q: %w", hostID, q, err)
}
}
return tx.Commit()
}
// UpsertHost creates or updates a host identity (used by the admin mint). On
// conflict it updates only operator-settable identity fields + updated_at; it does
// NOT touch the reality columns (agent_version/last_report_at) or the inert intent