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.
Files changed (56) hide show
  1. package/carbon/backup/compose-backup.sh +45 -121
  2. package/carbon/db/Dockerfile +14 -3
  3. package/carbon/docker-compose.dns01.prod.yml +42 -0
  4. package/carbon/docker-compose.metabase.prod.yml +1 -2
  5. package/carbon/docker-compose.n8n.prod.yml +1 -2
  6. package/carbon/docker-compose.prod.yml +9 -7
  7. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  8. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  9. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  10. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  11. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  12. package/carbon/k8s/base/network-policies.yaml +33 -0
  13. package/carbon/k8s/base/traefik/certificate.yaml +13 -7
  14. package/carbon/k8s/values/supabase.values.yaml +152 -0
  15. package/carbon/scripts/docker-up.js +13 -1
  16. package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
  17. package/carbon/src/client/locales/de.json +29 -0
  18. package/carbon/src/client/locales/en.json +29 -0
  19. package/carbon/src/client/locales/es.json +29 -0
  20. package/carbon/src/client/locales/fr.json +29 -0
  21. package/carbon/src/client/locales/pt.json +29 -0
  22. package/carbon/src/client/pages/Home.tsx +5 -0
  23. package/package.json +1 -2
  24. package/src/backup.js +13 -66
  25. package/src/create.js +9 -4
  26. package/src/deploy.js +12 -0
  27. package/src/destroy.js +5 -43
  28. package/src/lib/backup-s3.js +0 -31
  29. package/src/lib/cloudflare.js +0 -59
  30. package/src/lib/config.js +1 -42
  31. package/src/lib/deploy/acme.js +57 -0
  32. package/src/lib/deploy/admin-user.js +105 -0
  33. package/src/lib/deploy/bundle.js +137 -5
  34. package/src/lib/deploy/compose/ha.js +208 -92
  35. package/src/lib/deploy/compose/index.js +292 -424
  36. package/src/lib/deploy/k8s/ha/index.js +12 -148
  37. package/src/lib/deploy/k8s/index.js +1 -21
  38. package/src/lib/deploy/k8s/k3s.js +407 -168
  39. package/src/lib/deploy/orchestrator.js +90 -33
  40. package/src/lib/deploy/utils.js +20 -0
  41. package/src/lib/hetzner-guided-setup.js +0 -7
  42. package/src/lib/iac/index.js +45 -16
  43. package/src/lib/images.js +17 -0
  44. package/src/lib/licensing/index.js +1 -59
  45. package/src/lib/licensing/tiers.js +0 -8
  46. package/src/lib/pod-backups.js +53 -0
  47. package/src/lib/providers/index.js +0 -47
  48. package/src/lib/ssh.js +12 -0
  49. package/src/restore.js +216 -302
  50. package/src/scale.js +76 -77
  51. package/carbon/backup/Dockerfile +0 -75
  52. package/carbon/backup/backup.sh +0 -95
  53. package/carbon/runtime.Dockerfile +0 -17
  54. package/src/lib/deploy/compose/acme-verify.js +0 -121
  55. package/src/lib/deploy/index.js +0 -119
  56. 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, unlinkSync } from 'node:fs';
20
- import { basename, join } from 'node:path';
19
+ import { existsSync } from 'node:fs';
20
+ import { basename } from 'node:path';
21
21
  import * as p from '@clack/prompts';
22
- import { downloadS3Backup, listS3Backups } from './lib/backup-s3.js';
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
- * Restore from a full archive (.tar.gz containing postgres.sql.gz + optional storage.tar.gz)
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 restoreFromArchive(ip, sshKeyPath, remotePath) {
182
- const pod = getPostgresPod(ip, sshKeyPath);
183
-
184
- // Extract the archive on the server — argv form; remotePath is never shell-interpolated.
185
- sshRun(ip, sshKeyPath, ['mkdir', '-p', '/tmp/restore']);
186
- // GNU tar strips leading `/` by default (no `-P` / `--absolute-names`),
187
- // so absolute paths in a hostile archive never escape /tmp/restore.
188
- sshRun(ip, sshKeyPath, [
189
- 'tar',
190
- '--no-xattrs',
191
- '--no-same-owner',
192
- '-xzf',
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
- storageBackend = sshKubectl(ip, sshKeyPath, [
237
- 'get',
238
- 'deployment',
239
- 'storage',
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 (!storageBackend) storageBackend = 'file';
249
-
250
- if (storageBackend === 'file') {
251
- const storagePod = sshKubectl(ip, sshKeyPath, [
252
- 'get',
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
- * Restore from a legacy .sql.gz file (database only)
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
- const serverIp = envConfig.servers?.[0]?.ip;
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
- const chosenSource =
515
- !wantsLatest && values.source
516
- ? classifySource(/** @type {string} */ (values.source))
517
- : await pickInteractiveSource({
518
- isCompose,
519
- envName,
520
- projectName,
521
- serverIp,
522
- sshKeyPath,
523
- s3,
524
- useS3,
525
- yes: wantsLatest || !!values.y,
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
- s3WithCreds: s3,
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 = listRemoteBackups(serverIp, sshKeyPath);
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 = listRemoteBackups(serverIp, sshKeyPath);
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
- async function runComposeRestore({
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
- s3WithCreds,
651
+ envConfig = {},
652
+ restoreCompose = realRestoreCompose,
653
+ configureStandbyReplication = realConfigureStandbyReplication,
791
654
  }) {
792
- const backupFile = chosenSource.name;
793
-
794
655
  if (chosenSource.kind === 'local') {
795
- p.log.info(`Uploading local file: ${c.bold(chosenSource.path)}`);
796
- scpUpload(serverIp, sshKeyPath, chosenSource.path, `/opt/${projectName}/backups/${backupFile}`);
797
- } else if (s3WithCreds?.secretKey) {
798
- s.start(`Downloading ${backupFile} from S3 to server`);
799
- const tmpLocal = join(process.cwd(), `.restore-tmp-${backupFile}`);
800
- try {
801
- await downloadS3Backup(s3WithCreds, `backups/${backupFile}`, tmpLocal);
802
- scpUpload(serverIp, sshKeyPath, tmpLocal, `/opt/${projectName}/backups/${backupFile}`);
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
- unlinkSync(tmpLocal);
805
- } catch {
806
- // ignore cleanup errors
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
- s.stop('Backup transferred to server');
809
- } catch (error) {
810
- s.stop(`S3 download failed: ${error.message}`);
811
- throw error;
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
- // env name is unused by restoreCompose but kept in the closure for
821
- // diagnostic logging consistency.
822
- void envName;
702
+ p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
823
703
  }
824
704
 
825
- async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath, s3, useS3 }) {
826
- s.start('Restoring database (app will be temporarily unavailable)');
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
- if (!chosenSource.path.endsWith('.sql.gz') && !chosenSource.path.endsWith('.tar.gz')) {
830
- throw new Error('Backup file must be a .sql.gz or .tar.gz file');
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.');