also looking at this
fix(fleet): carry the on-box database when a site moves
#178
4 files
+295
-0
| @@ -5,11 +5,15 @@ import type { SiteMoveEffects } from '../../src/operations/site-move' | ||
| 5 | 5 | import { existsSync } from 'node:fs' |
| 6 | 6 | import { readFile, writeFile } from 'node:fs/promises' |
| 7 | 7 | import * as cli from '../../src/utils/cli' |
| 8 | import { resolveAppDatabase } from '@ts-cloud/core' | |
| 8 | 9 | import { initializeDashboardControlPlane } from '../../src/deploy/dashboard-control-plane' |
| 9 | 10 | import { createDnsProvider } from '../../src/dns' |
| 10 | 11 | import { normalizePublicIpv6, reconcileAddressRecords, verifyAddressRecord } from '../../src/deploy/server-dns' |
| 11 | 12 | import { addSiteToCloudConfig } from '../../src/deploy/site-config-editor' |
| 12 | 13 | import { siteInstallBase } from '../../src/deploy/site-target' |
| 14 | import { buildBackupScript } from '../../src/deploy/dashboard-database' | |
| 15 | import { buildDatabaseSetupScript, isLocalDatabase } from '../../src/drivers/shared/db-provision' | |
| 16 | import { buildBackupRestoreScript } from '../../src/drivers/shared/backups' | |
| 13 | 17 | import { buildRpxConfig, buildRpxFragmentRefreshScript } from '../../src/drivers/shared/rpx-gateway' |
| 14 | 18 | import { FleetStore, SystemFleetSshTransport } from '../../src/fleet' |
| 15 | 19 | import { applyPlan, formatPlan, resolvePlan } from '../../src/operations/plan' |
| @@ -114,6 +118,16 @@ async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): P | ||
| 114 | 118 | const zoneFor = (fqdn: string): string => |
| 115 | 119 | configuredZone && fqdn.endsWith(configuredZone) ? configuredZone : fqdn.split('.').slice(-2).join('.') |
| 116 | 120 | |
| 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 | ||
| 117 | 131 | const effects: SiteMoveEffects = { |
| 118 | 132 | runOnSource: (script) => execOn(transport, source, script), |
| 119 | 133 | runOnTarget: (script) => execOn(transport, target, script), |
| @@ -165,6 +179,60 @@ async function runSiteMove(siteName: string, options: SiteMoveCommandOptions): P | ||
| 165 | 179 | ) |
| 166 | 180 | return published ? target.endpoint : undefined |
| 167 | 181 | }, |
| 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 | : {}), | |
| 168 | 236 | cutoverDns: async () => { |
| 169 | 237 | if (!domain) return [] |
| 170 | 238 | 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 | ||
| 191 | 259 | targetAddress: target.endpoint, |
| 192 | 260 | port: site.port, |
| 193 | 261 | healthCheckPath: site.healthCheck?.path, |
| 262 | database: onBoxDatabase, | |
| 194 | 263 | }, |
| 195 | 264 | effects, |
| 196 | 265 | ) |