@jr2/cli 0.4.1 → 0.6.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/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. */
@@ -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;
@@ -145,20 +161,224 @@ export type KubeAdmin = {
145
161
  /** `kubectl create --dry-run=server -o json -f -`: the object as admission would store it,
146
162
  * stored nowhere. THROWS on a refusal, with kubectl's reason. */
147
163
  dryRunCreate(opts: { manifest: string; context?: string }): Promise<{ spec?: { resources?: unknown } }>;
148
- /** Run a one-shot node script IN the cluster (`kubectl run --rm`) and return its output — the
149
- * provider-preflight seam (ADR-0019: reachability must be probed from where pods live).
150
- * `caPem` makes the probe trust a private CA the same way the Harness does (ADR-0020):
151
- * `NODE_EXTRA_CA_CERTS`, which must be process env at node start — it cannot be set from
152
- * inside the script. */
153
- runOneShot(opts: {
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: {
154
170
  namespace: string;
155
171
  name: string;
156
- script: string;
157
- caPem?: string;
172
+ container: string;
173
+ startSeconds: number;
174
+ runSeconds: number;
158
175
  context?: string;
159
- }): 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[];
160
194
  };
161
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
+ };
213
+ };
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
+
162
382
  const nsArgs = (namespace?: string): string[] => (namespace ? ["--namespace", namespace] : []);
163
383
 
164
384
  /** The workload kinds `jr2 up` rolls out and waits on. */
@@ -270,9 +490,30 @@ export const kubectlAdmin: KubeAdmin = {
270
490
  return (JSON.parse(stdout) as { items?: never[] }).items ?? [];
271
491
  },
272
492
 
273
- async apply({ manifest, context, serverSide }) {
274
- const mode = serverSide ? ["--server-side", "--force-conflicts", "--field-manager=jr2"] : [];
275
- 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
+ }
276
517
  },
277
518
 
278
519
  async label({ kind, name, namespace, labels, context }) {
@@ -339,59 +580,32 @@ export const kubectlAdmin: KubeAdmin = {
339
580
  return JSON.parse(out) as { spec?: { resources?: unknown } };
340
581
  },
341
582
 
342
- async runOneShot({ namespace, name, script, caPem, context }) {
343
- // With a CA bundle: write the PEM to a file BEFORE node starts, because NODE_EXTRA_CA_CERTS
344
- // is only honored as process-start env. The PEM rides an env var base64'd (multiline values
345
- // and `--env` don't mix); `"$1"` keeps the script out of shell parsing entirely (execFile
346
- // passes argv verbatim, no host shell either).
347
- const command = caPem
348
- ? ["sh", "-ec", 'echo "$JR2_CA_B64" | base64 -d > /tmp/jr2-ca.crt && exec node -e "$1"', "sh", script]
349
- : ["node", "-e", script];
350
- try {
351
- const { stdout } = await exec(
352
- "kubectl",
353
- [
354
- ...ctxArgs(context),
355
- ...nsArgs(namespace),
356
- "run",
357
- name,
358
- "--rm",
359
- "--attach",
360
- "--restart=Never",
361
- "--quiet",
362
- "--image=node:24-slim",
363
- ...(caPem
364
- ? [`--env=JR2_CA_B64=${Buffer.from(caPem).toString("base64")}`, "--env=NODE_EXTRA_CA_CERTS=/tmp/jr2-ca.crt"]
365
- : []),
366
- "--command",
367
- "--",
368
- ...command,
369
- ],
370
- { timeout: 180_000 },
371
- );
372
- return stdout;
373
- } catch (err) {
374
- throw oneShotFailure(err);
375
- }
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
+ );
376
606
  },
377
607
  };
378
608
 
