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.
- 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/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- 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/Checkout.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 +968 -886
- 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 +106 -57
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +38 -30
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +411 -279
- package/src/lib/deploy/orchestrator.js +274 -453
- 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/tier-registry.js +30 -0
- 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/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +105 -133
- 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/server/lib/request.ts +0 -34
- 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=
|
|
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
|
+
}
|
package/src/lib/deploy/state.js
CHANGED
|
@@ -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
|
-
|
|
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));
|