vibecarbon 0.3.1 → 0.4.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 (42) hide show
  1. package/carbon/db/Dockerfile +14 -3
  2. package/carbon/docker-compose.dns01.prod.yml +42 -0
  3. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  4. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  5. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  6. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  7. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  8. package/carbon/k8s/base/network-policies.yaml +33 -0
  9. package/carbon/k8s/values/supabase.values.yaml +144 -0
  10. package/package.json +1 -2
  11. package/src/backup.js +2 -36
  12. package/src/create.js +9 -4
  13. package/src/deploy.js +12 -0
  14. package/src/lib/backup-s3.js +0 -31
  15. package/src/lib/cloudflare.js +0 -59
  16. package/src/lib/config.js +1 -42
  17. package/src/lib/deploy/acme.js +57 -0
  18. package/src/lib/deploy/admin-user.js +105 -0
  19. package/src/lib/deploy/bundle.js +17 -0
  20. package/src/lib/deploy/compose/ha.js +94 -91
  21. package/src/lib/deploy/compose/index.js +50 -248
  22. package/src/lib/deploy/k8s/ha/index.js +12 -148
  23. package/src/lib/deploy/k8s/index.js +1 -21
  24. package/src/lib/deploy/k8s/k3s.js +314 -128
  25. package/src/lib/deploy/orchestrator.js +70 -35
  26. package/src/lib/deploy/utils.js +20 -0
  27. package/src/lib/hetzner-guided-setup.js +0 -7
  28. package/src/lib/iac/index.js +0 -9
  29. package/src/lib/images.js +17 -0
  30. package/src/lib/licensing/index.js +1 -59
  31. package/src/lib/licensing/tiers.js +0 -8
  32. package/src/lib/pod-backups.js +53 -0
  33. package/src/lib/providers/index.js +0 -47
  34. package/src/lib/ssh.js +12 -0
  35. package/src/restore.js +98 -265
  36. package/src/scale.js +43 -20
  37. package/carbon/backup/Dockerfile +0 -75
  38. package/carbon/backup/backup.sh +0 -95
  39. package/carbon/runtime.Dockerfile +0 -17
  40. package/src/lib/deploy/compose/acme-verify.js +0 -121
  41. package/src/lib/deploy/index.js +0 -119
  42. package/src/lib/kubectl.js +0 -81
@@ -1,20 +1,31 @@
1
1
  # syntax=docker/dockerfile:1
2
2
  FROM supabase/postgres:15.8.1.085
3
3
 
4
- # Install WAL-G v3.0.5 kept in lockstep with carbon/backup/Dockerfile.
4
+ # Install WAL-G v3.0.5. This image IS the backup tooling now (both compose
5
+ # and k8s): wal-g runs co-located with PGDATA in this container for continuous
6
+ # WAL archiving, base backups, and restore — no separate backup image.
5
7
  # Earlier v3.0.3 URL 404'd in 2026-04-26 (alpine variant retired upstream),
6
8
  # and curl without --fail piped a 404 HTML body into tar which surfaced
7
9
  # as "not in gzip format" instead of an honest HTTP error. --fail makes
8
10
  # curl exit non-zero on 4xx/5xx so the failure is unmistakable.
9
11
  #
12
+ # Build target MUST match the base image's glibc: supabase/postgres:15.8.1.085
13
+ # is Ubuntu 20.04 (glibc 2.31). The ubuntu-22.04 wal-g build is linked against
14
+ # glibc 2.35 and dies at runtime with `version 'GLIBC_2.3{2,3,4}' not found`
15
+ # — which breaks BOTH base backups and WAL archiving (the archive_command
16
+ # shells out to wal-g), and is invisible at deploy time because we only assert
17
+ # archive_mode=on, never that wal-g actually runs. The ubuntu-20.04 build links
18
+ # against glibc 2.31 and runs on this base. (RCA 2026-05-30: e2e k8s/k8s-ha
19
+ # backup step — wal-g exited 1 with the GLIBC error inside the db pod.)
20
+ #
10
21
  # Single RUN with `apt-get purge -y --auto-remove curl` collapses the
11
22
  # install + cleanup into one layer so the curl binary doesn't end up
12
23
  # in the published image (saving ~5MB and reducing attack surface).
13
24
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
25
  curl \
15
26
  ca-certificates \
