vibecarbon 0.10.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.
@@ -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,
@@ -43,6 +43,7 @@ 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)}`);
@@ -1126,7 +1152,6 @@ export async function pushImageToLocalRegistry({
1126
1152
  const maxAttempts = settleDelays.length;
1127
1153
  const attemptErrors = [];
1128
1154
  const taggedAliases = new Set();
1129
- let pushed = false;
1130
1155
  // Per-attempt structured logging — without this, the only visible signal
1131
1156
  // for a slow push is the final perf duration (which collapses retry
1132
1157
  // count + per-attempt time into a single number). iter-perfwave2 showed
@@ -1135,48 +1160,63 @@ export async function pushImageToLocalRegistry({
1135
1160
  // make next run's analysis trivial: count `[push] attempt=` lines for
1136
1161
  // attempt-count, sum `dur=` for actual push wall-clock.
1137
1162
  const startedAt = Date.now();
1138
- 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) => {
1139
1173
  const attemptStart = Date.now();
1140
1174
  let outcome = 'ok';
1141
1175
  try {
1142
1176
  // openTunnel may walk to a different port on bind-collision; localTag
1143
1177
  // is updated in lockstep so push targets the actual chosen port.
1144
- openTunnel();
1178
+ await openTunnel();
1145
1179
  // Add the localhost-prefixed alias for the chosen port. Skip the
1146
1180
  // re-tag if we already aliased this port on an earlier attempt
1147
1181
  // (same port → same alias, docker tag is idempotent but the call
1148
1182
  // is wasteful). Track all aliases for cleanup so a port walk
1149
1183
  // doesn't leak local image refs.
1150
1184
  if (!taggedAliases.has(localTag)) {
1151
- execFileSync('docker', ['tag', tag, localTag], { stdio: 'inherit' });
1185
+ await runCommandAsync(['docker', 'tag', tag, localTag], { silent: true });
1152
1186
  taggedAliases.add(localTag);
1153
1187
  }
1154
1188
  await delay(settleDelays[attempt]);
1155
1189
  await push();
1156
- pushed = true;
1157
- break;
1158
1190
  } catch (err) {
1159
1191
  attemptErrors.push(err instanceof Error ? err.message : String(err));
1160
1192
  outcome = `fail(${(err instanceof Error ? err.message : String(err)).split('\n')[0].slice(0, 80)})`;
1193
+ throw err;
1161
1194
  } finally {
1162
1195
  // Tear down between attempts so a stuck tunnel from this attempt
1163
1196
  // doesn't bind-block the next attempt's openTunnel walk.
1164
- teardown();
1197
+ await teardown();
1165
1198
  const dur = Math.round((Date.now() - attemptStart) / 1000);
1166
1199
  const totalDur = Math.round((Date.now() - startedAt) / 1000);
1167
1200
  console.error(
1168
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}`,
1169
1202
  );
1170
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.
1171
1215
  }
1172
1216
  // Cleanup every localhost alias we created. Underlying image content
1173
1217
  // stays addressable as the original `tag` (used by sideload + Deployment).
1174
1218
  for (const alias of taggedAliases) {
1175
- try {
1176
- execFileSync('docker', ['rmi', alias], { stdio: 'ignore' });
1177
- } catch {
1178
- // non-fatal — leftover alias just sits in the local docker cache.
1179
- }
1219
+ await runCommandAsync(['docker', 'rmi', alias], { silent: true, ignoreError: true });
1180
1220
  }
