@jr2/orchestrator 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- 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 +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- 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 +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- 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 +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- 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 +285 -89
- 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 +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
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(
|
|
@@ -105,6 +121,8 @@ export type HarnessEnvVar = {
|
|
|
105
121
|
/** A whole-Secret/ConfigMap env injection (corev1.EnvFromSource) for the Harness container —
|
|
106
122
|
* e.g. `{ secretRef: { name: "anthropic" } }` to hand a real Harness its model API key. */
|
|
107
123
|
export type HarnessEnvFromSource = {
|
|
124
|
+
/** Put before every key's name, as corev1 does. */
|
|
125
|
+
prefix?: string;
|
|
108
126
|
secretRef?: { name: string };
|
|
109
127
|
configMapRef?: { name: string };
|
|
110
128
|
};
|
|
@@ -136,8 +154,9 @@ export type HarnessProvider = {
|
|
|
136
154
|
/** The endpoint — reachable FROM PODS (`localhost` never is; a LAN address works on kind).
|
|
137
155
|
* Deployment-varying → resolve from env (`.env`), never hardcode (ADR-0019). */
|
|
138
156
|
baseUrl: string;
|
|
139
|
-
/** API key, when the endpoint wants one. May read `process.env
|
|
140
|
-
*
|
|
157
|
+
/** API key, when the endpoint wants one. May read `process.env`. It is a held secret
|
|
158
|
+
* (ADR-0059): `jr2 up` gives the value to the Custodian alone, bound to `baseUrl`'s host, and the
|
|
159
|
+
* Harness container holds only its Stand-in. So `baseUrl` must be `https://` when this is set.
|
|
141
160
|
* Genuinely optional: an unauthenticated endpoint needs none (the Harness sends the wire
|
|
142
161
|
* library's placeholder, which vLLM/Ollama ignore). */
|
|
143
162
|
apiKey?: string;
|
|
@@ -153,6 +172,52 @@ export type HarnessProvider = {
|
|
|
153
172
|
models?: Record<string, HarnessProviderModel>;
|
|
154
173
|
};
|
|
155
174
|
|
|
175
|
+
/** Where one of pi's catalog providers sends its calls (ADR-0059) — reach, not a model choice
|
|
176
|
+
* (ADR-0018). The catalog keeps every model fact (limits, cost, thinking levels); this changes only
|
|
177
|
+
* the endpoint, for a gateway that serves the provider's own API (LiteLLM, for example). */
|
|
178
|
+
export type CatalogReach = {
|
|
179
|
+
/** Replaces `model.baseUrl` for every model of this provider — the WHOLE base the provider's API
|
|
180
|
+
* appends to (`https://litellm.corp.example` for `anthropic`, which appends `/v1/messages`;
|
|
181
|
+
* `https://litellm.corp.example/v1` for `openai`). HTTPS only. Deployment-varying → `.env`. */
|
|
182
|
+
baseUrl: string;
|
|
183
|
+
};
|
|
184
|
+
|
|
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. */
|
|
198
|
+
name: string;
|
|
199
|
+
/** The value as a literal, usually read from `process.env`/`.env`. `jr2 up` writes it into the
|
|
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`. */
|
|
202
|
+
value?: string;
|
|
203
|
+
/** Or a key of a Secret you manage (Sealed Secrets, External Secrets). `jr2 up` checks that the
|
|
204
|
+
* Secret and key exist; each placement mounts that one key as a file, and no other container does. */
|
|
205
|
+
valueFrom?: { secretKeyRef: { name: string; key: string } };
|
|
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. */
|
|
211
|
+
hosts?: readonly string[];
|
|
212
|
+
/** Request headers the Stand-in is swapped in, lowercase. Default
|
|
213
|
+
* `["authorization", "x-api-key", "x-goog-api-key", "api-key"]`. */
|
|
214
|
+
headers?: readonly string[];
|
|
215
|
+
/** Optional path prefixes the value may be sent to (`/v1/messages`). Absent → every path. A
|
|
216
|
+
* gateway key that can reach admin routes (a LiteLLM key that can mint keys) should narrow this:
|
|
217
|
+
* a key minted through the Custodian is a key the Agent can take away. */
|
|
218
|
+
paths?: readonly string[];
|
|
219
|
+
};
|
|
220
|
+
|
|
156
221
|
/** The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the
|
|
157
222
|
* Agent definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox`
|
|
158
223
|
* is pod transport (it still CARRIES this env to the Harness container), but model concerns are
|
|
@@ -164,20 +229,25 @@ export type HarnessProvider = {
|
|
|
164
229
|
* One definition value may still be carried by several Machines, so the variation that matters is
|
|
165
230
|
* per-definition and per-invocation, which one global default serves not at all. */
|
|
166
231
|
export type HarnessConfig = {
|
|
167
|
-
/** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019).
|
|
232
|
+
/** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019). Its
|
|
233
|
+
* `apiKey` is a held secret, `JR2_PROVIDER_API_KEY`, bound to `baseUrl`'s host (ADR-0059). */
|
|
168
234
|
provider?: HarnessProvider;
|
|
169
|
-
/**
|
|
170
|
-
* `
|
|
235
|
+
/** pi catalog providers whose calls go somewhere else, keyed by the catalog provider id
|
|
236
|
+
* (`anthropic`, `openai`, ...) — a gateway that serves the provider's own API (ADR-0059). */
|
|
237
|
+
catalog?: Readonly<Record<string, CatalogReach>>;
|
|
238
|
+
/** Env vars for the Harness container. Values read from `process.env`/`.env` are materialized
|
|
239
|
+
* into the instance-owned Secret by `jr2 up`. The Agent can read every one of them, so a known
|
|
240
|
+
* model key here is refused — it goes in the top-level `secrets` list (ADR-0059). */
|
|
171
241
|
env?: readonly HarnessEnvVar[];
|
|
172
242
|
/** Whole-Secret/ConfigMap env for the Harness container — `envFrom` refs to Secrets YOU manage
|
|
173
|
-
* (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019)
|
|
243
|
+
* (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019), and
|
|
244
|
+
* refuses one that carries a known model key (ADR-0059). */
|
|
174
245
|
envFrom?: readonly HarnessEnvFromSource[];
|
|
175
246
|
/** Path to a PEM CA bundle, RELATIVE to the instance folder — commit the file (CA certs are
|
|
176
247
|
* public; e.g. an internal CA in front of a LAN vLLM). Only `jr2 up` reads it (host-side): it
|
|
177
248
|
* materializes the `jr2-ca` ConfigMap and runs the provider preflight with the same trust. The
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* ADR-0013/0020). */
|
|
249
|
+
* Harness container trusts it, and so does the Custodian when it verifies a bound host
|
|
250
|
+
* (ADR-0020, ADR-0059). */
|
|
181
251
|
caBundle?: string;
|
|
182
252
|
};
|
|
183
253
|
|
|
@@ -207,19 +277,59 @@ export type SandboxPlacement = {
|
|
|
207
277
|
tolerations?: readonly Toleration[];
|
|
208
278
|
};
|
|
209
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
|
+
|
|
210
310
|
export type JR2Config = {
|
|
211
311
|
/** The instance's identity (ADR-0019): its kube namespace defaults to this (`-n` overrides),
|
|
212
312
|
* and `jr2 up` labels every object it owns with it. Default: the instance folder's name. */
|
|
213
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[];
|
|
214
320
|
/** How the cluster authenticates to Repos, and the fence a per-run url must pass (ADR-0051).
|
|
215
321
|
* NOT a list of Repos: a Machine names those itself, through its `workspace()`'s Repo Slots, and
|
|
216
322
|
* whether the instance has a data plane at all is read off the registered Machines. */
|
|
217
323
|
git?: GitConfig;
|
|
218
324
|
/** Agent-runtime config for the stock Harness (see `HarnessConfig`). */
|
|
219
325
|
harness?: HarnessConfig;
|
|
220
|
-
/** Which nodes are this Instance's Sandbox nodes (see `SandboxPlacement`, ADR-0052)
|
|
221
|
-
*
|
|
222
|
-
|
|
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;
|
|
223
333
|
/** Image registry prefix (deployment-varying — resolve from env). Absent → images are
|
|
224
334
|
* `kind load`-ed; present → pushed. A non-kind cluster without one fails loudly (ADR-0019). */
|
|
225
335
|
registry?: string;
|
|
@@ -231,7 +341,7 @@ export type JR2Config = {
|
|
|
231
341
|
*
|
|
232
342
|
* Separate from `registry` on purpose: `registry` addresses images THIS converge builds,
|
|
233
343
|
* `kitRegistry` addresses artifacts the kit already published. One key for both would make every
|
|
234
|
-
* private-registry user mirror
|
|
344
|
+
* private-registry user mirror the Kit images they could have pulled from the home. */
|
|
235
345
|
kitRegistry?: string;
|
|
236
346
|
/** What `jr2 up` builds its images FOR — docker platform strings, e.g. `["linux/arm64"]`
|
|
237
347
|
* (deployment-varying — resolve from env). Absent → derived from the cluster's schedulable nodes
|
|
@@ -260,10 +370,13 @@ export function defineConfig(c: JR2Config): JR2Config {
|
|
|
260
370
|
/**
|
|
261
371
|
* Load an instance's `jr2.config.ts` (default export). Absent file → undefined (an instance
|
|
262
372
|
* can boot configless); a file that fails to IMPORT throws — a broken config must be loud,
|
|
263
|
-
* never silently treated as "no config". The
|
|
264
|
-
*
|
|
265
|
-
* instance is zero-build and nothing typechecks this file before
|
|
266
|
-
* imports it.
|
|
373
|
+
* never silently treated as "no config". The shape checks live here, on the path the host CLI
|
|
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
|
|
377
|
+
* `value` reads `.env`, which the in-cluster evaluation does not have, so an absent value is not
|
|
378
|
+
* this function's to refuse — `jr2 up` checks presence and every held-secret rule host-side
|
|
379
|
+
* (`heldSecretsOf`, ADR-0059).
|
|
267
380
|
*/
|
|
268
381
|
export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
269
382
|
const file = join(dir, "jr2.config.ts");
|
|
@@ -271,11 +384,29 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
|
271
384
|
const mod = (await import(pathToFileURL(file).href)) as { default?: JR2Config };
|
|
272
385
|
if (!mod.default) throw new Error(`${file} has no default export (use \`export default defineConfig({…})\`)`);
|
|
273
386
|
if (mod.default.git !== undefined) checkGit(mod.default.git, file);
|
|
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);
|
|
274
391
|
return mod.default;
|
|
275
392
|
}
|
|
276
393
|
|
|
277
394
|
const NonEmpty = z.string().min(1);
|
|
278
|
-
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();
|
|
279
410
|
|
|
280
411
|
/** `git.credentials`' SHAPE, checked at load — by index and field, so a bad entry names itself
|
|
281
412
|
* (`git.credentials[1].token`) rather than surfacing as a Secret that never matches. */
|
|
@@ -294,4 +425,84 @@ function checkGit(git: unknown, where: string): void {
|
|
|
294
425
|
}
|
|
295
426
|
}
|
|
296
427
|
|
|
297
|
-
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)";
|
|
431
|
+
|
|
432
|
+
const OptionalText = z.string().optional();
|
|
433
|
+
const SecretShape = z
|
|
434
|
+
.object({
|
|
435
|
+
name: NonEmpty,
|
|
436
|
+
// `.env` values are absent in-cluster, so `undefined` must pass here — presence is `jr2 up`'s.
|
|
437
|
+
value: OptionalText,
|
|
438
|
+
valueFrom: z
|
|
439
|
+
.object({ secretKeyRef: z.object({ name: NonEmpty, key: NonEmpty }).strict() })
|
|
440
|
+
.strict()
|
|
441
|
+
.optional(),
|
|
442
|
+
hosts: z.array(z.string()).optional(),
|
|
443
|
+
headers: z.array(z.string()).optional(),
|
|
444
|
+
paths: z.array(z.string()).optional(),
|
|
445
|
+
})
|
|
446
|
+
.strict();
|
|
447
|
+
const CatalogReachShape = z.object({ baseUrl: z.string() }).strict();
|
|
448
|
+
|
|
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 {
|
|
464
|
+
if (typeof harness !== "object" || harness === null || Array.isArray(harness)) {
|
|
465
|
+
throw new Error(`${where} at harness: expected an object`);
|
|
466
|
+
}
|
|
467
|
+
const { catalog } = harness as { catalog?: unknown };
|
|
468
|
+
if (catalog !== undefined) {
|
|
469
|
+
if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) {
|
|
470
|
+
throw new Error(`${where} at harness.catalog: expected an object keyed by catalog provider id — ${CATALOG_HINT}`);
|
|
471
|
+
}
|
|
472
|
+
for (const [id, reach] of Object.entries(catalog)) {
|
|
473
|
+
const parsed = CatalogReachShape.safeParse(reach);
|
|
474
|
+
if (parsed.success) continue;
|
|
475
|
+
const issue = parsed.error.issues[0];
|
|
476
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
477
|
+
throw new Error(`${where} at harness.catalog.${id}${at}: ${issue?.message ?? "invalid"} — ${CATALOG_HINT}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const SECRET_HINT =
|
|
483
|
+
"an entry is { name, value? | valueFrom?: { secretKeyRef: { name, key } }, hosts?, headers?, paths? } (ADR-0059, ADR-0068)";
|
|
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
|
+
}
|