16
- && curl -fL https://github.com/wal-g/wal-g/releases/download/v3.0.5/wal-g-pg-ubuntu-22.04-amd64.tar.gz | tar -xz \
17
- && mv wal-g-pg-ubuntu-22.04-amd64 /usr/local/bin/wal-g \
27
+ && curl -fL https://github.com/wal-g/wal-g/releases/download/v3.0.5/wal-g-pg-ubuntu-20.04-amd64.tar.gz | tar -xz \
28
+ && mv wal-g-pg-ubuntu-20.04-amd64 /usr/local/bin/wal-g \
18
29
  && chmod +x /usr/local/bin/wal-g \
19
30
  && apt-get purge -y --auto-remove curl \
20
31
  && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,42 @@
1
+ # DNS-01 ACME override — applied ONLY for managed-DNS deploys (cloudflare / hetzner).
2
+ #
3
+ # Activated by the deploy bundler (src/lib/deploy/bundle.js -> composeFileFlags)
4
+ # when the deploy manages DNS via an API. It is appended AFTER
5
+ # docker-compose.prod.yml, so the `command:` below REPLACES the base Traefik
6
+ # command — Compose replaces sequences, it does not merge them. That means the
7
+ # full command list must be restated here; keep every non-ACME flag in sync
8
+ # with docker-compose.prod.yml. Only the two `httpchallenge` lines are swapped
9
+ # for the three `dnschallenge` lines.
10
+ #
11
+ # Why: cloudflare/hetzner expose an API lego (Traefik's ACME client) uses to
12
+ # solve the DNS-01 challenge by writing a TXT record. This removes the HTTP-01
13
+ # race against A-record propagation, so the deploy no longer needs the
14
+ # DNS-propagation poll or the Traefik cert self-heal. `manual` DNS keeps the
15
+ # base HTTP-01 path (this override is not applied for it).
16
+ #
17
+ # Tokens: lego picks the provider from ACME_DNS_PROVIDER and reads its token.
18
+ # Hetzner uses the consolidated Cloud API token HETZNER_API_TOKEN (lego v4.27+,
19
+ # Traefik >= 3.6.6; we pin v3.6.11) — the same token as server ops. Cloudflare
20
+ # uses CF_DNS_API_TOKEN. Both are passed through; only the active provider's
21
+ # token is read. All three are populated in .env by the bundler.
22
+ services:
23
+ traefik:
24
+ command:
25
+ - "--api.dashboard=true"
26
+ - "--providers.docker=true"
27
+ - "--providers.docker.endpoint=tcp://docker-socket-proxy:2375"
28
+ - "--providers.docker.exposedbydefault=false"
29
+ - "--providers.file.directory=/etc/traefik/dynamic"
30
+ - "--entrypoints.web.address=:80"
31
+ - "--entrypoints.websecure.address=:443"
32
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
33
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=${ACME_DNS_PROVIDER}"
34
+ - "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
35
+ - "--certificatesresolvers.letsencrypt.acme.email={{ADMIN_EMAIL}}"
36
+ - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
37
+ - "--certificatesresolvers.letsencrypt.acme.caserver=${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}"
38
+ - "--log.level=WARN"
39
+ environment:
40
+ ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
41
+ CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:-}
42
+ HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
@@ -0,0 +1,47 @@
1
+ # Fault-tolerant WAL-archive wrapper mounted into the supabase-db container at
2
+ # /etc/postgresql/wal-archive.sh (see k8s/values/supabase.values.yaml
3
+ # deployment.db.volumes/volumeMounts). Postgres' archive_command invokes it as
4
+ # `bash /etc/postgresql/wal-archive.sh %p` (enabled via ALTER SYSTEM in
5
+ # applyK3sManifests). wal-g reads WALG_S3_PREFIX / AWS_* from the db
6
+ # container's env.
7
+ #
8
+ # IMPORTANT: keep this script body byte-for-byte in sync with
9
+ # carbon/volumes/db/wal-archive.sh (the compose mount). Same retry + exit-0
10
+ # semantics: a persistent S3 outage degrades PITR for the failed segment but
11
+ # keeps the DB up instead of filling the disk.
12
+ apiVersion: v1
13
+ kind: ConfigMap
14
+ metadata:
15
+ name: vibecarbon-wal-archive
16
+ namespace: vibecarbon
17
+ labels:
18
+ app: vibecarbon-backup
19
+ component: backup
20
+ data:
21
+ wal-archive.sh: |
22
+ #!/bin/bash
23
+ # Fault-tolerant archive_command for Postgres + wal-g.
24
+ # Invoked by postgres as: wal-archive.sh <wal-file-path>
25
+ # (%p — absolute path to the WAL segment). Retries with backoff, then
26
+ # logs WAL_ARCHIVE_FAILED and exits 0 so PG recycles the segment rather
27
+ # than pinning it and filling the disk. Keep in sync with
28
+ # carbon/volumes/db/wal-archive.sh.
29
+ set -u
30
+ WAL_PATH="${1:?archive_command requires a WAL file path}"
31
+ RETRIES="${WAL_ARCHIVE_RETRIES:-3}"
32
+ SLEEP_BASE="${WAL_ARCHIVE_SLEEP:-2}"
33
+
34
+ attempt=1
35
+ while [ "$attempt" -le "$RETRIES" ]; do
36
+ if /usr/local/bin/wal-g wal-push "$WAL_PATH"; then
37
+ exit 0
38
+ fi
39
+ if [ "$attempt" -lt "$RETRIES" ]; then
40
+ sleep_for=$((SLEEP_BASE ** attempt))
41
+ sleep "$sleep_for"
42
+ fi
43
+ attempt=$((attempt + 1))
44
+ done
45
+
46
+ echo "WAL_ARCHIVE_FAILED: wal-g wal-push '$WAL_PATH' failed after $RETRIES attempts; allowing PG to recycle (PITR gap for this segment)" >&2
47
+ exit 0
@@ -1,3 +1,17 @@
1
+ # Nightly wal-g base backup.
2
+ #
3
+ # wal-g backup-push needs direct PGDATA filesystem access, which only the
4
+ # supabase-db pod has. So instead of a separate backup container that talks to
5
+ # postgres over the network (the old pg_dump design), this CronJob runs a tiny
6
+ # kubectl image and `kubectl exec`s wal-g INSIDE the db pod — where wal-g, the
7
+ # WALG_*/AWS_* env, and PGDATA already live (see k8s/values/supabase.values.yaml
8
+ # + the WAL-archiving setup in applyK3sManifests). No bespoke backup image, no
9
+ # sideload, no registry push.
10
+ #
11
+ # HA-safe: the command guards on pg_is_in_recovery()=f, so it only takes a base
12
+ # backup on the PRIMARY. On the standby (in recovery) it no-ops, and after a
13
+ # failover the new primary's CronJob transparently starts taking base backups —
14
+ # no per-cluster config.
1
15
  apiVersion: batch/v1
