vibecarbon 0.8.0 → 0.10.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 (121) 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/scripts/dev-init.js +6 -2
  12. package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
  13. package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
  14. package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
  15. package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
  16. package/carbon/src/client/components/AppSidebar.tsx +35 -28
  17. package/carbon/src/client/components/CTAFooter.tsx +1 -1
  18. package/carbon/src/client/components/ComparisonSection.tsx +1 -3
  19. package/carbon/src/client/components/ContentPanel.tsx +1 -1
  20. package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
  21. package/carbon/src/client/components/FAQSection.tsx +1 -1
  22. package/carbon/src/client/components/Hero.tsx +13 -1
  23. package/carbon/src/client/components/Logo.tsx +66 -49
  24. package/carbon/src/client/components/Nav.tsx +1 -1
  25. package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
  26. package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
  27. package/carbon/src/client/components/PageHeader.tsx +1 -1
  28. package/carbon/src/client/components/PricingSection.tsx +2 -6
  29. package/carbon/src/client/components/TechStackSection.tsx +1 -3
  30. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
  31. package/carbon/src/client/components/ui/button.tsx +1 -1
  32. package/carbon/src/client/components/ui/card.tsx +1 -1
  33. package/carbon/src/client/components/ui/dialog.tsx +1 -1
  34. package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
  35. package/carbon/src/client/components/ui/popover.tsx +1 -1
  36. package/carbon/src/client/components/ui/select.tsx +1 -1
  37. package/carbon/src/client/components/ui/sidebar.tsx +1 -1
  38. package/carbon/src/client/index.css +186 -104
  39. package/carbon/src/client/lib/supabase.ts +41 -0
  40. package/carbon/src/client/lib/theme.ts +14 -14
  41. package/carbon/src/client/locales/de.json +27 -12
  42. package/carbon/src/client/locales/en.json +33 -18
  43. package/carbon/src/client/locales/es.json +27 -12
  44. package/carbon/src/client/locales/fr.json +27 -12
  45. package/carbon/src/client/locales/pt.json +27 -12
  46. package/carbon/src/client/pages/Blog.tsx +1 -1
  47. package/carbon/src/client/pages/Changelog.tsx +1 -1
  48. package/carbon/src/client/pages/Checkout.tsx +1 -1
  49. package/carbon/src/client/pages/Dashboard.tsx +33 -40
  50. package/carbon/src/client/pages/Docs.tsx +1 -1
  51. package/carbon/src/client/pages/Home.tsx +1 -1
  52. package/carbon/src/client/pages/Legal.tsx +1 -1
  53. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  54. package/carbon/src/server/billing/providers/polar.ts +14 -0
  55. package/carbon/src/server/index.ts +12 -9
  56. package/carbon/src/server/lib/client-ip.ts +68 -0
  57. package/carbon/src/server/lib/env.ts +20 -6
  58. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  59. package/carbon/src/server/lib/supabase.ts +10 -4
  60. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  61. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  62. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  63. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  64. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  65. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  66. package/carbon/src/server/routes/v1/auth.ts +5 -2
  67. package/carbon/src/server/routes/v1/billing.ts +24 -64
  68. package/carbon/src/server/routes/v1/index.ts +8 -1
  69. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  70. package/carbon/src/server/routes/v1/theme.ts +36 -15
  71. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  72. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  73. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  74. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  75. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  76. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  77. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  78. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  79. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  80. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  81. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  82. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  83. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  84. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  85. package/carbon/volumes/db/wal-archive.sh +36 -0
  86. package/package.json +2 -2
  87. package/src/backup.js +0 -2
  88. package/src/cli.js +16 -0
  89. package/src/deploy.js +16 -5
  90. package/src/destroy.js +78 -0
  91. package/src/failover.js +118 -237
  92. package/src/lib/command.js +19 -1
  93. package/src/lib/deploy/compose/ha.js +180 -102
  94. package/src/lib/deploy/compose/index.js +63 -20
  95. package/src/lib/deploy/image.js +19 -23
  96. package/src/lib/deploy/k8s/ha/index.js +397 -150
  97. package/src/lib/deploy/k8s/k3s.js +65 -14
  98. package/src/lib/deploy/orchestrator.js +176 -6
  99. package/src/lib/deploy/remote-build.js +8 -1
  100. package/src/lib/deploy/replication.js +540 -0
  101. package/src/lib/deploy/state.js +62 -5
  102. package/src/lib/deploy/utils.js +30 -2
  103. package/src/lib/deploy/wireguard.js +146 -0
  104. package/src/lib/host-keys.js +120 -5
  105. package/src/lib/iac/index.js +57 -23
  106. package/src/lib/licensing/gate.js +59 -0
  107. package/src/lib/licensing/index.js +1 -1
  108. package/src/lib/licensing/tiers.js +6 -5
  109. package/src/lib/prod-confirm.js +65 -0
  110. package/src/lib/providers/hetzner-s3.js +85 -0
  111. package/src/lib/ssh.js +35 -35
  112. package/src/restore.js +102 -14
  113. package/src/scale.js +4 -2
  114. package/src/status.js +122 -0
  115. package/src/upgrade.js +0 -4
  116. package/carbon/ha/activate-standby.sh +0 -52
  117. package/carbon/ha/standby-init.sh +0 -74
  118. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
  119. package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
  120. package/carbon/src/server/lib/request.ts +0 -34
  121. package/src/lib/pod-backups.js +0 -53
@@ -2,7 +2,7 @@
2
2
  * High Availability Kubernetes Deployment Module (Diamond & Fullerene)
