@jr2/cli 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/build.ts +4 -4
- package/src/caller-tokens.ts +108 -0
- package/src/cli.ts +33 -2
- package/src/client.ts +227 -23
- package/src/command-table.ts +269 -20
- package/src/commands/caller.ts +115 -0
- package/src/commands/completion.ts +236 -0
- package/src/commands/gates.ts +23 -0
- package/src/commands/hooks.ts +31 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/logs.ts +107 -31
- package/src/commands/run.ts +77 -27
- package/src/commands/runs.ts +17 -78
- package/src/commands/send.ts +1 -1
- package/src/commands/status.ts +138 -7
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +440 -83
- package/src/commands/watch.ts +57 -0
- package/src/commands/whoami.ts +22 -0
- package/src/commands/workflows.ts +58 -0
- package/src/commands/workspace.ts +316 -0
- package/src/deploy.ts +409 -42
- package/src/instance-check.ts +57 -7
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/landlock.ts +129 -0
- package/src/mount-wait.ts +82 -0
- package/src/output.ts +3 -0
- package/src/sse.ts +1 -1
- package/src/workspaces.ts +307 -0
package/src/deploy.ts
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
// namespace are constants (namespace is the identity); every object carries the instance label so
|
|
4
4
|
// ownership is derivable from the cluster — there is no local target state.
|
|
5
5
|
|
|
6
|
-
import { createHmac } from "node:crypto";
|
|
6
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import {
|
|
10
|
+
CALLER_DIGESTS_KEY,
|
|
11
|
+
CALLER_DIGESTS_MOUNT,
|
|
12
|
+
CALLER_DIGESTS_SECRET,
|
|
13
|
+
callerTokenDigest,
|
|
14
|
+
callerTokenSecret,
|
|
10
15
|
CA_CONFIGMAP,
|
|
11
16
|
CA_MOUNT,
|
|
12
17
|
EFFECTOR_PORT,
|
|
@@ -19,6 +24,14 @@ import {
|
|
|
19
24
|
CUSTODIAN_PROGRAM,
|
|
20
25
|
CUSTODIAN_PROGRAM_KEY,
|
|
21
26
|
CUSTODIAN_SCRIPT_KEY,
|
|
27
|
+
CREDENTIAL_KEYS,
|
|
28
|
+
controlStandIn,
|
|
29
|
+
controlToken,
|
|
30
|
+
heldStandIn,
|
|
31
|
+
heldStandInKey,
|
|
32
|
+
HOOK_DIGESTS_KEY,
|
|
33
|
+
HOOK_DIGESTS_MOUNT,
|
|
34
|
+
HOOK_DIGESTS_SECRET,
|
|
22
35
|
INSTANCE_CUSTODIAN_CONFIGMAP,
|
|
23
36
|
GIT_SSH_SECRET,
|
|
24
37
|
HELD_CA_SECRET,
|
|
@@ -47,6 +60,8 @@ import {
|
|
|
47
60
|
REPO_CACHE_HOSTPATH,
|
|
48
61
|
SERVICE_ACCOUNT_CA,
|
|
49
62
|
STATE_PVC,
|
|
63
|
+
STATE_VOLUME_DEFAULT,
|
|
64
|
+
memoryBytes,
|
|
50
65
|
type EffectorPlan,
|
|
51
66
|
type SandboxEffectorPlan,
|
|
52
67
|
effectorManifestKey,
|
|
@@ -59,7 +74,11 @@ import {
|
|
|
59
74
|
type GitCloneSecret,
|
|
60
75
|
type GitPushManifest,
|
|
61
76
|
type HarnessConfig,
|
|
77
|
+
type JR2Config,
|
|
78
|
+
isStdioServer,
|
|
79
|
+
mcpTimeoutMs,
|
|
62
80
|
type HeldManifest,
|
|
81
|
+
markHarnessEnv,
|
|
63
82
|
type PriorityClasses,
|
|
64
83
|
type SandboxPlacement,
|
|
65
84
|
} from "@jr2/orchestrator/internal";
|
|
@@ -86,13 +105,122 @@ export const ANNOTATION_IMAGES = "jr2.dev/images";
|
|
|
86
105
|
|
|
87
106
|
export const ORCHESTRATOR_SA = "jr2-orchestrator";
|
|
88
107
|
|
|
108
|
+
/** The label on each named Caller token's Secret (ADR-0078), with the token's name as its value.
|
|
109
|
+
* `jr2 up` finds the Secrets of the names that the config no longer declares by it, and
|
|
110
|
+
* `jr2 caller rotate` finds every value whose digest the Orchestrator reads. */
|
|
111
|
+
export const LABEL_CALLER_TOKEN = "jr2.dev/caller-token";
|
|
112
|
+
|
|
113
|
+
/** The key of a named Caller token's value in its Secret (`callerTokenSecret`). */
|
|
114
|
+
export const CALLER_TOKEN_KEY = "token";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `jr2-caller-digests` file (ADR-0078): one JSON object from each named Caller token's name to
|
|
118
|
+
* the sha-256 of its value, in hex, names in order. The Orchestrator reads it through a mount, and
|
|
119
|
+
* never a value.
|
|
120
|
+
*/
|
|
121
|
+
export function callerDigestsJson(values: Readonly<Record<string, string>>): string {
|
|
122
|
+
const names = Object.keys(values).sort();
|
|
123
|
+
return JSON.stringify(Object.fromEntries(names.map((n) => [n, callerTokenDigest(values[n]!)])));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The Secrets of the named Caller tokens (ADR-0078): one per name, which holds its value and which
|
|
128
|
+
* no pod mounts (`jr2 caller token` reads it over the kube API), and `jr2-caller-digests`, which
|
|
129
|
+
* the Orchestrator mounts. The digests Secret is applied always, empty with no token, so the
|
|
130
|
+
* mount always has its Secret.
|
|
131
|
+
*/
|
|
132
|
+
export function callerTokenObjects(
|
|
133
|
+
values: Readonly<Record<string, string>>,
|
|
134
|
+
meta: (name: string, extra?: Record<string, string>) => KubeManifest,
|
|
135
|
+
): KubeManifest[] {
|
|
136
|
+
return [
|
|
137
|
+
...Object.keys(values)
|
|
138
|
+
.sort()
|
|
139
|
+
.map(
|
|
140
|
+
(name): KubeManifest => ({
|
|
141
|
+
apiVersion: "v1",
|
|
142
|
+
kind: "Secret",
|
|
143
|
+
metadata: meta(callerTokenSecret(name), { [LABEL_CALLER_TOKEN]: name }),
|
|
144
|
+
type: "Opaque",
|
|
145
|
+
stringData: { [CALLER_TOKEN_KEY]: values[name]! },
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
{
|
|
149
|
+
apiVersion: "v1",
|
|
150
|
+
kind: "Secret",
|
|
151
|
+
metadata: meta(CALLER_DIGESTS_SECRET),
|
|
152
|
+
type: "Opaque",
|
|
153
|
+
stringData: { [CALLER_DIGESTS_KEY]: callerDigestsJson(values) },
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `jr2-hook-digests` (ADR-0078): one JSON object from the secret name of each Hook whose verifier is
|
|
160
|
+
* `token` to the sha-256 of its value, in hex, names in order. The Orchestrator mounts it, reads it
|
|
161
|
+
* again when it changes, and never holds a value. Applied always, empty with no `token` Hook, so the
|
|
162
|
+
* mount always has its Secret.
|
|
163
|
+
*/
|
|
164
|
+
export function hookDigestsObject(
|
|
165
|
+
digests: Readonly<Record<string, string>>,
|
|
166
|
+
meta: (name: string) => KubeManifest,
|
|
167
|
+
): KubeManifest {
|
|
168
|
+
const names = Object.keys(digests).sort();
|
|
169
|
+
return {
|
|
170
|
+
apiVersion: "v1",
|
|
171
|
+
kind: "Secret",
|
|
172
|
+
metadata: meta(HOOK_DIGESTS_SECRET),
|
|
173
|
+
type: "Opaque",
|
|
174
|
+
stringData: { [HOOK_DIGESTS_KEY]: JSON.stringify(Object.fromEntries(names.map((n) => [n, digests[n]!]))) },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
89
178
|
/** The HMAC over the held-secret inputs, on the Instance Harness's pod template (ADR-0059). */
|
|
90
179
|
export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
|
|
91
180
|
|
|
181
|
+
/** The digest of the `jr2-harness` ConfigMap's text (`harnessConfigJson`), on the Instance
|
|
182
|
+
* Harness's pod template: the Harness reads the text once, at start, so an edit to the custom
|
|
183
|
+
* provider, a gateway or an MCP server (ADR-0079) rolls the pod. The text holds no value, so the
|
|
184
|
+
* digest is not keyed. */
|
|
185
|
+
export const ANNOTATION_HARNESS_CONFIG_DIGEST = "jr2.dev/harness-config-digest";
|
|
186
|
+
|
|
92
187
|
/** The Instance Harness pod's `fsGroup` — a Sandbox pod's default work group (ADR-0005), for the
|
|
93
188
|
* same reason: a group every container is granted, which the Custodian's values are readable by. */
|
|
94
189
|
const INSTANCE_HARNESS_FS_GROUP = 2000;
|
|
95
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The Instance Harness's credentials Secret (ADR-0076, ADR-0059): what a Sandbox's `<sandbox>-token`
|
|
193
|
+
* is to a Sandbox. It holds the pod's control token (`controlToken`, for the placement's name), its
|
|
194
|
+
* secret control Stand-in and one secret Stand-in per held secret of the pod, under
|
|
195
|
+
* `CREDENTIAL_KEYS` and `heldStandInKey`. `jr2 up` mints it, as the pod's maker. The Custodian
|
|
196
|
+
* mounts the token and the Stand-ins; the Harness container mounts the Stand-ins alone, as files
|
|
197
|
+
* under `/run/jr2/` (`custodianComposition`). A Secret of its own, never `jr2-instance`: the
|
|
198
|
+
* Instance token and the signing key share no Secret with anything the Harness container mounts.
|
|
199
|
+
*/
|
|
200
|
+
export const INSTANCE_HARNESS_CREDENTIALS = `${INSTANCE_HARNESS_SERVICE}-token`;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One pod's credentials Secret data (ADR-0076, ADR-0059), minted by `jr2 up` as that pod's maker:
|
|
204
|
+
* the control token, the secret control Stand-in, and one secret Stand-in per held secret of
|
|
205
|
+
* `manifest` (`heldStandIn`, under the pod key `heldPod`). The Instance Harness's control token is
|
|
206
|
+
* signed for the placement's name, so it speaks only for the Turns hosted there; a check pod's
|
|
207
|
+
* (`controlToken: "unsigned"`) is a value the Orchestrator never signed — it reads no Menu
|
|
208
|
+
* (ADR-0019). No pod here serves a fetch, so neither holds a fetch token. Never logged: the values
|
|
209
|
+
* reach a Secret and nothing else.
|
|
210
|
+
*/
|
|
211
|
+
export function podCredentials(
|
|
212
|
+
key: Buffer,
|
|
213
|
+
opts: { placement: string; heldPod: string; manifest: HeldManifest; controlToken?: "signed" | "unsigned" },
|
|
214
|
+
): Record<string, string> {
|
|
215
|
+
const data: Record<string, string> = {
|
|
216
|
+
[CREDENTIAL_KEYS.controlToken]:
|
|
217
|
+
opts.controlToken === "unsigned" ? randomBytes(24).toString("hex") : controlToken(key, opts.placement),
|
|
218
|
+
[CREDENTIAL_KEYS.controlStandIn]: controlStandIn(key, opts.placement),
|
|
219
|
+
};
|
|
220
|
+
for (const s of opts.manifest.secrets) data[heldStandInKey(s.name)] = heldStandIn(key, opts.heldPod, s.name);
|
|
221
|
+
return data;
|
|
222
|
+
}
|
|
223
|
+
|
|
96
224
|
/** How long the Instance Harness pod is given to stop (ADR-0031's drain): on SIGTERM it stops
|
|
97
225
|
* admitting and finishes the Turns it holds, so the grace is a Turn's worst case. `jr2 up` waits
|
|
98
226
|
* out a rollout that long, and no longer. */
|
|
@@ -110,6 +238,48 @@ export const INSTANCE_HARNESS_PODS_SERVICE = `${INSTANCE_HARNESS_SERVICE}-pods`;
|
|
|
110
238
|
/** The two ingress NetworkPolicies (ADR-0058) — see `harnessIngressPolicies`. */
|
|
111
239
|
export const SANDBOX_INGRESS_POLICY = "jr2-sandbox-ingress";
|
|
112
240
|
export const INSTANCE_HARNESS_INGRESS_POLICY = "jr2-instance-harness-ingress";
|
|
241
|
+
/** The two egress NetworkPolicies (ADR-0076) — see `metadataEgress`. */
|
|
242
|
+
export const SANDBOX_EGRESS_POLICY = "jr2-sandbox-egress";
|
|
243
|
+
export const INSTANCE_HARNESS_EGRESS_POLICY = "jr2-instance-harness-egress";
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The cloud metadata addresses that a Harness pod does not reach (ADR-0076): where a node's
|
|
247
|
+
* metadata service answers pods, it gives the node's own cloud credential, which no `workspace()`
|
|
248
|
+
* grants. AWS's IMDS (IPv4 and IPv6), ECS's and EKS Pod Identity's credential agents, the
|
|
249
|
+
* metadata service of GCP and Azure (the same 169.254.169.254), and Alibaba's. Each is a single
|
|
250
|
+
* address: all of 169.254.0.0/16 would cut NodeLocal DNSCache (169.254.20.10). The list is not
|
|
251
|
+
* complete for every provider, so the shared-cluster guide tells the admin to check theirs.
|
|
252
|
+
*/
|
|
253
|
+
export const METADATA_ADDRESSES_V4 = [
|
|
254
|
+
"169.254.169.254/32",
|
|
255
|
+
"169.254.170.2/32",
|
|
256
|
+
"169.254.170.23/32",
|
|
257
|
+
"100.100.100.200/32",
|
|
258
|
+
] as const;
|
|
259
|
+
export const METADATA_ADDRESSES_V6 = ["fd00:ec2::254/128"] as const;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The egress of a Harness pod (ADR-0076): everything, except the cloud metadata addresses.
|
|
263
|
+
* NetworkPolicy has no deny rule, so the rule allows every address but those, and every pod of the
|
|
264
|
+
* cluster as a peer of its own. A CNI that matches an `ipBlock` against a pod's address too (Cilium)
|
|
265
|
+
* would otherwise cut DNS, the Orchestrator and each Service. The policy needs a CNI that enforces
|
|
266
|
+
* NetworkPolicy; on one that does not, nothing is blocked.
|
|
267
|
+
*/
|
|
268
|
+
export function metadataEgress(podSelector: Record<string, unknown>): Record<string, unknown> {
|
|
269
|
+
return {
|
|
270
|
+
podSelector,
|
|
271
|
+
policyTypes: ["Egress"],
|
|
272
|
+
egress: [
|
|
273
|
+
{
|
|
274
|
+
to: [
|
|
275
|
+
{ ipBlock: { cidr: "0.0.0.0/0", except: [...METADATA_ADDRESSES_V4] } },
|
|
276
|
+
{ ipBlock: { cidr: "::/0", except: [...METADATA_ADDRESSES_V6] } },
|
|
277
|
+
{ namespaceSelector: {} },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
113
283
|
|
|
114
284
|
/** The operator's install location — per-cluster, shared by every instance (ADR-0019). */
|
|
115
285
|
export const OPERATOR_NAMESPACE = "jr2-system";
|
|
@@ -265,21 +435,77 @@ export type HeldObjects = {
|
|
|
265
435
|
/** Does any pod hold a secret? */
|
|
266
436
|
export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
|
|
267
437
|
|
|
438
|
+
/** What `jr2 up` does with the state volume claim (ADR-0065): the size to apply and the one line
|
|
439
|
+
* that says what changed, or the refusal's text. */
|
|
440
|
+
export type StateVolumePlan = { size: string; line?: string } | { refused: string };
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The size of the Orchestrator's state volume claim (ADR-0065). `configured` is `runs.volume`;
|
|
444
|
+
* `claim` the claim that exists, with its StorageClass and whether the class allows expansion
|
|
445
|
+
* (`undefined` when `jr2 up` did not read it). A new claim takes `runs.volume` or the default. A
|
|
446
|
+
* claim that exists keeps its size when the config sets none, grows when the config sets a larger
|
|
447
|
+
* size on a class that expands, and refuses a smaller size, or a larger one on a class that does
|
|
448
|
+
* not expand: Kubernetes never makes a claim smaller, and grows it only where the class allows.
|
|
449
|
+
*/
|
|
450
|
+
export function stateVolumePlan(
|
|
451
|
+
configured: string | undefined,
|
|
452
|
+
claim?: { size: string; storageClass?: string; expandable?: boolean },
|
|
453
|
+
): StateVolumePlan {
|
|
454
|
+
if (!claim) return { size: configured ?? STATE_VOLUME_DEFAULT };
|
|
455
|
+
if (configured === undefined) {
|
|
456
|
+
if (memoryBytes(claim.size) === memoryBytes(STATE_VOLUME_DEFAULT)) return { size: claim.size };
|
|
457
|
+
return {
|
|
458
|
+
size: claim.size,
|
|
459
|
+
line: `state volume: keeping the claim ${STATE_PVC} at ${claim.size} — runs.volume is not set, and the default ${STATE_VOLUME_DEFAULT} is for a new claim`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const want = memoryBytes(configured);
|
|
463
|
+
const have = memoryBytes(claim.size);
|
|
464
|
+
if (want === have) return { size: claim.size };
|
|
465
|
+
if (want < have) {
|
|
466
|
+
return {
|
|
467
|
+
refused:
|
|
468
|
+
`runs.volume is ${configured}, smaller than the state volume claim ${STATE_PVC} (${claim.size}) — Kubernetes never ` +
|
|
469
|
+
`makes a claim smaller. Set runs.volume to ${claim.size} or more (ADR-0065)`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const named = claim.storageClass ? `StorageClass "${claim.storageClass}"` : "claim's StorageClass";
|
|
473
|
+
if (claim.expandable !== true) {
|
|
474
|
+
return {
|
|
475
|
+
refused:
|
|
476
|
+
`runs.volume is ${configured}, larger than the state volume claim ${STATE_PVC} (${claim.size}), and its ${named} ` +
|
|
477
|
+
`does not allow expansion (allowVolumeExpansion is not true). Keep runs.volume at ${claim.size}, or use a ` +
|
|
478
|
+
`StorageClass that allows expansion (ADR-0065)`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return { size: configured, line: `state volume: growing the claim ${STATE_PVC} from ${claim.size} to ${configured}` };
|
|
482
|
+
}
|
|
483
|
+
|
|
268
484
|
/** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
|
|
269
485
|
export function instanceObjects(opts: {
|
|
270
486
|
name: string;
|
|
271
487
|
namespace: string;
|
|
488
|
+
/** The size of the state volume claim (`stateVolumePlan`, ADR-0065). Default: `5Gi`. */
|
|
489
|
+
stateVolume?: string;
|
|
272
490
|
/** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
|
|
273
491
|
* Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
|
|
274
492
|
image: string;
|
|
275
493
|
/** Secret data (the Instance token, the publish token, the signing key) — written stringData,
|
|
276
494
|
* kube encodes. The Orchestrator reads it through `envFrom`. */
|
|
277
495
|
secretData: Record<string, string>;
|
|
496
|
+
/** The values of the named Caller tokens, by name (ADR-0078): each goes into a Secret of its own,
|
|
497
|
+
* and the Orchestrator mounts only their digests (`callerTokenObjects`). Absent: none. */
|
|
498
|
+
callerTokens?: Readonly<Record<string, string>>;
|
|
499
|
+
/** The sha-256 digests of the `token` Hooks' secrets, by secret name (ADR-0078): the Orchestrator
|
|
500
|
+
* mounts them in `jr2-hook-digests` (`hookDigestsObject`). Absent: none. */
|
|
501
|
+
hookDigests?: Readonly<Record<string, string>>;
|
|
278
502
|
/** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
|
|
279
503
|
* doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
|
|
280
504
|
* key must never share a Secret with it, and no model key rides it either (ADR-0059). */
|
|
281
505
|
harnessEnvData: Record<string, string>;
|
|
282
506
|
harness?: HarnessConfig;
|
|
507
|
+
/** The config's `mcp` block (ADR-0079), for the `jr2-harness` ConfigMap. */
|
|
508
|
+
mcp?: JR2Config["mcp"];
|
|
283
509
|
/** Held secrets (ADR-0059): the Custodian's objects. The `jr2-held` ConfigMap is applied always —
|
|
284
510
|
* every Harness pod runs a Custodian — and the rest only while a secret is held. */
|
|
285
511
|
held: HeldObjects;
|
|
@@ -323,7 +549,7 @@ export function instanceObjects(opts: {
|
|
|
323
549
|
metadata: meta(STATE_PVC),
|
|
324
550
|
spec: {
|
|
325
551
|
accessModes: ["ReadWriteOnce"],
|
|
326
|
-
resources: { requests: { storage:
|
|
552
|
+
resources: { requests: { storage: opts.stateVolume ?? STATE_VOLUME_DEFAULT } },
|
|
327
553
|
},
|
|
328
554
|
},
|
|
329
555
|
{ apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
|
|
@@ -368,7 +594,7 @@ export function instanceObjects(opts: {
|
|
|
368
594
|
kind: "ConfigMap",
|
|
369
595
|
metadata: meta(HARNESS_CONFIGMAP),
|
|
370
596
|
data: {
|
|
371
|
-
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
|
|
597
|
+
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
|
|
372
598
|
},
|
|
373
599
|
},
|
|
374
600
|
{
|
|
@@ -460,6 +686,13 @@ export function instanceObjects(opts: {
|
|
|
460
686
|
type: "Opaque",
|
|
461
687
|
stringData: opts.secretData,
|
|
462
688
|
},
|
|
689
|
+
// The named Caller tokens (ADR-0078): a value in a Secret of its own, and the digests in the
|
|
690
|
+
// one Secret the Orchestrator mounts below. Not in `jr2-instance`: that one is `envFrom`, which
|
|
691
|
+
// a running pod reads once, and a new token or a rotation must roll no pod.
|
|
692
|
+
...callerTokenObjects(opts.callerTokens ?? {}, meta),
|
|
693
|
+
// The `token` Hooks' digests (ADR-0078): a Secret of their own, as the door reads them, and
|
|
694
|
+
// never `jr2-caller-digests`, which `jr2 caller rotate` writes whole. Never a value.
|
|
695
|
+
hookDigestsObject(opts.hookDigests ?? {}, meta),
|
|
463
696
|
{
|
|
464
697
|
// What the HARNESS containers envFrom (see `harnessEnvData` above): Agent creds, never the
|
|
465
698
|
// Instance token. Always applied (possibly empty) so the Sandbox spec can reference it
|
|
@@ -513,6 +746,9 @@ export function instanceObjects(opts: {
|
|
|
513
746
|
// built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
|
|
514
747
|
// else — no TLS code, no dependency.
|
|
515
748
|
{ name: "NODE_EXTRA_CA_CERTS", value: SERVICE_ACCOUNT_CA },
|
|
749
|
+
// The claim's size as this converge applied it (ADR-0065): the space guard
|
|
750
|
+
// measures against it, because a hostPath class's `statfs` gives the node's disk.
|
|
751
|
+
{ name: "JR2_STATE_VOLUME", value: opts.stateVolume ?? STATE_VOLUME_DEFAULT },
|
|
516
752
|
],
|
|
517
753
|
// The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
|
|
518
754
|
// agent on each node does, reading the credential Secret a Repo's `secretRef`
|
|
@@ -527,6 +763,13 @@ export function instanceObjects(opts: {
|
|
|
527
763
|
// (ADR-0059): names and hosts, which this process could not derive — its `.env`
|
|
528
764
|
// values are absent.
|
|
529
765
|
{ name: "held", mountPath: HELD_MOUNT, readOnly: true },
|
|
766
|
+
// The digests of the named Caller tokens (ADR-0078), read again when they
|
|
767
|
+
// change: a new token or a rotation costs one kubelet propagation window, about
|
|
768
|
+
// a minute, and no rollout. Never a value.
|
|
769
|
+
{ name: "callers", mountPath: CALLER_DIGESTS_MOUNT, readOnly: true },
|
|
770
|
+
// The digests of the `token` Hooks' secrets (ADR-0078), read again in the same
|
|
771
|
+
// way: a new or changed secret costs one kubelet propagation window. Never a value.
|
|
772
|
+
{ name: "hooks", mountPath: HOOK_DIGESTS_MOUNT, readOnly: true },
|
|
530
773
|
],
|
|
531
774
|
resources: sized(KIT_POD_SIZES.orchestrator),
|
|
532
775
|
// The period, not the boot, is what `up`'s rollout wait measures. Measured: the
|
|
@@ -564,6 +807,8 @@ export function instanceObjects(opts: {
|
|
|
564
807
|
{ name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
|
|
565
808
|
{ name: "images", configMap: { name: IMAGES_CONFIGMAP } },
|
|
566
809
|
{ name: "held", configMap: { name: HELD_CONFIGMAP } },
|
|
810
|
+
{ name: "callers", secret: { secretName: CALLER_DIGESTS_SECRET } },
|
|
811
|
+
{ name: "hooks", secret: { secretName: HOOK_DIGESTS_SECRET } },
|
|
567
812
|
],
|
|
568
813
|
},
|
|
569
814
|
},
|
|
@@ -579,6 +824,7 @@ export function instanceObjects(opts: {
|
|
|
579
824
|
},
|
|
580
825
|
},
|
|
581
826
|
...harnessIngressPolicies(opts.name, meta),
|
|
827
|
+
...harnessEgressPolicies(meta),
|
|
582
828
|
...(opts.repoCache
|
|
583
829
|
? repoCacheObjects({
|
|
584
830
|
...opts.repoCache,
|
|
@@ -610,7 +856,7 @@ const SANDBOX_POD_LABEL = "sandbox.jr2.dev/name";
|
|
|
610
856
|
*
|
|
611
857
|
* - The Orchestrator is not selected. Every route there is token-gated, and a Custodian in any
|
|
612
858
|
* Harness pod must reach it; a policy could not tell the Custodian's packets from the Agent's.
|
|
613
|
-
* - No egress.
|
|
859
|
+
* - No egress. That is `harnessEgressPolicies`: open, except the cloud metadata addresses (ADR-0076).
|
|
614
860
|
* - No port. The Orchestrator speaks only the Harness wire to these pods, and a port here would
|
|
615
861
|
* have to track the CR's `port`; `kubectl port-forward` (the CLI, a human's shell into the User
|
|
616
862
|
* Container — ADR-0005) and kubelet probes never cross a policy at all.
|
|
@@ -631,6 +877,26 @@ function harnessIngressPolicies(instance: string, meta: (name: string) => KubeMa
|
|
|
631
877
|
];
|
|
632
878
|
}
|
|
633
879
|
|
|
880
|
+
/**
|
|
881
|
+
* No Harness pod reaches a cloud metadata address (ADR-0076, `metadataEgress`). A Sandbox runs
|
|
882
|
+
* Workspace code, and the node's credential is one that no `workspace()` grants. The Instance
|
|
883
|
+
* Harness runs no Workspace code, but it gets the same rule: a check pod of the provider preflight
|
|
884
|
+
* stands for every Harness pod (ADR-0019), so a model that only the node's credential reaches must
|
|
885
|
+
* fail the check, not a Turn. Converged unconditionally, for the reason the ingress policies are.
|
|
886
|
+
*/
|
|
887
|
+
function harnessEgressPolicies(meta: (name: string) => KubeManifest): KubeManifest[] {
|
|
888
|
+
const policy = (name: string, podSelector: Record<string, unknown>): KubeManifest => ({
|
|
889
|
+
apiVersion: "networking.k8s.io/v1",
|
|
890
|
+
kind: "NetworkPolicy",
|
|
891
|
+
metadata: meta(name),
|
|
892
|
+
spec: metadataEgress(podSelector),
|
|
893
|
+
});
|
|
894
|
+
return [
|
|
895
|
+
policy(SANDBOX_EGRESS_POLICY, { matchExpressions: [{ key: SANDBOX_POD_LABEL, operator: "Exists" }] }),
|
|
896
|
+
policy(INSTANCE_HARNESS_EGRESS_POLICY, { matchLabels: { app: INSTANCE_HARNESS_SERVICE } }),
|
|
897
|
+
];
|
|
898
|
+
}
|
|
899
|
+
|
|
634
900
|
/** Where the cache agent's pod sees the node's directory: `--cache-dir`'s default. */
|
|
635
901
|
const REPO_CACHE_MOUNT = "/cache";
|
|
636
902
|
/** The agent's `$HOME` — an emptyDir, where it writes a deploy key for the life of the pod. */
|
|
@@ -651,6 +917,10 @@ const REPO_CACHE_HOME = "/home/jr2";
|
|
|
651
917
|
* (ADR-0052), so an agent lands on exactly the nodes a Sandbox can be placed on. The ServiceAccount token IS mounted: the agent is a client of the Repo resources and
|
|
652
918
|
* of the pods on its node (never of Sandboxes — demand is a pod's mount), unlike a Sandbox, whose
|
|
653
919
|
* north star is never reaching the API.
|
|
920
|
+
*
|
|
921
|
+
* Because it lands on exactly the Sandbox nodes, it is also the Landlock detector (ADR-0077): it
|
|
922
|
+
* probes at start and, on a node without Landlock, exits with the reason, and `jr2 up` refuses to
|
|
923
|
+
* converge (`landlock.ts`).
|
|
654
924
|
*/
|
|
655
925
|
function repoCacheObjects(opts: {
|
|
656
926
|
image: string;
|
|
@@ -713,6 +983,11 @@ function repoCacheObjects(opts: {
|
|
|
713
983
|
image,
|
|
714
984
|
imagePullPolicy: "IfNotPresent",
|
|
715
985
|
command: ["/manager", "repo-cache"],
|
|
986
|
+
// On a node without Landlock the agent exits at start with a message that names
|
|
987
|
+
// Landlock, the node and the reason (ADR-0077). The kubelet keeps it in the
|
|
988
|
+
// container's status, where `jr2 up` reads it; the log line serves if the file
|
|
989
|
+
// write fails.
|
|
990
|
+
terminationMessagePolicy: "FallbackToLogsOnError",
|
|
716
991
|
env: [
|
|
717
992
|
// The downward API names the node this pod is the writer for, and the namespace
|
|
718
993
|
// whose Repos and pods it watches (the agent's `--node` / `--namespace`).
|
|
@@ -844,7 +1119,8 @@ export function instanceHarnessObjects(opts: {
|
|
|
844
1119
|
* Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
|
|
845
1120
|
custodianImage: string;
|
|
846
1121
|
/** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
|
|
847
|
-
* keys its `"none"` Agents' providers read
|
|
1122
|
+
* keys its `"none"` Agents' providers read and the secrets of the remote MCP servers they name
|
|
1123
|
+
* (ADR-0079), and nothing else. */
|
|
848
1124
|
held: HeldManifest;
|
|
849
1125
|
/** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
|
|
850
1126
|
* (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
|
|
@@ -853,7 +1129,13 @@ export function instanceHarnessObjects(opts: {
|
|
|
853
1129
|
* Custodian's config), keyed with the signing key so it reveals nothing — on the pod template, so
|
|
854
1130
|
* a change to any of them rolls this pod (ADR-0059). */
|
|
855
1131
|
heldDigest: string;
|
|
1132
|
+
/** The pod's credentials Secret data (`podCredentials`): its control token and its secret
|
|
1133
|
+
* Stand-ins, applied here as {@link INSTANCE_HARNESS_CREDENTIALS} with the pod they are for. */
|
|
1134
|
+
credentials: Record<string, string>;
|
|
856
1135
|
harness?: HarnessConfig;
|
|
1136
|
+
/** The config's `mcp` block (ADR-0079): with `harness`, the text of the `jr2-harness` ConfigMap,
|
|
1137
|
+
* whose digest is on the pod template. */
|
|
1138
|
+
mcp?: JR2Config["mcp"];
|
|
857
1139
|
/** The instance ships a private-CA bundle (ADR-0020). */
|
|
858
1140
|
caBundle?: boolean;
|
|
859
1141
|
/** The digest of this placement's Harness bearer (ADR-0058; `harnessTokenDigest(key,
|
|
@@ -880,15 +1162,16 @@ export function instanceHarnessObjects(opts: {
|
|
|
880
1162
|
seccompProfile: { type: "RuntimeDefault" },
|
|
881
1163
|
});
|
|
882
1164
|
|
|
883
|
-
// The Custodian (ADR-0013, ADR-0059): the placement's
|
|
884
|
-
// placement's name
|
|
885
|
-
//
|
|
886
|
-
// the
|
|
1165
|
+
// The Custodian (ADR-0013, ADR-0059, ADR-0076): the placement's control token — signed for this
|
|
1166
|
+
// placement's name, so it speaks only for the Turns hosted HERE (tokens.ts) — and the held secrets
|
|
1167
|
+
// placed here, from the pod's own credentials Secret (`podCredentials`). The tokens are mounted
|
|
1168
|
+
// into the Custodian alone, the Stand-ins into the Custodian and, as files, the Harness; the
|
|
1169
|
+
// Instance token itself never enters this pod. The pod serves no fetch: no Repo is mounted here.
|
|
887
1170
|
const custodian = custodianComposition(opts.held, {
|
|
888
1171
|
image: opts.custodianImage,
|
|
889
1172
|
pod: INSTANCE_HARNESS_HELD_POD,
|
|
890
1173
|
config: INSTANCE_CUSTODIAN_CONFIGMAP,
|
|
891
|
-
|
|
1174
|
+
credentials: { secret: INSTANCE_HARNESS_CREDENTIALS },
|
|
892
1175
|
caBundle: opts.caBundle === true,
|
|
893
1176
|
resources: sized(KIT_POD_SIZES.instanceCustodian),
|
|
894
1177
|
});
|
|
@@ -901,24 +1184,28 @@ export function instanceHarnessObjects(opts: {
|
|
|
901
1184
|
// The same asymmetry the Sandbox pod builds (ADR-0013/0020/0059): the mounted harness config
|
|
902
1185
|
// and the instance's valueFrom entries ride `env` (literal values live in the jr2-harness-env
|
|
903
1186
|
// Secret), then the Custodian's address and every Stand-in, and no credential ever does.
|
|
904
|
-
env:
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1187
|
+
// Marked (ADR-0076, `harness-env.ts`): the stock image's command starts the Harness with
|
|
1188
|
+
// `effector nocore --harness-env`, which gives the Harness process jr2's names alone.
|
|
1189
|
+
...markHarnessEnv(
|
|
1190
|
+
[
|
|
1191
|
+
{
|
|
1192
|
+
name: "JR2_HARNESS_JSON",
|
|
1193
|
+
valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
|
|
1194
|
+
},
|
|
1195
|
+
// The placement gate (ADR-0031): every admission carries its own definition (ADR-0049), so
|
|
1196
|
+
// the Harness itself refuses any admission whose definition declares Workspace access —
|
|
1197
|
+
// Menu-only Agents alone run here, which is what makes "no code execution in this pod" true
|
|
1198
|
+
// rather than asserted. The bearer (below) narrows who may try; this decides what.
|
|
1199
|
+
{ name: "JR2_MENU_ONLY", value: "1" },
|
|
1200
|
+
...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
|
|
1201
|
+
...custodian.harnessEnv,
|
|
1202
|
+
// Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
|
|
1203
|
+
{ name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
|
|
1204
|
+
// The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
|
|
1205
|
+
{ name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
|
|
1206
|
+
],
|
|
1207
|
+
[{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
|
|
1208
|
+
),
|
|
922
1209
|
// The Harness container's alone: the Custodian has no business with an Agent's history.
|
|
923
1210
|
volumeMounts: [{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR }, ...custodian.harnessMounts],
|
|
924
1211
|
// The operator probes a Sandbox's Harness the same way: serving = the socket accepts.
|
|
@@ -938,6 +1225,14 @@ export function instanceHarnessObjects(opts: {
|
|
|
938
1225
|
};
|
|
939
1226
|
|
|
940
1227
|
const items: KubeManifest[] = [
|
|
1228
|
+
// The pod's credentials (ADR-0076): a Secret, never a ConfigMap, env or the pod template.
|
|
1229
|
+
{
|
|
1230
|
+
apiVersion: "v1",
|
|
1231
|
+
kind: "Secret",
|
|
1232
|
+
metadata: { ...meta(), name: INSTANCE_HARNESS_CREDENTIALS },
|
|
1233
|
+
type: "Opaque",
|
|
1234
|
+
stringData: opts.credentials,
|
|
1235
|
+
},
|
|
941
1236
|
// The one pod `jr2 up` makes, so the one whose Custodian files it renders: its bootstrap and
|
|
942
1237
|
// its HELD table, from its own manifest, beside the program its volume projects from `jr2-held`.
|
|
943
1238
|
{
|
|
@@ -982,7 +1277,16 @@ export function instanceHarnessObjects(opts: {
|
|
|
982
1277
|
// start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
|
|
983
1278
|
// (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
|
|
984
1279
|
// can.
|
|
985
|
-
|
|
1280
|
+
// The same for the `jr2-harness` text, which the Harness reads once at start: a server
|
|
1281
|
+
// added to `mcp`, or a url, an argv or a timeout changed, reaches the pod (ADR-0079).
|
|
1282
|
+
annotations: {
|
|
1283
|
+
[ANNOTATION_HELD_DIGEST]: opts.heldDigest,
|
|
1284
|
+
[ANNOTATION_HARNESS_CONFIG_DIGEST]: createHash("sha256")
|
|
1285
|
+
.update(harnessConfigJson(opts.harness, opts.mcp))
|
|
1286
|
+
.digest("base64url")
|
|
1287
|
+
.slice(0, 32),
|
|
1288
|
+
...NO_DISRUPT_ANNOTATIONS,
|
|
1289
|
+
},
|
|
986
1290
|
},
|
|
987
1291
|
spec: {
|
|
988
1292
|
// The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
|
|
@@ -1044,9 +1348,10 @@ export function instanceHarnessObjects(opts: {
|
|
|
1044
1348
|
}
|
|
1045
1349
|
|
|
1046
1350
|
/** What the Instance can REACH (ADR-0018), as the `jr2-harness` ConfigMap carries it to every
|
|
1047
|
-
* Harness container: the custom provider without its key
|
|
1048
|
-
*
|
|
1049
|
-
|
|
1351
|
+
* Harness container: the custom provider without its key, the catalog gateways, and the MCP servers
|
|
1352
|
+
* (ADR-0079) — a remote one's url and the NAME of its secret, never a value. The check pod carries
|
|
1353
|
+
* the same text in its own ConfigMap (ADR-0019). */
|
|
1354
|
+
export function harnessConfigJson(harness: HarnessConfig | undefined, mcp?: JR2Config["mcp"]): string {
|
|
1050
1355
|
return JSON.stringify(
|
|
1051
1356
|
{
|
|
1052
1357
|
// apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
|
|
@@ -1065,12 +1370,35 @@ export function harnessConfigJson(harness: HarnessConfig | undefined): string {
|
|
|
1065
1370
|
: undefined,
|
|
1066
1371
|
// Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
|
|
1067
1372
|
catalog: harness?.catalog,
|
|
1373
|
+
// The MCP servers (ADR-0079), by the config's name: the Harness joins each Agent's `mcp` list
|
|
1374
|
+
// to them. In-cluster the config has no `.env`, so the url rides from here, as baseUrl does.
|
|
1375
|
+
mcp: mcp === undefined ? undefined : mcpSpecOf(mcp),
|
|
1068
1376
|
},
|
|
1069
1377
|
null,
|
|
1070
1378
|
2,
|
|
1071
1379
|
);
|
|
1072
1380
|
}
|
|
1073
1381
|
|
|
1382
|
+
/** The `mcp` block as the Harness reads it (`McpServerSpec` in `@jr2/harness`): the timeout in
|
|
1383
|
+
* milliseconds, and a stdio server's `args` always a list. */
|
|
1384
|
+
function mcpSpecOf(mcp: NonNullable<JR2Config["mcp"]>): Record<string, unknown> {
|
|
1385
|
+
return Object.fromEntries(
|
|
1386
|
+
Object.entries(mcp).map(([name, server]) => {
|
|
1387
|
+
const timeoutMs = mcpTimeoutMs(server.timeout);
|
|
1388
|
+
return [
|
|
1389
|
+
name,
|
|
1390
|
+
isStdioServer(server)
|
|
1391
|
+
? { command: server.command, args: [...(server.args ?? [])], timeoutMs }
|
|
1392
|
+
: { url: server.url, ...(server.secret !== undefined ? { secret: server.secret } : {}), timeoutMs },
|
|
1393
|
+
];
|
|
1394
|
+
}),
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** The argv word of `provider-preflight.ts` before the MCP servers to list (`PREFLIGHT_MCP_ARG` in
|
|
1399
|
+
* `@jr2/harness`'s `preflight-line.ts`, which a test holds this to). */
|
|
1400
|
+
export const PREFLIGHT_MCP_ARG = "--mcp";
|
|
1401
|
+
|
|
1074
1402
|
/** The provider preflight's objects (ADR-0019): a check pod and its own copies of what it reads,
|
|
1075
1403
|
* each named `jr2-provider-preflight-<run>-<n>` or that name and a suffix, unique to one `jr2 up`. */
|
|
1076
1404
|
export const PROVIDER_PREFLIGHT = "jr2-provider-preflight";
|
|
@@ -1084,6 +1412,7 @@ export const CHECK_CONTAINER = "check";
|
|
|
1084
1412
|
export function checkPodObjectNames(object: string): Array<{ kind: string; name: string }> {
|
|
1085
1413
|
return [
|
|
1086
1414
|
{ kind: "pod", name: object },
|
|
1415
|
+
{ kind: "networkpolicy", name: object },
|
|
1087
1416
|
{ kind: "configmap", name: object },
|
|
1088
1417
|
{ kind: "configmap", name: `${object}-ca` },
|
|
1089
1418
|
{ kind: "secret", name: object },
|
|
@@ -1100,8 +1429,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
|
|
|
1100
1429
|
* - `<object>` (ConfigMap): the Custodian's bootstrap and script, the program and the harness config
|
|
1101
1430
|
* (`harnessConfigJson`).
|
|
1102
1431
|
* - `<object>-ca` (ConfigMap): the trust bundles, as `jr2-ca` would carry them.
|
|
1103
|
-
* - `<object>` (Secret): the Custodian's token, a value the Orchestrator never signed (the
|
|
1104
|
-
* reads no Menu),
|
|
1432
|
+
* - `<object>` (Secret): the Custodian's control token, a value the Orchestrator never signed (the
|
|
1433
|
+
* check pod reads no Menu), the check pod's own secret Stand-ins (`podCredentials`, ADR-0059), and
|
|
1434
|
+
* the LITERAL values of the keys it holds, from the host. A `valueFrom` key
|
|
1105
1435
|
* stays a reference to the user's Secret. The values are in this Secret for the check pod's life.
|
|
1106
1436
|
* - `<object>-tls` (Secret): the leaves of the bound hosts. `<object>-env` (Secret): the literal
|
|
1107
1437
|
* `harness.env` values, which `jr2-harness-env` would carry.
|
|
@@ -1112,6 +1442,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
|
|
|
1112
1442
|
* container's env, so the call is the one a Turn makes, and the key never enters that container,
|
|
1113
1443
|
* its argv or its log. The argv is the model specifiers alone.
|
|
1114
1444
|
*
|
|
1445
|
+
* - `<object>` (NetworkPolicy): the egress of a Harness pod (`metadataEgress`, ADR-0076), so the
|
|
1446
|
+
* check reaches a provider only as a Turn does. It is in the List before the pod.
|
|
1447
|
+
*
|
|
1115
1448
|
* The pod has the placement and the priority class of the Harness pods it stands for (ADR-0052,
|
|
1116
1449
|
* ADR-0060). Its labels are its own and match no Harness pod's: the Instance Harness Service
|
|
1117
1450
|
* selects by label, and would send Turns to it. The Custodian is a native sidecar with a startup
|
|
@@ -1136,11 +1469,18 @@ export function checkPodObjects(opts: {
|
|
|
1136
1469
|
trust: Record<string, string>;
|
|
1137
1470
|
/** The Custodian's two files, rendered from `held`. */
|
|
1138
1471
|
custodian: CustodianFiles;
|
|
1139
|
-
|
|
1472
|
+
/** The check pod's credentials (`podCredentials`, ADR-0076): a control token the Orchestrator
|
|
1473
|
+
* never signed, and its own secret Stand-ins, minted for this pod alone. */
|
|
1474
|
+
credentials: Record<string, string>;
|
|
1140
1475
|
/** The literal `harness.env` values. */
|
|
1141
1476
|
harnessEnvData: Record<string, string>;
|
|
1142
1477
|
/** The `<provider>/<model>` specifiers to check, in order. */
|
|
1143
1478
|
models: readonly string[];
|
|
1479
|
+
/** The remote MCP servers whose tools to list, by the config's name, in order (ADR-0079). */
|
|
1480
|
+
servers?: readonly string[];
|
|
1481
|
+
/** The config's `mcp` block: the check pod's harness config carries it, as the Harness pods'
|
|
1482
|
+
* does, so the check reads each server's url and secret name where a Turn reads them. */
|
|
1483
|
+
mcp?: JR2Config["mcp"];
|
|
1144
1484
|
/** The node predicate of the Harness pods this check pod stands for. */
|
|
1145
1485
|
placement?: SandboxPlacement;
|
|
1146
1486
|
priorityClassName: string;
|
|
@@ -1161,7 +1501,7 @@ export function checkPodObjects(opts: {
|
|
|
1161
1501
|
pod: o,
|
|
1162
1502
|
reads: { literals: o, tls: `${o}-tls`, trust: `${o}-ca`, program: o },
|
|
1163
1503
|
config: o,
|
|
1164
|
-
|
|
1504
|
+
credentials: { secret: o },
|
|
1165
1505
|
caBundle: opts.caBundle === true,
|
|
1166
1506
|
resources: sized(KIT_POD_SIZES.instanceCustodian),
|
|
1167
1507
|
});
|
|
@@ -1203,12 +1543,19 @@ export function checkPodObjects(opts: {
|
|
|
1203
1543
|
"/opt/jr2/bin/node",
|
|
1204
1544
|
"/opt/jr2/src/provider-preflight.ts",
|
|
1205
1545
|
...opts.models,
|
|
1546
|
+
...(opts.servers?.length ? [PREFLIGHT_MCP_ARG, ...opts.servers] : []),
|
|
1206
1547
|
],
|
|
1207
|
-
// The Harness container's env, in its order (`instanceHarnessObjects`): no credential.
|
|
1548
|
+
// The Harness container's env, in its order (`instanceHarnessObjects`): no credential. And
|
|
1549
|
+
// the check pod's own Stand-ins (ADR-0019): it runs no Workspace code, so it may hold them
|
|
1550
|
+
// in env, where pi reads a provider's key; each is a `secretKeyRef`, never a value here.
|
|
1208
1551
|
env: [
|
|
1209
1552
|
{ name: "JR2_HARNESS_JSON", valueFrom: { configMapKeyRef: { name: o, key: HARNESS_CONFIG_KEY } } },
|
|
1210
1553
|
...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
|
|
1211
1554
|
...custodian.harnessEnv,
|
|
1555
|
+
...opts.held.secrets.map((s) => ({
|
|
1556
|
+
name: s.name,
|
|
1557
|
+
valueFrom: { secretKeyRef: { name: o, key: heldStandInKey(s.name) } },
|
|
1558
|
+
})),
|
|
1212
1559
|
],
|
|
1213
1560
|
envFrom: [{ secretRef: { name: `${o}-env` } }, ...(opts.harness?.envFrom ?? [])],
|
|
1214
1561
|
volumeMounts: custodian.harnessMounts,
|
|
@@ -1250,13 +1597,19 @@ export function checkPodObjects(opts: {
|
|
|
1250
1597
|
[CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
|
|
1251
1598
|
[CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
|
|
1252
1599
|
[CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
|
|
1253
|
-
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
|
|
1600
|
+
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
|
|
1254
1601
|
},
|
|
1255
1602
|
},
|
|
1256
1603
|
{ apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
|
|
1257
|
-
secret(o, {
|
|
1604
|
+
secret(o, { ...opts.credentials, ...literals }),
|
|
1258
1605
|
secret(`${o}-tls`, leaves),
|
|
1259
1606
|
secret(`${o}-env`, opts.harnessEnvData),
|
|
1607
|
+
{
|
|
1608
|
+
apiVersion: "networking.k8s.io/v1",
|
|
1609
|
+
kind: "NetworkPolicy",
|
|
1610
|
+
metadata: meta(o),
|
|
1611
|
+
spec: metadataEgress({ matchLabels: { app: PROVIDER_PREFLIGHT, [LABEL_PROVIDER_PREFLIGHT]: opts.run } }),
|
|
1612
|
+
},
|
|
1260
1613
|
pod,
|
|
1261
1614
|
];
|
|
1262
1615
|
return JSON.stringify({ apiVersion: "v1", kind: "List", items });
|
|
@@ -1307,7 +1660,8 @@ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
|
|
|
1307
1660
|
|
|
1308
1661
|
/**
|
|
1309
1662
|
* The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
|
|
1310
|
-
* places an Effect there — an `http` Effect —
|
|
1663
|
+
* places an Effect there — an `http` Effect — or a Hook needs it (a `mac` verifier, or a kind that
|
|
1664
|
+
* declares `posts`), and deletes otherwise.
|
|
1311
1665
|
*
|
|
1312
1666
|
* - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
|
|
1313
1667
|
* the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
|
|
@@ -1358,7 +1712,13 @@ export function instanceEffectorObjects(opts: {
|
|
|
1358
1712
|
: [],
|
|
1359
1713
|
),
|
|
1360
1714
|
];
|
|
1361
|
-
|
|
1715
|
+
// The servers its `mcp` Effects call (ADR-0079): each one's url, its secret's name, its timeout.
|
|
1716
|
+
const config = {
|
|
1717
|
+
version: 1,
|
|
1718
|
+
placement: opts.plan.placement,
|
|
1719
|
+
targets: opts.plan.targets,
|
|
1720
|
+
...(Object.keys(opts.plan.mcp).length ? { mcp: opts.plan.mcp } : {}),
|
|
1721
|
+
};
|
|
1362
1722
|
|
|
1363
1723
|
const container = {
|
|
1364
1724
|
name: "effector",
|
|
@@ -1498,7 +1858,14 @@ export type ValueDigest = (secret: string, key: string) => string | undefined;
|
|
|
1498
1858
|
* annotation reveals nothing about a value. */
|
|
1499
1859
|
export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
|
|
1500
1860
|
const mac = createHmac("sha256", signingKey);
|
|
1501
|
-
mac.update(
|
|
1861
|
+
mac.update(
|
|
1862
|
+
JSON.stringify({
|
|
1863
|
+
secrets: plan.secrets,
|
|
1864
|
+
targets: plan.targets,
|
|
1865
|
+
longest: plan.longestTimeoutMs,
|
|
1866
|
+
...(Object.keys(plan.mcp).length ? { mcp: plan.mcp } : {}),
|
|
1867
|
+
}),
|
|
1868
|
+
);
|
|
1502
1869
|
for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
|
|
1503
1870
|
for (const s of plan.secrets) {
|
|
1504
1871
|
if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
|