vibecarbon 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/scripts/dev-init.js +6 -2
- package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
- package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
- package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
- package/carbon/src/client/components/AppSidebar.tsx +35 -28
- package/carbon/src/client/components/CTAFooter.tsx +1 -1
- package/carbon/src/client/components/ComparisonSection.tsx +1 -3
- package/carbon/src/client/components/ContentPanel.tsx +1 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
- package/carbon/src/client/components/FAQSection.tsx +1 -1
- package/carbon/src/client/components/Hero.tsx +13 -1
- package/carbon/src/client/components/Logo.tsx +66 -49
- package/carbon/src/client/components/Nav.tsx +1 -1
- package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
- package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
- package/carbon/src/client/components/PageHeader.tsx +1 -1
- package/carbon/src/client/components/PricingSection.tsx +2 -6
- package/carbon/src/client/components/TechStackSection.tsx +1 -3
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
- package/carbon/src/client/components/ui/button.tsx +1 -1
- package/carbon/src/client/components/ui/card.tsx +1 -1
- package/carbon/src/client/components/ui/dialog.tsx +1 -1
- package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
- package/carbon/src/client/components/ui/popover.tsx +1 -1
- package/carbon/src/client/components/ui/select.tsx +1 -1
- package/carbon/src/client/components/ui/sidebar.tsx +1 -1
- package/carbon/src/client/index.css +186 -104
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/lib/theme.ts +14 -14
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +33 -18
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Blog.tsx +1 -1
- package/carbon/src/client/pages/Changelog.tsx +1 -1
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/client/pages/Dashboard.tsx +33 -40
- package/carbon/src/client/pages/Docs.tsx +1 -1
- package/carbon/src/client/pages/Home.tsx +1 -1
- package/carbon/src/client/pages/Legal.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
- package/carbon/src/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
package/src/failover.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import * as p from '@clack/prompts';
|
|
15
|
+
import { formatInstant } from './lib/backup-format.js';
|
|
15
16
|
import { renderHelp } from './lib/cli/help.js';
|
|
16
17
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
17
18
|
import { selectEnvironment } from './lib/cli/select-environment.js';
|
|
@@ -19,17 +20,23 @@ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
|
19
20
|
import { createDNSRecord } from './lib/cloudflare.js';
|
|
20
21
|
import { c, printBanner } from './lib/colors.js';
|
|
21
22
|
import { loadCredentials, loadProjectConfig } from './lib/config.js';
|
|
23
|
+
import {
|
|
24
|
+
getReplPasswordFromSecret,
|
|
25
|
+
promoteStandby,
|
|
26
|
+
reseedStandbyFromPrimary,
|
|
27
|
+
} from './lib/deploy/replication.js';
|
|
22
28
|
import {
|
|
23
29
|
createDNSRecord as hetznerDnsCreateRecord,
|
|
24
30
|
getZone as hetznerDnsGetZone,
|
|
25
31
|
} from './lib/hetzner-dns.js';
|
|
26
|
-
import { requireLicense } from './lib/licensing/index.js';
|
|
27
32
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
28
33
|
import { perfTimer } from './lib/perf.js';
|
|
34
|
+
import { confirmProdOrExit } from './lib/prod-confirm.js';
|
|
29
35
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
30
|
-
import { getPostgresPod, getSSHKeyPath, sshKubectl
|
|
36
|
+
import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
|
|
31
37
|
import { createTracker } from './lib/tracker.js';
|
|
32
38
|
import { VERSION } from './lib/version.js';
|
|
39
|
+
import { listWalgBackups } from './lib/walg-backups.js';
|
|
33
40
|
|
|
34
41
|
// ============================================================================
|
|
35
42
|
// COMMAND SPEC — single source of truth for argv parsing AND help output.
|
|
@@ -170,113 +177,69 @@ async function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
|
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
179
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
* restarted and missed WAL. Without re-seeding, promoted standby can serve
|
|
178
|
-
* requests against a schema that's missing recent migrations, yielding
|
|
179
|
-
* "undefined column" (PG 42703) errors in auth/storage after failover.
|
|
180
|
-
*
|
|
181
|
-
* Requires `primarySupabaseIp` — the Hetzner private IP of the primary's
|
|
182
|
-
* supabase node (where the postgres pod runs with hostPort 5432). Returns
|
|
183
|
-
* true if re-seed succeeded, false if primary was unreachable or re-seed
|
|
184
|
-
* itself failed (caller proceeds to promote with existing state).
|
|
185
|
-
*/
|
|
186
|
-
async function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
|
|
187
|
-
if (!primarySupabaseIp) return false;
|
|
188
|
-
try {
|
|
189
|
-
// REPL_PASSWORD is written to both primary and standby .env.local at
|
|
190
|
-
// create time; read it from the standby's filesystem (easier than
|
|
191
|
-
// threading through the failover args).
|
|
192
|
-
const replPassword = await sshRun(standbyIp, sshKeyPath, [
|
|
193
|
-
'bash',
|
|
194
|
-
'-c',
|
|
195
|
-
'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
|
|
196
|
-
]);
|
|
197
|
-
if (!replPassword?.trim()) return false;
|
|
198
|
-
|
|
199
|
-
const pod = await getPostgresPod(standbyIp, sshKeyPath);
|
|
200
|
-
const script = [
|
|
201
|
-
`rm -rf /var/lib/postgresql/data/*`,
|
|
202
|
-
`PGPASSWORD='${replPassword.trim()}' pg_basebackup -h ${primarySupabaseIp} -p 5432 -U replicator -D /var/lib/postgresql/data -Fp -Xs -R -c fast -S vibecarbon_standby_slot`,
|
|
203
|
-
`echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
|
|
204
|
-
`chown -R postgres:postgres /var/lib/postgresql/data`,
|
|
205
|
-
].join('\n');
|
|
206
|
-
await sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
|
|
207
|
-
input: script,
|
|
208
|
-
});
|
|
209
|
-
// Restart the pod to pick up the new PGDATA + recovery config.
|
|
210
|
-
await sshKubectl(standbyIp, sshKeyPath, [
|
|
211
|
-
'exec',
|
|
212
|
-
'-n',
|
|
213
|
-
'vibecarbon',
|
|
214
|
-
pod,
|
|
215
|
-
'--',
|
|
216
|
-
'su',
|
|
217
|
-
'postgres',
|
|
218
|
-
'-c',
|
|
219
|
-
'pg_ctl restart -D /var/lib/postgresql/data -m fast',
|
|
220
|
-
]);
|
|
221
|
-
return true;
|
|
222
|
-
} catch {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
180
|
+
// getReplPasswordFromSecret + reseedStandbyFromPrimary + promoteStandby now live
|
|
181
|
+
// in src/lib/deploy/replication.js (finding #2 — shared with restore.js and the
|
|
182
|
+
// k8s deploy-time seed). They are imported at the top of this file. The perfTimer
|
|
183
|
+
// wrapper for promotion stays here since it is a failover-flow concern.
|
|
226
184
|
|
|
227
185
|
async function promoteStandbyDatabase(standbyIp, sshKeyPath) {
|
|
228
186
|
const _t = perfTimer('failover.promoteStandby');
|
|
229
187
|
try {
|
|
230
|
-
return await
|
|
188
|
+
return await promoteStandby(standbyIp, sshKeyPath);
|
|
231
189
|
} finally {
|
|
232
190
|
_t.end();
|
|
233
191
|
}
|
|
234
192
|
}
|
|
235
193
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
'su',
|
|
249
|
-
'postgres',
|
|
250
|
-
'-c',
|
|
251
|
-
'pg_ctl promote -D /var/lib/postgresql/data',
|
|
252
|
-
]);
|
|
253
|
-
} catch {
|
|
254
|
-
// pg_ctl promote may return non-zero even on success
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Poll until standby is no longer in recovery
|
|
258
|
-
for (let i = 0; i < 30; i++) {
|
|
194
|
+
/**
|
|
195
|
+
* Re-seed the standby from the primary (when reachable) then promote it.
|
|
196
|
+
* ABORTS the whole failover (throws) if the re-seed fails or the promotion
|
|
197
|
+
* cannot be confirmed — we must NEVER scale down the old primary and flip DNS
|
|
198
|
+
* onto a standby that was left half-seeded or is still in recovery. Brings the
|
|
199
|
+
* k8s failover path to parity with compose (compose/ha.js hard-fails both).
|
|
200
|
+
*/
|
|
201
|
+
async function reseedAndPromoteOrAbort(servers, sshKeyPath, s) {
|
|
202
|
+
// Step 0: Re-seed standby from primary (only when the primary is reachable).
|
|
203
|
+
if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
204
|
+
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
205
|
+
let result;
|
|
259
206
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
} catch {
|
|
274
|
-
// Retry
|
|
207
|
+
result = await reseedStandbyFromPrimary(
|
|
208
|
+
servers.standby.ip,
|
|
209
|
+
sshKeyPath,
|
|
210
|
+
servers.primary.supabaseIp,
|
|
211
|
+
);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
s.stop('Re-seed failed — aborting failover');
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Failover aborted before promotion: ${err.message}\n` +
|
|
216
|
+
`The standby was NOT promoted and the old primary is untouched — no ` +
|
|
217
|
+
`split-brain and no DNS flip. Resolve the issue (or redeploy the ` +
|
|
218
|
+
`standby) and retry.`,
|
|
219
|
+
);
|
|
275
220
|
}
|
|
276
|
-
|
|
221
|
+
s.stop(
|
|
222
|
+
result === 'reseeded'
|
|
223
|
+
? 'Standby re-seeded with primary state'
|
|
224
|
+
: 'Primary unreachable mid-probe — promoting standby with current state',
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
|
|
277
228
|
}
|
|
278
229
|
|
|
279
|
-
|
|
230
|
+
// Step 1: Promote standby database. A failure here is FATAL: continuing would
|
|
231
|
+
// scale down the old primary and flip DNS onto a read-only standby.
|
|
232
|
+
s.start('Promoting standby database');
|
|
233
|
+
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
234
|
+
if (!promoted) {
|
|
235
|
+
s.stop('Promotion could not be confirmed — aborting failover');
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Failover aborted: standby ${servers.standby.ip} did not exit recovery mode. ` +
|
|
238
|
+
`Refusing to scale down the old primary or flip DNS onto an unpromoted ` +
|
|
239
|
+
`standby (that would take the app read-only). Verify the standby db pod and retry.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
s.stop('Standby database promoted to primary');
|
|
280
243
|
}
|
|
281
244
|
|
|
282
245
|
async function scaleUpServices(ip, sshKeyPath) {
|
|
@@ -468,36 +431,13 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
|
|
|
468
431
|
}
|
|
469
432
|
}
|
|
470
433
|
|
|
471
|
-
//
|
|
472
|
-
// Streaming replication can silently fall behind; a stale standby
|
|
473
|
-
// to serve queries against an out-of-date schema and
|
|
474
|
-
// column" (PG 42703) errors.
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
479
|
-
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
480
|
-
const reseeded = await reseedStandbyFromPrimary(
|
|
481
|
-
servers.standby.ip,
|
|
482
|
-
sshKeyPath,
|
|
483
|
-
servers.primary.supabaseIp,
|
|
484
|
-
);
|
|
485
|
-
s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
|
|
486
|
-
} else {
|
|
487
|
-
p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Step 1: Promote standby database
|
|
491
|
-
s.start('Promoting standby database');
|
|
492
|
-
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
493
|
-
s.stop(
|
|
494
|
-
promoted
|
|
495
|
-
? 'Standby database promoted to primary'
|
|
496
|
-
: 'Database promotion may still be in progress',
|
|
497
|
-
);
|
|
498
|
-
if (!promoted) {
|
|
499
|
-
p.log.warn('Could not confirm promotion completed — verify manually');
|
|
500
|
-
}
|
|
434
|
+
// Steps 0 + 1: Re-seed standby from primary (if reachable) then promote.
|
|
435
|
+
// Streaming replication can silently fall behind; a stale standby would
|
|
436
|
+
// promote to serve queries against an out-of-date schema and hit "undefined
|
|
437
|
+
// column" (PG 42703) errors. A re-seed failure or an unconfirmed promotion
|
|
438
|
+
// ABORTS the failover (throws) — see reseedAndPromoteOrAbort — so we never
|
|
439
|
+
// scale down the old primary or flip DNS onto a half-seeded/unpromoted node.
|
|
440
|
+
await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
|
|
501
441
|
|
|
502
442
|
// Step 2: Scale down old primary services to prevent split-brain.
|
|
503
443
|
// Per-deployment errors (not-found, unreachable) are logged inside
|
|
@@ -631,36 +571,11 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
|
|
|
631
571
|
}
|
|
632
572
|
}
|
|
633
573
|
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
// has. Requires primarySupabaseIp from identifyServers (persisted in
|
|
640
|
-
// envConfig.servers by orchestrator.js post-deploy).
|
|
641
|
-
if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
642
|
-
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
643
|
-
const reseeded = await reseedStandbyFromPrimary(
|
|
644
|
-
servers.standby.ip,
|
|
645
|
-
sshKeyPath,
|
|
646
|
-
servers.primary.supabaseIp,
|
|
647
|
-
);
|
|
648
|
-
s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
|
|
649
|
-
} else {
|
|
650
|
-
p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Step 1: Promote standby database
|
|
654
|
-
s.start('Promoting standby database');
|
|
655
|
-
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
656
|
-
s.stop(
|
|
657
|
-
promoted
|
|
658
|
-
? 'Standby database promoted to primary'
|
|
659
|
-
: 'Database promotion may still be in progress',
|
|
660
|
-
);
|
|
661
|
-
if (!promoted) {
|
|
662
|
-
p.log.warn('Could not confirm promotion completed — verify manually');
|
|
663
|
-
}
|
|
574
|
+
// Steps 0 + 1: Re-seed standby from primary (if reachable) then promote.
|
|
575
|
+
// A re-seed failure or an unconfirmed promotion ABORTS the failover (throws)
|
|
576
|
+
// — see reseedAndPromoteOrAbort — so we never scale down the old primary or
|
|
577
|
+
// flip DNS onto a half-seeded/unpromoted node.
|
|
578
|
+
await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
|
|
664
579
|
|
|
665
580
|
// Step 2: Scale down old primary services to prevent split-brain.
|
|
666
581
|
// See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
|
|
@@ -771,49 +686,22 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
771
686
|
|
|
772
687
|
const s = tracker.spinner();
|
|
773
688
|
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
|
|
689
|
+
// Re-seed standby from primary (if reachable) then promote — aborts on
|
|
690
|
+
// failure so we never activate a half-seeded/unpromoted standby.
|
|
691
|
+
await reseedAndPromoteOrAbort(servers, sshKeyPath, s);
|
|
778
692
|
|
|
779
|
-
// Scale down old primary services to prevent split-brain
|
|
693
|
+
// Scale down old primary services to prevent split-brain. Uses the
|
|
694
|
+
// chart-canonical deployment names (FAILOVER_DEPLOYMENTS) — the previous bare
|
|
695
|
+
// `auth`/`rest`/`realtime` names silently no-op'd against the Helm-owned
|
|
696
|
+
// `supabase-supabase-*` deployments, so the old primary was never scaled down.
|
|
780
697
|
s.start('Scaling down old primary services');
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
'--replicas=0',
|
|
789
|
-
]);
|
|
790
|
-
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
791
|
-
'scale',
|
|
792
|
-
'deployment',
|
|
793
|
-
'auth',
|
|
794
|
-
'-n',
|
|
795
|
-
'vibecarbon',
|
|
796
|
-
'--replicas=0',
|
|
797
|
-
]);
|
|
798
|
-
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
799
|
-
'scale',
|
|
800
|
-
'deployment',
|
|
801
|
-
'rest',
|
|
802
|
-
'-n',
|
|
803
|
-
'vibecarbon',
|
|
804
|
-
'--replicas=0',
|
|
805
|
-
]);
|
|
806
|
-
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
807
|
-
'scale',
|
|
808
|
-
'deployment',
|
|
809
|
-
'realtime',
|
|
810
|
-
'-n',
|
|
811
|
-
'vibecarbon',
|
|
812
|
-
'--replicas=0',
|
|
813
|
-
]);
|
|
814
|
-
} catch {
|
|
815
|
-
p.log.warn('Could not reach old primary to scale down services');
|
|
816
|
-
}
|
|
698
|
+
await scaleDeployments(
|
|
699
|
+
servers.primary.ip,
|
|
700
|
+
sshKeyPath,
|
|
701
|
+
FAILOVER_DEPLOYMENTS,
|
|
702
|
+
0,
|
|
703
|
+
'scale-down primary',
|
|
704
|
+
);
|
|
817
705
|
s.stop('Old primary services scaled down');
|
|
818
706
|
|
|
819
707
|
// Scale up services
|
|
@@ -853,47 +741,28 @@ async function failoverSingleServer(envName, envConfig) {
|
|
|
853
741
|
p.log.warn('This environment has no standby region');
|
|
854
742
|
p.log.info('Recovery requires deploying a new server and restoring from backup');
|
|
855
743
|
|
|
856
|
-
// Check for available backups
|
|
744
|
+
// Check for available backups. wal-g is the SINGLE SOURCE OF TRUTH for what is
|
|
745
|
+
// actually restorable — the legacy `/backups/*_full.tar.gz` + `*.sql.gz` S3
|
|
746
|
+
// objects are NOT wal-g backups and `vibecarbon restore` cannot replay them,
|
|
747
|
+
// so listing them here would tempt an operator to pick a non-restorable
|
|
748
|
+
// artifact mid-disaster. List wal-g base backups only.
|
|
857
749
|
if (serverIp && existsSync(sshKeyPath)) {
|
|
858
750
|
const s = p.spinner();
|
|
859
|
-
s.start('Checking for available backups');
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
]);
|
|
871
|
-
const output = await sshKubectl(serverIp, sshKeyPath, [
|
|
872
|
-
'exec',
|
|
873
|
-
'-n',
|
|
874
|
-
'vibecarbon',
|
|
875
|
-
pod,
|
|
876
|
-
'--',
|
|
877
|
-
'sh',
|
|
878
|
-
'-c',
|
|
879
|
-
'ls -lt /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null | head -5 || echo NO_BACKUPS',
|
|
880
|
-
]);
|
|
881
|
-
s.stop('Backup check complete');
|
|
882
|
-
|
|
883
|
-
if (output !== 'NO_BACKUPS' && output) {
|
|
884
|
-
p.log.info(c.bold('Available backups:'));
|
|
885
|
-
for (const line of output
|
|
886
|
-
.split('\n')
|
|
887
|
-
.filter((l) => l.includes('.tar.gz') || l.includes('.sql.gz'))) {
|
|
888
|
-
p.log.message(` ${line.trim()}`);
|
|
889
|
-
}
|
|
890
|
-
} else {
|
|
891
|
-
p.log.info('No backups found on the server');
|
|
892
|
-
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
751
|
+
s.start('Checking for available backups (wal-g)');
|
|
752
|
+
const projectConfig = loadProjectConfig();
|
|
753
|
+
const projectName = projectConfig?.projectName || 'project';
|
|
754
|
+
const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
|
|
755
|
+
const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
|
|
756
|
+
s.stop('Backup check complete');
|
|
757
|
+
|
|
758
|
+
if (backups.length > 0) {
|
|
759
|
+
p.log.info(c.bold(`Available wal-g base backups (${backups.length}, newest first):`));
|
|
760
|
+
for (const b of backups.slice(0, 5)) {
|
|
761
|
+
p.log.message(` ${formatInstant(b.time).padEnd(22)} ${c.dim(b.name)}`);
|
|
893
762
|
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
p.log.
|
|
763
|
+
} else {
|
|
764
|
+
p.log.info('No wal-g base backups found on the server');
|
|
765
|
+
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
897
766
|
}
|
|
898
767
|
}
|
|
899
768
|
|
|
@@ -933,7 +802,6 @@ export async function run(args) {
|
|
|
933
802
|
// failover` from a parent directory emits the canonical message.
|
|
934
803
|
assertInProjectDir();
|
|
935
804
|
|
|
936
|
-
requireLicense('failover');
|
|
937
805
|
printBanner();
|
|
938
806
|
p.intro(`${c.bold('vibecarbon failover')} ${c.dim(`v${VERSION}`)}`);
|
|
939
807
|
|
|
@@ -1002,6 +870,19 @@ export async function run(args) {
|
|
|
1002
870
|
return;
|
|
1003
871
|
}
|
|
1004
872
|
|
|
873
|
+
// SECURITY: production type-to-confirm runs UNCONDITIONALLY here — even with
|
|
874
|
+
// -y — for any DESTRUCTIVE failover (compose-HA or an HA k8s scenario), so a
|
|
875
|
+
// scripted `failover -y prod` can never silently promote/flip production. This
|
|
876
|
+
// is deliberately OUTSIDE every `if (!parsed.yes)` soft-confirm block (those
|
|
877
|
+
// are skippable with -y; this hard gate is not). Mirrors restore/destroy.
|
|
878
|
+
// Skipped for -dry (previews nothing destructive) and for the single-server
|
|
879
|
+
// recovery guide (informational only — no promotion/DNS change).
|
|
880
|
+
const isDestructiveFailover =
|
|
881
|
+
envConfig.deployMode === 'compose-ha' || detectScenario(envConfig) !== 'single_server';
|
|
882
|
+
if (!parsed.dryRun && isDestructiveFailover) {
|
|
883
|
+
await confirmProdOrExit(envName, { actionLabel: 'failover', yes: !!parsed.yes });
|
|
884
|
+
}
|
|
885
|
+
|
|
1005
886
|
// Compose-HA failover uses docker compose exec instead of kubectl
|
|
1006
887
|
if (envConfig.deployMode === 'compose-ha') {
|
|
1007
888
|
const composeHATracker = createTracker('failover', {
|
|
@@ -1046,4 +927,4 @@ export async function run(args) {
|
|
|
1046
927
|
}
|
|
1047
928
|
|
|
1048
929
|
// Exported for tests.
|
|
1049
|
-
export { SPEC };
|
|
930
|
+
export { getReplPasswordFromSecret, reseedStandbyFromPrimary, SPEC };
|
package/src/lib/command.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import * as p from '@clack/prompts';
|
|
@@ -359,3 +359,21 @@ export function runCommandThroughTaskLog(cmd, options) {
|
|
|
359
359
|
export function checkDependency(cmd) {
|
|
360
360
|
return which.sync(cmd, { nothrow: true }) !== null;
|
|
361
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Write a file that contains secrets (rendered helm values, credentials, etc.)
|
|
365
|
+
* with owner-only 0o600 permissions, ALWAYS — even when overwriting an existing
|
|
366
|
+
* file (writeFileSync only applies `mode` on creation, so we chmod after).
|
|
367
|
+
*
|
|
368
|
+
* SECURITY: use this for any temp file holding secret material. A plain
|
|
369
|
+
* writeFileSync defaults to 0o644 (world-readable), which leaks credentials to
|
|
370
|
+
* every local user for the lifetime of the file (e.g. a rendered helm values
|
|
371
|
+
* file lives for the whole `helm upgrade --wait` window).
|
|
372
|
+
*
|
|
373
|
+
* @param {string} path
|
|
374
|
+
* @param {string | NodeJS.ArrayBufferView} data
|
|
375
|
+
*/
|
|
376
|
+
export function writeSecretFile(path, data) {
|
|
377
|
+
writeFileSync(path, data, { mode: 0o600 });
|
|
378
|
+
chmodSync(path, 0o600);
|
|
379
|
+
}
|