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
@@ -40,10 +40,17 @@ import * as p from '@clack/prompts';
40
40
  import { runCommandAsync } from '../../command.js';
41
41
  import { knownHostsPath } from '../../host-keys.js';
42
42
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
43
+ import { dbImageRef } from '../../images.js';
43
44
  import { perfAsync } from '../../perf.js';
45
+ import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
44
46
 
45
47
  export const K3S_VERSION = 'v1.31.5+k3s1';
46
48
 
49
+ /** The db image is pre-published + multi-arch — pulled, never built/sideloaded. */
50
+ export function resolveDbImageTag() {
51
+ return dbImageRef();
52
+ }
53
+
47
54
  /**
48
55
  * Pattern of kube-apiserver transient signatures we've actually observed
49
56
  * mid-RPC during k3s control-plane warm-up. See runKubectlWithRetry —
@@ -52,7 +59,12 @@ export const K3S_VERSION = 'v1.31.5+k3s1';
52
59
  * obvious place to do it).
53
60
  */
54
61
  export const KUBECTL_TRANSIENT_PATTERN =
55
- /http2: client connection lost|connection reset by peer|context deadline exceeded|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
62
+ // `connection timed out` covers cross-cluster API blips like
63
+ // `read tcp <ip>:<port>->...:6443: read: connection timed out` — seen when a
64
+ // kubectl exec (e.g. enable-WAL-archiving) hits a momentarily unreachable
65
+ // standby control-plane in k8s-ha. Without it the call failed without
66
+ // retrying, aborting the deploy. (RCA 2026-06-01: k8s-ha standby deploy.)
67
+ /http2: client connection lost|connection reset by peer|context deadline exceeded|connection timed out|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
56
68
 
57
69
  /**
58
70
  * Run a short-lived kubectl command with retry-on-transient-error.
@@ -208,6 +220,35 @@ export function pickIssuerName({ dnsProvider, acmeServer }) {
208
220
  return `letsencrypt-${stage}-${providerSuffix}`;
209
221
  }
210
222
 
223
+ /**
224
+ * Build the `dnsNames` list for the `Certificate/vibecarbon-tls` patch.
225
+ *
226
+ * DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — cert-manager
227
+ * can obtain `*.${domain}` in a single ACME order, covering every subdomain
228
+ * (api, studio, dashboard, grafana, n8n …) without separate per-subdomain
229
+ * certificates. One wildcard cert = no per-router cert-resolver needed =
230
+ * no per-domain ACME rate-limit risk.
231
+ *
232
+ * Manual issuers use HTTP-01 which cannot issue wildcards, so fall back to
233
+ * the apex domain only (the IngressRoutes already work; only non-apex
234
+ * subdomains are uncovered, but those are admin-only tools the operator
235
+ * configures manually for manual-DNS deploys).
236
+ *
237
+ * @param {string} domain - The apex deploy domain, e.g. `e1.carbonstack.dev`
238
+ * @param {string} issuerName - The ClusterIssuer name, e.g.
239
+ * `letsencrypt-prod-hetzner`, produced by `pickIssuerName`
240
+ * @returns {string[]}
241
+ */
242
+ export function certificateDnsNames(domain, issuerName) {
243
+ // Only the known DNS-01 providers (cloudflare, hetzner) support wildcard SANs.
244
+ // `manual` and any future/unknown issuer name falls back to apex-only so an
245
+ // unrecognised issuer never attempts a wildcard via HTTP-01 (which LE rejects).
246
+ const isDns01 = /-(cloudflare|hetzner)$/.test(issuerName);
247
+ if (!isDns01) return [domain];
248
+ // DNS-01 issuers (cloudflare, hetzner — prod or staging): issue one wildcard.
249
+ return [domain, `*.${domain}`];
250
+ }
251
+
211
252
  /**
212
253
  * Render the per-DNS-provider Secret YAML that cert-manager's DNS-01
213
254
  * solvers read for API auth, or null when the provider doesn't need a
@@ -1046,9 +1087,18 @@ export async function pushImageToLocalRegistry({
1046
1087
  const openTunnel = openTunnelOrWalk;
1047
1088
 
1048
1089
  /** Push the localhost-aliased tag through the local-forwarded port. */
