@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.
@@ -1,11 +1,13 @@
1
- // `jr2 up [--yes] [--force] [-n <ns>] [--context <ctx>]` (ADR-0019): idempotently converge the target
2
- // namespace to this instance — every layer, loudly narrated, safe to re-run. Layers in order:
3
- // typecheck → the Machine walk → staging the Assembly (ADR-0069) → the Kubernetes floor (ADR-0037)
4
- // → ownership → image resolution → operator → Kit images (the Harness, the Orchestrator) → Sandbox
5
- // Images → Secret (+ preflight of referenced Secrets) → apply + rollout → the cache agent
6
- // (ADR-0051: a DaemonSet converged when a registered Machine composes a Sandbox, deleted when none
7
- // does) → Instance Harness (ADR-0031: converged by convention when a carried definition declares
8
- // `workspace: "none"`, deleted when none does) → Instance Effector → publishing the Assembly to the
1
+ // `jr2 up [--yes] [--force] [--skip-provider-preflight] [-n <ns>] [--context <ctx>]` (ADR-0019): idempotently converge the target
2
+ // namespace to this instance — every layer, loudly narrated, safe to re-run. Layers in order: the
3
+ // AGENTS.md block (ADR-0072: a file in the Instance folder, not the cluster) → typecheck → the
4
+ // Machine walk → staging the Assembly (ADR-0069) → the Kubernetes floor (ADR-0037) → ownership →
5
+ // image resolution → Kit images (the operator, the Harness, the Orchestrator) → Sandbox Images →
6
+ // Secret (+ preflight of referenced Secrets) → the provider preflight (ADR-0019, ADR-0059: through a
7
+ // Custodian, in pods that read their own objects) → operator → git ssh keys → apply → rollout → the cache
8
+ // agent (ADR-0051: a DaemonSet converged when a registered Machine composes a Sandbox, deleted
9
+ // when none does) → Instance Harness (ADR-0031: converged by convention when a carried definition
10
+ // declares `workspace: "none"`, deleted when none does) → Instance Effector → publishing the Assembly to the
9
11
  // running Orchestrator (ADR-0069) → a report of live workspaces still on an older image.
10
12
  //
11
13
  // The Assembly is staged EARLY and published LAST (ADR-0069). Early, so its refusals (a second copy
@@ -15,7 +17,7 @@
15
17
  // version the Assembly names. A Machine edit therefore rolls nothing: it is a new Assembly, not a
16
18
  // new pod template. The Orchestrator creates a Repo resource per
17
19
  // Repo the walk found bound, at boot and at each publish, and the cache agent clones it onto a node on first need
18
- // (ADR-0051); a configured custom provider is preflighted from inside the cluster. A bound ssh url
20
+ // (ADR-0051); every provider a carried model names is preflighted from inside the cluster. A bound ssh url
19
21
  // asks where its deploy key comes from (ADR-0047), and a converge that GENERATED one ends by saying
20
22
  // so — the key is dead until a human registers it.
21
23
  //
@@ -25,7 +27,9 @@
25
27
  // the one converge-time refusal the compiler cannot make (ADR-0051): a registered Machine whose
26
28
  // Repo Slot is still open has nothing bound where the run would need a url, and a `workflows/`
27
29
  // export has no type to hang that on. Nothing is built, and nothing on the cluster is touched,
28
- // before the compiler has agreed the folder is coherent and every slot is bound.
30
+ // before the compiler has agreed the folder is coherent and every slot is bound. The two are one
31
+ // function, `checkInstance` (instance-check.ts), which `jr2 check` runs alone with no cluster, so
32
+ // an Assistant that may not run `jr2 up` gets the same refusals from the same code.
29
33
  //
30
34
  // Images (ADR-0038, as amended by ADR-0045): `jr2 up` builds every image it deploys, and every tag is
31
35
  // a content address of (its own inputs × the platform set it was built for) — `<hash>-<arch>`, with
@@ -55,11 +59,11 @@
55
59
  // (`--yes` for CI); objects labeled as a DIFFERENT instance → refuse.
56
60
 
57
61
  import { createHash, createHmac, randomBytes } from "node:crypto";
62
+ import { setTimeout as sleepMs } from "node:timers/promises";
58
63
  import { open, readdir, readFile } from "node:fs/promises";
59
64
  import { homedir } from "node:os";
60
65
  import { basename, join, relative } from "node:path";
61
66
  import { fileURLToPath } from "node:url";