1181
1221
  if (!pushed) {
1182
1222
  throw new Error(
@@ -1393,17 +1433,18 @@ export async function installSupabase({
1393
1433
  writeSecretFile(tmpValues, rendered);
1394
1434
  try {
1395
1435
  // Add the supabase-community helm repo (idempotent — non-zero if exists).
1396
- try {
1397
- execFileSync('helm', ['repo', 'add', SUPABASE_HELM_REPO_NAME, SUPABASE_HELM_REPO_URL], {
1398
- 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,
1399
1443
  env,
1400
- });
1401
- } catch {
1402
- // helm repo add fails if the repo already exists; `helm repo update`
1403
- // below refreshes either way.
1404
- }
1405
- execFileSync('helm', ['repo', 'update', SUPABASE_HELM_REPO_NAME], {
1406
- stdio: 'inherit',
1444
+ },
1445
+ );
1446
+ await runCommandAsync(['helm', 'repo', 'update', SUPABASE_HELM_REPO_NAME], {
1447
+ silent: true,
1407
1448
  env,
1408
1449
  });
1409
1450
  // --wait blocks until all pods are Ready. --create-namespace is a no-op
@@ -1446,20 +1487,22 @@ export async function installSupabase({
1446
1487
  // duration. iter-perfwave2 showed primary 3m47s vs standby 1m43s
1447
1488
  // with no way to localize which pod was the long pole.
1448
1489
  try {
1449
- const podSnapshot = execFileSync(
1450
- 'kubectl',
1451
- [
1452
- '-n',
1453
- 'vibecarbon',
1454
- 'get',
1455
- 'pods',
1456
- '-l',
1457
- 'app.kubernetes.io/instance=supabase',
1458
- '-o',
1459
- 'custom-columns=NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp,NODE:.spec.nodeName',
1460
- '--no-headers',
1461
- ],
1462
- { 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
+ )
1463
1506
  ).trim();
1464
1507
  console.error(`[supabase] helm-wait ${helmDur}s; post-wait pods:\n${podSnapshot}`);
1465
1508
  } catch (e) {
@@ -1628,28 +1671,34 @@ export async function applyMigrations({ kubeconfig, projectDir }) {
1628
1671
  await waitForSupabaseStorageSchema({ kubeconfig });
1629
1672
  for (const file of files) {
1630
1673
  const sql = readFileSync(join(migrationsDir, file), 'utf-8');
1631
- const result = spawnSync(
1632
- 'kubectl',
1633
- [
1634
- '-n',
1635
- 'vibecarbon',
1636
- 'exec',
1637
- '-i',
1638
- 'supabase-supabase-db-0',
1639
- '--',
1640
- 'psql',
1641
- '-U',
1642
- 'supabase_admin',
1643
- '-d',
1644
- 'postgres',
1645
- '-v',
1646
- 'ON_ERROR_STOP=1',
1647
- ],
1648
- { env, input: sql, stdio: ['pipe', 'inherit', 'inherit'] },
1649
- );
1650
- 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);
1651
1700
  throw new Error(
1652
- `applyMigrations: ${file} failed with exit ${result.status}. ` +
1701
+ `applyMigrations: ${file} failed with exit ${err.status}. ` +
1653
1702
  `Fix the migration or set ON_ERROR_STOP=0 manually.`,
1654
1703
  );
1655
1704
  }
@@ -1956,18 +2005,14 @@ export async function applyK3sManifests({
1956
2005
  // reconciled, otherwise Orders fail with "no provider for solver".
1957
2006
  // Mirrors the Supabase pattern at step 7.
1958
2007
  if (dnsProvider === 'hetzner') {
1959
- try {
1960
- execFileSync(
1961
- 'helm',
1962
- ['repo', 'add', HETZNER_WEBHOOK_HELM_REPO_NAME, HETZNER_WEBHOOK_HELM_REPO_URL],
1963
- { stdio: 'inherit', env },
1964
- );
1965
- } catch {
1966
- // helm repo add fails if the repo already exists; `helm repo update`
1967
- // below refreshes either way.
1968
- }
1969
- execFileSync('helm', ['repo', 'update', HETZNER_WEBHOOK_HELM_REPO_NAME], {
1970
- 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,
1971
2016
  env,
1972
2017
  });
1973
2018
  try {
@@ -2012,16 +2057,19 @@ export async function applyK3sManifests({
2012
2057
  } catch (err) {
2013
2058
  // Surface webhook pod logs on failure — chart install rarely
2014
2059
  // tells the operator anything useful when the actual cause is
2015
- // a CrashLoopBackOff or a missing dependency.
2016
- try {
2017
- 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
+ [
2018
2064
  'kubectl',
2019
- ['-n', 'cert-manager', 'logs', `deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`, '--tail=200'],
2020
- { stdio: 'inherit', env },
2021
- );
2022
- } catch {
2023
- // best-effort; continue to original throw
2024
- }
2065
+ '-n',
2066
+ 'cert-manager',
2067
+ 'logs',
2068
+ `deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`,
2069
+ '--tail=200',
2070
+ ],
2071
+ { silent: false, ignoreError: true, env },
2072
+ );
2025
2073
  throw err;
2026
2074
  }
2027
2075
  }
@@ -2109,10 +2157,10 @@ export async function applyK3sManifests({
2109
2157
  // Without this wait, the SSH-tunnel push race-targets a Pending
2110
2158
  // pod (registry container not started → connection refused on
2111
2159
  // :5000). 120s mirrors other base-pod readiness budgets.
2112
- await perfAsync(`deploy.${perfPrefix}.localRegistry.wait`, async () =>
2113
- execFileSync(
2114
- 'kubectl',
2160
+ await perfAsync(`deploy.${perfPrefix}.localRegistry.wait`, async () => {
2161
+ const ok = await runCommandAsync(
2115
2162
  [
2163
+ 'kubectl',
2116
2164
  '-n',
2117
2165
  'vibecarbon',
2118
2166
  'wait',
@@ -2122,9 +2170,10 @@ export async function applyK3sManifests({
2122
2170
  'app=local-registry',
2123
2171
  '--timeout=120s',
2124
2172
  ],
2125
- { stdio: 'inherit', env },
2126
- ),
2127
- );
2173
+ { silent: false, env },
2174
+ );
2175
+ if (ok === false) throw new Error('local-registry pod not Ready');
2176
+ });
2128
2177
  // 5b''. Push the app image to the local registry on master so
2129
2178
  // CA-spawned workers (which don't exist at sideload time) can
2130
2179
  // pull on first schedule. Sideload remains primary distribution
@@ -2317,8 +2366,8 @@ export async function applyK3sManifests({
2317
2366
  { env, description: 'applyK3sManifests: kubectl rollout restart cluster-autoscaler' },
2318
2367
  );
2319
2368
  // rollout-status has its own --timeout and benefits from live progress;
2320
- // leave on execFileSync. Internal apiserver-watch retries cover transient
2321
- // drops within the budget.
2369
+ // runs via runCommandAsync (kubectl rollout status). Internal
2370
+ // apiserver-watch retries cover transient drops within the budget.
2322
2371
  //
2323
2372
  // 300s (was 180s): Phase 5 fires THREE rolling updates back-to-back on a
2324
2373
  // `strategy: Recreate` Deployment (5d patch --nodes, 5d' set env,
@@ -2329,11 +2378,19 @@ export async function applyK3sManifests({
2329
2378
  // Capture pod state on failure so future timeouts surface root cause
2330
2379
  // instead of just "rollout status timed out".
2331
2380
  try {
2332
- execFileSync(
2333
- 'kubectl',
2334
- ['-n', 'kube-system', 'rollout', 'status', 'deploy/cluster-autoscaler', '--timeout=300s'],
2335
- { 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 },
2336
2392
  );
2393
+ if (ok === false) throw new Error('cluster-autoscaler rollout status failed (timeout 300s)');
2337
2394
  } catch (err) {
2338
2395
  console.error('[k3s] cluster-autoscaler rollout failed — capturing diagnostics:');
2339
2396
  for (const args of [
@@ -2341,11 +2398,7 @@ export async function applyK3sManifests({
2341
2398
  ['-n', 'kube-system', 'describe', 'deploy/cluster-autoscaler'],
2342
2399
  ['-n', 'kube-system', 'logs', '-l', 'app=cluster-autoscaler', '--tail=80'],
2343
2400
  ]) {
2344
- try {
2345
- execFileSync('kubectl', args, { stdio: 'inherit', env });
2346
- } catch {
2347
- // Best-effort diagnostics; original error is what matters
2348
- }
2401
+ await runCommandAsync(['kubectl', ...args], { silent: false, ignoreError: true, env });
2349
2402
  }
2350
2403
  throw err;
2351
2404
  }
@@ -2526,7 +2579,8 @@ export async function applyK3sManifests({
2526
2579
  'deployment/app',
2527
2580
  '--timeout=600s',
2528
2581
  ];
2529
- const ATTEMPTS = 3;
2582
+ const ROLLOUT_RETRY_DELAYS_MS = [2000, 4000];
2583
+ const ATTEMPTS = ROLLOUT_RETRY_DELAYS_MS.length + 1;
2530
2584
  // Async (spawn-based) — `kubectl rollout status --timeout=600s` can wait
2531
2585
  // up to 10 minutes for pods to roll. spawnSync would block Node's event
2532
2586
  // loop the whole time; in HA the parallel standby branch's perf substep
@@ -2548,22 +2602,27 @@ export async function applyK3sManifests({
2548
2602
  child.on('error', reject);
2549
2603
  child.on('exit', (code) => resolve({ status: code, stderr: stderrBuf }));
2550
2604
  });
2551
- let lastStderr = '';
2552
- let lastStatus;
2553
- for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
2554
- const result = await runRollout();
2555
- lastStatus = result.status;
2556
- lastStderr = result.stderr;
2557
- if (lastStatus === 0) return;
2558
- if (!KUBECTL_TRANSIENT_PATTERN.test(lastStderr) || attempt === ATTEMPTS) break;
2559
- const backoffMs = 2000 * attempt;
2560
- console.log(
2561
- `kubectl rollout status hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${backoffMs}ms`,
2562
- );
2563
- await delay(backoffMs);
2564
- }
2565
- throw new Error(
2566
- `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
+ },
2567
2626
  );
2568
2627
  });
2569
2628
  // 10. Force-restart traefik so it starts AFTER kube-router has installed
@@ -2600,11 +2659,27 @@ export async function applyK3sManifests({
2600
2659
  ],
2601
2660
  { env, description: 'applyK3sManifests: kubectl delete pod traefik' },
2602
2661
  );
2603
- execFileSync(
2604
- 'kubectl',
2605
- ['-n', 'vibecarbon', 'rollout', 'status', 'deployment/traefik', '--timeout=120s'],
2606
- { 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 },
2607
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
+ }
2608
2683
  });
2609
2684
  } catch (err) {
2610
2685
  console.error(
@@ -2760,7 +2835,13 @@ export async function deployK3s(options) {
2760
2835
  if (!state.shouldSkip('k3s-kubeconfig', { masterIp })) {
2761
2836
  state.startStep('k3s-kubeconfig', { masterIp });
2762
2837
  s.start('Retrieving kubeconfig');
2763
- kubeconfig = fetchKubeconfig(masterIp, privateKeyPath, khPath, projectDir, options.environment);
2838
+ kubeconfig = await fetchKubeconfig(
2839
+ masterIp,
2840
+ privateKeyPath,
2841
+ khPath,
2842
+ projectDir,
2843
+ options.environment,
2844
+ );
2764
2845
  state.completeStep('k3s-kubeconfig', { kubeconfig });
2765
2846
  s.stop('Kubeconfig saved');
2766
2847
  } else {