@jr2/cli 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +22 -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 +63 -11
- package/src/commands/up.ts +905 -384
- package/src/commands/version.ts +9 -13
- package/src/deploy.ts +394 -48
- package/src/index.ts +26 -0
- package/src/instance-check.ts +313 -0
- package/src/instance.ts +3 -9
- package/src/kit-version.ts +27 -17
- package/src/kube.ts +334 -67
- package/src/landlock.ts +129 -0
- 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,19 @@ import {
|
|
|
102
100
|
harnessTokenDigest,
|
|
103
101
|
formatCpu,
|
|
104
102
|
formatMemory,
|
|
105
|
-
|
|
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
|
-
|
|
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 {
|
|
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 } =
|
|
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
|
-
// ---
|
|
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
|
|
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));
|
|
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
|
|
716
|
-
await
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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(
|
|
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
|
-
// ---
|
|
1005
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
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
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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
|
-
//
|
|
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`);
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
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
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
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
|
-
`
|
|
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");`
|
|
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
|
-
}
|