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
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` ·
docs/config.mdmodified+26-3
Changes to docs/config.md
@@ -149,9 +149,32 @@ own provider project cannot be attached at all, because its token cannot see the
149149owner's box. Co-hosting trades credential isolation for a shared box; that trade
150150is often worth making, but it should be a decision rather than a surprise.
151151
152`describeCredentialReach()` and `formatCredentialReach()` report what a given
153token actually reaches, separating the owner's boxes from the ones no one asked
154for, so the radius can be shown before an attach is approved.
152Every attach deploy states this radius before it acts on it, splitting the
153owner's boxes from the ones nobody asked for:
154
155```
156Attaching to 'statushq' shares one provider project, so this deploy's credential
157can write to all 4 server(s) it can see.
1583 of them belong to neither project:
159 bughq: bughq-production-app
160 stacks: stacks-production-app
161 not managed by ts-cloud: some-legacy-box
162A compromised CI run or a mistargeted teardown in this project now reaches those.
163Keep the app in its own provider project instead if that is not acceptable, which
164rules out attaching.
165```
166
167It is reported, never enforced the trade is frequently worth making, and a
168deploy that started failing on upgrade would teach operators to silence it
169rather than read it. When the reach is exactly the two projects being joined it
170is one quiet line, because a warning that fires every time is a warning nobody
171reads.
172
173The radius comes from the driver (`CloudDriver.listReachableResources()`), not
174from a global assumption about tokens: a provider whose credential can be
175scoped per-resource simply enumerates less, and the same report comes out
176correct without a special case. `describeCredentialReach()` and
177`formatCredentialReach()` are exported for building your own plan output.
155178
156179### It cannot install services on the owner's box
157180
packages/core/src/drivers/types.tsmodified+31-0
Changes to packages/core/src/drivers/types.ts
@@ -134,6 +134,37 @@ export interface CloudDriver {
134134
135135 /** Run a shell script on every target (SSM, SSH, etc.) */
136136 runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult>
137
138 /**
139 * Enumerate every resource this driver's credential can see — and therefore,
140 * on providers without per-resource scoping, write to and delete.
141 *
142 * Exists so nothing above the driver has to assume "one all-powerful token"
143 * is the only credential shape. Attaching to another project's box works by
144 * LISTING the provider with the attaching project's own credential, so the
145 * radius is a property of that credential rather than of the config, and only
146 * the driver holding it can report it. A driver whose credential IS narrowly
147 * scoped simply enumerates less, and the same reporting comes out right
148 * without a special case.
149 *
150 * Optional: a driver that cannot enumerate omits it, and callers report no
151 * radius rather than a wrong one.
152 *
153 * @see https://github.com/stacksjs/ts-cloud/issues/169
154 */
155 listReachableResources?(): Promise<ReachableResource[]>
156}
157
158/**
159 * One resource a provider credential can reach, reduced to what attribution
160 * needs: a name to print and the labels that say who owns it.
161 *
162 * Deliberately structural and provider-agnostic — a Hetzner server satisfies it
163 * as-is, and another driver can satisfy it without importing anything.
164 */
165export interface ReachableResource {
166 name: string
167 labels?: Record<string, string>
137168}
138169
139170export interface DeploySiteReleaseOptions {
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/deploy/attach-credentials.tsmodified+4-8
Changes to packages/ts-cloud/src/deploy/attach-credentials.ts
@@ -1,3 +1,4 @@
1import type { ReachableResource } from '@ts-cloud/core'
12import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes'
23
34/**
@@ -28,15 +29,10 @@ import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes'
2829const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project`
2930
3031/**
31 * The minimum a driver has to produce for a server to be attributed.
32 *
33 * Structural rather than a provider type so a Hetzner server satisfies it as-is
34 * and another driver can satisfy it without importing anything.
32 * The minimum a driver has to produce for a server to be attributed — the shape
33 * `CloudDriver.listReachableResources()` returns.
3534 */
36export interface ReachableServer {
37 name: string
38 labels?: Record<string, string>
39}
35export type ReachableServer = ReachableResource
4036
4137export interface CredentialReach {
4238 /** Every server the credential enumerated. */
packages/ts-cloud/src/drivers/hetzner/driver.tsmodified+12-1
Changes to packages/ts-cloud/src/drivers/hetzner/driver.ts
@@ -1,4 +1,4 @@
1import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core'
1import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, ReachableResource, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core'
22import type { RpxLbAppBox } from '../shared/rpx-gateway'
33import type { HetznerFirewall, HetznerFirewallRule, HetznerServer } from './client'
44import type { HetznerDriverState } from './state'
@@ -1239,6 +1239,17 @@ export class HetznerDriver implements CloudDriver {
12391239 return this.outputsFromState(state)
12401240 }
12411241
1242 /**
1243 * Every server this token can see. On Hetzner that is every server in the
1244 * provider project, because a Cloud API token is project-scoped with Read or
1245 * Read & Write and no per-resource scoping — so "can see" and "can delete"
1246 * are the same set, and the count is the honest blast radius.
1247 */
1248 async listReachableResources(): Promise<ReachableResource[]> {
1249 const servers = await this.client.listServers()
1250 return servers.map((server) => ({ name: server.name, labels: server.labels }))
1251 }
1252
12421253 async runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult> {
12431254 if (options.targets.length === 0) {
12441255 return { success: false, instanceCount: 0, perInstance: [], error: 'No targets provided' }
packages/ts-cloud/src/drivers/shared/compute-deploy.tsmodified+52-0
Changes to packages/ts-cloud/src/drivers/shared/compute-deploy.ts
@@ -4,6 +4,7 @@ import { copyFileSync } from 'node:fs'
44import { tmpdir } from 'node:os'
55import { join } from 'node:path'
66import { hasManagementDashboardSite, resolveAppDatabase, resolveProjectStackName } from '@ts-cloud/core'
7import { describeCredentialReach, formatCredentialReach, unrelatedReachCount } from '../../deploy/attach-credentials'
78import { buildManagementDashboardArtifact, ensureManagementDashboard, managementDashboardSiteNames } from '../../deploy/management-dashboard'
89import { isPhpSite, resolveSiteKind, siteInstallBase } from '../../deploy/site-target'
910import { buildSiteServicesScript, siteHasServices } from './app-services'
@@ -494,6 +495,53 @@ async function reconcileManagementDashboardServices(
494495 return true
495496}
496497
498/**
499 * Attach mode: report what this deploy's credential can actually reach.
500 *
501 * Attaching resolves the owner's box by LISTING the provider with the ATTACHING
502 * project's own credential. That listing is the whole mechanism, and it has a
503 * consequence the config never states — the owner's box has to be visible to
504 * this credential, so both projects share one provider project, and on a
505 * provider without per-resource scoping that means write over every server in
506 * it. Three apps that each owned one box become three pipelines that each reach
507 * all three.
508 *
509 * Reported, never enforced. The trade is frequently worth making, and a deploy
510 * that started failing on upgrade would teach operators to silence it rather
511 * than to read it. The quiet case stays quiet: when the reach is exactly the two
512 * projects being joined, this is one info line, because a warning that fires
513 * every time is a warning nobody reads.
514 *
515 * @see https://github.com/stacksjs/ts-cloud/issues/169
516 */
517async function reportAttachCredentialReach(
518 driver: CloudDriver,
519 options: DeployAllSitesOptions,
520 logger: ComputeDeployLogger,
521): Promise<void> {
522 const { config } = options
523 const ownerSlug = config.cloud?.attachTo
524 // A driver that cannot enumerate reports no radius rather than a wrong one.
525 if (!ownerSlug || !driver.listReachableResources) return
526
527 let resources
528 try {
529 resources = await driver.listReachableResources()
530 } catch (error) {
531 // Never fail a deploy over the advisory. A token that cannot list is a
532 // problem the real work reports far better than this can.
533 logger.warn(`Could not determine this credential's reach: ${error instanceof Error ? error.message : String(error)}`)
534 return
535 }
536
537 const reach = describeCredentialReach(resources, { ownerSlug, selfSlug: config.project.slug })
538 const surprising = unrelatedReachCount(reach) > 0
539 for (const line of formatCredentialReach(reach, { ownerSlug, selfSlug: config.project.slug })) {
540 if (surprising) logger.warn(line)
541 else logger.info(line)
542 }
543}
544
497545/**
498546 * Attach mode preflight: does the owner's box actually provide the on-box
499547 * services this project declares?
@@ -713,6 +761,10 @@ export async function deployAllComputeSites(options: DeployAllSitesOptions): Pro
713761 // ran green and the box kept a hand-maintained fragment.
714762 if (deployable.length === 0) return reloadRpxGateway(options)
715763
764 // Attach mode (`cloud.attachTo`): state the credential radius this attach
765 // implies before it is acted on. Advisory — see the function.
766 await reportAttachCredentialReach(driver, options, logger)
767
716768 // Attach mode (`cloud.attachTo`): before anything is built ON the owner's
717769 // services, confirm the owner's box actually runs them.
718770 if (!(await preflightAttachedHostServices(driver, options, logger))) return false
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}
packages/ts-cloud/test/drivers/compute-deploy.test.tsmodified+114-0
Changes to packages/ts-cloud/test/drivers/compute-deploy.test.ts
@@ -1100,3 +1100,117 @@ describe('deployAllComputeSites attach-mode service preflight', () => {
11001100 expect(commands).not.toContain('ts_cloud_probe')
11011101 })
11021102})
1103
1104/**
1105 * Attaching resolves the owner's box by LISTING the provider with the ATTACHING
1106 * project's credential, so the owner's box must be visible to it — which on a
1107 * provider without per-resource scoping means write over every server in the
1108 * project. Reported so it is a decision rather than a discovery.
1109 */
1110describe('deployAllComputeSites attach-mode credential reach', () => {
1111 function attachedConfig(): CloudConfig {
1112 return {
1113 project: { name: 'Log HQ', slug: 'loghq', region: 'fsn1' },
1114 environments: { production: { type: 'production' } },
1115 cloud: { provider: 'hetzner', attachTo: 'statushq' },
1116 sites: { web: { domain: 'loghq.example.com', port: 3000, root: '.output', start: 'bun run server.ts' } },
1117 infrastructure: { compute: { runtime: 'bun', proxy: { engine: 'rpx' } } },
1118 }
1119 }
1120
1121 async function deployWith(reachable: Array<{ name: string, labels?: Record<string, string> }> | Error) {
1122 const warnings: string[] = []
1123 const infos: string[] = []
1124 const driver = createMockDriver({
1125 name: 'hetzner',
1126 usesCloudFormation: false,
1127 listReachableResources: mock(async () => {
1128 if (reachable instanceof Error) throw reachable
1129 return reachable
1130 }),
1131 })
1132 const tempDir = mkdtempSync(join(tmpdir(), 'ts-cloud-reach-'))
1133 const tarball = join(tempDir, 'release.tar.gz')
1134 writeFileSync(tarball, 'fake tarball')
1135 process.env.TS_CLOUD_UI_DISABLE = '1'
1136 const ok = await deployAllComputeSites({
1137 config: attachedConfig(),
1138 environment: 'production',
1139 driver,
1140 sha: 'abc',
1141 runtime: 'bun',
1142 tarballForSite: () => tarball,
1143 logger: {
1144 info: (message: string) => infos.push(message),
1145 warn: (message: string) => warnings.push(message),
1146 error: () => {},
1147 step: () => {},
1148 success: () => {},
1149 },
1150 }).finally(() => {
1151 delete process.env.TS_CLOUD_UI_DISABLE
1152 rmSync(tempDir, { recursive: true, force: true })
1153 })
1154 return { ok, warnings: warnings.join('\n'), infos: infos.join('\n') }
1155 }
1156
1157 const label = (project: string) => ({ 'ts-cloud/project': project })
1158
1159 it('warns, naming the servers neither project owns', async () => {
1160 const { ok, warnings } = await deployWith([
1161 { name: 'statushq-production-app', labels: label('statushq') },
1162 { name: 'bughq-production-app', labels: label('bughq') },
1163 { name: 'stacks-production-app', labels: label('stacks') },
1164 { name: 'some-legacy-box' },
1165 ])
1166 expect(ok).toBe(true)
1167 expect(warnings).toContain('all 4 server(s)')
1168 expect(warnings).toContain('bughq: bughq-production-app')
1169 expect(warnings).toContain('stacks: stacks-production-app')
1170 expect(warnings).toContain('not managed by ts-cloud: some-legacy-box')
1171 })
1172
1173 /**
1174 * A warning that fires every time is a warning nobody reads: when the reach is
1175 * exactly the two projects being joined there is nothing to decide.
1176 */
1177 it('stays quiet when the reach is only the two projects being joined', async () => {
1178 const { ok, warnings, infos } = await deployWith([
1179 { name: 'statushq-production-app', labels: label('statushq') },
1180 { name: 'loghq-production-app', labels: label('loghq') },
1181 ])
1182 expect(ok).toBe(true)
1183 expect(warnings).toBe('')
1184 expect(infos).toContain('Nothing outside the two projects being joined is reachable with it.')
1185 })
1186
1187 it('never fails the deploy when the credential cannot enumerate', async () => {
1188 const { ok, warnings } = await deployWith(new Error('403 forbidden'))
1189 expect(ok).toBe(true)
1190 expect(warnings).toContain('403 forbidden')
1191 })
1192
1193 it('reports nothing for a driver that cannot enumerate at all', async () => {
1194 const driver = createMockDriver({ name: 'hetzner', usesCloudFormation: false })
1195 expect(driver.listReachableResources).toBeUndefined()
1196 const warnings: string[] = []
1197 const tempDir = mkdtempSync(join(tmpdir(), 'ts-cloud-reach-'))
1198 const tarball = join(tempDir, 'release.tar.gz')
1199 writeFileSync(tarball, 'fake tarball')
1200 process.env.TS_CLOUD_UI_DISABLE = '1'
1201 const ok = await deployAllComputeSites({
1202 config: attachedConfig(),
1203 environment: 'production',
1204 driver,
1205 sha: 'abc',
1206 runtime: 'bun',
1207 tarballForSite: () => tarball,
1208 logger: { info: () => {}, warn: (m: string) => warnings.push(m), error: () => {}, step: () => {}, success: () => {} },
1209 }).finally(() => {
1210 delete process.env.TS_CLOUD_UI_DISABLE
1211 rmSync(tempDir, { recursive: true, force: true })
1212 })
1213 expect(ok).toBe(true)
1214 expect(warnings.join('\n')).not.toContain('credential')
1215 })
1216})