379
- /**
380
- * A one-shot probe's failure, made readable. `kubectl run --attach` streams the POD's own output
381
- * on ITS stdout, and kubectl writes only its verdict ("pod … terminated (Error)") to stderr — so
382
- * the one line that says WHY (a stack, a DNS error, an HTTP status) is exactly what execFile's
383
- * error message drops, since that message carries stderr alone. The provider preflight (ADR-0019)
384
- * exists to be the cheapest diagnosis available; without the pod's own words it can only report
385
- * that something in the cluster exited non-zero, and the caller is back to reproducing the probe
386
- * by hand. So the attached output leads, and the kubectl verdict follows it.
387
- */
388
- export function oneShotFailure(err: unknown): Error {
389
- const attached = typeof (err as { stdout?: unknown })?.stdout === "string" ? (err as { stdout: string }).stdout : "";
390
- const base = err instanceof Error ? err.message : String(err);
391
- const trimmed = attached.trim();
392
- return trimmed ? new Error(`${trimmed}\n${base}`) : err instanceof Error ? err : new Error(base);
393
- }
394
-
395
609
  /** What a rollout wait needs to know to go looking: the workload that did not come up (a Deployment
396
610
  * unless `kind` says otherwise), and the label selector its pods carry (each converge layer already
397
611
  * knows its own — it verifies the running image through the same selector). */
