@jr2/cli 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +265 -23
- package/package.json +5 -5
- package/src/agents-md.ts +149 -0
- package/src/assembly.ts +432 -0
- package/src/build.ts +145 -290
- package/src/cli.ts +20 -41
- package/src/client.ts +144 -33
- package/src/command-table.ts +641 -0
- package/src/commands/check.ts +37 -0
- package/src/commands/down.ts +7 -16
- package/src/commands/gc.ts +2 -11
- package/src/commands/init.ts +82 -19
- package/src/commands/kit.ts +6 -11
- package/src/commands/logs.ts +13 -9
- package/src/commands/run.ts +70 -17
- package/src/commands/runs.ts +83 -6
- package/src/commands/send.ts +22 -23
- package/src/commands/status.ts +134 -22
- package/src/commands/up.ts +1704 -386
- package/src/commands/version.ts +26 -25
- package/src/deploy.ts +880 -101
- package/src/effector-image.ts +133 -0
- package/src/index.ts +26 -0
- package/src/instance-check.ts +309 -0
- package/src/instance.ts +3 -9
- package/src/kit-version.ts +27 -17
- package/src/kube.ts +439 -70
- package/src/nodes.ts +33 -2
- package/src/output.ts +1 -1
- package/src/repo-sweep.ts +1 -1
- package/src/sweep.ts +30 -15
package/src/commands/up.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (ADR-
|
|
12
|
-
//
|
|
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
|
|
11
|
+
// running Orchestrator (ADR-0069) → a report of live workspaces still on an older image.
|
|
12
|
+
//
|
|
13
|
+
// The Assembly is staged EARLY and published LAST (ADR-0069). Early, so its refusals (a second copy
|
|
14
|
+
// of a Kit package, a Kit version mismatch, a tree that names its staging path) come before any
|
|
15
|
+
// cluster spend. Last, because the door's own checks are true only once the cluster work is done:
|
|
16
|
+
// the image map holds every `file:` digest, the Secrets exist, and the Orchestrator runs the Kit
|
|
17
|
+
// version the Assembly names. A Machine edit therefore rolls nothing: it is a new Assembly, not a
|
|
18
|
+
// new pod template. The Orchestrator creates a Repo resource per
|
|
19
|
+
// Repo the walk found bound, at boot and at each publish, and the cache agent clones it onto a node on first need
|
|
20
|
+
// (ADR-0051); every provider a carried model names is preflighted from inside the cluster. A bound ssh url
|
|
21
|
+
// asks where its deploy key comes from (ADR-0047), and a converge that GENERATED one ends by saying
|
|
22
|
+
// so — the key is dead until a human registers it.
|
|
13
23
|
//
|
|
14
24
|
// The typecheck is FIRST and is a gate (ADR-0050): a Machine names its Agents, its composed
|
|
15
25
|
// Machines, and its Repo Slots by string, and since ADR-0049 those strings are typed, so a wrong
|
|
@@ -17,18 +27,21 @@
|
|
|
17
27
|
// the one converge-time refusal the compiler cannot make (ADR-0051): a registered Machine whose
|
|
18
28
|
// Repo Slot is still open has nothing bound where the run would need a url, and a `workflows/`
|
|
19
29
|
// export has no type to hang that on. Nothing is built, and nothing on the cluster is touched,
|
|
20
|
-
// 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.
|
|
21
33
|
//
|
|
22
34
|
// Images (ADR-0038, as amended by ADR-0045): `jr2 up` builds every image it deploys, and every tag is
|
|
23
35
|
// a content address of (its own inputs × the platform set it was built for) — `<hash>-<arch>`, with
|
|
24
36
|
// `--platform` passed explicitly on every build. The cluster's schedulable nodes choose that set
|
|
25
37
|
// (`platforms` in the config overrides absolutely), so the daemon default and
|
|
26
38
|
// `DOCKER_DEFAULT_PLATFORM` steer nothing any more. In a kit CHECKOUT the built set includes the
|
|
27
|
-
// Harness and the
|
|
39
|
+
// Harness, the operator and the Orchestrator (ADR-0069); installed from npm those sources do not resolve and the published
|
|
28
40
|
// `<kitversion>` refs are used with no docker at all (they are multi-arch manifest lists, so they
|
|
29
41
|
// take no suffix and never had this hole). There is no `images` config block and no env escape
|
|
30
42
|
// hatch — nobody gets to point a real cluster at a hand-picked Harness (ADR-0027's no-eject-hatch,
|
|
31
|
-
// enforced rather than stated).
|
|
43
|
+
// enforced rather than stated). An installed Instance with no `workspace()` therefore needs no
|
|
44
|
+
// docker at all: it builds nothing, and its Workflows ride the Assembly (ADR-0069).
|
|
32
45
|
// The converged name→ref map is stamped on the Orchestrator Deployment and diffed on the next run,
|
|
33
46
|
// so a steady-state converge spends directory walks and no docker. When that record is silent (a
|
|
34
47
|
// fresh namespace), the host daemon's labeled listing answers the BUILD question instead
|
|
@@ -46,103 +59,156 @@
|
|
|
46
59
|
// (`--yes` for CI); objects labeled as a DIFFERENT instance → refuse.
|
|
47
60
|
|
|
48
61
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
62
|
+
import { setTimeout as sleepMs } from "node:timers/promises";
|
|
49
63
|
import { open, readdir, readFile } from "node:fs/promises";
|
|
50
64
|
import { homedir } from "node:os";
|
|
51
|
-
import { basename, join } from "node:path";
|
|
52
|
-
import {
|
|
65
|
+
import { basename, join, relative } from "node:path";
|
|
66
|
+
import { fileURLToPath } from "node:url";
|
|
53
67
|
import {
|
|
54
68
|
custodianBootstrap,
|
|
55
69
|
custodianRef,
|
|
56
70
|
custodianScript,
|
|
57
|
-
|
|
71
|
+
CUSTODIAN_PROGRAM,
|
|
58
72
|
defaultImageContext,
|
|
59
73
|
heldSecretsOf,
|
|
60
74
|
HELD_CA_SECRET,
|
|
61
|
-
HELD_SECRETS_SECRET,
|
|
62
75
|
HELD_TLS_SECRET,
|
|
76
|
+
INSTANCE_CUSTODIAN_CONFIGMAP,
|
|
77
|
+
INSTANCE_HARNESS_HELD_POD,
|
|
63
78
|
ORCHESTRATOR_PORT,
|
|
64
79
|
imageContextDigest,
|
|
65
80
|
isSshUrl,
|
|
66
81
|
loadConfig,
|
|
67
|
-
|
|
82
|
+
doorCompat,
|
|
68
83
|
matchCredential,
|
|
84
|
+
packAssembly,
|
|
85
|
+
configMapDigest,
|
|
86
|
+
MOUNTED_CONFIGMAPS,
|
|
69
87
|
partsOf,
|
|
88
|
+
LABEL_EFFECTOR_POD,
|
|
89
|
+
LABEL_GIT_PUSH,
|
|
90
|
+
LABEL_GIT_CLONE,
|
|
91
|
+
gitCloneSecretsOf,
|
|
92
|
+
gitPushPlansOf,
|
|
93
|
+
repoIdentity,
|
|
94
|
+
type GitPushManifest,
|
|
95
|
+
type SandboxEffectorPlan,
|
|
96
|
+
secretPlacementsOf,
|
|
97
|
+
storedValueLength,
|
|
98
|
+
effectorKey,
|
|
99
|
+
targetText,
|
|
70
100
|
harnessTokenDigest,
|
|
101
|
+
formatCpu,
|
|
102
|
+
formatMemory,
|
|
71
103
|
sandboxToken,
|
|
72
|
-
type
|
|
104
|
+
type CarriedEffect,
|
|
73
105
|
type CarriedImage,
|
|
74
106
|
type CarriedRepo,
|
|
75
107
|
type HeldManifest,
|
|
108
|
+
type HeldPodPlan,
|
|
76
109
|
type ImageRefs,
|
|
110
|
+
MODEL_KEYS,
|
|
111
|
+
PROVIDER_KEY_NAME,
|
|
112
|
+
type PlacementWalk,
|
|
113
|
+
type SandboxPlacement,
|
|
114
|
+
workspaceHeldPod,
|
|
77
115
|
type JR2Config,
|
|
78
|
-
} from "@jr2/orchestrator";
|
|
116
|
+
} from "@jr2/orchestrator/internal";
|
|
117
|
+
import { stageAssembly, stagedCopyOf } from "../assembly.ts";
|
|
79
118
|
import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
|
|
80
119
|
import {
|
|
81
120
|
assertEmulation,
|
|
82
121
|
buildSandboxImage,
|
|
83
122
|
choosePlatforms,
|
|
84
123
|
detectKitCheckout,
|
|
85
|
-
|
|
124
|
+
isMissingHostImage,
|
|
86
125
|
kitImageBuild,
|
|
87
126
|
kitImageRefs,
|
|
88
127
|
normalizeRef,
|
|
89
|
-
platformSuffix,
|
|
90
128
|
pnpmDockerBuild,
|
|
91
129
|
publishedKitRefs,
|
|
92
130
|
sandboxImageTag,
|
|
93
|
-
stageInstanceBundle,
|
|
94
|
-
INSTANCE_DOCKERFILE,
|
|
95
131
|
KIT_IMAGE_HOME,
|
|
96
132
|
type BuildPort,
|
|
133
|
+
type ImageFiles,
|
|
97
134
|
type KitImageName,
|
|
98
135
|
type KitImageRefs,
|
|
99
136
|
} from "../build.ts";
|
|
100
137
|
import {
|
|
101
138
|
ANNOTATION_IMAGES,
|
|
102
139
|
compareVersions,
|
|
140
|
+
INSTANCE_HARNESS_DRAIN_SECONDS,
|
|
141
|
+
INSTANCE_HARNESS_PODS_SERVICE,
|
|
142
|
+
INSTANCE_HARNESS_PVC,
|
|
103
143
|
INSTANCE_HARNESS_SERVICE,
|
|
104
144
|
instanceHarnessObjects,
|
|
145
|
+
instanceEffectorObjects,
|
|
146
|
+
effectorDigest,
|
|
147
|
+
storedValueDigest,
|
|
148
|
+
type ValueDigest,
|
|
149
|
+
effectorGraceSeconds,
|
|
150
|
+
INSTANCE_EFFECTOR,
|
|
105
151
|
instanceObjects,
|
|
152
|
+
KIT_POD_SIZES,
|
|
106
153
|
KIT_VERSION,
|
|
107
|
-
LABEL_HASH,
|
|
108
154
|
LABEL_INSTANCE,
|
|
109
155
|
LABEL_VERSION,
|
|
110
156
|
OPERATOR_DEPLOYMENT,
|
|
111
157
|
OPERATOR_NAMESPACE,
|
|
112
158
|
OPERATOR_SELECTOR,
|
|
113
159
|
operatorManifest,
|
|
160
|
+
priorityClassNames,
|
|
161
|
+
priorityClassObjects,
|
|
114
162
|
REPO_CACHE,
|
|
115
163
|
trustObject,
|
|
164
|
+
holdsAny,
|
|
165
|
+
LABEL_HELD_POD,
|
|
116
166
|
type HeldObjects,
|
|
167
|
+
type CustodianFiles,
|
|
168
|
+
type HeldPodObjects,
|
|
169
|
+
checkPodObjects,
|
|
170
|
+
LABEL_PROVIDER_PREFLIGHT,
|
|
171
|
+
checkPodObjectNames,
|
|
172
|
+
PROVIDER_PREFLIGHT,
|
|
173
|
+
CHECK_CONTAINER,
|
|
117
174
|
} from "../deploy.ts";
|
|
175
|
+
import { syncAgentsMd } from "../agents-md.ts";
|
|
118
176
|
import { resolveRoot } from "../instance.ts";
|
|
177
|
+
import { resolvePackage } from "../kit-version.ts";
|
|
178
|
+
import { effectorImagePaths, effectorImageRefusal, STOCK_HARNESS_IMAGE, type PushForm } from "../effector-image.ts";
|
|
119
179
|
import {
|
|
180
|
+
kubeFloorRefusal,
|
|
181
|
+
podResourcesProbe,
|
|
120
182
|
kubectlAdmin,
|
|
183
|
+
kubectlKube,
|
|
121
184
|
rolloutFailure,
|
|
185
|
+
KUBE_FLOOR,
|
|
122
186
|
ORCHESTRATOR_SERVICE,
|
|
123
187
|
type KubeAdmin,
|
|
124
188
|
type KubeObject,
|
|
189
|
+
type PodEnd,
|
|
125
190
|
type RolloutTarget,
|
|
126
191
|
} from "../kube.ts";
|
|
127
|
-
import { buildNodes, sandboxNodes, type NodeObject } from "../nodes.ts";
|
|
192
|
+
import { anyNodeHolds, buildNodes, sandboxNodes, type NodeObject } from "../nodes.ts";
|
|
128
193
|
import { activity, chooseOrBail, confirmOrBail, promptLine, readSecretInput, type Io } from "../output.ts";
|
|
129
194
|
import { kindCluster, sweepImages } from "../sweep.ts";
|
|
130
|
-
import {
|
|
195
|
+
import { chartFilesIn, checkInstance } from "../instance-check.ts";
|
|
196
|
+
import { parseCommand } from "../command-table.ts";
|
|
197
|
+
|
|
198
|
+
/** How many times one converge rebuilds a host image a sweep took before it uses it (ADR-0039).
|
|
199
|
+
* A bound, because a tag that is gone again after two rebuilds is not a race to wait out: something
|
|
200
|
+
* on the host removes it as fast as it is built, and docker's own error is the honest report. */
|
|
201
|
+
const HOST_REBUILDS = 2;
|
|
131
202
|
|
|
132
203
|
export async function up(args: string[], io: Io): Promise<number> {
|
|
133
|
-
const { values } =
|
|
134
|
-
args,
|
|
135
|
-
allowPositionals: true,
|
|
136
|
-
strict: false,
|
|
137
|
-
options: {
|
|
138
|
-
yes: { type: "boolean" },
|
|
139
|
-
force: { type: "boolean" },
|
|
140
|
-
namespace: { type: "string", short: "n" },
|
|
141
|
-
context: { type: "string" },
|
|
142
|
-
},
|
|
143
|
-
});
|
|
204
|
+
const { values } = parseCommand("up", args);
|
|
144
205
|
|
|
145
206
|
const root = resolveRoot(io.cwd);
|
|
207
|
+
// --- the AGENTS.md block (ADR-0072): before anything can refuse, because it describes the Kit the
|
|
208
|
+
// Instance resolves, not the cluster, so a converge that fails still leaves the index current.
|
|
209
|
+
const kit = resolvePackage("@jr2/orchestrator", root);
|
|
210
|
+
if (kit) await syncAgentsMd(io, root, kit.root, kit.version);
|
|
211
|
+
|
|
146
212
|
const config = (await loadConfig(root)) ?? {};
|
|
147
213
|
const name = config.name ?? basename(root);
|
|
148
214
|
const namespace = (values.namespace as string | undefined) ?? name;
|
|
@@ -155,73 +221,99 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
155
221
|
|
|
156
222
|
activity(io, `jr2 up — instance "${name}" → context ${context} / namespace ${namespace}`);
|
|
157
223
|
|
|
158
|
-
// ---
|
|
224
|
+
// --- the Instance check (ADR-0050): the typecheck and the Machine walk, shared with `jr2 check` -
|
|
159
225
|
// A REFUSAL, not a warning, and it comes before the namespace apply as well as before the builds:
|
|
160
|
-
// the names a Machine carries
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
// the names a Machine carries are typed since ADR-0049/0051, so the compiler's answer is the
|
|
227
|
+
// author's editor's answer; the walk then refuses what the compiler cannot see (an Open part, a
|
|
228
|
+
// Chart file, a dispatch, an Effect secret, a Size). `jr2 check` runs the same function, so an
|
|
229
|
+
// Assistant that may not run `jr2 up` gets the answer of this converge.
|
|
230
|
+
const checked = await checkInstance(io, root, config);
|
|
231
|
+
if (!checked) return 1;
|
|
232
|
+
const { workflows, carried, placementWalk, sizes, chartFiles } = checked;
|
|
233
|
+
const { agents, images: contexts } = carried;
|
|
234
|
+
|
|
235
|
+
// --- the Assembly (ADR-0069): staged here, published last ---------------------------------------
|
|
236
|
+
// Staged BEFORE any cluster spend, so its refusals cost nothing: a second copy of a Kit package
|
|
237
|
+
// (ADR-0054's silent-empty-Menus failure), a Kit version other than this CLI's (ADR-0056), a tree
|
|
238
|
+
// that names its own staging path (ADR-0038). Packed at once and the scratch tree removed: the
|
|
239
|
+
// archive is what the door receives, and no later layer reads the tree.
|
|
240
|
+
const build = io.build ?? pnpmDockerBuild;
|
|
241
|
+
activity(io, "assembly: staging");
|
|
242
|
+
const staged = await stageAssembly(build, root);
|
|
243
|
+
let assembly: { hash: string; kitVersion: string; workflows: string[]; tar: Buffer };
|
|
244
|
+
// The scaffolded `images/default` is a PATH CONVENTION, not discovery (ADR-0049/0050): exactly
|
|
245
|
+
// one path is checked, and it is keyed `default` rather than by digest, because that reserved
|
|
246
|
+
// key IS the middle leg of the resolution chain a `workspace()` that names no image lands on.
|
|
247
|
+
const fallback = await defaultImageContext(root);
|
|
248
|
+
// Every `file:` context is keyed by the digest of its STAGED copy (ADR-0037, ADR-0069): the
|
|
249
|
+
// bytes the door checks against the image map and a provision resolves, which the Instance
|
|
250
|
+
// folder's own copy need not equal (staging drops `node_modules`, `.git`, `.env`, `.npmrc` at
|
|
251
|
+
// every depth). The build below still reads the folder; only the key comes from here. A context
|
|
252
|
+
// the Assembly does not carry is refused now, before any cluster spend.
|
|
253
|
+
let contextDigests: Map<string, string>;
|
|
254
|
+
// The Chart files the staged Assembly carries (ADR-0070): Workflows the door registers too.
|
|
255
|
+
let stagedCharts: string[] = [];
|
|
256
|
+
try {
|
|
257
|
+
assembly = {
|
|
258
|
+
hash: staged.hash,
|
|
259
|
+
kitVersion: staged.kitVersion,
|
|
260
|
+
workflows: staged.workflows,
|
|
261
|
+
tar: await packAssembly(staged.dir),
|
|
262
|
+
};
|
|
263
|
+
contextDigests = await stagedContextDigests(staged.dir, root, [
|
|
264
|
+
...(fallback ? [fallback] : []),
|
|
265
|
+
...contexts.map((image: CarriedImage) => image.dir),
|
|
266
|
+
]);
|
|
267
|
+
stagedCharts = await chartFilesIn(staged.dir);
|
|
268
|
+
} finally {
|
|
269
|
+
await staged.dispose();
|
|
270
|
+
}
|
|
271
|
+
// The walk above loaded the Instance folder; the door loads the Assembly (ADR-0069). The two must
|
|
272
|
+
// hold the same Workflows, or a Workflow the walk built and placed for is absent at the door, and
|
|
273
|
+
// `jr2 run` names it unknown after a converge that said it succeeded.
|
|
274
|
+
// A Chart file is a Workflow too (ADR-0070), `<wrapper>/<name>`, at `workflows/<wrapper>/<name>.chart.json`.
|
|
275
|
+
const walked = [...workflows.map((w) => w.name), ...chartFiles];
|
|
276
|
+
const inAssembly = [...assembly.workflows, ...stagedCharts];
|
|
277
|
+
const absent = walked.filter((n) => !inAssembly.includes(n));
|
|
278
|
+
const extra = inAssembly.filter((n) => !walked.includes(n));
|
|
279
|
+
const fileOf = (n: string) => (n.includes("/") ? `workflows/${n}.chart.json` : `workflows/${n}.ts`);
|
|
280
|
+
if (absent.length || extra.length) {
|
|
281
|
+
activity(
|
|
282
|
+
io,
|
|
283
|
+
`refusing: the staged Assembly does not hold the Workflows this folder holds (ADR-0069)` +
|
|
284
|
+
(absent.length ? `; absent from the Assembly: ${absent.map(fileOf).join(", ")}` : "") +
|
|
285
|
+
(extra.length ? `; only in the Assembly: ${extra.map(fileOf).join(", ")}` : "") +
|
|
286
|
+
` — the Assembly holds what the Instance's install keeps; check the \`files:\` list in package.json`,
|
|
287
|
+
);
|
|
169
288
|
return 1;
|
|
170
289
|
}
|
|
290
|
+
activity(
|
|
291
|
+
io,
|
|
292
|
+
`assembly: ${assembly.hash} (kit v${assembly.kitVersion}; ${assembly.workflows.length} workflow(s): ` +
|
|
293
|
+
`${assembly.workflows.join(", ") || "(none)"})`,
|
|
294
|
+
);
|
|
171
295
|
|
|
172
|
-
// --- the
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// The Machine is named by where it sits under the registered root — the slot chain a
|
|
187
|
-
// `customize()` walks — and the fix line nests the same way, so it pastes into
|
|
188
|
-
// `workflows/<name>.ts` with the imported Machine and the url filled in.
|
|
189
|
-
// The two kinds of Open part are refused in ONE listing, in walk order within each kind: a
|
|
190
|
-
// composer fixing a packaged Machine fixes both in the same edit, and a converge that refused
|
|
191
|
-
// the Repo Slot, then the Agent one run later, would cost them two rounds to learn one thing.
|
|
192
|
-
for (const w of workflows) {
|
|
193
|
-
const walked = partsOf([w.machine]);
|
|
194
|
-
const opened = [
|
|
195
|
-
...walked.openSlots.map((o) => ({
|
|
196
|
-
...o,
|
|
197
|
-
part: "repo" as const,
|
|
198
|
-
// An Open MAP names no slot — the composer does — so it is refused as "the Repo Slots".
|
|
199
|
-
what: o.slot === undefined ? "its Repo Slots" : `Repo Slot "${o.slot}"`,
|
|
200
|
-
adr: "ADR-0051",
|
|
201
|
-
})),
|
|
202
|
-
...walked.openAgents.map((o) => ({ ...o, part: "agent" as const, what: `Agent "${o.slot}"`, adr: "ADR-0054" })),
|
|
203
|
-
];
|
|
204
|
-
for (const { slot, path, part, what, adr } of opened) {
|
|
205
|
-
if (path === undefined) {
|
|
206
|
-
activity(
|
|
207
|
-
io,
|
|
208
|
-
`refusing: workflow "${w.name}" leaves ${what} open on a Machine invoked inline — no ` +
|
|
209
|
-
"customize() reaches an actor without a slot key; declare that Machine under setup({ actors }) and " +
|
|
210
|
-
`bind it there (ADR-0049, ${adr})`,
|
|
211
|
-
);
|
|
212
|
-
return 1;
|
|
213
|
-
}
|
|
214
|
-
const where = path.length ? ` (on the Machine composed as ${path.map((k) => `"${k}"`).join(" → ")})` : "";
|
|
215
|
-
activity(
|
|
216
|
-
io,
|
|
217
|
-
`refusing: workflow "${w.name}" leaves ${what} open${where} — bind it where the Machine is ` +
|
|
218
|
-
`registered: export const machine = ${customizeLine("<import>", path, slot, part)} (${adr})`,
|
|
219
|
-
);
|
|
220
|
-
return 1;
|
|
221
|
-
}
|
|
296
|
+
// --- the Kubernetes floor (ADR-0037) ----------------------------------------------------------
|
|
297
|
+
// Every Sandbox mounts jr2's runtime as an `image` volume, and there is no fallback below the
|
|
298
|
+
// version that turns them on — so a cluster under it is refused HERE, naming what it runs and the
|
|
299
|
+
// floor (ADR-0046), rather than converging and leaving every Sandbox Pending on a volume type its
|
|
300
|
+
// kubelet does not know. Before ownership, so first contact never asks to deploy somewhere that
|
|
301
|
+
// cannot host it. Unconditional: the floor is the Instance's, not one Machine's. The container
|
|
302
|
+
// runtime owes image volumes too (containerd ≥ 2.1, CRI-O ≥ 1.31); that is not cheaply visible
|
|
303
|
+
// from here, so an older one fails at the first provision, in the pod's own events.
|
|
304
|
+
let serverVersion: string;
|
|
305
|
+
try {
|
|
306
|
+
serverVersion = await kube.serverVersion(ctx);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
activity(io, `refusing: cannot reach the API server of ${context} (${(e as Error).message}). Nothing was applied.`);
|
|
309
|
+
return 1;
|
|
222
310
|
}
|
|
223
|
-
const
|
|
224
|
-
|
|
311
|
+
const belowFloor = kubeFloorRefusal(serverVersion);
|
|
312
|
+
if (belowFloor) {
|
|
313
|
+
activity(io, `refusing: ${context} ${belowFloor}. Nothing was applied.`);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
activity(io, `kubernetes: ${serverVersion} (floor ${KUBE_FLOOR.major}.${KUBE_FLOOR.minor}, ADR-0037)`);
|
|
225
317
|
|
|
226
318
|
// --- ownership: the cluster is the record (ADR-0019) -------------------------------------------
|
|
227
319
|
const ns = await kube.getJson({ kind: "namespace", name: namespace, ...ctx });
|
|
@@ -250,8 +342,23 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
250
342
|
...ctx,
|
|
251
343
|
});
|
|
252
344
|
|
|
345
|
+
// --- pod-level resources (ADR-0060): the whole Size is one pod-level ceiling (KEP-2837) ------
|
|
346
|
+
// A server with the PodLevelResources gate off drops `spec.resources` silently, and every
|
|
347
|
+
// Sandbox runs Burstable. A server-side dry-run shows it; it needs the namespace, so it runs here.
|
|
348
|
+
try {
|
|
349
|
+
const admitted = await kube.dryRunCreate({ manifest: JSON.stringify(podResourcesProbe(namespace)), ...ctx });
|
|
350
|
+
if (admitted.spec?.resources === undefined) {
|
|
351
|
+
activity(
|
|
352
|
+
io,
|
|
353
|
+
`refusing: ${context} (Kubernetes ${serverVersion}) drops pod-level resources — the PodLevelResources feature gate is off, and a Workspace's Size cannot hold (ADR-0060). Enable the gate on the API server and kubelets. The namespace was created; nothing else was applied.`,
|
|
354
|
+
);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
} catch (e) {
|
|
358
|
+
activity(io, `warning: could not confirm pod-level resources on ${context}: ${(e as Error).message}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
253
361
|
// --- image resolution (ADR-0038): decide every ref BEFORE any layer spends a build -----------
|
|
254
|
-
const build = io.build ?? pnpmDockerBuild;
|
|
255
362
|
const registry = config.registry;
|
|
256
363
|
const cluster = kindCluster(context);
|
|
257
364
|
|
|
@@ -307,6 +414,22 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
307
414
|
"`sandbox: { nodeSelector, tolerations }` in jr2.config.ts (raw pod-spec shapes, ADR-0052).",
|
|
308
415
|
);
|
|
309
416
|
}
|
|
417
|
+
// A Size no Sandbox node could hold today WARNS (ADR-0060, the ADR-0052 stance): the node set
|
|
418
|
+
// moves, and an autoscaler may bring a bigger node for the Pending pod. A node that reports no
|
|
419
|
+
// allocatable is an unknown, never a no. One line per distinct Size, naming its workflows.
|
|
420
|
+
const unheld = new Map<string, string[]>();
|
|
421
|
+
for (const { workflow, size } of sizes) {
|
|
422
|
+
if (candidates.length === 0 || anyNodeHolds(candidates, size) !== false) continue;
|
|
423
|
+
const key = `memory ${formatMemory(size.memory.bytes)}, cpu ${formatCpu(size.cpu.millis)}`;
|
|
424
|
+
unheld.set(key, [...new Set([...(unheld.get(key) ?? []), workflow])]);
|
|
425
|
+
}
|
|
426
|
+
for (const [key, names] of unheld) {
|
|
427
|
+
activity(
|
|
428
|
+
io,
|
|
429
|
+
`warning: no Sandbox node right now has the allocatable for a Size of ${key} ` +
|
|
430
|
+
`(workflow ${names.map((n) => `"${n}"`).join(", ")}) — its Sandbox stays Pending until one does`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
310
433
|
}
|
|
311
434
|
// A multi-platform build is `docker buildx build --push`: it delivers by pushing, and there is
|
|
312
435
|
// nowhere to push without a registry. By CONSTRUCTION this never fires — a mixed-arch cluster is
|
|
@@ -335,8 +458,8 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
335
458
|
activity(
|
|
336
459
|
io,
|
|
337
460
|
kitRoot
|
|
338
|
-
? `images: kit checkout at ${kitRoot} — building the Harness and
|
|
339
|
-
: `images: installed kit — the published v${KIT_VERSION} Harness and
|
|
461
|
+
? `images: kit checkout at ${kitRoot} — building the Harness, operator and Orchestrator from source`
|
|
462
|
+
: `images: installed kit — the published v${KIT_VERSION} Harness, operator and Orchestrator, pulled from ` +
|
|
340
463
|
`${config.kitRegistry ? `${config.kitRegistry} (kitRegistry)` : KIT_IMAGE_HOME}`,
|
|
341
464
|
);
|
|
342
465
|
// The Custodian's image is deployed and never built, in either world (ADR-0038, ADR-0059): the
|
|
@@ -344,14 +467,20 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
344
467
|
const custodianImage = custodianRef(config.kitRegistry);
|
|
345
468
|
activity(io, `custodian image: ${custodianImage} (pinned, deployed — never built)`);
|
|
346
469
|
|
|
347
|
-
// Read the Orchestrator Deployment ONCE: it carries
|
|
348
|
-
//
|
|
470
|
+
// Read the Orchestrator Deployment ONCE: it carries the convergence record — the previous
|
|
471
|
+
// name→ref map (an annotation, ADR-0038), the Orchestrator's own Kit ref included (ADR-0069).
|
|
349
472
|
const orch = await kube.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
|
|
350
473
|
const previous = previousImages(orch);
|
|
351
|
-
const converged: ConvergedImages = {
|
|
474
|
+
const converged: ConvergedImages = {
|
|
475
|
+
harness: refs.harness,
|
|
476
|
+
custodian: custodianImage,
|
|
477
|
+
orchestrator: refs.orchestrator,
|
|
478
|
+
sandbox: {},
|
|
479
|
+
sandboxUser: {},
|
|
480
|
+
};
|
|
352
481
|
|
|
353
|
-
// ONE transport branch for every image (ADR-0038), so
|
|
354
|
-
// drift into
|
|
482
|
+
// ONE transport branch for every image (ADR-0038), so Kit images and Sandbox Images cannot
|
|
483
|
+
// drift into two delivery stories.
|
|
355
484
|
const deliver = async (tag: string): Promise<void> => {
|
|
356
485
|
// A multi-platform build already pushed (ADR-0045): `docker buildx build --push` IS the
|
|
357
486
|
// delivery, and pushing again would push a tag the daemon never held. Sound at every call site
|
|
@@ -403,6 +532,26 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
403
532
|
const hostBuilt = async (ref: string): Promise<boolean> =>
|
|
404
533
|
values.force !== true && (await hostHeld()).has(normalizeRef(ref));
|
|
405
534
|
|
|
535
|
+
// What this converge counts on finding on the host — its own build, or the host-held skip above —
|
|
536
|
+
// can be gone by the time it is used: another converge's sweep keeps only the roots of ITS context
|
|
537
|
+
// (ADR-0039), and a ref whose map is not written yet is in none of them. The tag is a content
|
|
538
|
+
// address and BuildKit's cache is a store no sweep touches, so a ref found missing at its use is
|
|
539
|
+
// built again (seconds) and the use retried, at most HOST_REBUILDS times. Only a singleton build
|
|
540
|
+
// lives on the daemon: a multi-platform one is in the registry (ADR-0045), where no sweep reaches.
|
|
541
|
+
const builders = new Map<string, () => Promise<void>>();
|
|
542
|
+
const onHost = async <T>(ref: string, use: () => Promise<T>): Promise<T> => {
|
|
543
|
+
for (let rebuilt = 0; ; rebuilt++) {
|
|
544
|
+
try {
|
|
545
|
+
return await use();
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const rebuild = builders.get(ref);
|
|
548
|
+
if (!rebuild || platforms.length > 1 || rebuilt >= HOST_REBUILDS || !isMissingHostImage(err)) throw err;
|
|
549
|
+
activity(io, ` ${ref}: gone from the host daemon (another converge's sweep took it) — rebuilding`);
|
|
550
|
+
await rebuild();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
406
555
|
// The binfmt preflight (ADR-0045), paid at most once and only when a build is really about to be
|
|
407
556
|
// spent: a steady-state converge must still spend no docker at all, and a foreign-arch build must
|
|
408
557
|
// fail BEFORE minutes of docker rather than mid-`RUN` with `exec format error`.
|
|
@@ -423,18 +572,33 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
423
572
|
return ref;
|
|
424
573
|
}
|
|
425
574
|
assertDeliverable();
|
|
575
|
+
const buildRef = async (): Promise<void> => {
|
|
576
|
+
await ensureEmulation();
|
|
577
|
+
await build.build(kitImageBuild(kitRoot, which, ref, platforms));
|
|
578
|
+
};
|
|
579
|
+
builders.set(ref, buildRef);
|
|
426
580
|
if (await hostBuilt(ref)) {
|
|
427
581
|
activity(io, `${which} image: ${ref} (host-built — build skipped, delivering; --force to rebuild)`);
|
|
428
|
-
await deliver(ref);
|
|
582
|
+
await onHost(ref, () => deliver(ref));
|
|
429
583
|
return ref;
|
|
430
584
|
}
|
|
431
585
|
activity(io, `${which} image: building ${ref}${values.force === true ? " (--force)" : ""}`);
|
|
432
|
-
await
|
|
433
|
-
await
|
|
434
|
-
await deliver(ref);
|
|
586
|
+
await buildRef();
|
|
587
|
+
await onHost(ref, () => deliver(ref));
|
|
435
588
|
return ref;
|
|
436
589
|
};
|
|
437
590
|
|
|
591
|
+
// --- priority (per-cluster, shared, ADR-0060) --------------------------------------------------
|
|
592
|
+
// The two PriorityClasses, beside the CRDs and before anything that names them: a pod whose class
|
|
593
|
+
// does not exist is refused at admission. Unconditional on `operator.manage` — the Instance's own
|
|
594
|
+
// pods name them too — and skipped per key the Instance's `priorityClasses` names.
|
|
595
|
+
const classes = priorityClassObjects(config.priorityClasses);
|
|
596
|
+
for (const manifest of classes) await kube.apply({ manifest, ...ctx });
|
|
597
|
+
const named = Object.entries(config.priorityClasses ?? {}).filter(([, v]) => v !== undefined);
|
|
598
|
+
if (named.length) {
|
|
599
|
+
activity(io, `priority: the Instance's own — ${named.map(([tier, cls]) => `${tier} "${cls}"`).join(", ")}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
438
602
|
// --- operator (per-cluster, shared) ------------------------------------------------------------
|
|
439
603
|
// The operator IMAGE is resolved whenever anything this converge deploys runs it: the operator
|
|
440
604
|
// layer itself, or the data plane's cache agent, which is the same binary (`/manager repo-cache`,
|
|
@@ -442,106 +606,24 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
442
606
|
const operatorImage =
|
|
443
607
|
config.operator?.manage === false && !carried.composesSandbox ? undefined : await ensureKitImage("operator");
|
|
444
608
|
if (operatorImage !== undefined) converged.operator = operatorImage;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
io,
|
|
448
|
-
"operator: skipped (operator.manage: false — run the controller loop yourself)" +
|
|
449
|
-
(operatorImage !== undefined ? `; its image ${operatorImage} still runs as the cache agent` : ""),
|
|
450
|
-
);
|
|
451
|
-
} else {
|
|
452
|
-
const image = operatorImage!;
|
|
453
|
-
const existing = await kube.getJson({
|
|
454
|
-
kind: "deployment",
|
|
455
|
-
name: OPERATOR_DEPLOYMENT,
|
|
456
|
-
namespace: OPERATOR_NAMESPACE,
|
|
457
|
-
...ctx,
|
|
458
|
-
});
|
|
459
|
-
const deployed = existing?.metadata.labels?.[LABEL_VERSION];
|
|
460
|
-
if (deployed && compareVersions(deployed, KIT_VERSION) > 0) {
|
|
461
|
-
activity(io, `operator: leaving v${deployed} (newer than this kit's v${KIT_VERSION} — never downgraded)`);
|
|
462
|
-
} else {
|
|
463
|
-
activity(io, `operator: applying v${KIT_VERSION} (${image})${deployed ? ` over v${deployed}` : ""}`);
|
|
464
|
-
await kube.apply({ manifest: await operatorManifest(image), ...ctx });
|
|
465
|
-
await kube.label({
|
|
466
|
-
kind: "deployment",
|
|
467
|
-
name: OPERATOR_DEPLOYMENT,
|
|
468
|
-
namespace: OPERATOR_NAMESPACE,
|
|
469
|
-
labels: { [LABEL_VERSION]: KIT_VERSION },
|
|
470
|
-
...ctx,
|
|
471
|
-
});
|
|
472
|
-
await awaitRollout(kube, {
|
|
473
|
-
name: OPERATOR_DEPLOYMENT,
|
|
474
|
-
namespace: OPERATOR_NAMESPACE,
|
|
475
|
-
selector: OPERATOR_SELECTOR,
|
|
476
|
-
...ctx,
|
|
477
|
-
});
|
|
478
|
-
// Sound for every layer now that EVERY tag is a content address (ADR-0038): a source edit
|
|
479
|
-
// moves the ref, which moves the pod template, which rolls. The old hole — kit dev pinning a
|
|
480
|
-
// static `:local` tag, so a rebuild left the template identical and nothing rolled — closed
|
|
481
|
-
// with the `images` block that created it.
|
|
482
|
-
await verifyRunningImage(io, kube, {
|
|
483
|
-
layer: "operator",
|
|
484
|
-
namespace: OPERATOR_NAMESPACE,
|
|
485
|
-
selector: OPERATOR_SELECTOR,
|
|
486
|
-
image,
|
|
487
|
-
...ctx,
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
}
|
|
609
|
+
// Applied after the provider preflight (below): the operator is the cluster's, and a converge
|
|
610
|
+
// whose provider check fails must not have changed it.
|
|
491
611
|
|
|
492
612
|
// --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
|
|
493
613
|
// Built here rather than lazily beside its consumers. The Harness is the injection source
|
|
494
|
-
// (ADR-0037):
|
|
495
|
-
//
|
|
614
|
+
// (ADR-0037): every Sandbox mounts this exact ref as an `image` volume, its `/opt/jr2` read-only
|
|
615
|
+
// at `/opt/jr2` in the Sandbox Image. It is not a hash input for a Sandbox Image — the runtime rides
|
|
496
616
|
// the pod's volume, so a kit edit re-images future pods and re-tags nothing of the user's.
|
|
497
617
|
await ensureKitImage("harness");
|
|
498
|
-
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
// kit included — so the tag is a content address rather than a guess about what changed.
|
|
502
|
-
const staged = await stageInstanceBundle(build, root);
|
|
503
|
-
// The content address covers (inputs × platform set), ADR-0045 — so the platform rides the HASH
|
|
504
|
-
// this layer records, not just the tag composed from it. That is what keeps the staleness key and
|
|
505
|
-
// the tag one fact: a converge whose platform set moved must not read the recorded hash as fresh,
|
|
506
|
-
// skip the build, and then apply a tag nothing ever delivered.
|
|
507
|
-
const hash = `${staged.hash}${platformSuffix(platforms)}`;
|
|
508
|
-
const tag = registry ? `${registry}/jr2-instance-${name}:${hash}` : `jr2-instance-${name}:${hash}`;
|
|
509
|
-
try {
|
|
510
|
-
if (orch?.metadata.labels?.[LABEL_HASH] === hash && values.force !== true) {
|
|
511
|
-
// Safe to skip only because the rolled-out pod is verified against `tag` below: this decides
|
|
512
|
-
// whether to spend a docker build, not whether the cluster is already correct.
|
|
513
|
-
activity(io, `image: ${tag} (fresh — build skipped; --force to rebuild anyway)`);
|
|
514
|
-
} else {
|
|
515
|
-
assertDeliverable();
|
|
516
|
-
if (await hostBuilt(tag)) {
|
|
517
|
-
activity(io, `image: ${tag} (host-built — build skipped, delivering; --force to rebuild)`);
|
|
518
|
-
await deliver(tag);
|
|
519
|
-
} else {
|
|
520
|
-
activity(io, `image: building ${tag}${values.force === true ? " (--force)" : ""}`);
|
|
521
|
-
await ensureEmulation();
|
|
522
|
-
await build.build({
|
|
523
|
-
tag,
|
|
524
|
-
platforms,
|
|
525
|
-
context: staged.dir,
|
|
526
|
-
dockerfileContent: INSTANCE_DOCKERFILE,
|
|
527
|
-
labels: instanceImageLabels(name),
|
|
528
|
-
});
|
|
529
|
-
await deliver(tag);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
} finally {
|
|
533
|
-
await staged.dispose();
|
|
534
|
-
}
|
|
618
|
+
// The Orchestrator is a Kit image too (ADR-0069): the engine and its closure, nothing of this
|
|
619
|
+
// Instance. A Machine edit moves the Assembly, never this ref, so it never rolls the pod.
|
|
620
|
+
await ensureKitImage("orchestrator");
|
|
535
621
|
|
|
536
622
|
// --- Sandbox Images (ADR-0037/0049): the contexts the Machines carry, plus `images/default` -----
|
|
537
623
|
// Gated on the data-plane switch (ADR-0012/0031/0051): an instance none of whose Machines
|
|
538
624
|
// composes a Sandbox has no Sandboxes, so it must not pay a docker build for an image it can
|
|
539
625
|
// never use. Said out loud, because a silent skip of a folder you just wrote reads as a bug.
|
|
540
626
|
if (carried.composesSandbox) {
|
|
541
|
-
// The scaffolded `images/default` is a PATH CONVENTION, not discovery (ADR-0049/0050): exactly
|
|
542
|
-
// one path is checked, and it is keyed `default` rather than by digest, because that reserved
|
|
543
|
-
// key IS the middle leg of the resolution chain a `workspace()` that names no image lands on.
|
|
544
|
-
const fallback = await defaultImageContext(root);
|
|
545
627
|
const built: Array<{ dir: string; name: string; reserved?: "default" }> = [
|
|
546
628
|
...(fallback ? [{ dir: fallback, name: "default", reserved: "default" as const }] : []),
|
|
547
629
|
...contexts.map((image: CarriedImage) => ({ dir: image.dir, name: image.name })),
|
|
@@ -550,29 +632,32 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
550
632
|
activity(io, "sandbox images: none carried (a workspace() names one, or scaffold images/default)");
|
|
551
633
|
}
|
|
552
634
|
for (const image of built) {
|
|
553
|
-
//
|
|
554
|
-
// context
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
`the Sandbox Image context ${image.dir} cannot be read (${err.code ?? "read failed"}) — a ` +
|
|
558
|
-
'`workspace()` names its image with `import.meta.resolve("./<dir>")`, so that directory must exist ' +
|
|
559
|
-
"beside the Machine's own module (ADR-0037/0049).",
|
|
560
|
-
);
|
|
561
|
-
});
|
|
562
|
-
// Every carried context is keyed by that digest, which is how the baked Orchestrator finds
|
|
563
|
-
// this ref again from the `file:` URL its own copy of the module carries — no path table on
|
|
564
|
-
// either side (ADR-0049). The TAG carries the digest either way, so a Machine that names
|
|
635
|
+
// The digest of the context's staged copy, taken above with the Assembly: every carried
|
|
636
|
+
// context is keyed by it, which is how the Orchestrator finds this ref again from the
|
|
637
|
+
// `file:` URL the Assembly's copy of the module carries — no path table on either side
|
|
638
|
+
// (ADR-0049, ADR-0069). The TAG carries the digest either way, so a Machine that names
|
|
565
639
|
// `images/default` by URL lands on the same tag as the reserved key: two records, one build
|
|
566
640
|
// (the host-held check below spends nothing the second time).
|
|
641
|
+
const digest = contextDigests.get(image.dir)!;
|
|
567
642
|
const key = image.reserved ?? digest;
|
|
568
643
|
const ref = sandboxImageTag(name, image.name, digest, { platforms, registry });
|
|
644
|
+
const buildRef = async (): Promise<void> => {
|
|
645
|
+
// ONE build of the user's own Dockerfile, straight to its content tag — no wrap, no
|
|
646
|
+
// intermediate tag, so two converges of one checkout share no mutable image name and
|
|
647
|
+
// the `@kind` tier's `--parallel` rides on it (ADR-0037).
|
|
648
|
+
await ensureEmulation();
|
|
649
|
+
await buildSandboxImage(build, { dir: image.dir, tag: ref, instance: name, platforms });
|
|
650
|
+
};
|
|
651
|
+
// Known on every path, the record's skip included: the Effector check reads this ref off the
|
|
652
|
+
// host daemon later in this converge, whatever decided its build.
|
|
653
|
+
builders.set(ref, buildRef);
|
|
569
654
|
// A record is only worth skipping on when it is COMPLETE: the ref AND the seat the image
|
|
570
655
|
// declared. A record from a kit that predates `sandboxUser` names the right image and
|
|
571
656
|
// silently drops the fallback fact, which surfaces as a pod that will not start — so it reads
|
|
572
657
|
// as stale here and the image is re-inspected (the host skip below then spends no build).
|
|
573
658
|
const remembered = previous?.sandboxUser?.[key];
|
|
574
659
|
if (previous?.sandbox?.[key] === ref && remembered !== undefined && values.force !== true) {
|
|
575
|
-
// Unlike
|
|
660
|
+
// Unlike a Kit image there is no rollout to verify a Sandbox Image against, so a
|
|
576
661
|
// recorded-but-absent ref only shows up as ImagePullBackOff at the next provision —
|
|
577
662
|
// `--force` rebuilds and re-delivers it. The seat is carried forward from the same record:
|
|
578
663
|
// the tag is a content address, so the image the record names declares what it declared.
|
|
@@ -586,11 +671,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
586
671
|
activity(io, `sandbox image "${image.name}": ${ref} (host-built — build skipped; --force to rebuild)`);
|
|
587
672
|
} else {
|
|
588
673
|
activity(io, `sandbox image "${image.name}": building ${ref}`);
|
|
589
|
-
|
|
590
|
-
// intermediate tag, so two converges of one checkout share no mutable image name and
|
|
591
|
-
// the `@kind` tier's `--parallel` rides on it (ADR-0037).
|
|
592
|
-
await ensureEmulation();
|
|
593
|
-
await buildSandboxImage(build, { dir: image.dir, tag: ref, instance: name, platforms });
|
|
674
|
+
await buildRef();
|
|
594
675
|
}
|
|
595
676
|
// The image's own `USER`, read while the image is certainly on this daemon (it was just
|
|
596
677
|
// built, or the host holds it). Recorded because a provision cannot inspect an image, and
|
|
@@ -600,10 +681,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
600
681
|
// (ADR-0005), which owes no floor — a distinction only the provision can make (ADR-0031).
|
|
601
682
|
// `platforms` says where to look, not which variant to read (ADR-0045): a singleton build is
|
|
602
683
|
// on this daemon, a multi-platform one is the manifest list buildx just pushed.
|
|
603
|
-
const user = await build.imageUser(ref, platforms);
|
|
684
|
+
const user = await onHost(ref, () => build.imageUser(ref, platforms));
|
|
604
685
|
converged.sandboxUser[key] = user;
|
|
605
686
|
activity(io, ` ${user ? `USER ${user}` : "no USER declared — the pod's uid-1000 fallback applies"}`);
|
|
606
|
-
await deliver(ref);
|
|
687
|
+
await onHost(ref, () => deliver(ref));
|
|
607
688
|
}
|
|
608
689
|
converged.sandbox[key] = ref;
|
|
609
690
|
}
|
|
@@ -623,9 +704,15 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
623
704
|
const keep = (key: string, mint: () => string): string =>
|
|
624
705
|
secret?.data?.[key] ? Buffer.from(secret.data[key], "base64").toString("utf8") : mint();
|
|
625
706
|
const instanceToken = keep("JR2_INSTANCE_TOKEN", () => randomBytes(24).toString("hex"));
|
|
707
|
+
// The publish token (ADR-0069): publishing an Assembly imports code into the process that holds
|
|
708
|
+
// the ServiceAccount, so it costs what deploying an image cost — cluster write access. It lives
|
|
709
|
+
// here beside the Instance token, and the publish below reads it from THIS Secret alone: no env
|
|
710
|
+
// var, flag or `--url` path can supply it, so an ingress-exposed Instance exposes no publish door.
|
|
711
|
+
const publishToken = keep("JR2_PUBLISH_TOKEN", () => randomBytes(24).toString("hex"));
|
|
626
712
|
const signingKey = keep("JR2_SIGNING_KEY", () => randomBytes(32).toString("base64"));
|
|
627
713
|
const secretData: Record<string, string> = {
|
|
628
714
|
JR2_INSTANCE_TOKEN: instanceToken,
|
|
715
|
+
JR2_PUBLISH_TOKEN: publishToken,
|
|
629
716
|
JR2_SIGNING_KEY: signingKey,
|
|
630
717
|
// The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
|
|
631
718
|
// for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
|
|
@@ -633,13 +720,12 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
633
720
|
// the same value; live Custodians keep verifying.
|
|
634
721
|
JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
|
|
635
722
|
};
|
|
636
|
-
// Git tokens (ADR-
|
|
637
|
-
//
|
|
638
|
-
// Orchestrator
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
}
|
|
723
|
+
// Git tokens (ADR-0051, ADR-0068): each entry's token, from `.env`, goes into that entry's own clone
|
|
724
|
+
// Secret (`jr2-git-<hash>`, Flux's key names), which the cache agent reads — never into this
|
|
725
|
+
// Secret, the Orchestrator's env. The Orchestrator names the Secret on a Repo and holds no git token.
|
|
726
|
+
// Only when a registered Machine composes a Sandbox: nothing else clones.
|
|
727
|
+
const gitClone = carried.composesSandbox ? gitCloneSecretsOf(config.git?.credentials ?? [], (n) => io.env[n]) : [];
|
|
728
|
+
for (const s of gitClone) activity(io, `git.credentials "${s.match}": clone Secret ${s.name}`);
|
|
643
729
|
|
|
644
730
|
// The HARNESS containers' env — a separate Secret (ADR-0013): Agent code executes where these
|
|
645
731
|
// land, so the Instance token/signing key above must be unreachable from it. Values declared in
|
|
@@ -675,56 +761,237 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
675
761
|
// touches the file (the path may not exist there); consumers get the ConfigMap.
|
|
676
762
|
const caPem = await readCaBundle(root, config);
|
|
677
763
|
|
|
678
|
-
// ---
|
|
764
|
+
// --- secrets (ADR-0059, ADR-0068): resolved HERE, host-side, where `.env` exists --------------
|
|
765
|
+
// R10: a Secret an entry references must exist with its key. Read once per Secret — each key's
|
|
766
|
+
// NAME, the LENGTH of its value, and a keyed digest of it, never the value — for both
|
|
767
|
+
// placements. The length is ADR-0068's: an Effect secret shorter than 8 characters is refused
|
|
768
|
+
// wherever its value is kept (ADR-0068). The digest goes into the template digest of each
|
|
769
|
+
// long-lived pod that reads the value once at start — the Instance Harness's Custodian and the
|
|
770
|
+
// Instance Effector — so a value edited in the user's Secret rolls them at the next `jr2 up`.
|
|
771
|
+
const referenced = new Map<string, Map<string, number> | undefined>();
|
|
772
|
+
const valueDigests = new Map<string, string>();
|
|
773
|
+
for (const entry of config.secrets ?? []) {
|
|
774
|
+
const ref = entry.valueFrom?.secretKeyRef.name;
|
|
775
|
+
if (ref === undefined || referenced.has(ref)) continue;
|
|
776
|
+
const found = await kube.getJson<KubeObject & { data?: Record<string, string> }>({
|
|
777
|
+
kind: "secret",
|
|
778
|
+
name: ref,
|
|
779
|
+
namespace,
|
|
780
|
+
...ctx,
|
|
781
|
+
});
|
|
782
|
+
referenced.set(
|
|
783
|
+
ref,
|
|
784
|
+
found ? new Map(Object.entries(found.data ?? {}).map(([k, b64]) => [k, storedValueLength(b64)])) : undefined,
|
|
785
|
+
);
|
|
786
|
+
for (const [k, b64] of Object.entries(found?.data ?? {})) {
|
|
787
|
+
valueDigests.set(`${ref}/${k}`, storedValueDigest(Buffer.from(signingKey, "base64"), ref, k, b64));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const valueDigest = (secret: string, key: string) => valueDigests.get(`${secret}/${key}`);
|
|
791
|
+
const secretKeys = (secret: string) => {
|
|
792
|
+
const keys = referenced.get(secret);
|
|
793
|
+
return keys && [...keys.keys()];
|
|
794
|
+
};
|
|
795
|
+
const valueLength = (secret: string, key: string) => referenced.get(secret)?.get(key);
|
|
796
|
+
// Where each secret goes (ADR-0059, ADR-0068): read off the list and what names it, never
|
|
797
|
+
// declared — each Harness pod gets the Held secrets something in it names, the Instance Effector
|
|
798
|
+
// what its Effects name. The walk's refusals ran above; a `valueFrom` the user's Secrets do not
|
|
799
|
+
// hold is refused here, before the instance objects are applied.
|
|
800
|
+
const placements = secretPlacementsOf(config, placementWalk, { secretKeys, valueLength });
|
|
801
|
+
for (const line of placements.report) activity(io, line);
|
|
802
|
+
for (const warning of placements.warnings) activity(io, `warning: ${warning}`);
|
|
679
803
|
const held = await resolveHeld(io, kube, {
|
|
804
|
+
pods: placements.held,
|
|
805
|
+
claimed: placements.claimed,
|
|
680
806
|
config,
|
|
681
807
|
name,
|
|
682
808
|
namespace,
|
|
683
809
|
ctx,
|
|
684
810
|
envFromKeys,
|
|
685
|
-
|
|
811
|
+
secretKeys,
|
|
812
|
+
// Every bound Agent's model, and every model an Open body allows a Chart's own Agents (ADR-0070).
|
|
813
|
+
models: carried.models,
|
|
686
814
|
caBundle: caPem,
|
|
687
815
|
});
|
|
688
816
|
|
|
817
|
+
// --- provider preflight (ADR-0019, ADR-0059): through a Custodian, FROM INSIDE the cluster -----
|
|
818
|
+
// BEFORE anything the Instance or the cluster reads is written — the operator, a deploy key, the
|
|
819
|
+
// trust, the held objects, the instance List: each check pod has its own copies of what it reads,
|
|
820
|
+
// from the values on this host. So a check that fails leaves the Instance, its Assembly and the
|
|
821
|
+
// operator as they were. Only the namespace, the PriorityClasses and the delivered images come
|
|
822
|
+
// first, because the check pod needs them.
|
|
823
|
+
const checkPods = checkPodsOf(config, placementWalk, held.objects.pods);
|
|
824
|
+
const custom = config.harness?.provider;
|
|
825
|
+
if (custom && !checkPods.some((p) => p.providers.some((v) => v.id === custom.id))) {
|
|
826
|
+
activity(
|
|
827
|
+
io,
|
|
828
|
+
`provider: ${custom.baseUrl} configured, but no carried Agent names a "${custom.id}/…" model — skipped`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
await preflightProviders(io, kube, {
|
|
832
|
+
checkPods,
|
|
833
|
+
skip: values["skip-provider-preflight"] === true,
|
|
834
|
+
name,
|
|
835
|
+
namespace,
|
|
836
|
+
ctx,
|
|
837
|
+
harnessImage: refs.harness,
|
|
838
|
+
custodianImage,
|
|
839
|
+
harness: config.harness,
|
|
840
|
+
caBundle: caPem !== undefined,
|
|
841
|
+
values: Object.assign({}, ...held.objects.pods.map((p) => p.values)) as Record<string, string>,
|
|
842
|
+
leaves: held.objects.leaves,
|
|
843
|
+
trust: trustData(caPem, held.bundles),
|
|
844
|
+
harnessEnvData,
|
|
845
|
+
priorityClassName: priorityClassNames(config.priorityClasses).sandbox,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// --- operator (per-cluster, shared): after the provider preflight ------------------------------
|
|
849
|
+
if (config.operator?.manage === false) {
|
|
850
|
+
activity(
|
|
851
|
+
io,
|
|
852
|
+
"operator: skipped (operator.manage: false — run the controller loop yourself)" +
|
|
853
|
+
(operatorImage !== undefined ? `; its image ${operatorImage} still runs as the cache agent` : ""),
|
|
854
|
+
);
|
|
855
|
+
} else {
|
|
856
|
+
const image = operatorImage!;
|
|
857
|
+
const existing = await kube.getJson({
|
|
858
|
+
kind: "deployment",
|
|
859
|
+
name: OPERATOR_DEPLOYMENT,
|
|
860
|
+
namespace: OPERATOR_NAMESPACE,
|
|
861
|
+
...ctx,
|
|
862
|
+
});
|
|
863
|
+
const deployed = existing?.metadata.labels?.[LABEL_VERSION];
|
|
864
|
+
if (deployed && compareVersions(deployed, KIT_VERSION) > 0) {
|
|
865
|
+
activity(io, `operator: leaving v${deployed} (newer than this kit's v${KIT_VERSION} — never downgraded)`);
|
|
866
|
+
} else {
|
|
867
|
+
activity(io, `operator: applying v${KIT_VERSION} (${image})${deployed ? ` over v${deployed}` : ""}`);
|
|
868
|
+
await kube.apply({
|
|
869
|
+
manifest: await operatorManifest(image, priorityClassNames(config.priorityClasses).control),
|
|
870
|
+
...ctx,
|
|
871
|
+
});
|
|
872
|
+
await kube.label({
|
|
873
|
+
kind: "deployment",
|
|
874
|
+
name: OPERATOR_DEPLOYMENT,
|
|
875
|
+
namespace: OPERATOR_NAMESPACE,
|
|
876
|
+
labels: { [LABEL_VERSION]: KIT_VERSION },
|
|
877
|
+
...ctx,
|
|
878
|
+
});
|
|
879
|
+
await awaitRollout(kube, {
|
|
880
|
+
name: OPERATOR_DEPLOYMENT,
|
|
881
|
+
namespace: OPERATOR_NAMESPACE,
|
|
882
|
+
selector: OPERATOR_SELECTOR,
|
|
883
|
+
...ctx,
|
|
884
|
+
});
|
|
885
|
+
// Sound for every layer now that EVERY tag is a content address (ADR-0038): a source edit
|
|
886
|
+
// moves the ref, which moves the pod template, which rolls. The old hole — kit dev pinning a
|
|
887
|
+
// static `:local` tag, so a rebuild left the template identical and nothing rolled — closed
|
|
888
|
+
// with the `images` block that created it.
|
|
889
|
+
await verifyRunningImage(io, kube, {
|
|
890
|
+
layer: "operator",
|
|
891
|
+
namespace: OPERATOR_NAMESPACE,
|
|
892
|
+
selector: OPERATOR_SELECTOR,
|
|
893
|
+
image,
|
|
894
|
+
...ctx,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
689
899
|
// --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
|
|
690
900
|
// Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
|
|
691
901
|
// notice it hands back is owed to the END of the converge — a generated key is dead until a human
|
|
692
902
|
// registers it, and by then this line has scrolled away.
|
|
693
903
|
const gitSsh = await ensureGitSsh(io, kube, config, carried.repos, namespace, ctx, values.yes === true);
|
|
694
904
|
|
|
695
|
-
// ---
|
|
696
|
-
|
|
905
|
+
// --- what each Sandbox Effector's image owes its pushes (ADR-0068) ------------------------
|
|
906
|
+
// Before anything is applied: an image that lacks a push form's tools, or the passwd entry ssh
|
|
907
|
+
// needs, is refused here with the fix, not at the first push in a pod.
|
|
908
|
+
await checkEffectorImages(
|
|
909
|
+
(ref, paths) => onHost(ref, () => build.imageFiles(ref, platforms, paths)),
|
|
910
|
+
config,
|
|
911
|
+
placements.sandbox,
|
|
912
|
+
converged,
|
|
913
|
+
contextDigests,
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
// --- push credentials (ADR-0051): one Secret per git.credentials entry, never per pod ----
|
|
917
|
+
// After the deploy keys exist (a generated one included), before anything else is applied: a
|
|
918
|
+
// static Binding whose entry cannot push is refused here.
|
|
919
|
+
const gitPush = await resolveGitPush(io, kube, config, placements.sandbox, namespace, ctx);
|
|
920
|
+
|
|
921
|
+
// --- dispatch preflight (ADR-0071): each foreign target's door, from the host ------------------
|
|
922
|
+
await preflightDispatch(io, carried.effects);
|
|
697
923
|
|
|
698
924
|
// --- apply + rollout ---------------------------------------------------------------------------
|
|
699
925
|
// The trust bundles first, and server-side: every pod that mounts them must find them, and they
|
|
700
926
|
// carry every root certificate — past what client-side apply's annotation can hold.
|
|
701
927
|
const trust = trustObject({ name, namespace, caBundle: caPem, bundles: held.bundles });
|
|
702
928
|
if (trust) await kube.apply({ manifest: trust, serverSide: true, ...ctx });
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
...ctx,
|
|
929
|
+
const instanceList = instanceObjects({
|
|
930
|
+
name,
|
|
931
|
+
namespace,
|
|
932
|
+
image: refs.orchestrator,
|
|
933
|
+
secretData,
|
|
934
|
+
harnessEnvData,
|
|
935
|
+
harness: config.harness,
|
|
936
|
+
held: held.objects,
|
|
937
|
+
imageRefs: converged,
|
|
938
|
+
repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
|
|
939
|
+
...(config.priorityClasses ? { priorityClasses: config.priorityClasses } : {}),
|
|
940
|
+
sandboxEffectors: placements.sandbox,
|
|
941
|
+
gitPush,
|
|
942
|
+
gitClone,
|
|
718
943
|
});
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
944
|
+
activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
|
|
945
|
+
await kube.apply({ manifest: instanceList, warn: (line) => activity(io, line), ...ctx });
|
|
946
|
+
// An entry whose clone Secret this converge did not write loses it: a dropped entry, a token
|
|
947
|
+
// unset, or nothing left that clones.
|
|
948
|
+
const cloneSecrets = new Set(gitClone.map((s) => s.name));
|
|
949
|
+
for (const secret of await kube.listJson<KubeObject>({
|
|
950
|
+
kind: "secret",
|
|
951
|
+
selector: LABEL_GIT_CLONE,
|
|
952
|
+
namespace,
|
|
953
|
+
...ctx,
|
|
954
|
+
})) {
|
|
955
|
+
if (cloneSecrets.has(secret.metadata.name)) continue;
|
|
956
|
+
await kube.deleteObject({ kind: "secret", name: secret.metadata.name, namespace, ...ctx });
|
|
727
957
|
}
|
|
958
|
+
// An entry whose push Secret this converge did not write loses it: a dropped entry, a token
|
|
959
|
+
// unset, or no push left anywhere.
|
|
960
|
+
const pushSecrets = new Set(gitPush.secrets.map((s) => s.name));
|
|
961
|
+
for (const secret of await kube.listJson<KubeObject>({
|
|
962
|
+
kind: "secret",
|
|
963
|
+
selector: LABEL_GIT_PUSH,
|
|
964
|
+
namespace,
|
|
965
|
+
...ctx,
|
|
966
|
+
})) {
|
|
967
|
+
if (pushSecrets.has(secret.metadata.name)) continue;
|
|
968
|
+
await kube.deleteObject({ kind: "secret", name: secret.metadata.name, namespace, ...ctx });
|
|
969
|
+
}
|
|
970
|
+
// A Workspace whose body no longer places an Effect in its Sandbox Effector loses its Secret;
|
|
971
|
+
// its manifest went with the ConfigMap's data above, so the next provision adds no container.
|
|
972
|
+
const effectorPods = new Set(placements.sandbox.map((p) => p.pod));
|
|
973
|
+
for (const secret of await kube.listJson<KubeObject>({
|
|
974
|
+
kind: "secret",
|
|
975
|
+
selector: LABEL_EFFECTOR_POD,
|
|
976
|
+
namespace,
|
|
977
|
+
...ctx,
|
|
978
|
+
})) {
|
|
979
|
+
if (effectorPods.has(secret.metadata.labels?.[LABEL_EFFECTOR_POD] ?? "")) continue;
|
|
980
|
+
await kube.deleteObject({ kind: "secret", name: secret.metadata.name, namespace, ...ctx });
|
|
981
|
+
}
|
|
982
|
+
// A pod's literal values go when it holds none any more, or when the pod is gone; with no secret
|
|
983
|
+
// held anywhere, the leaves go too. The CA stays (`jr2-held-ca`), so a later converge that holds
|
|
984
|
+
// one again issues leaves under the same CA.
|
|
985
|
+
const literalPods = new Set(held.objects.pods.filter((p) => Object.keys(p.values).length).map((p) => p.pod));
|
|
986
|
+
const stale = await kube.listJson<KubeObject>({ kind: "secret", selector: LABEL_HELD_POD, namespace, ...ctx });
|
|
987
|
+
for (const secret of stale) {
|
|
988
|
+
if (literalPods.has(secret.metadata.labels?.[LABEL_HELD_POD] ?? "")) continue;
|
|
989
|
+
await kube.deleteObject({ kind: "secret", name: secret.metadata.name, namespace, ...ctx });
|
|
990
|
+
}
|
|
991
|
+
if (!holdsAny(held.objects)) {
|
|
992
|
+
await kube.deleteObject({ kind: "secret", name: HELD_TLS_SECRET, namespace, ...ctx });
|
|
993
|
+
}
|
|
994
|
+
|
|
728
995
|
activity(io, "orchestrator: waiting for rollout");
|
|
729
996
|
await awaitRollout(kube, {
|
|
730
997
|
name: ORCHESTRATOR_SERVICE,
|
|
@@ -736,7 +1003,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
736
1003
|
layer: "orchestrator",
|
|
737
1004
|
namespace,
|
|
738
1005
|
selector: `app=${ORCHESTRATOR_SERVICE}`,
|
|
739
|
-
image:
|
|
1006
|
+
image: refs.orchestrator,
|
|
740
1007
|
...ctx,
|
|
741
1008
|
});
|
|
742
1009
|
|
|
@@ -766,8 +1033,8 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
766
1033
|
});
|
|
767
1034
|
} else {
|
|
768
1035
|
// The Repo resources are NOT deleted here: eviction is reachability plus age (ADR-0051), and
|
|
769
|
-
// the
|
|
770
|
-
// of them (server.ts) and `jr2 gc` takes them at the TTL. The bare clones under
|
|
1036
|
+
// the Assembly this converge publishes binds nothing, so the Orchestrator's Repo pass at that
|
|
1037
|
+
// publish unlabels every one of them (server.ts, ADR-0069) and `jr2 gc` takes them at the TTL. The bare clones under
|
|
771
1038
|
// `/var/lib/jr2/<ns>/repos` outlive the agent that would evict them — inert, like the ones
|
|
772
1039
|
// `jr2 down` leaves (down.ts), and swept by the agent's own walk if a `workspace()` ever
|
|
773
1040
|
// returns here.
|
|
@@ -780,36 +1047,54 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
780
1047
|
// The scan is the Machine walk above, and it is DEFINITION-level (the line ADR-0018 drew:
|
|
781
1048
|
// workflow internals are not statically recoverable) — a carried-but-never-invoked `"none"`
|
|
782
1049
|
// Agent over-deploys, erring toward "the convention works when you need it". No `"none"`
|
|
783
|
-
// definitions → nothing, and a stale
|
|
784
|
-
// deleted: the layer converges toward the Machines like every other layer
|
|
785
|
-
// config.
|
|
1050
|
+
// definitions → nothing, and a stale StatefulSet from a definition that dropped its `"none"` is
|
|
1051
|
+
// deleted with its conversations: the layer converges toward the Machines like every other layer
|
|
1052
|
+
// converges toward the config.
|
|
786
1053
|
const menuOnly = [...new Set(agents.filter((a) => a.definition.workspace === "none").map((a) => a.name))];
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1054
|
+
// A Chart wrapper whose Open body allows "none" needs it too: a Chart's own Agent may be Menu-only (ADR-0070).
|
|
1055
|
+
const noneWrappers = workflows
|
|
1056
|
+
.filter((w) => partsOf([w.machine]).openBodies.some((b) => b.declaration.allows.workspace.includes("none")))
|
|
1057
|
+
.map((w) => w.name);
|
|
1058
|
+
if (carried.instanceHarness !== undefined) {
|
|
1059
|
+
const why = [
|
|
1060
|
+
...(menuOnly.length
|
|
1061
|
+
? [`${menuOnly.join(", ")} declare${menuOnly.length === 1 ? "s" : ""} workspace: "none"`]
|
|
1062
|
+
: []),
|
|
1063
|
+
...(noneWrappers.length
|
|
1064
|
+
? [
|
|
1065
|
+
`chart wrapper${noneWrappers.length === 1 ? "" : "s"} ${noneWrappers.map((n) => `"${n}"`).join(", ")} allow${noneWrappers.length === 1 ? "s" : ""} workspace: "none"`,
|
|
1066
|
+
]
|
|
1067
|
+
: []),
|
|
1068
|
+
];
|
|
1069
|
+
activity(io, `instance harness: converging (${why.join("; ")})`);
|
|
792
1070
|
const harnessImage = refs.harness;
|
|
1071
|
+
const instance = instanceHeld(held.objects);
|
|
793
1072
|
await kube.apply({
|
|
794
1073
|
manifest: instanceHarnessObjects({
|
|
795
1074
|
name,
|
|
796
1075
|
namespace,
|
|
797
1076
|
harnessImage,
|
|
798
1077
|
custodianImage,
|
|
799
|
-
held:
|
|
800
|
-
|
|
1078
|
+
held: instance.manifest,
|
|
1079
|
+
custodian: instance.custodian,
|
|
1080
|
+
heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects, instance, valueDigest),
|
|
801
1081
|
harness: config.harness,
|
|
802
1082
|
caBundle: caPem !== undefined,
|
|
803
1083
|
// The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
|
|
804
1084
|
// placement from the kept key — the same shape it stamps onto every Sandbox at provision.
|
|
805
1085
|
bearerSha256: harnessTokenDigest(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
|
|
1086
|
+
...(config.priorityClasses ? { priorityClasses: config.priorityClasses } : {}),
|
|
806
1087
|
}),
|
|
807
1088
|
...ctx,
|
|
808
1089
|
});
|
|
809
1090
|
await awaitRollout(kube, {
|
|
1091
|
+
kind: "statefulset",
|
|
810
1092
|
name: INSTANCE_HARNESS_SERVICE,
|
|
811
1093
|
namespace,
|
|
812
1094
|
selector: `app=${INSTANCE_HARNESS_SERVICE}`,
|
|
1095
|
+
// A rollout waits out the old pod's drain (ADR-0031): up to a Turn's worst case, then the
|
|
1096
|
+
// new pod's start.
|
|
1097
|
+
timeoutSeconds: INSTANCE_HARNESS_DRAIN_SECONDS + 180,
|
|
813
1098
|
...ctx,
|
|
814
1099
|
});
|
|
815
1100
|
await verifyRunningImage(io, kube, {
|
|
@@ -821,12 +1106,84 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
821
1106
|
...ctx,
|
|
822
1107
|
});
|
|
823
1108
|
} else {
|
|
824
|
-
await kube.deleteObject({ kind: "
|
|
1109
|
+
await kube.deleteObject({ kind: "statefulset", name: INSTANCE_HARNESS_SERVICE, namespace, ...ctx });
|
|
1110
|
+
// The claim's retention policy deletes it with the StatefulSet; deleted here too, so the
|
|
1111
|
+
// conversations go even from a StatefulSet the controller never got to.
|
|
1112
|
+
await kube.deleteObject({ kind: "persistentvolumeclaim", name: INSTANCE_HARNESS_PVC, namespace, ...ctx });
|
|
825
1113
|
await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_SERVICE, namespace, ...ctx });
|
|
1114
|
+
await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_PODS_SERVICE, namespace, ...ctx });
|
|
1115
|
+
// Its Custodian's files go with the pod they were rendered for (ADR-0059).
|
|
1116
|
+
await kube.deleteObject({ kind: "configmap", name: INSTANCE_CUSTODIAN_CONFIGMAP, namespace, ...ctx });
|
|
826
1117
|
}
|
|
827
1118
|
|
|
1119
|
+
// --- Instance Effector (ADR-0068): converged when an Effect is placed there, absent otherwise ---
|
|
1120
|
+
// The placement is read off the Machines: an `http` Effect runs there. Its objects carry only the
|
|
1121
|
+
// secrets those Effects name, and the placement's key, derived from the kept signing key — the
|
|
1122
|
+
// key the Orchestrator derives again to sign every call and verify every answer.
|
|
1123
|
+
const effectorPlan = placements.instance;
|
|
1124
|
+
if (effectorPlan) {
|
|
1125
|
+
const names = effectorPlan.secrets.map((s) => `\`${s.name}\``).join(", ") || "none";
|
|
1126
|
+
activity(
|
|
1127
|
+
io,
|
|
1128
|
+
`instance effector: converging (${effectorPlan.effects.join(", ")}; secrets ${names}; grace ` +
|
|
1129
|
+
`${effectorGraceSeconds(effectorPlan)}s)`,
|
|
1130
|
+
);
|
|
1131
|
+
const signing = Buffer.from(signingKey, "base64");
|
|
1132
|
+
const key = effectorKey(signing, "instance");
|
|
1133
|
+
await kube.apply({
|
|
1134
|
+
manifest: instanceEffectorObjects({
|
|
1135
|
+
name,
|
|
1136
|
+
namespace,
|
|
1137
|
+
harnessImage: refs.harness,
|
|
1138
|
+
plan: effectorPlan,
|
|
1139
|
+
key,
|
|
1140
|
+
digest: effectorDigest(signing, effectorPlan, key, valueDigest),
|
|
1141
|
+
caBundle: caPem !== undefined,
|
|
1142
|
+
...(config.priorityClasses ? { priorityClasses: config.priorityClasses } : {}),
|
|
1143
|
+
}),
|
|
1144
|
+
warn: (line) => activity(io, line),
|
|
1145
|
+
...ctx,
|
|
1146
|
+
});
|
|
1147
|
+
await awaitRollout(kube, {
|
|
1148
|
+
name: INSTANCE_EFFECTOR,
|
|
1149
|
+
namespace,
|
|
1150
|
+
selector: `app=${INSTANCE_EFFECTOR}`,
|
|
1151
|
+
// A rollout waits out the old replica's drain: the longest Effect, and its read.
|
|
1152
|
+
timeoutSeconds: effectorGraceSeconds(effectorPlan) + 180,
|
|
1153
|
+
...ctx,
|
|
1154
|
+
});
|
|
1155
|
+
await verifyRunningImage(io, kube, {
|
|
1156
|
+
layer: "instance effector",
|
|
1157
|
+
namespace,
|
|
1158
|
+
selector: `app=${INSTANCE_EFFECTOR}`,
|
|
1159
|
+
image: refs.harness,
|
|
1160
|
+
container: "effector",
|
|
1161
|
+
...ctx,
|
|
1162
|
+
});
|
|
1163
|
+
} else {
|
|
1164
|
+
activity(io, "instance effector: none (no Effect is placed there)");
|
|
1165
|
+
for (const kind of ["deployment", "service", "networkpolicy", "secret"]) {
|
|
1166
|
+
await kube.deleteObject({ kind, name: INSTANCE_EFFECTOR, namespace, ...ctx });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// --- the Assembly (ADR-0069): published last of the layers, before the sweep -------------------
|
|
1171
|
+
// After every layer, because the door's checks are true only once the cluster work is done: the
|
|
1172
|
+
// image map names every `file:` digest, the Secrets exist, and the rollout above put the Kit
|
|
1173
|
+
// version the Assembly names in front of the door. A refusal leaves the previous Assembly
|
|
1174
|
+
// current, and the converge fails before the sweep, so nothing it still needs is collected.
|
|
1175
|
+
const published = await publishAssembly(io, {
|
|
1176
|
+
namespace,
|
|
1177
|
+
ctx,
|
|
1178
|
+
assembly,
|
|
1179
|
+
token: publishToken,
|
|
1180
|
+
force: values.force === true,
|
|
1181
|
+
mounts: mountedDigests(instanceList),
|
|
1182
|
+
});
|
|
1183
|
+
if (!published) return 1;
|
|
1184
|
+
|
|
828
1185
|
await reportOlderWorkspaces(io, kube, namespace, ctx, converged);
|
|
829
|
-
await sweepAfterConverge(io, { build, kube, context, ctx, converged,
|
|
1186
|
+
await sweepAfterConverge(io, { build, kube, context, ctx, converged, previous });
|
|
830
1187
|
noteDeferred(io, carried.repos);
|
|
831
1188
|
activity(io, `converged — \`jr2 run <workflow>\` when ready`);
|
|
832
1189
|
// LAST, after the success line (ADR-0047): the converge succeeded and the Repos still cannot be
|
|
@@ -837,7 +1194,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
837
1194
|
|
|
838
1195
|
/**
|
|
839
1196
|
* Every image ref one converge resolved. The Orchestrator reads `harness`/`custodian`/`sandbox` from
|
|
840
|
-
* the mounted map (`ImageRefs`); `operator`
|
|
1197
|
+
* the mounted map (`ImageRefs`); `operator` and `orchestrator` ride the same JSON because the record `jr2 up` diffs
|
|
841
1198
|
* must cover every image it builds, and one map is what keeps the record it diffs and the map pods
|
|
842
1199
|
* read from ever disagreeing (ADR-0038).
|
|
843
1200
|
*
|
|
@@ -851,7 +1208,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
851
1208
|
* never inspected, never in this map — so it runs as whatever its own `USER` says, and one that
|
|
852
1209
|
* would run as root fails the Harness container's `runAsNonRoot` at provision.
|
|
853
1210
|
*/
|
|
854
|
-
type ConvergedImages = ImageRefs & { operator?: string; sandboxUser: Record<string, string> };
|
|
1211
|
+
type ConvergedImages = ImageRefs & { operator?: string; orchestrator?: string; sandboxUser: Record<string, string> };
|
|
855
1212
|
|
|
856
1213
|
/** The map the LAST converge recorded, off the Orchestrator Deployment's annotation. Anything
|
|
857
1214
|
* unreadable (absent, hand-edited, a foreign shape) reads as "no record", which costs a rebuild —
|
|
@@ -869,11 +1226,177 @@ function previousImages(orch?: KubeObject): Partial<ConvergedImages> | undefined
|
|
|
869
1226
|
|
|
870
1227
|
/** Every ref one image map names, flattened — the kit's own plus each Sandbox Image. */
|
|
871
1228
|
function mapRefs(map: Partial<ConvergedImages>): string[] {
|
|
872
|
-
return [map.harness, map.custodian, map.operator, ...Object.values(map.sandbox ?? {})].filter(
|
|
1229
|
+
return [map.harness, map.custodian, map.operator, map.orchestrator, ...Object.values(map.sandbox ?? {})].filter(
|
|
873
1230
|
(ref): ref is string => typeof ref === "string",
|
|
874
1231
|
);
|
|
875
1232
|
}
|
|
876
1233
|
|
|
1234
|
+
/** What `GET /assembly` answers (ADR-0069), read loosely: this is the CLI's view of the door.
|
|
1235
|
+
* `mounts` is the digest of each ConfigMap the process reads through a mount. */
|
|
1236
|
+
type AssemblyView = { current?: { hash?: string; kitVersion?: string } | null; mounts?: Record<string, string> };
|
|
1237
|
+
|
|
1238
|
+
/** The digest of each mounted ConfigMap's data in the List this converge applied (ADR-0069), by
|
|
1239
|
+
* name: what the Orchestrator must read from its mounts before the publish. */
|
|
1240
|
+
function mountedDigests(list: string): Record<string, string> {
|
|
1241
|
+
const items = (JSON.parse(list) as { items: Array<KubeObject & { data?: Record<string, string> }> }).items;
|
|
1242
|
+
const out: Record<string, string> = {};
|
|
1243
|
+
for (const item of items) {
|
|
1244
|
+
if (item.kind === "ConfigMap" && item.metadata.name in MOUNTED_CONFIGMAPS) {
|
|
1245
|
+
out[item.metadata.name] = configMapDigest(item.data ?? {});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return out;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* The key of each `file:` Sandbox Image context (ADR-0037, ADR-0049, ADR-0069): the digest of its
|
|
1253
|
+
* copy in the staged Assembly at `stagedDir`, by the host directory the walk named. A context the
|
|
1254
|
+
* Assembly does not carry is refused by name: a `file:` URL that names no folder, or a folder the
|
|
1255
|
+
* Instance's install leaves out, is checkable HERE and nowhere later, since the pod only ever
|
|
1256
|
+
* resolves a digest.
|
|
1257
|
+
*/
|
|
1258
|
+
async function stagedContextDigests(stagedDir: string, root: string, dirs: string[]): Promise<Map<string, string>> {
|
|
1259
|
+
const digests = new Map<string, string>();
|
|
1260
|
+
for (const dir of new Set(dirs)) {
|
|
1261
|
+
const copy = await stagedCopyOf(stagedDir, root, dir);
|
|
1262
|
+
const shown = relative(root, dir).startsWith("..") ? dir : relative(root, dir);
|
|
1263
|
+
if (copy === undefined) {
|
|
1264
|
+
throw new Error(
|
|
1265
|
+
`the Sandbox Image context ${shown} is not in the staged Assembly — a \`workspace()\` names its image with ` +
|
|
1266
|
+
'`import.meta.resolve("./<dir>")`, so that directory must exist beside the Machine\'s own module and ' +
|
|
1267
|
+
"travel with it: check that it exists, and that the package's `files:` list keeps it (ADR-0037/0049, ADR-0069)",
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
digests.set(dir, await imageContextDigest(copy));
|
|
1271
|
+
}
|
|
1272
|
+
return digests;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** What `PUT /assembly` answers, read loosely: the 200 body, or a refusal with its report. */
|
|
1276
|
+
type PublishBody = {
|
|
1277
|
+
hash?: string;
|
|
1278
|
+
workflows?: string[];
|
|
1279
|
+
unchanged?: boolean;
|
|
1280
|
+
error?: string;
|
|
1281
|
+
report?: string[];
|
|
1282
|
+
kitVersion?: unknown;
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* How long a publish waits for the Orchestrator to read the ConfigMaps this converge wrote
|
|
1287
|
+
* (ADR-0038, ADR-0069). `jr2-images` and `jr2-held` are mounts, and the kubelet refreshes a mount
|
|
1288
|
+
* on its own period (about a minute by default). A publish rolls no pod, so until the refresh the
|
|
1289
|
+
* door's checks and the next provision read the previous data. The publish waits until the digests
|
|
1290
|
+
* `GET /assembly` reports are the ones this converge applied. Exported so a test can shorten it.
|
|
1291
|
+
*/
|
|
1292
|
+
export const MAP_PROPAGATION = { budgetMs: 150_000, stepMs: 5_000 };
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Publish the staged Assembly to the running Orchestrator (ADR-0069), over a port-forward of its
|
|
1296
|
+
* Service, as `resolveTarget` reaches it. `GET /assembly` first: when the current Assembly is this
|
|
1297
|
+
* one, at this Kit version, nothing is sent, so a steady-state converge publishes nothing. Then
|
|
1298
|
+
* `PUT /assembly` with the publish token from the Instance Secret — never from the environment.
|
|
1299
|
+
* Before the PUT, it waits until the Orchestrator reads the mounted ConfigMaps this converge wrote.
|
|
1300
|
+
* `--force` sends the PUT even when the hashes agree; the door answers `unchanged` and replaces
|
|
1301
|
+
* nothing, because the hash names the bytes it already holds.
|
|
1302
|
+
*
|
|
1303
|
+
* Returns false after it has said why, when the door refused: the previous Assembly is still
|
|
1304
|
+
* current, and the converge fails.
|
|
1305
|
+
*/
|
|
1306
|
+
async function publishAssembly(
|
|
1307
|
+
io: Io,
|
|
1308
|
+
opts: {
|
|
1309
|
+
namespace: string;
|
|
1310
|
+
ctx: { context?: string };
|
|
1311
|
+
assembly: { hash: string; kitVersion: string; workflows: string[]; tar: Buffer };
|
|
1312
|
+
token: string;
|
|
1313
|
+
force: boolean;
|
|
1314
|
+
/** The digest of each mounted ConfigMap this converge applied, by name. */
|
|
1315
|
+
mounts: Record<string, string>;
|
|
1316
|
+
},
|
|
1317
|
+
): Promise<boolean> {
|
|
1318
|
+
const { namespace, ctx, assembly, token, force } = opts;
|
|
1319
|
+
const fetchImpl = io.fetch ?? globalThis.fetch;
|
|
1320
|
+
const fwd = await (io.kube ?? kubectlKube).portForward({
|
|
1321
|
+
namespace,
|
|
1322
|
+
service: ORCHESTRATOR_SERVICE,
|
|
1323
|
+
port: ORCHESTRATOR_PORT,
|
|
1324
|
+
...ctx,
|
|
1325
|
+
});
|
|
1326
|
+
try {
|
|
1327
|
+
const base = fwd.url.replace(/\/+$/, "");
|
|
1328
|
+
const view = async (): Promise<AssemblyView> => {
|
|
1329
|
+
const seen = await fetchImpl(`${base}/assembly`);
|
|
1330
|
+
if (!seen.ok) throw new Error(`the Orchestrator answered GET /assembly with HTTP ${seen.status}`);
|
|
1331
|
+
return (await seen.json()) as AssemblyView;
|
|
1332
|
+
};
|
|
1333
|
+
let seen = await view();
|
|
1334
|
+
const current = seen.current ?? null;
|
|
1335
|
+
if (!force && current?.hash === assembly.hash && current.kitVersion === assembly.kitVersion) {
|
|
1336
|
+
activity(io, `assembly: ${assembly.hash} (current — publish skipped)`);
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
// The mounts first (ADR-0069): the door checks a `file:` digest against `jr2-images`, and a
|
|
1340
|
+
// provision after the converge reads `jr2-held`. A process that reports no mounts is not
|
|
1341
|
+
// deployed, and has nothing to wait for.
|
|
1342
|
+
const stale = (v: AssemblyView): string[] =>
|
|
1343
|
+
v.mounts ? Object.keys(opts.mounts).filter((name) => v.mounts![name] !== opts.mounts[name]) : [];
|
|
1344
|
+
const deadline = Date.now() + MAP_PROPAGATION.budgetMs;
|
|
1345
|
+
let said = false;
|
|
1346
|
+
for (let names = stale(seen); names.length; names = stale(seen)) {
|
|
1347
|
+
if (Date.now() >= deadline) {
|
|
1348
|
+
activity(
|
|
1349
|
+
io,
|
|
1350
|
+
`refusing: after ${Math.round(MAP_PROPAGATION.budgetMs / 1000)}s the Orchestrator still reads an older ` +
|
|
1351
|
+
`${names.join(", ")} from its mount — the kubelet has not refreshed it; nothing was published, and ` +
|
|
1352
|
+
`the previous Assembly is still current. Run \`jr2 up\` again`,
|
|
1353
|
+
);
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
if (!said) activity(io, `assembly: waiting for the kubelet to deliver ${names.join(", ")} to the Orchestrator`);
|
|
1357
|
+
said = true;
|
|
1358
|
+
await new Promise((ok) => setTimeout(ok, MAP_PROPAGATION.stepMs));
|
|
1359
|
+
seen = await view();
|
|
1360
|
+
}
|
|
1361
|
+
activity(
|
|
1362
|
+
io,
|
|
1363
|
+
`assembly: publishing ${assembly.hash} (${current?.hash ? `current is ${current.hash}` : "no current Assembly"})` +
|
|
1364
|
+
(force ? " (--force)" : ""),
|
|
1365
|
+
);
|
|
1366
|
+
const res = await fetchImpl(`${base}/assembly`, {
|
|
1367
|
+
method: "PUT",
|
|
1368
|
+
headers: {
|
|
1369
|
+
authorization: `Bearer ${token}`,
|
|
1370
|
+
"content-type": "application/x-tar",
|
|
1371
|
+
"jr2-kit-version": assembly.kitVersion,
|
|
1372
|
+
"jr2-assembly-hash": assembly.hash,
|
|
1373
|
+
},
|
|
1374
|
+
body: new Uint8Array(assembly.tar),
|
|
1375
|
+
});
|
|
1376
|
+
const body = (await res.json().catch(() => ({}))) as PublishBody;
|
|
1377
|
+
if (res.ok) {
|
|
1378
|
+
const names = body.workflows ?? assembly.workflows;
|
|
1379
|
+
activity(
|
|
1380
|
+
io,
|
|
1381
|
+
`assembly: ${body.hash ?? assembly.hash} is current${body.unchanged ? " (unchanged)" : ""} — ` +
|
|
1382
|
+
`workflows: ${names.join(", ") || "(none)"}`,
|
|
1383
|
+
);
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
const report = body.report ?? [];
|
|
1387
|
+
activity(
|
|
1388
|
+
io,
|
|
1389
|
+
`refusing: the Orchestrator refused Assembly ${assembly.hash} (HTTP ${res.status}): ` +
|
|
1390
|
+
`${body.error ?? "no reason given"}` +
|
|
1391
|
+
(res.status >= 500 ? "" : " — the previous Assembly is still current"),
|
|
1392
|
+
);
|
|
1393
|
+
for (const line of report) activity(io, ` ${line}`);
|
|
1394
|
+
return false;
|
|
1395
|
+
} finally {
|
|
1396
|
+
fwd.close();
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
877
1400
|
/**
|
|
878
1401
|
* Collect (ADR-0039) — and only here, at the end of a converge that fully succeeded: the annotation
|
|
879
1402
|
* is applied and every rollout is verified, which is the moment the root set moved, and moving the
|
|
@@ -899,11 +1422,10 @@ async function sweepAfterConverge(
|
|
|
899
1422
|
context: string;
|
|
900
1423
|
ctx: { context?: string };
|
|
901
1424
|
converged: ConvergedImages;
|
|
902
|
-
instanceImage: string;
|
|
903
1425
|
previous?: Partial<ConvergedImages>;
|
|
904
1426
|
},
|
|
905
1427
|
): Promise<void> {
|
|
906
|
-
const { build, kube, context, ctx, converged,
|
|
1428
|
+
const { build, kube, context, ctx, converged, previous } = opts;
|
|
907
1429
|
try {
|
|
908
1430
|
await sweepImages({
|
|
909
1431
|
io,
|
|
@@ -911,9 +1433,9 @@ async function sweepAfterConverge(
|
|
|
911
1433
|
kube,
|
|
912
1434
|
context,
|
|
913
1435
|
ctx,
|
|
914
|
-
// The
|
|
915
|
-
//
|
|
916
|
-
extraKeep:
|
|
1436
|
+
// The map carries every ref this converge resolved, the Orchestrator's own Kit ref included
|
|
1437
|
+
// (ADR-0069), so it is the whole of what this converge adds to the keep set.
|
|
1438
|
+
extraKeep: mapRefs(converged),
|
|
917
1439
|
grace: previous ? mapRefs(previous) : [],
|
|
918
1440
|
});
|
|
919
1441
|
} catch (err) {
|
|
@@ -922,20 +1444,26 @@ async function sweepAfterConverge(
|
|
|
922
1444
|
}
|
|
923
1445
|
|
|
924
1446
|
/**
|
|
925
|
-
* Held secrets, resolved (ADR-0059): the rules (`heldSecretsOf`,
|
|
926
|
-
* the
|
|
927
|
-
*
|
|
928
|
-
* — its name, its hosts, its source — and
|
|
1447
|
+
* Held secrets, resolved (ADR-0059): the rules (`heldSecretsOf`, over the names some pod claims, fed
|
|
1448
|
+
* what only the cluster knows), each pod's own manifest, values and Custodian config, the
|
|
1449
|
+
* per-Instance CA (kept, or minted on first need), one leaf per bound target (reused while it still
|
|
1450
|
+
* fits), and the trust bundles. Narrated one line per secret — its name, its hosts, its source — and
|
|
1451
|
+
* never a value.
|
|
929
1452
|
*/
|
|
930
1453
|
async function resolveHeld(
|
|
931
1454
|
io: Io,
|
|
932
1455
|
kube: KubeAdmin,
|
|
933
1456
|
opts: {
|
|
1457
|
+
/** Each Harness pod and the names it holds (`secretPlacementsOf`). */
|
|
1458
|
+
pods: readonly HeldPodPlan[];
|
|
1459
|
+
claimed: ReadonlySet<string>;
|
|
934
1460
|
config: JR2Config;
|
|
935
1461
|
name: string;
|
|
936
1462
|
namespace: string;
|
|
937
1463
|
ctx: { context?: string };
|
|
938
1464
|
envFromKeys: Map<string, string[]>;
|
|
1465
|
+
/** R10's read of the user's Secrets: key NAMES, never values. */
|
|
1466
|
+
secretKeys: (secret: string) => readonly string[] | undefined;
|
|
939
1467
|
models: string[];
|
|
940
1468
|
/** The user's `caBundle` (ADR-0020): trusted upstream, and by the Harness beside the jr2 CA. */
|
|
941
1469
|
caBundle?: string;
|
|
@@ -946,28 +1474,31 @@ async function resolveHeld(
|
|
|
946
1474
|
const decode = (data: Record<string, string> | undefined, key: string): string | undefined =>
|
|
947
1475
|
data?.[key] === undefined ? undefined : Buffer.from(data[key], "base64").toString("utf8");
|
|
948
1476
|
|
|
949
|
-
|
|
950
|
-
const referenced = new Map<string, string[] | undefined>();
|
|
951
|
-
for (const s of config.harness?.heldSecrets ?? []) {
|
|
952
|
-
const ref = s.valueFrom?.secretKeyRef.name;
|
|
953
|
-
if (ref === undefined || referenced.has(ref)) continue;
|
|
954
|
-
const found = await kube.getJson<Data>({ kind: "secret", name: ref, namespace, ...ctx });
|
|
955
|
-
referenced.set(ref, found ? Object.keys(found.data ?? {}) : undefined);
|
|
956
|
-
}
|
|
957
|
-
const { manifest, values, warnings } = heldSecretsOf(config, {
|
|
1477
|
+
const { manifest, values, warnings } = heldSecretsOf(config, opts.claimed, {
|
|
958
1478
|
envFromKeys: opts.envFromKeys,
|
|
959
|
-
secretKeys:
|
|
1479
|
+
secretKeys: opts.secretKeys,
|
|
960
1480
|
models: opts.models,
|
|
961
1481
|
});
|
|
962
1482
|
for (const warning of warnings) activity(io, `warning: ${warning}`);
|
|
1483
|
+
// Each pod's own: the bindings of the names it holds and their literal values. Its Custodian's
|
|
1484
|
+
// files are rendered by whoever makes the pod — here for the Instance Harness alone; the
|
|
1485
|
+
// Orchestrator at provision for a Sandbox, from the same manifest (ADR-0059).
|
|
1486
|
+
const pods: HeldPodObjects[] = opts.pods.map((plan) => {
|
|
1487
|
+
const own: HeldManifest = { version: 1, secrets: manifest.secrets.filter((b) => plan.names.includes(b.name)) };
|
|
1488
|
+
const literals = Object.fromEntries(
|
|
1489
|
+
own.secrets.filter((b) => b.source.kind === "literal").map((b) => [b.name, values[b.name] as string]),
|
|
1490
|
+
);
|
|
1491
|
+
return { pod: plan.pod, manifest: own, values: literals };
|
|
1492
|
+
});
|
|
1493
|
+
// The Instance Harness, when the walk placed one: the one pod `jr2 up` makes, so the one whose
|
|
1494
|
+
// Custodian files it renders.
|
|
1495
|
+
const instancePod = pods.find((p) => p.pod === INSTANCE_HARNESS_HELD_POD);
|
|
963
1496
|
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
|
|
964
|
-
const
|
|
965
|
-
bootstrap: JSON.stringify(custodianBootstrap(manifest, orchestrator)),
|
|
966
|
-
script: custodianScript(manifest),
|
|
1497
|
+
const instance: CustodianFiles | undefined = instancePod && {
|
|
1498
|
+
bootstrap: JSON.stringify(custodianBootstrap(instancePod.manifest, orchestrator)),
|
|
1499
|
+
script: custodianScript(instancePod.manifest),
|
|
967
1500
|
};
|
|
968
|
-
if (manifest.secrets.length === 0) {
|
|
969
|
-
return { objects: { manifest, values, leaves: {}, ...rendered } };
|
|
970
|
-
}
|
|
1501
|
+
if (manifest.secrets.length === 0) return { objects: { pods, ...(instance && { instance }), leaves: {} } };
|
|
971
1502
|
for (const s of manifest.secrets) {
|
|
972
1503
|
const where = s.hosts.map((h) => (h.port === 443 ? h.host : `${h.host}:${h.port}`)).join(", ");
|
|
973
1504
|
const source = s.source.kind === "literal" ? "literal" : `secret ${s.source.secret}/${s.source.key}`;
|
|
@@ -995,20 +1526,45 @@ async function resolveHeld(
|
|
|
995
1526
|
}
|
|
996
1527
|
}
|
|
997
1528
|
return {
|
|
998
|
-
objects: {
|
|
1529
|
+
objects: { pods, ...(instance && { instance }), ca, leaves },
|
|
999
1530
|
bundles: trustBundles(ca.cert, opts.caBundle),
|
|
1000
1531
|
};
|
|
1001
1532
|
}
|
|
1002
1533
|
|
|
1003
|
-
/** The Instance Harness's
|
|
1004
|
-
*
|
|
1005
|
-
function
|
|
1534
|
+
/** The Instance Harness's held objects and its Custodian's files — the walk always places it when
|
|
1535
|
+
* it converges. */
|
|
1536
|
+
function instanceHeld(held: HeldObjects): HeldPodObjects & { custodian: CustodianFiles } {
|
|
1537
|
+
const pod = held.pods.find((p) => p.pod === INSTANCE_HARNESS_HELD_POD);
|
|
1538
|
+
if (!pod || !held.instance)
|
|
1539
|
+
throw new Error("jr2 up placed no held secrets for the Instance Harness it converges — a jr2 defect");
|
|
1540
|
+
return { ...pod, custodian: held.instance };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** The Instance Harness's `jr2.dev/held-digest` (ADR-0059): an HMAC over its pod's held-secret
|
|
1544
|
+
* inputs — a literal's value, a `valueFrom`'s reference and the digest of its value — keyed with
|
|
1545
|
+
* the signing key so the annotation reveals nothing about a value. Its Custodian reads each value
|
|
1546
|
+
* once at start, so a value edited in the user's Secret must roll it — and so must an edit to its
|
|
1547
|
+
* two rendered files or the program they load. `program` is the kit's program; it is an input
|
|
1548
|
+
* only so that its test can change it. Exported for its test alone. */
|
|
1549
|
+
export function heldDigest(
|
|
1550
|
+
key: Buffer,
|
|
1551
|
+
all: HeldObjects,
|
|
1552
|
+
held: HeldPodObjects & { custodian: CustodianFiles },
|
|
1553
|
+
valueDigest: ValueDigest,
|
|
1554
|
+
program: string = CUSTODIAN_PROGRAM,
|
|
1555
|
+
): string {
|
|
1006
1556
|
const mac = createHmac("sha256", key);
|
|
1007
1557
|
mac.update(JSON.stringify(held.manifest));
|
|
1008
|
-
|
|
1558
|
+
const stems = new Set(held.manifest.secrets.flatMap((s) => s.hosts.map((h) => h.leaf)));
|
|
1559
|
+
for (const stem of [...stems].sort()) mac.update(`${stem}:${fingerprint(all.leaves[stem]!.cert)}`);
|
|
1009
1560
|
for (const name of Object.keys(held.values).sort()) mac.update(`${name}=${held.values[name]}`);
|
|
1010
|
-
|
|
1011
|
-
|
|
1561
|
+
for (const s of held.manifest.secrets) {
|
|
1562
|
+
if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
|
|
1563
|
+
}
|
|
1564
|
+
// Its Custodian's files, and the program they load: Envoy reads all three once at start.
|
|
1565
|
+
mac.update(held.custodian.bootstrap);
|
|
1566
|
+
mac.update(held.custodian.script);
|
|
1567
|
+
mac.update(program);
|
|
1012
1568
|
return mac.digest("base64url").slice(0, 32);
|
|
1013
1569
|
}
|
|
1014
1570
|
|
|
@@ -1066,9 +1622,15 @@ async function reportOlderWorkspaces(
|
|
|
1066
1622
|
* evidence gathering that hid inside the kubectl port could never be exercised without a cluster.
|
|
1067
1623
|
*/
|
|
1068
1624
|
async function awaitRollout(kube: KubeAdmin, target: RolloutTarget): Promise<void> {
|
|
1069
|
-
const { kind, name, namespace, context } = target;
|
|
1625
|
+
const { kind, name, namespace, context, timeoutSeconds } = target;
|
|
1070
1626
|
try {
|
|
1071
|
-
await kube.waitRollout({
|
|
1627
|
+
await kube.waitRollout({
|
|
1628
|
+
...(kind ? { kind } : {}),
|
|
1629
|
+
name,
|
|
1630
|
+
namespace,
|
|
1631
|
+
...(context ? { context } : {}),
|
|
1632
|
+
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
|
1633
|
+
});
|
|
1072
1634
|
} catch (err) {
|
|
1073
1635
|
throw await rolloutFailure(kube, err, target);
|
|
1074
1636
|
}
|
|
@@ -1081,7 +1643,7 @@ async function awaitRollout(kube: KubeAdmin, target: RolloutTarget): Promise<voi
|
|
|
1081
1643
|
* template never moved (stale image, no rollout) printed `converged` while running old code.
|
|
1082
1644
|
*
|
|
1083
1645
|
* Tag equality is the whole check, which is only sound because the tag IS the content address (see
|
|
1084
|
-
* `
|
|
1646
|
+
* `kitImageRefs`): on the `kind load` path a pod's `imageID` is containerd's manifest digest
|
|
1085
1647
|
* under a rewritten `import-<date>` repo, comparable to nothing the host holds.
|
|
1086
1648
|
*/
|
|
1087
1649
|
async function verifyRunningImage(
|
|
@@ -1124,6 +1686,196 @@ type PodObject = {
|
|
|
1124
1686
|
* does. A supplied key produces none of this — it is registered already. */
|
|
1125
1687
|
type GitSshNotice = { secret: string; publicKey: string; urls: string[] };
|
|
1126
1688
|
|
|
1689
|
+
/** What `jr2 up` writes for the push credentials (ADR-0051): each entry's Secret, its data base64, and
|
|
1690
|
+
* the manifest the provision reads. */
|
|
1691
|
+
export type GitPushObjects = {
|
|
1692
|
+
secrets: Array<{ name: string; data: Record<string, string> }>;
|
|
1693
|
+
manifest: GitPushManifest;
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Each `git.credentials` entry's push Secret (ADR-0051), when any Workspace pushes: the
|
|
1698
|
+
* entry's token from `.env` (Flux's `username`/`password`), and the keys of the Secrets its
|
|
1699
|
+
* `sshKey`, `push.sshKey` and `sign` name, copied as data — base64 to base64, never decoded — so
|
|
1700
|
+
* one Secret holds what the entry pushes and signs with, and the provision names it on the Sandbox
|
|
1701
|
+
* of a push whose url the entry matches. An entry with nothing it can push with is not written,
|
|
1702
|
+
* and says why. A push on a slot the Machine binds to an https url whose entry's token is unset is
|
|
1703
|
+
* refused, with its fix. A key's Secret that is absent, or a signing key's, is a warning, not a
|
|
1704
|
+
* refusal: the Secret may be written after `jr2 up` (ADR-0047's prompt, a scenario, a human), and
|
|
1705
|
+
* the entry's Secret is written without that key, so the provision refuses the push that needs it
|
|
1706
|
+
* until the Secret exists and `jr2 up` runs again.
|
|
1707
|
+
*/
|
|
1708
|
+
async function resolveGitPush(
|
|
1709
|
+
io: Io,
|
|
1710
|
+
kube: KubeAdmin,
|
|
1711
|
+
config: JR2Config,
|
|
1712
|
+
sandbox: readonly SandboxEffectorPlan[],
|
|
1713
|
+
namespace: string,
|
|
1714
|
+
ctx: { context?: string },
|
|
1715
|
+
): Promise<GitPushObjects> {
|
|
1716
|
+
const none: GitPushObjects = { secrets: [], manifest: { version: 1, entries: [] } };
|
|
1717
|
+
if (!sandbox.some((p) => p.pushes.length > 0)) return none;
|
|
1718
|
+
const { plans, skipped } = gitPushPlansOf(config.git?.credentials ?? [], (n) => io.env[n]);
|
|
1719
|
+
const read = new Map<string, Record<string, string> | undefined>();
|
|
1720
|
+
const dataOf = async (secret: string) => {
|
|
1721
|
+
if (!read.has(secret)) {
|
|
1722
|
+
const found = await kube.getJson<KubeObject & { data?: Record<string, string> }>({
|
|
1723
|
+
kind: "secret",
|
|
1724
|
+
name: secret,
|
|
1725
|
+
namespace,
|
|
1726
|
+
...ctx,
|
|
1727
|
+
});
|
|
1728
|
+
read.set(secret, found ? (found.data ?? {}) : undefined);
|
|
1729
|
+
}
|
|
1730
|
+
return read.get(secret);
|
|
1731
|
+
};
|
|
1732
|
+
const out: GitPushObjects = { secrets: [], manifest: { version: 1, entries: [] } };
|
|
1733
|
+
for (const plan of plans) {
|
|
1734
|
+
const data: Record<string, string> = {};
|
|
1735
|
+
let sshMissing: string | undefined;
|
|
1736
|
+
let signMissing: string | undefined;
|
|
1737
|
+
// One warning per entry and cause: an absent Secret is missing every key it was to give.
|
|
1738
|
+
const warned = new Set<string>();
|
|
1739
|
+
for (const source of plan.sources) {
|
|
1740
|
+
if ("literal" in source) {
|
|
1741
|
+
data[source.key] = Buffer.from(source.literal).toString("base64");
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const held = await dataOf(source.from.secret);
|
|
1745
|
+
const value = held?.[source.from.key];
|
|
1746
|
+
if (value !== undefined) {
|
|
1747
|
+
data[source.key] = value;
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (source.optional) continue;
|
|
1751
|
+
const why =
|
|
1752
|
+
held === undefined
|
|
1753
|
+
? `the Secret "${source.from.secret}" does not exist in namespace ${namespace}`
|
|
1754
|
+
: `the Secret "${source.from.secret}" holds no key ${source.from.key}`;
|
|
1755
|
+
if (source.key.startsWith("sign.")) signMissing ??= why;
|
|
1756
|
+
else sshMissing ??= why;
|
|
1757
|
+
if (warned.has(why)) continue;
|
|
1758
|
+
warned.add(why);
|
|
1759
|
+
// Not a refusal: the Secret may be written later (a scenario writes it, a human creates it).
|
|
1760
|
+
// A push under the entry that needs it is refused at provision until `jr2 up` runs again.
|
|
1761
|
+
activity(
|
|
1762
|
+
io,
|
|
1763
|
+
`warning: git.credentials "${plan.entry.match}": ${why} — a push that needs it is refused at provision ` +
|
|
1764
|
+
"until it exists and jr2 up runs again (ADR-0051)",
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
const entry = { ...plan.entry };
|
|
1768
|
+
if (sshMissing !== undefined) {
|
|
1769
|
+
entry.push = entry.push.filter((k) => k !== "ssh");
|
|
1770
|
+
for (const k of ["identity", "identity.pub", "known_hosts"]) delete data[k];
|
|
1771
|
+
}
|
|
1772
|
+
if (signMissing !== undefined) {
|
|
1773
|
+
delete entry.sign;
|
|
1774
|
+
for (const k of ["sign.identity", "sign.private.asc"]) delete data[k];
|
|
1775
|
+
const json = JSON.parse(Buffer.from(data["entry.json"]!, "base64").toString("utf8")) as Record<string, unknown>;
|
|
1776
|
+
delete json.sign;
|
|
1777
|
+
data["entry.json"] = Buffer.from(JSON.stringify({ ...json, push: entry.push })).toString("base64");
|
|
1778
|
+
} else if (sshMissing !== undefined) {
|
|
1779
|
+
const json = JSON.parse(Buffer.from(data["entry.json"]!, "base64").toString("utf8")) as Record<string, unknown>;
|
|
1780
|
+
data["entry.json"] = Buffer.from(JSON.stringify({ ...json, push: entry.push })).toString("base64");
|
|
1781
|
+
}
|
|
1782
|
+
if (entry.push.length === 0) continue;
|
|
1783
|
+
out.secrets.push({ name: entry.secret, data });
|
|
1784
|
+
out.manifest.entries.push(entry);
|
|
1785
|
+
activity(
|
|
1786
|
+
io,
|
|
1787
|
+
`git.credentials "${entry.match}": push Secret ${entry.secret} — pushes over ` +
|
|
1788
|
+
`${entry.push.map((k) => (k === "ssh" ? "ssh" : "https")).join(" and ")}` +
|
|
1789
|
+
`${entry.sign ? `; signs with ${entry.sign}` : ""}`,
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
for (const [match, why] of skipped) activity(io, `git.credentials "${match}": ${why}`);
|
|
1793
|
+
// A slot the Machine binds to an https url: its token's value is known now. An unset one is
|
|
1794
|
+
// refused, with the fix, as the clone's token is.
|
|
1795
|
+
for (const plan of sandbox) {
|
|
1796
|
+
for (const push of plan.pushes) {
|
|
1797
|
+
if (push.url === undefined || push.match === undefined) continue;
|
|
1798
|
+
if (repoIdentity(push.url).scheme === "ssh") continue;
|
|
1799
|
+
const placed = out.manifest.entries.find((e) => e.match === push.match);
|
|
1800
|
+
const why = skipped.get(push.match);
|
|
1801
|
+
if (!placed?.push.includes("token") && why !== undefined) {
|
|
1802
|
+
throw new Error(
|
|
1803
|
+
`${plan.label}: the push of Repo Slot "${push.slot}" (${push.url}): git.credentials entry ` +
|
|
1804
|
+
`"${push.match}" cannot push — ${why} (ADR-0051)`,
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return out;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* What each Workspace's Sandbox Effector image owes its pushes (ADR-0068), read off the image
|
|
1814
|
+
* this converge built — its effector image, else its Sandbox Image — by the push forms `jr2 up` can
|
|
1815
|
+
* see: a slot the Machine binds (its url's scheme, its entry's `sign`), never one the run or a
|
|
1816
|
+
* `customize()` binds, whose url is the provision's. The stock Harness image — the fallback when the
|
|
1817
|
+
* Workspace names no image and there is no `images/default` — is the kit's, so what it holds is
|
|
1818
|
+
* known, not read (STOCK_HARNESS_IMAGE). An author's image `jr2 up` did not build (a registry ref)
|
|
1819
|
+
* is not read, as its `USER` is not.
|
|
1820
|
+
*/
|
|
1821
|
+
async function checkEffectorImages(
|
|
1822
|
+
/** {@link BuildPort.imageFiles} for a ref this converge resolved — rebuilt if a sweep took it. */
|
|
1823
|
+
imageFiles: (ref: string, paths: readonly string[]) => Promise<ImageFiles>,
|
|
1824
|
+
config: JR2Config,
|
|
1825
|
+
sandbox: readonly SandboxEffectorPlan[],
|
|
1826
|
+
converged: ConvergedImages,
|
|
1827
|
+
/** Each `file:` context's key in the map: the digest of its staged copy (ADR-0069). */
|
|
1828
|
+
contextDigests: ReadonlyMap<string, string>,
|
|
1829
|
+
): Promise<void> {
|
|
1830
|
+
const list = config.git?.credentials ?? [];
|
|
1831
|
+
for (const plan of sandbox) {
|
|
1832
|
+
const forms: Array<{ form: PushForm; push: string }> = [];
|
|
1833
|
+
for (const push of plan.pushes) {
|
|
1834
|
+
if (push.url === undefined || push.match === undefined) continue;
|
|
1835
|
+
const what = `the push of Repo Slot "${push.slot}" (${push.url})`;
|
|
1836
|
+
if (isSshUrl(push.url)) forms.push({ form: "ssh-transport", push: what });
|
|
1837
|
+
const sign = push.sign ? list.find((e) => e.match === push.match)?.sign : undefined;
|
|
1838
|
+
if (sign) forms.push({ form: "gpgKey" in sign ? "gpg-sign" : "ssh-sign", push: what });
|
|
1839
|
+
}
|
|
1840
|
+
if (forms.length === 0) continue;
|
|
1841
|
+
const image = plan.image ?? plan.sandboxImage;
|
|
1842
|
+
let key = "default";
|
|
1843
|
+
if (image !== undefined) {
|
|
1844
|
+
if (!image.startsWith("file:")) continue;
|
|
1845
|
+
// A Workspace's image is a context the walk carried, so it was keyed with the Assembly.
|
|
1846
|
+
const digest = contextDigests.get(fileURLToPath(image));
|
|
1847
|
+
if (digest === undefined) continue;
|
|
1848
|
+
key = digest;
|
|
1849
|
+
}
|
|
1850
|
+
const ref = converged.sandbox[key];
|
|
1851
|
+
if (ref === undefined) {
|
|
1852
|
+
if (image !== undefined) continue;
|
|
1853
|
+
// No image named and no images/default: the Effector runs in the stock Harness image.
|
|
1854
|
+
const refusal = effectorImageRefusal({
|
|
1855
|
+
label: plan.label,
|
|
1856
|
+
ref: converged.harness,
|
|
1857
|
+
user: STOCK_HARNESS_IMAGE.user,
|
|
1858
|
+
forms,
|
|
1859
|
+
present: new Set(STOCK_HARNESS_IMAGE.present),
|
|
1860
|
+
passwd: STOCK_HARNESS_IMAGE.passwd,
|
|
1861
|
+
stock: true,
|
|
1862
|
+
});
|
|
1863
|
+
if (refusal) throw new Error(refusal);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
const files = await imageFiles(ref, effectorImagePaths(forms.map((f) => f.form)));
|
|
1867
|
+
const refusal = effectorImageRefusal({
|
|
1868
|
+
label: plan.label,
|
|
1869
|
+
ref,
|
|
1870
|
+
user: converged.sandboxUser[key] ?? "",
|
|
1871
|
+
forms,
|
|
1872
|
+
present: new Set(files.present),
|
|
1873
|
+
passwd: files.passwd,
|
|
1874
|
+
});
|
|
1875
|
+
if (refusal) throw new Error(refusal);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1127
1879
|
/** Where the git ssh key comes from — the three sources ADR-0047 offers, as a pick. */
|
|
1128
1880
|
type GitSshSource = { kind: "generate" } | { kind: "file"; path: string } | { kind: "paste" };
|
|
1129
1881
|
|
|
@@ -1193,7 +1945,7 @@ async function ensureGitSsh(
|
|
|
1193
1945
|
|
|
1194
1946
|
if (source.kind === "generate") {
|
|
1195
1947
|
const { privateKey, publicKey } = await (io.sshKeygen ?? sshKeygen)();
|
|
1196
|
-
await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1948
|
+
await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1197
1949
|
activity(io, `generated deploy key ${sshFingerprint(publicKey)} — register this PUBLIC key (read access):`);
|
|
1198
1950
|
activity(io, ` ${publicKey.trim()}`);
|
|
1199
1951
|
// The pause sits exactly where the user must act anyway (ADR-0047): the printed key is useless
|
|
@@ -1224,7 +1976,7 @@ async function ensureGitSsh(
|
|
|
1224
1976
|
} catch (err) {
|
|
1225
1977
|
throw new Error(`refusing the key from ${where}: ${err instanceof Error ? err.message : err}`);
|
|
1226
1978
|
}
|
|
1227
|
-
await applyGitSshSecret(kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1979
|
+
await applyGitSshSecret(io, kube, namespace, ctx, secret, privateKey, publicKey);
|
|
1228
1980
|
// The FINGERPRINT, never the material — not the private half, and not the public one either:
|
|
1229
1981
|
// this key is the user's own, and its public half identifies them wherever it is registered.
|
|
1230
1982
|
activity(io, `git ssh: "${secret}" applied from ${where} — ${sshFingerprint(publicKey)}`);
|
|
@@ -1322,6 +2074,7 @@ async function readSuppliedKey(path: string): Promise<string> {
|
|
|
1322
2074
|
* `identity.pub` rides along so the cluster can say which key it holds without holding it up to a
|
|
1323
2075
|
* human. Trailing newline enforced — ssh refuses a key file that lacks one. */
|
|
1324
2076
|
async function applyGitSshSecret(
|
|
2077
|
+
io: Io,
|
|
1325
2078
|
kube: KubeAdmin,
|
|
1326
2079
|
namespace: string,
|
|
1327
2080
|
ctx: { context?: string },
|
|
@@ -1337,6 +2090,7 @@ async function applyGitSshSecret(
|
|
|
1337
2090
|
type: "Opaque",
|
|
1338
2091
|
stringData: { identity: newlineTerminated(privateKey), "identity.pub": newlineTerminated(publicKey) },
|
|
1339
2092
|
}),
|
|
2093
|
+
warn: (line) => activity(io, line),
|
|
1340
2094
|
...ctx,
|
|
1341
2095
|
});
|
|
1342
2096
|
}
|
|
@@ -1443,98 +2197,662 @@ async function readCaBundle(root: string, config: JR2Config): Promise<string | u
|
|
|
1443
2197
|
}
|
|
1444
2198
|
|
|
1445
2199
|
/**
|
|
1446
|
-
* The
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
2200
|
+
* The foreign dispatch preflight (ADR-0071): for each dispatch whose target is another
|
|
2201
|
+
* Orchestrator, `GET <origin>/workflows/<name>`, and the served door compared with the slot's
|
|
2202
|
+
* input schema (`doorCompat`, one direction). The route is open band (ADR-0014), so no token is
|
|
2203
|
+
* sent: the token is the Effector's to hold, and the host would otherwise carry it over a wire the
|
|
2204
|
+
* target may not secure. Every failure — no answer, a miss, a mismatch, a schema not compared — is a
|
|
2205
|
+
* WARNING, never a refusal: the other Instance may be down or publish later, and refusing here
|
|
2206
|
+
* would couple two Instances' publish order. A run of the dispatch answers what the door then says,
|
|
2207
|
+
* the token's refusal included.
|
|
1450
2208
|
*/
|
|
1451
|
-
async function
|
|
2209
|
+
async function preflightDispatch(io: Io, effects: readonly CarriedEffect[]): Promise<void> {
|
|
2210
|
+
const fetchImpl = io.fetch ?? globalThis.fetch;
|
|
2211
|
+
const seen = new Set<string>();
|
|
2212
|
+
for (const e of effects) {
|
|
2213
|
+
if (e.kind !== "dispatch" || e.target === undefined) continue;
|
|
2214
|
+
const url = targetText({ target: e.target });
|
|
2215
|
+
const once = JSON.stringify([url, e.workflow, e.secrets, e.input ?? null]);
|
|
2216
|
+
if (seen.has(once)) continue;
|
|
2217
|
+
seen.add(once);
|
|
2218
|
+
const warn = (why: string) => activity(io, `warning: dispatch "${e.workflow}" at ${url}: ${why} (ADR-0071)`);
|
|
2219
|
+
let res: Response;
|
|
2220
|
+
try {
|
|
2221
|
+
// No redirect is followed: the Effector's dispatch client follows none, so a door that
|
|
2222
|
+
// redirects is one it never reaches.
|
|
2223
|
+
res = await fetchImpl(`${url}/workflows/${encodeURIComponent(e.workflow)}`, {
|
|
2224
|
+
headers: { accept: "application/json" },
|
|
2225
|
+
redirect: "manual",
|
|
2226
|
+
signal: AbortSignal.timeout(10_000),
|
|
2227
|
+
});
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
warn(
|
|
2230
|
+
`no answer from this host (${(err as Error).message}) — the other Instance may be down, or reached only ` +
|
|
2231
|
+
'from inside its cluster; while no door answers, the dispatch settles "unreachable"',
|
|
2232
|
+
);
|
|
2233
|
+
continue;
|
|
2234
|
+
}
|
|
2235
|
+
if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
|
|
2236
|
+
warn(
|
|
2237
|
+
`the door redirected (HTTP ${res.status}); the Effector follows no redirect, so a run of the dispatch is refused there`,
|
|
2238
|
+
);
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (res.status === 404) {
|
|
2242
|
+
warn(`no Workflow "${e.workflow}" there — a run of the dispatch is refused with the door's 404 until it has one`);
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (!res.ok) {
|
|
2246
|
+
warn(`the door answered HTTP ${res.status}`);
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
if (e.input === undefined) {
|
|
2250
|
+
activity(io, `dispatch "${e.workflow}" at ${url}: the door answers; the slot states no input schema to compare`);
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
// A Workflow detail carries its door as `input` (null: it declares none). Any other answer — a
|
|
2254
|
+
// page an ingress serves, another shape at another Kit version — is no door: not compared,
|
|
2255
|
+
// never compared as `{}`, which would call every slot field stripped.
|
|
2256
|
+
const detail: unknown = await res.json().catch(() => undefined);
|
|
2257
|
+
if (typeof detail !== "object" || detail === null || Array.isArray(detail) || !Object.hasOwn(detail, "input")) {
|
|
2258
|
+
warn("the door's answer is not a Workflow detail (no `input`); not compared");
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const compared = doorCompat(e.input, (detail as { input: unknown }).input);
|
|
2262
|
+
if (compared.ok === true) {
|
|
2263
|
+
activity(io, `dispatch "${e.workflow}" at ${url}: the door takes the slot's input (ADR-0071)`);
|
|
2264
|
+
} else if (compared.ok === "not-compared") {
|
|
2265
|
+
warn(`its input schema and the door are not compared — ${compared.why}`);
|
|
2266
|
+
} else {
|
|
2267
|
+
for (const fault of compared.faults) warn(`the door does not take the slot's input: ${fault}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** One provider of one check pod (ADR-0019, ADR-0059). */
|
|
2273
|
+
export type CheckedProvider = {
|
|
2274
|
+
/** The provider id, the specifier's part before the first `/`. */
|
|
2275
|
+
id: string;
|
|
2276
|
+
/** Every carried model of it that a Harness pod of this placement runs: a typo, a retired model
|
|
2277
|
+
* or a model the key has no access to is per model. */
|
|
2278
|
+
models: string[];
|
|
2279
|
+
/** The held secrets that are its keys in the check pod's Custodian; none when no pod of this
|
|
2280
|
+
* placement holds one, and the check then shows what a Turn there meets. */
|
|
2281
|
+
keys: string[];
|
|
2282
|
+
/** Where the calls go: the keys' bound hosts, else the provider's host as jr2 knows it. */
|
|
2283
|
+
hosts: string[];
|
|
2284
|
+
/** `harness.catalog.<id>.baseUrl`, when it moves the provider. */
|
|
2285
|
+
gateway?: string;
|
|
2286
|
+
/** The custom provider (`harness.provider`, ADR-0018): its key is optional, and its causes are
|
|
2287
|
+
* its own. */
|
|
2288
|
+
custom?: true;
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
/** One check pod: the providers that the Harness pods of one placement call. */
|
|
2292
|
+
export type CheckPod = {
|
|
2293
|
+
/** For a human: `the Sandbox nodes`, `the Instance Harness`, or both. */
|
|
2294
|
+
where: string;
|
|
2295
|
+
/** The node predicate of those pods (ADR-0052); none for the Instance Harness. */
|
|
2296
|
+
placement?: SandboxPlacement;
|
|
2297
|
+
/** The bindings of the checked keys: each a part of a held pod's manifest. */
|
|
2298
|
+
manifest: HeldManifest;
|
|
2299
|
+
providers: CheckedProvider[];
|
|
2300
|
+
};
|
|
2301
|
+
|
|
2302
|
+
/** One Harness pod as the walk places it: its held pod key, its class, the models it runs, and
|
|
2303
|
+
* the bindings it holds (none: its HTTPS goes direct, with no proxy). */
|
|
2304
|
+
type Seat = { pod: string; sandbox: boolean; models: readonly string[]; held: HeldManifest["secrets"] };
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Which providers the preflight checks, and in which pod (ADR-0019, ADR-0059). Every carried model
|
|
2308
|
+
* that a Harness pod runs is checked, in a pod with that pod's placement: a Workspace's model on the
|
|
2309
|
+
* Sandbox nodes, a `"none"` Agent's where the Instance Harness runs, so a node class whose egress is
|
|
2310
|
+
* closed fails here. The two are one check pod when their placements are the same. The custom provider
|
|
2311
|
+
* (`harness.provider`) goes the same way: its key is a held secret too (`JR2_PROVIDER_API_KEY`), so
|
|
2312
|
+
* the key never enters a pod spec, an argv or a script.
|
|
2313
|
+
*
|
|
2314
|
+
* In one placement, a provider's keys are the ones the first pod there that runs one of its models
|
|
2315
|
+
* holds (`MODEL_KEYS`, claimed by the provider). The check pod holds that pod's bindings of them and
|
|
2316
|
+
* reads their values where that pod reads them. A provider no pod there holds a key for is checked
|
|
2317
|
+
* all the same, with no key: a Turn there meets the same, and pi says so (`not configured`); a
|
|
2318
|
+
* signing credential in `harness.env` reaches the check as it reaches a Turn. Pure, for its test.
|
|
2319
|
+
*/
|
|
2320
|
+
export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readonly HeldPodObjects[]): CheckPod[] {
|
|
2321
|
+
const custom = config.harness?.provider;
|
|
2322
|
+
const heldOf = (pod: string) => pods.find((p) => p.pod === pod)?.manifest.secrets ?? [];
|
|
2323
|
+
const seats: Seat[] = [
|
|
2324
|
+
...walk.workspaces.map((ws) => {
|
|
2325
|
+
const pod = workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route });
|
|
2326
|
+
return { pod, sandbox: true, models: ws.pod.models, held: heldOf(pod) };
|
|
2327
|
+
}),
|
|
2328
|
+
...(walk.instanceHarness
|
|
2329
|
+
? [
|
|
2330
|
+
{
|
|
2331
|
+
pod: INSTANCE_HARNESS_HELD_POD,
|
|
2332
|
+
sandbox: false,
|
|
2333
|
+
models: walk.instanceHarness.models,
|
|
2334
|
+
held: heldOf(INSTANCE_HARNESS_HELD_POD),
|
|
2335
|
+
},
|
|
2336
|
+
]
|
|
2337
|
+
: []),
|
|
2338
|
+
];
|
|
2339
|
+
const sandbox = config.sandbox?.nodeSelector || config.sandbox?.tolerations?.length ? config.sandbox : undefined;
|
|
2340
|
+
const placementOf = (seat: Seat) => (seat.sandbox ? sandbox : undefined);
|
|
2341
|
+
// A check pod stands for one class of Harness pod: its nodes, and whether it holds a secret. A pod
|
|
2342
|
+
// that holds one sends its HTTPS through its Custodian (a tunnel for an unbound host); a pod that
|
|
2343
|
+
// holds none sends it direct. So the two are two check pods, even on the same nodes.
|
|
2344
|
+
const keyOf = (seat: Seat) => {
|
|
2345
|
+
const placement = placementOf(seat);
|
|
2346
|
+
return JSON.stringify({
|
|
2347
|
+
nodeSelector: placement?.nodeSelector ?? {},
|
|
2348
|
+
tolerations: placement?.tolerations ?? [],
|
|
2349
|
+
holds: seat.held.length > 0,
|
|
2350
|
+
});
|
|
2351
|
+
};
|
|
2352
|
+
const groups = new Map<string, { seats: Seat[]; placement?: SandboxPlacement }>();
|
|
2353
|
+
for (const seat of seats) {
|
|
2354
|
+
const key = keyOf(seat);
|
|
2355
|
+
const group = groups.get(key) ?? { seats: [], ...(placementOf(seat) ? { placement: placementOf(seat) } : {}) };
|
|
2356
|
+
group.seats.push(seat);
|
|
2357
|
+
groups.set(key, group);
|
|
2358
|
+
}
|
|
2359
|
+
const checkPods: CheckPod[] = [];
|
|
2360
|
+
for (const group of groups.values()) {
|
|
2361
|
+
const models: string[] = [];
|
|
2362
|
+
for (const seat of group.seats) for (const m of seat.models) if (!models.includes(m)) models.push(m);
|
|
2363
|
+
const checkPod: CheckPod = {
|
|
2364
|
+
where: [
|
|
2365
|
+
...(group.seats.some((s) => s.sandbox) ? ["the Sandbox nodes"] : []),
|
|
2366
|
+
...(group.seats.some((s) => !s.sandbox) ? ["the Instance Harness"] : []),
|
|
2367
|
+
].join(" and "),
|
|
2368
|
+
...(group.placement ? { placement: group.placement } : {}),
|
|
2369
|
+
// A class that holds a secret: the first pod's own held set, so the check pod's env (the proxy, the
|
|
2370
|
+
// Stand-ins) is that of a pod of the class; the keys the class's other pods hold join it below.
|
|
2371
|
+
manifest: { version: 1, secrets: [...group.seats[0]!.held] },
|
|
2372
|
+
providers: [],
|
|
2373
|
+
};
|
|
2374
|
+
for (const model of models) {
|
|
2375
|
+
const slash = model.indexOf("/");
|
|
2376
|
+
const id = slash > 0 ? model.slice(0, slash) : model;
|
|
2377
|
+
const known = checkPod.providers.find((p) => p.id === id);
|
|
2378
|
+
if (known) {
|
|
2379
|
+
known.models.push(model);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
const names =
|
|
2383
|
+
id === custom?.id
|
|
2384
|
+
? [PROVIDER_KEY_NAME]
|
|
2385
|
+
: Object.keys(MODEL_KEYS).filter((name) => MODEL_KEYS[name]!.providers.includes(id));
|
|
2386
|
+
const runs = group.seats.filter((s) => s.models.some((m) => m.startsWith(`${id}/`)));
|
|
2387
|
+
const holder = runs
|
|
2388
|
+
.map((s) => pods.find((p) => p.pod === s.pod))
|
|
2389
|
+
.find((p) => p?.manifest.secrets.some((b) => names.includes(b.name)));
|
|
2390
|
+
const bindings = holder?.manifest.secrets.filter((b) => names.includes(b.name)) ?? [];
|
|
2391
|
+
for (const b of bindings) {
|
|
2392
|
+
if (checkPod.manifest.secrets.some((h) => h.name === b.name)) continue;
|
|
2393
|
+
checkPod.manifest.secrets.push(b);
|
|
2394
|
+
}
|
|
2395
|
+
const gateway = config.harness?.catalog?.[id]?.baseUrl;
|
|
2396
|
+
const fallback =
|
|
2397
|
+
id === custom?.id
|
|
2398
|
+
? [new URL(custom.baseUrl).host]
|
|
2399
|
+
: gateway
|
|
2400
|
+
? [new URL(gateway).host]
|
|
2401
|
+
: names.flatMap((n) => (MODEL_KEYS[n]!.host ? [MODEL_KEYS[n]!.host!] : [])).slice(0, 1);
|
|
2402
|
+
checkPod.providers.push({
|
|
2403
|
+
id,
|
|
2404
|
+
...(id === custom?.id ? { custom: true } : {}),
|
|
2405
|
+
models: [model],
|
|
2406
|
+
keys: bindings.map((b) => b.name),
|
|
2407
|
+
hosts: bindings.length ? [...new Set(bindings.flatMap((b) => b.hosts.map(hostText)))] : fallback,
|
|
2408
|
+
...(gateway ? { gateway } : {}),
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
if (checkPod.providers.length) checkPods.push(checkPod);
|
|
2412
|
+
}
|
|
2413
|
+
return checkPods;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const hostText = (h: { host: string; port: number }): string => (h.port === 443 ? h.host : `${h.host}:${h.port}`);
|
|
2417
|
+
|
|
2418
|
+
/** How long a check pod may take to start: two cold image pulls (the Harness and the Custodian) and
|
|
2419
|
+
* Envoy's start. The check's own bound starts when its container runs. */
|
|
2420
|
+
const CHECK_START_SECONDS = 300;
|
|
2421
|
+
/** Each model's bound in the pod: its own budget in the check (`PREFLIGHT_MODEL_BUDGET_SECONDS` in
|
|
2422
|
+
* `@jr2/harness`'s `preflight-line.ts`, which a test holds this to), so one slow model never uses
|
|
2423
|
+
* the time of the others. Exported for that test. */
|
|
2424
|
+
export const CHECK_MODEL_SECONDS = 360;
|
|
2425
|
+
const CHECK_RUN_MARGIN_SECONDS = 30;
|
|
2426
|
+
/** A leftover a run left with no pod, older than this, is a dead run's, not a live one's. */
|
|
2427
|
+
const CHECK_LEFTOVER_SECONDS = 3600;
|
|
2428
|
+
|
|
2429
|
+
/**
|
|
2430
|
+
* The provider preflight (ADR-0019, ADR-0059): each check pod of `checkPodsOf`, one at a time — a
|
|
2431
|
+
* one-shot Harness-shaped pod, a Custodian that holds the providers' keys and the stock Harness
|
|
2432
|
+
* image's `provider-preflight.ts` — that demands one tool call of each model, on the path a Turn takes. So no
|
|
2433
|
+
* egress to the provider's host, a missing, wrong or empty key, a model no endpoint serves, and a
|
|
2434
|
+
* gateway that does not answer each fail HERE. Every check pod runs, a pod that cannot be applied
|
|
2435
|
+
* included, and every failure is in one error. A provider still busy after the retries, and a
|
|
2436
|
+
* model that answered but did not call a tool it was not forced to, are warnings (ADR-0064).
|
|
2437
|
+
*
|
|
2438
|
+
* Each pod reads its own objects (`checkPodObjects`), written from `values`, `leaves`, `trust`
|
|
2439
|
+
* and `harnessEnvData` — what the apply would write — and named for this run (`<run>-<n>`), so two
|
|
2440
|
+
* converges in one namespace never read or delete each other's. The ones of an earlier run of this
|
|
2441
|
+
* Instance that did not end go first (`removeCheckLeftovers`). A failed cleanup is a warning and
|
|
2442
|
+
* never hides the check's own answer.
|
|
2443
|
+
*/
|
|
2444
|
+
async function preflightProviders(
|
|
1452
2445
|
io: Io,
|
|
1453
2446
|
kube: KubeAdmin,
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2447
|
+
opts: {
|
|
2448
|
+
checkPods: CheckPod[];
|
|
2449
|
+
/** `--skip-provider-preflight`: no check pod runs, and one warning line says so. */
|
|
2450
|
+
skip: boolean;
|
|
2451
|
+
name: string;
|
|
2452
|
+
namespace: string;
|
|
2453
|
+
ctx: { context?: string };
|
|
2454
|
+
harnessImage: string;
|
|
2455
|
+
custodianImage: string;
|
|
2456
|
+
harness?: JR2Config["harness"];
|
|
2457
|
+
caBundle: boolean;
|
|
2458
|
+
values: Record<string, string>;
|
|
2459
|
+
leaves: Record<string, Pem>;
|
|
2460
|
+
trust: Record<string, string>;
|
|
2461
|
+
harnessEnvData: Record<string, string>;
|
|
2462
|
+
priorityClassName: string;
|
|
2463
|
+
},
|
|
1459
2464
|
): Promise<void> {
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
// An Open model is nobody's model yet (ADR-0054), so there is nothing to probe for it. The walk
|
|
1468
|
-
// already refused the converge above, so this filter is the type's, not the flow's.
|
|
1469
|
-
const prefix = `${provider.id}/`;
|
|
1470
|
-
const models = [
|
|
1471
|
-
...new Set(
|
|
1472
|
-
agents
|
|
1473
|
-
.map((a) => a.definition.model)
|
|
1474
|
-
.filter((m): m is string => typeof m === "string" && m.startsWith(prefix))
|
|
1475
|
-
.map((m) => m.slice(prefix.length)),
|
|
1476
|
-
),
|
|
1477
|
-
];
|
|
1478
|
-
if (models.length === 0) {
|
|
2465
|
+
const { namespace, ctx } = opts;
|
|
2466
|
+
// First, and with no check to run too: the leftovers of an earlier run can hold a key.
|
|
2467
|
+
await removeCheckLeftovers(io, kube, opts.name, namespace, ctx);
|
|
2468
|
+
if (opts.skip) {
|
|
2469
|
+
// Never silent: a converge that checked nothing must not read as one that checked. A model that
|
|
2470
|
+
// two check pods would check is named once.
|
|
2471
|
+
const models = [...new Set(opts.checkPods.flatMap((p) => p.providers.flatMap((v) => v.models)))];
|
|
1479
2472
|
activity(
|
|
1480
2473
|
io,
|
|
1481
|
-
|
|
2474
|
+
"warning: provider: preflight skipped (--skip-provider-preflight) — " +
|
|
2475
|
+
(models.length ? `these models were not checked: ${models.join(", ")}` : "no model was checked"),
|
|
1482
2476
|
);
|
|
1483
2477
|
return;
|
|
1484
2478
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
2479
|
+
if (!opts.checkPods.length) return;
|
|
2480
|
+
const run = randomBytes(4).toString("hex");
|
|
2481
|
+
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
|
|
2482
|
+
const faults: string[] = [];
|
|
2483
|
+
for (const [n, checkPod] of opts.checkPods.entries()) {
|
|
2484
|
+
const object = `${PROVIDER_PREFLIGHT}-${run}-${n}`;
|
|
2485
|
+
const models = checkPod.providers.flatMap((p) => p.models);
|
|
2486
|
+
/** The models whose result is already said, while the pod ran, and what it was: a final log
|
|
2487
|
+
* read that comes back empty must not turn a result already said into "not checked". */
|
|
2488
|
+
const said = new Set<string>();
|
|
2489
|
+
const live = new Map<string, CheckVerdict>();
|
|
2490
|
+
let lastLive = "";
|
|
2491
|
+
for (const p of checkPod.providers) {
|
|
2492
|
+
// No held key is not no credential: a credential that pi reads from `harness.env` (an AWS
|
|
2493
|
+
// role for Bedrock, say) reaches the check as it reaches a Turn.
|
|
2494
|
+
const why = p.custom
|
|
2495
|
+
? "harness.provider has no apiKey"
|
|
2496
|
+
: `no Harness pod there holds a key of "${p.id}"; a credential in harness.env still reaches it`;
|
|
2497
|
+
const holds = p.keys.length
|
|
2498
|
+
? `through a Custodian that holds ${p.keys.join(", ")} for ${p.hosts.join(", ")}`
|
|
2499
|
+
: checkPod.manifest.secrets.length
|
|
2500
|
+
? `with no held key (${why}), through the Custodian's tunnel, as those pods call it`
|
|
2501
|
+
: `with no held key (${why}), direct, as a pod that holds no secret calls it`;
|
|
2502
|
+
activity(io, `provider: checking ${p.models.join(", ")} on ${checkPod.where}, ${holds}`);
|
|
2503
|
+
}
|
|
2504
|
+
activity(
|
|
2505
|
+
io,
|
|
2506
|
+
`provider: the check pod has up to ${CHECK_START_SECONDS / 60} min to start, then up to ` +
|
|
2507
|
+
`${CHECK_MODEL_SECONDS / 60} min for ${models.length === 1 ? "its model" : `each of its ${models.length} models`}`,
|
|
2508
|
+
);
|
|
2509
|
+
const runSeconds = CHECK_MODEL_SECONDS * models.length + CHECK_RUN_MARGIN_SECONDS;
|
|
2510
|
+
// Built before the apply: a fault in jr2 here is jr2's, never the cluster's refusal.
|
|
2511
|
+
const manifest = checkPodObjects({
|
|
2512
|
+
name: opts.name,
|
|
2513
|
+
namespace,
|
|
2514
|
+
object,
|
|
2515
|
+
run,
|
|
2516
|
+
harnessImage: opts.harnessImage,
|
|
2517
|
+
custodianImage: opts.custodianImage,
|
|
2518
|
+
held: checkPod.manifest,
|
|
2519
|
+
values: opts.values,
|
|
2520
|
+
leaves: opts.leaves,
|
|
2521
|
+
trust: opts.trust,
|
|
2522
|
+
custodian: {
|
|
2523
|
+
bootstrap: JSON.stringify(custodianBootstrap(checkPod.manifest, orchestrator)),
|
|
2524
|
+
script: custodianScript(checkPod.manifest),
|
|
2525
|
+
},
|
|
2526
|
+
token: randomBytes(24).toString("hex"),
|
|
2527
|
+
harnessEnvData: opts.harnessEnvData,
|
|
2528
|
+
models,
|
|
2529
|
+
...(checkPod.placement ? { placement: checkPod.placement } : {}),
|
|
2530
|
+
priorityClassName: opts.priorityClassName,
|
|
2531
|
+
...(opts.harness ? { harness: opts.harness } : {}),
|
|
2532
|
+
caBundle: opts.caBundle,
|
|
2533
|
+
deadlineSeconds: CHECK_START_SECONDS + runSeconds + 60,
|
|
2534
|
+
});
|
|
2535
|
+
let end: PodEnd | undefined;
|
|
2536
|
+
let refused: string | undefined;
|
|
1488
2537
|
try {
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
await kube.runOneShot({
|
|
1492
|
-
namespace,
|
|
1493
|
-
name: `jr2-provider-preflight-${Date.now() % 100000}`,
|
|
1494
|
-
script,
|
|
1495
|
-
caPem,
|
|
1496
|
-
...ctx,
|
|
1497
|
-
});
|
|
1498
|
-
activity(io, `provider: ${model} reachable, and it completed a tool call`);
|
|
2538
|
+
// Server-side: the trust bundles carry every root, past client-side apply's annotation.
|
|
2539
|
+
await applyFirstContact(kube, { manifest, serverSide: true, warn: (line) => activity(io, line), ...ctx });
|
|
1499
2540
|
} catch (err) {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
2541
|
+
// A quota, a Pod Security level or a webhook that refuses the pod: this check pod's fault alone.
|
|
2542
|
+
refused = `the cluster refused the check pod's objects: ${errText(err)}`;
|
|
2543
|
+
}
|
|
2544
|
+
try {
|
|
2545
|
+
if (refused === undefined) {
|
|
2546
|
+
end = await kube.awaitPodEnd({
|
|
2547
|
+
namespace,
|
|
2548
|
+
name: object,
|
|
2549
|
+
container: CHECK_CONTAINER,
|
|
2550
|
+
startSeconds: CHECK_START_SECONDS,
|
|
2551
|
+
runSeconds,
|
|
2552
|
+
onPending: (line) => activity(io, `provider: the check pod is not running yet — ${line}`),
|
|
2553
|
+
// Each model's result as it arrives: a check of several slow models is not silence.
|
|
2554
|
+
onLog: (log: string) => {
|
|
2555
|
+
lastLive = log;
|
|
2556
|
+
for (const [model, verdict] of checkVerdicts(log)) {
|
|
2557
|
+
live.set(model, verdict);
|
|
2558
|
+
if (said.has(model) || verdict.kind === "fault") continue;
|
|
2559
|
+
said.add(model);
|
|
2560
|
+
sayVerdict(io, model, verdict);
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
...ctx,
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
} finally {
|
|
2567
|
+
await removeCheckPod(io, kube, object, namespace, ctx);
|
|
2568
|
+
}
|
|
2569
|
+
// The last log read while the pod ran stands in for an empty final read.
|
|
2570
|
+
const output = end?.output || lastLive;
|
|
2571
|
+
const verdicts = checkVerdicts(output);
|
|
2572
|
+
const started = checkStarts(output);
|
|
2573
|
+
for (const p of checkPod.providers) {
|
|
2574
|
+
for (const model of p.models) {
|
|
2575
|
+
const verdict = verdicts.get(model) ?? live.get(model);
|
|
2576
|
+
if (verdict && verdict.kind !== "fault") {
|
|
2577
|
+
if (!said.has(model)) sayVerdict(io, model, verdict);
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const pod = () => podFault(end!, checkPod, opts);
|
|
2581
|
+
const why =
|
|
2582
|
+
verdict?.reason ??
|
|
2583
|
+
refused ??
|
|
2584
|
+
(started.has(model)
|
|
2585
|
+
? `the check pod stopped while it checked this model: ${pod()}`
|
|
2586
|
+
: `not checked: the check pod stopped before it reached this model: ${pod()}`);
|
|
2587
|
+
faults.push(providerFault(p, model, checkPod.where, why, checkPod.manifest.secrets.length > 0));
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (faults.length) throw new Error(faults.join("\n"));
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** How long the first apply in a new namespace waits for its `default` ServiceAccount: the
|
|
2595
|
+
* controller makes it a moment after the namespace, and until then a pod is refused. */
|
|
2596
|
+
const FIRST_CONTACT_TRIES = 10;
|
|
2597
|
+
const FIRST_CONTACT_PAUSE_MS = 1000;
|
|
2598
|
+
|
|
2599
|
+
/** `kube.apply`, tried again while the namespace has no `default` ServiceAccount yet. Any other
|
|
2600
|
+
* refusal is the answer. */
|
|
2601
|
+
async function applyFirstContact(kube: KubeAdmin, opts: Parameters<KubeAdmin["apply"]>[0]): Promise<void> {
|
|
2602
|
+
for (let n = 1; ; n++) {
|
|
2603
|
+
try {
|
|
2604
|
+
return await kube.apply(opts);
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
if (n >= FIRST_CONTACT_TRIES || !/serviceaccount "default" not found/i.test(errText(err))) throw err;
|
|
2607
|
+
await sleepMs(FIRST_CONTACT_PAUSE_MS);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** The trust bundles a check pod reads (`<object>-ca`), as `trustObject` writes them to `jr2-ca`. */
|
|
2613
|
+
function trustData(
|
|
2614
|
+
caBundle: string | undefined,
|
|
2615
|
+
bundles: { extra: string; bundle: string; upstream: string } | undefined,
|
|
2616
|
+
) {
|
|
2617
|
+
return {
|
|
2618
|
+
...(caBundle !== undefined ? { "ca.crt": caBundle } : {}),
|
|
2619
|
+
...(bundles ? { "extra.crt": bundles.extra, "bundle.crt": bundles.bundle, "upstream.crt": bundles.upstream } : {}),
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/** How long after it ended an earlier run's check pod is left alone: that run may still be reading
|
|
2624
|
+
* its log. Exported for its test. */
|
|
2625
|
+
export const CHECK_ENDED_MARGIN_SECONDS = 600;
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* The leftovers of earlier runs of THIS Instance (`preflightProviders`), by label: a check pod
|
|
2629
|
+
* past its `activeDeadlineSeconds` and `CHECK_ENDED_MARGIN_SECONDS`, in any phase; a check pod that
|
|
2630
|
+
* ended more than `CHECK_ENDED_MARGIN_SECONDS` ago; each with its objects; and each object whose
|
|
2631
|
+
* pod is gone and that is older than any check pod lives. A live pod, a pod that ended a short time
|
|
2632
|
+
* ago and another Instance's objects stay. Every failure here is a warning.
|
|
2633
|
+
*/
|
|
2634
|
+
async function removeCheckLeftovers(
|
|
2635
|
+
io: Io,
|
|
2636
|
+
kube: KubeAdmin,
|
|
2637
|
+
name: string,
|
|
2638
|
+
namespace: string,
|
|
2639
|
+
ctx: { context?: string },
|
|
2640
|
+
) {
|
|
2641
|
+
const selector = `${LABEL_PROVIDER_PREFLIGHT},${LABEL_INSTANCE}=${name}`;
|
|
2642
|
+
const since = (at: string | undefined) => (Date.now() - Date.parse(at ?? "")) / 1000;
|
|
2643
|
+
try {
|
|
2644
|
+
type CheckObject = KubeObject & {
|
|
2645
|
+
metadata: { creationTimestamp?: string };
|
|
2646
|
+
spec?: { activeDeadlineSeconds?: number };
|
|
2647
|
+
status?: {
|
|
2648
|
+
phase?: string;
|
|
2649
|
+
containerStatuses?: Array<{ name?: string; state?: { terminated?: { finishedAt?: string } } }>;
|
|
2650
|
+
};
|
|
2651
|
+
};
|
|
2652
|
+
const pods = await kube.listJson<CheckObject>({ kind: "pod", selector, namespace, ...ctx });
|
|
2653
|
+
const named = new Set(pods.map((p) => p.metadata.name));
|
|
2654
|
+
for (const p of pods) {
|
|
2655
|
+
// Past its deadline and the margin, in any phase: a pod no node took has no start time, so
|
|
2656
|
+
// the kubelet never applies `activeDeadlineSeconds` to it, and its Secret holds a key.
|
|
2657
|
+
const deadline = (p.spec?.activeDeadlineSeconds ?? 0) + CHECK_ENDED_MARGIN_SECONDS;
|
|
2658
|
+
if (since(p.metadata.creationTimestamp) > deadline) {
|
|
2659
|
+
await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
if (!["Succeeded", "Failed"].includes(p.status?.phase ?? "")) continue;
|
|
2663
|
+
// The check container, or `probe` in a pod that an older `jr2 up` made: the label finds both.
|
|
2664
|
+
const finished = p.status?.containerStatuses?.find((c) => c.name === CHECK_CONTAINER || c.name === "probe")?.state
|
|
2665
|
+
?.terminated?.finishedAt;
|
|
2666
|
+
if (!(since(finished ?? p.metadata.creationTimestamp) > CHECK_ENDED_MARGIN_SECONDS)) continue;
|
|
2667
|
+
await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
|
|
2668
|
+
}
|
|
2669
|
+
for (const kind of ["configmap", "secret"]) {
|
|
2670
|
+
for (const o of await kube.listJson<CheckObject>({ kind, selector, namespace, ...ctx })) {
|
|
2671
|
+
const owner = o.metadata.name.replace(/-(ca|tls|env)$/, "");
|
|
2672
|
+
if (named.has(owner) || !(since(o.metadata.creationTimestamp) > CHECK_LEFTOVER_SECONDS)) continue;
|
|
2673
|
+
await kube.deleteObject({ kind, name: o.metadata.name, namespace, wait: false, ...ctx });
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
activity(io, `warning: provider: the leftovers of an earlier preflight were not removed (${errText(err)})`);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** One check pod's objects, deleted; a failure is a warning, so it hides no check's answer. */
|
|
2682
|
+
async function removeCheckPod(io: Io, kube: KubeAdmin, object: string, namespace: string, ctx: { context?: string }) {
|
|
2683
|
+
for (const { kind, name } of checkPodObjectNames(object)) {
|
|
2684
|
+
try {
|
|
2685
|
+
await kube.deleteObject({ kind, name, namespace, wait: false, ...ctx });
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
activity(
|
|
2688
|
+
io,
|
|
2689
|
+
`warning: provider: ${kind} ${name} was not deleted (${errText(err)}) — the next \`jr2 up\` removes it`,
|
|
1507
2690
|
);
|
|
1508
2691
|
}
|
|
1509
2692
|
}
|
|
1510
2693
|
}
|
|
1511
2694
|
|
|
2695
|
+
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
2696
|
+
|
|
2697
|
+
/** A fault in which the provider refused the key or its rights: a 401 or a 403, in the words the
|
|
2698
|
+
* apis use. The provider answered, so the key is the likely cause, and the egress is not. */
|
|
2699
|
+
export function refusedKey(fault: string): boolean {
|
|
2700
|
+
return /\b40[13]\b|authentication|unauthori[sz]ed|permission|forbidden|invalid[ _-]?(x-)?api[ _-]?key/i.test(fault);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/** One model's result line (`preflight-line.ts` in `@jr2/harness`). */
|
|
2704
|
+
type CheckVerdict = { kind: "ok" } | { kind: "fault" | "busy" | "warn"; reason: string };
|
|
2705
|
+
|
|
2706
|
+
/** One model's result that does not fail the converge, said as it comes. */
|
|
2707
|
+
function sayVerdict(
|
|
2708
|
+
io: Io,
|
|
2709
|
+
model: string,
|
|
2710
|
+
verdict: { kind: "ok" } | { kind: "busy" | "warn" | "fault"; reason: string },
|
|
2711
|
+
) {
|
|
2712
|
+
if (verdict.kind === "ok") activity(io, `provider: ${model} reachable, and it completed a tool call`);
|
|
2713
|
+
else if (verdict.kind === "busy") {
|
|
2714
|
+
activity(io, `warning: provider: ${model} answered with the key, but is busy — ${verdict.reason} (ADR-0064)`);
|
|
2715
|
+
} else if (verdict.kind === "warn")
|
|
2716
|
+
activity(io, `warning: provider: ${model} answered with the key — ${verdict.reason}`);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/** The models whose check began (`start` lines, `preflight-line.ts`): a model with a start and no
|
|
2720
|
+
* verdict was on when the pod stopped; a model with neither was never reached. */
|
|
2721
|
+
export function checkStarts(output: string): Set<string> {
|
|
2722
|
+
const started = new Set<string>();
|
|
2723
|
+
for (const line of output.split("\n")) {
|
|
2724
|
+
const m = /^jr2\.preflight model=(\S+) start$/.exec(line.trim());
|
|
2725
|
+
if (m) started.add(m[1]!);
|
|
2726
|
+
}
|
|
2727
|
+
return started;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** Each model's verdict, off the check container's output lines (`preflight-line.ts` in `@jr2/harness`, held
|
|
2731
|
+
* to this parser by a test). A line that is not a result is not a verdict. */
|
|
2732
|
+
export function checkVerdicts(output: string): Map<string, CheckVerdict> {
|
|
2733
|
+
const verdicts = new Map<string, CheckVerdict>();
|
|
2734
|
+
for (const line of output.split("\n")) {
|
|
2735
|
+
const m = /^jr2\.preflight model=(\S+) (?:(ok)|(fault|busy|warn)=(.*))$/.exec(line.trim());
|
|
2736
|
+
if (!m) continue;
|
|
2737
|
+
verdicts.set(m[1]!, m[2] ? { kind: "ok" } : { kind: m[3] as "fault" | "busy" | "warn", reason: m[4]! });
|
|
2738
|
+
}
|
|
2739
|
+
return verdicts;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
1512
2742
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
* empty tool_calls, which read as "the endpoint cannot call tools" when it could.
|
|
2743
|
+
* Why a check pod said nothing of a model, from how it ended (`PodEnd`): the hint follows the reason
|
|
2744
|
+
* the kubelet gave, and the Custodian's own refusal line is quoted when it has one. Exported for its
|
|
2745
|
+
* test.
|
|
1517
2746
|
*/
|
|
1518
|
-
function
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
2747
|
+
export function podFault(
|
|
2748
|
+
end: PodEnd,
|
|
2749
|
+
checkPod: Pick<CheckPod, "where">,
|
|
2750
|
+
images: { harnessImage: string; custodianImage: string },
|
|
2751
|
+
): string {
|
|
2752
|
+
const refusal = /jr2\.custodian refuse secret=\S+ reason=[\w-]+/.exec(end.custodian)?.[0];
|
|
2753
|
+
const said = end.reasons.length ? ` (${end.reasons.join("; ")})` : "";
|
|
2754
|
+
const stuck = end.stuck ?? "";
|
|
2755
|
+
if (/ErrImagePull|ImagePullBackOff|InvalidImageName/.test(stuck)) {
|
|
2756
|
+
return (
|
|
2757
|
+
`the check pod cannot start: ${stuck}${said} — a node on ${checkPod.where} cannot pull ` +
|
|
2758
|
+
`${stuck.startsWith("custodian") ? images.custodianImage : images.harnessImage}; check \`registry\` or ` +
|
|
2759
|
+
"`kitRegistry`, and that those nodes can pull from it"
|
|
2760
|
+
);
|
|
2761
|
+
}
|
|
2762
|
+
if (/CreateContainerConfigError/.test(stuck)) {
|
|
2763
|
+
// The env of the check container: what `harness.env` and `harness.envFrom` name.
|
|
2764
|
+
return (
|
|
2765
|
+
`the check pod cannot start: ${stuck}${said} — a Secret, a ConfigMap or a key that the Harness env names ` +
|
|
2766
|
+
"is missing (a `harness.envFrom` Secret, or a `harness.env` valueFrom)"
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
if (stuck.startsWith("FailedMount")) {
|
|
2770
|
+
// A volume of the Custodian: a held secret's `valueFrom` Secret, or its key.
|
|
2771
|
+
return (
|
|
2772
|
+
`the check pod cannot start: ${stuck} — a Secret or a key that a \`secrets\` valueFrom names is missing ` +
|
|
2773
|
+
"in the Instance's namespace"
|
|
2774
|
+
);
|
|
2775
|
+
}
|
|
2776
|
+
const oom = [stuck, ...end.reasons].find((r) => /OOMKilled/.test(r));
|
|
2777
|
+
if (oom) {
|
|
2778
|
+
const custodian = oom.startsWith("custodian");
|
|
2779
|
+
return (
|
|
2780
|
+
`the check pod ran out of memory: ${oom}${said} — ` +
|
|
2781
|
+
(custodian
|
|
2782
|
+
? `its Custodian has the Instance Harness Custodian's limit (${KIT_POD_SIZES.instanceCustodian.limits.memory})`
|
|
2783
|
+
: `the check container's limit is ${KIT_POD_SIZES.checkContainer.limits.memory}`) +
|
|
2784
|
+
"; a jr2 defect, not a fault of the provider"
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
if (refusal || stuck.startsWith("custodian")) {
|
|
2788
|
+
return (
|
|
2789
|
+
`the check pod's Custodian did not start: ${refusal ?? stuck}${said} — a Custodian stops at start on a held ` +
|
|
2790
|
+
"value that is missing, empty or not printable ASCII"
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
if (end.phase === "timeout") {
|
|
2794
|
+
return end.started
|
|
2795
|
+
? `the check did not end in time${said}`
|
|
2796
|
+
: `the check pod did not start in ${CHECK_START_SECONDS}s${said} — no node on ${checkPod.where} fits it, or its image pulls are slow`;
|
|
2797
|
+
}
|
|
2798
|
+
if (!end.output.trim()) {
|
|
2799
|
+
return `the check pod ended ${end.phase}${said}, and jr2 up could not read its log — run \`jr2 up\` again`;
|
|
2800
|
+
}
|
|
2801
|
+
const tail = end.output.trim().split("\n").filter(Boolean).slice(-5).join("; ");
|
|
2802
|
+
return `the check pod ended ${end.phase} before it checked this model${said} — it said: ${tail}`;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/** One model's preflight failure: the model, the host, and the likely causes. */
|
|
2806
|
+
function providerFault(p: CheckedProvider, model: string, where: string, fault: string, custodian: boolean): string {
|
|
2807
|
+
const hosts = p.hosts.join(", ") || `the host of "${p.id}"`;
|
|
2808
|
+
// A model that loads on first use (Ollama, a large vLLM model) can take longer than the bound.
|
|
2809
|
+
// Only where jr2 cannot know the endpoint: the custom provider, or a gateway.
|
|
2810
|
+
const slow =
|
|
2811
|
+
(p.custom || p.gateway) && /no answer in \d+ s|no verdict in \d+ s|time ran out/.test(fault)
|
|
2812
|
+
? " - a model that loads on first use can take longer than the check waits: run `jr2 up` again once it has loaded\n"
|
|
2813
|
+
: "";
|
|
2814
|
+
if (p.custom) {
|
|
2815
|
+
// The custom provider's own causes (ADR-0018): an endpoint jr2 knows nothing about but its url.
|
|
2816
|
+
return (
|
|
2817
|
+
`provider preflight failed for model "${model}" against ${hosts} (from a pod on ${where}, as a Turn calls it): ` +
|
|
2818
|
+
`${fault}\n` +
|
|
2819
|
+
slow +
|
|
2820
|
+
// A refused key: the endpoint answered, so the key comes first and the reach is not it.
|
|
2821
|
+
(refusedKey(fault)
|
|
2822
|
+
? (p.keys.length
|
|
2823
|
+
? ` - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)\n`
|
|
2824
|
+
: " - the endpoint wants a key: set harness.provider.apiKey\n") +
|
|
2825
|
+
" - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)"
|
|
2826
|
+
: " - localhost never works from a pod; use a LAN address\n" +
|
|
2827
|
+
" - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)\n" +
|
|
2828
|
+
" - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser\n" +
|
|
2829
|
+
' - stop reason "length" means the model ran out of tokens before the call (a thinking model reasons first)' +
|
|
2830
|
+
(p.keys.length ? `\n - the held secret ${p.keys.join(" or ")} may be wrong (harness.provider.apiKey)` : ""))
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
const key = p.keys.length
|
|
2834
|
+
? ` - the held secret ${p.keys.join(" or ")} may be empty, wrong, or without access to this model (\`secrets\` in jr2.config.ts)\n`
|
|
2835
|
+
: ` - no Harness pod on ${where} holds a key of "${p.id}": add its key to \`secrets\`` +
|
|
2836
|
+
(Object.keys(MODEL_KEYS).some((n) => MODEL_KEYS[n]!.providers.includes(p.id))
|
|
2837
|
+
? ` (${Object.keys(MODEL_KEYS)
|
|
2838
|
+
.filter((n) => MODEL_KEYS[n]!.providers.includes(p.id))
|
|
2839
|
+
.join(" or ")}), or set in harness.env the credential that pi reads for it\n`
|
|
2840
|
+
: `, or set in harness.env the credential that pi reads for it; jr2 knows no key name of "${p.id}" ` +
|
|
2841
|
+
"(when pi does not know the id either, the line above says that it resolves to nothing)\n");
|
|
2842
|
+
// The likely cause first: a provider that refused the key answered, so the egress is not it.
|
|
2843
|
+
const egress = ` - the pods may have no egress to ${hosts} (a NetworkPolicy, a firewall, no route out of the cluster)\n`;
|
|
2844
|
+
const causes = refusedKey(fault) ? key : egress + key;
|
|
1523
2845
|
return (
|
|
1524
|
-
`
|
|
1525
|
-
|
|
1526
|
-
`
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
`const choice = j.choices?.[0];` +
|
|
1535
|
-
`const calls = choice?.message?.tool_calls;` +
|
|
1536
|
-
`if (!calls?.length) throw new Error("completion carried no tool_calls (finish_reason " + JSON.stringify(choice?.finish_reason ?? null) + ")");` +
|
|
1537
|
-
`console.log("PROVIDER OK");`
|
|
2846
|
+
`provider preflight failed for model "${model}" toward ${hosts} (from a pod on ${where}, ` +
|
|
2847
|
+
`${custodian ? "through a Custodian" : "direct"}, ` +
|
|
2848
|
+
`as a Turn calls it): ${fault}\n` +
|
|
2849
|
+
slow +
|
|
2850
|
+
causes +
|
|
2851
|
+
(p.gateway
|
|
2852
|
+
? ` - harness.catalog.${p.id}.baseUrl (${p.gateway}) may not answer, or may not serve "${model.slice(p.id.length + 1)}" ` +
|
|
2853
|
+
"(a LiteLLM model_name alias)\n"
|
|
2854
|
+
: "") +
|
|
2855
|
+
" - a private CA on that path must be in harness.caBundle"
|
|
1538
2856
|
);
|
|
1539
2857
|
}
|
|
1540
2858
|
|
|
@@ -1543,7 +2861,7 @@ function noteDeferred(io: Io, repos: CarriedRepo[]): void {
|
|
|
1543
2861
|
if (repos.length) {
|
|
1544
2862
|
activity(
|
|
1545
2863
|
io,
|
|
1546
|
-
`repos: ${repos.length} bound Repo(s) — the Orchestrator creates their Repo resources at boot; the cache ` +
|
|
2864
|
+
`repos: ${repos.length} bound Repo(s) — the Orchestrator creates their Repo resources at boot and at each publish (ADR-0069); the cache ` +
|
|
1547
2865
|
"agent clones on first need (`jr2 status` reports sync state)",
|
|
1548
2866
|
);
|
|
1549
2867
|
}
|