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
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import * as p from '@clack/prompts';
11
11
  import { runCommand } from '../command.js';
12
+ import { knownHostsPathForKey } from '../host-keys.js';
12
13
  import { perfTimer } from '../perf.js';
13
14
 
14
15
  /**
@@ -41,8 +42,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
41
42
  // session helper drops with "error reading preface ... file already closed".
42
43
  // Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
43
44
  // so the retry below can re-establish, instead of hanging to the build timeout.
45
+ // Host-key pinned against the per-env known_hosts (passed via env var so no
46
+ // shell-quoting concern), NOT /dev/null + no. accept-new TOFU's an
47
+ // ephemeral/recycled Hetzner IP but rejects a changed key for an already-
48
+ // pinned host (MITM on an established env fails); GlobalKnownHostsFile=
49
+ // /dev/null ignores the system file and never touches ~/.ssh/known_hosts.
44
50
  const sshWrapper = `#!/bin/bash
45
- exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
51
+ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$VIBECARBON_KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
46
52
  `;
47
53
  writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
48
54
 
@@ -52,6 +58,7 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
52
58
  DOCKER_BUILDKIT: '1',
53
59
  PATH: `${tempBinDir}:${process.env.PATH}`, // Prepend our wrapper
54
60
  VIBECARBON_SSH_KEY: sshKeyPath,
61
+ VIBECARBON_KNOWN_HOSTS: knownHostsPathForKey(sshKeyPath),
55
62
  };
56
63
 
57
64
  try {
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Shared PostgreSQL replication / re-seed / promotion primitives.
3
+ *
4
+ * The standby re-seed, streaming verification, and promotion logic used to be
5
+ * duplicated (and slowly diverging) across:
6
+ * - src/lib/deploy/k8s/ha/index.js (setupReplication — deploy-time seed)
7
+ * - src/failover.js (reseed + promote at failover)
8
+ * - src/lib/deploy/compose/ha.js (configureStandbyReplication + verify)
9
+ * - src/restore.js (post-restore standby resync)
10
+ *
11
+ * This module houses the reusable, exec-backend-parameterized primitives so the
12
+ * call sites share ONE hardened implementation instead of forking it. The two
13
+ * exec backends differ only in HOW a command reaches postgres:
14
+ * - compose: `docker compose exec -T db psql …` (via sshRun)
15
+ * - k8s: `sshKubectl exec -n vibecarbon <pod> -- …`
16
+ * so the primitives take an injected runner / readState closure rather than
17
+ * hardcoding either transport.
18
+ */
19
+
20
+ import { getPostgresPod, sshKubectl } from '../ssh.js';
21
+
22
+ // The physical replication slot name is a project-wide constant — the primary
23
+ // creates it, the standby's pg_basebackup -S references it, and streaming uses
24
+ // it so the primary holds WAL for this standby specifically.
25
+ export const REPLICATION_SLOT = 'vibecarbon_standby_slot';
26
+
27
+ // The single replication port for BOTH HA modes. compose publishes db:5432 →
28
+ // host:5433 (supavisor owns host 5432); k8s uses a hostPort 5433 → container
29
+ // 5432. One port keeps primary_conninfo / pg_basebackup byte-identical.
30
+ export const REPL_PORT = 5433;
31
+
32
+ /**
33
+ * The `primary_conninfo` the standby uses to stream from the primary. The
34
+ * transport is a point-to-point WireGuard tunnel (see wireguard.js) — the
35
+ * wire is already encrypted at the network layer, so Postgres itself runs
36
+ * plaintext (`sslmode=disable`, no CA to verify against). `primaryHost` is
37
+ * the local tunnel-side endpoint the transport layer supplies (e.g. the
38
+ * WireGuard gateway IP), not a public address. `port` defaults to REPL_PORT
39
+ * (5433) for the compose path where the standby dials the primary db directly;
40
+ * the k8s-ha path passes the repl-gateway relay port (15433) since it dials its
41
+ * local socat gateway rather than postgres directly.
42
+ * @param {{primaryHost:string, replPassword:string, applicationName?:string, port?:string|number}} o
43
+ * @returns {string}
44
+ */
45
+ export function buildPrimaryConninfo({
46
+ primaryHost,
47
+ replPassword,
48
+ applicationName = 'standby',
49
+ port = REPL_PORT,
50
+ }) {
51
+ return (
52
+ `host=${primaryHost} port=${port} user=replicator ` +
53
+ `password=${replPassword} sslmode=disable ` +
54
+ `application_name=${applicationName}`
55
+ );
56
+ }
57
+
58
+ /**
59
+ * pg_hba.conf line allowing replication from the tunnel subnet. Plain `host`
60
+ * (not `hostssl`) — WireGuard encrypts the wire, so Postgres does not also
61
+ * require TLS. Scoped to the WireGuard tunnel's /30 (WG_SUBNET_CIDR), which
62
+ * covers both peers regardless of which side is currently primary/standby.
63
+ * @param {string} peerCidr
64
+ * @returns {string}
65
+ */
66
+ export function buildReplicationHbaLine(peerCidr) {
67
+ return `host replication replicator ${peerCidr} scram-sha-256`;
68
+ }
69
+
70
+ /**
71
+ * Compute the updated Hetzner firewall rule set admitting the peer supabase
72
+ * node's WireGuard tunnel endpoint (udp/51821), or null when no update is
73
+ * needed. Pure: deployK8sHA fetches the firewall, calls this, and PUTs the
74
+ * result — for BOTH clusters with the peer swapped (the post-failover
75
+ * reverse re-seed dials the promoted standby, so its firewall must admit the
76
+ * old primary too; neither failover.js nor restore.js touches firewalls).
77
+ *
78
+ * Replication moved from a public-IP TCP connection to a point-to-point
79
+ * WireGuard tunnel (wireguard.js — WG_PORT=51821; 51820 is flannel-wg's), so
80
+ * this also drops the stale TCP rules from that earlier architecture: the
81
+ * direct 5432 rule (never worked — no hostPort listener), the retired
82
+ * NodePort 30432 rule (SNAT masked the source IP, breaking the /32 pg_hba
83
+ * scope), and the superseded 5433 rule — plus any udp/51821 rule pointing at
84
+ * a previous peer IP (server replacement).
85
+ *
86
+ * @param {Array<object>} existingRules - The firewall's current rules.
87
+ * @param {string} peerIp - The peer supabase node's public IPv4.
88
+ * @returns {Array<object>|null} Updated rules, or null if already correct.
89
+ */
90
+ export function buildReplicationFirewallRules(existingRules, peerIp) {
91
+ const wgPort = '51821';
92
+ const staleTcpPorts = new Set(['5432', '5433', '30432']);
93
+ const isStaleTcp = (r) => r.protocol === 'tcp' && staleTcpPorts.has(r.port);
94
+ const isWgRule = (r) => r.protocol === 'udp' && r.port === wgPort;
95
+
96
+ const hasExactRule = existingRules.some(
97
+ (r) => isWgRule(r) && r.source_ips?.includes(`${peerIp}/32`),
98
+ );
99
+ const hasStale = existingRules.some((r) => isStaleTcp(r) || (isWgRule(r) && !hasExactRule));
100
+ if (hasExactRule && !hasStale) return null;
101
+ const cleaned = existingRules.filter((r) => !isStaleTcp(r) && !isWgRule(r));
102
+ return [
103
+ ...cleaned,
104
+ {
105
+ direction: 'in',
106
+ protocol: 'udp',
107
+ port: wgPort,
108
+ source_ips: [`${peerIp}/32`],
109
+ destination_ips: [],
110
+ },
111
+ ];
112
+ }
113
+
114
+ /**
115
+ * Build the hardened staged-basebackup + atomic-swap bash script.
116
+ *
117
+ * This is the SINGLE source for the k8s re-seed script used at deploy time
118
+ * (k8s/ha/index.js setupReplication) AND at failover/restore time (failover.js,
119
+ * restore.js). Hardenings preserved from the previous inline copies:
120
+ * 1. `set -e -o pipefail` — a partial pg_basebackup leaves PGDATA non-empty
121
+ * without PG_VERSION; the supabase-db entrypoint then runs initdb on next
122
+ * restart, aborts on "directory exists but is not empty", and CrashLoops.
123
+ * 2. Stage into a tmp dir + atomic swap — a failed backup never leaves PGDATA
124
+ * half-formed; the original survives at data.prev until the swap succeeds.
125
+ * 3. Verify PG_VERSION present before swapping in.
126
+ * 4. `probeFirst` embeds a pg_isready guard that aborts (exit 0) BEFORE any
127
+ * destructive work when the primary is unreachable — the standby then keeps
128
+ * its self-bootstrapped postgres instead of a permanent CrashLoopBackOff.
129
+ * (Used by the deploy-time k8s seed, where the probe is inline; the
130
+ * failover/restore path probes separately, so it passes probeFirst=false.)
131
+ *
132
+ * The replication password is embedded in PGPASSWORD inside the returned script,
133
+ * which callers MUST deliver over stdin (never argv) so it stays out of
134
+ * /proc/cmdline and the ssh command string.
135
+ *
136
+ * The connection itself is plaintext (no PGSSLMODE/PGSSLROOTCERT) — the
137
+ * transport is a point-to-point WireGuard tunnel (wireguard.js) that already
138
+ * encrypts the wire, so app-layer TLS is redundant.
139
+ *
140
+ * @param {object} opts
141
+ * @param {string} opts.replPassword
142
+ * @param {string} opts.primaryHost
143
+ * @param {string} [opts.primaryPort='5432']
144
+ * @param {string} [opts.slotName=REPLICATION_SLOT]
145
+ * @param {boolean} [opts.probeFirst=false]
146
+ * @param {string} [opts.label='reseed'] - tag for the script's stderr breadcrumbs
147
+ * @returns {string}
148
+ */
149
+ export function buildStagedBasebackupScript({
150
+ replPassword,
151
+ primaryHost,
152
+ primaryPort = String(REPL_PORT),
153
+ slotName = REPLICATION_SLOT,
154
+ probeFirst = false,
155
+ label = 'reseed',
156
+ }) {
157
+ const probeBlock = probeFirst
158
+ ? `
159
+ # Probe primary's postgres from inside the standby pod. If unreachable
160
+ # (cross-cluster network gap is the historical case), abort cleanly so the
161
+ # standby keeps its self-bootstrapped postgres rather than entering a
162
+ # permanent CrashLoopBackOff via half-wiped PGDATA.
163
+ if ! pg_isready -h ${primaryHost} -p ${primaryPort} -t 5 -U replicator -d postgres > /dev/null 2>&1; then
164
+ echo "[${label}] primary postgres at ${primaryHost}:${primaryPort} not reachable from standby — skipping pg_basebackup. Standby keeps its independent postgres; failover serves the app but data does not sync from primary." >&2
165
+ exit 0
166
+ fi
167
+ `
168
+ : '';
169
+
170
+ return `
171
+ set -e -o pipefail
172
+ ${probeBlock}
173
+ # Stage into a tmp dir; atomic swap only after basebackup verifies good.
174
+ rm -rf /var/lib/postgresql/data.staging
175
+ mkdir -p /var/lib/postgresql/data.staging
176
+ chown postgres:postgres /var/lib/postgresql/data.staging
177
+
178
+ PGPASSWORD='${replPassword}' pg_basebackup \\
179
+ -h ${primaryHost} -p ${primaryPort} \\
180
+ -U replicator -D /var/lib/postgresql/data.staging \\
181
+ -Fp -Xs -R -c fast -S ${slotName}
182
+
183
+ # A successful pg_basebackup always writes PG_VERSION (and standby.signal via -R).
184
+ if [ ! -f /var/lib/postgresql/data.staging/PG_VERSION ]; then
185
+ echo "[${label}] pg_basebackup produced no PG_VERSION — refusing to swap" >&2
186
+ rm -rf /var/lib/postgresql/data.staging
187
+ exit 1
188
+ fi
189
+ # -R should already have created standby.signal; touch belt-and-suspenders.
190
+ touch /var/lib/postgresql/data.staging/standby.signal
191
+ echo "primary_slot_name = '${slotName}'" >> /var/lib/postgresql/data.staging/postgresql.auto.conf
192
+ chown -R postgres:postgres /var/lib/postgresql/data.staging
193
+
194
+ # Replace PGDATA CONTENTS in place. /var/lib/postgresql/data is a container
195
+ # volume MOUNTPOINT in both compose (db_data:/var/lib/postgresql/data) and k8s —
196
+ # it CANNOT be renamed (mv of a mountpoint fails with EBUSY/EXDEV), so we clear
197
+ # the mountpoint and move the verified staged basebackup in. The staged copy is
198
+ # already verified good (PG_VERSION check above) before this destructive clear,
199
+ # so a known-good replacement exists before we touch the live dir.
200
+ find /var/lib/postgresql/data -mindepth 1 -delete
201
+ find /var/lib/postgresql/data.staging -mindepth 1 -maxdepth 1 -exec mv -t /var/lib/postgresql/data/ {} +
202
+ rmdir /var/lib/postgresql/data.staging
203
+ chown -R postgres:postgres /var/lib/postgresql/data
204
+ `.trim();
205
+ }
206
+
207
+ /**
208
+ * Poll a "read the standby's replication state on the primary" closure until it
209
+ * reports `streaming`, or the attempt budget lapses. Injected `readState`
210
+ * (returns pg_stat_replication.state, or '' when no replica is connected) keeps
211
+ * this transport-agnostic — compose passes a `docker compose exec` reader, k8s a
212
+ * `sshKubectl exec` reader.
213
+ *
214
+ * @param {object} opts
215
+ * @param {() => Promise<string>} opts.readState
216
+ * @param {number} [opts.attempts=15]
217
+ * @param {number[]} [opts.delaysMs]
218
+ * @param {(ms: number) => Promise<void>} [opts.sleep]
219
+ * @returns {Promise<{ streaming: boolean, lastState: string }>}
220
+ */
221
+ export async function verifyStreaming({
222
+ readState,
223
+ attempts = 15,
224
+ delaysMs = [250, 500, 1000, 2000, 3000],
225
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
226
+ }) {
227
+ let lastState = '';
228
+ let sawValidRead = false;
229
+ for (let i = 0; i < attempts; i++) {
230
+ try {
231
+ const state = await readState();
232
+ sawValidRead = true;
233
+ lastState = typeof state === 'string' ? state.trim() : '';
234
+ if (lastState === 'streaming') return { streaming: true, lastState };
235
+ } catch {
236
+ // Retry — the standby may still be catching up, or the read itself
237
+ // (SSH/kubectl exec) transiently failed. A read that never yields a valid
238
+ // answer is handled below so the abort message stays honest.
239
+ }
240
+ if (i < attempts - 1) {
241
+ await sleep(delaysMs[Math.min(i, delaysMs.length - 1)] ?? 3000);
242
+ }
243
+ }
244
+ // Distinguish "connected but not streaming" (reads succeeded, state != streaming)
245
+ // from "could not read at all" (every read threw — e.g. the primary was
246
+ // unreachable for the whole window). Without this, the latter reports as a bare
247
+ // "not streaming" which hides a connectivity failure.
248
+ if (!sawValidRead) lastState = 'unreadable (could not query pg_stat_replication)';
249
+ return { streaming: false, lastState };
250
+ }
251
+
252
+ /**
253
+ * Finding #1 — the hard-gate policy, shared by the compose-HA and k8s-HA deploy
254
+ * paths so they behave identically. Given the verified streaming result:
255
+ * - streaming → returns { degraded: false } (deploy proceeds).
256
+ * - !streaming + allowDegraded → returns { degraded: true, reason } (proceed,
257
+ * but the caller must record + surface a DEGRADED / warm-standby state).
258
+ * - !streaming + !allowDegraded → THROWS an actionable Error (abort the deploy):
259
+ * it states WHY (standby not streaming, last observed state) and that
260
+ * `-allow-degraded` will proceed with warm-standby DR.
261
+ *
262
+ * DECISION: hard-gate-by-default was the chosen policy — a green `deployed` HA
263
+ * env must have a verifiably streaming standby. Warm-standby DR is opt-in only.
264
+ *
265
+ * @param {object} opts
266
+ * @param {boolean} opts.streaming
267
+ * @param {string} [opts.lastState]
268
+ * @param {boolean} [opts.allowDegraded=false]
269
+ * @param {string} [opts.fixHint] - mode-specific remediation appended to the error
270
+ * @returns {{ degraded: boolean, reason?: string }}
271
+ */
272
+ export function assertReplicationStreamingOrDegraded({
273
+ streaming,
274
+ lastState = '',
275
+ allowDegraded = false,
276
+ fixHint = '',
277
+ }) {
278
+ if (streaming) return { degraded: false };
279
+
280
+ const stateDesc = lastState ? JSON.stringify(lastState) : 'no replica connected';
281
+ const reason = `standby not streaming (last pg_stat_replication.state=${stateDesc})`;
282
+
283
+ if (allowDegraded) {
284
+ return { degraded: true, reason };
285
+ }
286
+
287
+ throw new Error(
288
+ `HA deploy aborted: the standby is not verifiably streaming from the primary, so ` +
289
+ `disaster recovery is not guaranteed.\n Reason: ${reason}.\n` +
290
+ `${fixHint ? ` ${fixHint}\n` : ''}` +
291
+ ` Re-run \`vibecarbon deploy\` after fixing replication, or re-run with ` +
292
+ `\`-allow-degraded\` to finalize a warm-standby (DEGRADED) deployment that accepts ` +
293
+ `reduced DR.`,
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Read REPL_PASSWORD from the `vibecarbon-secrets` k8s Secret — the SAME source
299
+ * the db uses. k8s nodes have NO compose-style `/opt/<project>/.env.local`; the
300
+ * old grep of that path always returned empty, so a re-seed silently no-op'd and
301
+ * the caller promoted a possibly-stale (or empty) standby. jsonpath returns the
302
+ * base64-encoded value; decode it locally.
303
+ *
304
+ * Returns the plaintext password, or null if the key is absent/undecodable.
305
+ *
306
+ * @param {string} ip
307
+ * @param {string} sshKeyPath
308
+ * @returns {Promise<string|null>}
309
+ */
310
+ export async function getReplPasswordFromSecret(ip, sshKeyPath) {
311
+ const b64 = await sshKubectl(ip, sshKeyPath, [
312
+ 'get',
313
+ 'secret',
314
+ 'vibecarbon-secrets',
315
+ '-n',
316
+ 'vibecarbon',
317
+ '-o',
318
+ 'jsonpath={.data.REPL_PASSWORD}',
319
+ ]);
320
+ const trimmed = (typeof b64 === 'string' ? b64 : '').trim();
321
+ if (!trimmed) return null;
322
+ try {
323
+ const decoded = Buffer.from(trimmed, 'base64').toString('utf-8').trim();
324
+ return decoded || null;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Re-seed the standby Postgres from the primary via pg_basebackup (k8s transport).
332
+ * Closes the drift window that opens when streaming silently falls behind or the
333
+ * standby pod restarted and missed WAL. Used by BOTH failover (before promotion)
334
+ * and restore (post-restore standby resync).
335
+ *
336
+ * Safety contract:
337
+ * - REPL_PASSWORD is read from the `vibecarbon-secrets` Secret. If it is not
338
+ * found we CANNOT safely re-seed → throw a HARD ERROR so the caller aborts
339
+ * rather than acting on a stale standby. Distinct from "primary unreachable".
340
+ * - Reachability is probed from inside the standby pod BEFORE any destructive
341
+ * work. If the primary is not reachable we return 'skipped' WITHOUT touching
342
+ * PGDATA.
343
+ * - The basebackup stages into a tmp dir and atomically swaps in only after it
344
+ * verifies good (PG_VERSION present) — a failed backup never leaves a
345
+ * half-wiped PGDATA that would CrashLoopBackOff on initdb.
346
+ *
347
+ * @param {string} standbyIp
348
+ * @param {string} sshKeyPath
349
+ * @param {string} primarySupabaseIp
350
+ * @returns {Promise<'reseeded'|'skipped'>} 'reseeded' on success, 'skipped' when
351
+ * the primary is unreachable. Throws on password-missing or a real failure —
352
+ * NEVER silently returns as if skipped.
353
+ */
354
+ export async function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
355
+ if (!primarySupabaseIp) {
356
+ throw new Error(
357
+ 're-seed aborted: primary supabase IP is unknown, so pg_basebackup has no ' +
358
+ 'source. Cannot safely re-seed — redeploy to persist the primary supabase IP.',
359
+ );
360
+ }
361
+
362
+ const replPassword = await getReplPasswordFromSecret(standbyIp, sshKeyPath);
363
+ if (!replPassword) {
364
+ throw new Error(
365
+ 're-seed aborted: REPL_PASSWORD not found in the vibecarbon-secrets Secret. ' +
366
+ 'Cannot re-seed the standby from the primary — refusing to act on a ' +
367
+ 'possibly-stale standby. Verify the standby cluster was deployed with `vibecarbon deploy --ha`.',
368
+ );
369
+ }
370
+
371
+ const pod = await getPostgresPod(standbyIp, sshKeyPath);
372
+
373
+ // Connectivity pre-check FROM THE STANDBY POD, before any destructive work.
374
+ // pg_isready exits non-zero when the primary is not accepting connections;
375
+ // sshKubectl throws on non-zero → treat as "unreachable" and skip cleanly.
376
+ let reachable = false;
377
+ try {
378
+ await sshKubectl(standbyIp, sshKeyPath, [
379
+ 'exec',
380
+ '-n',
381
+ 'vibecarbon',
382
+ pod,
383
+ '--',
384
+ 'pg_isready',
385
+ '-h',
386
+ primarySupabaseIp,
387
+ '-p',
388
+ '5432',
389
+ '-t',
390
+ '5',
391
+ '-U',
392
+ 'replicator',
393
+ '-d',
394
+ 'postgres',
395
+ ]);
396
+ reachable = true;
397
+ } catch {
398
+ reachable = false;
399
+ }
400
+ if (!reachable) {
401
+ return 'skipped';
402
+ }
403
+
404
+ // Stop the standby postgres so PGDATA can be swapped safely, then stage a
405
+ // fresh basebackup and atomically swap it in.
406
+ await sshKubectl(
407
+ standbyIp,
408
+ sshKeyPath,
409
+ [
410
+ 'exec',
411
+ '-n',
412
+ 'vibecarbon',
413
+ pod,
414
+ '--',
415
+ 'su',
416
+ 'postgres',
417
+ '-c',
418
+ 'pg_ctl stop -D /var/lib/postgresql/data -m fast',
419
+ ],
420
+ {},
421
+ ).catch(() => {
422
+ // Already stopped / not running — the swap below is still safe.
423
+ });
424
+
425
+ const script = buildStagedBasebackupScript({
426
+ replPassword,
427
+ primaryHost: primarySupabaseIp,
428
+ primaryPort: '5432',
429
+ probeFirst: false,
430
+ label: 'reseed',
431
+ });
432
+ await sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
433
+ input: script,
434
+ });
435
+
436
+ // Bring the freshly-seeded standby back up (in recovery — streaming). Any
437
+ // promotion is the caller's next step (failover); restore leaves it streaming.
438
+ await sshKubectl(standbyIp, sshKeyPath, [
439
+ 'exec',
440
+ '-n',
441
+ 'vibecarbon',
442
+ pod,
443
+ '--',
444
+ 'su',
445
+ 'postgres',
446
+ '-c',
447
+ 'pg_ctl start -D /var/lib/postgresql/data -l /var/log/postgresql/postgresql.log',
448
+ ]);
449
+ return 'reseeded';
450
+ }
451
+
452
+ /**
453
+ * Promote the standby database to primary (k8s transport) and confirm it exited
454
+ * recovery. Returns true only when pg_is_in_recovery() flips to 'f' — the caller
455
+ * MUST abort the failover on false rather than flip DNS onto a read-only standby.
456
+ *
457
+ * @param {string} standbyIp
458
+ * @param {string} sshKeyPath
459
+ * @returns {Promise<boolean>}
460
+ */
461
+ export async function promoteStandby(standbyIp, sshKeyPath) {
462
+ const pod = await getPostgresPod(standbyIp, sshKeyPath);
463
+
464
+ // Promote PostgreSQL on standby. pg_ctl promote may return non-zero even on
465
+ // success, so we confirm via the pg_is_in_recovery() poll below.
466
+ try {
467
+ await sshKubectl(standbyIp, sshKeyPath, [
468
+ 'exec',
469
+ '-n',
470
+ 'vibecarbon',
471
+ pod,
472
+ '--',
473
+ 'su',
474
+ 'postgres',
475
+ '-c',
476
+ 'pg_ctl promote -D /var/lib/postgresql/data',
477
+ ]);
478
+ } catch {
479
+ // pg_ctl promote may return non-zero even on success — confirm via poll.
480
+ }
481
+
482
+ for (let i = 0; i < 30; i++) {
483
+ try {
484
+ const result = await sshKubectl(standbyIp, sshKeyPath, [
485
+ 'exec',
486
+ '-n',
487
+ 'vibecarbon',
488
+ pod,
489
+ '--',
490
+ 'psql',
491
+ '-U',
492
+ 'supabase_admin',
493
+ '-tAc',
494
+ 'SELECT pg_is_in_recovery()',
495
+ ]);
496
+ if (result.trim() === 'f') return true;
497
+ } catch {
498
+ // Retry
499
+ }
500
+ await new Promise((r) => setTimeout(r, 2000));
501
+ }
502
+
503
+ return false;
504
+ }
505
+
506
+ /**
507
+ * Read the standby's replication state as seen on the PRIMARY (k8s transport).
508
+ * Returns pg_stat_replication.state ('streaming'/'catchup'/…) or '' when no
509
+ * replica is connected. Suitable as the `readState` closure for verifyStreaming.
510
+ *
511
+ * @param {string} primaryIp
512
+ * @param {string} sshKeyPath
513
+ * @returns {Promise<string>}
514
+ */
515
+ export async function readK8sReplicationState(primaryIp, sshKeyPath) {
516
+ const pod = await getPostgresPod(primaryIp, sshKeyPath);
517
+ const result = await sshKubectl(primaryIp, sshKeyPath, [
518
+ 'exec',
519
+ '-n',
520
+ 'vibecarbon',
521
+ pod,
522
+ '--',
523
+ 'psql',
524
+ '-U',
525
+ 'supabase_admin',
526
+ '-tAc',
527
+ "SELECT state FROM pg_stat_replication ORDER BY (state = 'streaming') DESC LIMIT 1",
528
+ ]);
529
+ const out = (typeof result === 'string' ? result : '').trim();
530
+ // `psql -tAc` yields exactly one token: a pg_stat_replication.state value, or
531
+ // '' when no replica is connected. Anything else means the read itself failed —
532
+ // an SSH/kubectl error banner (e.g. "banner exchange: Connection timed out")
533
+ // leaked through as stdout. Throw so verifyStreaming RETRIES rather than letting
534
+ // the error text masquerade as a replication state in the deploy-abort message.
535
+ const VALID_STATES = new Set(['', 'startup', 'catchup', 'streaming', 'backup', 'stopping']);
536
+ if (!VALID_STATES.has(out)) {
537
+ throw new Error(`could not read pg_stat_replication (unexpected output: ${out.slice(0, 120)})`);
538
+ }
539
+ return out;
540
+ }
@@ -7,6 +7,18 @@ import { createHash } from 'node:crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
 
10
+ // Schema version for the persisted deploy-state file. Bump this whenever the
11
+ // on-disk shape or the meaning of a step's inputs/result changes. `load()`
12
+ // treats any file whose `version` doesn't match as fully-unstarted, so a CLI
13
+ // upgrade can't resume a deploy on top of an incompatible state shape (which
14
+ // would skip steps whose semantics have since changed).
15
+ //
16
+ // v2: introduced the `version` field itself + the dedicated-Pulumi-state
17
+ // bucket. Pre-v2 files (no `version`) are auto-invalidated — their `s3-setup`
18
+ // step predates the state bucket, so re-running it (to create + migrate the
19
+ // state bucket) is exactly what we want.
20
+ export const STATE_VERSION = 2;
21
+
10
22
  export class StateTracker {
11
23
  constructor(projectName, environment) {
12
24
  this.projectName = projectName;
@@ -17,17 +29,25 @@ export class StateTracker {
17
29
  }
18
30
 
19
31
  /**
20
- * Load state from disk
32
+ * Load state from disk. Auto-invalidates (returns a fresh, empty state) when
33
+ * the persisted `version` doesn't match STATE_VERSION, so an upgraded CLI
34
+ * never resumes on an incompatible state shape.
21
35
  */
22
36
  load() {
23
37
  if (existsSync(this.stateFile)) {
24
38
  try {
25
- return JSON.parse(readFileSync(this.stateFile, 'utf-8'));
39
+ const parsed = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
40
+ if (!parsed || parsed.version !== STATE_VERSION) {
41
+ // Version mismatch (or a pre-versioning file) — discard so every
42
+ // step is treated as not-completed and re-runs against reality.
43
+ return { version: STATE_VERSION, steps: {} };
44
+ }
45
+ return parsed;
26
46
  } catch {
27
- return { steps: {} };
47
+ return { version: STATE_VERSION, steps: {} };
28
48
  }
29
49
  }
30
- return { steps: {} };
50
+ return { version: STATE_VERSION, steps: {} };
31
51
  }
32
52
 
33
53
  /**
@@ -37,6 +57,9 @@ export class StateTracker {
37
57
  if (!existsSync(this.stateDir)) {
38
58
  mkdirSync(this.stateDir, { recursive: true });
39
59
  }
60
+ // Always stamp the current schema version so a later load() can detect
61
+ // incompatibility after a CLI upgrade.
62
+ this.state.version = STATE_VERSION;
40
63
  writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2));
41
64
  }
42
65
 
@@ -66,6 +89,40 @@ export class StateTracker {
66
89
  return false;
67
90
  }
68
91
 
92
+ /**
93
+ * Like shouldSkip, but consults an optional async remote-existence probe
94
+ * before agreeing to skip. Local hash equality only proves the step ran
95
+ * with these inputs *once* — it says nothing about whether the remote
96
+ * resource still exists (a bucket / server may have been deleted
97
+ * out-of-band, e.g. by a prior `destroy`). If `verifyFn` resolves falsy,
98
+ * the remote is gone and the step must re-run even on a hash match, so the
99
+ * reconciling step runs instead of a downstream step failing on the missing
100
+ * resource.
101
+ *
102
+ * `verifyFn` is only invoked when the hash already matches (the cheap local
103
+ * check gates the network probe). A probe that THROWS is treated as
104
+ * "can't confirm the remote is gone" → fall back to the hash decision
105
+ * (skip), so a transient S3/API blip doesn't force an expensive re-run.
106
+ *
107
+ * @param {string} stepId
108
+ * @param {object} inputs
109
+ * @param {() => Promise<boolean>} [verifyFn] - resolves true if the remote
110
+ * resource still exists, false if it's gone.
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ async shouldSkipWithVerify(stepId, inputs, verifyFn) {
114
+ if (!this.shouldSkip(stepId, inputs)) return false;
115
+ if (typeof verifyFn !== 'function') return true;
116
+ try {
117
+ const stillExists = await verifyFn();
118
+ return !!stillExists;
119
+ } catch {
120
+ // Probe failed — we can't prove the remote is gone. Honor the hash
121
+ // decision (skip) rather than forcing a re-run on a transient error.
122
+ return true;
123
+ }
124
+ }
125
+
69
126
  /**
70
127
  * Record a step as started
71
128
  * @param {string} stepId
@@ -110,7 +167,7 @@ export class StateTracker {
110
167
  * Clear state for a new deployment
111
168
  */
112
169
  clear() {
113
- this.state = { steps: {} };
170
+ this.state = { version: STATE_VERSION, steps: {} };
114
171
  if (existsSync(this.stateFile)) {
115
172
  try {
116
173
  writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2));