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.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +968 -886
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +106 -57
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +38 -30
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +411 -279
- package/src/lib/deploy/orchestrator.js +274 -453
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/tier-registry.js +30 -0
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +105 -133
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/server/lib/request.ts +0 -34
- 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
|
|
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
|
|
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)}`);
|
|
@@ -1101,25 +1127,31 @@ export async function pushImageToLocalRegistry({
|
|
|
1101
1127
|
);
|
|
1102
1128
|
});
|
|
1103
1129
|
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
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
|
|
1137
|
+
// so the later attempts have the longer backoff to let the
|
|
1112
1138
|
// replacement pod become Ready before re-pushing.
|
|
1113
|
-
// 3. S3
|
|
1114
|
-
// → registry
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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 =
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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 ${
|
|
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
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1997
|
-
|
|
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
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
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
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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 {
|
|
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(
|
|
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,
|