also looking at this
feat(fleet): move a deployed site to another server (#167 operation 2)
#176
4 files
+951
-0
| @@ -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') |