@jr2/cli 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/deploy.ts CHANGED
@@ -3,20 +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,
10
17
  custodianComposition,
11
18
  CUSTODIAN_BOOTSTRAP_KEY,
19
+ CUSTODIAN_PROGRAM,
20
+ CUSTODIAN_PROGRAM_KEY,
12
21
  CUSTODIAN_SCRIPT_KEY,
22
+ INSTANCE_CUSTODIAN_CONFIGMAP,
13
23
  GIT_SSH_SECRET,
14
24
  HELD_CA_SECRET,
15
25
  HELD_CONFIGMAP,
16
- HELD_KEY,
17
26
  HELD_MOUNT,
18
- HELD_SECRETS_SECRET,
19
27
  HELD_TLS_SECRET,
28
+ heldManifestKey,
29
+ heldSecretsSecret,
30
+ INSTANCE_HARNESS_HELD_POD,
20
31
  HARNESS_ENV_SECRET,
21
32
  IMAGES_CONFIGMAP,
22
33
  IMAGES_KEY,
@@ -27,18 +38,35 @@ import {
27
38
  INSTANCE_HARNESS_SERVICE,
28
39
  INSTANCE_SECRET,
29
40
  KIT_VERSION,
41
+ NO_DISRUPT_ANNOTATIONS,
30
42
  ORCHESTRATOR_PORT,
31
43
  ORCHESTRATOR_SERVICE,
32
44
  REPO_CACHE,
45
+ PRIORITY_CLASS_CONTROL,
46
+ PRIORITY_CLASS_SANDBOX,
33
47
  REPO_CACHE_HOSTPATH,
48
+ SERVICE_ACCOUNT_CA,
34
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,
35
61
  type HarnessConfig,
36
62
  type HeldManifest,
63
+ type PriorityClasses,
37
64
  type SandboxPlacement,
38
- } from "@jr2/orchestrator";
65
+ } from "@jr2/orchestrator/internal";
39
66
  import type { Pem } from "./held-pki.ts";
40
67
 
