@jr2/cli 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/build.ts CHANGED
@@ -1,27 +1,29 @@
1
- // The image build seam (ADR-0019/0038): `jr2 up` builds EVERY image it deploys. There are three
2
- // kinds — the instance's own (engine + this instance's workflows baked, ADR-0008), the instance's
3
- // Sandbox Images (the `file:` docker contexts its Machines carry, ADR-0037/0049), and — only when the
4
- // CLI is running out of
5
- // a kit CHECKOUT — the Harness, Adapter, and operator images. Installed from npm those kit sources
6
- // do not resolve, so a real instance takes the published-`<kitversion>` path and never needs docker
7
- // for them. The checkout IS the signal: no flag, no config key, no env.
1
+ // The image build seam (ADR-0019/0038): `jr2 up` builds EVERY image it deploys. There are two
2
+ // kinds — the Instance's Sandbox Images (the `file:` docker contexts its Machines carry,
3
+ // ADR-0037/0049), and — only when the CLI is running out of a kit CHECKOUT — the Kit images: the
4
+ // Harness, the operator and the Orchestrator (ADR-0069). An Instance produces no image of its own:
5
+ // its Workflows ride the Assembly `jr2 up` publishes (assembly.ts). Installed from npm the kit
6
+ // sources do not resolve, so a real instance takes the published-`<kitversion>` path and never
7
+ // needs docker for them. The checkout IS the signal: no flag, no config key, no env. One image is
8
+ // deployed and never built, in either world: the Custodian's, upstream Envoy pinned by digest
9
+ // (ADR-0038, ADR-0059).
8
10
  //
9
11
  // Every tag is a content address of its own inputs. That is what makes `imagePullPolicy:
10
12
  // IfNotPresent` correct rather than lucky (a unique tag per content means "present" implies
11
13
  // "current"), what lets a converge skip exactly what has not moved, and what makes the tag-equality
12
- // check in `verifyRunningImage` sound for every layer instead of only the instance's.
14
+ // check in `verifyRunningImage` sound for every layer.
13
15
  //
14
16
  // A Sandbox Image is ONE `docker build` of the user's own Dockerfile straight to its content tag
15
17
  // (ADR-0037): no kit-owned second stage, no intermediate tag, and the resolved harness ref is NOT
16
- // one of its hash inputs. The Harness arrives at POD time instead — an init container populates an
17
- // `/opt/jr2` volume from the kit's harness image — so the runtime's version rides the volume, a kit
18
+ // one of its hash inputs. The Harness arrives at POD time instead — every Sandbox mounts the kit's
19
+ // harness image as an `image` volume at `/opt/jr2` — so the runtime's version rides the volume, a kit
18
20
  // edit re-images future pods without moving one Sandbox Image tag, and an image the user merely
19
21
  // BROUGHT (a registry ref) is possible at all. Refs are deployed-never-built: nothing in this file
20
22
  // ever sees one.
21
23
  //
22
24
  // Content addressing also MAKES garbage — ten Dockerfile iterations leave ten full images — so the
23
25
  // same seam owns the collector (ADR-0039). Two facts shape it: every image jr2 builds is STAMPED
24
- // (`jr2.dev/kind`, plus `jr2.dev/instance` on the instance-owned kinds) at build time, so ownership is
26
+ // (`jr2.dev/kind`, plus `jr2.dev/instance` on a Sandbox Image) at build time, so ownership is
25
27
  // read off the image instead of parsed out of its name; and an image is garbage iff no live root
26
28
  // names its ref. The reachability part — assembling the keep set from the cluster — belongs to the
27
29
  // commands layer; what lives here is the part that touches images: the two stores' physics, the pure
@@ -30,19 +32,17 @@
30
32
  // `BuildPort` is what the converge logic drives (tests fake it); `pnpmDockerBuild` is the real one:
31
33
  // pnpm + docker + kind + crictl subprocesses.
32
34
 