1049
- const push = () => {
1050
- execFileSync('docker', ['push', localTag], { stdio: 'inherit' });
1051
- };
1090
+ // Async spawn (not execFileSync): a docker push of a multi-hundred-MB image
1091
+ // takes minutes, and a synchronous call blocks Node's event loop the whole
1092
+ // time — freezing the spinner/log output and leaking unreaped child
1093
+ // processes (SIGCHLD can't fire while blocked). spawn keeps the loop live.
1094
+ const push = () =>
1095
+ new Promise((resolve, reject) => {
1096
+ const child = spawn('docker', ['push', localTag], { stdio: 'inherit' });
1097
+ child.on('error', (err) => reject(new Error(`docker push spawn failed: ${err.message}`)));
1098
+ child.on('exit', (code) =>
1099
+ code === 0 ? resolve(undefined) : reject(new Error(`docker push exited with code ${code}`)),
1100
+ );
1101
+ });
1052
1102
 
1053
1103
  // Three attempts with progressive backoff. Failure modes we're
1054
1104
  // covering, in priority order:
@@ -1094,7 +1144,7 @@ export async function pushImageToLocalRegistry({
1094
1144
  taggedAliases.add(localTag);
1095
1145
  }
1096
1146
  await delay(settleDelays[attempt]);
1097
- push();
1147
+ await push();
1098
1148
  pushed = true;
1099
1149
  break;
1100
1150
  } catch (err) {
@@ -1201,6 +1251,14 @@ export async function applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, b
1201
1251
  stringData.S3_BUCKET = s3Config.bucket;
1202
1252
  stringData.S3_ENDPOINT = s3Config.endpoint;
1203
1253
  stringData.S3_REGION = s3Config.region;
1254
+ // AWS-SDK-format credentials file for wal-g in the supabase-db pod. The
1255
+ // supabase Helm chart can't inject secret env on the db container
1256
+ // (environment.db is string-only), so instead of a post-helm env patch
1257
+ // (which helm would strip every upgrade → a db restart every deploy) we
1258
+ // mount THIS key as a file and point AWS_SHARED_CREDENTIALS_FILE at it
1259
+ // (volume is helm-owned → never stripped → no per-deploy restart). See
1260
+ // k8s/values/supabase.values.yaml deployment.db.volumes.
1261
+ stringData.S3_CREDENTIALS_INI = `[default]\naws_access_key_id=${s3Config.accessKey}\naws_secret_access_key=${s3Config.secretKey}\n`;
1204
1262
  }
1205
1263
  if (backupBucketName) {
1206
1264
  stringData.S3_BACKUP_BUCKET = backupBucketName;
@@ -1255,6 +1313,8 @@ export async function installSupabase({
1255
1313
  domain,
1256
1314
  s3Config,
1257
1315
  envLocal,
1316
+ dbImageTag,
1317
+ backupBucketName,
1258
1318
  }) {
1259
1319
  const env = { ...process.env, KUBECONFIG: kubeconfig };
1260
1320
  // Render the values template — substitute every {{TOKEN}} the template
@@ -1265,10 +1325,35 @@ export async function installSupabase({
1265
1325
  `installSupabase: ${valuesTemplatePath} not found. Project must include the supabase values template.`,
1266
1326
  );
1267
1327
  }
1328
+ if (!dbImageTag) {
1329
+ throw new Error(
1330
+ 'installSupabase: dbImageTag is required (the pre-published wal-g db image, pulled from ghcr).',
1331
+ );
1332
+ }
1333
+ // Split the full image ref into repository + tag for the chart's
1334
+ // image.db.{repository,tag} (and the walg-restore init's `{{DB_IMAGE}}:{{DB_IMAGE_TAG}}`).
1335
+ // dbImageTag is now the pre-published `ghcr.io/<org>/postgres:<pg>-walg<ver>`
1336
+ // ref — split on the LAST colon so the registry host (and any future port
1337
+ // colon) stays in the repository.
1338
+ const lastColon = dbImageTag.lastIndexOf(':');
1339
+ const dbImageRepo = dbImageTag.slice(0, lastColon);
1340
+ const dbImageVer = dbImageTag.slice(lastColon + 1);
1341
+ // WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
1342
+ // bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
1343
+ // leave it as s3:/// (wal-g would error).
1344
+ const walgBucket = backupBucketName || s3Config?.bucket || '';
1345
+ const walgS3Prefix = walgBucket
1346
+ ? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
1347
+ : '';
1268
1348
  const rendered = readFileSync(valuesTemplatePath, 'utf-8')
1269
1349
  .replace(/\{\{DOMAIN\}\}/g, domain || 'localhost')
1270
1350
  .replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
1271
- .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, s3Config?.bucket ?? '')
1351
+ .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
1352
+ .replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
1353
+ .replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
1354
+ .replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
1355
+ .replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
1356
+ .replace(/\{\{DB_IMAGE_TAG\}\}/g, dbImageVer)
1272
1357
  // Studio dashboard creds — sourced from .env.local at deploy time. Empty
1273
1358
  // string fallback keeps yaml valid; chart inlines them into the
1274
1359
  // chart-generated supabase-dashboard secret.
@@ -1587,6 +1672,99 @@ export async function reloadPostgrest({ kubeconfig }) {
1587
1672
  }
1588
1673
  }
