vibecarbon 0.3.1 → 0.5.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 (56) hide show
  1. package/carbon/backup/compose-backup.sh +45 -121
  2. package/carbon/db/Dockerfile +14 -3
  3. package/carbon/docker-compose.dns01.prod.yml +42 -0
  4. package/carbon/docker-compose.metabase.prod.yml +1 -2
  5. package/carbon/docker-compose.n8n.prod.yml +1 -2
  6. package/carbon/docker-compose.prod.yml +9 -7
  7. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  8. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  9. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  10. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  11. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  12. package/carbon/k8s/base/network-policies.yaml +33 -0
  13. package/carbon/k8s/base/traefik/certificate.yaml +13 -7
  14. package/carbon/k8s/values/supabase.values.yaml +152 -0
  15. package/carbon/scripts/docker-up.js +13 -1
  16. package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
  17. package/carbon/src/client/locales/de.json +29 -0
  18. package/carbon/src/client/locales/en.json +29 -0
  19. package/carbon/src/client/locales/es.json +29 -0
  20. package/carbon/src/client/locales/fr.json +29 -0
  21. package/carbon/src/client/locales/pt.json +29 -0
  22. package/carbon/src/client/pages/Home.tsx +5 -0
  23. package/package.json +1 -2
  24. package/src/backup.js +13 -66
  25. package/src/create.js +9 -4
  26. package/src/deploy.js +12 -0
  27. package/src/destroy.js +5 -43
  28. package/src/lib/backup-s3.js +0 -31
  29. package/src/lib/cloudflare.js +0 -59
  30. package/src/lib/config.js +1 -42
  31. package/src/lib/deploy/acme.js +57 -0
  32. package/src/lib/deploy/admin-user.js +105 -0
  33. package/src/lib/deploy/bundle.js +137 -5
  34. package/src/lib/deploy/compose/ha.js +208 -92
  35. package/src/lib/deploy/compose/index.js +292 -424
  36. package/src/lib/deploy/k8s/ha/index.js +12 -148
  37. package/src/lib/deploy/k8s/index.js +1 -21
  38. package/src/lib/deploy/k8s/k3s.js +407 -168
  39. package/src/lib/deploy/orchestrator.js +90 -33
  40. package/src/lib/deploy/utils.js +20 -0
  41. package/src/lib/hetzner-guided-setup.js +0 -7
  42. package/src/lib/iac/index.js +45 -16
  43. package/src/lib/images.js +17 -0
  44. package/src/lib/licensing/index.js +1 -59
  45. package/src/lib/licensing/tiers.js +0 -8
  46. package/src/lib/pod-backups.js +53 -0
  47. package/src/lib/providers/index.js +0 -47
  48. package/src/lib/ssh.js +12 -0
  49. package/src/restore.js +216 -302
  50. package/src/scale.js +76 -77
  51. package/carbon/backup/Dockerfile +0 -75
  52. package/carbon/backup/backup.sh +0 -95
  53. package/carbon/runtime.Dockerfile +0 -17
  54. package/src/lib/deploy/compose/acme-verify.js +0 -121
  55. package/src/lib/deploy/index.js +0 -119
  56. package/src/lib/kubectl.js +0 -81
@@ -8,17 +8,26 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { escapeDotenv } from '../shell.js';
11
+ import { DNS01_OVERRIDE_FILE, dnsChallengeEnv } from './acme.js';
11
12
 
12
13
  /**
13
14
  * Render the reconcile.sh script
14
15
  */
