vibecarbon 0.5.0 → 0.6.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/README.md +1 -1
- package/carbon/.env.example +3 -0
- package/carbon/.github/workflows/deploy.yml +24 -21
- package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
- package/carbon/Dockerfile +3 -0
- package/carbon/biome.json +1 -1
- package/carbon/cloud-init/k3s/master-init.sh +26 -2
- package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
- package/carbon/cloud-init/k3s/worker-init.sh +22 -2
- package/carbon/content/docs/cli.mdx +1 -1
- package/carbon/docker-compose.yml +6 -0
- package/carbon/k8s/base/app/deployment.yaml +5 -0
- package/carbon/k8s/values/supabase.values.yaml +30 -0
- package/carbon/package.json +34 -36
- package/carbon/scripts/generate-sitemap.ts +18 -3
- package/carbon/src/client/App.tsx +2 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
- package/carbon/src/client/components/PricingSection.tsx +44 -1
- package/carbon/src/client/components/SEO.tsx +6 -3
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/index.css +9 -1
- package/carbon/src/client/index.html +3 -3
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/client/pages/Pricing.tsx +169 -0
- package/carbon/src/client/pages/settings/Billing.tsx +2 -6
- package/carbon/src/server/index.ts +23 -4
- package/carbon/src/shared/billing-catalog.ts +25 -0
- package/carbon/src/shared/billing-catalog.types.ts +35 -0
- package/carbon/src/shared/pricing.ts +56 -1
- package/package.json +13 -13
- package/src/activate.js +1 -1
- package/src/backup.js +33 -87
- package/src/configure.js +365 -137
- package/src/create.js +5 -2
- package/src/deploy.js +24 -7
- package/src/destroy.js +11 -4
- package/src/failover.js +64 -47
- package/src/lib/backup-format.js +84 -0
- package/src/lib/billing/stripe-catalog.js +152 -0
- package/src/lib/billing/write-catalog.js +123 -0
- package/src/lib/ci-setup.js +11 -1
- package/src/lib/config-registry.js +116 -0
- package/src/lib/deploy/compose/build-args.js +6 -0
- package/src/lib/deploy/compose/ha.js +25 -25
- package/src/lib/deploy/compose/index.js +29 -18
- package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
- package/src/lib/deploy/k8s/k3s.js +20 -10
- package/src/lib/deploy/orchestrator.js +22 -1
- package/src/lib/deploy/prompts.js +21 -2
- package/src/lib/deploy/utils.js +56 -32
- package/src/lib/github-environments.js +29 -0
- package/src/lib/licensing/index.js +8 -3
- package/src/lib/licensing/tiers.js +0 -3
- package/src/lib/pod-backups.js +3 -3
- package/src/lib/providers/base.js +1 -1
- package/src/lib/providers/hetzner.js +74 -82
- package/src/lib/server-types.js +3 -31
- package/src/lib/ssh.js +19 -18
- package/src/lib/walg-backups.js +81 -0
- package/src/restore.js +122 -202
- package/src/scale.js +25 -29
- package/src/status.js +0 -69
- package/src/lib/cost.js +0 -103
package/src/restore.js
CHANGED
|
@@ -19,33 +19,27 @@
|
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
20
|
import { basename } from 'node:path';
|
|
21
21
|
import * as p from '@clack/prompts';
|
|
22
|
-
import {
|
|
22
|
+
import { formatInstant } from './lib/backup-format.js';
|
|
23
23
|
import { renderHelp } from './lib/cli/help.js';
|
|
24
24
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
25
25
|
import { selectEnvironment } from './lib/cli/select-environment.js';
|
|
26
26
|
import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
27
27
|
import { c, printBanner } from './lib/colors.js';
|
|
28
|
-
import {
|
|
29
|
-
loadBackupS3Config,
|
|
30
|
-
loadCredentials,
|
|
31
|
-
loadProjectConfig,
|
|
32
|
-
loadS3Config,
|
|
33
|
-
} from './lib/config.js';
|
|
28
|
+
import { loadCredentials, loadProjectConfig } from './lib/config.js';
|
|
34
29
|
// Static imports (no cycle: neither compose/index.js nor compose/ha.js imports
|
|
35
30
|
// restore.js). Aliased so runComposeRestore can default to the real functions
|
|
36
31
|
// while still accepting injected mocks for unit tests.
|
|
37
32
|
import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
|
|
38
33
|
import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
|
|
39
|
-
import { getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
40
34
|
import { requireLicense } from './lib/licensing/index.js';
|
|
41
35
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
42
36
|
import { perfAsync } from './lib/perf.js';
|
|
43
|
-
import { listPodBackups } from './lib/pod-backups.js';
|
|
44
37
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
45
|
-
import { getPostgresPod, getSSHKeyPath, sshKubectl
|
|
38
|
+
import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
|
|
46
39
|
import { createTracker } from './lib/tracker.js';
|
|
47
40
|
import { validateBackupFilename } from './lib/validators.js';
|
|
48
41
|
import { VERSION } from './lib/version.js';
|
|
42
|
+
import { listWalgBackups } from './lib/walg-backups.js';
|
|
49
43
|
|
|
50
44
|
// ============================================================================
|
|
51
45
|
// COMMAND SPEC — single source of truth for argv parsing AND help output.
|
|
@@ -74,24 +68,24 @@ const SPEC = {
|
|
|
74
68
|
{ name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
|
|
75
69
|
{
|
|
76
70
|
name: 'source',
|
|
77
|
-
value: '<file
|
|
78
|
-
description: '
|
|
71
|
+
value: '<latest|ISO-timestamp|file>',
|
|
72
|
+
description: 'Restore point: `latest`, an ISO-8601 timestamp (PITR), or a local file path',
|
|
79
73
|
},
|
|
80
74
|
],
|
|
81
75
|
examples: [
|
|
82
|
-
{ command: 'vibecarbon restore', description: 'prompts for env and
|
|
76
|
+
{ command: 'vibecarbon restore', description: 'prompts for env and restore point' },
|
|
83
77
|
{ command: 'vibecarbon restore prod -l', description: 'list available backups for prod' },
|
|
84
78
|
{
|
|
85
|
-
command: 'vibecarbon restore prod -source
|
|
86
|
-
description: 'restore
|
|
79
|
+
command: 'vibecarbon restore prod -y -source latest',
|
|
80
|
+
description: 'restore the most-recent backup, non-interactively',
|
|
87
81
|
},
|
|
88
82
|
{
|
|
89
|
-
command: 'vibecarbon restore prod -source
|
|
90
|
-
description: '
|
|
83
|
+
command: 'vibecarbon restore prod -source 2026-06-22T14:30:00Z',
|
|
84
|
+
description: 'point-in-time recovery to a specific moment (wal-g WAL replay)',
|
|
91
85
|
},
|
|
92
86
|
{
|
|
93
|
-
command: 'vibecarbon restore prod -
|
|
94
|
-
description: 'restore
|
|
87
|
+
command: 'vibecarbon restore prod -source ./backup.tar.gz -y',
|
|
88
|
+
description: 'restore from a local file, skipping confirmation',
|
|
95
89
|
},
|
|
96
90
|
],
|
|
97
91
|
};
|
|
@@ -100,11 +94,18 @@ const SPEC = {
|
|
|
100
94
|
// RESTORE OPERATIONS (k8s mode)
|
|
101
95
|
// ============================================================================
|
|
102
96
|
|
|
103
|
-
function scaleDownApp(ip, sshKeyPath) {
|
|
104
|
-
sshKubectl(ip, sshKeyPath, [
|
|
97
|
+
async function scaleDownApp(ip, sshKeyPath) {
|
|
98
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
99
|
+
'scale',
|
|
100
|
+
'deployment',
|
|
101
|
+
'app',
|
|
102
|
+
'-n',
|
|
103
|
+
'vibecarbon',
|
|
104
|
+
'--replicas=0',
|
|
105
|
+
]);
|
|
105
106
|
|
|
106
107
|
try {
|
|
107
|
-
sshKubectl(ip, sshKeyPath, [
|
|
108
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
108
109
|
'rollout',
|
|
109
110
|
'status',
|
|
110
111
|
'deployment/app',
|
|
@@ -117,8 +118,15 @@ function scaleDownApp(ip, sshKeyPath) {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
function scaleUpApp(ip, sshKeyPath) {
|
|
121
|
-
sshKubectl(ip, sshKeyPath, [
|
|
121
|
+
async function scaleUpApp(ip, sshKeyPath) {
|
|
122
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
123
|
+
'scale',
|
|
124
|
+
'deployment',
|
|
125
|
+
'app',
|
|
126
|
+
'-n',
|
|
127
|
+
'vibecarbon',
|
|
128
|
+
'--replicas=2',
|
|
129
|
+
]);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/**
|
|
@@ -147,12 +155,21 @@ export async function verifyPostgres(ip, sshKeyPath, opts = {}) {
|
|
|
147
155
|
getPod = getPostgresPod,
|
|
148
156
|
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
149
157
|
} = opts;
|
|
150
|
-
const pod = getPod(ip, sshKeyPath);
|
|
158
|
+
const pod = await getPod(ip, sshKeyPath);
|
|
151
159
|
const deadline = Date.now() + timeoutMs;
|
|
152
160
|
let lastErr = '';
|
|
153
161
|
for (;;) {
|
|
154
162
|
try {
|
|
155
|
-
exec(ip, sshKeyPath, [
|
|
163
|
+
await exec(ip, sshKeyPath, [
|
|
164
|
+
'exec',
|
|
165
|
+
'-n',
|
|
166
|
+
'vibecarbon',
|
|
167
|
+
pod,
|
|
168
|
+
'--',
|
|
169
|
+
'pg_isready',
|
|
170
|
+
'-U',
|
|
171
|
+
'postgres',
|
|
172
|
+
]);
|
|
156
173
|
return; // exit 0 = accepting connections
|
|
157
174
|
} catch (err) {
|
|
158
175
|
lastErr = err instanceof Error ? err.message : String(err);
|
|
@@ -332,30 +349,18 @@ export async function run(args) {
|
|
|
332
349
|
return;
|
|
333
350
|
}
|
|
334
351
|
|
|
335
|
-
// Resolve
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// `-source latest`
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// restores) ask for "the most recent" without first listing + parsing
|
|
343
|
-
// names from `vibecarbon backup -l`.
|
|
352
|
+
// Resolve the chosen backup source. Restore is wal-g-native (compose + k8s):
|
|
353
|
+
// wal-g runs server-side and reads its own S3 config from the db container —
|
|
354
|
+
// the operator does NOT need local S3 credentials to restore.
|
|
355
|
+
//
|
|
356
|
+
// `-source latest` (e2e, post-incident scripts) and `-y` both resolve to the
|
|
357
|
+
// LATEST base backup; `-source <ISO-8601>` is a point-in-time target. A bare
|
|
358
|
+
// interactive run opens the restore-point chooser.
|
|
344
359
|
const wantsLatest = values.source === 'latest';
|
|
345
360
|
let chosenSource;
|
|
346
361
|
if (!wantsLatest && values.source) {
|
|
347
362
|
chosenSource = classifySource(/** @type {string} */ (values.source));
|
|
348
363
|
} else if (wantsLatest) {
|
|
349
|
-
// BOTH compose and k8s restore are now wal-g-native: `latest` resolves to
|
|
350
|
-
// the LATEST base backup that wal-g pushed to S3 — k8s via the db pod's
|
|
351
|
-
// walg-restore init container, compose via `restoreCompose(..., 'latest')`
|
|
352
|
-
// → `wal-g backup-fetch LATEST` (see runK8sRestore / runComposeRestore).
|
|
353
|
-
// pickInteractiveSource lists legacy pg_dump `*_full.tar.gz` backups via
|
|
354
|
-
// listS3Backups, which CANNOT see wal-g `basebackups_005/base_*` objects, so
|
|
355
|
-
// routing `-source latest` through it wrongly exits "No backups found in S3"
|
|
356
|
-
// even though a wal-g base backup exists. Hand the `latest` sentinel
|
|
357
|
-
// straight to the restore runner and let wal-g resolve it. (RCA 2026-05-31:
|
|
358
|
-
// e2e k8s/k8s-ha restore step; compose found via kept-rig manual restore.)
|
|
359
364
|
chosenSource = { kind: 's3', name: 'latest' };
|
|
360
365
|
} else {
|
|
361
366
|
chosenSource = await pickInteractiveSource({
|
|
@@ -364,9 +369,7 @@ export async function run(args) {
|
|
|
364
369
|
projectName,
|
|
365
370
|
serverIp,
|
|
366
371
|
sshKeyPath,
|
|
367
|
-
|
|
368
|
-
useS3,
|
|
369
|
-
yes: wantsLatest || !!values.y,
|
|
372
|
+
yes: !!values.y,
|
|
370
373
|
});
|
|
371
374
|
}
|
|
372
375
|
|
|
@@ -446,181 +449,98 @@ export async function run(args) {
|
|
|
446
449
|
// ORCHESTRATION HELPERS
|
|
447
450
|
// ============================================================================
|
|
448
451
|
|
|
449
|
-
async function resolveS3({ isCompose, envName, projectName }) {
|
|
450
|
-
if (isCompose) {
|
|
451
|
-
const backupS3 = loadBackupS3Config(envName);
|
|
452
|
-
if (!backupS3) return null;
|
|
453
|
-
const creds = await getS3Credentials(projectName, { save: false });
|
|
454
|
-
if (!creds) return null;
|
|
455
|
-
return { ...backupS3, accessKey: creds.accessKey, secretKey: creds.secretKey };
|
|
456
|
-
}
|
|
457
|
-
const backupS3 = loadBackupS3Config(envName);
|
|
458
|
-
const storageS3 = loadS3Config(envName);
|
|
459
|
-
let s3 = backupS3 || storageS3;
|
|
460
|
-
if (s3 && !s3.secretKey) {
|
|
461
|
-
const creds = await getS3Credentials(projectName, { save: false });
|
|
462
|
-
if (creds) {
|
|
463
|
-
s3 = { ...s3, accessKey: creds.accessKey, secretKey: creds.secretKey };
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
return s3;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
452
|
async function runList({ isCompose, envName, projectName, serverIp, sshKeyPath }) {
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
try {
|
|
475
|
-
const backups = await listS3Backups(s3);
|
|
476
|
-
printBackupList(backups, envName);
|
|
477
|
-
} catch (error) {
|
|
478
|
-
p.log.error(`Failed to list S3 backups: ${error.message}`);
|
|
479
|
-
process.exit(1);
|
|
480
|
-
}
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (isCompose) {
|
|
485
|
-
try {
|
|
486
|
-
const output = sshRunScript(
|
|
487
|
-
serverIp,
|
|
488
|
-
sshKeyPath,
|
|
489
|
-
`ls -lt /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
|
|
490
|
-
);
|
|
491
|
-
if (output === 'NO_BACKUPS' || !output.trim()) {
|
|
492
|
-
p.log.info('No backups found.');
|
|
493
|
-
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
const lines = output.trim().split('\n').filter(Boolean);
|
|
497
|
-
p.log.info(`${c.bold(`Backups for ${envName}`)} (${lines.length} found):`);
|
|
498
|
-
for (const line of lines) {
|
|
499
|
-
p.log.message(` ${line}`);
|
|
500
|
-
}
|
|
501
|
-
} catch (error) {
|
|
502
|
-
p.log.error(`Failed to list backups: ${error.message}`);
|
|
503
|
-
}
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
453
|
+
const s = p.spinner();
|
|
454
|
+
s.start('Reading wal-g backups');
|
|
455
|
+
const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
|
|
456
|
+
s.stop('Backups read');
|
|
506
457
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
printBackupList(backups, envName);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function printBackupList(backups, envName) {
|
|
513
|
-
if (!backups || backups.length === 0) {
|
|
514
|
-
p.log.info('No backups found.');
|
|
458
|
+
if (backups.length === 0) {
|
|
459
|
+
p.log.info('No wal-g base backups found.');
|
|
515
460
|
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
516
461
|
return;
|
|
517
462
|
}
|
|
518
|
-
p.log.info(
|
|
519
|
-
|
|
520
|
-
|
|
463
|
+
p.log.info(
|
|
464
|
+
`${c.bold(`Backups for ${envName}`)} (${backups.length} base backup(s), newest first):`,
|
|
465
|
+
);
|
|
466
|
+
for (const b of backups) {
|
|
467
|
+
p.log.message(` ${formatInstant(b.time).padEnd(22)} ${c.dim(b.name)}`);
|
|
521
468
|
}
|
|
522
469
|
}
|
|
523
470
|
|
|
471
|
+
// ISO-8601 datetime for point-in-time recovery. MUST stay in sync with
|
|
472
|
+
// composeRestoreScript's ISO_DATETIME_RE (compose/index.js) and the k8s
|
|
473
|
+
// walg-restore init container, which reject any other target format.
|
|
474
|
+
const RESTORE_PITR_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
|
|
475
|
+
|
|
524
476
|
async function pickInteractiveSource({
|
|
525
477
|
isCompose,
|
|
526
478
|
envName,
|
|
527
479
|
projectName,
|
|
528
480
|
serverIp,
|
|
529
481
|
sshKeyPath,
|
|
530
|
-
s3,
|
|
531
|
-
useS3,
|
|
532
482
|
yes,
|
|
533
483
|
}) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
544
|
-
process.exit(1);
|
|
545
|
-
}
|
|
546
|
-
if (yes) {
|
|
547
|
-
const latest = backups[0].name;
|
|
548
|
-
p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
|
|
549
|
-
return { kind: 's3', name: latest };
|
|
550
|
-
}
|
|
551
|
-
const selected = await p.select({
|
|
552
|
-
message: 'Select backup to restore:',
|
|
553
|
-
options: backups.map((b) => ({
|
|
554
|
-
value: b.name,
|
|
555
|
-
label: b.name,
|
|
556
|
-
hint: `${b.size} — ${b.date}`,
|
|
557
|
-
})),
|
|
558
|
-
});
|
|
559
|
-
if (p.isCancel(selected)) {
|
|
560
|
-
p.cancel('Operation cancelled.');
|
|
561
|
-
process.exit(0);
|
|
562
|
-
}
|
|
563
|
-
return { kind: 's3', name: /** @type {string} */ (selected) };
|
|
484
|
+
// Restore is wal-g-native for both compose and k8s: it ALWAYS fetches the
|
|
485
|
+
// LATEST base backup and replays WAL — either to the present ("latest") or to
|
|
486
|
+
// a point-in-time target. There is no "restore a specific older base backup",
|
|
487
|
+
// so this is a restore-POINT chooser, not a backup-file picker. (The legacy
|
|
488
|
+
// backups/*_full.tar.gz S3 objects are NOT wal-g backups — composeRestoreScript
|
|
489
|
+
// and the k8s walg-restore init container reject any target that isn't
|
|
490
|
+
// "latest" or an ISO-8601 datetime.)
|
|
491
|
+
if (yes) {
|
|
492
|
+
return { kind: 's3', name: 'latest' };
|
|
564
493
|
}
|
|
565
494
|
|
|
566
|
-
//
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
495
|
+
// Best-effort recovery-window context so point-in-time is meaningful. Never
|
|
496
|
+
// blocks the chooser: if wal-g listing fails (db down, no --json), omit it.
|
|
497
|
+
const tracker = createTracker('restore.window', { environment: envName });
|
|
498
|
+
const s = tracker.spinner();
|
|
499
|
+
s.start('Reading available backups');
|
|
500
|
+
const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
|
|
501
|
+
if (backups.length > 0) {
|
|
502
|
+
const newest = formatInstant(backups[0].time);
|
|
503
|
+
const oldest = formatInstant(backups[backups.length - 1].time);
|
|
504
|
+
s.stop(
|
|
505
|
+
backups.length === 1
|
|
506
|
+
? `1 base backup available (${newest})`
|
|
507
|
+
: `${backups.length} base backups — recovery window ${oldest} → ${newest}`,
|
|
573
508
|
);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
p.log.error('No backups found on the server.');
|
|
577
|
-
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
578
|
-
process.exit(1);
|
|
579
|
-
}
|
|
580
|
-
const names = output
|
|
581
|
-
.trim()
|
|
582
|
-
.split('\n')
|
|
583
|
-
.filter(Boolean)
|
|
584
|
-
.map((f) => basename(f));
|
|
585
|
-
if (yes) {
|
|
586
|
-
const latest = names[0];
|
|
587
|
-
p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
|
|
588
|
-
return { kind: 's3', name: latest };
|
|
589
|
-
}
|
|
590
|
-
const selected = await p.select({
|
|
591
|
-
message: 'Select backup to restore:',
|
|
592
|
-
options: names.map((b) => ({ value: b, label: b })),
|
|
593
|
-
});
|
|
594
|
-
if (p.isCancel(selected)) {
|
|
595
|
-
p.cancel('Operation cancelled.');
|
|
596
|
-
process.exit(0);
|
|
597
|
-
}
|
|
598
|
-
return { kind: 's3', name: /** @type {string} */ (selected) };
|
|
509
|
+
} else {
|
|
510
|
+
s.stop('Backups read');
|
|
599
511
|
}
|
|
600
512
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
513
|
+
const choice = await p.select({
|
|
514
|
+
message: 'Restore point:',
|
|
515
|
+
options: [
|
|
516
|
+
{ value: 'latest', label: 'Latest', hint: 'most recent backup + all WAL (recommended)' },
|
|
517
|
+
{ value: 'pitr', label: 'Point in time…', hint: 'recover to a specific timestamp' },
|
|
518
|
+
],
|
|
519
|
+
});
|
|
520
|
+
if (p.isCancel(choice)) {
|
|
521
|
+
p.cancel('Operation cancelled.');
|
|
522
|
+
process.exit(0);
|
|
609
523
|
}
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
|
|
613
|
-
return { kind: 's3', name: latest };
|
|
524
|
+
if (choice === 'latest') {
|
|
525
|
+
return { kind: 's3', name: 'latest' };
|
|
614
526
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
527
|
+
|
|
528
|
+
// Point-in-time: wal-g fetches the latest base backup and replays WAL up to
|
|
529
|
+
// this instant. Must match the ISO-8601 target validation in
|
|
530
|
+
// composeRestoreScript / the k8s walg-restore init container.
|
|
531
|
+
const ts = await p.text({
|
|
532
|
+
message: 'Recover to (ISO-8601 timestamp):',
|
|
533
|
+
placeholder: '2026-06-22T14:30:00Z',
|
|
534
|
+
validate: (v) =>
|
|
535
|
+
RESTORE_PITR_RE.test((v || '').trim())
|
|
536
|
+
? undefined
|
|
537
|
+
: 'Enter an ISO-8601 datetime, e.g. 2026-06-22T14:30:00Z',
|
|
618
538
|
});
|
|
619
|
-
if (p.isCancel(
|
|
539
|
+
if (p.isCancel(ts)) {
|
|
620
540
|
p.cancel('Operation cancelled.');
|
|
621
541
|
process.exit(0);
|
|
622
542
|
}
|
|
623
|
-
return { kind: 's3', name: /** @type {string} */ (
|
|
543
|
+
return { kind: 's3', name: /** @type {string} */ (ts).trim() };
|
|
624
544
|
}
|
|
625
545
|
|
|
626
546
|
// compose restore is wal-g-based (S3 pull, no file transfer). Mirrors
|
|
@@ -712,8 +632,8 @@ export async function runComposeRestore({
|
|
|
712
632
|
const DB_STATEFULSET = 'supabase-supabase-db';
|
|
713
633
|
|
|
714
634
|
/** Set or clear the RESTORE_TARGET marker the init container reads. */
|
|
715
|
-
function setRestoreMarker(ip, sshKeyPath, value) {
|
|
716
|
-
sshKubectl(ip, sshKeyPath, [
|
|
635
|
+
async function setRestoreMarker(ip, sshKeyPath, value) {
|
|
636
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
717
637
|
'patch',
|
|
718
638
|
'secret',
|
|
719
639
|
'vibecarbon-secrets',
|
|
@@ -738,7 +658,7 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
|
|
|
738
658
|
|
|
739
659
|
s.start('Restoring database via wal-g (app will be temporarily unavailable)');
|
|
740
660
|
// 1. Mark the restore so the db pod's init container fetches on next boot.
|
|
741
|
-
setRestoreMarker(serverIp, sshKeyPath, target);
|
|
661
|
+
await setRestoreMarker(serverIp, sshKeyPath, target);
|
|
742
662
|
// 2. Quiesce the app so it isn't querying mid-swap.
|
|
743
663
|
await perfAsync('restore.scaleDownApp', async () => scaleDownApp(serverIp, sshKeyPath));
|
|
744
664
|
try {
|
|
@@ -746,14 +666,14 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
|
|
|
746
666
|
// and runs `wal-g backup-fetch` before postgres starts.
|
|
747
667
|
s.message('Fetching from S3 + recovering (wal-g)');
|
|
748
668
|
await perfAsync('restore.walgFetch', async () => {
|
|
749
|
-
sshKubectl(serverIp, sshKeyPath, [
|
|
669
|
+
await sshKubectl(serverIp, sshKeyPath, [
|
|
750
670
|
'rollout',
|
|
751
671
|
'restart',
|
|
752
672
|
`statefulset/${DB_STATEFULSET}`,
|
|
753
673
|
'-n',
|
|
754
674
|
'vibecarbon',
|
|
755
675
|
]);
|
|
756
|
-
sshKubectl(serverIp, sshKeyPath, [
|
|
676
|
+
await sshKubectl(serverIp, sshKeyPath, [
|
|
757
677
|
'rollout',
|
|
758
678
|
'status',
|
|
759
679
|
`statefulset/${DB_STATEFULSET}`,
|
|
@@ -767,7 +687,7 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
|
|
|
767
687
|
} finally {
|
|
768
688
|
// 5. Always clear the marker so an unrelated future pod restart does NOT
|
|
769
689
|
// re-fetch and wipe live data. Runs even if the restore failed.
|
|
770
|
-
setRestoreMarker(serverIp, sshKeyPath, '');
|
|
690
|
+
await setRestoreMarker(serverIp, sshKeyPath, '');
|
|
771
691
|
}
|
|
772
692
|
// 6. Bring the app back.
|
|
773
693
|
await perfAsync('restore.scaleUpApp', async () => scaleUpApp(serverIp, sshKeyPath));
|
package/src/scale.js
CHANGED
|
@@ -23,8 +23,8 @@ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
|
23
23
|
import { c, printBanner } from './lib/colors.js';
|
|
24
24
|
import { runCommand } from './lib/command.js';
|
|
25
25
|
import { loadCredentials, saveProjectConfig } from './lib/config.js';
|
|
26
|
-
import { buildCostBreakdown, formatCostLines } from './lib/cost.js';
|
|
27
26
|
import { useDnsChallenge } from './lib/deploy/acme.js';
|
|
27
|
+
import { collectComposeBuildArgs } from './lib/deploy/compose/build-args.js';
|
|
28
28
|
import {
|
|
29
29
|
backupCompose,
|
|
30
30
|
dockerLoginOnServer,
|
|
@@ -45,7 +45,7 @@ import { requireLicense } from './lib/licensing/index.js';
|
|
|
45
45
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
46
46
|
import { perfAsync } from './lib/perf.js';
|
|
47
47
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
48
|
-
import { HetznerProvider } from './lib/providers/hetzner.js';
|
|
48
|
+
import { HETZNER_PRICING_URL, HetznerProvider } from './lib/providers/hetzner.js';
|
|
49
49
|
import { buildComposeTypeOptions, buildSimpleTypeOptions } from './lib/server-types.js';
|
|
50
50
|
import { parseDotenv } from './lib/shell.js';
|
|
51
51
|
import { sshRun, sshRunScript } from './lib/ssh.js';
|
|
@@ -149,8 +149,11 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
149
149
|
process.exit(1);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// Fetch live server types
|
|
152
|
+
// Fetch live server types (drives the per-region type/profile options below)
|
|
153
|
+
const typesSpinner = p.spinner();
|
|
154
|
+
typesSpinner.start('Fetching available server types...');
|
|
153
155
|
await HetznerProvider.fetchServerTypes(apiToken);
|
|
156
|
+
typesSpinner.stop('Server types loaded');
|
|
154
157
|
|
|
155
158
|
const region = envConfig.region || 'fsn1';
|
|
156
159
|
const currentType = envConfig.serverType || 'cpx21';
|
|
@@ -230,17 +233,9 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
230
233
|
return;
|
|
231
234
|
}
|
|
232
235
|
|
|
233
|
-
// Cost comparison
|
|
234
236
|
const ha = envConfig.deployMode === 'compose-ha';
|
|
235
237
|
const services = projectConfig.services || {};
|
|
236
|
-
|
|
237
|
-
isComposeDeploy: true,
|
|
238
|
-
ha,
|
|
239
|
-
serverType: newType,
|
|
240
|
-
region,
|
|
241
|
-
services,
|
|
242
|
-
});
|
|
243
|
-
p.note(formatCostLines(costBreakdown), 'New Estimated Cost');
|
|
238
|
+
p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
|
|
244
239
|
|
|
245
240
|
// Confirmation
|
|
246
241
|
const serverLabel =
|
|
@@ -280,7 +275,10 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
280
275
|
// name, so the `name` we pass is mostly cosmetic — keep it aligned with
|
|
281
276
|
// the rest of the codebase.
|
|
282
277
|
const sshKeyName = `${projectName}-${environment}-key`;
|
|
278
|
+
const prep = p.spinner();
|
|
279
|
+
prep.start('Registering SSH key with Hetzner...');
|
|
283
280
|
const sshKeyId = await provider.createSSHKey(sshKeyName, sshPubKey);
|
|
281
|
+
prep.stop('SSH key ready');
|
|
284
282
|
|
|
285
283
|
// Blue-green replacement for each target server. For compose-ha we run
|
|
286
284
|
// primary + standby in parallel — every per-server step (backup, create,
|
|
@@ -371,7 +369,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
371
369
|
// other keys flow through transparently.
|
|
372
370
|
let oldEnv = {};
|
|
373
371
|
try {
|
|
374
|
-
const envText = sshRun(server.ip, sshKeyPath, ['cat', `/opt/${projectName}/.env`]);
|
|
372
|
+
const envText = await sshRun(server.ip, sshKeyPath, ['cat', `/opt/${projectName}/.env`]);
|
|
375
373
|
oldEnv = parseDotenv(envText);
|
|
376
374
|
} catch {
|
|
377
375
|
// Old server may be unreachable; renderBundle will fall back to the
|
|
@@ -454,8 +452,14 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
454
452
|
// Reuses the same code that does direct-mode deploy builds.
|
|
455
453
|
if (oldAppImage && isLocalOnlyImageTag(oldAppImage)) {
|
|
456
454
|
s.start(`Building image ${oldAppImage} on new server...`);
|
|
455
|
+
// Pass the VITE_* build args (same as deploy/HA). Vite inlines
|
|
456
|
+
// import.meta.env.VITE_* at build time, so without these the new
|
|
457
|
+
// server's frontend bundle ships empty VITE_SUPABASE_URL/ANON_KEY and
|
|
458
|
+
// the browser throws "Missing Supabase environment variables". Deploy
|
|
459
|
+
// (orchestrator.js) and HA (ha.js) already do this — scale did not.
|
|
460
|
+
const scaleBuildArgs = collectComposeBuildArgs(process.cwd(), { projectName, domain });
|
|
457
461
|
const built = await perfAsync('scale.sideloadImage', () =>
|
|
458
|
-
buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd()),
|
|
462
|
+
buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd(), scaleBuildArgs),
|
|
459
463
|
);
|
|
460
464
|
// buildRemote returns false on failure (after its own retries). The app
|
|
461
465
|
// image is local-only, so if it isn't built on the new server, the
|
|
@@ -534,7 +538,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
534
538
|
// would force a needless re-issue), so skip it.
|
|
535
539
|
// Uses an SCP'd script so quoting/redirection stay intact without local shell.
|
|
536
540
|
if (!dnsChallenge) {
|
|
537
|
-
sshRunScript(
|
|
541
|
+
await sshRunScript(
|
|
538
542
|
newIp,
|
|
539
543
|
sshKeyPath,
|
|
540
544
|
`cd ${remoteDir} && docker compose exec -T traefik sh -c 'echo "{}" > /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json'`,
|
|
@@ -568,7 +572,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
568
572
|
const replOverlayFlag =
|
|
569
573
|
"$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
|
|
570
574
|
s.start('Recreating all services after restore...');
|
|
571
|
-
sshRunScript(
|
|
575
|
+
await sshRunScript(
|
|
572
576
|
newIp,
|
|
573
577
|
sshKeyPath,
|
|
574
578
|
`cd ${remoteDir} && docker compose ${composeFlags} ${replOverlayFlag} up -d --force-recreate 2>&1`,
|
|
@@ -580,7 +584,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
580
584
|
// the db container's env (rendered into .env at deploy), so the cron
|
|
581
585
|
// just runs compose-backup.sh on schedule — no separate S3 plumbing.
|
|
582
586
|
if (envConfig.backup) {
|
|
583
|
-
setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
|
|
587
|
+
await setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
|
|
584
588
|
p.log.info('Backup cron restored on new server');
|
|
585
589
|
}
|
|
586
590
|
|
|
@@ -1001,19 +1005,8 @@ export async function run(args) {
|
|
|
1001
1005
|
(c) => c === 'workerType' || c === 'masterType' || c === 'supabaseType',
|
|
1002
1006
|
);
|
|
1003
1007
|
|
|
1004
|
-
// Cost breakdown with new values applied
|
|
1005
|
-
const services = projectConfig.services || {};
|
|
1006
1008
|
const isHA = envConfig.ha?.enabled && envConfig.secondaryRegion;
|
|
1007
|
-
|
|
1008
|
-
isComposeDeploy: false,
|
|
1009
|
-
ha: !!isHA,
|
|
1010
|
-
masterServerType: newValues.masterType || currentMasterType,
|
|
1011
|
-
supabaseServerType: newValues.supabaseType || currentSupabaseType,
|
|
1012
|
-
workerServerType: newValues.workerType || currentWorkerType,
|
|
1013
|
-
region,
|
|
1014
|
-
services,
|
|
1015
|
-
});
|
|
1016
|
-
p.note(formatCostLines(costBreakdown), 'New Estimated Cost');
|
|
1009
|
+
p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
|
|
1017
1010
|
|
|
1018
1011
|
// 6. Require API token if infra changes (server type swap) needed
|
|
1019
1012
|
let apiToken = null;
|
|
@@ -1205,6 +1198,8 @@ export async function run(args) {
|
|
|
1205
1198
|
// unit tests for this seam (see tests/unit/iac/probe-classify.test.ts).
|
|
1206
1199
|
let probeOutputs = null;
|
|
1207
1200
|
let probeError = null;
|
|
1201
|
+
const probeSpinner = p.spinner();
|
|
1202
|
+
probeSpinner.start(`Reading prior cluster state (${label})...`);
|
|
1208
1203
|
try {
|
|
1209
1204
|
const probeProgram = buildHetznerK8sProgram(programConfig);
|
|
1210
1205
|
probeOutputs = await perfAsync(`scale.k8s.${clusterEnv}.probeOutputs`, () =>
|
|
@@ -1216,6 +1211,7 @@ export async function run(args) {
|
|
|
1216
1211
|
} catch (err) {
|
|
1217
1212
|
probeError = err instanceof Error ? err : new Error(String(err));
|
|
1218
1213
|
}
|
|
1214
|
+
probeSpinner.stop(`Prior cluster state read (${label})`);
|
|
1219
1215
|
const probe = classifyK3sTokenProbe({ outputs: probeOutputs, error: probeError });
|
|
1220
1216
|
const priorK3sToken = probe.priorK3sToken;
|
|
1221
1217
|
switch (probe.status) {
|