@jr2/cli 0.3.0 → 0.5.1

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/src/kube.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // one, shelling out to the same `kubectl` a human debugging the cluster would use.
6
6
 
7
7
  import { execFile, spawn } from "node:child_process";
8
+ import { setTimeout as sleep } from "node:timers/promises";
8
9
  import { promisify } from "node:util";
9
10
 
10
11
  const exec = promisify(execFile);
@@ -31,7 +32,7 @@ function unreachable(e: unknown): string {
31
32
 
32
33
  // The fixed in-namespace object names live with the orchestrator (its entrypoint consumes them
33
34
  // too); re-exported here for the CLI's own modules.
34
- export { INSTANCE_SECRET, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "@jr2/orchestrator";
35
+ export { INSTANCE_SECRET, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "@jr2/orchestrator/internal";
35
36
 
36
37
  export type KubePort = {
37
38
  /** The current kubectl context, or undefined when there is none configured. */
@@ -52,8 +53,8 @@ export type KubePort = {
52
53
  const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
53
54
 
54
55
  /** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
55
- * identity and the instance image's content hash; annotations carry the converged image map, which
56
- * is too long for a label value (ADR-0038). */
56
+ * identity; annotations carry the converged image map, which is too long for a label value
57
+ * (ADR-0038). */
57
58
  export type KubeObject = {
58
59
  metadata: {
59
60
  name: string;
@@ -101,8 +102,23 @@ export type KubeAdmin = {
101
102
  /** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string. `serverSide` is for an
102
103
  * object too large for client-side apply's `last-applied-configuration` annotation (256 KiB) —
103
104
  * the trust bundles, which carry every root certificate (ADR-0059); jr2 is its only writer, so it
104
- * takes the fields it owns (`--force-conflicts`). */
105
- apply(opts: { manifest: string; context?: string; serverSide?: boolean }): Promise<void>;
105
+ * takes the fields it owns (`--force-conflicts`).
106
+ *
107
+ * A Secret is ALWAYS applied server-side, whatever `serverSide` says (`splitSecrets`): a
108
+ * client-side apply copies the whole object, its values too, into that annotation, and a value
109
+ * there is in plaintext for every reader of the object's metadata. On a Secret that an older
110
+ * `jr2 up` applied client-side, the first server-side apply migrates: kubectl (1.26 and later)
111
+ * moves the client-side manager's fields to `jr2`, so that apply removes the annotation and each
112
+ * key that it does not name. A migration that fails is only a warning on kubectl's stderr. So a
113
+ * fallback removes the annotation from each Secret after the apply; when the fallback fails, it is
114
+ * one line to `warn` (default: stderr), and the apply goes on. The values go on stdin, never in
115
+ * argv. */
116
+ apply(opts: {
117
+ manifest: string;
118
+ context?: string;
119
+ serverSide?: boolean;
120
+ warn?: (line: string) => void;
121
+ }): Promise<void>;
106
122
  /** `kubectl label --overwrite`. */
107
123
  label(opts: {
108
124
  kind: string;
@@ -138,24 +154,235 @@ export type KubeAdmin = {
138
154
  previous?: boolean;
139
155
  context?: string;
140
156
  }): Promise<string>;
141
- /** Run a one-shot node script IN the cluster (`kubectl run --rm`) and return its output — the
142
- * provider-preflight seam (ADR-0019: reachability must be probed from where pods live).
143
- * `caPem` makes the probe trust a private CA the same way the Harness does (ADR-0020):
144
- * `NODE_EXTRA_CA_CERTS`, which must be process env at node start — it cannot be set from
145
- * inside the script. */
146
- runOneShot(opts: {
157
+ /** The API server's `gitVersion` (`kubectl get --raw /version`), e.g. `v1.35.0-gke.1200` — what
158
+ * ADR-0037's Kubernetes floor is judged against. THROWS when the server does not answer, like
159
+ * `listJson`: a floor check that passed because it could not look is no check at all. */
160
+ serverVersion(opts: { context?: string }): Promise<string>;
161
+ /** `kubectl create --dry-run=server -o json -f -`: the object as admission would store it,
162
+ * stored nowhere. THROWS on a refusal, with kubectl's reason. */
163
+ dryRunCreate(opts: { manifest: string; context?: string }): Promise<{ spec?: { resources?: unknown } }>;
164
+ /** Wait until one pod has run to its end, and read its logs — the provider preflight's seam
165
+ * (ADR-0019, ADR-0059). Its pod has a Custodian beside the check container, which `kubectl run` cannot
166
+ * make, so `jr2 up` applies it and waits here. Two bounds: `startSeconds` until the check
167
+ * container runs (the image pulls and the Custodian's start), then `runSeconds` for the check. It
168
+ * stops early on a pod that cannot start (`podProgress`). Never throws. */
169
+ awaitPodEnd(opts: {
147
170
  namespace: string;
148
171
  name: string;
149
- script: string;
150
- caPem?: string;
172
+ container: string;
173
+ startSeconds: number;
174
+ runSeconds: number;
151
175
  context?: string;
152
- }): Promise<string>;
176
+ /** Called with each new reason the pod gives for not running yet (`Unschedulable: …`). */
177
+ onPending?: (line: string) => void;
178
+ /** Called with the check container's log on each poll while it runs. */
179
+ onLog?: (log: string) => void;
180
+ }): Promise<PodEnd>;
181
+ };
182
+
183
+ /** How one pod ended (`awaitPodEnd`). `phase` is the pod's own, or `stuck` (it cannot start:
184
+ * `stuck` says why) or `timeout` (`started` says whether the check ran). `output` is the check
185
+ * container's whole log; `custodian` the Custodian's, its previous run's first when it restarted.
186
+ * `reasons` is each container's waiting or ended reason as the kubelet says it. */
187
+ export type PodEnd = {
188
+ phase: "Succeeded" | "Failed" | "stuck" | "timeout";
189
+ started: boolean;
190
+ stuck?: string;
191
+ output: string;
192
+ custodian: string;
193
+ reasons: string[];
194
+ };
195
+
196
+ /** A container's status as the kubelet reports it: the part `podProgress` reads. */
197
+ type CheckContainerStatus = {
198
+ name?: string;
199
+ restartCount?: number;
200
+ state?: Record<string, { reason?: string; message?: string }>;
201
+ /** Why the run before the current one ended: where an OOMKilled sidecar says so. */
202
+ lastState?: { terminated?: { reason?: string; exitCode?: number } };
203
+ };
204
+
205
+ /** The pod as `podProgress` reads it. */
206
+ export type CheckPodObject = {
207
+ status?: {
208
+ phase?: string;
209
+ conditions?: Array<{ type?: string; status?: string; reason?: string; message?: string }>;
210
+ initContainerStatuses?: CheckContainerStatus[];
211
+ containerStatuses?: CheckContainerStatus[];
212
+ };
153
213
  };
154
214
 
215
+ /** Why a pod that has not ended is not running yet, for a person who waits on it: the scheduler's
216
+ * reason, else the first container's waiting reason, else its phase. Undefined when it gives none,
217
+ * and for a pod that runs with no container waiting. */
218
+ export function podPending(pod: CheckPodObject | undefined): string | undefined {
219
+ const unscheduled = pod?.status?.conditions?.find((c) => c.type === "PodScheduled" && c.status === "False");
220
+ if (unscheduled)
221
+ return `${unscheduled.reason ?? "not scheduled"}${unscheduled.message ? `: ${unscheduled.message}` : ""}`;
222
+ const statuses = [...(pod?.status?.initContainerStatuses ?? []), ...(pod?.status?.containerStatuses ?? [])];
223
+ for (const c of statuses) {
224
+ const waiting = c.state?.waiting;
225
+ if (waiting?.reason) return `${c.name ?? "?"}: ${waiting.reason}`;
226
+ }
227
+ // A pod that runs, with no container waiting, is not waiting to start: its check is on its way.
228
+ return pod?.status?.phase === "Running" ? undefined : pod?.status?.phase;
229
+ }
230
+
231
+ /** A volume a pod cannot mount because a Secret, a ConfigMap or a key it names is missing: the
232
+ * kubelet keeps the pod in ContainerCreating and says so in a FailedMount event alone — `secret "x"
233
+ * not found`, `references non-existent secret key: y`, `configmap references non-existent config
234
+ * key`. The first such event can be a race with the objects that the same apply wrote, so it counts
235
+ * only when the kubelet says it again: its `count` or `lastTimestamp` went up since an earlier
236
+ * poll. `seen` keeps each message's last value from poll to poll. */
237
+ export function failedMount(
238
+ events: ReadonlyArray<{ reason?: string; message?: string; count?: number; lastTimestamp?: string }>,
239
+ seen: Map<string, string>,
240
+ ): string | undefined {
241
+ let found: string | undefined;
242
+ for (const e of events) {
243
+ if (e.reason !== "FailedMount" || !/not found|non-existent|couldn't find key|does not exist/i.test(e.message ?? ""))
244
+ continue;
245
+ const message = e.message ?? "";
246
+ const mark = `${e.count ?? 1}|${e.lastTimestamp ?? ""}`;
247
+ const before = seen.get(message);
248
+ if (before !== undefined && before !== mark) found ??= `FailedMount: ${message}`;
249
+ seen.set(message, mark);
250
+ }
251
+ return found;
252
+ }
253
+
254
+ /** The waiting reasons from which a pod does not come back by itself (ADR-0019): an image no node
255
+ * can pull, a Secret or ConfigMap key it names that is missing, a container that crashes at start. */
256
+ export const TERMINAL_WAITING = new Set(["InvalidImageName", "CreateContainerConfigError", "CrashLoopBackOff"]);
257
+
258
+ /** A pull that ends with one of these comes back by itself (a slow registry, a timeout, a rate
259
+ * limit); a pull whose message says that the image or the right to it is not there does not. */
260
+ const PULL_WAITING = new Set(["ErrImagePull", "ImagePullBackOff"]);
261
+ const PULL_FINAL = /not found|manifest unknown|unauthorized|denied/i;
262
+
263
+ /**
264
+ * Where a check pod is (`awaitPodEnd`): ended (`phase`), unable to start (`stuck`, the reason), or
265
+ * still on its way, with whether `container` runs yet. A restart of `sidecar` is `stuck`: the
266
+ * Custodian restarts only when it refused to start, and the next start refuses alike. Pure, for its
267
+ * test.
268
+ */
269
+ export function podProgress(
270
+ pod: CheckPodObject | undefined,
271
+ container: string,
272
+ sidecar: string,
273
+ ): { phase?: "Succeeded" | "Failed"; stuck?: string; running: boolean; reasons: string[] } {
274
+ const statuses = [...(pod?.status?.initContainerStatuses ?? []), ...(pod?.status?.containerStatuses ?? [])];
275
+ const reasons = statuses.flatMap((c) => [
276
+ ...Object.entries(c.state ?? {}).flatMap(([state, s]) =>
277
+ s.reason || s.message ? [`${c.name ?? "?"}: ${state} ${s.reason ?? ""} ${s.message ?? ""}`.trim()] : [],
278
+ ),
279
+ ...(c.lastState?.terminated?.reason ? [`${c.name ?? "?"}: last ended ${c.lastState.terminated.reason}`] : []),
280
+ ]);
281
+ const running = statuses.some((c) => c.name === container && c.state?.running !== undefined);
282
+ const phase = pod?.status?.phase;
283
+ if (phase === "Succeeded" || phase === "Failed") return { phase, running, reasons };
284
+ for (const c of statuses) {
285
+ const waiting = c.state?.waiting?.reason;
286
+ const pullFinal =
287
+ waiting !== undefined && PULL_WAITING.has(waiting) && PULL_FINAL.test(c.state?.waiting?.message ?? "");
288
+ if (waiting && (TERMINAL_WAITING.has(waiting) || pullFinal)) {
289
+ const last = c.lastState?.terminated?.reason;
290
+ return { stuck: `${c.name ?? "?"}: ${waiting}${last ? ` (last ended ${last})` : ""}`, running, reasons };
291
+ }
292
+ if (c.name === sidecar && (c.restartCount ?? 0) > 0) {
293
+ const last = c.lastState?.terminated?.reason;
294
+ return { stuck: `${sidecar}: restarted${last ? ` (last ended ${last})` : ""}`, running, reasons };
295
+ }
296
+ }
297
+ return { running, reasons };
298
+ }
299
+
300
+ /** What `waitPodEnd` reads the pod and the clock through: kubectl and the wall clock in
301
+ * `kubectlAdmin`, a script and a fake clock in its test. */
302
+ export type PodEndPorts = {
303
+ pod(): Promise<CheckPodObject | undefined>;
304
+ /** The pod's events: where a FailedMount is told. */
305
+ events(): Promise<Array<{ reason?: string; message?: string; count?: number; lastTimestamp?: string }>>;
306
+ /** Each new reason the pod gives for not running yet. */
307
+ pending?(line: string): void;
308
+ /** The container's log so far, on each poll while it runs: its results as they come. */
309
+ log?(output: string): void;
310
+ logs(container: string, opts: { tailLines: number; previous?: boolean }): Promise<string>;
311
+ sleep(ms: number): Promise<unknown>;
312
+ now(): number;
313
+ };
314
+
315
+ /**
316
+ * The wait of `awaitPodEnd`: poll the pod every 2 s until it ends (`podProgress`), cannot start
317
+ * (a terminal waiting reason, a Custodian restart, a `FailedMount` event), or runs out of time —
318
+ * `startSeconds` until `container` runs, then `runSeconds` from then on. While it waits to start,
319
+ * each new reason it gives goes to `pending`. Then it reads the logs: the check container's whole log, for
320
+ * every model's line, read again when a pod that ended answers nothing; and the Custodian's last
321
+ * 50 lines, its run before first when it restarted.
322
+ */
323
+ export async function waitPodEnd(
324
+ ports: PodEndPorts,
325
+ opts: { container: string; startSeconds: number; runSeconds: number },
326
+ ): Promise<PodEnd> {
327
+ const startBy = ports.now() + opts.startSeconds * 1000;
328
+ let runBy: number | undefined;
329
+ let seen: ReturnType<typeof podProgress>;
330
+ let end: PodEnd["phase"];
331
+ let restarted = false;
332
+ let said: string | undefined;
333
+ const mounts = new Map<string, string>();
334
+ for (;;) {
335
+ const pod = await ports.pod();
336
+ seen = podProgress(pod, opts.container, "custodian");
337
+ restarted ||= seen.stuck?.startsWith("custodian: restarted") === true;
338
+ if (seen.running && runBy === undefined) runBy = ports.now() + opts.runSeconds * 1000;
339
+ if (seen.phase) {
340
+ end = seen.phase;
341
+ break;
342
+ }
343
+ if (!seen.stuck && !seen.running) {
344
+ const mount = failedMount(await ports.events(), mounts);
345
+ if (mount) seen = { ...seen, stuck: mount };
346
+ }
347
+ if (seen.stuck) {
348
+ end = "stuck";
349
+ break;
350
+ }
351
+ if (ports.now() >= (runBy ?? startBy)) {
352
+ end = "timeout";
353
+ break;
354
+ }
355
+ if (seen.running && ports.log) ports.log(await ports.logs(opts.container, { tailLines: -1 }));
356
+ const pending = seen.running ? undefined : podPending(pod);
357
+ if (pending && pending !== said) ports.pending?.(pending);
358
+ said = pending;
359
+ await ports.sleep(2000);
360
+ }
361
+ // The whole log: one line per model. A check that ended always wrote one, so an empty read is a
362
+ // read that failed (kubectl answers nothing then): read again before calling it empty.
363
+ let output = await ports.logs(opts.container, { tailLines: -1 });
364
+ for (let n = 0; n < 3 && !output.trim() && (end === "Succeeded" || end === "Failed"); n++) {
365
+ await ports.sleep(1000);
366
+ output = await ports.logs(opts.container, { tailLines: -1 });
367
+ }
368
+ const custodian = [
369
+ ...(restarted ? [await ports.logs("custodian", { tailLines: 50, previous: true })] : []),
370
+ await ports.logs("custodian", { tailLines: 50 }),
371
+ ].join("\n");
372
+ return {
373
+ phase: end,
374
+ started: runBy !== undefined,
375
+ ...(seen.stuck ? { stuck: seen.stuck } : {}),
376
+ output,
377
+ custodian,
378
+ reasons: seen.reasons,
379
+ };
380
+ }
381
+
155
382
  const nsArgs = (namespace?: string): string[] => (namespace ? ["--namespace", namespace] : []);
156
383
 
157
- /** The two workload kinds `jr2 up` rolls out and waits on. */
158
- export type RolloutKind = "deployment" | "daemonset";
384
+ /** The workload kinds `jr2 up` rolls out and waits on. */
385
+ export type RolloutKind = "deployment" | "daemonset" | "statefulset";
159
386
 
160
387
  /** One rollout to wait for: the object, by kind and name, in its namespace. */
161
388
  export type RolloutRequest = {
@@ -186,6 +413,28 @@ export function rolloutStatusArgs({
186
413
  ];
187
414
  }
188
415
 
416
+ /** ADR-0037's Kubernetes floor: every Sandbox's `/opt/jr2` is an `image` volume of the harness
417
+ * image, and image volumes are on by default from 1.35 (GA in 1.36). There is no fallback below it
418
+ * — a second pod shape kept alive for clusters no Instance runs on — so `jr2 up` refuses there. */
419
+ export const KUBE_FLOOR = { major: 1, minor: 35 } as const;
420
+
421
+ /**
422
+ * Why this server version cannot host an Instance, or undefined when it can. Pure, so the floor is
423
+ * checkable without a cluster. Judged on the leading `v<major>.<minor>` alone: distributions append
424
+ * their own suffixes (`-gke.1200`, `+k3s1`, `-eks-…`), and none of them moves the feature gate. A
425
+ * version with no such prefix is refused BY NAME rather than passed — a floor that waves through
426
+ * what it cannot read is the timeout ADR-0046 exists to replace.
427
+ */
428
+ export function kubeFloorRefusal(gitVersion: string): string | undefined {
429
+ const floor = `${KUBE_FLOOR.major}.${KUBE_FLOOR.minor}`;
430
+ const why = `jr2 needs Kubernetes ${floor} or newer (every Sandbox mounts jr2's runtime as an image volume, ADR-0037)`;
431
+ const m = /^v?(\d+)\.(\d+)/.exec(gitVersion);
432
+ if (!m) return `reports version "${gitVersion}", which names no major.minor; ${why}`;
433
+ const [major, minor] = [Number(m[1]), Number(m[2])];
434
+ if (major > KUBE_FLOOR.major || (major === KUBE_FLOOR.major && minor >= KUBE_FLOOR.minor)) return undefined;
435
+ return `runs Kubernetes ${gitVersion}; ${why}`;
436
+ }
437
+
189
438
  /**
190
439
  * Did this read fail because the cluster has no such RESOURCE TYPE (`kubectl get sandboxes… ` on a
191
440
  * cluster with no jr2 CRD)? kubectl exits 1 with `the server doesn't have a resource type "…"`, and
@@ -241,9 +490,30 @@ export const kubectlAdmin: KubeAdmin = {
241
490
  return (JSON.parse(stdout) as { items?: never[] }).items ?? [];
242
491
  },
243
492
 
244
- async apply({ manifest, context, serverSide }) {
245
- const mode = serverSide ? ["--server-side", "--force-conflicts", "--field-manager=jr2"] : [];
246
- await execStdin(["kubectl", ...ctxArgs(context), "apply", ...mode, "-f", "-"], manifest);
493
+ async apply({ manifest, context, serverSide, warn = (line) => process.stderr.write(`${line}\n`) }) {
494
+ const { secrets, identities, rest } = splitSecrets(manifest);
495
+ if (secrets !== undefined) {
496
+ await execStdin(["kubectl", ...ctxArgs(context), "apply", ...SERVER_SIDE, "-f", "-"], secrets);
497
+ // The fallback for a migration that failed. Removing an annotation that is not there is no
498
+ // fault, so this changes nothing once it is gone.
499
+ try {
500
+ await execStdin(
501
+ ["kubectl", ...ctxArgs(context), "annotate", "--overwrite", "-f", "-", `${LAST_APPLIED}-`],
502
+ identities!,
503
+ );
504
+ } catch (err) {
505
+ const names = (JSON.parse(identities!) as { items: Array<{ metadata: { name: string } }> }).items
506
+ .map((o) => o.metadata.name)
507
+ .join(", ");
508
+ warn(
509
+ `warning: the ${LAST_APPLIED} annotation was not removed from Secret ${names} ` +
510
+ `(${(err as Error).message}) — the next \`jr2 up\` tries again`,
511
+ );
512
+ }
513
+ }
514
+ if (rest !== undefined) {
515
+ await execStdin(["kubectl", ...ctxArgs(context), "apply", ...(serverSide ? SERVER_SIDE : []), "-f", "-"], rest);
516
+ }
247
517
  },
248
518
 
249
519
  async label({ kind, name, namespace, labels, context }) {
@@ -288,58 +558,53 @@ export const kubectlAdmin: KubeAdmin = {
288
558
  }
289
559
  },
290
560
 
291
- async runOneShot({ namespace, name, script, caPem, context }) {
292
- // With a CA bundle: write the PEM to a file BEFORE node starts, because NODE_EXTRA_CA_CERTS
293
- // is only honored as process-start env. The PEM rides an env var base64'd (multiline values
294
- // and `--env` don't mix); `"$1"` keeps the script out of shell parsing entirely (execFile
295
- // passes argv verbatim, no host shell either).
296
- const command = caPem
297
- ? ["sh", "-ec", 'echo "$JR2_CA_B64" | base64 -d > /tmp/jr2-ca.crt && exec node -e "$1"', "sh", script]
298
- : ["node", "-e", script];
561
+ async serverVersion({ context }) {
562
+ // `get --raw /version`, not `kubectl version`: that one reports the CLIENT too; this asks the
563
+ // server alone, and answers in the server's own JSON. Bounded like every first contact: it is
564
+ // the first thing `jr2 up` asks, so a dead context fails here, fast, with kubectl's own reason.
299
565
  try {
300
- const { stdout } = await exec(
301
- "kubectl",
302
- [
303
- ...ctxArgs(context),
304
- ...nsArgs(namespace),
305
- "run",
306
- name,
307
- "--rm",
308
- "--attach",
309
- "--restart=Never",
310
- "--quiet",
311
- "--image=node:24-slim",
312
- ...(caPem
313
- ? [`--env=JR2_CA_B64=${Buffer.from(caPem).toString("base64")}`, "--env=NODE_EXTRA_CA_CERTS=/tmp/jr2-ca.crt"]
314
- : []),
315
- "--command",
316
- "--",
317
- ...command,
318
- ],
319
- { timeout: 180_000 },
320
- );
321
- return stdout;
322
- } catch (err) {
323
- throw oneShotFailure(err);
566
+ const { stdout } = await exec("kubectl", [...ctxArgs(context), "get", "--raw", "/version", ...reachArgs], {
567
+ timeout: REACH_BUDGET_MS,
568
+ });
569
+ return (JSON.parse(stdout) as { gitVersion?: string }).gitVersion ?? "";
570
+ } catch (e) {
571
+ throw new Error(unreachable(e));
324
572
  }
325
573
  },
326
- };
327
574
 
328
- /**
329
- * A one-shot probe's failure, made readable. `kubectl run --attach` streams the POD's own output
330
- * on ITS stdout, and kubectl writes only its verdict ("pod … terminated (Error)") to stderr — so
331
- * the one line that says WHY (a stack, a DNS error, an HTTP status) is exactly what execFile's
332
- * error message drops, since that message carries stderr alone. The provider preflight (ADR-0019)
333
- * exists to be the cheapest diagnosis available; without the pod's own words it can only report
334
- * that something in the cluster exited non-zero, and the caller is back to reproducing the probe
335
- * by hand. So the attached output leads, and the kubectl verdict follows it.
336
- */
337
- export function oneShotFailure(err: unknown): Error {
338
- const attached = typeof (err as { stdout?: unknown })?.stdout === "string" ? (err as { stdout: string }).stdout : "";
339
- const base = err instanceof Error ? err.message : String(err);
340
- const trimmed = attached.trim();
341
- return trimmed ? new Error(`${trimmed}\n${base}`) : err instanceof Error ? err : new Error(base);
342
- }
575
+ async dryRunCreate({ manifest, context }) {
576
+ const out = await execStdinOut(
577
+ ["kubectl", ...ctxArgs(context), "create", "--dry-run=server", "-o", "json", "-f", "-"],
578
+ manifest,
579
+ );
580
+ return JSON.parse(out) as { spec?: { resources?: unknown } };
581
+ },
582
+
583
+ async awaitPodEnd({ namespace, name, container, startSeconds, runSeconds, context, onPending, onLog }) {
584
+ const ctx = context ? { context } : {};
585
+ return waitPodEnd(
586
+ {
587
+ pod: () => kubectlAdmin.getJson<CheckPodObject>({ kind: "pod", name, namespace, ...ctx }),
588
+ // A read that fails is no event: the wait goes on, and its bounds still hold.
589
+ events: () =>
590
+ kubectlAdmin
591
+ .listJson<{ reason?: string; message?: string; count?: number; lastTimestamp?: string }>({
592
+ kind: "event",
593
+ fieldSelector: `involvedObject.name=${name}`,
594
+ namespace,
595
+ ...ctx,
596
+ })
597
+ .catch(() => []),
598
+ ...(onPending ? { pending: onPending } : {}),
599
+ ...(onLog ? { log: onLog } : {}),
600
+ logs: (c, opts) => kubectlAdmin.logs({ namespace, pod: name, container: c, ...opts, ...ctx }),
601
+ sleep,
602
+ now: Date.now,
603
+ },
604
+ { container, startSeconds, runSeconds },
605
+ );
606
+ },
607
+ };
343
608
 
344
609
  /** What a rollout wait needs to know to go looking: the workload that did not come up (a Deployment
345
610
  * unless `kind` says otherwise), and the label selector its pods carry (each converge layer already
@@ -350,6 +615,8 @@ export type RolloutTarget = {
350
615
  namespace: string;
351
616
  selector: string;
352
617
  context?: string;
618
+ /** Default 180s (see `rolloutStatusArgs`). */
619
+ timeoutSeconds?: number;
353
620
  };
354
621
 
355
622
  /** One container's state, as the kubelet tells it. `reason`/`message` are its words verbatim — the
@@ -425,8 +692,7 @@ type EventObject = {
425
692
  * A rollout's failure, made readable (ADR-0046). `kubectl rollout status` reports its verdict and
426
693
  * nothing else — `error: timed out waiting for the condition` — so the fact that says WHY (an
427
694
  * `exec format error`, a pull the kubelet gave up on, a Secret that is not there) lives in the
428
- * pods, which the caller then goes and reads by hand. This is `oneShotFailure()` one layer up: the
429
- * evidence leads, kubectl's verdict follows, and `jr2 up`'s three rollout waits share the one pair
695
+ * pods, which the caller then goes and reads by hand. So the evidence leads, kubectl's verdict follows, and `jr2 up`'s three rollout waits share the one pair
430
696
  * of eyes.
431
697
  *
432
698
  * Every read here is best-effort by construction. It runs only after a failure has already
@@ -474,7 +740,12 @@ async function gatherRolloutEvidence(kube: KubeAdmin, target: RolloutTarget): Pr
474
740
  const live = pods.filter((p) => !p.metadata.deletionTimestamp);
475
741
  if (pods.length > 0 && live.length === 0) notes.push(`every pod matching ${selector} is terminating`);
476
742
  if (pods.length === 0 && notes.length === 0) {
477
- const maker = target.kind === "daemonset" ? "the DaemonSet" : "the ReplicaSet";
743
+ const maker =
744
+ target.kind === "daemonset"
745
+ ? "the DaemonSet"
746
+ : target.kind === "statefulset"
747
+ ? "the StatefulSet"
748
+ : "the ReplicaSet";
478
749
  notes.push(`no pod matches ${selector} — ${maker} made none (check quota, node taints, and the selector)`);
479
750
  }
480
751
 
@@ -716,6 +987,60 @@ function resolveRef(ref?: string): string {
716
987
  return `docker.io/${ref.includes("/") ? ref : `library/${ref}`}`;
717
988
  }
718
989
 
990
+ /** A server-side apply as jr2's own field manager. jr2 is the only writer of what it applies, so it
991
+ * takes the fields it names (`--force-conflicts`) from an older client-side apply. */
992
+ const SERVER_SIDE = ["--server-side", "--force-conflicts", "--field-manager=jr2"];
993
+
994
+ /** The annotation in which a client-side apply keeps a copy of the whole object it applied. */
995
+ export const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration";
996
+
997
+ /**
998
+ * A manifest, split for `apply`: its Secrets as one List to apply server-side, the names of those
999
+ * Secrets alone (for the annotation's removal), and the other objects as they were given. A
1000
+ * Secret's `stringData` goes into `data` (base64): a server-side apply owns the fields it names,
1001
+ * and `stringData` is write-only, so a key that a later apply leaves out would stay in `data`. A
1002
+ * manifest that is not JSON (the operator's YAML, which holds no Secret) is all `rest`. Pure, for
1003
+ * its test.
1004
+ */
1005
+ export function splitSecrets(manifest: string): { secrets?: string; identities?: string; rest?: string } {
1006
+ let parsed: { kind?: string; items?: Array<Record<string, unknown>> } & Record<string, unknown>;
1007
+ try {
1008
+ parsed = JSON.parse(manifest);
1009
+ } catch {
1010
+ return { rest: manifest };
1011
+ }
1012
+ const items = parsed.kind === "List" ? (parsed.items ?? []) : [parsed];
1013
+ const secrets = items.filter((o) => o.kind === "Secret");
1014
+ if (!secrets.length) return { rest: manifest };
1015
+ const others = items.filter((o) => o.kind !== "Secret");
1016
+ const list = (objects: unknown[]) => JSON.stringify({ apiVersion: "v1", kind: "List", items: objects });
1017
+ return {
1018
+ secrets: list(secrets.map(serverSideSecret)),
1019
+ identities: list(
1020
+ secrets.map((o) => {
1021
+ const m = o.metadata as { name: string; namespace?: string };
1022
+ return {
1023
+ apiVersion: "v1",
1024
+ kind: "Secret",
1025
+ metadata: { name: m.name, ...(m.namespace ? { namespace: m.namespace } : {}) },
1026
+ };
1027
+ }),
1028
+ ),
1029
+ ...(others.length ? { rest: list(others) } : {}),
1030
+ };
1031
+ }
1032
+
1033
+ /** A Secret with its `stringData` in `data`, as Kubernetes stores it: a key in both takes the
1034
+ * `stringData` value, as the API server merges them. */
1035
+ function serverSideSecret(o: Record<string, unknown>): Record<string, unknown> {
1036
+ const { stringData, ...secret } = o as { stringData?: Record<string, string> } & Record<string, unknown>;
1037
+ if (stringData === undefined) return secret;
1038
+ const encoded = Object.fromEntries(
1039
+ Object.entries(stringData).map(([k, v]) => [k, Buffer.from(v, "utf8").toString("base64")]),
1040
+ );
1041
+ return { ...secret, data: { ...((secret.data as Record<string, string> | undefined) ?? {}), ...encoded } };
1042
+ }
1043
+
719
1044
  /** Run a command feeding `stdin`, surfacing stderr in the thrown error (kubectl's messages are the
720
1045
  * useful part of an apply failure). */
721
1046
  function execStdin(cmd: string[], stdin: string): Promise<void> {
@@ -732,6 +1057,50 @@ function execStdin(cmd: string[], stdin: string): Promise<void> {
732
1057
  });
733
1058
  }
734
1059
 
1060
+ /** `execStdin`, keeping stdout. */
1061
+ function execStdinOut(cmd: string[], stdin: string): Promise<string> {
1062
+ return new Promise((resolve, reject) => {
1063
+ const child = spawn(cmd[0]!, cmd.slice(1));
1064
+ let out = "";
1065
+ let err = "";
1066
+ child.stdout.on("data", (d: Buffer) => (out += d.toString()));
1067
+ child.stderr.on("data", (d: Buffer) => (err += d.toString()));
1068
+ child.on("error", reject);
1069
+ child.on("close", (code) =>
1070
+ code === 0 ? resolve(out) : reject(new Error(`${cmd.join(" ")} exited (${code}): ${err.trim()}`)),
1071
+ );
1072
+ child.stdin.write(stdin);
1073
+ child.stdin.end();
1074
+ });
1075
+ }
1076
+
1077
+ /**
1078
+ * The pod ADR-0060's check dry-runs: one that states pod-level resources (KEP-2837). A server
1079
+ * whose PodLevelResources gate is off drops `spec.resources` at admission without a word, and
1080
+ * every Sandbox then runs Burstable — a User Container with no split unbounded, against "jr2
1081
+ * reserves the whole Size". On by default from 1.34, below KUBE_FLOOR, so only an owner who turned
1082
+ * it off trips this. Shaped for the `restricted` Pod Security level, so a namespace that enforces
1083
+ * it does not refuse the probe for another reason. Never scheduled, never pulled.
1084
+ */
1085
+ export function podResourcesProbe(namespace: string): object {
1086
+ return {
1087
+ apiVersion: "v1",
1088
+ kind: "Pod",
1089
+ metadata: { name: "jr2-pod-resources-probe", namespace },
1090
+ spec: {
1091
+ resources: { requests: { cpu: "10m", memory: "16Mi" }, limits: { cpu: "10m", memory: "16Mi" } },
1092
+ securityContext: { runAsNonRoot: true, runAsUser: 65532, seccompProfile: { type: "RuntimeDefault" } },
1093
+ containers: [
1094
+ {
1095
+ name: "probe",
1096
+ image: "registry.k8s.io/pause:3.10",
1097
+ securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
1098
+ },
1099
+ ],
1100
+ },
1101
+ };
1102
+ }
1103
+
735
1104
  /** The real kube port, over `kubectl` subprocesses. */
736
1105
  export const kubectlKube: KubePort = {
737
1106
  async currentContext() {
package/src/nodes.ts CHANGED
@@ -7,13 +7,19 @@
7
7
  // at the moment of the read. The set moves (a pool autoscales, a node is cordoned), which is why an
8
8
  // empty set is reported and never refused.
9
9
 
10
- import type { SandboxPlacement, Toleration } from "@jr2/orchestrator";
10
+ import {
11
+ cpuMillis,
12
+ memoryBytes,
13
+ type ResolvedSize,
14
+ type SandboxPlacement,
15
+ type Toleration,
16
+ } from "@jr2/orchestrator/internal";
11
17
 
12
18
  /** A cluster node, read for the facts that decide placement and platform (ADR-0045/0052). */
13
19
  export type NodeObject = {
14
20
  metadata: { name: string; labels?: Record<string, string> };
15
21
  spec?: { unschedulable?: boolean; taints?: readonly Taint[] };
16
- status?: { nodeInfo?: { architecture?: string } };
22
+ status?: { nodeInfo?: { architecture?: string }; allocatable?: Record<string, string> };
17
23
  };
18
24
 
19
25
  export type Taint = { key: string; value?: string; effect: string };
@@ -72,3 +78,28 @@ export function tolerates(t: Toleration, taint: Taint): boolean {
72
78
  if (t.operator === "Exists") return true;
73
79
  return (t.value ?? "") === (taint.value ?? "");
74
80
  }
81
+
82
+ /**
83
+ * Whether any of `nodes` could hold `size` (ADR-0060): its allocatable memory and cpu both at least
84
+ * the whole Size — what the scheduler compares a Guaranteed pod against on an empty node. `undefined`
85
+ * when no node reports allocatable, which is an unknown, never a no. A WARNING at `jr2 up`, never a
86
+ * refusal: the node set moves (ADR-0052), and a pool may scale up a bigger node tomorrow.
87
+ */
88
+ export function anyNodeHolds(nodes: readonly NodeObject[], size: ResolvedSize): boolean | undefined {
89
+ let known = false;
90
+ for (const node of nodes) {
91
+ const alloc = node.status?.allocatable;
92
+ if (alloc?.memory === undefined || alloc.cpu === undefined) continue;
93
+ let memory: number;
94
+ let cpu: number;
95
+ try {
96
+ memory = memoryBytes(alloc.memory);
97
+ cpu = cpuMillis(alloc.cpu);
98
+ } catch {
99
+ continue;
100
+ }
101
+ known = true;
102
+ if (memory >= size.memory.bytes && cpu >= size.cpu.millis) return true;
103
+ }
104
+ return known ? false : undefined;
105
+ }