2
16
  kind: CronJob
3
17
  metadata:
@@ -23,98 +37,59 @@ spec:
23
37
  restartPolicy: OnFailure
24
38
  serviceAccountName: backup-sa
25
39
  containers:
26
- - name: backup
27
- image: ghcr.io/{{GITHUB_OWNER}}/{{PROJECT_NAME}}-backup:latest
28
- imagePullPolicy: Always
29
- env:
30
- - name: PGHOST
31
- valueFrom:
32
- configMapKeyRef:
33
- name: vibecarbon-config
34
- key: DB_HOST
35
- - name: PGPORT
36
- valueFrom:
37
- configMapKeyRef:
38
- name: vibecarbon-config
39
- key: DB_PORT
40
- - name: PGUSER
41
- value: supabase_admin
42
- - name: PGDATABASE
43
- valueFrom:
44
- configMapKeyRef:
45
- name: vibecarbon-config
46
- key: DB_NAME
47
- - name: PGPASSWORD
48
- valueFrom:
49
- secretKeyRef:
50
- name: vibecarbon-secrets
51
- key: DB_PASSWORD
52
- - name: S3_ACCESS_KEY
53
- valueFrom:
54
- secretKeyRef:
55
- name: vibecarbon-secrets
56
- key: S3_ACCESS_KEY
57
- optional: true
58
- - name: S3_SECRET_KEY
59
- valueFrom:
60
- secretKeyRef:
61
- name: vibecarbon-secrets
62
- key: S3_SECRET_KEY
63
- optional: true
64
- - name: S3_BUCKET
65
- valueFrom:
66
- secretKeyRef:
67
- name: vibecarbon-secrets
68
- key: S3_BUCKET
69
- optional: true
70
- - name: S3_ENDPOINT
71
- valueFrom:
72
- secretKeyRef:
73
- name: vibecarbon-secrets
74
- key: S3_ENDPOINT
75
- optional: true
76
- - name: S3_REGION
77
- valueFrom:
78
- secretKeyRef:
79
- name: vibecarbon-secrets
80
- key: S3_REGION
81
- optional: true
82
- - name: S3_BACKUP_BUCKET
83
- valueFrom:
84
- secretKeyRef:
85
- name: vibecarbon-secrets
86
- key: S3_BACKUP_BUCKET
87
- optional: true
88
- - name: BACKUP_RETENTION_DAYS
89
- valueFrom:
90
- configMapKeyRef:
91
- name: vibecarbon-config
92
- key: BACKUP_RETENTION_DAYS
93
- optional: true
94
- - name: STORAGE_BACKEND
95
- value: file
96
- # Use pg_dump (network protocol) instead of WAL-G in this
97
- # CronJob — WAL-G's backup-push needs direct filesystem access
98
- # to PGDATA, which only the supabase-db pod has. The backup
99
- # pod runs separately and connects via PGHOST, so it has to
100
- # use the libpq-based pg_dump path. Switching WAL-G on for
101
- # k8s would require a sidecar inside supabase-db.
102
- - name: BACKUP_MODE
103
- value: pg_dump
104
- - name: PROJECT_NAME
105
- valueFrom:
106
- configMapKeyRef:
107
- name: vibecarbon-config
108
- key: PROJECT_NAME
109
- resources:
110
- requests:
111
- cpu: 100m
112
- memory: 256Mi
113
- limits:
114
- cpu: 500m
115
- memory: 512Mi
116
- securityContext:
117
- allowPrivilegeEscalation: false
40
+ - name: backup
41
+ # Pinned public kubectl image — no build/sideload/push. Bump the
42
+ # tag deliberately; `latest` would risk a silent client/server
43
+ # skew on the scheduled run. Needs bash (the script below uses
44
+ # `set -euo pipefail`) + kubectl on PATH, so the distroless
45
+ # registry.k8s.io/kubectl is unusable. Was bitnami/kubectl:1.31
46
+ # until Bitnami sunset their public Docker Hub catalog (2025) and
47
+ # the tag vanished (`not found` → ImagePullBackOff); alpine/k8s
48
+ # is the maintained drop-in (alpine base, bash + kubectl).
49
+ image: alpine/k8s:1.31.12
50
+ imagePullPolicy: IfNotPresent
51
+ env:
52
+ - name: BACKUP_RETENTION_DAYS
53
+ valueFrom:
54
+ configMapKeyRef:
55
+ name: vibecarbon-config
56
+ key: BACKUP_RETENTION_DAYS
57
+ optional: true
58
+ command:
59
+ - /bin/bash
60
+ - -c
61
+ - |
62
+ set -euo pipefail
63
+ RETAIN="${BACKUP_RETENTION_DAYS:-7}"
64
+ # Exec wal-g inside the db pod. The guard makes this a no-op on
65
+ # a standby (in recovery) so only the primary takes base backups.
66
+ kubectl exec -n vibecarbon statefulset/supabase-supabase-db -- bash -c '
67
+ set -euo pipefail
68
+ # wal-g backup-push connects to Postgres (pg_backup_start) using
69
+ # libpq env. Unset, PGUSER defaults to the container OS user
70
+ # "root" → "role \"root\" does not exist". And only the cluster
71
+ # superuser may call pg_backup_start — supabase_admin is the sole
72
+ # superuser ("postgres" gets "permission denied for function
73
+ # pg_backup_start"). So pin the libpq connection explicitly.
74
+ # (wal-push/archiving and backup-fetch/restore do not connect to
75
+ # PG, which is why this only bit backup-push. RCA 2026-05-30.)
76
+ export PGUSER=supabase_admin PGHOST=localhost PGPORT=5432 PGDATABASE=postgres
77
+ if [ "$(psql -U supabase_admin -d postgres -tAc "SELECT pg_is_in_recovery()")" != "f" ]; then
78
+ echo "supabase-db is in recovery (standby) — skipping base backup."
79
+ exit 0
80
+ fi
81
+ wal-g backup-push "$PGDATA"
82
+ wal-g delete retain FULL '"${RETAIN}"' --confirm
83
+ '
84
+ resources:
85
+ requests:
86
+ cpu: 50m
87
+ memory: 64Mi
88
+ limits:
89
+ cpu: 250m
90
+ memory: 128Mi
91
+ securityContext:
92
+ allowPrivilegeEscalation: false
118
93
  securityContext:
