vibecarbon 0.9.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/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 +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/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
|
@@ -21,10 +21,24 @@ import {
|
|
|
21
21
|
saveProjectConfig,
|
|
22
22
|
} from '../../config.js';
|
|
23
23
|
import { fetchWithRetry } from '../../fetch-retry.js';
|
|
24
|
+
import { knownHostsPathForKey, seedKnownHosts } from '../../host-keys.js';
|
|
24
25
|
import { perfAsync } from '../../perf.js';
|
|
25
26
|
import { getProvider } from '../../providers/index.js';
|
|
26
27
|
import { useDnsChallenge } from '../acme.js';
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
assertReplicationStreamingOrDegraded,
|
|
30
|
+
buildPrimaryConninfo,
|
|
31
|
+
buildReplicationHbaLine,
|
|
32
|
+
buildStagedBasebackupScript,
|
|
33
|
+
verifyStreaming,
|
|
34
|
+
} from '../replication.js';
|
|
35
|
+
import {
|
|
36
|
+
generateSSHKeyPair,
|
|
37
|
+
mergeRemoteDotenv,
|
|
38
|
+
pinnedSshOptsString,
|
|
39
|
+
readReplPassword,
|
|
40
|
+
} from '../utils.js';
|
|
41
|
+
import { WG_SUBNET_CIDR } from '../wireguard.js';
|
|
28
42
|
import {
|
|
29
43
|
createAdminUser,
|
|
30
44
|
dockerLoginOnServer,
|
|
@@ -211,11 +225,12 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
|
211
225
|
* Configure the primary PostgreSQL server for streaming replication.
|
|
212
226
|
*
|
|
213
227
|
* 1. Create a dedicated replication user
|
|
214
|
-
* 2. Allow the
|
|
228
|
+
* 2. Allow replication from the WireGuard tunnel subnet in pg_hba.conf (plain
|
|
229
|
+
* `host` — WireGuard encrypts the wire, so Postgres doesn't also need TLS)
|
|
215
230
|
* 3. Set wal_level=replica and max_wal_senders
|
|
216
231
|
* 4. Restart PostgreSQL to apply changes
|
|
217
232
|
*/
|
|
218
|
-
async function configurePrimaryReplication(primaryIp,
|
|
233
|
+
async function configurePrimaryReplication(primaryIp, sshKeyPath, projectName) {
|
|
219
234
|
const remoteDir = `/opt/${projectName}`;
|
|
220
235
|
// replPassword is generated at create time from crypto.randomBytes and is
|
|
221
236
|
// restricted to base64url characters — no shell escaping needed.
|
|
@@ -239,8 +254,10 @@ async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, pro
|
|
|
239
254
|
// hold WAL until the standby confirms receipt, closing the drift
|
|
240
255
|
// window.
|
|
241
256
|
//
|
|
242
|
-
// The SQL
|
|
243
|
-
//
|
|
257
|
+
// The SQL (which embeds the replication password) is piped to psql via STDIN
|
|
258
|
+
// (`input:`), so it never appears in argv / the ssh command string / the
|
|
259
|
+
// remote shell's /proc/cmdline. `docker compose exec -T` forwards our stdin
|
|
260
|
+
// straight into psql.
|
|
244
261
|
const createRoleSql = `
|
|
245
262
|
DO $$
|
|
246
263
|
BEGIN
|
|
@@ -263,27 +280,29 @@ BEGIN
|
|
|
263
280
|
END IF;
|
|
264
281
|
END $$;
|
|
265
282
|
`;
|
|
266
|
-
const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
|
|
267
283
|
await sshRun(
|
|
268
284
|
primaryIp,
|
|
269
285
|
sshKeyPath,
|
|
270
|
-
`cd ${remoteDir} &&
|
|
271
|
-
{ timeout: 30_000 },
|
|
286
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres`,
|
|
287
|
+
{ timeout: 30_000, input: createRoleSql },
|
|
272
288
|
);
|
|
273
289
|
|
|
274
|
-
// Update pg_hba.conf to allow replication from
|
|
275
|
-
//
|
|
290
|
+
// Update pg_hba.conf to allow replication from the WireGuard tunnel subnet
|
|
291
|
+
// (WG_SUBNET_CIDR) — one line covers both peers regardless of which side is
|
|
292
|
+
// currently primary/standby. The supabase/postgres image overrides hba_file
|
|
293
|
+
// to /etc/postgresql/pg_hba.conf (NOT the default
|
|
276
294
|
// /var/lib/postgresql/data/pg_hba.conf). RCA: compose-ha 2026-05-01 fanout12
|
|
277
295
|
// — appended to the wrong file, postgres never saw the rule, every
|
|
278
296
|
// replication connection was rejected with "no pg_hba.conf entry" while
|
|
279
297
|
// pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
|
|
280
298
|
// entry). Resolve the path at runtime via SHOW hba_file so we don't break
|
|
281
299
|
// again if the image moves the file.
|
|
300
|
+
const hbaLine = buildReplicationHbaLine(WG_SUBNET_CIDR);
|
|
282
301
|
await sshRun(
|
|
283
302
|
primaryIp,
|
|
284
303
|
sshKeyPath,
|
|
285
304
|
`cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
|
|
286
|
-
`docker compose exec -T db sh -c "echo '
|
|
305
|
+
`docker compose exec -T db sh -c "echo '${hbaLine}' >> $HBA_FILE && ` +
|
|
287
306
|
`psql -U supabase_admin -d postgres -tAc 'SELECT pg_reload_conf();'"`,
|
|
288
307
|
{ timeout: 15_000 },
|
|
289
308
|
);
|
|
@@ -296,6 +315,15 @@ END $$;
|
|
|
296
315
|
{ timeout: 30_000 },
|
|
297
316
|
);
|
|
298
317
|
|
|
318
|
+
// TODO(wireguard-transport): tunnel bring-up — Task 6.
|
|
319
|
+
//
|
|
320
|
+
// This used to enable server-side TLS (verify-ca) here: copy the per-deploy
|
|
321
|
+
// CA/cert/key from the bind-mounted Secret into PGDATA and ALTER SYSTEM SET
|
|
322
|
+
// ssl = 'on' + ssl_cert_file/ssl_key_file/ssl_ca_file. That's public-IP-era
|
|
323
|
+
// plumbing for the TLS transport being replaced — WireGuard encrypts the
|
|
324
|
+
// wire instead, so there's no cert material to install here. Task 6 wires
|
|
325
|
+
// the actual wg0 tunnel bring-up for compose-ha.
|
|
326
|
+
|
|
299
327
|
// Restart PostgreSQL to apply WAL changes
|
|
300
328
|
await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
|
|
301
329
|
timeout: 60_000,
|
|
@@ -371,64 +399,34 @@ END $$;`;
|
|
|
371
399
|
timeout: 60_000,
|
|
372
400
|
});
|
|
373
401
|
|
|
374
|
-
// Run pg_basebackup from primary into standby's data directory
|
|
375
|
-
//
|
|
376
|
-
// -
|
|
377
|
-
// -
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// After pg_basebackup, append `primary_slot_name` to postgresql.auto.conf
|
|
386
|
-
// so the continuous-streaming connection (after initial backup) also uses
|
|
387
|
-
// the slot. `-R` writes primary_conninfo but not primary_slot_name.
|
|
402
|
+
// Run pg_basebackup from primary into standby's data directory, using the
|
|
403
|
+
// SHARED staged-basebackup script — the SAME hardened script the k8s path +
|
|
404
|
+
// failover/restore re-seed use, so the deploy-time seed and the
|
|
405
|
+
// recovery-time re-seed can't drift apart. It stages into a tmp dir +
|
|
406
|
+
// atomically swaps in only after verifying PG_VERSION, so a failed/partial
|
|
407
|
+
// basebackup never leaves PGDATA half-formed (the `initdb: directory exists
|
|
408
|
+
// but is not empty` crash-loop). probeFirst aborts cleanly (exit 0, PGDATA
|
|
409
|
+
// untouched) when the primary's REPL_PORT is unreachable. Plaintext (no
|
|
410
|
+
// PGSSLMODE/PGSSLROOTCERT) — see TODO(wireguard-transport) above; the
|
|
411
|
+
// connection is unencrypted until Task 6 lands the tunnel.
|
|
388
412
|
//
|
|
389
|
-
//
|
|
390
|
-
// pg_basebackup
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
# no standby.signal, came up as PRIMARY, started archiving WAL via
|
|
406
|
-
# wal-g, and pg_is_in_recovery() returned 'f' for the entire 60s probe
|
|
407
|
-
# window. Write everything ourselves — don't trust -R.
|
|
408
|
-
touch /var/lib/postgresql/data/standby.signal
|
|
409
|
-
# Replace postgresql.auto.conf wholesale (don't append — the upstream
|
|
410
|
-
# image's first start can ALTER SYSTEM RESET ALL and wipe whatever -R
|
|
411
|
-
# claimed to write). Both primary_conninfo and primary_slot_name are
|
|
412
|
-
# load-bearing: missing primary_conninfo means recovery has nowhere
|
|
413
|
-
# to read WAL from, missing primary_slot_name means primary won't
|
|
414
|
-
# hold WAL for this standby and streaming silently falls behind.
|
|
415
|
-
cat > /var/lib/postgresql/data/postgresql.auto.conf <<EOF
|
|
416
|
-
# Do not edit this file manually!
|
|
417
|
-
# It will be overwritten by the ALTER SYSTEM command.
|
|
418
|
-
primary_conninfo = 'host=${primaryIp} port=5433 user=replicator password=${replPassword} application_name=standby'
|
|
419
|
-
primary_slot_name = 'vibecarbon_standby_slot'
|
|
420
|
-
EOF
|
|
421
|
-
chown -R postgres:postgres /var/lib/postgresql/data
|
|
422
|
-
# Final sanity checks. Each one is the postcondition for a different
|
|
423
|
-
# downstream failure mode: PG_VERSION → docker compose start initdb
|
|
424
|
-
# loop; standby.signal → postgres comes up as primary; primary_conninfo
|
|
425
|
-
# in auto.conf → recovery has no source.
|
|
426
|
-
test -f /var/lib/postgresql/data/PG_VERSION
|
|
427
|
-
test -f /var/lib/postgresql/data/standby.signal
|
|
428
|
-
grep -q '^primary_conninfo' /var/lib/postgresql/data/postgresql.auto.conf
|
|
429
|
-
grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
413
|
+
// After the swap we pin primary_conninfo explicitly (last entry in
|
|
414
|
+
// postgresql.auto.conf wins): pg_basebackup -R records a conninfo from the
|
|
415
|
+
// backup connection, but we write our own anyway so it's byte-identical to
|
|
416
|
+
// what the shared builder produces. When probeFirst skips, the staged
|
|
417
|
+
// script exits 0 before reaching this tail, so the independent standby is
|
|
418
|
+
// left untouched.
|
|
419
|
+
const basebackupScript = `${buildStagedBasebackupScript({
|
|
420
|
+
replPassword,
|
|
421
|
+
primaryHost: primaryIp,
|
|
422
|
+
probeFirst: true,
|
|
423
|
+
label: 'compose-ha-repl',
|
|
424
|
+
})}
|
|
425
|
+
cat >> /var/lib/postgresql/data/postgresql.auto.conf <<'REPL_CONNINFO_EOF'
|
|
426
|
+
primary_conninfo = '${buildPrimaryConninfo({ primaryHost: primaryIp, replPassword })}'
|
|
427
|
+
REPL_CONNINFO_EOF
|
|
428
|
+
chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
|
|
430
429
|
`;
|
|
431
|
-
const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
|
|
432
430
|
try {
|
|
433
431
|
await sshRun(
|
|
434
432
|
standbyIp,
|
|
@@ -442,14 +440,22 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
|
442
440
|
// missing standby.signal / wiped postgresql.auto.conf goes
|
|
443
441
|
// undetected until pg_is_in_recovery() returns 'f' an entire
|
|
444
442
|
// basebackup later. RCA: compose-ha 2026-05-01 fanout10 failover.
|
|
445
|
-
|
|
443
|
+
//
|
|
444
|
+
// The script (which embeds the replication password in PGPASSWORD +
|
|
445
|
+
// primary_conninfo) is piped to bash via STDIN (`input:` below), so it
|
|
446
|
+
// never appears in argv / the ssh command string. `-T` forwards stdin.
|
|
447
|
+
//
|
|
448
|
+
// Include the replication overlay for consistency with the other
|
|
449
|
+
// compose invocations against this server (docker-compose.replication.yml
|
|
450
|
+
// carries the REPL_PORT mapping used elsewhere in the HA flow).
|
|
451
|
+
`cd ${remoteDir} && docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml run --rm -T --entrypoint bash db 2>&1`,
|
|
446
452
|
// silent:true makes sshRun THROW on non-zero exit instead of returning
|
|
447
453
|
// false. Without it, a failed basebackup (e.g. pg_hba.conf rejecting
|
|
448
454
|
// the connection) is silently swallowed: try-catch never enters, code
|
|
449
455
|
// proceeds to docker compose start db, probe loop times out, and the
|
|
450
456
|
// operator sees "did not enter recovery mode within 60s" with no clue
|
|
451
457
|
// that pg_basebackup never even ran. RCA: fanout12 2026-05-01.
|
|
452
|
-
{ timeout: 300_000, silent: true },
|
|
458
|
+
{ timeout: 300_000, silent: true, input: basebackupScript },
|
|
453
459
|
);
|
|
454
460
|
} catch (err) {
|
|
455
461
|
// Surface the actual basebackup output. sshRun on failure attaches
|
|
@@ -686,6 +692,7 @@ export async function deployComposeHA(options) {
|
|
|
686
692
|
backupConfig = null,
|
|
687
693
|
bundlePath = null,
|
|
688
694
|
operatorCidrs = [],
|
|
695
|
+
allowDegraded = false,
|
|
689
696
|
onProgress = () => {},
|
|
690
697
|
} = options;
|
|
691
698
|
|
|
@@ -834,6 +841,9 @@ export async function deployComposeHA(options) {
|
|
|
834
841
|
bucket: s3Config.bucket,
|
|
835
842
|
region: s3Config.region,
|
|
836
843
|
endpoint: s3Config.endpoint,
|
|
844
|
+
// Dedicated Pulumi-state bucket set by the orchestrator s3-setup step.
|
|
845
|
+
// Persisted so destroy / scale / failover resolve the same backend.
|
|
846
|
+
stateBucket: s3Config.stateBucket,
|
|
837
847
|
},
|
|
838
848
|
}),
|
|
839
849
|
...(backupS3Config && { backupS3: backupS3Config }),
|
|
@@ -860,6 +870,15 @@ export async function deployComposeHA(options) {
|
|
|
860
870
|
throw new Error('Timed out waiting for SSH on one or both servers');
|
|
861
871
|
}
|
|
862
872
|
|
|
873
|
+
// Seed the per-env known_hosts pin with BOTH servers' real host keys now that
|
|
874
|
+
// they answer SSH. Every subsequent ssh/scp (setupServerFiles, replication,
|
|
875
|
+
// failover) strict-checks against this file (accept-new), so pre-seeding via
|
|
876
|
+
// ssh-keyscan means we pin the real keys instead of blindly trusting whatever
|
|
877
|
+
// the first connection presents.
|
|
878
|
+
const haKnownHostsPath = knownHostsPathForKey(sshKeyPath);
|
|
879
|
+
await seedKnownHosts(haKnownHostsPath, primaryIp);
|
|
880
|
+
await seedKnownHosts(haKnownHostsPath, standbyIp);
|
|
881
|
+
|
|
863
882
|
// Ensure Docker is running, configure firewall, enable auto-updates
|
|
864
883
|
onProgress('Configuring servers...');
|
|
865
884
|
await perfAsync('deploy.ha.compose.cloudInitReady.both', () =>
|
|
@@ -993,6 +1012,21 @@ export async function deployComposeHA(options) {
|
|
|
993
1012
|
}
|
|
994
1013
|
await perfAsync('deploy.ha.compose.setupFan', () => Promise.all(setupTasks));
|
|
995
1014
|
|
|
1015
|
+
// wal-g WRITE-GUARD (finding #3): write WALG_ROLE into each node's .env. It is
|
|
1016
|
+
// NOT a path segment — the S3 prefix is a single canonical
|
|
1017
|
+
// `…/walg` on both nodes so restore/reseed on the standby can READ the
|
|
1018
|
+
// primary's base backups. WALG_ROLE=standby instead makes wal-archive.sh +
|
|
1019
|
+
// compose-backup.sh no-op on the standby, so a standby never WRITES into the
|
|
1020
|
+
// canonical prefix (prevents WAL-timeline collision / split-brain corruption).
|
|
1021
|
+
// Runs after setupServerFiles copied the base .env; before compose-up so the
|
|
1022
|
+
// db container reads the role. mergeRemoteDotenv is idempotent across redeploys.
|
|
1023
|
+
const haSshOpts = pinnedSshOptsString(sshKeyPath);
|
|
1024
|
+
const haRemoteDir = `/opt/${projectName}`;
|
|
1025
|
+
await Promise.all([
|
|
1026
|
+
mergeRemoteDotenv(primaryIp, haSshOpts, haRemoteDir, { WALG_ROLE: 'primary' }),
|
|
1027
|
+
mergeRemoteDotenv(standbyIp, haSshOpts, haRemoteDir, { WALG_ROLE: 'standby' }),
|
|
1028
|
+
]);
|
|
1029
|
+
|
|
996
1030
|
// Pull app + base images on both servers in parallel. No remote build —
|
|
997
1031
|
// image comes from ghcr.io (CI-built, multi-arch manifest; each node
|
|
998
1032
|
// pulls its arch). Each server is independent; no cross-cluster dep.
|
|
@@ -1153,14 +1187,16 @@ export async function deployComposeHA(options) {
|
|
|
1153
1187
|
}
|
|
1154
1188
|
|
|
1155
1189
|
// Expose raw PostgreSQL on port 5433 for replication (supavisor owns 5432).
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1190
|
+
// The docker-compose.replication.yml overlay maps db:5432 → host:5433 on
|
|
1191
|
+
// both nodes so pg_basebackup + streaming can reach the peer. Fan out
|
|
1192
|
+
// across primary + standby — the overlay write + db-only `compose up`
|
|
1158
1193
|
// touch disjoint VPS hosts and need to land on both before
|
|
1159
|
-
// configurePrimaryReplication grants pub access.
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
|
|
1194
|
+
// configurePrimaryReplication grants pub access.
|
|
1195
|
+
//
|
|
1196
|
+
// TODO(wireguard-transport): tunnel bring-up — Task 6. This overlay (direct
|
|
1197
|
+
// port-mapping, no TLS cert bind-mount) is the interim pre-WireGuard
|
|
1198
|
+
// transport; Task 6 replaces it with the wg0 tunnel.
|
|
1199
|
+
const replOverlay = 'version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n';
|
|
1164
1200
|
await Promise.all(
|
|
1165
1201
|
[primaryIp, standbyIp].map(async (ip) => {
|
|
1166
1202
|
await sshRunAsync(
|
|
@@ -1185,7 +1221,7 @@ export async function deployComposeHA(options) {
|
|
|
1185
1221
|
// dominated by pg_basebackup for the standby seed.
|
|
1186
1222
|
await perfAsync('deploy.ha.replication.setup', async () => {
|
|
1187
1223
|
onProgress('Configuring PostgreSQL replication on primary...');
|
|
1188
|
-
await configurePrimaryReplication(primaryIp,
|
|
1224
|
+
await configurePrimaryReplication(primaryIp, sshKeyPath, projectName);
|
|
1189
1225
|
|
|
1190
1226
|
onProgress('Configuring standby as hot replica...');
|
|
1191
1227
|
let replicationOk = false;
|
|
@@ -1197,13 +1233,15 @@ export async function deployComposeHA(options) {
|
|
|
1197
1233
|
projectName,
|
|
1198
1234
|
);
|
|
1199
1235
|
} catch (err) {
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1236
|
+
// DECISION (open, for the maintainer): replication failure is downgraded
|
|
1237
|
+
// to a WARNING and the deploy still finalizes as `deployed`. Replication
|
|
1238
|
+
// has been chronically fragile (memory: project_replication_broken) and
|
|
1239
|
+
// hard-failing every compose-ha deploy would block iteration on the rest
|
|
1240
|
+
// of the scenario. We keep the deploy green but report the REAL streaming
|
|
1241
|
+
// state below (never a hardcoded "streaming"). Promoting replication
|
|
1242
|
+
// failure to a hard deploy gate is a product decision NOT made here.
|
|
1243
|
+
// Failover-path still hard-fails (reseed abort) so a data-wipe +
|
|
1244
|
+
// CrashLoopBackOff can't cascade there.
|
|
1207
1245
|
p.log.warn(`Replication setup error (continuing with warning): ${err.message}`);
|
|
1208
1246
|
}
|
|
1209
1247
|
if (!replicationOk) {
|
|
@@ -1217,30 +1255,53 @@ export async function deployComposeHA(options) {
|
|
|
1217
1255
|
// hardcoded 1s/3s shell-out: fast happy path (probe at 250ms, 750ms,
|
|
1218
1256
|
// 1.75s, …) without giving up too early under load. Total budget ~25s.
|
|
1219
1257
|
onProgress('Verifying replication...');
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1258
|
+
// Shared verify poller (finding #2) — the k8s path uses the same primitive.
|
|
1259
|
+
// readState reports pg_stat_replication.state on the primary ('streaming' when
|
|
1260
|
+
// the standby is caught up, else another state / '' when no replica connected);
|
|
1261
|
+
// lastState feeds the finding-#1 hard-gate error message below.
|
|
1262
|
+
const { streaming: replActive, lastState: replLastState } = await verifyStreaming({
|
|
1263
|
+
readState: async () => {
|
|
1264
|
+
const state = await sshRun(
|
|
1225
1265
|
primaryIp,
|
|
1226
1266
|
sshKeyPath,
|
|
1227
|
-
`cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT
|
|
1267
|
+
`cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication ORDER BY (state = 'streaming') DESC LIMIT 1"`,
|
|
1228
1268
|
{ timeout: 10_000 },
|
|
1229
1269
|
);
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1270
|
+
return typeof state === 'string' ? state.trim() : '';
|
|
1271
|
+
},
|
|
1272
|
+
attempts: 10,
|
|
1273
|
+
delaysMs: [250, 500, 1000, 2000, 3000],
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// DECISION (finding #1, hard-gate-by-default): a compose-HA deploy whose
|
|
1277
|
+
// standby is NOT verifiably streaming now FAILS by default rather than
|
|
1278
|
+
// finalizing as `deployed` with a warning. Warm-standby / degraded DR is only
|
|
1279
|
+
// accepted when the operator explicitly opts in with `-allow-degraded`
|
|
1280
|
+
// (options.allowDegraded). The failover path already hard-fails a bad re-seed;
|
|
1281
|
+
// this brings the deploy path to the same posture so a green `deployed` always
|
|
1282
|
+
// means a real replica exists. Reuses the honest pg_stat_replication verify
|
|
1283
|
+
// above (never a hardcoded "streaming").
|
|
1284
|
+
// Shared hard-gate (finding #1): throws by default when not streaming; with
|
|
1285
|
+
// -allow-degraded it returns { degraded: true } so we finalize a warm standby.
|
|
1286
|
+
const { degraded } = assertReplicationStreamingOrDegraded({
|
|
1287
|
+
streaming: replActive,
|
|
1288
|
+
lastState: replLastState,
|
|
1289
|
+
allowDegraded,
|
|
1290
|
+
fixHint:
|
|
1291
|
+
'Verify the replication port (5433) is open between the two regions and the standby ' +
|
|
1292
|
+
'completed pg_basebackup.',
|
|
1293
|
+
});
|
|
1240
1294
|
if (replActive) {
|
|
1241
1295
|
p.log.success('Streaming replication active');
|
|
1242
1296
|
} else {
|
|
1243
|
-
p.log.warn(
|
|
1297
|
+
p.log.warn(
|
|
1298
|
+
'DEGRADED HA (-allow-degraded): streaming replication NOT detected ' +
|
|
1299
|
+
`(last pg_stat_replication.state=${
|
|
1300
|
+
replLastState ? JSON.stringify(replLastState) : 'no replica connected'
|
|
1301
|
+
}). The standby is a warm/cold spare and may be missing recent writes — ` +
|
|
1302
|
+
'disaster recovery is NOT guaranteed. Resync with `vibecarbon deploy` (no ' +
|
|
1303
|
+
'-allow-degraded) once the replication issue is resolved.',
|
|
1304
|
+
);
|
|
1244
1305
|
}
|
|
1245
1306
|
|
|
1246
1307
|
// Install the scheduled wal-g backup cron on BOTH nodes. Invariant 2: the
|
|
@@ -1285,6 +1346,11 @@ export async function deployComposeHA(options) {
|
|
|
1285
1346
|
[environment]: {
|
|
1286
1347
|
...pendingEnvConfig,
|
|
1287
1348
|
status: 'deployed',
|
|
1349
|
+
// Record DR posture so `status` and downstream commands can see the env
|
|
1350
|
+
// is running with a warm-standby (only reachable via -allow-degraded;
|
|
1351
|
+
// the default gate throws before this point when not streaming).
|
|
1352
|
+
replication: degraded ? 'degraded' : 'streaming',
|
|
1353
|
+
degraded,
|
|
1288
1354
|
deployedAt: new Date().toISOString(),
|
|
1289
1355
|
lastAttempt: undefined,
|
|
1290
1356
|
},
|
|
@@ -1300,7 +1366,17 @@ export async function deployComposeHA(options) {
|
|
|
1300
1366
|
p.log.message(` ${c.dim('Primary')} ${c.info(primaryIp)} (${region})`);
|
|
1301
1367
|
p.log.message(` ${c.dim('Standby')} ${c.info(standbyIp)} (${standbyRegion})`);
|
|
1302
1368
|
p.log.message(` ${c.dim('Deploy method')} Docker Compose HA`);
|
|
1303
|
-
|
|
1369
|
+
// Report the REAL replication state (verified via pg_stat_replication above),
|
|
1370
|
+
// not a hardcoded "streaming". A standby that isn't streaming means DR is not
|
|
1371
|
+
// guaranteed — say so loudly rather than implying a healthy replica.
|
|
1372
|
+
if (replActive) {
|
|
1373
|
+
p.log.message(` ${c.dim('Replication')} ${c.success('PostgreSQL streaming')}`);
|
|
1374
|
+
} else {
|
|
1375
|
+
// Only reachable via -allow-degraded (the default gate threw otherwise).
|
|
1376
|
+
p.log.message(
|
|
1377
|
+
` ${c.dim('Replication')} ${c.warning('DEGRADED (warm standby) — NOT streaming; DR is not guaranteed')}`,
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1304
1380
|
p.log.message('');
|
|
1305
1381
|
|
|
1306
1382
|
return {
|
|
@@ -1309,6 +1385,8 @@ export async function deployComposeHA(options) {
|
|
|
1309
1385
|
standbyIp,
|
|
1310
1386
|
domain,
|
|
1311
1387
|
deployMode: 'compose-ha',
|
|
1388
|
+
degraded,
|
|
1389
|
+
replication: degraded ? 'degraded' : 'streaming',
|
|
1312
1390
|
};
|
|
1313
1391
|
}
|
|
1314
1392
|
|
|
@@ -17,9 +17,10 @@ import { dirname, join } from 'node:path';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import * as p from '@clack/prompts';
|
|
19
19
|
import { runCommandAsync } from '../../command.js';
|
|
20
|
+
import { knownHostsPathForKey } from '../../host-keys.js';
|
|
20
21
|
import { perfAsync, perfTimer } from '../../perf.js';
|
|
21
22
|
import { parseDotenv, shEscape } from '../../shell.js';
|
|
22
|
-
import {
|
|
23
|
+
import { sshRunScript } from '../../ssh.js';
|
|
23
24
|
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
24
25
|
|
|
25
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -41,12 +42,52 @@ export function loadCloudInitScript() {
|
|
|
41
42
|
return readFileSync(CLOUD_INIT_PATH, 'utf-8');
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Build the compose deploy path's SSH `-o` option tokens, pinning host keys
|
|
47
|
+
* against the per-env known_hosts file DERIVED from the key path
|
|
48
|
+
* (knownHostsPathForKey) — so this path no longer uses UserKnownHostsFile=
|
|
49
|
+
* /dev/null + StrictHostKeyChecking=no. accept-new bootstraps an empty pin and
|
|
50
|
+
* TOFU's a fresh/recycled Hetzner IP, but rejects a changed key for an
|
|
51
|
+
* already-pinned host (MITM on an established env fails). GlobalKnownHostsFile=
|
|
52
|
+
* /dev/null ignores the system file; the operator's ~/.ssh/known_hosts is never
|
|
53
|
+
* touched. Keeps the ServerAlive keepalives that fail a banner-exchange hang
|
|
54
|
+
* fast (~60s) instead of stalling ~600s.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} sshKeyPath
|
|
57
|
+
* @returns {string[]} argv `-o` tokens
|
|
58
|
+
*/
|
|
59
|
+
function composeSshOpts(sshKeyPath) {
|
|
60
|
+
return [
|
|
61
|
+
'-o',
|
|
62
|
+
`UserKnownHostsFile=${knownHostsPathForKey(sshKeyPath)}`,
|
|
63
|
+
'-o',
|
|
64
|
+
'GlobalKnownHostsFile=/dev/null',
|
|
65
|
+
'-o',
|
|
66
|
+
'StrictHostKeyChecking=accept-new',
|
|
67
|
+
'-o',
|
|
68
|
+
'BatchMode=yes',
|
|
69
|
+
'-o',
|
|
70
|
+
'ConnectTimeout=10',
|
|
71
|
+
'-o',
|
|
72
|
+
'ServerAliveInterval=15',
|
|
73
|
+
'-o',
|
|
74
|
+
'ServerAliveCountMax=4',
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The same opts as a shell-safe space-joined string for the string-command
|
|
80
|
+
* runners / raw `ssh ...` interpolations below. The known_hosts path is
|
|
81
|
+
* shEscaped so a project dir with spaces can't split the argument.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} sshKeyPath
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function composeSshOptsString(sshKeyPath) {
|
|
87
|
+
return composeSshOpts(sshKeyPath)
|
|
88
|
+
.map((tok) => (tok.startsWith('UserKnownHostsFile=') ? shEscape(tok) : tok))
|
|
89
|
+
.join(' ');
|
|
90
|
+
}
|
|
50
91
|
|
|
51
92
|
/**
|
|
52
93
|
* Run a command on a remote server via SSH.
|
|
@@ -58,7 +99,8 @@ export async function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
|
58
99
|
const { timeout = 120_000, ...rest } = options;
|
|
59
100
|
try {
|
|
60
101
|
return await runCommandAsync(
|
|
61
|
-
|
|
102
|
+
// shell-safety-ignore: composeSshOpts() bakes in BatchMode=yes (validated in its definition)
|
|
103
|
+
['ssh', ...composeSshOpts(sshKeyPath), '-i', sshKeyPath, `root@${ip}`, command],
|
|
62
104
|
{ silent: true, timeout, ...rest },
|
|
63
105
|
);
|
|
64
106
|
} catch {
|
|
@@ -76,7 +118,7 @@ export async function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
|
76
118
|
*/
|
|
77
119
|
export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
|
|
78
120
|
const { timeout = 120_000, retries = 3, ignoreError, ...rest } = options;
|
|
79
|
-
const sshArgs = ['ssh', ...
|
|
121
|
+
const sshArgs = ['ssh', ...composeSshOpts(sshKeyPath), '-i', sshKeyPath, `root@${ip}`, command];
|
|
80
122
|
// Strip ignoreError from inner call so connection errors always throw and
|
|
81
123
|
// the retry loop can catch them. We apply ignoreError ourselves after retries.
|
|
82
124
|
const runOpts = { silent: true, timeout, ...rest };
|
|
@@ -140,8 +182,9 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
|
|
|
140
182
|
// this returned true on the very first probe regardless of SSH state.)
|
|
141
183
|
await runCommandAsync(
|
|
142
184
|
[
|
|
185
|
+
// shell-safety-ignore: composeSshOpts() bakes in BatchMode=yes (validated in its definition)
|
|
143
186
|
'ssh',
|
|
144
|
-
...
|
|
187
|
+
...composeSshOpts(sshKeyPath),
|
|
145
188
|
'-i',
|
|
146
189
|
sshKeyPath,
|
|
147
190
|
'-o',
|
|
@@ -367,13 +410,17 @@ export async function setupServerFiles(ip, sshKeyPath, projectName, options = {}
|
|
|
367
410
|
[
|
|
368
411
|
'bash',
|
|
369
412
|
'-c',
|
|
370
|
-
|
|
413
|
+
// Host-key pinned: accept-new against the per-env known_hosts
|
|
414
|
+
// ($6, a positional so the path is safely quoted), never
|
|
415
|
+
// /dev/null + StrictHostKeyChecking=no.
|
|
416
|
+
'cat "$1" | ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$6" -o GlobalKnownHostsFile=/dev/null -o BatchMode=yes -i "$2" "root@$3" "mkdir -p $4/backups && tar --no-xattrs --no-same-owner -xzf - -C $4 && cp $4/$5.service /etc/systemd/system/ && systemctl daemon-reload"',
|
|
371
417
|
'--',
|
|
372
418
|
tarPath,
|
|
373
419
|
sshKeyPath,
|
|
374
420
|
ip,
|
|
375
421
|
remoteDir,
|
|
376
422
|
projectName,
|
|
423
|
+
knownHostsPathForKey(sshKeyPath),
|
|
377
424
|
],
|
|
378
425
|
{ silent: true, timeout: 300_000 },
|
|
379
426
|
),
|
|
@@ -459,8 +506,9 @@ export async function transferImageBetweenServers(sourceIp, destIp, sshKeyPath,
|
|
|
459
506
|
// images compress 3-5x; without gzip a ~600MB save streams uncompressed
|
|
460
507
|
// across two regions on top of the operator's residential upload, which
|
|
461
508
|
// historically tripped the 600s timeout under load.
|
|
462
|
-
const
|
|
463
|
-
const
|
|
509
|
+
const sshOpts = composeSshOptsString(sshKeyPath);
|
|
510
|
+
const saveCmd = `ssh ${sshOpts} -i ${shEscape(sshKeyPath)} root@${sourceIp} docker save ${shEscape(imageRef)}`;
|
|
511
|
+
const loadCmd = `ssh ${sshOpts} -i ${shEscape(sshKeyPath)} root@${destIp} 'gunzip | docker load'`;
|
|
464
512
|
await runCommandAsync(['bash', '-c', `set -o pipefail && ${saveCmd} | gzip -1 | ${loadCmd}`], {
|
|
465
513
|
silent: true,
|
|
466
514
|
timeout: 600_000,
|
|
@@ -534,7 +582,7 @@ export async function startComposeStack(ip, sshKeyPath, projectName, _options =
|
|
|
534
582
|
// after the wrapper timed out) but the timer ate the run. 30 min covers
|
|
535
583
|
// a slow-pull cold start on any of the three S3 regions plus margin
|
|
536
584
|
// without masking a true regression (a real hang would still surface
|
|
537
|
-
// via the 60s ServerAlive keepalives in lib/ssh.js +
|
|
585
|
+
// via the 60s ServerAlive keepalives in lib/ssh.js + composeSshOpts).
|
|
538
586
|
await perfAsync('deploy.compose.up', () =>
|
|
539
587
|
sshRunAsync(ip, sshKeyPath, `/bin/bash ${remoteDir}/reconcile.sh`, { timeout: 1_800_000 }),
|
|
540
588
|
);
|
|
@@ -1193,12 +1241,7 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
|
1193
1241
|
const pf = spawn(
|
|
1194
1242
|
'ssh',
|
|
1195
1243
|
[
|
|
1196
|
-
|
|
1197
|
-
'StrictHostKeyChecking=no',
|
|
1198
|
-
'-o',
|
|
1199
|
-
'UserKnownHostsFile=/dev/null',
|
|
1200
|
-
'-o',
|
|
1201
|
-
'BatchMode=yes',
|
|
1244
|
+
...composeSshOpts(sshKeyPath),
|
|
1202
1245
|
'-i',
|
|
1203
1246
|
sshKeyPath,
|
|
1204
1247
|
'-N',
|