hub v0.61.0 + felhom-tenantsync v1.1.0: Customer RESET (middle lifecycle tier)

One operator action returns a customer to pre-first-install: all operational
state dies (offsite repo, PBS namespace+backups, DR recipe, one-time secret,
claim state, retained escrow custody); identity + basic config + provenance +
events survive. Sits between host delete and customer Delete.

- store/customer_reset.go: customer_resets journal, live inventory, ack-gated
  purge (never touches identity/provenance/events), DeleteClaim.
- claim.ResetToUnclaimed: delete claim row -> fresh code next onboarding.
- offsite.Deprovision (idempotent) + OffsiteIdentifier + ClearProvisionedDescriptor.
- tenantsync.Deprovision + felhom-tenantsync.sh deprovision op (destroys ns +
  backup groups + token; shared user untouched; idempotent).
- web/customer_reset.go: GET reset -> inventory JSON; POST -> orchestration
  (external teardown FIRST, DB purge LAST; refuse-while-hosts; typed-id +
  separate escrow ack). Amber RESET card distinct from red Danger-zone Delete.
- Red-proofs: ack-gate + partial-failure resumability (both proven red);
  store ack-gating + journal round-trip; offsite idempotency + descriptor clear;
  RESET-card render. Green: build + vet + test.
This commit is contained in:
2026-07-17 13:09:04 +02:00
parent 6b1fbca51d
commit 4009401f46
17 changed files with 1193 additions and 13 deletions
+99
View File
@@ -227,6 +227,80 @@ func (p *Provisioner) ReissueCredentials(ctx context.Context, customerID, typ st
return nil
}
// OffsiteIdentifier returns the customer's provisioned offsite name (sub-account username / box name)
// for the RESET preview inventory, "" if none is provisioned. Read-only.
func (p *Provisioner) OffsiteIdentifier(ctx context.Context, customerID, typ string) (string, error) {
switch typ {
case "dedicated":
boxes, err := p.API.ListStorageBoxes(ctx, customerSelector(customerID))
if err != nil || len(boxes) == 0 {
return "", err
}
return boxes[0].Name, nil
default: // shared / ""
if p.PoolBoxID == 0 {
return "", nil
}
subs, err := p.API.ListSubaccounts(ctx, p.PoolBoxID, customerSelector(customerID))
if err != nil || len(subs) == 0 {
return "", err
}
return subs[0].Username, nil
}
}
// Deprovision DELETES the customer's offsite storage — the RESET teardown (v0.61.0). The offsite repo
// DATA dies with the sub-account/box; irreversible, gated by the operator RESET confirm. IDEMPOTENT:
// zero labelled sub-accounts/boxes = already gone = success (a re-run after a partial reset does not
// error). The id is re-derived by the customer label each time — nothing stored to go stale.
func (p *Provisioner) Deprovision(ctx context.Context, customerID, typ string) error {
switch typ {
case "dedicated":
boxes, err := p.API.ListStorageBoxes(ctx, customerSelector(customerID))
if err != nil {
return fmt.Errorf("offsite: deprovision lookup: %w", err)
}
if len(boxes) == 0 {
p.logf("[offsite] deprovision: no box labelled for %s — already gone", customerID)
return nil
}
for _, b := range boxes {
act, err := p.API.DeleteStorageBox(ctx, b.ID)
if err != nil {
return fmt.Errorf("offsite: delete box %d: %w", b.ID, err)
}
if err := p.API.WaitAction(ctx, act); err != nil {
return fmt.Errorf("offsite: delete box action: %w", err)
}
p.logf("[offsite] deprovisioned dedicated box %d for %s (repo data destroyed)", b.ID, customerID)
}
return nil
default: // shared / ""
if p.PoolBoxID == 0 {
return fmt.Errorf("offsite: no shared pool box configured")
}
subs, err := p.API.ListSubaccounts(ctx, p.PoolBoxID, customerSelector(customerID))
if err != nil {
return fmt.Errorf("offsite: deprovision lookup: %w", err)
}
if len(subs) == 0 {
p.logf("[offsite] deprovision: no sub-account labelled for %s — already gone", customerID)
return nil
}
for _, sub := range subs {
act, err := p.API.DeleteSubaccount(ctx, p.PoolBoxID, sub.ID)
if err != nil {
return fmt.Errorf("offsite: delete sub-account %d: %w", sub.ID, err)
}
if err := p.API.WaitAction(ctx, act); err != nil {
return fmt.Errorf("offsite: delete sub-account action: %w", err)
}
p.logf("[offsite] deprovisioned shared sub-account %d for %s (repo data destroyed)", sub.ID, customerID)
}
return nil
}
}
func (p *Provisioner) provisionShared(ctx context.Context, customerID string, in Input) (*Descriptor, error) {
if p.PoolBoxID == 0 {
return nil, fmt.Errorf("offsite: no shared pool box configured")
@@ -344,6 +418,31 @@ func (p *Provisioner) SetOffsiteFrozen(ctx context.Context, customerID string, f
// MergeDescriptor merges the offsite descriptor under the "offsite" key of a ConfigJSON object, preserving
// all other keys. Returns the new ConfigJSON string. NEVER carries a secret (Descriptor is non-secret).
// ClearProvisionedDescriptor returns config_json with the offsite descriptor reset to its
// pre-first-install shape — the customer-RESET clear (v0.61.0). The TIER CHOICE
// (enabled/type/quota_gb/box_type — customer config, SURVIVES a RESET) is kept; every PROVISIONED
// field (host/user/port/repo_path/host_fingerprint — operational state, DIES with the Hetzner
// resource) is cleared, so re-onboarding re-provisions fresh against the retained choice. No-op-safe:
// an absent/empty offsite block returns the input unchanged.
func ClearProvisionedDescriptor(configJSON string) (string, error) {
obj := map[string]json.RawMessage{}
if strings.TrimSpace(configJSON) != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &obj); err != nil {
return "", fmt.Errorf("offsite: parse config_json: %w", err)
}
}
raw, ok := obj["offsite"]
if !ok {
return configJSON, nil // no offsite block — nothing to clear
}
var cur Descriptor
if err := json.Unmarshal(raw, &cur); err != nil {
return "", fmt.Errorf("offsite: parse offsite descriptor: %w", err)
}
cleared := &Descriptor{Enabled: cur.Enabled, Type: cur.Type, QuotaGB: cur.QuotaGB, BoxType: cur.BoxType}
return MergeDescriptor(configJSON, cleared)
}
func MergeDescriptor(configJSON string, d *Descriptor) (string, error) {
obj := map[string]json.RawMessage{}
if strings.TrimSpace(configJSON) != "" && configJSON != "{}" {
+51
View File
@@ -389,3 +389,54 @@ func TestProvision_DisableNoDeprovision(t *testing.T) {
t.Fatal("disable must NOT deprovision (data-loss guard)")
}
}
// Customer RESET (v0.61.0) — Deprovision DESTROYS the labelled shared sub-account, and is idempotent
// (a second call finds nothing and succeeds). This is the deliberate teardown the disable-guard above
// deliberately does NOT do.
func TestDeprovision_SharedIdempotent(t *testing.T) {
p, fake, _ := newTestProvisioner(t)
if _, err := p.ProvisionOffsite(context.Background(), "cust-d", Input{Enabled: true, Type: "shared", QuotaGB: 10}); err != nil {
t.Fatalf("provision: %v", err)
}
if fake.CreatedSubaccounts != 1 {
t.Fatalf("precondition: want 1 subaccount, got %d", fake.CreatedSubaccounts)
}
if err := p.Deprovision(context.Background(), "cust-d", "shared"); err != nil {
t.Fatalf("deprovision: %v", err)
}
if fake.DeletedSubaccounts != 1 {
t.Fatalf("want 1 subaccount deleted, got %d", fake.DeletedSubaccounts)
}
// Idempotent: nothing labelled now → success, no extra delete.
if err := p.Deprovision(context.Background(), "cust-d", "shared"); err != nil {
t.Fatalf("second deprovision (idempotent) errored: %v", err)
}
if fake.DeletedSubaccounts != 1 {
t.Fatalf("idempotent re-run deleted again: %d", fake.DeletedSubaccounts)
}
}
// ClearProvisionedDescriptor keeps the tier CHOICE (enabled/type/quota/box_type) and drops every
// PROVISIONED field — the pre-first-install shape a RESET returns the customer to.
func TestClearProvisionedDescriptor(t *testing.T) {
in := `{"git":{"token":"x"},"offsite":{"enabled":true,"type":"shared","host":"u1-sub3.your-storagebox.de","user":"u1-sub3","port":23,"repo_path":"/home/felhom","quota_gb":100,"box_type":"","host_fingerprint":"SHA256:abc"}}`
out, err := ClearProvisionedDescriptor(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"enabled":true`) || !strings.Contains(out, `"type":"shared"`) || !strings.Contains(out, `"quota_gb":100`) {
t.Errorf("tier choice lost: %s", out)
}
for _, gone := range []string{"your-storagebox.de", "u1-sub3", "repo_path", "host_fingerprint", `"port"`} {
if strings.Contains(out, gone) {
t.Errorf("provisioned field %q survived: %s", gone, out)
}
}
if !strings.Contains(out, `"git"`) {
t.Errorf("unrelated config keys dropped: %s", out)
}
// No-op-safe: absent offsite block returns input unchanged.
if got, _ := ClearProvisionedDescriptor(`{"git":{"token":"x"}}`); got != `{"git":{"token":"x"}}` {
t.Errorf("no-offsite clear mutated config: %s", got)
}
}