vibecarbon 0.4.0 → 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.
@@ -394,6 +394,35 @@
394
394
  "description": "Cada rota valida com Zod, cada consulta usa o cliente Supabase correto, cada tabela tem RLS. Os agentes de IA replicam sempre os mesmos padrões corretos."
395
395
  }
396
396
  },
397
+ "deploy": {
398
+ "headline": "Implante do seu jeito,",
399
+ "headlineHighlight": "com um único comando",
400
+ "subheading": "De um único VPS a Kubernetes multirregião — escolha seu nível de escala e resiliência. Suba para o próximo nível quando quiser.",
401
+ "compose": {
402
+ "name": "Docker Compose",
403
+ "tagline": "Um VPS, com toda a sua stack incluída.",
404
+ "features": ["1 servidor", "Região única"],
405
+ "bestFor": "MVPs e ferramentas internas"
406
+ },
407
+ "composeHa": {
408
+ "name": "Compose HA",
409
+ "tagline": "Dois servidores, failover automático.",
410
+ "features": ["2 servidores", "Replicação em streaming", "Failover automático"],
411
+ "bestFor": "SaaS em produção sem Kubernetes"
412
+ },
413
+ "k8s": {
414
+ "name": "Kubernetes",
415
+ "tagline": "Cluster k3s com escalonamento automático.",
416
+ "features": ["Região única", "Escalonamento horizontal", "Atualizações graduais"],
417
+ "bestFor": "Apps de alto tráfego"
418
+ },
419
+ "k8sHa": {
420
+ "name": "Kubernetes HA",
421
+ "tagline": "Clusters multirregião, disponibilidade máxima.",
422
+ "features": ["2 regiões", "Failover de região", "Escalonamento automático"],
423
+ "bestFor": "Crítico e global"
424
+ }
425
+ },
397
426
  "pricing": {
398
427
  "headline1": "Do",
399
428
  "headlineSketch": "esboço",
@@ -16,6 +16,7 @@ import { AIIntegrationSection } from '../components/AIIntegrationSection';
16
16
  import { ArchitectureSection } from '../components/ArchitectureSection';
17
17
  import { ComparisonSection } from '../components/ComparisonSection';
18
18
  import { CTAFooter } from '../components/CTAFooter';
19
+ import { DeploymentScenariosSection } from '../components/DeploymentScenariosSection';
19
20
  import { FilmGrainOverlay } from '../components/effects/FilmGrainOverlay';
20
21
  import { VGlowEffect } from '../components/effects/VGlowEffect';
21
22
  import { FAQSection } from '../components/FAQSection';
@@ -168,6 +169,10 @@ export default function VibecarbonHome() {
168
169
  <ArchitectureSection />
169
170
  </ScrollSection>
170
171
 
172
+ <ScrollSection id="deploy-scenarios">
173
+ <DeploymentScenariosSection />
174
+ </ScrollSection>
175
+
171
176
  <ScrollSection id="pricing">
172
177
  <PricingSection />
173
178
  </ScrollSection>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
package/src/backup.js CHANGED
@@ -448,6 +448,7 @@ export async function run(args) {
448
448
  sshKeyPath,
449
449
  projectName,
450
450
  envName,
451
+ backupRetain: envConfig.backup?.retentionDays,
451
452
  yes: !!values.y,
452
453
  });
453
454
  tracker.finish();
@@ -647,6 +648,7 @@ async function runCreate({
647
648
  sshKeyPath,
648
649
  projectName,
649
650
  envName,
651
+ backupRetain,
650
652
  yes,
651
653
  }) {
652
654
  if (!yes) {
@@ -660,37 +662,16 @@ async function runCreate({
660
662
  }
661
663
 
662
664
  if (isCompose) {
663
- s.start('Creating backup via Docker Compose...');
665
+ s.start('Creating wal-g base backup...');
664
666
  try {
665
- const { backupCompose, uploadComposeBackupToS3 } = await import(
666
- './lib/deploy/compose/index.js'
667
- );
668
- const backupFile = backupCompose(serverIp, sshKeyPath, projectName);
669
- s.stop('Backup created');
670
- p.log.success(`Backup: ${c.bold(backupFile)}`);
671
- p.log.info(`Location: /opt/${projectName}/backups/${backupFile}`);
672
-
673
- if (useS3) {
674
- s.start('Uploading backup to S3...');
675
- try {
676
- await uploadComposeBackupToS3(serverIp, sshKeyPath, projectName, backupFile, s3Config);
677
- s.stop(`Uploaded to S3: ${s3Config.bucket}/backups/${backupFile}`);
678
- } catch (uploadError) {
679
- s.stop(`S3 upload failed: ${uploadError.message}`);
680
- p.log.warn('Backup is available on the server but was not uploaded to S3.');
681
- // Dump the raw error to stderr so automation captures the
682
- // actual AWS SDK error code + HTTP status — the spinner
683
- // message above hides the detail e2e/CI needs.
684
- console.error(
685
- `[backup] S3 upload failed — name=${uploadError.name} code=${uploadError.Code ?? uploadError.code ?? 'n/a'} status=${uploadError.$metadata?.httpStatusCode ?? 'n/a'} bucket=${s3Config.bucket} endpoint=${s3Config.endpoint} region=${s3Config.region}`,
686
- );
687
- if (uploadError.stack) {
688
- console.error(uploadError.stack);
689
- }
690
- }
691
- } else {
692
- p.log.info(`Download: scp root@${serverIp}:/opt/${projectName}/backups/${backupFile} .`);
693
- }
667
+ const { backupCompose } = await import('./lib/deploy/compose/index.js');
668
+ backupCompose(serverIp, sshKeyPath, projectName, { retain: backupRetain });
669
+ // "uploaded to S3" wording matches the k8s path (line ~689) and the e2e
670
+ // backup-step verification (which greps /uploaded to s3/i to confirm the
671
+ // backup is durable before allowing destroy → restore).
672
+ s.stop('wal-g base backup uploaded to S3');
673
+ p.log.success('Backup uploaded to S3 — wal-g pushed the base backup directly.');
674
+ p.log.info(`List backups: ${c.info(`vibecarbon backup ${envName} -l`)}`);
694
675
  } catch (error) {
695
676
  s.stop('Backup failed');
696
677
  p.log.error(error.message);
package/src/destroy.js CHANGED
@@ -1055,49 +1055,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1055
1055
  backupSpinner.start('Creating backup...');
1056
1056
  try {
1057
1057
  if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
1058
- const { backupCompose, uploadComposeBackupToS3 } = await import(
1059
- './lib/deploy/compose/index.js'
1060
- );
1061
- const backupFile = backupCompose(serverIp, sshKeyPath, projectConfig.projectName);
1062
-
1063
- const backupS3 = envConfig.backupS3;
1064
- if (backupS3) {
1065
- const { getS3Credentials } = await import('./lib/hetzner-guided-setup.js');
1066
- const creds = await getS3Credentials(projectConfig.projectName, { save: false });
1067
- if (creds) {
1068
- await uploadComposeBackupToS3(
1069
- serverIp,
1070
- sshKeyPath,
1071
- projectConfig.projectName,
1072
- backupFile,
1073
- {
1074
- ...backupS3,
1075
- accessKey: creds.accessKey,
1076
- secretKey: creds.secretKey,
1077
- },
1078
- );
1079
- backupSpinner.stop(`Backup saved to S3: ${backupS3.bucket}/backups/${backupFile}`);
1080
- } else {
1081
- backupSpinner.stop(`Backup created on server: ${backupFile}`);
1082
- p.log.warn(
1083
- 'Could not upload to S3 (no credentials). Download before destroy completes:',
1084
- );
1085
- p.log.info(
1086
- ` scp root@${serverIp}:/opt/${projectConfig.projectName}/backups/${backupFile} .`,
1087
- );
1088
- }
1089
- } else {
1090
- // No S3 — download locally
1091
- const { scpDownload } = await import('./lib/ssh.js');
1092
- const localPath = join(cwd, backupFile);
1093
- scpDownload(
1094
- serverIp,
1095
- sshKeyPath,
1096
- `/opt/${projectConfig.projectName}/backups/${backupFile}`,
1097
- localPath,
1098
- );
1099
- backupSpinner.stop(`Backup saved locally: ${backupFile}`);
1100
- }
1058
+ const { backupCompose } = await import('./lib/deploy/compose/index.js');
1059
+ backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1060
+ retain: envConfig.backup?.retentionDays,
1061
+ });
1062
+ backupSpinner.stop('wal-g base backup pushed to S3');
1101
1063
  }
1102
1064
  } catch (backupError) {
1103
1065
  backupSpinner.stop(`Backup failed: ${backupError.message}`);
@@ -13,13 +13,21 @@ import { DNS01_OVERRIDE_FILE, dnsChallengeEnv } from './acme.js';
13
13
  /**
14
14
  * Render the reconcile.sh script
15
15
  */
16
- 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
+
17
25
  const pullStep = isFast
18
26
  ? ''
19
27
  : `
20
28
  # 1. Pull latest images if needed
21
29
  echo "Pulling images..."
22
- docker compose ${composeFlags} pull --quiet --policy missing --ignore-buildable || true
30
+ docker compose ${composeFlags} ${replOverlayFlag} pull --quiet --policy missing --ignore-buildable || true
23
31
  `;
24
32
 
25
33
  return `#!/bin/bash
@@ -47,7 +55,7 @@ fi
47
55
  ${pullStep}
48
56
  # 2. Re-create containers if state differs
49
57
  echo "Updating containers..."
50
- docker compose ${composeFlags} up -d --remove-orphans
58
+ docker compose ${composeFlags} ${replOverlayFlag} up -d --remove-orphans
51
59
 
52
60
  # 3. Ensure systemd service is active (if applicable)
53
61
  if [ -f "/etc/systemd/system/${projectName}.service" ]; then
@@ -107,6 +115,90 @@ function composeFileFlags(options = {}) {
107
115
  return files.join(' ');
108
116
  }
109
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
+
110
202
  /**
111
203
  * Render the deployment bundle to a local temp directory
112
204
  * @param {string} projectName
@@ -233,13 +325,36 @@ export function renderBundle(projectName, options = {}) {
233
325
  // template ships a stub at functions/main/index.ts. RCA: prod-1 2026-05-26.
234
326
  copyDirIfExist('functions', 'functions');
235
327
 
236
- // 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
+
237
333
  const traefikMiddlewares = join(cwd, 'volumes', 'traefik', 'middlewares.yml');
238
334
  if (existsSync(traefikMiddlewares)) {
239
- mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
240
335
  cpSync(traefikMiddlewares, join(stageDir, 'volumes', 'traefik', 'middlewares.yml'));
241
336
  }
242
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
+
243
358
  copyDirIfExist('backup', 'backup');
244
359
  if (options.n8n) copyDirIfExist('volumes/n8n', 'volumes/n8n');
245
360
  if (options.metabase) copyDirIfExist('volumes/metabase', 'volumes/metabase');
@@ -31,6 +31,7 @@ import {
31
31
  isLocalOnlyImageTag,
32
32
  pullComposeImages,
33
33
  runMigrations,
34
+ setupComposeBackupCron,
34
35
  setupServer,
35
36
  setupServerFiles,
36
37
  sshRun,
@@ -161,6 +162,51 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
161
162
  // POSTGRESQL STREAMING REPLICATION SETUP
162
163
  // ============================================================================
163
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
+
164
210
  /**
165
211
  * Configure the primary PostgreSQL server for streaming replication.
166
212
  *
@@ -279,7 +325,7 @@ END $$;
279
325
  * 3. Write standby.signal and configure primary_conninfo
280
326
  * 4. Restart as hot standby
281
327
  */
282
- async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
328
+ export async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
283
329
  const remoteDir = `/opt/${projectName}`;
284
330
  // replPassword is generated at create time from crypto.randomBytes and is
285
331
  // restricted to base64url characters — no shell escaping needed.
@@ -293,6 +339,33 @@ async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, pro
293
339
  );
294
340
  }
295
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
+
296
369
  // Stop the standby database
297
370
  sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
298
371
  timeout: 60_000,
@@ -1170,6 +1243,39 @@ export async function deployComposeHA(options) {
1170
1243
  p.log.warn('Streaming replication not detected — standby may fall behind');
1171
1244
  }
1172
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
+
1173
1279
  // Finalize config — promote pending → deployed
1174
1280
  saveProjectConfig({
1175
1281
  ...projectConfig,
@@ -1568,6 +1674,13 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1568
1674
  // — run pg_basebackup to guarantee schema parity, then promote. For a
1569
1675
  // real disaster failover where primary is unreachable, skip the re-seed
1570
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.
1571
1684
  const primaryReachable = await isPrimaryReachable(primaryServer.ip, sshKeyPath, remoteDir);
1572
1685
  if (primaryReachable) {
1573
1686
  s.start('Re-seeding standby from primary (pg_basebackup)');