9b3381be0a
Found validating v0.69.0 against the live hub. demo-vm-felhom was deleted on 07-18 and was still on the Customers list AND still raising offsite_stale (10 events, latest 07-21 17:34, operator email at 19:34) — because GetCustomers() is report-derived and no lifecycle tier ever deleted a report. New leg 3 (residue), before the record purge: reports, app_telemetry, app_log_tails, log_tail_requests, customer_notifications, plus the credential-bearing appliance_registrations and selfbind_tokens. Audit (events, notification_log) and F-14 provenance still survive. Ghost customers are now deletable: 404 means "nothing here", not "no config row". With no config row the offsite descriptor is unknowable, so the Hetzner and descriptor legs record skipped_no_config rather than a bare "skipped". Two more red-proofs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J55BQE1gE2V4ffud5jweGS
182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Customer RESET (v0.61.0) — the middle lifecycle tier (host delete < RESET < customer delete). The
|
|
// journal is F-14-style provenance: one row per attempt, per-leg status, resumable. External teardown
|
|
// runs FIRST and the DB-purge runs LAST (publish-last) so a re-run reads the journal to know what still
|
|
// needs tearing down. Provenance/events are NEVER wiped — audit outlives every tier.
|
|
|
|
// CustomerReset is one reset provenance/journal record.
|
|
type CustomerReset struct {
|
|
ID int64
|
|
CustomerID string
|
|
StartedAt time.Time
|
|
CompletedAt *time.Time
|
|
EscrowAcked bool
|
|
Legs map[string]string // hetzner | pbs | db_purge → pending|ok|failed|manual
|
|
}
|
|
|
|
// ResetInventory is the live count of what a RESET would destroy (the confirm surface, ruling 4).
|
|
// Sub-account name + PBS namespace are added by the web layer from the external clients.
|
|
type ResetInventory struct {
|
|
HostCount int // ruling 3: RESET refuses while any host row exists
|
|
SupersededBlobs int // M — retained recovery-key custody destroyed (ack-gated)
|
|
DRRecipePresent bool
|
|
OneTimeSecretPresent bool
|
|
ClaimPresent bool
|
|
}
|
|
|
|
// CustomerResetInventory builds the hub-DB side of the confirm inventory.
|
|
func (s *Store) CustomerResetInventory(customerID string) (*ResetInventory, error) {
|
|
inv := &ResetInventory{}
|
|
hosts, err := s.ListHostsByCustomer(customerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inv.HostCount = len(hosts)
|
|
queries := []struct {
|
|
dst *int
|
|
q string
|
|
arg string
|
|
}{
|
|
{&inv.SupersededBlobs, `SELECT COUNT(*) FROM host_escrow_superseded WHERE host_id IN (SELECT host_id FROM host_deletions WHERE customer_id = ? UNION SELECT host_id FROM hosts WHERE customer_id = ?)`, customerID},
|
|
}
|
|
for _, qq := range queries {
|
|
if err := s.db.QueryRow(qq.q, qq.arg, customerID).Scan(qq.dst); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
present := func(q string) (bool, error) {
|
|
var n int
|
|
err := s.db.QueryRow(q, customerID).Scan(&n)
|
|
return n > 0, err
|
|
}
|
|
if inv.DRRecipePresent, err = present(`SELECT COUNT(*) FROM dr_recipe WHERE customer_id = ?`); err != nil {
|
|
return nil, err
|
|
}
|
|
if inv.OneTimeSecretPresent, err = present(`SELECT COUNT(*) FROM one_time_secrets WHERE customer_id = ?`); err != nil {
|
|
return nil, err
|
|
}
|
|
if inv.ClaimPresent, err = present(`SELECT COUNT(*) FROM customer_claims WHERE customer_id = ?`); err != nil {
|
|
return nil, err
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// StartCustomerReset opens a reset journal row and returns its id.
|
|
func (s *Store) StartCustomerReset(customerID string, escrowAcked bool) (int64, error) {
|
|
acked := 0
|
|
if escrowAcked {
|
|
acked = 1
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO customer_resets (customer_id, escrow_acked, legs_json) VALUES (?, ?, '{}')`, customerID, acked)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// UpdateResetLeg merges one leg's status into the journal (read-modify-write; the reset is single-flight
|
|
// per customer at the handler, so no lock contention).
|
|
func (s *Store) UpdateResetLeg(resetID int64, leg, status string) error {
|
|
var raw string
|
|
if err := s.db.QueryRow(`SELECT legs_json FROM customer_resets WHERE id = ?`, resetID).Scan(&raw); err != nil {
|
|
return err
|
|
}
|
|
legs := map[string]string{}
|
|
if raw != "" {
|
|
_ = json.Unmarshal([]byte(raw), &legs)
|
|
}
|
|
legs[leg] = status
|
|
out, err := json.Marshal(legs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(`UPDATE customer_resets SET legs_json = ? WHERE id = ?`, string(out), resetID)
|
|
return err
|
|
}
|
|
|
|
// FinishCustomerReset stamps completion.
|
|
func (s *Store) FinishCustomerReset(resetID int64) error {
|
|
_, err := s.db.Exec(`UPDATE customer_resets SET completed_at = datetime('now') WHERE id = ?`, resetID)
|
|
return err
|
|
}
|
|
|
|
// LatestCustomerReset returns the customer's most recent reset record (nil = never).
|
|
func (s *Store) LatestCustomerReset(customerID string) (*CustomerReset, error) {
|
|
var (
|
|
cr CustomerReset
|
|
startedAt string
|
|
completedAt sql.NullString
|
|
acked int
|
|
legs string
|
|
)
|
|
err := s.db.QueryRow(`
|
|
SELECT id, customer_id, started_at, completed_at, escrow_acked, legs_json
|
|
FROM customer_resets WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, customerID).
|
|
Scan(&cr.ID, &cr.CustomerID, &startedAt, &completedAt, &acked, &legs)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cr.StartedAt = parseSQLiteTime(startedAt)
|
|
if completedAt.Valid && completedAt.String != "" {
|
|
t := parseSQLiteTime(completedAt.String)
|
|
cr.CompletedAt = &t
|
|
}
|
|
cr.EscrowAcked = acked != 0
|
|
cr.Legs = map[string]string{}
|
|
_ = json.Unmarshal([]byte(legs), &cr.Legs)
|
|
return &cr, nil
|
|
}
|
|
|
|
// PurgeCustomerResetDBState is the DB-purge phase — runs LAST, after the external teardown legs. In one
|
|
// tx it removes the customer-scoped operational state a RESET destroys: retained escrow custody
|
|
// (ack-gated), the one-time repo password, the DR recipe, and customer-scoped log bundles. It does NOT
|
|
// touch customer_configs identity/config (SURVIVES), provenance (host_deletions/customer_resets), or
|
|
// events (audit). The claim reset rides the claim engine separately; the offsite descriptor clear rides
|
|
// the offsite package (config_json). Idempotent — a re-run deletes nothing extra.
|
|
func (s *Store) PurgeCustomerResetDBState(customerID string, escrowAcked bool) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if escrowAcked {
|
|
// Retained (superseded) blobs — the hosts are gone by the RESET precondition, so they are found
|
|
// only via the F-14 host_deletions provenance (UNION current hosts as a belt-and-suspenders).
|
|
if _, err := tx.Exec(`DELETE FROM host_escrow_superseded WHERE host_id IN (
|
|
SELECT host_id FROM host_deletions WHERE customer_id = ?
|
|
UNION SELECT host_id FROM hosts WHERE customer_id = ?)`, customerID, customerID); err != nil {
|
|
return fmt.Errorf("purge superseded escrow: %w", err)
|
|
}
|
|
}
|
|
for _, q := range []string{
|
|
`DELETE FROM one_time_secrets WHERE customer_id = ?`,
|
|
`DELETE FROM dr_recipe WHERE customer_id = ?`,
|
|
`DELETE FROM log_bundles WHERE scope_id = ?`,
|
|
`DELETE FROM log_bundle_requests WHERE scope_id = ?`,
|
|
} {
|
|
if _, err := tx.Exec(q, customerID); err != nil {
|
|
return fmt.Errorf("reset purge %q: %w", q, err)
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteClaim removes a customer's claim row (RESET → unclaimed / pre-first-install). The claim ENGINE
|
|
// re-mints a fresh code via EnsureIssued on the next onboarding — this is the store primitive the
|
|
// engine's ResetToUnclaimed rides (no parallel claimed-flag mechanism).
|
|
func (s *Store) DeleteClaim(customerID string) error {
|
|
_, err := s.db.Exec(`DELETE FROM customer_claims WHERE customer_id = ?`, customerID)
|
|
return err
|
|
}
|