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.
Files changed (56) hide show
  1. package/carbon/backup/compose-backup.sh +45 -121
  2. package/carbon/db/Dockerfile +14 -3
  3. package/carbon/docker-compose.dns01.prod.yml +42 -0
  4. package/carbon/docker-compose.metabase.prod.yml +1 -2
  5. package/carbon/docker-compose.n8n.prod.yml +1 -2
  6. package/carbon/docker-compose.prod.yml +9 -7
  7. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  8. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  9. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  10. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  11. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  12. package/carbon/k8s/base/network-policies.yaml +33 -0
  13. package/carbon/k8s/base/traefik/certificate.yaml +13 -7
  14. package/carbon/k8s/values/supabase.values.yaml +152 -0
  15. package/carbon/scripts/docker-up.js +13 -1
  16. package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
  17. package/carbon/src/client/locales/de.json +29 -0
  18. package/carbon/src/client/locales/en.json +29 -0
  19. package/carbon/src/client/locales/es.json +29 -0
  20. package/carbon/src/client/locales/fr.json +29 -0
  21. package/carbon/src/client/locales/pt.json +29 -0
  22. package/carbon/src/client/pages/Home.tsx +5 -0
  23. package/package.json +1 -2
  24. package/src/backup.js +13 -66
  25. package/src/create.js +9 -4
  26. package/src/deploy.js +12 -0
  27. package/src/destroy.js +5 -43
  28. package/src/lib/backup-s3.js +0 -31
  29. package/src/lib/cloudflare.js +0 -59
  30. package/src/lib/config.js +1 -42
  31. package/src/lib/deploy/acme.js +57 -0
  32. package/src/lib/deploy/admin-user.js +105 -0
  33. package/src/lib/deploy/bundle.js +137 -5
  34. package/src/lib/deploy/compose/ha.js +208 -92
  35. package/src/lib/deploy/compose/index.js +292 -424
  36. package/src/lib/deploy/k8s/ha/index.js +12 -148
  37. package/src/lib/deploy/k8s/index.js +1 -21
  38. package/src/lib/deploy/k8s/k3s.js +407 -168
  39. package/src/lib/deploy/orchestrator.js +90 -33
  40. package/src/lib/deploy/utils.js +20 -0
  41. package/src/lib/hetzner-guided-setup.js +0 -7
  42. package/src/lib/iac/index.js +45 -16
  43. package/src/lib/images.js +17 -0
  44. package/src/lib/licensing/index.js +1 -59
  45. package/src/lib/licensing/tiers.js +0 -8
  46. package/src/lib/pod-backups.js +53 -0
  47. package/src/lib/providers/index.js +0 -47
  48. package/src/lib/ssh.js +12 -0
  49. package/src/restore.js +216 -302
  50. package/src/scale.js +76 -77
  51. package/carbon/backup/Dockerfile +0 -75
  52. package/carbon/backup/backup.sh +0 -95
  53. package/carbon/runtime.Dockerfile +0 -17
  54. package/src/lib/deploy/compose/acme-verify.js +0 -121
  55. package/src/lib/deploy/index.js +0 -119
  56. 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, unlinkSync } from 'node:fs';
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 { scpDownload, scpUpload, sshRun, sshRunScript } from './lib/ssh.js';
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 (--clean so restore can run without DROP DATABASE)
298
+ // 1. Backup database on old server via wal-g (pushes directly to S3)
294
299
  s.start(`Backing up database on ${label}...`);
295
- const archiveName = await perfAsync('scale.backupOldServer', async () =>
296
- backupCompose(server.ip, sshKeyPath, projectName, { clean: true }),
300
+ await perfAsync('scale.backupOldServer', async () =>
301
+ backupCompose(server.ip, sshKeyPath, projectName, {
302
+ retain: envConfig.backup?.retentionDays,
303
+ }),
297
304
  );
298
- s.stop(`Database backed up: ${archiveName}`);
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. Wait for DB to be ready (backup uses --clean so no DROP DATABASE needed)
477
- s.start('Waiting for database to be ready...');
478
- const remoteDir = `/opt/${projectName}`;
479
- // Shell loop + `$(...)` needs a real shell; projectName is basename-validated.
480
- sshRunScript(
481
- newIp,
482
- sshKeyPath,
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, archiveName),
499
+ restoreCompose(newIp, sshKeyPath, projectName, 'latest'),
508
500
  );
509
501
  s.stop('Database restored');
510
502
 
511
- // 9a. Update DNS BEFORE recreating services so Traefik can get ACME certs.
512
- // Traefik attempts HTTP-01 challenges immediately on startup; if DNS still
513
- // points to the old server, all challenges fail and the next retry is 30+ min.
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
- // Poll until Cloudflare/Google DNS returns the new IP (max 120s).
520
- // Fixed waits are unreliable propagation speed varies per resolver.
521
- s.start('Waiting for DNS propagation...');
522
- const dnsOk = await perfAsync('scale.waitForDNS', () =>
523
- waitForDNSPropagation(domain, newIp, 120_000),
524
- );
525
- s.stop(dnsOk ? 'DNS propagated' : 'DNS propagation timed out (proceeding anyway)');
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. Reset ACME state so Traefik retries cert challenges with the correct DNS.
529
- // startComposeStack (step 6) ran before DNS was updated — all challenges failed
530
- // and are cached as errors in the letsencrypt_data volume.
531
- // Use docker compose exec (Traefik is already running) instead of pulling alpine.
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
- sshRunScript(
534
- newIp,
535
- sshKeyPath,
536
- `cd ${remoteDir} && docker compose exec -T traefik sh -c 'echo "{}" > /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json'`,
537
- { timeout: 30_000 },
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. envConfig only persists
567
- // bucket/region/endpoint under `s3` accessKey/secretKey live in
568
- // ~/.vibecarbon/credentials.json (loaded into `creds` at step 4b).
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
- const backupS3Config =
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
 
@@ -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"]
@@ -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 };
@@ -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
- };