@jr2/cli 0.2.1 → 0.4.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/src/deploy.ts CHANGED
@@ -3,11 +3,31 @@
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
7
  import { readFile } from "node:fs/promises";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import {
9
10
  CA_CONFIGMAP,
11
+ CA_MOUNT,
12
+ EFFECTOR_PORT,
13
+ INSTANCE_EFFECTOR,
14
+ CONVERSATIONS_DIR,
15
+ CONVERSATIONS_DIR_ENV,
16
+ CONVERSATIONS_VOLUME,
17
+ custodianComposition,
18
+ CUSTODIAN_BOOTSTRAP_KEY,
19
+ CUSTODIAN_PROGRAM,
20
+ CUSTODIAN_PROGRAM_KEY,
21
+ CUSTODIAN_SCRIPT_KEY,
22
+ INSTANCE_CUSTODIAN_CONFIGMAP,
10
23
  GIT_SSH_SECRET,
24
+ HELD_CA_SECRET,
25
+ HELD_CONFIGMAP,
26
+ HELD_MOUNT,
27
+ HELD_TLS_SECRET,
28
+ heldManifestKey,
29
+ heldSecretsSecret,
30
+ INSTANCE_HARNESS_HELD_POD,
11
31
  HARNESS_ENV_SECRET,
12
32
  IMAGES_CONFIGMAP,
13
33
  IMAGES_KEY,
@@ -18,16 +38,35 @@ import {
18
38
  INSTANCE_HARNESS_SERVICE,
19
39
  INSTANCE_SECRET,
20
40
  KIT_VERSION,
41
+ NO_DISRUPT_ANNOTATIONS,
21
42
  ORCHESTRATOR_PORT,
22
43
  ORCHESTRATOR_SERVICE,
23
44
  REPO_CACHE,
45
+ PRIORITY_CLASS_CONTROL,
46
+ PRIORITY_CLASS_SANDBOX,
24
47
  REPO_CACHE_HOSTPATH,
48
+ SERVICE_ACCOUNT_CA,
25
49
  STATE_PVC,
50
+ type EffectorPlan,
51
+ type SandboxEffectorPlan,
52
+ effectorManifestKey,
53
+ LABEL_EFFECTOR_POD,
54
+ sandboxEffectorObjectsOf,
55
+ GIT_PUSH_MANIFEST_KEY,
56
+ LABEL_GIT_PUSH,
57
+ GIT_CLONE_MANIFEST_KEY,
58
+ LABEL_GIT_CLONE,
59
+ type GitCloneSecret,
60
+ type GitPushManifest,
26
61
  type HarnessConfig,
62
+ type HeldManifest,
63
+ type PriorityClasses,
27
64
  type SandboxPlacement,
28
65
  } from "@jr2/orchestrator";
66
+ import type { Pem } from "./held-pki.ts";
29
67
 
30
68
  export {
69
+ INSTANCE_EFFECTOR,
31
70
  GIT_SSH_SECRET,
32
71
  HARNESS_CONFIGMAP,
33
72
  HARNESS_ENV_SECRET,
@@ -39,7 +78,6 @@ export {
39
78
 
40
79
  export const LABEL_INSTANCE = "jr2.dev/instance";
41
80
  export const LABEL_VERSION = "jr2.dev/version";
42
- export const LABEL_HASH = "jr2.dev/content-hash";
43
81
 
44
82
  /** The converged name→ref image map, stamped on the Orchestrator Deployment's OWN metadata so the
45
83
  * next `jr2 up` can diff it and spend no docker on what has not moved (ADR-0038). An ANNOTATION, not
@@ -48,6 +86,27 @@ export const ANNOTATION_IMAGES = "jr2.dev/images";
48
86
 
49
87
  export const ORCHESTRATOR_SA = "jr2-orchestrator";
50
88
 
89
+ /** The HMAC over the held-secret inputs, on the Instance Harness's pod template (ADR-0059). */
90
+ export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
91
+
92
+ /** The Instance Harness pod's `fsGroup` — a Sandbox pod's default work group (ADR-0005), for the
93
+ * same reason: a group every container is granted, which the Custodian's values are readable by. */
94
+ const INSTANCE_HARNESS_FS_GROUP = 2000;
95
+
96
+ /** How long the Instance Harness pod is given to stop (ADR-0031's drain): on SIGTERM it stops
97
+ * admitting and finishes the Turns it holds, so the grace is a Turn's worst case. `jr2 up` waits
98
+ * out a rollout that long, and no longer. */
99
+ export const INSTANCE_HARNESS_DRAIN_SECONDS = 600;
100
+
101
+ /** The Instance Harness's conversations volume (ADR-0031): the StatefulSet's one claim, and the
102
+ * PVC ordinal 0 gets from it — `<claim>-<statefulset>-<ordinal>`. */
103
+ export const INSTANCE_HARNESS_PVC = `${CONVERSATIONS_VOLUME}-${INSTANCE_HARNESS_SERVICE}-0`;
104
+
105
+ /** The Instance Harness StatefulSet's governing Service (ADR-0031): headless, and dialed by no one
106
+ * today — it is what gives each ordinal the stable name N > 1 would pin a conversation to. The
107
+ * Orchestrator dials the ClusterIP Service `INSTANCE_HARNESS_SERVICE`, never this one. */
108
+ export const INSTANCE_HARNESS_PODS_SERVICE = `${INSTANCE_HARNESS_SERVICE}-pods`;
109
+
51
110
  /** The two ingress NetworkPolicies (ADR-0058) — see `harnessIngressPolicies`. */
52
111
  export const SANDBOX_INGRESS_POLICY = "jr2-sandbox-ingress";
53
112
  export const INSTANCE_HARNESS_INGRESS_POLICY = "jr2-instance-harness-ingress";
@@ -59,13 +118,21 @@ export const OPERATOR_DEPLOYMENT = "jr2-controller-manager";
59
118
  export const OPERATOR_SELECTOR = "control-plane=controller-manager";
60
119
 
61
120
  /** The rendered operator install manifest shipped inside this package (`just operator-manifest`
62
- * regenerates it from operator/config). The manager image ref is substituted at apply time. */
63
- export async function operatorManifest(image: string): Promise<string> {
121
+ * regenerates it from operator/config). The manager image ref is substituted at apply time, and so
122
+ * is its PriorityClass (ADR-0060): the manifest names `jr2-control`, and an Instance that names its
123
+ * own control class gets no pod that names one `jr2 up` did not create. */
124
+ export async function operatorManifest(image: string, controlClass = PRIORITY_CLASS_CONTROL): Promise<string> {
64
125
  const raw = await readFile(fileURLToPath(new URL("../manifests/operator.yaml", import.meta.url)), "utf8");
65
126
  if (!raw.includes("image: controller:latest")) {
66
127
  throw new Error("packaged operator.yaml has no `image: controller:latest` placeholder — regenerate it");
67
128
  }
68
- return raw.replace("image: controller:latest", `image: ${image}`);
129
+ const classLine = `priorityClassName: ${PRIORITY_CLASS_CONTROL}`;
130
+ if (!raw.includes(classLine)) {
131
+ throw new Error(`packaged operator.yaml has no \`${classLine}\` — regenerate it`);
132
+ }
133
+ return raw
134
+ .replace("image: controller:latest", `image: ${image}`)
135
+ .replace(classLine, `priorityClassName: ${controlClass}`);
69
136
  }
70
137
 
71
138
  /** Compare dotted versions: negative when a < b, 0 when equal, positive when a > b. */
@@ -81,22 +148,140 @@ export function compareVersions(a: string, b: string): number {
81
148
 
82
149
  type KubeManifest = Record<string, unknown>;
83
150
 
151
+ /**
152
+ * jr2's own pods are kit-sized, with no config key (ADR-0060): memory request = limit, a CPU request
153
+ * and NO CPU limit — a provisioning burst needs CPU, and no Agent Substrate move applies to them.
154
+ * The Repo cache agent is the one exception to request = limit: `git index-pack` spikes, and its OOM
155
+ * costs only a refetch. The operator's own size rides its manifest (operator/config).
156
+ */
157
+ export const KIT_POD_SIZES = {
158
+ orchestrator: { requests: { cpu: "500m", memory: "1Gi" }, limits: { memory: "1Gi" } },
159
+ /** It holds the conversations its live runs hold, and frees the rest on the live set (ADR-0031). */
160
+ instanceHarness: { requests: { cpu: "250m", memory: "1Gi" }, limits: { memory: "1Gi" } },
161
+ /** The Instance Harness pod's Custodian: the same 64Mi/50m slice a Sandbox's takes, kit-sized. */
162
+ instanceCustodian: { requests: { cpu: "50m", memory: "64Mi" }, limits: { memory: "64Mi" } },
163
+ repoCache: { requests: { cpu: "50m", memory: "128Mi" }, limits: { memory: "1Gi" } },
164
+ /** The Instance Effector (ADR-0068): one http call at a time per Effect, its text body bounded. */
165
+ instanceEffector: { requests: { cpu: "50m", memory: "128Mi" }, limits: { memory: "128Mi" } },
166
+ } as const;
167
+
168
+ /** A fresh copy, so no manifest shares an object with another (or with the constant). */
169
+ const sized = (r: { requests: Record<string, string>; limits: Record<string, string> }) => ({
170
+ requests: { ...r.requests },
171
+ limits: { ...r.limits },
172
+ });
173
+
174
+ /** The PriorityClass each of jr2's two tiers runs under (ADR-0060): the Instance's own when
175
+ * `priorityClasses` names one, else the one `jr2 up` creates. */
176
+ export function priorityClassNames(classes: PriorityClasses | undefined): { control: string; sandbox: string } {
177
+ return {
178
+ control: classes?.control ?? PRIORITY_CLASS_CONTROL,
179
+ sandbox: classes?.sandbox ?? PRIORITY_CLASS_SANDBOX,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The PriorityClasses `jr2 up` creates, cluster-scoped, beside the CRDs (ADR-0060) — one manifest
185
+ * each, and none for a key `priorityClasses` names, since the cluster then has its own scheme.
186
+ * `jr2-control` preempts lower priorities: the Orchestrator, the operator, the Repo cache agent and the
187
+ * Instance Effector are small and fixed, and the first three are needed by every Sandbox.
188
+ * `jr2-sandbox` never preempts: a waiting Sandbox evicts nobody, and an ordinary pod (priority 0)
189
+ * cannot preempt a live one. Both stay far below the
190
+ * system classes (2000000000+). Shared by every Instance on the cluster, like the operator, so they
191
+ * carry no instance label; a class's value and policy are immutable, and these never change.
192
+ */
193
+ export function priorityClassObjects(classes: PriorityClasses | undefined): string[] {
194
+ const managed = { "app.kubernetes.io/managed-by": "jr2" };
195
+ const pc = (name: string, value: number, preemptionPolicy: string, description: string): string =>
196
+ JSON.stringify({
197
+ apiVersion: "scheduling.k8s.io/v1",
198
+ kind: "PriorityClass",
199
+ metadata: { name, labels: managed },
200
+ value,
201
+ preemptionPolicy,
202
+ globalDefault: false,
203
+ description,
204
+ });
205
+ return [
206
+ ...(classes?.control === undefined
207
+ ? [
208
+ pc(
209
+ PRIORITY_CLASS_CONTROL,
210
+ 100000,
211
+ "PreemptLowerPriority",
212
+ "jr2 control: the Orchestrator, the operator and the Repo cache agent (ADR-0060)",
213
+ ),
214
+ ]
215
+ : []),
216
+ ...(classes?.sandbox === undefined
217
+ ? [
218
+ pc(
219
+ PRIORITY_CLASS_SANDBOX,
220
+ 1000,
221
+ "Never",
222
+ "jr2 Sandboxes and the Instance Harness: never preempted by ordinary pods, never preempting (ADR-0060)",
223
+ ),
224
+ ]
225
+ : []),
226
+ ];
227
+ }
228
+
229
+ /**
230
+ * One Harness pod's held objects (ADR-0059): what `jr2 up` placed in it, ready to become objects.
231
+ * A Workspace's, or the Instance Harness's.
232
+ */
233
+ export type HeldPodObjects = {
234
+ /** The pod key (`INSTANCE_HARNESS_HELD_POD`, `workspaceHeldPod`). */
235
+ pod: string;
236
+ manifest: HeldManifest;
237
+ /** Its literal values, by name — for its `jr2-held-secrets-<pod>` Secret; never in the manifest,
238
+ * never in a ConfigMap. */
239
+ values: Record<string, string>;
240
+ };
241
+
242
+ /** A Custodian's two rendered files: Envoy's bootstrap (JSON) and the `HELD` table script. */
243
+ export type CustodianFiles = { bootstrap: string; script: string };
244
+
245
+ /**
246
+ * What `jr2 up` resolved about held secrets (ADR-0059), ready to become objects. Values appear in
247
+ * two places only: each pod's `values` (the literals, for its own `jr2-held-secrets-<pod>` Secret)
248
+ * and the CA's and the leaves' keys — each lands in a Secret no Harness container mounts.
249
+ */
250
+ export type HeldObjects = {
251
+ /** Every Harness pod the walk found, each with what its Custodian holds. */
252
+ pods: HeldPodObjects[];
253
+ /** The Instance Harness's Custodian files, when the walk placed it — the one pod `jr2 up` makes,
254
+ * so the one it renders for; a Sandbox's are rendered by the Orchestrator at provision
255
+ * (`kubeSandbox`). */
256
+ instance?: CustodianFiles;
257
+ /** The per-Instance CA — kept across converges; present whenever a secret is held. */
258
+ ca?: Pem;
259
+ /** One leaf per bound target of any pod, by file stem (`leafStem`). */
260
+ leaves: Record<string, Pem>;
261
+ };
262
+
263
+ /** Does any pod hold a secret? */
264
+ export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
265
+
84
266
  /** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
85
267
  export function instanceObjects(opts: {
86
268
  name: string;
87
269
  namespace: string;
270
+ /** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
271
+ * Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
88
272
  image: string;
89
- hash: string;
90
- /** Secret data (token, signing key, harness env literals) — written stringData, kube encodes. */
273
+ /** Secret data (the Instance token, the publish token, the signing key) — written stringData,
274
+ * kube encodes. The Orchestrator reads it through `envFrom`. */
91
275
  secretData: Record<string, string>;
92
- /** The HARNESS containers' env values (creds, provider key) — a SEPARATE Secret from
93
- * `secretData` by doctrine (ADR-0013): Agent code executes where this lands, so the Instance
94
- * token and signing key must never share a Secret with it. */
276
+ /** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
277
+ * doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
278
+ * key must never share a Secret with it, and no model key rides it either (ADR-0059). */
95
279
  harnessEnvData: Record<string, string>;
96
280
  harness?: HarnessConfig;
97
- /** The private-CA PEM bundle (`harness.caBundle` file contents, read by `up` — ADR-0020). */
98
- caBundle?: string;
99
- /** Every image ref THIS converge resolved (ADR-0037/0038/0049): `{ harness, adapter, operator?,
281
+ /** Held secrets (ADR-0059): the Custodian's objects. The `jr2-held` ConfigMap is applied always —
282
+ * every Harness pod runs a Custodian — and the rest only while a secret is held. */
283
+ held: HeldObjects;
284
+ /** Every image ref THIS converge resolved (ADR-0037/0038/0049): `{ harness, custodian, operator?,
100
285
  * sandbox: { <key>: ref }, sandboxUser: { <key>: user } }`, where a key is a build context's
101
286
  * content digest or the reserved `default`. It lands twice, deliberately as one JSON so the
102
287
  * record `up` diffs and the map pods read can never disagree: as the `jr2-images` ConfigMap the
@@ -108,8 +293,20 @@ export function instanceObjects(opts: {
108
293
  * resolved operator ref — the same binary is the cache agent (`/manager repo-cache`). Absent, no
109
294
  * DaemonSet and none of its RBAC is applied; `up` deletes a stale one. */
110
295
  repoCache?: { image: string; placement?: SandboxPlacement };
296
+ /** `priorityClasses` from jr2.config.ts (ADR-0060): the classes to name instead of jr2's own. */
297
+ priorityClasses?: PriorityClasses;
298
+ /** Each Workspace's Sandbox Effector (ADR-0068): its manifest rides `jr2-held`, its
299
+ * `jr2-effector-<pod>` Secret holds its literals and `effector.json`. */
300
+ sandboxEffectors?: readonly SandboxEffectorPlan[];
301
+ /** Each `git.credentials` entry's push Secret and the manifest the provision reads (ADR-0051). */
302
+ gitPush?: { secrets: ReadonlyArray<{ name: string; data: Record<string, string> }>; manifest: GitPushManifest };
303
+ /** Each `git.credentials` entry's clone Secret (ADR-0068): the token the cache agent spends. The
304
+ * Orchestrator names it from `git-clone.json` and never holds it. */
305
+ gitClone?: readonly GitCloneSecret[];
111
306
  }): string {
112
307
  const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
308
+ const priority = priorityClassNames(opts.priorityClasses);
309
+ const effectors = (opts.sandboxEffectors ?? []).map((plan) => ({ plan, ...sandboxEffectorObjectsOf(plan) }));
113
310
  const meta = (name: string, extra: Record<string, string> = {}): KubeManifest => ({
114
311
  name,
115
312
  namespace: opts.namespace,
@@ -130,21 +327,27 @@ export function instanceObjects(opts: {
130
327
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
131
328
  {
132
329
  // The orchestrator drives Sandbox CRs (+ their token Secrets) in its own namespace
133
- // (ADR-0012/0013) and creates the Repo CRs the cache agent reconciles (ADR-0051); pod
134
- // exec/port-forward are the attach path (ADR-0004).
330
+ // (ADR-0012/0013), watches them (ADR-0063), and creates the Repo CRs the cache agent
331
+ // reconciles (ADR-0051). Nothing on Pods: it never reads one — the operator publishes the pod
332
+ // facts on the Sandbox's status — and the attach is the Harness's own `POST /attach`, so no
333
+ // `pods/exec` stream crosses the API server (ADR-0063).
135
334
  apiVersion: "rbac.authorization.k8s.io/v1",
136
335
  kind: "Role",
137
336
  metadata: meta(ORCHESTRATOR_SA),
138
337
  rules: [
139
338
  { apiGroups: ["core.jr2.dev"], resources: ["sandboxes", "repos"], verbs: ["*"] },
140
- // patch/update: the token Secret is `kubectl apply`d idempotently and later ownerRef-patched.
141
- {
142
- apiGroups: [""],
143
- resources: ["secrets"],
144
- verbs: ["get", "list", "create", "delete", "patch", "update"],
145
- },
146
- { apiGroups: [""], resources: ["pods", "pods/log"], verbs: ["get", "list", "watch"] },
147
- { apiGroups: [""], resources: ["pods/exec", "pods/portforward"], verbs: ["create"] },
339
+ // Secrets: create and patch, nothing else (ADR-0068). The Orchestrator server-side
340
+ // applies its own per-Sandbox `<sandbox>-token` and `<sandbox>-effector`, owned by the CR,
341
+ // and holds no git credential and no Effect secret (ADR-0068): no get, no list, so it cannot read or enumerate the Effect
342
+ // secrets, the clone and push Secrets, or a user's Secret through the API. RBAC cannot
343
+ // pin `create` to a name, and `resourceNames` has no prefix form.
344
+ { apiGroups: [""], resources: ["secrets"], verbs: ["create", "patch"] },
345
+ // A Sandbox's Custodian files, rendered at provision and server-side applied (ADR-0059);
346
+ // owned by the CR, so the garbage collector deletes them with the pod.
347
+ { apiGroups: [""], resources: ["configmaps"], verbs: ["create", "patch"] },
348
+ // A Sandbox Effector's headless Service, written at provision beside its key (ADR-0068): how
349
+ // this process reaches the pod's own address. Owned by the CR, like the ConfigMap.
350
+ { apiGroups: [""], resources: ["services"], verbs: ["create", "patch"] },
148
351
  ],
149
352
  },
150
353
  {
@@ -165,9 +368,8 @@ export function instanceObjects(opts: {
165
368
  data: {
166
369
  [HARNESS_CONFIG_KEY]: JSON.stringify(
167
370
  {
168
- // apiKey is deliberately dropped: it materializes into the Secret as
169
- // JR2_PROVIDER_API_KEY (`up`), and the Harness reads it from env — a ConfigMap is not
170
- // a place for a credential.
371
+ // apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
372
+ // it, the Harness's env holds its Stand-in, and a ConfigMap is no place for either.
171
373
  provider: opts.harness?.provider
172
374
  ? {
173
375
  id: opts.harness.provider.id,
@@ -180,6 +382,8 @@ export function instanceObjects(opts: {
180
382
  models: opts.harness.provider.models,
181
383
  }
182
384
  : undefined,
385
+ // Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
386
+ catalog: opts.harness?.catalog,
183
387
  },
184
388
  null,
185
389
  2,
@@ -197,18 +401,77 @@ export function instanceObjects(opts: {
197
401
  metadata: meta(IMAGES_CONFIGMAP),
198
402
  data: { [IMAGES_KEY]: imagesJson },
199
403
  },
200
- // The private-CA bundle (ADR-0020) — a ConfigMap, not a Secret: CA certs are public data.
201
- // kubectlSandbox mounts it into the Harness container and points NODE_EXTRA_CA_CERTS at it.
202
- ...(opts.caBundle
203
- ? [
204
- {
205
- apiVersion: "v1",
206
- kind: "ConfigMap",
207
- metadata: meta(CA_CONFIGMAP),
208
- data: { "ca.crt": opts.caBundle },
209
- },
210
- ]
211
- : []),
404
+ // Held secrets (ADR-0059): what `jr2 up` DECLARES. A pod's `held-<pod>.json` is what its
405
+ // composition reads — the Orchestrator per provision from the mount below, `jr2 up` for the
406
+ // Instance Harness — and the Custodian's program is here once. Envoy's per-pod files are
407
+ // rendered by whoever makes the pod, never collected here: a shared object stops at 1 MiB.
408
+ // Names, hosts, headers: never a value.
409
+ {
410
+ apiVersion: "v1",
411
+ kind: "ConfigMap",
412
+ metadata: meta(HELD_CONFIGMAP),
413
+ data: {
414
+ ...Object.fromEntries(opts.held.pods.map((p) => [heldManifestKey(p.pod), JSON.stringify(p.manifest, null, 2)])),
415
+ [CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
416
+ // Each Sandbox Effector's manifest (ADR-0068): the sources of its secrets mount, by name.
417
+ // The provision adds the container only for a pod whose manifest is here.
418
+ ...Object.fromEntries(
419
+ effectors.map((e) => [effectorManifestKey(e.plan.pod), JSON.stringify(e.manifest, null, 2)]),
420
+ ),
421
+ // The push manifest (ADR-0051): which entries have a push Secret, and what each can spend.
422
+ // Names only; the provision names the matched entry's Secret on the Sandbox CR.
423
+ ...(opts.gitPush?.manifest.entries.length
424
+ ? { [GIT_PUSH_MANIFEST_KEY]: JSON.stringify(opts.gitPush.manifest, null, 2) }
425
+ : {}),
426
+ // The clone manifest (ADR-0068): which entries' clone Secrets exist, by name. The Orchestrator
427
+ // names one as a Repo's `secretRef` only when it is listed here.
428
+ ...(opts.gitClone?.length
429
+ ? {
430
+ [GIT_CLONE_MANIFEST_KEY]: JSON.stringify(
431
+ { version: 1, secrets: opts.gitClone.map((s) => s.name) },
432
+ null,
433
+ 2,
434
+ ),
435
+ }
436
+ : {}),
437
+ },
438
+ },
439
+ // Each entry's clone Secret (ADR-0068), in Flux's key names: the operator's cache agent reads the
440
+ // one a Repo's `secretRef` names. The Orchestrator neither writes nor reads it.
441
+ ...(opts.gitClone ?? []).map(
442
+ (s): KubeManifest => ({
443
+ apiVersion: "v1",
444
+ kind: "Secret",
445
+ metadata: meta(s.name, { [LABEL_GIT_CLONE]: "true" }),
446
+ type: "Opaque",
447
+ stringData: s.data,
448
+ }),
449
+ ),
450
+ // Each entry's push Secret (ADR-0051): what it pushes and signs with, in Flux's key names where
451
+ // Flux has them. The operator projects one into the Sandbox Effector of a Sandbox whose push
452
+ // the entry's match covers; nothing else mounts it.
453
+ ...(opts.gitPush?.secrets ?? []).map(
454
+ (s): KubeManifest => ({
455
+ apiVersion: "v1",
456
+ kind: "Secret",
457
+ metadata: meta(s.name, { [LABEL_GIT_PUSH]: "true" }),
458
+ type: "Opaque",
459
+ data: s.data,
460
+ }),
461
+ ),
462
+ ...heldSecretObjects(opts.held, meta),
463
+ // Each Sandbox Effector's own Secret (ADR-0068): the literals its Effects name and
464
+ // `effector.json`. A `valueFrom` stays in the user's Secret, projected by reference at
465
+ // provision.
466
+ ...effectors.map(
467
+ (e): KubeManifest => ({
468
+ apiVersion: "v1",
469
+ kind: "Secret",
470
+ metadata: meta(e.secret.name, { [LABEL_EFFECTOR_POD]: e.plan.pod }),
471
+ type: "Opaque",
472
+ stringData: e.secret.stringData,
473
+ }),
474
+ ),
212
475
  {
213
476
  apiVersion: "v1",
214
477
  kind: "Secret",
@@ -230,7 +493,7 @@ export function instanceObjects(opts: {
230
493
  apiVersion: "apps/v1",
231
494
  kind: "Deployment",
232
495
  metadata: {
233
- ...meta(ORCHESTRATOR_SERVICE, { [LABEL_HASH]: opts.hash, [LABEL_VERSION]: KIT_VERSION }),
496
+ ...meta(ORCHESTRATOR_SERVICE, { [LABEL_VERSION]: KIT_VERSION }),
234
497
  // The converged image map, on the DEPLOYMENT'S OWN metadata and never on
235
498
  // `spec.template.metadata` (ADR-0038). On the pod template it would be part of the pod
236
499
  // spec, so every re-resolved ref would roll the Orchestrator — the exact cost the
@@ -239,13 +502,22 @@ export function instanceObjects(opts: {
239
502
  annotations: { [ANNOTATION_IMAGES]: imagesJson },
240
503
  },
241
504
  spec: {
242
- replicas: 1, // single WRITER (CONTEXT.md): the snapshot store brooks no split-brain
505
+ replicas: 1, // single WRITER (GLOSSARY.md): the snapshot store brooks no split-brain
243
506
  strategy: { type: "Recreate" }, // two writers may never overlap on the PVC
244
507
  selector: { matchLabels: { app: ORCHESTRATOR_SERVICE } },
245
508
  template: {
246
- metadata: { labels: { ...labels, app: ORCHESTRATOR_SERVICE } },
509
+ metadata: {
510
+ labels: { ...labels, app: ORCHESTRATOR_SERVICE },
511
+ // The Instance Secret reaches the process through `envFrom`, which a running pod reads
512
+ // once. A minted replacement for a lost Secret (a new publish token above all,
513
+ // ADR-0069) must roll the pod, or every publish after it is refused with 401.
514
+ annotations: { [ANNOTATION_SECRET_DIGEST]: instanceSecretDigest(opts.secretData) },
515
+ },
247
516
  spec: {
248
517
  serviceAccountName: ORCHESTRATOR_SA,
518
+ // Control tier (ADR-0060): small, fixed, needed by every Sandbox. No disruption opt-out:
519
+ // a restarted Orchestrator re-attaches (ADR-0007).
520
+ priorityClassName: priority.control,
249
521
  containers: [
250
522
  {
251
523
  name: "orchestrator",
@@ -256,20 +528,26 @@ export function instanceObjects(opts: {
256
528
  env: [
257
529
  // The entrypoint derives its own Service DNS + Sandbox namespace from these.
258
530
  { name: "JR2_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } },
259
- // What `/healthz` reports as this instance's identity. The same content address
260
- // the image tag carries (ADR-0019), in-process so a CLI can ask over HTTP
261
- // instead of needing kube access to read the Deployment's labels.
262
- { name: "JR2_CONTENT_HASH", value: opts.hash },
531
+ // The API server's trust (ADR-0063): the Orchestrator's own Kubernetes client is
532
+ // built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
533
+ // else — no TLS code, no dependency.
534
+ { name: "NODE_EXTRA_CA_CERTS", value: SERVICE_ACCOUNT_CA },
263
535
  ],
264
536
  // The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
265
537
  // agent on each node does, reading the credential Secret a Repo's `secretRef`
266
- // names — so nothing of git's is mounted here: state and the image map only.
538
+ // names, which `jr2 up` wrote (ADR-0068) — so nothing of git's is mounted here and no
539
+ // git token is in this container's env: state, the image map and the manifests.
267
540
  volumeMounts: [
268
541
  { name: "state", mountPath: "/instance/.jr2" },
269
542
  // The image map, read per provision (ADR-0038). A mount, so `jr2 up` rewriting
270
543
  // it costs one kubelet propagation window instead of a rollout.
271
544
  { name: "images", mountPath: IMAGES_MOUNT, readOnly: true },
545
+ // What each Workspace's Custodian holds, read per provision the same way
546
+ // (ADR-0059): names and hosts, which this process could not derive — its `.env`
547
+ // values are absent.
548
+ { name: "held", mountPath: HELD_MOUNT, readOnly: true },
272
549
  ],
550
+ resources: sized(KIT_POD_SIZES.orchestrator),
273
551
  // The period, not the boot, is what `up`'s rollout wait measures. Measured: the
274
552
  // container answers `/healthz` 1.1s after it starts, and the default 10s period
275
553
  // billed that as 11.0s — one probe fired before the server was up, then a whole
@@ -278,7 +556,7 @@ export function instanceObjects(opts: {
278
556
  // `failureThreshold` is then raised to hold the tolerance the default period gave,
279
557
  // because ONE knob sets two unrelated things: how fast a boot is noticed, and how
280
558
  // long a running pod may stall before the kubelet takes it out of service. The
281
- // Orchestrator is `replicas: 1` (CONTEXT.md: single writer), so its Service has
559
+ // Orchestrator is `replicas: 1` (GLOSSARY.md: single writer), so its Service has
282
560
  // exactly one endpoint and losing it is an outage, not a failover — and its store
283
561
  // writes are synchronous, so a GC pause or a node busy with a docker build can
284
562
  // silence `/healthz` for seconds. 15 × 2s keeps the 30s the default 3 × 10s gave.
@@ -288,11 +566,23 @@ export function instanceObjects(opts: {
288
566
  periodSeconds: 2,
289
567
  failureThreshold: 15,
290
568
  },
569
+ // Liveness restarts, which readiness never does (ADR-0008). A restart of this
570
+ // `replicas: 1` writer is an outage plus a restore, so it fires only on a process
571
+ // that has answered nothing for five minutes (30 × 10s): silent that long is dead,
572
+ // not stalled, and without it the pod sits NotReady until a human deletes it. A
573
+ // process that EXITS needs no probe — the restart policy already has it. The same
574
+ // five minutes cover a boot's restore, so there is no initial delay.
575
+ livenessProbe: {
576
+ httpGet: { path: "/healthz", port: ORCHESTRATOR_PORT },
577
+ periodSeconds: 10,
578
+ failureThreshold: 30,
579
+ },
291
580
  },
292
581
  ],
293
582
  volumes: [
294
583
  { name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
295
584
  { name: "images", configMap: { name: IMAGES_CONFIGMAP } },
585
+ { name: "held", configMap: { name: HELD_CONFIGMAP } },
296
586
  ],
297
587
  },
298
588
  },
@@ -308,7 +598,15 @@ export function instanceObjects(opts: {
308
598
  },
309
599
  },
310
600
  ...harnessIngressPolicies(opts.name, meta),
311
- ...(opts.repoCache ? repoCacheObjects({ ...opts.repoCache, namespace: opts.namespace, labels, meta }) : []),
601
+ ...(opts.repoCache
602
+ ? repoCacheObjects({
603
+ ...opts.repoCache,
604
+ namespace: opts.namespace,
605
+ labels,
606
+ meta,
607
+ priorityClassName: priority.control,
608
+ })
609
+ : []),
312
610
  ];
313
611
 
314
612
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
@@ -329,8 +627,8 @@ const SANDBOX_POD_LABEL = "sandbox.jr2.dev/name";
329
627
  * window. The peer is this instance's Orchestrator and no other — a `podSelector` never crosses the
330
628
  * namespace, and the instance label pins it within one. What is NOT here, on purpose:
331
629
  *
332
- * - The Orchestrator is not selected. Every route there is token-gated, and an Adapter in any
333
- * Harness pod must reach it; a policy could not tell the Adapter's packets from the Agent's.
630
+ * - The Orchestrator is not selected. Every route there is token-gated, and a Custodian in any
631
+ * Harness pod must reach it; a policy could not tell the Custodian's packets from the Agent's.
334
632
  * - No egress. What a Sandbox may reach is its own decision (ADR-0058 records it open).
335
633
  * - No port. The Orchestrator speaks only the Harness wire to these pods, and a port here would
336
634
  * have to track the CR's `port`; `kubectl port-forward` (the CLI, a human's shell into the User
@@ -381,8 +679,10 @@ function repoCacheObjects(opts: {
381
679
  namespace: string;
382
680
  labels: Record<string, string>;
383
681
  meta: (name: string, extra?: Record<string, string>) => KubeManifest;
682
+ /** The control tier's class (ADR-0060): every Sandbox on the node needs this agent. */
683
+ priorityClassName: string;
384
684
  }): KubeManifest[] {
385
- const { image, namespace, labels, meta, placement } = opts;
685
+ const { image, namespace, labels, meta, placement, priorityClassName } = opts;
386
686
  return [
387
687
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(REPO_CACHE) },
388
688
  {
@@ -419,6 +719,7 @@ function repoCacheObjects(opts: {
419
719
  spec: {
420
720
  serviceAccountName: REPO_CACHE,
421
721
  automountServiceAccountToken: true,
722
+ priorityClassName,
422
723
  // One agent per Sandbox node (ADR-0052): the pod's own placement, verbatim. A node no
423
724
  // Sandbox can reach gets no agent — a cache there is a clone nobody reads, and an
424
725
  // affinity term the scheduler cannot honor. (The DaemonSet controller still adds its
@@ -443,7 +744,8 @@ function repoCacheObjects(opts: {
443
744
  { name: "home", mountPath: REPO_CACHE_HOME },
444
745
  { name: "tmp", mountPath: "/tmp" },
445
746
  ],
446
- resources: { requests: { cpu: "20m", memory: "64Mi" } },
747
+ // Kit-sized (ADR-0060): the one request below its limit — `git index-pack` spikes.
748
+ resources: sized(KIT_POD_SIZES.repoCache),
447
749
  securityContext: {
448
750
  runAsUser: 0,
449
751
  runAsGroup: 0,
@@ -469,23 +771,82 @@ function repoCacheObjects(opts: {
469
771
  ];
470
772
  }
471
773
 
472
- /** Where the Instance Harness's Harness container sees the CA bundle — the same path
473
- * `kubectlSandbox` mounts it at in a Sandbox pod (ADR-0020). */
474
- const CA_MOUNT = "/etc/jr2/ca";
774
+ /** The label on each pod's `jr2-held-secrets-<pod>` Secret: its pod, so `jr2 up` finds and deletes
775
+ * the one a pod no longer needs. */
776
+ export const LABEL_HELD_POD = "jr2.dev/held-pod";
777
+
778
+ /**
779
+ * The held-secret Secrets (ADR-0059), present only while a secret is held: the CA (which NO pod
780
+ * mounts — `jr2 up` alone reads it back), each pod's literal values (its Custodian's alone) and the
781
+ * leaves (the Custodians'). `stringData` as written: no trailing newline is added to a value.
782
+ */
783
+ function heldSecretObjects(held: HeldObjects, meta: (name: string) => KubeManifest): KubeManifest[] {
784
+ if (!holdsAny(held) || !held.ca) return [];
785
+ const secret = (name: string, stringData: Record<string, string>, pod?: string): KubeManifest => {
786
+ const m = meta(name);
787
+ return {
788
+ apiVersion: "v1",
789
+ kind: "Secret",
790
+ metadata: pod === undefined ? m : { ...m, labels: { ...(m.labels as object), [LABEL_HELD_POD]: pod } },
791
+ type: "Opaque",
792
+ stringData,
793
+ };
794
+ };
795
+ const leaves: Record<string, string> = {};
796
+ for (const [stem, pem] of Object.entries(held.leaves)) {
797
+ leaves[`${stem}.crt`] = pem.cert;
798
+ leaves[`${stem}.key`] = pem.key;
799
+ }
800
+ return [
801
+ secret(HELD_CA_SECRET, { "ca.crt": held.ca.cert, "ca.key": held.ca.key }),
802
+ ...held.pods.flatMap((p) =>
803
+ Object.keys(p.values).length ? [secret(heldSecretsSecret(p.pod), p.values, p.pod)] : [],
804
+ ),
805
+ secret(HELD_TLS_SECRET, leaves),
806
+ ];
807
+ }
475
808
 
476
- /** The Adapter's port on the pod's loopback — the same default the Sandbox pod uses. */
477
- const ADAPTER_PORT = 8081;
809
+ /**
810
+ * The trust ConfigMap (ADR-0020, ADR-0059): the user's `caBundle` as `ca.crt`, and while a secret
811
+ * is held, the three bundles its path needs. Its own manifest, apart from the List, because the
812
+ * bundles carry every root certificate — past what client-side apply's annotation can hold — so
813
+ * `jr2 up` applies it server-side. Undefined when there is nothing to trust.
814
+ */
815
+ export function trustObject(opts: {
816
+ name: string;
817
+ namespace: string;
818
+ caBundle?: string;
819
+ bundles?: { extra: string; bundle: string; upstream: string };
820
+ }): string | undefined {
821
+ if (opts.caBundle === undefined && opts.bundles === undefined) return undefined;
822
+ return JSON.stringify({
823
+ apiVersion: "v1",
824
+ kind: "ConfigMap",
825
+ metadata: {
826
+ name: CA_CONFIGMAP,
827
+ namespace: opts.namespace,
828
+ labels: { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" },
829
+ },
830
+ data: {
831
+ ...(opts.caBundle !== undefined ? { "ca.crt": opts.caBundle } : {}),
832
+ ...(opts.bundles
833
+ ? { "extra.crt": opts.bundles.extra, "bundle.crt": opts.bundles.bundle, "upstream.crt": opts.bundles.upstream }
834
+ : {}),
835
+ },
836
+ });
837
+ }
478
838
 
479
839
  /**
480
- * The Instance Harness (ADR-0031): the per-instance Harness Deployment + Service `jr2 up`
840
+ * The Instance Harness (ADR-0031): the per-instance Harness StatefulSet + Service `jr2 up`
481
841
  * converges whenever an Agent a registered Machine CARRIES declares `workspace: "none"`
482
842
  * (ADR-0049's walk) — the placement for every Menu-only Agent's Turn, regardless of any enclosing
483
- * Workspace. The one Harness shape, minus the Workspace: the stock Harness image plus the Adapter
484
- * sidecar, the same harness-config ConfigMap and env/envFrom/CA wiring a Sandbox's Harness
485
- * container gets — and NO `/work` volume, no attach step. It runs the STOCK image permanently: `workspace: "none"` withholds the whole
486
- * Working toolset (ADR-0028), so there are no tools to carry and no Sandbox Image to resolve
487
- * (ADR-0037). No config key names, sizes, addresses, or enables it: the Machine walk is the
488
- * entire surface.
843
+ * Workspace. The one Harness shape, minus the Workspace: the stock Harness image plus the Custodian,
844
+ * composed by the same function a Sandbox's is (custodian.ts, ADR-0059), the same harness-config
845
+ * ConfigMap and env/envFrom/trust wiring a Sandbox's Harness container gets — and NO `/work`
846
+ * volume, no attach step. It runs the STOCK image permanently: `workspace: "none"` withholds the
847
+ * whole Working toolset (ADR-0028), so there are no tools to carry and no Sandbox Image to resolve
848
+ * (ADR-0037). No config key names, sizes, addresses, or enables it: the Machine walk is the entire
849
+ * surface.
489
850
  */
490
851
  export function instanceHarnessObjects(opts: {
491
852
  name: string;
@@ -496,19 +857,31 @@ export function instanceHarnessObjects(opts: {
496
857
  *
497
858
  * The accepted asymmetry: the Instance Harness names its images HERE, in the pod template, while
498
859
  * a Sandbox's refs travel through the `jr2-images` ConfigMap. Both are right for what they are —
499
- * this Deployment is supposed to roll when its image moves; the Orchestrator is not. */
860
+ * this StatefulSet is supposed to roll when its image moves; the Orchestrator is not. */
500
861
  harnessImage: string;
501
- /** The resolved Adapter ref: the Harness's one menu-delivery path, kept even though a `"none"`
862
+ /** The resolved Custodian ref: the Harness's one route to its Menu, kept even though a `"none"`
502
863
  * Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
503
- adapterImage: string;
864
+ custodianImage: string;
865
+ /** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
866
+ * keys its `"none"` Agents' providers read, and nothing else. */
867
+ held: HeldManifest;
868
+ /** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
869
+ * (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
870
+ custodian: CustodianFiles;
871
+ /** An HMAC over this pod's held-secret inputs (its manifest, its leaves, its literal values, its
872
+ * Custodian's config), keyed with the signing key so it reveals nothing — on the pod template, so
873
+ * a change to any of them rolls this pod (ADR-0059). */
874
+ heldDigest: string;
504
875
  harness?: HarnessConfig;
505
- /** The instance ships a private-CA bundle (ADR-0020): mount `jr2-ca` into the Harness container. */
876
+ /** The instance ships a private-CA bundle (ADR-0020). */
506
877
  caBundle?: boolean;
507
878
  /** The digest of this placement's Harness bearer (ADR-0058; `harnessTokenDigest(key,
508
879
  * "jr2-instance-harness")`) — the wire's gate. The digest, never the bearer: the same shape
509
880
  * every Sandbox Harness container gets, so this pod can verify the Orchestrator and mint
510
881
  * nothing. */
511
882
  bearerSha256: string;
883
+ /** `priorityClasses` from jr2.config.ts (ADR-0060): the classes to name instead of jr2's own. */
884
+ priorityClasses?: PriorityClasses;
512
885
  }): string {
513
886
  const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
514
887
  const meta = (): KubeManifest => ({
@@ -526,14 +899,27 @@ export function instanceHarnessObjects(opts: {
526
899
  seccompProfile: { type: "RuntimeDefault" },
527
900
  });
528
901
 
902
+ // The Custodian (ADR-0013, ADR-0059): the placement's Sandbox token — signed for this
903
+ // placement's name (`up.ts` mints it into the instance Secret), so it speaks only for the Turns
904
+ // hosted HERE (tokens.ts) — plus the held secrets placed here. Mounted into the Custodian alone;
905
+ // the Instance token itself never enters this pod.
906
+ const custodian = custodianComposition(opts.held, {
907
+ image: opts.custodianImage,
908
+ pod: INSTANCE_HARNESS_HELD_POD,
909
+ config: INSTANCE_CUSTODIAN_CONFIGMAP,
910
+ token: { secret: INSTANCE_SECRET, key: "JR2_INSTANCE_HARNESS_TOKEN" },
911
+ caBundle: opts.caBundle === true,
912
+ resources: sized(KIT_POD_SIZES.instanceCustodian),
913
+ });
914
+
529
915
  const harnessContainer = {
530
916
  name: "harness",
531
917
  image: opts.harnessImage,
532
918
  imagePullPolicy: "IfNotPresent",
533
919
  ports: [{ containerPort: INSTANCE_HARNESS_PORT }],
534
- // The same asymmetry the Sandbox pod builds (ADR-0013/0020): the mounted harness config and
535
- // the instance's valueFrom entries ride `env` (literal values live in the jr2-harness-env
536
- // Secret), the CA trust lands here and nowhere else, and no credential ever does.
920
+ // The same asymmetry the Sandbox pod builds (ADR-0013/0020/0059): the mounted harness config
921
+ // and the instance's valueFrom entries ride `env` (literal values live in the jr2-harness-env
922
+ // Secret), then the Custodian's address and every Stand-in, and no credential ever does.
537
923
  env: [
538
924
  {
539
925
  name: "JR2_HARNESS_JSON",
@@ -545,79 +931,103 @@ export function instanceHarnessObjects(opts: {
545
931
  // rather than asserted. The bearer (below) narrows who may try; this decides what.
546
932
  { name: "JR2_MENU_ONLY", value: "1" },
547
933
  ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
548
- { name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${ADAPTER_PORT}` },
549
- ...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
934
+ ...custodian.harnessEnv,
935
+ // Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
936
+ { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
550
937
  // The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
551
938
  { name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
552
939
  ],
553
940
  envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
554
- ...(opts.caBundle ? { volumeMounts: [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] } : {}),
941
+ // The Harness container's alone: the Custodian has no business with an Agent's history.
942
+ volumeMounts: [{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR }, ...custodian.harnessMounts],
555
943
  // The operator probes a Sandbox's Harness the same way: serving = the socket accepts.
556
944
  // Period and threshold as reasoned on the Orchestrator above: the default 10s period is the
557
945
  // rollout wait rather than the boot, and the threshold then has to carry the stall tolerance
558
946
  // the period used to supply. This pod is single-replica too.
947
+ // The Harness's own route, not the port (ADR-0063): a socket probe would pass for whatever
948
+ // listens there. A draining Harness still answers it (ADR-0031).
559
949
  readinessProbe: {
560
- tcpSocket: { port: INSTANCE_HARNESS_PORT },
950
+ httpGet: { path: "/healthz", port: INSTANCE_HARNESS_PORT },
561
951
  initialDelaySeconds: 1,
562
952
  periodSeconds: 2,
563
953
  failureThreshold: 15,
564
954
  },
955
+ resources: sized(KIT_POD_SIZES.instanceHarness),
565
956
  securityContext: hardenedContainerSecurityContext(),
566
957
  };
567
958
 
568
- const adapterContainer = {
569
- name: "adapter",
570
- image: opts.adapterImage,
571
- imagePullPolicy: "IfNotPresent",
572
- securityContext: hardenedContainerSecurityContext(),
573
- env: [
574
- {
575
- name: "JR2_ORCHESTRATOR_URL",
576
- value: `http://${ORCHESTRATOR_SERVICE}.${opts.namespace}.svc:${ORCHESTRATOR_PORT}`,
577
- },
578
- { name: "JR2_ADAPTER_PORT", value: String(ADAPTER_PORT) },
579
- // The Adapter's bearer env, carrying a sandbox-style token SIGNED FOR THIS PLACEMENT's
580
- // name (`up.ts` mints it into the instance Secret): ADR-0013's delivery doctrine, extended
581
- // to the second placement — the token speaks only for registrations that record the
582
- // Instance Harness as the pod hosting their Turn (tokens.ts), never a Workspace's, and the
583
- // Instance token itself never enters this pod. The credential lives in this container,
584
- // where no Agent can read it — and `JR2_MENU_ONLY` above is what keeps that true: only
585
- // Menu-only Agents run here, so nothing in this pod executes code (ADR-0031's
586
- // defense-in-depth bonus).
587
- {
588
- name: "JR2_SANDBOX_TOKEN",
589
- valueFrom: { secretKeyRef: { name: INSTANCE_SECRET, key: "JR2_INSTANCE_HARNESS_TOKEN" } },
590
- },
591
- ],
592
- };
593
-
594
959
  const items: KubeManifest[] = [
960
+ // The one pod `jr2 up` makes, so the one whose Custodian files it renders: its bootstrap and
961
+ // its HELD table, from its own manifest, beside the program its volume projects from `jr2-held`.
962
+ {
963
+ apiVersion: "v1",
964
+ kind: "ConfigMap",
965
+ metadata: { ...meta(), name: INSTANCE_CUSTODIAN_CONFIGMAP },
966
+ data: {
967
+ [CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
968
+ [CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
969
+ },
970
+ },
595
971
  {
596
972
  apiVersion: "apps/v1",
597
- kind: "Deployment",
973
+ kind: "StatefulSet",
598
974
  metadata: { ...meta(), labels: { ...labels, [LABEL_VERSION]: KIT_VERSION } },
599
975
  spec: {
600
- // ONE replica, Recreate: a conversation is an Instance ID on one Harness PROCESS
601
- // (ADR-0031) — two pods behind this Service would route one conversation to two servers,
602
- // the exact amnesia definition-wins placement exists to prevent. A restart loses the
603
- // conversations (live-only, ADR-0023); the Deployment restores the endpoint, not the
604
- // history.
976
+ // A StatefulSet of ONE (ADR-0031): the value is the stable volume — a restart or a rollout
977
+ // brings the same conversations back — and the stable name is what N > 1 would pin a
978
+ // conversation to. Never more than one pod behind this Service for one conversation, and
979
+ // never an HPA: a scale-down would kill conversations. At N = 1 a rollout has no surge,
980
+ // so admissions are refused while the new pod starts, which the Orchestrator absorbs
981
+ // (ADR-0042).
605
982
  replicas: 1,
606
- strategy: { type: "Recreate" },
983
+ serviceName: INSTANCE_HARNESS_PODS_SERVICE,
607
984
  selector: { matchLabels: { app: INSTANCE_HARNESS_SERVICE } },
985
+ // The conversations go with the StatefulSet, never with a scale-down: an instance whose
986
+ // Machines dropped every `"none"` Agent keeps no history for them.
987
+ persistentVolumeClaimRetentionPolicy: { whenDeleted: "Delete", whenScaled: "Retain" },
988
+ volumeClaimTemplates: [
989
+ {
990
+ metadata: { name: CONVERSATIONS_VOLUME, labels },
991
+ spec: {
992
+ accessModes: ["ReadWriteOnce"],
993
+ resources: { requests: { storage: "1Gi" } },
994
+ },
995
+ },
996
+ ],
608
997
  template: {
609
- metadata: { labels: { ...labels, app: INSTANCE_HARNESS_SERVICE } },
998
+ metadata: {
999
+ labels: { ...labels, app: INSTANCE_HARNESS_SERVICE },
1000
+ // A held-secret edit rolls this pod; a live Sandbox keeps what its Custodian read at
1001
+ // start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
1002
+ // (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
1003
+ // can.
1004
+ annotations: { [ANNOTATION_HELD_DIGEST]: opts.heldDigest, ...NO_DISRUPT_ANNOTATIONS },
1005
+ },
610
1006
  spec: {
611
- containers: [harnessContainer, adapterContainer],
1007
+ // The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
1008
+ // it holds, so the grace is a Turn's worst case — a deploy loses no Turn.
1009
+ terminationGracePeriodSeconds: INSTANCE_HARNESS_DRAIN_SECONDS,
1010
+ // The Sandbox tier's class (ADR-0060): it holds live conversations, as a Sandbox holds
1011
+ // `/work`, and it may wait rather than preempt.
1012
+ priorityClassName: priorityClassNames(opts.priorityClasses).sandbox,
1013
+ // The Custodian is a native sidecar, so it outlives the drain (ADR-0031): the kubelet
1014
+ // stops a pod's sidecars only after its main containers exit, and the draining Harness
1015
+ // needs its Custodian to the end — its one route to the Menu and to the model. As a
1016
+ // plain container Envoy took SIGTERM with the Harness and quit at once.
1017
+ initContainers: [{ ...custodian.custodianContainer, restartPolicy: "Always" }],
1018
+ containers: [harnessContainer],
612
1019
  // The operator's isolation baseline (sandbox_controller.go), mirrored: same Harness
613
1020
  // image, same "never reach the Kubernetes API" north star — JR2_MENU_ONLY makes code
614
- // execution here unlikely, not unimaginable.
1021
+ // execution here unlikely, not unimaginable. No `shareProcessNamespace`: the Harness
1022
+ // container must not read the Custodian's `/proc`. `fsGroup` makes the Custodian's
1023
+ // group-readable values readable by Envoy's own uid, as a Sandbox pod's does.
615
1024
  automountServiceAccountToken: false,
616
1025
  securityContext: {
617
1026
  runAsNonRoot: true,
618
1027
  seccompProfile: { type: "RuntimeDefault" },
1028
+ fsGroup: INSTANCE_HARNESS_FS_GROUP,
619
1029
  },
620
- ...(opts.caBundle ? { volumes: [{ name: "ca", configMap: { name: CA_CONFIGMAP } }] } : {}),
1030
+ volumes: custodian.volumes,
621
1031
  },
622
1032
  },
623
1033
  },
@@ -627,11 +1037,272 @@ export function instanceHarnessObjects(opts: {
627
1037
  kind: "Service",
628
1038
  metadata: meta(),
629
1039
  spec: {
1040
+ // The address the Agent actor dials: a ClusterIP Service, deliberately not the headless
1041
+ // one below. A draining pod is terminating, so DNS stops naming it, but kube-proxy still
1042
+ // routes a ClusterIP to a terminating pod that serves while no other is ready — and at
1043
+ // N = 1 none is until it exits. So during the drain reads, waits and aborts keep reaching
1044
+ // it (ADR-0031), and a refused admission meets its 503, not a name that resolves to nothing.
630
1045
  selector: { app: INSTANCE_HARNESS_SERVICE },
631
1046
  ports: [{ port: INSTANCE_HARNESS_PORT, targetPort: INSTANCE_HARNESS_PORT }],
632
1047
  },
633
1048
  },
1049
+ {
1050
+ apiVersion: "v1",
1051
+ kind: "Service",
1052
+ metadata: { ...meta(), name: INSTANCE_HARNESS_PODS_SERVICE },
1053
+ spec: {
1054
+ // Headless — the StatefulSet's governing Service (`serviceName`), for the per-ordinal names.
1055
+ clusterIP: "None",
1056
+ selector: { app: INSTANCE_HARNESS_SERVICE },
1057
+ ports: [{ port: INSTANCE_HARNESS_PORT, targetPort: INSTANCE_HARNESS_PORT }],
1058
+ },
1059
+ },
1060
+ ];
1061
+
1062
+ return JSON.stringify({ apiVersion: "v1", kind: "List", items });
1063
+ }
1064
+
1065
+ /** The Orchestrator's pod template digest of the Instance Secret: it rolls the pod when a value
1066
+ * the process reads through `envFrom` changes (ADR-0069). */
1067
+ export const ANNOTATION_SECRET_DIGEST = "jr2.dev/secret-digest";
1068
+
1069
+ /** A keyed digest of the Instance Secret's data, in key order. Keyed with the signing key it
1070
+ * holds, so the annotation reveals nothing about a token. */
1071
+ export function instanceSecretDigest(secretData: Record<string, string>): string {
1072
+ const mac = createHmac("sha256", Buffer.from(secretData.JR2_SIGNING_KEY ?? "", "base64"));
1073
+ mac.update("jr2-instance-secret\n");
1074
+ for (const key of Object.keys(secretData).sort()) {
1075
+ const value = Buffer.from(secretData[key]!, "utf8");
1076
+ mac.update(`${key}\n${value.length}:`).update(value);
1077
+ }
1078
+ return mac.digest("base64url");
1079
+ }
1080
+
1081
+ /** The Instance Effector's pod template digest (ADR-0068): an HMAC over every secret input, so an
1082
+ * edit to one rolls it, as `jr2.dev/held-digest` rolls the Instance Harness (ADR-0059). */
1083
+ export const ANNOTATION_EFFECTOR_DIGEST = "jr2.dev/effector-digest";
1084
+
1085
+ /** The Instance Effector pod's `fsGroup`: its Secret files are readable by that group, and the pod
1086
+ * has one container. The group a Sandbox's and the Instance Harness's pods use (ADR-0005). */
1087
+ const INSTANCE_EFFECTOR_FS_GROUP = 2000;
1088
+
1089
+ /** Where the Effector reads what it holds (ADR-0068): its secrets, one file each; its key and
1090
+ * its config. Its scratch is `$HOME`. */
1091
+ const EFFECTOR_SECRETS_DIR = "/run/jr2/secrets";
1092
+ const EFFECTOR_OWN_DIR = "/run/jr2/effector";
1093
+ const EFFECTOR_SCRATCH = "/run/jr2/scratch";
1094
+
1095
+ /** How much more than the longest Effect timeout a stopping replica is given: room for the
1096
+ * Orchestrator's next poll to read the outcome it finished. */
1097
+ export const EFFECTOR_READ_MARGIN_SECONDS = 30;
1098
+
1099
+ /** The Secret's key of one literal Effect secret: prefixed, so no secret's name is `key` or
1100
+ * `effector.json`. */
1101
+ const effectorSecretKey = (name: string): string => `secret.${name}`;
1102
+
1103
+ /** The Instance Effector's grace period: the longest timeout among the Effects placed there, plus
1104
+ * the read margin. */
1105
+ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
1106
+ Math.ceil(plan.longestTimeoutMs / 1000) + EFFECTOR_READ_MARGIN_SECONDS;
1107
+
1108
+ /**
1109
+ * The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
1110
+ * places an Effect there — an `http` Effect — and deletes otherwise.
1111
+ *
1112
+ * - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
1113
+ * the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
1114
+ * `jr2-harness` image with `/opt/jr2/bin/effector` as its command; hardened, non-root, a read-only
1115
+ * root filesystem, an emptyDir scratch as `$HOME`. Its grace period is the longest timeout among
1116
+ * the Effects placed there, plus a margin for the outcome to be read.
1117
+ * - A headless Service that publishes addresses that are not ready: "ask every replica" is its
1118
+ * name's addresses, a stopping replica included.
1119
+ * - A NetworkPolicy of `jr2-sandbox-ingress`'s form: ingress from this Instance's Orchestrator only.
1120
+ * No egress rule: the pod holds real values and calls the hosts its Effects name.
1121
+ * - The Secret `jr2-instance-effector`: the literal Effect secrets placed here, the placement's key,
1122
+ * and `effector.json` (the targets). A secret from the user's own Secret is projected from there
1123
+ * by reference, never copied.
1124
+ */
1125
+ export function instanceEffectorObjects(opts: {
1126
+ name: string;
1127
+ namespace: string;
1128
+ /** The resolved `jr2-harness` ref — it carries the binary (ADR-0037). */
1129
+ harnessImage: string;
1130
+ plan: EffectorPlan;
1131
+ /** `effectorKey(signingKey, "instance")`. */
1132
+ key: string;
1133
+ /** The HMAC over every secret input (`effectorDigest`). */
1134
+ digest: string;
1135
+ /** The instance ships a private-CA bundle (ADR-0020): it is mounted and trusted. */
1136
+ caBundle?: boolean;
1137
+ priorityClasses?: PriorityClasses;
1138
+ }): string {
1139
+ const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
1140
+ const meta = (): KubeManifest => ({ name: INSTANCE_EFFECTOR, namespace: opts.namespace, labels });
1141
+ const grace = effectorGraceSeconds(opts.plan);
1142
+
1143
+ const literal = opts.plan.secrets.filter((s) => s.source.kind === "literal");
1144
+ const sources: KubeManifest[] = [
1145
+ ...(literal.length
1146
+ ? [
1147
+ {
1148
+ secret: {
1149
+ name: INSTANCE_EFFECTOR,
1150
+ items: literal.map((s) => ({ key: effectorSecretKey(s.name), path: s.name })),
1151
+ },
1152
+ },
1153
+ ]
1154
+ : []),
1155
+ ...opts.plan.secrets.flatMap((s) =>
1156
+ s.source.kind === "secret"
1157
+ ? [{ secret: { name: s.source.secret, items: [{ key: s.source.key, path: s.name }] } }]
1158
+ : [],
1159
+ ),
634
1160
  ];
1161
+ const config = { version: 1, placement: opts.plan.placement, targets: opts.plan.targets };
1162
+
1163
+ const container = {
1164
+ name: "effector",
1165
+ image: opts.harnessImage,
1166
+ imagePullPolicy: "IfNotPresent",
1167
+ command: ["/opt/jr2/bin/effector"],
1168
+ ports: [{ containerPort: EFFECTOR_PORT }],
1169
+ env: [
1170
+ { name: "POD_NAME", valueFrom: { fieldRef: { fieldPath: "metadata.name" } } },
1171
+ { name: "POD_IP", valueFrom: { fieldRef: { fieldPath: "status.podIP" } } },
1172
+ { name: "JR2_EFFECTOR_PORT", value: String(EFFECTOR_PORT) },
1173
+ { name: "JR2_EFFECTOR_KEY", value: `${EFFECTOR_OWN_DIR}/key` },
1174
+ { name: "JR2_EFFECTOR_CONFIG", value: `${EFFECTOR_OWN_DIR}/effector.json` },
1175
+ { name: "JR2_EFFECTOR_SECRETS", value: EFFECTOR_SECRETS_DIR },
1176
+ // The drain ends a few seconds before the kubelet's kill, so the replica exits on its own.
1177
+ { name: "JR2_EFFECTOR_DRAIN_SECONDS", value: String(Math.max(grace - 5, 1)) },
1178
+ { name: "HOME", value: EFFECTOR_SCRATCH },
1179
+ ...(opts.caBundle ? [{ name: "JR2_CA_FILE", value: `${CA_MOUNT}/ca.crt` }] : []),
1180
+ ],
1181
+ volumeMounts: [
1182
+ { name: "secrets", mountPath: EFFECTOR_SECRETS_DIR, readOnly: true },
1183
+ { name: "effector", mountPath: EFFECTOR_OWN_DIR, readOnly: true },
1184
+ { name: "scratch", mountPath: EFFECTOR_SCRATCH },
1185
+ ...(opts.caBundle ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : []),
1186
+ ],
1187
+ readinessProbe: { httpGet: { path: "/healthz", port: EFFECTOR_PORT }, periodSeconds: 2, failureThreshold: 3 },
1188
+ resources: sized(KIT_POD_SIZES.instanceEffector),
1189
+ securityContext: {
1190
+ runAsNonRoot: true,
1191
+ allowPrivilegeEscalation: false,
1192
+ readOnlyRootFilesystem: true,
1193
+ capabilities: { drop: ["ALL"] },
1194
+ seccompProfile: { type: "RuntimeDefault" },
1195
+ },
1196
+ };
635
1197
 
1198
+ const items: KubeManifest[] = [
1199
+ {
1200
+ apiVersion: "v1",
1201
+ kind: "Secret",
1202
+ metadata: meta(),
1203
+ type: "Opaque",
1204
+ stringData: {
1205
+ key: opts.key,
1206
+ "effector.json": JSON.stringify(config, null, 2),
1207
+ ...Object.fromEntries(literal.map((s) => [effectorSecretKey(s.name), opts.plan.values[s.name] ?? ""])),
1208
+ },
1209
+ },
1210
+ {
1211
+ apiVersion: "apps/v1",
1212
+ kind: "Deployment",
1213
+ metadata: { ...meta(), labels: { ...labels, [LABEL_VERSION]: KIT_VERSION } },
1214
+ spec: {
1215
+ replicas: 1,
1216
+ selector: { matchLabels: { app: INSTANCE_EFFECTOR } },
1217
+ // Surge, never a gap: the new replica starts before the old one stops.
1218
+ strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } },
1219
+ template: {
1220
+ metadata: {
1221
+ labels: { ...labels, app: INSTANCE_EFFECTOR },
1222
+ annotations: { [ANNOTATION_EFFECTOR_DIGEST]: opts.digest },
1223
+ },
1224
+ spec: {
1225
+ terminationGracePeriodSeconds: grace,
1226
+ priorityClassName: priorityClassNames(opts.priorityClasses).control,
1227
+ automountServiceAccountToken: false,
1228
+ enableServiceLinks: false,
1229
+ securityContext: {
1230
+ runAsNonRoot: true,
1231
+ seccompProfile: { type: "RuntimeDefault" },
1232
+ fsGroup: INSTANCE_EFFECTOR_FS_GROUP,
1233
+ },
1234
+ containers: [container],
1235
+ volumes: [
1236
+ { name: "secrets", projected: { defaultMode: 0o400, sources } },
1237
+ {
1238
+ name: "effector",
1239
+ secret: {
1240
+ secretName: INSTANCE_EFFECTOR,
1241
+ defaultMode: 0o400,
1242
+ items: [
1243
+ { key: "key", path: "key" },
1244
+ { key: "effector.json", path: "effector.json" },
1245
+ ],
1246
+ },
1247
+ },
1248
+ { name: "scratch", emptyDir: {} },
1249
+ ...(opts.caBundle ? [{ name: "ca", configMap: { name: CA_CONFIGMAP } }] : []),
1250
+ ],
1251
+ },
1252
+ },
1253
+ },
1254
+ },
1255
+ {
1256
+ apiVersion: "v1",
1257
+ kind: "Service",
1258
+ metadata: meta(),
1259
+ spec: {
1260
+ clusterIP: "None",
1261
+ // A stopping replica still answers for the Effects it finishes: its address stays listed.
1262
+ publishNotReadyAddresses: true,
1263
+ selector: { app: INSTANCE_EFFECTOR },
1264
+ ports: [{ port: EFFECTOR_PORT, targetPort: EFFECTOR_PORT }],
1265
+ },
1266
+ },
1267
+ {
1268
+ apiVersion: "networking.k8s.io/v1",
1269
+ kind: "NetworkPolicy",
1270
+ metadata: meta(),
1271
+ spec: {
1272
+ podSelector: { matchLabels: { app: INSTANCE_EFFECTOR } },
1273
+ policyTypes: ["Ingress"],
1274
+ ingress: [
1275
+ { from: [{ podSelector: { matchLabels: { app: ORCHESTRATOR_SERVICE, [LABEL_INSTANCE]: opts.name } } }] },
1276
+ ],
1277
+ },
1278
+ },
1279
+ ];
636
1280
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
637
1281
  }
1282
+
1283
+ /** A keyed digest of one key's value in the user's Secret, as the API returns it (base64): what a
1284
+ * template digest holds for a `valueFrom`, so an edit of the value rolls the pod and the annotation
1285
+ * reveals nothing about it. `jr2 up` keeps the digest, never the value. */
1286
+ export function storedValueDigest(signingKey: Buffer, secret: string, key: string, base64: string): string {
1287
+ return createHmac("sha256", signingKey)
1288
+ .update(`jr2-secret-value\n${secret}\n${key}\n`)
1289
+ .update(Buffer.from(base64, "base64"))
1290
+ .digest("base64url");
1291
+ }
1292
+
1293
+ /** The digest of each `valueFrom` value, by the user's Secret and key; undefined when not read. */
1294
+ export type ValueDigest = (secret: string, key: string) => string | undefined;
1295
+
1296
+ /** The Instance Effector's template digest: an HMAC over every secret input — a literal's value, a
1297
+ * `valueFrom`'s reference and the digest of its value — keyed with the signing key so the
1298
+ * annotation reveals nothing about a value. */
1299
+ export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
1300
+ const mac = createHmac("sha256", signingKey);
1301
+ mac.update(JSON.stringify({ secrets: plan.secrets, targets: plan.targets, longest: plan.longestTimeoutMs }));
1302
+ for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
1303
+ for (const s of plan.secrets) {
1304
+ if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
1305
+ }
1306
+ mac.update(key);
1307
+ return mac.digest("base64url").slice(0, 32);
1308
+ }