15
- function renderReconcileScript(projectName, composeFlags, isFast = false) {
16
+ export function renderReconcileScript(projectName, composeFlags, isFast = false) {
17
+ // On compose-ha servers the replication overlay (docker-compose.replication.yml)
18
+ // publishes port 5433 → db:5432 for pg_basebackup / streaming replication.
19
+ // reconcile.sh is baked with static composeFlags at deploy time, so we embed a
20
+ // shell conditional here that appends the overlay at runtime when present.
21
+ // On single-server compose the file never exists, so this is a safe no-op.
22
+ const replOverlayFlag =
23
+ "$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
24
+
16
25
  const pullStep = isFast
17
26
  ? ''
18
27
  : `
19
28
  # 1. Pull latest images if needed
20
29
  echo "Pulling images..."
21
- docker compose ${composeFlags} pull --quiet --policy missing --ignore-buildable || true
30
+ docker compose ${composeFlags} ${replOverlayFlag} pull --quiet --policy missing --ignore-buildable || true
22
31
  `;
23
32
 
24
33
  return `#!/bin/bash
@@ -46,7 +55,7 @@ fi
46
55
  ${pullStep}
47
56
  # 2. Re-create containers if state differs
48
57
  echo "Updating containers..."
49
- docker compose ${composeFlags} up -d --remove-orphans
58
+ docker compose ${composeFlags} ${replOverlayFlag} up -d --remove-orphans
50
59
 
51
60
  # 3. Ensure systemd service is active (if applicable)
52
61
  if [ -f "/etc/systemd/system/${projectName}.service" ]; then
@@ -86,6 +95,10 @@ WantedBy=multi-user.target
86
95
  */
87
96
  function composeFileFlags(options = {}) {
88
97
  const files = ['-f', 'docker-compose.yml', '-f', 'docker-compose.prod.yml'];
98
+ // DNS-01 override must follow prod.yml so it replaces the Traefik command.
99
+ if (options.dnsChallenge) {
100
+ files.push('-f', DNS01_OVERRIDE_FILE);
101
+ }
89
102
  if (options.observability) {
90
103
  files.push('-f', 'docker-compose.observability.yml');
91
104
  files.push('-f', 'docker-compose.observability.prod.yml');
@@ -102,6 +115,90 @@ function composeFileFlags(options = {}) {
102
115
  return files.join(' ');
103
116
  }
104
117
 
118
+ /**
119
+ * Render the Traefik dynamic-config file that instructs Traefik to obtain and
120
+ * serve ONE cert for the entire deploy as the TLS default store entry.
121
+ *
122
+ * With `defaultGeneratedCert`, Traefik proactively requests a single cert via
123
+ * the named resolver at startup and serves it by default for every TLS router
124
+ * that does NOT carry an explicit `tls.certresolver` label. All our production
125
+ * routers use `tls=true` without a resolver — they all get this cert via SNI.
126
+ *
127
+ * Challenge-type differences:
128
+ * - DNS-01 (managed DNS — cloudflare/hetzner): `sans: ["*.${domain}"]`.
129
+ * One wildcard cert covers all subdomains, no per-subdomain ACME orders.
130
+ * - HTTP-01 (manual DNS): wildcards require DNS-01 (LE policy), so we list
131
+ * the explicit subdomains that are active for this deploy instead.
132
+ * `main` stays `${domain}` (the app root); `sans` lists api/studio/dashboard
133
+ * plus any enabled feature subdomains (grafana/prometheus, n8n, metabase).
134
+ *
135
+ * The file is written into `volumes/traefik/` which is already bind-mounted
136
+ * as `/etc/traefik/dynamic:ro`. Traefik hot-reloads files from that dir.
137
+ * Multiple files in the dir coexist fine; this file adds only a top-level
138
+ * `tls:` key that has no overlap with the `http:` keys in middlewares.yml.
139
+ *
140
+ * @param {{domain: string, dnsChallenge: boolean, features?: {observability?: boolean, n8n?: boolean, metabase?: boolean}}} opts
141
+ * @returns {string} YAML string suitable for writing to `volumes/traefik/tls-default.yml`
142
+ */
143
+ export function renderTraefikDefaultCert({ domain, dnsChallenge, features = {} }) {
144
+ // The default store cert covers the SUBDOMAINS only. The app at the bare apex
145
+ // (`${domain}`) gets its OWN single-domain cert via the app router's
146
+ // `tls.certresolver` label — it is deliberately NOT in this cert.
147
+ //
148
+ // Why split apex off the wildcard: a cert for [`${domain}`, `*.${domain}`] has
149
+ // TWO ACME authorizations that BOTH validate via the SAME TXT record
150
+ // `_acme-challenge.${domain}`, so both challenge values must be present there
151
+ // simultaneously. The Hetzner lego DNS provider REPLACES the TXT rrset instead
152
+ // of holding both values, so one authorization always fails with
153
+ // "403 unauthorized :: Incorrect TXT record". (Cloudflare holds both and
154
+ // worked — RCA 2026-06-01, compose e2e wildcard verify-deploy.) As two
155
+ // SEPARATE single-authz certs (apex via the app router, `*.${domain}` here),
156
+ // Traefik obtains them serially, so `_acme-challenge.${domain}` only ever
157
+ // needs one value at a time — robust on Hetzner AND Cloudflare.
158
+ let main;
159
+ let sans;
160
+ if (dnsChallenge) {
161
+ // DNS-01: one wildcard covers every subdomain (most general level).
162
+ main = `*.${domain}`;
163
+ sans = [];
164
+ } else {
165
+ // HTTP-01: wildcards require DNS-01 (LE policy), so enumerate the active
166
+ // subdomains. `api` (kong), `studio`, `dashboard` always exist; the rest are
167
+ // feature-gated. The apex app is covered by its own router cert, not here.
168
+ const subs = ['studio', 'dashboard'];
169
+ if (features.observability) subs.push('grafana', 'prometheus');
170
+ if (features.n8n) subs.push('n8n');
171
+ if (features.metabase) subs.push('metabase');
172
+ main = `api.${domain}`;
173
+ sans = subs.map((s) => `${s}.${domain}`);
174
+ }
175
+
176
+ const lines = [
177
+ 'tls:',
178
+ ' stores:',
179
+ ' default:',
180
+ ' defaultGeneratedCert:',
181
+ ' resolver: letsencrypt',
182
+ ' domain:',
183
+ ` main: "${main}"`,
184
+ ];
185
+ if (sans.length) {
186
+ lines.push(' sans:');
187
+ for (const d of sans) lines.push(` - "${d}"`);
188
+ }
189
+
190
+ return `# Traefik default TLS certificate — generated by Vibecarbon at deploy time.
191
+ #
192
+ # Serves as the default cert for ALL TLS routers carrying \`tls=true\` without an
193
+ # explicit certresolver (SNI fallback). Covers the SUBDOMAINS; the apex app gets
194
+ # its own cert via the app router's \`tls.certresolver\`.
195
+ #
196
+ # DNS-01 (cloudflare/hetzner): one wildcard cert covering every subdomain.
197
+ # HTTP-01 (manual DNS): explicit subdomain SANs (LE policy forbids wildcards).
198
+ ${lines.join('\n')}
199
+ `;
200
+ }
201
+
105
202
  /**
106
203
  * Render the deployment bundle to a local temp directory
107
204
  * @param {string} projectName
@@ -129,6 +226,7 @@ export function renderBundle(projectName, options = {}) {
129
226
  rootFiles.push('docker-compose.metabase.yml', 'docker-compose.metabase.prod.yml');
130
227
  }
131
228
  if (options.redis) rootFiles.push('docker-compose.redis.yml', 'docker-compose.redis.prod.yml');
229
+ if (options.dnsChallenge) rootFiles.push(DNS01_OVERRIDE_FILE);
132
230
 
133
231
  for (const file of rootFiles) {
134
232
  const src = join(cwd, file);
@@ -168,6 +266,17 @@ export function renderBundle(projectName, options = {}) {
168
266
  if (projectName) {
169
267
  envOverrides.PROJECT_NAME = projectName;
170
268
  }
269
+ // DNS-01: lego reads ACME_DNS_PROVIDER + the matching provider token from the
270
+ // server .env (consumed by docker-compose.dns01.prod.yml's Traefik env).
271
+ if (options.dnsChallenge) {
272
+ Object.assign(
273
+ envOverrides,
274
+ dnsChallengeEnv(options.dnsProvider, {
275
+ cloudflareApiToken: options.cloudflareApiToken,
276
+ hetznerApiToken: options.hetznerApiToken,
277
+ }),
278
+ );
279
+ }
171
280
 
172
281
  // Merge overrides into local copy
173
282
  const lines = envContent.split('\n');
@@ -216,13 +325,36 @@ export function renderBundle(projectName, options = {}) {
216
325
  // template ships a stub at functions/main/index.ts. RCA: prod-1 2026-05-26.
217
326
  copyDirIfExist('functions', 'functions');
218
327
 
219
- // volumes/traefik/middlewares.yml
328
+ // volumes/traefik/ — dynamic-config directory mounted into Traefik.
329
+ // Always ensure the dir exists in the bundle so Traefik's file provider
330
+ // has a valid mount target even if the source dir is absent.
331
+ mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
332
+
220
333
  const traefikMiddlewares = join(cwd, 'volumes', 'traefik', 'middlewares.yml');
221
334
  if (existsSync(traefikMiddlewares)) {
222
- mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
223
335
  cpSync(traefikMiddlewares, join(stageDir, 'volumes', 'traefik', 'middlewares.yml'));
224
336
  }
225
337
 
338
+ // Render tls-default.yml only when a domain is known (production deploys).
339
+ // This instructs Traefik to proactively obtain ONE cert for the deploy and
340
+ // serve it as the default for all TLS routers via SNI. DNS-01 deploys get
341
+ // a wildcard SAN (*.${domain}); HTTP-01 deploys get explicit subdomain SANs.
342
+ // The file coexists with middlewares.yml — both live under the same `tls:`
343
+ // / `http:` top-level keys respectively; Traefik merges all files in the dir.
344
+ if (options.domain) {
345
+ const tlsDefaultYaml = renderTraefikDefaultCert({
346
+ domain: options.domain,
347
+ dnsChallenge: Boolean(options.dnsChallenge),
348
+ features: {
349
+ observability: options.observability,
350
+ n8n: options.n8n,
351
+ metabase: options.metabase,
352
+ },
353
+ });
354
+ writeFileSync(join(stageDir, 'volumes', 'traefik', 'tls-default.yml'), tlsDefaultYaml);
355
+ if (options.verbose) console.log('[bundle] Rendered volumes/traefik/tls-default.yml');
356
+ }
357
+
226
358
  copyDirIfExist('backup', 'backup');
227
359
  if (options.n8n) copyDirIfExist('volumes/n8n', 'volumes/n8n');
228
360
  if (options.metabase) copyDirIfExist('volumes/metabase', 'volumes/metabase');
@@ -14,7 +14,6 @@ import { join } from 'node:path';
14
14
  import * as p from '@clack/prompts';
15
15
  import { setupHA as setupCloudflareHA } from '../../cloudflare.js';
16
16
  import { c } from '../../colors.js';
17
- import { runCommand } from '../../command.js';
18
17
  import {
19
18
  loadCredentials,
20
19
  loadProjectConfig,
@@ -24,34 +23,23 @@ import {
24
23
  import { fetchWithRetry } from '../../fetch-retry.js';
25
24
  import { perfAsync } from '../../perf.js';
26
25
  import { getProvider } from '../../providers/index.js';
26
+ import { useDnsChallenge } from '../acme.js';
27
+ import { generateSSHKeyPair, readReplPassword } from '../utils.js';
27
28
  import {
28
29
  createAdminUser,
29
30
  dockerLoginOnServer,
30
31
  isLocalOnlyImageTag,
31
32
  pullComposeImages,
32
33
  runMigrations,
34
+ setupComposeBackupCron,
33
35
  setupServer,
34
36
  setupServerFiles,
37
+ sshRun,
35
38
  sshRunAsync,
36
39
  startComposeStack,
37
40
  waitForSSH,
38
41
  } from './index.js';
39
42
 
40
- const SSH_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes';
41
-
42
- /**
43
- * Run a command on a remote server via SSH
44
- */
45
- function sshRun(ip, sshKeyPath, command, options = {}) {
46
- const { timeout = 120_000 } = options;
47
- return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
48
- stdio: 'pipe',
49
- timeout,
50
- encoding: 'utf-8',
51
- ...options,
52
- });
53
- }
54
-
55
43
  /**
56
44
  * Look up a Hetzner firewall by exact name and DELETE it. Polls afterwards
57
45
  * until the GET returns nothing (firewall actually gone) or the deadline
@@ -170,41 +158,55 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
170
158
  return false;
171
159
  }
172
160
 
173
- /**
174
- * Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
175
- * (where `vibecarbon create` writes it at project-init time). Every HA code
176
- * path funnels through this so one fix handles all three call sites that
177
- * previously threw `REPL_PASSWORD is not set` when run without the env var.
178
- * Returns null if the value is not present anywhere.
179
- */
180
- function readReplPassword(cwd = process.cwd()) {
181
- if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
182
- const envLocalPath = join(cwd, '.env.local');
183
- if (!existsSync(envLocalPath)) return null;
184
- const content = readFileSync(envLocalPath, 'utf-8');
185
- // Accept both double-quoted (machine secrets) and single-quoted
186
- // (escapeDotenv'd user secrets) forms, matching create.js output.
187
- const m =
188
- content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
189
- return m ? m[1] : null;
190
- }
191
-
192
- /**
193
- * Generate SSH key pair for deployment
194
- */
195
- function generateSSHKeyPair(sshKeyPath) {
196
- if (!existsSync(sshKeyPath)) {
197
- runCommand(
198
- ['ssh-keygen', '-t', 'ed25519', '-f', sshKeyPath, '-N', '', '-C', 'vibecarbon-deploy'],
199
- { stdio: 'pipe' },
200
- );
201
- }
202
- }
203
-
204
161
  // ============================================================================
205
162
  // POSTGRESQL STREAMING REPLICATION SETUP
206
163
  // ============================================================================
207
164
 
165
+ // WAL-G / ARCHIVING DOVETAIL INVARIANTS (wal-g unify, 2026-05-31)
166
+ //
167
+ // Invariant 1 — archiving survives failover.
168
+ // carbon/docker-compose.yml hardcodes `archive_mode=on` and
169
+ // `archive_command=bash /etc/postgresql/wal-archive.sh %p` in the db
170
+ // container's `command:`. A promoted standby runs the SAME image/command,
171
+ // so it starts archiving WAL on the new timeline immediately after promotion.
172
+ // No extra step is needed.
173
+ // WALG_S3_PREFIX is derived from S3_BACKUP_BUCKET + PROJECT_NAME (see
174
+ // docker-compose.yml:197) and is identical on both nodes — same .env is
175
+ // deployed to both servers via setupServerFiles. The WAL timeline is therefore
176
+ // continuous across failover: pre-promotion WAL is on TL1, post-promotion WAL
177
+ // is on TL2, both under the same S3 prefix.
178
+ // Note: a PostgreSQL standby with archive_mode=on does NOT archive while it is
179
+ // in recovery — only the primary archives. There is no double-archive risk
180
+ // before failover.
181
+ //
182
+ // Invariant 2 — backup guard means only the active primary backs up.
183
+ // carbon/backup/compose-backup.sh checks `pg_is_in_recovery()='f'` before
184
+ // calling `wal-g backup-push` (and self-skips with exit 0 when no S3 backup
185
+ // target / credentials are configured). Before failover: standby cron is a
186
+ // no-op; primary cron backs up. After failover: promoted standby cron backs
187
+ // up; old primary (if still alive) is now a standby, so its cron is a no-op.
188
+ // The scheduled backup cron is installed on BOTH nodes at the end of
189
+ // deployComposeHA (see the setupComposeBackupCron fan-out after replication
190
+ // verification). The standby's cron is a guarded no-op until it is promoted —
191
+ // installing on both means the survivor always has the cron after a failover,
192
+ // so scheduled backups don't silently stop when DNS flips to the standby.
193
+ //
194
+ // Invariant 3 — restore seeds the primary; standby is REBUILT afterward.
195
+ // compose restore (restoreCompose in compose/index.js) rewinds the primary's
196
+ // data directory and replays WAL from S3. A standby that was streaming from the
197
+ // old (pre-restore) primary then has WAL *ahead* of the restored primary's LSN
198
+ // and CANNOT resume streaming — PG would reject it with "requested timeline
199
+ // does not contain minimum recovery point" (timeline divergence). The correct
200
+ // post-restore sequence is:
201
+ // 1. Restore the primary (restoreCompose → backup-fetch + archive recovery).
202
+ // 2. Re-seed the standby via configureStandbyReplication (pg_basebackup
203
+ // from the now-restored primary). This wipes the diverged standby state
204
+ // and brings it in sync with the new primary timeline.
205
+ // This is wired in restore.js:runComposeRestore — after restoreCompose on the
206
+ // primary, it re-runs configureStandbyReplication against the standby whenever
207
+ // deployMode === 'compose-ha' (skipped for single-region compose, which has no
208
+ // standby). configureStandbyReplication is exported from this file for that.
209
+
208
210
  /**
209
211
  * Configure the primary PostgreSQL server for streaming replication.
210
212
  *
@@ -323,7 +325,7 @@ END $$;
323
325
  * 3. Write standby.signal and configure primary_conninfo
324
326
  * 4. Restart as hot standby
325
327
  */
326
- async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
328
+ export async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
327
329
  const remoteDir = `/opt/${projectName}`;
328
330
  // replPassword is generated at create time from crypto.randomBytes and is
329
331
  // restricted to base64url characters — no shell escaping needed.
@@ -337,6 +339,33 @@ async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, pro
337
339
  );
338
340
  }
339
341
 
342
+ // Ensure the physical replication slot exists on the PRIMARY before the
343
+ // standby's pg_basebackup -S below. configurePrimaryReplication creates it at
344
+ // deploy time, but a wal-g RESTORE overwrites the primary's PGDATA — which
345
+ // includes pg_replslot — so the slot is wiped. Without re-creating it,
346
+ // pg_basebackup fails late with `replication slot "vibecarbon_standby_slot"
347
+ // does not exist` (it connects + checkpoints, then the -Xs WAL receiver's
348
+ // START_REPLICATION errors). This bit the failover re-seed after a wal-g
349
+ // restore — the pg_dump restore never had this problem because it loaded
350
+ // logical data without touching pg_replslot. Idempotent (IF NOT EXISTS), so
351
+ // it's a safe no-op on the deploy path where the slot already exists.
352
+ // RCA 2026-06-01: compose-ha failover after wal-g restore.
353
+ const ensureSlotSql = `DO $$
354
+ BEGIN
355
+ IF NOT EXISTS (
356
+ SELECT 1 FROM pg_replication_slots WHERE slot_name = 'vibecarbon_standby_slot'
357
+ ) THEN
358
+ PERFORM pg_create_physical_replication_slot('vibecarbon_standby_slot');
359
+ END IF;
360
+ END $$;`;
361
+ const ensureSlotB64 = Buffer.from(ensureSlotSql).toString('base64');
362
+ sshRun(
363
+ primaryIp,
364
+ sshKeyPath,
365
+ `cd ${remoteDir} && echo ${ensureSlotB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
366
+ { timeout: 30_000 },
367
+ );
368
+
340
369
  // Stop the standby database
341
370
  sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
342
371
  timeout: 60_000,
@@ -1022,7 +1051,11 @@ export async function deployComposeHA(options) {
1022
1051
  // future deploys that shrink that gap. Same fix lives in orchestrator.js
1023
1052
  // for single-region compose where the race was actively biting (RCA from
1024
1053
  // vibecarbon.com cold-deploy 2026-05-19).
1025
- if (domain && dnsProvider && dnsProvider !== 'manual') {
1054
+ // HTTP-01 only: managed-DNS HA deploys issue certs via DNS-01 (the shared
1055
+ // bundle ships docker-compose.dns01.prod.yml to both nodes), which doesn't
1056
+ // gate on the A record propagating — so skip the wait there. `manual` keeps
1057
+ // HTTP-01 but isn't reached here (the guard already excludes it).
1058
+ if (domain && dnsProvider && dnsProvider !== 'manual' && !useDnsChallenge(dnsProvider)) {
1026
1059
  // Surface this in the enclosing tracker spinner so the operator sees
1027
1060
  // why we're waiting (up to 120s on a cold edge). Without the message
1028
1061
  // the HA spinner sits on the prior "Configuring servers..." text for
@@ -1210,6 +1243,39 @@ export async function deployComposeHA(options) {
1210
1243
  p.log.warn('Streaming replication not detected — standby may fall behind');
1211
1244
  }
1212
1245
 
1246
+ // Install the scheduled wal-g backup cron on BOTH nodes. Invariant 2: the
1247
+ // standby's cron is a harmless no-op — compose-backup.sh guards on
1248
+ // `pg_is_in_recovery()='f'` and exits 0 on a standby without pushing. We
1249
+ // install on both so that after a failover the SURVIVOR (whichever node was
1250
+ // promoted) already has the cron and keeps backing up — without it, a
1251
+ // failover to the standby would silently lose scheduled backups until the
1252
+ // operator re-ran deploy. setupComposeBackupCron defaults schedule + retain
1253
+ // when backupConfig is absent; compose-backup.sh self-skips (exit 0) when no
1254
+ // S3 backup target is configured, so an always-installed cron never hard-fails.
1255
+ //
1256
+ // allSettled (not all) + per-node warn: a cron-install hiccup on one node
1257
+ // must not fail an already-serving HA deploy, nor mask the other node. The
1258
+ // app + replication are already up by this point.
1259
+ onProgress('Installing backup cron on both servers...');
1260
+ const cronResults = await perfAsync('deploy.ha.compose.backupCron.both', () =>
1261
+ Promise.allSettled([
1262
+ perfAsync('deploy.ha.compose.backupCron.primary', async () =>
1263
+ setupComposeBackupCron(primaryIp, sshKeyPath, projectName, backupConfig),
1264
+ ),
1265
+ perfAsync('deploy.ha.compose.backupCron.standby', async () =>
1266
+ setupComposeBackupCron(standbyIp, sshKeyPath, projectName, backupConfig),
1267
+ ),
1268
+ ]),
1269
+ );
1270
+ for (const [i, result] of cronResults.entries()) {
1271
+ if (result.status === 'rejected') {
1272
+ const node = i === 0 ? `primary (${primaryIp})` : `standby (${standbyIp})`;
1273
+ p.log.warn(
1274
+ `Scheduled backup cron install failed on ${node} (deploy still succeeded): ${result.reason?.message ?? result.reason}`,
1275
+ );
1276
+ }
1277
+ }
1278
+
1213
1279
  // Finalize config — promote pending → deployed
1214
1280
  saveProjectConfig({
1215
1281
  ...projectConfig,
@@ -1471,54 +1537,80 @@ export async function destroyComposeHA(options) {
1471
1537
  }
1472
1538
 
1473
1539
  // ============================================================================
1474
- // STATUS
1540
+ // FAILOVER
1475
1541
  // ============================================================================
1476
1542
 
1477
1543
  /**
1478
- * Get status of both Compose HA servers in parallel
1544
+ * Build the curl used to probe the new primary's real API endpoint locally.
1545
+ *
1546
+ * `--resolve <domain>:443:127.0.0.1` pins the hostname to loopback, so the
1547
+ * probe hits Traefik on the new-primary host itself and is independent of DNS
1548
+ * propagation (the DNS flip happens later in failover, and the operator host's
1549
+ * resolver may still be cached). `-k` skips the LE-staging cert. The path is
1550
+ * the *exact* endpoint verify-failover checks, so a 200 here means the whole
1551
+ * app → Kong → PostgREST → DB path is serving — including PostgREST's schema
1552
+ * cache, the thing that's still cold right after `docker compose restart`.
1553
+ *
1554
+ * @param {string} domain
1555
+ * @returns {string}
1479
1556
  */
1480
- export async function getComposeHAStatus(envConfig, sshKeyPath, projectName) {
1481
- const results = await Promise.all(
1482
- (envConfig.servers || []).map(async (server) => {
1483
- try {
1484
- const remoteDir = `/opt/${projectName}`;
1485
- const output = await sshRunAsync(
1486
- server.ip,
1487
- sshKeyPath,
1488
- `cd ${remoteDir} && docker compose ps --format json`,
1489
- { timeout: 15_000 },
1490
- );
1491
- if (!output?.trim()) {
1492
- return { ...server, running: false, services: [] };
1493
- }
1494
- const services = output
1495
- .trim()
1496
- .split('\n')
1497
- .filter(Boolean)
1498
- .map((line) => {
1499
- try {
1500
- return JSON.parse(line);
1501
- } catch {
1502
- return null;
1503
- }
1504
- })
1505
- .filter(Boolean);
1506
- return {
1507
- ...server,
1508
- running: services.some((s) => s.State === 'running'),
1509
- services,
1510
- };
1511
- } catch {
1512
- return { ...server, running: false, error: 'Unreachable' };
1513
- }
1514
- }),
1515
- );
1516
- return results;
1557
+ export function newPrimaryApiProbeCmd(domain) {
1558
+ const url = `https://${domain}/api/v1/notifications`;
1559
+ return `curl -sk -o /dev/null -w '%{http_code}' --max-time 5 --resolve ${domain}:443:127.0.0.1 ${url}`;
1517
1560
  }
1518
1561
 
1519
- // ============================================================================
1520
- // FAILOVER
1521
- // ============================================================================
1562
+ /**
1563
+ * Poll the new primary until it actually SERVES a DB-backed API request.
1564
+ *
1565
+ * Root-cause fix for the compose-ha failover-readiness race: Step 1b runs
1566
+ * `docker compose restart … rest app …` to force a clean reconnect against the
1567
+ * now-read-write DB, but `restart` returns when containers *start*, not when
1568
+ * they're *ready*. PostgREST then spends ~10-30s reconnecting and rebuilding
1569
+ * its schema cache; until it does, /api/v1/notifications 500s with a
1570
+ * PGRST schema-cache miss. failover used to return straight into that window,
1571
+ * and verify-failover (which does NOT retry on 500) caught it. Gating on the
1572
+ * real endpoint closes the race for the e2e *and* real operators (a failover
1573
+ * that reports success before the app serves traffic is itself a bug).
1574
+ *
1575
+ * Best-effort: returns false (and logs the last status) on timeout rather than
1576
+ * throwing — a not-yet-confirmed new primary is still better than aborting a
1577
+ * promotion that already happened.
1578
+ *
1579
+ * @param {string} ip - new primary IP
1580
+ * @param {string} sshKeyPath
1581
+ * @param {string} domain
1582
+ * @param {object} [opts]
1583
+ * @param {number} [opts.attempts=40]
1584
+ * @param {number} [opts.intervalMs=3000]
1585
+ * @param {(ip: string, key: string, cmd: string, o: object) => string|false} [opts.runner=sshRun]
1586
+ * @param {(ms: number) => Promise<void>} [opts.sleep]
1587
+ * @returns {Promise<boolean>}
1588
+ */
1589
+ export async function waitForNewPrimaryApi(ip, sshKeyPath, domain, opts = {}) {
1590
+ const {
1591
+ attempts = 40,
1592
+ intervalMs = 3000,
1593
+ runner = sshRun,
1594
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
1595
+ } = opts;
1596
+ const cmd = newPrimaryApiProbeCmd(domain);
1597
+ let lastCode = '';
1598
+ for (let i = 0; i < attempts; i++) {
1599
+ try {
1600
+ const code = runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
1601
+ lastCode = typeof code === 'string' ? code.trim() : '';
1602
+ if (lastCode === '200') return true;
1603
+ } catch {
1604
+ lastCode = 'error';
1605
+ }
1606
+ if (i < attempts - 1) await sleep(intervalMs);
1607
+ }
1608
+ console.error(
1609
+ `[ha] new primary did not serve /api/v1/notifications (last HTTP ${lastCode || 'none'}) ` +
1610
+ `after ${attempts} probes — proceeding, but verify-failover may race PostgREST readiness`,
1611
+ );
1612
+ return false;
1613
+ }
1522
1614
 
1523
1615
  /**
1524
1616
  * Perform failover for a Compose HA environment.
@@ -1582,6 +1674,13 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1582
1674
  // — run pg_basebackup to guarantee schema parity, then promote. For a
1583
1675
  // real disaster failover where primary is unreachable, skip the re-seed
1584
1676
  // and promote whatever state standby has (best-effort recovery).
1677
+ //
1678
+ // WAL-G archiving invariant (Invariant 1): the promoted node starts
1679
+ // archiving on the new timeline immediately after pg_promote() — the
1680
+ // docker-compose.yml command hardcodes archive_mode=on + archive_command
1681
+ // on the db image. WALG_S3_PREFIX is the same on both nodes (same .env),
1682
+ // so the WAL timeline is continuous in S3 across the failover. No extra
1683
+ // action needed here.
1585
1684
  const primaryReachable = await isPrimaryReachable(primaryServer.ip, sshKeyPath, remoteDir);
1586
1685
  if (primaryReachable) {
1587
1686
  s.start('Re-seeding standby from primary (pg_basebackup)');
@@ -1632,7 +1731,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1632
1731
  // and returns the result. We retry a handful of times because the very
1633
1732
  // first call can race the postmaster startup ("connection refused" /
1634
1733
  // "the database system is starting up").
1635
- // `silent: true` is load-bearing: ha.js's local sshRun wraps runCommand,
1734
+ // `silent: true` is load-bearing: the shared compose sshRun wraps runCommand,
1636
1735
  // which on non-zero exit returns `false` (not throws) unless silent — so
1637
1736
  // without it, a still-booting postmaster's exit-1 manifests downstream as
1638
1737
  // `false?.trim is not a function`, the catch records it, and all 5 attempts
@@ -1749,6 +1848,23 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1749
1848
  }
1750
1849
  s.stop('New-primary app-tier restarted');
1751
1850
 
1851
+ // Step 1c: Gate on the new primary actually SERVING a DB-backed request
1852
+ // before we stop the old primary and flip DNS. The Step 1b restart only
1853
+ // *starts* the app tier; PostgREST's schema cache is cold for ~10-30s after,
1854
+ // during which /api/v1/notifications 500s. Without this wait, failover
1855
+ // returned into that window and verify-failover (no retry on 500) failed.
1856
+ // Probed locally via curl --resolve so readiness is independent of the DNS
1857
+ // flip below. Best-effort — see waitForNewPrimaryApi.
1858
+ if (envConfig.domain) {
1859
+ s.start('Waiting for new primary to serve API traffic');
1860
+ const apiReady = await waitForNewPrimaryApi(standbyServer.ip, sshKeyPath, envConfig.domain);
1861
+ s.stop(
1862
+ apiReady
1863
+ ? 'New primary serving API traffic'
1864
+ : 'New primary API not confirmed — proceeding (verify may race readiness)',
1865
+ );
1866
+ }
1867
+
1752
1868
  // Step 2: Stop old primary services to prevent split-brain
1753
1869
  s.start('Stopping old primary services');
1754
1870
  try {