3
3
  *
4
4
  * Handles multi-region Kubernetes deployment with HA capabilities.
5
- * Licensed under AGPL v3.
5
+ * Licensed under FSL-1.1-MIT.
6
6
  *
7
7
  * Features:
8
8
  * - Multi-region Kubernetes clusters
@@ -29,12 +29,28 @@ import { c } from '../../../colors.js';
29
29
  import { runCommandAsync } from '../../../command.js';
30
30
  import { fetchWithRetry } from '../../../fetch-retry.js';
31
31
  import { setupHA as setupHetznerDnsHA } from '../../../hetzner-dns.js';
32
+ import { knownHostsPathForKey, seedKnownHosts } from '../../../host-keys.js';
32
33
  import { initBackend } from '../../../iac/index.js';
33
34
  import { perfAsync } from '../../../perf.js';
34
35
  import { HetznerProvider } from '../../../providers/hetzner.js';
35
36
  import { createPrefixedTracker } from '../../../tracker.js';
37
+ import {
38
+ assertReplicationStreamingOrDegraded,
39
+ buildPrimaryConninfo,
40
+ buildReplicationFirewallRules,
41
+ buildReplicationHbaLine,
42
+ buildStagedBasebackupScript,
43
+ REPL_PORT,
44
+ verifyStreaming,
45
+ } from '../../replication.js';
36
46
  import { StateTracker } from '../../state.js';
37
47
  import { readReplPassword } from '../../utils.js';
48
+ import {
49
+ exchangeAndBringUpTunnel,
50
+ REPL_GATEWAY_PORT,
51
+ WG_PRIMARY_IP,
52
+ WG_SUBNET_CIDR,
53
+ } from '../../wireguard.js';
38
54
  import { deployK3s } from '../k3s.js';
39
55
 
