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.
@@ -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
+ }
@@ -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 = envConfig.deployMode === 'compose-ha';
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 (envConfig.deployMode === 'compose-ha' && resizableServers.length > 1) {
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 = envConfig.deployMode === 'compose-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
- // Compose deployments: vertical scaling via Hetzner API
833
- if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
834
- await scaleCompose(environment, envConfig, projectConfig, {
835
- type: parsed.type,
836
- yes: parsed.yes,
837
- });
838
- return;
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
- if (parsed.yes && parsed.type) {
887
- // Non-interactive: `--type X` means "scale every node role to X" for
888
- // parity with compose's `--type X` (which resizes the one VPS).
889
- // Without this, a k8s `--type cx33` only changed worker pools and
890
- // left master + supabase on the previous type — a UX inconsistency
891
- // the e2e verify-scale assertion caught (matrix run #10 k8s
892
- // scale: only the worker IP showed cx33 post-scale, supabase stayed
893
- // cx23).
894
- //
895
- // Hetzner server_type changes are in-place resizes (~2 min reboot
896
- // per node), so the master-replace guard below does NOT trip on
897
- // legitimate type bumps — that guard fires only on userData drift
898
- // (k3sToken, labels, cloud-init template), which the probe-and-replay
899
- // earlier in this function already prevents. Master upgrades cause
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
- const isHA = envConfig.ha?.enabled && envConfig.secondaryRegion;
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
- const isHA = !!envConfig.ha?.enabled;
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
- allowedSshIps: allowedCidrs,
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
- networkZone: REGION_TO_NETWORK_ZONE[clusterRegion] || 'eu-central',
1172
- masterServerType: newValues.masterType ?? currentMasterType,
1173
- supabaseServerType: newValues.supabaseType ?? currentSupabaseType,
1174
- workerServerType: newValues.workerType ?? currentWorkerType,
1175
- // Phase 8: minWorkers/maxWorkers replace workerCount. minWorkers is
1176
- // what Pulumi consumes (static floor of worker servers it owns).
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.map((a) =>
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
- let apiReady = false;
1523
- let lastApiErr = '';
1524
- while (Date.now() - apiStart < API_READY_BUDGET_MS) {
1525
- try {
1526
- runCommand(['kubectl', '--kubeconfig', kubeconfigPath, 'get', '--raw=/healthz'], {
1527
- silent: true,
1528
- timeout: 10_000,
1529
- });
1530
- apiReady = true;
1531
- break;
1532
- } catch (err) {
1533
- lastApiErr = err.message?.split('\n')[0]?.slice(0, 120) || String(err).slice(0, 120);
1534
- await new Promise((r) => setTimeout(r, 5_000));
1535
- }
1536
- }
1537
- if (!apiReady) {
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):