@jr2/cli 0.6.0 → 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 +224 -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 +121 -6
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +373 -70
- 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 +233 -12
- package/src/instance-check.ts +47 -1
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- 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, randomBytes } 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,
|
|
@@ -24,6 +29,9 @@ import {
|
|
|
24
29
|
controlToken,
|
|
25
30
|
heldStandIn,
|
|
26
31
|
heldStandInKey,
|
|
32
|
+
HOOK_DIGESTS_KEY,
|
|
33
|
+
HOOK_DIGESTS_MOUNT,
|
|
34
|
+
HOOK_DIGESTS_SECRET,
|
|
27
35
|
INSTANCE_CUSTODIAN_CONFIGMAP,
|
|
28
36
|
GIT_SSH_SECRET,
|
|
29
37
|
HELD_CA_SECRET,
|
|
@@ -52,6 +60,8 @@ import {
|
|
|
52
60
|
REPO_CACHE_HOSTPATH,
|
|
53
61
|
SERVICE_ACCOUNT_CA,
|
|
54
62
|
STATE_PVC,
|
|
63
|
+
STATE_VOLUME_DEFAULT,
|
|
64
|
+
memoryBytes,
|
|
55
65
|
type EffectorPlan,
|
|
56
66
|
type SandboxEffectorPlan,
|
|
57
67
|
effectorManifestKey,
|
|
@@ -64,6 +74,9 @@ import {
|
|
|
64
74
|
type GitCloneSecret,
|
|
65
75
|
type GitPushManifest,
|
|
66
76
|
type HarnessConfig,
|
|
77
|
+
type JR2Config,
|
|
78
|
+
isStdioServer,
|
|
79
|
+
mcpTimeoutMs,
|
|
67
80
|
type HeldManifest,
|
|
68
81
|
markHarnessEnv,
|
|
69
82
|
type PriorityClasses,
|
|
@@ -92,9 +105,85 @@ export const ANNOTATION_IMAGES = "jr2.dev/images";
|
|
|
92
105
|
|
|
93
106
|
export const ORCHESTRATOR_SA = "jr2-orchestrator";
|
|
94
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
|
+
|
|
95
178
|
/** The HMAC over the held-secret inputs, on the Instance Harness's pod template (ADR-0059). */
|
|
96
179
|
export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
|
|
97
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
|
+
|
|
98
187
|
/** The Instance Harness pod's `fsGroup` — a Sandbox pod's default work group (ADR-0005), for the
|
|
99
188
|
* same reason: a group every container is granted, which the Custodian's values are readable by. */
|
|
100
189
|
const INSTANCE_HARNESS_FS_GROUP = 2000;
|
|
@@ -346,21 +435,77 @@ export type HeldObjects = {
|
|
|
346
435
|
/** Does any pod hold a secret? */
|
|
347
436
|
export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
|
|
348
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
|
+
|
|
349
484
|
/** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
|
|
350
485
|
export function instanceObjects(opts: {
|
|
351
486
|
name: string;
|
|
352
487
|
namespace: string;
|
|
488
|
+
/** The size of the state volume claim (`stateVolumePlan`, ADR-0065). Default: `5Gi`. */
|
|
489
|
+
stateVolume?: string;
|
|
353
490
|
/** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
|
|
354
491
|
* Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
|
|
355
492
|
image: string;
|
|
356
493
|
/** Secret data (the Instance token, the publish token, the signing key) — written stringData,
|
|
357
494
|
* kube encodes. The Orchestrator reads it through `envFrom`. */
|
|
358
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>>;
|
|
359
502
|
/** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
|
|
360
503
|
* doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
|
|
361
504
|
* key must never share a Secret with it, and no model key rides it either (ADR-0059). */
|
|
362
505
|
harnessEnvData: Record<string, string>;
|
|
363
506
|
harness?: HarnessConfig;
|
|
507
|
+
/** The config's `mcp` block (ADR-0079), for the `jr2-harness` ConfigMap. */
|
|
508
|
+
mcp?: JR2Config["mcp"];
|
|
364
509
|
/** Held secrets (ADR-0059): the Custodian's objects. The `jr2-held` ConfigMap is applied always —
|
|
365
510
|
* every Harness pod runs a Custodian — and the rest only while a secret is held. */
|
|
366
511
|
held: HeldObjects;
|
|
@@ -404,7 +549,7 @@ export function instanceObjects(opts: {
|
|
|
404
549
|
metadata: meta(STATE_PVC),
|
|
405
550
|
spec: {
|
|
406
551
|
accessModes: ["ReadWriteOnce"],
|
|
407
|
-
resources: { requests: { storage:
|
|
552
|
+
resources: { requests: { storage: opts.stateVolume ?? STATE_VOLUME_DEFAULT } },
|
|
408
553
|
},
|
|
409
554
|
},
|
|
410
555
|
{ apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
|
|
@@ -449,7 +594,7 @@ export function instanceObjects(opts: {
|
|
|
449
594
|
kind: "ConfigMap",
|
|
450
595
|
metadata: meta(HARNESS_CONFIGMAP),
|
|
451
596
|
data: {
|
|
452
|
-
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
|
|
597
|
+
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
|
|
453
598
|
},
|
|
454
599
|
},
|
|
455
600
|
{
|
|
@@ -541,6 +686,13 @@ export function instanceObjects(opts: {
|
|
|
541
686
|
type: "Opaque",
|
|
542
687
|
stringData: opts.secretData,
|
|
543
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),
|
|
544
696
|
{
|
|
545
697
|
// What the HARNESS containers envFrom (see `harnessEnvData` above): Agent creds, never the
|
|
546
698
|
// Instance token. Always applied (possibly empty) so the Sandbox spec can reference it
|
|
@@ -594,6 +746,9 @@ export function instanceObjects(opts: {
|
|
|
594
746
|
// built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
|
|
595
747
|
// else — no TLS code, no dependency.
|
|
596
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 },
|
|
597
752
|
],
|
|
598
753
|
// The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
|
|
599
754
|
// agent on each node does, reading the credential Secret a Repo's `secretRef`
|
|
@@ -608,6 +763,13 @@ export function instanceObjects(opts: {
|
|
|
608
763
|
// (ADR-0059): names and hosts, which this process could not derive — its `.env`
|
|
609
764
|
// values are absent.
|
|
610
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 },
|
|
611
773
|
],
|
|
612
774
|
resources: sized(KIT_POD_SIZES.orchestrator),
|
|
613
775
|
// The period, not the boot, is what `up`'s rollout wait measures. Measured: the
|
|
@@ -645,6 +807,8 @@ export function instanceObjects(opts: {
|
|
|
645
807
|
{ name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
|
|
646
808
|
{ name: "images", configMap: { name: IMAGES_CONFIGMAP } },
|
|
647
809
|
{ name: "held", configMap: { name: HELD_CONFIGMAP } },
|
|
810
|
+
{ name: "callers", secret: { secretName: CALLER_DIGESTS_SECRET } },
|
|
811
|
+
{ name: "hooks", secret: { secretName: HOOK_DIGESTS_SECRET } },
|
|
648
812
|
],
|
|
649
813
|
},
|
|
650
814
|
},
|
|
@@ -955,7 +1119,8 @@ export function instanceHarnessObjects(opts: {
|
|
|
955
1119
|
* Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
|
|
956
1120
|
custodianImage: string;
|
|
957
1121
|
/** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
|
|
958
|
-
* 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. */
|
|
959
1124
|
held: HeldManifest;
|
|
960
1125
|
/** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
|
|
961
1126
|
* (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
|
|
@@ -968,6 +1133,9 @@ export function instanceHarnessObjects(opts: {
|
|
|
968
1133
|
* Stand-ins, applied here as {@link INSTANCE_HARNESS_CREDENTIALS} with the pod they are for. */
|
|
969
1134
|
credentials: Record<string, string>;
|
|
970
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"];
|
|
971
1139
|
/** The instance ships a private-CA bundle (ADR-0020). */
|
|
972
1140
|
caBundle?: boolean;
|
|
973
1141
|
/** The digest of this placement's Harness bearer (ADR-0058; `harnessTokenDigest(key,
|
|
@@ -1109,7 +1277,16 @@ export function instanceHarnessObjects(opts: {
|
|
|
1109
1277
|
// start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
|
|
1110
1278
|
// (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
|
|
1111
1279
|
// can.
|
|
1112
|
-
|
|
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
|
+
},
|
|
1113
1290
|
},
|
|
1114
1291
|
spec: {
|
|
1115
1292
|
// The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
|
|
@@ -1171,9 +1348,10 @@ export function instanceHarnessObjects(opts: {
|
|
|
1171
1348
|
}
|
|
1172
1349
|
|
|
1173
1350
|
/** What the Instance can REACH (ADR-0018), as the `jr2-harness` ConfigMap carries it to every
|
|
1174
|
-
* Harness container: the custom provider without its key
|
|
1175
|
-
*
|
|
1176
|
-
|
|
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 {
|
|
1177
1355
|
return JSON.stringify(
|
|
1178
1356
|
{
|
|
1179
1357
|
// apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
|
|
@@ -1192,12 +1370,35 @@ export function harnessConfigJson(harness: HarnessConfig | undefined): string {
|
|
|
1192
1370
|
: undefined,
|
|
1193
1371
|
// Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
|
|
1194
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),
|
|
1195
1376
|
},
|
|
1196
1377
|
null,
|
|
1197
1378
|
2,
|
|
1198
1379
|
);
|
|
1199
1380
|
}
|
|
1200
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
|
+
|
|
1201
1402
|
/** The provider preflight's objects (ADR-0019): a check pod and its own copies of what it reads,
|
|
1202
1403
|
* each named `jr2-provider-preflight-<run>-<n>` or that name and a suffix, unique to one `jr2 up`. */
|
|
1203
1404
|
export const PROVIDER_PREFLIGHT = "jr2-provider-preflight";
|
|
@@ -1275,6 +1476,11 @@ export function checkPodObjects(opts: {
|
|
|
1275
1476
|
harnessEnvData: Record<string, string>;
|
|
1276
1477
|
/** The `<provider>/<model>` specifiers to check, in order. */
|
|
1277
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"];
|
|
1278
1484
|
/** The node predicate of the Harness pods this check pod stands for. */
|
|
1279
1485
|
placement?: SandboxPlacement;
|
|
1280
1486
|
priorityClassName: string;
|
|
@@ -1337,6 +1543,7 @@ export function checkPodObjects(opts: {
|
|
|
1337
1543
|
"/opt/jr2/bin/node",
|
|
1338
1544
|
"/opt/jr2/src/provider-preflight.ts",
|
|
1339
1545
|
...opts.models,
|
|
1546
|
+
...(opts.servers?.length ? [PREFLIGHT_MCP_ARG, ...opts.servers] : []),
|
|
1340
1547
|
],
|
|
1341
1548
|
// The Harness container's env, in its order (`instanceHarnessObjects`): no credential. And
|
|
1342
1549
|
// the check pod's own Stand-ins (ADR-0019): it runs no Workspace code, so it may hold them
|
|
@@ -1390,7 +1597,7 @@ export function checkPodObjects(opts: {
|
|
|
1390
1597
|
[CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
|
|
1391
1598
|
[CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
|
|
1392
1599
|
[CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
|
|
1393
|
-
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
|
|
1600
|
+
[HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
|
|
1394
1601
|
},
|
|
1395
1602
|
},
|
|
1396
1603
|
{ apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
|
|
@@ -1453,7 +1660,8 @@ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
|
|
|
1453
1660
|
|
|
1454
1661
|
/**
|
|
1455
1662
|
* The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
|
|
1456
|
-
* 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.
|
|
1457
1665
|
*
|
|
1458
1666
|
* - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
|
|
1459
1667
|
* the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
|
|
@@ -1504,7 +1712,13 @@ export function instanceEffectorObjects(opts: {
|
|
|
1504
1712
|
: [],
|
|
1505
1713
|
),
|
|
1506
1714
|
];
|
|
1507
|
-
|
|
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
|
+
};
|
|
1508
1722
|
|
|
1509
1723
|
const container = {
|
|
1510
1724
|
name: "effector",
|
|
@@ -1644,7 +1858,14 @@ export type ValueDigest = (secret: string, key: string) => string | undefined;
|
|
|
1644
1858
|
* annotation reveals nothing about a value. */
|
|
1645
1859
|
export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
|
|
1646
1860
|
const mac = createHmac("sha256", signingKey);
|
|
1647
|
-
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
|
+
);
|
|
1648
1869
|
for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
|
|
1649
1870
|
for (const s of plan.secrets) {
|
|
1650
1871
|
if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
|
package/src/instance-check.ts
CHANGED
|
@@ -18,9 +18,15 @@ import {
|
|
|
18
18
|
CUSTOM_PROVIDER_APIS,
|
|
19
19
|
dispatchRefusals,
|
|
20
20
|
doorRefusals,
|
|
21
|
+
gateLookWarnings,
|
|
22
|
+
gateScopeChecks,
|
|
23
|
+
standingPoolLines,
|
|
24
|
+
standingPoolRefusals,
|
|
21
25
|
loadCharts,
|
|
22
26
|
loadWorkflows,
|
|
23
27
|
machineBodies,
|
|
28
|
+
mcpRefusals,
|
|
29
|
+
mcpUrlRefusals,
|
|
24
30
|
openPartRefusals,
|
|
25
31
|
partsOf,
|
|
26
32
|
placementWalkOf,
|
|
@@ -106,7 +112,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
106
112
|
// and the door's walk cannot drift.
|
|
107
113
|
// A zod `input` that no door reads (ADR-0033) is in the same listing, first: the door refuses it
|
|
108
114
|
// with the same line.
|
|
109
|
-
|
|
115
|
+
// A standing Pool that is not the top Machine of its Workflow (ADR-0017) is in the listing too.
|
|
116
|
+
// An Agent's `mcp` list against the config's `mcp` block (ADR-0079) is in the same listing: a
|
|
117
|
+
// server name the block does not declare is refused as an Open part is, with its customize() line,
|
|
118
|
+
// and the door refuses the same lines. The urls are the host's alone (`.env`).
|
|
119
|
+
const open = [
|
|
120
|
+
...doorRefusals(workflows),
|
|
121
|
+
...standingPoolRefusals(workflows),
|
|
122
|
+
...openPartRefusals(workflows),
|
|
123
|
+
...mcpRefusals(workflows, config),
|
|
124
|
+
...mcpUrlRefusals(config),
|
|
125
|
+
];
|
|
110
126
|
if (open.length) {
|
|
111
127
|
for (const line of open) activity(io, `refusing: ${line}`);
|
|
112
128
|
return undefined;
|
|
@@ -120,6 +136,15 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
120
136
|
for (const line of chartRefusals) activity(io, `refusing: ${line}`);
|
|
121
137
|
return undefined;
|
|
122
138
|
}
|
|
139
|
+
// Each `answer` item that names single Gates (ADR-0078) names Gates that the Workflow's Machines or
|
|
140
|
+
// its Chart files have: the door's own check, so a typo fails here and not at the first answer. A
|
|
141
|
+
// name that only a runtime Chart can have is a warning, because the host cannot see those Charts.
|
|
142
|
+
const gateScopes = gateScopeChecks(workflows, config, charts);
|
|
143
|
+
for (const line of gateScopes.warnings) activity(io, `warning: ${line}`);
|
|
144
|
+
if (gateScopes.errors.length) {
|
|
145
|
+
for (const line of gateScopes.errors) activity(io, `refusing: ${line}`);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
123
148
|
// What each same-Instance dispatch names resolves in this Assembly, and its slot schema matches
|
|
124
149
|
// the callee's door (ADR-0071): the door's own check, so the publish would refuse the same lines.
|
|
125
150
|
// The host cannot see the Orchestrator's volume, so a named Chart that is no Chart file here is a
|
|
@@ -130,6 +155,15 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
130
155
|
for (const line of dispatched.errors) activity(io, `refusing: ${line}`);
|
|
131
156
|
return undefined;
|
|
132
157
|
}
|
|
158
|
+
// A hooked Gate that a lost delivery can stop (ADR-0011, ADR-0054): no look and no `after` on its
|
|
159
|
+
// state, or a look that a package left Open and nobody bound. A warning, never a refusal: a person
|
|
160
|
+
// can still answer the Gate with `jr2 send`.
|
|
161
|
+
for (const line of gateLookWarnings(workflows)) activity(io, `warning: ${line}`);
|
|
162
|
+
// Each standing Pool (ADR-0017): the chain from each Hook that reaches it to its callee, and a
|
|
163
|
+
// warning when two of them dispatch to one Workflow, whose caps then add.
|
|
164
|
+
const standing = standingPoolLines(workflows, config.hooks);
|
|
165
|
+
for (const chain of standing.chains) activity(io, `standing: ${chain}`);
|
|
166
|
+
for (const line of standing.warnings) activity(io, `warning: ${line}`);
|
|
133
167
|
// What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
|
|
134
168
|
// the plain data it carries — a command's argv and its timeout; an http
|
|
135
169
|
// Effect's method, its fixed target and the secrets it names — read, never run.
|
|
@@ -162,6 +196,18 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
162
196
|
);
|
|
163
197
|
continue;
|
|
164
198
|
}
|
|
199
|
+
if (e.kind === "mcp") {
|
|
200
|
+
// The server's url and secret are the config's (ADR-0079): the line names the server, and
|
|
201
|
+
// its timeout is the server's unless the Effect sets one.
|
|
202
|
+
const asks =
|
|
203
|
+
e.op === "call" ? `call ${e.server ?? "<open>"}/${e.tool ?? "<open>"}` : `${e.op} ${e.server ?? "<open>"}`;
|
|
204
|
+
const timeout = e.timeoutMs > 0 ? `${e.timeoutMs / 1000}s` : "the server's timeout";
|
|
205
|
+
activity(
|
|
206
|
+
io,
|
|
207
|
+
` ${e.name}${where}: mcp ${asks} — ${timeout}, in the Instance Effector${e.idempotent ? ", idempotent" : ""}`,
|
|
208
|
+
);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
165
211
|
if (e.kind === "push") {
|
|
166
212
|
const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
|
|
167
213
|
const slot = e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
|
package/src/instance.ts
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
// `config.name` > folder name): the Instance token is read from the in-cluster `jr2-instance`
|
|
12
12
|
// Secret and the `jr2-orchestrator` Service is port-forwarded for the life of the command —
|
|
13
13
|
// kube RBAC is the real gate, and no local state file can go stale (ADR-0019).
|
|
14
|
+
// - On the kube path the CLI says whom it acts for (ADR-0078): the user name a kube
|
|
15
|
+
// `SelfSubjectReview` gives for the context, sent as `jr2-on-behalf-of`. The Instance token is
|
|
16
|
+
// the Caller `token/instance`; the name says which person held it. With `--url` it says none.
|
|
14
17
|
// - Every resolution prints its target on stderr, so ambient-context drift stays visible.
|
|
15
18
|
|
|
16
19
|
import { basename } from "node:path";
|
|
17
|
-
import { loadConfig } from "@jr2/orchestrator/internal";
|
|
20
|
+
import { loadConfig, ON_BEHALF_OF_MAX } from "@jr2/orchestrator/internal";
|
|
18
21
|
import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
|
|
19
22
|
import { assertKitVersion } from "./kit-version.ts";
|
|
20
23
|
import { activity, type Io } from "./output.ts";
|
|
@@ -31,8 +34,9 @@ export function resolveRoot(cwd: string): string {
|
|
|
31
34
|
return root;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
/** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb).
|
|
35
|
-
|
|
37
|
+
/** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb).
|
|
38
|
+
* `onBehalfOf` is the party the CLI says it acts for (ADR-0078): on the kube path, the kube user. */
|
|
39
|
+
export type Target = { url: string; token?: string; onBehalfOf?: string; close?: () => void };
|
|
36
40
|
|
|
37
41
|
export type TargetOptions = {
|
|
38
42
|
/** Explicit orchestrator address (`--url`); with `JR2_URL` the escape hatch past the kube path. */
|
|
@@ -90,6 +94,13 @@ export async function resolveTarget(io: Io, opts: TargetOptions): Promise<Target
|
|
|
90
94
|
`not deployed here — right context? (context ${context}, namespace ${namespace}; \`jr2 up\` deploys)`,
|
|
91
95
|
);
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
// The kube user, read beside the forward so it costs no time of its own. A name past the
|
|
98
|
+
// header's bound is cut, never sent whole to a refusal. A name that is not printable ASCII is not
|
|
99
|
+
// sent: a header value cannot carry it, and the claim is not worth a failed verb.
|
|
100
|
+
const [fwd, user] = await Promise.all([
|
|
101
|
+
kube.portForward({ namespace, service: ORCHESTRATOR_SERVICE, port: ORCHESTRATOR_PORT, ...ctx }),
|
|
102
|
+
kube.selfSubject(ctx),
|
|
103
|
+
]);
|
|
104
|
+
const onBehalfOf = user && /^[\x20-\x7e]+$/.test(user) ? user.slice(0, ON_BEHALF_OF_MAX).trim() : undefined;
|
|
105
|
+
return { url: fwd.url, token, ...(onBehalfOf ? { onBehalfOf } : {}), close: fwd.close };
|
|
95
106
|
}
|
package/src/kube.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `KubePort` is what target resolution consumes (tests inject a fake), `kubectlKube` is the real
|
|
5
5
|
// one, shelling out to the same `kubectl` a human debugging the cluster would use.
|
|
6
6
|
|
|
7
|
-
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
|
|
@@ -41,6 +41,11 @@ export type KubePort = {
|
|
|
41
41
|
* cluster could not answer at all — the two are different faults (ADR-0019), and a caller that
|
|
42
42
|
* merged them would send a user with a dead VPN off to check a context that is correct. */
|
|
43
43
|
readSecret(opts: { namespace: string; name: string; key: string; context?: string }): Promise<string | undefined>;
|
|
44
|
+
/** The user name that the API server gives the current credentials, from a `SelfSubjectReview`
|
|
45
|
+
* (`kubectl auth whoami`): the party the CLI says it acts for (ADR-0078). Undefined when the
|
|
46
|
+
* server does not answer it. Never rejects: the name is a claim the CLI adds, and a verb does not
|
|
47
|
+
* fail for want of it. */
|
|
48
|
+
selfSubject(opts: { context?: string }): Promise<string | undefined>;
|
|
44
49
|
/** Forward a Service port to an ephemeral local port for the life of the command. */
|
|
45
50
|
portForward(opts: {
|
|
46
51
|
namespace: string;
|
|
@@ -50,6 +55,21 @@ export type KubePort = {
|
|
|
50
55
|
}): Promise<{ url: string; close: () => void }>;
|
|
51
56
|
};
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A long-lived kubectl child (a port-forward) ends with this process. Node does not end a child
|
|
60
|
+
* when it exits, so a verb that `process.exit`s, or that a signal handler ends, leaves the child
|
|
61
|
+
* alive, and it holds an API-server stream. Here the child is killed on this process's `exit`.
|
|
62
|
+
* Returns the kill, which is also what `close` calls; the hook goes when the child closes.
|
|
63
|
+
*/
|
|
64
|
+
export function endsWithProcess(child: ChildProcess): () => void {
|
|
65
|
+
const kill = () => {
|
|
66
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
67
|
+
};
|
|
68
|
+
process.once("exit", kill);
|
|
69
|
+
child.once("close", () => process.removeListener("exit", kill));
|
|
70
|
+
return kill;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
|
|
54
74
|
|
|
55
75
|
/** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
|
|
@@ -1139,6 +1159,19 @@ export const kubectlKube: KubePort = {
|
|
|
1139
1159
|
}
|
|
1140
1160
|
},
|
|
1141
1161
|
|
|
1162
|
+
async selfSubject({ context }) {
|
|
1163
|
+
try {
|
|
1164
|
+
const { stdout } = await exec(
|
|
1165
|
+
"kubectl",
|
|
1166
|
+
[...ctxArgs(context), "auth", "whoami", "-o", "jsonpath={.status.userInfo.username}", ...reachArgs],
|
|
1167
|
+
{ timeout: REACH_BUDGET_MS },
|
|
1168
|
+
);
|
|
1169
|
+
return stdout.trim() || undefined;
|
|
1170
|
+
} catch {
|
|
1171
|
+
return undefined; // a server without the API, or RBAC that refuses it: no claim, and no failure
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
|
|
1142
1175
|
// `:port` asks kubectl for an ephemeral local port, announced on its stdout as
|
|
1143
1176
|
// "Forwarding from 127.0.0.1:<local> -> <remote>" — parsed rather than raced.
|
|
1144
1177
|
portForward({ namespace, service, port, context }) {
|
|
@@ -1151,13 +1184,14 @@ export const kubectlKube: KubePort = {
|
|
|
1151
1184
|
`service/${service}`,
|
|
1152
1185
|
`:${port}`,
|
|
1153
1186
|
]);
|
|
1187
|
+
const kill = endsWithProcess(child);
|
|
1154
1188
|
let out = "";
|
|
1155
1189
|
let err = "";
|
|
1156
1190
|
// The forward is long-lived by design, so the bound is on its FIRST BYTE, not on the child:
|
|
1157
1191
|
// silence here is the same unreachable cluster the Secret read just survived, and a verb that
|
|
1158
1192
|
// waits forever for the announcement has only moved the hang one line down.
|
|
1159
1193
|
const gaveUp = setTimeout(() => {
|
|
1160
|
-
|
|
1194
|
+
kill();
|
|
1161
1195
|
reject(new Error(`kubectl port-forward: no answer after ${REACH_BUDGET_MS / 1000}s`));
|
|
1162
1196
|
}, REACH_BUDGET_MS);
|
|
1163
1197
|
gaveUp.unref?.();
|
|
@@ -1172,7 +1206,7 @@ export const kubectlKube: KubePort = {
|
|
|
1172
1206
|
child.stdout.on("data", (d: Buffer) => {
|
|
1173
1207
|
out += d.toString();
|
|
1174
1208
|
const m = /Forwarding from 127\.0\.0\.1:(\d+)/.exec(out);
|
|
1175
|
-
if (m) done({ url: `http://127.0.0.1:${m[1]}`, close:
|
|
1209
|
+
if (m) done({ url: `http://127.0.0.1:${m[1]}`, close: kill });
|
|
1176
1210
|
});
|
|
1177
1211
|
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
1178
1212
|
child.on("error", (e) => failed(new Error(`kubectl port-forward failed: ${e.message}`)));
|