40
56
  /**
@@ -104,6 +120,99 @@ const __dirname = dirname(__filename);
104
120
  // Path to HA scripts in the template
105
121
  const HA_SCRIPTS_PATH = join(__dirname, '../../../../../carbon/ha');
106
122
 
123
+ // The repl-gateway manifest (Pod + additive egress NetworkPolicy). HA-only, so
124
+ // it lives outside the base kustomization; setupReplication renders its
125
+ // placeholders per-cluster and applies it via `kubectl apply -f -`.
126
+ const REPL_GATEWAY_MANIFEST = join(
127
+ __dirname,
128
+ '../../../../../carbon/k8s/base/repl-gateway/repl-gateway.yaml',
129
+ );
130
+
131
+ /**
132
+ * The two socat address specs for the repl-gateway relay, keyed by cluster
133
+ * role. Pure — unit-tested; rendered into the repl-gateway Pod manifest.
134
+ *
135
+ * - primary: bind the tunnel IP (10.99.0.1:15433) and forward to the local
136
+ * postgres hostPort (127.0.0.1:5433). Reachable only via wg0.
137
+ * - standby: bind the node's private IP (<priv>:15433) and forward INTO the
138
+ * tunnel to the primary gateway (10.99.0.1:15433). This is the address the
139
+ * standby db pod dials.
140
+ *
141
+ * @param {'primary'|'standby'} role
142
+ * @param {string} nodePrivateIp - this cluster's supabase node private IP
143
+ * @returns {{relayListen: string, relayTarget: string}}
144
+ */
145
+ export function buildReplGatewaySocatArgs(role, nodePrivateIp) {
146
+ if (role === 'primary') {
147
+ return {
148
+ relayListen: `TCP-LISTEN:${REPL_GATEWAY_PORT},bind=${WG_PRIMARY_IP},fork,reuseaddr`,
149
+ relayTarget: `TCP:127.0.0.1:${REPL_PORT}`,
150
+ };
151
+ }
152
+ if (role === 'standby') {
153
+ return {
154
+ relayListen: `TCP-LISTEN:${REPL_GATEWAY_PORT},bind=${nodePrivateIp},fork,reuseaddr`,
155
+ relayTarget: `TCP:${WG_PRIMARY_IP}:${REPL_GATEWAY_PORT}`,
156
+ };
157
+ }
158
+ throw new Error(`buildReplGatewaySocatArgs: unknown role '${role}'`);
159
+ }
160
+
161
+ /**
162
+ * Render the repl-gateway manifest for one cluster: substitute the socat relay
163
+ * direction + the local node private IP (egress NetworkPolicy scope). The
164
+ * result is applied via `kubectl apply -f -` — placeholders are resolved at
165
+ * apply time, never left for runtime.
166
+ *
167
+ * @param {object} o
168
+ * @param {string} o.template - the raw repl-gateway.yaml
169
+ * @param {'primary'|'standby'} o.role
170
+ * @param {string} o.nodePrivateIp - this cluster's supabase node private IP
171
+ * @returns {string}
172
+ */
173
+ export function renderReplGatewayManifest({ template, role, nodePrivateIp }) {
174
+ const { relayListen, relayTarget } = buildReplGatewaySocatArgs(role, nodePrivateIp);
175
+ return template
176
+ .replaceAll('__RELAY_LISTEN__', relayListen)
177
+ .replaceAll('__RELAY_TARGET__', relayTarget)
178
+ .replaceAll('__SUPABASE_PRIVATE_IP__', nodePrivateIp);
179
+ }
180
+
181
+ /**
182
+ * Strategic-merge patch that binds the primary's postgres to REPL_PORT (hostPort)
183
+ * → containerPort 5432 on the node. The primary repl-gateway (hostNetwork socat)
184
+ * relays tunnel traffic to `127.0.0.1:REPL_PORT` — this hostPort IS that local
185
+ * listener. NOTE: the port is NOT opened in the Hetzner firewall (the firewall
186
+ * now admits only WG udp/51821), so postgres is reachable only via localhost on
187
+ * the node, never cross-cluster over the public internet. Without this hostPort
188
+ * the db is reachable only inside the cluster and the gateway's local relay
189
+ * target would have nothing to connect to. Mirrors the traefik/registry hostPort
190
+ * pattern. One db pod per node (StatefulSet), so the node's :REPL_PORT is
191
+ * uncontended.
192
+ *
193
+ * The container name in the supabase chart's db StatefulSet is `supabase-db`.
194
+ * A strategic-merge patch keyed by container `name` + port `containerPort`
195
+ * updates the existing port entry in place (adds hostPort) rather than
196
+ * appending a duplicate.
197
+ * @returns {object}
198
+ */
199
+ export function buildDbHostPortPatch() {
200
+ return {
201
+ spec: {
202
+ template: {
203
+ spec: {
204
+ containers: [
205
+ {
206
+ name: 'supabase-db',
207
+ ports: [{ containerPort: 5432, hostPort: REPL_PORT }],
208
+ },
209
+ ],
210
+ },
211
+ },
212
+ },
213
+ };
214
+ }
215
+
107
216
  /**
108
217
  * Deploy with High Availability
109
218
  * @param {object} options - Deployment options
@@ -308,84 +417,129 @@ export async function deployK8sHA(options) {
308
417
  const standbyResult = standbySettled.value;
309
418
  p.log.success('Both clusters deployed');
310
419
 
311
- // 3. Open port 5432 in the primary's Hetzner Cloud firewall so the standby's supabase node
312
- // can reach the primary's postgres directly via hostPort. The base firewall (declared in
313
- // src/lib/iac/programs/hetzner-k8s.js) only allows SSH, HTTP, HTTPS, K8s API, and
314
- // private-network traffic.
315
- if (options.apiToken && standbyResult.supabaseIp) {
316
- const primaryClusterName = `${options.projectName}-${options.environment}-primary`;
317
- const firewallName = `${primaryClusterName}-firewall`;
318
- try {
319
- const headers = {
320
- Authorization: `Bearer ${options.apiToken}`,
321
- 'Content-Type': 'application/json',
322
- };
323
- const fwListRes = await fetchWithRetry(
324
- `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(firewallName)}`,
325
- { headers },
326
- );
327
- const fwListData = await fwListRes.json();
328
- const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
329
- if (firewall) {
330
- const existingRules = firewall.rules || [];
331
- const hasReplRule = existingRules.some(
332
- (r) => r.port === '5432' && r.source_ips?.includes(`${standbyResult.supabaseIp}/32`),
420
+ // 3. Open the WireGuard tunnel port (UDP 51821) in BOTH clusters' Hetzner
421
+ // Cloud firewalls, each admitting its PEER supabase node's PUBLIC IP the
422
+ // WG endpoints are the supabase nodes' public IPs, and the firewall's default
423
+ // udp rule only admits the private range. Symmetric on both clusters: the
424
+ // standby streams from the primary at deploy time, and the post-failover
425
+ // reverse re-seed dials the promoted standby (neither failover.js nor
426
+ // restore.js touches firewalls, so deploy must set up both directions). The
427
+ // per-cluster firewall (`<project>-<env>-<role>-firewall`) is attached to all
428
+ // that cluster's nodes incl. the supabase/WG node. buildReplicationFirewallRules
429
+ // emits the udp/51821 rule (and scrubs stale TCP 5432/5433/30432 rules from the
430
+ // retired public-IP TLS transport).
431
+ if (options.apiToken) {
432
+ const headers = {
433
+ Authorization: `Bearer ${options.apiToken}`,
434
+ 'Content-Type': 'application/json',
435
+ };
436
+ for (const [clusterRole, peerSupabaseIp] of [
437
+ ['primary', standbyResult.supabaseIp],
438
+ ['standby', primaryResult.supabaseIp],
439
+ ]) {
440
+ if (!peerSupabaseIp) continue;
441
+ const firewallName = `${options.projectName}-${options.environment}-${clusterRole}-firewall`;
442
+ try {
443
+ const fwListRes = await fetchWithRetry(
444
+ `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(firewallName)}`,
445
+ { headers },
333
446
  );
334
- if (!hasReplRule) {
335
- // Remove any stale NodePort 30432 rules from previous deploys
336
- const cleanedRules = existingRules.filter((r) => r.port !== '30432');
337
- const updatedRules = [
338
- ...cleanedRules,
339
- {
340
- direction: 'in',
341
- protocol: 'tcp',
342
- port: '5432',
343
- source_ips: [`${standbyResult.supabaseIp}/32`],
344
- description: 'PostgreSQL replication from standby supabase node',
345
- },
346
- ];
347
- await fetchWithRetry(
348
- `https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`,
349
- {
350
- method: 'POST',
351
- headers,
352
- body: JSON.stringify({ rules: updatedRules }),
353
- },
354
- );
447
+ const fwListData = await fwListRes.json();
448
+ const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
449
+ if (firewall) {
450
+ const updatedRules = buildReplicationFirewallRules(firewall.rules || [], peerSupabaseIp);
451
+ if (updatedRules) {
452
+ await fetchWithRetry(
453
+ `https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`,
454
+ {
455
+ method: 'POST',
456
+ headers,
457
+ body: JSON.stringify({ rules: updatedRules }),
458
+ },
459
+ );
460
+ }
355
461
  }
462
+ } catch {
463
+ // Non-fatal: manual firewall config may suffice
356
464
  }
357
- } catch {
358
- // Non-fatal: manual firewall config may suffice
359
465
  }
360
466
  }
361
467
 
362
468
  // 4. Configure PostgreSQL replication (must run after both clusters finish —
363
469
  // replication restarts postgres pods, which would break concurrent tail work).
470
+ //
471
+ // DECISION (finding #1, hard-gate-by-default): an HA deploy whose standby is
472
+ // NOT verifiably streaming now FAILS by default. Warm-standby / degraded DR is
473
+ // no longer silently accepted — a deploy that reports `deployed` must have a
474
+ // real replica. The operator can opt back into the old warn-and-continue
475
+ // behavior with `vibecarbon deploy -allow-degraded` (options.allowDegraded),
476
+ // which finalizes the env in a DEGRADED (warm-standby) state. k8s-HA
477
+ // replication has a documented history of fragility (memory:
478
+ // project_replication_broken), so this gate MAY turn previously green deploys
479
+ // red — that is the intended safety behavior; the failure message below is
480
+ // actionable (why + last observed state + the -allow-degraded escape hatch).
481
+ const allowDegraded = !!options.allowDegraded;
364
482
  s.start('Configuring PostgreSQL replication');
365
483
  let replicationStatus = 'not configured';
484
+ let replStreaming = false;
485
+ let replLastState = '';
486
+ let replError = null;
366
487
  try {
367
488
  const replResult = await setupReplication({
368
489
  primaryIp: primaryResult.masterIp,
369
490
  standbyIp: standbyResult.masterIp,
370
491
  primarySupabaseIp: primaryResult.supabaseIp,
371
492
  standbySupabaseIp: standbyResult.supabaseIp,
493
+ primarySupabasePrivateIp: primaryResult.supabasePrivateIp,
494
+ standbySupabasePrivateIp: standbyResult.supabasePrivateIp,
372
495
  sshKeyPath: sharedSshKeyPath,
373
496
  });
374
- if (replResult.streaming) {
375
- s.stop('PostgreSQL replication configured streaming');
376
- replicationStatus = 'streaming';
377
- } else {
378
- s.stop('PostgreSQL replication configured — verify manually');
379
- replicationStatus = 'configured (verify streaming)';
380
- }
497
+ replStreaming = replResult.streaming;
498
+ replLastState = replResult.lastState || '';
381
499
  } catch (error) {
382
- const msg = error.message || String(error);
500
+ replError = error;
501
+ }
502
+
503
+ // If setupReplication threw, fold its message into the last-state hint so the
504
+ // shared gate surfaces the real cause (e.g. a cross-cluster connectivity error)
505
+ // rather than a generic "no replica connected".
506
+ let lastStateHint = replLastState;
507
+ if (replError && !lastStateHint) {
508
+ const msg = replError.message || String(replError);
383
509
  const lines = msg.split('\n').filter((l) => l.trim() && !l.includes('Warning: Permanently'));
384
510
  const lastLine = lines[lines.length - 1]?.trim() || msg;
385
- const shortError = lastLine.length > 120 ? `${lastLine.slice(0, 120)}…` : lastLine;
386
- s.stop(`Replication setup failed: ${shortError}`);
387
- replicationStatus = `failed: ${shortError}`;
388
- p.log.warn('You may need to configure replication manually');
511
+ lastStateHint = lastLine.length > 160 ? `${lastLine.slice(0, 160)}…` : lastLine;
512
+ }
513
+
514
+ // Shared hard-gate (finding #1): throws by default when not streaming; with
515
+ // -allow-degraded it returns { degraded: true }. k8s-HA replication has a
516
+ // documented history of fragility (memory: project_replication_broken), so
517
+ // this gate MAY turn previously green deploys red — that is the intended
518
+ // safety behavior.
519
+ try {
520
+ assertReplicationStreamingOrDegraded({
521
+ streaming: replStreaming,
522
+ lastState: lastStateHint,
523
+ allowDegraded,
524
+ fixHint: 'Fix the replication issue (see the k8s-ha replication notes).',
525
+ });
526
+ } catch (gateErr) {
527
+ s.stop('Replication verification failed — aborting HA deploy');
528
+ throw gateErr;
529
+ }
530
+
531
+ if (replStreaming) {
532
+ s.stop('PostgreSQL replication configured — streaming');
533
+ replicationStatus = 'streaming';
534
+ } else {
535
+ s.stop('Replication DEGRADED — continuing (-allow-degraded)');
536
+ replicationStatus = 'DEGRADED (warm standby — NOT streaming)';
537
+ p.log.warn(
538
+ 'DEGRADED HA: the standby is not streaming from the primary. Disaster recovery ' +
539
+ 'is NOT guaranteed — the standby is a warm/cold spare that may be missing recent ' +
540
+ 'writes. You proceeded with -allow-degraded; resync with `vibecarbon deploy` (no ' +
541
+ '-allow-degraded) once the replication issue is resolved.',
542
+ );
389
543
  }
390
544
 
391
545
  // 5. Configure Cloudflare DNS for failover
@@ -474,6 +628,11 @@ export async function deployK8sHA(options) {
474
628
  primary: primaryResult,
475
629
  standby: standbyResult,
476
630
  domain: options.domain,
631
+ // Surface DR posture so the orchestrator can persist a degraded marker. When
632
+ // the gate passed, replicationStatus === 'streaming'; with -allow-degraded it
633
+ // reflects the warm-standby state.
634
+ replicationStatus,
635
+ degraded: replicationStatus !== 'streaming',
477
636
  };
478
637
  }
479
638
 
@@ -483,16 +642,24 @@ export async function deployK8sHA(options) {
483
642
  * @param {string} options.primaryIp - Primary master node IP (for SSH)
484
643
  * @param {string} options.standbyIp - Standby master node IP (for SSH)
485
644
  * @param {string} options.sshKeyPath - Path to shared SSH key
486
- * @param {string} options.primarySupabaseIp - Primary supabase node IP (where postgres runs)
487
- * @param {string} options.standbySupabaseIp - Standby supabase node IP (where postgres runs)
645
+ * @param {string} options.primarySupabaseIp - Primary supabase node PUBLIC IP (WG endpoint + SSH)
646
+ * @param {string} options.standbySupabaseIp - Standby supabase node PUBLIC IP (WG endpoint + SSH)
647
+ * @param {string} [options.primarySupabasePrivateIp] - Primary supabase node private IP (gateway bind + egress netpol)
648
+ * @param {string} [options.standbySupabasePrivateIp] - Standby supabase node private IP (gateway bind + egress netpol)
488
649
  * @returns {Promise<{streaming: boolean}>} Replication status
489
650
  */
