vibecarbon 0.9.0 → 0.11.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 (88) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +968 -886
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. package/src/lib/pod-backups.js +0 -53
package/src/failover.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync } from 'node:fs';
14
14
  import * as p from '@clack/prompts';
15
+ import { formatInstant } from './lib/backup-format.js';
15
16
  import { renderHelp } from './lib/cli/help.js';
16
17
  import { parseFlags } from './lib/cli/parse-flags.js';
17
18
  import { selectEnvironment } from './lib/cli/select-environment.js';
@@ -19,17 +20,23 @@ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
19
20
  import { createDNSRecord } from './lib/cloudflare.js';
20
21
  import { c, printBanner } from './lib/colors.js';
21
22
  import { loadCredentials, loadProjectConfig } from './lib/config.js';
23
+ import {
24
+ getReplPasswordFromSecret,
25
+ promoteStandby,
26
+ reseedStandbyFromPrimary,
27
+ } from './lib/deploy/replication.js';
22
28
  import {
23
29
  createDNSRecord as hetznerDnsCreateRecord,
24
30
  getZone as hetznerDnsGetZone,
25
31
  } from './lib/hetzner-dns.js';
26
- import { requireLicense } from './lib/licensing/index.js';
27
32
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
28
33
  import { perfTimer } from './lib/perf.js';
34
+ import { confirmProdOrExit } from './lib/prod-confirm.js';
29
35
  import { assertInProjectDir } from './lib/project-guard.js';
30
- import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRun } from './lib/ssh.js';
36
+ import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
31
37
  import { createTracker } from './lib/tracker.js';
32
38
  import { VERSION } from './lib/version.js';
39
+ import { listWalgBackups } from './lib/walg-backups.js';
33
40
 
34
41
  // ============================================================================
35
42
  // COMMAND SPEC — single source of truth for argv parsing AND help output.
@@ -170,113 +177,69 @@ async function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
170
177
  }
171
178
  }
172
179
 
