also looking at this
feat(fleet): move a deployed site to another server (#167 operation 2)
#176
4 files
+951
-0
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -155,6 +155,7 @@ See [Preview environments](/features/preview-environments) for policy, source li | ||
| 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 | 157 | | `cloud server:rename <name> <new-name> [--apply]` | Rename a server in place. Prints the plan; `--apply` performs it. | |
| 158 | | `cloud site:move <name> --to <server> [--apply]` | Move a deployed site to another server, DNS cutover included. | | |
| 158 | 159 | | `cloud server:recipe <name> <recipe>` | Run a reusable script across servers. | |
| 159 | 160 | | `cloud server:worker:add/list/restart/remove` | Queue workers (Supervisor). | |
| 160 | 161 | | `cloud server:cron:add/list/remove` | Scheduled jobs / cron. | |
| @@ -165,6 +166,53 @@ See [Preview environments](/features/preview-environments) for policy, source li | ||
| 165 | 166 | |
| 166 | 167 | See [Laravel / Forge-style](/features/laravel) for the `infrastructure.compute` + `sites` config. |
| 167 | 168 | |
| 169 | ### Moving a site to another server | |
| 170 | ||
| 171 | Consolidation's second operation: three boxes with one app each becoming one box | |
| 172 | with three sites. | |
| 173 | ||
| 174 | ```bash | |
| 175 | cloud site:move bughq --to statushq-box # plan only | |
| 176 | cloud site:move bughq --to statushq-box --apply # perform it | |
| 177 | ``` | |
| 178 | ||
| 179 | It moves the site's on-box footprint wholesale — the whole | |
| 180 | `/var/www/<slug>-<site>` tree (every release, `shared/`, the `current` symlink) | |
| 181 | plus the systemd units that run it. Those units are not regenerated, they are | |
| 182 | moved: rebuilding from the repo on the target would not be a move but a fresh | |
| 183 | deploy that happens to be preceded by a data copy, picking up whatever the repo | |
| 184 | says today rather than what is actually running. | |
| 185 | ||
| 186 | The order is chosen so that **every prefix of the plan is a working system**, | |
| 187 | on the old box or the new one: | |
| 188 | ||
| 189 | | Step | Why there | | |
| 190 | |---|---| | |
| 191 | | Stop background work on the source | A queue worker writing mid-`tar` produces a torn snapshot. The web service keeps serving — the source is still live. | | |
| 192 | | Archive, carry, unpack, start | The target comes up but nothing routes to it yet. | | |
| 193 | | Health gate on the target's loopback | The public name still points at the source, so asking it would wave a broken target through. | | |
| 194 | | Route the site on the target | Same gateway builder the deploy uses, so a moved site is routed byte-identically to a deployed one. | | |
| 195 | | Cut DNS over | Only after the target has proved itself. A provider warning stops the run here rather than continuing to the drain. | | |
| 196 | | Drain the source | Units stopped and disabled, gateway fragment removed. **Files left in place.** | | |
| 197 | ||
| 198 | Nothing in the operation deletes anything, so a bad cutover is undone by starting | |
| 199 | the source's units again and pointing DNS back. That reversibility lasts until | |
| 200 | the source *server* is destroyed, which is a separate and deliberately separate | |
| 201 | command. Background units are enabled but not started on the target until the | |
| 202 | source is drained, so the two boxes can never both run a scheduler against one | |
| 203 | dataset. | |
| 204 | ||
| 205 | The archive travels through the machine running the command rather than directly | |
| 206 | between the boxes: a direct hop would need the target to hold a credential for | |
| 207 | the source, which is the same credential-radius problem consolidation already | |
| 208 | has. Both boxes must be enrolled with pinned host keys (`cloud server:validate`). | |
| 209 | ||
| 210 | Resuming works the same way as `server:rename` — re-run the identical command and | |
| 211 | the finished steps skip themselves. Two steps deliberately never skip: the | |
| 212 | snapshot (an archive from an earlier attempt predates whatever the source has | |
| 213 | served since) and the health gate (a gate that remembers a previous pass is not a | |
| 214 | gate). | |
| 215 | ||
| 168 | 216 | ### Renaming a server |
| 169 | 217 | |
| 170 | 218 | A name is spelled in four places, and a rename is only done when all four agree: |
| @@ -1,8 +1,20 @@ | ||
| 1 | 1 | import type { CLI } from '@stacksjs/clapp' |
| 2 | import type { CloudConfig } from '@ts-cloud/core' | |
| 3 | import type { FleetServer } from '../../src/fleet' | |
| 4 | import type { SiteMoveEffects } from '../../src/operations/site-move' | |
| 2 | 5 | import { existsSync } from 'node:fs' |
| 3 | 6 | import { readFile, writeFile } from 'node:fs/promises' |
| 4 | 7 | import * as cli from '../../src/utils/cli' |
| 8 | import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane' | |
| 9 | import { createDnsProvider } from '../../src/dns' | |
| 10 | import { normalizePublicIpv6, reconcileAddressRecords, verifyAddressRecord } from '../../src/deploy/server-dns' | |
| 5 | 11 | import { addSiteToCloudConfig } from '../../src/deploy/site-config-editor' |
| 12 | import { siteInstallBase } from '../../src/deploy/site-target' | |
| 13 | import { buildRpxConfig, buildRpxFragmentRefreshScript } from '../../src/drivers/shared/rpx-gateway' | |
| 14 | import { FleetStore, SystemFleetSshTransport } from '../../src/fleet' | |
| 15 | import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan' | |
| 16 | import { planSiteMove, siteMoveArchivePath } from '../../src/operations/site-move' | |
| 17 | import { loadValidatedConfig, resolveDnsProviderConfig } from './shared' | |
| 6 | 18 | |
| 7 | 19 | interface SiteAddOptions { |
| 8 | 20 | config?: string |
| @@ -18,7 +30,250 @@ interface SiteAddOptions { | ||
| 18 | 30 | dryRun?: boolean |
| 19 | 31 | } |
| 20 | 32 | |
| 33 | interface SiteMoveCommandOptions { | |
| 34 | to?: string | |
| 35 | from?: string | |
| 36 | apply?: boolean | |
| 37 | json?: boolean | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Run one script on a fleet server over the enrolled, host-key-pinned SSH | |
| 42 | * endpoint, and fail loudly on a non-zero exit. | |
| 43 | * | |
| 44 | * Pinned-only by construction (the transport refuses anything else): a move | |
| 45 | * copies an application's entire dataset between two machines, which is the last | |
| 46 | * place to accept an unverified host. | |
| 47 | */ | |
| 48 | async function execOn(transport: SystemFleetSshTransport, server: FleetServer, script: string): Promise<string> { | |
| 49 | const result = await transport.exec(server, script) | |
| 50 | if (result.code !== 0) | |
| 51 | throw new Error(`${server.name}: ${result.stderr.trim() || result.stdout.trim() || `exited ${result.code}`}`) | |
| 52 | return result.stdout | |
| 53 | } | |
| 54 | ||
| 55 | /** `scp` one file off a server, or onto it, over the enrolled endpoint. */ | |
| 56 | async function copyFile(server: FleetServer, from: string, to: string): Promise<void> { | |
| 57 | const child = Bun.spawn( | |
| 58 | ['scp', '-P', String(server.sshPort), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', from, to], | |
| 59 | { stdout: 'pipe', stderr: 'pipe' }, | |
| 60 | ) | |
| 61 | const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) | |
| 62 | if (code !== 0) throw new Error(`scp failed: ${stderr.trim() || `exited ${code}`}`) | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Move a deployed site to another box: plan it, print it, and perform it only | |
| 67 | * when asked. | |
| 68 | * | |
| 69 | * The archive travels THROUGH this machine rather than directly between the two | |
| 70 | * boxes. A direct hop would need the target to hold a credential for the source, | |
| 71 | * which is exactly the credential-radius problem consolidation already has | |
| 72 | * (#169) — and the operator running this already has verified access to both. | |
| 73 | */ | |
| 74 | async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): Promise<void> { | |
| 75 | if (!options.to) throw new Error('Name the target server with --to <server>.') | |
| 76 | ||
| 77 | const config = (await loadValidatedConfig()) as CloudConfig | |
| 78 | const site = config.sites?.[siteName] | |
| 79 | if (!site) throw new Error(`Site '${siteName}' is not in cloud.config.ts.`) | |
| 80 | ||
| 81 | const slug = config.project.slug | |
| 82 | const controlPlane = initializeDashboardControlPlane(process.cwd(), config) | |
| 83 | try { | |
| 84 | const store = new FleetStore(controlPlane.store) | |
| 85 | const servers = store.list(controlPlane.project.id) | |
| 86 | const pick = (name: string): FleetServer => { | |
| 87 | const found = servers.find((item) => item.id === name || item.name === name) | |
| 88 | if (!found) throw new Error(`Server ${name} was not found. Run \`cloud server:list\`.`) | |
| 89 | if (found.trustState !== 'pinned') | |
| 90 | throw new Error(`${found.name} has no pinned host key. Run \`cloud server:validate ${found.name}\` first.`) | |
| 91 | return found | |
| 92 | } | |
| 93 | ||
| 94 | const target = pick(options.to) | |
| 95 | // Without --from, the source is the only OTHER enrolled server, which is the | |
| 96 | // common shape; anything ambiguous has to be named rather than guessed. | |
| 97 | const candidates = servers.filter((item) => item.id !== target.id) | |
| 98 | if (!options.from && candidates.length !== 1) | |
| 99 | throw new Error( | |
| 100 | `Name the source server with --from <server> — ${candidates.length} others are enrolled.`, | |
| 101 | ) | |
| 102 | const source = pick(options.from ?? candidates[0].name) | |
| 103 | ||
| 104 | const transport = new SystemFleetSshTransport() | |
| 105 | const appBase = siteInstallBase(slug, siteName) | |
| 106 | const archive = siteMoveArchivePath(slug, siteName) | |
| 107 | const domain = site.domain | |
| 108 | const dnsName = config.infrastructure?.dns?.provider | |
| 109 | const proxy = config.infrastructure?.compute?.proxy?.engine === 'rpx' | |
| 110 | ? config.infrastructure.compute.proxy | |
| 111 | : undefined | |
| 112 | // The registrable apex: config `dns.domain` wins, else the last two labels. | |
| 113 | const configuredZone = config.infrastructure?.dns?.domain as string | undefined | |
| 114 | const zoneFor = (fqdn: string): string => | |
| 115 | configuredZone && fqdn.endsWith(configuredZone) ? configuredZone : fqdn.split('.').slice(-2).join('.') | |
| 116 | ||
| 117 | const effects: SiteMoveEffects = { | |
| 118 | runOnSource: (script) => execOn(transport, source, script), | |
| 119 | runOnTarget: (script) => execOn(transport, target, script), | |
| 120 | archiveStaged: async () => { | |
| 121 | const result = await transport.exec(target, `test -f ${archive} && echo staged || true`) | |
| 122 | return result.stdout.includes('staged') | |
| 123 | }, | |
| 124 | transferArchive: async () => { | |
| 125 | const local = `${process.cwd()}/.ts-cloud-move-${slug}-${siteName}.tar.gz` | |
| 126 | await copyFile(source, `${source.sshUser}@${source.endpoint}:${archive}`, local) | |
| 127 | await copyFile(target, local, `${target.sshUser}@${target.endpoint}:${archive}`) | |
| 128 | await Bun.file(local).delete().catch(() => {}) | |
| 129 | }, | |
| 130 | targetRoutesSite: async () => { | |
| 131 | if (!proxy || !domain) return true | |
| 132 | const result = await transport.exec( | |
| 133 | target, | |
| 134 | `grep -qF ${JSON.stringify(domain)} /etc/rpx/sites.d/${slug}.json 2>/dev/null && echo routed || true`, | |
| 135 | ) | |
| 136 | return result.stdout.includes('routed') | |
| 137 | }, | |
| 138 | refreshTargetGateway: async () => { | |
| 139 | if (!proxy) | |
| 140 | throw new Error( | |
| 141 | 'This project does not run the rpx gateway, so the move cannot publish a route. ' | |
| 142 | + `Point ${target.name}'s web server at ${appBase}/current yourself, then re-run to continue.`, | |
| 143 | ) | |
| 144 | // The SAME builder the deploy uses to write the fragment — one code | |
| 145 | // path, so a moved site is routed byte-identically to a deployed one. | |
| 146 | await execOn(transport, target, buildRpxFragmentRefreshScript({ | |
| 147 | config: buildRpxConfig(config.sites ?? {}, { proxy, slug }), | |
| 148 | slug, | |
| 149 | preserveManagementDashboardRoutes: true, | |
| 150 | }).join('\n')) | |
| 151 | }, | |
| 152 | publishedAddress: async () => { | |
| 153 | // No hostname or no configured provider: there is no cutover to make, | |
| 154 | // so report the target as already published rather than looping on a | |
| 155 | // step that can never be satisfied. | |
| 156 | if (!domain || !dnsName) return target.endpoint | |
| 157 | const dnsConfig = resolveDnsProviderConfig(dnsName) | |
| 158 | if (!dnsConfig) return undefined | |
| 159 | const published = await verifyAddressRecord( | |
| 160 | createDnsProvider(dnsConfig), | |
| 161 | zoneFor(domain), | |
| 162 | domain, | |
| 163 | target.endpoint, | |
| 164 | 'A', | |
| 165 | ) | |
| 166 | return published ? target.endpoint : undefined | |
| 167 | }, | |
| 168 | cutoverDns: async () => { | |
| 169 | if (!domain) return [] | |
| 170 | if (!dnsName) return [`No DNS provider configured — point ${domain} at ${target.endpoint} manually.`] | |
| 171 | const dnsConfig = resolveDnsProviderConfig(dnsName) | |
| 172 | if (!dnsConfig) return [`DNS provider '${dnsName}' is not configured.`] | |
| 173 | const report = await reconcileAddressRecords({ | |
| 174 | provider: createDnsProvider(dnsConfig), | |
| 175 | zone: zoneFor(domain), | |
| 176 | fqdn: domain, | |
| 177 | ipv4: target.endpoint, | |
| 178 | ipv6: normalizePublicIpv6(undefined), | |
| 179 | }) | |
| 180 | return report.warnings | |
| 181 | }, | |
| 182 | } | |
| 183 | ||
| 184 | const plan = await planSiteMove( | |
| 185 | { | |
| 186 | slug, | |
| 187 | siteName, | |
| 188 | appBase, | |
| 189 | from: source.name, | |
| 190 | to: target.name, | |
| 191 | targetAddress: target.endpoint, | |
| 192 | port: site.port, | |
| 193 | healthCheckPath: site.healthCheck?.path, | |
| 194 | }, | |
| 195 | effects, | |
| 196 | ) | |
| 197 | const resolved = await resolvePlan(plan) | |
| 198 | ||
| 199 | if (!options.apply) { | |
| 200 | if (options.json) { | |
| 201 | console.log( | |
| 202 | JSON.stringify( | |
| 203 | { | |
| 204 | schemaVersion: 1, | |
| 205 | operation: plan.operation, | |
| 206 | target: plan.target, | |
| 207 | move: { site: siteName, from: source.name, to: target.name, address: target.endpoint }, | |
| 208 | steps: resolved.map((item) => ({ | |
| 209 | id: item.step.id, | |
| 210 | title: item.step.title, | |
| 211 | state: item.state, | |
| 212 | change: item.step.change, | |
| 213 | reason: item.reason, | |
| 214 | })), | |
| 215 | }, | |
| 216 | null, | |
| 217 | 2, | |
| 218 | ), | |
| 219 | ) | |
| 220 | return | |
| 221 | } | |
| 222 | for (const line of formatPlan(plan, resolved)) console.log(line) | |
| 223 | console.log(` The source keeps its files either way — reversible until ${source.name} is destroyed.`) | |
| 224 | console.log(' Re-run with --apply to perform it.') | |
| 225 | return | |
| 226 | } | |
| 227 | ||
| 228 | const outcome = await applyPlan(plan, resolved, { | |
| 229 | log: (message) => cli.info(message), | |
| 230 | audit: (event) => | |
| 231 | controlPlane.store.appendEvent({ | |
| 232 | projectId: controlPlane.project.id, | |
| 233 | type: `${event.operation}.${event.step}.${event.state}`, | |
| 234 | level: event.state === 'failed' ? 'error' : 'info', | |
| 235 | payload: { | |
| 236 | site: siteName, | |
| 237 | from: source.name, | |
| 238 | to: target.name, | |
| 239 | ...(event.error ? { error: event.error } : {}), | |
| 240 | }, | |
| 241 | }), | |
| 242 | }) | |
| 243 | ||
| 244 | if (options.json) console.log(JSON.stringify({ schemaVersion: 1, outcome }, null, 2)) | |
| 245 | if (!outcome.success) { | |
| 246 | const failed = outcome.steps.find((step) => step.state === 'failed') | |
| 247 | throw new Error( | |
| 248 | `${plan.operation} stopped at '${failed?.title}': ${failed?.error}. ` | |
| 249 | + `${source.name} still has the site and its files; re-run the same command to continue from here.`, | |
| 250 | ) | |
| 251 | } | |
| 252 | if (!options.json) | |
| 253 | cli.success( | |
| 254 | `Moved ${siteName} from ${source.name} to ${target.name}. ` | |
| 255 | + `${source.name} keeps its copy until you destroy it.`, | |
| 256 | ) | |
| 257 | } finally { | |
| 258 | controlPlane.store.close() | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 21 | 262 | export function registerSiteCommands(app: CLI): void { |
| 263 | app | |
| 264 | .command('site:move <name>', 'Move a deployed site to another server') | |
| 265 | .option('--to <server>', 'Target server (enrolled name or id)') | |
| 266 | .option('--from <server>', 'Source server; inferred when only one other is enrolled') | |
| 267 | .option('--apply', 'Perform the reviewed move plan') | |
| 268 | .option('--json', 'Print structured JSON') | |
| 269 | .action(async (name: string, options: SiteMoveCommandOptions) => { | |
| 270 | try { | |
| 271 | await runSiteMove(name, options) | |
| 272 | } catch (error) { | |
| 273 | cli.error(error instanceof Error ? error.message : String(error)) | |
| 274 | process.exitCode = 1 | |
| 275 | } | |
| 276 | }) | |
| 22 | 277 | app |
| 23 | 278 | .command('site:add <name>', 'Add a site entry to cloud.config.ts') |
| 24 | 279 | .option('--config <path>', 'Path to cloud config file') |
| @@ -0,0 +1,287 @@ | ||
| 1 | import type { SiteMoveEffects } from './site-move' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { applyPlan, formatPlan, resolvePlan } from './plan' | |
| 4 | import { | |
| 5 | buildDrainSourceScript, | |
| 6 | buildHealthGateScript, | |
| 7 | buildPauseWorkersScript, | |
| 8 | buildRestoreScript, | |
| 9 | buildSnapshotScript, | |
| 10 | buildSourceStateScript, | |
| 11 | buildWorkersStateScript, | |
| 12 | parseSourceDrained, | |
| 13 | parseTargetReady, | |
| 14 | parseWorkersPaused, | |
| 15 | planSiteMove, | |
| 16 | siteMoveArchivePath, | |
| 17 | ||
| 18 | } from './site-move' | |
| 19 | ||
| 20 | const options = { | |
| 21 | slug: 'hq', | |
| 22 | siteName: 'bughq', | |
| 23 | appBase: '/var/www/hq-bughq', | |
| 24 | from: 'bughq-box', | |
| 25 | to: 'statushq-box', | |
| 26 | targetAddress: '203.0.113.9', | |
| 27 | port: 3010, | |
| 28 | } | |
| 29 | ||
| 30 | /** A world where the move has not started: source serving, target empty. */ | |
| 31 | function world() { | |
| 32 | const state = { | |
| 33 | workersRunning: true, | |
| 34 | onTarget: false, | |
| 35 | targetRunning: false, | |
| 36 | staged: false, | |
| 37 | published: '203.0.113.1', | |
| 38 | routed: false, | |
| 39 | sourceRunning: true, | |
| 40 | ran: [] as string[], | |
| 41 | } | |
| 42 | const effects: SiteMoveEffects = { | |
| 43 | runOnSource: async (script) => { | |
| 44 | state.ran.push(`source:${script.slice(0, 24)}`) | |
| 45 | if (script === buildWorkersStateScript('hq', 'bughq')) | |
| 46 | return state.workersRunning ? 'active:hq-bughq-scheduler.service' : '' | |
| 47 | if (script === buildSourceStateScript('hq', 'bughq')) | |
| 48 | return state.sourceRunning ? 'active:hq-bughq.service' : '' | |
| 49 | if (script.startsWith('set -eu\nfor unit') && script.includes('systemctl stop')) state.workersRunning = false | |
| 50 | if (script.includes('disable --now')) { state.sourceRunning = false; state.workersRunning = false } | |
| 51 | if (script.includes('tar czf')) state.staged = false | |
| 52 | return '' | |
| 53 | }, | |
| 54 | runOnTarget: async (script) => { | |
| 55 | state.ran.push(`target:${script.slice(0, 24)}`) | |
| 56 | if (script.startsWith('test -d')) | |
| 57 | return `${state.onTarget ? 'tree:present' : 'tree:absent'}\n${state.targetRunning ? 'unit:active' : 'unit:inactive'}` | |
| 58 | if (script.includes('tar xzf')) { state.onTarget = true; state.targetRunning = true } | |
| 59 | if (script.includes('curl')) { | |
| 60 | if (!state.targetRunning) throw new Error('no healthy response') | |
| 61 | return 'healthy' | |
| 62 | } | |
| 63 | return '' | |
| 64 | }, | |
| 65 | transferArchive: async () => { state.staged = true }, | |
| 66 | archiveStaged: async () => state.staged, | |
| 67 | cutoverDns: async () => { state.published = options.targetAddress; return [] }, | |
| 68 | publishedAddress: async () => state.published, | |
| 69 | refreshTargetGateway: async () => { state.routed = true }, | |
| 70 | targetRoutesSite: async () => state.routed, | |
| 71 | } | |
| 72 | return { state, effects } | |
| 73 | } | |
| 74 | ||
| 75 | describe('planSiteMove ordering', () => { | |
| 76 | /** | |
| 77 | * Every prefix of the plan has to be a working system — on the old box or the | |
| 78 | * new one. The source keeps serving until the target passes a health gate, | |
| 79 | * DNS moves only after that, and the source is drained last. | |
| 80 | */ | |
| 81 | it('gates health before routing, routes before DNS, and drains last', async () => { | |
| 82 | const p = await planSiteMove(options, world().effects) | |
| 83 | expect(p.steps.map(step => step.id)).toEqual([ | |
| 84 | 'pause-workers', | |
| 85 | 'snapshot', | |
| 86 | 'transfer', | |
| 87 | 'restore', | |
| 88 | 'health', | |
| 89 | 'gateway', | |
| 90 | 'dns', | |
| 91 | 'drain-source', | |
| 92 | ]) | |
| 93 | }) | |
| 94 | ||
| 95 | /** A worker or scheduler binds no port; gating on one would fail every move. */ | |
| 96 | it('omits the health gate for a portless site', async () => { | |
| 97 | const p = await planSiteMove({ ...options, port: undefined }, world().effects) | |
| 98 | expect(p.steps.map(step => step.id)).not.toContain('health') | |
| 99 | }) | |
| 100 | ||
| 101 | it('refuses a move to the box the site is already on', async () => { | |
| 102 | await expect(planSiteMove({ ...options, to: options.from }, world().effects)).rejects.toThrow('already on') | |
| 103 | }) | |
| 104 | ||
| 105 | /** | |
| 106 | * Nothing here deletes: the source tree survives, so a bad cutover is undone | |
| 107 | * by starting the units again. Irreversibility begins at `server:destroy`. | |
| 108 | */ | |
| 109 | it('declares no destructive step, because the source is drained and not deleted', async () => { | |
| 110 | const p = await planSiteMove(options, world().effects) | |
| 111 | expect(p.steps.some(step => step.destructive)).toBe(false) | |
| 112 | }) | |
| 113 | }) | |
| 114 | ||
| 115 | describe('planSiteMove execution', () => { | |
| 116 | it('moves the site end to end', async () => { | |
| 117 | const { state, effects } = world() | |
| 118 | const p = await planSiteMove(options, effects) | |
| 119 | const outcome = await applyPlan(p, await resolvePlan(p)) | |
| 120 | expect(outcome.success).toBe(true) | |
| 121 | expect(state.onTarget).toBe(true) | |
| 122 | expect(state.targetRunning).toBe(true) | |
| 123 | expect(state.routed).toBe(true) | |
| 124 | expect(state.published).toBe('203.0.113.9') | |
| 125 | expect(state.sourceRunning).toBe(false) | |
| 126 | }) | |
| 127 | ||
| 128 | /** | |
| 129 | * A cutover that reports success while editing nothing is the failure the | |
| 130 | * whole ordering exists to avoid — it must not reach the drain. | |
| 131 | */ | |
| 132 | it('refuses to drain the source when DNS reported a warning', async () => { | |
| 133 | const { state, effects } = world() | |
| 134 | const p = await planSiteMove(options, { | |
| 135 | ...effects, | |
| 136 | cutoverDns: async () => ['bughq.example.com → 203.0.113.9 failed: zone not found'], | |
| 137 | }) | |
| 138 | const outcome = await applyPlan(p, await resolvePlan(p)) | |
| 139 | expect(outcome.success).toBe(false) | |
| 140 | expect(outcome.steps.at(-1)?.id).toBe('dns') | |
| 141 | expect(state.sourceRunning).toBe(true) | |
| 142 | }) | |
| 143 | ||
| 144 | it('leaves the source serving when the target never becomes healthy', async () => { | |
| 145 | const { state, effects } = world() | |
| 146 | const p = await planSiteMove(options, { | |
| 147 | ...effects, | |
| 148 | runOnTarget: async (script) => { | |
| 149 | if (script.startsWith('test -d')) return 'tree:absent\nunit:inactive' | |
| 150 | if (script.includes('curl')) throw new Error('no healthy response from http://127.0.0.1:3010/') | |
| 151 | return '' | |
| 152 | }, | |
| 153 | }) | |
| 154 | const outcome = await applyPlan(p, await resolvePlan(p)) | |
| 155 | expect(outcome.success).toBe(false) | |
| 156 | expect(outcome.steps.at(-1)?.id).toBe('health') | |
| 157 | expect(state.published).toBe('203.0.113.1') | |
| 158 | expect(state.sourceRunning).toBe(true) | |
| 159 | }) | |
| 160 | ||
| 161 | /** The point of the scaffolding: a run that died is resumed, not restarted. */ | |
| 162 | it('resumes after a failed cutover without re-transferring', async () => { | |
| 163 | const { state, effects } = world() | |
| 164 | let transfers = 0 | |
| 165 | const counted = { ...effects, transferArchive: async () => { transfers++; await effects.transferArchive() } } | |
| 166 | let failDns = true | |
| 167 | const withFlakyDns = { | |
| 168 | ...counted, | |
| 169 | cutoverDns: async () => (failDns ? ['provider timed out'] : counted.cutoverDns()), | |
| 170 | } | |
| 171 | ||
| 172 | const first = await planSiteMove(options, withFlakyDns) | |
| 173 | expect((await applyPlan(first, await resolvePlan(first))).success).toBe(false) | |
| 174 | expect(transfers).toBe(1) | |
| 175 | ||
| 176 | failDns = false | |
| 177 | const second = await planSiteMove(options, withFlakyDns) | |
| 178 | const resolved = await resolvePlan(second) | |
| 179 | // Everything up to the cutover is already done and skips itself. | |
| 180 | expect(resolved.find(item => item.step.id === 'transfer')?.state).toBe('satisfied') | |
| 181 | expect(resolved.find(item => item.step.id === 'restore')?.state).toBe('satisfied') | |
| 182 | expect((await applyPlan(second, resolved)).success).toBe(true) | |
| 183 | expect(transfers).toBe(1) | |
| 184 | expect(state.sourceRunning).toBe(false) | |
| 185 | }) | |
| 186 | ||
| 187 | /** | |
| 188 | * The health gate decides whether traffic may move. A gate that remembers a | |
| 189 | * previous pass is not a gate. | |
| 190 | */ | |
| 191 | it('re-runs the health gate on every attempt', async () => { | |
| 192 | const p = await planSiteMove(options, world().effects) | |
| 193 | const resolved = await resolvePlan(p) | |
| 194 | expect(resolved.find(item => item.step.id === 'health')?.state).toBe('pending') | |
| 195 | expect(resolved.find(item => item.step.id === 'snapshot')?.state).toBe('pending') | |
| 196 | }) | |
| 197 | ||
| 198 | it('prints a plan that names both boxes before touching anything', async () => { | |
| 199 | const { state, effects } = world() | |
| 200 | const p = await planSiteMove(options, effects) | |
| 201 | const lines = formatPlan(p, await resolvePlan(p)).join('\n') | |
| 202 | expect(lines).toContain('site:move bughq') | |
| 203 | expect(lines).toContain('bughq-box:/var/www/hq-bughq → /tmp/ts-cloud-move-hq-bughq.tar.gz') | |
| 204 | expect(lines).toContain('bughq-box → 203.0.113.9') | |
| 205 | // A plan changes nothing. | |
| 206 | expect(state.onTarget).toBe(false) | |
| 207 | expect(state.sourceRunning).toBe(true) | |
| 208 | expect(state.workersRunning).toBe(true) | |
| 209 | }) | |
| 210 | }) | |
| 211 | ||
| 212 | describe('remote scripts', () => { | |
| 213 | /** | |
| 214 | * Dereferencing symlinks would flatten `current` into a second copy of the | |
| 215 | * live release and turn every shared path back into a per-release file — the | |
| 216 | * exact failure `sharedPaths` exists to prevent. | |
| 217 | */ | |
| 218 | it('archives the tree without dereferencing symlinks', () => { | |
| 219 | const script = buildSnapshotScript('hq', 'bughq', '/var/www/hq-bughq') | |
| 220 | expect(script).toContain('tar czf') | |
| 221 | expect(script).not.toContain('tar czhf') | |
| 222 | expect(script).not.toContain(' -h ') | |
| 223 | expect(script).toContain('hq-bughq*.service') | |
| 224 | }) | |
| 225 | ||
| 226 | it('carries the unit files alongside the tree', () => { | |
| 227 | expect(buildRestoreScript('hq', 'bughq', '/var/www/hq-bughq')).toContain('-C /etc/systemd/system') | |
| 228 | expect(buildSnapshotScript('hq', 'bughq', '/var/www/hq-bughq')).toContain('-C /etc/systemd/system') | |
| 229 | }) | |
| 230 | ||
| 231 | /** Two boxes running one scheduler against one dataset is the thing to avoid. */ | |
| 232 | it('starts the app on the target but not its background units', () => { | |
| 233 | const script = buildRestoreScript('hq', 'bughq', '/var/www/hq-bughq') | |
| 234 | expect(script).toContain('systemctl restart hq-bughq.service') | |
| 235 | expect(script).not.toContain('scheduler') | |
| 236 | expect(script).not.toContain('queue') | |
| 237 | }) | |
| 238 | ||
| 239 | /** The public name still points at the SOURCE at gate time. */ | |
| 240 | it('polls loopback, never the public name', () => { | |
| 241 | const script = buildHealthGateScript(3010, '/health') | |
| 242 | expect(script).toContain('http://127.0.0.1:3010/health') | |
| 243 | expect(script).not.toContain('bughq') | |
| 244 | }) | |
| 245 | ||
| 246 | it('normalizes a health path without a leading slash', () => { | |
| 247 | expect(buildHealthGateScript(3010, 'health')).toContain('http://127.0.0.1:3010/health') | |
| 248 | }) | |
| 249 | ||
| 250 | it('pauses only background units, leaving the site serving', () => { | |
| 251 | const script = buildPauseWorkersScript('hq', 'bughq') | |
| 252 | expect(script).toContain('queue|daemon') | |
| 253 | expect(script).toContain('scheduler') | |
| 254 | expect(script).toContain('systemctl stop') | |
| 255 | expect(script).not.toContain('disable') | |
| 256 | }) | |
| 257 | ||
| 258 | it('drains every unit on the source but deletes no files', () => { | |
| 259 | const script = buildDrainSourceScript('hq', 'bughq') | |
| 260 | expect(script).toContain('systemctl disable --now') | |
| 261 | expect(script).toContain('rm -f /etc/rpx/sites.d/hq.json') | |
| 262 | expect(script).not.toContain('/var/www') | |
| 263 | expect(script).not.toContain('rm -rf') | |
| 264 | }) | |
| 265 | ||
| 266 | /** A dry run must not change the world — the checks only report. */ | |
| 267 | it('checks state without mutating it', () => { | |
| 268 | for (const script of [buildWorkersStateScript('hq', 'bughq'), buildSourceStateScript('hq', 'bughq')]) { | |
| 269 | expect(script).not.toContain('systemctl stop') | |
| 270 | expect(script).not.toContain('systemctl disable') | |
| 271 | expect(script).not.toContain('rm ') | |
| 272 | } | |
| 273 | }) | |
| 274 | ||
| 275 | it('parses unit and tree state', () => { | |
| 276 | expect(parseWorkersPaused('')).toBe(true) | |
| 277 | expect(parseWorkersPaused('active:hq-bughq-scheduler.service')).toBe(false) | |
| 278 | expect(parseSourceDrained('active:hq-bughq.service')).toBe(false) | |
| 279 | expect(parseTargetReady('tree:present\nunit:active')).toBe(true) | |
| 280 | expect(parseTargetReady('tree:present\nunit:inactive')).toBe(false) | |
| 281 | expect(parseTargetReady('tree:absent\nunit:active')).toBe(false) | |
| 282 | }) | |
| 283 | ||
| 284 | it('stages the archive under a slug- and site-scoped name', () => { | |
| 285 | expect(siteMoveArchivePath('hq', 'bughq')).toBe('/tmp/ts-cloud-move-hq-bughq.tar.gz') | |
| 286 | }) | |
| 287 | }) | |
| @@ -0,0 +1,361 @@ | ||
| 1 | /** | |
| 2 | * Move a deployed site from one box to another. | |
| 3 | * | |
| 4 | * The second of the consolidation operations: three boxes with one app each | |
| 5 | * should be able to become one box with three sites, and today that is an | |
| 6 | * afternoon of SSH. What makes it an afternoon is not the individual steps but | |
| 7 | * the fact that a half-finished one leaves nothing to tell you what already | |
| 8 | * happened — so this is expressed as a plan on the same scaffolding as | |
| 9 | * `server:rename` (see `./plan`), where every step re-derives its own state and | |
| 10 | * a run that dies is resumed by running it again. | |
| 11 | * | |
| 12 | * It moves the site's on-box footprint WHOLESALE — the whole | |
| 13 | * `/var/www/<slug>-<site>` tree (every release, `shared/`, and the `current` | |
| 14 | * symlink) plus the systemd units that run it. Copying those units is not a | |
| 15 | * parallel mechanism: they are literally the units the deploy wrote, moved. The | |
| 16 | * alternative, rebuilding from the repo on the target, would not be a move — it | |
| 17 | * would be a fresh deploy that happens to be preceded by a data copy, and it | |
| 18 | * would silently pick up whatever the repo says today rather than what is | |
| 19 | * actually running. | |
| 20 | * | |
| 21 | * **Nothing here is irreversible.** The source's tree is never deleted, only | |
| 22 | * drained: its units are stopped and disabled and its gateway route removed, so | |
| 23 | * a bad cutover is undone by starting them again and pointing DNS back. That is | |
| 24 | * the reversibility the operation is required to have — it lasts until the | |
| 25 | * source SERVER is deleted, which is a separate, deliberately separate, | |
| 26 | * destructive operation (`server:destroy`). | |
| 27 | * | |
| 28 | * Ordering is chosen for what a failure in the middle leaves behind. The source | |
| 29 | * keeps serving until the target has passed a health gate, DNS is cut over only | |
| 30 | * after that, and the source is drained last — so every prefix of this plan is a | |
| 31 | * working system, either on the old box or the new one. | |
| 32 | * | |
| 33 | * @see https://github.com/stacksjs/ts-cloud/issues/167 | |
| 34 | */ | |
| 35 | import type { OperationPlan, OperationStep } from './plan' | |
| 36 | ||
| 37 | /** Where a snapshot archive is staged on both boxes. */ | |
| 38 | export function siteMoveArchivePath(slug: string, siteName: string): string { | |
| 39 | return `/tmp/ts-cloud-move-${slug}-${siteName}.tar.gz` | |
| 40 | } | |
| 41 | ||
| 42 | /** Systemd unit-file glob covering a site's app, scheduler, queue and daemon units. */ | |
| 43 | export function siteUnitGlob(slug: string, siteName: string): string { | |
| 44 | return `${slug}-${siteName}*.service` | |
| 45 | } | |
| 46 | ||
| 47 | /** Single-quote a value for safe embedding in the generated shell. */ | |
| 48 | function sh(value: string): string { | |
| 49 | return `'${value.split('\'').join('\'\\\'\'')}'` | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Stop the site's BACKGROUND units on the source — scheduler, queue workers, | |
| 54 | * daemons — leaving the web service running. | |
| 55 | * | |
| 56 | * The snapshot has to be consistent, and background work is what writes to it: a | |
| 57 | * queue worker committing to a SQLite database halfway through `tar` produces an | |
| 58 | * archive with a torn page in it. The web service keeps serving because the | |
| 59 | * source is still the live site at this point; read traffic during the copy is | |
| 60 | * fine, and stopping it here would be an outage taken long before the target is | |
| 61 | * ready to replace it. | |
| 62 | */ | |
| 63 | export function buildPauseWorkersScript(slug: string, siteName: string): string { | |
| 64 | return [ | |
| 65 | 'set -eu', | |
| 66 | `for unit in $(ls /etc/systemd/system/ 2>/dev/null | grep -E ${sh(backgroundUnitPattern(slug, siteName))} || true); do`, | |
| 67 | ' systemctl stop "$unit" 2>/dev/null || true', | |
| 68 | 'done', | |
| 69 | ].join('\n') | |
| 70 | } | |
| 71 | ||
| 72 | /** Background units for a site: scheduler, queue workers, daemons. */ | |
| 73 | function backgroundUnitPattern(slug: string, siteName: string): string { | |
| 74 | return `^${slug}-${siteName}-((queue|daemon)-.*|scheduler)\\.service$` | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Read-only companion to {@link buildPauseWorkersScript}. Separate because a | |
| 79 | * plan's `satisfied()` must never mutate: if checking whether the workers are | |
| 80 | * paused also paused them, a dry run would change the world. | |
| 81 | */ | |
| 82 | export function buildWorkersStateScript(slug: string, siteName: string): string { | |
| 83 | return reportActiveUnits(backgroundUnitPattern(slug, siteName)) | |
| 84 | } | |
| 85 | ||
| 86 | /** Emit one `active:<unit>` line per running unit matching `pattern`. */ | |
| 87 | function reportActiveUnits(pattern: string): string { | |
| 88 | return [ | |
| 89 | `ls /etc/systemd/system/ 2>/dev/null | grep -E ${sh(pattern)} | while read -r unit; do`, | |
| 90 | ' systemctl is-active "$unit" >/dev/null 2>&1 && echo "active:$unit" || true', | |
| 91 | 'done', | |
| 92 | ].join('\n') | |
| 93 | } | |
| 94 | ||
| 95 | /** Are the site's background units all stopped on the source? */ | |
| 96 | export function parseWorkersPaused(output: string): boolean { | |
| 97 | return !output.split('\n').some(line => line.trim().startsWith('active:')) | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Archive the site's whole footprint on the source: its install tree and its | |
| 102 | * unit files, in one tarball. | |
| 103 | * | |
| 104 | * `-h` is deliberately NOT passed: `current` and the shared-path symlinks must | |
| 105 | * stay symlinks. Dereferencing them would flatten `current` into a second copy | |
| 106 | * of the live release and turn every shared path into a per-release file again — | |
| 107 | * which is the exact failure `sharedPaths` exists to prevent. | |
| 108 | */ | |
| 109 | export function buildSnapshotScript(slug: string, siteName: string, appBase: string): string { | |
| 110 | const archive = siteMoveArchivePath(slug, siteName) | |
| 111 | return [ | |
| 112 | 'set -eu', | |
| 113 | `test -d ${sh(appBase)} || { echo "no site tree at ${appBase}" >&2; exit 1; }`, | |
| 114 | `rm -f ${sh(archive)}`, | |
| 115 | // Two roots in one archive, each stored relative to its own parent so the | |
| 116 | // restore can place them without a path-rewriting step. | |
| 117 | `tar czf ${sh(archive)}` | |
| 118 | + ` -C "$(dirname ${sh(appBase)})" "$(basename ${sh(appBase)})"` | |
| 119 | + ` -C /etc/systemd/system $(cd /etc/systemd/system && ls ${siteUnitGlob(slug, siteName)} 2>/dev/null | tr '\\n' ' ')`, | |
| 120 | `sha256sum ${sh(archive)} | cut -d' ' -f1`, | |
| 121 | ].join('\n') | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Unpack the archive on the target and bring the app up. | |
| 126 | * | |
| 127 | * The app unit is started but the gateway is not yet pointed at it: the site | |
| 128 | * has to answer on loopback before anything is routed to it, and DNS is not | |
| 129 | * touched until it has. Background units are enabled but NOT started — they stay | |
| 130 | * paused until the source has been drained, so the two boxes can never both be | |
| 131 | * running the same scheduler against the same data. | |
| 132 | */ | |
| 133 | export function buildRestoreScript(slug: string, siteName: string, appBase: string): string { | |
| 134 | const archive = siteMoveArchivePath(slug, siteName) | |
| 135 | return [ | |
| 136 | 'set -eu', | |
| 137 | `test -f ${sh(archive)} || { echo "archive not staged at ${archive}" >&2; exit 1; }`, | |
| 138 | `mkdir -p "$(dirname ${sh(appBase)})"`, | |
| 139 | // Extract the tree first, then the units, from the same archive. | |
| 140 | `tar xzf ${sh(archive)} -C "$(dirname ${sh(appBase)})" "$(basename ${sh(appBase)})"`, | |
| 141 | `tar xzf ${sh(archive)} -C /etc/systemd/system --wildcards ${sh(siteUnitGlob(slug, siteName))} 2>/dev/null || true`, | |
| 142 | 'systemctl daemon-reload', | |
| 143 | `systemctl enable ${slug}-${siteName}.service 2>/dev/null || true`, | |
| 144 | `systemctl restart ${slug}-${siteName}.service`, | |
| 145 | `rm -f ${sh(archive)}`, | |
| 146 | ].join('\n') | |
| 147 | } | |
| 148 | ||
| 149 | /** Is the site's tree already on the target, with its app unit running? */ | |
| 150 | export function buildTargetStateScript(slug: string, siteName: string, appBase: string): string { | |
| 151 | return [ | |
| 152 | `test -d ${sh(appBase)} && echo tree:present || echo tree:absent`, | |
| 153 | `systemctl is-active ${slug}-${siteName}.service >/dev/null 2>&1 && echo unit:active || echo unit:inactive`, | |
| 154 | ].join('\n') | |
| 155 | } | |
| 156 | ||
| 157 | /** Does the target already hold the site's tree AND run its app unit? */ | |
| 158 | export function parseTargetReady(output: string): boolean { | |
| 159 | return output.includes('tree:present') && output.includes('unit:active') | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Health gate on the target, before any traffic is sent to it. | |
| 164 | * | |
| 165 | * Polls loopback rather than the public name on purpose: the public name still | |
| 166 | * points at the SOURCE at this point in the plan, so asking it would cheerfully | |
| 167 | * report the old box as healthy and wave a broken target through. | |
| 168 | */ | |
| 169 | export function buildHealthGateScript(port: number, path = '/', attempts = 10): string { | |
| 170 | const url = `http://127.0.0.1:${port}${path.startsWith('/') ? path : `/${path}`}` | |
| 171 | return [ | |
| 172 | 'set -eu', | |
| 173 | `for i in $(seq 1 ${attempts}); do`, | |
| 174 | ` if curl -fsS -o /dev/null --max-time 5 ${sh(url)}; then echo healthy; exit 0; fi`, | |
| 175 | ' sleep 3', | |
| 176 | 'done', | |
| 177 | `echo "no healthy response from ${url}" >&2`, | |
| 178 | 'exit 1', | |
| 179 | ].join('\n') | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Drain the site on the source: stop and disable every unit, and drop its | |
| 184 | * gateway fragment so the old box stops answering for it. | |
| 185 | * | |
| 186 | * The tree is left exactly where it is. That is what keeps the whole operation | |
| 187 | * reversible: undoing a bad cutover is `systemctl start` plus pointing DNS back, | |
| 188 | * with the data still sitting on the source. It stops being reversible when the | |
| 189 | * source SERVER is deleted, which this operation deliberately does not do. | |
| 190 | */ | |
| 191 | export function buildDrainSourceScript(slug: string, siteName: string): string { | |
| 192 | return [ | |
| 193 | 'set -eu', | |
| 194 | `for unit in $(ls /etc/systemd/system/ 2>/dev/null | grep -E ${sh(siteUnitPattern(slug, siteName))} || true); do`, | |
| 195 | ' systemctl disable --now "$unit" 2>/dev/null || true', | |
| 196 | 'done', | |
| 197 | `rm -f /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`, | |
| 198 | 'systemctl reload rpx-gateway.service 2>/dev/null || systemctl restart rpx-gateway.service 2>/dev/null || true', | |
| 199 | ].join('\n') | |
| 200 | } | |
| 201 | ||
| 202 | /** Every unit belonging to a site: the app, its template, and its background units. */ | |
| 203 | function siteUnitPattern(slug: string, siteName: string): string { | |
| 204 | return `^${slug}-${siteName}[-@.]` | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Read-only companion to {@link buildDrainSourceScript} — see | |
| 209 | * {@link buildWorkersStateScript} for why the two are not one script. | |
| 210 | */ | |
| 211 | export function buildSourceStateScript(slug: string, siteName: string): string { | |
| 212 | return reportActiveUnits(siteUnitPattern(slug, siteName)) | |
| 213 | } | |
| 214 | ||
| 215 | /** Is the site fully stopped on the source? */ | |
| 216 | export function parseSourceDrained(output: string): boolean { | |
| 217 | return !output.split('\n').some(line => line.trim().startsWith('active:')) | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * The side effects a move needs, injected so the operation is testable without | |
| 222 | * two live boxes, a provider, or a DNS account. | |
| 223 | */ | |
| 224 | export interface SiteMoveEffects { | |
| 225 | /** Run a script on the source box; resolves with stdout, rejects on failure. */ | |
| 226 | runOnSource: (script: string) => Promise<string> | |
| 227 | /** Run a script on the target box. */ | |
| 228 | runOnTarget: (script: string) => Promise<string> | |
| 229 | /** | |
| 230 | * Carry the staged archive from the source to the target. Separate from the | |
| 231 | * two exec effects because how bytes get between two boxes is a deployment | |
| 232 | * decision — through the operator, over a direct SSH hop, via object storage — | |
| 233 | * and none of it belongs in this plan. | |
| 234 | */ | |
| 235 | transferArchive: () => Promise<void> | |
| 236 | /** Is the archive already staged on the target? Lets the transfer resume. */ | |
| 237 | archiveStaged: () => Promise<boolean> | |
| 238 | /** Point the site's hostname at the target. Resolves with any provider warnings. */ | |
| 239 | cutoverDns: () => Promise<string[]> | |
| 240 | /** Address the site's hostname currently publishes, for the cutover check. */ | |
| 241 | publishedAddress: () => Promise<string | undefined> | |
| 242 | /** Refresh the target's gateway so it routes the site. */ | |
| 243 | refreshTargetGateway: () => Promise<void> | |
| 244 | /** Does the target's gateway already route this site? */ | |
| 245 | targetRoutesSite: () => Promise<boolean> | |
| 246 | } | |
| 247 | ||
| 248 | export interface SiteMoveOptions { | |
| 249 | slug: string | |
| 250 | siteName: string | |
| 251 | /** Install base on both boxes — `siteInstallBase(slug, siteName)`. */ | |
| 252 | appBase: string | |
| 253 | /** Source and target box names, for the plan's own prose. */ | |
| 254 | from: string | |
| 255 | to: string | |
| 256 | /** Target's public address, which DNS is cut over to. */ | |
| 257 | targetAddress: string | |
| 258 | /** Loopback port the health gate polls. Omitted for a portless site. */ | |
| 259 | port?: number | |
| 260 | /** Health-check path, defaulting to `/`. */ | |
| 261 | healthCheckPath?: string | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Build the plan that moves `siteName` from one box to another. | |
| 266 | * | |
| 267 | * Async because the preconditions — the site exists on the source, the target is | |
| 268 | * not already serving a different tree there — are checked here rather than | |
| 269 | * becoming steps. A precondition is not a unit of work. | |
| 270 | */ | |
| 271 | export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEffects): Promise<OperationPlan> { | |
| 272 | const { slug, siteName, appBase, from, to, targetAddress } = options | |
| 273 | ||
| 274 | if (from === to) throw new Error(`'${siteName}' is already on ${to}.`) | |
| 275 | ||
| 276 | const steps: OperationStep[] = [ | |
| 277 | { | |
| 278 | id: 'pause-workers', | |
| 279 | title: `Stop background work on ${from} so the snapshot is consistent`, | |
| 280 | satisfied: async () => parseWorkersPaused(await effects.runOnSource(buildWorkersStateScript(slug, siteName))), | |
| 281 | apply: async () => { | |
| 282 | await effects.runOnSource(buildPauseWorkersScript(slug, siteName)) | |
| 283 | }, | |
| 284 | }, | |
| 285 | { | |
| 286 | id: 'snapshot', | |
| 287 | title: `Archive the site's tree and units on ${from}`, | |
| 288 | change: { from: `${from}:${appBase}`, to: siteMoveArchivePath(slug, siteName) }, | |
| 289 | // Always re-taken: an archive from an earlier attempt predates whatever | |
| 290 | // the source served since, and a move that silently shipped stale data | |
| 291 | // would be worse than one that copies twice. | |
| 292 | satisfied: async () => false, | |
| 293 | apply: async () => { | |
| 294 | await effects.runOnSource(buildSnapshotScript(slug, siteName, appBase)) | |
| 295 | }, | |
| 296 | }, | |
| 297 | { | |
| 298 | id: 'transfer', | |
| 299 | title: `Carry the archive to ${to}`, | |
| 300 | change: { from, to }, | |
| 301 | satisfied: () => effects.archiveStaged(), | |
| 302 | apply: () => effects.transferArchive(), | |
| 303 | }, | |
| 304 | { | |
| 305 | id: 'restore', | |
| 306 | title: `Unpack the site on ${to} and start it`, | |
| 307 | change: { from: siteMoveArchivePath(slug, siteName), to: `${to}:${appBase}` }, | |
| 308 | satisfied: async () => parseTargetReady(await effects.runOnTarget(buildTargetStateScript(slug, siteName, appBase))), | |
| 309 | apply: async () => { | |
| 310 | await effects.runOnTarget(buildRestoreScript(slug, siteName, appBase)) | |
| 311 | }, | |
| 312 | }, | |
| 313 | ] | |
| 314 | ||
| 315 | // A portless site — a worker or a scheduler — has nothing to poll, and gating | |
| 316 | // on a port it never binds would fail every move of one. | |
| 317 | if (options.port != null) { | |
| 318 | const { port } = options | |
| 319 | steps.push({ | |
| 320 | id: 'health', | |
| 321 | title: `Wait for the site to answer on ${to} (127.0.0.1:${port})`, | |
| 322 | // Re-run every time: this is the gate that decides whether traffic may be | |
| 323 | // moved, and a gate that remembers a previous pass is not a gate. | |
| 324 | satisfied: async () => false, | |
| 325 | apply: async () => { | |
| 326 | await effects.runOnTarget(buildHealthGateScript(port, options.healthCheckPath ?? '/')) | |
| 327 | }, | |
| 328 | }) | |
| 329 | } | |
| 330 | ||
| 331 | steps.push( | |
| 332 | { | |
| 333 | id: 'gateway', | |
| 334 | title: `Route the site on ${to}`, | |
| 335 | satisfied: () => effects.targetRoutesSite(), | |
| 336 | apply: () => effects.refreshTargetGateway(), | |
| 337 | }, | |
| 338 | { | |
| 339 | id: 'dns', | |
| 340 | title: `Point DNS at ${to}`, | |
| 341 | change: { from: `${from}`, to: targetAddress }, | |
| 342 | satisfied: async () => (await effects.publishedAddress()) === targetAddress, | |
| 343 | apply: async () => { | |
| 344 | const warnings = await effects.cutoverDns() | |
| 345 | // A cutover that reported success while editing nothing is the failure | |
| 346 | // this whole ordering exists to avoid; refuse to continue to the drain. | |
| 347 | if (warnings.length > 0) throw new Error(warnings.join('; ')) | |
| 348 | }, | |
| 349 | }, | |
| 350 | { | |
| 351 | id: 'drain-source', | |
| 352 | title: `Stop the site on ${from}, leaving its files in place`, | |
| 353 | satisfied: async () => parseSourceDrained(await effects.runOnSource(buildSourceStateScript(slug, siteName))), | |
| 354 | apply: async () => { | |
| 355 | await effects.runOnSource(buildDrainSourceScript(slug, siteName)) | |
| 356 | }, | |
| 357 | }, | |
| 358 | ) | |
| 359 | ||
| 360 | return { operation: 'site:move', target: siteName, steps } | |
| 361 | } | |