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