ReviewOS

also looking at this

stacks/ts-cloud

fix(fleet): carry the on-box database when a site moves

#178
Merged chrisbbreuer wants to merge feat/site-move-database into main
4 files +295 -0
packages/ts-cloud/bin/commands/site.tsmodified+69-0
Changes to packages/ts-cloud/bin/commands/site.ts
@@ -5,11 +5,15 @@ import type { SiteMoveEffects } from '../../src/operations/site-move'
55import { existsSync } from 'node:fs'
66import { readFile, writeFile } from 'node:fs/promises'
77import * as cli from '../../src/utils/cli'
8import { resolveAppDatabase } from '@ts-cloud/core'
89import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane'
910import { createDnsProvider } from '../../src/dns'
1011import { normalizePublicIpv6, reconcileAddressRecords, verifyAddressRecord } from '../../src/deploy/server-dns'
1112import { addSiteToCloudConfig } from '../../src/deploy/site-config-editor'
1213import { siteInstallBase } from '../../src/deploy/site-target'
14import { buildBackupScript } from '../../src/deploy/dashboard-database'
15import { buildDatabaseSetupScript, isLocalDatabase } from '../../src/drivers/shared/db-provision'
16import { buildBackupRestoreScript } from '../../src/drivers/shared/backups'
1317import { buildRpxConfig, buildRpxFragmentRefreshScript } from '../../src/drivers/shared/rpx-gateway'
1418import { FleetStore, SystemFleetSshTransport } from '../../src/fleet'
1519import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan'
@@ -114,6 +118,16 @@ async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): P
114118 const zoneFor = (fqdn: string): string =>
115119 configuredZone && fqdn.endsWith(configuredZone) ? configuredZone : fqdn.split('.').slice(-2).join('.')
116120
121 // An ON-BOX engine database has to be carried separately: it lives in the
122 // engine's data directory, not in the site tree. An external one needs
123 // nothing — the target reaches the same endpoint the source did. SQLite
124 // needs nothing either; it is under `shared/` and rides along in the tree.
125 const appDatabase = resolveAppDatabase(config)
126 const onBoxDatabase =
127 appDatabase?.name && isLocalDatabase(appDatabase) ? { name: appDatabase.name } : undefined
128 const dumpPath = `/tmp/ts-cloud-move-${slug}-${siteName}.sql.gz`
129 const engine = (appDatabase?.engine ?? 'mysql') as 'mysql' | 'mariadb' | 'postgres'
130
117131 const effects: SiteMoveEffects = {
118132 runOnSource: (script) => execOn(transport, source, script),
119133 runOnTarget: (script) => execOn(transport, target, script),
@@ -165,6 +179,60 @@ async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): P
165179 )
166180 return published ? target.endpoint : undefined
167181 },
182 ...(onBoxDatabase
183 ? {
184 database: {
185 // The SAME dump the backup command takes, written to a known path
186 // so the transfer and the restore can find it without parsing.
187 dump: async () => {
188 await execOn(
189 transport,
190 source,
191 [
192 'set -euo pipefail',
193 'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
194 ...buildBackupScript(engine, onBoxDatabase.name, '/tmp', appDatabase),
195 `mv -f "$(ls -1t /tmp/${onBoxDatabase.name}-*.sql.gz | head -1)" ${dumpPath}`,
196 ].join('\n'),
197 )
198 },
199 dumpStaged: async () => {
200 const result = await transport.exec(target, `test -s ${dumpPath} && echo staged || true`)
201 return result.stdout.includes('staged')
202 },
203 transferDump: async () => {
204 const local = `${process.cwd()}/.ts-cloud-move-${slug}-${siteName}.sql.gz`
205 await copyFile(source, `${source.sshUser}@${source.endpoint}:${dumpPath}`, local)
206 await copyFile(target, local, `${target.sshUser}@${target.endpoint}:${dumpPath}`)
207 await Bun.file(local).delete().catch(() => {})
208 },
209 restore: async () => {
210 await execOn(
211 transport,
212 target,
213 [
214 // Create the role + database first, with the SAME idempotent
215 // script provisioning runs, then load the dump into it.
216 ...buildDatabaseSetupScript(appDatabase, config.infrastructure?.compute?.managedServices ?? {}),
217 ...buildBackupRestoreScript(appDatabase, { from: dumpPath }),
218 `rm -f ${dumpPath}`,
219 ].join('\n'),
220 )
221 },
222 targetHasData: async () => {
223 const query =
224 engine === 'postgres'
225 ? `psql -tAc "select count(*) from information_schema.tables where table_schema='public'" -d ${onBoxDatabase.name} 2>/dev/null || echo 0`
226 : `mysql -N -B -e "select count(*) from information_schema.tables where table_schema='${onBoxDatabase.name}'" 2>/dev/null || echo 0`
227 const result = await transport.exec(
228 target,
229 `eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true\n${query}`,
230 )
231 return Number.parseInt(result.stdout.trim().split('\n').pop() ?? '0', 10) > 0
232 },
233 },
234 }
235 : {}),
168236 cutoverDns: async () => {
169237 if (!domain) return []
170238 if (!dnsName) return [`No DNS provider configured — point ${domain} at ${target.endpoint} manually.`]
@@ -191,6 +259,7 @@ async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): P
191259 targetAddress: target.endpoint,
192260 port: site.port,
193261 healthCheckPath: site.healthCheck?.path,
262 database: onBoxDatabase,
194263 },
195264 effects,
196265 )