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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user