41
68
  export {
69
+ INSTANCE_EFFECTOR,
42
70
  GIT_SSH_SECRET,
43
71
  HARNESS_CONFIGMAP,
44
72
  HARNESS_ENV_SECRET,
@@ -50,7 +78,6 @@ export {
50
78
 
51
79
  export const LABEL_INSTANCE = "jr2.dev/instance";
52
80
  export const LABEL_VERSION = "jr2.dev/version";
53
- export const LABEL_HASH = "jr2.dev/content-hash";
54
81
 
55
82
  /** The converged name→ref image map, stamped on the Orchestrator Deployment's OWN metadata so the
56
83
  * next `jr2 up` can diff it and spend no docker on what has not moved (ADR-0038). An ANNOTATION, not
@@ -66,6 +93,20 @@ export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
66
93
  * same reason: a group every container is granted, which the Custodian's values are readable by. */
67
94
  const INSTANCE_HARNESS_FS_GROUP = 2000;
68
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
+
69
110
  /** The two ingress NetworkPolicies (ADR-0058) — see `harnessIngressPolicies`. */
70
111
  export const SANDBOX_INGRESS_POLICY = "jr2-sandbox-ingress";
71
112
  export const INSTANCE_HARNESS_INGRESS_POLICY = "jr2-instance-harness-ingress";
@@ -77,13 +118,21 @@ export const OPERATOR_DEPLOYMENT = "jr2-controller-manager";
77
118
  export const OPERATOR_SELECTOR = "control-plane=controller-manager";
78
119
 
79
120
  /** The rendered operator install manifest shipped inside this package (`just operator-manifest`
80
- * regenerates it from operator/config). The manager image ref is substituted at apply time. */
81
- 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> {
82
125
  const raw = await readFile(fileURLToPath(new URL("../manifests/operator.yaml", import.meta.url)), "utf8");
83
126
  if (!raw.includes("image: controller:latest")) {
84
127
  throw new Error("packaged operator.yaml has no `image: controller:latest` placeholder — regenerate it");
85
128
  }
86
- 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}`);
87
136
  }
88
137
 
89
138
  /** Compare dotted versions: negative when a < b, 0 when equal, positive when a > b. */
@@ -100,30 +149,131 @@ export function compareVersions(a: string, b: string): number {
100
149
  type KubeManifest = Record<string, unknown>;
101
150
 
102
151
  /**
103
- * What `jr2 up` resolved about held secrets (ADR-0059), ready to become objects. Values appear in
104
- * two places only: `values` (the literals, for the `jr2-held-secrets` Secret) and the CA's and the
105
- * leaves' keys — each lands in a Secret no Harness container mounts.
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).
106
156
  */
107
- export type HeldObjects = {
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
+ /** The provider preflight's check container (ADR-0019): one Node.js process, pi-ai, one call at a time. */
167
+ checkContainer: { requests: { cpu: "100m", memory: "512Mi" }, limits: { memory: "512Mi" } },
168
+ } as const;
169
+
170
+ /** A fresh copy, so no manifest shares an object with another (or with the constant). */
171
+ const sized = (r: { requests: Record<string, string>; limits: Record<string, string> }) => ({
172
+ requests: { ...r.requests },
173
+ limits: { ...r.limits },
174
+ });
175
+
176
+ /** The PriorityClass each of jr2's two tiers runs under (ADR-0060): the Instance's own when
177
+ * `priorityClasses` names one, else the one `jr2 up` creates. */
178
+ export function priorityClassNames(classes: PriorityClasses | undefined): { control: string; sandbox: string } {
179
+ return {
180
+ control: classes?.control ?? PRIORITY_CLASS_CONTROL,
181
+ sandbox: classes?.sandbox ?? PRIORITY_CLASS_SANDBOX,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The PriorityClasses `jr2 up` creates, cluster-scoped, beside the CRDs (ADR-0060) — one manifest
187
+ * each, and none for a key `priorityClasses` names, since the cluster then has its own scheme.
188
+ * `jr2-control` preempts lower priorities: the Orchestrator, the operator, the Repo cache agent and the
189
+ * Instance Effector are small and fixed, and the first three are needed by every Sandbox.
190
+ * `jr2-sandbox` never preempts: a waiting Sandbox evicts nobody, and an ordinary pod (priority 0)
191
+ * cannot preempt a live one. Both stay far below the
192
+ * system classes (2000000000+). Shared by every Instance on the cluster, like the operator, so they
193
+ * carry no instance label; a class's value and policy are immutable, and these never change.
194
+ */
195
+ export function priorityClassObjects(classes: PriorityClasses | undefined): string[] {
196
+ const managed = { "app.kubernetes.io/managed-by": "jr2" };
197
+ const pc = (name: string, value: number, preemptionPolicy: string, description: string): string =>
198
+ JSON.stringify({
199
+ apiVersion: "scheduling.k8s.io/v1",
200
+ kind: "PriorityClass",
201
+ metadata: { name, labels: managed },
202
+ value,
203
+ preemptionPolicy,
204
+ globalDefault: false,
205
+ description,
206
+ });
207
+ return [
208
+ ...(classes?.control === undefined
209
+ ? [
210
+ pc(
211
+ PRIORITY_CLASS_CONTROL,
212
+ 100000,
213
+ "PreemptLowerPriority",
214
+ "jr2 control: the Orchestrator, the operator and the Repo cache agent (ADR-0060)",
215
+ ),
216
+ ]
217
+ : []),
218
+ ...(classes?.sandbox === undefined
219
+ ? [
220
+ pc(
221
+ PRIORITY_CLASS_SANDBOX,
222
+ 1000,
223
+ "Never",
224
+ "jr2 Sandboxes and the Instance Harness: never preempted by ordinary pods, never preempting (ADR-0060)",
225
+ ),
226
+ ]
227
+ : []),
228
+ ];
229
+ }
230
+
231
+ /**
232
+ * One Harness pod's held objects (ADR-0059): what `jr2 up` placed in it, ready to become objects.
233
+ * A Workspace's, or the Instance Harness's.
234
+ */
235
+ export type HeldPodObjects = {
236
+ /** The pod key (`INSTANCE_HARNESS_HELD_POD`, `workspaceHeldPod`). */
237
+ pod: string;
108
238
  manifest: HeldManifest;
109
- /** The literal values, by name — never in the manifest, never in a ConfigMap. */
239
+ /** Its literal values, by name — for its `jr2-held-secrets-<pod>` Secret; never in the manifest,
240
+ * never in a ConfigMap. */
110
241
  values: Record<string, string>;
242
+ };
243
+
244
+ /** A Custodian's two rendered files: Envoy's bootstrap (JSON) and the `HELD` table script. */
245
+ export type CustodianFiles = { bootstrap: string; script: string };
246
+
247
+ /**
248
+ * What `jr2 up` resolved about held secrets (ADR-0059), ready to become objects. Values appear in
249
+ * two places only: each pod's `values` (the literals, for its own `jr2-held-secrets-<pod>` Secret)
250
+ * and the CA's and the leaves' keys — each lands in a Secret no Harness container mounts.
251
+ */
252
+ export type HeldObjects = {
253
+ /** Every Harness pod the walk found, each with what its Custodian holds. */
254
+ pods: HeldPodObjects[];
255
+ /** The Instance Harness's Custodian files, when the walk placed it — the one pod `jr2 up` makes,
256
+ * so the one it renders for; a Sandbox's are rendered by the Orchestrator at provision
257
+ * (`kubeSandbox`). */
258
+ instance?: CustodianFiles;
111
259
  /** The per-Instance CA — kept across converges; present whenever a secret is held. */
112
260
  ca?: Pem;
113
- /** One leaf per bound target, by file stem (`leafStem`). */
261
+ /** One leaf per bound target of any pod, by file stem (`leafStem`). */
114
262
  leaves: Record<string, Pem>;
115
- /** The Custodian's rendered bootstrap (JSON) and script. */
116
- bootstrap: string;
117
- script: string;
118
263
  };
119
264
 
265
+ /** Does any pod hold a secret? */
266
+ export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
267
+
120
268
  /** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
121
269
  export function instanceObjects(opts: {
122
270
  name: string;
123
271
  namespace: string;
272
+ /** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
273
+ * Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
124
274
  image: string;
125
- hash: string;
126
- /** Secret data (token, signing key, harness env literals) — written stringData, kube encodes. */
275
+ /** Secret data (the Instance token, the publish token, the signing key) — written stringData,
276
+ * kube encodes. The Orchestrator reads it through `envFrom`. */
127
277
  secretData: Record<string, string>;
128
278
  /** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
129
279
  * doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
@@ -145,8 +295,20 @@ export function instanceObjects(opts: {
145
295
  * resolved operator ref — the same binary is the cache agent (`/manager repo-cache`). Absent, no
146
296
  * DaemonSet and none of its RBAC is applied; `up` deletes a stale one. */
147
297
  repoCache?: { image: string; placement?: SandboxPlacement };
298
+ /** `priorityClasses` from jr2.config.ts (ADR-0060): the classes to name instead of jr2's own. */
299
+ priorityClasses?: PriorityClasses;
300
+ /** Each Workspace's Sandbox Effector (ADR-0068): its manifest rides `jr2-held`, its
301
+ * `jr2-effector-<pod>` Secret holds its literals and `effector.json`. */
302
+ sandboxEffectors?: readonly SandboxEffectorPlan[];
303
+ /** Each `git.credentials` entry's push Secret and the manifest the provision reads (ADR-0051). */
304
+ gitPush?: { secrets: ReadonlyArray<{ name: string; data: Record<string, string> }>; manifest: GitPushManifest };
305
+ /** Each `git.credentials` entry's clone Secret (ADR-0068): the token the cache agent spends. The
306
+ * Orchestrator names it from `git-clone.json` and never holds it. */
307
+ gitClone?: readonly GitCloneSecret[];
148
308
  }): string {
149
309
  const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
310
+ const priority = priorityClassNames(opts.priorityClasses);
311
+ const effectors = (opts.sandboxEffectors ?? []).map((plan) => ({ plan, ...sandboxEffectorObjectsOf(plan) }));
150
312
  const meta = (name: string, extra: Record<string, string> = {}): KubeManifest => ({
151
313
  name,
152
314
  namespace: opts.namespace,
@@ -167,21 +329,27 @@ export function instanceObjects(opts: {
167
329
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
168
330
  {
169
331
  // The orchestrator drives Sandbox CRs (+ their token Secrets) in its own namespace
170
- // (ADR-0012/0013) and creates the Repo CRs the cache agent reconciles (ADR-0051); pod
171
- // exec/port-forward are the attach path (ADR-0004).
332
+ // (ADR-0012/0013), watches them (ADR-0063), and creates the Repo CRs the cache agent
333
+ // reconciles (ADR-0051). Nothing on Pods: it never reads one — the operator publishes the pod
334
+ // facts on the Sandbox's status — and the attach is the Harness's own `POST /attach`, so no
335
+ // `pods/exec` stream crosses the API server (ADR-0063).
172
336
  apiVersion: "rbac.authorization.k8s.io/v1",
173
337
  kind: "Role",
174
338
  metadata: meta(ORCHESTRATOR_SA),
175
339
  rules: [
176
340
  { apiGroups: ["core.jr2.dev"], resources: ["sandboxes", "repos"], verbs: ["*"] },
177
- // patch/update: the token Secret is `kubectl apply`d idempotently and later ownerRef-patched.
178
- {
179
- apiGroups: [""],
180
- resources: ["secrets"],
181
- verbs: ["get", "list", "create", "delete", "patch", "update"],
182
- },
183
- { apiGroups: [""], resources: ["pods", "pods/log"], verbs: ["get", "list", "watch"] },
184
- { apiGroups: [""], resources: ["pods/exec", "pods/portforward"], verbs: ["create"] },
341
+ // Secrets: create and patch, nothing else (ADR-0068). The Orchestrator server-side
342
+ // applies its own per-Sandbox `<sandbox>-token` and `<sandbox>-effector`, owned by the CR,
343
+ // and holds no git credential and no Effect secret (ADR-0068): no get, no list, so it cannot read or enumerate the Effect
344
+ // secrets, the clone and push Secrets, or a user's Secret through the API. RBAC cannot
345
+ // pin `create` to a name, and `resourceNames` has no prefix form.
346
+ { apiGroups: [""], resources: ["secrets"], verbs: ["create", "patch"] },
347
+ // A Sandbox's Custodian files, rendered at provision and server-side applied (ADR-0059);
348
+ // owned by the CR, so the garbage collector deletes them with the pod.
349
+ { apiGroups: [""], resources: ["configmaps"], verbs: ["create", "patch"] },
350
+ // A Sandbox Effector's headless Service, written at provision beside its key (ADR-0068): how
351
+ // this process reaches the pod's own address. Owned by the CR, like the ConfigMap.
352
+ { apiGroups: [""], resources: ["services"], verbs: ["create", "patch"] },
185
353
  ],
186
354
  },
187
355
  {
@@ -200,28 +368,7 @@ export function instanceObjects(opts: {
200
368
  kind: "ConfigMap",
201
369
  metadata: meta(HARNESS_CONFIGMAP),
202
370
  data: {
203
- [HARNESS_CONFIG_KEY]: JSON.stringify(
204
- {
205
- // apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
206
- // it, the Harness's env holds its Stand-in, and a ConfigMap is no place for either.
207
- provider: opts.harness?.provider
208
- ? {
209
- id: opts.harness.provider.id,
210
- api: opts.harness.provider.api,
211
- baseUrl: opts.harness.provider.baseUrl,
212
- // Token limits are model properties, not credentials — they ride the ConfigMap
213
- // so the Harness can register the provider with them.
214
- contextWindow: opts.harness.provider.contextWindow,
215
- maxTokens: opts.harness.provider.maxTokens,
216
- models: opts.harness.provider.models,
217
- }
218
- : undefined,
219
- // Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
220
- catalog: opts.harness?.catalog,
221
- },
222
- null,
223
- 2,
224
- ),
371
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
225
372
  },
226
373
  },
227
374
  {
@@ -235,20 +382,77 @@ export function instanceObjects(opts: {
235
382
  metadata: meta(IMAGES_CONFIGMAP),
236
383
  data: { [IMAGES_KEY]: imagesJson },
237
384
  },
238
- // Held secrets (ADR-0059). `held.json` is what every composition reads — the Orchestrator per
239
- // provision from the mount below, `jr2 up` for the Instance Harness — beside the Custodian's
240
- // bootstrap and script, which Envoy reads at start. Names, hosts, headers: never a value.
385
+ // Held secrets (ADR-0059): what `jr2 up` DECLARES. A pod's `held-<pod>.json` is what its
386
+ // composition reads — the Orchestrator per provision from the mount below, `jr2 up` for the
387
+ // Instance Harness — and the Custodian's program is here once. Envoy's per-pod files are
388
+ // rendered by whoever makes the pod, never collected here: a shared object stops at 1 MiB.
389
+ // Names, hosts, headers: never a value.
241
390
  {
242
391
  apiVersion: "v1",
243
392
  kind: "ConfigMap",
244
393
  metadata: meta(HELD_CONFIGMAP),
245
394
  data: {
246
- [HELD_KEY]: JSON.stringify(opts.held.manifest, null, 2),
247
- [CUSTODIAN_BOOTSTRAP_KEY]: opts.held.bootstrap,
248
- [CUSTODIAN_SCRIPT_KEY]: opts.held.script,
395
+ ...Object.fromEntries(opts.held.pods.map((p) => [heldManifestKey(p.pod), JSON.stringify(p.manifest, null, 2)])),
396
+ [CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
397
+ // Each Sandbox Effector's manifest (ADR-0068): the sources of its secrets mount, by name.
398
+ // The provision adds the container only for a pod whose manifest is here.
399
+ ...Object.fromEntries(
400
+ effectors.map((e) => [effectorManifestKey(e.plan.pod), JSON.stringify(e.manifest, null, 2)]),
401
+ ),
402
+ // The push manifest (ADR-0051): which entries have a push Secret, and what each can spend.
403
+ // Names only; the provision names the matched entry's Secret on the Sandbox CR.
404
+ ...(opts.gitPush?.manifest.entries.length
405
+ ? { [GIT_PUSH_MANIFEST_KEY]: JSON.stringify(opts.gitPush.manifest, null, 2) }
406
+ : {}),
407
+ // The clone manifest (ADR-0068): which entries' clone Secrets exist, by name. The Orchestrator
408
+ // names one as a Repo's `secretRef` only when it is listed here.
409
+ ...(opts.gitClone?.length
410
+ ? {
411
+ [GIT_CLONE_MANIFEST_KEY]: JSON.stringify(
412
+ { version: 1, secrets: opts.gitClone.map((s) => s.name) },
413
+ null,
414
+ 2,
415
+ ),
416
+ }
417
+ : {}),
249
418
  },
250
419
  },
420
+ // Each entry's clone Secret (ADR-0068), in Flux's key names: the operator's cache agent reads the
421
+ // one a Repo's `secretRef` names. The Orchestrator neither writes nor reads it.
422
+ ...(opts.gitClone ?? []).map(
423
+ (s): KubeManifest => ({
424
+ apiVersion: "v1",
425
+ kind: "Secret",
426
+ metadata: meta(s.name, { [LABEL_GIT_CLONE]: "true" }),
427
+ type: "Opaque",
428
+ stringData: s.data,
429
+ }),
430
+ ),
431
+ // Each entry's push Secret (ADR-0051): what it pushes and signs with, in Flux's key names where
432
+ // Flux has them. The operator projects one into the Sandbox Effector of a Sandbox whose push
433
+ // the entry's match covers; nothing else mounts it.
434
+ ...(opts.gitPush?.secrets ?? []).map(
435
+ (s): KubeManifest => ({
436
+ apiVersion: "v1",
437
+ kind: "Secret",
438
+ metadata: meta(s.name, { [LABEL_GIT_PUSH]: "true" }),
439
+ type: "Opaque",
440
+ data: s.data,
441
+ }),
442
+ ),
251
443
  ...heldSecretObjects(opts.held, meta),
444
+ // Each Sandbox Effector's own Secret (ADR-0068): the literals its Effects name and
445
+ // `effector.json`. A `valueFrom` stays in the user's Secret, projected by reference at
446
+ // provision.
447
+ ...effectors.map(
448
+ (e): KubeManifest => ({
449
+ apiVersion: "v1",
450
+ kind: "Secret",
451
+ metadata: meta(e.secret.name, { [LABEL_EFFECTOR_POD]: e.plan.pod }),
452
+ type: "Opaque",
453
+ stringData: e.secret.stringData,
454
+ }),
455
+ ),
252
456
  {
253
457
  apiVersion: "v1",
254
458
  kind: "Secret",
@@ -270,7 +474,7 @@ export function instanceObjects(opts: {
270
474
  apiVersion: "apps/v1",
271
475
  kind: "Deployment",
272
476
  metadata: {
273
- ...meta(ORCHESTRATOR_SERVICE, { [LABEL_HASH]: opts.hash, [LABEL_VERSION]: KIT_VERSION }),
477
+ ...meta(ORCHESTRATOR_SERVICE, { [LABEL_VERSION]: KIT_VERSION }),
274
478
  // The converged image map, on the DEPLOYMENT'S OWN metadata and never on
275
479
  // `spec.template.metadata` (ADR-0038). On the pod template it would be part of the pod
276
480
  // spec, so every re-resolved ref would roll the Orchestrator — the exact cost the
@@ -279,13 +483,22 @@ export function instanceObjects(opts: {
279
483
  annotations: { [ANNOTATION_IMAGES]: imagesJson },
280
484
  },
281
485
  spec: {
282
- replicas: 1, // single WRITER (CONTEXT.md): the snapshot store brooks no split-brain
486
+ replicas: 1, // single WRITER (GLOSSARY.md): the snapshot store brooks no split-brain
283
487
  strategy: { type: "Recreate" }, // two writers may never overlap on the PVC
284
488
  selector: { matchLabels: { app: ORCHESTRATOR_SERVICE } },
285
489
  template: {
286
- metadata: { labels: { ...labels, app: ORCHESTRATOR_SERVICE } },
490
+ metadata: {
491
+ labels: { ...labels, app: ORCHESTRATOR_SERVICE },
492
+ // The Instance Secret reaches the process through `envFrom`, which a running pod reads
493
+ // once. A minted replacement for a lost Secret (a new publish token above all,
494
+ // ADR-0069) must roll the pod, or every publish after it is refused with 401.
495
+ annotations: { [ANNOTATION_SECRET_DIGEST]: instanceSecretDigest(opts.secretData) },
496
+ },
287
497
  spec: {
288
498
  serviceAccountName: ORCHESTRATOR_SA,
499
+ // Control tier (ADR-0060): small, fixed, needed by every Sandbox. No disruption opt-out:
500
+ // a restarted Orchestrator re-attaches (ADR-0007).
501
+ priorityClassName: priority.control,
289
502
  containers: [
290
503
  {
291
504
  name: "orchestrator",
@@ -296,23 +509,26 @@ export function instanceObjects(opts: {
296
509
  env: [
297
510
  // The entrypoint derives its own Service DNS + Sandbox namespace from these.
298
511
  { name: "JR2_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } },
299
- // What `/healthz` reports as this instance's identity. The same content address
300
- // the image tag carries (ADR-0019), in-process so a CLI can ask over HTTP
301
- // instead of needing kube access to read the Deployment's labels.
302
- { name: "JR2_CONTENT_HASH", value: opts.hash },
512
+ // The API server's trust (ADR-0063): the Orchestrator's own Kubernetes client is
513
+ // built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
514
+ // else — no TLS code, no dependency.
515
+ { name: "NODE_EXTRA_CA_CERTS", value: SERVICE_ACCOUNT_CA },
303
516
  ],
304
517
  // The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
305
518
  // agent on each node does, reading the credential Secret a Repo's `secretRef`
306
- // names — so nothing of git's is mounted here: state and the image map only.
519
+ // names, which `jr2 up` wrote (ADR-0068) — so nothing of git's is mounted here and no
520
+ // git token is in this container's env: state, the image map and the manifests.
307
521
  volumeMounts: [
308
522
  { name: "state", mountPath: "/instance/.jr2" },
309
523
  // The image map, read per provision (ADR-0038). A mount, so `jr2 up` rewriting
310
524
  // it costs one kubelet propagation window instead of a rollout.
311
525
  { name: "images", mountPath: IMAGES_MOUNT, readOnly: true },
312
- // What the Custodian holds, read per provision the same way (ADR-0059): names
313
- // and hosts, which this process could not derive — its `.env` values are absent.
526
+ // What each Workspace's Custodian holds, read per provision the same way
527
+ // (ADR-0059): names and hosts, which this process could not derive — its `.env`
528
+ // values are absent.
314
529
  { name: "held", mountPath: HELD_MOUNT, readOnly: true },
315
530
  ],
531
+ resources: sized(KIT_POD_SIZES.orchestrator),
316
532
  // The period, not the boot, is what `up`'s rollout wait measures. Measured: the
317
533
  // container answers `/healthz` 1.1s after it starts, and the default 10s period
318
534
  // billed that as 11.0s — one probe fired before the server was up, then a whole
@@ -321,7 +537,7 @@ export function instanceObjects(opts: {
321
537
  // `failureThreshold` is then raised to hold the tolerance the default period gave,
322
538
  // because ONE knob sets two unrelated things: how fast a boot is noticed, and how
323
539
  // long a running pod may stall before the kubelet takes it out of service. The
324
- // Orchestrator is `replicas: 1` (CONTEXT.md: single writer), so its Service has
540
+ // Orchestrator is `replicas: 1` (GLOSSARY.md: single writer), so its Service has
325
541
  // exactly one endpoint and losing it is an outage, not a failover — and its store
326
542
  // writes are synchronous, so a GC pause or a node busy with a docker build can
327
543
  // silence `/healthz` for seconds. 15 × 2s keeps the 30s the default 3 × 10s gave.
@@ -331,12 +547,23 @@ export function instanceObjects(opts: {
331
547
  periodSeconds: 2,
332
548
  failureThreshold: 15,
333
549
  },
550
+ // Liveness restarts, which readiness never does (ADR-0008). A restart of this
551
+ // `replicas: 1` writer is an outage plus a restore, so it fires only on a process
552
+ // that has answered nothing for five minutes (30 × 10s): silent that long is dead,
553
+ // not stalled, and without it the pod sits NotReady until a human deletes it. A
554
+ // process that EXITS needs no probe — the restart policy already has it. The same
555
+ // five minutes cover a boot's restore, so there is no initial delay.
556
+ livenessProbe: {
557
+ httpGet: { path: "/healthz", port: ORCHESTRATOR_PORT },
558
+ periodSeconds: 10,
559
+ failureThreshold: 30,
560
+ },
334
561
  },
335
562
  ],
336
563
  volumes: [
337
564
  { name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
338
565
  { name: "images", configMap: { name: IMAGES_CONFIGMAP } },
339
- { name: "held", configMap: { name: HELD_CONFIGMAP, items: [{ key: HELD_KEY, path: HELD_KEY }] } },
566
+ { name: "held", configMap: { name: HELD_CONFIGMAP } },
340
567
  ],
341
568
  },
342
569
  },
@@ -352,7 +579,15 @@ export function instanceObjects(opts: {
352
579
  },
353
580
  },
354
581
  ...harnessIngressPolicies(opts.name, meta),
355
- ...(opts.repoCache ? repoCacheObjects({ ...opts.repoCache, namespace: opts.namespace, labels, meta }) : []),
582
+ ...(opts.repoCache
583
+ ? repoCacheObjects({
584
+ ...opts.repoCache,
585
+ namespace: opts.namespace,
586
+ labels,
587
+ meta,
588
+ priorityClassName: priority.control,
589
+ })
590
+ : []),
356
591
  ];
357
592
 
358
593
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
@@ -425,8 +660,10 @@ function repoCacheObjects(opts: {
425
660
  namespace: string;
426
661
  labels: Record<string, string>;
427
662
  meta: (name: string, extra?: Record<string, string>) => KubeManifest;
663
+ /** The control tier's class (ADR-0060): every Sandbox on the node needs this agent. */
664
+ priorityClassName: string;
428
665
  }): KubeManifest[] {
429
- const { image, namespace, labels, meta, placement } = opts;
666
+ const { image, namespace, labels, meta, placement, priorityClassName } = opts;
430
667
  return [
431
668
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(REPO_CACHE) },
432
669
  {
@@ -463,6 +700,7 @@ function repoCacheObjects(opts: {
463
700
  spec: {
464
701
  serviceAccountName: REPO_CACHE,
465
702
  automountServiceAccountToken: true,
703
+ priorityClassName,
466
704
  // One agent per Sandbox node (ADR-0052): the pod's own placement, verbatim. A node no
467
705
  // Sandbox can reach gets no agent — a cache there is a clone nobody reads, and an
468
706
  // affinity term the scheduler cannot honor. (The DaemonSet controller still adds its
@@ -487,7 +725,8 @@ function repoCacheObjects(opts: {
487
725
  { name: "home", mountPath: REPO_CACHE_HOME },
488
726
  { name: "tmp", mountPath: "/tmp" },
489
727
  ],
490
- resources: { requests: { cpu: "20m", memory: "64Mi" } },
728
+ // Kit-sized (ADR-0060): the one request below its limit — `git index-pack` spikes.
729
+ resources: sized(KIT_POD_SIZES.repoCache),
491
730
  securityContext: {
492
731
  runAsUser: 0,
493
732
  runAsGroup: 0,
@@ -513,20 +752,27 @@ function repoCacheObjects(opts: {
513
752
  ];
514
753
  }
515
754
 
755
+ /** The label on each pod's `jr2-held-secrets-<pod>` Secret: its pod, so `jr2 up` finds and deletes
756
+ * the one a pod no longer needs. */
757
+ export const LABEL_HELD_POD = "jr2.dev/held-pod";
758
+
516
759
  /**
517
760
  * The held-secret Secrets (ADR-0059), present only while a secret is held: the CA (which NO pod
518
- * mounts — `jr2 up` alone reads it back), the literal values and the leaves (the Custodian's alone).
519
- * `stringData` as written: no trailing newline is added to a value.
761
+ * mounts — `jr2 up` alone reads it back), each pod's literal values (its Custodian's alone) and the
762
+ * leaves (the Custodians'). `stringData` as written: no trailing newline is added to a value.
520
763
  */
521
764
  function heldSecretObjects(held: HeldObjects, meta: (name: string) => KubeManifest): KubeManifest[] {
522
- if (held.manifest.secrets.length === 0 || !held.ca) return [];
523
- const secret = (name: string, stringData: Record<string, string>): KubeManifest => ({
524
- apiVersion: "v1",
525
- kind: "Secret",
526
- metadata: meta(name),
527
- type: "Opaque",
528
- stringData,
529
- });
765
+ if (!holdsAny(held) || !held.ca) return [];
766
+ const secret = (name: string, stringData: Record<string, string>, pod?: string): KubeManifest => {
767
+ const m = meta(name);
768
+ return {
769
+ apiVersion: "v1",
770
+ kind: "Secret",
771
+ metadata: pod === undefined ? m : { ...m, labels: { ...(m.labels as object), [LABEL_HELD_POD]: pod } },
772
+ type: "Opaque",
773
+ stringData,
774
+ };
775
+ };
530
776
  const leaves: Record<string, string> = {};
531
777
  for (const [stem, pem] of Object.entries(held.leaves)) {
532
778
  leaves[`${stem}.crt`] = pem.cert;
@@ -534,7 +780,9 @@ function heldSecretObjects(held: HeldObjects, meta: (name: string) => KubeManife
534
780
  }
535
781
  return [
536
782
  secret(HELD_CA_SECRET, { "ca.crt": held.ca.cert, "ca.key": held.ca.key }),
537
- ...(Object.keys(held.values).length ? [secret(HELD_SECRETS_SECRET, held.values)] : []),
783
+ ...held.pods.flatMap((p) =>
784
+ Object.keys(p.values).length ? [secret(heldSecretsSecret(p.pod), p.values, p.pod)] : [],
785
+ ),
538
786
  secret(HELD_TLS_SECRET, leaves),
539
787
  ];
540
788
  }
@@ -570,7 +818,7 @@ export function trustObject(opts: {
570
818
  }
571
819
 
572
820
  /**
573
- * The Instance Harness (ADR-0031): the per-instance Harness Deployment + Service `jr2 up`
821
+ * The Instance Harness (ADR-0031): the per-instance Harness StatefulSet + Service `jr2 up`
574
822
  * converges whenever an Agent a registered Machine CARRIES declares `workspace: "none"`
575
823
  * (ADR-0049's walk) — the placement for every Menu-only Agent's Turn, regardless of any enclosing
576
824
  * Workspace. The one Harness shape, minus the Workspace: the stock Harness image plus the Custodian,
@@ -590,15 +838,18 @@ export function instanceHarnessObjects(opts: {
590
838
  *
591
839
  * The accepted asymmetry: the Instance Harness names its images HERE, in the pod template, while
592
840
  * a Sandbox's refs travel through the `jr2-images` ConfigMap. Both are right for what they are —
593
- * this Deployment is supposed to roll when its image moves; the Orchestrator is not. */
841
+ * this StatefulSet is supposed to roll when its image moves; the Orchestrator is not. */
594
842
  harnessImage: string;
595
843
  /** The resolved Custodian ref: the Harness's one route to its Menu, kept even though a `"none"`
596
844
  * Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
597
845
  custodianImage: string;
598
- /** What this converge resolved about held secrets (ADR-0059) — the same manifest the
599
- * Orchestrator composes every Sandbox's Custodian from. */
846
+ /** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
847
+ * keys its `"none"` Agents' providers read, and nothing else. */
600
848
  held: HeldManifest;
601
- /** An HMAC over every held-secret input (`held.json`, the leaves, the literal values, the
849
+ /** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
850
+ * (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
851
+ custodian: CustodianFiles;
852
+ /** An HMAC over this pod's held-secret inputs (its manifest, its leaves, its literal values, its
602
853
  * Custodian's config), keyed with the signing key so it reveals nothing — on the pod template, so
603
854
  * a change to any of them rolls this pod (ADR-0059). */
604
855
  heldDigest: string;
@@ -610,6 +861,8 @@ export function instanceHarnessObjects(opts: {
610
861
  * every Sandbox Harness container gets, so this pod can verify the Orchestrator and mint
611
862
  * nothing. */
612
863
  bearerSha256: string;
864
+ /** `priorityClasses` from jr2.config.ts (ADR-0060): the classes to name instead of jr2's own. */
865
+ priorityClasses?: PriorityClasses;
613
866
  }): string {
614
867
  const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
615
868
  const meta = (): KubeManifest => ({
@@ -629,12 +882,15 @@ export function instanceHarnessObjects(opts: {
629
882
 
630
883
  // The Custodian (ADR-0013, ADR-0059): the placement's Sandbox token — signed for this
631
884
  // placement's name (`up.ts` mints it into the instance Secret), so it speaks only for the Turns
632
- // hosted HERE (tokens.ts) — plus every held secret. Mounted into the Custodian alone; the Instance
633
- // token itself never enters this pod.
885
+ // hosted HERE (tokens.ts) — plus the held secrets placed here. Mounted into the Custodian alone;
886
+ // the Instance token itself never enters this pod.
634
887
  const custodian = custodianComposition(opts.held, {
635
888
  image: opts.custodianImage,
889
+ pod: INSTANCE_HARNESS_HELD_POD,
890
+ config: INSTANCE_CUSTODIAN_CONFIGMAP,
636
891
  token: { secret: INSTANCE_SECRET, key: "JR2_INSTANCE_HARNESS_TOKEN" },
637
892
  caBundle: opts.caBundle === true,
893
+ resources: sized(KIT_POD_SIZES.instanceCustodian),
638
894
  });
639
895
 
640
896
  const harnessContainer = {
@@ -657,47 +913,90 @@ export function instanceHarnessObjects(opts: {
657
913
  { name: "JR2_MENU_ONLY", value: "1" },
658
914
  ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
659
915
  ...custodian.harnessEnv,
916
+ // Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
917
+ { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
660
918
  // The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
661
919
  { name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
662
920
  ],
663
921
  envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
664
- ...(custodian.harnessMounts.length ? { volumeMounts: custodian.harnessMounts } : {}),
922
+ // The Harness container's alone: the Custodian has no business with an Agent's history.
923
+ volumeMounts: [{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR }, ...custodian.harnessMounts],
665
924
  // The operator probes a Sandbox's Harness the same way: serving = the socket accepts.
666
925
  // Period and threshold as reasoned on the Orchestrator above: the default 10s period is the
667
926
  // rollout wait rather than the boot, and the threshold then has to carry the stall tolerance
668
927
  // the period used to supply. This pod is single-replica too.
928
+ // The Harness's own route, not the port (ADR-0063): a socket probe would pass for whatever
929
+ // listens there. A draining Harness still answers it (ADR-0031).
669
930
  readinessProbe: {
670
- tcpSocket: { port: INSTANCE_HARNESS_PORT },
931
+ httpGet: { path: "/healthz", port: INSTANCE_HARNESS_PORT },
671
932
  initialDelaySeconds: 1,
672
933
  periodSeconds: 2,
673
934
  failureThreshold: 15,
674
935
  },
936
+ resources: sized(KIT_POD_SIZES.instanceHarness),
675
937
  securityContext: hardenedContainerSecurityContext(),
676
938
  };
677
939
 
678
940
  const items: KubeManifest[] = [
941
+ // The one pod `jr2 up` makes, so the one whose Custodian files it renders: its bootstrap and
942
+ // its HELD table, from its own manifest, beside the program its volume projects from `jr2-held`.
943
+ {
944
+ apiVersion: "v1",
945
+ kind: "ConfigMap",
946
+ metadata: { ...meta(), name: INSTANCE_CUSTODIAN_CONFIGMAP },
947
+ data: {
948
+ [CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
949
+ [CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
950
+ },
951
+ },
679
952
  {
680
953
  apiVersion: "apps/v1",
681
- kind: "Deployment",
954
+ kind: "StatefulSet",
682
955
  metadata: { ...meta(), labels: { ...labels, [LABEL_VERSION]: KIT_VERSION } },
683
956
  spec: {
684
- // ONE replica, Recreate: a conversation is an Instance ID on one Harness PROCESS
685
- // (ADR-0031) — two pods behind this Service would route one conversation to two servers,
686
- // the exact amnesia definition-wins placement exists to prevent. A restart loses the
687
- // conversations (live-only, ADR-0023); the Deployment restores the endpoint, not the
688
- // history.
957
+ // A StatefulSet of ONE (ADR-0031): the value is the stable volume — a restart or a rollout
958
+ // brings the same conversations back — and the stable name is what N > 1 would pin a
959
+ // conversation to. Never more than one pod behind this Service for one conversation, and
960
+ // never an HPA: a scale-down would kill conversations. At N = 1 a rollout has no surge,
961
+ // so admissions are refused while the new pod starts, which the Orchestrator absorbs
962
+ // (ADR-0042).
689
963
  replicas: 1,
690
- strategy: { type: "Recreate" },
964
+ serviceName: INSTANCE_HARNESS_PODS_SERVICE,
691
965
  selector: { matchLabels: { app: INSTANCE_HARNESS_SERVICE } },
966
+ // The conversations go with the StatefulSet, never with a scale-down: an instance whose
967
+ // Machines dropped every `"none"` Agent keeps no history for them.
968
+ persistentVolumeClaimRetentionPolicy: { whenDeleted: "Delete", whenScaled: "Retain" },
969
+ volumeClaimTemplates: [
970
+ {
971
+ metadata: { name: CONVERSATIONS_VOLUME, labels },
972
+ spec: {
973
+ accessModes: ["ReadWriteOnce"],
974
+ resources: { requests: { storage: "1Gi" } },
975
+ },
976
+ },
977
+ ],
692
978
  template: {
693
979
  metadata: {
694
980
  labels: { ...labels, app: INSTANCE_HARNESS_SERVICE },
695
981
  // A held-secret edit rolls this pod; a live Sandbox keeps what its Custodian read at
696
- // start (ADR-0059, the ADR-0037 stance).
697
- annotations: { [ANNOTATION_HELD_DIGEST]: opts.heldDigest },
982
+ // start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
983
+ // (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
984
+ // can.
985
+ annotations: { [ANNOTATION_HELD_DIGEST]: opts.heldDigest, ...NO_DISRUPT_ANNOTATIONS },
698
986
  },
699
987
  spec: {
700
- containers: [harnessContainer, custodian.custodianContainer],
988
+ // The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
989
+ // it holds, so the grace is a Turn's worst case — a deploy loses no Turn.
990
+ terminationGracePeriodSeconds: INSTANCE_HARNESS_DRAIN_SECONDS,
991
+ // The Sandbox tier's class (ADR-0060): it holds live conversations, as a Sandbox holds
992
+ // `/work`, and it may wait rather than preempt.
993
+ priorityClassName: priorityClassNames(opts.priorityClasses).sandbox,
994
+ // The Custodian is a native sidecar, so it outlives the drain (ADR-0031): the kubelet
995
+ // stops a pod's sidecars only after its main containers exit, and the draining Harness
996
+ // needs its Custodian to the end — its one route to the Menu and to the model. As a
997
+ // plain container Envoy took SIGTERM with the Harness and quit at once.
998
+ initContainers: [{ ...custodian.custodianContainer, restartPolicy: "Always" }],
999
+ containers: [harnessContainer],
701
1000
  // The operator's isolation baseline (sandbox_controller.go), mirrored: same Harness
702
1001
  // image, same "never reach the Kubernetes API" north star — JR2_MENU_ONLY makes code
703
1002
  // execution here unlikely, not unimaginable. No `shareProcessNamespace`: the Harness
@@ -719,6 +1018,22 @@ export function instanceHarnessObjects(opts: {
719
1018
  kind: "Service",
720
1019
  metadata: meta(),
721
1020
  spec: {
1021
+ // The address the Agent actor dials: a ClusterIP Service, deliberately not the headless
1022
+ // one below. A draining pod is terminating, so DNS stops naming it, but kube-proxy still
1023
+ // routes a ClusterIP to a terminating pod that serves while no other is ready — and at
1024
+ // N = 1 none is until it exits. So during the drain reads, waits and aborts keep reaching
1025
+ // it (ADR-0031), and a refused admission meets its 503, not a name that resolves to nothing.
1026
+ selector: { app: INSTANCE_HARNESS_SERVICE },
1027
+ ports: [{ port: INSTANCE_HARNESS_PORT, targetPort: INSTANCE_HARNESS_PORT }],
1028
+ },
1029
+ },
1030
+ {
1031
+ apiVersion: "v1",
1032
+ kind: "Service",
1033
+ metadata: { ...meta(), name: INSTANCE_HARNESS_PODS_SERVICE },
1034
+ spec: {
1035
+ // Headless — the StatefulSet's governing Service (`serviceName`), for the per-ordinal names.
1036
+ clusterIP: "None",
722
1037
  selector: { app: INSTANCE_HARNESS_SERVICE },
723
1038
  ports: [{ port: INSTANCE_HARNESS_PORT, targetPort: INSTANCE_HARNESS_PORT }],
724
1039
  },
@@ -727,3 +1042,467 @@ export function instanceHarnessObjects(opts: {
727
1042
 
728
1043
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
729
1044
  }
1045
+
1046
+ /** What the Instance can REACH (ADR-0018), as the `jr2-harness` ConfigMap carries it to every
1047
+ * Harness container: the custom provider without its key and the catalog gateways. The check pod
1048
+ * carries the same text in its own ConfigMap (ADR-0019). */
1049
+ export function harnessConfigJson(harness: HarnessConfig | undefined): string {
1050
+ return JSON.stringify(
1051
+ {
1052
+ // apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
1053
+ // it, the Harness's env holds its Stand-in, and a ConfigMap is no place for either.
1054
+ provider: harness?.provider
1055
+ ? {
1056
+ id: harness.provider.id,
1057
+ api: harness.provider.api,
1058
+ baseUrl: harness.provider.baseUrl,
1059
+ // Token limits are model properties, not credentials — they ride the ConfigMap
1060
+ // so the Harness can register the provider with them.
1061
+ contextWindow: harness.provider.contextWindow,
1062
+ maxTokens: harness.provider.maxTokens,
1063
+ models: harness.provider.models,
1064
+ }
1065
+ : undefined,
1066
+ // Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
1067
+ catalog: harness?.catalog,
1068
+ },
1069
+ null,
1070
+ 2,
1071
+ );
1072
+ }
1073
+
1074
+ /** The provider preflight's objects (ADR-0019): a check pod and its own copies of what it reads,
1075
+ * each named `jr2-provider-preflight-<run>-<n>` or that name and a suffix, unique to one `jr2 up`. */
1076
+ export const PROVIDER_PREFLIGHT = "jr2-provider-preflight";
1077
+ /** The label every one of them carries, its value the `jr2 up` run: how a later run finds the
1078
+ * leftovers of a run that did not end, and leaves another run's objects alone. */
1079
+ export const LABEL_PROVIDER_PREFLIGHT = "jr2.dev/provider-preflight";
1080
+ /** The check container in that pod, whose output `jr2 up` reads. An older `jr2 up` named it `probe`. */
1081
+ export const CHECK_CONTAINER = "check";
1082
+
1083
+ /** Every object of one check pod (`checkPodObjects`), for its deletion. */
1084
+ export function checkPodObjectNames(object: string): Array<{ kind: string; name: string }> {
1085
+ return [
1086
+ { kind: "pod", name: object },
1087
+ { kind: "configmap", name: object },
1088
+ { kind: "configmap", name: `${object}-ca` },
1089
+ { kind: "secret", name: object },
1090
+ { kind: "secret", name: `${object}-tls` },
1091
+ { kind: "secret", name: `${object}-env` },
1092
+ ];
1093
+ }
1094
+
1095
+ /**
1096
+ * One check pod of the provider preflight (ADR-0019, ADR-0059): a Harness pod with no Harness in
1097
+ * it, and its own copies of every object a Harness pod reads. So a check pod writes nothing that a
1098
+ * running pod of the Instance reads, and a check that fails leaves the Instance as it was.
1099
+ *
1100
+ * - `<object>` (ConfigMap): the Custodian's bootstrap and script, the program and the harness config
1101
+ * (`harnessConfigJson`).
1102
+ * - `<object>-ca` (ConfigMap): the trust bundles, as `jr2-ca` would carry them.
1103
+ * - `<object>` (Secret): the Custodian's token, a value the Orchestrator never signed (the check pod
1104
+ * reads no Menu), and the LITERAL values of the keys it holds, from the host. A `valueFrom` key
1105
+ * stays a reference to the user's Secret. The values are in this Secret for the check pod's life.
1106
+ * - `<object>-tls` (Secret): the leaves of the bound hosts. `<object>-env` (Secret): the literal
1107
+ * `harness.env` values, which `jr2-harness-env` would carry.
1108
+ *
1109
+ * The Custodian is composed by the function every Harness pod's is (`custodianComposition`). With
1110
+ * no binding, it holds nothing and the check pod's HTTPS goes direct, as in a Harness pod that holds no
1111
+ * secret. The check container runs the stock Harness image's `provider-preflight.ts` with the Harness
1112
+ * container's env, so the call is the one a Turn makes, and the key never enters that container,
1113
+ * its argv or its log. The argv is the model specifiers alone.
1114
+ *
1115
+ * The pod has the placement and the priority class of the Harness pods it stands for (ADR-0052,
1116
+ * ADR-0060). Its labels are its own and match no Harness pod's: the Instance Harness Service
1117
+ * selects by label, and would send Turns to it. The Custodian is a native sidecar with a startup
1118
+ * probe, so the check starts only once Envoy serves and the pod ends when the check does.
1119
+ */
1120
+ export function checkPodObjects(opts: {
1121
+ name: string;
1122
+ namespace: string;
1123
+ /** This check pod's object name (`jr2-provider-preflight-<run>-<n>`) and its run, for the label. */
1124
+ object: string;
1125
+ run: string;
1126
+ /** The resolved stock Harness ref: the image a Turn's Harness runs, with pi-ai at the pin. */
1127
+ harnessImage: string;
1128
+ custodianImage: string;
1129
+ /** The bindings of the keys this check pod holds: each a part of a held pod's manifest. */
1130
+ held: HeldManifest;
1131
+ /** The literal values of those keys, by name, from the host. */
1132
+ values: Record<string, string>;
1133
+ /** The leaves of their bound hosts, by file stem. */
1134
+ leaves: Record<string, Pem>;
1135
+ /** The trust bundles (`ca.crt`, `extra.crt`, `bundle.crt`, `upstream.crt`), as `jr2-ca` has them. */
1136
+ trust: Record<string, string>;
1137
+ /** The Custodian's two files, rendered from `held`. */
1138
+ custodian: CustodianFiles;
1139
+ token: string;
1140
+ /** The literal `harness.env` values. */
1141
+ harnessEnvData: Record<string, string>;
1142
+ /** The `<provider>/<model>` specifiers to check, in order. */
1143
+ models: readonly string[];
1144
+ /** The node predicate of the Harness pods this check pod stands for. */
1145
+ placement?: SandboxPlacement;
1146
+ priorityClassName: string;
1147
+ harness?: HarnessConfig;
1148
+ caBundle?: boolean;
1149
+ /** The pod's whole budget: the kubelet ends it at this deadline, even when `jr2 up` is gone. */
1150
+ deadlineSeconds: number;
1151
+ }): string {
1152
+ const labels = {
1153
+ [LABEL_INSTANCE]: opts.name,
1154
+ "app.kubernetes.io/managed-by": "jr2",
1155
+ [LABEL_PROVIDER_PREFLIGHT]: opts.run,
1156
+ };
1157
+ const meta = (name: string) => ({ name, namespace: opts.namespace, labels });
1158
+ const o = opts.object;
1159
+ const custodian = custodianComposition(opts.held, {
1160
+ image: opts.custodianImage,
1161
+ pod: o,
1162
+ reads: { literals: o, tls: `${o}-tls`, trust: `${o}-ca`, program: o },
1163
+ config: o,
1164
+ token: { secret: o, key: "token" },
1165
+ caBundle: opts.caBundle === true,
1166
+ resources: sized(KIT_POD_SIZES.instanceCustodian),
1167
+ });
1168
+ const ready = custodian.custodianContainer.readinessProbe as { httpGet: unknown };
1169
+ const pod = {
1170
+ apiVersion: "v1",
1171
+ kind: "Pod",
1172
+ metadata: { ...meta(o), labels: { ...labels, app: PROVIDER_PREFLIGHT } },
1173
+ spec: {
1174
+ restartPolicy: "Never",
1175
+ activeDeadlineSeconds: opts.deadlineSeconds,
1176
+ automountServiceAccountToken: false,
1177
+ priorityClassName: opts.priorityClassName,
1178
+ ...(opts.placement?.nodeSelector ? { nodeSelector: opts.placement.nodeSelector } : {}),
1179
+ ...(opts.placement?.tolerations?.length ? { tolerations: opts.placement.tolerations } : {}),
1180
+ // The Instance Harness pod's baseline, and its `fsGroup` for the same reason: the
1181
+ // Custodian's values are group-readable, and Envoy runs as its own uid.
1182
+ securityContext: {
1183
+ runAsNonRoot: true,
1184
+ seccompProfile: { type: "RuntimeDefault" },
1185
+ fsGroup: INSTANCE_HARNESS_FS_GROUP,
1186
+ },
1187
+ initContainers: [
1188
+ {
1189
+ ...custodian.custodianContainer,
1190
+ restartPolicy: "Always",
1191
+ startupProbe: { httpGet: ready.httpGet, periodSeconds: 1, failureThreshold: 60 },
1192
+ },
1193
+ ],
1194
+ containers: [
1195
+ {
1196
+ name: CHECK_CONTAINER,
1197
+ image: opts.harnessImage,
1198
+ imagePullPolicy: "IfNotPresent",
1199
+ // Under tini, as the Harness runs (ADR-0061): PID 1 reaps, and forwards a stop.
1200
+ command: [
1201
+ "/opt/jr2/bin/tini",
1202
+ "--",
1203
+ "/opt/jr2/bin/node",
1204
+ "/opt/jr2/src/provider-preflight.ts",
1205
+ ...opts.models,
1206
+ ],
1207
+ // The Harness container's env, in its order (`instanceHarnessObjects`): no credential.
1208
+ env: [
1209
+ { name: "JR2_HARNESS_JSON", valueFrom: { configMapKeyRef: { name: o, key: HARNESS_CONFIG_KEY } } },
1210
+ ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
1211
+ ...custodian.harnessEnv,
1212
+ ],
1213
+ envFrom: [{ secretRef: { name: `${o}-env` } }, ...(opts.harness?.envFrom ?? [])],
1214
+ volumeMounts: custodian.harnessMounts,
1215
+ resources: sized(KIT_POD_SIZES.checkContainer),
1216
+ securityContext: {
1217
+ runAsNonRoot: true,
1218
+ allowPrivilegeEscalation: false,
1219
+ capabilities: { drop: ["ALL"] },
1220
+ seccompProfile: { type: "RuntimeDefault" },
1221
+ },
1222
+ },
1223
+ ],
1224
+ volumes: custodian.volumes,
1225
+ },
1226
+ };
1227
+ const secret = (name: string, stringData: Record<string, string>): KubeManifest => ({
1228
+ apiVersion: "v1",
1229
+ kind: "Secret",
1230
+ metadata: meta(name),
1231
+ type: "Opaque",
1232
+ stringData,
1233
+ });
1234
+ const leaves: Record<string, string> = {};
1235
+ for (const stem of new Set(opts.held.secrets.flatMap((s) => s.hosts.map((h) => h.leaf)))) {
1236
+ const pem = opts.leaves[stem];
1237
+ if (!pem) throw new Error(`no leaf for a bound host of the provider preflight (${stem}) — a jr2 defect`);
1238
+ leaves[`${stem}.crt`] = pem.cert;
1239
+ leaves[`${stem}.key`] = pem.key;
1240
+ }
1241
+ const literals = Object.fromEntries(
1242
+ opts.held.secrets.filter((s) => s.source.kind === "literal").map((s) => [s.name, opts.values[s.name] ?? ""]),
1243
+ );
1244
+ const items: KubeManifest[] = [
1245
+ {
1246
+ apiVersion: "v1",
1247
+ kind: "ConfigMap",
1248
+ metadata: meta(o),
1249
+ data: {
1250
+ [CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
1251
+ [CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
1252
+ [CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
1253
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
1254
+ },
1255
+ },
1256
+ { apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
1257
+ secret(o, { token: opts.token, ...literals }),
1258
+ secret(`${o}-tls`, leaves),
1259
+ secret(`${o}-env`, opts.harnessEnvData),
1260
+ pod,
1261
+ ];
1262
+ return JSON.stringify({ apiVersion: "v1", kind: "List", items });
1263
+ }
1264
+
1265
+ /** The Orchestrator's pod template digest of the Instance Secret: it rolls the pod when a value
1266
+ * the process reads through `envFrom` changes (ADR-0069). */
1267
+ export const ANNOTATION_SECRET_DIGEST = "jr2.dev/secret-digest";
1268
+
1269
+ /** A keyed digest of the Instance Secret's data, in key order. Keyed with the signing key it
1270
+ * holds, so the annotation reveals nothing about a token. */
1271
+ export function instanceSecretDigest(secretData: Record<string, string>): string {
1272
+ const mac = createHmac("sha256", Buffer.from(secretData.JR2_SIGNING_KEY ?? "", "base64"));
1273
+ mac.update("jr2-instance-secret\n");
1274
+ for (const key of Object.keys(secretData).sort()) {
1275
+ const value = Buffer.from(secretData[key]!, "utf8");
1276
+ mac.update(`${key}\n${value.length}:`).update(value);
1277
+ }
1278
+ return mac.digest("base64url");
1279
+ }
1280
+
1281
+ /** The Instance Effector's pod template digest (ADR-0068): an HMAC over every secret input, so an
1282
+ * edit to one rolls it, as `jr2.dev/held-digest` rolls the Instance Harness (ADR-0059). */
1283
+ export const ANNOTATION_EFFECTOR_DIGEST = "jr2.dev/effector-digest";
1284
+
1285
+ /** The Instance Effector pod's `fsGroup`: its Secret files are readable by that group, and the pod
1286
+ * has one container. The group a Sandbox's and the Instance Harness's pods use (ADR-0005). */
1287
+ const INSTANCE_EFFECTOR_FS_GROUP = 2000;
1288
+
1289
+ /** Where the Effector reads what it holds (ADR-0068): its secrets, one file each; its key and
1290
+ * its config. Its scratch is `$HOME`. */
1291
+ const EFFECTOR_SECRETS_DIR = "/run/jr2/secrets";
1292
+ const EFFECTOR_OWN_DIR = "/run/jr2/effector";
1293
+ const EFFECTOR_SCRATCH = "/run/jr2/scratch";
1294
+
1295
+ /** How much more than the longest Effect timeout a stopping replica is given: room for the
1296
+ * Orchestrator's next poll to read the outcome it finished. */
1297
+ export const EFFECTOR_READ_MARGIN_SECONDS = 30;
1298
+
1299
+ /** The Secret's key of one literal Effect secret: prefixed, so no secret's name is `key` or
1300
+ * `effector.json`. */
1301
+ const effectorSecretKey = (name: string): string => `secret.${name}`;
1302
+
1303
+ /** The Instance Effector's grace period: the longest timeout among the Effects placed there, plus
1304
+ * the read margin. */
1305
+ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
1306
+ Math.ceil(plan.longestTimeoutMs / 1000) + EFFECTOR_READ_MARGIN_SECONDS;
1307
+
1308
+ /**
1309
+ * The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
1310
+ * places an Effect there — an `http` Effect — and deletes otherwise.
1311
+ *
1312
+ * - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
1313
+ * the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
1314
+ * `jr2-harness` image with `/opt/jr2/bin/effector` as its command; hardened, non-root, a read-only
1315
+ * root filesystem, an emptyDir scratch as `$HOME`. Its grace period is the longest timeout among
1316
+ * the Effects placed there, plus a margin for the outcome to be read.
1317
+ * - A headless Service that publishes addresses that are not ready: "ask every replica" is its
1318
+ * name's addresses, a stopping replica included.
1319
+ * - A NetworkPolicy of `jr2-sandbox-ingress`'s form: ingress from this Instance's Orchestrator only.
1320
+ * No egress rule: the pod holds real values and calls the hosts its Effects name.
1321
+ * - The Secret `jr2-instance-effector`: the literal Effect secrets placed here, the placement's key,
1322
+ * and `effector.json` (the targets). A secret from the user's own Secret is projected from there
1323
+ * by reference, never copied.
1324
+ */
1325
+ export function instanceEffectorObjects(opts: {
1326
+ name: string;
1327
+ namespace: string;
1328
+ /** The resolved `jr2-harness` ref — it carries the binary (ADR-0037). */
1329
+ harnessImage: string;
1330
+ plan: EffectorPlan;
1331
+ /** `effectorKey(signingKey, "instance")`. */
1332
+ key: string;
1333
+ /** The HMAC over every secret input (`effectorDigest`). */
1334
+ digest: string;
1335
+ /** The instance ships a private-CA bundle (ADR-0020): it is mounted and trusted. */
1336
+ caBundle?: boolean;
1337
+ priorityClasses?: PriorityClasses;
1338
+ }): string {
1339
+ const labels = { [LABEL_INSTANCE]: opts.name, "app.kubernetes.io/managed-by": "jr2" };
1340
+ const meta = (): KubeManifest => ({ name: INSTANCE_EFFECTOR, namespace: opts.namespace, labels });
1341
+ const grace = effectorGraceSeconds(opts.plan);
1342
+
1343
+ const literal = opts.plan.secrets.filter((s) => s.source.kind === "literal");
1344
+ const sources: KubeManifest[] = [
1345
+ ...(literal.length
1346
+ ? [
1347
+ {
1348
+ secret: {
1349
+ name: INSTANCE_EFFECTOR,
1350
+ items: literal.map((s) => ({ key: effectorSecretKey(s.name), path: s.name })),
1351
+ },
1352
+ },
1353
+ ]
1354
+ : []),
1355
+ ...opts.plan.secrets.flatMap((s) =>
1356
+ s.source.kind === "secret"
1357
+ ? [{ secret: { name: s.source.secret, items: [{ key: s.source.key, path: s.name }] } }]
1358
+ : [],
1359
+ ),
1360
+ ];
1361
+ const config = { version: 1, placement: opts.plan.placement, targets: opts.plan.targets };
1362
+
1363
+ const container = {
1364
+ name: "effector",
1365
+ image: opts.harnessImage,
1366
+ imagePullPolicy: "IfNotPresent",
1367
+ command: ["/opt/jr2/bin/effector"],
1368
+ ports: [{ containerPort: EFFECTOR_PORT }],
1369
+ env: [
1370
+ { name: "POD_NAME", valueFrom: { fieldRef: { fieldPath: "metadata.name" } } },
1371
+ { name: "POD_IP", valueFrom: { fieldRef: { fieldPath: "status.podIP" } } },
1372
+ { name: "JR2_EFFECTOR_PORT", value: String(EFFECTOR_PORT) },
1373
+ { name: "JR2_EFFECTOR_KEY", value: `${EFFECTOR_OWN_DIR}/key` },
1374
+ { name: "JR2_EFFECTOR_CONFIG", value: `${EFFECTOR_OWN_DIR}/effector.json` },
1375
+ { name: "JR2_EFFECTOR_SECRETS", value: EFFECTOR_SECRETS_DIR },
1376
+ // The drain ends a few seconds before the kubelet's kill, so the replica exits on its own.
1377
+ { name: "JR2_EFFECTOR_DRAIN_SECONDS", value: String(Math.max(grace - 5, 1)) },
1378
+ { name: "HOME", value: EFFECTOR_SCRATCH },
1379
+ ...(opts.caBundle ? [{ name: "JR2_CA_FILE", value: `${CA_MOUNT}/ca.crt` }] : []),
1380
+ ],
1381
+ volumeMounts: [
1382
+ { name: "secrets", mountPath: EFFECTOR_SECRETS_DIR, readOnly: true },
1383
+ { name: "effector", mountPath: EFFECTOR_OWN_DIR, readOnly: true },
1384
+ { name: "scratch", mountPath: EFFECTOR_SCRATCH },
1385
+ ...(opts.caBundle ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : []),
1386
+ ],
1387
+ readinessProbe: { httpGet: { path: "/healthz", port: EFFECTOR_PORT }, periodSeconds: 2, failureThreshold: 3 },
1388
+ resources: sized(KIT_POD_SIZES.instanceEffector),
1389
+ securityContext: {
1390
+ runAsNonRoot: true,
1391
+ allowPrivilegeEscalation: false,
1392
+ readOnlyRootFilesystem: true,
1393
+ capabilities: { drop: ["ALL"] },
1394
+ seccompProfile: { type: "RuntimeDefault" },
1395
+ },
1396
+ };
1397
+
1398
+ const items: KubeManifest[] = [
1399
+ {
1400
+ apiVersion: "v1",
1401
+ kind: "Secret",
1402
+ metadata: meta(),
1403
+ type: "Opaque",
1404
+ stringData: {
1405
+ key: opts.key,
1406
+ "effector.json": JSON.stringify(config, null, 2),
1407
+ ...Object.fromEntries(literal.map((s) => [effectorSecretKey(s.name), opts.plan.values[s.name] ?? ""])),
1408
+ },
1409
+ },
1410
+ {
1411
+ apiVersion: "apps/v1",
1412
+ kind: "Deployment",
1413
+ metadata: { ...meta(), labels: { ...labels, [LABEL_VERSION]: KIT_VERSION } },
1414
+ spec: {
1415
+ replicas: 1,
1416
+ selector: { matchLabels: { app: INSTANCE_EFFECTOR } },
1417
+ // Surge, never a gap: the new replica starts before the old one stops.
1418
+ strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } },
1419
+ template: {
1420
+ metadata: {
1421
+ labels: { ...labels, app: INSTANCE_EFFECTOR },
1422
+ annotations: { [ANNOTATION_EFFECTOR_DIGEST]: opts.digest },
1423
+ },
1424
+ spec: {
1425
+ terminationGracePeriodSeconds: grace,
1426
+ priorityClassName: priorityClassNames(opts.priorityClasses).control,
1427
+ automountServiceAccountToken: false,
1428
+ enableServiceLinks: false,
1429
+ securityContext: {
1430
+ runAsNonRoot: true,
1431
+ seccompProfile: { type: "RuntimeDefault" },
1432
+ fsGroup: INSTANCE_EFFECTOR_FS_GROUP,
1433
+ },
1434
+ containers: [container],
1435
+ volumes: [
1436
+ { name: "secrets", projected: { defaultMode: 0o400, sources } },
1437
+ {
1438
+ name: "effector",
1439
+ secret: {
1440
+ secretName: INSTANCE_EFFECTOR,
1441
+ defaultMode: 0o400,
1442
+ items: [
1443
+ { key: "key", path: "key" },
1444
+ { key: "effector.json", path: "effector.json" },
1445
+ ],
1446
+ },
1447
+ },
1448
+ { name: "scratch", emptyDir: {} },
1449
+ ...(opts.caBundle ? [{ name: "ca", configMap: { name: CA_CONFIGMAP } }] : []),
1450
+ ],
1451
+ },
1452
+ },
1453
+ },
1454
+ },
1455
+ {
1456
+ apiVersion: "v1",
1457
+ kind: "Service",
1458
+ metadata: meta(),
1459
+ spec: {
1460
+ clusterIP: "None",
1461
+ // A stopping replica still answers for the Effects it finishes: its address stays listed.
1462
+ publishNotReadyAddresses: true,
1463
+ selector: { app: INSTANCE_EFFECTOR },
1464
+ ports: [{ port: EFFECTOR_PORT, targetPort: EFFECTOR_PORT }],
1465
+ },
1466
+ },
1467
+ {
1468
+ apiVersion: "networking.k8s.io/v1",
1469
+ kind: "NetworkPolicy",
1470
+ metadata: meta(),
1471
+ spec: {
1472
+ podSelector: { matchLabels: { app: INSTANCE_EFFECTOR } },
1473
+ policyTypes: ["Ingress"],
1474
+ ingress: [
1475
+ { from: [{ podSelector: { matchLabels: { app: ORCHESTRATOR_SERVICE, [LABEL_INSTANCE]: opts.name } } }] },
1476
+ ],
1477
+ },
1478
+ },
1479
+ ];
1480
+ return JSON.stringify({ apiVersion: "v1", kind: "List", items });
1481
+ }
1482
+
1483
+ /** A keyed digest of one key's value in the user's Secret, as the API returns it (base64): what a
1484
+ * template digest holds for a `valueFrom`, so an edit of the value rolls the pod and the annotation
1485
+ * reveals nothing about it. `jr2 up` keeps the digest, never the value. */
1486
+ export function storedValueDigest(signingKey: Buffer, secret: string, key: string, base64: string): string {
1487
+ return createHmac("sha256", signingKey)
1488
+ .update(`jr2-secret-value\n${secret}\n${key}\n`)
1489
+ .update(Buffer.from(base64, "base64"))
1490
+ .digest("base64url");
1491
+ }
1492
+
1493
+ /** The digest of each `valueFrom` value, by the user's Secret and key; undefined when not read. */
1494
+ export type ValueDigest = (secret: string, key: string) => string | undefined;
1495
+
1496
+ /** The Instance Effector's template digest: an HMAC over every secret input — a literal's value, a
1497
+ * `valueFrom`'s reference and the digest of its value — keyed with the signing key so the
1498
+ * annotation reveals nothing about a value. */
1499
+ export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
1500
+ const mac = createHmac("sha256", signingKey);
1501
+ mac.update(JSON.stringify({ secrets: plan.secrets, targets: plan.targets, longest: plan.longestTimeoutMs }));
1502
+ for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
1503
+ for (const s of plan.secrets) {
1504
+ if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
1505
+ }
1506
+ mac.update(key);
1507
+ return mac.digest("base64url").slice(0, 32);
1508
+ }