490
651
  export async function setupReplication(options) {
491
652
  const { primaryIp, standbyIp, sshKeyPath } = options;
492
- // pg_basebackup connects to the supabase node IP (where postgres pod runs),
493
- // not the master node. Fall back to master IP if supabase IP not provided.
653
+ // The supabase node PUBLIC IPs are BOTH the WireGuard tunnel endpoints and the
654
+ // SSH targets for the gateway bring-up. Fall back to master IP if not provided.
494
655
  const _primarySupabaseIp = options.primarySupabaseIp || primaryIp;
495
656
  const _standbySupabaseIp = options.standbySupabaseIp || standbyIp;
657
+ // The supabase nodes' PRIVATE IPs. Deterministic in the IaC program
658
+ // (10.0.1.2 on both clusters' 10.0.1.0/24 subnets), but threaded through the
659
+ // deploy outputs rather than hardcoded at the use site. Used for the
660
+ // repl-gateway socat bind + the egress NetworkPolicy scope on each cluster.
661
+ const _primarySupabasePrivateIp = options.primarySupabasePrivateIp || '10.0.1.2';
662
+ const _standbySupabasePrivateIp = options.standbySupabasePrivateIp || '10.0.1.2';
496
663
 
497
664
  // replPassword is generated at create time from crypto.randomBytes and is
498
665
  // restricted to base64url characters — no shell escaping needed. Read from
@@ -506,15 +673,26 @@ export async function setupReplication(options) {
506
673
  );
507
674
  }