1589
1674
 
1675
+ /**
1676
+ * Create the production app super-admin in `auth.users` via GoTrue's admin API.
1677
+ *
1678
+ * The k8s mirror of compose/index.js `createAdminUser`: identical GoTrue
1679
+ * admin-API contract (POST the super_admin payload with the service-role
1680
+ * bearer, idempotent on 422 via the shared admin-user helper), reached via
1681
+ * `kubectl port-forward svc/supabase-supabase-auth` instead of an `ssh -L`
1682
+ * tunnel. Goes direct to GoTrue (auth :9999, path `/admin/users`) — no Kong
1683
+ * `/auth/v1` prefix to strip.
1684
+ *
1685
+ * Without this, a k8s deploy shipped a prod app the operator couldn't log
1686
+ * into: ADMIN_EMAIL/ADMIN_PASSWORD only seeded the Supabase Studio dashboard
1687
+ * basic-auth secret, never an app `auth.users` row.
1688
+ *
1689
+ * @param {{kubeconfig: string, envLocal: Record<string,string>, localPort?: number}} args
1690
+ * @returns {Promise<{success: boolean, message: string}>}
1691
+ */
1692
+ export async function provisionAdminUser({ kubeconfig, envLocal, localPort = 15000 }) {
1693
+ const adminEmail = envLocal?.ADMIN_EMAIL;
1694
+ const adminPassword = envLocal?.ADMIN_PASSWORD;
1695
+ const serviceRoleKey = envLocal?.SERVICE_ROLE_KEY ?? envLocal?.SUPABASE_SERVICE_ROLE_KEY;
1696
+ if (!adminEmail || !adminPassword || !serviceRoleKey) {
1697
+ return { success: false, message: 'Admin credentials not found in .env.local' };
1698
+ }
1699
+
1700
+ const env = { ...process.env, KUBECONFIG: kubeconfig };
1701
+ // Background port-forward to GoTrue. Distinct localPort per cluster so the
1702
+ // parallel HA fan-out (primary + standby on one operator host) doesn't race
1703
+ // for the same bind — mirrors the 5000/5001 registry-tunnel split.
1704
+ const pf = spawn(
1705
+ 'kubectl',
1706
+ ['-n', 'vibecarbon', 'port-forward', 'svc/supabase-supabase-auth', `${localPort}:9999`],
1707
+ { env, stdio: 'ignore' },
1708
+ );
1709
+ try {
1710
+ const connected = await waitForGotrueHealth(`http://localhost:${localPort}/health`, {
1711
+ attempts: 20,
1712
+ intervalMs: 500,
1713
+ });
1714
+ if (!connected) {
1715
+ return { success: false, message: 'Could not reach GoTrue via kubectl port-forward' };
1716
+ }
1717
+ return await postAdminUser({
1718
+ adminUsersUrl: `http://localhost:${localPort}/admin/users`,
1719
+ serviceRoleKey,
1720
+ adminEmail,
1721
+ adminPassword,
1722
+ });
1723
+ } finally {
1724
+ pf.kill();
1725
+ }
1726
+ }
1727
+
1728
+ /**
1729
+ * The four `ALTER SYSTEM` settings that turn on continuous WAL archiving for
1730
+ * wal-g on the chart-installed supabase-db.
1731
+ */
1732
+ export const WAL_ARCHIVING_SETTINGS = [
1733
+ "ALTER SYSTEM SET archive_mode='on'",
1734
+ "ALTER SYSTEM SET archive_command='bash /etc/postgresql/wal-archive.sh %p'",
1735
+ "ALTER SYSTEM SET archive_timeout='900'",
1736
+ "ALTER SYSTEM SET wal_level='replica'",
1737
+ ];
1738
+
1739
+ /**
1740
+ * Build the `kubectl exec … psql …` argument list that enables WAL archiving.
1741
+ *
1742
+ * Each `ALTER SYSTEM` MUST be its own `-c` option. psql sends a single `-c`
1743
+ * string as one simple-query request, and Postgres runs a multi-statement
1744
+ * simple query inside ONE implicit transaction — where `ALTER SYSTEM` is
1745
+ * rejected with "ALTER SYSTEM cannot run inside a transaction block". The
1746
+ * documented remedy (and the compose/ha.js pattern) is to pass each statement
1747
+ * as a separate `-c`, so every ALTER SYSTEM is its own implicit transaction.
1748
+ *
1749
+ * @param {string} dbPod - e.g. `supabase-supabase-db-0`
1750
+ * @returns {string[]}
1751
+ */
1752
+ export function enableWalArchivingPsqlArgs(dbPod) {
1753
+ return [
1754
+ '-n',
1755
+ 'vibecarbon',
1756
+ 'exec',
1757
+ dbPod,
1758
+ '--',
1759
+ 'psql',
1760
+ '-U',
1761
+ 'supabase_admin',
1762
+ '-d',
1763
+ 'postgres',
1764
+ ...WAL_ARCHIVING_SETTINGS.flatMap((stmt) => ['-c', stmt]),
1765
+ ];
1766
+ }
1767
+
1590
1768
  /**
1591
1769
  * Apply cert-manager + vibecarbon-secrets + the project's k8s/infra +
1592
1770
  * k8s/base kustomizations, install supabase via helm, patch the app
@@ -1595,18 +1773,21 @@ export async function reloadPostgrest({ kubeconfig }) {
1595
1773
  * Local-first by default — no Flux on the deploy path; Flux is opt-in
1596
1774
  * via `vibecarbon configure cicd`.
1597
1775
  *
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
1776
+ * @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
1777
+ * `restore` (when set, e.g. 'latest' or an ISO timestamp) skips applyMigrations
1778
+ * — the wal-g-restored DB already carries schema_migrations.
1599
1779
  */
