@jr2/orchestrator 0.3.0 → 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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -4
- package/src/actor.ts +502 -49
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +281 -84
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
package/src/config.ts
CHANGED
|
@@ -21,11 +21,12 @@ import { join } from "node:path";
|
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { repoIdentity } from "./repo-identity.ts";
|
|
24
|
+
import { assertSize, type Size } from "./size.ts";
|
|
24
25
|
|
|
25
26
|
// ------------------------------------------------------------------------------------------------
|
|
26
27
|
// `git.credentials` (ADR-0051): how the cluster authenticates to a Repo, and the fence.
|
|
27
28
|
//
|
|
28
|
-
// A Repo is identified by its url and nothing in this file names one (
|
|
29
|
+
// A Repo is identified by its url and nothing in this file names one (GLOSSARY.md "Repo"). What
|
|
29
30
|
// the config declares is CREDENTIALS, matched by prefix on the identity — the Argo and Flux shape.
|
|
30
31
|
// The Orchestrator resolves the entry when it creates a Repo CR and writes a `secretRef`; the
|
|
31
32
|
// cache agent reads only that. The list is also the fence: a per-run url (run input, a ticket
|
|
@@ -38,10 +39,25 @@ import { repoIdentity } from "./repo-identity.ts";
|
|
|
38
39
|
export type GitCredential = {
|
|
39
40
|
/** A prefix on the identity (`github.com/ourorg/`), or `*` for everything. */
|
|
40
41
|
match: string;
|
|
41
|
-
/** Env var name holding an HTTPS token; `jr2 up`
|
|
42
|
+
/** Env var name holding an HTTPS token; `jr2 up` writes its value into the entry's clone Secret
|
|
43
|
+
* (`jr2-git-<hash>`), which the cache agent spends. The Orchestrator never holds it (ADR-0068). */
|
|
42
44
|
token?: string;
|
|
43
45
|
/** A Secret (Flux key names: `identity`, `identity.pub`, `known_hosts`) holding a deploy key. */
|
|
44
46
|
sshKey?: string;
|
|
47
|
+
/** The credential a `push()` spends (ADR-0051, ADR-0067), when it is not the clone credential: an
|
|
48
|
+
* ssh key (a Secret of `sshKey`'s shape) or a token (an env var, as `token`). Absent, the clone
|
|
49
|
+
* credential pushes; an entry with neither admits a clone, never a push. A token goes Basic
|
|
50
|
+
* through a credential helper only. */
|
|
51
|
+
push?: { sshKey: string } | { token: string };
|
|
52
|
+
/** The key a `push({ sign: true })` signs with, and the committer it signs as (ADR-0067): an ssh
|
|
53
|
+
* key (a Secret with `identity`) by default, or a GPG key (a Secret with `private.asc`, no
|
|
54
|
+
* passphrase). Explicit: an auth key is never reused as a signing key. The committer's email must
|
|
55
|
+
* belong to the forge account that holds the key, or the forge shows the signature unverified. */
|
|
56
|
+
sign?: { sshKey: string; name: string; email: string } | { gpgKey: string; name: string; email: string };
|
|
57
|
+
/** Who an unsigned `push({ squash: true })` is committed by (ADR-0067): the squashed commit is
|
|
58
|
+
* new, and without `sign` no signer names its committer. Its date is the Effector's clock; the
|
|
59
|
+
* author line stays the Agent's. A signed push's committer is the signer's. */
|
|
60
|
+
committer?: { name: string; email: string };
|
|
45
61
|
};
|
|
46
62
|
|
|
47
63
|
export type GitConfig = {
|
|
@@ -63,7 +79,7 @@ export function matchCredential(identity: string, list: readonly GitCredential[]
|
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
/** The Secret a Repo CR's `secretRef` names for `url` under `entry`, picked by the url's scheme:
|
|
66
|
-
* https/http spend a token (
|
|
82
|
+
* https/http spend a token (the clone Secret `jr2 up` writes from the env var), ssh spends the
|
|
67
83
|
* named deploy-key Secret. `undefined` when no entry, no applicable field, or a scheme that carries
|
|
68
84
|
* no credential (`git://`, a local path) — the clone is anonymous. */
|
|
69
85
|
export function credentialSecretFor(
|
|
@@ -166,21 +182,32 @@ export type CatalogReach = {
|
|
|
166
182
|
baseUrl: string;
|
|
167
183
|
};
|
|
168
184
|
|
|
169
|
-
/**
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
/**
|
|
186
|
+
* One secret of the Instance (ADR-0059's entry shape, ADR-0068's list). Its placement is READ, never
|
|
187
|
+
* declared, and naming is placement: a pod gets the secrets something in it names. A Held secret is
|
|
188
|
+
* one a Harness pod names — a `workspace()` lists it under `secrets: [...]`, or it is a known model
|
|
189
|
+
* key the provider of an Agent whose Turns run there reads — and there the Harness container's env
|
|
190
|
+
* carries its Stand-in, `jr2-held-<name>`, and that pod's Custodian puts the value into requests
|
|
191
|
+
* toward `hosts` alone. An entry an Effect names with `secret("name")` is an Effect secret, a file in
|
|
192
|
+
* that Effect's Effector. Both is both, and `jr2 up` reports it per pod; an entry nothing names goes
|
|
193
|
+
* nowhere, and `jr2 up` warns. `hosts`, `headers` and `paths` are the binding, never the placement.
|
|
194
|
+
*/
|
|
195
|
+
export type SecretEntry = {
|
|
196
|
+
/** As a Held secret, the env var the Harness container sees — `[A-Z_][A-Z0-9_]*`, holding the
|
|
197
|
+
* Stand-in. As an Effect secret, the name `secret("name")` names and the Effector's file. */
|
|
173
198
|
name: string;
|
|
174
199
|
/** The value as a literal, usually read from `process.env`/`.env`. `jr2 up` writes it into the
|
|
175
|
-
*
|
|
200
|
+
* Secret of each placement that names it — `jr2-held-secrets-<pod>` (that pod's Custodian's),
|
|
201
|
+
* `jr2-instance-effector` — and nowhere else. Exactly one of `value`/`valueFrom`. */
|
|
176
202
|
value?: string;
|
|
177
203
|
/** Or a key of a Secret you manage (Sealed Secrets, External Secrets). `jr2 up` checks that the
|
|
178
|
-
* Secret and key exist;
|
|
204
|
+
* Secret and key exist; each placement mounts that one key as a file, and no other container does. */
|
|
179
205
|
valueFrom?: { secretKeyRef: { name: string; key: string } };
|
|
180
|
-
/** Where the value may go
|
|
181
|
-
* port
|
|
182
|
-
*
|
|
183
|
-
*
|
|
206
|
+
/** Where the value may go as a Held secret — its binding, not its placement. Each entry is `host`,
|
|
207
|
+
* `host:port`, or an `https://`
|
|
208
|
+
* URL (its host and port are taken; the path is ignored). Exact DNS names or IP literals; no
|
|
209
|
+
* wildcards. Port default 443. A known model-key name (`MODEL_KEYS`) may leave it out: it then
|
|
210
|
+
* goes to the one host that provider's calls go to. */
|
|
184
211
|
hosts?: readonly string[];
|
|
185
212
|
/** Request headers the Stand-in is swapped in, lowercase. Default
|
|
186
213
|
* `["authorization", "x-api-key", "x-goog-api-key", "api-key"]`. */
|
|
@@ -208,13 +235,9 @@ export type HarnessConfig = {
|
|
|
208
235
|
/** pi catalog providers whose calls go somewhere else, keyed by the catalog provider id
|
|
209
236
|
* (`anthropic`, `openai`, ...) — a gateway that serves the provider's own API (ADR-0059). */
|
|
210
237
|
catalog?: Readonly<Record<string, CatalogReach>>;
|
|
211
|
-
/** Credentials the Harness uses and never holds (ADR-0059): the Harness container sees a
|
|
212
|
-
* Stand-in, and the Custodian puts the value into requests toward the bound hosts alone. A model
|
|
213
|
-
* API key belongs here, never in `env`. */
|
|
214
|
-
heldSecrets?: readonly HeldSecret[];
|
|
215
238
|
/** Env vars for the Harness container. Values read from `process.env`/`.env` are materialized
|
|
216
239
|
* into the instance-owned Secret by `jr2 up`. The Agent can read every one of them, so a known
|
|
217
|
-
* model key here is refused — it goes in `
|
|
240
|
+
* model key here is refused — it goes in the top-level `secrets` list (ADR-0059). */
|
|
218
241
|
env?: readonly HarnessEnvVar[];
|
|
219
242
|
/** Whole-Secret/ConfigMap env for the Harness container — `envFrom` refs to Secrets YOU manage
|
|
220
243
|
* (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019), and
|
|
@@ -254,19 +277,59 @@ export type SandboxPlacement = {
|
|
|
254
277
|
tolerations?: readonly Toleration[];
|
|
255
278
|
};
|
|
256
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The Instance's Sandbox defaults (ADR-0052, ADR-0060): placement, and the Size for a Workspace
|
|
282
|
+
* that states none.
|
|
283
|
+
*/
|
|
284
|
+
export type SandboxConfig = SandboxPlacement & {
|
|
285
|
+
/**
|
|
286
|
+
* The Instance's default Size (ADR-0060) — `{ limits: { memory, cpu } }` and nothing else — for
|
|
287
|
+
* a Workspace that states no Size at all; the kit fills what it leaves out. NEVER an override of a Size a Machine states: a
|
|
288
|
+
* Size is a fact of the Machine, and its one override path is `customize(machine, { resources })`.
|
|
289
|
+
* Absent → the kit default, memory 2Gi and cpu 1. jr2 reserves the whole Size (every request
|
|
290
|
+
* equals its limit) and enforces the CPU limit.
|
|
291
|
+
*/
|
|
292
|
+
resources?: Size;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The PriorityClasses jr2's pods run under (ADR-0060). Absent, `jr2 up` creates two, cluster-scoped,
|
|
297
|
+
* beside the CRDs: `jr2-control` (100000, PreemptLowerPriority) for the Orchestrator, the operator
|
|
298
|
+
* and the Repo cache agent, and `jr2-sandbox` (1000, preemptionPolicy Never) for Sandboxes and the
|
|
299
|
+
* Instance Harness — so an ordinary pod cannot preempt a live Workspace, and a waiting Sandbox
|
|
300
|
+
* evicts nobody. A cluster with its own scheme names existing classes here, and `jr2 up` then
|
|
301
|
+
* creates none for that key.
|
|
302
|
+
*/
|
|
303
|
+
export type PriorityClasses = {
|
|
304
|
+
/** An existing PriorityClass for the Orchestrator, the operator and the Repo cache agent. */
|
|
305
|
+
control?: string;
|
|
306
|
+
/** An existing PriorityClass for Sandboxes and the Instance Harness. */
|
|
307
|
+
sandbox?: string;
|
|
308
|
+
};
|
|
309
|
+
|
|
257
310
|
export type JR2Config = {
|
|
258
311
|
/** The instance's identity (ADR-0019): its kube namespace defaults to this (`-n` overrides),
|
|
259
312
|
* and `jr2 up` labels every object it owns with it. Default: the instance folder's name. */
|
|
260
313
|
name?: string;
|
|
314
|
+
/** Every secret of the Instance, one list for both sides (ADR-0068). Naming is the placement: a
|
|
315
|
+
* Held secret where a `workspace()` lists it under `secrets: [...]` or an Agent's provider reads
|
|
316
|
+
* it — a model API key belongs here, never in `harness.env` (ADR-0059) — and an Effect secret
|
|
317
|
+
* where an Effect names it with `secret("name")`. `hosts` is a Held secret's binding, never its
|
|
318
|
+
* placement: an entry nothing names goes nowhere. See `SecretEntry`. */
|
|
319
|
+
secrets?: readonly SecretEntry[];
|
|
261
320
|
/** How the cluster authenticates to Repos, and the fence a per-run url must pass (ADR-0051).
|
|
262
321
|
* NOT a list of Repos: a Machine names those itself, through its `workspace()`'s Repo Slots, and
|
|
263
322
|
* whether the instance has a data plane at all is read off the registered Machines. */
|
|
264
323
|
git?: GitConfig;
|
|
265
324
|
/** Agent-runtime config for the stock Harness (see `HarnessConfig`). */
|
|
266
325
|
harness?: HarnessConfig;
|
|
267
|
-
/** Which nodes are this Instance's Sandbox nodes (see `SandboxPlacement`, ADR-0052)
|
|
268
|
-
*
|
|
269
|
-
|
|
326
|
+
/** Which nodes are this Instance's Sandbox nodes (see `SandboxPlacement`, ADR-0052), and the
|
|
327
|
+
* Size of a Workspace that states none (see `SandboxConfig`, ADR-0060). Absent → wherever an
|
|
328
|
+
* ordinary pod lands, at the kit's Size. */
|
|
329
|
+
sandbox?: SandboxConfig;
|
|
330
|
+
/** Existing PriorityClasses to use instead of the two `jr2 up` creates (see `PriorityClasses`,
|
|
331
|
+
* ADR-0060). */
|
|
332
|
+
priorityClasses?: PriorityClasses;
|
|
270
333
|
/** Image registry prefix (deployment-varying — resolve from env). Absent → images are
|
|
271
334
|
* `kind load`-ed; present → pushed. A non-kind cluster without one fails loudly (ADR-0019). */
|
|
272
335
|
registry?: string;
|
|
@@ -308,9 +371,9 @@ export function defineConfig(c: JR2Config): JR2Config {
|
|
|
308
371
|
* Load an instance's `jr2.config.ts` (default export). Absent file → undefined (an instance
|
|
309
372
|
* can boot configless); a file that fails to IMPORT throws — a broken config must be loud,
|
|
310
373
|
* never silently treated as "no config". The shape checks live here, on the path the host CLI
|
|
311
|
-
* and the in-cluster Orchestrator share: `git.credentials`, `harness.
|
|
312
|
-
*
|
|
313
|
-
*
|
|
374
|
+
* and the in-cluster Orchestrator share: `git.credentials`, `secrets` and `harness.catalog` are
|
|
375
|
+
* checked at runtime, because an instance is zero-build and nothing typechecks this file before
|
|
376
|
+
* Node strips its types and imports it. SHAPE only: a secret's
|
|
314
377
|
* `value` reads `.env`, which the in-cluster evaluation does not have, so an absent value is not
|
|
315
378
|
* this function's to refuse — `jr2 up` checks presence and every held-secret rule host-side
|
|
316
379
|
* (`heldSecretsOf`, ADR-0059).
|
|
@@ -321,12 +384,29 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
|
321
384
|
const mod = (await import(pathToFileURL(file).href)) as { default?: JR2Config };
|
|
322
385
|
if (!mod.default) throw new Error(`${file} has no default export (use \`export default defineConfig({…})\`)`);
|
|
323
386
|
if (mod.default.git !== undefined) checkGit(mod.default.git, file);
|
|
324
|
-
if (mod.default.
|
|
387
|
+
if (mod.default.secrets !== undefined) checkSecrets(mod.default.secrets, file);
|
|
388
|
+
if (mod.default.harness !== undefined) checkHarness(mod.default.harness, file);
|
|
389
|
+
if (mod.default.sandbox !== undefined) checkSandbox(mod.default.sandbox, file);
|
|
390
|
+
if (mod.default.priorityClasses !== undefined) checkPriorityClasses(mod.default.priorityClasses, file);
|
|
325
391
|
return mod.default;
|
|
326
392
|
}
|
|
327
393
|
|
|
328
394
|
const NonEmpty = z.string().min(1);
|
|
329
|
-
const Credential = z
|
|
395
|
+
const Credential = z
|
|
396
|
+
.object({
|
|
397
|
+
match: NonEmpty,
|
|
398
|
+
token: NonEmpty.optional(),
|
|
399
|
+
sshKey: NonEmpty.optional(),
|
|
400
|
+
push: z.union([z.object({ sshKey: NonEmpty }).strict(), z.object({ token: NonEmpty }).strict()]).optional(),
|
|
401
|
+
sign: z
|
|
402
|
+
.union([
|
|
403
|
+
z.object({ sshKey: NonEmpty, name: NonEmpty, email: NonEmpty }).strict(),
|
|
404
|
+
z.object({ gpgKey: NonEmpty, name: NonEmpty, email: NonEmpty }).strict(),
|
|
405
|
+
])
|
|
406
|
+
.optional(),
|
|
407
|
+
committer: z.object({ name: NonEmpty, email: NonEmpty }).strict().optional(),
|
|
408
|
+
})
|
|
409
|
+
.strict();
|
|
330
410
|
|
|
331
411
|
/** `git.credentials`' SHAPE, checked at load — by index and field, so a bad entry names itself
|
|
332
412
|
* (`git.credentials[1].token`) rather than surfacing as a Secret that never matches. */
|
|
@@ -345,10 +425,12 @@ function checkGit(git: unknown, where: string): void {
|
|
|
345
425
|
}
|
|
346
426
|
}
|
|
347
427
|
|
|
348
|
-
const GIT_HINT =
|
|
428
|
+
const GIT_HINT =
|
|
429
|
+
"an entry is { match, token?, sshKey?, push?: { sshKey } | { token }, sign?: { sshKey | gpgKey, name, email }, " +
|
|
430
|
+
"committer?: { name, email } } (ADR-0051)";
|
|
349
431
|
|
|
350
432
|
const OptionalText = z.string().optional();
|
|
351
|
-
const
|
|
433
|
+
const SecretShape = z
|
|
352
434
|
.object({
|
|
353
435
|
name: NonEmpty,
|
|
354
436
|
// `.env` values are absent in-cluster, so `undefined` must pass here — presence is `jr2 up`'s.
|
|
@@ -364,25 +446,25 @@ const HeldSecretShape = z
|
|
|
364
446
|
.strict();
|
|
365
447
|
const CatalogReachShape = z.object({ baseUrl: z.string() }).strict();
|
|
366
448
|
|
|
367
|
-
/** `
|
|
368
|
-
*
|
|
369
|
-
function
|
|
449
|
+
/** `secrets`' SHAPE (ADR-0059's entry, ADR-0068's list) — by index and field, the `checkGit` style,
|
|
450
|
+
* so a bad entry names itself (`secrets[1].hosts`). */
|
|
451
|
+
function checkSecrets(secrets: unknown, where: string): void {
|
|
452
|
+
if (!Array.isArray(secrets)) throw new Error(`${where} at secrets: expected an array — ${SECRET_HINT}`);
|
|
453
|
+
for (const [i, entry] of secrets.entries()) {
|
|
454
|
+
const parsed = SecretShape.safeParse(entry);
|
|
455
|
+
if (parsed.success) continue;
|
|
456
|
+
const issue = parsed.error.issues[0];
|
|
457
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
458
|
+
throw new Error(`${where} at secrets[${i}]${at}: ${issue?.message ?? "invalid"} — ${SECRET_HINT}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** `harness.catalog`'s SHAPE (ADR-0059) — by provider id and field, the `checkGit` style. */
|
|
463
|
+
function checkHarness(harness: unknown, where: string): void {
|
|
370
464
|
if (typeof harness !== "object" || harness === null || Array.isArray(harness)) {
|
|
371
465
|
throw new Error(`${where} at harness: expected an object`);
|
|
372
466
|
}
|
|
373
|
-
const {
|
|
374
|
-
if (heldSecrets !== undefined) {
|
|
375
|
-
if (!Array.isArray(heldSecrets)) {
|
|
376
|
-
throw new Error(`${where} at harness.heldSecrets: expected an array — ${HELD_HINT}`);
|
|
377
|
-
}
|
|
378
|
-
for (const [i, entry] of heldSecrets.entries()) {
|
|
379
|
-
const parsed = HeldSecretShape.safeParse(entry);
|
|
380
|
-
if (parsed.success) continue;
|
|
381
|
-
const issue = parsed.error.issues[0];
|
|
382
|
-
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
383
|
-
throw new Error(`${where} at harness.heldSecrets[${i}]${at}: ${issue?.message ?? "invalid"} — ${HELD_HINT}`);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
467
|
+
const { catalog } = harness as { catalog?: unknown };
|
|
386
468
|
if (catalog !== undefined) {
|
|
387
469
|
if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) {
|
|
388
470
|
throw new Error(`${where} at harness.catalog: expected an object keyed by catalog provider id — ${CATALOG_HINT}`);
|
|
@@ -397,6 +479,30 @@ function checkHeld(harness: unknown, where: string): void {
|
|
|
397
479
|
}
|
|
398
480
|
}
|
|
399
481
|
|
|
400
|
-
const
|
|
401
|
-
"an entry is { name, value? | valueFrom?: { secretKeyRef: { name, key } }, hosts?, headers?, paths? } (ADR-0059)";
|
|
482
|
+
const SECRET_HINT =
|
|
483
|
+
"an entry is { name, value? | valueFrom?: { secretKeyRef: { name, key } }, hosts?, headers?, paths? } (ADR-0059, ADR-0068)";
|
|
402
484
|
const CATALOG_HINT = "an entry is <provider id>: { baseUrl } (ADR-0059)";
|
|
485
|
+
|
|
486
|
+
/** `sandbox.resources`' SHAPE (ADR-0060), checked at load by field — `requests`, or any key beside
|
|
487
|
+
* the two limits, is refused naming itself (`sandbox.resources.requests`). */
|
|
488
|
+
function checkSandbox(sandbox: unknown, where: string): void {
|
|
489
|
+
if (typeof sandbox !== "object" || sandbox === null || Array.isArray(sandbox)) {
|
|
490
|
+
throw new Error(`${where} at sandbox: expected an object — { nodeSelector?, tolerations?, resources? }`);
|
|
491
|
+
}
|
|
492
|
+
const { resources } = sandbox as { resources?: unknown };
|
|
493
|
+
if (resources !== undefined) assertSize(`${where} at sandbox.resources`, resources);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const PriorityClassesShape = z.object({ control: NonEmpty.optional(), sandbox: NonEmpty.optional() }).strict();
|
|
497
|
+
|
|
498
|
+
/** `priorityClasses`' SHAPE (ADR-0060): two optional class names, nothing else. */
|
|
499
|
+
function checkPriorityClasses(classes: unknown, where: string): void {
|
|
500
|
+
const parsed = PriorityClassesShape.safeParse(classes);
|
|
501
|
+
if (parsed.success) return;
|
|
502
|
+
const issue = parsed.error.issues[0];
|
|
503
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
504
|
+
throw new Error(
|
|
505
|
+
`${where} at priorityClasses${at}: ${issue?.message ?? "invalid"} — { control?, sandbox? }, each the name of ` +
|
|
506
|
+
"an existing PriorityClass (ADR-0060)",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The content address jr2 takes of a tree (ADR-0038, ADR-0069). It lives here, not in the CLI,
|
|
2
|
+
// because two ends compute it over one Assembly: `jr2 up` hashes the staged tree, and the door
|
|
3
|
+
// recomputes the hash over the unpacked tree before it accepts a publish. One implementation, so
|
|
4
|
+
// the two ends cannot drift.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readdir, readFile, readlink, stat } from "node:fs/promises";
|
|
8
|
+
import { basename, join, relative } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** The salt of an Assembly's hash (ADR-0069). The host and the door both hash with it. */
|
|
11
|
+
export const ASSEMBLY_HASH_SALT = "assembly";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The name of an Assembly (ADR-0069): a hash over every entry the Assembly's archive carries. The
|
|
15
|
+
* host names its staged tree with it, and the door recomputes it over the unpacked tree. The door
|
|
16
|
+
* then trusts "one hash names one Assembly" three times: `jr2 up` skips a publish whose hash is
|
|
17
|
+
* current, the door answers `unchanged`, and the door reuses an `assemblies/<hash>/` it already
|
|
18
|
+
* holds. So the hash covers what the archive carries, and nothing can change under one hash:
|
|
19
|
+
*
|
|
20
|
+
* - Each directory is a record, so an empty one counts.
|
|
21
|
+
* - Each symlink is a record with its target. `contentHash` skips links (an image tag must not see
|
|
22
|
+
* pnpm's store links twice), but an Assembly's links travel, and a changed target is a changed
|
|
23
|
+
* Assembly.
|
|
24
|
+
* - Each record is framed: a type, then every field as its byte length and its bytes. Without the
|
|
25
|
+
* frame, a file `a` that holds `X0:b\nY` hashes as the files `a` = `X` and `b` = `Y`.
|
|
26
|
+
*
|
|
27
|
+
* The walk is in byte order (`byteOrder`). Modes are not in the hash: nothing at the door reads
|
|
28
|
+
* them, and an umask must not move an Assembly's name. Other entry types (a socket, a FIFO) are not
|
|
29
|
+
* records: the archive refuses them, so they never reach the door. Nothing is excluded: the hash
|
|
30
|
+
* names every byte the archive carries.
|
|
31
|
+
*/
|
|
32
|
+
export async function assemblyHash(dir: string): Promise<string> {
|
|
33
|
+
const h = createHash("sha256");
|
|
34
|
+
h.update(`salt:${ASSEMBLY_HASH_SALT}\n`);
|
|
35
|
+
const field = (bytes: Buffer): void => {
|
|
36
|
+
h.update(`${bytes.length}:`);
|
|
37
|
+
h.update(bytes);
|
|
38
|
+
};
|
|
39
|
+
const walk = async (d: string, rel: string): Promise<void> => {
|
|
40
|
+
const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => byteOrder(a.name, b.name));
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const p = join(d, e.name);
|
|
43
|
+
const path = Buffer.from(rel === "" ? e.name : `${rel}/${e.name}`, "utf8");
|
|
44
|
+
if (e.isSymbolicLink()) {
|
|
45
|
+
h.update("l");
|
|
46
|
+
field(path);
|
|
47
|
+
field(Buffer.from(await readlink(p), "utf8"));
|
|
48
|
+
} else if (e.isDirectory()) {
|
|
49
|
+
h.update("d");
|
|
50
|
+
field(path);
|
|
51
|
+
await walk(p, path.toString("utf8"));
|
|
52
|
+
} else if (e.isFile()) {
|
|
53
|
+
h.update("f");
|
|
54
|
+
field(path);
|
|
55
|
+
field(await readFile(p));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
await walk(dir, "");
|
|
60
|
+
return h.digest("hex").slice(0, 12);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The order a content address walks a directory in: UTF-8 byte order of the entry names, never a
|
|
65
|
+
* locale's collation. `localeCompare` reads the ICU locale from `LANG`/`LC_ALL`, and the two ends
|
|
66
|
+
* of a hash run in different locales: in `cs_CZ`, "ch" sorts after "h", so a host there would
|
|
67
|
+
* hash `node_modules/chalk` after `hono` while the Kit image hashes it first, and every publish
|
|
68
|
+
* would fail the door's check. The Assembly's tar orders its entries by the same rule.
|
|
69
|
+
*/
|
|
70
|
+
export function byteOrder(a: string, b: string): number {
|
|
71
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A short content hash over `paths` (files and/or directories, sorted walk), salted with `salt`.
|
|
76
|
+
* Symlinks are skipped rather than followed: in a pnpm bundle `node_modules/<pkg>` links into the
|
|
77
|
+
* virtual store, whose real files the walk already visits — following would hash the same bytes
|
|
78
|
+
* twice. Each path contributes its entries under its own index prefix, so two directories that
|
|
79
|
+
* happen to hold the same relative filenames stay distinguishable.
|
|
80
|
+
*/
|
|
81
|
+
export async function contentHash(paths: string[], salt: string, exclude: Set<string> = new Set()): Promise<string> {
|
|
82
|
+
const h = createHash("sha256");
|
|
83
|
+
h.update(`salt:${salt}\n`);
|
|
84
|
+
const walk = async (root: string, d: string, index: number): Promise<void> => {
|
|
85
|
+
const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => byteOrder(a.name, b.name));
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (exclude.has(e.name)) continue;
|
|
88
|
+
const p = join(d, e.name);
|
|
89
|
+
if (e.isDirectory()) await walk(root, p, index);
|
|
90
|
+
else if (e.isFile()) {
|
|
91
|
+
h.update(`${index}:${relative(root, p)}\n`);
|
|
92
|
+
h.update(await readFile(p));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
for (const [index, p] of paths.entries()) {
|
|
97
|
+
const st = await stat(p);
|
|
98
|
+
if (st.isDirectory()) await walk(p, p, index);
|
|
99
|
+
else {
|
|
100
|
+
h.update(`${index}:${basename(p)}\n`);
|
|
101
|
+
h.update(await readFile(p));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return h.digest("hex").slice(0, 12);
|
|
105
|
+
}
|
package/src/custodian.lua
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
-- The Custodian's decisions (ADR-0059), run by Envoy's Lua filter on every listener it owns.
|
|
2
2
|
--
|
|
3
3
|
-- Envoy carries the bytes: the CONNECT listener, TLS on both sides, the upstream pools, streaming.
|
|
4
|
-
-- This script makes every choice about a credential, and nothing else.
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
4
|
+
-- This script makes every choice about a credential, and nothing else. It ships once per Instance
|
|
5
|
+
-- in the `jr2-held` ConfigMap (`program.lua`); each pod's own `custodian.lua` is a generated `HELD`
|
|
6
|
+
-- table (names, headers, paths, bound targets — never a value) that ends by loading this file.
|
|
7
|
+
-- Envoy loads that once per Lua state at start, so the top-level code below runs before any
|
|
8
|
+
-- listener serves, with `HELD` already set: a value that is missing or cannot ride a header stops
|
|
9
|
+
-- the start-up with a line that names the secret, never the value.
|
|
9
10
|
--
|
|
10
11
|
-- Four roles, one per route family, read from the route's metadata:
|
|
11
12
|
--
|
package/src/custodian.ts
CHANGED
|
@@ -5,22 +5,29 @@
|
|
|
5
5
|
// Its engine is Envoy, the upstream distroless image pinned by digest: jr2 deploys it and never
|
|
6
6
|
// builds it (ADR-0038). This module is the jr2 half — everything Envoy is told, and where:
|
|
7
7
|
//
|
|
8
|
-
// custodianBootstrap(...) the static bootstrap
|
|
8
|
+
// custodianBootstrap(...) the static bootstrap, one per pod, from that pod's manifest:
|
|
9
9
|
// no admin listener, no xDS, one internal listener per bound host
|
|
10
|
-
// custodianScript(...) the Lua the bootstrap runs:
|
|
11
|
-
//
|
|
10
|
+
// custodianScript(...) the Lua the bootstrap runs: a generated table of names, headers,
|
|
11
|
+
// paths and targets — never a value — then `dofile` of the program
|
|
12
|
+
// CUSTODIAN_PROGRAM `custodian.lua` as written: in `jr2-held` once per Instance
|
|
13
|
+
//
|
|
14
|
+
// Who renders the two per-pod files is who makes the pod: the Orchestrator at provision, into a
|
|
15
|
+
// ConfigMap the Sandbox CR owns; `jr2 up` for the Instance Harness. `jr2 up` DECLARES — each pod's
|
|
16
|
+
// manifest, the program — and nothing rendered per pod lands in a shared object (a ConfigMap
|
|
17
|
+
// stops at 1 MiB; a per-pod copy of Envoy's files reached it at nine Workspaces).
|
|
12
18
|
// custodianComposition(...) what a Harness pod gains: the Custodian container, its volumes, and
|
|
13
19
|
// the Harness container's env and mounts. BOTH placements call it —
|
|
14
|
-
// `
|
|
20
|
+
// `kubeSandbox` per provision, `instanceHarnessObjects` at converge —
|
|
15
21
|
// so the two pod shapes cannot drift
|
|
16
22
|
//
|
|
17
23
|
// The Custodian is a separate container from the Harness, and that is the point: the Agent
|
|
18
24
|
// executes code in the Harness container and nothing here (ADR-0005). What it holds — the
|
|
19
|
-
// Sandbox token and every held secret — is mounted into this container alone, and
|
|
25
|
+
// Sandbox token and every held secret its pod claims — is mounted into this container alone, and
|
|
20
26
|
// `shareProcessNamespace` stays off, so the Agent cannot read its env, its files or its `/proc`.
|
|
21
27
|
|
|
22
28
|
import { readFileSync } from "node:fs";
|
|
23
29
|
import type { HarnessEnvVar } from "./config.ts";
|
|
30
|
+
import type { ResourceRequirements } from "./size.ts";
|
|
24
31
|
import {
|
|
25
32
|
CREDENTIAL_HEADERS,
|
|
26
33
|
SANDBOX_TOKEN_NAME,
|
|
@@ -33,9 +40,10 @@ import {
|
|
|
33
40
|
CUSTODIAN_CONTROL_PORT,
|
|
34
41
|
CUSTODIAN_HEALTH_PORT,
|
|
35
42
|
CUSTODIAN_PORT,
|
|
43
|
+
CUSTODIAN_PROGRAM_KEY,
|
|
36
44
|
HELD_CONFIGMAP,
|
|
37
|
-
HELD_SECRETS_SECRET,
|
|
38
45
|
HELD_TLS_SECRET,
|
|
46
|
+
heldSecretsSecret,
|
|
39
47
|
} from "./names.ts";
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -62,7 +70,10 @@ export function custodianRef(kitRegistry?: string): string {
|
|
|
62
70
|
export const CUSTODIAN_VALUES = "/etc/jr2/custodian/values";
|
|
63
71
|
export const CUSTODIAN_TLS = "/etc/jr2/custodian/tls";
|
|
64
72
|
export const CUSTODIAN_CONFIG = "/etc/jr2/custodian/config";
|
|
65
|
-
/** The
|
|
73
|
+
/** The files the Custodian reads under its config mount: its own pod's rendered bootstrap and
|
|
74
|
+
* script (the two keys of the pod's Custodian ConfigMap — `sandboxCustodianConfigMap`,
|
|
75
|
+
* `INSTANCE_CUSTODIAN_CONFIGMAP`), and the fixed program the script loads, projected from
|
|
76
|
+
* `jr2-held` (`CUSTODIAN_PROGRAM_KEY`). */
|
|
66
77
|
export const CUSTODIAN_BOOTSTRAP_KEY = "envoy.json";
|
|
67
78
|
export const CUSTODIAN_SCRIPT_KEY = "custodian.lua";
|
|
68
79
|
|
|
@@ -356,8 +367,8 @@ function isIpLiteral(host: string): boolean {
|
|
|
356
367
|
* The Custodian's static bootstrap (ADR-0059): Envoy's whole configuration, rendered once per
|
|
357
368
|
* converge by `jr2 up`, identical for every Harness pod of the Instance.
|
|
358
369
|
*
|
|
359
|
-
* control 127.0.0.1:8081 the Menu and the ask, plain HTTP, to the Orchestrator's Service —
|
|
360
|
-
*
|
|
370
|
+
* control 127.0.0.1:8081 the Menu, a pick's answer and the ask, plain HTTP, to the Orchestrator's Service —
|
|
371
|
+
* four routes and nothing else, each swapping the Sandbox token's
|
|
361
372
|
* Stand-in for the token (ADR-0013, ADR-0053)
|
|
362
373
|
* health 0.0.0.0:15021 GET /healthz, for the kubelet, which cannot reach loopback
|
|
363
374
|
* egress 127.0.0.1:15001 CONNECT only — present when a held secret exists; a bound target
|
|
@@ -401,6 +412,11 @@ export function custodianBootstrap(manifest: HeldManifest, orchestrator: Orchest
|
|
|
401
412
|
route("events", "POST", "/agents/[^/]+/events", {
|
|
402
413
|
retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
|
|
403
414
|
}),
|
|
415
|
+
// An outstanding pick's answer, long-polled (ADR-0024): a read, so any reset
|
|
416
|
+
// is retried — an Orchestrator restart mid-poll costs a retry, not the pick.
|
|
417
|
+
route("deliveries", "GET", "/agents/[^/]+/deliveries/[^/]+", {
|
|
418
|
+
retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
|
|
419
|
+
}),
|
|
404
420
|
route("fetch", "POST", "/fetch", {
|
|
405
421
|
retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
|
|
406
422
|
}),
|
|
@@ -715,8 +731,11 @@ export function custodianBootstrap(manifest: HeldManifest, orchestrator: Orchest
|
|
|
715
731
|
|
|
716
732
|
// ---- the script ---------------------------------------------------------------------------------
|
|
717
733
|
|
|
718
|
-
/** `custodian.lua` beside this module — the fixed half of the script.
|
|
719
|
-
|
|
734
|
+
/** `custodian.lua` beside this module — the fixed half of the script, as written. `jr2 up` puts it
|
|
735
|
+
* in `jr2-held` once (`CUSTODIAN_PROGRAM_KEY`); every pod's own script loads it from the config
|
|
736
|
+
* mount. The per-pod half is the table alone, so a pod's rendered files stay small and nothing
|
|
737
|
+
* fixed is copied per Workspace. */
|
|
738
|
+
export const CUSTODIAN_PROGRAM: string = readFileSync(new URL("./custodian.lua", import.meta.url), "utf8");
|
|
720
739
|
|
|
721
740
|
/** A Lua string literal. Every string that reaches here passed `heldSecretsOf`'s rules (names,
|
|
722
741
|
* header tokens, printable paths), so escaping the two characters Lua gives meaning to is enough. */
|
|
@@ -729,14 +748,15 @@ function luaList(values: readonly string[]): string {
|
|
|
729
748
|
}
|
|
730
749
|
|
|
731
750
|
/**
|
|
732
|
-
* The script the Custodian runs: a generated `HELD` table
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
751
|
+
* The script the Custodian runs: a generated `HELD` table, then `dofile` of the fixed program
|
|
752
|
+
* (`CUSTODIAN_PROGRAM`, mounted beside it from `jr2-held`). The table names every secret this pod
|
|
753
|
+
* holds — the Sandbox token first, then the manifest's — with the headers and paths each may ride
|
|
754
|
+
* and the targets each is bound to. It never holds a value: the program reads those from the
|
|
755
|
+
* mounted files at start.
|
|
736
756
|
*/
|
|
737
757
|
export function custodianScript(manifest: HeldManifest): string {
|
|
738
758
|
const lines = [
|
|
739
|
-
"-- Generated by `jr2 up` from held
|
|
759
|
+
"-- Generated by `jr2 up` from its pod's held manifest (ADR-0059). Names, headers, paths, targets; never a value.",
|
|
740
760
|
`HELD = { secrets = {}, targets = {}, hosts = ${targetsOf(manifest).length} }`,
|
|
741
761
|
`HELD.credential = ${luaList(CREDENTIAL_HEADERS)}`,
|
|
742
762
|
`HELD.secrets[1] = { name = ${lua(SANDBOX_TOKEN_NAME)}, headers = { "authorization" } }`,
|
|
@@ -750,7 +770,10 @@ export function custodianScript(manifest: HeldManifest): string {
|
|
|
750
770
|
const refs = t.secrets.map((s) => `HELD.secrets[${manifest.secrets.indexOf(s) + 2}]`);
|
|
751
771
|
lines.push(`HELD.targets[${lua(t.key)}] = { ${refs.join(", ")} }`);
|
|
752
772
|
}
|
|
753
|
-
|
|
773
|
+
// `dofile`, not a copy: Envoy's Lua opens the standard libraries (the program already reads its
|
|
774
|
+
// values with `io`), and the program's top-level code runs here, after the table it reads.
|
|
775
|
+
lines.push(`dofile(${lua(`${CUSTODIAN_CONFIG}/${CUSTODIAN_PROGRAM_KEY}`)})`);
|
|
776
|
+
return `${lines.join("\n")}\n`;
|
|
754
777
|
}
|
|
755
778
|
|
|
756
779
|
// ---- the composition ----------------------------------------------------------------------------
|
|
@@ -758,6 +781,13 @@ export function custodianScript(manifest: HeldManifest): string {
|
|
|
758
781
|
export type CustodianCompositionOptions = {
|
|
759
782
|
/** The Custodian's resolved ref (`custodianRef`, from the image map). */
|
|
760
783
|
image: string;
|
|
784
|
+
/** The pod whose held objects these are (ADR-0059): `INSTANCE_HARNESS_HELD_POD`, or a Workspace's
|
|
785
|
+
* `workspaceHeldPod` — its manifest and its literal values' Secret. */
|
|
786
|
+
pod: string;
|
|
787
|
+
/** The ConfigMap with THIS pod's rendered `envoy.json` and `custodian.lua` — written by whoever
|
|
788
|
+
* makes the pod: the Orchestrator at provision (`sandboxCustodianConfigMap`, owned by the CR),
|
|
789
|
+
* `jr2 up` for the Instance Harness (`INSTANCE_CUSTODIAN_CONFIGMAP`). */
|
|
790
|
+
config: string;
|
|
761
791
|
/** Where the Sandbox token lives: a Sandbox's `<name>-token` Secret, or the Instance Harness's key
|
|
762
792
|
* of `jr2-instance`. Mounted into the Custodian alone (ADR-0013). */
|
|
763
793
|
token: { secret: string; key: string };
|
|
@@ -766,6 +796,9 @@ export type CustodianCompositionOptions = {
|
|
|
766
796
|
sandbox?: string;
|
|
767
797
|
/** The Instance ships a `harness.caBundle` (ADR-0020). */
|
|
768
798
|
caBundle: boolean;
|
|
799
|
+
/** The Custodian container's resources (ADR-0060) — the placement's to choose: a Sandbox's fixed
|
|
800
|
+
* slice of its Size (requests = limits), the Instance Harness's kit-sized request. */
|
|
801
|
+
resources?: ResourceRequirements;
|
|
769
802
|
};
|
|
770
803
|
|
|
771
804
|
type Container = Record<string, unknown>;
|
|
@@ -838,7 +871,7 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
|
|
|
838
871
|
sources: [
|
|
839
872
|
{ secret: { name: opts.token.secret, items: [{ key: opts.token.key, path: SANDBOX_TOKEN_NAME }] } },
|
|
840
873
|
...(literal.length
|
|
841
|
-
? [{ secret: { name:
|
|
874
|
+
? [{ secret: { name: heldSecretsSecret(opts.pod), items: literal.map((n) => ({ key: n, path: n })) } }]
|
|
842
875
|
: []),
|
|
843
876
|
...manifest.secrets.flatMap((s) =>
|
|
844
877
|
s.source.kind === "secret"
|
|
@@ -848,7 +881,25 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
|
|
|
848
881
|
],
|
|
849
882
|
},
|
|
850
883
|
},
|
|
851
|
-
|
|
884
|
+
// This pod's own bootstrap and script, under the names Envoy is started with, beside the fixed
|
|
885
|
+
// program the script loads — the one thing every pod shares, from `jr2-held`.
|
|
886
|
+
{
|
|
887
|
+
name: "custodian-config",
|
|
888
|
+
projected: {
|
|
889
|
+
sources: [
|
|
890
|
+
{
|
|
891
|
+
configMap: {
|
|
892
|
+
name: opts.config,
|
|
893
|
+
items: [
|
|
894
|
+
{ key: CUSTODIAN_BOOTSTRAP_KEY, path: CUSTODIAN_BOOTSTRAP_KEY },
|
|
895
|
+
{ key: CUSTODIAN_SCRIPT_KEY, path: CUSTODIAN_SCRIPT_KEY },
|
|
896
|
+
],
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
{ configMap: { name: HELD_CONFIGMAP, items: [{ key: CUSTODIAN_PROGRAM_KEY, path: CUSTODIAN_PROGRAM_KEY }] } },
|
|
900
|
+
],
|
|
901
|
+
},
|
|
902
|
+
},
|
|
852
903
|
...(held
|
|
853
904
|
? [
|
|
854
905
|
{ name: "custodian-tls", secret: { secretName: HELD_TLS_SECRET, defaultMode: 0o440 } },
|
|
@@ -887,6 +938,7 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
|
|
|
887
938
|
periodSeconds: 2,
|
|
888
939
|
failureThreshold: 15,
|
|
889
940
|
},
|
|
941
|
+
...(opts.resources !== undefined ? { resources: opts.resources } : {}),
|
|
890
942
|
securityContext: CUSTODIAN_SECURITY,
|
|
891
943
|
};
|
|
892
944
|
|