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.
- package/package.json +1 -1
- package/src/destroy.js +903 -899
- package/src/lib/deploy/compose/index.js +43 -37
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +19 -7
- package/src/lib/deploy/k8s/k3s.js +346 -265
- package/src/lib/deploy/orchestrator.js +110 -459
- package/src/lib/deploy/tier-registry.js +30 -0
- package/src/lib/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/scale.js +101 -131
|
@@ -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
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
}
|
|
128
|
-
if (result?.status !== 0) {
|
|
131
|
+
} catch (err) {
|
|
129
132
|
throw new Error(
|
|
130
|
-
`${desc} failed with exit ${
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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 =
|
|
805
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
|
907
|
+
`[sideloadK3s] ${target} attempt ${attempt} failed (${err.message}); retrying in ${SIDELOAD_DELAYS_MS[attempt - 1] / 1000}s`,
|
|
917
908
|
);
|
|
918
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1089
|
+
await openSshTunnel([
|
|
1061
1090
|
'ssh',
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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 =
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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 ${
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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(
|
|
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 {
|