also looking at this
feat(consolidation): report an attach's credential radius, and rename a server in place
#175
12 files
+1138
-14
| @@ -154,6 +154,7 @@ See [Preview environments](/features/preview-environments) for policy, source li | ||
| 154 | 154 | | `cloud server:list` / `server:create <name>` / `server:destroy <name>` | Manage servers. | |
| 155 | 155 | | `cloud server:ssh <name>` / `server:logs <name>` / `server:monitoring <name>` | Connect / logs / metrics. | |
| 156 | 156 | | `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. | | |
| 157 | 158 | | `cloud server:recipe <name> <recipe>` | Run a reusable script across servers. | |
| 158 | 159 | | `cloud server:worker:add/list/restart/remove` | Queue workers (Supervisor). | |
| 159 | 160 | | `cloud server:cron:add/list/remove` | Scheduled jobs / cron. | |
| @@ -164,6 +165,49 @@ See [Preview environments](/features/preview-environments) for policy, source li | ||
| 164 | 165 | |
| 165 | 166 | See [Laravel / Forge-style](/features/laravel) for the `infrastructure.compute` + `sites` config. |
| 166 | 167 | |
| 168 | ### Renaming a server | |
| 169 | ||
| 170 | A name is spelled in four places, and a rename is only done when all four agree: | |
| 171 | the provider record, the local driver state pin | |
| 172 | (`storage/cloud/state/<stack>.json`), the box's own hostname, and the fleet | |
| 173 | inventory record. The state pin is the one that is not cosmetic — a deploy | |
| 174 | REFUSES a pinned server whose live name no longer matches the recorded one, a | |
| 175 | guard 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 | |
| 179 | cloud server:rename bughq hq-production-server # plan only | |
| 180 | cloud server:rename bughq hq-production-server --apply # perform it | |
| 181 | ``` | |
| 182 | ||
| 183 | Without `--apply` it prints the plan and changes nothing: | |
| 184 | ||
| 185 | ``` | |
| 186 | server: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 | ||
| 198 | Every step asks reality whether it is already done, so a rename that dies | |
| 199 | halfway is resumed by running the same command again — the finished steps skip | |
| 200 | themselves 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 | |
| 202 | under `not covered`: a hand-enrolled server has no provider record, a project | |
| 203 | deploying purely from labels has no state pin, and a box whose host key is not | |
| 204 | pinned cannot be reached to set a hostname (`cloud server:validate` first). | |
| 205 | ||
| 206 | Renaming is reversible by renaming back, so it needs no typed confirmation. It | |
| 207 | is also non-interactive by construction — the plan is data and the authorization | |
| 208 | is a flag — so it runs unattended from CI, and `--json` emits both the plan and | |
| 209 | the outcome. Each step appends to the operation log as it starts and finishes. | |
| 210 | ||
| 167 | 211 | ## Databases |
| 168 | 212 | |
| 169 | 213 | `cloud db:create` · `db:list` · `db:connect` · `db:tunnel` · `db:users:add` · `db:users:list` · |
| @@ -149,9 +149,32 @@ own provider project cannot be attached at all, because its token cannot see the | ||
| 149 | 149 | owner's box. Co-hosting trades credential isolation for a shared box; that trade |
| 150 | 150 | is often worth making, but it should be a decision rather than a surprise. |
| 151 | 151 | |
| 152 | `describeCredentialReach()` and `formatCredentialReach()` report what a given | |
| 153 | token actually reaches, separating the owner's boxes from the ones no one asked | |
| 154 | for, so the radius can be shown before an attach is approved. | |
| 152 | Every attach deploy states this radius before it acts on it, splitting the | |
| 153 | owner's boxes from the ones nobody asked for: | |
| 154 | ||
| 155 | ``` | |
| 156 | Attaching to 'statushq' shares one provider project, so this deploy's credential | |
| 157 | can write to all 4 server(s) it can see. | |
| 158 | 3 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 | |
| 162 | A compromised CI run or a mistargeted teardown in this project now reaches those. | |
| 163 | Keep the app in its own provider project instead if that is not acceptable, which | |
| 164 | rules out attaching. | |
| 165 | ``` | |
| 166 | ||
| 167 | It is reported, never enforced — the trade is frequently worth making, and a | |
| 168 | deploy that started failing on upgrade would teach operators to silence it | |
| 169 | rather than read it. When the reach is exactly the two projects being joined it | |
| 170 | is one quiet line, because a warning that fires every time is a warning nobody | |
| 171 | reads. | |
| 172 | ||
| 173 | The radius comes from the driver (`CloudDriver.listReachableResources()`), not | |
| 174 | from a global assumption about tokens: a provider whose credential can be | |
| 175 | scoped per-resource simply enumerates less, and the same report comes out | |
| 176 | correct without a special case. `describeCredentialReach()` and | |
| 177 | `formatCredentialReach()` are exported for building your own plan output. | |
| 155 | 178 | |
| 156 | 179 | ### It cannot install services on the owner's box |
| 157 | 180 | |
| @@ -134,6 +134,37 @@ export interface CloudDriver { | ||
| 134 | 134 | |
| 135 | 135 | /** Run a shell script on every target (SSM, SSH, etc.) */ |
| 136 | 136 | 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 | */ | |
| 165 | export interface ReachableResource { | |
| 166 | name: string | |
| 167 | labels?: Record<string, string> | |
| 137 | 168 | } |
| 138 | 169 | |
| 139 | 170 | export interface DeploySiteReleaseOptions { |
| @@ -2,6 +2,7 @@ import type { CLI } from '@stacksjs/clapp' | ||
| 2 | 2 | import type { EnvironmentType } from '@ts-cloud/core' |
| 3 | 3 | import type { HetznerResizeCheckpoint } from '../../src/drivers/hetzner/resize-state' |
| 4 | 4 | import type { ServerProvider, ServerRole } from '../../src/fleet' |
| 5 | import type { ServerRenameEffects } from '../../src/operations/server-rename' | |
| 5 | 6 | import { resolveProjectStackName } from '@ts-cloud/core' |
| 6 | 7 | import * as cli from '../../src/utils/cli' |
| 7 | 8 | import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane' |
| @@ -20,9 +21,11 @@ import { resolveHetznerServerType } from '../../src/drivers/hetzner/instance-siz | ||
| 20 | 21 | import { executeHetznerServerResize, planHetznerServerResize } from '../../src/drivers/hetzner/resize' |
| 21 | 22 | import { collectHetznerResizeManifest, prepareHetznerResize, verifyHetznerResize } from '../../src/drivers/hetzner/resize-remote' |
| 22 | 23 | import { acquireResizeLock, readResizeCheckpoint, writeResizeCheckpoint } from '../../src/drivers/hetzner/resize-state' |
| 23 | import { readDriverState } from '../../src/drivers/hetzner/state' | |
| 24 | import { readDriverState, writeDriverState } from '../../src/drivers/hetzner/state' | |
| 24 | 25 | import { usesRpxProxy } from '../../src/drivers/shared/rpx-gateway' |
| 25 | import { FleetService, FleetStore, SshFleetDriver } from '../../src/fleet' | |
| 26 | import { FleetService, FleetStore, SshFleetDriver, SystemFleetSshTransport } from '../../src/fleet' | |
| 27 | import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan' | |
| 28 | import { buildSetHostnameScript, planServerRename } from '../../src/operations/server-rename' | |
| 26 | 29 | import { unsupportedCommand } from './capability-command' |
| 27 | 30 | import { loadValidatedConfig } from './shared' |
| 28 | 31 | |
| @@ -482,6 +485,157 @@ async function runHetznerHostOptimization(name: string, options: OptimizeCommand | ||
| 482 | 485 | else cli.success(`${server.name} passed full host, rpx route, service, release, and data verification.`) |
| 483 | 486 | } |
| 484 | 487 | |
| 488 | interface 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 | */ | |
| 504 | async 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 | ||
| 485 | 639 | export function registerServerCommands(app: CLI): void { |
| 486 | 640 | app |
| 487 | 641 | .command('capabilities [server]', 'Show provider and target operation support') |
| @@ -673,6 +827,18 @@ export function registerServerCommands(app: CLI): void { | ||
| 673 | 827 | fail(error) |
| 674 | 828 | } |
| 675 | 829 | }) |
| 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 | }) | |
| 676 | 842 | app |
| 677 | 843 | .command('server:drain <name>', 'Drain without terminating') |
| 678 | 844 | .option('--complete', 'Mark movement complete') |
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { ReachableResource } from '@ts-cloud/core' | |
| 1 | 2 | import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes' |
| 2 | 3 | |
| 3 | 4 | /** |
| @@ -28,15 +29,10 @@ import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes' | ||
| 28 | 29 | const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project` |
| 29 | 30 | |
| 30 | 31 | /** |
| 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. | |
| 35 | 34 | */ |
| 36 | export interface ReachableServer { | |
| 37 | name: string | |
| 38 | labels?: Record<string, string> | |
| 39 | } | |
| 35 | export type ReachableServer = ReachableResource | |
| 40 | 36 | |
| 41 | 37 | export interface CredentialReach { |
| 42 | 38 | /** Every server the credential enumerated. */ |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core' | |
| 1 | import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, ReachableResource, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core' | |
| 2 | 2 | import type { RpxLbAppBox } from '../shared/rpx-gateway' |
| 3 | 3 | import type { HetznerFirewall, HetznerFirewallRule, HetznerServer } from './client' |
| 4 | 4 | import type { HetznerDriverState } from './state' |
| @@ -1239,6 +1239,17 @@ export class HetznerDriver implements CloudDriver { | ||
| 1239 | 1239 | return this.outputsFromState(state) |
| 1240 | 1240 | } |
| 1241 | 1241 | |
| 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 | ||
| 1242 | 1253 | async runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult> { |
| 1243 | 1254 | if (options.targets.length === 0) { |
| 1244 | 1255 | return { success: false, instanceCount: 0, perInstance: [], error: 'No targets provided' } |
| @@ -0,0 +1,127 @@ | ||
| 1 | import type { OperationPlan, OperationStep } from './plan' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { applyPlan, formatPlan, pendingSteps, planIsDestructive, resolvePlan } from './plan' | |
| 4 | ||
| 5 | function 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 | ||
| 14 | const plan = (steps: OperationStep[]): OperationPlan => ({ operation: 'server:rename', target: 'bughq', steps }) | |
| 15 | ||
| 16 | describe('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 | ||
| 40 | describe('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 | ||
| 71 | describe('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 | }) | |
| @@ -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`. */ | |
| 30 | export interface StepChange { | |
| 31 | from: string | |
| 32 | to: string | |
| 33 | } | |
| 34 | ||
| 35 | export 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 | ||
| 60 | export 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. */ | |
| 69 | export 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 | */ | |
| 85 | export 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. */ | |
| 98 | export 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? */ | |
| 103 | export 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 | */ | |
| 114 | export 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 | ||
| 142 | export 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 | ||
| 150 | export 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 | ||
| 158 | export 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 | */ | |
| 182 | export 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 | } | |
| @@ -0,0 +1,154 @@ | ||
| 1 | import type { ServerRenameEffects } from './server-rename' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { applyPlan, formatPlan, resolvePlan } from './plan' | |
| 4 | import { buildSetHostnameScript, planServerRename, validateServerName } from './server-rename' | |
| 5 | ||
| 6 | /** A fully-capable server: provider record, state pin, reachable box, inventory. */ | |
| 7 | function 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 | ||
| 23 | describe('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 | ||
| 39 | describe('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 | ||
| 57 | describe('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 | ||
| 141 | describe('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 | }) | |
| @@ -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 | */ | |
| 32 | import 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 | */ | |
| 45 | export 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 | */ | |
| 71 | export 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 | */ | |
| 92 | export 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 | */ | |
| 172 | export 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 | } | |
| @@ -1100,3 +1100,117 @@ describe('deployAllComputeSites attach-mode service preflight', () => { | ||
| 1100 | 1100 | expect(commands).not.toContain('ts_cloud_probe') |
| 1101 | 1101 | }) |
| 1102 | 1102 | }) |
| 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 | */ | |
| 1110 | describe('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 | }) | |