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:
@@ -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 != "{}" {
|
||||
|
||||
Reference in New Issue
Block a user