33
- import { createHash } from "node:crypto";
34
- import { execFile, spawn } from "node:child_process";
35
- import { cp, mkdir, mkdtemp, readdir, readFile, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
36
- import { tmpdir } from "node:os";
37
- import { basename, dirname, join, relative, resolve } from "node:path";
35
+ import { execFile } from "node:child_process";
36
+ import { cp, readdir, readFile, rm, stat } from "node:fs/promises";
37
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
  import { promisify } from "node:util";
40
- import { KIT_VERSION } from "@jr2/orchestrator";
40
+ import { contentHash, KIT_VERSION } from "@jr2/orchestrator";
41
41
  import { LABEL_INSTANCE } from "./deploy.ts";
42
42
 
43
43
  const exec = promisify(execFile);
44
44
 
45
- /** One `docker build`. Exactly one of `dockerfile`/`dockerfileContent` may be set. */
45
+ /** One `docker build`. */
46
46
  export type BuildRequest = {
47
47
  /** The tag to build — always a content address of (inputs × platform set), ADR-0038/0045. */
48
48
  tag: string;
@@ -57,16 +57,11 @@ export type BuildRequest = {
57
57
  /** `-f <path>`: a Dockerfile COMMITTED in the repo, whose context is somewhere else (the kit
58
58
  * images build from the kit root; a Sandbox Image's own Dockerfile is its context's default). */
59
59
  dockerfile?: string;
60
- /** `-f -`: a Dockerfile jr2 GENERATES (the instance image — the only one left, now that a Sandbox
61
- * Image is the user's file alone). Fed on stdin rather than written into the context, so a
62
- * generated file can never be mistaken for a user's own and can never perturb the content hash
63
- * of the directory it is built from. */
64
- dockerfileContent?: string;
65
60
  /** `--label k=v`: who built this image (ADR-0039). Stamped at BUILD time, never written into a
66
61
  * Dockerfile — the user's file keeps zero jr2 knowledge (ADR-0037) and the committed kit
67
62
  * Dockerfiles stay plain. The labels ride the image config through `kind load` into containerd,
68
63
  * so both stores can read provenance back, and the sweep touches labeled images and nothing
69
- * else. Use {@link kitImageLabels}/{@link instanceImageLabels}/{@link sandboxImageLabels}: an
64
+ * else. Use {@link kitImageLabels}/{@link sandboxImageLabels}: an
70
65
  * unstamped build is an image no sweep can ever collect. */
71
66
  labels?: Record<string, string>;
72
67
  };
@@ -87,6 +82,11 @@ export type BuildPort = {
87
82
  * is on the host daemon (`docker inspect`), while a multi-platform one went straight to the
88
83
  * registry as a manifest list the daemon never held (`docker buildx imagetools inspect`). */
89
84
  imageUser(image: string, platforms: readonly string[]): Promise<string>;
85
+ /** What a Sandbox Effector's image holds for a push (ADR-0068): which of `paths` it has,
86
+ * links followed, and its `/etc/passwd` (`""` when it has none). Read off a container created and
87
+ * never started, so nothing of the image runs. A multi-platform build is read for its first
88
+ * platform: the files a push needs do not differ by architecture. */
89
+ imageFiles(image: string, platforms: readonly string[], paths: readonly string[]): Promise<ImageFiles>;
90
90
 
91
91
  /** What this host can build for: its own platform, plus every foreign one binfmt emulation
92
92
  * registers (`docker buildx inspect`'s `Platforms:` line). The converge's binfmt preflight
@@ -121,15 +121,6 @@ export type BuildPort = {
121
121
  removeNodeImage(cluster: string, node: string, id: string): Promise<void>;
122
122
  };
123
123
 
124
- /**
125
- * Nothing is excluded from a BUNDLE hash (ADR-0038). The old exclude set named `.modules.yaml` and
126
- * `.bin` — exactly the entries that varied between two stagings — so the tag stood still while the
127
- * bytes moved, and the mechanism that should have exposed the drift was the one hiding it. The
128
- * bundle is SEALED instead (`sealInstanceBundle`), and the empty default is what guards the seal:
129
- * with nothing excluded, a bundle that ever varies again re-tags on every converge, in the open,
130
- * where a rebuild-and-reload every single time is impossible to miss.
131
- */
132
-
133
124
  /**
134
125
  * Exclude sets for hashing KIT source directories — one per build CONTEXT, because an exclusion is
135
126
  * only sound when the `.dockerignore` governing that context really drops the entry. Excluding
@@ -144,7 +135,7 @@ export type BuildPort = {
144
135
  * context and carries no `.dockerignore`, so docker copies `dist/` and `node_modules/` straight in.
145
136
  */
146
137
 
147
- /** For walks under `packages/*` (harness, adapter): their context is the KIT ROOT, so the root
138
+ /** For walks under `packages/*` (the Harness, the Orchestrator): their context is the KIT ROOT, so the root
148
139
  * `.dockerignore` governs, and the only entries it drops at any depth are `**\/node_modules` and
149
140
  * `**\/dist` (plus `*.log`/`.env` globs `contentHash`'s name-set cannot express — hashing a stray
150
141
  * one of those over-hashes, which is allowed). A `packages/harness/bin/` would be context-VISIBLE,
@@ -158,59 +149,9 @@ const KIT_PACKAGE_EXCLUDE = new Set(["node_modules", "dist"]);
158
149
  * rest of the non-go tree (Makefile, config/, hack/) stays hashed: over-hash, the cheap side. */
159
150
  const KIT_OPERATOR_EXCLUDE = new Set(["bin", "testbin", "cover.out"]);
160
151
 
161
- /**
162
- * A short content hash over `paths` (files and/or directories, sorted walk), salted with `salt`.
163
- * Symlinks are skipped rather than followed: in a pnpm bundle `node_modules/<pkg>` links into the
164
- * virtual store, whose real files the walk already visits — following would hash the same bytes
165
- * twice. Each path contributes its entries under its own index prefix, so two directories that
166
- * happen to hold the same relative filenames stay distinguishable.
167
- */
168
- export async function contentHash(paths: string[], salt: string, exclude: Set<string> = new Set()): Promise<string> {
169
- const h = createHash("sha256");
170
- h.update(`salt:${salt}\n`);
171
- const walk = async (root: string, d: string, index: number): Promise<void> => {
172
- const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
173
- for (const e of entries) {
174
- if (exclude.has(e.name)) continue;
175
- const p = join(d, e.name);
176
- if (e.isDirectory()) await walk(root, p, index);
177
- else if (e.isFile()) {
178
- h.update(`${index}:${relative(root, p)}\n`);
179
- h.update(await readFile(p));
180
- }
181
- }
182
- };
183
- for (const [index, p] of paths.entries()) {
184
- const st = await stat(p);
185
- if (st.isDirectory()) await walk(p, p, index);
186
- else {
187
- h.update(`${index}:${basename(p)}\n`);
188
- h.update(await readFile(p));
189
- }
190
- }
191
- return h.digest("hex").slice(0, 12);
192
- }
193
-
194
- /** The Dockerfile every instance image is built from — generated, never user-authored (ADR-0019).
195
- * No git and no ssh client: the Orchestrator clones nothing (ADR-0051) — the cache agent on each
196
- * node does, reading the credential Secret a Repo's `secretRef` names (ADR-0047), and the attach's
197
- * git runs inside the Sandbox pod over `kubectl exec` (ADR-0004).
198
- * kubectl: the Sandbox backend shells it against the pod's ServiceAccount (sandbox-kubectl).
199
- * tsx: in the bundle the kit's `.ts` sources live under node_modules (materialized, not
200
- * workspace-linked), where Node's own type stripping refuses to run — so the image runs the
201
- * entrypoint through tsx. An image-runtime detail only; the repo stays zero-build. */
202
- export const INSTANCE_DOCKERFILE = `FROM node:24-slim
203
- RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \\
204
- && curl -fsSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/v1.31.4/bin/linux/$(dpkg --print-architecture)/kubectl" \\
205
- && chmod +x /usr/local/bin/kubectl \\
206
- && apt-get purge -y curl && rm -rf /var/lib/apt/lists/* \\
207
- && npm install -g --no-audit --no-fund tsx@4
208
- WORKDIR /instance
209
- COPY . .
210
- ENV NODE_ENV=production
211
- EXPOSE 4000
212
- CMD ["tsx", "node_modules/@jr2/orchestrator/bin/server.ts"]
213
- `;
152
+ // `contentHash` lives in `@jr2/orchestrator` (ADR-0069): the door recomputes an Assembly's hash
153
+ // with the same function. Re-exported so every CLI caller keeps its import.
154
+ export { contentHash } from "@jr2/orchestrator";
214
155
 
215
156
  // --- ownership: who built this image (ADR-0039) ------------------------------------------------
216
157
 
@@ -223,10 +164,11 @@ CMD ["tsx", "node_modules/@jr2/orchestrator/bin/server.ts"]
223
164
  */
224
165
  export const LABEL_IMAGE_KIND = "jr2.dev/kind";
225
166
 
226
- /** The three kinds ADR-0038 builds, and the whole value domain of {@link LABEL_IMAGE_KIND}. */
227
- export type ImageKind = "instance" | "sandbox" | "kit";
167
+ /** The two kinds jr2 builds, and the whole value domain of {@link LABEL_IMAGE_KIND}. The
168
+ * per-Instance image retired with ADR-0069: an Instance publishes an Assembly instead. */
169
+ export type ImageKind = "sandbox" | "kit";
228
170
 
229
- /** The kit's own images (Harness, Adapter, operator). No instance label: every instance on the
171
+ /** The kit's own images (Harness, operator, Orchestrator). No instance label: every instance on the
230
172
  * cluster shares one copy, and "kit images are never swept" is not a rule any more — a kit ref is
231
173
  * kept because some instance's map or pod names it, and collects with everything else when the
232
174
  * last instance leaves (ADR-0039). */
@@ -234,13 +176,6 @@ export function kitImageLabels(): Record<string, string> {
234
176
  return { [LABEL_IMAGE_KIND]: "kit" };
235
177
  }
236
178
 
237
- /** The instance's own image (engine + workflows baked). `jr2.dev/instance` is the SAME key the
238
- * Namespace and the rest of the converged objects wear (deploy.ts) — deliberately one word for one
239
- * owner, whether it labels a Kubernetes object or an image config. */
240
- export function instanceImageLabels(instance: string): Record<string, string> {
241
- return { [LABEL_IMAGE_KIND]: "instance", [LABEL_INSTANCE]: instance };
242
- }
243
-
244
179
  /** A Sandbox Image — the user's Dockerfile, built once, straight to its content tag (ADR-0037).
245
180
  * The stamp is applied on the command line, never written into the Dockerfile: the file stays the
246
181
  * user's, with zero jr2 knowledge in it (ADR-0039). A ref the user merely BROUGHT is never stamped,
@@ -257,9 +192,9 @@ export function sandboxImageLabels(instance: string): Record<string, string> {
257
192
  * same list: two hand-kept copies desynchronize silently, so `test/kit-push.test.ts` reads the
258
193
  * script's default and fails the gate when they drift.
259
194
  *
260
- * An arch outside this set is never built for. An instance image for `s390x` is dead weight: no Kit
261
- * image could sit beside it in the pod, so the Sandbox would fail at the Adapter or the Harness
262
- * instead of at the image nobody published.
195
+ * An arch outside this set is never built for. A Sandbox Image for `s390x` is dead weight: no Kit
196
+ * image could sit beside it in the pod, so the Sandbox would fail at the Harness instead of at
197
+ * the image nobody published.
263
198
  */
264
199
  export const SUPPORTED_PLATFORMS: readonly string[] = ["linux/amd64", "linux/arm64"];
265
200
 
@@ -365,8 +300,9 @@ export async function assertEmulation(port: BuildPort, platforms: readonly strin
365
300
 
366
301
  // --- the kit images (ADR-0038) --------------------------------------------------------------
367
302
 
368
- /** The three images the KIT owns. An instance deploys them but never authors them. */
369
- export type KitImageName = "harness" | "adapter" | "operator";
303
+ /** The images the KIT owns and builds. An Instance deploys them but never authors them. The
304
+ * Orchestrator joined them in ADR-0069: an Instance publishes an Assembly, and builds no image. */
305
+ export type KitImageName = "harness" | "operator" | "orchestrator";
370
306
  export type KitImageRefs = Record<KitImageName, string>;
371
307
 
372
308
  type KitImage = {
@@ -391,11 +327,11 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
391
327
  repo: "jr2-harness",
392
328
  dockerfile: "deploy/harness/Dockerfile",
393
329
  context: ".",
394
- // The whole `deploy/harness/` directory, not just its Dockerfile: since ADR-0037 the image also
395
- // ships `init-copy`, the script the init container runs to publish /opt/jr2 onto a Sandbox's
396
- // volume. Naming the two files by hand is the desynchronization this over-hash rule exists to
397
- // delete — an init-copy edit would move no tag, and `jr2 up` would report convergence onto pods
398
- // injecting the previous script. The directory covers whatever the next `COPY` adds.
330
+ // The whole `deploy/harness/` directory, not just its Dockerfile: the image also compiles
331
+ // `work-acl.c`, the static program the attach runs in every Sandbox (ADR-0005/0037). Naming the
332
+ // files by hand is the desynchronization this over-hash rule exists to delete — a work-acl edit
333
+ // would move no tag, and `jr2 up` would report convergence onto pods mounting the previous
334
+ // runtime. The directory covers whatever the next `COPY` adds.
399
335
  //
400
336
  // And the ONE thing this image builds from outside those two trees: `jr2-upload-pack`, the
401
337
  // program behind `origin`'s fetch url in every Sandbox (ADR-0053). Its source lives in the
@@ -405,6 +341,9 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
405
341
  // not the whole module: the rest of `operator/` addresses the operator image, and the program
406
342
  // imports the standard library alone — which the Dockerfile's builder stage enforces by
407
343
  // copying no more than this and downloading nothing.
344
+ //
345
+ // And the Effector (ADR-0068), on /opt/jr2 beside it: its own Go module, again exactly what the
346
+ // Dockerfile's stage copies — never `effector/bin`, the linter and the local build.
408
347
  sources: [
409
348
  "packages/harness",
410
349
  "deploy/harness",
@@ -412,16 +351,12 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
412
351
  "operator/go.sum",
413
352
  "operator/cmd/jr2-upload-pack",
414
353
  "operator/internal/uploadpack",
354
+ "effector/go.mod",
355
+ "effector/cmd",
356
+ "effector/internal",
415
357
  ],
416
358
  exclude: KIT_PACKAGE_EXCLUDE,
417
359
  },
418
- adapter: {
419
- repo: "jr2-adapter",
420
- dockerfile: "deploy/adapter/Dockerfile",
421
- context: ".",
422
- sources: ["packages/adapter", "deploy/adapter/Dockerfile"],
423
- exclude: KIT_PACKAGE_EXCLUDE,
424
- },
425
360
  operator: {
426
361
  repo: "jr2-operator",
427
362
  dockerfile: "operator/Dockerfile",
@@ -429,6 +364,20 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
429
364
  sources: ["operator"],
430
365
  exclude: KIT_OPERATOR_EXCLUDE,
431
366
  },
367
+ orchestrator: {
368
+ repo: "jr2-orchestrator",
369
+ dockerfile: "deploy/orchestrator/Dockerfile",
370
+ context: ".",
371
+ // `@jr2/orchestrator` and every kit package it depends on, which the Dockerfile packs into the
372
+ // image (ADR-0069). `packages/agent-protocol` is a runtime dependency, so an edit there changes
373
+ // the image and must move its tag. The third-party closure (xstate, zod, hono, …) comes from
374
+ // the registry, each package forced to the version `deploy/orchestrator/closure.json` pins, so
375
+ // the tag addresses it through that file. closure.json equals pnpm-lock.yaml's closure, and
376
+ // this list equals the Orchestrator's `workspace:` dependencies, both asserted in
377
+ // test/orchestrator-image.test.ts.
378
+ sources: ["packages/orchestrator", "packages/agent-protocol", "deploy/orchestrator"],
379
+ exclude: KIT_PACKAGE_EXCLUDE,
380
+ },
432
381
  };
433
382
 
434
383
  /**
@@ -451,15 +400,15 @@ export const KIT_IMAGE_HOME = "ghcr.io/snapwich";
451
400
  * home rather than nesting under it: a mirror holds the same tags under its own name, seeded
452
401
  * deliberately (`jr2 kit push`) and never by a converge. It is NOT `config.registry`: that key says
453
402
  * where images this converge BUILDS go, and prefixing both with one key would make every
454
- * private-registry user mirror three images they could have pulled from the home. The version is
403
+ * private-registry user mirror the Kit images they could have pulled from the home. The version is
455
404
  * still the CLI's own — re-homing says where the tags live, never which ones.
456
405
  */
457
406
  export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
458
407
  const home = kitRegistry ?? KIT_IMAGE_HOME;
459
408
  return {
460
409
  harness: `${home}/jr2-harness:${KIT_VERSION}`,
461
- adapter: `${home}/jr2-adapter:${KIT_VERSION}`,
462
410
  operator: `${home}/jr2-operator:${KIT_VERSION}`,
411
+ orchestrator: `${home}/jr2-orchestrator:${KIT_VERSION}`,
463
412
  };
464
413
  }
465
414
 
@@ -468,12 +417,18 @@ export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
468
417
  * requiring BOTH `deploy/harness/Dockerfile` and a `packages/harness/package.json` that names
469
418
  * `@jr2/harness`. Either marker alone matches an unrelated tree — someone else's `deploy/harness`,
470
419
  * or a vendored copy of one package — and a false positive means `jr2 up` tries to docker-build a
471
- * kit that is not there. Installed from npm neither resolves and the answer is `undefined`.
420
+ * kit that is not there. Installed from npm the answer is `undefined`.
421
+ *
422
+ * A module under `node_modules` came from a registry, so the walk stops there: an instance that
423
+ * lives INSIDE a checkout but installs `@jr2/*` from npm would otherwise reach the checkout root and
424
+ * build a Harness at HEAD beside an Orchestrator at the pinned version. The checkout's own instances
425
+ * link the workspace packages, and node resolves those links, so their CLI loads from `packages/`.
472
426
  */
473
427
  export async function detectKitCheckout(
474
428
  fromDir: string = fileURLToPath(new URL(".", import.meta.url)),
475
429
  ): Promise<string | undefined> {
476
430
  let dir = resolve(fromDir);
431
+ if (dir.split(sep).includes("node_modules")) return undefined;
477
432
  for (;;) {
478
433
  if (await isKitRoot(dir)) return dir;
479
434
  const parent = dirname(dir);
@@ -546,7 +501,7 @@ export function kitImageBuild(
546
501
  * `docker images` readable, while the hash is the identity.
547
502
  * Names are for humans and for content addressing only: nothing reads ownership out of this string
548
503
  * any more (ADR-0039). `jr2-sandbox-`, never `jr2-workspace-`: a Workspace is a Machine, and the image
549
- * is the POD's (CONTEXT.md, Sandbox Image's first `Avoid:`). A ref the user merely BROUGHT takes no
504
+ * is the POD's (GLOSSARY.md, Sandbox Image's first `Avoid:`). A ref the user merely BROUGHT takes no
550
505
  * suffix and never passes here — jr2 never builds it, so its platforms are the registry's business. */
551
506
  export function sandboxImageTag(
552
507
  instance: string,
@@ -737,7 +692,7 @@ const emptySweep = (): SweepResult => ({ removed: [], kept: [], failed: [], byte
737
692
  * One report out of several — the host's and every node's, or several converges' (`mergeSweeps` is
738
693
  * associative, so a caller can fold as it goes). Refs are de-duplicated AFTER {@link normalizeRef},
739
694
  * because that is the only way the promise holds: the two stores spell one image differently
740
- * (`jr2-adapter:33a4` on the host, `docker.io/library/jr2-adapter:33a4` on a node), so a raw-string
695
+ * (`jr2-harness:33a4` on the host, `docker.io/library/jr2-harness:33a4` on a node), so a raw-string
741
696
  * set reports one image twice. The merged refs are the normalized spelling — the one the roots, and
742
697
  * the user, name an image by.
743
698
  *
@@ -889,7 +844,7 @@ export function formatBytes(bytes: number): string {
889
844
  * workflow-internal and statically unrecoverable (ADR-0031, the same line that puts an unknown image
890
845
  * name at provision). So a converge cannot know what to hold an image to. The probe lives at the one
891
846
  * place the seat IS known: the `preflight` init step at provision, in the user's own image, on the
892
- * mounted `/opt/jr2` (sandbox-kubectl.ts owns it). That is also the only thing that can ever prove a
847
+ * mounted `/opt/jr2` (sandbox-kube.ts owns it). That is also the only thing that can ever prove a
893
848
  * registry ref, which no converge sees at all — so one prover, not two that can disagree.
894
849
  */
895
850
 
@@ -906,7 +861,7 @@ export type InstallCommand = { command: string; args: string[] };
906
861
  * lockfile with it. jr2 itself depends on no package manager.
907
862
  *
908
863
  * pnpm gets `node-linker=hoisted` so the bundle is flat REAL files whichever PM wrote it: one
909
- * image shape to seal, hash, and resolve from, instead of one per manager.
864
+ * Assembly shape to strip, seal, hash, and resolve from, instead of one per manager (ADR-0069).
910
865
  *
911
866
  * bun's two spellings are ONE row — `bun.lockb` is the binary format `bun.lock` replaced — so an
912
867
  * instance holding both is mid-migration, not ambiguous: one manager, one command, bun's own
@@ -973,9 +928,10 @@ export async function lockfileInstall(instanceDir: string): Promise<InstallComma
973
928
  * input (above); `.jr2/` because it is CLI-local state; `.git/` because history is not image
974
929
  * content; `.env`/`.env.*` and `.npmrc` because those are the two files a user keeps credentials
975
930
  * in — `jr2 init` writes `.env` into the scaffold's `.gitignore` saying exactly that, and `jr2 up`
976
- * reads it HOST-side into the Orchestrator's Secret (ADR-0019). Copied, they would bake a
977
- * credential into an image layer AND into the content address that names it, so rotating a key
978
- * would re-tag and roll the Orchestrator. The workspace branch already drops both: `pnpm deploy`
931
+ * reads it HOST-side into the Secrets it writes for the pods that spend each value, never into
932
+ * the Orchestrator's (ADR-0019, ADR-0068). Copied, they would put a
933
+ * credential into the Assembly AND into the content address that names it, so rotating a key
934
+ * would publish a new Assembly (ADR-0069). The workspace branch already drops both: `pnpm deploy`
979
935
  * filters through npm-packlist. A registry the frozen install needs reaches it the way a
980
936
  * deployment-varying value should — the environment (`npm_config_registry`) or the user-level
981
937
  * npmrc — neither of which is image content.
@@ -1008,7 +964,7 @@ const execCommand: RunCommand = async (command, args, cwd) => {
1008
964
  * workspace above it says nothing about how it installs.
1009
965
  * 2. A `pnpm-workspace.yaml` above it. A workspace member carries no lockfile of its own — the
1010
966
  * workspace root holds it — so it is the ABSENCE of one that sends the instance to `pnpm deploy
1011
- * --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
967
+ * --legacy`, hoisted like the lockfile path. pnpm is a contributor prerequisite, like go for the operator, never a
1012
968
  * product dependency.
1013
969
  * 3. Neither: {@link lockfileInstall}'s named refusal.
1014
970
  *
@@ -1017,7 +973,7 @@ const execCommand: RunCommand = async (command, args, cwd) => {
1017
973
  * the user's own monorepo. Asking "is there a workspace file above me" FIRST — the previous rule —
1018
974
  * answered a question about an ancestor, and sent shape (1) down `pnpm deploy`, which pnpm answers
1019
975
  * for a non-member with "No projects matched the filters" and exit 0: no bundle, and the failure
1020
- * surfaced later as an ENOENT from the seal. That silent success is why the deploy branch checks
976
+ * surfaced later as an ENOENT from the staging. That silent success is why the deploy branch checks
1021
977
  * that the bundle exists, and names the shape when it does not.
1022
978
  */
1023
979
  export async function bundleInstance(
@@ -1030,7 +986,14 @@ export async function bundleInstance(
1030
986
  const pkg = JSON.parse(await readFile(join(instanceDir, "package.json"), "utf8")) as { name?: string };
1031
987
  if (!pkg.name) throw new Error(`${instanceDir}/package.json has no "name" — needed to bundle the instance`);
1032
988
  // --legacy: materialize (copy) workspace deps into the bundle rather than linking them.
1033
- await run("pnpm", ["--filter", pkg.name, "--prod", "deploy", "--legacy", outDir], instanceDir);
989
+ // node-linker=hoisted, as on the lockfile path: a flat `node_modules` of real directories. The
990
+ // Assembly codec is ustar, which cannot hold pnpm's long store entry names, and a flat tree is
991
+ // also what lets the Kit closure strip be a top-level delete (ADR-0069).
992
+ await run(
993
+ "pnpm",
994
+ ["--filter", pkg.name, "--prod", "deploy", "--legacy", "--config.node-linker=hoisted", outDir],
995
+ instanceDir,
996
+ );
1034
997
  // pnpm exits 0 — "No projects matched the filters" — for a `--filter` that names no member.
1035
998
  if (!(await exists(outDir))) {
1036
999
  throw new Error(
@@ -1043,8 +1006,11 @@ export async function bundleInstance(
1043
1006
  }
1044
1007
  // Asked before the copy: an instance with no lockfile must fail on the cheap half.
1045
1008
  const { command, args } = await lockfileInstall(instanceDir);
1009
+ // `verbatimSymlinks`: a relative link inside the Instance stays relative. Without it, `cp` writes
1010
+ // an absolute link back to the source folder, which the seal refuses (ADR-0069).
1046
1011
  await cp(instanceDir, outDir, {
1047
1012
  recursive: true,
1013
+ verbatimSymlinks: true,
1048
1014
  filter: (src) => src === instanceDir || !excludedFromStage(basename(src)),
1049
1015
  });
1050
1016
  await run(command, args, outDir);
@@ -1088,6 +1054,17 @@ async function pnpmWorkspaceRoot(dir: string): Promise<string | undefined> {
1088
1054
 
1089
1055
  const BIG = { maxBuffer: 64 * 1024 * 1024 };
1090
1056
 
1057
+ /** What {@link BuildPort.imageFiles} reads off an image. */
1058
+ export type ImageFiles = { present: string[]; passwd: string };
1059
+
1060
+ /** The one file a `docker cp … -` tar stream carries, as text: a 512-byte header whose size field
1061
+ * (octal, at offset 124) says how long the content after it is. */
1062
+ export function tarFileText(tar: Buffer): string {
1063
+ if (tar.length < 512) return "";
1064
+ const size = parseInt(tar.subarray(124, 136).toString("latin1").replace(/\0.*$/s, "").trim() || "0", 8);
1065
+ return tar.subarray(512, 512 + (Number.isFinite(size) ? size : 0)).toString("utf8");
1066
+ }
1067
+
1091
1068
  /** The real build port: pnpm + docker + kind + crictl subprocesses. */
1092
1069
  export const pnpmDockerBuild: BuildPort = {
1093
1070
  bundle: (instanceDir, outDir) => bundleInstance(instanceDir, outDir),
@@ -1109,7 +1086,7 @@ export const pnpmDockerBuild: BuildPort = {
1109
1086
  * index to the platforms asked for — attestation manifests ride an index as extra
1110
1087
  * `unknown/unknown` entries, read by nothing here.
1111
1088
  */
1112
- async build({ tag, platforms, context, dockerfile, dockerfileContent, labels }) {
1089
+ async build({ tag, platforms, context, dockerfile, labels }) {
1113
1090
  const args =
1114
1091
  platforms.length > 1
1115
1092
  ? ["buildx", "build", "--builder", await ensureMultiArchBuilder(), "--provenance=false", "--push"]
@@ -1117,10 +1094,39 @@ export const pnpmDockerBuild: BuildPort = {
1117
1094
  args.push("--platform", platforms.join(","), "-t", tag);
1118
1095
  for (const [k, v] of Object.entries(labels ?? {})) args.push("--label", `${k}=${v}`);
1119
1096
  if (dockerfile) args.push("-f", dockerfile);
1120
- if (dockerfileContent) args.push("-f", "-");
1121
1097
  args.push(context);
1122
- if (dockerfileContent) await execStdin(["docker", ...args], dockerfileContent);
1123
- else await exec("docker", args, BIG);
1098
+ await exec("docker", args, BIG);
1099
+ },
1100
+
1101
+ async imageFiles(image, platforms, paths) {
1102
+ const platform = platforms.length > 1 ? ["--platform", platforms[0]!] : [];
1103
+ // `--entrypoint` names nothing that must exist: the container is created, read and removed,
1104
+ // never started.
1105
+ const { stdout } = await exec("docker", ["create", ...platform, "--entrypoint", "/jr2-not-run", image], BIG);
1106
+ const container = stdout.trim();
1107
+ try {
1108
+ const present: string[] = [];
1109
+ for (const path of paths) {
1110
+ const found = await exec("docker", ["cp", "-L", `${container}:${path}`, "-"], {
1111
+ ...BIG,
1112
+ encoding: "buffer",
1113
+ }).then(
1114
+ () => true,
1115
+ () => false,
1116
+ );
1117
+ if (found) present.push(path);
1118
+ }
1119
+ const passwd = await exec("docker", ["cp", "-L", `${container}:/etc/passwd`, "-"], {
1120
+ ...BIG,
1121
+ encoding: "buffer",
1122
+ }).then(
1123
+ ({ stdout: tar }) => tarFileText(tar as Buffer),
1124
+ () => "",
1125
+ );
1126
+ return { present, passwd };
1127
+ } finally {
1128
+ await exec("docker", ["rm", "-f", container]).catch(() => undefined);
1129
+ }
1124
1130
  },
1125
1131
 
1126
1132
  async imageUser(image, platforms) {
@@ -1414,172 +1420,3 @@ export async function crictlLabels(
1414
1420
  }
1415
1421
  return out;
1416
1422
  }
1417
-
1418
- /** Run a command with a string on stdin (`docker build -f -`). */
1419
- async function execStdin(argv: string[], stdin: string): Promise<void> {
1420
- await new Promise<void>((ok, fail) => {
1421
- const child = spawn(argv[0]!, argv.slice(1), { stdio: ["pipe", "inherit", "inherit"] });
1422
- child.on("error", fail);
1423
- child.on("close", (code) => (code === 0 ? ok() : fail(new Error(`${argv.join(" ")} exited ${code}`))));
1424
- child.stdin.end(stdin);
1425
- });
1426
- }
1427
-
1428
- /** Where the bundle really lives: the `WORKDIR` of {@link INSTANCE_DOCKERFILE}, which `COPY . .`
1429
- * puts it at. The one path a staged bundle is allowed to name itself by. */
1430
- const BUNDLE_WORKDIR = "/instance";
1431
-
1432
- /** Fatal on purpose — a lenient decode would replace the bytes it could not read (see the seal). */
1433
- const UTF8 = new TextDecoder("utf8", { fatal: true });
1434
-
1435
- /**
1436
- * Every substitution the seal makes, longest needle first. A `.bin` shim's `NODE_PATH` is a CHAIN —
1437
- * the bundle's own `node_modules`, then the `node_modules` of each directory above it, up to the
1438
- * root — and `pnpm deploy` writes the staging path into it in BOTH spellings it knows: the one
1439
- * `mkdtemp` returned, and the one `realpath` resolves it to. The two coincide only where the temp
1440
- * root is a real directory, so anchoring on the returned spelling alone is correct on Linux and
1441
- * wrong on macOS, where `os.tmpdir()` is `/var/folders/…` and `/var` is a symlink to `/private/var`.
1442
- * There the resolved spelling keeps the random `mkdtemp` component, the bundle stays a different
1443
- * artifact every converge, and the tag it is addressed by never notices.
1444
- *
1445
- * The rungs ABOVE the bundle are the same failure one level up — macOS puts a per-boot random
1446
- * segment there (`/var/folders/<xy>/<random>`) — so each is rewritten to the root's, which is
1447
- * where `/instance`'s ancestors actually are. They are matched as `<ancestor>/node_modules` and
1448
- * never as a bare directory: replacing every occurrence of `/tmp` would rewrite that string
1449
- * wherever some dependency's own source happens to hold it.
1450
- */
1451
- async function bundleRewrites(dir: string): Promise<Array<[string, string]>> {
1452
- const spellings = new Set([dir, await realpath(dir)]);
1453
- const rewrites: Array<[string, string]> = [];
1454
- for (const form of spellings) {
1455
- rewrites.push([form, BUNDLE_WORKDIR]);
1456
- for (let up = dirname(form); up !== dirname(up); up = dirname(up)) {
1457
- rewrites.push([`${up}/node_modules`, "/node_modules"]);
1458
- }
1459
- }
1460
- return rewrites.sort(([a], [b]) => b.length - a.length);
1461
- }
1462
-
1463
- /**
1464
- * Seal the staged bundle (ADR-0038): after this, its bytes are a function of its inputs alone.
1465
- * `pnpm deploy` writes the scratch directory into every `.bin` shim's `NODE_PATH`, and that
1466
- * directory is a fresh `mkdtemp` per converge — so the same sources staged twice are two different
1467
- * images under one content-addressed tag, which is "present implies current" broken for the one
1468
- * image every Instance runs. The rewrite targets `/instance` rather than any stable placeholder
1469
- * because that is where the image holds the bundle: the shims go from WRONG to CORRECT, and
1470
- * determinism falls out of fixing them. What gets rewritten, and why it is more than one string,
1471
- * is {@link bundleRewrites}.
1472
- *
1473
- * A file that holds the path but does not decode as UTF-8 stops the converge, named. `/instance` is
1474
- * SHORTER than the scratch path, so rewriting inside a binary slides every offset after it — that
1475
- * ships an Orchestrator image whose executable fails in the cluster, where nothing can attribute
1476
- * it, instead of a converge that failed on the machine that built it.
1477
- */
1478
- async function sealInstanceBundle(dir: string): Promise<void> {
1479
- const rewrites = await bundleRewrites(dir);
1480
- const needles = rewrites.map(([from]) => Buffer.from(from));
1481
- const walk = async (d: string): Promise<void> => {
1482
- for (const e of await readdir(d, { withFileTypes: true })) {
1483
- const p = join(d, e.name);
1484
- // A symlink's CONTENT is its target, and `contentHash` skips symlinks too — so a link naming
1485
- // the staging path would be neither sealed nor addressed: the bundle would vary at a tag that
1486
- // never moved, which is the one failure this seal exists to make impossible, arriving
1487
- // silently. pnpm writes relative targets (a real bundle: 276 links, none absolute), so this
1488
- // throws rather than rewriting — nothing is known about what such a link would mean.
1489
- if (e.isSymbolicLink()) {
1490
- const target = await readlink(p);
1491
- if (rewrites.some(([from]) => target.includes(from))) {
1492
- throw new Error(
1493
- `cannot seal the instance bundle: the symlink ${relative(dir, p)} points at the staging path ` +
1494
- `("${target}"), which would leave the bundle naming a directory the image does not have`,
1495
- );
1496
- }
1497
- continue;
1498
- }
1499
- // Directories and files below are reached through `Dirent`, which is lstat-based, so the walk
1500
- // cannot follow a link out of the bundle.
1501
- if (e.isDirectory()) await walk(p);
1502
- else if (e.isFile()) {
1503
- const bytes = await readFile(p);
1504
- if (!needles.some((needle) => bytes.includes(needle))) continue;
1505
- let text: string;
1506
- try {
1507
- text = UTF8.decode(bytes);
1508
- } catch {
1509
- throw new Error(
1510
- `cannot seal the instance bundle: ${relative(dir, p)} holds the staging path but is not valid UTF-8 — ` +
1511
- `"${BUNDLE_WORKDIR}" is shorter than "${dir}", so rewriting it would corrupt every offset after it`,
1512
- );
1513
- }
1514
- for (const [from, to] of rewrites) text = text.replaceAll(from, to);
1515
- await writeFile(p, text);
1516
- }
1517
- }
1518
- };
1519
- await walk(dir);
1520
- }
1521
-
1522
- /** A materialized instance bundle: the exact bytes the image is built FROM, and their address. */
1523
- export type StagedBundle = {
1524
- /** The bundle directory — the build context. */
1525
- dir: string;
1526
- /** Content address of `dir` (+ the Dockerfile that bakes it): the image tag, and the staleness key. */
1527
- hash: string;
1528
- /** Remove the scratch dir. Always call it; the bundle is a few thousand files. */
1529
- dispose(): Promise<void>;
1530
- };
1531
-
1532
- /**
1533
- * Materialize the bundle and address it by content (ADR-0019 — the deployed image must be derivable
1534
- * from what `up` can see). The bundle, not the instance folder, is the thing hashed: `pnpm deploy`
1535
- * resolves the kit into it, so a kit edit in a workspace checkout and a kit upgrade from the
1536
- * registry both move the hash, by the same rule and with no knowledge of which world it is in.
1537
- * Hashing the instance folder instead missed kit sources entirely, which is how `jr2 up` came to
1538
- * skip builds it needed and report convergence on code it had not deployed.
1539
- *
1540
- * The bundle is SEALED before it is hashed, and NOTHING is excluded from that hash (ADR-0038): a
1541
- * bundle that named its own scratch dir made one tag address many images, and the exclude set that
1542
- * used to paper over it hid exactly the drift it was supposed to expose. With an empty exclude set
1543
- * the failure inverts — anything that ever varies again re-tags on every converge, in the open.
1544
- *
1545
- * `images/` rides along with the rest, and MUST (ADR-0049). A Machine names a Sandbox Image context
1546
- * by `file:` URL — `import.meta.resolve("../images/default")` — and the DEPLOYED Orchestrator reads
1547
- * that folder back: it recomputes the context digest at provision to look up the ref `jr2 up`
1548
- * published under it, which is what lets the two sides agree with no path table between them.
1549
- * Dropping the folder here (it used to be dropped, as host-side-only) converged green and then
1550
- * failed every provision of such a Machine on an ENOENT the converge could never see. So the
1551
- * scaffolded Dockerfile is instance-image content: editing it re-tags the instance and rolls the
1552
- * Orchestrator. ADR-0038's map still buys what it was taken for — the resolved REF stays out of the
1553
- * pod template, so a rebuilt Sandbox Image alone rolls nothing.
1554
- *
1555
- * Staging precedes the staleness decision, so `pnpm deploy` (~1s) runs even on the skip path; the
1556
- * docker build it guards is the expensive half. The Dockerfile salts the hash — it is image content
1557
- * that never lands in the context (it rides `docker build -f -`).
1558
- */
1559
- export async function stageInstanceBundle(port: BuildPort, instanceDir: string): Promise<StagedBundle> {
1560
- const scratch = await mkdtemp(join(tmpdir(), "jr2-image-"));
1561
- const dir = join(scratch, "bundle");
1562
- try {
1563
- await port.bundle(instanceDir, dir);
1564
- // pnpm leaves two files that are nothing but a record of where and when this staging happened
1565
- // — `.modules.yaml` (a `prunedAt` stamp and the scratch paths, written by `pnpm deploy`) and
1566
- // `.pnpm-workspace-state.json` (a `lastValidatedTimestamp`, written by `pnpm install`) — and
1567
- // the image reads neither. Both go — they are the one exception to "nothing is excluded",
1568
- // earned because they are a record OF the staging rather than content of it: the timestamp
1569
- // alone re-addressed every pnpm bundle on every converge, which is a pod-template change on a
1570
- // no-op `up`. The rest of the where-and-when — the shims' baked `NODE_PATH` — the seal
1571
- // corrects.
1572
- for (const record of [".modules.yaml", ".pnpm-workspace-state.json"]) {
1573
- await rm(join(dir, "node_modules", record), { force: true });
1574
- }
1575
- await sealInstanceBundle(dir);
1576
- return {
1577
- dir,
1578
- hash: await contentHash([dir], INSTANCE_DOCKERFILE),
1579
- dispose: () => rm(scratch, { recursive: true, force: true }),
1580
- };
1581
- } catch (err) {
1582
- await rm(scratch, { recursive: true, force: true });
1583
- throw err;
1584
- }
1585
- }