119
94
  seccompProfile:
120
95
  type: RuntimeDefault
@@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
2
2
  kind: Kustomization
3
3
 
4
4
  resources:
5
+ - configmap-walg.yaml
5
6
  - cronjob.yaml
6
7
  - rbac.yaml
7
8
  - network-policy.yaml
@@ -1,3 +1,9 @@
1
+ # The backup CronJob pod no longer talks to postgres:5432 or S3 directly — it
2
+ # runs `kubectl exec` into the supabase-db pod (wal-g runs there). So its only
3
+ # egress need is the Kubernetes apiserver. DNS + intra-namespace egress come
4
+ # from the cluster-baseline policies (network-policies.yaml, podSelector {}).
5
+ # The wal-g → S3 egress now happens from the supabase-db pod, allowed by the
6
+ # supabase-db-s3-egress policy in network-policies.yaml.
1
7
  apiVersion: networking.k8s.io/v1
2
8
  kind: NetworkPolicy
3
9
  metadata:
@@ -10,22 +16,11 @@ spec:
10
16
  policyTypes:
11
17
  - Egress
12
18
  egress:
13
- # Connect to PostgreSQL for pg_dump
14
- - to:
15
- - podSelector:
16
- matchLabels:
17
- app: vibecarbon-postgres
18
- ports:
19
- - protocol: TCP
20
- port: 5432
21
- # Upload to S3 (external HTTPS)
19
+ # Kubernetes apiserver (kubernetes.default ClusterIP 10.43.0.1:443) so
20
+ # `kubectl exec` works. Mirrors the app/traefik apiserver-egress pattern.
22
21
  - to:
