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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical tier taxonomy. Config persists deployMode ('compose' | 'compose-ha'
|
|
3
|
+
* | 'kubernetes') plus an ha flag ('kubernetes-ha' is normalized away at
|
|
4
|
+
* prompt time, see prompts.js). Everything downstream should dispatch on the
|
|
5
|
+
* tier id returned here instead of re-deriving mode+ha combinations.
|
|
6
|
+
*/
|
|
7
|
+
export const TIERS = ['compose', 'compose-ha', 'k8s', 'k8s-ha'];
|
|
8
|
+
|
|
9
|
+
export function resolveTier(envConfig = {}) {
|
|
10
|
+
const { deployMode, ha } = envConfig;
|
|
11
|
+
const haEnabled = typeof ha === 'object' ? !!ha?.enabled : !!ha;
|
|
12
|
+
switch (deployMode) {
|
|
13
|
+
case 'compose':
|
|
14
|
+
return 'compose';
|
|
15
|
+
case 'compose-ha':
|
|
16
|
+
return 'compose-ha';
|
|
17
|
+
case 'kubernetes':
|
|
18
|
+
return haEnabled ? 'k8s-ha' : 'k8s';
|
|
19
|
+
default:
|
|
20
|
+
throw new Error(`Unknown deployMode: ${JSON.stringify(deployMode)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const isHATier = (tier) => tier === 'compose-ha' || tier === 'k8s-ha';
|
|
25
|
+
export const isComposeTier = (tier) => tier === 'compose' || tier === 'compose-ha';
|
|
26
|
+
export const isK8sTier = (tier) => tier === 'k8s' || tier === 'k8s-ha';
|
|
27
|
+
|
|
28
|
+
export function pulumiStackEnvs(tier, environment) {
|
|
29
|
+
return tier === 'k8s-ha' ? [`${environment}-primary`, `${environment}-standby`] : [environment];
|
|
30
|
+
}
|
package/src/lib/retry.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry/polling primitives for the exec layer.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes cover every bespoke loop in the codebase:
|
|
5
|
+
* - runWithRetry: N attempts with explicit inter-attempt delays
|
|
6
|
+
* (kubectl transient retries, ssh transient retries, sideload/push settles).
|
|
7
|
+
* - pollUntil: probe under a time budget with capped exponential backoff
|
|
8
|
+
* (wait-for-k3s, wait-for-schema, wait-for-API-healthy, delete-until-404).
|
|
9
|
+
*/
|
|
10
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
+
|
|
12
|
+
export async function runWithRetry(
|
|
13
|
+
fn,
|
|
14
|
+
{ delaysMs = [5000, 5000], isTransient = () => true, onRetry } = {},
|
|
15
|
+
) {
|
|
16
|
+
let attempt = 0;
|
|
17
|
+
// attempts = delaysMs.length + 1
|
|
18
|
+
for (;;) {
|
|
19
|
+
try {
|
|
20
|
+
return await fn(attempt);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (attempt >= delaysMs.length || !isTransient(err)) throw err;
|
|
23
|
+
attempt += 1;
|
|
24
|
+
if (onRetry) onRetry(err, attempt);
|
|
25
|
+
await sleep(delaysMs[attempt - 1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function pollUntil(
|
|
31
|
+
probe,
|
|
32
|
+
{
|
|
33
|
+
budgetMs,
|
|
34
|
+
initialDelayMs = 2000,
|
|
35
|
+
maxDelayMs = 15_000,
|
|
36
|
+
backoffFactor = 2,
|
|
37
|
+
description = 'condition',
|
|
38
|
+
} = {},
|
|
39
|
+
) {
|
|
40
|
+
const deadline = Date.now() + budgetMs;
|
|
41
|
+
let delay = initialDelayMs;
|
|
42
|
+
let lastErr;
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await probe();
|
|
46
|
+
if (result) return result;
|
|
47
|
+
lastErr = undefined;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
lastErr = err;
|
|
50
|
+
}
|
|
51
|
+
if (Date.now() + delay > deadline) break;
|
|
52
|
+
await sleep(delay);
|
|
53
|
+
delay = Math.min(delay * backoffFactor, maxDelayMs);
|
|
54
|
+
}
|
|
55
|
+
const cause = lastErr ? `: ${lastErr.message}` : '';
|
|
56
|
+
const err = new Error(`Timed out after ${budgetMs}ms waiting for ${description}${cause}`);
|
|
57
|
+
err.cause = lastErr;
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning helpers for `vibecarbon scale` (k8s / k8s-ha).
|
|
3
|
+
*
|
|
4
|
+
* These functions carry NO side effects — no clack prompts, no SSH, no fetch,
|
|
5
|
+
* no Pulumi. They translate parsed flags and resolved config into the plain
|
|
6
|
+
* data shapes the orchestration code in src/scale.js consumes, which keeps the
|
|
7
|
+
* two behaviors that matter most testable in isolation:
|
|
8
|
+
*
|
|
9
|
+
* - the non-interactive `-yes`/`-type`/bounds flag matrix, and
|
|
10
|
+
* - the Pulumi programConfig assembly, whose output MUST stay byte-identical
|
|
11
|
+
* to what `deploy` produced or Pulumi plans a destructive replace of every
|
|
12
|
+
* node (etcd loss). See the extensive comments at the call site.
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_WORKER_MAX, DEFAULT_WORKER_MIN } from './deploy/utils.js';
|
|
15
|
+
|
|
16
|
+
const REGION_TO_NETWORK_ZONE = {
|
|
17
|
+
nbg1: 'eu-central',
|
|
18
|
+
hel1: 'eu-central',
|
|
19
|
+
fsn1: 'eu-central',
|
|
20
|
+
hil: 'us-west',
|
|
21
|
+
ash: 'us-east',
|
|
22
|
+
sin: 'ap-southeast',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the non-interactive scale plan from parsed flags.
|
|
27
|
+
*
|
|
28
|
+
* Returns `{ changes, newValues }` for the two scripted branches:
|
|
29
|
+
* - `-yes -type X` → resize every node role to X (plus workerBounds when
|
|
30
|
+
* --min/--max are also present).
|
|
31
|
+
* - `-yes` + bounds → cluster-autoscaler bounds only.
|
|
32
|
+
* Returns `null` when neither branch matches, signalling the caller to fall
|
|
33
|
+
* back to the interactive multiselect prompt.
|
|
34
|
+
*
|
|
35
|
+
* `changes[]` elements are the string ids the caller dispatches on:
|
|
36
|
+
* 'masterType' | 'supabaseType' | 'workerType' | 'workerBounds'.
|
|
37
|
+
*
|
|
38
|
+
* envConfig is accepted for signature parity with the interactive caller; the
|
|
39
|
+
* non-interactive branches are a pure function of `parsed`.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ yes?: boolean, type?: string|null, minWorkers?: number|null, maxWorkers?: number|null }} parsed
|
|
42
|
+
* @param {object} envConfig
|
|
43
|
+
* @returns {{ changes: string[], newValues: Record<string, unknown> } | null}
|
|
44
|
+
*/
|
|
45
|
+
export function planK8sScaleChanges(parsed, _envConfig) {
|
|
46
|
+
const newValues = {};
|
|
47
|
+
|
|
48
|
+
if (parsed.yes && parsed.type) {
|
|
49
|
+
// `--type X` means "scale every node role to X" for parity with compose's
|
|
50
|
+
// `--type X` (which resizes the one VPS).
|
|
51
|
+
const changes = ['masterType', 'supabaseType', 'workerType'];
|
|
52
|
+
newValues.masterType = parsed.type;
|
|
53
|
+
newValues.supabaseType = parsed.type;
|
|
54
|
+
newValues.workerType = parsed.type;
|
|
55
|
+
// Allow --type and bounds to be combined in one invocation.
|
|
56
|
+
if (parsed.minWorkers != null || parsed.maxWorkers != null) {
|
|
57
|
+
changes.push('workerBounds');
|
|
58
|
+
if (parsed.minWorkers != null) newValues.minWorkers = parsed.minWorkers;
|
|
59
|
+
if (parsed.maxWorkers != null) newValues.maxWorkers = parsed.maxWorkers;
|
|
60
|
+
}
|
|
61
|
+
return { changes, newValues };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (parsed.yes && (parsed.minWorkers != null || parsed.maxWorkers != null)) {
|
|
65
|
+
// Bounds-only path: --min-workers and/or --max-workers without --type.
|
|
66
|
+
const changes = ['workerBounds'];
|
|
67
|
+
if (parsed.minWorkers != null) newValues.minWorkers = parsed.minWorkers;
|
|
68
|
+
if (parsed.maxWorkers != null) newValues.maxWorkers = parsed.maxWorkers;
|
|
69
|
+
return { changes, newValues };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the Hetzner-k8s Pulumi programConfig. Its output MUST be
|
|
77
|
+
* byte-identical to what deployK3s passed at deploy time — every field that
|
|
78
|
+
* flows into a Server's `sshKeys`, `firewallIds`, `labels`, or `userData` is a
|
|
79
|
+
* tripwire for a destructive replace, and replacing master wipes etcd.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} inputs
|
|
82
|
+
* @param {string} inputs.projectName
|
|
83
|
+
* @param {string} inputs.environment Cluster env (e.g. `prod`, `prod-primary`).
|
|
84
|
+
* @param {string} inputs.sshPublicKey
|
|
85
|
+
* @param {string[]} inputs.allowedCidrs Operator CIDRs (SSH + k8s API).
|
|
86
|
+
* @param {string|undefined} inputs.existingSshKeyId
|
|
87
|
+
* @param {string} inputs.location Cluster region.
|
|
88
|
+
* @param {Record<string, unknown>} inputs.newValues Plan output; may set *Type/*Workers.
|
|
89
|
+
* @param {string} inputs.currentMasterType
|
|
90
|
+
* @param {string} inputs.currentSupabaseType
|
|
91
|
+
* @param {string} inputs.currentWorkerType
|
|
92
|
+
* @param {number|undefined} inputs.persistedMinWorkers envConfig.minWorkers
|
|
93
|
+
* @param {number|undefined} inputs.persistedMaxWorkers envConfig.maxWorkers
|
|
94
|
+
* @param {string} inputs.k3sVersion
|
|
95
|
+
* @param {Record<string, string>} inputs.labels
|
|
96
|
+
* @param {string} inputs.apiToken
|
|
97
|
+
* @returns {object}
|
|
98
|
+
*/
|
|
99
|
+
export function buildProgramConfig({
|
|
100
|
+
projectName,
|
|
101
|
+
environment,
|
|
102
|
+
sshPublicKey,
|
|
103
|
+
allowedCidrs,
|
|
104
|
+
existingSshKeyId,
|
|
105
|
+
location,
|
|
106
|
+
newValues,
|
|
107
|
+
currentMasterType,
|
|
108
|
+
currentSupabaseType,
|
|
109
|
+
currentWorkerType,
|
|
110
|
+
persistedMinWorkers,
|
|
111
|
+
persistedMaxWorkers,
|
|
112
|
+
k3sVersion,
|
|
113
|
+
labels,
|
|
114
|
+
apiToken,
|
|
115
|
+
}) {
|
|
116
|
+
return {
|
|
117
|
+
projectName,
|
|
118
|
+
environment,
|
|
119
|
+
sshPublicKey,
|
|
120
|
+
allowedSshIps: allowedCidrs,
|
|
121
|
+
allowedK8sApiIps: allowedCidrs,
|
|
122
|
+
// HA deploys share one Hetzner SshKey across primary + standby and pass
|
|
123
|
+
// `existingSshKeyId` so neither stack manages the resource. Single-cluster
|
|
124
|
+
// lets Pulumi own the SshKey (existingSshKeyId = undefined).
|
|
125
|
+
existingSshKeyId,
|
|
126
|
+
location,
|
|
127
|
+
networkZone: REGION_TO_NETWORK_ZONE[location] || 'eu-central',
|
|
128
|
+
masterServerType: newValues.masterType ?? currentMasterType,
|
|
129
|
+
supabaseServerType: newValues.supabaseType ?? currentSupabaseType,
|
|
130
|
+
workerServerType: newValues.workerType ?? currentWorkerType,
|
|
131
|
+
// minWorkers is Pulumi's static worker floor; maxWorkers flows to the CA
|
|
132
|
+
// patch (not consumed by Pulumi). Replay persisted values when the operator
|
|
133
|
+
// didn't override, keeping Pulumi's worker-pool plan a no-op.
|
|
134
|
+
minWorkers: newValues.minWorkers ?? persistedMinWorkers ?? DEFAULT_WORKER_MIN,
|
|
135
|
+
maxWorkers: newValues.maxWorkers ?? persistedMaxWorkers ?? DEFAULT_WORKER_MAX,
|
|
136
|
+
k3sVersion,
|
|
137
|
+
labels,
|
|
138
|
+
apiToken,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Rewrite the cluster-autoscaler container's `--nodes=` arg to a freshly
|
|
144
|
+
* formatted spec, preserving every other arg. Appends the flag if the deploy
|
|
145
|
+
* container somehow shipped without it. Pure list transform.
|
|
146
|
+
*
|
|
147
|
+
* @param {string[]} argList Current container args (from kubectl jsonpath).
|
|
148
|
+
* @param {string} caNodesSpec New `<min>:<max>:<type>:<region>:<pool>` spec.
|
|
149
|
+
* @returns {string[]}
|
|
150
|
+
*/
|
|
151
|
+
export function updateCaNodesArgs(argList, caNodesSpec) {
|
|
152
|
+
const updated = (Array.isArray(argList) ? argList : []).map((a) =>
|
|
153
|
+
typeof a === 'string' && a.startsWith('--nodes=') ? `--nodes=${caNodesSpec}` : a,
|
|
154
|
+
);
|
|
155
|
+
if (!updated.some((a) => typeof a === 'string' && a.startsWith('--nodes='))) {
|
|
156
|
+
updated.push(`--nodes=${caNodesSpec}`);
|
|
157
|
+
}
|
|
158
|
+
return updated;
|
|
159
|
+
}
|
package/src/scale.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
waitForSSH,
|
|
39
39
|
} from './lib/deploy/compose/index.js';
|
|
40
40
|
import { buildRemote } from './lib/deploy/remote-build.js';
|
|
41
|
+
import { isHATier, resolveTier } from './lib/deploy/tier-registry.js';
|
|
41
42
|
import { DEFAULT_WORKER_MAX, DEFAULT_WORKER_MIN } from './lib/deploy/utils.js';
|
|
42
43
|
import { waitForDNSPropagation } from './lib/dns-propagation.js';
|
|
43
44
|
import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
@@ -45,6 +46,8 @@ import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
|
45
46
|
import { perfAsync } from './lib/perf.js';
|
|
46
47
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
47
48
|
import { HETZNER_PRICING_URL, HetznerProvider } from './lib/providers/hetzner.js';
|
|
49
|
+
import { pollUntil } from './lib/retry.js';
|
|
50
|
+
import { buildProgramConfig, planK8sScaleChanges, updateCaNodesArgs } from './lib/scale-plan.js';
|
|
48
51
|
import { buildComposeTypeOptions, buildSimpleTypeOptions } from './lib/server-types.js';
|
|
49
52
|
import { parseDotenv } from './lib/shell.js';
|
|
50
53
|
import { sshRun, sshRunScript } from './lib/ssh.js';
|
|
@@ -98,6 +101,7 @@ const SPEC = {
|
|
|
98
101
|
// ============================================================================
|
|
99
102
|
|
|
100
103
|
async function scaleCompose(environment, envConfig, projectConfig, options = {}) {
|
|
104
|
+
const tier = options.tier || resolveTier(envConfig);
|
|
101
105
|
const servers = envConfig.servers || [];
|
|
102
106
|
const projectName = projectConfig.projectName;
|
|
103
107
|
|
|
@@ -131,7 +135,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
131
135
|
|
|
132
136
|
// Ensure operator IP is in the firewall allowlist before any SSH work.
|
|
133
137
|
try {
|
|
134
|
-
const isHA =
|
|
138
|
+
const isHA = isHATier(tier);
|
|
135
139
|
const result = await ensureOperatorIpAccess({
|
|
136
140
|
projectConfig,
|
|
137
141
|
environment,
|
|
@@ -174,7 +178,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
174
178
|
|
|
175
179
|
// For compose-ha: select which server(s) to scale
|
|
176
180
|
let targetServers = resizableServers;
|
|
177
|
-
if (
|
|
181
|
+
if (isHATier(tier) && resizableServers.length > 1) {
|
|
178
182
|
const primaryServer = resizableServers.find((s) => s.role === 'primary');
|
|
179
183
|
const standbyServer = resizableServers.find((s) => s.role === 'standby');
|
|
180
184
|
|
|
@@ -232,7 +236,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
232
236
|
return;
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
const ha =
|
|
239
|
+
const ha = isHATier(tier);
|
|
236
240
|
const services = projectConfig.services || {};
|
|
237
241
|
p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
|
|
238
242
|
|
|
@@ -288,8 +292,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
|
|
|
288
292
|
// exactly 2× single-server scale (711s vs ~365s); parallelizing makes it
|
|
289
293
|
// ~max(primary, standby) ≈ 6m off the critical path.
|
|
290
294
|
const scaleSingleServer = async (server) => {
|
|
291
|
-
const label =
|
|
292
|
-
envConfig.deployMode === 'compose-ha' ? `${server.role} (${server.ip})` : server.ip;
|
|
295
|
+
const label = isHATier(tier) ? `${server.role} (${server.ip})` : server.ip;
|
|
293
296
|
const s = p.spinner();
|
|
294
297
|
|
|
295
298
|
// 1. Backup database on old server via wal-g (pushes directly to S3)
|
|
@@ -739,6 +742,17 @@ async function updateDNS(envConfig, apiToken, domain, newIp) {
|
|
|
739
742
|
// MAIN
|
|
740
743
|
// ============================================================================
|
|
741
744
|
|
|
745
|
+
// Tier → scale strategy. scaleCompose / scaleK8s are hoisted function
|
|
746
|
+
// declarations, so referencing them here at module scope is safe. Compose and
|
|
747
|
+
// compose-ha share one strategy (it branches on isHATier internally); likewise
|
|
748
|
+
// k8s / k8s-ha.
|
|
749
|
+
const SCALE_STRATEGIES = {
|
|
750
|
+
compose: scaleCompose,
|
|
751
|
+
'compose-ha': scaleCompose,
|
|
752
|
+
k8s: scaleK8s,
|
|
753
|
+
'k8s-ha': scaleK8s,
|
|
754
|
+
};
|
|
755
|
+
|
|
742
756
|
export async function run(args) {
|
|
743
757
|
const { values, positional, errors } = parseFlags(args, SPEC);
|
|
744
758
|
|
|
@@ -829,15 +843,18 @@ export async function run(args) {
|
|
|
829
843
|
process.exit(1);
|
|
830
844
|
}
|
|
831
845
|
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
846
|
+
// Dispatch to the tier's scale strategy. The 4th arg carries the parsed
|
|
847
|
+
// flags plus the resolved `tier` so each strategy dispatches on tier /
|
|
848
|
+
// isHATier(tier) instead of re-deriving deployMode+ha.
|
|
849
|
+
const tier = resolveTier(envConfig);
|
|
850
|
+
await SCALE_STRATEGIES[tier](environment, envConfig, projectConfig, { ...parsed, tier });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ============================================================================
|
|
854
|
+
// KUBERNETES SCALING
|
|
855
|
+
// ============================================================================
|
|
840
856
|
|
|
857
|
+
async function scaleK8s(environment, envConfig, projectConfig, parsed) {
|
|
841
858
|
const region = envConfig.region || 'fsn1';
|
|
842
859
|
|
|
843
860
|
// Pre-fetch live server types (non-blocking, uses saved credentials if available)
|
|
@@ -882,60 +899,28 @@ export async function run(args) {
|
|
|
882
899
|
const regionTypes = HetznerProvider.getServerTypesForRegion(region);
|
|
883
900
|
const serverTypeOptions = buildSimpleTypeOptions(regionTypes, { filterSharedCpu: false });
|
|
884
901
|
|
|
902
|
+
// Non-interactive plan (pure): `-yes -type` → resize every role (matrix run
|
|
903
|
+
// #10 caught the old behavior of only changing worker pools); `-yes` + bounds
|
|
904
|
+
// → cluster-autoscaler bounds only. `null` means no scripted plan matched —
|
|
905
|
+
// fall through to the interactive multiselect. See src/lib/scale-plan.js for
|
|
906
|
+
// the branch rationale (in-place resize vs. master-replace guard, phase-8
|
|
907
|
+
// bounds TODO). The user-facing logging stays here (side-effectful).
|
|
885
908
|
let changes;
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
// ~2 min of control-plane downtime; running under -y means the
|
|
901
|
-
// operator accepted the cost.
|
|
902
|
-
//
|
|
903
|
-
// Per-role overrides remain available through the interactive
|
|
904
|
-
// multiselect when finer control is needed.
|
|
905
|
-
changes = ['masterType', 'supabaseType', 'workerType'];
|
|
906
|
-
newValues.masterType = parsed.type;
|
|
907
|
-
newValues.supabaseType = parsed.type;
|
|
908
|
-
newValues.workerType = parsed.type;
|
|
909
|
-
// Allow --type and bounds to be combined in one invocation. CA
|
|
910
|
-
// re-patch happens regardless once changes include 'workerBounds'.
|
|
911
|
-
if (parsed.minWorkers != null || parsed.maxWorkers != null) {
|
|
912
|
-
changes.push('workerBounds');
|
|
913
|
-
if (parsed.minWorkers != null) newValues.minWorkers = parsed.minWorkers;
|
|
914
|
-
if (parsed.maxWorkers != null) newValues.maxWorkers = parsed.maxWorkers;
|
|
909
|
+
const plan = planK8sScaleChanges(parsed, envConfig);
|
|
910
|
+
if (plan) {
|
|
911
|
+
changes = plan.changes;
|
|
912
|
+
Object.assign(newValues, plan.newValues);
|
|
913
|
+
if (changes.includes('masterType')) {
|
|
914
|
+
p.log.info(
|
|
915
|
+
`Scaling all node roles (master, supabase, worker) to ${c.bold(parsed.type)} ${c.dim('(in-place resize, ~2 min downtime per node)')}`,
|
|
916
|
+
);
|
|
917
|
+
} else {
|
|
918
|
+
p.log.info(
|
|
919
|
+
`Updating cluster-autoscaler bounds: ${c.bold(
|
|
920
|
+
`min=${newValues.minWorkers ?? envConfig.minWorkers ?? DEFAULT_WORKER_MIN}`,
|
|
921
|
+
)} ${c.bold(`max=${newValues.maxWorkers ?? envConfig.maxWorkers ?? DEFAULT_WORKER_MAX}`)}`,
|
|
922
|
+
);
|
|
915
923
|
}
|
|
916
|
-
p.log.info(
|
|
917
|
-
`Scaling all node roles (master, supabase, worker) to ${c.bold(parsed.type)} ${c.dim('(in-place resize, ~2 min downtime per node)')}`,
|
|
918
|
-
);
|
|
919
|
-
} else if (parsed.yes && (parsed.minWorkers != null || parsed.maxWorkers != null)) {
|
|
920
|
-
// Non-interactive bounds-only path: --min-workers and/or --max-workers
|
|
921
|
-
// without --type. We do not run Pulumi here unless minWorkers rose above
|
|
922
|
-
// the previously persisted floor (which would require new static workers
|
|
923
|
-
// to be provisioned).
|
|
924
|
-
//
|
|
925
|
-
// TODO(phase 8 follow-up): bounds-driven Pulumi changes — when
|
|
926
|
-
// minWorkers rises above the persisted floor, run Pulumi to add static
|
|
927
|
-
// workers; when minWorkers drops, leave Pulumi alone (don't reap static
|
|
928
|
-
// workers on bounds relax). For now we patch CA only; bumping min above
|
|
929
|
-
// the persisted floor will not provision new static workers until the
|
|
930
|
-
// next deploy.
|
|
931
|
-
changes = ['workerBounds'];
|
|
932
|
-
if (parsed.minWorkers != null) newValues.minWorkers = parsed.minWorkers;
|
|
933
|
-
if (parsed.maxWorkers != null) newValues.maxWorkers = parsed.maxWorkers;
|
|
934
|
-
p.log.info(
|
|
935
|
-
`Updating cluster-autoscaler bounds: ${c.bold(
|
|
936
|
-
`min=${newValues.minWorkers ?? envConfig.minWorkers ?? DEFAULT_WORKER_MIN}`,
|
|
937
|
-
)} ${c.bold(`max=${newValues.maxWorkers ?? envConfig.maxWorkers ?? DEFAULT_WORKER_MAX}`)}`,
|
|
938
|
-
);
|
|
939
924
|
} else {
|
|
940
925
|
changes = await p.multiselect({
|
|
941
926
|
message: 'What would you like to change?',
|
|
@@ -1003,7 +988,11 @@ export async function run(args) {
|
|
|
1003
988
|
(c) => c === 'workerType' || c === 'masterType' || c === 'supabaseType',
|
|
1004
989
|
);
|
|
1005
990
|
|
|
1006
|
-
|
|
991
|
+
// isHATier(tier) is behavior-identical to `envConfig.ha?.enabled` for the k8s
|
|
992
|
+
// tier; the `&& secondaryRegion` conjunct is kept because HA cluster fan-out
|
|
993
|
+
// below also requires a configured standby region (registry predicate alone
|
|
994
|
+
// is NOT equivalent here).
|
|
995
|
+
const isHA = isHATier(parsed.tier) && envConfig.secondaryRegion;
|
|
1007
996
|
p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
|
|
1008
997
|
|
|
1009
998
|
// 6. Require API token if infra changes (server type swap) needed
|
|
@@ -1021,11 +1010,12 @@ export async function run(args) {
|
|
|
1021
1010
|
// a no-op scale that just returns) since both paths below require token.
|
|
1022
1011
|
if (apiToken) {
|
|
1023
1012
|
try {
|
|
1024
|
-
|
|
1013
|
+
// Firewall allowlist gate uses ha-vs-not only (no secondaryRegion
|
|
1014
|
+
// conjunct) — isHATier(tier) === `!!envConfig.ha?.enabled` for k8s.
|
|
1025
1015
|
const result = await ensureOperatorIpAccess({
|
|
1026
1016
|
projectConfig,
|
|
1027
1017
|
environment,
|
|
1028
|
-
isHA,
|
|
1018
|
+
isHA: isHATier(parsed.tier),
|
|
1029
1019
|
apiToken,
|
|
1030
1020
|
yes: !!parsed.yes,
|
|
1031
1021
|
onMessage: (msg) => p.log.info(msg),
|
|
@@ -1054,14 +1044,6 @@ export async function run(args) {
|
|
|
1054
1044
|
);
|
|
1055
1045
|
const { buildHetznerK8sProgram } = await import('./lib/iac/programs/hetzner-k8s.js');
|
|
1056
1046
|
const { K3S_VERSION } = await import('./lib/deploy/k8s/k3s.js');
|
|
1057
|
-
const REGION_TO_NETWORK_ZONE = {
|
|
1058
|
-
nbg1: 'eu-central',
|
|
1059
|
-
hel1: 'eu-central',
|
|
1060
|
-
fsn1: 'eu-central',
|
|
1061
|
-
hil: 'us-west',
|
|
1062
|
-
ash: 'us-east',
|
|
1063
|
-
sin: 'ap-southeast',
|
|
1064
|
-
};
|
|
1065
1047
|
// SSH key location depends on deploy topology:
|
|
1066
1048
|
// - Single-cluster k8s: deployK3s' ensureSshKey writes
|
|
1067
1049
|
// `.vibecarbon/ssh-<env>` and lets Pulumi manage the SshKey resource.
|
|
@@ -1154,45 +1136,30 @@ export async function run(args) {
|
|
|
1154
1136
|
// destructive replace. The k3sToken probe-and-replay (further below)
|
|
1155
1137
|
// keeps userData stable; HA needed `existingSshKeyId` to also be
|
|
1156
1138
|
// mirrored or master.sshKeys would still drift and replace etcd.
|
|
1139
|
+
// Byte-identical to deployK3s' program inputs — every field that flows
|
|
1140
|
+
// into a Server's sshKeys/firewallIds/labels/userData is a destructive-
|
|
1141
|
+
// replace tripwire (replacing master = etcd wipe). buildProgramConfig
|
|
1142
|
+
// (src/lib/scale-plan.js) owns the assembly + the region→zone map + the
|
|
1143
|
+
// newValues/persisted/default `??` resolution. k3sVersion and labels MUST
|
|
1144
|
+
// match deployK3s (both are interpolated into userData / resource labels).
|
|
1157
1145
|
const allowedCidrs = (projectConfig.operatorCidrs ?? []).map((e) => e.cidr);
|
|
1158
|
-
const programConfig = {
|
|
1146
|
+
const programConfig = buildProgramConfig({
|
|
1159
1147
|
projectName: projectConfig.projectName,
|
|
1160
1148
|
environment: clusterEnv,
|
|
1161
1149
|
sshPublicKey,
|
|
1162
|
-
|
|
1163
|
-
allowedK8sApiIps: allowedCidrs,
|
|
1164
|
-
// HA deploys share one Hetzner SshKey across primary + standby and
|
|
1165
|
-
// pass `existingSshKeyId` so neither stack manages the resource.
|
|
1166
|
-
// Single-cluster lets Pulumi own the SshKey (existingSshKeyId =
|
|
1167
|
-
// undefined). Mirror whichever the deploy did or master.sshKeys
|
|
1168
|
-
// changes and Pulumi replaces every node.
|
|
1150
|
+
allowedCidrs,
|
|
1169
1151
|
existingSshKeyId,
|
|
1170
1152
|
location: clusterRegion,
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
// maxWorkers is not consumed by Pulumi; it flows to the CA Deployment
|
|
1178
|
-
// patch below. If the operator passed --min-workers/--max-workers
|
|
1179
|
-
// we honor them; otherwise replay the previously persisted values
|
|
1180
|
-
// (which are needed verbatim to keep Pulumi's plan a no-op for the
|
|
1181
|
-
// worker pool when the operator is just resizing types).
|
|
1182
|
-
minWorkers: newValues.minWorkers ?? envConfig.minWorkers ?? DEFAULT_WORKER_MIN,
|
|
1183
|
-
maxWorkers: newValues.maxWorkers ?? envConfig.maxWorkers ?? DEFAULT_WORKER_MAX,
|
|
1184
|
-
// MUST match the K3S_VERSION used by deployK3s — k3sVersion is
|
|
1185
|
-
// interpolated into each server's userData (cloud-init script), so a
|
|
1186
|
-
// mismatch makes Pulumi see ~userData on master/supabase/workers and
|
|
1187
|
-
// tries to replace them. Replacing master = etcd wipe.
|
|
1153
|
+
newValues,
|
|
1154
|
+
currentMasterType,
|
|
1155
|
+
currentSupabaseType,
|
|
1156
|
+
currentWorkerType,
|
|
1157
|
+
persistedMinWorkers: envConfig.minWorkers,
|
|
1158
|
+
persistedMaxWorkers: envConfig.maxWorkers,
|
|
1188
1159
|
k3sVersion: K3S_VERSION,
|
|
1189
|
-
// MUST match the labels passed by deployK3s (src/lib/deploy/k8s/k3s.js).
|
|
1190
|
-
// Mismatched labels make Pulumi mark every resource as ~labels and
|
|
1191
|
-
// attempt a replace — for hcloud.SshKey, replace = delete+create with
|
|
1192
|
-
// the same public-key bytes, which Hetzner rejects with HTTP 409.
|
|
1193
1160
|
labels: { 'managed-by': 'vibecarbon', 'os-flavor': 'k3s' },
|
|
1194
1161
|
apiToken,
|
|
1195
|
-
};
|
|
1162
|
+
});
|
|
1196
1163
|
|
|
1197
1164
|
// Probe prior stack outputs for the k3sToken so we can replay it into
|
|
1198
1165
|
// the real program. classifyK3sTokenProbe normalizes recovered/empty/
|
|
@@ -1388,13 +1355,7 @@ export async function run(args) {
|
|
|
1388
1355
|
} catch {
|
|
1389
1356
|
argList = [];
|
|
1390
1357
|
}
|
|
1391
|
-
const updated = argList
|
|
1392
|
-
typeof a === 'string' && a.startsWith('--nodes=') ? `--nodes=${caNodesSpec}` : a,
|
|
1393
|
-
);
|
|
1394
|
-
// If for some reason --nodes wasn't there, append it.
|
|
1395
|
-
if (!updated.some((a) => typeof a === 'string' && a.startsWith('--nodes='))) {
|
|
1396
|
-
updated.push(`--nodes=${caNodesSpec}`);
|
|
1397
|
-
}
|
|
1358
|
+
const updated = updateCaNodesArgs(argList, caNodesSpec);
|
|
1398
1359
|
const patch = JSON.stringify({
|
|
1399
1360
|
spec: {
|
|
1400
1361
|
template: { spec: { containers: [{ name: 'cluster-autoscaler', args: updated }] } },
|
|
@@ -1517,31 +1478,40 @@ export async function run(args) {
|
|
|
1517
1478
|
// plane has truly stabilized post-resize.
|
|
1518
1479
|
const apiSpinner = p.spinner();
|
|
1519
1480
|
apiSpinner.start(`Waiting for API server to be reachable (${label})`);
|
|
1520
|
-
const apiStart = Date.now();
|
|
1521
1481
|
const API_READY_BUDGET_MS = 300_000;
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1482
|
+
// Fixed 5s interval (backoffFactor: 1) under a 300s budget — the probe
|
|
1483
|
+
// throws while the floating IP is re-attaching (runCommand rejects on
|
|
1484
|
+
// nonzero exit when silent), and pollUntil retries a throwing probe.
|
|
1485
|
+
// Timeout still throws (preserving the old fail-fast); we re-wrap so the
|
|
1486
|
+
// operator-facing message and last-error detail are unchanged.
|
|
1487
|
+
try {
|
|
1488
|
+
await pollUntil(
|
|
1489
|
+
() => {
|
|
1490
|
+
runCommand(['kubectl', '--kubeconfig', kubeconfigPath, 'get', '--raw=/healthz'], {
|
|
1491
|
+
silent: true,
|
|
1492
|
+
timeout: 10_000,
|
|
1493
|
+
});
|
|
1494
|
+
return true;
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
budgetMs: API_READY_BUDGET_MS,
|
|
1498
|
+
initialDelayMs: 5000,
|
|
1499
|
+
backoffFactor: 1,
|
|
1500
|
+
description: 'k8s API healthy after resize',
|
|
1501
|
+
},
|
|
1502
|
+
);
|
|
1503
|
+
apiSpinner.stop(`API server reachable (${label})`);
|
|
1504
|
+
} catch (err) {
|
|
1538
1505
|
apiSpinner.stop(`API server (${label}) not reachable within 5 min`);
|
|
1506
|
+
const cause = err.cause;
|
|
1507
|
+
const lastApiErr = cause
|
|
1508
|
+
? cause.message?.split('\n')[0]?.slice(0, 120) || String(cause).slice(0, 120)
|
|
1509
|
+
: '';
|
|
1539
1510
|
throw new Error(
|
|
1540
1511
|
`Kube API server for ${label} never returned /healthz=ok within 5 min after Pulumi resize. ` +
|
|
1541
1512
|
`Last error: ${lastApiErr}`,
|
|
1542
1513
|
);
|
|
1543
1514
|
}
|
|
1544
|
-
apiSpinner.stop(`API server reachable (${label})`);
|
|
1545
1515
|
// Wait for the Hetzner CSI driver DaemonSet to register on every
|
|
1546
1516
|
// node BEFORE checking pod-Ready. RCA from k8s-ha standby failure
|
|
1547
1517
|
// 2026-04-28 (live cluster repro under triple-node-reboot):
|