hub v0.41.0: OffsiteChecker (fill 90/95 + staleness >48h) + operator freeze lever (SLICE 4)
Sibling checker over the controller report's offsite object: quota-fill warn/crit + the silently-stuck staleness detector (escrowed-only, red-proofed; nil-safe on pre-v0.109 reports; same-second tie-guard). SetOffsiteFrozen flips ONLY readonly on the exactly-1 labelled sub-account (SSH preserved); Freeze/Unfreeze buttons — manual only, never automatic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -283,6 +283,35 @@ func (p *Provisioner) provisionDedicated(ctx context.Context, customerID string,
|
||||
return &Descriptor{Enabled: true, Type: "dedicated", Host: box.Server, User: box.Username, Port: sftpPort, RepoPath: repoPath, BoxType: boxType}, nil
|
||||
}
|
||||
|
||||
// SetOffsiteFrozen freezes/unfreezes the customer's SHARED sub-account (SLICE 4: readonly access) — an
|
||||
// OPERATOR lever, NEVER automatic: freezing also blocks prune/forget, which is the customer's only way
|
||||
// DOWN from over-quota, so only a human weighs that trade-off. Same exactly-1 label guard as the
|
||||
// re-issue. Preserves the sub-account's other access settings (SSH must stay on — only readonly flips).
|
||||
// Dedicated boxes have no freeze path (Hetzner enforces their size physically; the UI hides the button).
|
||||
func (p *Provisioner) SetOffsiteFrozen(ctx context.Context, customerID string, frozen bool) error {
|
||||
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: freeze lookup: %w", err)
|
||||
}
|
||||
if len(subs) != 1 {
|
||||
return fmt.Errorf("offsite: freeze needs exactly 1 sub-account labelled for %s, found %d — refusing", customerID, len(subs))
|
||||
}
|
||||
as := subs[0].AccessSettings
|
||||
as.Readonly = frozen
|
||||
act, err := p.API.UpdateSubaccountAccess(ctx, p.PoolBoxID, subs[0].ID, as)
|
||||
if err != nil {
|
||||
return fmt.Errorf("offsite: update access: %w", err)
|
||||
}
|
||||
if err := p.API.WaitAction(ctx, act); err != nil {
|
||||
return fmt.Errorf("offsite: freeze action: %w", err)
|
||||
}
|
||||
p.logf("[offsite] set frozen=%v (readonly) for %s (subaccount %d)", frozen, customerID, subs[0].ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
func MergeDescriptor(configJSON string, d *Descriptor) (string, error) {
|
||||
|
||||
@@ -211,6 +211,42 @@ func TestReissue_RefusesAmbiguousLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E (SLICE 4) — the freeze lever flips ONLY readonly on the exactly-1 labelled sub-account
|
||||
// (SSH stays on — a freeze must not cut access, just writes); ambiguity refuses; unfreeze reverses.
|
||||
func TestFreeze_SharedTogglesReadonlyOnly(t *testing.T) {
|
||||
p, fake, _ := newTestProvisioner(t)
|
||||
if _, err := p.ProvisionOffsite(context.Background(), "cust-fz", Input{Enabled: true, Type: "shared", QuotaGB: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.SetOffsiteFrozen(context.Background(), "cust-fz", true); err != nil {
|
||||
t.Fatalf("freeze: %v", err)
|
||||
}
|
||||
subs, _ := fake.ListSubaccounts(context.Background(), 611421, "felhom-customer=cust-fz")
|
||||
if len(subs) != 1 || !subs[0].AccessSettings.Readonly {
|
||||
t.Fatalf("freeze must set readonly on the labelled sub-account: %+v", subs)
|
||||
}
|
||||
if !subs[0].AccessSettings.SSHEnabled {
|
||||
t.Fatal("freeze must NOT disable SSH — only readonly flips")
|
||||
}
|
||||
if err := p.SetOffsiteFrozen(context.Background(), "cust-fz", false); err != nil {
|
||||
t.Fatalf("unfreeze: %v", err)
|
||||
}
|
||||
subs, _ = fake.ListSubaccounts(context.Background(), 611421, "felhom-customer=cust-fz")
|
||||
if subs[0].AccessSettings.Readonly {
|
||||
t.Fatal("unfreeze must clear readonly")
|
||||
}
|
||||
// nothing provisioned → refuse
|
||||
if err := p.SetOffsiteFrozen(context.Background(), "cust-none", true); err == nil {
|
||||
t.Fatal("freeze with no labelled sub-account must refuse")
|
||||
}
|
||||
// ambiguous → refuse
|
||||
fake.Subaccounts[71] = hetznerapi.Subaccount{ID: 71, StorageBox: 611421, Labels: map[string]string{"felhom-customer": "cust-amb2"}}
|
||||
fake.Subaccounts[72] = hetznerapi.Subaccount{ID: 72, StorageBox: 611421, Labels: map[string]string{"felhom-customer": "cust-amb2"}}
|
||||
if err := p.SetOffsiteFrozen(context.Background(), "cust-amb2", true); err == nil || !strings.Contains(err.Error(), "exactly 1") {
|
||||
t.Fatalf("ambiguous freeze must refuse, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — enable dedicated → box provisioned.
|
||||
func TestProvision_Dedicated(t *testing.T) {
|
||||
p, fake, st := newTestProvisioner(t)
|
||||
|
||||
Reference in New Issue
Block a user