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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/cli.mdmodified+44-0
Changes to docs/cli.md
@@ -154,6 +154,7 @@ See [Preview environments](/features/preview-environments) for policy, source li
154154| `cloud server:list` / `server:create <name>` / `server:destroy <name>` | Manage servers. |
155155| `cloud server:ssh <name>` / `server:logs <name>` / `server:monitoring <name>` | Connect / logs / metrics. |
156156| `cloud server:deploy <name>` / `server:reboot` / `server:resize <name> <type>` | Deploy / reboot / resize. |
157| `cloud server:rename <name> <new-name> [--apply]` | Rename a server in place. Prints the plan; `--apply` performs it. |
157158| `cloud server:recipe <name> <recipe>` | Run a reusable script across servers. |
158159| `cloud server:worker:add/list/restart/remove` | Queue workers (Supervisor). |
159160| `cloud server:cron:add/list/remove` | Scheduled jobs / cron. |
@@ -164,6 +165,49 @@ See [Preview environments](/features/preview-environments) for policy, source li
164165
165166See [Laravel / Forge-style](/features/laravel) for the `infrastructure.compute` + `sites` config.
166167
168### Renaming a server
169
170A name is spelled in four places, and a rename is only done when all four agree:
171the provider record, the local driver state pin
172(`storage/cloud/state/<stack>.json`), the box's own hostname, and the fleet
173inventory record. The state pin is the one that is not cosmetic a deploy
174REFUSES a pinned server whose live name no longer matches the recorded one, a
175guard against a stale pin sending a database operation to another project's box
176 so renaming at the provider alone quietly invalidates it.
177
178```bash
179cloud server:rename bughq hq-production-server # plan only
180cloud server:rename bughq hq-production-server --apply # perform it
181```
182
183Without `--apply` it prints the plan and changes nothing:
184
185```
186server:rename bughq
187 Rename the server at the provider
188 bughq hq-production-server
189 Update the recorded name in the local driver state
190 bughq hq-production-server
191 ok Set the hostname on the box [already done]
192 Rename the fleet inventory record
193 bughq hq-production-server
194 3 step(s) would run
195 Re-run with --apply to perform it.
196```
197
198Every step asks reality whether it is already done, so a rename that dies
199halfway is resumed by running the same command again the finished steps skip
200themselves rather than being attempted twice and a completed rename re-runs as
201`Nothing to do`. A step whose capability is missing is dropped and reported
202under `not covered`: a hand-enrolled server has no provider record, a project
203deploying purely from labels has no state pin, and a box whose host key is not
204pinned cannot be reached to set a hostname (`cloud server:validate` first).
205
206Renaming is reversible by renaming back, so it needs no typed confirmation. It
207is also non-interactive by construction the plan is data and the authorization
208is a flag so it runs unattended from CI, and `--json` emits both the plan and
209the outcome. Each step appends to the operation log as it starts and finishes.
210
167211## Databases
168212
169213`cloud db:create` · `db:list` · `db:connect` · `db:tunnel` · `db:users:add` · `db:users:list` ·
packages/ts-cloud/bin/commands/server.tsmodified+168-2
Changes to packages/ts-cloud/bin/commands/server.ts
@@ -2,6 +2,7 @@ import type { CLI } from '@stacksjs/clapp'
22import type { EnvironmentType } from '@ts-cloud/core'
33import type { HetznerResizeCheckpoint } from '../../src/drivers/hetzner/resize-state'
44import type { ServerProvider, ServerRole } from '../../src/fleet'
5import type { ServerRenameEffects } from '../../src/operations/server-rename'
56import { resolveProjectStackName } from '@ts-cloud/core'
67import * as cli from '../../src/utils/cli'
78import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane'
@@ -20,9 +21,11 @@ import { resolveHetznerServerType } from '../../src/drivers/hetzner/instance-siz
2021import { executeHetznerServerResize, planHetznerServerResize } from '../../src/drivers/hetzner/resize'
2122import { collectHetznerResizeManifest, prepareHetznerResize, verifyHetznerResize } from '../../src/drivers/hetzner/resize-remote'
2223import { acquireResizeLock, readResizeCheckpoint, writeResizeCheckpoint } from '../../src/drivers/hetzner/resize-state'
23import { readDriverState } from '../../src/drivers/hetzner/state'
24import { readDriverState, writeDriverState } from '../../src/drivers/hetzner/state'
2425import { usesRpxProxy } from '../../src/drivers/shared/rpx-gateway'
25import { FleetService, FleetStore, SshFleetDriver } from '../../src/fleet'
26import { FleetService, FleetStore, SshFleetDriver, SystemFleetSshTransport } from '../../src/fleet'
27import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan'
28import { buildSetHostnameScript, planServerRename } from '../../src/operations/server-rename'
2629import { unsupportedCommand } from './capability-command'
2730import { loadValidatedConfig } from './shared'
2831
@@ -482,6 +485,157 @@ async function runHetznerHostOptimization(name: string, options: OptimizeCommand
482485 else cli.success(`${server.name} passed full host, rpx route, service, release, and data verification.`)
483486}
484487
488interface RenameCommandOptions {
489 env?: string
490 apply?: boolean
491 json?: boolean
492}
493
494/**
495 * Wire the four records a rename touches to live effects, plan it, print the
496 * plan, and apply it only when asked.
497 *
498 * Each capability is wired only when it is actually available: a server with no
499 * provider id has no provider record to rename, a project with no state pin for
500 * this server has nothing to repin, and a box whose host key is not pinned
501 * cannot be reached to set a hostname. A missing one drops its step rather than
502 * failing the rename — see `planServerRename`.
503 */
504async function runServerRename(name: string, next: string, options: RenameCommandOptions): Promise<void> {
505 const config = await loadValidatedConfig()
506 const environment = (options.env ?? 'production') as EnvironmentType
507 const stackName = resolveProjectStackName(config, environment)
508
509 await use(async (value) => {
510 const server = find(value, name)
511 const inventory = value.store.list(value.controlPlane.project.id, true)
512
513 // A Hetzner client is only reachable with a token; without one the provider
514 // record simply is not part of this rename, and the plan says so.
515 const hetzner =
516 server.provider === 'hetzner' && server.providerId
517 ? (() => {
518 try {
519 const settings = resolveHetznerSettings(config)
520 return new HetznerClient({ apiToken: resolveHetznerApiToken(settings.apiToken, config) })
521 } catch {
522 return null
523 }
524 })()
525 : null
526 const providerId = server.providerId ? Number(server.providerId) : Number.NaN
527
528 const state = await readDriverState(stackName)
529 // Only repin state that actually points at THIS server. Rewriting a pin for
530 // a different box would be exactly the stale-pin bug the guard exists for.
531 const pinned = state != null && state.serverId === providerId
532
533 const transport = new SystemFleetSshTransport()
534 const reachable = server.trustState === 'pinned'
535
536 const effects: ServerRenameEffects = {
537 takenNames: async () => {
538 const names = inventory.map((item) => item.name)
539 if (hetzner) names.push(...(await hetzner.listServers()).map((item) => item.name))
540 return names
541 },
542 inventoryName: () => value.store.get(server.id)?.name ?? server.name,
543 renameInventory: (value_) => {
544 value.store.update(server.id, { name: value_ })
545 },
546 ...(hetzner && Number.isFinite(providerId)
547 ? {
548 providerName: async () => (await hetzner.getServer(providerId)).name,
549 renameProvider: async (value_: string) => {
550 await hetzner.renameServer(providerId, value_)
551 },
552 }
553 : {}),
554 ...(pinned
555 ? {
556 stateName: async () => (await readDriverState(stackName))?.serverName,
557 writeStateName: async (value_: string) => {
558 const current = await readDriverState(stackName)
559 if (current) await writeDriverState(stackName, { ...current, serverName: value_ })
560 },
561 }
562 : {}),
563 ...(reachable
564 ? {
565 remoteHostname: async () => {
566 const result = await transport.exec(server, 'hostname')
567 return result.code === 0 ? result.stdout.trim() : undefined
568 },
569 setRemoteHostname: async (value_: string) => {
570 const result = await transport.exec(server, buildSetHostnameScript(value_))
571 if (result.code !== 0) throw new Error(result.stderr.trim() || `hostname exited ${result.code}`)
572 },
573 }
574 : {}),
575 }
576
577 const plan = await planServerRename(server.name, next, effects)
578 const resolved = await resolvePlan(plan)
579
580 if (!options.apply) {
581 const skipped = [
582 hetzner ? '' : 'provider record (no provider id or no API token)',
583 pinned ? '' : 'local state pin (none points at this server)',
584 reachable ? '' : 'box hostname (host key is not pinned — run server:validate first)',
585 ].filter(Boolean)
586 if (options.json) {
587 console.log(
588 JSON.stringify(
589 {
590 schemaVersion: 1,
591 operation: plan.operation,
592 target: plan.target,
593 rename: { from: server.name, to: next },
594 notCovered: skipped,
595 steps: resolved.map((item) => ({
596 id: item.step.id,
597 title: item.step.title,
598 state: item.state,
599 change: item.step.change,
600 reason: item.reason,
601 })),
602 },
603 null,
604 2,
605 ),
606 )
607 return
608 }
609 for (const line of formatPlan(plan, resolved)) console.log(line)
610 for (const item of skipped) console.log(` not covered: ${item}`)
611 console.log(' Re-run with --apply to perform it.')
612 return
613 }
614
615 const outcome = await applyPlan(plan, resolved, {
616 log: (message) => cli.info(message),
617 audit: (event) =>
618 value.controlPlane.store.appendEvent({
619 projectId: value.controlPlane.project.id,
620 resourceId: server.resourceId,
621 type: `${event.operation}.${event.step}.${event.state}`,
622 level: event.state === 'failed' ? 'error' : 'info',
623 payload: { target: event.target, renameTo: next, ...(event.error ? { error: event.error } : {}) },
624 }),
625 })
626
627 if (options.json) console.log(JSON.stringify({ schemaVersion: 1, outcome }, null, 2))
628 if (!outcome.success) {
629 const failed = outcome.steps.find((step) => step.state === 'failed')
630 throw new Error(
631 `${plan.operation} stopped at '${failed?.title}': ${failed?.error}. `
632 + 'Earlier steps stayed applied; re-run the same command to continue from here.',
633 )
634 }
635 if (!options.json) cli.success(`Renamed ${server.name} to ${next}.`)
636 })
637}
638
485639export function registerServerCommands(app: CLI): void {
486640 app
487641 .command('capabilities [server]', 'Show provider and target operation support')
@@ -673,6 +827,18 @@ export function registerServerCommands(app: CLI): void {
673827 fail(error)
674828 }
675829 })
830 app
831 .command('server:rename <name> <new-name>', 'Rename a server in place, without recreating it')
832 .option('--env <environment>', 'Deployment environment', { default: 'production' })
833 .option('--apply', 'Perform the reviewed rename plan')
834 .option('--json', 'Print structured JSON')
835 .action(async (name: string, newName: string, options: RenameCommandOptions) => {
836 try {
837 await runServerRename(name, newName, options)
838 } catch (error) {
839 fail(error)
840 }
841 })
676842 app
677843 .command('server:drain <name>', 'Drain without terminating')
678844 .option('--complete', 'Mark movement complete')
packages/ts-cloud/src/operations/plan.test.tsadded+127-0
Changes to packages/ts-cloud/src/operations/plan.test.ts
@@ -0,0 +1,127 @@
1import type { OperationPlan, OperationStep } from './plan'
2import { describe, expect, it } from 'bun:test'
3import { applyPlan, formatPlan, pendingSteps, planIsDestructive, resolvePlan } from './plan'
4
5function step(overrides: Partial<OperationStep> & { id: string }): OperationStep {
6 return {
7 title: `step ${overrides.id}`,
8 satisfied: async () => false,
9 apply: async () => {},
10 ...overrides,
11 }
12}
13
14const plan = (steps: OperationStep[]): OperationPlan => ({ operation: 'server:rename', target: 'bughq', steps })
15
16describe('resolvePlan', () => {
17 it('separates what would run from what is already done', async () => {
18 const resolved = await resolvePlan(
19 plan([step({ id: 'a', satisfied: async () => true }), step({ id: 'b' })]),
20 )
21 expect(resolved.map(item => item.state)).toEqual(['satisfied', 'pending'])
22 expect(pendingSteps(resolved).map(item => item.step.id)).toEqual(['b'])
23 })
24
25 /**
26 * Not being able to check is a reason to run the step and say so, not a reason
27 * to refuse the operator a plan.
28 */
29 it('marks a step whose check throws as unknown rather than failing the plan', async () => {
30 const resolved = await resolvePlan(
31 plan([step({ id: 'a', satisfied: async () => { throw new Error('token expired') } })]),
32 )
33 expect(resolved[0].state).toBe('unknown')
34 expect(resolved[0].reason).toBe('token expired')
35 // Unknown is never skipped.
36 expect(pendingSteps(resolved)).toHaveLength(1)
37 })
38})
39
40describe('formatPlan', () => {
41 it('shows each change as from → to, in declaration order', async () => {
42 const p = plan([
43 step({ id: 'provider', title: 'Rename at the provider', change: { from: 'bughq', to: 'hq-production' } }),
44 step({ id: 'inventory', title: 'Rename the record', satisfied: async () => true }),
45 ])
46 const lines = formatPlan(p, await resolvePlan(p)).join('\n')
47 expect(lines).toContain('server:rename bughq')
48 expect(lines).toContain('→ Rename at the provider')
49 expect(lines).toContain('bughq → hq-production')
50 expect(lines).toContain('ok Rename the record [already done]')
51 expect(lines).toContain('1 step(s) would run')
52 })
53
54 it('says so plainly when a fully-applied operation is re-run', async () => {
55 const p = plan([step({ id: 'a', satisfied: async () => true })])
56 expect(formatPlan(p, await resolvePlan(p)).join('\n')).toContain('Nothing to do')
57 })
58
59 it('names the confirmation an irreversible step needs', async () => {
60 const p = plan([step({ id: 'delete', title: 'Delete the drained server', destructive: true })])
61 expect(formatPlan(p, await resolvePlan(p)).join('\n')).toContain('1 irreversible — re-run with --confirm bughq')
62 })
63
64 it('does not demand confirmation for an irreversible step that is already done', async () => {
65 const p = plan([step({ id: 'delete', destructive: true, satisfied: async () => true })])
66 const resolved = await resolvePlan(p)
67 expect(planIsDestructive(resolved)).toBe(false)
68 })
69})
70
71describe('applyPlan', () => {
72 it('runs pending steps and skips satisfied ones', async () => {
73 const ran: string[] = []
74 const p = plan([
75 step({ id: 'a', satisfied: async () => true, apply: async () => { ran.push('a') } }),
76 step({ id: 'b', apply: async () => { ran.push('b') } }),
77 ])
78 const outcome = await applyPlan(p, await resolvePlan(p))
79 expect(ran).toEqual(['b'])
80 expect(outcome.success).toBe(true)
81 expect(outcome.steps.map(s => s.state)).toEqual(['skipped', 'applied'])
82 })
83
84 /**
85 * A topology change half-applied and reported is recoverable; one silently
86 * rolled back to a state nobody has verified is not.
87 */
88 it('stops at the first failure and leaves earlier steps applied', async () => {
89 const ran: string[] = []
90 const p = plan([
91 step({ id: 'a', apply: async () => { ran.push('a') } }),
92 step({ id: 'b', apply: async () => { throw new Error('provider said no') } }),
93 step({ id: 'c', apply: async () => { ran.push('c') } }),
94 ])
95 const outcome = await applyPlan(p, await resolvePlan(p))
96 expect(ran).toEqual(['a'])
97 expect(outcome.success).toBe(false)
98 expect(outcome.steps.map(s => s.state)).toEqual(['applied', 'failed'])
99 expect(outcome.steps[1].error).toBe('provider said no')
100 })
101
102 it('re-runs as a clean no-op once everything is satisfied', async () => {
103 let applied = false
104 const p = plan([step({ id: 'a', satisfied: async () => applied, apply: async () => { applied = true } })])
105 expect((await applyPlan(p, await resolvePlan(p))).steps[0].state).toBe('applied')
106 expect((await applyPlan(p, await resolvePlan(p))).steps[0].state).toBe('skipped')
107 })
108
109 it('refuses an irreversible step without the exact target as confirmation', async () => {
110 const p = plan([step({ id: 'delete', destructive: true })])
111 const resolved = await resolvePlan(p)
112 await expect(applyPlan(p, resolved)).rejects.toThrow('--confirm bughq')
113 await expect(applyPlan(p, resolved, { confirm: 'wrong' })).rejects.toThrow('--confirm bughq')
114 expect((await applyPlan(p, resolved, { confirm: 'bughq' })).success).toBe(true)
115 })
116
117 /**
118 * The start is recorded before the step runs, because a run that dies mid-step
119 * is exactly the case an operator is trying to reconstruct afterwards.
120 */
121 it('audits each step starting and finishing', async () => {
122 const events: string[] = []
123 const p = plan([step({ id: 'a' }), step({ id: 'b', apply: async () => { throw new Error('boom') } })])
124 await applyPlan(p, await resolvePlan(p), { audit: e => events.push(`${e.step}:${e.state}`) })
125 expect(events).toEqual(['a:started', 'a:applied', 'b:started', 'b:failed'])
126 })
127})
packages/ts-cloud/src/operations/plan.tsadded+219-0
Changes to packages/ts-cloud/src/operations/plan.ts
@@ -0,0 +1,219 @@
1/**
2 * Plan-then-apply scaffolding for fleet operations that change live topology.
3 *
4 * Consolidating servers — moving an app to another box, attaching one as a site,
5 * renaming a box — is a routine cleanup that is currently an afternoon of SSH.
6 * What makes it an afternoon is not the individual steps, which are small; it is
7 * that a half-finished one leaves no way to tell what already happened. So every
8 * such operation is expressed the same way here:
9 *
10 * - **Plan first.** A step says what it would change (`from → to`) before
11 * anything is touched, and prints the same way every run so a plan can be
12 * diffed.
13 * - **Idempotent and resumable.** Resumability comes from `satisfied()`, which
14 * asks REALITY whether the step's intent already holds — not from a checkpoint
15 * file, which can disagree with the world after a crash. Re-running an
16 * operation that died halfway continues rather than starting over or
17 * double-applying, and a fully-applied operation re-runs as a clean no-op.
18 * - **Typed confirmation for the destructive half.** Irreversible steps are
19 * marked, counted separately, and gated on the operator typing the target's
20 * exact name — separately from the operation itself.
21 * - **Non-interactive.** Nothing here reads stdin: a plan is data, the
22 * confirmation is a flag, so the whole sequence is drivable from CI.
23 * - **Audited.** Each step reports through {@link ApplyPlanOptions.audit} as it
24 * starts and finishes, so the operation log carries what actually ran.
25 *
26 * @see https://github.com/stacksjs/ts-cloud/issues/167
27 */
28
29/** A value a step changes, rendered in the plan as `from → to`. */
30export interface StepChange {
31 from: string
32 to: string
33}
34
35export interface OperationStep {
36 /** Stable across runs — this is what an audit log and a resumed run key on. */
37 id: string
38 /** One line, imperative: "Rename the Hetzner server". */
39 title: string
40 /** What the step changes. Omitted for steps that only verify. */
41 change?: StepChange
42 /**
43 * Irreversible. Marked in the plan and gated on typed confirmation, because
44 * "delete the drained source server" and "move an app" deserve different
45 * levels of ceremony even inside one operation.
46 */
47 destructive?: boolean
48 /**
49 * Does reality ALREADY match this step's intent? This is what makes an
50 * operation resumable: a step that is satisfied is skipped, so a run that died
51 * halfway picks up where it stopped without the caller tracking progress.
52 *
53 * Must not mutate anything, and must tolerate a partially-applied world.
54 */
55 satisfied: () => Promise<boolean>
56 /** Perform the change. Only called when `satisfied()` returned false. */
57 apply: () => Promise<void>
58}
59
60export interface OperationPlan {
61 /** Stable operation name, e.g. `server:rename`. */
62 operation: string
63 /** What is being operated on, and what a destructive step's confirmation must match. */
64 target: string
65 steps: OperationStep[]
66}
67
68/** A step paired with what resolving it against the live world found. */
69export interface ResolvedStep {
70 step: OperationStep
71 /** `satisfied` — nothing to do; `pending` — would run; `unknown` — could not tell. */
72 state: 'satisfied' | 'pending' | 'unknown'
73 /** Why the state is `unknown`. A step that cannot be checked is never skipped. */
74 reason?: string
75}
76
77/**
78 * Ask every step whether it is already satisfied.
79 *
80 * A `satisfied()` that THROWS resolves to `unknown` rather than failing the
81 * plan: not being able to check is a reason to run the step and to say so, not a
82 * reason to refuse to show the operator a plan. Steps are resolved in order,
83 * because a later step's check may depend on an earlier one's subject existing.
84 */
85export async function resolvePlan(plan: OperationPlan): Promise<ResolvedStep[]> {
86 const resolved: ResolvedStep[] = []
87 for (const step of plan.steps) {
88 try {
89 resolved.push({ step, state: (await step.satisfied()) ? 'satisfied' : 'pending' })
90 } catch (error) {
91 resolved.push({ step, state: 'unknown', reason: error instanceof Error ? error.message : String(error) })
92 }
93 }
94 return resolved
95}
96
97/** Steps that would actually run — pending, plus the ones that could not be checked. */
98export function pendingSteps(resolved: readonly ResolvedStep[]): ResolvedStep[] {
99 return resolved.filter(item => item.state !== 'satisfied')
100}
101
102/** Do any steps that would run make an irreversible change? */
103export function planIsDestructive(resolved: readonly ResolvedStep[]): boolean {
104 return pendingSteps(resolved).some(item => item.step.destructive === true)
105}
106
107/**
108 * The plan as lines, ready to print.
109 *
110 * Lines rather than printed output so the caller owns the stream and this stays
111 * testable, and in declaration order so two runs of an unchanged plan produce an
112 * identical diff.
113 */
114export function formatPlan(plan: OperationPlan, resolved: readonly ResolvedStep[]): string[] {
115 const pending = pendingSteps(resolved)
116 const lines = [`${plan.operation} ${plan.target}`]
117
118 if (pending.length === 0) {
119 lines.push(' Nothing to do — every step is already satisfied.')
120 return lines
121 }
122
123 for (const { step, state, reason } of resolved) {
124 const mark = state === 'satisfied' ? 'ok ' : state === 'unknown' ? '? ' : '→ '
125 const flags = [
126 step.destructive ? 'DESTRUCTIVE' : '',
127 state === 'satisfied' ? 'already done' : '',
128 ].filter(Boolean)
129 lines.push(` ${mark}${step.title}${flags.length > 0 ? ` [${flags.join(', ')}]` : ''}`)
130 if (step.change) lines.push(` ${step.change.from} → ${step.change.to}`)
131 if (reason) lines.push(` could not check: ${reason}`)
132 }
133
134 const destructive = pending.filter(item => item.step.destructive).length
135 lines.push(
136 ` ${pending.length} step(s) would run`
137 + (destructive > 0 ? `, ${destructive} irreversible — re-run with --confirm ${plan.target}` : ''),
138 )
139 return lines
140}
141
142export interface StepOutcome {
143 id: string
144 title: string
145 /** `applied` — it ran; `skipped` — already satisfied; `failed` — it threw. */
146 state: 'applied' | 'skipped' | 'failed'
147 error?: string
148}
149
150export interface OperationOutcome {
151 operation: string
152 target: string
153 steps: StepOutcome[]
154 /** False when any step failed. The steps before it stay applied — see below. */
155 success: boolean
156}
157
158export interface ApplyPlanOptions {
159 /** Progress, one line per step. */
160 log?: (message: string) => void
161 /**
162 * Append to the operation log. Called as each step starts and finishes, so a
163 * run that dies mid-step still leaves the start recorded — which is exactly
164 * the case an operator is trying to reconstruct afterwards.
165 */
166 audit?: (event: { operation: string, target: string, step: string, state: 'started' | StepOutcome['state'], error?: string }) => void
167 /**
168 * Exact target name, required before any irreversible step runs. A plan with
169 * no destructive pending step ignores this.
170 */
171 confirm?: string
172}
173
174/**
175 * Run the pending steps in order.
176 *
177 * Stops at the first failure and leaves the earlier steps applied, deliberately:
178 * a topology change half-applied and reported is recoverable — re-run it, the
179 * satisfied steps skip themselves — while one silently rolled back to a state
180 * nobody has verified is not.
181 */
182export async function applyPlan(
183 plan: OperationPlan,
184 resolved: readonly ResolvedStep[],
185 options: ApplyPlanOptions = {},
186): Promise<OperationOutcome> {
187 const outcome: OperationOutcome = { operation: plan.operation, target: plan.target, steps: [], success: true }
188
189 if (planIsDestructive(resolved) && options.confirm !== plan.target) {
190 throw new Error(
191 `${plan.operation} includes an irreversible step. Re-run with --confirm ${plan.target} to authorize it.`,
192 )
193 }
194
195 for (const { step, state } of resolved) {
196 if (state === 'satisfied') {
197 outcome.steps.push({ id: step.id, title: step.title, state: 'skipped' })
198 options.log?.(`skip ${step.title} (already done)`)
199 continue
200 }
201
202 options.audit?.({ operation: plan.operation, target: plan.target, step: step.id, state: 'started' })
203 try {
204 await step.apply()
205 outcome.steps.push({ id: step.id, title: step.title, state: 'applied' })
206 options.log?.(`done ${step.title}`)
207 options.audit?.({ operation: plan.operation, target: plan.target, step: step.id, state: 'applied' })
208 } catch (error) {
209 const message = error instanceof Error ? error.message : String(error)
210 outcome.steps.push({ id: step.id, title: step.title, state: 'failed', error: message })
211 outcome.success = false
212 options.log?.(`FAIL ${step.title}: ${message}`)
213 options.audit?.({ operation: plan.operation, target: plan.target, step: step.id, state: 'failed', error: message })
214 return outcome
215 }
216 }
217
218 return outcome
219}
packages/ts-cloud/src/operations/server-rename.test.tsadded+154-0
Changes to packages/ts-cloud/src/operations/server-rename.test.ts
@@ -0,0 +1,154 @@
1import type { ServerRenameEffects } from './server-rename'
2import { describe, expect, it } from 'bun:test'
3import { applyPlan, formatPlan, resolvePlan } from './plan'
4import { buildSetHostnameScript, planServerRename, validateServerName } from './server-rename'
5
6/** A fully-capable server: provider record, state pin, reachable box, inventory. */
7function world(name = 'bughq') {
8 const state = { provider: name, pin: name, hostname: name, inventory: name, taken: [name, 'statushq'] }
9 const effects: ServerRenameEffects = {
10 takenNames: async () => state.taken,
11 providerName: async () => state.provider,
12 renameProvider: async (next) => { state.provider = next },
13 stateName: async () => state.pin,
14 writeStateName: async (next) => { state.pin = next },
15 remoteHostname: async () => state.hostname,
16 setRemoteHostname: async (next) => { state.hostname = next },
17 inventoryName: () => state.inventory,
18 renameInventory: (next) => { state.inventory = next },
19 }
20 return { state, effects }
21}
22
23describe('validateServerName', () => {
24 it('accepts a hostname', () => {
25 expect(() => validateServerName('hq-production-server')).not.toThrow()
26 expect(() => validateServerName('hq.example.com')).not.toThrow()
27 })
28
29 it('refuses what a provider or /etc/hostname would refuse', () => {
30 expect(() => validateServerName('')).toThrow('cannot be empty')
31 expect(() => validateServerName('-leading')).toThrow('not a valid hostname label')
32 expect(() => validateServerName('trailing-')).toThrow('not a valid hostname label')
33 expect(() => validateServerName('under_score')).toThrow('not a valid hostname label')
34 expect(() => validateServerName('a..b')).toThrow('empty label')
35 expect(() => validateServerName(`${'a'.repeat(64)}.com`)).toThrow('63 characters')
36 })
37})
38
39describe('planServerRename preconditions', () => {
40 it('refuses a name another server already holds', async () => {
41 await expect(planServerRename('bughq', 'statushq', world().effects)).rejects.toThrow('already taken')
42 })
43
44 it('refuses a no-op rename', async () => {
45 await expect(planServerRename('bughq', 'bughq', world().effects)).rejects.toThrow('already named that')
46 })
47
48 /**
49 * A precondition is not a unit of work: an illegal name must fail before the
50 * plan exists, not on its first step with the provider already renamed.
51 */
52 it('refuses an illegal name before building any step', async () => {
53 await expect(planServerRename('bughq', 'not_a_hostname', world().effects)).rejects.toThrow('valid hostname')
54 })
55})
56
57describe('planServerRename', () => {
58 it('covers all four records, provider before the state pin', async () => {
59 const plan = await planServerRename('bughq', 'hq-production-server', world().effects)
60 expect(plan.steps.map(step => step.id)).toEqual(['provider', 'state-pin', 'hostname', 'inventory'])
61 })
62
63 it('renames every record when applied', async () => {
64 const { state, effects } = world()
65 const plan = await planServerRename('bughq', 'hq-production-server', effects)
66 const outcome = await applyPlan(plan, await resolvePlan(plan))
67 expect(outcome.success).toBe(true)
68 expect(state).toMatchObject({
69 provider: 'hq-production-server',
70 pin: 'hq-production-server',
71 hostname: 'hq-production-server',
72 inventory: 'hq-production-server',
73 })
74 })
75
76 it('needs no confirmation — a rename is undone by renaming back', async () => {
77 const plan = await planServerRename('bughq', 'hq-production-server', world().effects)
78 expect(plan.steps.some(step => step.destructive)).toBe(false)
79 })
80
81 /**
82 * The point of resumability: a rename that died after the provider call is
83 * re-run, and the provider step skips itself instead of being attempted again.
84 */
85 it('resumes a half-finished rename without redoing the finished half', async () => {
86 const { state, effects } = world()
87 state.provider = 'hq-production-server'
88 let providerCalls = 0
89 const plan = await planServerRename('bughq', 'hq-production-server', {
90 ...effects,
91 renameProvider: async (next) => { providerCalls++; state.provider = next },
92 })
93 const resolved = await resolvePlan(plan)
94 expect(resolved[0].state).toBe('satisfied')
95 const outcome = await applyPlan(plan, resolved)
96 expect(providerCalls).toBe(0)
97 expect(outcome.steps[0].state).toBe('skipped')
98 expect(state.inventory).toBe('hq-production-server')
99 })
100
101 it('is a clean no-op when re-run after finishing', async () => {
102 const { effects } = world()
103 const plan = await planServerRename('bughq', 'hq-production-server', effects)
104 await applyPlan(plan, await resolvePlan(plan))
105 const again = await resolvePlan(plan)
106 expect(formatPlan(plan, again).join('\n')).toContain('Nothing to do')
107 })
108
109 /**
110 * A server enrolled by hand has no provider record, a project deploying purely
111 * from labels has no pin, and an unpinned host key means no SSH. Each missing
112 * capability drops its step rather than failing the rename.
113 */
114 it('drops the steps whose capability is missing', async () => {
115 const { state } = world()
116 const plan = await planServerRename('bughq', 'hq-production-server', {
117 takenNames: async () => state.taken,
118 inventoryName: () => state.inventory,
119 renameInventory: (next) => { state.inventory = next },
120 })
121 expect(plan.steps.map(step => step.id)).toEqual(['inventory'])
122 expect((await applyPlan(plan, await resolvePlan(plan))).success).toBe(true)
123 expect(state.inventory).toBe('hq-production-server')
124 })
125
126 /** A project with no pin at all must not gain one it never asked for. */
127 it('treats an absent state pin as nothing to update', async () => {
128 const { state, effects } = world()
129 let wrote = false
130 const plan = await planServerRename('bughq', 'hq-production-server', {
131 ...effects,
132 stateName: async () => undefined,
133 writeStateName: async () => { wrote = true },
134 })
135 await applyPlan(plan, await resolvePlan(plan))
136 expect(wrote).toBe(false)
137 expect(state.inventory).toBe('hq-production-server')
138 })
139})
140
141describe('buildSetHostnameScript', () => {
142 it('sets the hostname persistently with a fallback', () => {
143 const script = buildSetHostnameScript('hq-production-server')
144 expect(script).toContain("hostnamectl set-hostname 'hq-production-server'")
145 expect(script).toContain('/etc/hostname')
146 })
147
148 /** Two 127.0.1.1 lines would leave the box resolving its own name two ways. */
149 it('replaces the existing 127.0.1.1 line rather than appending a second', () => {
150 const script = buildSetHostnameScript('hq-production-server')
151 expect(script).toContain('s/^127\\.0\\.1\\.1.*/127.0.1.1\\thq-production-server/')
152 expect(script).toContain('if grep -q "127.0.1.1" /etc/hosts; then')
153 })
154})
packages/ts-cloud/src/operations/server-rename.tsadded+187-0
Changes to packages/ts-cloud/src/operations/server-rename.ts
@@ -0,0 +1,187 @@
1/**
2 * Rename a server in place.
3 *
4 * Renaming is purely an identity change, and today it implies destroy-and-
5 * recreate — which is not an acceptable cost for a naming-convention fix. It is
6 * the smallest of the consolidation operations and the one with no data to move:
7 * four records that all spell the same name, kept in step.
8 *
9 * The four, and why each matters:
10 *
11 * 1. **The provider record.** What the console shows and what a human greps for.
12 * 2. **The local driver state pin** (`storage/cloud/state/<stack>.json`). This
13 * one is not cosmetic. `findComputeTargets` REJECTS a pinned server whose
14 * live name no longer matches the recorded one — a deliberate guard against
15 * a stale pin sending a database operation to another project's box — so a
16 * provider-side rename that does not update the pin quietly invalidates it.
17 * 3. **The box's hostname.** What shells, logs, and the box's own reports say.
18 * 4. **The fleet inventory record.** What every `server:*` command addresses.
19 *
20 * Ordering is chosen for the failure in between: the provider is renamed BEFORE
21 * the pin is rewritten, so a crash between them leaves the pin stale (deploys
22 * fall back to label matching and keep working) rather than pointing at a name
23 * that does not exist yet. Every step re-derives its own state, so the fix for a
24 * half-finished rename is to run it again.
25 *
26 * Nothing here is destructive: a rename is undone by renaming back, and putting
27 * typed-confirmation ceremony on a reversible operation only teaches people to
28 * type confirmations without reading them.
29 *
30 * @see https://github.com/stacksjs/ts-cloud/issues/167
31 */
32import type { OperationPlan, OperationStep } from './plan'
33
34/**
35 * The side effects a rename needs, injected so the operation is testable without
36 * a provider, an SSH host, or a control-plane database — and so a second driver
37 * can supply its own without this module knowing about it.
38 *
39 * The optional members are genuinely optional: a server enrolled by hand has no
40 * provider record to rename, a project deploying purely from labels has no state
41 * pin, and a box whose host key is not pinned cannot be reached to set a
42 * hostname. Each missing capability drops its step from the plan rather than
43 * failing the operation, and the plan says which ones it left out.
44 */
45export interface ServerRenameEffects {
46 /** Every name already taken — provider project and inventory both. */
47 takenNames: () => Promise<string[]>
48 /** Live provider-side name. */
49 providerName?: () => Promise<string | undefined>
50 renameProvider?: (next: string) => Promise<void>
51 /** Name recorded in the local driver state pin. */
52 stateName?: () => Promise<string | undefined>
53 writeStateName?: (next: string) => Promise<void>
54 /** Hostname reported by the box itself. */
55 remoteHostname?: () => Promise<string | undefined>
56 setRemoteHostname?: (next: string) => Promise<void>
57 /** Name on the fleet inventory record. */
58 inventoryName: () => string
59 renameInventory: (next: string) => Promise<void> | void
60}
61
62/**
63 * A server name has to be a valid hostname, because it becomes one: providers
64 * reject anything else, and the box's own `hostname` is set from it.
65 *
66 * RFC 1123 labels — letters, digits and hyphens, not starting or ending with a
67 * hyphen, at most 63 characters each — joined by dots. Checked up front so a
68 * rename fails before it has touched anything, rather than halfway through with
69 * the provider renamed and the box not.
70 */
71export function validateServerName(name: string): void {
72 if (name.length === 0) throw new Error('A server name cannot be empty.')
73 if (name.length > 253) throw new Error(`'${name}' is longer than the 253 characters a hostname allows.`)
74 for (const label of name.split('.')) {
75 if (label.length === 0) throw new Error(`'${name}' has an empty label — a hostname cannot contain '..'.`)
76 if (label.length > 63) throw new Error(`'${label}' is longer than the 63 characters a hostname label allows.`)
77 if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))
78 throw new Error(
79 `'${label}' is not a valid hostname label. Use letters, digits and hyphens, not starting or ending with a hyphen.`,
80 )
81 }
82}
83
84/**
85 * Build the plan that renames `current` to `next`.
86 *
87 * Async because the preconditions — a legal name, and one nobody else holds —
88 * are checked here rather than becoming steps. A precondition is not a unit of
89 * work; making it one would let a plan print as if it were runnable and then
90 * fail on its first step.
91 */
92export async function planServerRename(
93 current: string,
94 next: string,
95 effects: ServerRenameEffects,
96): Promise<OperationPlan> {
97 validateServerName(next)
98
99 if (current === next) throw new Error(`'${current}' is already named that.`)
100
101 const taken = await effects.takenNames()
102 if (taken.some(name => name === next && name !== current))
103 throw new Error(`'${next}' is already taken. Server names have to be unique within a provider project.`)
104
105 const steps: OperationStep[] = []
106
107 // 1. The provider record first: a crash after this leaves the state pin stale,
108 // which degrades to label matching, rather than pinned to a name that does
109 // not exist yet.
110 if (effects.providerName && effects.renameProvider) {
111 const { providerName, renameProvider } = effects
112 steps.push({
113 id: 'provider',
114 title: 'Rename the server at the provider',
115 change: { from: current, to: next },
116 satisfied: async () => (await providerName()) === next,
117 apply: () => renameProvider(next),
118 })
119 }
120
121 // 2. The state pin, immediately after — see the note above findComputeTargets.
122 if (effects.stateName && effects.writeStateName) {
123 const { stateName, writeStateName } = effects
124 steps.push({
125 id: 'state-pin',
126 title: 'Update the recorded name in the local driver state',
127 change: { from: current, to: next },
128 // A project with no pin at all has nothing to update; treat it as done
129 // rather than writing a pin the deploy never asked for.
130 satisfied: async () => {
131 const recorded = await stateName()
132 return recorded === undefined || recorded === next
133 },
134 apply: () => writeStateName(next),
135 })
136 }
137
138 // 3. The box's own hostname. Last of the remote changes because it is the only
139 // cosmetic one — a box whose hostname lags is confusing, not broken.
140 if (effects.remoteHostname && effects.setRemoteHostname) {
141 const { remoteHostname, setRemoteHostname } = effects
142 steps.push({
143 id: 'hostname',
144 title: 'Set the hostname on the box',
145 change: { from: current, to: next },
146 satisfied: async () => (await remoteHostname()) === next,
147 apply: () => setRemoteHostname(next),
148 })
149 }
150
151 // 4. The inventory record last: it is what every `server:*` command addresses,
152 // so renaming it first would leave the operator addressing a server whose
153 // other three records still answer to the old name.
154 steps.push({
155 id: 'inventory',
156 title: 'Rename the fleet inventory record',
157 change: { from: current, to: next },
158 satisfied: async () => effects.inventoryName() === next,
159 apply: async () => {
160 await effects.renameInventory(next)
161 },
162 })
163
164 return { operation: 'server:rename', target: current, steps }
165}
166
167/**
168 * Shell that sets the box's hostname persistently and keeps `/etc/hosts` in
169 * step, so `sudo` and anything else resolving the local name does not stall on a
170 * hostname with no entry.
171 */
172export function buildSetHostnameScript(next: string): string {
173 const quoted = `'${next.replace(/'/g, `'"'"'`)}'`
174 return [
175 'set -eu',
176 `TS_CLOUD_OLD="$(hostname)"`,
177 `hostnamectl set-hostname ${quoted} 2>/dev/null || { echo ${quoted} > /etc/hostname && hostname ${quoted}; }`,
178 // Replace the old name where it stands rather than appending: a second
179 // 127.0.1.1 line would leave the box resolving its own name two ways.
180 `if grep -q "127.0.1.1" /etc/hosts; then`,
181 ` sed -i "s/^127\\.0\\.1\\.1.*/127.0.1.1\\t${next}/" /etc/hosts`,
182 'else',
183 ` printf '127.0.1.1\\t%s\\n' ${quoted} >> /etc/hosts`,
184 'fi',
185 'printf "%s -> %s\\n" "$TS_CLOUD_OLD" "$(hostname)"',
186 ].join('\n')
187}