23
22
  - ipBlock:
24
- cidr: 0.0.0.0/0
25
- except:
26
- - 10.0.0.0/8
27
- - 172.16.0.0/12
28
- - 192.168.0.0/16
23
+ cidr: 10.43.0.1/32
29
24
  ports:
30
25
  - protocol: TCP
31
26
  port: 443
@@ -5,3 +5,43 @@ metadata:
5
5
  labels:
6
6
  app: vibecarbon-backup
7
7
  component: backup
8
+ ---
9
+ # The backup CronJob runs `kubectl exec` into the supabase-db pod to invoke
10
+ # wal-g backup-push (wal-g needs local PGDATA access). That requires
11
+ # pods/exec in the vibecarbon namespace. `pods` get/list is needed so
12
+ # `kubectl exec statefulset/...` can resolve the pod.
13
+ apiVersion: rbac.authorization.k8s.io/v1
14
+ kind: Role
15
+ metadata:
16
+ name: backup-exec
17
+ namespace: vibecarbon
18
+ labels:
19
+ app: vibecarbon-backup
20
+ component: backup
21
+ rules:
22
+ - apiGroups: [""]
23
+ resources: ["pods"]
24
+ verbs: ["get", "list"]
25
+ - apiGroups: [""]
26
+ resources: ["pods/exec"]
27
+ verbs: ["create"]
28
+ - apiGroups: ["apps"]
29
+ resources: ["statefulsets"]
30
+ verbs: ["get"]
31
+ ---
32
+ apiVersion: rbac.authorization.k8s.io/v1
33
+ kind: RoleBinding
34
+ metadata:
35
+ name: backup-exec
36
+ namespace: vibecarbon
37
+ labels:
38
+ app: vibecarbon-backup
39
+ component: backup
40
+ subjects:
41
+ - kind: ServiceAccount
42
+ name: backup-sa
43
+ namespace: vibecarbon
44
+ roleRef:
45
+ kind: Role
46
+ name: backup-exec
47
+ apiGroup: rbac.authorization.k8s.io
@@ -93,3 +93,36 @@ spec:
93
93
  port: 53
94
94
  - protocol: TCP
95
95
  port: 53
