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
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')