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.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- 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/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +152 -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 -2
- package/src/backup.js +13 -66
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/destroy.js +5 -43
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +137 -5
- package/src/lib/deploy/compose/ha.js +208 -92
- package/src/lib/deploy/compose/index.js +292 -424
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +407 -168
- package/src/lib/deploy/orchestrator.js +90 -33
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +45 -16
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +216 -302
- package/src/scale.js +76 -77
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- package/src/lib/kubectl.js +0 -81
package/src/lib/deploy/bundle.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
//
|
|
1540
|
+
// FAILOVER
|
|
1475
1541
|
// ============================================================================
|
|
1476
1542
|
|
|
1477
1543
|
/**
|
|
1478
|
-
*
|
|
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
|
|
1481
|
-
const
|
|
1482
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|