508
675
 
509
- // SSH opts as argv — keeps all command builders below in argv form
510
- // (no local-shell parsing). Host-key pinning migration tracked as H-1.
676
+ // SSH opts as argv — keeps all command builders below in argv form (no
677
+ // local-shell parsing). Host keys are pinned to the per-env known_hosts file
678
+ // derived from the shared HA deploy key. Seed both cluster IPs first (the
679
+ // clusters just finished deploying, so they're reachable) so accept-new pins
680
+ // the real host keys via ssh-keyscan instead of blindly trusting the first
681
+ // connection. GlobalKnownHostsFile=/dev/null ignores the system-wide file;
682
+ // accept-new TOFUs a fresh/recycled Hetzner IP but REJECTS a changed key for
683
+ // an already-pinned host.
684
+ const khPath = knownHostsPathForKey(sshKeyPath);
685
+ await seedKnownHosts(khPath, primaryIp);
686
+ await seedKnownHosts(khPath, standbyIp);
511
687
  const sshOpts = [
512
688
  '-i',
513
689
  sshKeyPath,
514
690
  '-o',
515
- 'StrictHostKeyChecking=no',
691
+ `UserKnownHostsFile=${khPath}`,
692
+ '-o',
693
+ 'GlobalKnownHostsFile=/dev/null',
516
694
  '-o',
517
- 'UserKnownHostsFile=/dev/null',
695
+ 'StrictHostKeyChecking=accept-new',
518
696
  '-o',
519
697
  'BatchMode=yes',
520
698
  ];
@@ -531,14 +709,13 @@ export async function setupReplication(options) {
531
709
  // separate tokens and the final remote-command string is the only place
532
710
  // a shell parses anything. No local template-literal interpolation.
533
711
 
534
- // Render primary-init.sql with the real REPL_PASSWORD, SCP to remote, then clean up.
535
- // The SQL template contains the legacy placeholder password; replace it with the
536
- // per-deploy random password. We avoid writing the placeholder literal here by
537
- // building it from parts so this source file itself passes the no-legacy-password check.
712
+ // Render primary-init.sql with the real REPL_PASSWORD, SCP to remote, then
713
+ // clean up. The SQL template ships the `{{REPL_PASSWORD}}` placeholder (a
714
+ // token, not a credential); substitute the per-deploy random password. These
715
+ // two MUST stay in sync if the template's placeholder is renamed, k8s-HA
716
+ // would SCP an unrendered SQL file and replication auth would break.
538
717
  const sqlTemplate = readFileSync(primaryInitSql, 'utf-8');
539
- // Legacy placeholder in the SQL file: 'repl' + '_password' (split to avoid literal match)
540
- const legacyPlaceholder = "'repl" + "_password'";
541
- const renderedSql = sqlTemplate.replaceAll(legacyPlaceholder, `'${replPassword}'`);
718
+ const renderedSql = sqlTemplate.replaceAll('{{REPL_PASSWORD}}', replPassword);
542
719
  const tmpDir = mkdtempSync(join(tmpdir(), 'vibecarbon-repl-'));
543
720
  const renderedSqlPath = join(tmpDir, 'primary-init.sql');
544
721
  try {
@@ -568,22 +745,100 @@ export async function setupReplication(options) {
568
745
  });
569
746
  }
