vibecarbon 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +152 -0
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +1 -2
- package/src/backup.js +13 -66
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/destroy.js +5 -43
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +137 -5
- package/src/lib/deploy/compose/ha.js +208 -92
- package/src/lib/deploy/compose/index.js +292 -424
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +407 -168
- package/src/lib/deploy/orchestrator.js +90 -33
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +45 -16
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +216 -302
- package/src/scale.js +76 -77
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- package/src/lib/kubectl.js +0 -81
package/src/scale.js
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
* prompt covers that need without tying up two top-level letters.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readFileSync
|
|
17
|
-
import os from 'node:os';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
17
|
import { join } from 'node:path';
|
|
19
18
|
import * as p from '@clack/prompts';
|
|
20
19
|
import { getGHCRCredentials } from './lib/build.js';
|
|
@@ -25,6 +24,7 @@ import { c, printBanner } from './lib/colors.js';
|
|
|
25
24
|
import { runCommand } from './lib/command.js';
|
|
26
25
|
import { loadCredentials, saveProjectConfig } from './lib/config.js';
|
|
27
26
|
import { buildCostBreakdown, formatCostLines } from './lib/cost.js';
|
|
27
|
+
import { useDnsChallenge } from './lib/deploy/acme.js';
|
|
28
28
|
import {
|
|
29
29
|
backupCompose,
|
|
30
30
|
dockerLoginOnServer,
|
|
@@ -48,7 +48,7 @@ import { assertInProjectDir } from './lib/project-guard.js';
|
|
|
48
48
|
import { 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
|
-
import {
|
|
51
|
+
import { sshRun, sshRunScript } from './lib/ssh.js';
|
|
52
52
|
import { VERSION } from './lib/version.js';
|
|
53
53
|
|
|
54
54
|
// ============================================================================
|
|
@@ -265,6 +265,11 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
265
265
|
const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
|
|
266
266
|
const sshPubKeyPath = `${sshKeyPath}.pub`;
|
|
267
267
|
const domain = envConfig.domain || null;
|
|
268
|
+
// Managed-DNS (cloudflare/hetzner) deploys issue certs via ACME DNS-01, so
|
|
269
|
+
// the new/recreated server needs the DNS-01 Traefik override and has no
|
|
270
|
+
// HTTP-01 race to wait on or reset. `manual` keeps HTTP-01.
|
|
271
|
+
const dnsProvider = envConfig.dnsProvider || envConfig.dns?.provider || null;
|
|
272
|
+
const dnsChallenge = useDnsChallenge(dnsProvider);
|
|
268
273
|
|
|
269
274
|
// Read SSH public key and register/reuse in Hetzner
|
|
270
275
|
const sshPubKey = readFileSync(sshPubKeyPath, 'utf-8').trim();
|
|
@@ -290,12 +295,14 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
290
295
|
envConfig.deployMode === 'compose-ha' ? `${server.role} (${server.ip})` : server.ip;
|
|
291
296
|
const s = p.spinner();
|
|
292
297
|
|
|
293
|
-
// 1. Backup database on old server
|
|
298
|
+
// 1. Backup database on old server via wal-g (pushes directly to S3)
|
|
294
299
|
s.start(`Backing up database on ${label}...`);
|
|
295
|
-
|
|
296
|
-
backupCompose(server.ip, sshKeyPath, projectName, {
|
|
300
|
+
await perfAsync('scale.backupOldServer', async () =>
|
|
301
|
+
backupCompose(server.ip, sshKeyPath, projectName, {
|
|
302
|
+
retain: envConfig.backup?.retentionDays,
|
|
303
|
+
}),
|
|
297
304
|
);
|
|
298
|
-
s.stop(
|
|
305
|
+
s.stop('Database backed up (wal-g base backup pushed to S3)');
|
|
299
306
|
|
|
300
307
|
// 2. Create new server. user_data front-loads ufw + unattended-upgrades
|
|
301
308
|
// during boot so setupServer is a ~2s marker-file probe below.
|
|
@@ -383,6 +390,14 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
383
390
|
n8n: services.n8n,
|
|
384
391
|
metabase: services.metabase,
|
|
385
392
|
redis: services.redis,
|
|
393
|
+
// DNS-01: ship the Traefik override + re-affirm ACME_DNS_PROVIDER.
|
|
394
|
+
// The provider token flows through `envOverrides: oldEnv` (the
|
|
395
|
+
// original deploy baked it into the server .env); tokens are passed
|
|
396
|
+
// too so a fresh bundle still works if the old server was unreachable.
|
|
397
|
+
dnsChallenge,
|
|
398
|
+
dnsProvider,
|
|
399
|
+
hetznerApiToken: apiToken,
|
|
400
|
+
cloudflareApiToken: loadCredentials().cloudflare?.apiToken || null,
|
|
386
401
|
// Replay every env var the old server had. renderBundle's existing
|
|
387
402
|
// envOverrides path overlays these onto the project-local `.env`
|
|
388
403
|
// baseline, then re-applies the deploy-time overrides (DOMAIN,
|
|
@@ -473,69 +488,59 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
473
488
|
);
|
|
474
489
|
s.stop('Services started');
|
|
475
490
|
|
|
476
|
-
// 7.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
`cd ${remoteDir} && for i in $(seq 1 30); do docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1 && break || sleep 2; done`,
|
|
484
|
-
{ timeout: 120_000 },
|
|
485
|
-
);
|
|
486
|
-
s.stop('Database ready for restore');
|
|
487
|
-
|
|
488
|
-
// 8. Transfer backup from old server to new server
|
|
489
|
-
s.start('Transferring backup to new server...');
|
|
490
|
-
const localTmpBackup = join(os.tmpdir(), archiveName);
|
|
491
|
-
try {
|
|
492
|
-
scpDownload(server.ip, sshKeyPath, `${remoteDir}/backups/${archiveName}`, localTmpBackup);
|
|
493
|
-
sshRun(newIp, sshKeyPath, ['mkdir', '-p', `${remoteDir}/backups`]);
|
|
494
|
-
scpUpload(newIp, sshKeyPath, localTmpBackup, `${remoteDir}/backups/${archiveName}`);
|
|
495
|
-
} finally {
|
|
496
|
-
try {
|
|
497
|
-
unlinkSync(localTmpBackup);
|
|
498
|
-
} catch {
|
|
499
|
-
// ignore cleanup errors
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
s.stop('Backup transferred');
|
|
503
|
-
|
|
504
|
-
// 9. Restore database on new server
|
|
505
|
-
s.start('Restoring database...');
|
|
491
|
+
// 7. Restore database on new server from S3 via wal-g. The base backup +
|
|
492
|
+
// WAL were pushed straight to S3 by step 1's backupCompose, so the
|
|
493
|
+
// restore fetches LATEST directly — no local archive to transfer.
|
|
494
|
+
// S3 credentials come from the db container's env (sourced from .env)
|
|
495
|
+
// so no opts are needed; restoreCompose handles its own verify loop
|
|
496
|
+
// (pg_isready + pg_is_in_recovery() = f) before returning.
|
|
497
|
+
s.start('Restoring database from S3 via wal-g...');
|
|
506
498
|
await perfAsync('scale.restoreDb', async () =>
|
|
507
|
-
restoreCompose(newIp, sshKeyPath, projectName,
|
|
499
|
+
restoreCompose(newIp, sshKeyPath, projectName, 'latest'),
|
|
508
500
|
);
|
|
509
501
|
s.stop('Database restored');
|
|
510
502
|
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
503
|
+
// Remote compose project dir on the new server — used by the ACME reset
|
|
504
|
+
// and the post-restore service recreate below. (Previously declared by a
|
|
505
|
+
// since-removed pre-restore readiness step; restoreCompose now owns that
|
|
506
|
+
// wait, so declare it here.)
|
|
507
|
+
const remoteDir = `/opt/${projectName}`;
|
|
508
|
+
|
|
509
|
+
// 9a. Update DNS to the new server. On HTTP-01 this must happen BEFORE
|
|
510
|
+
// recreating services (Traefik challenges the A record on startup; a
|
|
511
|
+
// stale record fails all challenges with a 30+ min retry). On DNS-01 the
|
|
512
|
+
// ordering is irrelevant — lego validates a TXT record, not the A record.
|
|
514
513
|
if (domain) {
|
|
515
514
|
s.start('Updating DNS to new server...');
|
|
516
515
|
await perfAsync('scale.updateDNS', () => updateDNS(envConfig, apiToken, domain, newIp));
|
|
517
516
|
s.stop('DNS updated');
|
|
518
517
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
518
|
+
// HTTP-01 only: poll until public resolvers return the new IP (max
|
|
519
|
+
// 120s) so Traefik's first challenge sees it. DNS-01 doesn't gate on
|
|
520
|
+
// A-record propagation, so skip the wait.
|
|
521
|
+
if (!dnsChallenge) {
|
|
522
|
+
s.start('Waiting for DNS propagation...');
|
|
523
|
+
const dnsOk = await perfAsync('scale.waitForDNS', () =>
|
|
524
|
+
waitForDNSPropagation(domain, newIp, 120_000),
|
|
525
|
+
);
|
|
526
|
+
s.stop(dnsOk ? 'DNS propagated' : 'DNS propagation timed out (proceeding anyway)');
|
|
527
|
+
}
|
|
526
528
|
}
|
|
527
529
|
|
|
528
|
-
// 9b.
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
530
|
+
// 9b. HTTP-01 only: reset ACME state so Traefik retries challenges with
|
|
531
|
+
// the corrected DNS — step 6's startComposeStack ran before the A record
|
|
532
|
+
// moved, so failed challenges are cached as errors in letsencrypt_data.
|
|
533
|
+
// DNS-01 has no such cached HTTP-01 failure to clear (and wiping acme.json
|
|
534
|
+
// would force a needless re-issue), so skip it.
|
|
532
535
|
// Uses an SCP'd script so quoting/redirection stay intact without local shell.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
536
|
+
if (!dnsChallenge) {
|
|
537
|
+
sshRunScript(
|
|
538
|
+
newIp,
|
|
539
|
+
sshKeyPath,
|
|
540
|
+
`cd ${remoteDir} && docker compose exec -T traefik sh -c 'echo "{}" > /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json'`,
|
|
541
|
+
{ timeout: 30_000 },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
539
544
|
|
|
540
545
|
// 9c. Recreate all services after restore so they reconnect to the restored DB.
|
|
541
546
|
// Include all feature compose file flags so feature containers (redis, n8n,
|
|
@@ -545,6 +550,8 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
545
550
|
const composeFlags = [
|
|
546
551
|
'-f docker-compose.yml',
|
|
547
552
|
'-f docker-compose.prod.yml',
|
|
553
|
+
// DNS-01 override must follow prod.yml so it replaces the Traefik command.
|
|
554
|
+
...(dnsChallenge ? ['-f docker-compose.dns01.prod.yml'] : []),
|
|
548
555
|
...(services.observability
|
|
549
556
|
? ['-f docker-compose.observability.yml', '-f docker-compose.observability.prod.yml']
|
|
550
557
|
: []),
|
|
@@ -554,34 +561,26 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
554
561
|
: []),
|
|
555
562
|
...(services.redis ? ['-f docker-compose.redis.yml'] : []),
|
|
556
563
|
].join(' ');
|
|
564
|
+
// On compose-ha, docker-compose.replication.yml (written by configurePrimaryReplication)
|
|
565
|
+
// publishes 5433 → db:5432 for pg_basebackup / streaming replication.
|
|
566
|
+
// Append it when present so force-recreate doesn't silently drop the 5433 mapping.
|
|
567
|
+
// On single-server compose the file never exists — the shell conditional is a no-op.
|
|
568
|
+
const replOverlayFlag =
|
|
569
|
+
"$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
|
|
557
570
|
s.start('Recreating all services after restore...');
|
|
558
571
|
sshRunScript(
|
|
559
572
|
newIp,
|
|
560
573
|
sshKeyPath,
|
|
561
|
-
`cd ${remoteDir} && docker compose ${composeFlags} up -d --force-recreate 2>&1`,
|
|
574
|
+
`cd ${remoteDir} && docker compose ${composeFlags} ${replOverlayFlag} up -d --force-recreate 2>&1`,
|
|
562
575
|
{ timeout: 600_000 },
|
|
563
576
|
);
|
|
564
577
|
s.stop('All services running');
|
|
565
578
|
|
|
566
|
-
// 11. Re-setup backup cron if configured.
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
// Without merging them in, setupComposeBackupCron would write a
|
|
570
|
-
// /etc/vibecarbon/backup.env with empty AWS_ACCESS_KEY_ID /
|
|
571
|
-
// AWS_SECRET_ACCESS_KEY and every cron-triggered backup would 403
|
|
572
|
-
// against S3.
|
|
579
|
+
// 11. Re-setup backup cron if configured. wal-g reads its S3 config from
|
|
580
|
+
// the db container's env (rendered into .env at deploy), so the cron
|
|
581
|
+
// just runs compose-backup.sh on schedule — no separate S3 plumbing.
|
|
573
582
|
if (envConfig.backup) {
|
|
574
|
-
|
|
575
|
-
envConfig.s3 && creds.s3
|
|
576
|
-
? {
|
|
577
|
-
bucket: envConfig.s3.bucket,
|
|
578
|
-
accessKey: creds.s3.accessKey,
|
|
579
|
-
secretKey: creds.s3.secretKey,
|
|
580
|
-
endpoint: envConfig.s3.endpoint,
|
|
581
|
-
region: envConfig.s3.region,
|
|
582
|
-
}
|
|
583
|
-
: null;
|
|
584
|
-
setupComposeBackupCron(newIp, sshKeyPath, projectName, backupS3Config, envConfig.backup);
|
|
583
|
+
setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
|
|
585
584
|
p.log.info('Backup cron restored on new server');
|
|
586
585
|
}
|
|
587
586
|
|
package/carbon/backup/Dockerfile
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# syntax=docker/dockerfile:1
|
|
2
|
-
FROM ubuntu:22.04
|
|
3
|
-
|
|
4
|
-
# Base image is ubuntu, not alpine, because wal-g v3.x stopped publishing
|
|
5
|
-
# alpine-musl prebuilt binaries — only ubuntu glibc builds remain. We need
|
|
6
|
-
# wal-g v3+ for the current S3 + Postgres 16 wire protocol support.
|
|
7
|
-
|
|
8
|
-
ENV DEBIAN_FRONTEND=noninteractive
|
|
9
|
-
|
|
10
|
-
# Install PostgreSQL 16 client + utilities. PG 16 isn't in the default
|
|
11
|
-
# ubuntu 22.04 repos; pull it from the postgresql apt repo. AWS CLI is
|
|
12
|
-
# installed as the standalone v2 binary further down (the apt-shipped
|
|
13
|
-
# `awscli` is v1 and pulls ~500MB of Python deps; v2 is ~80MB self-
|
|
14
|
-
# contained and the only version AWS itself supports for new code).
|
|
15
|
-
#
|
|
16
|
-
# apt cache mount: keeps /var/cache/apt populated across rebuilds so
|
|
17
|
-
# `apt-get install` reuses .deb files. The cache lives outside the layer,
|
|
18
|
-
# so we don't need the historical `rm -rf /var/lib/apt/lists/*` to keep
|
|
19
|
-
# the final image small — that's still done below as a belt-and-braces
|
|
20
|
-
# measure for cases where the cache mount isn't honored (e.g. legacy
|
|
21
|
-
# `docker build` without BuildKit).
|
|
22
|
-
# `sharing=locked` serializes parallel builds against the same cache
|
|
23
|
-
# (apt's lock semantics expect exclusive access).
|
|
24
|
-
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|
25
|
-
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
|
26
|
-
apt-get update && apt-get install -y --no-install-recommends \
|
|
27
|
-
ca-certificates curl gnupg lsb-release \
|
|
28
|
-
bash tar gzip unzip && \
|
|
29
|
-
install -d /usr/share/postgresql-common/pgdg && \
|
|
30
|
-
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
|
31
|
-
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc && \
|
|
32
|
-
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" \
|
|
33
|
-
> /etc/apt/sources.list.d/pgdg.list && \
|
|
34
|
-
apt-get update && apt-get install -y --no-install-recommends \
|
|
35
|
-
postgresql-client-16
|
|
36
|
-
|
|
37
|
-
# Install AWS CLI v2 standalone binary (~80MB) instead of `apt-get install
|
|
38
|
-
# awscli` (v1 with ~500MB of Python/boto3 deps). Saves ~400MB on the
|
|
39
|
-
# final image, which matters because the backup image is sideloaded to
|
|
40
|
-
# every k3s node (~6 nodes in HA = ~2.4GB less network per deploy).
|
|
41
|
-
# --fail makes curl exit non-zero on HTTP 4xx/5xx instead of piping a
|
|
42
|
-
# 404 HTML body into unzip.
|
|
43
|
-
RUN curl -fL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip \
|
|
44
|
-
-o /tmp/awscli.zip && \
|
|
45
|
-
unzip -q /tmp/awscli.zip -d /tmp && \
|
|
46
|
-
/tmp/aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli && \
|
|
47
|
-
rm -rf /tmp/aws /tmp/awscli.zip
|
|
48
|
-
|
|
49
|
-
# Install WAL-G v3.0.5 (ubuntu 22.04 amd64 build).
|
|
50
|
-
# --fail makes curl exit non-zero on HTTP 4xx/5xx instead of piping a 404
|
|
51
|
-
# HTML body into tar (the previous v3.0.3-alpine URL 404'd, tar saw "<html>"
|
|
52
|
-
# and errored "not in gzip format" — observed 2026-04-26 matrix run #2).
|
|
53
|
-
RUN curl -fL https://github.com/wal-g/wal-g/releases/download/v3.0.5/wal-g-pg-ubuntu-22.04-amd64.tar.gz \
|
|
54
|
-
| tar -xz && \
|
|
55
|
-
mv wal-g-pg-ubuntu-22.04-amd64 /usr/local/bin/wal-g && \
|
|
56
|
-
chmod +x /usr/local/bin/wal-g
|
|
57
|
-
|
|
58
|
-
# Security: non-root system user. We can't reuse the name `backup` —
|
|
59
|
-
# ubuntu pre-populates a `backup` system group (GID 34, used for system
|
|
60
|
-
# backup operations) and `groupadd backup` errors with exit 9 ("group
|
|
61
|
-
# 'backup' already exists"). The previous alpine version sidestepped
|
|
62
|
-
# this because alpine doesn't ship the same default. Use a unique name
|
|
63
|
-
# (`vbbackup`) and let --system pick a free system GID/UID.
|
|
64
|
-
# The k8s manifest doesn't pin runAsUser so any UID works.
|
|
65
|
-
RUN groupadd --system vbbackup && \
|
|
66
|
-
useradd --system -g vbbackup -s /usr/sbin/nologin -M vbbackup
|
|
67
|
-
|
|
68
|
-
# Copy backup script
|
|
69
|
-
COPY backup.sh /usr/local/bin/backup.sh
|
|
70
|
-
RUN chmod +x /usr/local/bin/backup.sh
|
|
71
|
-
|
|
72
|
-
USER vbbackup
|
|
73
|
-
|
|
74
|
-
# CronJob handles scheduling — script runs once and exits
|
|
75
|
-
ENTRYPOINT ["/usr/local/bin/backup.sh"]
|
package/carbon/backup/backup.sh
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
set -e
|
|
3
|
-
# pipefail is critical: the pg_dump | gzip pipeline below silently uploaded
|
|
4
|
-
# 170-byte gzip-header-only tarballs when pg_dump failed (wrong PGHOST),
|
|
5
|
-
# making restore think backups existed when they were empty. Observed
|
|
6
|
-
# 2026-04-27 k8s-ha matrix #6 — restore failed "No backups found in S3"
|
|
7
|
-
# because the broken tarball had no SQL inside.
|
|
8
|
-
set -o pipefail
|
|
9
|
-
|
|
10
|
-
# ============================================================================
|
|
11
|
-
# Vibecarbon Blazing Fast Backup Script (WAL-G)
|
|
12
|
-
#
|
|
13
|
-
# Optimized for performance and robustness using continuous archiving.
|
|
14
|
-
# Fallback to pg_dump for legacy compatibility.
|
|
15
|
-
#
|
|
16
|
-
# Environment Variables:
|
|
17
|
-
# PGHOST, PGPORT, PGUSER, PGDATABASE, PGPASSWORD - PostgreSQL connection
|
|
18
|
-
# S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION - S3 config
|
|
19
|
-
# S3_BACKUP_BUCKET - Dedicated backup bucket (falls back to S3_BUCKET)
|
|
20
|
-
# BACKUP_RETENTION_DAYS - Days to keep backups (default: 7)
|
|
21
|
-
# PROJECT_NAME - Project name for backup naming
|
|
22
|
-
# BACKUP_MODE - "walg" (default) or "pg_dump"
|
|
23
|
-
# ============================================================================
|
|
24
|
-
|
|
25
|
-
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
26
|
-
PROJECT="${PROJECT_NAME:-vibecarbon}"
|
|
27
|
-
MODE="${BACKUP_MODE:-walg}"
|
|
28
|
-
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
|
|
29
|
-
|
|
30
|
-
echo "=== Vibecarbon Backup (${MODE}) ==="
|
|
31
|
-
echo "Started at $(date)"
|
|
32
|
-
|
|
33
|
-
# --- S3 CONFIGURATION ---
|
|
34
|
-
UPLOAD_BUCKET="${S3_BACKUP_BUCKET:-${S3_BUCKET}}"
|
|
35
|
-
export AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY}"
|
|
36
|
-
export AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY}"
|
|
37
|
-
export AWS_DEFAULT_REGION="${S3_REGION:-us-east-1}"
|
|
38
|
-
export AWS_ENDPOINT="${S3_ENDPOINT}"
|
|
39
|
-
|
|
40
|
-
if [ -z "${UPLOAD_BUCKET}" ] || [ -z "${AWS_ACCESS_KEY_ID}" ] || [ -z "${AWS_SECRET_ACCESS_KEY}" ] || [ -z "${AWS_ENDPOINT}" ]; then
|
|
41
|
-
echo "Error: S3 configuration missing. Backup cannot proceed."
|
|
42
|
-
exit 1
|
|
43
|
-
fi
|
|
44
|
-
|
|
45
|
-
# --- WAL-G CONFIGURATION ---
|
|
46
|
-
export WALG_S3_PREFIX="s3://${UPLOAD_BUCKET}/backups/${PROJECT}/walg"
|
|
47
|
-
# Use the same credentials for WAL-G (it uses AWS_* by default, but we can be explicit)
|
|
48
|
-
export WALE_S3_PREFIX="${WALG_S3_PREFIX}"
|
|
49
|
-
# Parallelism for faster compression/upload
|
|
50
|
-
export WALG_COMPRESSION_METHOD="lz4"
|
|
51
|
-
export WALG_UPLOAD_CONCURRENCY=4
|
|
52
|
-
|
|
53
|
-
if [ "${MODE}" = "walg" ]; then
|
|
54
|
-
echo "Creating full base backup with WAL-G..."
|
|
55
|
-
# WAL-G requires PGHOST to be reachable.
|
|
56
|
-
# If running in a sidecar, it might need to connect via socket or network.
|
|
57
|
-
# We assume PGHOST is set correctly (e.g. "db").
|
|
58
|
-
export PGDATA="/var/lib/postgresql/data"
|
|
59
|
-
|
|
60
|
-
# Check if we can run backup-push.
|
|
61
|
-
# Note: backup-push usually needs to run on the database server or have access to PGDATA
|
|
62
|
-
# if it's doing a direct filesystem scan.
|
|
63
|
-
# If this script runs in a separate container, it uses the network protocol.
|
|
64
|
-
|
|
65
|
-
wal-g backup-push "${PGDATA}"
|
|
66
|
-
|
|
67
|
-
echo "Full base backup completed."
|
|
68
|
-
|
|
69
|
-
echo "Cleaning up old backups (retention: ${RETENTION_DAYS} days)..."
|
|
70
|
-
wal-g delete before "$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)" --confirm
|
|
71
|
-
|
|
72
|
-
else
|
|
73
|
-
# --- LEGACY PG_DUMP FALLBACK ---
|
|
74
|
-
echo "Falling back to pg_dump..."
|
|
75
|
-
WORK_DIR="/tmp/backup_${TIMESTAMP}"
|
|
76
|
-
mkdir -p "${WORK_DIR}"
|
|
77
|
-
ARCHIVE_NAME="${PROJECT}_${TIMESTAMP}_full.tar.gz"
|
|
78
|
-
|
|
79
|
-
PGPASSWORD="${PGPASSWORD}" pg_dump \
|
|
80
|
-
-h "${PGHOST:-postgres}" \
|
|
81
|
-
-p "${PGPORT:-5432}" \
|
|
82
|
-
-U "${PGUSER:-supabase_admin}" \
|
|
83
|
-
"${PGDATABASE:-postgres}" | gzip > "${WORK_DIR}/postgres.sql.gz"
|
|
84
|
-
|
|
85
|
-
tar czf "/tmp/${ARCHIVE_NAME}" -C "${WORK_DIR}" .
|
|
86
|
-
|
|
87
|
-
aws s3 cp "/tmp/${ARCHIVE_NAME}" \
|
|
88
|
-
"s3://${UPLOAD_BUCKET}/backups/${ARCHIVE_NAME}" \
|
|
89
|
-
--endpoint-url "${S3_ENDPOINT}"
|
|
90
|
-
|
|
91
|
-
rm -rf "${WORK_DIR}"
|
|
92
|
-
rm "/tmp/${ARCHIVE_NAME}"
|
|
93
|
-
fi
|
|
94
|
-
|
|
95
|
-
echo "=== Backup completed at $(date) ==="
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# syntax=docker/dockerfile:1
|
|
2
|
-
# Vibecarbon Runtime Base Image
|
|
3
|
-
# This image contains the pre-installed node_modules for the Vibecarbon template.
|
|
4
|
-
# Using this as a base for your app image reduces build times from minutes to seconds.
|
|
5
|
-
|
|
6
|
-
FROM node:22-alpine3.23 AS base
|
|
7
|
-
|
|
8
|
-
RUN npm install -g pnpm@9
|
|
9
|
-
WORKDIR /app
|
|
10
|
-
|
|
11
|
-
# Pre-install dependencies
|
|
12
|
-
COPY package.json pnpm-lock.yaml ./
|
|
13
|
-
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
|
14
|
-
pnpm install --frozen-lockfile
|
|
15
|
-
|
|
16
|
-
# This image is meant to be a base, so we stop here.
|
|
17
|
-
# The app build will continue FROM this image.
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Verify Traefik has acquired Let's Encrypt certificates after compose-up,
|
|
3
|
-
* and self-heal a stuck ACME state by restarting Traefik.
|
|
4
|
-
*
|
|
5
|
-
* Why this exists: Traefik's ACME engine bursts ~7 attempts in ~30s on
|
|
6
|
-
* container start, then waits ~24h for the next renewal-check tick. If
|
|
7
|
-
* the first burst fails (typically because DNS hasn't propagated yet —
|
|
8
|
-
* see src/lib/dns-propagation.js for the cold-deploy fix), Traefik
|
|
9
|
-
* settles into a state where acme.json has 0 issued certs and the
|
|
10
|
-
* customer's browser sees the TRAEFIK DEFAULT CERT
|
|
11
|
-
* (NET::ERR_CERT_AUTHORITY_INVALID).
|
|
12
|
-
*
|
|
13
|
-
* Cold deploys on v3.6.2+ avoid the original race by writing DNS before
|
|
14
|
-
* compose-up. But customers stuck on v3.6.1's failure state can't recover
|
|
15
|
-
* via warm redeploy alone — `docker compose up -d` is a no-op for
|
|
16
|
-
* Traefik when nothing changed, so the broken in-memory state persists.
|
|
17
|
-
*
|
|
18
|
-
* This helper polls acme.json post-deploy expecting at least one cert;
|
|
19
|
-
* if Traefik is still empty after ~90s, it restarts the container,
|
|
20
|
-
* which forces a fresh ACME run against now-valid DNS. Healthy DNS
|
|
21
|
-
* yields a cert in ~30s of the restart. If the restart still doesn't
|
|
22
|
-
* produce a cert, we log a warning but don't fail the deploy — could
|
|
23
|
-
* be LE rate limits, port 80 unreachable, etc., none of which a CLI
|
|
24
|
-
* restart can fix.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { sshRunAsync } from './index.js';
|
|
28
|
-
|
|
29
|
-
const POLL_INTERVAL_MS = 5_000;
|
|
30
|
-
const INITIAL_POLL_TIMEOUT_MS = 90_000;
|
|
31
|
-
const POST_RESTART_POLL_TIMEOUT_MS = 90_000;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* SSH into the server, exec `cat /letsencrypt/acme.json` inside the
|
|
35
|
-
* Traefik container, return the count of issued certs (0 if anything
|
|
36
|
-
* goes wrong — empty acme.json, malformed JSON, SSH error, etc.).
|
|
37
|
-
*/
|
|
38
|
-
async function readAcmeCertCount(ip, sshKeyPath, projectName) {
|
|
39
|
-
let raw;
|
|
40
|
-
try {
|
|
41
|
-
raw = await sshRunAsync(
|
|
42
|
-
ip,
|
|
43
|
-
sshKeyPath,
|
|
44
|
-
`cd /opt/${projectName} && docker compose exec -T traefik cat /letsencrypt/acme.json 2>/dev/null`,
|
|
45
|
-
{ timeout: 15_000 },
|
|
46
|
-
);
|
|
47
|
-
} catch {
|
|
48
|
-
return 0;
|
|
49
|
-
}
|
|
50
|
-
if (!raw || typeof raw !== 'string') return 0;
|
|
51
|
-
try {
|
|
52
|
-
const parsed = JSON.parse(raw);
|
|
53
|
-
const certs = parsed?.letsencrypt?.Certificates;
|
|
54
|
-
return Array.isArray(certs) ? certs.length : 0;
|
|
55
|
-
} catch {
|
|
56
|
-
return 0;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function pollForCert(ip, sshKeyPath, projectName, timeoutMs) {
|
|
61
|
-
const deadline = Date.now() + timeoutMs;
|
|
62
|
-
while (Date.now() < deadline) {
|
|
63
|
-
if ((await readAcmeCertCount(ip, sshKeyPath, projectName)) > 0) return true;
|
|
64
|
-
const remaining = deadline - Date.now();
|
|
65
|
-
if (remaining <= 0) break;
|
|
66
|
-
await new Promise((r) => setTimeout(r, Math.min(POLL_INTERVAL_MS, remaining)));
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Wait for Traefik to acquire at least one cert; if it doesn't within
|
|
73
|
-
* the initial budget, restart the container and wait again. Logs progress
|
|
74
|
-
* via the provided logger (typically @clack/prompts p.log). Never throws
|
|
75
|
-
* — TLS issuance is best-effort; the deploy succeeds even if the cert
|
|
76
|
-
* never lands (operator can investigate via SSH).
|
|
77
|
-
*
|
|
78
|
-
* @param {string} ip - server IP
|
|
79
|
-
* @param {string} sshKeyPath - path to private key
|
|
80
|
-
* @param {string} projectName - compose project name (= directory in /opt/)
|
|
81
|
-
* @param {string} domain - configured domain (for log messages only)
|
|
82
|
-
* @param {{ message?: Function, success?: Function, warn?: Function }} log
|
|
83
|
-
*/
|
|
84
|
-
export async function ensureTraefikCert(ip, sshKeyPath, projectName, domain, log) {
|
|
85
|
-
log.message?.(`Verifying TLS certificate for ${domain}...`);
|
|
86
|
-
const acquired = await pollForCert(ip, sshKeyPath, projectName, INITIAL_POLL_TIMEOUT_MS);
|
|
87
|
-
if (acquired) {
|
|
88
|
-
log.success?.(`TLS certificate issued for ${domain}`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
log.warn?.(
|
|
92
|
-
`Traefik has no certificate for ${domain} after ${INITIAL_POLL_TIMEOUT_MS / 1000}s — ` +
|
|
93
|
-
`restarting to clear stale ACME state and retry`,
|
|
94
|
-
);
|
|
95
|
-
try {
|
|
96
|
-
await sshRunAsync(ip, sshKeyPath, `cd /opt/${projectName} && docker compose restart traefik`, {
|
|
97
|
-
timeout: 60_000,
|
|
98
|
-
});
|
|
99
|
-
} catch (err) {
|
|
100
|
-
log.warn?.(`Traefik restart failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const acquiredAfterRestart = await pollForCert(
|
|
104
|
-
ip,
|
|
105
|
-
sshKeyPath,
|
|
106
|
-
projectName,
|
|
107
|
-
POST_RESTART_POLL_TIMEOUT_MS,
|
|
108
|
-
);
|
|
109
|
-
if (acquiredAfterRestart) {
|
|
110
|
-
log.success?.(`TLS certificate issued for ${domain} after Traefik restart`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
log.warn?.(
|
|
114
|
-
`Traefik still has no certificate for ${domain} after restart. ` +
|
|
115
|
-
`Check DNS resolution (dig +short ${domain}) and acme.json on the server. ` +
|
|
116
|
-
`If both look correct, ACME may complete in the background within minutes.`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Re-exported for tests; not part of the public surface.
|
|
121
|
-
export const __test__ = { readAcmeCertCount, pollForCert };
|
package/src/lib/deploy/index.js
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deployment Module Registry
|
|
3
|
-
*
|
|
4
|
-
* Four deployment modes:
|
|
5
|
-
* - compose: Docker Compose on single VPS (Fast + Pro)
|
|
6
|
-
* - compose-ha: Docker Compose HA on 2 VPS with PostgreSQL replication (Fast + Pro)
|
|
7
|
-
* - standard: Kubernetes single-region (Pro only)
|
|
8
|
-
* - ha: Kubernetes multi-region HA (Pro only)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export {
|
|
12
|
-
deployComposeHA,
|
|
13
|
-
destroyComposeHA,
|
|
14
|
-
getComposeHAStatus,
|
|
15
|
-
} from './compose/ha.js';
|
|
16
|
-
export {
|
|
17
|
-
backupCompose,
|
|
18
|
-
deployCompose,
|
|
19
|
-
destroyCompose,
|
|
20
|
-
getComposeStatus,
|
|
21
|
-
redeployCompose,
|
|
22
|
-
restoreCompose,
|
|
23
|
-
setupServer,
|
|
24
|
-
setupServerFiles,
|
|
25
|
-
waitForSSH as waitForComposeSSH,
|
|
26
|
-
} from './compose/index.js';
|
|
27
|
-
export { deployK8sHA, destroyK8sHA, getK8sHAStatus, triggerFailover } from './k8s/ha/index.js';
|
|
28
|
-
export { destroyK8s, getK8sStatus } from './k8s/index.js';
|
|
29
|
-
export { deployK3s } from './k8s/k3s.js';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Get the appropriate deploy function based on mode
|
|
33
|
-
* @param {string} mode - 'compose', 'compose-ha', 'kubernetes', or 'ha'
|
|
34
|
-
* @returns {Promise<Function>} Deploy function
|
|
35
|
-
*/
|
|
36
|
-
export function getDeployFunction(mode = 'compose') {
|
|
37
|
-
if (mode === 'ha') {
|
|
38
|
-
return import('./k8s/ha/index.js').then((m) => m.deployK8sHA);
|
|
39
|
-
}
|
|
40
|
-
if (mode === 'compose-ha') {
|
|
41
|
-
return import('./compose/ha.js').then((m) => m.deployComposeHA);
|
|
42
|
-
}
|
|
43
|
-
if (mode === 'kubernetes') {
|
|
44
|
-
return import('./k8s/k3s.js').then((m) => m.deployK3s);
|
|
45
|
-
}
|
|
46
|
-
return import('./compose/index.js').then((m) => m.deployCompose);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get the appropriate destroy function based on mode
|
|
51
|
-
* @param {string} mode - 'compose', 'compose-ha', 'kubernetes', or 'ha'
|
|
52
|
-
* @returns {Promise<Function>} Destroy function
|
|
53
|
-
*/
|
|
54
|
-
export function getDestroyFunction(mode = 'compose') {
|
|
55
|
-
if (mode === 'ha') {
|
|
56
|
-
return import('./k8s/ha/index.js').then((m) => m.destroyK8sHA);
|
|
57
|
-
}
|
|
58
|
-
if (mode === 'compose-ha') {
|
|
59
|
-
return import('./compose/ha.js').then((m) => m.destroyComposeHA);
|
|
60
|
-
}
|
|
61
|
-
if (mode === 'kubernetes') {
|
|
62
|
-
return import('./k8s/index.js').then((m) => m.destroyK8s);
|
|
63
|
-
}
|
|
64
|
-
return import('./compose/index.js').then((m) => m.destroyCompose);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Deployment mode information
|
|
69
|
-
*/
|
|
70
|
-
export const MODES = {
|
|
71
|
-
compose: {
|
|
72
|
-
name: 'Compose',
|
|
73
|
-
displayName: 'Docker Compose',
|
|
74
|
-
description: 'Single-server deployment with Docker Compose',
|
|
75
|
-
tier: 'diamond',
|
|
76
|
-
features: [
|
|
77
|
-
'Docker Compose on single VPS',
|
|
78
|
-
'Auto HTTPS via Traefik/Caddy',
|
|
79
|
-
'All add-ons supported',
|
|
80
|
-
'Direct image transfer (no registry needed)',
|
|
81
|
-
],
|
|
82
|
-
},
|
|
83
|
-
'compose-ha': {
|
|
84
|
-
name: 'Compose HA',
|
|
85
|
-
displayName: 'Docker Compose HA',
|
|
86
|
-
description: 'Multi-region Docker Compose with PostgreSQL replication',
|
|
87
|
-
tier: 'diamond',
|
|
88
|
-
features: [
|
|
89
|
-
'Docker Compose on 2 VPS',
|
|
90
|
-
'PostgreSQL streaming replication',
|
|
91
|
-
'Cloudflare health checks + failover',
|
|
92
|
-
'Auto HTTPS via Traefik',
|
|
93
|
-
],
|
|
94
|
-
},
|
|
95
|
-
standard: {
|
|
96
|
-
name: 'Standard',
|
|
97
|
-
displayName: 'Kubernetes',
|
|
98
|
-
description: 'Single-region Kubernetes cluster',
|
|
99
|
-
tier: 'fullerene',
|
|
100
|
-
features: [
|
|
101
|
-
'Kubernetes cluster (1 master + workers)',
|
|
102
|
-
'Horizontal Pod Autoscaling',
|
|
103
|
-
'Traefik ingress with auto-SSL',
|
|
104
|
-
'Local Docker build & push',
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
ha: {
|
|
108
|
-
name: 'HA',
|
|
109
|
-
displayName: 'Kubernetes HA',
|
|
110
|
-
description: 'Multi-region Kubernetes with high availability',
|
|
111
|
-
tier: 'fullerene',
|
|
112
|
-
features: [
|
|
113
|
-
'Multi-region clusters',
|
|
114
|
-
'PostgreSQL streaming replication',
|
|
115
|
-
'One-command failover',
|
|
116
|
-
'DNS health checks',
|
|
117
|
-
],
|
|
118
|
-
},
|
|
119
|
-
};
|