96
+
97
+ ---
98
+ # Allow the supabase-db pod to egress to S3 (HTTPS) for wal-g: continuous WAL
99
+ # archiving (archive_command → wal-g wal-push) and the nightly base backup
100
+ # (kubectl exec → wal-g backup-push) both run INSIDE the db container and push
101
+ # to Hetzner Object Storage. Without this, the implicit default-deny-egress
102
+ # (allow-dns/allow-intra-namespace-egress select all pods with Egress type)
103
+ # drops the db pod's outbound 443 and wal-g hangs/fails.
104
+ # Selector matches the chart-generated db pod labels; base kustomize `labels:`
105
+ # does not include selectors, so these are not mutated.
106
+ apiVersion: networking.k8s.io/v1
107
+ kind: NetworkPolicy
108
+ metadata:
109
+ name: supabase-db-s3-egress
110
+ namespace: vibecarbon
111
+ spec:
112
+ podSelector:
113
+ matchLabels:
114
+ app.kubernetes.io/name: supabase-db
115
+ app.kubernetes.io/instance: supabase
116
+ policyTypes:
117
+ - Egress
118
+ egress:
119
+ - to:
120
+ - ipBlock:
121
+ cidr: 0.0.0.0/0
122
+ except:
123
+ - 10.0.0.0/8
124
+ - 172.16.0.0/12
125
+ - 192.168.0.0/16
126
+ ports:
127
+ - protocol: TCP
128
+ port: 443
@@ -54,6 +54,18 @@ secret:
54
54
  password: DB_PASSWORD
55
55
  database: DB_NAME
56
56
 
57
+ # Override the db image with vibecarbon's wal-g-equipped build (carbon/db/
58
+ # Dockerfile: FROM supabase/postgres + wal-g v3.0.5). The stock chart image
59
+ # has no wal-g, which is why k8s historically had no WAL archiving / PITR.
60
+ # Built + sideloaded to the supabase node by deployK3s (k3s-db-build /
61
+ # k3s-db-sideload); IfNotPresent so the sideloaded image is used, not pulled.
62
+ # {{DB_IMAGE}}/{{DB_IMAGE_TAG}} substituted at deploy time in installSupabase.
63
+ image:
64
+ db:
65
+ repository: "{{DB_IMAGE}}"
66
+ tag: "{{DB_IMAGE_TAG}}"
67
+ pullPolicy: IfNotPresent
68
+
57
69
  # Component enable toggles — chart's path is deployment.<comp>.enabled.
58
70
  # Top-level `functions:`/`analytics:`/`vector:` are silently ignored
59
71
  # (was the bug behind the 2026-04-26 e2e helm-wait timeout).
@@ -81,6 +93,121 @@ deployment:
81
93
  operator: Equal
82
94
  value: supabase
83
95
  effect: NoSchedule
