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/create.js
CHANGED
|
@@ -1245,6 +1245,9 @@ ADMIN_PASSWORD=${escapeDotenv(variables.ADMIN_PASSWORD)}
|
|
|
1245
1245
|
|
|
1246
1246
|
# Site URL
|
|
1247
1247
|
SITE_URL="http://localhost:5173"
|
|
1248
|
+
# Public canonical URL baked into the client (og: tags, sitemap). Overridden at
|
|
1249
|
+
# deploy with the real apex domain; localhost is correct for local dev.
|
|
1250
|
+
VITE_PUBLIC_URL="http://localhost:5173"
|
|
1248
1251
|
|
|
1249
1252
|
# =============================================================================
|
|
1250
1253
|
# OAUTH PROVIDERS (optional)
|
|
@@ -1269,10 +1272,10 @@ STRIPE_WEBHOOK_SECRET=""
|
|
|
1269
1272
|
|
|
1270
1273
|
# SMTP Email (shared by Supabase Auth and app transactional emails)
|
|
1271
1274
|
SMTP_HOST=""
|
|
1272
|
-
SMTP_PORT=
|
|
1275
|
+
SMTP_PORT=587
|
|
1273
1276
|
SMTP_USER=""
|
|
1274
1277
|
SMTP_PASS=""
|
|
1275
|
-
SMTP_ADMIN_EMAIL="
|
|
1278
|
+
SMTP_ADMIN_EMAIL=""
|
|
1276
1279
|
SMTP_SENDER_NAME="${variables.PROJECT_NAME}"
|
|
1277
1280
|
|
|
1278
1281
|
# =============================================================================
|
package/src/deploy.js
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
setupHA as setupHetznerDnsHA,
|
|
51
51
|
setupSimple as setupHetznerDnsSimple,
|
|
52
52
|
} from './lib/hetzner-dns.js';
|
|
53
|
+
import { requireLicense } from './lib/licensing/index.js';
|
|
53
54
|
import { detectPackageManager } from './lib/project.js';
|
|
54
55
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
55
56
|
import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
|
|
@@ -101,6 +102,11 @@ const SPEC = {
|
|
|
101
102
|
value: '<id>',
|
|
102
103
|
description: 'Primary Hetzner region (e.g. hel1, nbg1, fsn1)',
|
|
103
104
|
},
|
|
105
|
+
{
|
|
106
|
+
name: 'standby-region',
|
|
107
|
+
value: '<id>',
|
|
108
|
+
description: 'HA standby/failover region (defaults to a same-continent partner of -region)',
|
|
109
|
+
},
|
|
104
110
|
{
|
|
105
111
|
name: 'mode',
|
|
106
112
|
value: '<mode>',
|
|
@@ -155,11 +161,13 @@ function buildLegacyArgs(values, positional) {
|
|
|
155
161
|
compose: mode === 'compose' || mode === 'compose-ha',
|
|
156
162
|
k8s: mode === 'k8s' || mode === 'k8s-ha',
|
|
157
163
|
ha: mode === 'compose-ha' || mode === 'k8s-ha',
|
|
164
|
+
// HA standby region — settable via -standby-region, else the interactive
|
|
165
|
+
// prompt / saved config / same-continent default fill it in.
|
|
166
|
+
secondaryRegion: values['standby-region'] || null,
|
|
158
167
|
// Everything below is no longer settable via CLI flag; the
|
|
159
168
|
// interactive prompt + `.vibecarbon.json` cover these. Defaults
|
|
160
169
|
// keep gatherDeploymentConfig's `args.X || envConfig.X || …`
|
|
161
170
|
// chains working unchanged.
|
|
162
|
-
secondaryRegion: null,
|
|
163
171
|
serverType: null,
|
|
164
172
|
masterServerType: null,
|
|
165
173
|
workerServerType: null,
|
|
@@ -207,14 +215,23 @@ async function main() {
|
|
|
207
215
|
process.exit(0);
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
// 0a. Confirm we're inside a Vibecarbon project before
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
218
|
+
// 0a. Confirm we're inside a Vibecarbon project before anything else.
|
|
219
|
+
// assertInProjectDir() is the documented FIRST action for every
|
|
220
|
+
// project-scoped command (see lib/project-guard.js): an accidental
|
|
221
|
+
// `vibecarbon deploy` from a parent directory (e.g. ~/repos) must print
|
|
222
|
+
// the canonical "not in a project" message and exit non-zero — it must not
|
|
223
|
+
// fall through to the license upsell or a secret-scan that walks sibling
|
|
224
|
+
// repos. This matches scale/backup/restore/failover, which all assert
|
|
225
|
+
// first; deploy previously gated the license first and so exited 0 (the
|
|
226
|
+
// upsell path) outside a project, tripping the not-in-project contract.
|
|
215
227
|
assertInProjectDir();
|
|
216
228
|
|
|
217
|
-
// 0b.
|
|
229
|
+
// 0b. Deploying to production requires a paid license. Graphite (free)
|
|
230
|
+
// covers local create/up/test; deploy/scale/backup/etc. need Diamond+.
|
|
231
|
+
// Gate after the project check — help/version already returned above.
|
|
232
|
+
requireLicense('deploy');
|
|
233
|
+
|
|
234
|
+
// 0c. Refuse to deploy if the working tree contains likely secrets.
|
|
218
235
|
// We push the project's tracked files to GitHub during deploy (CI,
|
|
219
236
|
// GitOps, throwaway e2e repos), and a leak there is permanent
|
|
220
237
|
// — GitHub's secret-scanner will email the user even if the repo is
|
package/src/destroy.js
CHANGED
|
@@ -837,7 +837,12 @@ async function main() {
|
|
|
837
837
|
|
|
838
838
|
if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
|
|
839
839
|
// Check for orphan Pulumi stacks (deployment interrupted before config save)
|
|
840
|
+
const orphanScan = p.spinner();
|
|
841
|
+
orphanScan.start('Scanning for orphan Pulumi stacks...');
|
|
840
842
|
const orphans = await findOrphanPulumiStacks(projectConfig);
|
|
843
|
+
orphanScan.stop(
|
|
844
|
+
orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
|
|
845
|
+
);
|
|
841
846
|
|
|
842
847
|
if (orphans.length > 0) {
|
|
843
848
|
p.log.warn('No environments found in .vibecarbon.json');
|
|
@@ -906,15 +911,17 @@ async function main() {
|
|
|
906
911
|
process.exit(1);
|
|
907
912
|
}
|
|
908
913
|
|
|
914
|
+
const orphanSpinner = p.spinner();
|
|
909
915
|
for (const orphan of orphans) {
|
|
916
|
+
orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
|
|
910
917
|
try {
|
|
911
918
|
await destroyOrphanPulumiStack(orphan, {
|
|
912
919
|
apiToken: orphanApiToken,
|
|
913
920
|
s3Config: orphanS3Config,
|
|
914
921
|
});
|
|
915
|
-
|
|
922
|
+
orphanSpinner.stop(`Destroyed: ${orphan.name}`);
|
|
916
923
|
} catch (error) {
|
|
917
|
-
|
|
924
|
+
orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
|
|
918
925
|
}
|
|
919
926
|
}
|
|
920
927
|
|
|
@@ -1056,7 +1063,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1056
1063
|
try {
|
|
1057
1064
|
if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
|
|
1058
1065
|
const { backupCompose } = await import('./lib/deploy/compose/index.js');
|
|
1059
|
-
backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
1066
|
+
await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
1060
1067
|
retain: envConfig.backup?.retentionDays,
|
|
1061
1068
|
});
|
|
1062
1069
|
backupSpinner.stop('wal-g base backup pushed to S3');
|
|
@@ -1215,7 +1222,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1215
1222
|
s.start('Stopping Docker Compose services...');
|
|
1216
1223
|
try {
|
|
1217
1224
|
const { destroyCompose } = await import('./lib/deploy/compose/index.js');
|
|
1218
|
-
destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1225
|
+
await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1219
1226
|
s.stop('Docker Compose services removed');
|
|
1220
1227
|
results.servers.push(`vps-${serverIp}`);
|
|
1221
1228
|
} catch (error) {
|
package/src/failover.js
CHANGED
|
@@ -18,7 +18,6 @@ import { selectEnvironment } from './lib/cli/select-environment.js';
|
|
|
18
18
|
import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
19
19
|
import { createDNSRecord } from './lib/cloudflare.js';
|
|
20
20
|
import { c, printBanner } from './lib/colors.js';
|
|
21
|
-
import { runCommand } from './lib/command.js';
|
|
22
21
|
import { loadCredentials, loadProjectConfig } from './lib/config.js';
|
|
23
22
|
import {
|
|
24
23
|
createDNSRecord as hetznerDnsCreateRecord,
|
|
@@ -150,10 +149,10 @@ export function identifyServers(envName, envConfig, _projectConfig) {
|
|
|
150
149
|
* whether to attempt a pg_basebackup re-seed before promoting the standby.
|
|
151
150
|
* Primary-dead failovers skip the re-seed (can't; best-effort promote).
|
|
152
151
|
*/
|
|
153
|
-
function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
|
|
152
|
+
async function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
|
|
154
153
|
try {
|
|
155
|
-
const pod = getPostgresPod(primaryIp, sshKeyPath);
|
|
156
|
-
const result = sshKubectl(primaryIp, sshKeyPath, [
|
|
154
|
+
const pod = await getPostgresPod(primaryIp, sshKeyPath);
|
|
155
|
+
const result = await sshKubectl(primaryIp, sshKeyPath, [
|
|
157
156
|
'exec',
|
|
158
157
|
'-n',
|
|
159
158
|
'vibecarbon',
|
|
@@ -184,31 +183,31 @@ function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
|
|
|
184
183
|
* true if re-seed succeeded, false if primary was unreachable or re-seed
|
|
185
184
|
* itself failed (caller proceeds to promote with existing state).
|
|
186
185
|
*/
|
|
187
|
-
function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
|
|
186
|
+
async function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
|
|
188
187
|
if (!primarySupabaseIp) return false;
|
|
189
188
|
try {
|
|
190
189
|
// REPL_PASSWORD is written to both primary and standby .env.local at
|
|
191
190
|
// create time; read it from the standby's filesystem (easier than
|
|
192
191
|
// threading through the failover args).
|
|
193
|
-
const replPassword = sshRun(standbyIp, sshKeyPath, [
|
|
192
|
+
const replPassword = await sshRun(standbyIp, sshKeyPath, [
|
|
194
193
|
'bash',
|
|
195
194
|
'-c',
|
|
196
195
|
'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
|
|
197
196
|
]);
|
|
198
197
|
if (!replPassword?.trim()) return false;
|
|
199
198
|
|
|
200
|
-
const pod = getPostgresPod(standbyIp, sshKeyPath);
|
|
199
|
+
const pod = await getPostgresPod(standbyIp, sshKeyPath);
|
|
201
200
|
const script = [
|
|
202
201
|
`rm -rf /var/lib/postgresql/data/*`,
|
|
203
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`,
|
|
204
203
|
`echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
|
|
205
204
|
`chown -R postgres:postgres /var/lib/postgresql/data`,
|
|
206
205
|
].join('\n');
|
|
207
|
-
sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
|
|
206
|
+
await sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
|
|
208
207
|
input: script,
|
|
209
208
|
});
|
|
210
209
|
// Restart the pod to pick up the new PGDATA + recovery config.
|
|
211
|
-
sshKubectl(standbyIp, sshKeyPath, [
|
|
210
|
+
await sshKubectl(standbyIp, sshKeyPath, [
|
|
212
211
|
'exec',
|
|
213
212
|
'-n',
|
|
214
213
|
'vibecarbon',
|
|
@@ -225,22 +224,22 @@ function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
|
|
|
225
224
|
}
|
|
226
225
|
}
|
|
227
226
|
|
|
228
|
-
function promoteStandbyDatabase(standbyIp, sshKeyPath) {
|
|
227
|
+
async function promoteStandbyDatabase(standbyIp, sshKeyPath) {
|
|
229
228
|
const _t = perfTimer('failover.promoteStandby');
|
|
230
229
|
try {
|
|
231
|
-
return _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
|
|
230
|
+
return await _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
|
|
232
231
|
} finally {
|
|
233
232
|
_t.end();
|
|
234
233
|
}
|
|
235
234
|
}
|
|
236
235
|
|
|
237
|
-
function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
|
|
236
|
+
async function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
|
|
238
237
|
// Get the postgres pod name via a separate SSH call (avoids local shell expansion of $())
|
|
239
|
-
const pod = getPostgresPod(standbyIp, sshKeyPath);
|
|
238
|
+
const pod = await getPostgresPod(standbyIp, sshKeyPath);
|
|
240
239
|
|
|
241
240
|
// Promote PostgreSQL on standby
|
|
242
241
|
try {
|
|
243
|
-
sshKubectl(standbyIp, sshKeyPath, [
|
|
242
|
+
await sshKubectl(standbyIp, sshKeyPath, [
|
|
244
243
|
'exec',
|
|
245
244
|
'-n',
|
|
246
245
|
'vibecarbon',
|
|
@@ -258,7 +257,7 @@ function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
|
|
|
258
257
|
// Poll until standby is no longer in recovery
|
|
259
258
|
for (let i = 0; i < 30; i++) {
|
|
260
259
|
try {
|
|
261
|
-
const result = sshKubectl(standbyIp, sshKeyPath, [
|
|
260
|
+
const result = await sshKubectl(standbyIp, sshKeyPath, [
|
|
262
261
|
'exec',
|
|
263
262
|
'-n',
|
|
264
263
|
'vibecarbon',
|
|
@@ -274,16 +273,16 @@ function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
|
|
|
274
273
|
} catch {
|
|
275
274
|
// Retry
|
|
276
275
|
}
|
|
277
|
-
|
|
276
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
278
277
|
}
|
|
279
278
|
|
|
280
279
|
return false;
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
function scaleUpServices(ip, sshKeyPath) {
|
|
282
|
+
async function scaleUpServices(ip, sshKeyPath) {
|
|
284
283
|
const _t = perfTimer('failover.scaleUpServices');
|
|
285
284
|
try {
|
|
286
|
-
return _scaleUpServicesImpl(ip, sshKeyPath);
|
|
285
|
+
return await _scaleUpServicesImpl(ip, sshKeyPath);
|
|
287
286
|
} finally {
|
|
288
287
|
_t.end();
|
|
289
288
|
}
|
|
@@ -317,10 +316,10 @@ const FAILOVER_DEPLOYMENTS = [
|
|
|
317
316
|
* the whole failover. Reports each result so the operator can see what
|
|
318
317
|
* actually scaled.
|
|
319
318
|
*/
|
|
320
|
-
function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
|
|
319
|
+
async function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
|
|
321
320
|
for (const dep of deployments) {
|
|
322
321
|
try {
|
|
323
|
-
sshKubectl(ip, sshKeyPath, [
|
|
322
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
324
323
|
'scale',
|
|
325
324
|
'deployment',
|
|
326
325
|
dep.name,
|
|
@@ -342,7 +341,7 @@ function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
|
|
|
342
341
|
}
|
|
343
342
|
}
|
|
344
343
|
|
|
345
|
-
function _scaleUpServicesImpl(ip, sshKeyPath) {
|
|
344
|
+
async function _scaleUpServicesImpl(ip, sshKeyPath) {
|
|
346
345
|
// Group by upReplicas so we don't touch the same deployment twice.
|
|
347
346
|
const byReplicas = new Map();
|
|
348
347
|
for (const dep of FAILOVER_DEPLOYMENTS) {
|
|
@@ -350,20 +349,26 @@ function _scaleUpServicesImpl(ip, sshKeyPath) {
|
|
|
350
349
|
byReplicas.get(dep.upReplicas).push(dep);
|
|
351
350
|
}
|
|
352
351
|
for (const [replicas, deps] of byReplicas.entries()) {
|
|
353
|
-
scaleDeployments(
|
|
352
|
+
await scaleDeployments(
|
|
353
|
+
ip,
|
|
354
|
+
sshKeyPath,
|
|
355
|
+
deps,
|
|
356
|
+
replicas,
|
|
357
|
+
`scale-up standby (replicas=${replicas})`,
|
|
358
|
+
);
|
|
354
359
|
}
|
|
355
360
|
}
|
|
356
361
|
|
|
357
|
-
function waitForHealth(ip, sshKeyPath) {
|
|
362
|
+
async function waitForHealth(ip, sshKeyPath) {
|
|
358
363
|
const _t = perfTimer('failover.waitForHealth');
|
|
359
364
|
try {
|
|
360
|
-
return _waitForHealthImpl(ip, sshKeyPath);
|
|
365
|
+
return await _waitForHealthImpl(ip, sshKeyPath);
|
|
361
366
|
} finally {
|
|
362
367
|
_t.end();
|
|
363
368
|
}
|
|
364
369
|
}
|
|
365
370
|
|
|
366
|
-
function _waitForHealthImpl(ip, sshKeyPath) {
|
|
371
|
+
async function _waitForHealthImpl(ip, sshKeyPath) {
|
|
367
372
|
// The previous implementation curled http://localhost:3000/api/health from
|
|
368
373
|
// the standby master — but K8s app pods don't run on the master's host
|
|
369
374
|
// network (no hostPort:3000), so localhost:3000 has nothing listening and
|
|
@@ -373,7 +378,7 @@ function _waitForHealthImpl(ip, sshKeyPath) {
|
|
|
373
378
|
// surfaces the real failure (ImagePullBackOff, CrashLoop, missing secret)
|
|
374
379
|
// in the kubectl error rather than a generic timeout.
|
|
375
380
|
try {
|
|
376
|
-
sshKubectl(ip, sshKeyPath, [
|
|
381
|
+
await sshKubectl(ip, sshKeyPath, [
|
|
377
382
|
'rollout',
|
|
378
383
|
'status',
|
|
379
384
|
'deployment/app',
|
|
@@ -470,9 +475,9 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
|
|
|
470
475
|
// disaster failover) — promotion proceeds with whatever state standby
|
|
471
476
|
// has. Requires primarySupabaseIp from identifyServers (persisted in
|
|
472
477
|
// envConfig.servers by orchestrator.js post-deploy).
|
|
473
|
-
if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
478
|
+
if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
474
479
|
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
475
|
-
const reseeded = reseedStandbyFromPrimary(
|
|
480
|
+
const reseeded = await reseedStandbyFromPrimary(
|
|
476
481
|
servers.standby.ip,
|
|
477
482
|
sshKeyPath,
|
|
478
483
|
servers.primary.supabaseIp,
|
|
@@ -484,7 +489,7 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
|
|
|
484
489
|
|
|
485
490
|
// Step 1: Promote standby database
|
|
486
491
|
s.start('Promoting standby database');
|
|
487
|
-
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
492
|
+
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
488
493
|
s.stop(
|
|
489
494
|
promoted
|
|
490
495
|
? 'Standby database promoted to primary'
|
|
@@ -499,13 +504,19 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
|
|
|
499
504
|
// scaleDeployments — don't abort the whole failover on one missing
|
|
500
505
|
// chart name.
|
|
501
506
|
s.start('Scaling down old primary services');
|
|
502
|
-
scaleDeployments(
|
|
507
|
+
await scaleDeployments(
|
|
508
|
+
servers.primary.ip,
|
|
509
|
+
sshKeyPath,
|
|
510
|
+
FAILOVER_DEPLOYMENTS,
|
|
511
|
+
0,
|
|
512
|
+
'scale-down primary',
|
|
513
|
+
);
|
|
503
514
|
s.stop('Old primary services scaled down');
|
|
504
515
|
|
|
505
516
|
// Step 3: Scale up standby services
|
|
506
517
|
s.start('Scaling up standby services');
|
|
507
518
|
try {
|
|
508
|
-
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
519
|
+
await scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
509
520
|
} catch (error) {
|
|
510
521
|
s.stop('Some services failed to scale');
|
|
511
522
|
p.log.warn(`Scale error: ${error.message}`);
|
|
@@ -514,7 +525,7 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
|
|
|
514
525
|
|
|
515
526
|
// Wait for health endpoint
|
|
516
527
|
s.start('Waiting for standby health check');
|
|
517
|
-
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
528
|
+
const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
|
|
518
529
|
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
519
530
|
|
|
520
531
|
// Step 4: Switch DNS A record to standby floating IP. proxied:false
|
|
@@ -627,9 +638,9 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
|
|
|
627
638
|
// disaster failover) — promotion proceeds with whatever state standby
|
|
628
639
|
// has. Requires primarySupabaseIp from identifyServers (persisted in
|
|
629
640
|
// envConfig.servers by orchestrator.js post-deploy).
|
|
630
|
-
if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
641
|
+
if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
631
642
|
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
632
|
-
const reseeded = reseedStandbyFromPrimary(
|
|
643
|
+
const reseeded = await reseedStandbyFromPrimary(
|
|
633
644
|
servers.standby.ip,
|
|
634
645
|
sshKeyPath,
|
|
635
646
|
servers.primary.supabaseIp,
|
|
@@ -641,7 +652,7 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
|
|
|
641
652
|
|
|
642
653
|
// Step 1: Promote standby database
|
|
643
654
|
s.start('Promoting standby database');
|
|
644
|
-
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
655
|
+
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
645
656
|
s.stop(
|
|
646
657
|
promoted
|
|
647
658
|
? 'Standby database promoted to primary'
|
|
@@ -655,13 +666,19 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
|
|
|
655
666
|
// See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
|
|
656
667
|
// `auth`/`rest`/etc. silently no-op'd in an earlier version.
|
|
657
668
|
s.start('Scaling down old primary services');
|
|
658
|
-
scaleDeployments(
|
|
669
|
+
await scaleDeployments(
|
|
670
|
+
servers.primary.ip,
|
|
671
|
+
sshKeyPath,
|
|
672
|
+
FAILOVER_DEPLOYMENTS,
|
|
673
|
+
0,
|
|
674
|
+
'scale-down primary',
|
|
675
|
+
);
|
|
659
676
|
s.stop('Old primary services scaled down');
|
|
660
677
|
|
|
661
678
|
// Step 3: Scale up standby services
|
|
662
679
|
s.start('Scaling up standby services');
|
|
663
680
|
try {
|
|
664
|
-
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
681
|
+
await scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
665
682
|
} catch (error) {
|
|
666
683
|
s.stop('Some services failed to scale');
|
|
667
684
|
p.log.warn(`Scale error: ${error.message}`);
|
|
@@ -670,7 +687,7 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
|
|
|
670
687
|
|
|
671
688
|
// Wait for health endpoint
|
|
672
689
|
s.start('Waiting for standby health check');
|
|
673
|
-
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
690
|
+
const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
|
|
674
691
|
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
675
692
|
|
|
676
693
|
// Step 4: Update DNS A record via Hetzner DNS API
|
|
@@ -756,13 +773,13 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
756
773
|
|
|
757
774
|
// Promote standby database
|
|
758
775
|
s.start('Promoting standby database');
|
|
759
|
-
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
776
|
+
const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
760
777
|
s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
|
|
761
778
|
|
|
762
779
|
// Scale down old primary services to prevent split-brain
|
|
763
780
|
s.start('Scaling down old primary services');
|
|
764
781
|
try {
|
|
765
|
-
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
782
|
+
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
766
783
|
'scale',
|
|
767
784
|
'deployment',
|
|
768
785
|
'app',
|
|
@@ -770,7 +787,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
770
787
|
'vibecarbon',
|
|
771
788
|
'--replicas=0',
|
|
772
789
|
]);
|
|
773
|
-
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
790
|
+
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
774
791
|
'scale',
|
|
775
792
|
'deployment',
|
|
776
793
|
'auth',
|
|
@@ -778,7 +795,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
778
795
|
'vibecarbon',
|
|
779
796
|
'--replicas=0',
|
|
780
797
|
]);
|
|
781
|
-
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
798
|
+
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
782
799
|
'scale',
|
|
783
800
|
'deployment',
|
|
784
801
|
'rest',
|
|
@@ -786,7 +803,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
786
803
|
'vibecarbon',
|
|
787
804
|
'--replicas=0',
|
|
788
805
|
]);
|
|
789
|
-
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
806
|
+
await sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
790
807
|
'scale',
|
|
791
808
|
'deployment',
|
|
792
809
|
'realtime',
|
|
@@ -802,7 +819,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
802
819
|
// Scale up services
|
|
803
820
|
s.start('Scaling up standby services');
|
|
804
821
|
try {
|
|
805
|
-
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
822
|
+
await scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
806
823
|
} catch (error) {
|
|
807
824
|
s.stop('Some services failed to scale');
|
|
808
825
|
p.log.warn(`Scale error: ${error.message}`);
|
|
@@ -811,7 +828,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
|
|
|
811
828
|
|
|
812
829
|
// Wait for health
|
|
813
830
|
s.start('Waiting for standby health check');
|
|
814
|
-
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
831
|
+
const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
|
|
815
832
|
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
816
833
|
|
|
817
834
|
// Print DNS instructions
|
|
@@ -841,7 +858,7 @@ async function failoverSingleServer(envName, envConfig) {
|
|
|
841
858
|
const s = p.spinner();
|
|
842
859
|
s.start('Checking for available backups');
|
|
843
860
|
try {
|
|
844
|
-
const pod = sshKubectl(serverIp, sshKeyPath, [
|
|
861
|
+
const pod = await sshKubectl(serverIp, sshKeyPath, [
|
|
845
862
|
'get',
|
|
846
863
|
'pods',
|
|
847
864
|
'-n',
|
|
@@ -851,7 +868,7 @@ async function failoverSingleServer(envName, envConfig) {
|
|
|
851
868
|
'-o',
|
|
852
869
|
'jsonpath={.items[0].metadata.name}',
|
|
853
870
|
]);
|
|
854
|
-
const output = sshKubectl(serverIp, sshKeyPath, [
|
|
871
|
+
const output = await sshKubectl(serverIp, sshKeyPath, [
|
|
855
872
|
'exec',
|
|
856
873
|
'-n',
|
|
857
874
|
'vibecarbon',
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-friendly formatting for backup listings.
|
|
3
|
+
*
|
|
4
|
+
* Backup filenames embed the canonical backup time as `_YYYYMMDD_HHMMSS_`
|
|
5
|
+
* (e.g. `myproject_20260519_120001_full.tar.gz`). Both `restore` (the picker)
|
|
6
|
+
* and `backup list` parse that and render a relative, scannable label —
|
|
7
|
+
* "Today, 12:00 PM" — instead of the raw S3 key.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const TS_RE = /_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_/;
|
|
11
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the embedded backup timestamp from a backup filename.
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @returns {Date|null} local-time Date, or null if no timestamp is present
|
|
17
|
+
*/
|
|
18
|
+
export function parseBackupTime(name) {
|
|
19
|
+
const m = typeof name === 'string' ? name.match(TS_RE) : null;
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
const [, y, mo, d, h, mi, s] = m.map(Number);
|
|
22
|
+
const date = new Date(y, mo - 1, d, h, mi, s);
|
|
23
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clockTime(date) {
|
|
27
|
+
const h24 = date.getHours();
|
|
28
|
+
const m = date.getMinutes();
|
|
29
|
+
const ampm = h24 < 12 ? 'AM' : 'PM';
|
|
30
|
+
const h = h24 % 12 || 12;
|
|
31
|
+
return `${h}:${String(m).padStart(2, '0')} ${ampm}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function startOfDay(d) {
|
|
35
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a relative, human-readable label for a backup filename:
|
|
40
|
+
* "Today, 12:00 PM" / "Yesterday, 6:00 PM" / "May 17, 12:00 PM" /
|
|
41
|
+
* "Dec 31 2025, 6:00 PM" (older year). Falls back to the raw name if the
|
|
42
|
+
* timestamp can't be parsed.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @param {Date} [now] - injectable for testing; defaults to the current time
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Render a Date as a relative, human-readable label.
|
|
50
|
+
* "Today, 12:00 PM" / "Yesterday, 6:00 PM" / "May 17, 12:00 PM" /
|
|
51
|
+
* "Dec 31 2025, 6:00 PM" (older year).
|
|
52
|
+
* @param {Date} date
|
|
53
|
+
* @param {Date} [now]
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function formatDate(date, now = new Date()) {
|
|
57
|
+
const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
|
|
58
|
+
let day;
|
|
59
|
+
if (dayDiff === 0) day = 'Today';
|
|
60
|
+
else if (dayDiff === 1) day = 'Yesterday';
|
|
61
|
+
else if (date.getFullYear() === now.getFullYear())
|
|
62
|
+
day = `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
63
|
+
else day = `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
|
|
64
|
+
|
|
65
|
+
return `${day}, ${clockTime(date)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatBackupTime(name, now = new Date()) {
|
|
69
|
+
const date = parseBackupTime(name);
|
|
70
|
+
if (!date) return name;
|
|
71
|
+
return formatDate(date, now);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render an instant (Date or ISO-8601 string, e.g. wal-g's `time` field) using
|
|
76
|
+
* the same relative labels as formatBackupTime. Returns '' for unparseable input.
|
|
77
|
+
* @param {Date|string} value
|
|
78
|
+
* @param {Date} [now]
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function formatInstant(value, now = new Date()) {
|
|
82
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
83
|
+
return Number.isNaN(date.getTime()) ? '' : formatDate(date, now);
|
|
84
|
+
}
|