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.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +8 -0
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +1 -1
- package/src/backup.js +11 -30
- package/src/destroy.js +5 -43
- package/src/lib/deploy/bundle.js +120 -5
- package/src/lib/deploy/compose/ha.js +114 -1
- package/src/lib/deploy/compose/index.js +243 -177
- package/src/lib/deploy/k8s/k3s.js +119 -66
- package/src/lib/deploy/orchestrator.js +22 -0
- package/src/lib/iac/index.js +45 -7
- package/src/restore.js +135 -54
- package/src/scale.js +33 -57
|
@@ -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
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
|
|
665
|
+
s.start('Creating wal-g base backup...');
|
|
664
666
|
try {
|
|
665
|
-
const { backupCompose
|
|
666
|
-
|
|
667
|
-
)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
p.log.
|
|
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
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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}`);
|
package/src/lib/deploy/bundle.js
CHANGED
|
@@ -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/
|
|
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)');
|