96
+ # Headroom for the daily wal-g base backup, which runs INSIDE this
97
+ # container's cgroup (via `kubectl exec`, see k8s/base/backup/cronjob.yaml).
98
+ # backup-push streams PGDATA → lz4 → S3; size the limit for the backup
99
+ # window, not steady state, or it throttles postgres too. WALG_UPLOAD_
100
+ # CONCURRENCY is kept at 2 (vs compose's 4) to bound the burst.
101
+ resources:
102
+ requests:
103
+ cpu: 250m
104
+ memory: 512Mi
105
+ limits:
106
+ cpu: "2"
107
+ memory: 2Gi
108
+ # Mount the fault-tolerant WAL-archive wrapper that postgres'
109
+ # archive_command calls (enabled at runtime via ALTER SYSTEM in
110
+ # applyK3sManifests). Script body lives in the vibecarbon-wal-archive
111
+ # ConfigMap (k8s/base/backup/configmap-walg.yaml) — verbatim copy of
112
+ # carbon/volumes/db/wal-archive.sh (retry + exit-0 prevents disk-fill).
113
+ volumeMounts:
114
+ - name: wal-archive-script
115
+ mountPath: /etc/postgresql/wal-archive.sh
116
+ subPath: wal-archive.sh
117
+ readOnly: true
118
+ - name: walg-s3-creds
119
+ mountPath: /etc/walg
120
+ readOnly: true
121
+ volumes:
122
+ - name: wal-archive-script
123
+ configMap:
124
+ # Invoked as `bash /etc/postgresql/wal-archive.sh` (archive_command),
125
+ # so no exec bit is required on the mounted file.
126
+ name: vibecarbon-wal-archive
127
+ - name: walg-s3-creds
128
+ secret:
129
+ secretName: vibecarbon-secrets
130
+ # Optional so a no-S3 (dev) deploy still schedules the db pod; wal-g
131
+ # is simply unconfigured then. items maps the INI key → credentials.
132
+ optional: true
133
+ items:
134
+ - key: S3_CREDENTIALS_INI
135
+ path: credentials
136
+ # Restore bootstrap: a marker-gated init container that, ONLY when a
137
+ # restore is requested (RESTORE_TARGET set in vibecarbon-secrets), fetches
138
+ # the latest wal-g base backup into PGDATA before postgres starts. On a
139
+ # normal boot RESTORE_TARGET is empty and the init container is a no-op.
140
+ # Uses the same wal-g-equipped db image. RESTORE_TARGET is an OPTIONAL
141
+ # secretKeyRef so deploys without the key still schedule.
142
+ extraInitContainers:
143
+ - name: walg-restore
144
+ image: "{{DB_IMAGE}}:{{DB_IMAGE_TAG}}"
145
+ imagePullPolicy: IfNotPresent
146
+ command:
147
+ - /bin/bash
148
+ - -c
149
+ - |
150
+ set -euo pipefail
151
+ if [ -z "${RESTORE_TARGET:-}" ]; then
152
+ echo "walg-restore: RESTORE_TARGET empty — normal boot, skipping."
153
+ exit 0
154
+ fi
155
+ echo "walg-restore: RESTORE_TARGET=${RESTORE_TARGET} — restoring from S3."
156
+ if [ -f "${PGDATA}/PG_VERSION" ]; then
157
+ echo "walg-restore: existing PGDATA — clearing for backup-fetch."
158
+ rm -rf "${PGDATA:?}/"* "${PGDATA:?}/".* 2>/dev/null || true
159
+ fi
160
+ # Fetch the latest base backup into PGDATA. (For PITR the base that
161
+ # precedes the target is also the latest base — base backups are
162
+ # frequent, so LATEST is the right starting point either way.)
163
+ wal-g backup-fetch "${PGDATA}" LATEST
164
+ # Configure archive recovery. A fetched base backup is only
165
+ # consistent AFTER replaying the WAL from its redo point to its
166
+ # stop point — and that WAL lives in S3, not in the fetched PGDATA.
167
+ # Without restore_command + recovery.signal postgres aborts startup
168
+ # with "could not locate required checkpoint record" (the bug this
169
+ # fixes — the `latest` branch previously did neither). restore_command
170
+ # pulls each segment back from S3 via `wal-g wal-fetch`; recovery.signal
171
+ # puts postgres into archive recovery; on reaching the end of WAL (or
172
+ # the PITR target) it promotes to read-write. (RCA 2026-05-31: e2e
173
+ # k8s/k8s-ha restore — postgres crash-looped post-fetch.)
174
+ {
175
+ echo "restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'"
176
+ echo "recovery_target_action = 'promote'"
177
+ if [ "${RESTORE_TARGET}" != "latest" ]; then
178
+ # PITR: RESTORE_TARGET is an ISO-8601 timestamp — replay only up
179
+ # to that point instead of to the end of the WAL stream.
180
+ echo "recovery_target_time = '${RESTORE_TARGET}'"
181
+ fi
182
+ } >> "${PGDATA}/postgresql.auto.conf"
183
+ touch "${PGDATA}/recovery.signal"
184
+ echo "walg-restore: backup-fetch complete; archive recovery configured."
185
+ env:
186
+ - name: PGDATA
187
+ value: /var/lib/postgresql/data
188
+ - name: RESTORE_TARGET
189
+ valueFrom:
190
+ secretKeyRef:
191
+ name: vibecarbon-secrets
192
+ key: RESTORE_TARGET
193
+ optional: true
194
+ - name: WALG_S3_PREFIX
195
+ value: "{{WALG_S3_PREFIX}}"
196
+ - name: WALG_COMPRESSION_METHOD
197
+ value: lz4
198
+ - name: AWS_ENDPOINT
199
+ value: "{{S3_ENDPOINT}}"
200
+ - name: AWS_REGION
201
+ value: "{{S3_REGION}}"
202
+ - name: AWS_SHARED_CREDENTIALS_FILE
203
+ value: /etc/walg/credentials
204
+ volumeMounts:
205
+ - name: postgres-volume
206
+ mountPath: /var/lib/postgresql/data
207
+ subPath: postgres-data
208
+ - name: walg-s3-creds
209
+ mountPath: /etc/walg
210
+ readOnly: true
84
211
 