570
747
 
571
- // 2. Add pg_hba.conf entry allowing replication from any IP (password-protected).
572
- // NodePort traffic is SNAT'd by kube-proxy, so the source IP seen by postgres is
573
- // an internal cluster IP, not the standby's public IP. We cannot tighten the CIDR
574
- // without moving replication off NodePort (follow-up). The defense in depth is:
575
- // 1. Per-deploy random REPL_PASSWORD (generated in create.js via crypto.randomBytes).
576
- // 2. Hetzner firewall restricts external source IPs reaching the NodePort.
577
- // 3. md5 auth on pg_hba.conf.
748
+ // 1a. Stand up the WireGuard replication transport.
749
+ //
750
+ // Replication runs over a point-to-point WireGuard tunnel (wg0, UDP
751
+ // WG_PORT=51821) between the two clusters' supabase nodes, relayed by a
752
+ // hostNetwork `repl-gateway` socat pod on each cluster:
753
+ //
754
+ // standby db pod ─tcp <priv>:15433─► standby repl-gateway ─wg0→10.99.0.1:15433─►
755
+ // primary repl-gateway ─tcp 127.0.0.1:5433─► primary postgres (hostPort)
756
+ //
757
+ // The old public-IP TLS transport (per-deploy CA/cert Secret + public-IP
758
+ // NetworkPolicy allowances) is gone: WireGuard encrypts the wire, so there is
759
+ // no cert Secret, and the ONLY netpol change is the single additive
760
+ // `allow-db-repl-gateway-egress` shipped INSIDE the gateway manifest (db pod →
761
+ // local node private IP tcp 15433) — default-deny-all and rp_filter are
762
+ // untouched.
763
+ //
764
+ // Render + apply the gateway manifest to BOTH clusters (each with its own
765
+ // relay direction + node private IP), then generate keys + bring up wg0.
766
+ const gatewayTemplate = readFileSync(REPL_GATEWAY_MANIFEST, 'utf-8');
767
+ await runCommandAsync(['ssh', ...sshOpts, `root@${primaryIp}`, 'kubectl apply -f -'], {
768
+ silent: true,
769
+ input: renderReplGatewayManifest({
770
+ template: gatewayTemplate,
771
+ role: 'primary',
772
+ nodePrivateIp: _primarySupabasePrivateIp,
773
+ }),
774
+ });
775
+ await runCommandAsync(['ssh', ...sshOpts, `root@${standbyIp}`, 'kubectl apply -f -'], {
776
+ silent: true,
777
+ input: renderReplGatewayManifest({
778
+ template: gatewayTemplate,
779
+ role: 'standby',
780
+ nodePrivateIp: _standbySupabasePrivateIp,
781
+ }),
782
+ });
783
+
784
+ // Generate a keypair on each supabase node, cross-distribute PUBLIC keys, and
785
+ // bring up wg0. Private keys are generated on-node and never leave it (see
786
+ // exchangeAndBringUpTunnel). The WG endpoints/SSH targets are the supabase
787
+ // nodes' PUBLIC IPs.
788
+ await exchangeAndBringUpTunnel({
789
+ primaryIp: _primarySupabaseIp,
790
+ standbyIp: _standbySupabaseIp,
791
+ sshKeyPath,
792
+ });
793
+
794
+ // 1b. Bind the primary's postgres to a node hostPort (REPL_PORT → containerPort
795
+ // 5432) so the primary repl-gateway's socat can relay tunnel traffic to it at
796
+ // 127.0.0.1:REPL_PORT. Not opened in the firewall — localhost-only on the node
797
+ // (the gateway is hostNetwork, so it shares the node netns). Without the
798
+ // hostPort the db is cluster-internal only and the gateway relay target has
799
+ // nothing to connect to. The strategic-merge patch travels via stdin
800
+ // (--patch-file /dev/stdin) so the JSON never hits argv / the remote shell's
801
+ // /proc/cmdline. The patch triggers a pod recreate; wait for the StatefulSet
802
+ // rollout before opening replication.
578
803
  await runCommandAsync(
579
804
  [
580
805
  'ssh',
581
806
  ...sshOpts,
582
807
  `root@${primaryIp}`,
583
- `kubectl exec -n vibecarbon supabase-supabase-db-0 -- bash -c "echo 'host replication replicator 0.0.0.0/0 md5' >> /var/lib/postgresql/data/pg_hba.conf"`,
808
+ 'kubectl patch statefulset supabase-supabase-db -n vibecarbon --type strategic --patch-file /dev/stdin',
809
+ ],
810
+ { silent: true, input: JSON.stringify(buildDbHostPortPatch()) },
811
+ );
812
+ await runCommandAsync(
813
+ [
814
+ 'ssh',
815
+ ...sshOpts,
816
+ `root@${primaryIp}`,
817
+ 'kubectl rollout status statefulset/supabase-supabase-db -n vibecarbon --timeout=180s',
584
818
  ],
585
819
  { silent: true },
586
820
  );
