ReviewOS

also looking at this

stacks/ts-cloud

feat(fleet): move a deployed site to another server (#167 operation 2)

#176
Merged glennmichael123 wants to merge feat/site-move into main
4 files +951 -0
docs/cli.mdmodified+48-0
Changes to docs/cli.md
@@ -155,6 +155,7 @@ See [Preview environments](/features/preview-environments) for policy, source li
155155| `cloud server:ssh <name>` / `server:logs <name>` / `server:monitoring <name>` | Connect / logs / metrics. |
156156| `cloud server:deploy <name>` / `server:reboot` / `server:resize <name> <type>` | Deploy / reboot / resize. |
157157| `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. |
158159| `cloud server:recipe <name> <recipe>` | Run a reusable script across servers. |
159160| `cloud server:worker:add/list/restart/remove` | Queue workers (Supervisor). |
160161| `cloud server:cron:add/list/remove` | Scheduled jobs / cron. |
@@ -165,6 +166,53 @@ See [Preview environments](/features/preview-environments) for policy, source li
165166
166167See [Laravel / Forge-style](/features/laravel) for the `infrastructure.compute` + `sites` config.
167168
169### Moving a site to another server
170
171Consolidation's second operation: three boxes with one app each becoming one box
172with three sites.
173
174```bash
175cloud site:move bughq --to statushq-box # plan only
176cloud site:move bughq --to statushq-box --apply # perform it
177```
178
179It moves the site's on-box footprint wholesale — the whole
180`/var/www/<slug>-<site>` tree (every release, `shared/`, the `current` symlink)
181plus the systemd units that run it. Those units are not regenerated, they are
182moved: rebuilding from the repo on the target would not be a move but a fresh
183deploy that happens to be preceded by a data copy, picking up whatever the repo
184says today rather than what is actually running.
185
186The order is chosen so that **every prefix of the plan is a working system**,
187on 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
198Nothing in the operation deletes anything, so a bad cutover is undone by starting
199the source's units again and pointing DNS back. That reversibility lasts until
200the source *server* is destroyed, which is a separate and deliberately separate
201command. Background units are enabled but not started on the target until the
202source is drained, so the two boxes can never both run a scheduler against one
203dataset.
204
205The archive travels through the machine running the command rather than directly
206between the boxes: a direct hop would need the target to hold a credential for
207the source, which is the same credential-radius problem consolidation already
208has. Both boxes must be enrolled with pinned host keys (`cloud server:validate`).
209
210Resuming works the same way as `server:rename` re-run the identical command and
211the finished steps skip themselves. Two steps deliberately never skip: the
212snapshot (an archive from an earlier attempt predates whatever the source has
213served since) and the health gate (a gate that remembers a previous pass is not a
214gate).
215
168216### Renaming a server
169217
170218A name is spelled in four places, and a rename is only done when all four agree:
packages/ts-cloud/bin/commands/site.tsmodified+255-0
Changes to packages/ts-cloud/bin/commands/site.ts
@@ -1,8 +1,20 @@
11import type { CLI } from '@stacksjs/clapp'
2import type { CloudConfig } from '@ts-cloud/core'
3import type { FleetServer } from '../../src/fleet'
4import type { SiteMoveEffects } from '../../src/operations/site-move'
25import { existsSync } from 'node:fs'
36import { readFile, writeFile } from 'node:fs/promises'
47import * as cli from '../../src/utils/cli'
8import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane'
9import { createDnsProvider } from '../../src/dns'
10import { normalizePublicIpv6, reconcileAddressRecords, verifyAddressRecord } from '../../src/deploy/server-dns'
511import { addSiteToCloudConfig } from '../../src/deploy/site-config-editor'
12import { siteInstallBase } from '../../src/deploy/site-target'
13import { buildRpxConfig, buildRpxFragmentRefreshScript } from '../../src/drivers/shared/rpx-gateway'
14import { FleetStore, SystemFleetSshTransport } from '../../src/fleet'
15import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan'
16import { planSiteMove, siteMoveArchivePath } from '../../src/operations/site-move'
17import { loadValidatedConfig, resolveDnsProviderConfig } from './shared'
618
719interface SiteAddOptions {
820 config?: string
@@ -18,7 +30,250 @@ interface SiteAddOptions {
1830 dryRun?: boolean
1931}
2032
33interface 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 */
48async 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. */
56async 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 */
74async 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
21262export 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 })
22277 app
23278 .command('site:add <name>', 'Add a site entry to cloud.config.ts')
24279 .option('--config <path>', 'Path to cloud config file')
packages/ts-cloud/src/operations/site-move.test.tsadded+287-0
Changes to packages/ts-cloud/src/operations/site-move.test.ts
@@ -0,0 +1,287 @@
1import type { SiteMoveEffects } from './site-move'
2import { describe, expect, it } from 'bun:test'
3import { applyPlan, formatPlan, resolvePlan } from './plan'
4import {
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
20const 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. */
31function 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
75describe('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
115describe('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
212describe('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})
packages/ts-cloud/src/operations/site-move.tsadded+361-0
Changes to packages/ts-cloud/src/operations/site-move.ts
@@ -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 */
35import type { OperationPlan, OperationStep } from './plan'
36
37/** Where a snapshot archive is staged on both boxes. */
38export 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. */
43export 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. */
48function 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 */
63export 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. */
73function 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 */
82export 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`. */
87function 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? */
96export 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 */
109export 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 */
133export 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? */
150export 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? */
158export 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 */
169export 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 */
191export 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. */
203function 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 */
211export function buildSourceStateScript(slug: string, siteName: string): string {
212 return reportActiveUnits(siteUnitPattern(slug, siteName))
213}
214
215/** Is the site fully stopped on the source? */
216export 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 */
224export 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
248export 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 */
271export 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}