feat(hub): v0.70.0 — a deleted customer actually disappears (residue leg + ghost cleanup)
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
This commit is contained in:
@@ -13,16 +13,20 @@ package web
|
||||
// (ONLINE refuses; escrow is DEMOTED to retained custody, never destroyed)
|
||||
// leg 2 reset — the committed RESET sequence verbatim (Hetzner FIRST, PBS, claim, descriptor,
|
||||
// DB purge) via commitCustomerReset — with purgeEscrow=FALSE, see below
|
||||
// leg 3 purge — DeleteCustomerConfig: the customer record AND all escrow ciphertext
|
||||
// leg 3 residue — the report stream + credential-bearing bindings (v0.70.0): reports, app
|
||||
// telemetry/log tails, notification prefs, self-bind tokens, appliance
|
||||
// registrations. Without this leg a deleted customer stays on the Customers list
|
||||
// and keeps raising staleness alerts, because GetCustomers() is report-derived
|
||||
// leg 4 purge — DeleteCustomerConfig: the customer record AND all escrow ciphertext
|
||||
//
|
||||
// Nothing here is newly destructive: the cascade only SEQUENCES three operations that already exist,
|
||||
// each keeping its own safety rules. Two invariants are load-bearing:
|
||||
// Nothing here is newly destructive: the cascade only SEQUENCES operations that already exist (plus
|
||||
// the v0.70.0 residue sweep), each keeping its own safety rules. Two invariants are load-bearing:
|
||||
//
|
||||
// - Ruling 3 is preserved BY CONSTRUCTION: leg 2 can only run after leg 1, so the RESET sequence
|
||||
// never sees a host row. The standalone RESET handler's 409 gate is untouched.
|
||||
// - Custody is purged EXACTLY ONCE, in leg 3. Leg 1 demotes (host_escrow → host_escrow_superseded);
|
||||
// - Custody is purged EXACTLY ONCE, in leg 4. Leg 1 demotes (host_escrow → host_escrow_superseded);
|
||||
// leg 2 is called with purgeEscrow=false so PurgeCustomerResetDBState leaves the retained blobs
|
||||
// alone; leg 3's DeleteCustomerConfig is the one true purge point (v0.60.1).
|
||||
// alone; leg 4's DeleteCustomerConfig is the one true purge point (v0.60.1).
|
||||
//
|
||||
// A leg that fails leaves the journal row retained and the error names the leg. A re-run resumes:
|
||||
// leg 1 is a no-op once the hosts are gone, and every leg of the RESET sequence is idempotent.
|
||||
@@ -55,7 +59,8 @@ func readDeleteCascadeAcks(r *http.Request) deleteCascadeAcks {
|
||||
func (a deleteCascadeAcks) complete() bool { return a.Hosts && a.Reset && a.Purge }
|
||||
|
||||
// handleCustomerDeletePreview — GET /configs/{id}/delete. The read-only inventory the guided dialog
|
||||
// renders: the RESET inventory EXTENDED with the host list (ruling 4 applied to all three legs).
|
||||
// renders: the RESET inventory EXTENDED with the host list and the residue counts (ruling 4 applied
|
||||
// to every leg).
|
||||
// Counts, names and booleans only — never a secret, blob or key. Also surfaces an incomplete journal
|
||||
// row so the dialog can offer "Resume".
|
||||
func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
@@ -65,10 +70,6 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
inv, err := s.store.CustomerResetInventory(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] delete preview %s: inventory: %v", customerID, err)
|
||||
@@ -81,6 +82,18 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
residue, err := s.store.CustomerResidue(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] delete preview %s: residue: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Ghost customers (config row already gone, residue alive) MUST preview — the dialog is the only
|
||||
// surface that can clear them. 404 only when there is genuinely nothing left.
|
||||
if cfg == nil && len(hosts) == 0 && residue.Total() == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hostRows := make([]map[string]any, 0, len(hosts))
|
||||
onlineBlocked := false
|
||||
for i := range hosts {
|
||||
@@ -94,7 +107,10 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
"online": status == "ok",
|
||||
})
|
||||
}
|
||||
offsiteEnabled, offsiteType := offsiteChoice(cfg.ConfigJSON)
|
||||
offsiteEnabled, offsiteType := false, ""
|
||||
if cfg != nil {
|
||||
offsiteEnabled, offsiteType = offsiteChoice(cfg.ConfigJSON)
|
||||
}
|
||||
offsiteName := ""
|
||||
if offsiteEnabled && s.offsite != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
@@ -105,6 +121,10 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
offsiteName = n
|
||||
}
|
||||
}
|
||||
customerName := ""
|
||||
if cfg != nil {
|
||||
customerName = cfg.CustomerName
|
||||
}
|
||||
// An incomplete journal row = a cascade that stopped mid-way; the dialog renders it + Resume.
|
||||
var pending map[string]any
|
||||
if cr, jerr := s.store.LatestCustomerReset(customerID); jerr != nil {
|
||||
@@ -119,7 +139,8 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"customer_id": customerID,
|
||||
"customer_name": cfg.CustomerName,
|
||||
"customer_name": customerName,
|
||||
"has_config": cfg != nil, // false = GHOST (config already gone, residue alive)
|
||||
"hosts": hostRows,
|
||||
"host_count": inv.HostCount,
|
||||
"online_host_present": onlineBlocked, // leg 1 refuses; decommission the agent first
|
||||
@@ -132,6 +153,16 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
|
||||
"offsite_identifier": offsiteName,
|
||||
"pbs_tenancy_configured": s.tenantsync != nil,
|
||||
"pending_journal": pending,
|
||||
"residue_total": residue.Total(),
|
||||
"residue": map[string]int{
|
||||
"reports": residue.Reports,
|
||||
"app_telemetry": residue.AppTelemetry,
|
||||
"app_log_tails": residue.AppLogTails,
|
||||
"log_tail_requests": residue.LogTailRequests,
|
||||
"notification_prefs": residue.NotificationPrefs,
|
||||
"selfbind_tokens": residue.SelfBindTokens,
|
||||
"appliance_registrations": residue.ApplianceRegistrations,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,16 +176,26 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hosts, err := s.store.ListHostsByCustomer(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] delete %s: hosts: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
residue, err := s.store.CustomerResidue(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] delete %s: residue: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 404 means "there is nothing here", not "there is no config row". A GHOST customer — config
|
||||
// already deleted but the report stream (and therefore the Customers list, and therefore the
|
||||
// staleness alerts) still alive — MUST be deletable; before v0.70.0 it 404'd and no operator
|
||||
// surface could clear it.
|
||||
if cfg == nil && len(hosts) == 0 && residue.Total() == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Gates (all before any write) ────────────────────────────────────────────────────────────
|
||||
acks := readDeleteCascadeAcks(r)
|
||||
@@ -214,18 +255,36 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
_ = s.store.UpdateResetLeg(journalID, "hosts", "ok")
|
||||
|
||||
// ── Leg 2: the committed RESET sequence (external teardown FIRST, DB purge last) ─────────────
|
||||
// purgeEscrow=false — retained custody dies exactly once, in leg 3.
|
||||
if lerr := s.commitCustomerReset(ctx, cfg, journalID, false); lerr != nil {
|
||||
// purgeEscrow=false — retained custody dies exactly once, in leg 4.
|
||||
// cfg may be nil (ghost customer): the Hetzner + descriptor legs then record `skipped_no_config`.
|
||||
if lerr := s.commitCustomerReset(ctx, cfg, customerID, journalID, false); lerr != nil {
|
||||
s.logger.Printf("[ERROR] delete %s: cascade stopped at leg 2 (%s) — journal #%d retained", customerID, lerr.Leg, journalID)
|
||||
http.Error(w, "Delete incomplete at leg 2 (reset/"+lerr.Leg+"): "+lerr.Msg+" The host(s) are already deleted; re-run to resume.", lerr.Status)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Leg 3: the one true purge point — customer record + ALL escrow ciphertext ────────────────
|
||||
// ── Leg 3: residue — the report stream + the credential-bearing bindings ─────────────────────
|
||||
// This is what actually makes the customer DISAPPEAR: `GetCustomers()` builds the Customers list
|
||||
// (and the staleness/offsite checkers' work list) purely from `reports`, so before v0.70.0 a
|
||||
// fully deleted customer stayed visible AND kept emailing the operator. It runs BEFORE the record
|
||||
// purge on purpose — the customer_configs row is the identifying descriptor and goes LAST, and a
|
||||
// crash between the two legs leaves a ghost the cascade can now clean up on a re-run.
|
||||
if perr := s.store.PurgeCustomerResidue(customerID); perr != nil {
|
||||
_ = s.store.UpdateResetLeg(journalID, "residue", "failed")
|
||||
s.logger.Printf("[ERROR] delete %s: residue purge FAILED (journal #%d retained; re-run to resume): %v", customerID, journalID, perr)
|
||||
http.Error(w, "Delete incomplete at leg 3 (residue): the report stream and bindings could not be purged — the customer record is NOT yet removed; re-run to resume. ("+perr.Error()+")", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateResetLeg(journalID, "residue", "ok")
|
||||
s.logger.Printf("[INFO] delete %s: residue purged (reports=%d app_telemetry=%d app_log_tails=%d log_tail_requests=%d notif_prefs=%d selfbind_tokens=%d appliance_registrations=%d)",
|
||||
customerID, residue.Reports, residue.AppTelemetry, residue.AppLogTails, residue.LogTailRequests,
|
||||
residue.NotificationPrefs, residue.SelfBindTokens, residue.ApplianceRegistrations)
|
||||
|
||||
// ── Leg 4: the one true purge point — customer record + ALL escrow ciphertext ────────────────
|
||||
if derr := s.store.DeleteCustomerConfig(customerID); derr != nil {
|
||||
_ = s.store.UpdateResetLeg(journalID, "customer_delete", "failed")
|
||||
s.logger.Printf("[ERROR] delete %s: final purge FAILED (journal #%d retained; re-run to resume): %v", customerID, journalID, derr)
|
||||
http.Error(w, "Delete incomplete at leg 3 (purge): the customer record could not be removed — re-run to resume. ("+derr.Error()+")", http.StatusInternalServerError)
|
||||
http.Error(w, "Delete incomplete at leg 4 (purge): the customer record could not be removed — re-run to resume. ("+derr.Error()+")", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateResetLeg(journalID, "customer_delete", "ok")
|
||||
|
||||
Reference in New Issue
Block a user