vibecarbon 0.3.1 → 0.5.0
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/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- 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/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +152 -0
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- 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 +1 -2
- package/src/backup.js +13 -66
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/destroy.js +5 -43
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +137 -5
- package/src/lib/deploy/compose/ha.js +208 -92
- package/src/lib/deploy/compose/index.js +292 -424
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +407 -168
- package/src/lib/deploy/orchestrator.js +90 -33
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +45 -16
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +216 -302
- package/src/scale.js +76 -77
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- package/src/lib/kubectl.js +0 -81
package/src/restore.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
* memory:feedback_cli_single_dash_flags.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync
|
|
20
|
-
import { basename
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { basename } from 'node:path';
|
|
21
21
|
import * as p from '@clack/prompts';
|
|
22
|
-
import {
|
|
22
|
+
import { listS3Backups } from './lib/backup-s3.js';
|
|
23
23
|
import { renderHelp } from './lib/cli/help.js';
|
|
24
24
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
25
25
|
import { selectEnvironment } from './lib/cli/select-environment.js';
|
|
@@ -31,19 +31,18 @@ import {
|
|
|
31
31
|
loadProjectConfig,
|
|
32
32
|
loadS3Config,
|
|
33
33
|
} from './lib/config.js';
|
|
34
|
+
// Static imports (no cycle: neither compose/index.js nor compose/ha.js imports
|
|
35
|
+
// restore.js). Aliased so runComposeRestore can default to the real functions
|
|
36
|
+
// while still accepting injected mocks for unit tests.
|
|
37
|
+
import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
|
|
38
|
+
import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
|
|
34
39
|
import { getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
35
40
|
import { requireLicense } from './lib/licensing/index.js';
|
|
36
41
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
37
42
|
import { perfAsync } from './lib/perf.js';
|
|
43
|
+
import { listPodBackups } from './lib/pod-backups.js';
|
|
38
44
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
39
|
-
import {
|
|
40
|
-
getPostgresPod,
|
|
41
|
-
getSSHKeyPath,
|
|
42
|
-
scpUpload,
|
|
43
|
-
sshKubectl,
|
|
44
|
-
sshRun,
|
|
45
|
-
sshRunScript,
|
|
46
|
-
} from './lib/ssh.js';
|
|
45
|
+
import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRunScript } from './lib/ssh.js';
|
|
47
46
|
import { createTracker } from './lib/tracker.js';
|
|
48
47
|
import { validateBackupFilename } from './lib/validators.js';
|
|
49
48
|
import { VERSION } from './lib/version.js';
|
|
@@ -97,45 +96,6 @@ const SPEC = {
|
|
|
97
96
|
],
|
|
98
97
|
};
|
|
99
98
|
|
|
100
|
-
// ============================================================================
|
|
101
|
-
// KUBECTL HELPERS (k8s mode)
|
|
102
|
-
// ============================================================================
|
|
103
|
-
|
|
104
|
-
function listRemoteBackups(ip, sshKeyPath) {
|
|
105
|
-
const pod = getPostgresPod(ip, sshKeyPath);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const output = sshKubectl(ip, sshKeyPath, [
|
|
109
|
-
'exec',
|
|
110
|
-
'-n',
|
|
111
|
-
'vibecarbon',
|
|
112
|
-
pod,
|
|
113
|
-
'--',
|
|
114
|
-
'sh',
|
|
115
|
-
'-c',
|
|
116
|
-
'ls -lt /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS',
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
if (output === 'NO_BACKUPS' || !output) {
|
|
120
|
-
return [];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return output
|
|
124
|
-
.split('\n')
|
|
125
|
-
.filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
|
|
126
|
-
.map((line) => {
|
|
127
|
-
const parts = line.split(/\s+/);
|
|
128
|
-
return {
|
|
129
|
-
size: parts[4],
|
|
130
|
-
date: `${parts[5]} ${parts[6]} ${parts[7]}`,
|
|
131
|
-
name: parts[parts.length - 1].replace('/backups/', ''),
|
|
132
|
-
};
|
|
133
|
-
});
|
|
134
|
-
} catch {
|
|
135
|
-
return [];
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
99
|
// ============================================================================
|
|
140
100
|
// RESTORE OPERATIONS (k8s mode)
|
|
141
101
|
// ============================================================================
|
|
@@ -161,189 +121,54 @@ function scaleUpApp(ip, sshKeyPath) {
|
|
|
161
121
|
sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=2']);
|
|
162
122
|
}
|
|
163
123
|
|
|
164
|
-
function verifyPostgres(ip, sshKeyPath) {
|
|
165
|
-
const pod = getPostgresPod(ip, sshKeyPath);
|
|
166
|
-
sshKubectl(ip, sshKeyPath, [
|
|
167
|
-
'exec',
|
|
168
|
-
'-n',
|
|
169
|
-
'vibecarbon',
|
|
170
|
-
pod,
|
|
171
|
-
'--',
|
|
172
|
-
'pg_isready',
|
|
173
|
-
'-U',
|
|
174
|
-
'postgres',
|
|
175
|
-
]);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
124
|
/**
|
|
179
|
-
*
|
|
125
|
+
* Wait until postgres on the db pod is actually accepting connections.
|
|
126
|
+
*
|
|
127
|
+
* After a wal-g restore, postgres replays archived WAL (restore_command) before
|
|
128
|
+
* promoting — minutes on a busy DB (observed 112s). The pod can be Ready while
|
|
129
|
+
* postgres is still "not accepting connections", so a single pg_isready races
|
|
130
|
+
* slow replay and reports a false restore failure. Poll until it accepts (or
|
|
131
|
+
* give up after `timeoutMs`).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} ip
|
|
134
|
+
* @param {string} sshKeyPath
|
|
135
|
+
* @param {object} [opts]
|
|
136
|
+
* @param {number} [opts.timeoutMs=300000]
|
|
137
|
+
* @param {number} [opts.intervalMs=5000]
|
|
138
|
+
* @param {(ip: string, key: string, args: string[]) => unknown} [opts.exec=sshKubectl]
|
|
139
|
+
* @param {(ip: string, key: string) => string} [opts.getPod=getPostgresPod]
|
|
140
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep]
|
|
180
141
|
*/
|
|
181
|
-
function
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
remotePath,
|
|
194
|
-
'-C',
|
|
195
|
-
'/tmp/restore',
|
|
196
|
-
]);
|
|
197
|
-
|
|
198
|
-
// Check archive contents
|
|
199
|
-
const contents = sshRun(ip, sshKeyPath, ['ls', '/tmp/restore/']);
|
|
200
|
-
|
|
201
|
-
// Restore database
|
|
202
|
-
if (contents.includes('postgres.sql.gz')) {
|
|
203
|
-
sshKubectl(ip, sshKeyPath, [
|
|
204
|
-
'cp',
|
|
205
|
-
'/tmp/restore/postgres.sql.gz',
|
|
206
|
-
`vibecarbon/${pod}:/tmp/postgres.sql.gz`,
|
|
207
|
-
]);
|
|
208
|
-
sshKubectl(ip, sshKeyPath, [
|
|
209
|
-
'exec',
|
|
210
|
-
'-n',
|
|
211
|
-
'vibecarbon',
|
|
212
|
-
pod,
|
|
213
|
-
'--',
|
|
214
|
-
'sh',
|
|
215
|
-
'-c',
|
|
216
|
-
'gunzip -c /tmp/postgres.sql.gz | psql -U supabase_admin postgres',
|
|
217
|
-
]);
|
|
218
|
-
sshKubectl(ip, sshKeyPath, [
|
|
219
|
-
'exec',
|
|
220
|
-
'-n',
|
|
221
|
-
'vibecarbon',
|
|
222
|
-
pod,
|
|
223
|
-
'--',
|
|
224
|
-
'rm',
|
|
225
|
-
'-f',
|
|
226
|
-
'/tmp/postgres.sql.gz',
|
|
227
|
-
]);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Restore storage files if present and using file backend
|
|
231
|
-
if (contents.includes('storage.tar.gz')) {
|
|
232
|
-
// kubectl get returns empty string if STORAGE_BACKEND env var is unset;
|
|
233
|
-
// default to 'file' so the legacy single-backend path still works.
|
|
234
|
-
let storageBackend;
|
|
142
|
+
export async function verifyPostgres(ip, sshKeyPath, opts = {}) {
|
|
143
|
+
const {
|
|
144
|
+
timeoutMs = 300_000,
|
|
145
|
+
intervalMs = 5_000,
|
|
146
|
+
exec = sshKubectl,
|
|
147
|
+
getPod = getPostgresPod,
|
|
148
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
149
|
+
} = opts;
|
|
150
|
+
const pod = getPod(ip, sshKeyPath);
|
|
151
|
+
const deadline = Date.now() + timeoutMs;
|
|
152
|
+
let lastErr = '';
|
|
153
|
+
for (;;) {
|
|
235
154
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
'-n',
|
|
241
|
-
'vibecarbon',
|
|
242
|
-
'-o',
|
|
243
|
-
'jsonpath={.spec.template.spec.containers[0].env[?(@.name=="STORAGE_BACKEND")].value}',
|
|
244
|
-
]);
|
|
245
|
-
} catch {
|
|
246
|
-
storageBackend = 'file';
|
|
155
|
+
exec(ip, sshKeyPath, ['exec', '-n', 'vibecarbon', pod, '--', 'pg_isready', '-U', 'postgres']);
|
|
156
|
+
return; // exit 0 = accepting connections
|
|
157
|
+
} catch (err) {
|
|
158
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
247
159
|
}
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
'pods',
|
|
254
|
-
'-n',
|
|
255
|
-
'vibecarbon',
|
|
256
|
-
'-l',
|
|
257
|
-
'app.kubernetes.io/name=supabase-storage',
|
|
258
|
-
'-o',
|
|
259
|
-
'jsonpath={.items[0].metadata.name}',
|
|
260
|
-
]);
|
|
261
|
-
|
|
262
|
-
sshKubectl(ip, sshKeyPath, [
|
|
263
|
-
'cp',
|
|
264
|
-
'/tmp/restore/storage.tar.gz',
|
|
265
|
-
`vibecarbon/${storagePod}:/tmp/storage.tar.gz`,
|
|
266
|
-
]);
|
|
267
|
-
sshKubectl(ip, sshKeyPath, [
|
|
268
|
-
'exec',
|
|
269
|
-
'-n',
|
|
270
|
-
'vibecarbon',
|
|
271
|
-
storagePod,
|
|
272
|
-
'--',
|
|
273
|
-
'sh',
|
|
274
|
-
'-c',
|
|
275
|
-
'tar --no-xattrs --no-same-owner -xzf /tmp/storage.tar.gz -C / && rm -f /tmp/storage.tar.gz',
|
|
276
|
-
]);
|
|
277
|
-
} else {
|
|
278
|
-
p.log.info('Storage backend is S3 — skipping file restore (files already in S3)');
|
|
160
|
+
if (Date.now() >= deadline) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`verifyPostgres: postgres did not accept connections within ${Math.round(timeoutMs / 1000)}s ` +
|
|
163
|
+
`(still replaying WAL after a wal-g restore?). Last pg_isready error: ${lastErr}`,
|
|
164
|
+
);
|
|
279
165
|
}
|
|
166
|
+
await sleep(intervalMs);
|
|
280
167
|
}
|
|
281
|
-
|
|
282
|
-
// Cleanup
|
|
283
|
-
sshRun(ip, sshKeyPath, ['rm', '-rf', '/tmp/restore']);
|
|
284
168
|
}
|
|
285
169
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*/
|
|
289
|
-
function restoreFromSqlGz(ip, sshKeyPath, backupName) {
|
|
290
|
-
const pod = getPostgresPod(ip, sshKeyPath);
|
|
291
|
-
|
|
292
|
-
// backupName is validated via validateBackupFilename at entry; still pass it
|
|
293
|
-
// as a positional sh arg so the remote shell never expands it as code.
|
|
294
|
-
sshKubectl(ip, sshKeyPath, [
|
|
295
|
-
'exec',
|
|
296
|
-
'-n',
|
|
297
|
-
'vibecarbon',
|
|
298
|
-
pod,
|
|
299
|
-
'--',
|
|
300
|
-
'sh',
|
|
301
|
-
'-c',
|
|
302
|
-
'gunzip -c "/backups/$1" | psql -U supabase_admin postgres',
|
|
303
|
-
'_',
|
|
304
|
-
backupName,
|
|
305
|
-
]);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function restoreFromRemote(ip, sshKeyPath, backupName) {
|
|
309
|
-
scaleDownApp(ip, sshKeyPath);
|
|
310
|
-
|
|
311
|
-
if (backupName.endsWith('_full.tar.gz')) {
|
|
312
|
-
restoreFromArchive(ip, sshKeyPath, `/tmp/${backupName}`);
|
|
313
|
-
} else {
|
|
314
|
-
restoreFromSqlGz(ip, sshKeyPath, backupName);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
scaleUpApp(ip, sshKeyPath);
|
|
318
|
-
verifyPostgres(ip, sshKeyPath);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function uploadAndRestore(ip, sshKeyPath, localFile) {
|
|
322
|
-
const filename = basename(localFile);
|
|
323
|
-
|
|
324
|
-
// Upload local file to server
|
|
325
|
-
scpUpload(ip, sshKeyPath, localFile, `/tmp/${filename}`);
|
|
326
|
-
|
|
327
|
-
scaleDownApp(ip, sshKeyPath);
|
|
328
|
-
|
|
329
|
-
if (filename.endsWith('_full.tar.gz')) {
|
|
330
|
-
restoreFromArchive(ip, sshKeyPath, `/tmp/${filename}`);
|
|
331
|
-
} else if (filename.endsWith('.sql.gz')) {
|
|
332
|
-
const pod = getPostgresPod(ip, sshKeyPath);
|
|
333
|
-
sshKubectl(ip, sshKeyPath, [
|
|
334
|
-
'cp',
|
|
335
|
-
`/tmp/${filename}`,
|
|
336
|
-
`vibecarbon/${pod}:/backups/${filename}`,
|
|
337
|
-
]);
|
|
338
|
-
restoreFromSqlGz(ip, sshKeyPath, filename);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Clean up temp file on host
|
|
342
|
-
sshRun(ip, sshKeyPath, ['rm', '-f', `/tmp/${filename}`]);
|
|
343
|
-
|
|
344
|
-
scaleUpApp(ip, sshKeyPath);
|
|
345
|
-
verifyPostgres(ip, sshKeyPath);
|
|
346
|
-
}
|
|
170
|
+
// (k8s pg_dump restore helpers removed — k8s restore is now wal-g-native via
|
|
171
|
+
// the db pod's init container; see runK8sRestore below.)
|
|
347
172
|
|
|
348
173
|
// ============================================================================
|
|
349
174
|
// SOURCE INTERPRETATION
|
|
@@ -434,7 +259,13 @@ export async function run(args) {
|
|
|
434
259
|
seed: envSeed,
|
|
435
260
|
});
|
|
436
261
|
|
|
437
|
-
|
|
262
|
+
// compose-ha: pick the CURRENT primary by role (a prior failover may have
|
|
263
|
+
// swapped roles while preserving array order, so servers[0] is not reliably
|
|
264
|
+
// the primary). Falls back to servers[0] for non-HA modes that have no role.
|
|
265
|
+
// This same serverIp feeds both the restore target and (in runComposeRestore)
|
|
266
|
+
// the re-seed source, so they stay consistent with the standby selection.
|
|
267
|
+
const primaryServer = envConfig.servers?.find((sv) => sv.role === 'primary');
|
|
268
|
+
const serverIp = primaryServer?.ip || envConfig.servers?.[0]?.ip;
|
|
438
269
|
if (!serverIp) {
|
|
439
270
|
p.log.error(`No server IP found for environment '${envName}'.`);
|
|
440
271
|
process.exit(1);
|
|
@@ -511,19 +342,33 @@ export async function run(args) {
|
|
|
511
342
|
// restores) ask for "the most recent" without first listing + parsing
|
|
512
343
|
// names from `vibecarbon backup -l`.
|
|
513
344
|
const wantsLatest = values.source === 'latest';
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
345
|
+
let chosenSource;
|
|
346
|
+
if (!wantsLatest && values.source) {
|
|
347
|
+
chosenSource = classifySource(/** @type {string} */ (values.source));
|
|
348
|
+
} else if (wantsLatest) {
|
|
349
|
+
// BOTH compose and k8s restore are now wal-g-native: `latest` resolves to
|
|
350
|
+
// the LATEST base backup that wal-g pushed to S3 — k8s via the db pod's
|
|
351
|
+
// walg-restore init container, compose via `restoreCompose(..., 'latest')`
|
|
352
|
+
// → `wal-g backup-fetch LATEST` (see runK8sRestore / runComposeRestore).
|
|
353
|
+
// pickInteractiveSource lists legacy pg_dump `*_full.tar.gz` backups via
|
|
354
|
+
// listS3Backups, which CANNOT see wal-g `basebackups_005/base_*` objects, so
|
|
355
|
+
// routing `-source latest` through it wrongly exits "No backups found in S3"
|
|
356
|
+
// even though a wal-g base backup exists. Hand the `latest` sentinel
|
|
357
|
+
// straight to the restore runner and let wal-g resolve it. (RCA 2026-05-31:
|
|
358
|
+
// e2e k8s/k8s-ha restore step; compose found via kept-rig manual restore.)
|
|
359
|
+
chosenSource = { kind: 's3', name: 'latest' };
|
|
360
|
+
} else {
|
|
361
|
+
chosenSource = await pickInteractiveSource({
|
|
362
|
+
isCompose,
|
|
363
|
+
envName,
|
|
364
|
+
projectName,
|
|
365
|
+
serverIp,
|
|
366
|
+
sshKeyPath,
|
|
367
|
+
s3,
|
|
368
|
+
useS3,
|
|
369
|
+
yes: wantsLatest || !!values.y,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
527
372
|
|
|
528
373
|
if (!chosenSource) {
|
|
529
374
|
p.log.error('No backup selected.');
|
|
@@ -570,7 +415,7 @@ export async function run(args) {
|
|
|
570
415
|
projectName,
|
|
571
416
|
serverIp,
|
|
572
417
|
sshKeyPath,
|
|
573
|
-
|
|
418
|
+
envConfig,
|
|
574
419
|
});
|
|
575
420
|
} else {
|
|
576
421
|
await runK8sRestore({
|
|
@@ -579,8 +424,6 @@ export async function run(args) {
|
|
|
579
424
|
envName,
|
|
580
425
|
serverIp,
|
|
581
426
|
sshKeyPath,
|
|
582
|
-
s3,
|
|
583
|
-
useS3,
|
|
584
427
|
});
|
|
585
428
|
}
|
|
586
429
|
} catch (error) {
|
|
@@ -662,7 +505,7 @@ async function runList({ isCompose, envName, projectName, serverIp, sshKeyPath }
|
|
|
662
505
|
}
|
|
663
506
|
|
|
664
507
|
// K8s pod fallback
|
|
665
|
-
const backups =
|
|
508
|
+
const backups = listPodBackups(serverIp, sshKeyPath);
|
|
666
509
|
printBackupList(backups, envName);
|
|
667
510
|
}
|
|
668
511
|
|
|
@@ -757,7 +600,7 @@ async function pickInteractiveSource({
|
|
|
757
600
|
|
|
758
601
|
// K8s pod fallback
|
|
759
602
|
s.start('Fetching available backups');
|
|
760
|
-
const backups =
|
|
603
|
+
const backups = listPodBackups(serverIp, sshKeyPath);
|
|
761
604
|
s.stop('Backups retrieved');
|
|
762
605
|
if (backups.length === 0) {
|
|
763
606
|
p.log.error('No backups found on the server.');
|
|
@@ -780,84 +623,155 @@ async function pickInteractiveSource({
|
|
|
780
623
|
return { kind: 's3', name: /** @type {string} */ (selected) };
|
|
781
624
|
}
|
|
782
625
|
|
|
783
|
-
|
|
626
|
+
// compose restore is wal-g-based (S3 pull, no file transfer). Mirrors
|
|
627
|
+
// runK8sRestore's structure: reject local-file sources, resolve target,
|
|
628
|
+
// call restoreCompose, verify postgres.
|
|
629
|
+
//
|
|
630
|
+
// COMPOSE-HA re-seed (Invariant 3): the wal-g restore rewinds the PRIMARY
|
|
631
|
+
// (servers[0]) to an earlier LSN. For a compose-ha deployment the standby then
|
|
632
|
+
// has WAL *ahead* of the restored primary (streamed from the old primary) and
|
|
633
|
+
// cannot resume streaming — PostgreSQL would reject it with "requested timeline
|
|
634
|
+
// does not contain minimum recovery point" (timeline divergence). So after the
|
|
635
|
+
// primary promotes we re-seed the standby with a fresh pg_basebackup from the
|
|
636
|
+
// restored primary via configureStandbyReplication, which is destructive-by-
|
|
637
|
+
// design (it wipes the standby's PGDATA and re-basebackups — exactly what we
|
|
638
|
+
// want here). Single-region compose has no standby, so the re-seed is skipped.
|
|
639
|
+
//
|
|
640
|
+
// restoreCompose + configureStandbyReplication are injectable so the dispatch
|
|
641
|
+
// logic is unit-testable without SSH; production passes neither and they resolve
|
|
642
|
+
// to the real module functions (static imports — no cycle: ha.js/compose-index
|
|
643
|
+
// do not import restore.js).
|
|
644
|
+
export async function runComposeRestore({
|
|
784
645
|
s,
|
|
785
646
|
chosenSource,
|
|
786
647
|
envName,
|
|
787
648
|
projectName,
|
|
788
649
|
serverIp,
|
|
789
650
|
sshKeyPath,
|
|
790
|
-
|
|
651
|
+
envConfig = {},
|
|
652
|
+
restoreCompose = realRestoreCompose,
|
|
653
|
+
configureStandbyReplication = realConfigureStandbyReplication,
|
|
791
654
|
}) {
|
|
792
|
-
const backupFile = chosenSource.name;
|
|
793
|
-
|
|
794
655
|
if (chosenSource.kind === 'local') {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
656
|
+
throw new Error(
|
|
657
|
+
'compose restore is wal-g-based and pulls from S3 — local backup files are not supported. ' +
|
|
658
|
+
'Use `-source latest` or an ISO-8601 timestamp for PITR.',
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// wal-g target: 'latest' → LATEST base backup; otherwise treat the source
|
|
663
|
+
// name as an ISO-8601 timestamp for point-in-time recovery.
|
|
664
|
+
const target =
|
|
665
|
+
chosenSource.name === 'latest' || !chosenSource.name ? 'latest' : chosenSource.name;
|
|
666
|
+
|
|
667
|
+
s.start('Restoring database via wal-g (app will be temporarily unavailable)');
|
|
668
|
+
await restoreCompose(serverIp, sshKeyPath, projectName, target);
|
|
669
|
+
s.stop('Database restored');
|
|
670
|
+
|
|
671
|
+
// compose-ha: the wal-g restore rewound the primary to an earlier LSN, so the
|
|
672
|
+
// standby now has WAL AHEAD of the primary and cannot resume streaming. Re-seed
|
|
673
|
+
// it with a fresh pg_basebackup from the restored primary so it rejoins the new
|
|
674
|
+
// timeline. (configureStandbyReplication is destructive-by-design: it wipes the
|
|
675
|
+
// standby's PGDATA and re-basebackups — exactly what we want here.)
|
|
676
|
+
//
|
|
677
|
+
// A re-seed failure is NOT a restore failure: the primary is restored and
|
|
678
|
+
// serving — only the standby is degraded. Warn (don't rethrow) so the restore
|
|
679
|
+
// still reports success; the operator can resync the standby via `deploy`.
|
|
680
|
+
if (envConfig.deployMode === 'compose-ha') {
|
|
681
|
+
const standby = envConfig.servers?.find((sv) => sv.role === 'standby');
|
|
682
|
+
if (standby?.ip) {
|
|
803
683
|
try {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
684
|
+
s.start('Re-seeding standby from restored primary (pg_basebackup)');
|
|
685
|
+
await configureStandbyReplication(standby.ip, serverIp, sshKeyPath, projectName);
|
|
686
|
+
s.stop('Standby re-seeded from restored primary');
|
|
687
|
+
} catch (err) {
|
|
688
|
+
s.stop('Standby re-seed failed');
|
|
689
|
+
p.log.warn(
|
|
690
|
+
`Primary restored and serving, but standby re-seed failed: ${err.message}. ` +
|
|
691
|
+
`Run \`vibecarbon deploy ${envName}\` to resync the standby.`,
|
|
692
|
+
);
|
|
807
693
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
694
|
+
} else {
|
|
695
|
+
p.log.warn(
|
|
696
|
+
'compose-ha restore: no standby server found to re-seed — skipping (verify replication manually).',
|
|
697
|
+
);
|
|
812
698
|
}
|
|
813
699
|
}
|
|
814
700
|
|
|
815
|
-
s.start('Restoring database...');
|
|
816
|
-
const { restoreCompose } = await import('./lib/deploy/compose/index.js');
|
|
817
|
-
restoreCompose(serverIp, sshKeyPath, projectName, backupFile);
|
|
818
|
-
s.stop('Database restored');
|
|
819
701
|
p.log.success('Restore completed successfully');
|
|
820
|
-
|
|
821
|
-
// diagnostic logging consistency.
|
|
822
|
-
void envName;
|
|
702
|
+
p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
|
|
823
703
|
}
|
|
824
704
|
|
|
825
|
-
|
|
826
|
-
|
|
705
|
+
// k8s restore is wal-g-native: postgres + wal-g live in the supabase-db pod,
|
|
706
|
+
// which continuously archives WAL + base backups to S3. Restore = set a
|
|
707
|
+
// RESTORE_TARGET marker on vibecarbon-secrets → bounce the db StatefulSet so
|
|
708
|
+
// its walg-restore init container runs `wal-g backup-fetch` into PGDATA before
|
|
709
|
+
// postgres starts → clear the marker. No bytes flow through the operator and
|
|
710
|
+
// no pg_dump/kubectl-cp; wal-g pulls from S3 inside the cluster. Mirrors the
|
|
711
|
+
// compose wal-g restore, adapted to the StatefulSet/init-container shape.
|
|
712
|
+
const DB_STATEFULSET = 'supabase-supabase-db';
|
|
713
|
+
|
|
714
|
+
/** Set or clear the RESTORE_TARGET marker the init container reads. */
|
|
715
|
+
function setRestoreMarker(ip, sshKeyPath, value) {
|
|
716
|
+
sshKubectl(ip, sshKeyPath, [
|
|
717
|
+
'patch',
|
|
718
|
+
'secret',
|
|
719
|
+
'vibecarbon-secrets',
|
|
720
|
+
'-n',
|
|
721
|
+
'vibecarbon',
|
|
722
|
+
'--type=merge',
|
|
723
|
+
'-p',
|
|
724
|
+
JSON.stringify({ stringData: { RESTORE_TARGET: value } }),
|
|
725
|
+
]);
|
|
726
|
+
}
|
|
827
727
|
|
|
728
|
+
async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath }) {
|
|
828
729
|
if (chosenSource.kind === 'local') {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
await perfAsync('restore.uploadAndRestore', () =>
|
|
833
|
-
uploadAndRestore(serverIp, sshKeyPath, chosenSource.path),
|
|
834
|
-
);
|
|
835
|
-
s.stop('Database restored');
|
|
836
|
-
} else if (useS3) {
|
|
837
|
-
s.message('Downloading backup from S3');
|
|
838
|
-
const tempPath = join(process.cwd(), `.restore-tmp-${chosenSource.name}`);
|
|
839
|
-
await perfAsync('restore.downloadS3', () =>
|
|
840
|
-
downloadS3Backup(s3, `backups/${chosenSource.name}`, tempPath),
|
|
730
|
+
throw new Error(
|
|
731
|
+
'k8s restore is wal-g-based and pulls from S3 — local backup files are not supported. Use `-source latest` (or a timestamp for PITR).',
|
|
841
732
|
);
|
|
842
|
-
s.message('Uploading backup to server');
|
|
843
|
-
await perfAsync('restore.scpUpload', async () =>
|
|
844
|
-
scpUpload(serverIp, sshKeyPath, tempPath, `/tmp/${chosenSource.name}`),
|
|
845
|
-
);
|
|
846
|
-
try {
|
|
847
|
-
unlinkSync(tempPath);
|
|
848
|
-
} catch {
|
|
849
|
-
// ignore cleanup errors
|
|
850
|
-
}
|
|
851
|
-
s.message('Restoring database');
|
|
852
|
-
await perfAsync('restore.restoreFromRemote', () =>
|
|
853
|
-
restoreFromRemote(serverIp, sshKeyPath, chosenSource.name),
|
|
854
|
-
);
|
|
855
|
-
s.stop('Database restored');
|
|
856
|
-
} else {
|
|
857
|
-
// Pod-only fallback: backup name refers to a file already in the pod.
|
|
858
|
-
await restoreFromRemote(serverIp, sshKeyPath, chosenSource.name);
|
|
859
|
-
s.stop('Database restored');
|
|
860
733
|
}
|
|
734
|
+
// wal-g target: 'latest' → LATEST base backup; otherwise treat the source
|
|
735
|
+
// name as an ISO-8601 timestamp for point-in-time recovery.
|
|
736
|
+
const target =
|
|
737
|
+
chosenSource.name === 'latest' || !chosenSource.name ? 'latest' : chosenSource.name;
|
|
738
|
+
|
|
739
|
+
s.start('Restoring database via wal-g (app will be temporarily unavailable)');
|
|
740
|
+
// 1. Mark the restore so the db pod's init container fetches on next boot.
|
|
741
|
+
setRestoreMarker(serverIp, sshKeyPath, target);
|
|
742
|
+
// 2. Quiesce the app so it isn't querying mid-swap.
|
|
743
|
+
await perfAsync('restore.scaleDownApp', async () => scaleDownApp(serverIp, sshKeyPath));
|
|
744
|
+
try {
|
|
745
|
+
// 3. Bounce the db StatefulSet → walg-restore init container clears PGDATA
|
|
746
|
+
// and runs `wal-g backup-fetch` before postgres starts.
|
|
747
|
+
s.message('Fetching from S3 + recovering (wal-g)');
|
|
748
|
+
await perfAsync('restore.walgFetch', async () => {
|
|
749
|
+
sshKubectl(serverIp, sshKeyPath, [
|
|
750
|
+
'rollout',
|
|
751
|
+
'restart',
|
|
752
|
+
`statefulset/${DB_STATEFULSET}`,
|
|
753
|
+
'-n',
|
|
754
|
+
'vibecarbon',
|
|
755
|
+
]);
|
|
756
|
+
sshKubectl(serverIp, sshKeyPath, [
|
|
757
|
+
'rollout',
|
|
758
|
+
'status',
|
|
759
|
+
`statefulset/${DB_STATEFULSET}`,
|
|
760
|
+
'-n',
|
|
761
|
+
'vibecarbon',
|
|
762
|
+
'--timeout=600s',
|
|
763
|
+
]);
|
|
764
|
+
});
|
|
765
|
+
// 4. Confirm postgres accepts connections post-recovery.
|
|
766
|
+
await perfAsync('restore.verifyPostgres', async () => verifyPostgres(serverIp, sshKeyPath));
|
|
767
|
+
} finally {
|
|
768
|
+
// 5. Always clear the marker so an unrelated future pod restart does NOT
|
|
769
|
+
// re-fetch and wipe live data. Runs even if the restore failed.
|
|
770
|
+
setRestoreMarker(serverIp, sshKeyPath, '');
|
|
771
|
+
}
|
|
772
|
+
// 6. Bring the app back.
|
|
773
|
+
await perfAsync('restore.scaleUpApp', async () => scaleUpApp(serverIp, sshKeyPath));
|
|
774
|
+
s.stop('Database restored');
|
|
861
775
|
|
|
862
776
|
p.log.success('Restore completed successfully');
|
|
863
777
|
p.log.info('The application has been scaled back up.');
|