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
97 lines
4.4 KiB
Go
97 lines
4.4 KiB
Go
package store
|
|
|
|
import "fmt"
|
|
|
|
// Customer DELETE residue (v0.70.0, R-25b follow-up).
|
|
//
|
|
// The v0.69.0 cascade tore down hosts, external state and the customer record — and a fully deleted
|
|
// customer STILL came back on the Customers list, because `GetCustomers()` derives the list purely
|
|
// from the REPORT stream (`SELECT ... FROM reports ... GROUP BY customer_id`). Nothing in any
|
|
// lifecycle tier ever deleted a report. Worse than cosmetic: the staleness and offsite checkers
|
|
// iterate the same report-derived list, so a deleted customer kept raising `offsite_stale` and kept
|
|
// emailing the operator — observed live on `demo-vm-felhom`, deleted 2026-07-18, still alerting
|
|
// 2026-07-21.
|
|
//
|
|
// Two of these tables are not telemetry at all but CREDENTIAL-BEARING, and outliving their customer
|
|
// is a security defect rather than noise:
|
|
//
|
|
// - `appliance_registrations` — a `token_hash` + `status='delivered'` row binding a box to the
|
|
// customer id. Deleting the row returns a still-living box to the unclaimed pool on its next
|
|
// registration, which is exactly the right state for a decommissioned appliance.
|
|
// - `selfbind_tokens` — an unconsumed 7-day bind token would remain a working path to bind a box
|
|
// to a customer that no longer exists.
|
|
//
|
|
// What deliberately SURVIVES (unchanged from every other tier): `events` (the audit stream),
|
|
// `notification_log` (the send-attempt audit), `host_deletions` and `customer_resets` (F-14
|
|
// provenance). The audit trail outlives every lifecycle tier — that rule is not relaxed here.
|
|
|
|
// CustomerResidue counts what a customer has left behind OUTSIDE the config row: the report-derived
|
|
// state that keeps a deleted customer visible and alerting, plus the credential-bearing bindings.
|
|
// Counts only — never a token, hash or payload.
|
|
type CustomerResidue struct {
|
|
Reports int
|
|
AppTelemetry int
|
|
AppLogTails int
|
|
LogTailRequests int
|
|
NotificationPrefs int
|
|
SelfBindTokens int
|
|
ApplianceRegistrations int
|
|
}
|
|
|
|
// Total is the single number the "is there anything left at all?" decision hangs on.
|
|
func (r *CustomerResidue) Total() int {
|
|
if r == nil {
|
|
return 0
|
|
}
|
|
return r.Reports + r.AppTelemetry + r.AppLogTails + r.LogTailRequests +
|
|
r.NotificationPrefs + r.SelfBindTokens + r.ApplianceRegistrations
|
|
}
|
|
|
|
// residueQueries is the ONE list both the counter and the purge walk, so a table can never be
|
|
// counted-but-not-purged (or purged-but-not-counted) — the two drift silently otherwise.
|
|
var residueQueries = []struct {
|
|
table string
|
|
dst func(*CustomerResidue) *int
|
|
}{
|
|
{"reports", func(r *CustomerResidue) *int { return &r.Reports }},
|
|
{"app_telemetry", func(r *CustomerResidue) *int { return &r.AppTelemetry }},
|
|
{"app_log_tails", func(r *CustomerResidue) *int { return &r.AppLogTails }},
|
|
{"log_tail_requests", func(r *CustomerResidue) *int { return &r.LogTailRequests }},
|
|
{"customer_notifications", func(r *CustomerResidue) *int { return &r.NotificationPrefs }},
|
|
{"selfbind_tokens", func(r *CustomerResidue) *int { return &r.SelfBindTokens }},
|
|
{"appliance_registrations", func(r *CustomerResidue) *int { return &r.ApplianceRegistrations }},
|
|
}
|
|
|
|
// CustomerResidue counts the customer's report-derived + binding residue. Read-only.
|
|
func (s *Store) CustomerResidue(customerID string) (*CustomerResidue, error) {
|
|
res := &CustomerResidue{}
|
|
for _, q := range residueQueries {
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM `+q.table+` WHERE customer_id = ?`, customerID).
|
|
Scan(q.dst(res)); err != nil {
|
|
return nil, fmt.Errorf("CustomerResidue %s: count %s: %w", customerID, q.table, err)
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// PurgeCustomerResidue removes the report-derived state and the credential-bearing bindings in ONE
|
|
// transaction — the leg that actually makes a deleted customer disappear from the Customers list and
|
|
// stop alerting. Idempotent: a re-run deletes nothing extra. It NEVER touches events,
|
|
// notification_log, host_deletions or customer_resets.
|
|
func (s *Store) PurgeCustomerResidue(customerID string) error {
|
|
if customerID == "" {
|
|
return fmt.Errorf("PurgeCustomerResidue: empty customer_id")
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, q := range residueQueries {
|
|
if _, err := tx.Exec(`DELETE FROM `+q.table+` WHERE customer_id = ?`, customerID); err != nil {
|
|
return fmt.Errorf("PurgeCustomerResidue %s: purge %s: %w", customerID, q.table, err)
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|