85
212
  # Per-component environment — chart's path is environment.<comp>.
86
213
  environment:
@@ -99,6 +226,23 @@ environment:
99
226
  GOTRUE_MAILER_AUTOCONFIRM: "true"
100
227
  realtime:
101
228
  DB_NAME: postgres
229
+ # Non-secret WAL-G env for the db container (continuous WAL archiving +
230
+ # the exec'd base-backup CronJob both read these). The chart renders
231
+ # environment.<comp> as plain `value:` strings (no valueFrom), so the
232
+ # SECRET keys (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) are injected
233
+ # separately via a StatefulSet env patch in applyK3sManifests.
234
+ # {{WALG_S3_PREFIX}} resolves to s3://<backup-bucket|storage-bucket>/
235
+ # backups/<project>/walg (fallback handled in installSupabase).
236
+ db:
237
+ WALG_S3_PREFIX: "{{WALG_S3_PREFIX}}"
238
+ WALG_COMPRESSION_METHOD: lz4
239
+ WALG_UPLOAD_CONCURRENCY: "2"
240
+ AWS_ENDPOINT: "{{S3_ENDPOINT}}"
241
+ AWS_REGION: "{{S3_REGION}}"
242
+ # wal-g (AWS SDK) reads S3 creds from this INI file, mounted from
243
+ # vibecarbon-secrets (deployment.db.volumes below). File-mount, not env,
244
+ # so it's helm-owned and survives every upgrade with no db restart.
245
+ AWS_SHARED_CREDENTIALS_FILE: /etc/walg/credentials
102
246
  storage:
103
247
  STORAGE_BACKEND: s3
104
248
  imgproxy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
@@ -89,7 +89,6 @@
89
89
  "autocannon": "^8.0.0",
90
90
  "better-sqlite3": "^12.8.0",
91
91
  "conventional-changelog-conventionalcommits": "^9.3.1",
92
- "msw": "^2.12.14",
93
92
  "semantic-release": "^25.0.3",
94
93
  "tsx": "^4.21.0",
95
94
  "vite": "^8.0.14",
package/src/backup.js CHANGED
@@ -30,6 +30,7 @@ import { getS3Credentials } from './lib/hetzner-guided-setup.js';
30
30
  import { requireLicense } from './lib/licensing/index.js';
31
31
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
32
32
  import { perfAsync } from './lib/perf.js';
33
+ import { listPodBackups } from './lib/pod-backups.js';
33
34
  import { assertInProjectDir } from './lib/project-guard.js';
34
35
  import {
35
36
  getPostgresPod,
@@ -101,41 +102,6 @@ const ACTION_CHOICES = [
101
102
  // LEGACY POD HELPERS (used when a k8s env has no S3 configured)
102
103
  // ============================================================================
103
104
 
104
- async function listPodBackups(ip, sshKeyPath) {
105
- const pod = getPostgresPod(ip, sshKeyPath);
106
-
107
- try {
108
- const output = sshKubectl(ip, sshKeyPath, [
109
- 'exec',
110
- '-n',
111
- 'vibecarbon',
112
- pod,
113
- '--',
114
- 'sh',
115
- '-c',
116
- 'ls -lhS /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS',
117
- ]);
118
-
119
- if (output === 'NO_BACKUPS' || !output) {
120
- return [];
121
- }
122
-
123
- return output
124
- .split('\n')
125
- .filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
126
- .map((line) => {
127
- const parts = line.split(/\s+/);
128
- return {
129
- size: parts[4],
130
- date: `${parts[5]} ${parts[6]} ${parts[7]}`,
131
- name: parts[parts.length - 1].replace('/backups/', ''),
132
- };
133
- });
134
- } catch {
135
- return [];
136
- }
137
- }
138
-
139
105
  async function downloadPodBackup(ip, sshKeyPath, filename) {
140
106
  const pod = getPostgresPod(ip, sshKeyPath);
141
107
 
@@ -613,7 +579,7 @@ async function runList({
613
579
  return;
614
580
  }
615
581
 
616
- const backups = await listPodBackups(serverIp, sshKeyPath);
582
+ const backups = listPodBackups(serverIp, sshKeyPath, { sort: 'largest' });
617
583
  s.stop('Backups retrieved');
618
584
  printBackupList(backups, envName);
619
585
  }