62
- import { parseArgs } from "node:util";
63
67
  import {
64
68
  custodianBootstrap,
65
69
  custodianRef,
@@ -75,18 +79,12 @@ import {
75
79
  imageContextDigest,
76
80
  isSshUrl,
77
81
  loadConfig,
78
- loadCharts,
79
- loadWorkflows,
80
- chartFileRefusals,
81
- machineBodies,
82
+ doorCompat,
82
83
  matchCredential,
83
- openPartRefusals,
84
84
  packAssembly,
85
85
  configMapDigest,
86
86
  MOUNTED_CONFIGMAPS,
87
87
  partsOf,
88
- placementWalkOf,
89
- refuseEffectSecrets,
90
88
  LABEL_EFFECTOR_POD,
91
89
  LABEL_GIT_PUSH,
92
90
  LABEL_GIT_CLONE,
@@ -102,19 +100,19 @@ import {
102
100
  harnessTokenDigest,
103
101
  formatCpu,
104
102
  formatMemory,
105
- resolveSize,
106
- sandboxToken,
107
- seatLine,
108
- serializeMachine,
109
- splitSize,
103
+ type CarriedEffect,
110
104
  type CarriedImage,
111
105
  type CarriedRepo,
112
106
  type HeldManifest,
113
107
  type HeldPodPlan,
114
108
  type ImageRefs,
109
+ MODEL_KEYS,
110
+ PROVIDER_KEY_NAME,
111
+ type PlacementWalk,
112
+ type SandboxPlacement,
113
+ workspaceHeldPod,
115
114
  type JR2Config,
116
- type ResolvedSize,
117
- } from "@jr2/orchestrator";
115
+ } from "@jr2/orchestrator/internal";
118
116
  import { stageAssembly, stagedCopyOf } from "../assembly.ts";
119
117
  import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
120
118
  import {
@@ -122,6 +120,7 @@ import {
122
120
  buildSandboxImage,
123
121
  choosePlatforms,
124
122
  detectKitCheckout,
123
+ isMissingHostImage,
125
124
  kitImageBuild,
126
125
  kitImageRefs,
127
126
  normalizeRef,
@@ -130,6 +129,7 @@ import {
130
129
  sandboxImageTag,
131
130
  KIT_IMAGE_HOME,
132
131
  type BuildPort,
132
+ type ImageFiles,
133
133
  type KitImageName,
134
134
  type KitImageRefs,
135
135
  } from "../build.ts";
@@ -141,6 +141,8 @@ import {
141
141
  INSTANCE_HARNESS_PVC,
142
142
  INSTANCE_HARNESS_SERVICE,
143
143
  instanceHarnessObjects,
144
+ INSTANCE_HARNESS_CREDENTIALS,
145
+ podCredentials,
144
146
  instanceEffectorObjects,
145
147
  effectorDigest,
146
148
  storedValueDigest,
@@ -148,6 +150,7 @@ import {
148
150
  effectorGraceSeconds,
149
151
  INSTANCE_EFFECTOR,
150
152
  instanceObjects,
153
+ KIT_POD_SIZES,
151
154
  KIT_VERSION,
152
155
  LABEL_INSTANCE,
153
156
  LABEL_VERSION,
@@ -164,8 +167,15 @@ import {
164
167
  type HeldObjects,
165
168
  type CustodianFiles,
166
169
  type HeldPodObjects,
170
+ checkPodObjects,
171
+ LABEL_PROVIDER_PREFLIGHT,
172
+ checkPodObjectNames,
173
+ PROVIDER_PREFLIGHT,
174
+ CHECK_CONTAINER,
167
175
  } from "../deploy.ts";
176
+ import { syncAgentsMd } from "../agents-md.ts";
168
177
  import { resolveRoot } from "../instance.ts";
178
+ import { resolvePackage } from "../kit-version.ts";
169
179
  import { effectorImagePaths, effectorImageRefusal, STOCK_HARNESS_IMAGE, type PushForm } from "../effector-image.ts";
170
180
  import {
171
181
  kubeFloorRefusal,
@@ -177,27 +187,30 @@ import {
177
187
  ORCHESTRATOR_SERVICE,
178
188
  type KubeAdmin,
179
189
  type KubeObject,
190
+ type PodEnd,
180
191
  type RolloutTarget,
181
192
  } from "../kube.ts";
193
+ import { awaitCacheAgent } from "../landlock.ts";
182
194
  import { anyNodeHolds, buildNodes, sandboxNodes, type NodeObject } from "../nodes.ts";
183
195
  import { activity, chooseOrBail, confirmOrBail, promptLine, readSecretInput, type Io } from "../output.ts";
184
196
  import { kindCluster, sweepImages } from "../sweep.ts";
185
- import { tscTypecheck } from "../typecheck.ts";
197
+ import { chartFilesIn, checkInstance } from "../instance-check.ts";
198
+ import { parseCommand } from "../command-table.ts";
199
+
200
+ /** How many times one converge rebuilds a host image a sweep took before it uses it (ADR-0039).
201
+ * A bound, because a tag that is gone again after two rebuilds is not a race to wait out: something
202
+ * on the host removes it as fast as it is built, and docker's own error is the honest report. */
203
+ const HOST_REBUILDS = 2;
186
204
 
187
205
  export async function up(args: string[], io: Io): Promise<number> {
188
- const { values } = parseArgs({
189
- args,
190
- allowPositionals: true,
191
- strict: false,
192
- options: {
193
- yes: { type: "boolean" },
194
- force: { type: "boolean" },
195
- namespace: { type: "string", short: "n" },
196
- context: { type: "string" },
197
- },
198
- });
206
+ const { values } = parseCommand("up", args);
199
207
 
200
208
  const root = resolveRoot(io.cwd);
209
+ // --- the AGENTS.md block (ADR-0072): before anything can refuse, because it describes the Kit the
210
+ // Instance resolves, not the cluster, so a converge that fails still leaves the index current.
211
+ const kit = resolvePackage("@jr2/orchestrator", root);
212
+ if (kit) await syncAgentsMd(io, root, kit.root, kit.version);
213
+
201
214
  const config = (await loadConfig(root)) ?? {};
202
215
  const name = config.name ?? basename(root);
203
216
  const namespace = (values.namespace as string | undefined) ?? name;
@@ -210,181 +223,16 @@ export async function up(args: string[], io: Io): Promise<number> {
210
223
 
211
224
  activity(io, `jr2 up — instance "${name}" → context ${context} / namespace ${namespace}`);
212
225
 
213
- // --- typecheck (ADR-0050): the compiler agrees the folder is coherent, before anything is spent -
226
+ // --- the Instance check (ADR-0050): the typecheck and the Machine walk, shared with `jr2 check` -
214
227
  // A REFUSAL, not a warning, and it comes before the namespace apply as well as before the builds:
215
- // the names a Machine carries (an Agent slot, a composed Machine, a Repo Slot, a `customize()` of
216
- // any of them) are typed since ADR-0049/0051, so the answer here is the same answer the author's
217
- // editor gives — and a converge that shipped a Machine the compiler rejects would surface it as
218
- // an invoke-time failure mid-run, minutes and three image builds later.
219
- activity(io, "typecheck: tsc --noEmit (the instance's own compiler)");
220
- const checked = await (io.typecheck ?? tscTypecheck)(root);
221
- if (!checked.ok) {
222
- activity(io, "refusing: this instance does not typecheck — nothing was built and nothing was applied");
223
- for (const line of checked.output.split("\n")) activity(io, ` ${line}`);
224
- return 1;
225
- }
226
-
227
- // --- the Machine walk (ADR-0049, ADR-0051) -----------------------------------------------------
228
- // A Machine carries its parts, so this converge LOADS the instance's registered Workflows — the
229
- // same discovery and module contract the Orchestrator boots with — and walks them for everything
230
- // the later layers need: the Agent definitions whose models are preflighted (ADR-0018), any
231
- // `workspace: "none"`, which converges the Instance Harness (ADR-0031), every `file:` docker
232
- // context a `workspace()` names, which is built below (ADR-0037), every bound Repo, whose ssh
233
- // key is asked for here and whose resource the Orchestrator creates at boot (ADR-0051), and
234
- // whether any Machine composes a Sandbox at all — the data-plane switch. It runs right after the
235
- // gate and before ownership because it holds the one REFUSAL the compiler cannot make: a part
236
- // left Open on a registered Machine — a Repo Slot with no url, an Agent with no model
237
- // (ADR-0054). Nothing about Agents is published, because the definition rides each Turn.
238
- const workflows = await loadWorkflows(root);
239
- const carried = partsOf(workflows.map((w) => w.machine));
228
+ // the names a Machine carries are typed since ADR-0049/0051, so the compiler's answer is the
229
+ // author's editor's answer; the walk then refuses what the compiler cannot see (an Open part, a
230
+ // Chart file, a dispatch, an Effect secret, a Size). `jr2 check` runs the same function, so an
231
+ // Assistant that may not run `jr2 up` gets the answer of this converge.
232
+ const checked = await checkInstance(io, root, config);
233
+ if (!checked) return 1;
234
+ const { workflows, carried, placementWalk, sizes, chartFiles } = checked;
240
235
  const { agents, images: contexts } = carried;
241
- // The Machine is named by where it sits under the registered root — the slot chain a
242
- // `customize()` walks — and the fix line nests the same way, so it pastes into
243
- // `workflows/<name>.ts` with the imported Machine and the url filled in.
244
- // Every Open part is refused in ONE listing, in walk order within each kind: a composer fixing a
245
- // packaged Machine fixes all of them in the same edit, and a converge that refused the Repo Slot,
246
- // then the Agent one run later, would cost them two rounds to learn one thing. The listing is the
247
- // door's own (ADR-0069): the Orchestrator refuses a publish with the same lines, so the host walk
248
- // and the door's walk cannot drift.
249
- const open = openPartRefusals(workflows);
250
- if (open.length) {
251
- for (const line of open) activity(io, `refusing: ${line}`);
252
- return 1;
253
- }
254
- // Each Chart file is admitted HERE by the door's own check (ADR-0070), so a Chart file the
255
- // Orchestrator would refuse is refused at the same command as `tsc`'s errors, before any
256
- // cluster work, with the lines the publish would answer.
257
- const chartRefusals = chartFileRefusals(
258
- await loadCharts(root, (line) => activity(io, `warning: ${line}`)),
259
- workflows,
260
- );
261
- if (chartRefusals.length) {
262
- for (const line of chartRefusals) activity(io, `refusing: ${line}`);
263
- return 1;
264
- }
265
- // What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
266
- // the plain data it carries — a command's argv, its timeout, the Repo Slot it runs in; an http
267
- // Effect's method, its fixed target and the secrets it names — read, never run.
268
- for (const w of workflows) {
269
- const effects = partsOf([w.machine]).effects;
270
- if (!effects.length) continue;
271
- activity(io, `effects: workflow "${w.name}" — what it runs outside itself (ADR-0067)`);
272
- for (const e of effects) {
273
- const where = e.path?.length ? ` (on ${e.path.map((k) => `"${k}"`).join(" → ")})` : "";
274
- const slot = e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
275
- if (e.kind === "http") {
276
- const names = e.secrets.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
277
- // Its body decides where it runs (ADR-0067): a Worktree file needs its Workspace's Sandbox Effector.
278
- const where2 = e.file
279
- ? `in the Sandbox Effector, a Worktree file as its body${slot}`
280
- : "in the Instance Effector";
281
- activity(
282
- io,
283
- ` ${e.name}${where}: http ${e.method} ${e.target ? targetText({ target: e.target }) : "<open>"} — ${e.timeoutMs / 1000}s, ` +
284
- `${where2}${names}${e.idempotent ? ", idempotent" : ""}`,
285
- );
286
- continue;
287
- }
288
- if (e.kind === "push") {
289
- const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
290
- activity(
291
- io,
292
- ` ${e.name}${where}: push${opts.length ? ` (${opts.join(", ")})` : ""} — ${e.timeoutMs / 1000}s, ` +
293
- `in the Sandbox Effector${slot}`,
294
- );
295
- continue;
296
- }
297
- if (!("argv" in e)) continue;
298
- const names = e.secrets?.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
299
- const seat = e.secrets?.length ? `the Sandbox Effector${e.pin ? ", pinned" : ""}` : "the Harness";
300
- activity(
301
- io,
302
- ` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${slot}${names}`,
303
- );
304
- }
305
- }
306
- // An Effect that names a secret the `secrets` list does not hold, and a literal Effect secret too
307
- // short to scrub (ADR-0068), are refused HERE, off the walk and the config alone — before anything is
308
- // built or applied (ADR-0050, ADR-0068). A secret read from the user's Secret is placed below,
309
- // once the cluster is reached.
310
- // What names each secret, read off the walk (ADR-0059, ADR-0068): every Effect, every Workspace's
311
- // pod — its `workspace()`'s `secrets` and the models of the Agents whose Turns run there — and the
312
- // models of the `"none"` Agents the Instance Harness hosts.
313
- // An Open body that allows "none" needs the Instance Harness, holding a key for each model it
314
- // allows (ADR-0070), as a "none" Agent does. The door's walk says both, so `jr2 up` and the
315
- // publish door place the same set.
316
- const placementWalk = placementWalkOf(workflows);
317
- refuseEffectSecrets(config, placementWalk);
318
- // Who may send what, per state (ADR-0015): the audience rule's answer for each registered
319
- // Machine and every Machine it composes, as its doc carries it — so an author reads who holds a
320
- // pick before a run finds out. Then what the build found legal and suspect: a warning, never a
321
- // refusal (the refusals threw when the Machine loaded).
322
- for (const w of workflows) {
323
- const bodies = machineBodies(serializeMachine(w.name, w.machine));
324
- const seats = bodies.flatMap((body) => body.seats.map(seatLine));
325
- if (seats.length) {
326
- activity(io, `seats: workflow "${w.name}" — who may send what, per state (ADR-0015)`);
327
- for (const line of seats) activity(io, ` ${line}`);
328
- }
329
- for (const body of bodies) {
330
- for (const warning of body.warnings) activity(io, `warning: workflow "${w.name}": ${warning} (ADR-0015)`);
331
- }
332
- }
333
- // The Size (ADR-0060): every `workspace()` a registered Machine composes, resolved down its chain
334
- // (the Machine's, then `sandbox.resources`, then the kit's) and split inside the pod, exactly as
335
- // the provision will. A split that leaves the Harness below its floor is refused HERE, naming the
336
- // Machine and the number to change, rather than at the first provision of a run. The resolved
337
- // Sizes are kept for the Sandbox-node warning below.
338
- const sizes: Array<{ workflow: string; size: ResolvedSize }> = [];
339
- for (const w of workflows) {
340
- for (const { path, resources, user } of partsOf([w.machine]).sizes) {
341
- try {
342
- const size = resolveSize(resources, config.sandbox?.resources);
343
- splitSize(size, user);
344
- sizes.push({ workflow: w.name, size });
345
- } catch (e) {
346
- const where =
347
- path !== undefined && path.length
348
- ? ` (on the Machine composed as ${path.map((k) => `"${k}"`).join(" → ")})`
349
- : "";
350
- const fix =
351
- path === undefined
352
- ? ""
353
- : `; a composer retunes it where the Machine is registered: export const machine = ${sizeLine(path)}`;
354
- activity(io, `refusing: workflow "${w.name}"${where}: ${(e as Error).message}${fix}`);
355
- return 1;
356
- }
357
- }
358
- }
359
- const agentNames = [...new Set(agents.map((a) => a.name))].join(", ") || "(none)";
360
- activity(io, `agents: ${agentNames} (carried by ${workflows.length} workflow machine(s))`);
361
- // Each Chart wrapper (ADR-0070): what its Open body lends a Chart, how many models a Chart's own
362
- // Agents may name (each gets its key placed below), and the Chart files the Assembly carries for
363
- // it. The walk reads the Open body's declaration, so the keys and the Instance Harness a Chart
364
- // could need are placed before any run.
365
- const chartFiles = await chartFilesIn(root);
366
- for (const w of workflows) {
367
- const bodies = partsOf([w.machine]).openBodies.filter((b) => b.path !== undefined && b.path.length === 0);
368
- if (!bodies.length) continue;
369
- const lends = [
370
- ...new Set(
371
- bodies.flatMap(({ declaration: { lends } }) => [
372
- ...(lends.agents ?? []),
373
- ...(lends.effects ?? []),
374
- ...(lends.machines ?? []),
375
- ...(lends.guards ?? []),
376
- ...(lends.actions ?? []),
377
- ...(lends.delays ?? []),
378
- ]),
379
- ),
380
- ];
381
- const models = new Set(bodies.flatMap((b) => b.declaration.allows.models));
382
- const files = chartFiles.filter((n) => n.startsWith(`${w.name}/`)).length;
383
- activity(
384
- io,
385
- `chart wrapper "${w.name}": lends ${lends.join(", ") || "nothing"}; allows ${models.size} model(s); ${files} Chart file(s)`,
386
- );
387
- }
388
236
 
389
237
  // --- the Assembly (ADR-0069): staged here, published last ---------------------------------------
390
238
  // Staged BEFORE any cluster spend, so its refusals cost nothing: a second copy of a Kit package
@@ -686,6 +534,26 @@ export async function up(args: string[], io: Io): Promise<number> {
686
534
  const hostBuilt = async (ref: string): Promise<boolean> =>
687
535
  values.force !== true && (await hostHeld()).has(normalizeRef(ref));
688
536
 
537
+ // What this converge counts on finding on the host — its own build, or the host-held skip above —
538
+ // can be gone by the time it is used: another converge's sweep keeps only the roots of ITS context
539
+ // (ADR-0039), and a ref whose map is not written yet is in none of them. The tag is a content
540
+ // address and BuildKit's cache is a store no sweep touches, so a ref found missing at its use is
541
+ // built again (seconds) and the use retried, at most HOST_REBUILDS times. Only a singleton build
542
+ // lives on the daemon: a multi-platform one is in the registry (ADR-0045), where no sweep reaches.
543
+ const builders = new Map<string, () => Promise<void>>();
544
+ const onHost = async <T>(ref: string, use: () => Promise<T>): Promise<T> => {
545
+ for (let rebuilt = 0; ; rebuilt++) {
546
+ try {
547
+ return await use();
548
+ } catch (err) {
549
+ const rebuild = builders.get(ref);
550
+ if (!rebuild || platforms.length > 1 || rebuilt >= HOST_REBUILDS || !isMissingHostImage(err)) throw err;
551
+ activity(io, ` ${ref}: gone from the host daemon (another converge's sweep took it) — rebuilding`);
552
+ await rebuild();
553
+ }
554
+ }
555
+ };
556
+
689
557
  // The binfmt preflight (ADR-0045), paid at most once and only when a build is really about to be
690
558
  // spent: a steady-state converge must still spend no docker at all, and a foreign-arch build must
691
559
  // fail BEFORE minutes of docker rather than mid-`RUN` with `exec format error`.
@@ -706,15 +574,19 @@ export async function up(args: string[], io: Io): Promise<number> {
706
574
  return ref;
707
575
  }
708
576
  assertDeliverable();
577
+ const buildRef = async (): Promise<void> => {
578
+ await ensureEmulation();
579
+ await build.build(kitImageBuild(kitRoot, which, ref, platforms));
580
+ };
581
+ builders.set(ref, buildRef);
709
582
  if (await hostBuilt(ref)) {
710
583
  activity(io, `${which} image: ${ref} (host-built — build skipped, delivering; --force to rebuild)`);
711
- await deliver(ref);
584
+ await onHost(ref, () => deliver(ref));
712
585
  return ref;
713
586
  }
714
587
  activity(io, `${which} image: building ${ref}${values.force === true ? " (--force)" : ""}`);
715
- await ensureEmulation();
716
- await build.build(kitImageBuild(kitRoot, which, ref, platforms));
717
- await deliver(ref);
588
+ await buildRef();
589
+ await onHost(ref, () => deliver(ref));
718
590
  return ref;
719
591
  };
720
592
 
@@ -736,55 +608,8 @@ export async function up(args: string[], io: Io): Promise<number> {
736
608
  const operatorImage =
737
609
  config.operator?.manage === false && !carried.composesSandbox ? undefined : await ensureKitImage("operator");
738
610
  if (operatorImage !== undefined) converged.operator = operatorImage;
739
- if (config.operator?.manage === false) {
740
- activity(
741
- io,
742
- "operator: skipped (operator.manage: false — run the controller loop yourself)" +
743
- (operatorImage !== undefined ? `; its image ${operatorImage} still runs as the cache agent` : ""),
744
- );
745
- } else {
746
- const image = operatorImage!;
747
- const existing = await kube.getJson({
748
- kind: "deployment",
749
- name: OPERATOR_DEPLOYMENT,
750
- namespace: OPERATOR_NAMESPACE,
751
- ...ctx,
752
- });
753
- const deployed = existing?.metadata.labels?.[LABEL_VERSION];
754
- if (deployed && compareVersions(deployed, KIT_VERSION) > 0) {
755
- activity(io, `operator: leaving v${deployed} (newer than this kit's v${KIT_VERSION} — never downgraded)`);
756
- } else {
757
- activity(io, `operator: applying v${KIT_VERSION} (${image})${deployed ? ` over v${deployed}` : ""}`);
758
- await kube.apply({
759
- manifest: await operatorManifest(image, priorityClassNames(config.priorityClasses).control),
760
- ...ctx,
761
- });
762
- await kube.label({
763
- kind: "deployment",
764
- name: OPERATOR_DEPLOYMENT,
765
- namespace: OPERATOR_NAMESPACE,
766
- labels: { [LABEL_VERSION]: KIT_VERSION },
767
- ...ctx,
768
- });
769
- await awaitRollout(kube, {
770
- name: OPERATOR_DEPLOYMENT,
771
- namespace: OPERATOR_NAMESPACE,
772
- selector: OPERATOR_SELECTOR,
773
- ...ctx,
774
- });
775
- // Sound for every layer now that EVERY tag is a content address (ADR-0038): a source edit
776
- // moves the ref, which moves the pod template, which rolls. The old hole — kit dev pinning a
777
- // static `:local` tag, so a rebuild left the template identical and nothing rolled — closed
778
- // with the `images` block that created it.
779
- await verifyRunningImage(io, kube, {
780
- layer: "operator",
781
- namespace: OPERATOR_NAMESPACE,
782
- selector: OPERATOR_SELECTOR,
783
- image,
784
- ...ctx,
785
- });
786
- }
787
- }
611
+ // Applied after the provider preflight (below): the operator is the cluster's, and a converge
612
+ // whose provider check fails must not have changed it.
788
613
 
789
614
  // --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
790
615
  // Built here rather than lazily beside its consumers. The Harness is the injection source
@@ -818,6 +643,16 @@ export async function up(args: string[], io: Io): Promise<number> {
818
643
  const digest = contextDigests.get(image.dir)!;
819
644
  const key = image.reserved ?? digest;
820
645
  const ref = sandboxImageTag(name, image.name, digest, { platforms, registry });
646
+ const buildRef = async (): Promise<void> => {
647
+ // ONE build of the user's own Dockerfile, straight to its content tag — no wrap, no
648
+ // intermediate tag, so two converges of one checkout share no mutable image name and
649
+ // the `@kind` tier's `--parallel` rides on it (ADR-0037).
650
+ await ensureEmulation();
651
+ await buildSandboxImage(build, { dir: image.dir, tag: ref, instance: name, platforms });
652
+ };
653
+ // Known on every path, the record's skip included: the Effector check reads this ref off the
654
+ // host daemon later in this converge, whatever decided its build.
655
+ builders.set(ref, buildRef);
821
656
  // A record is only worth skipping on when it is COMPLETE: the ref AND the seat the image
822
657
  // declared. A record from a kit that predates `sandboxUser` names the right image and
823
658
  // silently drops the fallback fact, which surfaces as a pod that will not start — so it reads
@@ -838,11 +673,7 @@ export async function up(args: string[], io: Io): Promise<number> {
838
673
  activity(io, `sandbox image "${image.name}": ${ref} (host-built — build skipped; --force to rebuild)`);
839
674
  } else {
840
675
  activity(io, `sandbox image "${image.name}": building ${ref}`);
841
- // ONE build of the user's own Dockerfile, straight to its content tag — no wrap, no
842
- // intermediate tag, so two converges of one checkout share no mutable image name and
843
- // the `@kind` tier's `--parallel` rides on it (ADR-0037).
844
- await ensureEmulation();
845
- await buildSandboxImage(build, { dir: image.dir, tag: ref, instance: name, platforms });
676
+ await buildRef();
846
677
  }
847
678
  // The image's own `USER`, read while the image is certainly on this daemon (it was just
848
679
  // built, or the host holds it). Recorded because a provision cannot inspect an image, and
@@ -852,10 +683,10 @@ export async function up(args: string[], io: Io): Promise<number> {
852
683
  // (ADR-0005), which owes no floor — a distinction only the provision can make (ADR-0031).
853
684
  // `platforms` says where to look, not which variant to read (ADR-0045): a singleton build is
854
685
  // on this daemon, a multi-platform one is the manifest list buildx just pushed.
855
- const user = await build.imageUser(ref, platforms);
686
+ const user = await onHost(ref, () => build.imageUser(ref, platforms));
856
687
  converged.sandboxUser[key] = user;
857
688
  activity(io, ` ${user ? `USER ${user}` : "no USER declared — the pod's uid-1000 fallback applies"}`);
858
- await deliver(ref);
689
+ await onHost(ref, () => deliver(ref));
859
690
  }
860
691
  converged.sandbox[key] = ref;
861
692
  }
@@ -885,12 +716,10 @@ export async function up(args: string[], io: Io): Promise<number> {
885
716
  JR2_INSTANCE_TOKEN: instanceToken,
886
717
  JR2_PUBLISH_TOKEN: publishToken,
887
718
  JR2_SIGNING_KEY: signingKey,
888
- // The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
889
- // for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
890
- // Instance token never enters that pod. Derived from the kept key, so re-runs converge to
891
- // the same value; live Custodians keep verifying.
892
- JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
893
719
  };
720
+ // The Instance Harness Custodian's credentials are not here (ADR-0076): its control token and
721
+ // its secret Stand-ins ride its own Secret (`INSTANCE_HARNESS_CREDENTIALS`), whose Stand-ins its
722
+ // Harness container mounts — so it must never be this one.
894
723
  // Git tokens (ADR-0051, ADR-0068): each entry's token, from `.env`, goes into that entry's own clone
895
724
  // Secret (`jr2-git-<hash>`, Flux's key names), which the cache agent reads — never into this
896
725
  // Secret, the Orchestrator's env. The Orchestrator names the Secret on a Repo and holds no git token.
@@ -985,6 +814,89 @@ export async function up(args: string[], io: Io): Promise<number> {
985
814
  caBundle: caPem,
986
815
  });
987
816
 
817
+ // --- provider preflight (ADR-0019, ADR-0059): through a Custodian, FROM INSIDE the cluster -----
818
+ // BEFORE anything the Instance or the cluster reads is written — the operator, a deploy key, the
819
+ // trust, the held objects, the instance List: each check pod has its own copies of what it reads,
820
+ // from the values on this host. So a check that fails leaves the Instance, its Assembly and the
821
+ // operator as they were. Only the namespace, the PriorityClasses and the delivered images come
822
+ // first, because the check pod needs them.
823
+ const checkPods = checkPodsOf(config, placementWalk, held.objects.pods);
824
+ const custom = config.harness?.provider;
825
+ if (custom && !checkPods.some((p) => p.providers.some((v) => v.id === custom.id))) {
826
+ activity(
827
+ io,
828
+ `provider: ${custom.baseUrl} configured, but no carried Agent names a "${custom.id}/…" model — skipped`,
829
+ );
830
+ }
831
+ await preflightProviders(io, kube, {
832
+ checkPods,
833
+ skip: values["skip-provider-preflight"] === true,
834
+ name,
835
+ namespace,
836
+ ctx,
837
+ harnessImage: refs.harness,
838
+ custodianImage,
839
+ harness: config.harness,
840
+ caBundle: caPem !== undefined,
841
+ values: Object.assign({}, ...held.objects.pods.map((p) => p.values)) as Record<string, string>,
842
+ leaves: held.objects.leaves,
843
+ trust: trustData(caPem, held.bundles),
844
+ harnessEnvData,
845
+ priorityClassName: priorityClassNames(config.priorityClasses).sandbox,
846
+ signingKey: Buffer.from(signingKey, "base64"),
847
+ });
848
+
849
+ // --- operator (per-cluster, shared): after the provider preflight ------------------------------
850
+ if (config.operator?.manage === false) {
851
+ activity(
852
+ io,
853
+ "operator: skipped (operator.manage: false — run the controller loop yourself)" +
854
+ (operatorImage !== undefined ? `; its image ${operatorImage} still runs as the cache agent` : ""),
855
+ );
856
+ } else {
857
+ const image = operatorImage!;
858
+ const existing = await kube.getJson({
859
+ kind: "deployment",
860
+ name: OPERATOR_DEPLOYMENT,
861
+ namespace: OPERATOR_NAMESPACE,
862
+ ...ctx,
863
+ });
864
+ const deployed = existing?.metadata.labels?.[LABEL_VERSION];
865
+ if (deployed && compareVersions(deployed, KIT_VERSION) > 0) {
866
+ activity(io, `operator: leaving v${deployed} (newer than this kit's v${KIT_VERSION} — never downgraded)`);
867
+ } else {
868
+ activity(io, `operator: applying v${KIT_VERSION} (${image})${deployed ? ` over v${deployed}` : ""}`);
869
+ await kube.apply({
870
+ manifest: await operatorManifest(image, priorityClassNames(config.priorityClasses).control),
871
+ ...ctx,
872
+ });
873
+ await kube.label({
874
+ kind: "deployment",
875
+ name: OPERATOR_DEPLOYMENT,
876
+ namespace: OPERATOR_NAMESPACE,
877
+ labels: { [LABEL_VERSION]: KIT_VERSION },
878
+ ...ctx,
879
+ });
880
+ await awaitRollout(kube, {
881
+ name: OPERATOR_DEPLOYMENT,
882
+ namespace: OPERATOR_NAMESPACE,
883
+ selector: OPERATOR_SELECTOR,
884
+ ...ctx,
885
+ });
886
+ // Sound for every layer now that EVERY tag is a content address (ADR-0038): a source edit
887
+ // moves the ref, which moves the pod template, which rolls. The old hole — kit dev pinning a
888
+ // static `:local` tag, so a rebuild left the template identical and nothing rolled — closed
889
+ // with the `images` block that created it.
890
+ await verifyRunningImage(io, kube, {
891
+ layer: "operator",
892
+ namespace: OPERATOR_NAMESPACE,
893
+ selector: OPERATOR_SELECTOR,
894
+ image,
895
+ ...ctx,
896
+ });
897
+ }
898
+ }
899
+
988
900
  // --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
989
901
  // Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
990
902
  // notice it hands back is owed to the END of the converge — a generated key is dead until a human
@@ -994,22 +906,27 @@ export async function up(args: string[], io: Io): Promise<number> {
994
906
  // --- what each Sandbox Effector's image owes its pushes (ADR-0068) ------------------------
995
907
  // Before anything is applied: an image that lacks a push form's tools, or the passwd entry ssh
996
908
  // needs, is refused here with the fix, not at the first push in a pod.
997
- await checkEffectorImages(build, config, placements.sandbox, converged, platforms, contextDigests);
909
+ await checkEffectorImages(
910
+ (ref, paths) => onHost(ref, () => build.imageFiles(ref, platforms, paths)),
911
+ config,
912
+ placements.sandbox,
913
+ converged,
914
+ contextDigests,
915
+ );
998
916
 
999
917
  // --- push credentials (ADR-0051): one Secret per git.credentials entry, never per pod ----
1000
918
  // After the deploy keys exist (a generated one included), before anything else is applied: a
1001
919
  // static Binding whose entry cannot push is refused here.
1002
920
  const gitPush = await resolveGitPush(io, kube, config, placements.sandbox, namespace, ctx);
1003
921
 
1004
- // --- provider preflight (ADR-0019): probe the endpoint FROM INSIDE the cluster ----------------
1005
- await preflightProvider(io, kube, config, carried.models, namespace, ctx, caPem);
922
+ // --- dispatch preflight (ADR-0071): each foreign target's door, from the host ------------------
923
+ await preflightDispatch(io, carried.effects);
1006
924
 
1007
925
  // --- apply + rollout ---------------------------------------------------------------------------
1008
926
  // The trust bundles first, and server-side: every pod that mounts them must find them, and they
1009
927
  // carry every root certificate — past what client-side apply's annotation can hold.
1010
928
  const trust = trustObject({ name, namespace, caBundle: caPem, bundles: held.bundles });
1011
929
  if (trust) await kube.apply({ manifest: trust, serverSide: true, ...ctx });
1012
- activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
1013
930
  const instanceList = instanceObjects({
1014
931
  name,
1015
932
  namespace,
@@ -1025,7 +942,8 @@ export async function up(args: string[], io: Io): Promise<number> {
1025
942
  gitPush,
1026
943
  gitClone,
1027
944
  });
1028
- await kube.apply({ manifest: instanceList, ...ctx });
945
+ activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
946
+ await kube.apply({ manifest: instanceList, warn: (line) => activity(io, line), ...ctx });
1029
947
  // An entry whose clone Secret this converge did not write loses it: a dropped entry, a token
1030
948
  // unset, or nothing left that clones.
1031
949
  const cloneSecrets = new Set(gitClone.map((s) => s.name));
@@ -1074,6 +992,7 @@ export async function up(args: string[], io: Io): Promise<number> {
1074
992
  if (!holdsAny(held.objects)) {
1075
993
  await kube.deleteObject({ kind: "secret", name: HELD_TLS_SECRET, namespace, ...ctx });
1076
994
  }
995
+
1077
996
  activity(io, "orchestrator: waiting for rollout");
1078
997
  await awaitRollout(kube, {
1079
998
  name: ORCHESTRATOR_SERVICE,
@@ -1097,14 +1016,17 @@ export async function up(args: string[], io: Io): Promise<number> {
1097
1016
  // over from a Machine that dropped its `workspace()` is deleted with its RBAC: the layer
1098
1017
  // converges toward the Machines like the Instance Harness does.
1099
1018
  if (carried.composesSandbox) {
1019
+ // The agent is also the Landlock detector (ADR-0077): on a Sandbox node without Landlock it
1020
+ // exits at start with the reason, and the converge refuses, naming each such node.
1100
1021
  activity(io, "repo cache: waiting for the cache agent on every Sandbox node");
1101
- await awaitRollout(kube, {
1022
+ const cacheTarget: RolloutTarget = {
1102
1023
  kind: "daemonset",
1103
1024
  name: REPO_CACHE,
1104
1025
  namespace,
1105
1026
  selector: `app=${REPO_CACHE}`,
1106
1027
  ...ctx,
1107
- });
1028
+ };
1029
+ await awaitCacheAgent(kube, cacheTarget, () => awaitRollout(kube, cacheTarget));
1108
1030
  await verifyRunningImage(io, kube, {
1109
1031
  layer: "repo cache",
1110
1032
  namespace,
@@ -1160,6 +1082,13 @@ export async function up(args: string[], io: Io): Promise<number> {
1160
1082
  held: instance.manifest,
1161
1083
  custodian: instance.custodian,
1162
1084
  heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects, instance, valueDigest),
1085
+ // Its control token and secret Stand-ins (ADR-0076, ADR-0059): minted here, as the pod's
1086
+ // maker, from the kept key — so a re-run converges to the same values.
1087
+ credentials: podCredentials(Buffer.from(signingKey, "base64"), {
1088
+ placement: INSTANCE_HARNESS_SERVICE,
1089
+ heldPod: INSTANCE_HARNESS_HELD_POD,
1090
+ manifest: instance.manifest,
1091
+ }),
1163
1092
  harness: config.harness,
1164
1093
  caBundle: caPem !== undefined,
1165
1094
  // The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
@@ -1194,8 +1123,10 @@ export async function up(args: string[], io: Io): Promise<number> {
1194
1123
  await kube.deleteObject({ kind: "persistentvolumeclaim", name: INSTANCE_HARNESS_PVC, namespace, ...ctx });
1195
1124
  await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_SERVICE, namespace, ...ctx });
1196
1125
  await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_PODS_SERVICE, namespace, ...ctx });
1197
- // Its Custodian's files go with the pod they were rendered for (ADR-0059).
1126
+ // Its Custodian's files and its credentials go with the pod they were made for (ADR-0059,
1127
+ // ADR-0076).
1198
1128
  await kube.deleteObject({ kind: "configmap", name: INSTANCE_CUSTODIAN_CONFIGMAP, namespace, ...ctx });
1129
+ await kube.deleteObject({ kind: "secret", name: INSTANCE_HARNESS_CREDENTIALS, namespace, ...ctx });
1199
1130
  }
1200
1131
 
1201
1132
  // --- Instance Effector (ADR-0068): converged when an Effect is placed there, absent otherwise ---
@@ -1223,6 +1154,7 @@ export async function up(args: string[], io: Io): Promise<number> {
1223
1154
  caBundle: caPem !== undefined,
1224
1155
  ...(config.priorityClasses ? { priorityClasses: config.priorityClasses } : {}),
1225
1156
  }),
1157
+ warn: (line) => activity(io, line),
1226
1158
  ...ctx,
1227
1159
  });
1228
1160
  await awaitRollout(kube, {
@@ -1561,6 +1493,8 @@ async function resolveHeld(
1561
1493
  models: opts.models,
1562
1494
  });
1563
1495
  for (const warning of warnings) activity(io, `warning: ${warning}`);
1496
+ const r14 = envFromHeldRefusal(config, manifest, opts.envFromKeys);
1497
+ if (r14) throw new Error(r14);
1564
1498
  // Each pod's own: the bindings of the names it holds and their literal values. Its Custodian's
1565
1499
  // files are rendered by whoever makes the pod — here for the Instance Harness alone; the
1566
1500
  // Orchestrator at provision for a Sandbox, from the same manifest (ADR-0059).
@@ -1577,7 +1511,7 @@ async function resolveHeld(
1577
1511
  const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
1578
1512
  const instance: CustodianFiles | undefined = instancePod && {
1579
1513
  bootstrap: JSON.stringify(custodianBootstrap(instancePod.manifest, orchestrator)),
1580
- script: custodianScript(instancePod.manifest),
1514
+ script: custodianScript(instancePod.manifest, { fetch: false }),
1581
1515
  };
1582
1516
  if (manifest.secrets.length === 0) return { objects: { pods, ...(instance && { instance }), leaves: {} } };
1583
1517
  for (const s of manifest.secrets) {
@@ -1612,6 +1546,36 @@ async function resolveHeld(
1612
1546
  };
1613
1547
  }
1614
1548
 
1549
+ /**
1550
+ * R14 (ADR-0059): a held secret's name that is also a key of a Secret `harness.envFrom` loads. The
1551
+ * Stand-ins are files, not env (ADR-0076), so no Stand-in hides that key: the Secret's real value
1552
+ * would reach the Harness container's env under the held secret's own name, and the Harness uses a
1553
+ * held secret but never holds it. The key is compared as the kubelet writes it, `prefix` first.
1554
+ * The message names the Secret, the key and the held secret, and never a value. Exported for its
1555
+ * test.
1556
+ */
1557
+ export function envFromHeldRefusal(
1558
+ config: JR2Config,
1559
+ manifest: HeldManifest,
1560
+ envFromKeys: ReadonlyMap<string, readonly string[]>,
1561
+ ): string | undefined {
1562
+ const held = new Set(manifest.secrets.map((s) => s.name));
1563
+ for (const [i, ref] of (config.harness?.envFrom ?? []).entries()) {
1564
+ const secret = ref.secretRef?.name;
1565
+ if (secret === undefined) continue;
1566
+ for (const key of envFromKeys.get(`secret/${secret}`) ?? []) {
1567
+ const name = (ref.prefix ?? "") + key;
1568
+ if (!held.has(name)) continue;
1569
+ return (
1570
+ `harness.envFrom[${i}] loads Secret "${secret}", whose key "${key}" is the env var ${name} — and ${name} is ` +
1571
+ `a held secret. Its Stand-in is a file, so nothing hides that key, and its value would reach the ` +
1572
+ `Harness's env. Drop the key from Secret "${secret}", or rename the held secret (ADR-0059, R14)`
1573
+ );
1574
+ }
1575
+ }
1576
+ return undefined;
1577
+ }
1578
+
1615
1579
  /** The Instance Harness's held objects and its Custodian's files — the walk always places it when
1616
1580
  * it converges. */
1617
1581
  function instanceHeld(held: HeldObjects): HeldPodObjects & { custodian: CustodianFiles } {
@@ -1815,6 +1779,8 @@ async function resolveGitPush(
1815
1779
  const data: Record<string, string> = {};
1816
1780
  let sshMissing: string | undefined;
1817
1781
  let signMissing: string | undefined;
1782
+ // One warning per entry and cause: an absent Secret is missing every key it was to give.
1783
+ const warned = new Set<string>();
1818
1784
  for (const source of plan.sources) {
1819
1785
  if ("literal" in source) {
1820
1786
  data[source.key] = Buffer.from(source.literal).toString("base64");
@@ -1833,6 +1799,8 @@ async function resolveGitPush(
1833
1799
  : `the Secret "${source.from.secret}" holds no key ${source.from.key}`;
1834
1800
  if (source.key.startsWith("sign.")) signMissing ??= why;
1835
1801
  else sshMissing ??= why;
1802
+ if (warned.has(why)) continue;
1803
+ warned.add(why);
1836
1804
  // Not a refusal: the Secret may be written later (a scenario writes it, a human creates it).
1837
1805
  // A push under the entry that needs it is refused at provision until `jr2 up` runs again.
1838
1806
  activity(
@@ -1896,11 +1864,11 @@ async function resolveGitPush(
1896
1864
  * is not read, as its `USER` is not.
1897
1865
  */
1898
1866
  async function checkEffectorImages(
1899
- build: BuildPort,
1867
+ /** {@link BuildPort.imageFiles} for a ref this converge resolved — rebuilt if a sweep took it. */
1868
+ imageFiles: (ref: string, paths: readonly string[]) => Promise<ImageFiles>,
1900
1869
  config: JR2Config,
1901
1870
  sandbox: readonly SandboxEffectorPlan[],
1902
1871
  converged: ConvergedImages,
1903
- platforms: readonly string[],
1904
1872
  /** Each `file:` context's key in the map: the digest of its staged copy (ADR-0069). */
1905
1873
  contextDigests: ReadonlyMap<string, string>,
1906
1874
  ): Promise<void> {
@@ -1940,7 +1908,7 @@ async function checkEffectorImages(
1940
1908
  if (refusal) throw new Error(refusal);
1941
1909
  continue;
1942
1910
  }
1943
- const files = await build.imageFiles(ref, platforms, effectorImagePaths(forms.map((f) => f.form)));
1911
+ const files = await imageFiles(ref, effectorImagePaths(forms.map((f) => f.form)));
1944
1912
  const refusal = effectorImageRefusal({
1945
1913
  label: plan.label,
1946
1914
  ref,
@@ -2022,7 +1990,7 @@ async function ensureGitSsh(
2022
1990
 
2023
1991
  if (source.kind === "generate") {
2024
1992
  const { privateKey, publicKey } = await (io.sshKeygen ?? sshKeygen)();
2025
- await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
1993
+ await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
2026
1994
  activity(io, `generated deploy key ${sshFingerprint(publicKey)} — register this PUBLIC key (read access):`);
2027
1995
  activity(io, ` ${publicKey.trim()}`);
2028
1996
  // The pause sits exactly where the user must act anyway (ADR-0047): the printed key is useless
@@ -2053,7 +2021,7 @@ async function ensureGitSsh(
2053
2021
  } catch (err) {
2054
2022
  throw new Error(`refusing the key from ${where}: ${err instanceof Error ? err.message : err}`);
2055
2023
  }
2056
- await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
2024
+ await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
2057
2025
  // The FINGERPRINT, never the material — not the private half, and not the public one either:
2058
2026
  // this key is the user's own, and its public half identifies them wherever it is registered.
2059
2027
  activity(io, `git ssh: "${secret}" applied from ${where} — ${sshFingerprint(publicKey)}`);
@@ -2151,6 +2119,7 @@ async function readSuppliedKey(path: string): Promise<string> {
2151
2119
  * `identity.pub` rides along so the cluster can say which key it holds without holding it up to a
2152
2120
  * human. Trailing newline enforced — ssh refuses a key file that lacks one. */
2153
2121
  async function applyGitSshSecret(
2122
+ io: Io,
2154
2123
  kube: KubeAdmin,
2155
2124
  namespace: string,
2156
2125
  ctx: { context?: string },
@@ -2166,6 +2135,7 @@ async function applyGitSshSecret(
2166
2135
  type: "Opaque",
2167
2136
  stringData: { identity: newlineTerminated(privateKey), "identity.pub": newlineTerminated(publicKey) },
2168
2137
  }),
2138
+ warn: (line) => activity(io, line),
2169
2139
  ...ctx,
2170
2140
  });
2171
2141
  }
@@ -2272,92 +2242,671 @@ async function readCaBundle(root: string, config: JR2Config): Promise<string | u
2272
2242
  }
2273
2243
 
2274
2244
  /**
2275
- * The custom-provider preflight (ADR-0019): from a pod, list models AND demand one trivial
2276
- * tool-call completion — so an unreachable endpoint (localhost never works from a pod) or a vLLM
2277
- * missing `--enable-auto-tool-choice` fails HERE, at converge time, not as an `agent.fault`
2278
- * mid-run. Reachability itself stays the user's concern; this makes the answer immediate.
2245
+ * The foreign dispatch preflight (ADR-0071): for each dispatch whose target is another
2246
+ * Orchestrator, `GET <origin>/workflows/<name>`, and the served door compared with the slot's
2247
+ * input schema (`doorCompat`, one direction). The route is open band (ADR-0014), so no token is
2248
+ * sent: the token is the Effector's to hold, and the host would otherwise carry it over a wire the
2249
+ * target may not secure. Every failure — no answer, a miss, a mismatch, a schema not compared — is a
2250
+ * WARNING, never a refusal: the other Instance may be down or publish later, and refusing here
2251
+ * would couple two Instances' publish order. A run of the dispatch answers what the door then says,
2252
+ * the token's refusal included.
2279
2253
  */
2280
- async function preflightProvider(
2254
+ async function preflightDispatch(io: Io, effects: readonly CarriedEffect[]): Promise<void> {
2255
+ const fetchImpl = io.fetch ?? globalThis.fetch;
2256
+ const seen = new Set<string>();
2257
+ for (const e of effects) {
2258
+ if (e.kind !== "dispatch" || e.target === undefined) continue;
2259
+ const url = targetText({ target: e.target });
2260
+ const once = JSON.stringify([url, e.workflow, e.secrets, e.input ?? null]);
2261
+ if (seen.has(once)) continue;
2262
+ seen.add(once);
2263
+ const warn = (why: string) => activity(io, `warning: dispatch "${e.workflow}" at ${url}: ${why} (ADR-0071)`);
2264
+ let res: Response;
2265
+ try {
2266
+ // No redirect is followed: the Effector's dispatch client follows none, so a door that
2267
+ // redirects is one it never reaches.
2268
+ res = await fetchImpl(`${url}/workflows/${encodeURIComponent(e.workflow)}`, {
2269
+ headers: { accept: "application/json" },
2270
+ redirect: "manual",
2271
+ signal: AbortSignal.timeout(10_000),
2272
+ });
2273
+ } catch (err) {
2274
+ warn(
2275
+ `no answer from this host (${(err as Error).message}) — the other Instance may be down, or reached only ` +
2276
+ 'from inside its cluster; while no door answers, the dispatch settles "unreachable"',
2277
+ );
2278
+ continue;
2279
+ }
2280
+ if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
2281
+ warn(
2282
+ `the door redirected (HTTP ${res.status}); the Effector follows no redirect, so a run of the dispatch is refused there`,
2283
+ );
2284
+ continue;
2285
+ }
2286
+ if (res.status === 404) {
2287
+ warn(`no Workflow "${e.workflow}" there — a run of the dispatch is refused with the door's 404 until it has one`);
2288
+ continue;
2289
+ }
2290
+ if (!res.ok) {
2291
+ warn(`the door answered HTTP ${res.status}`);
2292
+ continue;
2293
+ }
2294
+ if (e.input === undefined) {
2295
+ activity(io, `dispatch "${e.workflow}" at ${url}: the door answers; the slot states no input schema to compare`);
2296
+ continue;
2297
+ }
2298
+ // A Workflow detail carries its door as `input` (null: it declares none). Any other answer — a
2299
+ // page an ingress serves, another shape at another Kit version — is no door: not compared,
2300
+ // never compared as `{}`, which would call every slot field stripped.
2301
+ const detail: unknown = await res.json().catch(() => undefined);
2302
+ if (typeof detail !== "object" || detail === null || Array.isArray(detail) || !Object.hasOwn(detail, "input")) {
2303
+ warn("the door's answer is not a Workflow detail (no `input`); not compared");
2304
+ continue;
2305
+ }
2306
+ const compared = doorCompat(e.input, (detail as { input: unknown }).input);
2307
+ if (compared.ok === true) {
2308
+ activity(io, `dispatch "${e.workflow}" at ${url}: the door takes the slot's input (ADR-0071)`);
2309
+ } else if (compared.ok === "not-compared") {
2310
+ warn(`its input schema and the door are not compared — ${compared.why}`);
2311
+ } else {
2312
+ for (const fault of compared.faults) warn(`the door does not take the slot's input: ${fault}`);
2313
+ }
2314
+ }
2315
+ }
2316
+
2317
+ /** One provider of one check pod (ADR-0019, ADR-0059). */
2318
+ export type CheckedProvider = {
2319
+ /** The provider id, the specifier's part before the first `/`. */
2320
+ id: string;
2321
+ /** Every carried model of it that a Harness pod of this placement runs: a typo, a retired model
2322
+ * or a model the key has no access to is per model. */
2323
+ models: string[];
2324
+ /** The held secrets that are its keys in the check pod's Custodian; none when no pod of this
2325
+ * placement holds one, and the check then shows what a Turn there meets. */
2326
+ keys: string[];
2327
+ /** Where the calls go: the keys' bound hosts, else the provider's host as jr2 knows it. */
2328
+ hosts: string[];
2329
+ /** `harness.catalog.<id>.baseUrl`, when it moves the provider. */
2330
+ gateway?: string;
2331
+ /** The custom provider (`harness.provider`, ADR-0018): its key is optional, and its causes are
2332
+ * its own. */
2333
+ custom?: true;
2334
+ };
2335
+
2336
+ /** One check pod: the providers that the Harness pods of one placement call. */
2337
+ export type CheckPod = {
2338
+ /** For a human: `the Sandbox nodes`, `the Instance Harness`, or both. */
2339
+ where: string;
2340
+ /** The node predicate of those pods (ADR-0052); none for the Instance Harness. */
2341
+ placement?: SandboxPlacement;
2342
+ /** The bindings of the checked keys: each a part of a held pod's manifest. */
2343
+ manifest: HeldManifest;
2344
+ providers: CheckedProvider[];
2345
+ };
2346
+
2347
+ /** One Harness pod as the walk places it: its held pod key, its class, the models it runs, and
2348
+ * the bindings it holds (none: its HTTPS goes direct, with no proxy). */
2349
+ type Seat = { pod: string; sandbox: boolean; models: readonly string[]; held: HeldManifest["secrets"] };
2350
+
2351
+ /**
2352
+ * Which providers the preflight checks, and in which pod (ADR-0019, ADR-0059). Every carried model
2353
+ * that a Harness pod runs is checked, in a pod with that pod's placement: a Workspace's model on the
2354
+ * Sandbox nodes, a `"none"` Agent's where the Instance Harness runs, so a node class whose egress is
2355
+ * closed fails here. The two are one check pod when their placements are the same. The custom provider
2356
+ * (`harness.provider`) goes the same way: its key is a held secret too (`JR2_PROVIDER_API_KEY`), so
2357
+ * the key never enters a pod spec, an argv or a script.
2358
+ *
2359
+ * In one placement, a provider's keys are the ones the first pod there that runs one of its models
2360
+ * holds (`MODEL_KEYS`, claimed by the provider). The check pod holds that pod's bindings of them and
2361
+ * reads their values where that pod reads them. A provider no pod there holds a key for is checked
2362
+ * all the same, with no key: a Turn there meets the same, and pi says so (`not configured`); a
2363
+ * signing credential in `harness.env` reaches the check as it reaches a Turn. Pure, for its test.
2364
+ */
2365
+ export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readonly HeldPodObjects[]): CheckPod[] {
2366
+ const custom = config.harness?.provider;
2367
+ const heldOf = (pod: string) => pods.find((p) => p.pod === pod)?.manifest.secrets ?? [];
2368
+ const seats: Seat[] = [
2369
+ ...walk.workspaces.map((ws) => {
2370
+ const pod = workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route });
2371
+ return { pod, sandbox: true, models: ws.pod.models, held: heldOf(pod) };
2372
+ }),
2373
+ ...(walk.instanceHarness
2374
+ ? [
2375
+ {
2376
+ pod: INSTANCE_HARNESS_HELD_POD,
2377
+ sandbox: false,
2378
+ models: walk.instanceHarness.models,
2379
+ held: heldOf(INSTANCE_HARNESS_HELD_POD),
2380
+ },
2381
+ ]
2382
+ : []),
2383
+ ];
2384
+ const sandbox = config.sandbox?.nodeSelector || config.sandbox?.tolerations?.length ? config.sandbox : undefined;
2385
+ const placementOf = (seat: Seat) => (seat.sandbox ? sandbox : undefined);
2386
+ // A check pod stands for one class of Harness pod: its nodes, and whether it holds a secret. A pod
2387
+ // that holds one sends its HTTPS through its Custodian (a tunnel for an unbound host); a pod that
2388
+ // holds none sends it direct. So the two are two check pods, even on the same nodes.
2389
+ const keyOf = (seat: Seat) => {
2390
+ const placement = placementOf(seat);
2391
+ return JSON.stringify({
2392
+ nodeSelector: placement?.nodeSelector ?? {},
2393
+ tolerations: placement?.tolerations ?? [],
2394
+ holds: seat.held.length > 0,
2395
+ });
2396
+ };
2397
+ const groups = new Map<string, { seats: Seat[]; placement?: SandboxPlacement }>();
2398
+ for (const seat of seats) {
2399
+ const key = keyOf(seat);
2400
+ const group = groups.get(key) ?? { seats: [], ...(placementOf(seat) ? { placement: placementOf(seat) } : {}) };
2401
+ group.seats.push(seat);
2402
+ groups.set(key, group);
2403
+ }
2404
+ const checkPods: CheckPod[] = [];
2405
+ for (const group of groups.values()) {
2406
+ const models: string[] = [];
2407
+ for (const seat of group.seats) for (const m of seat.models) if (!models.includes(m)) models.push(m);
2408
+ const checkPod: CheckPod = {
2409
+ where: [
2410
+ ...(group.seats.some((s) => s.sandbox) ? ["the Sandbox nodes"] : []),
2411
+ ...(group.seats.some((s) => !s.sandbox) ? ["the Instance Harness"] : []),
2412
+ ].join(" and "),
2413
+ ...(group.placement ? { placement: group.placement } : {}),
2414
+ // A class that holds a secret: the first pod's own held set, so the check pod's env (the proxy, the
2415
+ // Stand-ins) is that of a pod of the class; the keys the class's other pods hold join it below.
2416
+ manifest: { version: 1, secrets: [...group.seats[0]!.held] },
2417
+ providers: [],
2418
+ };
2419
+ for (const model of models) {
2420
+ const slash = model.indexOf("/");
2421
+ const id = slash > 0 ? model.slice(0, slash) : model;
2422
+ const known = checkPod.providers.find((p) => p.id === id);
2423
+ if (known) {
2424
+ known.models.push(model);
2425
+ continue;
2426
+ }
2427
+ const names =
2428
+ id === custom?.id
2429
+ ? [PROVIDER_KEY_NAME]
2430
+ : Object.keys(MODEL_KEYS).filter((name) => MODEL_KEYS[name]!.providers.includes(id));
2431
+ const runs = group.seats.filter((s) => s.models.some((m) => m.startsWith(`${id}/`)));
2432
+ const holder = runs
2433
+ .map((s) => pods.find((p) => p.pod === s.pod))
2434
+ .find((p) => p?.manifest.secrets.some((b) => names.includes(b.name)));
2435
+ const bindings = holder?.manifest.secrets.filter((b) => names.includes(b.name)) ?? [];
2436
+ for (const b of bindings) {
2437
+ if (checkPod.manifest.secrets.some((h) => h.name === b.name)) continue;
2438
+ checkPod.manifest.secrets.push(b);
2439
+ }
2440
+ const gateway = config.harness?.catalog?.[id]?.baseUrl;
2441
+ const fallback =
2442
+ id === custom?.id
2443
+ ? [new URL(custom.baseUrl).host]
2444
+ : gateway
2445
+ ? [new URL(gateway).host]
2446
+ : names.flatMap((n) => (MODEL_KEYS[n]!.host ? [MODEL_KEYS[n]!.host!] : [])).slice(0, 1);
2447
+ checkPod.providers.push({
2448
+ id,
2449
+ ...(id === custom?.id ? { custom: true } : {}),
2450
+ models: [model],
2451
+ keys: bindings.map((b) => b.name),
2452
+ hosts: bindings.length ? [...new Set(bindings.flatMap((b) => b.hosts.map(hostText)))] : fallback,
2453
+ ...(gateway ? { gateway } : {}),
2454
+ });
2455
+ }
2456
+ if (checkPod.providers.length) checkPods.push(checkPod);
2457
+ }
2458
+ return checkPods;
2459
+ }
2460
+
2461
+ const hostText = (h: { host: string; port: number }): string => (h.port === 443 ? h.host : `${h.host}:${h.port}`);
2462
+
2463
+ /** How long a check pod may take to start: two cold image pulls (the Harness and the Custodian) and
2464
+ * Envoy's start. The check's own bound starts when its container runs. */
2465
+ const CHECK_START_SECONDS = 300;
2466
+ /** Each model's bound in the pod: its own budget in the check (`PREFLIGHT_MODEL_BUDGET_SECONDS` in
2467
+ * `@jr2/harness`'s `preflight-line.ts`, which a test holds this to), so one slow model never uses
2468
+ * the time of the others. Exported for that test. */
2469
+ export const CHECK_MODEL_SECONDS = 360;
2470
+ const CHECK_RUN_MARGIN_SECONDS = 30;
2471
+ /** A leftover a run left with no pod, older than this, is a dead run's, not a live one's. */
2472
+ const CHECK_LEFTOVER_SECONDS = 3600;
2473
+
2474
+ /**
2475
+ * The provider preflight (ADR-0019, ADR-0059): each check pod of `checkPodsOf`, one at a time — a
2476
+ * one-shot Harness-shaped pod, a Custodian that holds the providers' keys and the stock Harness
2477
+ * image's `provider-preflight.ts` — that demands one tool call of each model, on the path a Turn takes. So no
2478
+ * egress to the provider's host, a missing, wrong or empty key, a model no endpoint serves, and a
2479
+ * gateway that does not answer each fail HERE. Every check pod runs, a pod that cannot be applied
2480
+ * included, and every failure is in one error. A provider still busy after the retries, and a
2481
+ * model that answered but did not call a tool it was not forced to, are warnings (ADR-0064).
2482
+ *
2483
+ * Each pod reads its own objects (`checkPodObjects`), written from `values`, `leaves`, `trust`
2484
+ * and `harnessEnvData` — what the apply would write — and named for this run (`<run>-<n>`), so two
2485
+ * converges in one namespace never read or delete each other's. The ones of an earlier run of this
2486
+ * Instance that did not end go first (`removeCheckLeftovers`). A failed cleanup is a warning and
2487
+ * never hides the check's own answer.
2488
+ */
2489
+ async function preflightProviders(
2281
2490
  io: Io,
2282
2491
  kube: KubeAdmin,
2283
- config: JR2Config,
2284
- carriedModels: readonly string[],
2285
- namespace: string,
2286
- ctx: { context?: string },
2287
- caPem?: string,
2492
+ opts: {
2493
+ checkPods: CheckPod[];
2494
+ /** `--skip-provider-preflight`: no check pod runs, and one warning line says so. */
2495
+ skip: boolean;
2496
+ name: string;
2497
+ namespace: string;
2498
+ ctx: { context?: string };
2499
+ harnessImage: string;
2500
+ custodianImage: string;
2501
+ harness?: JR2Config["harness"];
2502
+ caBundle: boolean;
2503
+ values: Record<string, string>;
2504
+ leaves: Record<string, Pem>;
2505
+ trust: Record<string, string>;
2506
+ harnessEnvData: Record<string, string>;
2507
+ priorityClassName: string;
2508
+ /** The signing key, for each check pod's own Stand-ins (ADR-0019, ADR-0059). */
2509
+ signingKey: Buffer;
2510
+ },
2288
2511
  ): Promise<void> {
2289
- const provider = config.harness?.provider;
2290
- if (!provider) return;
2291
- // The DEFINITIONS name the models (ADR-0018) and the Machines carry the definitions (ADR-0049),
2292
- // so probe the ones that will actually run, not one instance-wide default.
2293
- // "vllm/Qwen/Qwen3-32B" → the endpoint's model id is everything after the provider prefix; a
2294
- // definition on a different provider is not this endpoint's business. A workflow's per-turn dial cannot be probed here — invoke `input` is a
2295
- // function, so it is not statically recoverable; it is checked at admission instead.
2296
- // An Open model is nobody's model yet (ADR-0054), so there is nothing to probe for it. The walk
2297
- // already refused the converge above, so this filter is the type's, not the flow's.
2298
- const prefix = `${provider.id}/`;
2299
- // An Open body's allowed models are probed too (ADR-0070): a Chart's own Agent may name one.
2300
- const models = [...new Set(carriedModels.filter((m) => m.startsWith(prefix)).map((m) => m.slice(prefix.length)))];
2301
- if (models.length === 0) {
2512
+ const { namespace, ctx } = opts;
2513
+ // First, and with no check to run too: the leftovers of an earlier run can hold a key.
2514
+ await removeCheckLeftovers(io, kube, opts.name, namespace, ctx);
2515
+ if (opts.skip) {
2516
+ // Never silent: a converge that checked nothing must not read as one that checked. A model that
2517
+ // two check pods would check is named once.
2518
+ const models = [...new Set(opts.checkPods.flatMap((p) => p.providers.flatMap((v) => v.models)))];
2302
2519
  activity(
2303
2520
  io,
2304
- `provider: ${provider.baseUrl} configured, but no carried Agent names a "${provider.id}/…" model — skipped`,
2521
+ "warning: provider: preflight skipped (--skip-provider-preflight) — " +
2522
+ (models.length ? `these models were not checked: ${models.join(", ")}` : "no model was checked"),
2305
2523
  );
2306
2524
  return;
2307
2525
  }
2308
- for (const model of models) {
2309
- activity(io, `provider: probing ${provider.baseUrl} from inside the cluster (model ${model})`);
2310
- const script = providerProbeScript(provider.baseUrl, model, provider.apiKey);
2526
+ if (!opts.checkPods.length) return;
2527
+ const run = randomBytes(4).toString("hex");
2528
+ const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
2529
+ const faults: string[] = [];
2530
+ for (const [n, checkPod] of opts.checkPods.entries()) {
2531
+ const object = `${PROVIDER_PREFLIGHT}-${run}-${n}`;
2532
+ const models = checkPod.providers.flatMap((p) => p.models);
2533
+ /** The models whose result is already said, while the pod ran, and what it was: a final log
2534
+ * read that comes back empty must not turn a result already said into "not checked". */
2535
+ const said = new Set<string>();
2536
+ const live = new Map<string, CheckVerdict>();
2537
+ let lastLive = "";
2538
+ for (const p of checkPod.providers) {
2539
+ // No held key is not no credential: a credential that pi reads from `harness.env` (an AWS
2540
+ // role for Bedrock, say) reaches the check as it reaches a Turn.
2541
+ const why = p.custom
2542
+ ? "harness.provider has no apiKey"
2543
+ : `no Harness pod there holds a key of "${p.id}"; a credential in harness.env still reaches it`;
2544
+ const holds = p.keys.length
2545
+ ? `through a Custodian that holds ${p.keys.join(", ")} for ${p.hosts.join(", ")}`
2546
+ : checkPod.manifest.secrets.length
2547
+ ? `with no held key (${why}), through the Custodian's tunnel, as those pods call it`
2548
+ : `with no held key (${why}), direct, as a pod that holds no secret calls it`;
2549
+ activity(io, `provider: checking ${p.models.join(", ")} on ${checkPod.where}, ${holds}`);
2550
+ }
2551
+ activity(
2552
+ io,
2553
+ `provider: the check pod has up to ${CHECK_START_SECONDS / 60} min to start, then up to ` +
2554
+ `${CHECK_MODEL_SECONDS / 60} min for ${models.length === 1 ? "its model" : `each of its ${models.length} models`}`,
2555
+ );
2556
+ const runSeconds = CHECK_MODEL_SECONDS * models.length + CHECK_RUN_MARGIN_SECONDS;
2557
+ // Built before the apply: a fault in jr2 here is jr2's, never the cluster's refusal.
2558
+ const manifest = checkPodObjects({
2559
+ name: opts.name,
2560
+ namespace,
2561
+ object,
2562
+ run,
2563
+ harnessImage: opts.harnessImage,
2564
+ custodianImage: opts.custodianImage,
2565
+ held: checkPod.manifest,
2566
+ values: opts.values,
2567
+ leaves: opts.leaves,
2568
+ trust: opts.trust,
2569
+ custodian: {
2570
+ bootstrap: JSON.stringify(custodianBootstrap(checkPod.manifest, orchestrator)),
2571
+ script: custodianScript(checkPod.manifest, { fetch: false }),
2572
+ },
2573
+ // Its own credentials (ADR-0019, ADR-0076): a control token the Orchestrator never signed,
2574
+ // and secret Stand-ins minted for this pod alone.
2575
+ credentials: podCredentials(opts.signingKey, {
2576
+ placement: object,
2577
+ heldPod: object,
2578
+ manifest: checkPod.manifest,
2579
+ controlToken: "unsigned",
2580
+ }),
2581
+ harnessEnvData: opts.harnessEnvData,
2582
+ models,
2583
+ ...(checkPod.placement ? { placement: checkPod.placement } : {}),
2584
+ priorityClassName: opts.priorityClassName,
2585
+ ...(opts.harness ? { harness: opts.harness } : {}),
2586
+ caBundle: opts.caBundle,
2587
+ deadlineSeconds: CHECK_START_SECONDS + runSeconds + 60,
2588
+ });
2589
+ let end: PodEnd | undefined;
2590
+ let refused: string | undefined;
2311
2591
  try {
2312
- // caPem: the probe trusts the instance's CA bundle exactly like the Harness will (ADR-0020) —
2313
- // a preflight that fails where the Harness would succeed is a broken promise, and vice versa.
2314
- await kube.runOneShot({
2315
- namespace,
2316
- name: `jr2-provider-preflight-${Date.now() % 100000}`,
2317
- script,
2318
- caPem,
2319
- ...ctx,
2320
- });
2321
- activity(io, `provider: ${model} reachable, and it completed a tool call`);
2592
+ // Server-side: the trust bundles carry every root, past client-side apply's annotation.
2593
+ await applyFirstContact(kube, { manifest, serverSide: true, warn: (line) => activity(io, line), ...ctx });
2322
2594
  } catch (err) {
2323
- throw new Error(
2324
- `provider preflight failed for model "${model}" against ${provider.baseUrl} (from inside the cluster): ` +
2325
- `${err instanceof Error ? err.message : err}\n` +
2326
- ` - localhost never works from a pod; use a LAN address\n` +
2327
- ` - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)\n` +
2328
- ` - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser\n` +
2329
- ` - finish_reason "length" means the model ran out of tokens before the call (a thinking model reasons first)`,
2595
+ // A quota, a Pod Security level or a webhook that refuses the pod: this check pod's fault alone.
2596
+ refused = `the cluster refused the check pod's objects: ${errText(err)}`;
2597
+ }
2598
+ try {
2599
+ if (refused === undefined) {
2600
+ end = await kube.awaitPodEnd({
2601
+ namespace,
2602
+ name: object,
2603
+ container: CHECK_CONTAINER,
2604
+ startSeconds: CHECK_START_SECONDS,
2605
+ runSeconds,
2606
+ onPending: (line) => activity(io, `provider: the check pod is not running yet — ${line}`),
2607
+ // Each model's result as it arrives: a check of several slow models is not silence.
2608
+ onLog: (log: string) => {
2609
+ lastLive = log;
2610
+ for (const [model, verdict] of checkVerdicts(log)) {
2611
+ live.set(model, verdict);
2612
+ if (said.has(model) || verdict.kind === "fault") continue;
2613
+ said.add(model);
2614
+ sayVerdict(io, model, verdict);
2615
+ }
2616
+ },
2617
+ ...ctx,
2618
+ });
2619
+ }
2620
+ } finally {
2621
+ await removeCheckPod(io, kube, object, namespace, ctx);
2622
+ }
2623
+ // The last log read while the pod ran stands in for an empty final read.
2624
+ const output = end?.output || lastLive;
2625
+ const verdicts = checkVerdicts(output);
2626
+ const started = checkStarts(output);
2627
+ for (const p of checkPod.providers) {
2628
+ for (const model of p.models) {
2629
+ const verdict = verdicts.get(model) ?? live.get(model);
2630
+ if (verdict && verdict.kind !== "fault") {
2631
+ if (!said.has(model)) sayVerdict(io, model, verdict);
2632
+ continue;
2633
+ }
2634
+ const pod = () => podFault(end!, checkPod, opts);
2635
+ const why =
2636
+ verdict?.reason ??
2637
+ refused ??
2638
+ (started.has(model)
2639
+ ? `the check pod stopped while it checked this model: ${pod()}`
2640
+ : `not checked: the check pod stopped before it reached this model: ${pod()}`);
2641
+ faults.push(providerFault(p, model, checkPod.where, why, checkPod.manifest.secrets.length > 0));
2642
+ }
2643
+ }
2644
+ }
2645
+ if (faults.length) throw new Error(faults.join("\n"));
2646
+ }
2647
+
2648
+ /** How long the first apply in a new namespace waits for its `default` ServiceAccount: the
2649
+ * controller makes it a moment after the namespace, and until then a pod is refused. */
2650
+ const FIRST_CONTACT_TRIES = 10;
2651
+ const FIRST_CONTACT_PAUSE_MS = 1000;
2652
+
2653
+ /** `kube.apply`, tried again while the namespace has no `default` ServiceAccount yet. Any other
2654
+ * refusal is the answer. */
2655
+ async function applyFirstContact(kube: KubeAdmin, opts: Parameters<KubeAdmin["apply"]>[0]): Promise<void> {
2656
+ for (let n = 1; ; n++) {
2657
+ try {
2658
+ return await kube.apply(opts);
2659
+ } catch (err) {
2660
+ if (n >= FIRST_CONTACT_TRIES || !/serviceaccount "default" not found/i.test(errText(err))) throw err;
2661
+ await sleepMs(FIRST_CONTACT_PAUSE_MS);
2662
+ }
2663
+ }
2664
+ }
2665
+
2666
+ /** The trust bundles a check pod reads (`<object>-ca`), as `trustObject` writes them to `jr2-ca`. */
2667
+ function trustData(
2668
+ caBundle: string | undefined,
2669
+ bundles: { extra: string; bundle: string; upstream: string } | undefined,
2670
+ ) {
2671
+ return {
2672
+ ...(caBundle !== undefined ? { "ca.crt": caBundle } : {}),
2673
+ ...(bundles ? { "extra.crt": bundles.extra, "bundle.crt": bundles.bundle, "upstream.crt": bundles.upstream } : {}),
2674
+ };
2675
+ }
2676
+
2677
+ /** How long after it ended an earlier run's check pod is left alone: that run may still be reading
2678
+ * its log. Exported for its test. */
2679
+ export const CHECK_ENDED_MARGIN_SECONDS = 600;
2680
+
2681
+ /**
2682
+ * The leftovers of earlier runs of THIS Instance (`preflightProviders`), by label: a check pod
2683
+ * past its `activeDeadlineSeconds` and `CHECK_ENDED_MARGIN_SECONDS`, in any phase; a check pod that
2684
+ * ended more than `CHECK_ENDED_MARGIN_SECONDS` ago; each with its objects; and each object whose
2685
+ * pod is gone and that is older than any check pod lives. A live pod, a pod that ended a short time
2686
+ * ago and another Instance's objects stay. Every failure here is a warning.
2687
+ */
2688
+ async function removeCheckLeftovers(
2689
+ io: Io,
2690
+ kube: KubeAdmin,
2691
+ name: string,
2692
+ namespace: string,
2693
+ ctx: { context?: string },
2694
+ ) {
2695
+ const selector = `${LABEL_PROVIDER_PREFLIGHT},${LABEL_INSTANCE}=${name}`;
2696
+ const since = (at: string | undefined) => (Date.now() - Date.parse(at ?? "")) / 1000;
2697
+ try {
2698
+ type CheckObject = KubeObject & {
2699
+ metadata: { creationTimestamp?: string };
2700
+ spec?: { activeDeadlineSeconds?: number };
2701
+ status?: {
2702
+ phase?: string;
2703
+ containerStatuses?: Array<{ name?: string; state?: { terminated?: { finishedAt?: string } } }>;
2704
+ };
2705
+ };
2706
+ const pods = await kube.listJson<CheckObject>({ kind: "pod", selector, namespace, ...ctx });
2707
+ const named = new Set(pods.map((p) => p.metadata.name));
2708
+ for (const p of pods) {
2709
+ // Past its deadline and the margin, in any phase: a pod no node took has no start time, so
2710
+ // the kubelet never applies `activeDeadlineSeconds` to it, and its Secret holds a key.
2711
+ const deadline = (p.spec?.activeDeadlineSeconds ?? 0) + CHECK_ENDED_MARGIN_SECONDS;
2712
+ if (since(p.metadata.creationTimestamp) > deadline) {
2713
+ await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
2714
+ continue;
2715
+ }
2716
+ if (!["Succeeded", "Failed"].includes(p.status?.phase ?? "")) continue;
2717
+ // The check container, or `probe` in a pod that an older `jr2 up` made: the label finds both.
2718
+ const finished = p.status?.containerStatuses?.find((c) => c.name === CHECK_CONTAINER || c.name === "probe")?.state
2719
+ ?.terminated?.finishedAt;
2720
+ if (!(since(finished ?? p.metadata.creationTimestamp) > CHECK_ENDED_MARGIN_SECONDS)) continue;
2721
+ await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
2722
+ }
2723
+ for (const kind of ["configmap", "secret", "networkpolicy"]) {
2724
+ for (const o of await kube.listJson<CheckObject>({ kind, selector, namespace, ...ctx })) {
2725
+ const owner = o.metadata.name.replace(/-(ca|tls|env)$/, "");
2726
+ if (named.has(owner) || !(since(o.metadata.creationTimestamp) > CHECK_LEFTOVER_SECONDS)) continue;
2727
+ await kube.deleteObject({ kind, name: o.metadata.name, namespace, wait: false, ...ctx });
2728
+ }
2729
+ }
2730
+ } catch (err) {
2731
+ activity(io, `warning: provider: the leftovers of an earlier preflight were not removed (${errText(err)})`);
2732
+ }
2733
+ }
2734
+
2735
+ /** One check pod's objects, deleted; a failure is a warning, so it hides no check's answer. */
2736
+ async function removeCheckPod(io: Io, kube: KubeAdmin, object: string, namespace: string, ctx: { context?: string }) {
2737
+ for (const { kind, name } of checkPodObjectNames(object)) {
2738
+ try {
2739
+ await kube.deleteObject({ kind, name, namespace, wait: false, ...ctx });
2740
+ } catch (err) {
2741
+ activity(
2742
+ io,
2743
+ `warning: provider: ${kind} ${name} was not deleted (${errText(err)}) — the next \`jr2 up\` removes it`,
2330
2744
  );
2331
2745
  }
2332
2746
  }
2333
2747
  }
2334
2748
 
2749
+ const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
2750
+
2751
+ /** A fault in which the provider refused the key or its rights: a 401 or a 403, in the words the
2752
+ * apis use. The provider answered, so the key is the likely cause, and the egress is not. */
2753
+ export function refusedKey(fault: string): boolean {
2754
+ return /\b40[13]\b|authentication|unauthori[sz]ed|permission|forbidden|invalid[ _-]?(x-)?api[ _-]?key/i.test(fault);
2755
+ }
2756
+
2757
+ /** One model's result line (`preflight-line.ts` in `@jr2/harness`). */
2758
+ type CheckVerdict = { kind: "ok" } | { kind: "fault" | "busy" | "warn"; reason: string };
2759
+
2760
+ /** One model's result that does not fail the converge, said as it comes. */
2761
+ function sayVerdict(
2762
+ io: Io,
2763
+ model: string,
2764
+ verdict: { kind: "ok" } | { kind: "busy" | "warn" | "fault"; reason: string },
2765
+ ) {
2766
+ if (verdict.kind === "ok") activity(io, `provider: ${model} reachable, and it completed a tool call`);
2767
+ else if (verdict.kind === "busy") {
2768
+ activity(io, `warning: provider: ${model} answered with the key, but is busy — ${verdict.reason} (ADR-0064)`);
2769
+ } else if (verdict.kind === "warn")
2770
+ activity(io, `warning: provider: ${model} answered with the key — ${verdict.reason}`);
2771
+ }
2772
+
2773
+ /** The models whose check began (`start` lines, `preflight-line.ts`): a model with a start and no
2774
+ * verdict was on when the pod stopped; a model with neither was never reached. */
2775
+ export function checkStarts(output: string): Set<string> {
2776
+ const started = new Set<string>();
2777
+ for (const line of output.split("\n")) {
2778
+ const m = /^jr2\.preflight model=(\S+) start$/.exec(line.trim());
2779
+ if (m) started.add(m[1]!);
2780
+ }
2781
+ return started;
2782
+ }
2783
+
2784
+ /** Each model's verdict, off the check container's output lines (`preflight-line.ts` in `@jr2/harness`, held
2785
+ * to this parser by a test). A line that is not a result is not a verdict. */
2786
+ export function checkVerdicts(output: string): Map<string, CheckVerdict> {
2787
+ const verdicts = new Map<string, CheckVerdict>();
2788
+ for (const line of output.split("\n")) {
2789
+ const m = /^jr2\.preflight model=(\S+) (?:(ok)|(fault|busy|warn)=(.*))$/.exec(line.trim());
2790
+ if (!m) continue;
2791
+ verdicts.set(m[1]!, m[2] ? { kind: "ok" } : { kind: m[3] as "fault" | "busy" | "warn", reason: m[4]! });
2792
+ }
2793
+ return verdicts;
2794
+ }
2795
+
2335
2796
  /**
2336
- * The in-cluster probe: GET /models, then one chat completion that must answer with tool_calls. The budget
2337
- * is generous because a thinking model (Qwen3, DeepSeek-R1) reasons BEFORE it calls the tool, and that
2338
- * reasoning counts against max_tokens — at 64 the completion ended with finish_reason "length" and an
2339
- * empty tool_calls, which read as "the endpoint cannot call tools" when it could.
2797
+ * Why a check pod said nothing of a model, from how it ended (`PodEnd`): the hint follows the reason
2798
+ * the kubelet gave, and the Custodian's own refusal line is quoted when it has one. Exported for its
2799
+ * test.
2340
2800
  */
2341
- function providerProbeScript(baseUrl: string, model: string, apiKey?: string): string {
2342
- const base = JSON.stringify(baseUrl.replace(/\/+$/, ""));
2343
- const headers = apiKey
2344
- ? `{ "content-type": "application/json", authorization: "Bearer " + ${JSON.stringify(apiKey)} }`
2345
- : `{ "content-type": "application/json" }`;
2801
+ export function podFault(
2802
+ end: PodEnd,
2803
+ checkPod: Pick<CheckPod, "where">,
2804
+ images: { harnessImage: string; custodianImage: string },
2805
+ ): string {
2806
+ const refusal = /jr2\.custodian refuse secret=\S+ reason=[\w-]+/.exec(end.custodian)?.[0];
2807
+ const said = end.reasons.length ? ` (${end.reasons.join("; ")})` : "";
2808
+ const stuck = end.stuck ?? "";
2809
+ if (/ErrImagePull|ImagePullBackOff|InvalidImageName/.test(stuck)) {
2810
+ return (
2811
+ `the check pod cannot start: ${stuck}${said} — a node on ${checkPod.where} cannot pull ` +
2812
+ `${stuck.startsWith("custodian") ? images.custodianImage : images.harnessImage}; check \`registry\` or ` +
2813
+ "`kitRegistry`, and that those nodes can pull from it"
2814
+ );
2815
+ }
2816
+ if (/CreateContainerConfigError/.test(stuck)) {
2817
+ // The env of the check container: what `harness.env` and `harness.envFrom` name.
2818
+ return (
2819
+ `the check pod cannot start: ${stuck}${said} — a Secret, a ConfigMap or a key that the Harness env names ` +
2820
+ "is missing (a `harness.envFrom` Secret, or a `harness.env` valueFrom)"
2821
+ );
2822
+ }
2823
+ if (stuck.startsWith("FailedMount")) {
2824
+ // A volume of the Custodian: a held secret's `valueFrom` Secret, or its key.
2825
+ return (
2826
+ `the check pod cannot start: ${stuck} — a Secret or a key that a \`secrets\` valueFrom names is missing ` +
2827
+ "in the Instance's namespace"
2828
+ );
2829
+ }
2830
+ const oom = [stuck, ...end.reasons].find((r) => /OOMKilled/.test(r));
2831
+ if (oom) {
2832
+ const custodian = oom.startsWith("custodian");
2833
+ return (
2834
+ `the check pod ran out of memory: ${oom}${said} — ` +
2835
+ (custodian
2836
+ ? `its Custodian has the Instance Harness Custodian's limit (${KIT_POD_SIZES.instanceCustodian.limits.memory})`
2837
+ : `the check container's limit is ${KIT_POD_SIZES.checkContainer.limits.memory}`) +
2838
+ "; a jr2 defect, not a fault of the provider"
2839
+ );
2840
+ }
2841
+ if (refusal || stuck.startsWith("custodian")) {
2842
+ return (
2843
+ `the check pod's Custodian did not start: ${refusal ?? stuck}${said} — a Custodian stops at start on a held ` +
2844
+ "value that is missing, empty or not printable ASCII"
2845
+ );
2846
+ }
2847
+ if (end.phase === "timeout") {
2848
+ return end.started
2849
+ ? `the check did not end in time${said}`
2850
+ : `the check pod did not start in ${CHECK_START_SECONDS}s${said} — no node on ${checkPod.where} fits it, or its image pulls are slow`;
2851
+ }
2852
+ if (!end.output.trim()) {
2853
+ return `the check pod ended ${end.phase}${said}, and jr2 up could not read its log — run \`jr2 up\` again`;
2854
+ }
2855
+ const tail = end.output.trim().split("\n").filter(Boolean).slice(-5).join("; ");
2856
+ return `the check pod ended ${end.phase} before it checked this model${said} — it said: ${tail}`;
2857
+ }
2858
+
2859
+ /** One model's preflight failure: the model, the host, and the likely causes. */
2860
+ function providerFault(p: CheckedProvider, model: string, where: string, fault: string, custodian: boolean): string {
2861
+ const hosts = p.hosts.join(", ") || `the host of "${p.id}"`;
2862
+ // A model that loads on first use (Ollama, a large vLLM model) can take longer than the bound.
2863
+ // Only where jr2 cannot know the endpoint: the custom provider, or a gateway.
2864
+ const slow =
2865
+ (p.custom || p.gateway) && /no answer in \d+ s|no verdict in \d+ s|time ran out/.test(fault)
2866
+ ? " - a model that loads on first use can take longer than the check waits: run `jr2 up` again once it has loaded\n"
2867
+ : "";
2868
+ if (p.custom) {
2869
+ // The custom provider's own causes (ADR-0018): an endpoint jr2 knows nothing about but its url.
2870
+ return (
2871
+ `provider preflight failed for model "${model}" against ${hosts} (from a pod on ${where}, as a Turn calls it): ` +
2872
+ `${fault}\n` +
2873
+ slow +
2874
+ // A refused key: the endpoint answered, so the key comes first and the reach is not it.
2875
+ (refusedKey(fault)
2876
+ ? (p.keys.length
2877
+ ? ` - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)\n`
2878
+ : " - the endpoint wants a key: set harness.provider.apiKey\n") +
2879
+ " - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)"
2880
+ : " - localhost never works from a pod; use a LAN address\n" +
2881
+ " - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)\n" +
2882
+ " - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser\n" +
2883
+ ' - stop reason "length" means the model ran out of tokens before the call (a thinking model reasons first)' +
2884
+ (p.keys.length ? `\n - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)` : ""))
2885
+ );
2886
+ }
2887
+ const key = p.keys.length
2888
+ ? ` - the held secret ${p.keys.join(" or ")} may be empty, wrong, or without access to this model (\`secrets\` in jr2.config.ts)\n`
2889
+ : ` - no Harness pod on ${where} holds a key of "${p.id}": add its key to \`secrets\`` +
2890
+ (Object.keys(MODEL_KEYS).some((n) => MODEL_KEYS[n]!.providers.includes(p.id))
2891
+ ? ` (${Object.keys(MODEL_KEYS)
2892
+ .filter((n) => MODEL_KEYS[n]!.providers.includes(p.id))
2893
+ .join(" or ")}), or set in harness.env the credential that pi reads for it\n`
2894
+ : `, or set in harness.env the credential that pi reads for it; jr2 knows no key name of "${p.id}" ` +
2895
+ "(when pi does not know the id either, the line above says that it resolves to nothing)\n");
2896
+ // The likely cause first: a provider that refused the key answered, so the egress is not it.
2897
+ const egress = ` - the pods may have no egress to ${hosts} (a NetworkPolicy, a firewall, no route out of the cluster)\n`;
2898
+ const causes = refusedKey(fault) ? key : egress + key;
2346
2899
  return (
2347
- `const h = ${headers};` +
2348
- `const m = await fetch(${base} + "/models", { headers: h });` +
2349
- `if (!m.ok) throw new Error("GET /models: HTTP " + m.status);` +
2350
- `const c = await fetch(${base} + "/chat/completions", { method: "POST", headers: h, body: JSON.stringify({` +
2351
- ` model: ${JSON.stringify(model)}, max_tokens: 1024,` +
2352
- ` messages: [{ role: "user", content: "Call the ping tool." }],` +
2353
- ` tools: [{ type: "function", function: { name: "ping", description: "reply with a ping", parameters: { type: "object", properties: {} } } }]` +
2354
- ` }) });` +
2355
- `const j = await c.json();` +
2356
- `if (!c.ok) throw new Error("POST /chat/completions: HTTP " + c.status + " " + JSON.stringify(j).slice(0, 300));` +
2357
- `const choice = j.choices?.[0];` +
2358
- `const calls = choice?.message?.tool_calls;` +
2359
- `if (!calls?.length) throw new Error("completion carried no tool_calls (finish_reason " + JSON.stringify(choice?.finish_reason ?? null) + ")");` +
2360
- `console.log("PROVIDER OK");`
2900
+ `provider preflight failed for model "${model}" toward ${hosts} (from a pod on ${where}, ` +
2901
+ `${custodian ? "through a Custodian" : "direct"}, ` +
2902
+ `as a Turn calls it): ${fault}\n` +
2903
+ slow +
2904
+ causes +
2905
+ (p.gateway
2906
+ ? ` - harness.catalog.${p.id}.baseUrl (${p.gateway}) may not answer, or may not serve "${model.slice(p.id.length + 1)}" ` +
2907
+ "(a LiteLLM model_name alias)\n"
2908
+ : "") +
2909
+ " - a private CA on that path must be in harness.caBundle"
2361
2910
  );
2362
2911
  }
2363
2912
 
@@ -2371,31 +2920,3 @@ function noteDeferred(io: Io, repos: CarriedRepo[]): void {
2371
2920
  );
2372
2921
  }
2373
2922
  }
2374
-
2375
- /**
2376
- * The `customize` line that retunes a composed Machine's Size (ADR-0060), nested through `actors`
2377
- * the way `customize()` accepts — the Size twin of `customizeLine`, so the refusal pastes.
2378
- */
2379
- function sizeLine(path: readonly string[]): string {
2380
- const binds = '{ resources: { limits: { memory: "<memory>" } } }';
2381
- return `customize(<import>, ${path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds)})`;
2382
- }
2383
-
2384
- /**
2385
- * The Chart files under an Assembly root (ADR-0070), as Workflow names `<wrapper>/<name>`: each
2386
- * `workflows/<wrapper>/<name>.chart.json`, one level below the flat `workflows/*.ts`, in a directory
2387
- * named after its wrapper. The layout the Orchestrator's discovery reads (charts.ts); a directory
2388
- * whose name starts with `_` or `.` is not a wrapper's.
2389
- */
2390
- async function chartFilesIn(root: string): Promise<string[]> {
2391
- const out: string[] = [];
2392
- const dir = join(root, "workflows");
2393
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
2394
- for (const entry of entries) {
2395
- if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
2396
- const files = await readdir(join(dir, entry.name)).catch(() => [] as string[]);
2397
- for (const f of files)
2398
- if (f.endsWith(".chart.json")) out.push(`${entry.name}/${f.slice(0, -".chart.json".length)}`);
2399
- }
2400
- return out.sort();
2401
- }