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 != "{}" {