@@ -478,8 +692,7 @@ type EventObject = {
478
692
  * A rollout's failure, made readable (ADR-0046). `kubectl rollout status` reports its verdict and
479
693
  * nothing else — `error: timed out waiting for the condition` — so the fact that says WHY (an
480
694
  * `exec format error`, a pull the kubelet gave up on, a Secret that is not there) lives in the
481
- * pods, which the caller then goes and reads by hand. This is `oneShotFailure()` one layer up: the
482
- * 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
483
696
  * of eyes.
484
697
  *
485
698
  * Every read here is best-effort by construction. It runs only after a failure has already
@@ -774,6 +987,60 @@ function resolveRef(ref?: string): string {
774
987
  return `docker.io/${ref.includes("/") ? ref : `library/${ref}`}`;
775
988
  }
776
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
+
777
1044
  /** Run a command feeding `stdin`, surfacing stderr in the thrown error (kubectl's messages are the
778
1045
  * useful part of an apply failure). */
779
1046
  function execStdin(cmd: string[], stdin: string): Promise<void> {
@@ -0,0 +1,129 @@
1
+ // jr2 requires Landlock on every Sandbox node (ADR-0077). The Repo cache agent is the detector: its
2
+ // DaemonSet has one pod on each Sandbox node, by the Sandboxes' own placement (ADR-0052), and on a
3
+ // node without Landlock, or with an ABI below 3, the agent exits at start with a termination
4
+ // message that names Landlock, the node and the probe's reason, which states the ABI it found
5
+ // (`operator/internal/repocache/landlock.go`). jr2's minimum is ABI 3: under ABI 1 no confined
6
+ // process can move a file into another folder, and under ABI 2 a confined process can truncate a
7
+ // file of jr2's that it may not write. That pod never gets Ready, so the cache agent's
8
+ // rollout never completes.
9
+ //
10
+ // `jr2 up` reads the DaemonSet's pods within the rollout wait it already makes, and refuses to
11
+ // converge while any of them reports no Landlock. It names each node, the reason, and the fix: keep
12
+ // the node out of the Sandbox nodes with `sandbox.nodeSelector` or a taint, or enable Landlock. There
13
+ // is no check pod, no node label and no flag to skip it. A run after the fix converges.
14
+
15
+ import { setTimeout as sleep } from "node:timers/promises";
16
+ import type { KubeAdmin, RolloutTarget } from "./kube.ts";
17
+
18
+ /** The start of the cache agent's termination message on a node without Landlock — the Go side's
19
+ * `LandlockMessagePrefix`. The two change together. */
20
+ export const LANDLOCK_MESSAGE_PREFIX = "no Landlock on node ";
21
+
22
+ /** How often `awaitCacheAgent` reads the DaemonSet's pods while the rollout wait runs. */
23
+ const POLL_MS = 3000;
24
+
25
+ type Terminated = { message?: string };
26
+ type ContainerStatus = {
27
+ state?: { terminated?: Terminated };
28
+ lastState?: { terminated?: Terminated };
29
+ };
30
+ /** A cache agent pod, read for the node it runs on and its containers' termination messages. */
31
+ export type CacheAgentPod = {
32
+ metadata: { name: string; deletionTimestamp?: string };
33
+ spec?: { nodeName?: string };
34
+ status?: { containerStatuses?: ContainerStatus[] };
35
+ };
36
+
37
+ /** One Sandbox node whose cache agent reports no Landlock, and the probe's reason. */
38
+ export type NoLandlock = { node: string; reason: string };
39
+
40
+ // The node, then the reason up to the sentence the agent ends its message with. Not anchored: with
41
+ // `FallbackToLogsOnError` the message can be the agent's log line, which quotes it.
42
+ const MESSAGE = /no Landlock on node ([^\s:]+): (.+?) — jr2 needs Landlock/;
43
+
44
+ /** The Sandbox nodes whose cache agent reports no Landlock, each once, in node order. */
45
+ export function nodesWithoutLandlock(pods: readonly CacheAgentPod[]): NoLandlock[] {
46
+ const found = new Map<string, string>();
47
+ for (const pod of pods) {
48
+ if (pod.metadata.deletionTimestamp) continue;
49
+ for (const s of pod.status?.containerStatuses ?? []) {
50
+ const message = s.state?.terminated?.message ?? s.lastState?.terminated?.message ?? "";
51
+ const match = MESSAGE.exec(message);
52
+ if (!match) continue;
53
+ const node = pod.spec?.nodeName ?? match[1]!;
54
+ if (!found.has(node)) found.set(node, match[2]!);
55
+ }
56
+ }
57
+ return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([node, reason]) => ({ node, reason }));
58
+ }
59
+
60
+ /** The refusal (ADR-0077, ADR-0019): each node, its reason, and the fix. */
61
+ export function landlockRefusal(nodes: readonly NoLandlock[]): Error {
62
+ return new Error(
63
+ [
64
+ `repo cache: ${nodes.length === 1 ? "a Sandbox node has" : `${nodes.length} Sandbox nodes have`} no Landlock, ` +
65
+ "and jr2 needs it on every Sandbox node (ADR-0077):",
66
+ ...nodes.map((n) => ` ${n.node}: ${n.reason}`),
67
+ "Keep each such node out of the Sandbox nodes with sandbox.nodeSelector or a taint (ADR-0052), or enable " +
68
+ "Landlock ABI 3 or later in its kernel (Linux 6.2 or later, or a kernel with a backport, with landlock in " +
69
+ "its lsm= list). Then run `jr2 up` again.",
70
+ ].join("\n"),
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Wait for the cache agent's rollout, and refuse as soon as one of its pods reports no Landlock:
76
+ * a pod that exits at start never gets Ready, so the wait alone would only time out. `rollout` is
77
+ * the converge's own rollout wait (with its own diagnosis on failure); while it runs, the
78
+ * DaemonSet's pods are read every few seconds, one list call each. A failed rollout is read once
79
+ * more, so a Landlock refusal wins over the generic diagnosis.
80
+ */
81
+ export async function awaitCacheAgent(
82
+ kube: KubeAdmin,
83
+ target: RolloutTarget,
84
+ rollout: () => Promise<void>,
85
+ opts: { pollMs?: number } = {},
86
+ ): Promise<void> {
87
+ const read = async (): Promise<NoLandlock[]> => {
88
+ try {
89
+ return nodesWithoutLandlock(
90
+ await kube.listJson<CacheAgentPod>({
91
+ kind: "pod",
92
+ selector: target.selector,
93
+ namespace: target.namespace,
94
+ ...(target.context ? { context: target.context } : {}),
95
+ }),
96
+ );
97
+ } catch {
98
+ // A read that fails is no evidence: the rollout wait still decides.
99
+ return [];
100
+ }
101
+ };
102
+ const stop = new AbortController();
103
+ const waited = rollout().then(
104
+ () => ({ ok: true as const }),
105
+ (err: unknown) => ({ ok: false as const, err }),
106
+ );
107
+ const polled = (async (): Promise<NoLandlock[] | undefined> => {
108
+ for (;;) {
109
+ try {
110
+ await sleep(opts.pollMs ?? POLL_MS, undefined, { signal: stop.signal });
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ const nodes = await read();
115
+ if (nodes.length > 0) return nodes;
116
+ }
117
+ })();
118
+ const first = await Promise.race([waited, polled.then((nodes) => ({ nodes }))]);
119
+ stop.abort();
120
+ if ("nodes" in first) {
121
+ if (first.nodes) throw landlockRefusal(first.nodes);
122
+ // The poll stops only when aborted, so the rollout settled first.
123
+ }
124
+ const result = await waited;
125
+ if (result.ok) return;
126
+ const nodes = await read();
127
+ if (nodes.length > 0) throw landlockRefusal(nodes);
128
+ throw result.err;
129
+ }
package/src/nodes.ts CHANGED
@@ -7,7 +7,13 @@
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 { cpuMillis, memoryBytes, type ResolvedSize, type SandboxPlacement, type 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 = {
package/src/output.ts CHANGED
@@ -24,7 +24,7 @@ export type Io = {
24
24
  kubeAdmin?: KubeAdmin;
25
25
  /** Override the image build port; default → pnpm + docker + kind subprocesses. */
26
26
  build?: BuildPort;
27
- /** Override `jr2 up`'s Instance typecheck (ADR-0050); default → the Instance's own `tsc`. */
27
+ /** Override the Instance typecheck of `jr2 up` and `jr2 check` (ADR-0050); default → the Instance's own `tsc`. */
28
28
  typecheck?: TypecheckPort;
29
29
  /** Where kit-checkout detection starts walking up from (ADR-0038); default → the CLI's own
30
30
  * module directory, which is the whole signal: a checkout resolves the kit sources, an npm
package/src/repo-sweep.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // no Repos — the one read that may answer "none" — while any other failure throws and the caller
12
12
  // sweeps nothing.
13
13
 
14
- import { ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "@jr2/orchestrator";
14
+ import { ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "@jr2/orchestrator/internal";
15
15
  import { LABEL_INSTANCE } from "./deploy.ts";
16
16
  import { isMissingResourceType, type KubeAdmin } from "./kube.ts";
17
17
  import { activity, type Io } from "./output.ts";
package/src/sweep.ts CHANGED
@@ -24,12 +24,13 @@
24
24
  // all: a cluster with no Sandbox CRD holds no Sandbox CRs, so that read's "none" is complete (see
25
25
  // `listSandboxes`).
26
26
  //
27
- // Accepted, and written down so nobody adds a name filter to "fix" it (ADR-0039): the host keep set
28
- // only sees the CURRENT context's roots, so a second checkout converging to a different cluster can
29
- // have its host kit generation swept. The rebuild is BuildKit-cached seconds. Re-introducing a name
30
- // filter would resurrect the exact primitive this ADR deletes.
27
+ // Written down so nobody adds a name filter to "fix" it (ADR-0039): the host keep set only sees the
28
+ // CURRENT context's roots, so a converge to a different cluster — a second checkout's, or this
29
+ // one's — can lose a host image it built and has not used yet. `jr2 up` answers that itself: a tag
30
+ // it counted on and finds missing is rebuilt (BuildKit-cached seconds) and the use retried.
31
+ // Re-introducing a name filter would resurrect the exact primitive this ADR deletes.
31
32
 
32
- import { IMAGES_CONFIGMAP, IMAGES_KEY } from "@jr2/orchestrator";
33
+ import { IMAGES_CONFIGMAP, IMAGES_KEY } from "@jr2/orchestrator/internal";
33
34
  import { formatBytes, mergeSweeps, sweepHost, sweepNodes, type BuildPort, type SweepResult } from "./build.ts";
34
35
  import { LABEL_INSTANCE, OPERATOR_NAMESPACE } from "./deploy.ts";
35
36
  import { isMissingResourceType, type KubeAdmin } from "./kube.ts";