821
+
822
+ // 2. Add a pg_hba.conf entry allowing replication from the WireGuard tunnel
823
+ // subnet (WG_SUBNET_CIDR). Plain `host` (buildReplicationHbaLine), not
824
+ // `hostssl` — WireGuard encrypts the wire, so Postgres doesn't also require
825
+ // TLS. One line (not per-peer /32s) because the tunnel subnet covers BOTH
826
+ // peers regardless of which side is currently primary/standby: after a
827
+ // failover the roles swap, and pg_basebackup copies this line byte-for-byte
828
+ // into the standby, so it stays valid for the reverse re-seed with no
829
+ // per-peer bookkeeping. Defense in depth: per-deploy random REPL_PASSWORD +
830
+ // the WireGuard tunnel + scram-sha-256 auth + this subnet-scoped pg_hba
831
+ // entry.
832
+ const hbaLine = buildReplicationHbaLine(WG_SUBNET_CIDR);
833
+ await runCommandAsync(
834
+ [
835
+ 'ssh',
836
+ ...sshOpts,
837
+ `root@${primaryIp}`,
838
+ 'kubectl exec -i -n vibecarbon supabase-supabase-db-0 -- bash -c "cat >> /var/lib/postgresql/data/pg_hba.conf"',
839
+ ],
840
+ { silent: true, input: `${hbaLine}\n` },
841
+ );
587
842
  await runCommandAsync(
588
843
  [
589
844
  'ssh',
@@ -727,12 +982,14 @@ export async function setupReplication(options) {
727
982
  );
728
983
 
729
984
  // 4. Initialize standby as a streaming replica of the primary.
730
- // Modeled after standby-init.sh runs pg_basebackup directly inside the standby pod
731
- // connecting to the primary's supabase node via hostPort 5432 (the Hetzner Cloud firewall
732
- // was opened in step 3 of the HA orchestrator). Uses -Fp (plain format) and -R to auto-
733
- // create standby.signal and primary_conninfo.
734
- const primaryHost = _primarySupabaseIp;
735
- const primaryPort = '5432';
985
+ // Runs pg_basebackup inside the standby pod, dialing the standby's OWN node
986
+ // private IP on the repl-gateway relay port (REPL_GATEWAY_PORT=15433). That
987
+ // intra-node hop is admitted by the additive allow-db-repl-gateway-egress
988
+ // netpol; the standby gateway relays it through the WireGuard tunnel to the
989
+ // primary gateway, which forwards to the primary postgres hostPort. Plaintext
990
+ // (no PGSSLMODE/PGSSLROOTCERT) — WireGuard is the encryption layer.
991
+ const primaryHost = _standbySupabasePrivateIp;
992
+ const primaryPort = String(REPL_GATEWAY_PORT);
736
993
 
737
994
  // 4a. Stop standby postgres gracefully
738
995
  await runCommandAsync(
@@ -762,51 +1019,17 @@ export async function setupReplication(options) {
762
1019
  // PGDATA in a half-formed state.
763
1020
  // 4. Sanity check PG_VERSION is present after basebackup before swapping in.
764
1021
  //
765
- // -Fp/-Xs/-R/-S: see prior comment — unchanged semantics.
766
- // Static staging path (single-instance pod, no PID collision concern).
767
- // Literal strings everywhere avoids the JS-template / bash-escape
768
- // confusion that would silently produce `$STAGE` literals if backslashes
769
- // are off by one.
770
- const pgBasebackupScript = `
771
- set -e -o pipefail
772
-
773
- # Probe primary's hostPort 5432 from inside the standby pod. If unreachable
774
- # (cross-cluster network gap is the historical case), abort cleanly so the
775
- # standby keeps its self-bootstrapped postgres rather than entering a
776
- # permanent CrashLoopBackOff via half-wiped PGDATA.
777
- if ! pg_isready -h ${primaryHost} -p ${primaryPort} -t 5 -U replicator -d postgres > /dev/null 2>&1; then
778
- echo "[ha-replication] primary postgres at ${primaryHost}:${primaryPort} not reachable from standby pod — skipping pg_basebackup. Standby will run as an independent postgres; failover serves the app but data does not sync from primary." >&2
779
- exit 0
780
- fi
781
-
782
- # Stage into a tmp dir; atomic swap only after basebackup verifies good.
783
- rm -rf /var/lib/postgresql/data.staging
784
- mkdir -p /var/lib/postgresql/data.staging
785
- chown postgres:postgres /var/lib/postgresql/data.staging
786
-
787
- PGPASSWORD='${replPassword}' pg_basebackup \\
788
- -h ${primaryHost} -p ${primaryPort} \\
789
- -U replicator -D /var/lib/postgresql/data.staging \\
790
- -Fp -Xs -R -c fast -S vibecarbon_standby_slot
791
-
792
- # Sanity: a successful pg_basebackup always writes PG_VERSION + standby.signal (with -R).
793
- if [ ! -f /var/lib/postgresql/data.staging/PG_VERSION ]; then
794
- echo "[ha-replication] pg_basebackup completed without PG_VERSION — refusing to swap" >&2
795
- rm -rf /var/lib/postgresql/data.staging
796
- exit 1
797
- fi
798
- # -R should already have created standby.signal; touch belt-and-suspenders.
799
- touch /var/lib/postgresql/data.staging/standby.signal
800
- echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data.staging/postgresql.auto.conf
801
- chown -R postgres:postgres /var/lib/postgresql/data.staging
802
-
803
- # Atomic-ish swap. Old PGDATA is moved aside first; on success it's removed.
804
- # A failure during swap leaves the original at data.prev for manual recovery.
805
- rm -rf /var/lib/postgresql/data.prev
806
- mv /var/lib/postgresql/data /var/lib/postgresql/data.prev
807
- mv /var/lib/postgresql/data.staging /var/lib/postgresql/data
808
- rm -rf /var/lib/postgresql/data.prev
809
- `.trim();
1022
+ // -Fp/-Xs/-R/-S: see prior comment — unchanged semantics. The staged-swap
1023
+ // script + probe-first guard are built by the shared buildStagedBasebackupScript
1024
+ // (finding #2): the SAME hardened script the failover/restore re-seed uses, so
1025
+ // the deploy-time seed and the recovery-time re-seed can't drift apart.
1026
+ const pgBasebackupScript = buildStagedBasebackupScript({
1027
+ replPassword,
1028
+ primaryHost,
1029
+ primaryPort,
1030
+ probeFirst: true,
1031
+ label: 'ha-replication',
1032
+ });
810
1033
  await runCommandAsync(
811
1034
  [
812
1035
  'ssh',
@@ -814,7 +1037,35 @@ rm -rf /var/lib/postgresql/data.prev
814
1037
  `root@${standbyIp}`,
815
1038
  'kubectl exec -i -n vibecarbon supabase-supabase-db-0 -- bash',
816
1039
  ],
817
- { silent: false, input: pgBasebackupScript, ignoreError: true },
1040
+ // input: pipes the basebackup script over stdin — requires silent:true
1041
+ // (the stdin pipe is unavailable with inherited stdio). Without this the
1042
+ // call throws before the standby is ever seeded, so it never streams.
1043
+ { silent: true, input: pgBasebackupScript, ignoreError: true },
1044
+ );
1045
+
1046
+ // 4b2. Pin the standby's streaming primary_conninfo (plaintext — sslmode=disable;
1047
+ // WireGuard is the encryption layer). The standby streams through its LOCAL
1048
+ // repl-gateway (own node private IP : REPL_GATEWAY_PORT), same path as the
1049
+ // basebackup above — NOT the primary's public IP. The staged basebackup's
1050
+ // `-R` writes a primary_conninfo from the backup connection, but we append our
1051
+ // explicit conninfo anyway (last entry in postgresql.auto.conf wins) so it's
1052
+ // byte-identical to what the shared builder produces. The password is
1053
+ // delivered over stdin (never argv). No-op-safe when basebackup was skipped:
1054
+ // without standby.signal, postgres ignores primary_conninfo.
1055
+ const standbyConninfo = buildPrimaryConninfo({
1056
+ primaryHost: _standbySupabasePrivateIp,
1057
+ replPassword,
1058
+ port: REPL_GATEWAY_PORT,
1059
+ });
1060
+ await runCommandAsync(
1061
+ [
1062
+ 'ssh',
1063
+ ...sshOpts,
1064
+ `root@${standbyIp}`,
1065
+ 'kubectl exec -i -n vibecarbon supabase-supabase-db-0 -- bash -c ' +
1066
+ '"cat >> /var/lib/postgresql/data/postgresql.auto.conf"',
1067
+ ],
1068
+ { silent: true, input: `primary_conninfo = '${standbyConninfo}'\n`, ignoreError: true },
818
1069
  );
819
1070
 
820
1071
  // 4c. Start standby postgres in recovery mode (no-op when basebackup was
@@ -853,30 +1104,26 @@ rm -rf /var/lib/postgresql/data.prev
853
1104
  }
854
1105
 
855
1106
  // 5. Verify replication is streaming on the primary. Once standby catches
856
- // up, pg_stat_replication.state flips to 'streaming' on the next read.
857
- let streaming = false;
858
- for (let i = 0; i < 15; i++) {
859
- try {
1107
+ // up, pg_stat_replication.state flips to 'streaming' on the next read. Uses
1108
+ // the shared verifyStreaming poller (finding #2) with a k8s readState closure
1109
+ // so compose + k8s share ONE verify implementation; lastState feeds the
1110
+ // finding-#1 hard-gate error message.
1111
+ const { streaming, lastState } = await verifyStreaming({
1112
+ readState: async () => {
860
1113
  const result = await runCommandAsync(
861
1114
  [
862
1115
  'ssh',
863
1116
  ...sshOpts,
864
1117
  `root@${primaryIp}`,
865
- `kubectl exec -n vibecarbon supabase-supabase-db-0 -- psql -U supabase_admin -tAc 'SELECT state FROM pg_stat_replication LIMIT 1'`,
1118
+ `kubectl exec -n vibecarbon supabase-supabase-db-0 -- psql -U supabase_admin -tAc "SELECT state FROM pg_stat_replication ORDER BY (state = 'streaming') DESC LIMIT 1"`,
866
1119
  ],
867
1120
  { silent: true, ignoreError: true },
868
1121
  );
869
- if (result?.trim() === 'streaming') {
870
- streaming = true;
871
- break;
872
- }
873
- } catch {
874
- // Retry
875
- }
876
- if (i === 14) break;
877
- const interval = i < 3 ? 500 : 2000;
878
- await new Promise((r) => setTimeout(r, interval));
879
- }
1122
+ return typeof result === 'string' ? result.trim() : '';
1123
+ },
1124
+ attempts: 15,
1125
+ delaysMs: [500, 500, 2000],
1126
+ });
880
1127
 
881
- return { streaming };
1128
+ return { streaming, lastState };
882
1129
  }