feat(hub): v0.69.0 — customer DELETE is the guided full-teardown cascade (R-25b)

POST /configs/{id}/delete now runs hosts -> RESET -> purge behind three
acknowledgements, a typed customer-id, a stale-preview check and the
ONLINE-host refusal (every gate before any write, so a refusal has zero
side effects). The shallow handleConfigDelete is gone.

Two invariants are asserted, not just commented: ruling 3 is preserved by
construction (leg 2 never sees a host row) and retained escrow custody is
purged exactly once, in leg 3 (leg 2 runs with purgeEscrow=false).

handleCustomerReset's committed half was extracted as commitCustomerReset;
the standalone RESET path is byte-identical to v0.68.1 and its suite is
untouched. Five red-proofs run.

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:
2026-07-21 19:31:48 +02:00
parent f59aa97d0c
commit 61dbd870c3
13 changed files with 1087 additions and 208 deletions
+69
View File
@@ -1,5 +1,74 @@
# Felhom Hub — Changelog
## v0.69.0 — customer DELETE becomes the guided full-teardown cascade (R-25b) (2026-07-21)
Implements the operator ruling of 2026-07-21. The customer page carried two half-truths: **RESET**
was the real teardown but refused while any host row existed, and **DELETE** quietly removed only
the `customer_configs` row (plus escrow custody) — leaving the Hetzner Storage-Box repo, the PBS
namespace + credentials, the tunnel/zone plumbing and the host rows behind. DELETE now does what its
name promises.
### The cascade
`POST /configs/{id}/delete` (same route, new behaviour) runs three legs in a fixed order:
1. **hosts** — every host row deleted through host-delete's own rules: an **ONLINE host refuses the
whole cascade** (checked for every host up front, so it never half-runs) and escrow is **DEMOTED**
to retained custody, never destroyed.
2. **reset** — the committed RESET sequence verbatim (Hetzner FIRST → PBS → claim → descriptor → DB
purge), reached through the newly extracted `commitCustomerReset`.
3. **purge**`DeleteCustomerConfig`: the customer record **and all escrow ciphertext**.
Nothing here is newly destructive: the cascade only *sequences* three operations that already
existed, each keeping its own safety rules. Two invariants are load-bearing and asserted, not merely
commented:
- **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; leg 2 is called with
`purgeEscrow=false` so `PurgeCustomerResetDBState` leaves retained blobs alone; leg 3 is the one
true purge point (v0.60.1). Both are proven from *inside* leg 2 by a fake that observes store state
at the moment the PBS deprovision fires.
### Gates (all before any write — a refused delete has ZERO side effects)
Three separate acknowledgements (`ack_hosts`, `ack_reset`, `ack_purge`, each must be exactly `1`),
the **typed customer-id**, a **stale-preview** check (the acknowledged host count must still match
live — otherwise 409 "re-open the dialog"), and the ONLINE-host refusal. No force flag, no skip flag,
no partial-run downgrade anywhere in this path.
### Resume
A failed leg retains the journal row (`customer_resets`, per-leg status) and the HTTP error **names
the leg**. Re-opening the dialog renders the incomplete journal and offers **Resume**; a re-run is
idempotent (leg 1 is a no-op once the hosts are gone). The acknowledgements are **not** cached across
attempts — a resume passes every gate again.
### UI
Danger zone → **Delete customer…** opens a guided dialog: live inventory panel (hosts by name +
status, offsite repository identifier, PBS namespace, custody state), the three consequence
checkboxes, the typed customer-id field, one submit. Mid-cascade failures render the journal state.
The client-side checks are convenience — every gate is enforced server-side.
### Refactor (standalone RESET behaviour unchanged)
`handleCustomerReset`'s committed half became `commitCustomerReset(ctx, cfg, resetID, purgeEscrow)`,
returning a `resetLegError` (leg name + status + the exact operator-facing message). The standalone
path is byte-identical to v0.68.1: same order, same leg names, same messages, same status codes; its
suite is untouched and green. The shallow `handleConfigDelete` is **gone** — do not reintroduce a
shallow delete path.
### Tests
New `internal/web/customer_delete_test.go`: happy-path leg ORDER (observed from inside leg 2), nine
fail-closed gate cases each asserting zero mutations *and* zero external calls *and* no journal row,
resume-after-external-failure (custody + customer survive the failure, then converge), resume is
still gated, the `purgeEscrow` flag's custody semantics, and a preview test asserting the inventory
names real things and leaks no secret. **Five red-proofs run** (ack gate, stale-preview gate,
ONLINE-host gate, leg order inverted, `purgeEscrow=true`) — all failed red with the wrong value
visible, then restored. Full suite green.
## v0.68.1 — fix the Configuration page layout broken by the wrapper-sha field (2026-07-21)
The v0.68.0 wrapper-sha256 row wrapped itself in a `<div>`. The artifacts **`<form>` IS the CSS
+5 -12
View File
@@ -833,18 +833,11 @@ func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, cus
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther)
}
// handleConfigDelete deletes a customer config.
func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) {
if err := s.store.DeleteCustomerConfig(customerID); err != nil {
s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Customer config deleted: %s", customerID)
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
}
// The shallow customer DELETE that used to live here (a bare DeleteCustomerConfig behind a native
// confirm) was REPLACED in v0.69.0 by the guided full-teardown cascade — see web/customer_delete.go
// (R-25b). The route is unchanged (POST /configs/{id}/delete); what changed is that it now tears the
// hosts, the offsite repo, the PBS namespace and the tunnel/zone down before purging the record,
// behind three acknowledgements and a typed customer-id. Do NOT reintroduce a shallow delete path.
// handleConfigPreview returns the generated YAML for a customer config.
func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) {
+247
View File
@@ -0,0 +1,247 @@
package web
// Customer DELETE cascade (v0.69.0, R-25b — operator ruling 2026-07-21).
//
// Before v0.69.0 the customer page carried two half-truths: RESET was the real teardown but REFUSED
// while any host row existed, and DELETE quietly removed only the customer_configs row (plus escrow
// custody) — leaving the Hetzner Storage-Box repo, the PBS namespace + credentials, the tunnel/zone
// plumbing and the host rows themselves behind. The ruling makes DELETE what its name promises: ONE
// guided flow that shows exactly what exists, takes THREE explicit acknowledgements plus the typed
// customer-id, then runs the full teardown in the safe order:
//
// leg 1 hosts — every host row deleted through the SAME service path as a manual host delete
// (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
//
// 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:
//
// - 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);
// 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).
//
// 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.
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
// deleteCascadeAcks is the three-acknowledgement gate. Every one is REQUIRED — there is no force or
// skip flag anywhere in this file (a missing ack is a refusal, never a downgrade to a partial run).
type deleteCascadeAcks struct {
Hosts bool // "N host(s) will be deleted — recovery-key custody is demoted, not destroyed"
Reset bool // "the customer will be RESET — offsite repo DESTROYED, PBS revoked, tunnel/zone removed"
Purge bool // "the customer record and ALL escrow ciphertext are PURGED — unrecoverable"
}
func readDeleteCascadeAcks(r *http.Request) deleteCascadeAcks {
return deleteCascadeAcks{
Hosts: r.FormValue("ack_hosts") == "1",
Reset: r.FormValue("ack_reset") == "1",
Purge: r.FormValue("ack_purge") == "1",
}
}
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).
// 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) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil {
s.logger.Printf("[ERROR] delete preview %s: %v", customerID, err)
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)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
hosts, err := s.store.ListHostsByCustomer(customerID)
if err != nil {
s.logger.Printf("[ERROR] delete preview %s: hosts: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
hostRows := make([]map[string]any, 0, len(hosts))
onlineBlocked := false
for i := range hosts {
status := s.hostStatus(hosts[i].LastReportAt)
if status == "ok" {
onlineBlocked = true
}
hostRows = append(hostRows, map[string]any{
"host_id": hosts[i].HostID,
"status": status,
"online": status == "ok",
})
}
offsiteEnabled, offsiteType := offsiteChoice(cfg.ConfigJSON)
offsiteName := ""
if offsiteEnabled && s.offsite != nil {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
if n, oerr := s.offsite.OffsiteIdentifier(ctx, customerID, offsiteType); oerr != nil {
s.logger.Printf("[WARN] delete preview %s: offsite identifier lookup: %v", customerID, oerr)
} else {
offsiteName = n
}
}
// 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 {
s.logger.Printf("[WARN] delete preview %s: journal read: %v", customerID, jerr)
} else if cr != nil && cr.CompletedAt == nil {
pending = map[string]any{
"id": cr.ID,
"started_at": cr.StartedAt.UTC().Format(time.RFC3339),
"legs": cr.Legs,
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"customer_id": customerID,
"customer_name": cfg.CustomerName,
"hosts": hostRows,
"host_count": inv.HostCount,
"online_host_present": onlineBlocked, // leg 1 refuses; decommission the agent first
"superseded_blobs": inv.SupersededBlobs,
"dr_recipe_present": inv.DRRecipePresent,
"one_time_secret": inv.OneTimeSecretPresent,
"claim_present": inv.ClaimPresent,
"offsite_enabled": offsiteEnabled,
"offsite_type": offsiteType,
"offsite_identifier": offsiteName,
"pbs_tenancy_configured": s.tenantsync != nil,
"pending_journal": pending,
})
}
// handleCustomerDelete — POST /configs/{id}/delete. The guided full-teardown cascade. EVERY gate is
// checked before ANY write or external call: a refused delete leaves ZERO side effects (no host
// deleted, no journal row opened, no external call made, no config row touched).
func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, customerID string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil {
s.logger.Printf("[ERROR] delete %s: %v", customerID, err)
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
}
// ── Gates (all before any write) ────────────────────────────────────────────────────────────
acks := readDeleteCascadeAcks(r)
if !acks.complete() {
s.logger.Printf("[WARN] delete %s REFUSED: acknowledgements incomplete (hosts=%t reset=%t purge=%t)",
customerID, acks.Hosts, acks.Reset, acks.Purge)
http.Error(w, "Delete refused: all three acknowledgements are required — nothing was deleted.", http.StatusBadRequest)
return
}
if strings.TrimSpace(r.FormValue("confirm_id")) != customerID {
s.logger.Printf("[WARN] delete %s REFUSED: typed customer-id mismatch", customerID)
http.Error(w, "Delete refused: the typed customer-id does not match — nothing was deleted.", http.StatusBadRequest)
return
}
// Stale-preview gate: the operator acknowledged a specific host count. If the fleet changed
// between opening the dialog and submitting, the acknowledgement no longer describes reality.
if expect := strings.TrimSpace(r.FormValue("expect_hosts")); expect == "" || expect != strconv.Itoa(len(hosts)) {
s.logger.Printf("[WARN] delete %s REFUSED: stale preview (acknowledged %q host(s), live %d)", customerID, expect, len(hosts))
http.Error(w, "Delete refused: the inventory changed since the dialog was opened — re-open it and confirm again. Nothing was deleted.", http.StatusConflict)
return
}
// Leg 1 keeps host-delete's own safety rule: an ONLINE host is never deleted (a live agent would
// receive 401s permanently). Checked for EVERY host up front, so the cascade never half-runs.
for i := range hosts {
if s.hostStatus(hosts[i].LastReportAt) == "ok" {
s.logger.Printf("[WARN] delete %s REFUSED: host %s is ONLINE", customerID, hosts[i].HostID)
http.Error(w, "Delete refused: host "+hosts[i].HostID+" is ONLINE. Decommission the box first — the cascade never deletes a live host.", http.StatusConflict)
return
}
}
// ── Committed. Detached ctx: once teardown starts it must run to a clean journal state ───────
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Minute)
defer cancel()
// escrow_acked=true on the journal row: ack #3 carries the ruling-1 custody-destruction
// acknowledgement. The PURGE itself is leg 3's, not the RESET sequence's (see the file header).
journalID, err := s.store.StartCustomerReset(customerID, true)
if err != nil {
s.logger.Printf("[ERROR] delete %s: open journal: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] customer DELETE cascade started for %s (journal #%d, %d host(s))", customerID, journalID, len(hosts))
// ── Leg 1: hosts (demotion, never destruction) ───────────────────────────────────────────────
for i := range hosts {
hostID := hosts[i].HostID
if derr := s.store.DeleteHost(hostID, true); derr != nil {
_ = s.store.UpdateResetLeg(journalID, "hosts", "failed")
s.logger.Printf("[ERROR] delete %s: host %s delete FAILED (journal #%d retained; re-run to resume): %v", customerID, hostID, journalID, derr)
http.Error(w, "Delete incomplete at leg 1 (hosts): removing host "+hostID+" failed — nothing else was touched; re-run to resume. ("+derr.Error()+")", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] delete %s: host %s deleted (escrow DEMOTED to retained custody)", customerID, hostID)
}
_ = 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 {
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 ────────────────
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)
return
}
_ = s.store.UpdateResetLeg(journalID, "customer_delete", "ok")
if ferr := s.store.FinishCustomerReset(journalID); ferr != nil {
s.logger.Printf("[WARN] delete %s: journal finish stamp failed (state is complete): %v", customerID, ferr)
}
// Audit event SURVIVES the delete (events are never wiped — the audit trail outlives every
// lifecycle tier, and the customer_configs row is gone by now, which is fine: events are keyed
// by customer_id, not by a foreign key).
msg := "Ügyfél TÖRLÉSE (teljes lebontás): host(ok) törölve, offsite tároló és PBS névtér megsemmisítve, majd az ügyfélrekord és a teljes helyreállítási-kulcs letét véglegesen törölve. Visszafordíthatatlan."
if _, eerr := s.store.SaveEvent(customerID, "customer_deleted", "critical", msg, "", "hub"); eerr != nil {
s.logger.Printf("[WARN] delete %s: save audit event: %v", customerID, eerr)
}
s.logger.Printf("[INFO] customer DELETE cascade COMPLETE for %s (journal #%d) — full teardown", customerID, journalID)
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
}
+421
View File
@@ -0,0 +1,421 @@
package web
// Customer DELETE cascade (v0.69.0, R-25b) — the load-bearing contracts:
//
// A) Happy cascade: the legs run in the ORDER hosts → reset → purge. Proven from inside leg 2 (the
// PBS deprovision callback): at that instant the host rows are ALREADY gone (leg 1 done) and the
// customer row is STILL there (leg 3 not started). Ruling 3 — RESET never runs while a host
// exists — is therefore preserved BY CONSTRUCTION and asserted, not merely commented.
// B) Gates fail-closed: any missing ack / typed-id mismatch / stale host count / ONLINE host →
// 4xx and ZERO mutations (no host deleted, no journal row, no external call, no config touched).
// C) Resume: a leg-2 external failure retains the journal and names the leg; the hosts are already
// gone and the customer + custody SURVIVE; a re-run resumes and completes without re-demoting.
// D) Standalone RESET is untouched (its own suite stays green; here: the cascade's purgeEscrow=false
// does not change what a standalone RESET purges).
// E) Custody: leg 1 DEMOTES (never purges); the RESET leg with purgeEscrow=false leaves the retained
// blobs alone; the purge happens exactly once, in leg 3 (DeleteCustomerConfig).
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
)
// errCascadeBoom is the injected external-teardown failure (scenario C).
var errCascadeBoom = errors.New("pbs unreachable")
// orderTenancy is a tenancyProvisioner that runs a callback at the exact moment leg 2's PBS
// deprovision fires — the observation point that proves the cascade's leg ORDER.
type orderTenancy struct {
deprovisionCalls int
err error
onDeprovision func()
}
func (f *orderTenancy) Provision(ctx context.Context, customerID string) (*tenantsync.Result, error) {
return nil, nil
}
func (f *orderTenancy) Reissue(ctx context.Context, customerID string) (*tenantsync.Result, error) {
return nil, nil
}
func (f *orderTenancy) Deprovision(ctx context.Context, customerID string) (bool, error) {
f.deprovisionCalls++
if f.onDeprovision != nil {
f.onDeprovision()
}
if f.err != nil {
return false, f.err
}
return true, nil
}
// seedDeletable seeds a customer with a full footprint INCLUDING one OFFLINE host that carries a
// current escrow blob plus one already-retained (superseded) blob. Returns the host id.
func seedDeletable(t *testing.T, st *store.Store, customerID string) string {
t.Helper()
cfgJSON := `{"offsite":{"enabled":true,"type":"shared","host":"u1-sub3.your-storagebox.de","user":"u1-sub3","port":23,"repo_path":"/home/felhom","quota_gb":100,"host_fingerprint":"SHA256:abc"}}`
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: customerID, CustomerName: "Teszt", Domain: customerID + ".example",
Email: "t@example.com", RetrievalPassword: "pw", APIKey: "capi-" + customerID, ConfigJSON: cfgJSON,
}); err != nil {
t.Fatalf("seed config: %v", err)
}
hostID := customerID + "-01"
long := time.Now().Add(-72 * time.Hour) // far past the stale threshold → status "down", deletable
if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "hapi-" + hostID, LastReportAt: &long}); err != nil {
t.Fatalf("seed host: %v", err)
}
if _, err := st.SaveHostEscrow(hostID, []byte("blobA"), "fpA", "posture", "2026-01-01T00:00:00Z", "shaA"); err != nil {
t.Fatalf("seed escrow A: %v", err)
}
// A second save supersedes A → one RETAINED blob + one CURRENT blob before the cascade runs.
if _, err := st.SaveHostEscrow(hostID, []byte("blobB"), "fpB", "posture", "2026-01-02T00:00:00Z", "shaB"); err != nil {
t.Fatalf("seed escrow B: %v", err)
}
if err := st.SaveOneTimeSecret(customerID, "one-time-pw"); err != nil {
t.Fatalf("seed one-time secret: %v", err)
}
if err := st.SaveDRRecipeHostHalf(customerID, hostID, 1, []byte("half")); err != nil {
t.Fatalf("seed dr recipe: %v", err)
}
if _, err := st.RotateClaimCode(customerID, "$2a$10$hashhashhashhashhashha"); err != nil {
t.Fatalf("seed claim: %v", err)
}
return hostID
}
func cascadeForm(customerID string, hostCount int) url.Values {
return url.Values{
"ack_hosts": {"1"},
"ack_reset": {"1"},
"ack_purge": {"1"},
"confirm_id": {customerID},
"expect_hosts": {strconv.Itoa(hostCount)},
}
}
func postDelete(t *testing.T, s *Server, customerID string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/configs/"+customerID+"/delete", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleCustomerDelete(rr, req, customerID)
return rr
}
func hostCount(t *testing.T, st *store.Store, customerID string) int {
t.Helper()
hosts, err := st.ListHostsByCustomer(customerID)
if err != nil {
t.Fatalf("list hosts: %v", err)
}
return len(hosts)
}
func configPresent(t *testing.T, st *store.Store, customerID string) bool {
t.Helper()
cfg, err := st.GetCustomerConfig(customerID)
if err != nil {
t.Fatalf("get config: %v", err)
}
return cfg != nil
}
// ── Scenario A: the happy cascade, and the leg ORDER it must run in ─────────────────────────────
func TestDeleteCascade_HappyPath_LegOrder(t *testing.T) {
s, st := newTestServer(t)
hostID := seedDeletable(t, st, "acme")
var (
hostsAtResetLeg = -1
configAtResetLeg = false
custodyAtResetLeg = -1
)
fake := &orderTenancy{onDeprovision: func() {
hostsAtResetLeg = hostCount(t, st, "acme")
configAtResetLeg = configPresent(t, st, "acme")
custodyAtResetLeg = superseded(t, st, "acme")
}}
s.SetTenantSync(fake)
rr := postDelete(t, s, "acme", cascadeForm("acme", 1))
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != "/configs?flash=deleted" {
t.Errorf("Location = %q, want /configs?flash=deleted", loc)
}
// ORDER, observed from inside leg 2 — this is the assertion the whole cascade hangs on.
if fake.deprovisionCalls != 1 {
t.Fatalf("PBS deprovision calls = %d, want 1 (leg 2 must run)", fake.deprovisionCalls)
}
if hostsAtResetLeg != 0 {
t.Errorf("at the RESET leg the customer still had %d host(s) — leg 1 must complete FIRST "+
"(ruling 3: the RESET sequence never runs while a host row exists)", hostsAtResetLeg)
}
if !configAtResetLeg {
t.Error("at the RESET leg the customer row was already gone — leg 3 must run LAST")
}
if custodyAtResetLeg == 0 {
t.Error("at the RESET leg the retained custody was already gone — leg 1 DEMOTES, it must never purge")
}
// Final state: hosts gone, customer gone, ALL custody gone (leg 3 is the one true purge point).
if n := hostCount(t, st, "acme"); n != 0 {
t.Errorf("hosts after cascade = %d, want 0", n)
}
if configPresent(t, st, "acme") {
t.Error("customer row survived the cascade")
}
if esc, err := st.GetHostEscrow(hostID); err != nil || esc != nil {
t.Errorf("current escrow survived the cascade (err=%v, row=%v)", err, esc != nil)
}
if n := superseded(t, st, "acme"); n != 0 {
t.Errorf("retained escrow blobs after cascade = %d, want 0 (leg 3 purges custody)", n)
}
// Journal: every leg stamped, completion stamped.
cr, err := st.LatestCustomerReset("acme")
if err != nil || cr == nil {
t.Fatalf("journal: %v (row=%v)", err, cr != nil)
}
if cr.CompletedAt == nil {
t.Error("journal not stamped complete")
}
for leg, want := range map[string]string{"hosts": "ok", "pbs": "ok", "db_purge": "ok", "customer_delete": "ok"} {
if got := cr.Legs[leg]; got != want {
t.Errorf("journal leg %q = %q, want %q (legs=%v)", leg, got, want, cr.Legs)
}
}
// The audit event SURVIVES the customer row (events are keyed by id, never wiped).
evs, err := st.GetRecentEvents("acme", 10)
if err != nil {
t.Fatalf("events: %v", err)
}
found := false
for _, e := range evs {
if e.EventType == "customer_deleted" {
found = true
}
}
if !found {
t.Errorf("no customer_deleted audit event survived the cascade (events=%d)", len(evs))
}
}
// ── Scenario B: every gate fails closed, with ZERO mutations ────────────────────────────────────
func TestDeleteCascade_GatesFailClosed(t *testing.T) {
base := func() url.Values { return cascadeForm("acme", 1) }
cases := []struct {
name string
mutate func(url.Values)
online bool
wantCode int
}{
{"missing ack 1 (hosts)", func(f url.Values) { f.Del("ack_hosts") }, false, http.StatusBadRequest},
{"missing ack 2 (reset)", func(f url.Values) { f.Del("ack_reset") }, false, http.StatusBadRequest},
{"missing ack 3 (purge)", func(f url.Values) { f.Del("ack_purge") }, false, http.StatusBadRequest},
{"ack sent as something other than 1", func(f url.Values) { f.Set("ack_purge", "yes") }, false, http.StatusBadRequest},
{"typed id mismatch", func(f url.Values) { f.Set("confirm_id", "acm") }, false, http.StatusBadRequest},
{"typed id absent", func(f url.Values) { f.Del("confirm_id") }, false, http.StatusBadRequest},
{"stale preview (host count moved)", func(f url.Values) { f.Set("expect_hosts", "0") }, false, http.StatusConflict},
{"stale preview (count absent)", func(f url.Values) { f.Del("expect_hosts") }, false, http.StatusConflict},
{"ONLINE host", func(f url.Values) {}, true, http.StatusConflict},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, st := newTestServer(t)
hostID := seedDeletable(t, st, "acme")
if tc.online {
// A just-saved report → status "ok" (online), the same way host-delete's own gate sees it.
if err := st.SaveHostReport(hostID, "acme", []byte(`{}`), store.HostReportDenorm{}); err != nil {
t.Fatalf("make host online: %v", err)
}
}
fake := &orderTenancy{}
s.SetTenantSync(fake)
custodyBefore := superseded(t, st, "acme")
f := base()
tc.mutate(f)
rr := postDelete(t, s, "acme", f)
if rr.Code != tc.wantCode {
t.Errorf("status = %d, want %d: %s", rr.Code, tc.wantCode, rr.Body.String())
}
// ZERO mutations — the whole point of a fail-closed gate.
if n := hostCount(t, st, "acme"); n != 1 {
t.Errorf("hosts = %d, want 1 (a refused delete deletes NOTHING)", n)
}
if !configPresent(t, st, "acme") {
t.Error("customer row was deleted by a REFUSED delete")
}
if esc, _ := st.GetHostEscrow(hostID); esc == nil {
t.Error("current escrow was touched by a REFUSED delete")
}
if n := superseded(t, st, "acme"); n != custodyBefore {
t.Errorf("retained custody = %d, want %d (untouched)", n, custodyBefore)
}
if fake.deprovisionCalls != 0 {
t.Errorf("PBS deprovision called %d time(s) on a REFUSED delete — no external call may fire", fake.deprovisionCalls)
}
if cr, _ := st.LatestCustomerReset("acme"); cr != nil {
t.Errorf("a journal row was opened by a REFUSED delete (#%d) — gates run before any write", cr.ID)
}
})
}
}
// ── Scenario C: a mid-cascade external failure is resumable ─────────────────────────────────────
func TestDeleteCascade_ResumesAfterExternalFailure(t *testing.T) {
s, st := newTestServer(t)
hostID := seedDeletable(t, st, "acme")
fake := &orderTenancy{err: errCascadeBoom}
s.SetTenantSync(fake)
rr := postDelete(t, s, "acme", cascadeForm("acme", 1))
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502: %s", rr.Code, rr.Body.String())
}
if body := rr.Body.String(); !strings.Contains(body, "leg 2") || !strings.Contains(body, "pbs") {
t.Errorf("the error must NAME the failed leg, got %q", body)
}
// Leg 1 completed; legs 2-3 did not. The customer and ALL custody SURVIVE.
if n := hostCount(t, st, "acme"); n != 0 {
t.Errorf("hosts = %d, want 0 (leg 1 completed before the failure)", n)
}
if !configPresent(t, st, "acme") {
t.Fatal("the customer row was purged despite a failed external leg — the purge must be withheld")
}
custodyAfterFailure := superseded(t, st, "acme")
if custodyAfterFailure == 0 {
t.Error("retained custody was destroyed by a FAILED cascade — leg 3 is the only purge point")
}
cr, err := st.LatestCustomerReset("acme")
if err != nil || cr == nil {
t.Fatalf("journal retained? err=%v row=%v", err, cr != nil)
}
if cr.CompletedAt != nil {
t.Error("journal stamped complete despite a failed leg")
}
if cr.Legs["hosts"] != "ok" || cr.Legs["pbs"] != "failed" {
t.Errorf("journal legs = %v, want hosts=ok pbs=failed", cr.Legs)
}
// ── Re-run: resumes at leg 2, does NOT re-delete/re-demote hosts, completes. ────────────────
fake.err = nil
rr2 := postDelete(t, s, "acme", cascadeForm("acme", 0)) // the live host count is now 0
if rr2.Code != http.StatusSeeOther {
t.Fatalf("resume status = %d, want 303: %s", rr2.Code, rr2.Body.String())
}
if fake.deprovisionCalls != 2 {
t.Errorf("PBS deprovision calls = %d, want 2 (failed + resumed)", fake.deprovisionCalls)
}
if configPresent(t, st, "acme") {
t.Error("customer row survived the resumed cascade")
}
if esc, _ := st.GetHostEscrow(hostID); esc != nil {
t.Error("current escrow survived the resumed cascade")
}
if n := superseded(t, st, "acme"); n != 0 {
t.Errorf("retained custody after resume = %d, want 0", n)
}
cr2, _ := st.LatestCustomerReset("acme")
if cr2 == nil || cr2.CompletedAt == nil {
t.Error("the resumed run did not stamp a completed journal")
}
}
// A resumed run must still pass every gate — the acknowledgements are not cached across attempts.
func TestDeleteCascade_ResumeStillGated(t *testing.T) {
s, st := newTestServer(t)
seedDeletable(t, st, "acme")
fake := &orderTenancy{err: errCascadeBoom}
s.SetTenantSync(fake)
if rr := postDelete(t, s, "acme", cascadeForm("acme", 1)); rr.Code != http.StatusBadGateway {
t.Fatalf("first run status = %d, want 502", rr.Code)
}
fake.err = nil
f := cascadeForm("acme", 0)
f.Del("ack_purge")
if rr := postDelete(t, s, "acme", f); rr.Code != http.StatusBadRequest {
t.Fatalf("resume without ack #3 status = %d, want 400", rr.Code)
}
if !configPresent(t, st, "acme") {
t.Error("an ungated resume purged the customer")
}
}
// ── Scenario E: custody is purged exactly once, in leg 3 ────────────────────────────────────────
// The cascade calls commitCustomerReset with purgeEscrow=FALSE so the retained custody survives the
// RESET leg and dies only in DeleteCustomerConfig. RED-PROOF: pass true here and the first assertion
// fails with 0 retained blobs — i.e. the purge would have moved into leg 2.
func TestCommitCustomerReset_PurgeEscrowFlagGovernsCustody(t *testing.T) {
s, st := newTestServer(t)
hostID := seedDeletable(t, st, "acme")
if err := st.DeleteHost(hostID, true); err != nil { // leg 1: DEMOTE
t.Fatalf("demote: %v", err)
}
if n := superseded(t, st, "acme"); n != 2 {
t.Fatalf("retained blobs after demotion = %d, want 2 (leg 1 demotes, never purges)", n)
}
cfg, _ := st.GetCustomerConfig("acme")
id, err := st.StartCustomerReset("acme", true)
if err != nil {
t.Fatalf("journal: %v", err)
}
if lerr := s.commitCustomerReset(context.Background(), cfg, id, false); lerr != nil {
t.Fatalf("commitCustomerReset: %v", lerr)
}
if n := superseded(t, st, "acme"); n != 2 {
t.Errorf("retained blobs after the RESET leg = %d, want 2 — the cascade's RESET leg must NOT purge custody", n)
}
// Leg 3 is the one true purge point.
if err := st.DeleteCustomerConfig("acme"); err != nil {
t.Fatalf("leg 3: %v", err)
}
if n := superseded(t, st, "acme"); n != 0 {
t.Errorf("retained blobs after leg 3 = %d, want 0", n)
}
}
// ── Preview: the dialog's inventory names the real things (never a secret) ──────────────────────
func TestDeleteCascadePreview_Inventory(t *testing.T) {
s, st := newTestServer(t)
hostID := seedDeletable(t, st, "acme")
s.SetTenantSync(&orderTenancy{})
req := httptest.NewRequest("GET", "/configs/acme/delete", nil)
rr := httptest.NewRecorder()
s.handleCustomerDeletePreview(rr, req, "acme")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{hostID, `"host_count":1`, `"online_host_present":false`,
`"offsite_enabled":true`, `"pbs_tenancy_configured":true`, `"claim_present":true`, `"superseded_blobs":1`} {
if !strings.Contains(body, want) {
t.Errorf("preview missing %s\nbody: %s", want, body)
}
}
for _, secret := range []string{"one-time-pw", "capi-acme", "hapi-", "blobA", "blobB"} {
if strings.Contains(body, secret) {
t.Errorf("preview leaked %q — counts and names only", secret)
}
}
}
+4 -1
View File
@@ -263,8 +263,11 @@ func TestCustomerActions_RedirectAnchors(t *testing.T) {
{"regen-password lands on Setup (its card lives there)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigRegenPassword(rr, postForm("/configs/c1/regen-password", ""), "c1")
}, false, "/customers/c1?flash=password_regenerated#tab=setup"},
// v0.69.0 (R-25b): the shallow delete became the guided cascade — same route, same
// anchor-free landing, but the three acks + typed id + host-count are now required.
{"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigDelete(rr, postForm("/configs/c1/delete", ""), "c1")
s.handleCustomerDelete(rr, postForm("/configs/c1/delete",
"ack_hosts=1&ack_reset=1&ack_purge=1&confirm_id=c1&expect_hosts=0"), "c1")
}, false, "/configs?flash=deleted"},
}
+109 -72
View File
@@ -7,6 +7,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Customer RESET (v0.61.0) — the middle lifecycle tier (host delete < RESET < customer DELETE). One
@@ -135,80 +136,11 @@ func (s *Server) handleCustomerReset(w http.ResponseWriter, r *http.Request, cus
}
s.logger.Printf("[INFO] customer RESET started for %s (journal #%d, escrow_ack=%t)", customerID, resetID, escrowAck)
// Leg: Hetzner offsite (repo DATA destroyed). Only when the customer chose an offsite tier.
offsiteEnabled, offsiteType := offsiteChoice(cfg.ConfigJSON)
if offsiteEnabled && s.offsite != nil {
if derr := s.offsite.Deprovision(ctx, customerID, offsiteType); derr != nil {
_ = s.store.UpdateResetLeg(resetID, "hetzner", "failed")
s.logger.Printf("[ERROR] reset %s: hetzner deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
http.Error(w, "Reset incomplete: the offsite (Hetzner) teardown failed — nothing was purged; re-run to resume. ("+derr.Error()+")", http.StatusBadGateway)
return
}
_ = s.store.UpdateResetLeg(resetID, "hetzner", "ok")
s.logger.Printf("[INFO] reset %s: offsite deprovisioned (repo data destroyed)", customerID)
} else {
_ = s.store.UpdateResetLeg(resetID, "hetzner", "skipped")
}
// Leg: PBS DR tenancy (namespace + backups + token destroyed). The namespace is customer-id-keyed
// and survives host deletion, so it is torn down here by id; idempotent when absent.
if s.tenantsync != nil {
if _, derr := s.tenantsync.Deprovision(ctx, customerID); derr != nil {
_ = s.store.UpdateResetLeg(resetID, "pbs", "failed")
s.logger.Printf("[ERROR] reset %s: PBS deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
http.Error(w, "Reset incomplete: the PBS namespace teardown failed — nothing was purged; re-run to resume. ("+derr.Error()+")", http.StatusBadGateway)
return
}
_ = s.store.UpdateResetLeg(resetID, "pbs", "ok")
s.logger.Printf("[INFO] reset %s: PBS tenancy deprovisioned", customerID)
} else {
_ = s.store.UpdateResetLeg(resetID, "pbs", "skipped")
}
// All external legs are ok — now the DB side (publish-last, one leg at a time so the journal
// records where a mid-purge crash stopped). Claim → unclaimed (fresh code next onboarding).
if s.claimEngine != nil {
if cerr := s.claimEngine.ResetToUnclaimed(cfg); cerr != nil {
_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
s.logger.Printf("[ERROR] reset %s: claim reset failed: %v", customerID, cerr)
http.Error(w, "Reset incomplete: the claim reset failed — re-run to resume. ("+cerr.Error()+")", http.StatusInternalServerError)
return
}
} else if derr := s.store.DeleteClaim(customerID); derr != nil { // no engine wired: use the store primitive directly
_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
s.logger.Printf("[ERROR] reset %s: claim delete failed: %v", customerID, derr)
http.Error(w, "Internal error", http.StatusInternalServerError)
// Standalone RESET purges the retained custody itself, gated by the ack it just checked.
if lerr := s.commitCustomerReset(ctx, cfg, resetID, escrowAck); lerr != nil {
http.Error(w, lerr.Msg, lerr.Status)
return
}
_ = s.store.UpdateResetLeg(resetID, "claim", "ok")
// Clear the provisioned offsite descriptor (keep the tier CHOICE, drop provisioned host/user/repo/
// fingerprint) and re-save → ConfigVersion bump. Identity + basic config survive intact.
newConfigJSON, cerr := offsite.ClearProvisionedDescriptor(cfg.ConfigJSON)
if cerr != nil {
_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
s.logger.Printf("[ERROR] reset %s: clear offsite descriptor: %v", customerID, cerr)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
cfg.ConfigJSON = newConfigJSON
if serr := s.store.SaveCustomerConfig(cfg); serr != nil {
_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
s.logger.Printf("[ERROR] reset %s: save cleared config: %v", customerID, serr)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
_ = s.store.UpdateResetLeg(resetID, "descriptor", "ok")
// DB purge LAST: retained escrow (ack-gated), one-time secret, DR recipe, log bundles.
if perr := s.store.PurgeCustomerResetDBState(customerID, escrowAck); perr != nil {
_ = s.store.UpdateResetLeg(resetID, "db_purge", "failed")
s.logger.Printf("[ERROR] reset %s: DB purge failed: %v", customerID, perr)
http.Error(w, "Reset incomplete: the DB purge failed — re-run to resume. ("+perr.Error()+")", http.StatusInternalServerError)
return
}
_ = s.store.UpdateResetLeg(resetID, "db_purge", "ok")
if ferr := s.store.FinishCustomerReset(resetID); ferr != nil {
s.logger.Printf("[WARN] reset %s: journal finish stamp failed (state is complete): %v", customerID, ferr)
}
@@ -231,3 +163,108 @@ func (s *Server) handleCustomerReset(w http.ResponseWriter, r *http.Request, cus
s.bumpIntent(customerID) // wake any holding wait so a lingering box sees the cleared state promptly
http.Redirect(w, r, "/customers/"+customerID+"?flash=reset_done", http.StatusSeeOther)
}
// resetLegError names the leg of the committed RESET sequence that failed, carrying the exact
// operator-facing message + HTTP status the standalone RESET handler has always returned. The DELETE
// cascade (v0.69.0, R-25b) reuses the same values so a mid-cascade failure names its leg too.
type resetLegError struct {
Leg string // journal leg name: hetzner | pbs | claim | descriptor | db_purge
Status int
Msg string
Err error
}
func (e *resetLegError) Error() string {
if e.Err != nil {
return e.Leg + ": " + e.Err.Error()
}
return e.Leg
}
// commitCustomerReset runs the COMMITTED reset sequence against an already-gated customer: external
// teardown FIRST (Hetzner, PBS), then the DB side (claim → descriptor → purge), each leg stamped into
// the journal so a failed run is resumable. It deliberately owns no gate, no audit event, no journal
// open/close and no redirect — those belong to the caller, because the two callers differ there:
//
// - standalone RESET (v0.61.0): purgeEscrow = the operator's escrow_ack; the customer survives.
// - DELETE cascade (v0.69.0, R-25b): purgeEscrow = FALSE — retained custody is purged exactly ONCE,
// in the cascade's final leg (DeleteCustomerConfig, the one true purge point). Purging here too
// would split the single custody-destruction point across two legs.
//
// Behaviour for the standalone caller is byte-identical to v0.68.1 (same order, same leg names, same
// messages, same status codes).
func (s *Server) commitCustomerReset(ctx context.Context, cfg *store.CustomerConfig, resetID int64, purgeEscrow bool) *resetLegError {
customerID := cfg.CustomerID
// Leg: Hetzner offsite (repo DATA destroyed). Only when the customer chose an offsite tier.
offsiteEnabled, offsiteType := offsiteChoice(cfg.ConfigJSON)
if offsiteEnabled && s.offsite != nil {
if derr := s.offsite.Deprovision(ctx, customerID, offsiteType); derr != nil {
_ = s.store.UpdateResetLeg(resetID, "hetzner", "failed")
s.logger.Printf("[ERROR] reset %s: hetzner deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
return &resetLegError{Leg: "hetzner", Status: http.StatusBadGateway, Err: derr,
Msg: "Reset incomplete: the offsite (Hetzner) teardown failed — nothing was purged; re-run to resume. (" + derr.Error() + ")"}
}
_ = s.store.UpdateResetLeg(resetID, "hetzner", "ok")
s.logger.Printf("[INFO] reset %s: offsite deprovisioned (repo data destroyed)", customerID)
} else {
_ = s.store.UpdateResetLeg(resetID, "hetzner", "skipped")
}
// Leg: PBS DR tenancy (namespace + backups + token destroyed). The namespace is customer-id-keyed
// and survives host deletion, so it is torn down here by id; idempotent when absent.
if s.tenantsync != nil {
if _, derr := s.tenantsync.Deprovision(ctx, customerID); derr != nil {
_ = s.store.UpdateResetLeg(resetID, "pbs", "failed")
s.logger.Printf("[ERROR] reset %s: PBS deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
return &resetLegError{Leg: "pbs", Status: http.StatusBadGateway, Err: derr,
Msg: "Reset incomplete: the PBS namespace teardown failed — nothing was purged; re-run to resume. (" + derr.Error() + ")"}
}
_ = s.store.UpdateResetLeg(resetID, "pbs", "ok")
s.logger.Printf("[INFO] reset %s: PBS tenancy deprovisioned", customerID)
} else {
_ = s.store.UpdateResetLeg(resetID, "pbs", "skipped")
}
// All external legs are ok — now the DB side (publish-last, one leg at a time so the journal
// records where a mid-purge crash stopped). Claim → unclaimed (fresh code next onboarding).
if s.claimEngine != nil {
if cerr := s.claimEngine.ResetToUnclaimed(cfg); cerr != nil {
_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
s.logger.Printf("[ERROR] reset %s: claim reset failed: %v", customerID, cerr)
return &resetLegError{Leg: "claim", Status: http.StatusInternalServerError, Err: cerr,
Msg: "Reset incomplete: the claim reset failed — re-run to resume. (" + cerr.Error() + ")"}
}
} else if derr := s.store.DeleteClaim(customerID); derr != nil { // no engine wired: use the store primitive directly
_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
s.logger.Printf("[ERROR] reset %s: claim delete failed: %v", customerID, derr)
return &resetLegError{Leg: "claim", Status: http.StatusInternalServerError, Err: derr, Msg: "Internal error"}
}
_ = s.store.UpdateResetLeg(resetID, "claim", "ok")
// Clear the provisioned offsite descriptor (keep the tier CHOICE, drop provisioned host/user/repo/
// fingerprint) and re-save → ConfigVersion bump. Identity + basic config survive intact.
newConfigJSON, cerr := offsite.ClearProvisionedDescriptor(cfg.ConfigJSON)
if cerr != nil {
_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
s.logger.Printf("[ERROR] reset %s: clear offsite descriptor: %v", customerID, cerr)
return &resetLegError{Leg: "descriptor", Status: http.StatusInternalServerError, Err: cerr, Msg: "Internal error"}
}
cfg.ConfigJSON = newConfigJSON
if serr := s.store.SaveCustomerConfig(cfg); serr != nil {
_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
s.logger.Printf("[ERROR] reset %s: save cleared config: %v", customerID, serr)
return &resetLegError{Leg: "descriptor", Status: http.StatusInternalServerError, Err: serr, Msg: "Internal error"}
}
_ = s.store.UpdateResetLeg(resetID, "descriptor", "ok")
// DB purge LAST: retained escrow (ack-gated), one-time secret, DR recipe, log bundles.
if perr := s.store.PurgeCustomerResetDBState(customerID, purgeEscrow); perr != nil {
_ = s.store.UpdateResetLeg(resetID, "db_purge", "failed")
s.logger.Printf("[ERROR] reset %s: DB purge failed: %v", customerID, perr)
return &resetLegError{Leg: "db_purge", Status: http.StatusInternalServerError, Err: perr,
Msg: "Reset incomplete: the DB purge failed — re-run to resume. (" + perr.Error() + ")"}
}
_ = s.store.UpdateResetLeg(resetID, "db_purge", "ok")
return nil
}
+4 -2
View File
@@ -494,12 +494,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
// Customer DELETE cascade (v0.69.0, R-25b): GET returns the guided dialog's live inventory
// (hosts + offsite + PBS + custody + any incomplete journal), POST runs the full teardown.
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/delete")
if r.Method == http.MethodPost {
s.handleConfigDelete(w, r, customerID)
s.handleCustomerDelete(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
s.handleCustomerDeletePreview(w, r, customerID)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"):
customerID := strings.TrimPrefix(path, "/configs/")
@@ -816,7 +816,7 @@
Customer Info header — endpoints and confirm() handlers unchanged. -->
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently — and permanently removes the retained recovery-key custody (escrow blobs) for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it.</p>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
@@ -829,12 +829,109 @@
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline">
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer&hellip;</button>
</div>
<!-- Guided full-teardown cascade (v0.69.0, R-25b). The inventory panel is fetched from
GET /configs/{id}/delete; the three acknowledgements + typed customer-id are ALSO
enforced server-side (this is convenience, never the gate). -->
<div id="cust-del-box-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); border-radius: var(--radius); max-width: 52em;">
<p id="cust-del-inv-{{.CustomerID}}" style="margin: 0 0 0.75rem; font-size: 0.9em;">&hellip;</p>
<div id="cust-del-journal-{{.CustomerID}}" style="display: none; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--warn); font-size: 0.85em;"></div>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack1-{{.CustomerID}}">
<strong>1.</strong> <span id="cust-del-ack1-text-{{.CustomerID}}">The host(s) will be deleted</span> — recovery-key custody is <strong>demoted</strong> to retained custody, not destroyed.
</label>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
</label>
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
<strong>3.</strong> The customer record and <strong>ALL escrow ciphertext</strong> are <strong>PURGED</strong> — unrecoverable.
</label>
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Type the customer-id to confirm:</p>
<form method="POST" action="/configs/{{.CustomerID}}/delete" id="cust-del-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm" data-confirm="Delete configuration for {{.CustomerID}}? This cannot be undone.">Delete</button>
<input type="hidden" name="confirm_id" id="cust-del-cid-{{.CustomerID}}" value="">
<input type="hidden" name="ack_hosts" id="cust-del-h1-{{.CustomerID}}" value="">
<input type="hidden" name="ack_reset" id="cust-del-h2-{{.CustomerID}}" value="">
<input type="hidden" name="ack_purge" id="cust-del-h3-{{.CustomerID}}" value="">
<input type="hidden" name="expect_hosts" id="cust-del-exp-{{.CustomerID}}" value="">
<input type="text" id="cust-del-input-{{.CustomerID}}" placeholder="customer-id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" id="cust-del-go-{{.CustomerID}}" onclick="customerDeleteSubmit('{{.CustomerID}}')">Confirm &amp; delete everything</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-del-box-{{.CustomerID}}').style.display='none';">Cancel</button>
</form>
<p id="cust-del-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function customerDeleteOpen(cid) {
var box = document.getElementById('cust-del-box-' + cid);
var inv = document.getElementById('cust-del-inv-' + cid);
var go = document.getElementById('cust-del-go-' + cid);
var jr = document.getElementById('cust-del-journal-' + cid);
box.style.display = 'block';
inv.textContent = 'Loading inventory…';
jr.style.display = 'none';
document.getElementById('cust-del-input-' + cid).value = '';
document.getElementById('cust-del-err-' + cid).textContent = '';
document.getElementById('cust-del-exp-' + cid).value = '';
['ack1', 'ack2', 'ack3'].forEach(function(a) { document.getElementById('cust-del-' + a + '-' + cid).checked = false; });
go.disabled = false;
fetch('/configs/' + encodeURIComponent(cid) + '/delete')
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById('cust-del-exp-' + cid).value = String(d.host_count);
var hostNames = (d.hosts || []).map(function(h){ return h.host_id + ' (' + h.status + ')'; });
document.getElementById('cust-del-ack1-text-' + cid).textContent =
d.host_count + ' host(s) will be deleted' + (hostNames.length ? ': ' + hostNames.join(', ') : '');
var dies = [];
if (d.host_count) dies.push(d.host_count + ' host row(s)');
if (d.offsite_enabled) dies.push('offsite repository' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
if (d.pbs_tenancy_configured) dies.push('PBS namespace + backups + token');
if (d.dr_recipe_present) dies.push('DR recipe');
if (d.one_time_secret) dies.push('one-time password');
if (d.claim_present) dies.push('claim state');
dies.push('customer record');
var custody = d.superseded_blobs > 0
? d.superseded_blobs + ' retained escrow blob(s) + every current host escrow'
: 'every current host escrow';
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
'<strong>Survives:</strong> the audit event stream and the deletion provenance.';
if (d.online_host_present) {
inv.innerHTML += '<br><strong style="color: var(--crit)">Refused:</strong> a host is ONLINE. ' +
'Decommission the box first — the cascade never deletes a live host.';
go.disabled = true;
}
if (d.pending_journal) {
var legs = d.pending_journal.legs || {};
var parts = Object.keys(legs).map(function(k){ return k + '=' + legs[k]; });
jr.innerHTML = '<strong>An earlier cascade stopped mid-way</strong> (journal #' + d.pending_journal.id +
', started ' + d.pending_journal.started_at + '). Legs: ' + (parts.length ? parts.join(', ') : 'none recorded') +
'. Confirming again RESUMES it — completed legs are no-ops.';
jr.style.display = 'block';
document.getElementById('cust-del-go-' + cid).textContent = 'Confirm & resume teardown';
}
})
.catch(function(){ inv.textContent = 'Inventory unavailable — the server enforces every gate regardless.'; });
}
function customerDeleteSubmit(cid) {
var err = document.getElementById('cust-del-err-' + cid);
var a1 = document.getElementById('cust-del-ack1-' + cid).checked;
var a2 = document.getElementById('cust-del-ack2-' + cid).checked;
var a3 = document.getElementById('cust-del-ack3-' + cid).checked;
if (!a1 || !a2 || !a3) { err.textContent = 'All three acknowledgements are required.'; return; }
var typed = document.getElementById('cust-del-input-' + cid).value.trim();
if (typed !== cid) { err.textContent = 'The typed customer-id does not match.'; return; }
document.getElementById('cust-del-cid-' + cid).value = typed;
document.getElementById('cust-del-h1-' + cid).value = '1';
document.getElementById('cust-del-h2-' + cid).value = '1';
document.getElementById('cust-del-h3-' + cid).value = '1';
document.getElementById('cust-del-form-' + cid).submit();
}
</script>
{{end}}
</div>