173
- /**
174
- * Re-seed the standby Postgres from the primary via pg_basebackup before
175
- * promotion. Closes the drift window that opens when streaming replication
176
- * silently falls behind (no replication slot) or when the standby's pod
177
- * restarted and missed WAL. Without re-seeding, promoted standby can serve
178
- * requests against a schema that's missing recent migrations, yielding
179
- * "undefined column" (PG 42703) errors in auth/storage after failover.
180
- *
181
- * Requires `primarySupabaseIp` — the Hetzner private IP of the primary's
182
- * supabase node (where the postgres pod runs with hostPort 5432). Returns
183
- * true if re-seed succeeded, false if primary was unreachable or re-seed
184
- * itself failed (caller proceeds to promote with existing state).
185
- */
186
- async function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
187
- if (!primarySupabaseIp) return false;
188
- try {
189
- // REPL_PASSWORD is written to both primary and standby .env.local at
190
- // create time; read it from the standby's filesystem (easier than
191
- // threading through the failover args).
192
- const replPassword = await sshRun(standbyIp, sshKeyPath, [
193
- 'bash',
194
- '-c',
195
- 'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
196
- ]);
197
- if (!replPassword?.trim()) return false;
198
-
199
- const pod = await getPostgresPod(standbyIp, sshKeyPath);
200
- const script = [
201
- `rm -rf /var/lib/postgresql/data/*`,
202
- `PGPASSWORD='${replPassword.trim()}' pg_basebackup -h ${primarySupabaseIp} -p 5432 -U replicator -D /var/lib/postgresql/data -Fp -Xs -R -c fast -S vibecarbon_standby_slot`,
203
- `echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
204
- `chown -R postgres:postgres /var/lib/postgresql/data`,
205
- ].join('\n');
206
- await sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
207
- input: script,
208
- });
209
- // Restart the pod to pick up the new PGDATA + recovery config.
210
- await sshKubectl(standbyIp, sshKeyPath, [
211
- 'exec',
212
- '-n',
213
- 'vibecarbon',
214
- pod,
215
- '--',
216
- 'su',
217
- 'postgres',
218
- '-c',
219
- 'pg_ctl restart -D /var/lib/postgresql/data -m fast',
220
- ]);
221
- return true;
222
- } catch {
223
- return false;
224
- }
225
- }
180
+ // getReplPasswordFromSecret + reseedStandbyFromPrimary + promoteStandby now live
181
+ // in src/lib/deploy/replication.js (finding #2 shared with restore.js and the
182
+ // k8s deploy-time seed). They are imported at the top of this file. The perfTimer
183
+ // wrapper for promotion stays here since it is a failover-flow concern.
226
184
 
227
185
  async function promoteStandbyDatabase(standbyIp, sshKeyPath) {
228
186
  const _t = perfTimer('failover.promoteStandby');
229
187
  try {
230
- return await _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
188
+ return await promoteStandby(standbyIp, sshKeyPath);
231
189
  } finally {
232
190
  _t.end();
233
191
  }
234
192
  }
235
193
 
236
- async function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
237
- // Get the postgres pod name via a separate SSH call (avoids local shell expansion of $())
238
- const pod = await getPostgresPod(standbyIp, sshKeyPath);
239
-
240
- // Promote PostgreSQL on standby
241
- try {
242
- await sshKubectl(standbyIp, sshKeyPath, [
243
- 'exec',
244
- '-n',
245
- 'vibecarbon',
246
- pod,
247
- '--',
248
- 'su',
249
- 'postgres',
250
- '-c',
251
- 'pg_ctl promote -D /var/lib/postgresql/data',
252
- ]);
253
- } catch {
254
- // pg_ctl promote may return non-zero even on success
255
- }
256
-
257
- // Poll until standby is no longer in recovery
258
- for (let i = 0; i < 30; i++) {
194
+ /**
195
+ * Re-seed the standby from the primary (when reachable) then promote it.
196
+ * ABORTS the whole failover (throws) if the re-seed fails or the promotion
197
+ * cannot be confirmed — we must NEVER scale down the old primary and flip DNS
198
+ * onto a standby that was left half-seeded or is still in recovery. Brings the
199
+ * k8s failover path to parity with compose (compose/ha.js hard-fails both).
200
+ */
201
+ async function reseedAndPromoteOrAbort(servers, sshKeyPath, s) {
202
+ // Step 0: Re-seed standby from primary (only when the primary is reachable).
203
+ if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
204
+ s.start('Re-seeding standby from primary (pg_basebackup)');
205
+ let result;
259
206
  try {
260
- const result = await sshKubectl(standbyIp, sshKeyPath, [
261
- 'exec',
262
- '-n',
263
- 'vibecarbon',
264
- pod,
265
- '--',
266
- 'psql',
267
- '-U',
268
- 'supabase_admin',
269
- '-tAc',
270
- 'SELECT pg_is_in_recovery()',
271
- ]);
272
- if (result.trim() === 'f') return true;
273
- } catch {
274
- // Retry
207
+ result = await reseedStandbyFromPrimary(
208
+ servers.standby.ip,
209
+ sshKeyPath,
210
+ servers.primary.supabaseIp,
211
+ );
212
+ } catch (err) {
213
+ s.stop('Re-seed failed — aborting failover');
214
+ throw new Error(
215
+ `Failover aborted before promotion: ${err.message}\n` +
216
+ `The standby was NOT promoted and the old primary is untouched — no ` +
217
+ `split-brain and no DNS flip. Resolve the issue (or redeploy the ` +
218
+ `standby) and retry.`,
219
+ );
275
220
  }
276
- await new Promise((r) => setTimeout(r, 2000));
221
+ s.stop(
222
+ result === 'reseeded'
223
+ ? 'Standby re-seeded with primary state'
224
+ : 'Primary unreachable mid-probe — promoting standby with current state',
225
+ );
226
+ } else {
227
+ p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
277
228
  }
278
229
 
279
- return false;
230
+ // Step 1: Promote standby database. A failure here is FATAL: continuing would
231
+ // scale down the old primary and flip DNS onto a read-only standby.
232
+ s.start('Promoting standby database');
233
+ const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
234
+ if (!promoted) {
235
+ s.stop('Promotion could not be confirmed — aborting failover');
236
+ throw new Error(
237
+ `Failover aborted: standby ${servers.standby.ip} did not exit recovery mode. ` +
238
+ `Refusing to scale down the old primary or flip DNS onto an unpromoted ` +
239
+ `standby (that would take the app read-only). Verify the standby db pod and retry.`,
240
+ );
241
+ }
242
+ s.stop('Standby database promoted to primary');
280
243
  }
281
244
 
282
245
  async function scaleUpServices(ip, sshKeyPath) {
@@ -468,36 +431,13 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
468
431
  }
469
432
  }
470
433
 
471
- // Step 0: Re-seed standby from primary (if primary is still reachable).
472
- // Streaming replication can silently fall behind; a stale standby promotes
473
- // to serve queries against an out-of-date schema and hits "undefined
474
- // column" (PG 42703) errors. Skipped when primary is unreachable (true
475
- // disaster failover) — promotion proceeds with whatever state standby
476
- // has. Requires primarySupabaseIp from identifyServers (persisted in
477
- // envConfig.servers by orchestrator.js post-deploy).
478
- if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
479
- s.start('Re-seeding standby from primary (pg_basebackup)');
480
- const reseeded = await reseedStandbyFromPrimary(
481
- servers.standby.ip,
482
- sshKeyPath,
483
- servers.primary.supabaseIp,
484
- );
485
- s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
486
- } else {
487
- p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
488
- }
489
-
490
- // Step 1: Promote standby database
491
- s.start('Promoting standby database');
492
- const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
493
- s.stop(
494
- promoted
495
- ? 'Standby database promoted to primary'
496
- : 'Database promotion may still be in progress',
497
- );
498
- if (!promoted) {
499
- p.log.warn('Could not confirm promotion completed — verify manually');
500
- }
434
+ // Steps 0 + 1: Re-seed standby from primary (if reachable) then promote.
435
+ // Streaming replication can silently fall behind; a stale standby would
436
+ // promote to serve queries against an out-of-date schema and hit "undefined
437
+ // column" (PG 42703) errors. A re-seed failure or an unconfirmed promotion
438
+ // ABORTS the failover (throws) — see reseedAndPromoteOrAbort so we never
439
+ // scale down the old primary or flip DNS onto a half-seeded/unpromoted node.
440
+ await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
501
441
 
502
442
  // Step 2: Scale down old primary services to prevent split-brain.
503
443
  // Per-deployment errors (not-found, unreachable) are logged inside
@@ -631,36 +571,11 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
631
571
  }
632
572
  }
633
573
 
634
- // Step 0: Re-seed standby from primary (if primary is still reachable).
635
- // Streaming replication can silently fall behind; a stale standby promotes
636
- // to serve queries against an out-of-date schema and hits "undefined
637
- // column" (PG 42703) errors. Skipped when primary is unreachable (true
638
- // disaster failover) — promotion proceeds with whatever state standby
639
- // has. Requires primarySupabaseIp from identifyServers (persisted in
640
- // envConfig.servers by orchestrator.js post-deploy).
641
- if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
642
- s.start('Re-seeding standby from primary (pg_basebackup)');
643
- const reseeded = await reseedStandbyFromPrimary(
644
- servers.standby.ip,
645
- sshKeyPath,
646
- servers.primary.supabaseIp,
647
- );
648
- s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
649
- } else {
650
- p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
651
- }
652
-
653
- // Step 1: Promote standby database
654
- s.start('Promoting standby database');
655
- const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
656
- s.stop(
657
- promoted
658
- ? 'Standby database promoted to primary'
659
- : 'Database promotion may still be in progress',
660
- );
661
- if (!promoted) {
662
- p.log.warn('Could not confirm promotion completed — verify manually');
663
- }
574
+ // Steps 0 + 1: Re-seed standby from primary (if reachable) then promote.
575
+ // A re-seed failure or an unconfirmed promotion ABORTS the failover (throws)
576
+ // see reseedAndPromoteOrAbort so we never scale down the old primary or
577
+ // flip DNS onto a half-seeded/unpromoted node.
578
+ await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
664
579
 
665
580
  // Step 2: Scale down old primary services to prevent split-brain.
666
581
  // See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
@@ -771,49 +686,22 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
771
686
 
772
687
  const s = tracker.spinner();
773
688
 
774
- // Promote standby database
775
- s.start('Promoting standby database');
776
- const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
777
- s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
689
+ // Re-seed standby from primary (if reachable) then promote — aborts on
690
+ // failure so we never activate a half-seeded/unpromoted standby.
691
+ await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
778
692
 
779
- // Scale down old primary services to prevent split-brain
693
+ // Scale down old primary services to prevent split-brain. Uses the
694
+ // chart-canonical deployment names (FAILOVER_DEPLOYMENTS) — the previous bare
695
+ // `auth`/`rest`/`realtime` names silently no-op'd against the Helm-owned
696
+ // `supabase-supabase-*` deployments, so the old primary was never scaled down.
780
697
  s.start('Scaling down old primary services');
781
- try {
782
- await sshKubectl(servers.primary.ip, sshKeyPath, [
783
- 'scale',
784
- 'deployment',
785
- 'app',
786
- '-n',
787
- 'vibecarbon',
788
- '--replicas=0',
789
- ]);
790
- await sshKubectl(servers.primary.ip, sshKeyPath, [
791
- 'scale',
792
- 'deployment',
793
- 'auth',
794
- '-n',
795
- 'vibecarbon',
796
- '--replicas=0',
797
- ]);
798
- await sshKubectl(servers.primary.ip, sshKeyPath, [
799
- 'scale',
800
- 'deployment',
801
- 'rest',
802
- '-n',
803
- 'vibecarbon',
804
- '--replicas=0',
805
- ]);
806
- await sshKubectl(servers.primary.ip, sshKeyPath, [
807
- 'scale',
808
- 'deployment',
809
- 'realtime',
810
- '-n',
811
- 'vibecarbon',
812
- '--replicas=0',
813
- ]);
814
- } catch {
815
- p.log.warn('Could not reach old primary to scale down services');
816
- }
698
+ await scaleDeployments(
699
+ servers.primary.ip,
700
+ sshKeyPath,
701
+ FAILOVER_DEPLOYMENTS,
702
+ 0,
703
+ 'scale-down primary',
704
+ );
817
705
  s.stop('Old primary services scaled down');
818
706
 
819
707
  // Scale up services
@@ -853,47 +741,28 @@ async function failoverSingleServer(envName, envConfig) {
853
741
  p.log.warn('This environment has no standby region');
854
742
  p.log.info('Recovery requires deploying a new server and restoring from backup');
855
743
 
856
- // Check for available backups
744
+ // Check for available backups. wal-g is the SINGLE SOURCE OF TRUTH for what is
745
+ // actually restorable — the legacy `/backups/*_full.tar.gz` + `*.sql.gz` S3
746
+ // objects are NOT wal-g backups and `vibecarbon restore` cannot replay them,
747
+ // so listing them here would tempt an operator to pick a non-restorable
748
+ // artifact mid-disaster. List wal-g base backups only.
857
749
  if (serverIp && existsSync(sshKeyPath)) {
858
750
  const s = p.spinner();
859
- s.start('Checking for available backups');
860
- try {
861
- const pod = await sshKubectl(serverIp, sshKeyPath, [
862
- 'get',
863
- 'pods',
864
- '-n',
865
- 'vibecarbon',
866
- '-l',
867
- 'app.kubernetes.io/name=supabase-db',
868
- '-o',
869
- 'jsonpath={.items[0].metadata.name}',
870
- ]);
871
- const output = await sshKubectl(serverIp, sshKeyPath, [
872
- 'exec',
873
- '-n',
874
- 'vibecarbon',
875
- pod,
876
- '--',
877
- 'sh',
878
- '-c',
879
- 'ls -lt /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null | head -5 || echo NO_BACKUPS',
880
- ]);
881
- s.stop('Backup check complete');
882
-
883
- if (output !== 'NO_BACKUPS' && output) {
884
- p.log.info(c.bold('Available backups:'));
885
- for (const line of output
886
- .split('\n')
887
- .filter((l) => l.includes('.tar.gz') || l.includes('.sql.gz'))) {
888
- p.log.message(` ${line.trim()}`);
889
- }
890
- } else {
891
- p.log.info('No backups found on the server');
892
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
751
+ s.start('Checking for available backups (wal-g)');
752
+ const projectConfig = loadProjectConfig();
753
+ const projectName = projectConfig?.projectName || 'project';
754
+ const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
755
+ const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
756
+ s.stop('Backup check complete');
757
+
758
+ if (backups.length > 0) {
759
+ p.log.info(c.bold(`Available wal-g base backups (${backups.length}, newest first):`));
760
+ for (const b of backups.slice(0, 5)) {
761
+ p.log.message(` ${formatInstant(b.time).padEnd(22)} ${c.dim(b.name)}`);
893
762
  }
894
- } catch {
895
- s.stop('Could not reach server');
896
- p.log.warn('Server may be unreachable backups may not be accessible');
763
+ } else {
764
+ p.log.info('No wal-g base backups found on the server');
765
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
897
766
  }
898
767
  }
899
768
 
@@ -933,7 +802,6 @@ export async function run(args) {
933
802
  // failover` from a parent directory emits the canonical message.
934
803
  assertInProjectDir();
935
804
 
936
- requireLicense('failover');
937
805
  printBanner();
938
806
  p.intro(`${c.bold('vibecarbon failover')} ${c.dim(`v${VERSION}`)}`);
939
807
 
@@ -1002,6 +870,19 @@ export async function run(args) {
1002
870
  return;
1003
871
  }
1004
872
 
873
+ // SECURITY: production type-to-confirm runs UNCONDITIONALLY here — even with
874
+ // -y — for any DESTRUCTIVE failover (compose-HA or an HA k8s scenario), so a
875
+ // scripted `failover -y prod` can never silently promote/flip production. This
876
+ // is deliberately OUTSIDE every `if (!parsed.yes)` soft-confirm block (those
877
+ // are skippable with -y; this hard gate is not). Mirrors restore/destroy.
878
+ // Skipped for -dry (previews nothing destructive) and for the single-server
879
+ // recovery guide (informational only — no promotion/DNS change).
880
+ const isDestructiveFailover =
881
+ envConfig.deployMode === 'compose-ha' || detectScenario(envConfig) !== 'single_server';
882
+ if (!parsed.dryRun && isDestructiveFailover) {
883
+ await confirmProdOrExit(envName, { actionLabel: 'failover', yes: !!parsed.yes });
884
+ }
885
+
1005
886
  // Compose-HA failover uses docker compose exec instead of kubectl
1006
887
  if (envConfig.deployMode === 'compose-ha') {
1007
888
  const composeHATracker = createTracker('failover', {
@@ -1046,4 +927,4 @@ export async function run(args) {
1046
927
  }
1047
928
 
1048
929
  // Exported for tests.
1049
- export { SPEC };
930
+ export { getReplPasswordFromSecret, reseedStandbyFromPrimary, SPEC };
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { spawn, spawnSync } from 'node:child_process';
14
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import * as p from '@clack/prompts';
@@ -359,3 +359,21 @@ export function runCommandThroughTaskLog(cmd, options) {
359
359
  export function checkDependency(cmd) {
360
360
  return which.sync(cmd, { nothrow: true }) !== null;
361
361
  }
362
+
363
+ /**
364
+ * Write a file that contains secrets (rendered helm values, credentials, etc.)
365
+ * with owner-only 0o600 permissions, ALWAYS — even when overwriting an existing
366
+ * file (writeFileSync only applies `mode` on creation, so we chmod after).
367
+ *
368
+ * SECURITY: use this for any temp file holding secret material. A plain
369
+ * writeFileSync defaults to 0o644 (world-readable), which leaks credentials to
370
+ * every local user for the lifetime of the file (e.g. a rendered helm values
371
+ * file lives for the whole `helm upgrade --wait` window).
372
+ *
373
+ * @param {string} path
374
+ * @param {string | NodeJS.ArrayBufferView} data
375
+ */
376
+ export function writeSecretFile(path, data) {
377
+ writeFileSync(path, data, { mode: 0o600 });
378
+ chmodSync(path, 0o600);
379
+ }