vibecarbon 0.9.0 → 0.11.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 (88) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +968 -886
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. package/src/lib/pod-backups.js +0 -53
@@ -23,7 +23,7 @@
23
23
  * Spike on 2026-04-25 measured 82s for cluster bring-up alone.
24
24
  */
25
25
 
26
- import { execFileSync, spawn, spawnSync } from 'node:child_process';
26
+ import { execFileSync, spawn } from 'node:child_process';
27
27
  import {
28
28
  chmodSync,
29
29
  existsSync,
@@ -37,12 +37,13 @@ import { tmpdir } from 'node:os';
37
37
  import { join } from 'node:path';
38
38
  import { setTimeout as delay } from 'node:timers/promises';
39
39
  import * as p from '@clack/prompts';
40
- import { runCommandAsync } from '../../command.js';
40
+ import { runCommandAsync, writeSecretFile } from '../../command.js';
41
41
  import { featureConfigKeys, featureSecretKeys } from '../../config-registry.js';
42
- import { knownHostsPath } from '../../host-keys.js';
42
+ import { knownHostsPath, seedKnownHosts } from '../../host-keys.js';
43
43
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
44
44
  import { dbImageRef } from '../../images.js';
45
45
  import { perfAsync } from '../../perf.js';
46
+ import { pollUntil, runWithRetry } from '../../retry.js';
46
47
  import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
47
48
 
48
49
  export const K3S_VERSION = 'v1.31.5+k3s1';
@@ -97,40 +98,41 @@ export async function runKubectlWithRetry(
97
98
  args,
98
99
  { env, input, captureStdout = false, description } = {},
99
100
  ) {
100
- const ATTEMPTS = 3;
101
+ const RETRY_DELAYS_MS = [2000, 4000];
102
+ const ATTEMPTS = RETRY_DELAYS_MS.length + 1;
101
103
  const desc = description ?? `kubectl ${args.slice(0, 4).join(' ')}`;
102
- let result;
103
- let lastStderr = '';
104
- let lastStdout = '';
105
- for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
106
- result = spawnSync('kubectl', args, {
107
- env,
108
- input,
109
- stdio: ['pipe', 'pipe', 'pipe'],
110
- });
111
- lastStdout = (result.stdout ?? '').toString();
112
- lastStderr = (result.stderr ?? '').toString();
113
- // Stream captured streams through to the operator's terminal so the
114
- // success path looks identical to the legacy `stdio: 'inherit'` shape.
115
- // captureStdout suppresses stdout streaming because the caller wants
116
- // the bytes (e.g. `kubectl get -o jsonpath`) — stderr still streams
117
- // so a real warning is still visible.
118
- if (!captureStdout && lastStdout) process.stdout.write(lastStdout);
119
- if (lastStderr) process.stderr.write(lastStderr);
120
- if (result.status === 0) break;
121
- if (!KUBECTL_TRANSIENT_PATTERN.test(lastStderr) || attempt === ATTEMPTS) break;
122
- const backoffMs = 2000 * attempt;
123
- console.log(
124
- `${desc} hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${backoffMs}ms`,
104
+ try {
105
+ return await runWithRetry(
106
+ async () => {
107
+ try {
108
+ const out = await runCommandAsync(['kubectl', ...args], { env, input, silent: true });
109
+ // Stream captured stdout through to the operator's terminal so the
110
+ // success path looks identical to the legacy `stdio: 'inherit'` shape.
111
+ // captureStdout suppresses stdout streaming because the caller wants
112
+ // the bytes (e.g. `kubectl get -o jsonpath`).
113
+ if (!captureStdout && out) process.stdout.write(out);
114
+ return out;
115
+ } catch (err) {
116
+ if (!captureStdout && err.stdout) process.stdout.write(err.stdout);
117
+ if (err.stderr) process.stderr.write(err.stderr);
118
+ throw err;
119
+ }
120
+ },
121
+ {
122
+ delaysMs: RETRY_DELAYS_MS,
123
+ isTransient: (err) => KUBECTL_TRANSIENT_PATTERN.test(String(err.stderr ?? err.message)),
124
+ onRetry: (_err, attempt) => {
125
+ console.log(
126
+ `${desc} hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${RETRY_DELAYS_MS[attempt - 1]}ms`,
127
+ );
128
+ },
129
+ },
125
130
  );
126
- await delay(backoffMs);
127
- }
128
- if (result?.status !== 0) {
131
+ } catch (err) {
129
132
  throw new Error(
130
- `${desc} failed with exit ${result?.status ?? '?'}: ${lastStderr.trim().slice(-500)}`,
133
+ `${desc} failed with exit ${err.status ?? '?'}: ${(err.stderr ?? '').toString().trim().slice(-500)}`,
131
134
  );
132
135
  }
133
- return lastStdout;
134
136
  }
135
137
 
136
138
  /**
@@ -447,44 +449,42 @@ function sshHostKeyOpts(khPath) {
447
449
  * @returns {Promise<void>}
448
450
  */
449
451
  export async function waitForK3sReady(masterIp, sshKeyPath, khPath, maxWaitSec = 600) {
450
- const start = Date.now();
451
- let waitMs = 2000;
452
- let lastErr;
453
- while ((Date.now() - start) / 1000 < maxWaitSec) {
454
- try {
455
- execFileSync(
456
- 'ssh',
457
- [
458
- '-i',
459
- sshKeyPath,
460
- ...sshHostKeyOpts(khPath),
461
- '-o',
462
- 'ConnectTimeout=5',
463
- '-o',
464
- 'BatchMode=yes',
465
- `root@${masterIp}`,
466
- 'test -f /tmp/k3s-ready',
467
- ],
468
- { stdio: 'ignore' },
469
- );
470
- return;
471
- } catch (err) {
472
- lastErr = err;
473
- await delay(waitMs);
474
- waitMs = Math.min(waitMs * 2, 15000);
475
- }
452
+ try {
453
+ await pollUntil(
454
+ async () => {
455
+ await runCommandAsync(
456
+ [
457
+ 'ssh',
458
+ '-i',
459
+ sshKeyPath,
460
+ ...sshHostKeyOpts(khPath),
461
+ '-o',
462
+ 'ConnectTimeout=5',
463
+ '-o',
464
+ 'BatchMode=yes',
465
+ `root@${masterIp}`,
466
+ 'test -f /tmp/k3s-ready',
467
+ ],
468
+ { silent: true },
469
+ );
470
+ return true;
471
+ },
472
+ { budgetMs: maxWaitSec * 1000, description: 'k3s ready flag' },
473
+ );
474
+ } catch (err) {
475
+ // Capture cloud-init + syslog before throwing so the failure log says
476
+ // WHY install timed out (apt slow? k3s download 5xx? script crashed?)
477
+ // instead of just "300s passed and the marker isn't there." Best-effort —
478
+ // if the box is unreachable, captureK3sInstallDiag returns an empty string
479
+ // and we surface only the SSH probe error.
480
+ const lastErr = err.cause ?? err;
481
+ const diag = await captureK3sInstallDiag(masterIp, sshKeyPath, khPath);
482
+ throw new Error(
483
+ `k3s did not become ready on ${masterIp} within ${maxWaitSec}s. ` +
484
+ `Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
485
+ (diag ? `\n\n--- install diagnostics from ${masterIp} ---\n${diag}` : ''),
486
+ );
476
487
  }
477
- // Capture cloud-init + syslog before throwing so the failure log says
478
- // WHY install timed out (apt slow? k3s download 5xx? script crashed?)
479
- // instead of just "300s passed and the marker isn't there." Best-effort —
480
- // if the box is unreachable, captureK3sInstallDiag returns an empty string
481
- // and we surface only the SSH probe error.
482
- const diag = captureK3sInstallDiag(masterIp, sshKeyPath, khPath);
483
- throw new Error(
484
- `k3s did not become ready on ${masterIp} within ${maxWaitSec}s. ` +
485
- `Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
486
- (diag ? `\n\n--- install diagnostics from ${masterIp} ---\n${diag}` : ''),
487
- );
488
488
  }
489
489
 
490
490
  /**
@@ -497,20 +497,20 @@ export async function waitForK3sReady(masterIp, sshKeyPath, khPath, maxWaitSec =
497
497
  * @param {string} khPath - per-env known_hosts file path
498
498
  * @param {string} projectDir
499
499
  * @param {string} environment
500
- * @returns {string} absolute path to the local kubeconfig
500
+ * @returns {Promise<string>} absolute path to the local kubeconfig
501
501
  */
502
- export function fetchKubeconfig(masterIp, sshKeyPath, khPath, projectDir, environment) {
502
+ export async function fetchKubeconfig(masterIp, sshKeyPath, khPath, projectDir, environment) {
503
503
  const localPath = join(projectDir, '.vibecarbon', `kubeconfig-${environment}`);
504
- execFileSync(
505
- 'scp',
504
+ await runCommandAsync(
506
505
  [
506
+ 'scp',
507
507
  '-i',
508
508
  sshKeyPath,
509
509
  ...sshHostKeyOpts(khPath),
510
510
  `root@${masterIp}:/etc/rancher/k3s/k3s.yaml`,
511
511
  localPath,
512
512
  ],
513
- { stdio: 'pipe' },
513
+ { silent: true },
514
514
  );
515
515
  const kc = readFileSync(localPath, 'utf-8');
516
516
  const patched = kc.replace(
@@ -611,7 +611,7 @@ export async function buildAppImage(projectDir, projectName, rebuild = false, do
611
611
  const { collectComposeBuildArgs } = await import('../compose/build-args.js');
612
612
  buildArgs = collectComposeBuildArgs(projectDir, { projectName, domain });
613
613
  }
614
- return buildLocalImage(projectDir, {
614
+ return await buildLocalImage(projectDir, {
615
615
  projectName,
616
616
  rebuild,
617
617
  tagPrefix: '10.0.1.1:5000',
@@ -642,42 +642,40 @@ export async function buildAppImage(projectDir, projectName, rebuild = false, do
642
642
  * @returns {Promise<void>}
643
643
  */
644
644
  export async function waitForK3sBinary(target, sshKeyPath, khPath, maxWaitSec = 300) {
645
- const start = Date.now();
646
- let waitMs = 2000;
647
- let lastErr;
648
- while ((Date.now() - start) / 1000 < maxWaitSec) {
649
- try {
650
- execFileSync(
651
- 'ssh',
652
- [
653
- '-i',
654
- sshKeyPath,
655
- ...sshHostKeyOpts(khPath),
656
- '-o',
657
- 'ConnectTimeout=5',
658
- '-o',
659
- 'BatchMode=yes',
660
- target,
661
- 'command -v k3s >/dev/null',
662
- ],
663
- { stdio: 'ignore' },
664
- );
665
- return;
666
- } catch (err) {
667
- lastErr = err;
668
- await delay(waitMs);
669
- waitMs = Math.min(waitMs * 2, 15000);
670
- }
645
+ try {
646
+ await pollUntil(
647
+ async () => {
648
+ await runCommandAsync(
649
+ [
650
+ 'ssh',
651
+ '-i',
652
+ sshKeyPath,
653
+ ...sshHostKeyOpts(khPath),
654
+ '-o',
655
+ 'ConnectTimeout=5',
656
+ '-o',
657
+ 'BatchMode=yes',
658
+ target,
659
+ 'command -v k3s >/dev/null',
660
+ ],
661
+ { silent: true },
662
+ );
663
+ return true;
664
+ },
665
+ { budgetMs: maxWaitSec * 1000, description: 'k3s binary on PATH' },
666
+ );
667
+ } catch (err) {
668
+ // Same diagnostic capture as waitForK3sReady — see that function for why.
669
+ // `target` here is `root@<ip>` form; strip the user@ prefix for the host.
670
+ const lastErr = err.cause ?? err;
671
+ const ip = String(target).includes('@') ? String(target).split('@').pop() : String(target);
672
+ const diag = await captureK3sInstallDiag(ip, sshKeyPath, khPath);
673
+ throw new Error(
674
+ `k3s binary did not appear on ${target} within ${maxWaitSec}s. ` +
675
+ `Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
676
+ (diag ? `\n\n--- install diagnostics from ${ip} ---\n${diag}` : ''),
677
+ );
671
678
  }
672
- // Same diagnostic capture as waitForK3sReady — see that function for why.
673
- // `target` here is `root@<ip>` form; strip the user@ prefix for the host.
674
- const ip = String(target).includes('@') ? String(target).split('@').pop() : String(target);
675
- const diag = captureK3sInstallDiag(ip, sshKeyPath, khPath);
676
- throw new Error(
677
- `k3s binary did not appear on ${target} within ${maxWaitSec}s. ` +
678
- `Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
679
- (diag ? `\n\n--- install diagnostics from ${ip} ---\n${diag}` : ''),
680
- );
681
679
  }
682
680
 
683
681
  /**
@@ -706,7 +704,7 @@ export async function waitForK3sBinary(target, sshKeyPath, khPath, maxWaitSec =
706
704
  *
707
705
  * Returns '' if even the first SSH fails (box unreachable / down).
708
706
  */
709
- function captureK3sInstallDiag(host, sshKeyPath, khPath) {
707
+ async function captureK3sInstallDiag(host, sshKeyPath, khPath) {
710
708
  const sshArgs = (cmd) => [
711
709
  '-i',
712
710
  sshKeyPath,
@@ -801,11 +799,9 @@ function captureK3sInstallDiag(host, sshKeyPath, khPath) {
801
799
  for (const probe of probes) {
802
800
  const sliceLen = probe.slice ?? 4000;
803
801
  try {
804
- const result = execFileSync('ssh', sshArgs(probe.cmd), {
805
- encoding: 'utf-8',
802
+ const result = await runCommandAsync(['ssh', ...sshArgs(probe.cmd)], {
803
+ silent: true,
806
804
  timeout: 20_000,
807
- stdio: ['ignore', 'pipe', 'pipe'],
808
- maxBuffer: 4 * 1024 * 1024,
809
805
  });
810
806
  // Keep the END of the output (failure tail), not the start. ci-info
811
807
  // tables at the head of cloud-init-output.log used to push the
@@ -904,21 +900,14 @@ export async function sideloadK3s({ tag, sshTargets, sshKey, khPath }) {
904
900
  });
905
901
  });
906
902
 
907
- let lastErr;
908
- for (let attempt = 0; attempt <= SIDELOAD_DELAYS_MS.length; attempt++) {
909
- try {
910
- await runOnce();
911
- return;
912
- } catch (err) {
913
- lastErr = err;
914
- if (attempt === SIDELOAD_DELAYS_MS.length) break;
903
+ await runWithRetry(runOnce, {
904
+ delaysMs: SIDELOAD_DELAYS_MS,
905
+ onRetry: (err, attempt) => {
915
906
  console.warn(
916
- `[sideloadK3s] ${target} attempt ${attempt + 1} failed (${err.message}); retrying in ${SIDELOAD_DELAYS_MS[attempt] / 1000}s`,
907
+ `[sideloadK3s] ${target} attempt ${attempt} failed (${err.message}); retrying in ${SIDELOAD_DELAYS_MS[attempt - 1] / 1000}s`,
917
908
  );
918
- await new Promise((r) => setTimeout(r, SIDELOAD_DELAYS_MS[attempt]));
919
- }
920
- }
921
- throw lastErr;
909
+ },
910
+ });
922
911
  }),
923
912
  );
924
913
  }
@@ -1034,14 +1023,54 @@ export async function pushImageToLocalRegistry({
1034
1023
  setPort(localTunnelPort);
1035
1024
 
1036
1025
  /** Best-effort teardown of any existing tunnel on localhost:<port>. */
1037
- const teardown = () => {
1038
- try {
1039
- execFileSync('pkill', ['-f', tunnelPattern], { stdio: 'ignore' });
1040
- } catch {
1041
- // pkill exits 1 when no matching process — that's fine.
1042
- }
1026
+ const teardown = async () => {
1027
+ // pkill exits 1 when no matching process — that's fine, hence ignoreError.
1028
+ await runCommandAsync(['pkill', '-f', tunnelPattern], { silent: true, ignoreError: true });
1043
1029
  };
1044
1030
 
1031
+ /**
1032
+ * Open a backgrounding SSH tunnel (`-N -f`) and settle on the child's
1033
+ * 'exit' event — NOT 'close'.
1034
+ *
1035
+ * OpenSSH's `-f` forks a daemon that inherits the tunnel's stdout/stderr
1036
+ * fds and holds them open for the tunnel's entire lifetime. With piped
1037
+ * stdio the parent process therefore never sees 'close' on SUCCESS (the
1038
+ * fds stay open in the daemon), so a runCommandAsync-style settle-on-close
1039
+ * would hang the whole deploy. 'exit' fires when the foreground ssh
1040
+ * process exits, which happens right after the successful fork (code 0)
1041
+ * or immediately on a pre-fork bind failure (nonzero under
1042
+ * ExitOnForwardFailure=yes). stdout is ignored; only stderr is piped so a
1043
+ * bind failure's diagnostic text survives for the port-walk catch below.
1044
+ * The rejected error mirrors runCommandAsync's shape (.status + .stderr +
1045
+ * `Command failed: …` message) so the surrounding catch is unchanged. The
1046
+ * daemon reaps itself, so we don't register it in command.js's
1047
+ * activeChildren (which isn't exported anyway).
1048
+ */
1049
+ const openSshTunnel = (argv) =>
1050
+ new Promise((resolve, reject) => {
1051
+ const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'ignore', 'pipe'] });
1052
+ // The -f daemon inherits the stderr pipe for its lifetime; without
1053
+ // unref a missed pkill teardown would hold the event loop open.
1054
+ child.unref();
1055
+ let stderr = '';
1056
+ child.stderr?.on('data', (d) => {
1057
+ stderr += d;
1058
+ });
1059
+ child.on('error', (err) => reject(err));
1060
+ child.on('exit', (code, signal) => {
1061
+ if (code === 0) return resolve(undefined);
1062
+ const cmdStr = argv.join(' ');
1063
+ const trimmed = stderr.trim();
1064
+ const error = new Error(
1065
+ trimmed ? `Command failed: ${cmdStr}\n${trimmed}` : `Command failed: ${cmdStr}`,
1066
+ );
1067
+ error.status = code;
1068
+ error.stderr = stderr;
1069
+ error.signal = signal;
1070
+ reject(error);
1071
+ });
1072
+ });
1073
+
1045
1074
  /**
1046
1075
  * Try to open the SSH tunnel on the currently-set port; if SSH bind
1047
1076
  * fails (sibling deploy holds the port), walk forward up to 20 ports.
@@ -1050,29 +1079,26 @@ export async function pushImageToLocalRegistry({
1050
1079
  * because ssh exits non-zero immediately on bind failure (via
1051
1080
  * ExitOnForwardFailure=yes).
1052
1081
  */
1053
- const openTunnelOrWalk = () => {
1082
+ const openTunnelOrWalk = async () => {
1054
1083
  const errors = [];
1055
1084
  for (let probe = 0; probe < 20; probe++) {
1056
1085
  const candidate = localTunnelPort + probe;
1057
1086
  if (candidate > 65535) break;
1058
1087
  setPort(candidate);
1059
1088
  try {
1060
- execFileSync(
1089
+ await openSshTunnel([
1061
1090
  'ssh',
1062
- [
1063
- '-i',
1064
- sshKey,
1065
- ...sshHostKeyOpts(khPath),
1066
- '-o',
1067
- 'ExitOnForwardFailure=yes',
1068
- '-L',
1069
- forwardSpec,
1070
- '-N',
1071
- '-f',
1072
- target,
1073
- ],
1074
- { stdio: 'inherit' },
1075
- );
1091
+ '-i',
1092
+ sshKey,
1093
+ ...sshHostKeyOpts(khPath),
1094
+ '-o',
1095
+ 'ExitOnForwardFailure=yes',
1096
+ '-L',
1097
+ forwardSpec,
1098
+ '-N',
1099
+ '-f',
1100
+ target,
1101
+ ]);
1076
1102
  return;
1077
1103
  } catch (err) {
1078
1104
  errors.push(`port ${candidate}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1101,25 +1127,31 @@ export async function pushImageToLocalRegistry({
1101
1127
  );
1102
1128
  });
1103
1129
 
1104
- // Three attempts with progressive backoff. Failure modes we're
1105
- // covering, in priority order:
1130
+ // Progressive backoff across attempts. Failure modes we're covering, in
1131
+ // priority order:
1106
1132
  // 1. Stale tunnel from a parallel run holds :5000 → reopen tunnel.
1107
1133
  // Recovers in seconds (handled by the per-attempt teardown).
1108
1134
  // 2. Registry pod restarted between back-to-back pushes (e.g.
1109
1135
  // probe-killed mid-prior-push) → manifest PUT returns EOF or
1110
1136
  // `manifest blob unknown to registry`. Pod recovery is ~10–20s,
1111
- // so the second/third attempt has the longer backoff to let the
1137
+ // so the later attempts have the longer backoff to let the
1112
1138
  // replacement pod become Ready before re-pushing.
1113
- // 3. S3 transient (Hetzner Object Storage 5xx during blob upload)
1114
- // → registry returns the same `blob unknown` after the layer is
1115
- // "uploaded" but not yet readable. Same recovery shape as (2).
1116
- // The 1s/15s/30s schedule keeps the happy path fast (single attempt
1117
- // ~6s end-to-end) while giving genuine restarts time to recover.
1118
- const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000];
1139
+ // 3. S3 throttle (Hetzner Object Storage 503 SlowDown during blob
1140
+ // upload) the registry propagates a `503 Service Unavailable` to
1141
+ // the docker client. This is LOAD-induced: k8s-HA deploys the primary
1142
+ // and standby clusters in parallel, and BOTH registries write layers
1143
+ // to the same Hetzner S3 account at once, so the account throttles.
1144
+ // 3 short attempts (~46s of settle) couldn't ride it out. The longer
1145
+ // tail (60s/120s) extends the window past the throttle window AND past
1146
+ // the sibling cluster's push draining, which halves the S3 write load.
1147
+ // The 1s/15s head keeps the happy path fast (single attempt ~6s end-to-end)
1148
+ // while the 30s/60s/120s tail rides out genuine restarts + S3 throttling.
1149
+ // Note docker push is content-addressable: layers whose S3 write already
1150
+ // succeeded are skipped on retry, so later attempts get progressively cheaper.
1151
+ const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000, 60_000, 120_000];
1119
1152
  const maxAttempts = settleDelays.length;
1120
1153
  const attemptErrors = [];
1121
1154
  const taggedAliases = new Set();
1122
- let pushed = false;
1123
1155
  // Per-attempt structured logging — without this, the only visible signal
1124
1156
  // for a slow push is the final perf duration (which collapses retry
1125
1157
  // count + per-attempt time into a single number). iter-perfwave2 showed
@@ -1128,48 +1160,63 @@ export async function pushImageToLocalRegistry({
1128
1160
  // make next run's analysis trivial: count `[push] attempt=` lines for
1129
1161
  // attempt-count, sum `dur=` for actual push wall-clock.
1130
1162
  const startedAt = Date.now();
1131
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1163
+
1164
+ // The per-attempt work, folded onto runWithRetry below. The settle delay
1165
+ // stays INSIDE this function (keyed by the same `attempt` index
1166
+ // runWithRetry hands us) because it must apply before every push,
1167
+ // including the very first — the freshly-opened tunnel needs a moment to
1168
+ // bind, it's not just a backoff between failures. runWithRetry's own
1169
+ // inter-attempt delay is left at 0ms (see below) so the total attempt
1170
+ // count matches settleDelays.length exactly, same as the old hand-rolled
1171
+ // loop; the meaningful delay is the one computed here.
1172
+ const runAttempt = async (attempt) => {
1132
1173
  const attemptStart = Date.now();
1133
1174
  let outcome = 'ok';
1134
1175
  try {
1135
1176
  // openTunnel may walk to a different port on bind-collision; localTag
1136
1177
  // is updated in lockstep so push targets the actual chosen port.
1137
- openTunnel();
1178
+ await openTunnel();
1138
1179
  // Add the localhost-prefixed alias for the chosen port. Skip the
1139
1180
  // re-tag if we already aliased this port on an earlier attempt
1140
1181
  // (same port → same alias, docker tag is idempotent but the call
1141
1182
  // is wasteful). Track all aliases for cleanup so a port walk
1142
1183
  // doesn't leak local image refs.
1143
1184
  if (!taggedAliases.has(localTag)) {
1144
- execFileSync('docker', ['tag', tag, localTag], { stdio: 'inherit' });
1185
+ await runCommandAsync(['docker', 'tag', tag, localTag], { silent: true });
1145
1186
  taggedAliases.add(localTag);
1146
1187
  }
1147
1188
  await delay(settleDelays[attempt]);
1148
1189
  await push();
1149
- pushed = true;
1150
- break;
1151
1190
  } catch (err) {
1152
1191
  attemptErrors.push(err instanceof Error ? err.message : String(err));
1153
1192
  outcome = `fail(${(err instanceof Error ? err.message : String(err)).split('\n')[0].slice(0, 80)})`;
1193
+ throw err;
1154
1194
  } finally {
1155
1195
  // Tear down between attempts so a stuck tunnel from this attempt
1156
1196
  // doesn't bind-block the next attempt's openTunnel walk.
1157
- teardown();
1197
+ await teardown();
1158
1198
  const dur = Math.round((Date.now() - attemptStart) / 1000);
1159
1199
  const totalDur = Math.round((Date.now() - startedAt) / 1000);
1160
1200
  console.error(
1161
1201
  `[push] tag=${tag.split('/').pop()} attempt=${attempt + 1}/${maxAttempts} port=${localTag.split('/')[0]} settle=${settleDelays[attempt] / 1000}s dur=${dur}s totalDur=${totalDur}s ${outcome}`,
1162
1202
  );
1163
1203
  }
1204
+ };
1205
+
1206
+ let pushed = false;
1207
+ try {
1208
+ await runWithRetry(runAttempt, {
1209
+ delaysMs: new Array(Math.max(maxAttempts - 1, 0)).fill(0),
1210
+ });
1211
+ pushed = true;
1212
+ } catch {
1213
+ // attemptErrors was already populated inside runAttempt; the
1214
+ // exhausted-attempts error below is what the caller actually sees.
1164
1215
  }
1165
1216
  // Cleanup every localhost alias we created. Underlying image content
1166
1217
  // stays addressable as the original `tag` (used by sideload + Deployment).
1167
1218
  for (const alias of taggedAliases) {
1168
- try {
1169
- execFileSync('docker', ['rmi', alias], { stdio: 'ignore' });
1170
- } catch {
1171
- // non-fatal — leftover alias just sits in the local docker cache.
1172
- }
1219
+ await runCommandAsync(['docker', 'rmi', alias], { silent: true, ignoreError: true });
1173
1220
  }
1174
1221
  if (!pushed) {
1175
1222
  throw new Error(
@@ -1325,6 +1372,7 @@ export async function installSupabase({
1325
1372
  envLocal,
1326
1373
  dbImageTag,
1327
1374
  backupBucketName,
1375
+ walgRole = 'primary',
1328
1376
  }) {
1329
1377
  const env = { ...process.env, KUBECONFIG: kubeconfig };
1330
1378
  // Render the values template — substitute every {{TOKEN}} the template
@@ -1351,6 +1399,15 @@ export async function installSupabase({
1351
1399
  // WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
1352
1400
  // bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
1353
1401
  // leave it as s3:/// (wal-g would error).
1402
+ //
1403
+ // SINGLE canonical prefix (NO role segment). Reads (backup-fetch/restore/
1404
+ // reseed) and writes (backup-push/wal-push) must all agree on ONE prefix — a
1405
+ // role-segmented prefix made the standby read an empty `…/walg/standby` and
1406
+ // fail restore/reseed with "No backups found" (caught by compose-ha scale
1407
+ // e2e). Anti-collision (finding #3: a standby / bring-up-phase independent
1408
+ // primary must never WRITE here) is enforced by the WALG_ROLE WRITE-GUARD:
1409
+ // wal-archive.sh and the backup CronJob both no-op when WALG_ROLE=standby.
1410
+ // walgRole is rendered into the db env below so those write guards can read it.
1354
1411
  const walgBucket = backupBucketName || s3Config?.bucket || '';
1355
1412
  const walgS3Prefix = walgBucket
1356
1413
  ? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
@@ -1360,6 +1417,7 @@ export async function installSupabase({
1360
1417
  .replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
1361
1418
  .replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
1362
1419
  .replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
1420
+ .replace(/\{\{WALG_ROLE\}\}/g, walgRole)
1363
1421
  .replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
1364
1422
  .replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
1365
1423
  .replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
@@ -1370,20 +1428,23 @@ export async function installSupabase({
1370
1428
  .replace(/\{\{ADMIN_EMAIL\}\}/g, envLocal?.ADMIN_EMAIL ?? '')
1371
1429
  .replace(/\{\{ADMIN_PASSWORD\}\}/g, envLocal?.ADMIN_PASSWORD ?? '');
1372
1430
  const tmpValues = join(tmpdir(), `vibecarbon-supabase-values-${process.pid}-${Date.now()}.yaml`);
1373
- writeFileSync(tmpValues, rendered);
1431
+ // SECURITY: the rendered values contain ADMIN_EMAIL/ADMIN_PASSWORD and live
1432
+ // for the whole `helm upgrade --wait` window — write 0o600, never 0o644.
1433
+ writeSecretFile(tmpValues, rendered);
1374
1434
  try {
1375
1435
  // Add the supabase-community helm repo (idempotent — non-zero if exists).
1376
- try {
1377
- execFileSync('helm', ['repo', 'add', SUPABASE_HELM_REPO_NAME, SUPABASE_HELM_REPO_URL], {
1378
- stdio: 'inherit',
1436
+ // helm repo add fails if the repo already exists; `helm repo update`
1437
+ // below refreshes either way, hence ignoreError.
1438
+ await runCommandAsync(
1439
+ ['helm', 'repo', 'add', SUPABASE_HELM_REPO_NAME, SUPABASE_HELM_REPO_URL],
1440
+ {
1441
+ silent: true,
1442
+ ignoreError: true,
1379
1443
  env,
1380
- });
1381
- } catch {
1382
- // helm repo add fails if the repo already exists; `helm repo update`
1383
- // below refreshes either way.
1384
- }
1385
- execFileSync('helm', ['repo', 'update', SUPABASE_HELM_REPO_NAME], {
1386
- stdio: 'inherit',
1444
+ },
1445
+ );
1446
+ await runCommandAsync(['helm', 'repo', 'update', SUPABASE_HELM_REPO_NAME], {
1447
+ silent: true,
1387
1448
  env,
1388
1449
  });
1389
1450
  // --wait blocks until all pods are Ready. --create-namespace is a no-op
@@ -1426,20 +1487,22 @@ export async function installSupabase({
1426
1487
  // duration. iter-perfwave2 showed primary 3m47s vs standby 1m43s
1427
1488
  // with no way to localize which pod was the long pole.
1428
1489
  try {
1429
- const podSnapshot = execFileSync(
1430
- 'kubectl',
1431
- [
1432
- '-n',
1433
- 'vibecarbon',
1434
- 'get',
1435
- 'pods',
1436
- '-l',
1437
- 'app.kubernetes.io/instance=supabase',
1438
- '-o',
1439
- 'custom-columns=NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp,NODE:.spec.nodeName',
1440
- '--no-headers',
1441
- ],
1442
- { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] },
1490
+ const podSnapshot = (
1491
+ await runCommandAsync(
1492
+ [
1493
+ 'kubectl',
1494
+ '-n',
1495
+ 'vibecarbon',
1496
+ 'get',
1497
+ 'pods',
1498
+ '-l',
1499
+ 'app.kubernetes.io/instance=supabase',
1500
+ '-o',
1501
+ 'custom-columns=NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp,NODE:.spec.nodeName',
1502
+ '--no-headers',
1503
+ ],
1504
+ { env, silent: true },
1505
+ )
1443
1506
  ).trim();
1444
1507
  console.error(`[supabase] helm-wait ${helmDur}s; post-wait pods:\n${podSnapshot}`);
1445
1508
  } catch (e) {
@@ -1608,28 +1671,34 @@ export async function applyMigrations({ kubeconfig, projectDir }) {
1608
1671
  await waitForSupabaseStorageSchema({ kubeconfig });
1609
1672
  for (const file of files) {
1610
1673
  const sql = readFileSync(join(migrationsDir, file), 'utf-8');
1611
- const result = spawnSync(
1612
- 'kubectl',
1613
- [
1614
- '-n',
1615
- 'vibecarbon',
1616
- 'exec',
1617
- '-i',
1618
- 'supabase-supabase-db-0',
1619
- '--',
1620
- 'psql',
1621
- '-U',
1622
- 'supabase_admin',
1623
- '-d',
1624
- 'postgres',
1625
- '-v',
1626
- 'ON_ERROR_STOP=1',
1627
- ],
1628
- { env, input: sql, stdio: ['pipe', 'inherit', 'inherit'] },
1629
- );
1630
- if (result.status !== 0) {
1674
+ try {
1675
+ await runCommandAsync(
1676
+ [
1677
+ 'kubectl',
1678
+ '-n',
1679
+ 'vibecarbon',
1680
+ 'exec',
1681
+ '-i',
1682
+ 'supabase-supabase-db-0',
1683
+ '--',
1684
+ 'psql',
1685
+ '-U',
1686
+ 'supabase_admin',
1687
+ '-d',
1688
+ 'postgres',
1689
+ '-v',
1690
+ 'ON_ERROR_STOP=1',
1691
+ ],
1692
+ { env, input: sql, silent: true },
1693
+ );
1694
+ } catch (err) {
1695
+ // silent:true buffers stdout/stderr instead of streaming them
1696
+ // (unlike the old stdio: [...,'inherit','inherit']) — print them here
1697
+ // on failure so the operator still sees the psql error output.
1698
+ if (err.stdout) process.stdout.write(err.stdout);
1699
+ if (err.stderr) process.stderr.write(err.stderr);
1631
1700
  throw new Error(
1632
- `applyMigrations: ${file} failed with exit ${result.status}. ` +
1701
+ `applyMigrations: ${file} failed with exit ${err.status}. ` +
1633
1702
  `Fix the migration or set ON_ERROR_STOP=0 manually.`,
1634
1703
  );
1635
1704
  }
@@ -1936,18 +2005,14 @@ export async function applyK3sManifests({
1936
2005
  // reconciled, otherwise Orders fail with "no provider for solver".
1937
2006
  // Mirrors the Supabase pattern at step 7.
1938
2007
  if (dnsProvider === 'hetzner') {
1939
- try {
1940
- execFileSync(
1941
- 'helm',
1942
- ['repo', 'add', HETZNER_WEBHOOK_HELM_REPO_NAME, HETZNER_WEBHOOK_HELM_REPO_URL],
1943
- { stdio: 'inherit', env },
1944
- );
1945
- } catch {
1946
- // helm repo add fails if the repo already exists; `helm repo update`
1947
- // below refreshes either way.
1948
- }
1949
- execFileSync('helm', ['repo', 'update', HETZNER_WEBHOOK_HELM_REPO_NAME], {
1950
- stdio: 'inherit',
2008
+ // helm repo add fails if the repo already exists; `helm repo update`
2009
+ // below refreshes either way, hence ignoreError.
2010
+ await runCommandAsync(
2011
+ ['helm', 'repo', 'add', HETZNER_WEBHOOK_HELM_REPO_NAME, HETZNER_WEBHOOK_HELM_REPO_URL],
2012
+ { silent: true, ignoreError: true, env },
2013
+ );
2014
+ await runCommandAsync(['helm', 'repo', 'update', HETZNER_WEBHOOK_HELM_REPO_NAME], {
2015
+ silent: true,
1951
2016
  env,
1952
2017
  });
1953
2018
  try {
@@ -1992,16 +2057,19 @@ export async function applyK3sManifests({
1992
2057
  } catch (err) {
1993
2058
  // Surface webhook pod logs on failure — chart install rarely
1994
2059
  // tells the operator anything useful when the actual cause is
1995
- // a CrashLoopBackOff or a missing dependency.
1996
- try {
1997
- execFileSync(
2060
+ // a CrashLoopBackOff or a missing dependency. Best-effort: diag
2061
+ // output should stream to the operator regardless of outcome.
2062
+ await runCommandAsync(
2063
+ [
1998
2064
  'kubectl',
1999
- ['-n', 'cert-manager', 'logs', `deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`, '--tail=200'],
2000
- { stdio: 'inherit', env },
2001
- );
2002
- } catch {
2003
- // best-effort; continue to original throw
2004
- }
2065
+ '-n',
2066
+ 'cert-manager',
2067
+ 'logs',
2068
+ `deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`,
2069
+ '--tail=200',
2070
+ ],
2071
+ { silent: false, ignoreError: true, env },
2072
+ );
2005
2073
  throw err;
2006
2074
  }
2007
2075
  }
@@ -2089,10 +2157,10 @@ export async function applyK3sManifests({
2089
2157
  // Without this wait, the SSH-tunnel push race-targets a Pending
2090
2158
  // pod (registry container not started → connection refused on
2091
2159
  // :5000). 120s mirrors other base-pod readiness budgets.
2092
- await perfAsync(`deploy.${perfPrefix}.localRegistry.wait`, async () =>
2093
- execFileSync(
2094
- 'kubectl',
2160
+ await perfAsync(`deploy.${perfPrefix}.localRegistry.wait`, async () => {
2161
+ const ok = await runCommandAsync(
2095
2162
  [
2163
+ 'kubectl',
2096
2164
  '-n',
2097
2165
  'vibecarbon',
2098
2166
  'wait',
@@ -2102,9 +2170,10 @@ export async function applyK3sManifests({
2102
2170
  'app=local-registry',
2103
2171
  '--timeout=120s',
2104
2172
  ],
2105
- { stdio: 'inherit', env },
2106
- ),
2107
- );
2173
+ { silent: false, env },
2174
+ );
2175
+ if (ok === false) throw new Error('local-registry pod not Ready');
2176
+ });
2108
2177
  // 5b''. Push the app image to the local registry on master so
2109
2178
  // CA-spawned workers (which don't exist at sideload time) can
2110
2179
  // pull on first schedule. Sideload remains primary distribution
@@ -2297,8 +2366,8 @@ export async function applyK3sManifests({
2297
2366
  { env, description: 'applyK3sManifests: kubectl rollout restart cluster-autoscaler' },
2298
2367
  );
2299
2368
  // rollout-status has its own --timeout and benefits from live progress;
2300
- // leave on execFileSync. Internal apiserver-watch retries cover transient
2301
- // drops within the budget.
2369
+ // runs via runCommandAsync (kubectl rollout status). Internal
2370
+ // apiserver-watch retries cover transient drops within the budget.
2302
2371
  //
2303
2372
  // 300s (was 180s): Phase 5 fires THREE rolling updates back-to-back on a
2304
2373
  // `strategy: Recreate` Deployment (5d patch --nodes, 5d' set env,
@@ -2309,11 +2378,19 @@ export async function applyK3sManifests({
2309
2378
  // Capture pod state on failure so future timeouts surface root cause
2310
2379
  // instead of just "rollout status timed out".
2311
2380
  try {
2312
- execFileSync(
2313
- 'kubectl',
2314
- ['-n', 'kube-system', 'rollout', 'status', 'deploy/cluster-autoscaler', '--timeout=300s'],
2315
- { stdio: 'inherit', env },
2381
+ const ok = await runCommandAsync(
2382
+ [
2383
+ 'kubectl',
2384
+ '-n',
2385
+ 'kube-system',
2386
+ 'rollout',
2387
+ 'status',
2388
+ 'deploy/cluster-autoscaler',
2389
+ '--timeout=300s',
2390
+ ],
2391
+ { silent: false, env },
2316
2392
  );
2393
+ if (ok === false) throw new Error('cluster-autoscaler rollout status failed (timeout 300s)');
2317
2394
  } catch (err) {
2318
2395
  console.error('[k3s] cluster-autoscaler rollout failed — capturing diagnostics:');
2319
2396
  for (const args of [
@@ -2321,11 +2398,7 @@ export async function applyK3sManifests({
2321
2398
  ['-n', 'kube-system', 'describe', 'deploy/cluster-autoscaler'],
2322
2399
  ['-n', 'kube-system', 'logs', '-l', 'app=cluster-autoscaler', '--tail=80'],
2323
2400
  ]) {
2324
- try {
2325
- execFileSync('kubectl', args, { stdio: 'inherit', env });
2326
- } catch {
2327
- // Best-effort diagnostics; original error is what matters
2328
- }
2401
+ await runCommandAsync(['kubectl', ...args], { silent: false, ignoreError: true, env });
2329
2402
  }
2330
2403
  throw err;
2331
2404
  }
@@ -2344,6 +2417,12 @@ export async function applyK3sManifests({
2344
2417
  // timeout in chart). image.db points at the wal-g-equipped db image
2345
2418
  // (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
2346
2419
  // non-secret WALG_* env from supabase.values.yaml.
2420
+ // Derive the wal-g WRITE-GUARD role (NOT a path segment). deployK8sHA calls
2421
+ // deployK3s with environment `${env}-primary` / `${env}-standby`; single-
2422
+ // cluster deploys pass the plain env name → 'primary'. Rendered into the db
2423
+ // container env as WALG_ROLE so wal-archive.sh + the backup CronJob no-op on
2424
+ // the standby. The S3 prefix itself stays canonical (no role segment).
2425
+ const walgRole = environment?.endsWith('-standby') ? 'standby' : 'primary';
2347
2426
  await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
2348
2427
  installSupabase({
2349
2428
  kubeconfig,
@@ -2354,6 +2433,7 @@ export async function applyK3sManifests({
2354
2433
  envLocal,
2355
2434
  dbImageTag,
2356
2435
  backupBucketName,
2436
+ walgRole,
2357
2437
  }),
2358
2438
  );
2359
2439
  // 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
@@ -2499,7 +2579,8 @@ export async function applyK3sManifests({
2499
2579
  'deployment/app',
2500
2580
  '--timeout=600s',
2501
2581
  ];
2502
- const ATTEMPTS = 3;
2582
+ const ROLLOUT_RETRY_DELAYS_MS = [2000, 4000];
2583
+ const ATTEMPTS = ROLLOUT_RETRY_DELAYS_MS.length + 1;
2503
2584
  // Async (spawn-based) — `kubectl rollout status --timeout=600s` can wait
2504
2585
  // up to 10 minutes for pods to roll. spawnSync would block Node's event
2505
2586
  // loop the whole time; in HA the parallel standby branch's perf substep
@@ -2521,22 +2602,27 @@ export async function applyK3sManifests({
2521
2602
  child.on('error', reject);
2522
2603
  child.on('exit', (code) => resolve({ status: code, stderr: stderrBuf }));
2523
2604
  });
2524
- let lastStderr = '';
2525
- let lastStatus;
2526
- for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
2527
- const result = await runRollout();
2528
- lastStatus = result.status;
2529
- lastStderr = result.stderr;
2530
- if (lastStatus === 0) return;
2531
- if (!KUBECTL_TRANSIENT_PATTERN.test(lastStderr) || attempt === ATTEMPTS) break;
2532
- const backoffMs = 2000 * attempt;
2533
- console.log(
2534
- `kubectl rollout status hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${backoffMs}ms`,
2535
- );
2536
- await delay(backoffMs);
2537
- }
2538
- throw new Error(
2539
- `kubectl rollout status failed with exit ${lastStatus ?? '?'}: ${lastStderr.trim().slice(-500)}`,
2605
+ await runWithRetry(
2606
+ async () => {
2607
+ const result = await runRollout();
2608
+ if (result.status !== 0) {
2609
+ const err = new Error(
2610
+ `kubectl rollout status failed with exit ${result.status ?? '?'}: ${result.stderr.trim().slice(-500)}`,
2611
+ );
2612
+ err.stderr = result.stderr;
2613
+ err.status = result.status;
2614
+ throw err;
2615
+ }
2616
+ },
2617
+ {
2618
+ delaysMs: ROLLOUT_RETRY_DELAYS_MS,
2619
+ isTransient: (err) => KUBECTL_TRANSIENT_PATTERN.test(String(err.stderr ?? err.message)),
2620
+ onRetry: (_err, attempt) => {
2621
+ console.log(
2622
+ `kubectl rollout status hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${ROLLOUT_RETRY_DELAYS_MS[attempt - 1]}ms`,
2623
+ );
2624
+ },
2625
+ },
2540
2626
  );
2541
2627
  });
2542
2628
  // 10. Force-restart traefik so it starts AFTER kube-router has installed
@@ -2573,11 +2659,27 @@ export async function applyK3sManifests({
2573
2659
  ],
2574
2660
  { env, description: 'applyK3sManifests: kubectl delete pod traefik' },
2575
2661
  );
2576
- execFileSync(
2577
- 'kubectl',
2578
- ['-n', 'vibecarbon', 'rollout', 'status', 'deployment/traefik', '--timeout=120s'],
2579
- { stdio: 'inherit', env },
2662
+ // ignoreError: true means this resolves null/false on failure instead
2663
+ // of throwing — check explicitly and warn so the non-fatal-failure
2664
+ // signal isn't silently dropped (the outer catch below only fires on
2665
+ // a THROWN error, e.g. from the delete-pod call above).
2666
+ const ok = await runCommandAsync(
2667
+ [
2668
+ 'kubectl',
2669
+ '-n',
2670
+ 'vibecarbon',
2671
+ 'rollout',
2672
+ 'status',
2673
+ 'deployment/traefik',
2674
+ '--timeout=120s',
2675
+ ],
2676
+ { silent: false, ignoreError: true, env },
2580
2677
  );
2678
+ if (ok === null || ok === false) {
2679
+ console.error(
2680
+ '[k3s] traefik post-apply restart warning: kubectl rollout status deployment/traefik did not complete within 120s',
2681
+ );
2682
+ }
2581
2683
  });
2582
2684
  } catch (err) {
2583
2685
  console.error(
@@ -2593,7 +2695,7 @@ export async function applyK3sManifests({
2593
2695
  * App install + sideload land in a follow-up commit (Task 7).
2594
2696
  *
2595
2697
  * @param {K3sDeployOptions} options
2596
- * @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
2698
+ * @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, supabasePrivateIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
2597
2699
  */
2598
2700
  export async function deployK3s(options) {
2599
2701
  const { state } = options;
@@ -2689,7 +2791,14 @@ export async function deployK3s(options) {
2689
2791
  s.stop(`Cluster provisioned — master=${outputs.masterIp} floating=${outputs.floatingIp}`);
2690
2792
  }
2691
2793
  const infraOutputs = state.getStepResult('k3s-infra');
2692
- const { masterIp, floatingIp, supabaseIp, workerIps, networkId } = infraOutputs;
2794
+ const {
2795
+ masterIp,
2796
+ floatingIp,
2797
+ supabaseIp,
2798
+ supabasePrivateIp = '10.0.1.2',
2799
+ workerIps,
2800
+ networkId,
2801
+ } = infraOutputs;
2693
2802
 
2694
2803
  // 4. Wait for cloud-init to finish + k3s to be Ready
2695
2804
  if (!state.shouldSkip('k3s-ready', { masterIp })) {
@@ -2709,12 +2818,30 @@ export async function deployK3s(options) {
2709
2818
  s.stop('k3s reachable');
2710
2819
  }
2711
2820
 
2821
+ // Trusted host-key seed. The SSH opts above pin per-env with accept-new
2822
+ // (TOFU on first connect); seeding the known_hosts from a real ssh-keyscan
2823
+ // now — on (re)provision, once SSH is reachable — turns accept-new into a
2824
+ // strict pin for the rest of this deploy AND every later command, and
2825
+ // re-pins a Hetzner-recycled IP cleanly (seedKnownHosts drops the stale
2826
+ // per-IP line). Best-effort: if ssh-keyscan can't reach a node we fall back
2827
+ // to accept-new TOFU. See host-keys.js seedKnownHosts for the full security
2828
+ // rationale (why only provisioning re-seeds).
2829
+ for (const seedIp of [masterIp, supabaseIp, ...(workerIps ?? [])].filter(Boolean)) {
2830
+ await seedKnownHosts(khPath, seedIp);
2831
+ }
2832
+
2712
2833
  // 5. Fetch + patch kubeconfig
2713
2834
  let kubeconfig;
2714
2835
  if (!state.shouldSkip('k3s-kubeconfig', { masterIp })) {
2715
2836
  state.startStep('k3s-kubeconfig', { masterIp });
2716
2837
  s.start('Retrieving kubeconfig');
2717
- kubeconfig = fetchKubeconfig(masterIp, privateKeyPath, khPath, projectDir, options.environment);
2838
+ kubeconfig = await fetchKubeconfig(
2839
+ masterIp,
2840
+ privateKeyPath,
2841
+ khPath,
2842
+ projectDir,
2843
+ options.environment,
2844
+ );
2718
2845
  state.completeStep('k3s-kubeconfig', { kubeconfig });
2719
2846
  s.stop('Kubeconfig saved');
2720
2847
  } else {
@@ -2827,6 +2954,11 @@ export async function deployK3s(options) {
2827
2954
  masterIp,
2828
2955
  floatingIp,
2829
2956
  supabaseIp,
2957
+ // The supabase node's Hetzner private-network IP (deterministic in the IaC
2958
+ // program, but threaded through the output rather than hardcoded at the use
2959
+ // site). setupReplication renders it into the repl-gateway egress
2960
+ // NetworkPolicy + socat bind for this cluster.
2961
+ supabasePrivateIp,
2830
2962
  workerIps,
2831
2963
  networkId,
2832
2964
  kubeconfig,