@jr2/cli 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +3 -3
- package/src/agents-md.ts +149 -0
- package/src/assembly.ts +1 -1
- package/src/build.ts +24 -7
- package/src/cli.ts +20 -45
- package/src/client.ts +19 -1
- package/src/command-table.ts +641 -0
- package/src/commands/check.ts +37 -0
- package/src/commands/down.ts +7 -17
- package/src/commands/gc.ts +2 -11
- package/src/commands/init.ts +73 -17
- package/src/commands/kit.ts +6 -11
- package/src/commands/logs.ts +4 -9
- package/src/commands/run.ts +14 -15
- package/src/commands/runs.ts +13 -28
- package/src/commands/send.ts +4 -18
- package/src/commands/status.ts +47 -11
- package/src/commands/up.ts +841 -374
- package/src/commands/version.ts +9 -13
- package/src/deploy.ts +223 -23
- package/src/index.ts +26 -0
- package/src/instance-check.ts +309 -0
- package/src/instance.ts +3 -9
- package/src/kit-version.ts +27 -17
- package/src/kube.ts +334 -67
- package/src/nodes.ts +7 -1
- package/src/output.ts +1 -1
- package/src/repo-sweep.ts +1 -1
- package/src/sweep.ts +6 -5
package/src/commands/up.ts
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
-
|
|
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,20 @@ import {
|
|
|
102
100
|
harnessTokenDigest,
|
|
103
101
|
formatCpu,
|
|
104
102
|
formatMemory,
|
|
105
|
-
resolveSize,
|
|
106
103
|
sandboxToken,
|
|
107
|
-
|
|
108
|
-
serializeMachine,
|
|
109
|
-
splitSize,
|
|
104
|
+
type CarriedEffect,
|
|
110
105
|
type CarriedImage,
|
|
111
106
|
type CarriedRepo,
|
|
112
107
|
type HeldManifest,
|
|
113
108
|
type HeldPodPlan,
|
|
114
109
|
type ImageRefs,
|
|
110
|
+
MODEL_KEYS,
|
|
111
|
+
PROVIDER_KEY_NAME,
|
|
112
|
+
type PlacementWalk,
|
|
113
|
+
type SandboxPlacement,
|
|
114
|
+
workspaceHeldPod,
|
|
115
115
|
type JR2Config,
|
|
116
|
-
|
|
117
|
-
} from "@jr2/orchestrator";
|
|
116
|
+
} from "@jr2/orchestrator/internal";
|
|
118
117
|
import { stageAssembly, stagedCopyOf } from "../assembly.ts";
|
|
119
118
|
import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
|
|
120
119
|
import {
|
|
@@ -122,6 +121,7 @@ import {
|
|
|
122
121
|
buildSandboxImage,
|
|
123
122
|
choosePlatforms,
|
|
124
123
|
detectKitCheckout,
|
|
124
|
+
isMissingHostImage,
|
|
125
125
|
kitImageBuild,
|
|
126
126
|
kitImageRefs,
|
|
127
127
|
normalizeRef,
|
|
@@ -130,6 +130,7 @@ import {
|
|
|
130
130
|
sandboxImageTag,
|
|
131
131
|
KIT_IMAGE_HOME,
|
|
132
132
|
type BuildPort,
|
|
133
|
+
type ImageFiles,
|
|
133
134
|
type KitImageName,
|
|
134
135
|
type KitImageRefs,
|
|
135
136
|
} from "../build.ts";
|
|
@@ -148,6 +149,7 @@ import {
|
|
|
148
149
|
effectorGraceSeconds,
|
|
149
150
|
INSTANCE_EFFECTOR,
|
|
150
151
|
instanceObjects,
|
|
152
|
+
KIT_POD_SIZES,
|
|
151
153
|
KIT_VERSION,
|
|
152
154
|
LABEL_INSTANCE,
|
|
153
155
|
LABEL_VERSION,
|
|
@@ -164,8 +166,15 @@ import {
|
|
|
164
166
|
type HeldObjects,
|
|
165
167
|
type CustodianFiles,
|
|
166
168
|
type HeldPodObjects,
|
|
169
|
+
checkPodObjects,
|
|
170
|
+
LABEL_PROVIDER_PREFLIGHT,
|
|
171
|
+
checkPodObjectNames,
|
|
172
|
+
PROVIDER_PREFLIGHT,
|
|
173
|
+
CHECK_CONTAINER,
|
|
167
174
|
} from "../deploy.ts";
|
|
175
|
+
import { syncAgentsMd } from "../agents-md.ts";
|
|
168
176
|
import { resolveRoot } from "../instance.ts";
|
|
177
|
+
import { resolvePackage } from "../kit-version.ts";
|
|
169
178
|
import { effectorImagePaths, effectorImageRefusal, STOCK_HARNESS_IMAGE, type PushForm } from "../effector-image.ts";
|
|
170
179
|
import {
|
|
171
180
|
kubeFloorRefusal,
|
|
@@ -177,27 +186,29 @@ import {
|
|
|
177
186
|
ORCHESTRATOR_SERVICE,
|
|
178
187
|
type KubeAdmin,
|
|
179
188
|
type KubeObject,
|
|
189
|
+
type PodEnd,
|
|
180
190
|
type RolloutTarget,
|
|
181
191
|
} from "../kube.ts";
|
|
182
192
|
import { anyNodeHolds, buildNodes, sandboxNodes, type NodeObject } from "../nodes.ts";
|
|
183
193
|
import { activity, chooseOrBail, confirmOrBail, promptLine, readSecretInput, type Io } from "../output.ts";
|
|
184
194
|
import { kindCluster, sweepImages } from "../sweep.ts";
|
|
185
|
-
import {
|
|
195
|
+
import { chartFilesIn, checkInstance } from "../instance-check.ts";
|
|
196
|
+
import { parseCommand } from "../command-table.ts";
|
|
197
|
+
|
|
198
|
+
/** How many times one converge rebuilds a host image a sweep took before it uses it (ADR-0039).
|
|
199
|
+
* A bound, because a tag that is gone again after two rebuilds is not a race to wait out: something
|
|
200
|
+
* on the host removes it as fast as it is built, and docker's own error is the honest report. */
|
|
201
|
+
const HOST_REBUILDS = 2;
|
|
186
202
|
|
|
187
203
|
export async function up(args: string[], io: Io): Promise<number> {
|
|
188
|
-
const { values } =
|
|
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
|
-
});
|
|
204
|
+
const { values } = parseCommand("up", args);
|
|
199
205
|
|
|
200
206
|
const root = resolveRoot(io.cwd);
|
|
207
|
+
// --- the AGENTS.md block (ADR-0072): before anything can refuse, because it describes the Kit the
|
|
208
|
+
// Instance resolves, not the cluster, so a converge that fails still leaves the index current.
|
|
209
|
+
const kit = resolvePackage("@jr2/orchestrator", root);
|
|
210
|
+
if (kit) await syncAgentsMd(io, root, kit.root, kit.version);
|
|
211
|
+
|
|
201
212
|
const config = (await loadConfig(root)) ?? {};
|
|
202
213
|
const name = config.name ?? basename(root);
|
|
203
214
|
const namespace = (values.namespace as string | undefined) ?? name;
|
|
@@ -210,181 +221,16 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
210
221
|
|
|
211
222
|
activity(io, `jr2 up — instance "${name}" → context ${context} / namespace ${namespace}`);
|
|
212
223
|
|
|
213
|
-
// ---
|
|
224
|
+
// --- the Instance check (ADR-0050): the typecheck and the Machine walk, shared with `jr2 check` -
|
|
214
225
|
// A REFUSAL, not a warning, and it comes before the namespace apply as well as before the builds:
|
|
215
|
-
// the names a Machine carries
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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));
|
|
226
|
+
// the names a Machine carries are typed since ADR-0049/0051, so the compiler's answer is the
|
|
227
|
+
// author's editor's answer; the walk then refuses what the compiler cannot see (an Open part, a
|
|
228
|
+
// Chart file, a dispatch, an Effect secret, a Size). `jr2 check` runs the same function, so an
|
|
229
|
+
// Assistant that may not run `jr2 up` gets the answer of this converge.
|
|
230
|
+
const checked = await checkInstance(io, root, config);
|
|
231
|
+
if (!checked) return 1;
|
|
232
|
+
const { workflows, carried, placementWalk, sizes, chartFiles } = checked;
|
|
240
233
|
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
234
|
|
|
389
235
|
// --- the Assembly (ADR-0069): staged here, published last ---------------------------------------
|
|
390
236
|
// Staged BEFORE any cluster spend, so its refusals cost nothing: a second copy of a Kit package
|
|
@@ -686,6 +532,26 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
686
532
|
const hostBuilt = async (ref: string): Promise<boolean> =>
|
|
687
533
|
values.force !== true && (await hostHeld()).has(normalizeRef(ref));
|
|
688
534
|
|
|
535
|
+
// What this converge counts on finding on the host — its own build, or the host-held skip above —
|
|
536
|
+
// can be gone by the time it is used: another converge's sweep keeps only the roots of ITS context
|
|
537
|
+
// (ADR-0039), and a ref whose map is not written yet is in none of them. The tag is a content
|
|
538
|
+
// address and BuildKit's cache is a store no sweep touches, so a ref found missing at its use is
|
|
539
|
+
// built again (seconds) and the use retried, at most HOST_REBUILDS times. Only a singleton build
|
|
540
|
+
// lives on the daemon: a multi-platform one is in the registry (ADR-0045), where no sweep reaches.
|
|
541
|
+
const builders = new Map<string, () => Promise<void>>();
|
|
542
|
+
const onHost = async <T>(ref: string, use: () => Promise<T>): Promise<T> => {
|
|
543
|
+
for (let rebuilt = 0; ; rebuilt++) {
|
|
544
|
+
try {
|
|
545
|
+
return await use();
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const rebuild = builders.get(ref);
|
|
548
|
+
if (!rebuild || platforms.length > 1 || rebuilt >= HOST_REBUILDS || !isMissingHostImage(err)) throw err;
|
|
549
|
+
activity(io, ` ${ref}: gone from the host daemon (another converge's sweep took it) — rebuilding`);
|
|
550
|
+
await rebuild();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
689
555
|
// The binfmt preflight (ADR-0045), paid at most once and only when a build is really about to be
|
|
690
556
|
// spent: a steady-state converge must still spend no docker at all, and a foreign-arch build must
|
|
691
557
|
// fail BEFORE minutes of docker rather than mid-`RUN` with `exec format error`.
|
|
@@ -706,15 +572,19 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
706
572
|
return ref;
|
|
707
573
|
}
|
|
708
574
|
assertDeliverable();
|
|
575
|
+
const buildRef = async (): Promise<void> => {
|
|
576
|
+
await ensureEmulation();
|
|
577
|
+
await build.build(kitImageBuild(kitRoot, which, ref, platforms));
|
|
578
|
+
};
|
|
579
|
+
builders.set(ref, buildRef);
|
|
709
580
|
if (await hostBuilt(ref)) {
|
|
710
581
|
activity(io, `${which} image: ${ref} (host-built — build skipped, delivering; --force to rebuild)`);
|
|
711
|
-
await deliver(ref);
|
|
582
|
+
await onHost(ref, () => deliver(ref));
|
|
712
583
|
return ref;
|
|
713
584
|
}
|
|
714
585
|
activity(io, `${which} image: building ${ref}${values.force === true ? " (--force)" : ""}`);
|
|
715
|
-
await
|
|
716
|
-
await
|
|
717
|
-
await deliver(ref);
|
|
586
|
+
await buildRef();
|
|
587
|
+
await onHost(ref, () => deliver(ref));
|
|
718
588
|
return ref;
|
|
719
589
|
};
|
|
720
590
|
|
|
@@ -736,55 +606,8 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
736
606
|
const operatorImage =
|
|
737
607
|
config.operator?.manage === false && !carried.composesSandbox ? undefined : await ensureKitImage("operator");
|
|
738
608
|
if (operatorImage !== undefined) converged.operator = operatorImage;
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
}
|
|
609
|
+
// Applied after the provider preflight (below): the operator is the cluster's, and a converge
|
|
610
|
+
// whose provider check fails must not have changed it.
|
|
788
611
|
|
|
789
612
|
// --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
|
|
790
613
|
// Built here rather than lazily beside its consumers. The Harness is the injection source
|
|
@@ -818,6 +641,16 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
818
641
|
const digest = contextDigests.get(image.dir)!;
|
|
819
642
|
const key = image.reserved ?? digest;
|
|
820
643
|
const ref = sandboxImageTag(name, image.name, digest, { platforms, registry });
|
|
644
|
+
const buildRef = async (): Promise<void> => {
|
|
645
|
+
// ONE build of the user's own Dockerfile, straight to its content tag — no wrap, no
|
|
646
|
+
// intermediate tag, so two converges of one checkout share no mutable image name and
|
|
647
|
+
// the `@kind` tier's `--parallel` rides on it (ADR-0037).
|
|
648
|
+
await ensureEmulation();
|
|
649
|
+
await buildSandboxImage(build, { dir: image.dir, tag: ref, instance: name, platforms });
|
|
650
|
+
};
|
|
651
|
+
// Known on every path, the record's skip included: the Effector check reads this ref off the
|
|
652
|
+
// host daemon later in this converge, whatever decided its build.
|
|
653
|
+
builders.set(ref, buildRef);
|
|
821
654
|
// A record is only worth skipping on when it is COMPLETE: the ref AND the seat the image
|
|
822
655
|
// declared. A record from a kit that predates `sandboxUser` names the right image and
|
|
823
656
|
// silently drops the fallback fact, which surfaces as a pod that will not start — so it reads
|
|
@@ -838,11 +671,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
838
671
|
activity(io, `sandbox image "${image.name}": ${ref} (host-built — build skipped; --force to rebuild)`);
|
|
839
672
|
} else {
|
|
840
673
|
activity(io, `sandbox image "${image.name}": building ${ref}`);
|
|
841
|
-
|
|
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 });
|
|
674
|
+
await buildRef();
|
|
846
675
|
}
|
|
847
676
|
// The image's own `USER`, read while the image is certainly on this daemon (it was just
|
|
848
677
|
// built, or the host holds it). Recorded because a provision cannot inspect an image, and
|
|
@@ -852,10 +681,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
852
681
|
// (ADR-0005), which owes no floor — a distinction only the provision can make (ADR-0031).
|
|
853
682
|
// `platforms` says where to look, not which variant to read (ADR-0045): a singleton build is
|
|
854
683
|
// on this daemon, a multi-platform one is the manifest list buildx just pushed.
|
|
855
|
-
const user = await build.imageUser(ref, platforms);
|
|
684
|
+
const user = await onHost(ref, () => build.imageUser(ref, platforms));
|
|
856
685
|
converged.sandboxUser[key] = user;
|
|
857
686
|
activity(io, ` ${user ? `USER ${user}` : "no USER declared — the pod's uid-1000 fallback applies"}`);
|
|
858
|
-
await deliver(ref);
|
|
687
|
+
await onHost(ref, () => deliver(ref));
|
|
859
688
|
}
|
|
860
689
|
converged.sandbox[key] = ref;
|
|
861
690
|
}
|
|
@@ -985,6 +814,88 @@ 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
|
+
});
|
|
847
|
+
|
|
848
|
+
// --- operator (per-cluster, shared): after the provider preflight ------------------------------
|
|
849
|
+
if (config.operator?.manage === false) {
|
|
850
|
+
activity(
|
|
851
|
+
io,
|
|
852
|
+
"operator: skipped (operator.manage: false — run the controller loop yourself)" +
|
|
853
|
+
(operatorImage !== undefined ? `; its image ${operatorImage} still runs as the cache agent` : ""),
|
|
854
|
+
);
|
|
855
|
+
} else {
|
|
856
|
+
const image = operatorImage!;
|
|
857
|
+
const existing = await kube.getJson({
|
|
858
|
+
kind: "deployment",
|
|
859
|
+
name: OPERATOR_DEPLOYMENT,
|
|
860
|
+
namespace: OPERATOR_NAMESPACE,
|
|
861
|
+
...ctx,
|
|
862
|
+
});
|
|
863
|
+
const deployed = existing?.metadata.labels?.[LABEL_VERSION];
|
|
864
|
+
if (deployed && compareVersions(deployed, KIT_VERSION) > 0) {
|
|
865
|
+
activity(io, `operator: leaving v${deployed} (newer than this kit's v${KIT_VERSION} — never downgraded)`);
|
|
866
|
+
} else {
|
|
867
|
+
activity(io, `operator: applying v${KIT_VERSION} (${image})${deployed ? ` over v${deployed}` : ""}`);
|
|
868
|
+
await kube.apply({
|
|
869
|
+
manifest: await operatorManifest(image, priorityClassNames(config.priorityClasses).control),
|
|
870
|
+
...ctx,
|
|
871
|
+
});
|
|
872
|
+
await kube.label({
|
|
873
|
+
kind: "deployment",
|
|
874
|
+
name: OPERATOR_DEPLOYMENT,
|
|
875
|
+
namespace: OPERATOR_NAMESPACE,
|
|
876
|
+
labels: { [LABEL_VERSION]: KIT_VERSION },
|
|
877
|
+
...ctx,
|
|
878
|
+
});
|
|
879
|
+
await awaitRollout(kube, {
|
|
880
|
+
name: OPERATOR_DEPLOYMENT,
|
|
881
|
+
namespace: OPERATOR_NAMESPACE,
|
|
882
|
+
selector: OPERATOR_SELECTOR,
|
|
883
|
+
...ctx,
|
|
884
|
+
});
|
|
885
|
+
// Sound for every layer now that EVERY tag is a content address (ADR-0038): a source edit
|
|
886
|
+
// moves the ref, which moves the pod template, which rolls. The old hole — kit dev pinning a
|
|
887
|
+
// static `:local` tag, so a rebuild left the template identical and nothing rolled — closed
|
|
888
|
+
// with the `images` block that created it.
|
|
889
|
+
await verifyRunningImage(io, kube, {
|
|
890
|
+
layer: "operator",
|
|
891
|
+
namespace: OPERATOR_NAMESPACE,
|
|
892
|
+
selector: OPERATOR_SELECTOR,
|
|
893
|
+
image,
|
|
894
|
+
...ctx,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
988
899
|
// --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
|
|
989
900
|
// Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
|
|
990
901
|
// notice it hands back is owed to the END of the converge — a generated key is dead until a human
|
|
@@ -994,22 +905,27 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
994
905
|
// --- what each Sandbox Effector's image owes its pushes (ADR-0068) ------------------------
|
|
995
906
|
// Before anything is applied: an image that lacks a push form's tools, or the passwd entry ssh
|
|
996
907
|
// needs, is refused here with the fix, not at the first push in a pod.
|
|
997
|
-
await checkEffectorImages(
|
|
908
|
+
await checkEffectorImages(
|
|
909
|
+
(ref, paths) => onHost(ref, () => build.imageFiles(ref, platforms, paths)),
|
|
910
|
+
config,
|
|
911
|
+
placements.sandbox,
|
|
912
|
+
converged,
|
|
913
|
+
contextDigests,
|
|
914
|
+
);
|
|
998
915
|
|
|
999
916
|
// --- push credentials (ADR-0051): one Secret per git.credentials entry, never per pod ----
|
|
1000
917
|
// After the deploy keys exist (a generated one included), before anything else is applied: a
|
|
1001
918
|
// static Binding whose entry cannot push is refused here.
|
|
1002
919
|
const gitPush = await resolveGitPush(io, kube, config, placements.sandbox, namespace, ctx);
|
|
1003
920
|
|
|
1004
|
-
// ---
|
|
1005
|
-
await
|
|
921
|
+
// --- dispatch preflight (ADR-0071): each foreign target's door, from the host ------------------
|
|
922
|
+
await preflightDispatch(io, carried.effects);
|
|
1006
923
|
|
|
1007
924
|
// --- apply + rollout ---------------------------------------------------------------------------
|
|
1008
925
|
// The trust bundles first, and server-side: every pod that mounts them must find them, and they
|
|
1009
926
|
// carry every root certificate — past what client-side apply's annotation can hold.
|
|
1010
927
|
const trust = trustObject({ name, namespace, caBundle: caPem, bundles: held.bundles });
|
|
1011
928
|
if (trust) await kube.apply({ manifest: trust, serverSide: true, ...ctx });
|
|
1012
|
-
activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
|
|
1013
929
|
const instanceList = instanceObjects({
|
|
1014
930
|
name,
|
|
1015
931
|
namespace,
|
|
@@ -1025,7 +941,8 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1025
941
|
gitPush,
|
|
1026
942
|
gitClone,
|
|
1027
943
|
});
|
|
1028
|
-
|
|
944
|
+
activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
|
|
945
|
+
await kube.apply({ manifest: instanceList, warn: (line) => activity(io, line), ...ctx });
|
|
1029
946
|
// An entry whose clone Secret this converge did not write loses it: a dropped entry, a token
|
|
1030
947
|
// unset, or nothing left that clones.
|
|
1031
948
|
const cloneSecrets = new Set(gitClone.map((s) => s.name));
|
|
@@ -1074,6 +991,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1074
991
|
if (!holdsAny(held.objects)) {
|
|
1075
992
|
await kube.deleteObject({ kind: "secret", name: HELD_TLS_SECRET, namespace, ...ctx });
|
|
1076
993
|
}
|
|
994
|
+
|
|
1077
995
|
activity(io, "orchestrator: waiting for rollout");
|
|
1078
996
|
await awaitRollout(kube, {
|
|
1079
997
|
name: ORCHESTRATOR_SERVICE,
|
|
@@ -1223,6 +1141,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1223
1141
|
caBundle: caPem !== undefined,
|
|
1224
1142
|
...(config.priorityClasses ? { priorityClasses: config.priorityClasses } : {}),
|
|
1225
1143
|
}),
|
|
1144
|
+
warn: (line) => activity(io, line),
|
|
1226
1145
|
...ctx,
|
|
1227
1146
|
});
|
|
1228
1147
|
await awaitRollout(kube, {
|
|
@@ -1815,6 +1734,8 @@ async function resolveGitPush(
|
|
|
1815
1734
|
const data: Record<string, string> = {};
|
|
1816
1735
|
let sshMissing: string | undefined;
|
|
1817
1736
|
let signMissing: string | undefined;
|
|
1737
|
+
// One warning per entry and cause: an absent Secret is missing every key it was to give.
|
|
1738
|
+
const warned = new Set<string>();
|
|
1818
1739
|
for (const source of plan.sources) {
|
|
1819
1740
|
if ("literal" in source) {
|
|
1820
1741
|
data[source.key] = Buffer.from(source.literal).toString("base64");
|
|
@@ -1833,6 +1754,8 @@ async function resolveGitPush(
|
|
|
1833
1754
|
: `the Secret "${source.from.secret}" holds no key ${source.from.key}`;
|
|
1834
1755
|
if (source.key.startsWith("sign.")) signMissing ??= why;
|
|
1835
1756
|
else sshMissing ??= why;
|
|
1757
|
+
if (warned.has(why)) continue;
|
|
1758
|
+
warned.add(why);
|
|
1836
1759
|
// Not a refusal: the Secret may be written later (a scenario writes it, a human creates it).
|
|
1837
1760
|
// A push under the entry that needs it is refused at provision until `jr2 up` runs again.
|
|
1838
1761
|
activity(
|
|
@@ -1896,11 +1819,11 @@ async function resolveGitPush(
|
|
|
1896
1819
|
* is not read, as its `USER` is not.
|
|
1897
1820
|
*/
|
|
1898
1821
|
async function checkEffectorImages(
|
|
1899
|
-
|
|
1822
|
+
/** {@link BuildPort.imageFiles} for a ref this converge resolved — rebuilt if a sweep took it. */
|
|
1823
|
+
imageFiles: (ref: string, paths: readonly string[]) => Promise<ImageFiles>,
|
|
1900
1824
|
config: JR2Config,
|
|
1901
1825
|
sandbox: readonly SandboxEffectorPlan[],
|
|
1902
1826
|
converged: ConvergedImages,
|
|
1903
|
-
platforms: readonly string[],
|
|
1904
1827
|
/** Each `file:` context's key in the map: the digest of its staged copy (ADR-0069). */
|
|
1905
1828
|
contextDigests: ReadonlyMap<string, string>,
|
|
1906
1829
|
): Promise<void> {
|
|
@@ -1940,7 +1863,7 @@ async function checkEffectorImages(
|
|
|
1940
1863
|
if (refusal) throw new Error(refusal);
|
|
1941
1864
|
continue;
|
|
1942
1865
|
}
|
|
1943
|
-
const files = await
|
|
1866
|
+
const files = await imageFiles(ref, effectorImagePaths(forms.map((f) => f.form)));
|
|
1944
1867
|
const refusal = effectorImageRefusal({
|
|
1945
1868
|
label: plan.label,
|
|
1946
1869
|
ref,
|
|
@@ -2022,7 +1945,7 @@ async function ensureGitSsh(
|
|
|
2022
1945
|
|
|
2023
1946
|
if (source.kind === "generate") {
|
|
2024
1947
|
const { privateKey, publicKey } = await (io.sshKeygen ?? sshKeygen)();
|
|
2025
|
-
await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1948
|
+
await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
|
|
2026
1949
|
activity(io, `generated deploy key ${sshFingerprint(publicKey)} — register this PUBLIC key (read access):`);
|
|
2027
1950
|
activity(io, ` ${publicKey.trim()}`);
|
|
2028
1951
|
// The pause sits exactly where the user must act anyway (ADR-0047): the printed key is useless
|
|
@@ -2053,7 +1976,7 @@ async function ensureGitSsh(
|
|
|
2053
1976
|
} catch (err) {
|
|
2054
1977
|
throw new Error(`refusing the key from ${where}: ${err instanceof Error ? err.message : err}`);
|
|
2055
1978
|
}
|
|
2056
|
-
await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1979
|
+
await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
|
|
2057
1980
|
// The FINGERPRINT, never the material — not the private half, and not the public one either:
|
|
2058
1981
|
// this key is the user's own, and its public half identifies them wherever it is registered.
|
|
2059
1982
|
activity(io, `git ssh: "${secret}" applied from ${where} — ${sshFingerprint(publicKey)}`);
|
|
@@ -2151,6 +2074,7 @@ async function readSuppliedKey(path: string): Promise<string> {
|
|
|
2151
2074
|
* `identity.pub` rides along so the cluster can say which key it holds without holding it up to a
|
|
2152
2075
|
* human. Trailing newline enforced — ssh refuses a key file that lacks one. */
|
|
2153
2076
|
async function applyGitSshSecret(
|
|
2077
|
+
io: Io,
|
|
2154
2078
|
kube: KubeAdmin,
|
|
2155
2079
|
namespace: string,
|
|
2156
2080
|
ctx: { context?: string },
|
|
@@ -2166,6 +2090,7 @@ async function applyGitSshSecret(
|
|
|
2166
2090
|
type: "Opaque",
|
|
2167
2091
|
stringData: { identity: newlineTerminated(privateKey), "identity.pub": newlineTerminated(publicKey) },
|
|
2168
2092
|
}),
|
|
2093
|
+
warn: (line) => activity(io, line),
|
|
2169
2094
|
...ctx,
|
|
2170
2095
|
});
|
|
2171
2096
|
}
|
|
@@ -2272,92 +2197,662 @@ async function readCaBundle(root: string, config: JR2Config): Promise<string | u
|
|
|
2272
2197
|
}
|
|
2273
2198
|
|
|
2274
2199
|
/**
|
|
2275
|
-
* The
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2200
|
+
* The foreign dispatch preflight (ADR-0071): for each dispatch whose target is another
|
|
2201
|
+
* Orchestrator, `GET <origin>/workflows/<name>`, and the served door compared with the slot's
|
|
2202
|
+
* input schema (`doorCompat`, one direction). The route is open band (ADR-0014), so no token is
|
|
2203
|
+
* sent: the token is the Effector's to hold, and the host would otherwise carry it over a wire the
|
|
2204
|
+
* target may not secure. Every failure — no answer, a miss, a mismatch, a schema not compared — is a
|
|
2205
|
+
* WARNING, never a refusal: the other Instance may be down or publish later, and refusing here
|
|
2206
|
+
* would couple two Instances' publish order. A run of the dispatch answers what the door then says,
|
|
2207
|
+
* the token's refusal included.
|
|
2208
|
+
*/
|
|
2209
|
+
async function preflightDispatch(io: Io, effects: readonly CarriedEffect[]): Promise<void> {
|
|
2210
|
+
const fetchImpl = io.fetch ?? globalThis.fetch;
|
|
2211
|
+
const seen = new Set<string>();
|
|
2212
|
+
for (const e of effects) {
|
|
2213
|
+
if (e.kind !== "dispatch" || e.target === undefined) continue;
|
|
2214
|
+
const url = targetText({ target: e.target });
|
|
2215
|
+
const once = JSON.stringify([url, e.workflow, e.secrets, e.input ?? null]);
|
|
2216
|
+
if (seen.has(once)) continue;
|
|
2217
|
+
seen.add(once);
|
|
2218
|
+
const warn = (why: string) => activity(io, `warning: dispatch "${e.workflow}" at ${url}: ${why} (ADR-0071)`);
|
|
2219
|
+
let res: Response;
|
|
2220
|
+
try {
|
|
2221
|
+
// No redirect is followed: the Effector's dispatch client follows none, so a door that
|
|
2222
|
+
// redirects is one it never reaches.
|
|
2223
|
+
res = await fetchImpl(`${url}/workflows/${encodeURIComponent(e.workflow)}`, {
|
|
2224
|
+
headers: { accept: "application/json" },
|
|
2225
|
+
redirect: "manual",
|
|
2226
|
+
signal: AbortSignal.timeout(10_000),
|
|
2227
|
+
});
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
warn(
|
|
2230
|
+
`no answer from this host (${(err as Error).message}) — the other Instance may be down, or reached only ` +
|
|
2231
|
+
'from inside its cluster; while no door answers, the dispatch settles "unreachable"',
|
|
2232
|
+
);
|
|
2233
|
+
continue;
|
|
2234
|
+
}
|
|
2235
|
+
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
2236
|
+
warn(
|
|
2237
|
+
`the door redirected (HTTP ${res.status}); the Effector follows no redirect, so a run of the dispatch is refused there`,
|
|
2238
|
+
);
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (res.status === 404) {
|
|
2242
|
+
warn(`no Workflow "${e.workflow}" there — a run of the dispatch is refused with the door's 404 until it has one`);
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (!res.ok) {
|
|
2246
|
+
warn(`the door answered HTTP ${res.status}`);
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
if (e.input === undefined) {
|
|
2250
|
+
activity(io, `dispatch "${e.workflow}" at ${url}: the door answers; the slot states no input schema to compare`);
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
// A Workflow detail carries its door as `input` (null: it declares none). Any other answer — a
|
|
2254
|
+
// page an ingress serves, another shape at another Kit version — is no door: not compared,
|
|
2255
|
+
// never compared as `{}`, which would call every slot field stripped.
|
|
2256
|
+
const detail: unknown = await res.json().catch(() => undefined);
|
|
2257
|
+
if (typeof detail !== "object" || detail === null || Array.isArray(detail) || !Object.hasOwn(detail, "input")) {
|
|
2258
|
+
warn("the door's answer is not a Workflow detail (no `input`); not compared");
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const compared = doorCompat(e.input, (detail as { input: unknown }).input);
|
|
2262
|
+
if (compared.ok === true) {
|
|
2263
|
+
activity(io, `dispatch "${e.workflow}" at ${url}: the door takes the slot's input (ADR-0071)`);
|
|
2264
|
+
} else if (compared.ok === "not-compared") {
|
|
2265
|
+
warn(`its input schema and the door are not compared — ${compared.why}`);
|
|
2266
|
+
} else {
|
|
2267
|
+
for (const fault of compared.faults) warn(`the door does not take the slot's input: ${fault}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** One provider of one check pod (ADR-0019, ADR-0059). */
|
|
2273
|
+
export type CheckedProvider = {
|
|
2274
|
+
/** The provider id, the specifier's part before the first `/`. */
|
|
2275
|
+
id: string;
|
|
2276
|
+
/** Every carried model of it that a Harness pod of this placement runs: a typo, a retired model
|
|
2277
|
+
* or a model the key has no access to is per model. */
|
|
2278
|
+
models: string[];
|
|
2279
|
+
/** The held secrets that are its keys in the check pod's Custodian; none when no pod of this
|
|
2280
|
+
* placement holds one, and the check then shows what a Turn there meets. */
|
|
2281
|
+
keys: string[];
|
|
2282
|
+
/** Where the calls go: the keys' bound hosts, else the provider's host as jr2 knows it. */
|
|
2283
|
+
hosts: string[];
|
|
2284
|
+
/** `harness.catalog.<id>.baseUrl`, when it moves the provider. */
|
|
2285
|
+
gateway?: string;
|
|
2286
|
+
/** The custom provider (`harness.provider`, ADR-0018): its key is optional, and its causes are
|
|
2287
|
+
* its own. */
|
|
2288
|
+
custom?: true;
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
/** One check pod: the providers that the Harness pods of one placement call. */
|
|
2292
|
+
export type CheckPod = {
|
|
2293
|
+
/** For a human: `the Sandbox nodes`, `the Instance Harness`, or both. */
|
|
2294
|
+
where: string;
|
|
2295
|
+
/** The node predicate of those pods (ADR-0052); none for the Instance Harness. */
|
|
2296
|
+
placement?: SandboxPlacement;
|
|
2297
|
+
/** The bindings of the checked keys: each a part of a held pod's manifest. */
|
|
2298
|
+
manifest: HeldManifest;
|
|
2299
|
+
providers: CheckedProvider[];
|
|
2300
|
+
};
|
|
2301
|
+
|
|
2302
|
+
/** One Harness pod as the walk places it: its held pod key, its class, the models it runs, and
|
|
2303
|
+
* the bindings it holds (none: its HTTPS goes direct, with no proxy). */
|
|
2304
|
+
type Seat = { pod: string; sandbox: boolean; models: readonly string[]; held: HeldManifest["secrets"] };
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Which providers the preflight checks, and in which pod (ADR-0019, ADR-0059). Every carried model
|
|
2308
|
+
* that a Harness pod runs is checked, in a pod with that pod's placement: a Workspace's model on the
|
|
2309
|
+
* Sandbox nodes, a `"none"` Agent's where the Instance Harness runs, so a node class whose egress is
|
|
2310
|
+
* closed fails here. The two are one check pod when their placements are the same. The custom provider
|
|
2311
|
+
* (`harness.provider`) goes the same way: its key is a held secret too (`JR2_PROVIDER_API_KEY`), so
|
|
2312
|
+
* the key never enters a pod spec, an argv or a script.
|
|
2313
|
+
*
|
|
2314
|
+
* In one placement, a provider's keys are the ones the first pod there that runs one of its models
|
|
2315
|
+
* holds (`MODEL_KEYS`, claimed by the provider). The check pod holds that pod's bindings of them and
|
|
2316
|
+
* reads their values where that pod reads them. A provider no pod there holds a key for is checked
|
|
2317
|
+
* all the same, with no key: a Turn there meets the same, and pi says so (`not configured`); a
|
|
2318
|
+
* signing credential in `harness.env` reaches the check as it reaches a Turn. Pure, for its test.
|
|
2319
|
+
*/
|
|
2320
|
+
export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readonly HeldPodObjects[]): CheckPod[] {
|
|
2321
|
+
const custom = config.harness?.provider;
|
|
2322
|
+
const heldOf = (pod: string) => pods.find((p) => p.pod === pod)?.manifest.secrets ?? [];
|
|
2323
|
+
const seats: Seat[] = [
|
|
2324
|
+
...walk.workspaces.map((ws) => {
|
|
2325
|
+
const pod = workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route });
|
|
2326
|
+
return { pod, sandbox: true, models: ws.pod.models, held: heldOf(pod) };
|
|
2327
|
+
}),
|
|
2328
|
+
...(walk.instanceHarness
|
|
2329
|
+
? [
|
|
2330
|
+
{
|
|
2331
|
+
pod: INSTANCE_HARNESS_HELD_POD,
|
|
2332
|
+
sandbox: false,
|
|
2333
|
+
models: walk.instanceHarness.models,
|
|
2334
|
+
held: heldOf(INSTANCE_HARNESS_HELD_POD),
|
|
2335
|
+
},
|
|
2336
|
+
]
|
|
2337
|
+
: []),
|
|
2338
|
+
];
|
|
2339
|
+
const sandbox = config.sandbox?.nodeSelector || config.sandbox?.tolerations?.length ? config.sandbox : undefined;
|
|
2340
|
+
const placementOf = (seat: Seat) => (seat.sandbox ? sandbox : undefined);
|
|
2341
|
+
// A check pod stands for one class of Harness pod: its nodes, and whether it holds a secret. A pod
|
|
2342
|
+
// that holds one sends its HTTPS through its Custodian (a tunnel for an unbound host); a pod that
|
|
2343
|
+
// holds none sends it direct. So the two are two check pods, even on the same nodes.
|
|
2344
|
+
const keyOf = (seat: Seat) => {
|
|
2345
|
+
const placement = placementOf(seat);
|
|
2346
|
+
return JSON.stringify({
|
|
2347
|
+
nodeSelector: placement?.nodeSelector ?? {},
|
|
2348
|
+
tolerations: placement?.tolerations ?? [],
|
|
2349
|
+
holds: seat.held.length > 0,
|
|
2350
|
+
});
|
|
2351
|
+
};
|
|
2352
|
+
const groups = new Map<string, { seats: Seat[]; placement?: SandboxPlacement }>();
|
|
2353
|
+
for (const seat of seats) {
|
|
2354
|
+
const key = keyOf(seat);
|
|
2355
|
+
const group = groups.get(key) ?? { seats: [], ...(placementOf(seat) ? { placement: placementOf(seat) } : {}) };
|
|
2356
|
+
group.seats.push(seat);
|
|
2357
|
+
groups.set(key, group);
|
|
2358
|
+
}
|
|
2359
|
+
const checkPods: CheckPod[] = [];
|
|
2360
|
+
for (const group of groups.values()) {
|
|
2361
|
+
const models: string[] = [];
|
|
2362
|
+
for (const seat of group.seats) for (const m of seat.models) if (!models.includes(m)) models.push(m);
|
|
2363
|
+
const checkPod: CheckPod = {
|
|
2364
|
+
where: [
|
|
2365
|
+
...(group.seats.some((s) => s.sandbox) ? ["the Sandbox nodes"] : []),
|
|
2366
|
+
...(group.seats.some((s) => !s.sandbox) ? ["the Instance Harness"] : []),
|
|
2367
|
+
].join(" and "),
|
|
2368
|
+
...(group.placement ? { placement: group.placement } : {}),
|
|
2369
|
+
// A class that holds a secret: the first pod's own held set, so the check pod's env (the proxy, the
|
|
2370
|
+
// Stand-ins) is that of a pod of the class; the keys the class's other pods hold join it below.
|
|
2371
|
+
manifest: { version: 1, secrets: [...group.seats[0]!.held] },
|
|
2372
|
+
providers: [],
|
|
2373
|
+
};
|
|
2374
|
+
for (const model of models) {
|
|
2375
|
+
const slash = model.indexOf("/");
|
|
2376
|
+
const id = slash > 0 ? model.slice(0, slash) : model;
|
|
2377
|
+
const known = checkPod.providers.find((p) => p.id === id);
|
|
2378
|
+
if (known) {
|
|
2379
|
+
known.models.push(model);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
const names =
|
|
2383
|
+
id === custom?.id
|
|
2384
|
+
? [PROVIDER_KEY_NAME]
|
|
2385
|
+
: Object.keys(MODEL_KEYS).filter((name) => MODEL_KEYS[name]!.providers.includes(id));
|
|
2386
|
+
const runs = group.seats.filter((s) => s.models.some((m) => m.startsWith(`${id}/`)));
|
|
2387
|
+
const holder = runs
|
|
2388
|
+
.map((s) => pods.find((p) => p.pod === s.pod))
|
|
2389
|
+
.find((p) => p?.manifest.secrets.some((b) => names.includes(b.name)));
|
|
2390
|
+
const bindings = holder?.manifest.secrets.filter((b) => names.includes(b.name)) ?? [];
|
|
2391
|
+
for (const b of bindings) {
|
|
2392
|
+
if (checkPod.manifest.secrets.some((h) => h.name === b.name)) continue;
|
|
2393
|
+
checkPod.manifest.secrets.push(b);
|
|
2394
|
+
}
|
|
2395
|
+
const gateway = config.harness?.catalog?.[id]?.baseUrl;
|
|
2396
|
+
const fallback =
|
|
2397
|
+
id === custom?.id
|
|
2398
|
+
? [new URL(custom.baseUrl).host]
|
|
2399
|
+
: gateway
|
|
2400
|
+
? [new URL(gateway).host]
|
|
2401
|
+
: names.flatMap((n) => (MODEL_KEYS[n]!.host ? [MODEL_KEYS[n]!.host!] : [])).slice(0, 1);
|
|
2402
|
+
checkPod.providers.push({
|
|
2403
|
+
id,
|
|
2404
|
+
...(id === custom?.id ? { custom: true } : {}),
|
|
2405
|
+
models: [model],
|
|
2406
|
+
keys: bindings.map((b) => b.name),
|
|
2407
|
+
hosts: bindings.length ? [...new Set(bindings.flatMap((b) => b.hosts.map(hostText)))] : fallback,
|
|
2408
|
+
...(gateway ? { gateway } : {}),
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
if (checkPod.providers.length) checkPods.push(checkPod);
|
|
2412
|
+
}
|
|
2413
|
+
return checkPods;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const hostText = (h: { host: string; port: number }): string => (h.port === 443 ? h.host : `${h.host}:${h.port}`);
|
|
2417
|
+
|
|
2418
|
+
/** How long a check pod may take to start: two cold image pulls (the Harness and the Custodian) and
|
|
2419
|
+
* Envoy's start. The check's own bound starts when its container runs. */
|
|
2420
|
+
const CHECK_START_SECONDS = 300;
|
|
2421
|
+
/** Each model's bound in the pod: its own budget in the check (`PREFLIGHT_MODEL_BUDGET_SECONDS` in
|
|
2422
|
+
* `@jr2/harness`'s `preflight-line.ts`, which a test holds this to), so one slow model never uses
|
|
2423
|
+
* the time of the others. Exported for that test. */
|
|
2424
|
+
export const CHECK_MODEL_SECONDS = 360;
|
|
2425
|
+
const CHECK_RUN_MARGIN_SECONDS = 30;
|
|
2426
|
+
/** A leftover a run left with no pod, older than this, is a dead run's, not a live one's. */
|
|
2427
|
+
const CHECK_LEFTOVER_SECONDS = 3600;
|
|
2428
|
+
|
|
2429
|
+
/**
|
|
2430
|
+
* The provider preflight (ADR-0019, ADR-0059): each check pod of `checkPodsOf`, one at a time — a
|
|
2431
|
+
* one-shot Harness-shaped pod, a Custodian that holds the providers' keys and the stock Harness
|
|
2432
|
+
* image's `provider-preflight.ts` — that demands one tool call of each model, on the path a Turn takes. So no
|
|
2433
|
+
* egress to the provider's host, a missing, wrong or empty key, a model no endpoint serves, and a
|
|
2434
|
+
* gateway that does not answer each fail HERE. Every check pod runs, a pod that cannot be applied
|
|
2435
|
+
* included, and every failure is in one error. A provider still busy after the retries, and a
|
|
2436
|
+
* model that answered but did not call a tool it was not forced to, are warnings (ADR-0064).
|
|
2437
|
+
*
|
|
2438
|
+
* Each pod reads its own objects (`checkPodObjects`), written from `values`, `leaves`, `trust`
|
|
2439
|
+
* and `harnessEnvData` — what the apply would write — and named for this run (`<run>-<n>`), so two
|
|
2440
|
+
* converges in one namespace never read or delete each other's. The ones of an earlier run of this
|
|
2441
|
+
* Instance that did not end go first (`removeCheckLeftovers`). A failed cleanup is a warning and
|
|
2442
|
+
* never hides the check's own answer.
|
|
2279
2443
|
*/
|
|
2280
|
-
async function
|
|
2444
|
+
async function preflightProviders(
|
|
2281
2445
|
io: Io,
|
|
2282
2446
|
kube: KubeAdmin,
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2447
|
+
opts: {
|
|
2448
|
+
checkPods: CheckPod[];
|
|
2449
|
+
/** `--skip-provider-preflight`: no check pod runs, and one warning line says so. */
|
|
2450
|
+
skip: boolean;
|
|
2451
|
+
name: string;
|
|
2452
|
+
namespace: string;
|
|
2453
|
+
ctx: { context?: string };
|
|
2454
|
+
harnessImage: string;
|
|
2455
|
+
custodianImage: string;
|
|
2456
|
+
harness?: JR2Config["harness"];
|
|
2457
|
+
caBundle: boolean;
|
|
2458
|
+
values: Record<string, string>;
|
|
2459
|
+
leaves: Record<string, Pem>;
|
|
2460
|
+
trust: Record<string, string>;
|
|
2461
|
+
harnessEnvData: Record<string, string>;
|
|
2462
|
+
priorityClassName: string;
|
|
2463
|
+
},
|
|
2288
2464
|
): Promise<void> {
|
|
2289
|
-
const
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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) {
|
|
2465
|
+
const { namespace, ctx } = opts;
|
|
2466
|
+
// First, and with no check to run too: the leftovers of an earlier run can hold a key.
|
|
2467
|
+
await removeCheckLeftovers(io, kube, opts.name, namespace, ctx);
|
|
2468
|
+
if (opts.skip) {
|
|
2469
|
+
// Never silent: a converge that checked nothing must not read as one that checked. A model that
|
|
2470
|
+
// two check pods would check is named once.
|
|
2471
|
+
const models = [...new Set(opts.checkPods.flatMap((p) => p.providers.flatMap((v) => v.models)))];
|
|
2302
2472
|
activity(
|
|
2303
2473
|
io,
|
|
2304
|
-
|
|
2474
|
+
"warning: provider: preflight skipped (--skip-provider-preflight) — " +
|
|
2475
|
+
(models.length ? `these models were not checked: ${models.join(", ")}` : "no model was checked"),
|
|
2305
2476
|
);
|
|
2306
2477
|
return;
|
|
2307
2478
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2479
|
+
if (!opts.checkPods.length) return;
|
|
2480
|
+
const run = randomBytes(4).toString("hex");
|
|
2481
|
+
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
|
|
2482
|
+
const faults: string[] = [];
|
|
2483
|
+
for (const [n, checkPod] of opts.checkPods.entries()) {
|
|
2484
|
+
const object = `${PROVIDER_PREFLIGHT}-${run}-${n}`;
|
|
2485
|
+
const models = checkPod.providers.flatMap((p) => p.models);
|
|
2486
|
+
/** The models whose result is already said, while the pod ran, and what it was: a final log
|
|
2487
|
+
* read that comes back empty must not turn a result already said into "not checked". */
|
|
2488
|
+
const said = new Set<string>();
|
|
2489
|
+
const live = new Map<string, CheckVerdict>();
|
|
2490
|
+
let lastLive = "";
|
|
2491
|
+
for (const p of checkPod.providers) {
|
|
2492
|
+
// No held key is not no credential: a credential that pi reads from `harness.env` (an AWS
|
|
2493
|
+
// role for Bedrock, say) reaches the check as it reaches a Turn.
|
|
2494
|
+
const why = p.custom
|
|
2495
|
+
? "harness.provider has no apiKey"
|
|
2496
|
+
: `no Harness pod there holds a key of "${p.id}"; a credential in harness.env still reaches it`;
|
|
2497
|
+
const holds = p.keys.length
|
|
2498
|
+
? `through a Custodian that holds ${p.keys.join(", ")} for ${p.hosts.join(", ")}`
|
|
2499
|
+
: checkPod.manifest.secrets.length
|
|
2500
|
+
? `with no held key (${why}), through the Custodian's tunnel, as those pods call it`
|
|
2501
|
+
: `with no held key (${why}), direct, as a pod that holds no secret calls it`;
|
|
2502
|
+
activity(io, `provider: checking ${p.models.join(", ")} on ${checkPod.where}, ${holds}`);
|
|
2503
|
+
}
|
|
2504
|
+
activity(
|
|
2505
|
+
io,
|
|
2506
|
+
`provider: the check pod has up to ${CHECK_START_SECONDS / 60} min to start, then up to ` +
|
|
2507
|
+
`${CHECK_MODEL_SECONDS / 60} min for ${models.length === 1 ? "its model" : `each of its ${models.length} models`}`,
|
|
2508
|
+
);
|
|
2509
|
+
const runSeconds = CHECK_MODEL_SECONDS * models.length + CHECK_RUN_MARGIN_SECONDS;
|
|
2510
|
+
// Built before the apply: a fault in jr2 here is jr2's, never the cluster's refusal.
|
|
2511
|
+
const manifest = checkPodObjects({
|
|
2512
|
+
name: opts.name,
|
|
2513
|
+
namespace,
|
|
2514
|
+
object,
|
|
2515
|
+
run,
|
|
2516
|
+
harnessImage: opts.harnessImage,
|
|
2517
|
+
custodianImage: opts.custodianImage,
|
|
2518
|
+
held: checkPod.manifest,
|
|
2519
|
+
values: opts.values,
|
|
2520
|
+
leaves: opts.leaves,
|
|
2521
|
+
trust: opts.trust,
|
|
2522
|
+
custodian: {
|
|
2523
|
+
bootstrap: JSON.stringify(custodianBootstrap(checkPod.manifest, orchestrator)),
|
|
2524
|
+
script: custodianScript(checkPod.manifest),
|
|
2525
|
+
},
|
|
2526
|
+
token: randomBytes(24).toString("hex"),
|
|
2527
|
+
harnessEnvData: opts.harnessEnvData,
|
|
2528
|
+
models,
|
|
2529
|
+
...(checkPod.placement ? { placement: checkPod.placement } : {}),
|
|
2530
|
+
priorityClassName: opts.priorityClassName,
|
|
2531
|
+
...(opts.harness ? { harness: opts.harness } : {}),
|
|
2532
|
+
caBundle: opts.caBundle,
|
|
2533
|
+
deadlineSeconds: CHECK_START_SECONDS + runSeconds + 60,
|
|
2534
|
+
});
|
|
2535
|
+
let end: PodEnd | undefined;
|
|
2536
|
+
let refused: string | undefined;
|
|
2311
2537
|
try {
|
|
2312
|
-
//
|
|
2313
|
-
|
|
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`);
|
|
2538
|
+
// Server-side: the trust bundles carry every root, past client-side apply's annotation.
|
|
2539
|
+
await applyFirstContact(kube, { manifest, serverSide: true, warn: (line) => activity(io, line), ...ctx });
|
|
2322
2540
|
} catch (err) {
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2541
|
+
// A quota, a Pod Security level or a webhook that refuses the pod: this check pod's fault alone.
|
|
2542
|
+
refused = `the cluster refused the check pod's objects: ${errText(err)}`;
|
|
2543
|
+
}
|
|
2544
|
+
try {
|
|
2545
|
+
if (refused === undefined) {
|
|
2546
|
+
end = await kube.awaitPodEnd({
|
|
2547
|
+
namespace,
|
|
2548
|
+
name: object,
|
|
2549
|
+
container: CHECK_CONTAINER,
|
|
2550
|
+
startSeconds: CHECK_START_SECONDS,
|
|
2551
|
+
runSeconds,
|
|
2552
|
+
onPending: (line) => activity(io, `provider: the check pod is not running yet — ${line}`),
|
|
2553
|
+
// Each model's result as it arrives: a check of several slow models is not silence.
|
|
2554
|
+
onLog: (log: string) => {
|
|
2555
|
+
lastLive = log;
|
|
2556
|
+
for (const [model, verdict] of checkVerdicts(log)) {
|
|
2557
|
+
live.set(model, verdict);
|
|
2558
|
+
if (said.has(model) || verdict.kind === "fault") continue;
|
|
2559
|
+
said.add(model);
|
|
2560
|
+
sayVerdict(io, model, verdict);
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
...ctx,
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
} finally {
|
|
2567
|
+
await removeCheckPod(io, kube, object, namespace, ctx);
|
|
2568
|
+
}
|
|
2569
|
+
// The last log read while the pod ran stands in for an empty final read.
|
|
2570
|
+
const output = end?.output || lastLive;
|
|
2571
|
+
const verdicts = checkVerdicts(output);
|
|
2572
|
+
const started = checkStarts(output);
|
|
2573
|
+
for (const p of checkPod.providers) {
|
|
2574
|
+
for (const model of p.models) {
|
|
2575
|
+
const verdict = verdicts.get(model) ?? live.get(model);
|
|
2576
|
+
if (verdict && verdict.kind !== "fault") {
|
|
2577
|
+
if (!said.has(model)) sayVerdict(io, model, verdict);
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const pod = () => podFault(end!, checkPod, opts);
|
|
2581
|
+
const why =
|
|
2582
|
+
verdict?.reason ??
|
|
2583
|
+
refused ??
|
|
2584
|
+
(started.has(model)
|
|
2585
|
+
? `the check pod stopped while it checked this model: ${pod()}`
|
|
2586
|
+
: `not checked: the check pod stopped before it reached this model: ${pod()}`);
|
|
2587
|
+
faults.push(providerFault(p, model, checkPod.where, why, checkPod.manifest.secrets.length > 0));
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (faults.length) throw new Error(faults.join("\n"));
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** How long the first apply in a new namespace waits for its `default` ServiceAccount: the
|
|
2595
|
+
* controller makes it a moment after the namespace, and until then a pod is refused. */
|
|
2596
|
+
const FIRST_CONTACT_TRIES = 10;
|
|
2597
|
+
const FIRST_CONTACT_PAUSE_MS = 1000;
|
|
2598
|
+
|
|
2599
|
+
/** `kube.apply`, tried again while the namespace has no `default` ServiceAccount yet. Any other
|
|
2600
|
+
* refusal is the answer. */
|
|
2601
|
+
async function applyFirstContact(kube: KubeAdmin, opts: Parameters<KubeAdmin["apply"]>[0]): Promise<void> {
|
|
2602
|
+
for (let n = 1; ; n++) {
|
|
2603
|
+
try {
|
|
2604
|
+
return await kube.apply(opts);
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
if (n >= FIRST_CONTACT_TRIES || !/serviceaccount "default" not found/i.test(errText(err))) throw err;
|
|
2607
|
+
await sleepMs(FIRST_CONTACT_PAUSE_MS);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** The trust bundles a check pod reads (`<object>-ca`), as `trustObject` writes them to `jr2-ca`. */
|
|
2613
|
+
function trustData(
|
|
2614
|
+
caBundle: string | undefined,
|
|
2615
|
+
bundles: { extra: string; bundle: string; upstream: string } | undefined,
|
|
2616
|
+
) {
|
|
2617
|
+
return {
|
|
2618
|
+
...(caBundle !== undefined ? { "ca.crt": caBundle } : {}),
|
|
2619
|
+
...(bundles ? { "extra.crt": bundles.extra, "bundle.crt": bundles.bundle, "upstream.crt": bundles.upstream } : {}),
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/** How long after it ended an earlier run's check pod is left alone: that run may still be reading
|
|
2624
|
+
* its log. Exported for its test. */
|
|
2625
|
+
export const CHECK_ENDED_MARGIN_SECONDS = 600;
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* The leftovers of earlier runs of THIS Instance (`preflightProviders`), by label: a check pod
|
|
2629
|
+
* past its `activeDeadlineSeconds` and `CHECK_ENDED_MARGIN_SECONDS`, in any phase; a check pod that
|
|
2630
|
+
* ended more than `CHECK_ENDED_MARGIN_SECONDS` ago; each with its objects; and each object whose
|
|
2631
|
+
* pod is gone and that is older than any check pod lives. A live pod, a pod that ended a short time
|
|
2632
|
+
* ago and another Instance's objects stay. Every failure here is a warning.
|
|
2633
|
+
*/
|
|
2634
|
+
async function removeCheckLeftovers(
|
|
2635
|
+
io: Io,
|
|
2636
|
+
kube: KubeAdmin,
|
|
2637
|
+
name: string,
|
|
2638
|
+
namespace: string,
|
|
2639
|
+
ctx: { context?: string },
|
|
2640
|
+
) {
|
|
2641
|
+
const selector = `${LABEL_PROVIDER_PREFLIGHT},${LABEL_INSTANCE}=${name}`;
|
|
2642
|
+
const since = (at: string | undefined) => (Date.now() - Date.parse(at ?? "")) / 1000;
|
|
2643
|
+
try {
|
|
2644
|
+
type CheckObject = KubeObject & {
|
|
2645
|
+
metadata: { creationTimestamp?: string };
|
|
2646
|
+
spec?: { activeDeadlineSeconds?: number };
|
|
2647
|
+
status?: {
|
|
2648
|
+
phase?: string;
|
|
2649
|
+
containerStatuses?: Array<{ name?: string; state?: { terminated?: { finishedAt?: string } } }>;
|
|
2650
|
+
};
|
|
2651
|
+
};
|
|
2652
|
+
const pods = await kube.listJson<CheckObject>({ kind: "pod", selector, namespace, ...ctx });
|
|
2653
|
+
const named = new Set(pods.map((p) => p.metadata.name));
|
|
2654
|
+
for (const p of pods) {
|
|
2655
|
+
// Past its deadline and the margin, in any phase: a pod no node took has no start time, so
|
|
2656
|
+
// the kubelet never applies `activeDeadlineSeconds` to it, and its Secret holds a key.
|
|
2657
|
+
const deadline = (p.spec?.activeDeadlineSeconds ?? 0) + CHECK_ENDED_MARGIN_SECONDS;
|
|
2658
|
+
if (since(p.metadata.creationTimestamp) > deadline) {
|
|
2659
|
+
await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
if (!["Succeeded", "Failed"].includes(p.status?.phase ?? "")) continue;
|
|
2663
|
+
// The check container, or `probe` in a pod that an older `jr2 up` made: the label finds both.
|
|
2664
|
+
const finished = p.status?.containerStatuses?.find((c) => c.name === CHECK_CONTAINER || c.name === "probe")?.state
|
|
2665
|
+
?.terminated?.finishedAt;
|
|
2666
|
+
if (!(since(finished ?? p.metadata.creationTimestamp) > CHECK_ENDED_MARGIN_SECONDS)) continue;
|
|
2667
|
+
await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
|
|
2668
|
+
}
|
|
2669
|
+
for (const kind of ["configmap", "secret"]) {
|
|
2670
|
+
for (const o of await kube.listJson<CheckObject>({ kind, selector, namespace, ...ctx })) {
|
|
2671
|
+
const owner = o.metadata.name.replace(/-(ca|tls|env)$/, "");
|
|
2672
|
+
if (named.has(owner) || !(since(o.metadata.creationTimestamp) > CHECK_LEFTOVER_SECONDS)) continue;
|
|
2673
|
+
await kube.deleteObject({ kind, name: o.metadata.name, namespace, wait: false, ...ctx });
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
activity(io, `warning: provider: the leftovers of an earlier preflight were not removed (${errText(err)})`);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** One check pod's objects, deleted; a failure is a warning, so it hides no check's answer. */
|
|
2682
|
+
async function removeCheckPod(io: Io, kube: KubeAdmin, object: string, namespace: string, ctx: { context?: string }) {
|
|
2683
|
+
for (const { kind, name } of checkPodObjectNames(object)) {
|
|
2684
|
+
try {
|
|
2685
|
+
await kube.deleteObject({ kind, name, namespace, wait: false, ...ctx });
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
activity(
|
|
2688
|
+
io,
|
|
2689
|
+
`warning: provider: ${kind} ${name} was not deleted (${errText(err)}) — the next \`jr2 up\` removes it`,
|
|
2330
2690
|
);
|
|
2331
2691
|
}
|
|
2332
2692
|
}
|
|
2333
2693
|
}
|
|
2334
2694
|
|
|
2695
|
+
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
2696
|
+
|
|
2697
|
+
/** A fault in which the provider refused the key or its rights: a 401 or a 403, in the words the
|
|
2698
|
+
* apis use. The provider answered, so the key is the likely cause, and the egress is not. */
|
|
2699
|
+
export function refusedKey(fault: string): boolean {
|
|
2700
|
+
return /\b40[13]\b|authentication|unauthori[sz]ed|permission|forbidden|invalid[ _-]?(x-)?api[ _-]?key/i.test(fault);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/** One model's result line (`preflight-line.ts` in `@jr2/harness`). */
|
|
2704
|
+
type CheckVerdict = { kind: "ok" } | { kind: "fault" | "busy" | "warn"; reason: string };
|
|
2705
|
+
|
|
2706
|
+
/** One model's result that does not fail the converge, said as it comes. */
|
|
2707
|
+
function sayVerdict(
|
|
2708
|
+
io: Io,
|
|
2709
|
+
model: string,
|
|
2710
|
+
verdict: { kind: "ok" } | { kind: "busy" | "warn" | "fault"; reason: string },
|
|
2711
|
+
) {
|
|
2712
|
+
if (verdict.kind === "ok") activity(io, `provider: ${model} reachable, and it completed a tool call`);
|
|
2713
|
+
else if (verdict.kind === "busy") {
|
|
2714
|
+
activity(io, `warning: provider: ${model} answered with the key, but is busy — ${verdict.reason} (ADR-0064)`);
|
|
2715
|
+
} else if (verdict.kind === "warn")
|
|
2716
|
+
activity(io, `warning: provider: ${model} answered with the key — ${verdict.reason}`);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/** The models whose check began (`start` lines, `preflight-line.ts`): a model with a start and no
|
|
2720
|
+
* verdict was on when the pod stopped; a model with neither was never reached. */
|
|
2721
|
+
export function checkStarts(output: string): Set<string> {
|
|
2722
|
+
const started = new Set<string>();
|
|
2723
|
+
for (const line of output.split("\n")) {
|
|
2724
|
+
const m = /^jr2\.preflight model=(\S+) start$/.exec(line.trim());
|
|
2725
|
+
if (m) started.add(m[1]!);
|
|
2726
|
+
}
|
|
2727
|
+
return started;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** Each model's verdict, off the check container's output lines (`preflight-line.ts` in `@jr2/harness`, held
|
|
2731
|
+
* to this parser by a test). A line that is not a result is not a verdict. */
|
|
2732
|
+
export function checkVerdicts(output: string): Map<string, CheckVerdict> {
|
|
2733
|
+
const verdicts = new Map<string, CheckVerdict>();
|
|
2734
|
+
for (const line of output.split("\n")) {
|
|
2735
|
+
const m = /^jr2\.preflight model=(\S+) (?:(ok)|(fault|busy|warn)=(.*))$/.exec(line.trim());
|
|
2736
|
+
if (!m) continue;
|
|
2737
|
+
verdicts.set(m[1]!, m[2] ? { kind: "ok" } : { kind: m[3] as "fault" | "busy" | "warn", reason: m[4]! });
|
|
2738
|
+
}
|
|
2739
|
+
return verdicts;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2335
2742
|
/**
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
* empty tool_calls, which read as "the endpoint cannot call tools" when it could.
|
|
2743
|
+
* Why a check pod said nothing of a model, from how it ended (`PodEnd`): the hint follows the reason
|
|
2744
|
+
* the kubelet gave, and the Custodian's own refusal line is quoted when it has one. Exported for its
|
|
2745
|
+
* test.
|
|
2340
2746
|
*/
|
|
2341
|
-
function
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2747
|
+
export function podFault(
|
|
2748
|
+
end: PodEnd,
|
|
2749
|
+
checkPod: Pick<CheckPod, "where">,
|
|
2750
|
+
images: { harnessImage: string; custodianImage: string },
|
|
2751
|
+
): string {
|
|
2752
|
+
const refusal = /jr2\.custodian refuse secret=\S+ reason=[\w-]+/.exec(end.custodian)?.[0];
|
|
2753
|
+
const said = end.reasons.length ? ` (${end.reasons.join("; ")})` : "";
|
|
2754
|
+
const stuck = end.stuck ?? "";
|
|
2755
|
+
if (/ErrImagePull|ImagePullBackOff|InvalidImageName/.test(stuck)) {
|
|
2756
|
+
return (
|
|
2757
|
+
`the check pod cannot start: ${stuck}${said} — a node on ${checkPod.where} cannot pull ` +
|
|
2758
|
+
`${stuck.startsWith("custodian") ? images.custodianImage : images.harnessImage}; check \`registry\` or ` +
|
|
2759
|
+
"`kitRegistry`, and that those nodes can pull from it"
|
|
2760
|
+
);
|
|
2761
|
+
}
|
|
2762
|
+
if (/CreateContainerConfigError/.test(stuck)) {
|
|
2763
|
+
// The env of the check container: what `harness.env` and `harness.envFrom` name.
|
|
2764
|
+
return (
|
|
2765
|
+
`the check pod cannot start: ${stuck}${said} — a Secret, a ConfigMap or a key that the Harness env names ` +
|
|
2766
|
+
"is missing (a `harness.envFrom` Secret, or a `harness.env` valueFrom)"
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
if (stuck.startsWith("FailedMount")) {
|
|
2770
|
+
// A volume of the Custodian: a held secret's `valueFrom` Secret, or its key.
|
|
2771
|
+
return (
|
|
2772
|
+
`the check pod cannot start: ${stuck} — a Secret or a key that a \`secrets\` valueFrom names is missing ` +
|
|
2773
|
+
"in the Instance's namespace"
|
|
2774
|
+
);
|
|
2775
|
+
}
|
|
2776
|
+
const oom = [stuck, ...end.reasons].find((r) => /OOMKilled/.test(r));
|
|
2777
|
+
if (oom) {
|
|
2778
|
+
const custodian = oom.startsWith("custodian");
|
|
2779
|
+
return (
|
|
2780
|
+
`the check pod ran out of memory: ${oom}${said} — ` +
|
|
2781
|
+
(custodian
|
|
2782
|
+
? `its Custodian has the Instance Harness Custodian's limit (${KIT_POD_SIZES.instanceCustodian.limits.memory})`
|
|
2783
|
+
: `the check container's limit is ${KIT_POD_SIZES.checkContainer.limits.memory}`) +
|
|
2784
|
+
"; a jr2 defect, not a fault of the provider"
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
if (refusal || stuck.startsWith("custodian")) {
|
|
2788
|
+
return (
|
|
2789
|
+
`the check pod's Custodian did not start: ${refusal ?? stuck}${said} — a Custodian stops at start on a held ` +
|
|
2790
|
+
"value that is missing, empty or not printable ASCII"
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
if (end.phase === "timeout") {
|
|
2794
|
+
return end.started
|
|
2795
|
+
? `the check did not end in time${said}`
|
|
2796
|
+
: `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`;
|
|
2797
|
+
}
|
|
2798
|
+
if (!end.output.trim()) {
|
|
2799
|
+
return `the check pod ended ${end.phase}${said}, and jr2 up could not read its log — run \`jr2 up\` again`;
|
|
2800
|
+
}
|
|
2801
|
+
const tail = end.output.trim().split("\n").filter(Boolean).slice(-5).join("; ");
|
|
2802
|
+
return `the check pod ended ${end.phase} before it checked this model${said} — it said: ${tail}`;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/** One model's preflight failure: the model, the host, and the likely causes. */
|
|
2806
|
+
function providerFault(p: CheckedProvider, model: string, where: string, fault: string, custodian: boolean): string {
|
|
2807
|
+
const hosts = p.hosts.join(", ") || `the host of "${p.id}"`;
|
|
2808
|
+
// A model that loads on first use (Ollama, a large vLLM model) can take longer than the bound.
|
|
2809
|
+
// Only where jr2 cannot know the endpoint: the custom provider, or a gateway.
|
|
2810
|
+
const slow =
|
|
2811
|
+
(p.custom || p.gateway) && /no answer in \d+ s|no verdict in \d+ s|time ran out/.test(fault)
|
|
2812
|
+
? " - a model that loads on first use can take longer than the check waits: run `jr2 up` again once it has loaded\n"
|
|
2813
|
+
: "";
|
|
2814
|
+
if (p.custom) {
|
|
2815
|
+
// The custom provider's own causes (ADR-0018): an endpoint jr2 knows nothing about but its url.
|
|
2816
|
+
return (
|
|
2817
|
+
`provider preflight failed for model "${model}" against ${hosts} (from a pod on ${where}, as a Turn calls it): ` +
|
|
2818
|
+
`${fault}\n` +
|
|
2819
|
+
slow +
|
|
2820
|
+
// A refused key: the endpoint answered, so the key comes first and the reach is not it.
|
|
2821
|
+
(refusedKey(fault)
|
|
2822
|
+
? (p.keys.length
|
|
2823
|
+
? ` - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)\n`
|
|
2824
|
+
: " - the endpoint wants a key: set harness.provider.apiKey\n") +
|
|
2825
|
+
" - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)"
|
|
2826
|
+
: " - localhost never works from a pod; use a LAN address\n" +
|
|
2827
|
+
" - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)\n" +
|
|
2828
|
+
" - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser\n" +
|
|
2829
|
+
' - stop reason "length" means the model ran out of tokens before the call (a thinking model reasons first)' +
|
|
2830
|
+
(p.keys.length ? `\n - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)` : ""))
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
const key = p.keys.length
|
|
2834
|
+
? ` - the held secret ${p.keys.join(" or ")} may be empty, wrong, or without access to this model (\`secrets\` in jr2.config.ts)\n`
|
|
2835
|
+
: ` - no Harness pod on ${where} holds a key of "${p.id}": add its key to \`secrets\`` +
|
|
2836
|
+
(Object.keys(MODEL_KEYS).some((n) => MODEL_KEYS[n]!.providers.includes(p.id))
|
|
2837
|
+
? ` (${Object.keys(MODEL_KEYS)
|
|
2838
|
+
.filter((n) => MODEL_KEYS[n]!.providers.includes(p.id))
|
|
2839
|
+
.join(" or ")}), or set in harness.env the credential that pi reads for it\n`
|
|
2840
|
+
: `, or set in harness.env the credential that pi reads for it; jr2 knows no key name of "${p.id}" ` +
|
|
2841
|
+
"(when pi does not know the id either, the line above says that it resolves to nothing)\n");
|
|
2842
|
+
// The likely cause first: a provider that refused the key answered, so the egress is not it.
|
|
2843
|
+
const egress = ` - the pods may have no egress to ${hosts} (a NetworkPolicy, a firewall, no route out of the cluster)\n`;
|
|
2844
|
+
const causes = refusedKey(fault) ? key : egress + key;
|
|
2346
2845
|
return (
|
|
2347
|
-
`
|
|
2348
|
-
|
|
2349
|
-
`
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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");`
|
|
2846
|
+
`provider preflight failed for model "${model}" toward ${hosts} (from a pod on ${where}, ` +
|
|
2847
|
+
`${custodian ? "through a Custodian" : "direct"}, ` +
|
|
2848
|
+
`as a Turn calls it): ${fault}\n` +
|
|
2849
|
+
slow +
|
|
2850
|
+
causes +
|
|
2851
|
+
(p.gateway
|
|
2852
|
+
? ` - harness.catalog.${p.id}.baseUrl (${p.gateway}) may not answer, or may not serve "${model.slice(p.id.length + 1)}" ` +
|
|
2853
|
+
"(a LiteLLM model_name alias)\n"
|
|
2854
|
+
: "") +
|
|
2855
|
+
" - a private CA on that path must be in harness.caBundle"
|
|
2361
2856
|
);
|
|
2362
2857
|
}
|
|
2363
2858
|
|
|
@@ -2371,31 +2866,3 @@ function noteDeferred(io: Io, repos: CarriedRepo[]): void {
|
|
|
2371
2866
|
);
|
|
2372
2867
|
}
|
|
2373
2868
|
}
|
|
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
|
-
}
|