1600
1780
  export async function applyK3sManifests({
1601
1781
  kubeconfig,
1602
1782
  projectDir,
1603
1783
  projectName,
1604
1784
  imageTag,
1605
- backupImageTag,
1785
+ dbImageTag,
1606
1786
  envLocal,
1607
1787
  domain,
1608
1788
  s3Config,
1609
1789
  backupBucketName,
1790
+ restore,
1610
1791
  dnsProvider,
1611
1792
  cloudflareApiToken,
1612
1793
  hetznerApiToken,
@@ -1658,42 +1839,73 @@ export async function applyK3sManifests({
1658
1839
  // Fan out the three deploys in parallel: kubectl wait blocks per-arg
1659
1840
  // sequentially, and the cainjector + webhook usually become Available
1660
1841
  // ~10-30s after the core deploy, so the serial form pays the longest
1661
- // arrival time three times in a row. Two-stage retry preserves the
1662
- // outer 180s+120s envelope of the previous --timeout=180s call.
1842
+ // arrival time three times in a row.
1843
+ //
1844
+ // Each deploy POLLS to Available within a generous total budget rather than
1845
+ // one or two fixed `kubectl wait` calls. On a fresh worker, cold image pulls
1846
+ // can push cainjector/webhook readiness past a tight envelope even though the
1847
+ // pod ultimately comes up healthy (observed 2026-06-01: k8s e2e failed
1848
+ // `kubectl wait deploy/cert-manager-cainjector exited 1` while the pod was
1849
+ // 1/1 Running moments later — the old 90s→120s=210s envelope just gave up
1850
+ // first). Re-attempting `kubectl wait` in chunks until the budget also
1851
+ // self-heals the fast-fail race where `wait` runs before the just-applied
1852
+ // Deployment object has propagated (NotFound → exit 1 immediately). A
1853
+ // genuinely stuck deploy still fails once the budget is exhausted.
1663
1854
  const certManagerDeploys = [
1664
1855
  'deploy/cert-manager',
1665
1856
  'deploy/cert-manager-webhook',
1666
1857
  'deploy/cert-manager-cainjector',
1667
1858
  ];
1859
+ // 8-min budget: on a freshly provisioned cluster under load, cert-manager
1860
+ // pods have been observed not even getting created until ~5 min after apply
1861
+ // (control-plane/scheduler lag, not image pull), overrunning a tighter
1862
+ // envelope even though the pods then come up healthy. This margin covers that
1863
+ // cold-start tail; the poll below re-attempts within it rather than giving up.
1864
+ const CERT_MANAGER_READY_BUDGET_MS = 480_000;
1865
+ const waitDeployAvailableOnce = (deploy, timeoutSec) =>
1866
+ new Promise((resolveFn, reject) => {
1867
+ const child = spawn(
1868
+ 'kubectl',
1869
+ [
1870
+ '-n',
1871
+ 'cert-manager',
1872
+ 'wait',
1873
+ '--for=condition=Available',
1874
+ `--timeout=${timeoutSec}s`,
1875
+ deploy,
1876
+ ],
1877
+ { stdio: 'inherit', env },
1878
+ );
1879
+ child.on('error', reject);
1880
+ child.on('exit', (code) =>
1881
+ code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
1882
+ );
1883
+ });
1884
+ const waitDeployAvailable = async (deploy) => {
1885
+ const deadline = Date.now() + CERT_MANAGER_READY_BUDGET_MS;
1886
+ let lastErr;
1887
+ while (Date.now() < deadline) {
1888
+ const remainingSec = Math.max(5, Math.ceil((deadline - Date.now()) / 1000));
1889
+ try {
1890
+ await waitDeployAvailableOnce(deploy, Math.min(60, remainingSec));
1891
+ return;
1892
+ } catch (err) {
1893
+ lastErr = err;
1894
+ if (Date.now() >= deadline) break;
1895
+ // Brief backoff so a fast-failing wait (e.g. the Deployment object not
1896
+ // yet propagated → immediate NotFound) doesn't busy-spin the budget.
1897
+ await new Promise((r) => setTimeout(r, 3000));
1898
+ }
1899
+ }
1900
+ throw (
1901
+ lastErr ??
1902
+ new Error(
1903
+ `kubectl wait ${deploy} never became Available within ${CERT_MANAGER_READY_BUDGET_MS / 1000}s`,
1904
+ )
1905
+ );
1906
+ };
1668
1907
  await perfAsync(`deploy.${perfPrefix}.certManager.wait`, () =>
1669
- Promise.all(
1670
- certManagerDeploys.map(async (deploy) => {
1671
- const waitOnce = (timeoutSec) =>
1672
- new Promise((resolveFn, reject) => {
1673
- const child = spawn(
1674
- 'kubectl',
1675
- [
1676
- '-n',
1677
- 'cert-manager',
1678
- 'wait',
1679
- '--for=condition=Available',
1680
- `--timeout=${timeoutSec}s`,
1681
- deploy,
1682
- ],
1683
- { stdio: 'inherit', env },
1684
- );
1685
- child.on('error', reject);
1686
- child.on('exit', (code) =>
1687
- code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
1688
- );
1689
- });
1690
- try {
1691
- await waitOnce(90);
1692
- } catch {
1693
- await waitOnce(120);
1694
- }
1695
- }),
1696
- ),
1908
+ Promise.all(certManagerDeploys.map((deploy) => waitDeployAvailable(deploy))),
1697
1909
  );
1698
1910
  // 2b. Land the per-DNS-provider Secret in the cert-manager namespace
1699
1911
  // BEFORE the kustomization that defines the issuers, so when the
@@ -1898,55 +2110,21 @@ export async function applyK3sManifests({
1898
2110
  if (!khPath) {
1899
2111
  throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
1900
2112
  }
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).
2113
+ // Push the app image to the in-cluster registry so CA-spawned workers
2114
+ // (which don't exist at sideload time) can pull it on first schedule.
2115
+ // The db image is NOT pushed here: it's pinned to the static supabase
2116
+ // node, reaches it via sideload, and the chart references it with
2117
+ // imagePullPolicy: IfNotPresent CA workers never run postgres.
1910
2118
  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);
2119
+ await perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
2120
+ pushImageToLocalRegistry({
2121
+ tag: imageTag,
2122
+ masterIp,
2123
+ sshKey: sshKeyPath,
2124
+ khPath,
2125
+ localTunnelPort: appPushPort,
2126
+ }),
2127
+ );
1950
2128
  // 5a'. Apply cluster-autoscaler manifests SEPARATELY. CA lives in
1951
2129
  // kube-system but base/'s parent kustomization sets
1952
2130
  // `namespace: vibecarbon`, whose namespace transformer would
@@ -1979,6 +2157,10 @@ export async function applyK3sManifests({
1979
2157
  // — DNS-01 vs HTTP-01 — cert-manager actually uses; see pickIssuerName.
1980
2158
  const acmeServer = envLocal?.ACME_CA_SERVER || process.env.ACME_CA_SERVER || '';
1981
2159
  const issuerName = pickIssuerName({ dnsProvider, acmeServer });
2160
+ // DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — one cert
2161
+ // covers *.${domain} so every IngressRoute subdomain is included without
2162
+ // separate per-router ACME orders. Manual (HTTP-01) falls back to apex-only.
2163
+ const dnsNames = certificateDnsNames(domain, issuerName);
1982
2164
  await runKubectlWithRetry(
1983
2165
  [
1984
2166
  '-n',
@@ -1989,7 +2171,7 @@ export async function applyK3sManifests({
1989
2171
  '--type=merge',
1990
2172
  '-p',
1991
2173
  JSON.stringify({
1992
- spec: { dnsNames: [domain], issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
2174
+ spec: { dnsNames, issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
1993
2175
  }),
1994
2176
  ],
1995
2177
  { env, description: 'applyK3sManifests: kubectl patch certificate/vibecarbon-tls' },
@@ -2148,53 +2330,109 @@ export async function applyK3sManifests({
2148
2330
  ['-n', 'vibecarbon', 'set', 'image', 'deployment/app', `app=${imageTag}`],
2149
2331
  { env, description: 'applyK3sManifests: kubectl set image deployment/app' },
2150
2332
  );
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
2333
  // 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.
2334
+ // timeout in chart). image.db points at the wal-g-equipped db image
2335
+ // (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
2336
+ // non-secret WALG_* env from supabase.values.yaml.
2187
2337
  await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
2188
- installSupabase({ kubeconfig, projectDir, projectName, domain, s3Config, envLocal }),
2338
+ installSupabase({
2339
+ kubeconfig,
2340
+ projectDir,
2341
+ projectName,
2342
+ domain,
2343
+ s3Config,
2344
+ envLocal,
2345
+ dbImageTag,
2346
+ backupBucketName,
2347
+ }),
2189
2348
  );
2349
+ // 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
2350
+ // inject secret env (environment.db is string-only) or postgres archive
2351
+ // flags, so we (a) ALTER SYSTEM the archive params (persisted to
2352
+ // postgresql.auto.conf on the PVC), and (b) inject the SECRET S3 creds
2353
+ // via a strategic-merge env patch (merged by env name → idempotent;
2354
+ // re-applied every deploy because helm upgrade strips non-chart env).
2355
+ // The cred patch rolls the pod when it changes the template; on first
2356
+ // enable that rollout is what makes postgres pick up archive_mode.
2357
+ // Skipped without S3 (dev / no backups configured).
2358
+ if (s3Config?.accessKey) {
2359
+ await perfAsync(`deploy.${perfPrefix}.enableWalArchiving`, async () => {
2360
+ const dbSts = 'supabase-supabase-db';
2361
+ const dbPod = `${dbSts}-0`;
2362
+ // S3 creds reach the db container as a mounted file (helm-owned, see
2363
+ // supabase.values.yaml walg-s3-creds volume) — no env patch, so no
2364
+ // db restart on warm deploys. Only the archive params need ALTER
2365
+ // SYSTEM, and archive_mode flips only with a RESTART. So: check the
2366
+ // RUNNING archive_mode; if already 'on' (persisted in
2367
+ // postgresql.auto.conf from a prior enable), this is a warm deploy —
2368
+ // nothing to do. If 'off', set the params and restart ONCE.
2369
+ let archiveMode = '';
2370
+ try {
2371
+ archiveMode = (
2372
+ await runKubectlWithRetry(
2373
+ [
2374
+ '-n',
2375
+ 'vibecarbon',
2376
+ 'exec',
2377
+ dbPod,
2378
+ '--',
2379
+ 'psql',
2380
+ '-U',
2381
+ 'supabase_admin',
2382
+ '-d',
2383
+ 'postgres',
2384
+ '-tAc',
2385
+ 'SHOW archive_mode',
2386
+ ],
2387
+ {
2388
+ env,
2389
+ captureStdout: true,
2390
+ description: 'applyK3sManifests: read archive_mode',
2391
+ },
2392
+ )
2393
+ )
2394
+ ?.toString()
2395
+ .trim();
2396
+ } catch {
2397
+ // Treat an unreadable value as "not yet enabled" — the worst case is
2398
+ // one extra ALTER SYSTEM + restart, which is idempotent.
2399
+ }
2400
+ if (archiveMode === 'on') {
2401
+ console.error('[k3s] WAL archiving already enabled — skipping ALTER SYSTEM + restart.');
2402
+ return;
2403
+ }
2404
+ await runKubectlWithRetry(enableWalArchivingPsqlArgs(dbPod), {
2405
+ env,
2406
+ description: 'applyK3sManifests: enable WAL archiving (ALTER SYSTEM)',
2407
+ });
2408
+ // archive_mode requires a restart (reload is insufficient). Restart the
2409
+ // StatefulSet ONCE; persisted to postgresql.auto.conf on the PVC, so
2410
+ // subsequent deploys hit the archiveMode==='on' fast path above.
2411
+ await runKubectlWithRetry(
2412
+ ['-n', 'vibecarbon', 'rollout', 'restart', `statefulset/${dbSts}`],
2413
+ { env, description: 'applyK3sManifests: restart supabase-db to enable WAL archiving' },
2414
+ );
2415
+ await runKubectlWithRetry(
2416
+ ['-n', 'vibecarbon', 'rollout', 'status', `statefulset/${dbSts}`, '--timeout=300s'],
2417
+ { env, description: 'applyK3sManifests: wait supabase-db rollout (WAL archiving)' },
2418
+ );
2419
+ });
2420
+ }
2190
2421
  // 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
- );
2422
+ // SKIPPED when restoring: a wal-g restore brings the full DB including
2423
+ // supabase_migrations.schema_migrations, so re-running migrations would
2424
+ // hit duplicate-key errors (applyMigrations deliberately does not
2425
+ // swallow them). On a normal deploy, helm's `--wait` guarantees the db
2426
+ // pod is up; without migrations the app's /api/health/ready 503s.
2427
+ if (restore) {
2428
+ console.error(
2429
+ `[k3s] restore=${restore} requested — skipping applyMigrations (restored DB carries schema_migrations).`,
2430
+ );
2431
+ } else {
2432
+ await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
2433
+ applyMigrations({ kubeconfig, projectDir }),
2434
+ );
2435
+ }
2198
2436
  // 8b. Reload PostgREST's schema cache so it sees the tables applyMigrations
2199
2437
  // just created. rest came up during helm --wait (before migrations) and
2200
2438
  // no DDL-watch trigger exists to auto-NOTIFY — without this, /rest/v1/*
@@ -2202,6 +2440,28 @@ export async function applyK3sManifests({
2202
2440
  await perfAsync(`deploy.${perfPrefix}.reloadPostgrest`, async () =>
2203
2441
  reloadPostgrest({ kubeconfig }),
2204
2442
  );
2443
+ // 8c. Create the production app super-admin via GoTrue's admin API. Mirrors
2444
+ // the compose path — without it the operator can't log into their own
2445
+ // deployed app (ADMIN_* only seeds the Studio dashboard secret, never an
2446
+ // auth.users row). Runs unconditionally (incl. restore: idempotent on
2447
+ // 422, and a backup predating admin-creation still gets seeded). In HA
2448
+ // both clusters run this against their independently-writable DB at
2449
+ // deploy time (same as applyMigrations); the localPort is derived from
2450
+ // localTunnelPort so the parallel primary/standby port-forwards don't
2451
+ // race for the same bind. Non-fatal: a GoTrue blip warns + retries on
2452
+ // the next deploy rather than failing an otherwise-healthy one.
2453
+ await perfAsync(`deploy.${perfPrefix}.createAdminUser`, async () => {
2454
+ const adminResult = await provisionAdminUser({
2455
+ kubeconfig,
2456
+ envLocal,
2457
+ localPort: 15000 + (localTunnelPort - 5000),
2458
+ });
2459
+ if (adminResult.success) {
2460
+ console.log(`[k3s] ${adminResult.message}`);
2461
+ } else {
2462
+ console.warn(`[k3s] ${adminResult.message} — re-run \`vibecarbon deploy\` to retry.`);
2463
+ }
2464
+ });
2205
2465
  // 9. Wait for app rollout. The deployment template already has
2206
2466
  // imagePullPolicy: IfNotPresent, so kubelet uses the sideloaded image
2207
2467
  // without pulling. 300s wasn't enough on a cold deploy where the app
@@ -2469,37 +2729,23 @@ export async function deployK3s(options) {
2469
2729
  }
2470
2730
  const { tag: imageTag } = state.getStepResult('k3s-build');
2471
2731
 
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', {
2483
- tag: built.tag,
2484
- gitSha: built.gitSha,
2485
- isDirty: built.isDirty,
2486
- });
2487
- s.stop(`Backup image built: ${built.tag}`);
2488
- }
2489
- const { tag: backupImageTag } = state.getStepResult('k3s-backup-build');
2732
+ // 7. The wal-g-equipped db image (supabase/postgres + wal-g) is now a
2733
+ // pre-published, multi-arch image pulled from ghcr — no per-deploy build
2734
+ // or sideload. The supabase Helm chart's image.db + the walg-restore init
2735
+ // container both reference it (see installSupabase + supabase.values.yaml).
2736
+ const dbImageTag = resolveDbImageTag();
2490
2737
 
2491
2738
  // 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.
2739
+ // the app+db sideloads against each other. Both writes go through
2740
+ // `docker save | ssh ... gunzip | k3s ctr import` against disjoint
2741
+ // image refs on the same target nodes kernel disk buffer + sshd
2742
+ // multiplex two parallel streams. Critical path = max(app, db).
2743
+ // The db image only NEEDS the supabase node (db is pinned there), but
2744
+ // sideloading to all nodes is harmless and keeps the target list shared.
2498
2745
  const sshTargets = [`root@${masterIp}`];
2499
2746
  if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
2500
2747
  for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
2501
2748
  const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
2502
- const backupSideloadInputs = { backupImageTag, targets: sshTargets.join(',') };
2503
2749
  const sideloadTasks = [];
2504
2750
  if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
2505
2751
  state.startStep('k3s-sideload', sideloadInputs);
@@ -2509,23 +2755,15 @@ export async function deployK3s(options) {
2509
2755
  ).then(() => state.completeStep('k3s-sideload')),
2510
2756
  );
2511
2757
  }
2512
- if (!state.shouldSkip('k3s-backup-sideload', backupSideloadInputs)) {
2513
- state.startStep('k3s-backup-sideload', backupSideloadInputs);
2514
- sideloadTasks.push(
2515
- perfAsync(`deploy.${perfPrefix}.sideload.backup`, () =>
2516
- sideloadK3s({ tag: backupImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
2517
- ).then(() => state.completeStep('k3s-backup-sideload')),
2518
- );
2519
- }
2520
2758
  if (sideloadTasks.length > 0) {
2521
- s.start(`Sideloading app + backup to ${sshTargets.length} node(s) in parallel`);
2759
+ s.start(`Sideloading app to ${sshTargets.length} node(s)`);
2522
2760
  await Promise.all(sideloadTasks);
2523
- s.stop('Sideload complete (app + backup)');
2761
+ s.stop('Sideload complete (app)');
2524
2762
  }
2525
2763
 
2526
2764
  // 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 });
2765
+ if (!state.shouldSkip('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' })) {
2766
+ state.startStep('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' });
2529
2767
  s.start('Applying k8s manifests + installing Supabase + rolling out app');
2530
2768
  const envLocal = loadEnvLocal(join(projectDir, '.env.local'));
2531
2769
  await perfAsync(`deploy.${perfPrefix}.applyManifests`, () =>
@@ -2535,11 +2773,12 @@ export async function deployK3s(options) {
2535
2773
  projectDir,
2536
2774
  projectName: options.projectName,
2537
2775
  imageTag,
2538
- backupImageTag,
2776
+ dbImageTag,
2539
2777
  envLocal,
2540
2778
  domain: options.domain || 'localhost',
2541
2779
  s3Config: options.s3Config,
2542
2780
  backupBucketName: options.backupBucketName,
2781
+ restore: options.restore,
2543
2782
  dnsProvider: options.dnsProvider,
2544
2783
  cloudflareApiToken: options.cloudflareApiToken,
2545
2784
  // Hetzner DNS-01 uses the SAME Cloud API token (api.hetzner.cloud)