vibecarbon 0.3.0 → 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 +3 -3
  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
@@ -41,6 +41,7 @@ import { runCommandAsync } from '../../command.js';
41
41
  import { knownHostsPath } from '../../host-keys.js';
42
42
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
43
43
  import { perfAsync } from '../../perf.js';
44
+ import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
44
45
 
45
46
  export const K3S_VERSION = 'v1.31.5+k3s1';
46
47
 
@@ -1046,9 +1047,18 @@ export async function pushImageToLocalRegistry({
1046
1047
  const openTunnel = openTunnelOrWalk;
1047
1048
 
1048
1049
  /** Push the localhost-aliased tag through the local-forwarded port. */
1049
- const push = () => {
1050
- execFileSync('docker', ['push', localTag], { stdio: 'inherit' });
1051
- };
1050
+ // Async spawn (not execFileSync): a docker push of a multi-hundred-MB image
1051
+ // takes minutes, and a synchronous call blocks Node's event loop the whole
1052
+ // time — freezing the spinner/log output and leaking unreaped child
1053
+ // processes (SIGCHLD can't fire while blocked). spawn keeps the loop live.
1054
+ const push = () =>
1055
+ new Promise((resolve, reject) => {
1056
+ const child = spawn('docker', ['push', localTag], { stdio: 'inherit' });
1057
+ child.on('error', (err) => reject(new Error(`docker push spawn failed: ${err.message}`)));
1058
+ child.on('exit', (code) =>
1059
+ code === 0 ? resolve(undefined) : reject(new Error(`docker push exited with code ${code}`)),
1060
+ );
1061
+ });
1052
1062
 
1053
1063
  // Three attempts with progressive backoff. Failure modes we're
1054
1064
  // covering, in priority order:
@@ -1094,7 +1104,7 @@ export async function pushImageToLocalRegistry({
1094
1104
  taggedAliases.add(localTag);
1095
1105
  }
1096
1106
  await delay(settleDelays[attempt]);
1097
- push();
1107
+ await push();
1098
1108
  pushed = true;
1099
1109
  break;
1100
1110
  } catch (err) {
@@ -1201,6 +1211,14 @@ export async function applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, b
1201
1211
  stringData.S3_BUCKET = s3Config.bucket;
1202
1212
  stringData.S3_ENDPOINT = s3Config.endpoint;
1203
1213
  stringData.S3_REGION = s3Config.region;
1214
+ // AWS-SDK-format credentials file for wal-g in the supabase-db pod. The
1215
+ // supabase Helm chart can't inject secret env on the db container
1216
+ // (environment.db is string-only), so instead of a post-helm env patch
1217
+ // (which helm would strip every upgrade → a db restart every deploy) we
1218
+ // mount THIS key as a file and point AWS_SHARED_CREDENTIALS_FILE at it
1219
+ // (volume is helm-owned → never stripped → no per-deploy restart). See
1220
+ // k8s/values/supabase.values.yaml deployment.db.volumes.
1221
+ stringData.S3_CREDENTIALS_INI = `[default]\naws_access_key_id=${s3Config.accessKey}\naws_secret_access_key=${s3Config.secretKey}\n`;
1204
1222
  }
1205
1223
  if (backupBucketName) {
1206
1224
  stringData.S3_BACKUP_BUCKET = backupBucketName;
@@ -1255,6 +1273,8 @@ export async function installSupabase({
1255
1273
  domain,
1256
1274
  s3Config,
1257
1275
  envLocal,
1276
+ dbImageTag,
1277
+ backupBucketName,
1258
1278
  }) {
1259
1279
  const env = { ...process.env, KUBECONFIG: kubeconfig };
1260
1280
  // Render the values template — substitute every {{TOKEN}} the template
@@ -1265,10 +1285,33 @@ export async function installSupabase({
1265
1285
  `installSupabase: ${valuesTemplatePath} not found. Project must include the supabase values template.`,
1266
1286
  );
1267
1287
  }
1288
+ if (!dbImageTag) {
1289
+ throw new Error(
1290
+ 'installSupabase: dbImageTag is required (the wal-g-equipped db image, built by k3s-db-build).',
1291
+ );
1292
+ }
1293
+ // Split the full image ref into repository + tag for the chart's
1294
+ // image.db.{repository,tag}. dbImageTag is `10.0.1.1:5000/<project>-db:<ver>`
1295
+ // — the registry host carries a port colon, so split on the LAST colon.
1296
+ const lastColon = dbImageTag.lastIndexOf(':');
1297
+ const dbImageRepo = dbImageTag.slice(0, lastColon);
1298
+ const dbImageVer = dbImageTag.slice(lastColon + 1);
1299
+ // WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
1300
+ // bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
1301
+ // leave it as s3:/// (wal-g would error).
1302
+ const walgBucket = backupBucketName || s3Config?.bucket || '';
1303
+ const walgS3Prefix = walgBucket
1304
+ ? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
1305
+ : '';
1268
1306
  const rendered = readFileSync(valuesTemplatePath, 'utf-8')
1269
1307
  .replace(/\{\{DOMAIN\}\}/g, domain || 'localhost')
1270
1308
  .replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
1271
- .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, s3Config?.bucket ?? '')
1309
+ .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
1310
+ .replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
1311
+ .replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
1312
+ .replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
1313
+ .replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
1314
+ .replace(/\{\{DB_IMAGE_TAG\}\}/g, dbImageVer)
1272
1315
  // Studio dashboard creds — sourced from .env.local at deploy time. Empty
1273
1316
  // string fallback keeps yaml valid; chart inlines them into the
1274
1317
  // chart-generated supabase-dashboard secret.
@@ -1587,6 +1630,99 @@ export async function reloadPostgrest({ kubeconfig }) {
1587
1630
  }
1588
1631
  }
1589
1632
 
1633
+ /**
1634
+ * Create the production app super-admin in `auth.users` via GoTrue's admin API.
1635
+ *
1636
+ * The k8s mirror of compose/index.js `createAdminUser`: identical GoTrue
1637
+ * admin-API contract (POST the super_admin payload with the service-role
1638
+ * bearer, idempotent on 422 via the shared admin-user helper), reached via
1639
+ * `kubectl port-forward svc/supabase-supabase-auth` instead of an `ssh -L`
1640
+ * tunnel. Goes direct to GoTrue (auth :9999, path `/admin/users`) — no Kong
1641
+ * `/auth/v1` prefix to strip.
1642
+ *
1643
+ * Without this, a k8s deploy shipped a prod app the operator couldn't log
1644
+ * into: ADMIN_EMAIL/ADMIN_PASSWORD only seeded the Supabase Studio dashboard
1645
+ * basic-auth secret, never an app `auth.users` row.
1646
+ *
1647
+ * @param {{kubeconfig: string, envLocal: Record<string,string>, localPort?: number}} args
1648
+ * @returns {Promise<{success: boolean, message: string}>}
1649
+ */
1650
+ export async function provisionAdminUser({ kubeconfig, envLocal, localPort = 15000 }) {
1651
+ const adminEmail = envLocal?.ADMIN_EMAIL;
1652
+ const adminPassword = envLocal?.ADMIN_PASSWORD;
1653
+ const serviceRoleKey = envLocal?.SERVICE_ROLE_KEY ?? envLocal?.SUPABASE_SERVICE_ROLE_KEY;
1654
+ if (!adminEmail || !adminPassword || !serviceRoleKey) {
1655
+ return { success: false, message: 'Admin credentials not found in .env.local' };
1656
+ }
1657
+
1658
+ const env = { ...process.env, KUBECONFIG: kubeconfig };
1659
+ // Background port-forward to GoTrue. Distinct localPort per cluster so the
1660
+ // parallel HA fan-out (primary + standby on one operator host) doesn't race
1661
+ // for the same bind — mirrors the 5000/5001 registry-tunnel split.
1662
+ const pf = spawn(
1663
+ 'kubectl',
1664
+ ['-n', 'vibecarbon', 'port-forward', 'svc/supabase-supabase-auth', `${localPort}:9999`],
1665
+ { env, stdio: 'ignore' },
1666
+ );
1667
+ try {
1668
+ const connected = await waitForGotrueHealth(`http://localhost:${localPort}/health`, {
1669
+ attempts: 20,
1670
+ intervalMs: 500,
1671
+ });
1672
+ if (!connected) {
1673
+ return { success: false, message: 'Could not reach GoTrue via kubectl port-forward' };
1674
+ }
1675
+ return await postAdminUser({
1676
+ adminUsersUrl: `http://localhost:${localPort}/admin/users`,
1677
+ serviceRoleKey,
1678
+ adminEmail,
1679
+ adminPassword,
1680
+ });
1681
+ } finally {
1682
+ pf.kill();
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * The four `ALTER SYSTEM` settings that turn on continuous WAL archiving for
1688
+ * wal-g on the chart-installed supabase-db.
1689
+ */
1690
+ export const WAL_ARCHIVING_SETTINGS = [
1691
+ "ALTER SYSTEM SET archive_mode='on'",
1692
+ "ALTER SYSTEM SET archive_command='bash /etc/postgresql/wal-archive.sh %p'",
1693
+ "ALTER SYSTEM SET archive_timeout='900'",
1694
+ "ALTER SYSTEM SET wal_level='replica'",
1695
+ ];
1696
+
1697
+ /**
1698
+ * Build the `kubectl exec … psql …` argument list that enables WAL archiving.
1699
+ *
1700
+ * Each `ALTER SYSTEM` MUST be its own `-c` option. psql sends a single `-c`
1701
+ * string as one simple-query request, and Postgres runs a multi-statement
1702
+ * simple query inside ONE implicit transaction — where `ALTER SYSTEM` is
1703
+ * rejected with "ALTER SYSTEM cannot run inside a transaction block". The
1704
+ * documented remedy (and the compose/ha.js pattern) is to pass each statement
1705
+ * as a separate `-c`, so every ALTER SYSTEM is its own implicit transaction.
1706
+ *
1707
+ * @param {string} dbPod - e.g. `supabase-supabase-db-0`
1708
+ * @returns {string[]}
1709
+ */
1710
+ export function enableWalArchivingPsqlArgs(dbPod) {
1711
+ return [
1712
+ '-n',
1713
+ 'vibecarbon',
1714
+ 'exec',
1715
+ dbPod,
1716
+ '--',
1717
+ 'psql',
1718
+ '-U',
1719
+ 'supabase_admin',
1720
+ '-d',
1721
+ 'postgres',
1722
+ ...WAL_ARCHIVING_SETTINGS.flatMap((stmt) => ['-c', stmt]),
1723
+ ];
1724
+ }
1725
+
1590
1726
  /**
1591
1727
  * Apply cert-manager + vibecarbon-secrets + the project's k8s/infra +
1592
1728
  * k8s/base kustomizations, install supabase via helm, patch the app
@@ -1595,18 +1731,21 @@ export async function reloadPostgrest({ kubeconfig }) {
1595
1731
  * Local-first by default — no Flux on the deploy path; Flux is opt-in
1596
1732
  * via `vibecarbon configure cicd`.
1597
1733
  *
1598
- * @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string, backupImageTag?: string, envLocal: Record<string,string>, domain: string, s3Config?: {accessKey: string, secretKey: string, bucket: string, endpoint: string, region: string}, backupBucketName?: string|null, dnsProvider?: string|null, cloudflareApiToken?: string|null, hetznerApiToken?: string|null, apiToken?: string, region?: string, environment?: string, minWorkers?: number, maxWorkers?: number, workerServerType?: string, k3sToken?: string, masterIp?: string, sshKeyPath?: string, khPath?: string}} args
1734
+ * @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string, dbImageTag: string, envLocal: Record<string,string>, domain: string, s3Config?: {accessKey: string, secretKey: string, bucket: string, endpoint: string, region: string}, backupBucketName?: string|null, restore?: string|null, dnsProvider?: string|null, cloudflareApiToken?: string|null, hetznerApiToken?: string|null, apiToken?: string, region?: string, environment?: string, minWorkers?: number, maxWorkers?: number, workerServerType?: string, k3sToken?: string, masterIp?: string, sshKeyPath?: string, khPath?: string}} args
1735
+ * `restore` (when set, e.g. 'latest' or an ISO timestamp) skips applyMigrations
1736
+ * — the wal-g-restored DB already carries schema_migrations.
1599
1737
  */
1600
1738
  export async function applyK3sManifests({
1601
1739
  kubeconfig,
1602
1740
  projectDir,
1603
1741
  projectName,
1604
1742
  imageTag,
1605
- backupImageTag,
1743
+ dbImageTag,
1606
1744
  envLocal,
1607
1745
  domain,
1608
1746
  s3Config,
1609
1747
  backupBucketName,
1748
+ restore,
1610
1749
  dnsProvider,
1611
1750
  cloudflareApiToken,
1612
1751
  hetznerApiToken,
@@ -1898,55 +2037,21 @@ export async function applyK3sManifests({
1898
2037
  if (!khPath) {
1899
2038
  throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
1900
2039
  }
1901
- // Push app + backup images in parallel. Each opens its own SSH tunnel,
1902
- // so they need DISTINCT localTunnelPorts pushImageToLocalRegistry's
1903
- // teardown is `pkill -f 'ssh.*-L.*<port>:localhost:5000'`, which would
1904
- // kill a sibling push's tunnel mid-operation if both used the same port.
1905
- // Offset the backup push by +100 so even the openTunnelOrWalk's 20-port
1906
- // fallback has 80 ports of headroom before colliding (plus HA's 5001
1907
- // standby cluster lands on +100=5101, well clear of primary's 5000+19).
1908
- // RCA: parallelizing without an offset would silently corrupt pushes
1909
- // (registry returns "blob unknown" on a teardown'd partial upload).
2040
+ // Push the app image to the in-cluster registry so CA-spawned workers
2041
+ // (which don't exist at sideload time) can pull it on first schedule.
2042
+ // The db image is NOT pushed here: it's pinned to the static supabase
2043
+ // node, reaches it via sideload, and the chart references it with
2044
+ // imagePullPolicy: IfNotPresent CA workers never run postgres.
1910
2045
  const appPushPort = localTunnelPort;
1911
- const backupPushPort = localTunnelPort + 100;
1912
- if (!masterIp) {
1913
- throw new Error('applyK3sManifests: masterIp required for registry push (Phase 6)');
1914
- }
1915
- if (!sshKeyPath) {
1916
- throw new Error('applyK3sManifests: sshKeyPath required for registry push (Phase 6)');
1917
- }
1918
- if (!khPath) {
1919
- throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
1920
- }
1921
- const pushTasks = [
1922
- perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
1923
- pushImageToLocalRegistry({
1924
- tag: imageTag,
1925
- masterIp,
1926
- sshKey: sshKeyPath,
1927
- khPath,
1928
- localTunnelPort: appPushPort,
1929
- }),
1930
- ),
1931
- ];
1932
- // 5b'''. Push the backup image too. The backup CronJob's pods may
1933
- // eventually schedule onto a CA-spawned worker (no node
1934
- // affinity pinning them to the static pool), so the same
1935
- // registry-fallback story applies.
1936
- if (backupImageTag) {
1937
- pushTasks.push(
1938
- perfAsync(`deploy.${perfPrefix}.registryPush.backup`, () =>
1939
- pushImageToLocalRegistry({
1940
- tag: backupImageTag,
1941
- masterIp,
1942
- sshKey: sshKeyPath,
1943
- khPath,
1944
- localTunnelPort: backupPushPort,
1945
- }),
1946
- ),
1947
- );
1948
- }
1949
- await Promise.all(pushTasks);
2046
+ await perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
2047
+ pushImageToLocalRegistry({
2048
+ tag: imageTag,
2049
+ masterIp,
2050
+ sshKey: sshKeyPath,
2051
+ khPath,
2052
+ localTunnelPort: appPushPort,
2053
+ }),
2054
+ );
1950
2055
  // 5a'. Apply cluster-autoscaler manifests SEPARATELY. CA lives in
1951
2056
  // kube-system but base/'s parent kustomization sets
1952
2057
  // `namespace: vibecarbon`, whose namespace transformer would
@@ -2148,53 +2253,109 @@ export async function applyK3sManifests({
2148
2253
  ['-n', 'vibecarbon', 'set', 'image', 'deployment/app', `app=${imageTag}`],
2149
2254
  { env, description: 'applyK3sManifests: kubectl set image deployment/app' },
2150
2255
  );
2151
- // 6b. Patch the backup CronJob image. base/backup/cronjob.yaml ships
2152
- // image=ghcr.io/<owner>/<project>-backup:latest — never pushed in
2153
- // direct/local mode, so the scheduled job pod fails ImagePullBackOff.
2154
- // deployK3s sideloaded the backup image to every node (step 7b);
2155
- // point the CronJob at it. imagePullPolicy: Always in the template
2156
- // would defeat the sideload — switch to IfNotPresent at the same
2157
- // time so kubelet uses the local copy.
2158
- if (backupImageTag) {
2159
- await runKubectlWithRetry(
2160
- ['-n', 'vibecarbon', 'set', 'image', 'cronjob/backup', `backup=${backupImageTag}`],
2161
- { env, description: 'applyK3sManifests: kubectl set image cronjob/backup' },
2162
- );
2163
- await runKubectlWithRetry(
2164
- [
2165
- '-n',
2166
- 'vibecarbon',
2167
- 'patch',
2168
- 'cronjob/backup',
2169
- '--type=json',
2170
- '-p',
2171
- JSON.stringify([
2172
- {
2173
- op: 'replace',
2174
- path: '/spec/jobTemplate/spec/template/spec/containers/0/imagePullPolicy',
2175
- value: 'IfNotPresent',
2176
- },
2177
- ]),
2178
- ],
2179
- { env, description: 'applyK3sManifests: kubectl patch cronjob/backup imagePullPolicy' },
2180
- );
2181
- }
2182
2256
  // 7. Install Supabase via helm. Blocks until release converges (15m
2183
- // timeout in chart). App pods may stay Pending on supabase URLs in
2184
- // their config until kong/auth/db are Ready, but that's fine they
2185
- // won't churn ImagePullBackOff because step 6 already set them to
2186
- // the sideloaded image.
2257
+ // timeout in chart). image.db points at the wal-g-equipped db image
2258
+ // (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
2259
+ // non-secret WALG_* env from supabase.values.yaml.
2187
2260
  await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
2188
- installSupabase({ kubeconfig, projectDir, projectName, domain, s3Config, envLocal }),
2261
+ installSupabase({
2262
+ kubeconfig,
2263
+ projectDir,
2264
+ projectName,
2265
+ domain,
2266
+ s3Config,
2267
+ envLocal,
2268
+ dbImageTag,
2269
+ backupBucketName,
2270
+ }),
2189
2271
  );
2272
+ // 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
2273
+ // inject secret env (environment.db is string-only) or postgres archive
2274
+ // flags, so we (a) ALTER SYSTEM the archive params (persisted to
2275
+ // postgresql.auto.conf on the PVC), and (b) inject the SECRET S3 creds
2276
+ // via a strategic-merge env patch (merged by env name → idempotent;
2277
+ // re-applied every deploy because helm upgrade strips non-chart env).
2278
+ // The cred patch rolls the pod when it changes the template; on first
2279
+ // enable that rollout is what makes postgres pick up archive_mode.
2280
+ // Skipped without S3 (dev / no backups configured).
2281
+ if (s3Config?.accessKey) {
2282
+ await perfAsync(`deploy.${perfPrefix}.enableWalArchiving`, async () => {
2283
+ const dbSts = 'supabase-supabase-db';
2284
+ const dbPod = `${dbSts}-0`;
2285
+ // S3 creds reach the db container as a mounted file (helm-owned, see
2286
+ // supabase.values.yaml walg-s3-creds volume) — no env patch, so no
2287
+ // db restart on warm deploys. Only the archive params need ALTER
2288
+ // SYSTEM, and archive_mode flips only with a RESTART. So: check the
2289
+ // RUNNING archive_mode; if already 'on' (persisted in
2290
+ // postgresql.auto.conf from a prior enable), this is a warm deploy —
2291
+ // nothing to do. If 'off', set the params and restart ONCE.
2292
+ let archiveMode = '';
2293
+ try {
2294
+ archiveMode = (
2295
+ await runKubectlWithRetry(
2296
+ [
2297
+ '-n',
2298
+ 'vibecarbon',
2299
+ 'exec',
2300
+ dbPod,
2301
+ '--',
2302
+ 'psql',
2303
+ '-U',
2304
+ 'supabase_admin',
2305
+ '-d',
2306
+ 'postgres',
2307
+ '-tAc',
2308
+ 'SHOW archive_mode',
2309
+ ],
2310
+ {
2311
+ env,
2312
+ captureStdout: true,
2313
+ description: 'applyK3sManifests: read archive_mode',
2314
+ },
2315
+ )
2316
+ )
2317
+ ?.toString()
2318
+ .trim();
2319
+ } catch {
2320
+ // Treat an unreadable value as "not yet enabled" — the worst case is
2321
+ // one extra ALTER SYSTEM + restart, which is idempotent.
2322
+ }
2323
+ if (archiveMode === 'on') {
2324
+ console.error('[k3s] WAL archiving already enabled — skipping ALTER SYSTEM + restart.');
2325
+ return;
2326
+ }
2327
+ await runKubectlWithRetry(enableWalArchivingPsqlArgs(dbPod), {
2328
+ env,
2329
+ description: 'applyK3sManifests: enable WAL archiving (ALTER SYSTEM)',
2330
+ });
2331
+ // archive_mode requires a restart (reload is insufficient). Restart the
2332
+ // StatefulSet ONCE; persisted to postgresql.auto.conf on the PVC, so
2333
+ // subsequent deploys hit the archiveMode==='on' fast path above.
2334
+ await runKubectlWithRetry(
2335
+ ['-n', 'vibecarbon', 'rollout', 'restart', `statefulset/${dbSts}`],
2336
+ { env, description: 'applyK3sManifests: restart supabase-db to enable WAL archiving' },
2337
+ );
2338
+ await runKubectlWithRetry(
2339
+ ['-n', 'vibecarbon', 'rollout', 'status', `statefulset/${dbSts}`, '--timeout=300s'],
2340
+ { env, description: 'applyK3sManifests: wait supabase-db rollout (WAL archiving)' },
2341
+ );
2342
+ });
2343
+ }
2190
2344
  // 8. Apply project SQL migrations against the chart-installed postgres.
2191
- // Helm's `--wait` returned only after supabase-db was Ready, so the
2192
- // pod is guaranteed up here. Without this, the app's
2193
- // /api/health/ready returns 503 forever ("Could not find table
2194
- // public.organizations").
2195
- await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
2196
- applyMigrations({ kubeconfig, projectDir }),
2197
- );
2345
+ // SKIPPED when restoring: a wal-g restore brings the full DB including
2346
+ // supabase_migrations.schema_migrations, so re-running migrations would
2347
+ // hit duplicate-key errors (applyMigrations deliberately does not
2348
+ // swallow them). On a normal deploy, helm's `--wait` guarantees the db
2349
+ // pod is up; without migrations the app's /api/health/ready 503s.
2350
+ if (restore) {
2351
+ console.error(
2352
+ `[k3s] restore=${restore} requested — skipping applyMigrations (restored DB carries schema_migrations).`,
2353
+ );
2354
+ } else {
2355
+ await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
2356
+ applyMigrations({ kubeconfig, projectDir }),
2357
+ );
2358
+ }
2198
2359
  // 8b. Reload PostgREST's schema cache so it sees the tables applyMigrations
2199
2360
  // just created. rest came up during helm --wait (before migrations) and
2200
2361
  // no DDL-watch trigger exists to auto-NOTIFY — without this, /rest/v1/*
@@ -2202,6 +2363,28 @@ export async function applyK3sManifests({
2202
2363
  await perfAsync(`deploy.${perfPrefix}.reloadPostgrest`, async () =>
2203
2364
  reloadPostgrest({ kubeconfig }),
2204
2365
  );
2366
+ // 8c. Create the production app super-admin via GoTrue's admin API. Mirrors
2367
+ // the compose path — without it the operator can't log into their own
2368
+ // deployed app (ADMIN_* only seeds the Studio dashboard secret, never an
2369
+ // auth.users row). Runs unconditionally (incl. restore: idempotent on
2370
+ // 422, and a backup predating admin-creation still gets seeded). In HA
2371
+ // both clusters run this against their independently-writable DB at
2372
+ // deploy time (same as applyMigrations); the localPort is derived from
2373
+ // localTunnelPort so the parallel primary/standby port-forwards don't
2374
+ // race for the same bind. Non-fatal: a GoTrue blip warns + retries on
2375
+ // the next deploy rather than failing an otherwise-healthy one.
2376
+ await perfAsync(`deploy.${perfPrefix}.createAdminUser`, async () => {
2377
+ const adminResult = await provisionAdminUser({
2378
+ kubeconfig,
2379
+ envLocal,
2380
+ localPort: 15000 + (localTunnelPort - 5000),
2381
+ });
2382
+ if (adminResult.success) {
2383
+ console.log(`[k3s] ${adminResult.message}`);
2384
+ } else {
2385
+ console.warn(`[k3s] ${adminResult.message} — re-run \`vibecarbon deploy\` to retry.`);
2386
+ }
2387
+ });
2205
2388
  // 9. Wait for app rollout. The deployment template already has
2206
2389
  // imagePullPolicy: IfNotPresent, so kubelet uses the sideloaded image
2207
2390
  // without pulling. 300s wasn't enough on a cold deploy where the app
@@ -2469,37 +2652,39 @@ export async function deployK3s(options) {
2469
2652
  }
2470
2653
  const { tag: imageTag } = state.getStepResult('k3s-build');
2471
2654
 
2472
- // 7. Build the backup image up-front so we can fan out BOTH sideloads in
2473
- // parallel. base/'s CronJob ships image=ghcr.io/<owner>/<project>-backup
2474
- // unreachable in direct/local mode (no GHCR push). Build the
2475
- // <projectDir>/backup context locally, then later patch the CronJob
2476
- // image in applyK3sManifests (step 5c).
2477
- const backupBuildInputs = { projectName: `${options.projectName}-backup` };
2478
- if (!state.shouldSkip('k3s-backup-build', backupBuildInputs)) {
2479
- state.startStep('k3s-backup-build', backupBuildInputs);
2480
- s.start('Building backup image');
2481
- const built = await buildAppImage(join(projectDir, 'backup'), `${options.projectName}-backup`);
2482
- state.completeStep('k3s-backup-build', {
2655
+ // 7. Build the wal-g-equipped db image (carbon/db/Dockerfile: FROM
2656
+ // supabase/postgres + wal-g v3.0.5). This REPLACES the old separate
2657
+ // ~500MB backup image: the db image already carries wal-g + psql, and
2658
+ // co-locating wal-g with PGDATA is what enables physical base backups +
2659
+ // PITR (the old backup pod ran off-node and was stuck on pg_dump). The
2660
+ // supabase Helm chart's image.db is pointed at this build in
2661
+ // installSupabase. Built up-front so its sideload fans out with the app.
2662
+ const dbBuildInputs = { projectName: `${options.projectName}-db` };
2663
+ if (!state.shouldSkip('k3s-db-build', dbBuildInputs)) {
2664
+ state.startStep('k3s-db-build', dbBuildInputs);
2665
+ s.start('Building db image (wal-g)');
2666
+ const built = await buildAppImage(join(projectDir, 'db'), `${options.projectName}-db`);
2667
+ state.completeStep('k3s-db-build', {
2483
2668
  tag: built.tag,
2484
2669
  gitSha: built.gitSha,
2485
2670
  isDirty: built.isDirty,
2486
2671
  });
2487
- s.stop(`Backup image built: ${built.tag}`);
2672
+ s.stop(`DB image built: ${built.tag}`);
2488
2673
  }
2489
- const { tag: backupImageTag } = state.getStepResult('k3s-backup-build');
2674
+ const { tag: dbImageTag } = state.getStepResult('k3s-db-build');
2490
2675
 
2491
2676
  // 7b. Sideload to every node (parallel SSH within sideloadK3s) AND fan out
2492
- // the app+backup sideloads against each other. Previously sequential:
2493
- // sideload.app then sideload.backup 1m4s + 57s on k8s-ha primary.
2494
- // Both writes go through `docker save | ssh ... gunzip | k3s ctr import`
2495
- // against disjoint image refs on the same target nodes — kernel disk
2496
- // buffer + sshd are happy to multiplex two parallel streams. Cuts the
2497
- // critical path to max(app, backup) instead of sum.
2677
+ // the app+db sideloads against each other. Both writes go through
2678
+ // `docker save | ssh ... gunzip | k3s ctr import` against disjoint
2679
+ // image refs on the same target nodes kernel disk buffer + sshd
2680
+ // multiplex two parallel streams. Critical path = max(app, db).
2681
+ // The db image only NEEDS the supabase node (db is pinned there), but
2682
+ // sideloading to all nodes is harmless and keeps the target list shared.
2498
2683
  const sshTargets = [`root@${masterIp}`];
2499
2684
  if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
2500
2685
  for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
2501
2686
  const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
2502
- const backupSideloadInputs = { backupImageTag, targets: sshTargets.join(',') };
2687
+ const dbSideloadInputs = { dbImageTag, targets: sshTargets.join(',') };
2503
2688
  const sideloadTasks = [];
2504
2689
  if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
2505
2690
  state.startStep('k3s-sideload', sideloadInputs);
@@ -2509,23 +2694,23 @@ export async function deployK3s(options) {
2509
2694
  ).then(() => state.completeStep('k3s-sideload')),
2510
2695
  );
2511
2696
  }
2512
- if (!state.shouldSkip('k3s-backup-sideload', backupSideloadInputs)) {
2513
- state.startStep('k3s-backup-sideload', backupSideloadInputs);
2697
+ if (!state.shouldSkip('k3s-db-sideload', dbSideloadInputs)) {
2698
+ state.startStep('k3s-db-sideload', dbSideloadInputs);
2514
2699
  sideloadTasks.push(
2515
- perfAsync(`deploy.${perfPrefix}.sideload.backup`, () =>
2516
- sideloadK3s({ tag: backupImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
2517
- ).then(() => state.completeStep('k3s-backup-sideload')),
2700
+ perfAsync(`deploy.${perfPrefix}.sideload.db`, () =>
2701
+ sideloadK3s({ tag: dbImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
2702
+ ).then(() => state.completeStep('k3s-db-sideload')),
2518
2703
  );
2519
2704
  }
2520
2705
  if (sideloadTasks.length > 0) {
2521
- s.start(`Sideloading app + backup to ${sshTargets.length} node(s) in parallel`);
2706
+ s.start(`Sideloading app + db to ${sshTargets.length} node(s) in parallel`);
2522
2707
  await Promise.all(sideloadTasks);
2523
- s.stop('Sideload complete (app + backup)');
2708
+ s.stop('Sideload complete (app + db)');
2524
2709
  }
2525
2710
 
2526
2711
  // 8. Apply manifests + supabase Helm release + roll out the app.
2527
- if (!state.shouldSkip('k3s-apply', { imageTag, backupImageTag })) {
2528
- state.startStep('k3s-apply', { imageTag, backupImageTag });
2712
+ if (!state.shouldSkip('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' })) {
2713
+ state.startStep('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' });
2529
2714
  s.start('Applying k8s manifests + installing Supabase + rolling out app');
2530
2715
  const envLocal = loadEnvLocal(join(projectDir, '.env.local'));
2531
2716
  await perfAsync(`deploy.${perfPrefix}.applyManifests`, () =>
@@ -2535,11 +2720,12 @@ export async function deployK3s(options) {
2535
2720
  projectDir,
2536
2721
  projectName: options.projectName,
2537
2722
  imageTag,
2538
- backupImageTag,
2723
+ dbImageTag,
2539
2724
  envLocal,
2540
2725
  domain: options.domain || 'localhost',
2541
2726
  s3Config: options.s3Config,
2542
2727
  backupBucketName: options.backupBucketName,
2728
+ restore: options.restore,
2543
2729
  dnsProvider: options.dnsProvider,
2544
2730
  cloudflareApiToken: options.cloudflareApiToken,
2545
2731
  // Hetzner DNS-01 uses the SAME Cloud API token (api.hetzner.cloud)