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.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/scripts/dev-init.js +6 -2
- package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
- package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
- package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
- package/carbon/src/client/components/AppSidebar.tsx +35 -28
- package/carbon/src/client/components/CTAFooter.tsx +1 -1
- package/carbon/src/client/components/ComparisonSection.tsx +1 -3
- package/carbon/src/client/components/ContentPanel.tsx +1 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
- package/carbon/src/client/components/FAQSection.tsx +1 -1
- package/carbon/src/client/components/Hero.tsx +13 -1
- package/carbon/src/client/components/Logo.tsx +66 -49
- package/carbon/src/client/components/Nav.tsx +1 -1
- package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
- package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
- package/carbon/src/client/components/PageHeader.tsx +1 -1
- package/carbon/src/client/components/PricingSection.tsx +2 -6
- package/carbon/src/client/components/TechStackSection.tsx +1 -3
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
- package/carbon/src/client/components/ui/button.tsx +1 -1
- package/carbon/src/client/components/ui/card.tsx +1 -1
- package/carbon/src/client/components/ui/dialog.tsx +1 -1
- package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
- package/carbon/src/client/components/ui/popover.tsx +1 -1
- package/carbon/src/client/components/ui/select.tsx +1 -1
- package/carbon/src/client/components/ui/sidebar.tsx +1 -1
- package/carbon/src/client/index.css +186 -104
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/lib/theme.ts +14 -14
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +33 -18
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Blog.tsx +1 -1
- package/carbon/src/client/pages/Changelog.tsx +1 -1
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/client/pages/Dashboard.tsx +33 -40
- package/carbon/src/client/pages/Docs.tsx +1 -1
- package/carbon/src/client/pages/Home.tsx +1 -1
- package/carbon/src/client/pages/Legal.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
- package/carbon/src/server/lib/request.ts +0 -34
- 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
|
|
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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// private
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const updatedRules = [
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 (
|
|
487
|
-
* @param {string} options.standbySupabaseIp - Standby supabase node IP (
|
|
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
|
-
//
|
|
493
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
691
|
+
`UserKnownHostsFile=${khPath}`,
|
|
692
|
+
'-o',
|
|
693
|
+
'GlobalKnownHostsFile=/dev/null',
|
|
516
694
|
'-o',
|
|
517
|
-
'
|
|
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
|
|
535
|
-
// The SQL template
|
|
536
|
-
//
|
|
537
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
}
|