vibecarbon 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/biome.json +1 -1
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +8 -0
- package/carbon/package.json +25 -27
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +13 -13
- package/src/backup.js +11 -30
- package/src/destroy.js +5 -43
- package/src/lib/deploy/bundle.js +120 -5
- package/src/lib/deploy/compose/ha.js +114 -1
- package/src/lib/deploy/compose/index.js +243 -177
- package/src/lib/deploy/k8s/k3s.js +120 -67
- package/src/lib/deploy/orchestrator.js +22 -0
- package/src/lib/iac/index.js +45 -7
- package/src/restore.js +135 -54
- package/src/scale.js +33 -57
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
isLocalOnlyImageTag,
|
|
32
32
|
pullComposeImages,
|
|
33
33
|
runMigrations,
|
|
34
|
+
setupComposeBackupCron,
|
|
34
35
|
setupServer,
|
|
35
36
|
setupServerFiles,
|
|
36
37
|
sshRun,
|
|
@@ -161,6 +162,51 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
|
161
162
|
// POSTGRESQL STREAMING REPLICATION SETUP
|
|
162
163
|
// ============================================================================
|
|
163
164
|
|
|
165
|
+
// WAL-G / ARCHIVING DOVETAIL INVARIANTS (wal-g unify, 2026-05-31)
|
|
166
|
+
//
|
|
167
|
+
// Invariant 1 — archiving survives failover.
|
|
168
|
+
// carbon/docker-compose.yml hardcodes `archive_mode=on` and
|
|
169
|
+
// `archive_command=bash /etc/postgresql/wal-archive.sh %p` in the db
|
|
170
|
+
// container's `command:`. A promoted standby runs the SAME image/command,
|
|
171
|
+
// so it starts archiving WAL on the new timeline immediately after promotion.
|
|
172
|
+
// No extra step is needed.
|
|
173
|
+
// WALG_S3_PREFIX is derived from S3_BACKUP_BUCKET + PROJECT_NAME (see
|
|
174
|
+
// docker-compose.yml:197) and is identical on both nodes — same .env is
|
|
175
|
+
// deployed to both servers via setupServerFiles. The WAL timeline is therefore
|
|
176
|
+
// continuous across failover: pre-promotion WAL is on TL1, post-promotion WAL
|
|
177
|
+
// is on TL2, both under the same S3 prefix.
|
|
178
|
+
// Note: a PostgreSQL standby with archive_mode=on does NOT archive while it is
|
|
179
|
+
// in recovery — only the primary archives. There is no double-archive risk
|
|
180
|
+
// before failover.
|
|
181
|
+
//
|
|
182
|
+
// Invariant 2 — backup guard means only the active primary backs up.
|
|
183
|
+
// carbon/backup/compose-backup.sh checks `pg_is_in_recovery()='f'` before
|
|
184
|
+
// calling `wal-g backup-push` (and self-skips with exit 0 when no S3 backup
|
|
185
|
+
// target / credentials are configured). Before failover: standby cron is a
|
|
186
|
+
// no-op; primary cron backs up. After failover: promoted standby cron backs
|
|
187
|
+
// up; old primary (if still alive) is now a standby, so its cron is a no-op.
|
|
188
|
+
// The scheduled backup cron is installed on BOTH nodes at the end of
|
|
189
|
+
// deployComposeHA (see the setupComposeBackupCron fan-out after replication
|
|
190
|
+
// verification). The standby's cron is a guarded no-op until it is promoted —
|
|
191
|
+
// installing on both means the survivor always has the cron after a failover,
|
|
192
|
+
// so scheduled backups don't silently stop when DNS flips to the standby.
|
|
193
|
+
//
|
|
194
|
+
// Invariant 3 — restore seeds the primary; standby is REBUILT afterward.
|
|
195
|
+
// compose restore (restoreCompose in compose/index.js) rewinds the primary's
|
|
196
|
+
// data directory and replays WAL from S3. A standby that was streaming from the
|
|
197
|
+
// old (pre-restore) primary then has WAL *ahead* of the restored primary's LSN
|
|
198
|
+
// and CANNOT resume streaming — PG would reject it with "requested timeline
|
|
199
|
+
// does not contain minimum recovery point" (timeline divergence). The correct
|
|
200
|
+
// post-restore sequence is:
|
|
201
|
+
// 1. Restore the primary (restoreCompose → backup-fetch + archive recovery).
|
|
202
|
+
// 2. Re-seed the standby via configureStandbyReplication (pg_basebackup
|
|
203
|
+
// from the now-restored primary). This wipes the diverged standby state
|
|
204
|
+
// and brings it in sync with the new primary timeline.
|
|
205
|
+
// This is wired in restore.js:runComposeRestore — after restoreCompose on the
|
|
206
|
+
// primary, it re-runs configureStandbyReplication against the standby whenever
|
|
207
|
+
// deployMode === 'compose-ha' (skipped for single-region compose, which has no
|
|
208
|
+
// standby). configureStandbyReplication is exported from this file for that.
|
|
209
|
+
|
|
164
210
|
/**
|
|
165
211
|
* Configure the primary PostgreSQL server for streaming replication.
|
|
166
212
|
*
|
|
@@ -279,7 +325,7 @@ END $$;
|
|
|
279
325
|
* 3. Write standby.signal and configure primary_conninfo
|
|
280
326
|
* 4. Restart as hot standby
|
|
281
327
|
*/
|
|
282
|
-
async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
|
|
328
|
+
export async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
|
|
283
329
|
const remoteDir = `/opt/${projectName}`;
|
|
284
330
|
// replPassword is generated at create time from crypto.randomBytes and is
|
|
285
331
|
// restricted to base64url characters — no shell escaping needed.
|
|
@@ -293,6 +339,33 @@ async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, pro
|
|
|
293
339
|
);
|
|
294
340
|
}
|
|
295
341
|
|
|
342
|
+
// Ensure the physical replication slot exists on the PRIMARY before the
|
|
343
|
+
// standby's pg_basebackup -S below. configurePrimaryReplication creates it at
|
|
344
|
+
// deploy time, but a wal-g RESTORE overwrites the primary's PGDATA — which
|
|
345
|
+
// includes pg_replslot — so the slot is wiped. Without re-creating it,
|
|
346
|
+
// pg_basebackup fails late with `replication slot "vibecarbon_standby_slot"
|
|
347
|
+
// does not exist` (it connects + checkpoints, then the -Xs WAL receiver's
|
|
348
|
+
// START_REPLICATION errors). This bit the failover re-seed after a wal-g
|
|
349
|
+
// restore — the pg_dump restore never had this problem because it loaded
|
|
350
|
+
// logical data without touching pg_replslot. Idempotent (IF NOT EXISTS), so
|
|
351
|
+
// it's a safe no-op on the deploy path where the slot already exists.
|
|
352
|
+
// RCA 2026-06-01: compose-ha failover after wal-g restore.
|
|
353
|
+
const ensureSlotSql = `DO $$
|
|
354
|
+
BEGIN
|
|
355
|
+
IF NOT EXISTS (
|
|
356
|
+
SELECT 1 FROM pg_replication_slots WHERE slot_name = 'vibecarbon_standby_slot'
|
|
357
|
+
) THEN
|
|
358
|
+
PERFORM pg_create_physical_replication_slot('vibecarbon_standby_slot');
|
|
359
|
+
END IF;
|
|
360
|
+
END $$;`;
|
|
361
|
+
const ensureSlotB64 = Buffer.from(ensureSlotSql).toString('base64');
|
|
362
|
+
sshRun(
|
|
363
|
+
primaryIp,
|
|
364
|
+
sshKeyPath,
|
|
365
|
+
`cd ${remoteDir} && echo ${ensureSlotB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
|
|
366
|
+
{ timeout: 30_000 },
|
|
367
|
+
);
|
|
368
|
+
|
|
296
369
|
// Stop the standby database
|
|
297
370
|
sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
|
|
298
371
|
timeout: 60_000,
|
|
@@ -1170,6 +1243,39 @@ export async function deployComposeHA(options) {
|
|
|
1170
1243
|
p.log.warn('Streaming replication not detected — standby may fall behind');
|
|
1171
1244
|
}
|
|
1172
1245
|
|
|
1246
|
+
// Install the scheduled wal-g backup cron on BOTH nodes. Invariant 2: the
|
|
1247
|
+
// standby's cron is a harmless no-op — compose-backup.sh guards on
|
|
1248
|
+
// `pg_is_in_recovery()='f'` and exits 0 on a standby without pushing. We
|
|
1249
|
+
// install on both so that after a failover the SURVIVOR (whichever node was
|
|
1250
|
+
// promoted) already has the cron and keeps backing up — without it, a
|
|
1251
|
+
// failover to the standby would silently lose scheduled backups until the
|
|
1252
|
+
// operator re-ran deploy. setupComposeBackupCron defaults schedule + retain
|
|
1253
|
+
// when backupConfig is absent; compose-backup.sh self-skips (exit 0) when no
|
|
1254
|
+
// S3 backup target is configured, so an always-installed cron never hard-fails.
|
|
1255
|
+
//
|
|
1256
|
+
// allSettled (not all) + per-node warn: a cron-install hiccup on one node
|
|
1257
|
+
// must not fail an already-serving HA deploy, nor mask the other node. The
|
|
1258
|
+
// app + replication are already up by this point.
|
|
1259
|
+
onProgress('Installing backup cron on both servers...');
|
|
1260
|
+
const cronResults = await perfAsync('deploy.ha.compose.backupCron.both', () =>
|
|
1261
|
+
Promise.allSettled([
|
|
1262
|
+
perfAsync('deploy.ha.compose.backupCron.primary', async () =>
|
|
1263
|
+
setupComposeBackupCron(primaryIp, sshKeyPath, projectName, backupConfig),
|
|
1264
|
+
),
|
|
1265
|
+
perfAsync('deploy.ha.compose.backupCron.standby', async () =>
|
|
1266
|
+
setupComposeBackupCron(standbyIp, sshKeyPath, projectName, backupConfig),
|
|
1267
|
+
),
|
|
1268
|
+
]),
|
|
1269
|
+
);
|
|
1270
|
+
for (const [i, result] of cronResults.entries()) {
|
|
1271
|
+
if (result.status === 'rejected') {
|
|
1272
|
+
const node = i === 0 ? `primary (${primaryIp})` : `standby (${standbyIp})`;
|
|
1273
|
+
p.log.warn(
|
|
1274
|
+
`Scheduled backup cron install failed on ${node} (deploy still succeeded): ${result.reason?.message ?? result.reason}`,
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1173
1279
|
// Finalize config — promote pending → deployed
|
|
1174
1280
|
saveProjectConfig({
|
|
1175
1281
|
...projectConfig,
|
|
@@ -1568,6 +1674,13 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1568
1674
|
// — run pg_basebackup to guarantee schema parity, then promote. For a
|
|
1569
1675
|
// real disaster failover where primary is unreachable, skip the re-seed
|
|
1570
1676
|
// and promote whatever state standby has (best-effort recovery).
|
|
1677
|
+
//
|
|
1678
|
+
// WAL-G archiving invariant (Invariant 1): the promoted node starts
|
|
1679
|
+
// archiving on the new timeline immediately after pg_promote() — the
|
|
1680
|
+
// docker-compose.yml command hardcodes archive_mode=on + archive_command
|
|
1681
|
+
// on the db image. WALG_S3_PREFIX is the same on both nodes (same .env),
|
|
1682
|
+
// so the WAL timeline is continuous in S3 across the failover. No extra
|
|
1683
|
+
// action needed here.
|
|
1571
1684
|
const primaryReachable = await isPrimaryReachable(primaryServer.ip, sshKeyPath, remoteDir);
|
|
1572
1685
|
if (primaryReachable) {
|
|
1573
1686
|
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
@@ -11,16 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync
|
|
14
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import * as p from '@clack/prompts';
|
|
19
|
-
import { uploadS3Backup } from '../../backup-s3.js';
|
|
20
19
|
import { runCommand, runCommandAsync } from '../../command.js';
|
|
21
20
|
import { perfAsync, perfTimer } from '../../perf.js';
|
|
22
21
|
import { parseDotenv, shEscape } from '../../shell.js';
|
|
23
|
-
import { NO_PIN_SSH_OPTS,
|
|
22
|
+
import { NO_PIN_SSH_OPTS, sshRunScript } from '../../ssh.js';
|
|
24
23
|
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
25
24
|
|
|
26
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -825,216 +824,283 @@ export function destroyCompose(ip, sshKeyPath, projectName) {
|
|
|
825
824
|
}
|
|
826
825
|
|
|
827
826
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
827
|
+
* Build the shell command that runs the wal-g base backup on a Compose VPS.
|
|
828
|
+
*
|
|
829
|
+
* `carbon/backup/compose-backup.sh` is the SINGLE source of truth for the
|
|
830
|
+
* backup logic (guard + PGUSER=supabase_admin + wal-g backup-push + delete
|
|
831
|
+
* retain, plus a flock and set -e). This builder is a thin, quote-safe
|
|
832
|
+
* wrapper so the on-demand path and the cron path run byte-identical commands.
|
|
833
|
+
*
|
|
834
|
+
* The `RETAIN=<n>` prefix binds to `bash` (a real command, so the assignment
|
|
835
|
+
* is exported into the script's environment) — NOT to `cd` (a builtin, where
|
|
836
|
+
* it would be discarded). The script reads it via `${RETAIN:-7}`.
|
|
837
|
+
*
|
|
838
|
+
* Invoking via `bash backup/compose-backup.sh` (relative to remoteDir, which
|
|
839
|
+
* we cd into) means the script's exec bit is irrelevant — same lesson as
|
|
840
|
+
* wal-archive.sh. The bundle ships it to `${remoteDir}/backup/`.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} remoteDir Absolute path to the project directory on the VPS
|
|
843
|
+
* (e.g. /opt/myapp)
|
|
844
|
+
* @param {number} [retain=7] Number of full base backups to keep. Validated
|
|
845
|
+
* to a positive integer (injection-safe + sane).
|
|
846
|
+
* @returns {string}
|
|
834
847
|
*/
|
|
835
|
-
export function
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '').slice(0, 15);
|
|
839
|
-
const archiveName = `${projectName}_${timestamp}_full.tar.gz`;
|
|
840
|
-
const cleanFlags = clean ? '--clean --if-exists' : '';
|
|
841
|
-
|
|
842
|
-
// Step 1: Dump postgres database
|
|
843
|
-
const t1 = perfTimer('backup.pgDump.main');
|
|
844
|
-
sshRun(
|
|
845
|
-
ip,
|
|
846
|
-
sshKeyPath,
|
|
847
|
-
`cd ${remoteDir} && docker compose exec -T db pg_dump -U postgres ${cleanFlags} postgres | gzip > backups/_postgres.sql.gz`,
|
|
848
|
-
{ timeout: 300_000 },
|
|
849
|
-
);
|
|
850
|
-
t1.end();
|
|
851
|
-
|
|
852
|
-
// Step 2: Dump optional service databases (n8n, metabase) if they exist
|
|
853
|
-
const t2 = perfTimer('backup.pgDump.features');
|
|
854
|
-
sshRun(
|
|
855
|
-
ip,
|
|
856
|
-
sshKeyPath,
|
|
857
|
-
`cd ${remoteDir} && for db in n8n metabase; do docker compose exec -T db psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\${db}'" 2>/dev/null | grep -q 1 && docker compose exec -T db pg_dump -U postgres "\${db}" | gzip > "backups/_\${db}.sql.gz" || true; done`,
|
|
858
|
-
{ timeout: 300_000 },
|
|
859
|
-
);
|
|
860
|
-
t2.end();
|
|
861
|
-
|
|
862
|
-
// Step 3: Backup storage files if using file backend
|
|
863
|
-
const t3 = perfTimer('backup.storageFiles');
|
|
864
|
-
sshRun(
|
|
865
|
-
ip,
|
|
866
|
-
sshKeyPath,
|
|
867
|
-
`cd ${remoteDir} && FCOUNT=$(docker compose exec -T storage sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo 0) && [ "$FCOUNT" -gt 0 ] 2>/dev/null && docker compose exec -T storage tar czf - -C / var/lib/storage > backups/_storage.tar.gz || true`,
|
|
868
|
-
{ timeout: 300_000 },
|
|
869
|
-
);
|
|
870
|
-
t3.end();
|
|
871
|
-
|
|
872
|
-
// Step 4: Create combined archive from work files (prefixed with _)
|
|
873
|
-
const t4 = perfTimer('backup.combineArchive');
|
|
874
|
-
sshRun(
|
|
875
|
-
ip,
|
|
876
|
-
sshKeyPath,
|
|
877
|
-
`cd ${remoteDir}/backups && tar czf ${archiveName} $(ls _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz 2>/dev/null) && rm -f _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz`,
|
|
878
|
-
{ timeout: 120_000 },
|
|
879
|
-
);
|
|
880
|
-
t4.end();
|
|
881
|
-
|
|
882
|
-
return archiveName;
|
|
848
|
+
export function composeBackupCmd(remoteDir, retain = 7) {
|
|
849
|
+
const r = Number.isInteger(retain) && retain > 0 ? retain : 7;
|
|
850
|
+
return `cd ${remoteDir} && RETAIN=${r} bash backup/compose-backup.sh`;
|
|
883
851
|
}
|
|
884
852
|
|
|
885
853
|
/**
|
|
886
|
-
*
|
|
854
|
+
* Trigger a wal-g base backup on a Compose VPS via compose-backup.sh.
|
|
855
|
+
*
|
|
856
|
+
* wal-g pushes the backup directly to S3 from inside the db container —
|
|
857
|
+
* there is no local archive to scp or upload. Success = exit 0.
|
|
887
858
|
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
859
|
+
* @param {string} ip
|
|
860
|
+
* @param {string} sshKeyPath
|
|
861
|
+
* @param {string} projectName
|
|
862
|
+
* @param {object} [options]
|
|
863
|
+
* @param {number} [options.retain] Full base backups to keep (default 7).
|
|
891
864
|
*/
|
|
892
|
-
export
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
backupS3Config,
|
|
898
|
-
) {
|
|
899
|
-
const localTmpPath = join(tmpdir(), `vc-backup-${archiveName}`);
|
|
900
|
-
try {
|
|
901
|
-
await perfAsync('backup.scpFromServer', async () =>
|
|
902
|
-
scpDownload(ip, sshKeyPath, `/opt/${projectName}/backups/${archiveName}`, localTmpPath),
|
|
903
|
-
);
|
|
904
|
-
await perfAsync('backup.uploadS3', async () =>
|
|
905
|
-
uploadS3Backup(backupS3Config, localTmpPath, `backups/${archiveName}`),
|
|
906
|
-
);
|
|
907
|
-
} finally {
|
|
908
|
-
try {
|
|
909
|
-
unlinkSync(localTmpPath);
|
|
910
|
-
} catch {
|
|
911
|
-
// Ignore cleanup errors
|
|
912
|
-
}
|
|
913
|
-
}
|
|
865
|
+
export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
|
|
866
|
+
const remoteDir = `/opt/${projectName}`;
|
|
867
|
+
const t = perfTimer('backup.walgPush');
|
|
868
|
+
sshRun(ip, sshKeyPath, composeBackupCmd(remoteDir, options.retain ?? 7), { timeout: 900_000 });
|
|
869
|
+
t.end();
|
|
914
870
|
}
|
|
915
871
|
|
|
916
872
|
/**
|
|
917
|
-
* Set up automated backup cron on the VPS.
|
|
873
|
+
* Set up the automated wal-g backup cron on the VPS.
|
|
874
|
+
*
|
|
875
|
+
* Installs a crontab entry that runs compose-backup.sh on the configured
|
|
876
|
+
* schedule — the SAME builder the on-demand path uses, so there is exactly
|
|
877
|
+
* one quoting/invocation path. No awscli required — wal-g already has S3
|
|
878
|
+
* credentials via env vars in docker-compose.yml.
|
|
879
|
+
*
|
|
880
|
+
* The cron line contains no single quotes (the builder is plain
|
|
881
|
+
* `cd … && RETAIN=N bash …`), so it is installed quote-safely by piping the
|
|
882
|
+
* new crontab through stdin (heredoc) rather than wrapping it in `echo '…'`.
|
|
883
|
+
*
|
|
884
|
+
* @param {string} ip
|
|
885
|
+
* @param {string} sshKeyPath
|
|
886
|
+
* @param {string} projectName
|
|
887
|
+
* @param {object} [backupConfig]
|
|
888
|
+
* @param {string} [backupConfig.schedule]
|
|
889
|
+
* @param {number} [backupConfig.retentionDays]
|
|
890
|
+
* @param {object} [opts]
|
|
891
|
+
* @param {(ip: string, key: string, script: string, o: object) => unknown} [opts.runScript=sshRunScript]
|
|
892
|
+
* Injectable script runner — defaults to the real SSH runner; overridden in unit tests.
|
|
918
893
|
*/
|
|
919
|
-
export function setupComposeBackupCron(ip, sshKeyPath, projectName,
|
|
894
|
+
export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig, opts = {}) {
|
|
895
|
+
const { runScript = sshRunScript } = opts;
|
|
920
896
|
const remoteDir = `/opt/${projectName}`;
|
|
921
|
-
const schedule = backupConfig?.schedule || '0
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
897
|
+
const schedule = backupConfig?.schedule || '0 2 * * *';
|
|
898
|
+
const retain =
|
|
899
|
+
Number.isInteger(backupConfig?.retentionDays) && backupConfig.retentionDays > 0
|
|
900
|
+
? backupConfig.retentionDays
|
|
901
|
+
: 7;
|
|
902
|
+
|
|
903
|
+
const cronLine = `${schedule} ${composeBackupCmd(remoteDir, retain)} >> ${remoteDir}/backups/backup.log 2>&1`;
|
|
904
|
+
|
|
905
|
+
// Install quote-safely: build the next crontab on the server (existing minus
|
|
906
|
+
// any prior compose-backup.sh line, plus our line) and pipe via stdin. The
|
|
907
|
+
// cron line is fed to `cat` through a heredoc so no shell quoting can mangle
|
|
908
|
+
// it — there are no single quotes in it to collide with anyway.
|
|
909
|
+
const installScript = [
|
|
910
|
+
'set -e',
|
|
911
|
+
`mkdir -p ${remoteDir}/backups`,
|
|
912
|
+
'TMP_CRON=$(mktemp)',
|
|
913
|
+
'crontab -l 2>/dev/null | grep -v \'compose-backup.sh\' > "$TMP_CRON" || true',
|
|
914
|
+
'cat >> "$TMP_CRON" <<\'VC_CRON_EOF\'',
|
|
915
|
+
cronLine,
|
|
916
|
+
'VC_CRON_EOF',
|
|
917
|
+
'crontab "$TMP_CRON"',
|
|
918
|
+
'rm -f "$TMP_CRON"',
|
|
919
|
+
].join('\n');
|
|
920
|
+
|
|
921
|
+
runScript(ip, sshKeyPath, installScript, { timeout: 60_000 });
|
|
922
|
+
}
|
|
935
923
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
924
|
+
// ISO-8601 datetime: YYYY-MM-DDTHH:MM:SS followed by Z or ±HH:MM
|
|
925
|
+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Build the shell script that fetches a wal-g base backup and writes the
|
|
929
|
+
* archive-recovery configuration so postgres replays WAL from S3 and
|
|
930
|
+
* promotes to read-write on completion (or at a PITR target).
|
|
931
|
+
*
|
|
932
|
+
* This is a pure function (no I/O) so it can be unit-tested without SSH.
|
|
933
|
+
* The script is SCP'd to the server and bind-mounted into the db container
|
|
934
|
+
* via `docker compose run --rm -v …:/restore.sh:ro db bash /restore.sh`,
|
|
935
|
+
* which avoids the quoting collision that would arise from embedding
|
|
936
|
+
* single-quoted postgres config values in a deeply-nested ssh command.
|
|
937
|
+
*
|
|
938
|
+
* Mirrors the k8s walg-restore init container in carbon/k8s/values/supabase.values.yaml.
|
|
939
|
+
*
|
|
940
|
+
* @param {'latest' | string} target 'latest' for the most-recent base backup,
|
|
941
|
+
* or an ISO-8601 datetime string for point-in-time recovery.
|
|
942
|
+
* @returns {string} The bash script body.
|
|
943
|
+
*/
|
|
944
|
+
export function composeRestoreScript(target) {
|
|
945
|
+
if (target !== 'latest' && !ISO_DATETIME_RE.test(target)) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Invalid target ${JSON.stringify(target)}: must be "latest" or an ISO-8601 datetime (e.g. 2026-05-31T12:00:00Z)`,
|
|
943
948
|
);
|
|
944
949
|
}
|
|
945
950
|
|
|
946
|
-
//
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
951
|
+
// pitrLine goes inside the { ... } block — no separate redirect needed
|
|
952
|
+
const pitrLine = target !== 'latest' ? ` echo "recovery_target_time = '${target}'"` : null;
|
|
953
|
+
|
|
954
|
+
return [
|
|
955
|
+
'#!/bin/bash',
|
|
956
|
+
'set -euo pipefail',
|
|
957
|
+
'PGDATA=/var/lib/postgresql/data',
|
|
958
|
+
'',
|
|
959
|
+
'# Empty PGDATA — wal-g backup-fetch requires an empty target dir. We are',
|
|
960
|
+
'# inside the db container, which mounts the real <project>_db_data volume',
|
|
961
|
+
'# at $PGDATA, so this clears the correct data (unlike a `docker compose run',
|
|
962
|
+
"# -v db_data:/data` flag, which bypasses Compose's project-volume naming).",
|
|
963
|
+
'if [ -f "$PGDATA/PG_VERSION" ]; then',
|
|
964
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${PGDATA:?} is a deliberate bash "fail if unset" guard, not a JS template placeholder
|
|
965
|
+
' rm -rf "${PGDATA:?}/"* "${PGDATA:?}/".* 2>/dev/null || true',
|
|
966
|
+
'fi',
|
|
967
|
+
'',
|
|
968
|
+
'# Fetch the base backup from S3 (wal-g reads S3 credentials from container env)',
|
|
969
|
+
`wal-g backup-fetch "$PGDATA" LATEST`,
|
|
970
|
+
'',
|
|
971
|
+
'# Remove any recovery settings left in postgresql.auto.conf by a previous',
|
|
972
|
+
"# restore so they don't accumulate across cycles (postgres takes the last",
|
|
973
|
+
'# value, but stale duplicates are confusing).',
|
|
974
|
+
'if [ -f "$PGDATA/postgresql.auto.conf" ]; then',
|
|
975
|
+
' sed -i "/^restore_command =/d; /^recovery_target_action =/d; /^recovery_target_time =/d; /^recovery_target_timeline =/d" "$PGDATA/postgresql.auto.conf"',
|
|
976
|
+
'fi',
|
|
977
|
+
'',
|
|
978
|
+
'# Write archive-recovery config so postgres replays WAL segments from S3',
|
|
979
|
+
'# and promotes to read-write on end-of-WAL (or at the PITR target).',
|
|
980
|
+
'# Uses >> so any existing postgresql.auto.conf settings are preserved.',
|
|
981
|
+
'{',
|
|
982
|
+
` echo "restore_command = 'wal-g wal-fetch \\"%f\\" \\"%p\\"'"`,
|
|
983
|
+
` echo "recovery_target_action = 'promote'"`,
|
|
984
|
+
// Pin recovery to the FETCHED base backup's own timeline. Default is
|
|
985
|
+
// 'latest', which makes postgres chase the newest timeline it can find a
|
|
986
|
+
// .history file for. In HA, repeated restore→promote cycles accumulate
|
|
987
|
+
// DIVERGENT timelines in the shared wal-g S3 prefix, and 'latest' picks one
|
|
988
|
+
// that forked off BEFORE this base backup's checkpoint — postgres then
|
|
989
|
+
// crash-loops "requested timeline N is not a child of this server's history"
|
|
990
|
+
// / wal-g "Archive '0000000N.history' does not exist". 'current' recovers
|
|
991
|
+
// along the base backup's timeline to end-of-WAL, then promotes fresh.
|
|
992
|
+
// (RCA 2026-06-01: compose-ha kept-rig restore.)
|
|
993
|
+
` echo "recovery_target_timeline = 'current'"`,
|
|
994
|
+
pitrLine,
|
|
995
|
+
'} >> "$PGDATA/postgresql.auto.conf"',
|
|
996
|
+
'',
|
|
997
|
+
'# Signal file that tells postgres to enter archive recovery mode (PG 12+)',
|
|
998
|
+
'touch "$PGDATA/recovery.signal"',
|
|
999
|
+
]
|
|
1000
|
+
.filter((line) => line !== null)
|
|
1001
|
+
.join('\n');
|
|
959
1002
|
}
|
|
960
1003
|
|
|
961
1004
|
/**
|
|
962
|
-
* Restore from a backup
|
|
963
|
-
*
|
|
1005
|
+
* Restore from a wal-g base backup via Docker Compose with full archive
|
|
1006
|
+
* recovery. Postgres replays WAL segments from S3 via restore_command and
|
|
1007
|
+
* promotes to read-write on reaching end-of-WAL or the PITR target.
|
|
1008
|
+
*
|
|
1009
|
+
* S3 credentials are read from the db container's environment (sourced from
|
|
1010
|
+
* the project .env file) — DO NOT pass them as -e overrides; the container
|
|
1011
|
+
* already has WALG_S3_PREFIX + AWS_* set by carbon/docker-compose.yml.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {string} ip
|
|
1014
|
+
* @param {string} sshKeyPath
|
|
1015
|
+
* @param {string} projectName
|
|
1016
|
+
* @param {'latest' | string} [target='latest'] 'latest' or ISO-8601 PITR timestamp.
|
|
964
1017
|
*/
|
|
965
|
-
export async function restoreCompose(ip, sshKeyPath, projectName,
|
|
1018
|
+
export async function restoreCompose(ip, sshKeyPath, projectName, target = 'latest') {
|
|
966
1019
|
const remoteDir = `/opt/${projectName}`;
|
|
967
|
-
const isWalg = backupFile === 'latest' || backupFile === 'walg';
|
|
968
1020
|
|
|
969
|
-
//
|
|
1021
|
+
// Build and validate the restore script early — fail before touching the
|
|
1022
|
+
// running server if the target is malformed.
|
|
1023
|
+
const scriptBody = composeRestoreScript(target);
|
|
1024
|
+
|
|
1025
|
+
// 1. Stop app to prevent connections during restore
|
|
970
1026
|
await sshRunAsync(
|
|
971
1027
|
ip,
|
|
972
1028
|
sshKeyPath,
|
|
973
1029
|
`cd ${remoteDir} && docker compose stop app 2>/dev/null || true`,
|
|
974
1030
|
);
|
|
975
1031
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
)
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
-e
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
await sshRunAsync(
|
|
1001
|
-
ip,
|
|
1002
|
-
sshKeyPath,
|
|
1003
|
-
`mkdir -p /tmp/vb-restore && tar --no-xattrs --no-same-owner -xzf ${remoteDir}/backups/${backupFile} -C /tmp/vb-restore`,
|
|
1004
|
-
{ timeout: 120_000 },
|
|
1005
|
-
);
|
|
1032
|
+
// 2. Stop the DB. The restore container (step 4) clears PGDATA *itself*
|
|
1033
|
+
// from inside the db container — where the real ${project}_db_data volume
|
|
1034
|
+
// is mounted at $PGDATA — so there is no separate volume-clearing step.
|
|
1035
|
+
// (A `docker compose run --rm -v db_data:/data alpine` would clear a
|
|
1036
|
+
// literal `db_data` volume, NOT the project-prefixed one Compose uses.)
|
|
1037
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
|
|
1038
|
+
|
|
1039
|
+
// 3. Write the restore script to the server (heredoc — no quoting risk) and
|
|
1040
|
+
// bind-mount it into the db container. The script body contains single
|
|
1041
|
+
// quotes (restore_command = 'wal-g wal-fetch ...') so it MUST go via a
|
|
1042
|
+
// file rather than a nested bash -c '...' invocation. The script clears
|
|
1043
|
+
// PGDATA, runs wal-g backup-fetch, and writes the archive-recovery config.
|
|
1044
|
+
sshRunScript(
|
|
1045
|
+
ip,
|
|
1046
|
+
sshKeyPath,
|
|
1047
|
+
[
|
|
1048
|
+
'set -e',
|
|
1049
|
+
`cat > ${remoteDir}/restore-walg.sh <<'VC_RESTORE_EOF'`,
|
|
1050
|
+
scriptBody,
|
|
1051
|
+
'VC_RESTORE_EOF',
|
|
1052
|
+
`chmod +x ${remoteDir}/restore-walg.sh`,
|
|
1053
|
+
].join('\n'),
|
|
1054
|
+
{ timeout: 30_000 },
|
|
1055
|
+
);
|
|
1006
1056
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1057
|
+
await sshRunAsync(
|
|
1058
|
+
ip,
|
|
1059
|
+
sshKeyPath,
|
|
1060
|
+
`cd ${remoteDir} && docker compose run --rm -v ${remoteDir}/restore-walg.sh:/restore.sh:ro db bash /restore.sh`,
|
|
1061
|
+
{ timeout: 900_000 },
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// 4. Start the DB — postgres enters archive recovery, replays WAL, promotes
|
|
1065
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
|
|
1014
1066
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1067
|
+
// 5. Poll until postgres has promoted (pg_isready + pg_is_in_recovery() = f).
|
|
1068
|
+
// WAL replay can take 1-2 minutes; deadline is 300s.
|
|
1069
|
+
const deadline = Date.now() + 300_000;
|
|
1070
|
+
let lastErr = '';
|
|
1071
|
+
for (;;) {
|
|
1072
|
+
try {
|
|
1073
|
+
// First check: postgres accepting connections
|
|
1017
1074
|
await sshRunAsync(
|
|
1018
1075
|
ip,
|
|
1019
1076
|
sshKeyPath,
|
|
1020
|
-
`
|
|
1021
|
-
{ timeout:
|
|
1077
|
+
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
|
|
1078
|
+
{ timeout: 10_000 },
|
|
1022
1079
|
);
|
|
1080
|
+
// Second check: promoted (no longer in recovery)
|
|
1081
|
+
const recovering = await sshRunAsync(
|
|
1082
|
+
ip,
|
|
1083
|
+
sshKeyPath,
|
|
1084
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U postgres -At -c 'SELECT pg_is_in_recovery()'`,
|
|
1085
|
+
{ timeout: 10_000 },
|
|
1086
|
+
);
|
|
1087
|
+
if (String(recovering).trim() === 'f') {
|
|
1088
|
+
break; // promoted — restore complete
|
|
1089
|
+
}
|
|
1090
|
+
lastErr = 'pg_is_in_recovery() still true';
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
1023
1093
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
);
|
|
1032
|
-
|
|
1033
|
-
// Cleanup
|
|
1034
|
-
await sshRunAsync(ip, sshKeyPath, 'rm -rf /tmp/vb-restore');
|
|
1094
|
+
if (Date.now() >= deadline) {
|
|
1095
|
+
throw new Error(
|
|
1096
|
+
`restoreCompose: postgres did not promote within 300s after wal-g restore. ` +
|
|
1097
|
+
`Last error: ${lastErr}`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
1035
1101
|
}
|
|
1036
1102
|
|
|
1037
|
-
//
|
|
1103
|
+
// 6. Bring the app back up
|
|
1038
1104
|
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
|
|
1039
1105
|
}
|
|
1040
1106
|
|