ReviewOS

also looking at this

stacks/ts-cloud

feat(consolidation): report an attach's credential radius, and rename a server in place

#175
Merged glennmichael123 wants to merge feat/consolidation-credential-reach-and-rename into main
12 files +1138 -14

Refs #169. Refs #167 (operation 3 + the scaffolding for operation 2).

#169 — the credential radius is now stated

describeCredentialReach() and formatCredentialReach() already existed and nothing called them — the radius was computed, formatted, and never printed.

Attaching resolves the owner's box by LISTING the provider with the attaching project's own credential. That listing is the whole mechanism, and it forces shared project scope: on a provider without per-resource scoping, that is write over every server in the project. Three apps that each owned one box become three pipelines that each reach all three.

Every attach deploy now says so before acting on it:

Attaching to 'statushq' shares one provider project, so this deploy's credential
can write to all 4 server(s) it can see.
3 of them belong to neither project:
  bughq: bughq-production-app
  stacks: stacks-production-app
  not managed by ts-cloud: some-legacy-box
A compromised CI run or a mistargeted teardown in this project now reaches those.

Reported, never enforced — the trade is frequently worth making, and a deploy that started failing on upgrade would teach operators to silence the warning rather than read it. The quiet case stays quiet: reach confined to the two projects being joined is one info line.

The radius is asked of the driver (CloudDriver.listReachableResources()) rather than assumed from one all-powerful token, which is the "don't hardcode one credential shape" criterion: a provider whose credential can be scoped per-resource simply enumerates less and the same report comes out correct with no special case. Hetzner implements it over listServers(), where "can see" and "can delete" are the same set.

Against the issue's checklist: documenting the trade and the isolation/attaching exclusivity landed in #171; this adds the plan reporting and the driver-interface criterion. Narrowing the credential itself remains provider-side — Hetzner still offers nothing narrower.

#167server:rename, on reusable plan-then-apply scaffolding

Operation 1 (attach-as-a-site) already works, as your own comment on the issue found. This adds operation 3 and the scaffolding operation 2 should be built on.

cloud server:rename bughq hq-production-server            # plan only
cloud server:rename bughq hq-production-server --apply    # perform it

A name is spelled in four places and the rename is only done when all four agree: the provider record, the local driver state pin, the box's hostname, and the fleet inventory record.

The state pin is the one that is not cosmetic. findComputeTargets rejects a pinned server whose live name no longer matches the recorded one — a deliberate guard against a stale pin sending a database operation to another project's box. So renaming at the provider alone quietly invalidates it. The provider is renamed before the pin is rewritten, so a crash between them leaves the pin stale (deploys fall back to label matching and keep working) rather than pinned to a name that does not exist yet.

The scaffolding (src/operations/plan.ts)

Built to the control-plane requirements in the issue, and deliberately reusable:

  • Plan first. Each step prints from → to before anything is touched, in declaration order, so two runs of an unchanged plan diff identically.
  • Idempotent and resumable. Resumability comes from asking reality whether a step's intent already holds — not from a checkpoint file, which can disagree with the world after a crash. A half-finished operation is resumed by running it again; a finished one re-runs as Nothing to do.
  • Typed confirmation for the irreversible half, and never for a step already satisfied. Rename declares nothing destructive: it is undone by renaming back, and ceremony on a reversible operation teaches people to type confirmations without reading them.
  • Non-interactive. The plan is data and the authorization is a flag; nothing reads stdin. --json emits both plan and outcome.
  • Audited. Each step reports as it starts and as it finishes, so a run that dies mid-step still leaves the start recorded — the case an operator is reconstructing afterwards.
  • Fails forward, not back. A failure stops the run and leaves earlier steps applied: half-applied and reported is recoverable, silently rolled back to a state nobody verified is not.

A missing capability drops its step rather than failing the operation — a hand-enrolled server has no provider record, a label-only project has no state pin, an unpinned host key means no SSH — and the plan lists what it left out.

Still open on #167: operation 2, moving a deployed app between servers (data, scheduled work, TLS, DNS cutover). That is the large one and is unstarted; this PR gives it the plan/apply engine and a worked example to follow.

Verification

bun test — 4097 pass, 0 fail. Typecheck and lint clean.

New coverage: plan.test.ts (11 cases — resume, no-op re-run, destructive gating, fail-forward, audit ordering, unknown-state handling) and server-rename.test.ts (14 cases — hostname validation, collision and no-op preconditions, all four records, resuming a half-finished rename, dropped capabilities, the /etc/hosts rewrite), plus four attach-mode credential-reach cases in compute-deploy.test.ts.

Docs: docs/cli.md gains a "Renaming a server" section with the plan output; docs/config.md shows the radius report and where it comes from.

12 changed files on the files tab, with 0 review threads.