@jr2/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +18 -0
- package/bin/jr2.js +38 -0
- package/manifests/operator.yaml +6669 -0
- package/package.json +56 -0
- package/src/build.ts +1551 -0
- package/src/cli.ts +110 -0
- package/src/client.ts +219 -0
- package/src/commands/down.ts +104 -0
- package/src/commands/gc.ts +73 -0
- package/src/commands/init.ts +238 -0
- package/src/commands/kit.ts +141 -0
- package/src/commands/logs.ts +50 -0
- package/src/commands/run.ts +64 -0
- package/src/commands/runs.ts +19 -0
- package/src/commands/send.ts +83 -0
- package/src/commands/status.ts +90 -0
- package/src/commands/up.ts +1402 -0
- package/src/deploy.ts +592 -0
- package/src/env.ts +68 -0
- package/src/index.ts +11 -0
- package/src/instance.ts +105 -0
- package/src/kube.ts +809 -0
- package/src/nodes.ts +74 -0
- package/src/output.ts +211 -0
- package/src/repo-sweep.ts +134 -0
- package/src/run-id.ts +85 -0
- package/src/sse.ts +41 -0
- package/src/sweep.ts +232 -0
- package/src/typecheck.ts +75 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,1551 @@
|
|
|
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.
|
|
8
|
+
//
|
|
9
|
+
// Every tag is a content address of its own inputs. That is what makes `imagePullPolicy:
|
|
10
|
+
// IfNotPresent` correct rather than lucky (a unique tag per content means "present" implies
|
|
11
|
+
// "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.
|
|
13
|
+
//
|
|
14
|
+
// A Sandbox Image is ONE `docker build` of the user's own Dockerfile straight to its content tag
|
|
15
|
+
// (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
|
+
// edit re-images future pods without moving one Sandbox Image tag, and an image the user merely
|
|
19
|
+
// BROUGHT (a registry ref) is possible at all. Refs are deployed-never-built: nothing in this file
|
|
20
|
+
// ever sees one.
|
|
21
|
+
//
|
|
22
|
+
// Content addressing also MAKES garbage — ten Dockerfile iterations leave ten full images — so the
|
|
23
|
+
// 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
|
|
25
|
+
// read off the image instead of parsed out of its name; and an image is garbage iff no live root
|
|
26
|
+
// names its ref. The reachability part — assembling the keep set from the cluster — belongs to the
|
|
27
|
+
// commands layer; what lives here is the part that touches images: the two stores' physics, the pure
|
|
28
|
+
// removal policy over them, and the loop that executes it.
|
|
29
|
+
//
|
|
30
|
+
// `BuildPort` is what the converge logic drives (tests fake it); `pnpmDockerBuild` is the real one:
|
|
31
|
+
// pnpm + docker + kind + crictl subprocesses.
|
|
32
|
+
|
|
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";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
import { promisify } from "node:util";
|
|
40
|
+
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
41
|
+
import { LABEL_INSTANCE } from "./deploy.ts";
|
|
42
|
+
|
|
43
|
+
const exec = promisify(execFile);
|
|
44
|
+
|
|
45
|
+
/** One `docker build`. Exactly one of `dockerfile`/`dockerfileContent` may be set. */
|
|
46
|
+
export type BuildRequest = {
|
|
47
|
+
/** The tag to build — always a content address of (inputs × platform set), ADR-0038/0045. */
|
|
48
|
+
tag: string;
|
|
49
|
+
/** `--platform`: what the bytes are FOR, always explicit (ADR-0045). Never empty, and never
|
|
50
|
+
* left to the daemon default or to a remembered `DOCKER_DEFAULT_PLATFORM` — an implicit platform
|
|
51
|
+
* is what let one tag name an amd64 image on one host and an arm64 image on another. A singleton
|
|
52
|
+
* set is a plain `docker build`; more than one is `docker buildx build --push`, which delivers
|
|
53
|
+
* itself (see {@link pnpmDockerBuild.build}). */
|
|
54
|
+
platforms: readonly string[];
|
|
55
|
+
/** The build context directory. */
|
|
56
|
+
context: string;
|
|
57
|
+
/** `-f <path>`: a Dockerfile COMMITTED in the repo, whose context is somewhere else (the kit
|
|
58
|
+
* images build from the kit root; a Sandbox Image's own Dockerfile is its context's default). */
|
|
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
|
+
/** `--label k=v`: who built this image (ADR-0039). Stamped at BUILD time, never written into a
|
|
66
|
+
* Dockerfile — the user's file keeps zero jr2 knowledge (ADR-0037) and the committed kit
|
|
67
|
+
* Dockerfiles stay plain. The labels ride the image config through `kind load` into containerd,
|
|
68
|
+
* 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
|
|
70
|
+
* unstamped build is an image no sweep can ever collect. */
|
|
71
|
+
labels?: Record<string, string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type BuildPort = {
|
|
75
|
+
/** Materialize the instance package (+ resolved prod deps) into `outDir` — `pnpm deploy` for a
|
|
76
|
+
* workspace member, a staged copy plus a frozen lockfile install for a standalone Instance
|
|
77
|
+
* (ADR-0043; {@link bundleInstance}). */
|
|
78
|
+
bundle(instanceDir: string, outDir: string): Promise<void>;
|
|
79
|
+
/** `docker build` one image. */
|
|
80
|
+
build(req: BuildRequest): Promise<void>;
|
|
81
|
+
/** The image's own declared `USER`, `""` when it declares none. The converge is the ONLY place
|
|
82
|
+
* this is knowable — a provision cannot inspect an image — so the resolved map records it and the
|
|
83
|
+
* port answers two questions off that record: whether ADR-0037's uid-1000 fallback applies, and
|
|
84
|
+
* whether the kubelet will refuse the seat outright.
|
|
85
|
+
*
|
|
86
|
+
* `platforms` says WHERE the artifact is, not which variant to read (ADR-0045): a singleton build
|
|
87
|
+
* is on the host daemon (`docker inspect`), while a multi-platform one went straight to the
|
|
88
|
+
* registry as a manifest list the daemon never held (`docker buildx imagetools inspect`). */
|
|
89
|
+
imageUser(image: string, platforms: readonly string[]): Promise<string>;
|
|
90
|
+
|
|
91
|
+
/** What this host can build for: its own platform, plus every foreign one binfmt emulation
|
|
92
|
+
* registers (`docker buildx inspect`'s `Platforms:` line). The converge's binfmt preflight
|
|
93
|
+
* (ADR-0045, {@link assertEmulation}) is the only caller — a foreign `RUN` step without qemu
|
|
94
|
+
* fails mid-build with the same cryptic `exec format error` that ADR exists to delete, so the
|
|
95
|
+
* question is asked before any build is spent rather than diagnosed after. */
|
|
96
|
+
buildablePlatforms(): Promise<string[]>;
|
|
97
|
+
/** `docker push` — the registry delivery (ADR-0019). */
|
|
98
|
+
push(tag: string): Promise<void>;
|
|
99
|
+
/** `kind load docker-image` — the no-registry delivery onto a kind cluster's nodes. */
|
|
100
|
+
kindLoad(tag: string, cluster: string): Promise<void>;
|
|
101
|
+
|
|
102
|
+
/** Every LABELED image the host daemon holds (ADR-0039) — the daemon filters by label key, so
|
|
103
|
+
* an image jr2 did not build never reaches the policy at all. Reports exact bytes and ALL tags
|
|
104
|
+
* per image id, including the id that has none left (a rebuilt tag leaves its predecessor
|
|
105
|
+
* `<none>:<none>`, still labeled, reachable by no ref — the bulk of an iteration session's
|
|
106
|
+
* garbage). */
|
|
107
|
+
hostImages(): Promise<ObservedImage[]>;
|
|
108
|
+
/** Drop one host ref (`docker rmi <tag|id>`). The host sweep is now the only caller — with the
|
|
109
|
+
* wrap gone there is no intermediate tag to untag (ADR-0037) — and it removes per TAG precisely
|
|
110
|
+
* because `docker rmi` untags, the bytes coming back only with an id's last tag.
|
|
111
|
+
* Delete-if-present. */
|
|
112
|
+
removeHostImage(ref: string): Promise<void>;
|
|
113
|
+
|
|
114
|
+
/** What each of a kind cluster's nodes holds, per node: containerd's own view (`crictl images`),
|
|
115
|
+
* with the ownership label read back per image. Labels are not in CRI's image list — they live
|
|
116
|
+
* in the image config, one `crictl inspecti` away — so the port pays that read and hands the
|
|
117
|
+
* policy one shape for both stores. */
|
|
118
|
+
nodeImages(cluster: string): Promise<NodeImages[]>;
|
|
119
|
+
/** Remove one node image BY ID (`crictl rmi`), which takes every tag on it — CRI has no untag
|
|
120
|
+
* verb, which is why the node policy is a per-id decision. Delete-if-present. */
|
|
121
|
+
removeNodeImage(cluster: string, node: string, id: string): Promise<void>;
|
|
122
|
+
};
|
|
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
|
+
/**
|
|
134
|
+
* Exclude sets for hashing KIT source directories — one per build CONTEXT, because an exclusion is
|
|
135
|
+
* only sound when the `.dockerignore` governing that context really drops the entry. Excluding
|
|
136
|
+
* anything context-visible under-hashes: an edit there changes the image at an unchanged tag, the
|
|
137
|
+
* silent-stale-image bug ADR-0038 exists to delete. The inverse (hashing something the context
|
|
138
|
+
* drops) merely costs a needless rebuild — the direction that ADR chose. So everything else is
|
|
139
|
+
* hashed deliberately, tests included; each entry below is justified against its own ignore file.
|
|
140
|
+
*
|
|
141
|
+
* Named `KIT_` so neither can be reached for a USER directory by accident. A Sandbox Image's
|
|
142
|
+
* context is hashed by the ORCHESTRATOR's `imageContextDigest` instead (ADR-0049 — both sides
|
|
143
|
+
* compute it), which excludes nothing at all, for the same reason: that folder IS the build
|
|
144
|
+
* context and carries no `.dockerignore`, so docker copies `dist/` and `node_modules/` straight in.
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/** For walks under `packages/*` (harness, adapter): their context is the KIT ROOT, so the root
|
|
148
|
+
* `.dockerignore` governs, and the only entries it drops at any depth are `**\/node_modules` and
|
|
149
|
+
* `**\/dist` (plus `*.log`/`.env` globs `contentHash`'s name-set cannot express — hashing a stray
|
|
150
|
+
* one of those over-hashes, which is allowed). A `packages/harness/bin/` would be context-VISIBLE,
|
|
151
|
+
* so it must stay hashed — the old shared set excluded it and lied. */
|
|
152
|
+
const KIT_PACKAGE_EXCLUDE = new Set(["node_modules", "dist"]);
|
|
153
|
+
|
|
154
|
+
/** For the walk of `operator/`: its context is `operator/` itself, governed by
|
|
155
|
+
* `operator/.dockerignore`, an ALLOWLIST (`**` then `!**\/*.go`, go.mod, go.sum). `bin/` (~400 MB
|
|
156
|
+
* of downloaded tooling) and `testbin/` hold no `.go`, and `cover.out` is not one — all three are
|
|
157
|
+
* context-invisible, so excluding them is sound and keeps the converge walk off the tooling. The
|
|
158
|
+
* rest of the non-go tree (Makefile, config/, hack/) stays hashed: over-hash, the cheap side. */
|
|
159
|
+
const KIT_OPERATOR_EXCLUDE = new Set(["bin", "testbin", "cover.out"]);
|
|
160
|
+
|
|
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
|
+
`;
|
|
214
|
+
|
|
215
|
+
// --- ownership: who built this image (ADR-0039) ------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Ownership is a LABEL, not a naming convention (ADR-0039). Name grammar was load-bearing and
|
|
219
|
+
* ambiguous — `jr2-sandbox-<instance>-<name>` has no reserved delimiter, so instance `my` + image
|
|
220
|
+
* `extra-default` and instance `my-extra` + image `default` collide on one repo — and it could not
|
|
221
|
+
* survive its own source: deleting `images/<x>/` orphaned that image's tags, because nothing
|
|
222
|
+
* derived their names any more. A stamp answers both: the image says who built it.
|
|
223
|
+
*/
|
|
224
|
+
export const LABEL_IMAGE_KIND = "jr2.dev/kind";
|
|
225
|
+
|
|
226
|
+
/** The three kinds ADR-0038 builds, and the whole value domain of {@link LABEL_IMAGE_KIND}. */
|
|
227
|
+
export type ImageKind = "instance" | "sandbox" | "kit";
|
|
228
|
+
|
|
229
|
+
/** The kit's own images (Harness, Adapter, operator). No instance label: every instance on the
|
|
230
|
+
* cluster shares one copy, and "kit images are never swept" is not a rule any more — a kit ref is
|
|
231
|
+
* kept because some instance's map or pod names it, and collects with everything else when the
|
|
232
|
+
* last instance leaves (ADR-0039). */
|
|
233
|
+
export function kitImageLabels(): Record<string, string> {
|
|
234
|
+
return { [LABEL_IMAGE_KIND]: "kit" };
|
|
235
|
+
}
|
|
236
|
+
|
|
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
|
+
/** A Sandbox Image — the user's Dockerfile, built once, straight to its content tag (ADR-0037).
|
|
245
|
+
* The stamp is applied on the command line, never written into the Dockerfile: the file stays the
|
|
246
|
+
* user's, with zero jr2 knowledge in it (ADR-0039). A ref the user merely BROUGHT is never stamped,
|
|
247
|
+
* because jr2 never builds it — and what jr2 did not stamp, jr2 does not sweep. */
|
|
248
|
+
export function sandboxImageLabels(instance: string): Record<string, string> {
|
|
249
|
+
return { [LABEL_IMAGE_KIND]: "sandbox", [LABEL_INSTANCE]: instance };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- the platform set (ADR-0045) ---------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The platforms the kit RELEASES for — the supported set, and the only architectures jr2 will build
|
|
256
|
+
* an image for. One constant, because `scripts/kit-push.sh` builds the published Kit images from the
|
|
257
|
+
* same list: two hand-kept copies desynchronize silently, so `test/kit-push.test.ts` reads the
|
|
258
|
+
* script's default and fails the gate when they drift.
|
|
259
|
+
*
|
|
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.
|
|
263
|
+
*/
|
|
264
|
+
export const SUPPORTED_PLATFORMS: readonly string[] = ["linux/amd64", "linux/arm64"];
|
|
265
|
+
|
|
266
|
+
/** `linux/arm64` → `arm64` — the short name a node reports, a tag suffix carries, and `binfmt
|
|
267
|
+
* --install` takes. */
|
|
268
|
+
export function platformArch(platform: string): string {
|
|
269
|
+
return platform.slice(platform.lastIndexOf("/") + 1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The platform half of an image address: `-arm64`, or `-amd64-arm64` for a multi-platform build
|
|
274
|
+
* (ADR-0045). It goes in the TAG rather than the hash salt, and that is the decision: the bytes are
|
|
275
|
+
* a function of (inputs × platform), so a tag that named only the inputs was a lie — and the failure
|
|
276
|
+
* it produced (an amd64 image delivered to an arm64 cluster, surfacing as a rollout timeout) was
|
|
277
|
+
* invisible precisely because the tag did not say. Sorted, so one platform set has one spelling.
|
|
278
|
+
*/
|
|
279
|
+
export function platformSuffix(platforms: readonly string[]): string {
|
|
280
|
+
return [...platforms]
|
|
281
|
+
.map(platformArch)
|
|
282
|
+
.sort()
|
|
283
|
+
.map((arch) => `-${arch}`)
|
|
284
|
+
.join("");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** What one converge builds for, and how it was decided (ADR-0045). */
|
|
288
|
+
export type PlatformChoice = {
|
|
289
|
+
/** The build set: non-empty, sorted, and a subset of {@link SUPPORTED_PLATFORMS}. */
|
|
290
|
+
platforms: string[];
|
|
291
|
+
/** Node architectures reported and skipped — outside the supported set, so never built for.
|
|
292
|
+
* Reported rather than swallowed: a silently ignored node is a pod that will never schedule. */
|
|
293
|
+
skipped: string[];
|
|
294
|
+
/** `nodes` = derived from the cluster; `config` = the `platforms` key spoke instead. */
|
|
295
|
+
source: "config" | "nodes";
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The cluster's nodes choose the platform set (ADR-0045): the schedulable nodes'
|
|
300
|
+
* `.status.nodeInfo.architecture`, mapped to `linux/<arch>` and intersected with the supported set.
|
|
301
|
+
* Reading a singleton set is not an assumption — it is the cluster stating what it can run; only a
|
|
302
|
+
* non-singleton set involves judgement, and that case builds for both rather than guessing.
|
|
303
|
+
*
|
|
304
|
+
* `configured` is the one escape hatch, and it is ABSOLUTE: when set, derivation is skipped entirely.
|
|
305
|
+
* It exists for the two cases derivation cannot see — autoscale-from-zero (the target pool has no
|
|
306
|
+
* nodes yet) and set pollution (an amd64 GPU pool beside arm64 workers costs a needless qemu build).
|
|
307
|
+
* Not additive or subtractive: cleverness the rare case does not earn. An entry outside the
|
|
308
|
+
* supported set is an ERROR rather than a skip, unlike a node's — a key the user typed is a claim,
|
|
309
|
+
* and quietly dropping half of it would build something other than what it asked for.
|
|
310
|
+
*/
|
|
311
|
+
export function choosePlatforms(opts: { nodeArches: string[]; configured?: readonly string[] }): PlatformChoice {
|
|
312
|
+
const supported = new Set(SUPPORTED_PLATFORMS);
|
|
313
|
+
if (opts.configured !== undefined) {
|
|
314
|
+
const asked = [...new Set(opts.configured)].sort();
|
|
315
|
+
const unsupported = asked.filter((p) => !supported.has(p));
|
|
316
|
+
if (asked.length === 0 || unsupported.length > 0) throw noSupportedPlatform(unsupported, "config");
|
|
317
|
+
return { platforms: asked, skipped: [], source: "config" };
|
|
318
|
+
}
|
|
319
|
+
const arches = [...new Set(opts.nodeArches)].sort();
|
|
320
|
+
const platforms = arches.map((a) => `linux/${a}`).filter((p) => supported.has(p));
|
|
321
|
+
const skipped = arches.filter((a) => !supported.has(`linux/${a}`));
|
|
322
|
+
if (platforms.length === 0) throw noSupportedPlatform(arches, "nodes");
|
|
323
|
+
return { platforms, skipped, source: "nodes" };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The empty-intersection error, named: what was found, what the kit publishes, and the one key that
|
|
327
|
+
* overrides the answer. Loud on purpose — the alternative is building for a platform whose pod could
|
|
328
|
+
* never be assembled, and discovering it as a rollout timeout. */
|
|
329
|
+
function noSupportedPlatform(found: string[], source: "config" | "nodes"): Error {
|
|
330
|
+
const what =
|
|
331
|
+
source === "config"
|
|
332
|
+
? `\`platforms\` names ${found.join(", ") || "an empty list"}`
|
|
333
|
+
: `this cluster's schedulable nodes report ${found.join(", ") || "no architecture at all"}`;
|
|
334
|
+
return new Error(
|
|
335
|
+
`${what} — the kit releases images for ${SUPPORTED_PLATFORMS.join(", ")} and nothing else, so an ` +
|
|
336
|
+
`image built for anything else could not be joined by a Kit image in the same pod. ` +
|
|
337
|
+
`Set \`platforms\` in jr2.config.ts (docker platform strings, e.g. "linux/arm64") to name the ` +
|
|
338
|
+
`set to build for.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The binfmt preflight (ADR-0045): a foreign platform's `RUN` steps are EMULATED, and without qemu
|
|
344
|
+
* registered docker fails minutes into the build with the same cryptic `exec format error` this
|
|
345
|
+
* whole ADR exists to delete. So the question is asked before any build is spent, and the answer
|
|
346
|
+
* names the fix.
|
|
347
|
+
*
|
|
348
|
+
* A port that cannot answer at all reads as "no opinion" and the preflight stands aside: this check
|
|
349
|
+
* may only ever turn a late cryptic failure into an early named one, never invent a failure of its
|
|
350
|
+
* own — a docker too broken to list its builder's platforms fails at the build that follows, with
|
|
351
|
+
* docker's own error naming it.
|
|
352
|
+
*/
|
|
353
|
+
export async function assertEmulation(port: BuildPort, platforms: readonly string[]): Promise<void> {
|
|
354
|
+
const buildable = await port.buildablePlatforms().catch((): string[] => []);
|
|
355
|
+
if (buildable.length === 0) return;
|
|
356
|
+
const missing = platforms.filter((p) => !buildable.includes(p));
|
|
357
|
+
if (missing.length === 0) return;
|
|
358
|
+
throw new Error(
|
|
359
|
+
`this host cannot build for ${missing.join(", ")}: a foreign architecture's RUN steps need qemu ` +
|
|
360
|
+
`binfmt emulation, and none is registered (this builder targets ${buildable.join(", ")}). ` +
|
|
361
|
+
`Install it, then re-run:\n` +
|
|
362
|
+
` docker run --privileged --rm tonistiigi/binfmt --install ${missing.map(platformArch).join(",")}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- the kit images (ADR-0038) --------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
/** The three images the KIT owns. An instance deploys them but never authors them. */
|
|
369
|
+
export type KitImageName = "harness" | "adapter" | "operator";
|
|
370
|
+
export type KitImageRefs = Record<KitImageName, string>;
|
|
371
|
+
|
|
372
|
+
type KitImage = {
|
|
373
|
+
/** The image repo — `<repo>:<hash>` built, `<repo>:<kitversion>` published. */
|
|
374
|
+
repo: string;
|
|
375
|
+
/** The committed Dockerfile, relative to the kit root. */
|
|
376
|
+
dockerfile: string;
|
|
377
|
+
/** The docker build context, relative to the kit root. */
|
|
378
|
+
context: string;
|
|
379
|
+
/** What the hash covers, relative to the kit root — deliberately OVER-hashed (ADR-0038): the
|
|
380
|
+
* whole source directory, tests included, rather than the exact file list the Dockerfile copies.
|
|
381
|
+
* A hand-derived list desynchronizes silently the first time a `COPY` is added, which is the
|
|
382
|
+
* invisible-stale-image bug this whole layer deletes; a needless rebuild costs cached seconds. */
|
|
383
|
+
sources: string[];
|
|
384
|
+
/** What the walk skips — only entries this image's OWN `.dockerignore` really drops (see the
|
|
385
|
+
* `KIT_*_EXCLUDE` sets above): context-invisible, so skipping them cannot under-hash. */
|
|
386
|
+
exclude: Set<string>;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
export const KIT_IMAGES: Record<KitImageName, KitImage> = {
|
|
390
|
+
harness: {
|
|
391
|
+
repo: "jr2-harness",
|
|
392
|
+
dockerfile: "deploy/harness/Dockerfile",
|
|
393
|
+
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.
|
|
399
|
+
//
|
|
400
|
+
// And the ONE thing this image builds from outside those two trees: `jr2-upload-pack`, the
|
|
401
|
+
// program behind `origin`'s fetch url in every Sandbox (ADR-0053). Its source lives in the
|
|
402
|
+
// operator's Go module, because the ask and the cache agent that answers it are one decision —
|
|
403
|
+
// so the harness image's address has to cover it, or an edit to the program would move no tag
|
|
404
|
+
// and `jr2 up` would report convergence onto pods running the previous one. Its packages and
|
|
405
|
+
// not the whole module: the rest of `operator/` addresses the operator image, and the program
|
|
406
|
+
// imports the standard library alone — which the Dockerfile's builder stage enforces by
|
|
407
|
+
// copying no more than this and downloading nothing.
|
|
408
|
+
sources: [
|
|
409
|
+
"packages/harness",
|
|
410
|
+
"deploy/harness",
|
|
411
|
+
"operator/go.mod",
|
|
412
|
+
"operator/go.sum",
|
|
413
|
+
"operator/cmd/jr2-upload-pack",
|
|
414
|
+
"operator/internal/uploadpack",
|
|
415
|
+
],
|
|
416
|
+
exclude: KIT_PACKAGE_EXCLUDE,
|
|
417
|
+
},
|
|
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
|
+
operator: {
|
|
426
|
+
repo: "jr2-operator",
|
|
427
|
+
dockerfile: "operator/Dockerfile",
|
|
428
|
+
context: "operator",
|
|
429
|
+
sources: ["operator"],
|
|
430
|
+
exclude: KIT_OPERATOR_EXCLUDE,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Where the published Kit images live (ADR-0044). A bare `jr2-harness:<kitversion>` resolves against
|
|
436
|
+
* `docker.io/library/` on a node, where nothing is — so the canonical home is BAKED, not configured:
|
|
437
|
+
* only a home every user's nodes can pull from makes `npm i -g @jr2/cli && jr2 init && jr2 up` work
|
|
438
|
+
* with zero image plumbing. Public GHCR egress is GitHub's cost, so the project can afford one.
|
|
439
|
+
*/
|
|
440
|
+
export const KIT_IMAGE_HOME = "ghcr.io/snapwich";
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* What an INSTALLED kit deploys: the published `<kitversion>` tags at the canonical home
|
|
444
|
+
* ({@link KIT_IMAGE_HOME}), one release train with the npm version (ADR-0019/0044). These constants
|
|
445
|
+
* used to live in `jr2.config.ts`'s `images` block as its defaults; they belong here instead, because
|
|
446
|
+
* a config key would be an override seat — and "nobody runs a patched Harness against a real
|
|
447
|
+
* cluster" is ADR-0027's no-eject-hatch enforced rather than merely stated.
|
|
448
|
+
*
|
|
449
|
+
* `kitRegistry` re-homes them — `<kitRegistry>/jr2-harness:<kitversion>` — for a self-hosted,
|
|
450
|
+
* air-gapped, or mirror-only cluster, which is ADR-0038's deferred edge, now closed. It replaces the
|
|
451
|
+
* home rather than nesting under it: a mirror holds the same tags under its own name, seeded
|
|
452
|
+
* deliberately (`jr2 kit push`) and never by a converge. It is NOT `config.registry`: that key says
|
|
453
|
+
* 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
|
|
455
|
+
* still the CLI's own — re-homing says where the tags live, never which ones.
|
|
456
|
+
*/
|
|
457
|
+
export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
|
|
458
|
+
const home = kitRegistry ?? KIT_IMAGE_HOME;
|
|
459
|
+
return {
|
|
460
|
+
harness: `${home}/jr2-harness:${KIT_VERSION}`,
|
|
461
|
+
adapter: `${home}/jr2-adapter:${KIT_VERSION}`,
|
|
462
|
+
operator: `${home}/jr2-operator:${KIT_VERSION}`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Is the CLI running out of a kit checkout (ADR-0038)? Resolve upward from this module's own URL,
|
|
468
|
+
* requiring BOTH `deploy/harness/Dockerfile` and a `packages/harness/package.json` that names
|
|
469
|
+
* `@jr2/harness`. Either marker alone matches an unrelated tree — someone else's `deploy/harness`,
|
|
470
|
+
* 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`.
|
|
472
|
+
*/
|
|
473
|
+
export async function detectKitCheckout(
|
|
474
|
+
fromDir: string = fileURLToPath(new URL(".", import.meta.url)),
|
|
475
|
+
): Promise<string | undefined> {
|
|
476
|
+
let dir = resolve(fromDir);
|
|
477
|
+
for (;;) {
|
|
478
|
+
if (await isKitRoot(dir)) return dir;
|
|
479
|
+
const parent = dirname(dir);
|
|
480
|
+
if (parent === dir) return undefined;
|
|
481
|
+
dir = parent;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function isKitRoot(dir: string): Promise<boolean> {
|
|
486
|
+
try {
|
|
487
|
+
await stat(join(dir, KIT_IMAGES.harness.dockerfile));
|
|
488
|
+
const pkg = JSON.parse(await readFile(join(dir, "packages", "harness", "package.json"), "utf8")) as {
|
|
489
|
+
name?: string;
|
|
490
|
+
};
|
|
491
|
+
return pkg.name === "@jr2/harness";
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Address each kit image by its own sources and the platform set it is built for:
|
|
498
|
+
* `[<registry>/]jr2-<x>:<hash>-<arch>` (ADR-0038/0045). A `packages/harness` edit moves the harness
|
|
499
|
+
* ref with no bookkeeping — and NO Sandbox Image ref with it: the runtime arrives on the pod's
|
|
500
|
+
* `/opt/jr2` volume, so future pods take the new one and every user image keeps its tag, its layers,
|
|
501
|
+
* and its delivery (ADR-0037). The registry prefix rides here because a built kit image is delivered
|
|
502
|
+
* down the same transport branch as everything else. */
|
|
503
|
+
export async function kitImageRefs(
|
|
504
|
+
kitRoot: string,
|
|
505
|
+
opts: { platforms: readonly string[]; registry?: string },
|
|
506
|
+
): Promise<KitImageRefs> {
|
|
507
|
+
const refs = {} as KitImageRefs;
|
|
508
|
+
const suffix = platformSuffix(opts.platforms);
|
|
509
|
+
for (const name of Object.keys(KIT_IMAGES) as KitImageName[]) {
|
|
510
|
+
const image = KIT_IMAGES[name];
|
|
511
|
+
const hash = await contentHash(
|
|
512
|
+
image.sources.map((s) => join(kitRoot, s)),
|
|
513
|
+
`kit:${image.repo}`,
|
|
514
|
+
image.exclude,
|
|
515
|
+
);
|
|
516
|
+
refs[name] = `${opts.registry ? `${opts.registry}/` : ""}${image.repo}:${hash}${suffix}`;
|
|
517
|
+
}
|
|
518
|
+
return refs;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The `docker build` for one kit image: its committed Dockerfile against its own context, for an
|
|
522
|
+
* explicit platform set (ADR-0045), stamped `jr2.dev/kind=kit` on the command line — the committed
|
|
523
|
+
* Dockerfiles stay plain (ADR-0039). */
|
|
524
|
+
export function kitImageBuild(
|
|
525
|
+
kitRoot: string,
|
|
526
|
+
name: KitImageName,
|
|
527
|
+
tag: string,
|
|
528
|
+
platforms: readonly string[],
|
|
529
|
+
): BuildRequest {
|
|
530
|
+
const image = KIT_IMAGES[name];
|
|
531
|
+
return {
|
|
532
|
+
tag,
|
|
533
|
+
platforms,
|
|
534
|
+
context: join(kitRoot, image.context),
|
|
535
|
+
dockerfile: join(kitRoot, image.dockerfile),
|
|
536
|
+
labels: kitImageLabels(),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// --- Sandbox Images (ADR-0037) ---------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
/** `[<registry>/]jr2-sandbox-<instance>-<name>:<hash>-<arch>` — the image a Sandbox's primary
|
|
543
|
+
* container runs, addressed by its build context's content digest (`imageContextDigest`, which the
|
|
544
|
+
* ORCHESTRATOR owns because both sides compute it — ADR-0049) and the platform set it was built for
|
|
545
|
+
* (ADR-0045). `name` is the context directory's basename and is decoration: it makes
|
|
546
|
+
* `docker images` readable, while the hash is the identity.
|
|
547
|
+
* Names are for humans and for content addressing only: nothing reads ownership out of this string
|
|
548
|
+
* 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
|
|
550
|
+
* suffix and never passes here — jr2 never builds it, so its platforms are the registry's business. */
|
|
551
|
+
export function sandboxImageTag(
|
|
552
|
+
instance: string,
|
|
553
|
+
name: string,
|
|
554
|
+
hash: string,
|
|
555
|
+
opts: { platforms: readonly string[]; registry?: string },
|
|
556
|
+
): string {
|
|
557
|
+
const suffix = platformSuffix(opts.platforms);
|
|
558
|
+
return `${opts.registry ? `${opts.registry}/` : ""}jr2-sandbox-${instance}-${name}:${hash}${suffix}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* ONE build (ADR-0037): the user's Dockerfile, its own directory as the context, straight to its
|
|
563
|
+
* content tag. No second stage, no intermediate tag — the mutable shared name that used to
|
|
564
|
+
* serialize concurrent converges of one checkout existed only because the wrap did, and there is
|
|
565
|
+
* no wrap. jr2 never reads the file, which is exactly why `instance` is a parameter: the ownership
|
|
566
|
+
* stamp is applied on the command line, so the Dockerfile stays the user's (ADR-0039).
|
|
567
|
+
*/
|
|
568
|
+
export async function buildSandboxImage(
|
|
569
|
+
port: BuildPort,
|
|
570
|
+
opts: { dir: string; tag: string; instance: string; platforms: readonly string[] },
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
await port.build({
|
|
573
|
+
tag: opts.tag,
|
|
574
|
+
platforms: opts.platforms,
|
|
575
|
+
context: opts.dir,
|
|
576
|
+
labels: sandboxImageLabels(opts.instance),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// --- the sweep (ADR-0039) ----------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* One image as either store reports it. The two stores disagree about almost everything — the host
|
|
584
|
+
* daemon can untag and filters by label, containerd can do neither and counts its own snapshot
|
|
585
|
+
* bytes — but they agree about this much, so one shape carries both and the policy below is one
|
|
586
|
+
* body of reasoning instead of two.
|
|
587
|
+
*
|
|
588
|
+
* `tags` is EVERY tag on the id, not the interesting ones: both policies below turn on "are they
|
|
589
|
+
* ALL unreachable", and an id with no tags at all (a rebuilt tag's predecessor, on either side) is
|
|
590
|
+
* reachable by nothing and is therefore garbage by construction.
|
|
591
|
+
*/
|
|
592
|
+
export type ObservedImage = {
|
|
593
|
+
/** The image id — the unit containerd removes, and the unit bytes are counted in. */
|
|
594
|
+
id: string;
|
|
595
|
+
/** Every ref the store names this id by, verbatim (containerd's are fully qualified). */
|
|
596
|
+
tags: string[];
|
|
597
|
+
/** The store's own byte count. Never compare one store's to the other's: containerd counts its
|
|
598
|
+
* snapshots and the daemon counts its layers, and the same image differs by several percent. */
|
|
599
|
+
bytes: number;
|
|
600
|
+
/** Does the image config carry {@link LABEL_IMAGE_KIND}? An unlabeled image is not jr2's to take
|
|
601
|
+
* (ADR-0039), so it is invisible: never removed, never even reported as kept. */
|
|
602
|
+
labeled: boolean;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/** What one kind node holds. `kind load` put it there; only `docker exec <node> crictl` can see it. */
|
|
606
|
+
export type NodeImages = { node: string; images: ObservedImage[] };
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The namespace containerd gives a local, unqualified tag. `kind load` imports into containerd,
|
|
610
|
+
* which NORMALIZES `jr2-instance-x:h` to `docker.io/library/jr2-instance-x:h`, while every root that
|
|
611
|
+
* names an image — the `jr2-images` ConfigMap, a Sandbox's `spec.image`, a pod's container image —
|
|
612
|
+
* spells it the short way.
|
|
613
|
+
*/
|
|
614
|
+
const CONTAINERD_LOCAL_NS = "docker.io/library/";
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The one normalization, applied to both sides before comparison: strip `docker.io/library/` and
|
|
618
|
+
* nothing else. Stripping only that namespace is what keeps a registry ref comparable to itself —
|
|
619
|
+
* `reg.example.com/jr2-instance-x:h` and `jr2-instance-x:h` are two different refs of two different
|
|
620
|
+
* copies, and a keep set that names one must not protect the other (ADR-0039: a registry-delivered
|
|
621
|
+
* copy is cache and sweeps like everything else).
|
|
622
|
+
*/
|
|
623
|
+
export function normalizeRef(ref: string): string {
|
|
624
|
+
return ref.startsWith(CONTAINERD_LOCAL_NS) ? ref.slice(CONTAINERD_LOCAL_NS.length) : ref;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The keep set: the refs live roots name, normalized. Membership is WHOLE-REF equality — the
|
|
628
|
+
* prefix matching this replaces is the primitive ADR-0039 deletes, so nothing here may grow a
|
|
629
|
+
* `startsWith` back. */
|
|
630
|
+
function keepSet(keep: Iterable<string>): Set<string> {
|
|
631
|
+
return new Set([...keep].map(normalizeRef));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** What one host sweep will do (see {@link hostSweepPlan}). */
|
|
635
|
+
export type HostSweepPlan = {
|
|
636
|
+
/** One entry per REF to drop, in listing order. */
|
|
637
|
+
remove: Array<{
|
|
638
|
+
/** What `docker rmi` is given: the tag, or the id when the image has no tags left. */
|
|
639
|
+
ref: string;
|
|
640
|
+
/** The image this ref names — the unit bytes belong to. */
|
|
641
|
+
id: string;
|
|
642
|
+
/** Bytes credited to THIS removal: the id's size on the removal that takes its last labeled
|
|
643
|
+
* ref, and 0 on every other, because that is when the daemon actually gives the disk back. */
|
|
644
|
+
bytes: number;
|
|
645
|
+
}>;
|
|
646
|
+
/** The plan's upper bound on reclaimed bytes (see {@link SweepResult.bytes}). */
|
|
647
|
+
bytes: number;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
/** What one node sweep will do, and what it deliberately will not (see {@link nodeSweepPlan}). */
|
|
651
|
+
export type NodeSweepPlan = {
|
|
652
|
+
/** One removal per image id — every tag on it is unreachable, so the whole image goes. `tags` is
|
|
653
|
+
* all of them, for the report; `crictl rmi` gets the ID. */
|
|
654
|
+
remove: Array<{ id: string; tags: string[]; bytes: number }>;
|
|
655
|
+
/** Unreachable tags left in place: their image id also carries a tag some root still names, and
|
|
656
|
+
* crictl cannot take one without the others. Reported, because silence would read as "swept". */
|
|
657
|
+
kept: string[];
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The HOST policy: per TAG, because `docker rmi <tag>` untags — an id keeps living under its other
|
|
662
|
+
* tags. So a labeled ref no root names goes, even when a sibling tag on the same id stays; there
|
|
663
|
+
* is no mixed-id case on this side.
|
|
664
|
+
*
|
|
665
|
+
* Aggressive by ADR-0039: nothing RUNS from the host daemon — its images are scratch awaiting
|
|
666
|
+
* delivery — and BuildKit's cache is a separate store `docker rmi` does not touch, so regenerating
|
|
667
|
+
* a swept tag costs seconds. The accepted cost, written down so nobody adds a name filter to
|
|
668
|
+
* "fix" it: a second checkout converging to a different cluster can have its host kit generation
|
|
669
|
+
* swept, because this keep set only sees the current context's roots.
|
|
670
|
+
*/
|
|
671
|
+
export function hostSweepPlan(images: ObservedImage[], keep: Iterable<string>): HostSweepPlan {
|
|
672
|
+
const reachable = keepSet(keep);
|
|
673
|
+
const remove: HostSweepPlan["remove"] = [];
|
|
674
|
+
let bytes = 0;
|
|
675
|
+
for (const image of images) {
|
|
676
|
+
if (!image.labeled) continue;
|
|
677
|
+
const garbage = image.tags.filter((t) => !reachable.has(normalizeRef(t)));
|
|
678
|
+
// No tags at all: a rebuilt tag left this id behind. No ref can ever name it, so it is garbage
|
|
679
|
+
// by construction — and it must be removed BY ID, since a ref-only sweep leaks exactly the
|
|
680
|
+
// iteration garbage the sweep exists for.
|
|
681
|
+
if (image.tags.length === 0) {
|
|
682
|
+
remove.push({ ref: image.id, id: image.id, bytes: image.bytes });
|
|
683
|
+
bytes += image.bytes;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
// The bytes come back with the LAST tag, so they are credited to that one removal and to no
|
|
687
|
+
// other. Summing per tag would double count an id that carries two.
|
|
688
|
+
const last = garbage.length === image.tags.length;
|
|
689
|
+
for (const [i, ref] of garbage.entries()) {
|
|
690
|
+
const credited = last && i === garbage.length - 1 ? image.bytes : 0;
|
|
691
|
+
remove.push({ ref, id: image.id, bytes: credited });
|
|
692
|
+
bytes += credited;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return { remove, bytes };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The NODE policy: per ID, unchanged physics from ADR-0038's fix. `crictl rmi <tag>` resolves the
|
|
700
|
+
* tag to its image id and removes the whole image, every tag with it — CRI has no untag verb. So
|
|
701
|
+
* an id is removed (once) only when EVERY tag on it is unreachable, and an id carrying a tag some
|
|
702
|
+
* root still names is kept whole and reported. The mixed id is a real case, not a hypothetical:
|
|
703
|
+
* two instances whose `images/<x>` trees and harness ref are byte-identical produce the same image
|
|
704
|
+
* id under different tags, and one of them is still running.
|
|
705
|
+
*/
|
|
706
|
+
export function nodeSweepPlan(images: ObservedImage[], keep: Iterable<string>): NodeSweepPlan {
|
|
707
|
+
const reachable = keepSet(keep);
|
|
708
|
+
const remove: NodeSweepPlan["remove"] = [];
|
|
709
|
+
const kept: string[] = [];
|
|
710
|
+
for (const image of images) {
|
|
711
|
+
if (!image.labeled) continue;
|
|
712
|
+
const garbage = image.tags.filter((t) => !reachable.has(normalizeRef(t)));
|
|
713
|
+
if (garbage.length === image.tags.length) remove.push({ id: image.id, tags: image.tags, bytes: image.bytes });
|
|
714
|
+
else kept.push(...garbage);
|
|
715
|
+
}
|
|
716
|
+
return { remove, kept };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** What a sweep actually did — the one report `up`, `down`, and `gc` narrate. */
|
|
720
|
+
export type SweepResult = {
|
|
721
|
+
/** Gone: the ref removed, or the id for an image that had no tags left to name it. */
|
|
722
|
+
removed: string[];
|
|
723
|
+
/** Unreachable tags deliberately left in place — a node id that also carries a reachable tag. */
|
|
724
|
+
kept: string[];
|
|
725
|
+
/** `ref (error)` per removal that failed. The loop continues past a failure rather than
|
|
726
|
+
* abandoning everything behind it (ADR-0039). */
|
|
727
|
+
failed: string[];
|
|
728
|
+
/** Bytes the removals gave back, counted once per image id. An UPPER BOUND, and narrated as the
|
|
729
|
+
* quantity the user feels rather than a count (ADR-0039): an image reports every layer it holds
|
|
730
|
+
* as its own, so two images sharing a base each report the shared bytes in full. */
|
|
731
|
+
bytes: number;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const emptySweep = (): SweepResult => ({ removed: [], kept: [], failed: [], bytes: 0 });
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* One report out of several — the host's and every node's, or several converges' (`mergeSweeps` is
|
|
738
|
+
* associative, so a caller can fold as it goes). Refs are de-duplicated AFTER {@link normalizeRef},
|
|
739
|
+
* 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
|
|
741
|
+
* set reports one image twice. The merged refs are the normalized spelling — the one the roots, and
|
|
742
|
+
* the user, name an image by.
|
|
743
|
+
*
|
|
744
|
+
* BYTES are summed, not de-duplicated, and that is not the same oversight: the host's copy and each
|
|
745
|
+
* node's copy are distinct bytes on the user's one disk, and removing both gives back both. Same
|
|
746
|
+
* rule as two nodes holding the same ref — two copies, two lots of disk (ADR-0039: bytes are the
|
|
747
|
+
* quantity the user feels). The count answers "how many images", the bytes "how much disk".
|
|
748
|
+
*/
|
|
749
|
+
export function mergeSweeps(...results: SweepResult[]): SweepResult {
|
|
750
|
+
const merged = emptySweep();
|
|
751
|
+
const removed = new Set<string>();
|
|
752
|
+
const kept = new Set<string>();
|
|
753
|
+
for (const r of results) {
|
|
754
|
+
for (const ref of r.removed) removed.add(normalizeRef(ref));
|
|
755
|
+
for (const ref of r.kept) kept.add(normalizeRef(ref));
|
|
756
|
+
merged.failed.push(...r.failed);
|
|
757
|
+
merged.bytes += r.bytes;
|
|
758
|
+
}
|
|
759
|
+
merged.removed = [...removed];
|
|
760
|
+
merged.kept = [...kept];
|
|
761
|
+
return merged;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** "was already gone" is the goal state, however it was reached — the delete-if-present rule both
|
|
765
|
+
* stores need, since neither `docker rmi` nor `crictl rmi` is idempotent (both exit 1). */
|
|
766
|
+
function isAlreadyGone(err: unknown): boolean {
|
|
767
|
+
return /no such image/i.test(err instanceof Error ? err.message : String(err));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Sweep the host daemon: every labeled ref no root names. `keep` is the caller's assembled keep
|
|
772
|
+
* set — the union of the cluster's live roots plus whatever this converge just resolved, which the
|
|
773
|
+
* commands layer owns because reachability is a question about Kubernetes, not about images.
|
|
774
|
+
*
|
|
775
|
+
* A failed removal is reported and skipped; nothing here throws for one image's sake.
|
|
776
|
+
*/
|
|
777
|
+
export async function sweepHost(
|
|
778
|
+
port: BuildPort,
|
|
779
|
+
opts: { keep: Iterable<string>; dryRun?: boolean },
|
|
780
|
+
): Promise<SweepResult> {
|
|
781
|
+
const result = emptySweep();
|
|
782
|
+
const plan = hostSweepPlan(await port.hostImages(), opts.keep);
|
|
783
|
+
for (const entry of plan.remove) {
|
|
784
|
+
if (opts.dryRun) {
|
|
785
|
+
result.removed.push(entry.ref);
|
|
786
|
+
result.bytes += entry.bytes;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
await port.removeHostImage(entry.ref);
|
|
791
|
+
result.removed.push(entry.ref);
|
|
792
|
+
result.bytes += entry.bytes;
|
|
793
|
+
} catch (err) {
|
|
794
|
+
// Already gone counts as removed but frees nothing: something else took those bytes.
|
|
795
|
+
if (isAlreadyGone(err)) result.removed.push(entry.ref);
|
|
796
|
+
else result.failed.push(`${entry.ref} (${(err instanceof Error ? err.message : String(err)).split("\n")[0]})`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return result;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Sweep every node of a kind cluster. Only kind: elsewhere the nodes pull from a registry, whose
|
|
804
|
+
* retention is the registry's business (ADR-0038's line, kept). One removal per image id, because
|
|
805
|
+
* that is the only granularity CRI offers.
|
|
806
|
+
*
|
|
807
|
+
* Nothing here believes an exit code. A node removal is confirmed by RE-LISTING the store and
|
|
808
|
+
* checking the id is gone, and only a confirmed one is counted as removed or credited with bytes.
|
|
809
|
+
* That is not defensive programming, it is this store's physics: `crictl rmi <id>` exits 0 having
|
|
810
|
+
* dropped only the names CRI knows, while a `kind load`ed image is ALSO held under an
|
|
811
|
+
* `import-<date>@<digest>` ref it does not (see {@link pnpmDockerBuild.removeNodeImage}) — so
|
|
812
|
+
* "exited 0" was, for the whole node half, compatible with reclaiming nothing and saying gigabytes.
|
|
813
|
+
* A ref-driven removal fixes that; the re-list is what makes the report true whatever the store
|
|
814
|
+
* does next.
|
|
815
|
+
*/
|
|
816
|
+
export async function sweepNodes(
|
|
817
|
+
port: BuildPort,
|
|
818
|
+
opts: { cluster: string; keep: Iterable<string>; dryRun?: boolean },
|
|
819
|
+
): Promise<SweepResult> {
|
|
820
|
+
const result = emptySweep();
|
|
821
|
+
/** One attempted removal, awaiting the re-list that says whether it happened. */
|
|
822
|
+
const attempted: Array<{ node: string; id: string; names: string[]; bytes: number; error?: string }> = [];
|
|
823
|
+
for (const { node, images } of await port.nodeImages(opts.cluster)) {
|
|
824
|
+
const plan = nodeSweepPlan(images, opts.keep);
|
|
825
|
+
result.kept.push(...plan.kept);
|
|
826
|
+
for (const entry of plan.remove) {
|
|
827
|
+
// What the report names: the tags if it has any, else the id — a tagless leftover has no
|
|
828
|
+
// other name, and "removed sha256:abc…" is still the truth about a disk.
|
|
829
|
+
const names = entry.tags.length ? entry.tags : [entry.id];
|
|
830
|
+
if (opts.dryRun) {
|
|
831
|
+
result.removed.push(...names);
|
|
832
|
+
result.bytes += entry.bytes;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const attempt = { node, id: entry.id, names, bytes: entry.bytes, error: undefined as string | undefined };
|
|
836
|
+
try {
|
|
837
|
+
await port.removeNodeImage(opts.cluster, node, entry.id);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
// Already gone counts as removed but frees nothing: something else took those bytes.
|
|
840
|
+
if (isAlreadyGone(err)) attempt.bytes = 0;
|
|
841
|
+
else attempt.error = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
842
|
+
}
|
|
843
|
+
attempted.push(attempt);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (attempted.length === 0) return result;
|
|
847
|
+
|
|
848
|
+
let survivors: Map<string, Set<string>> | undefined;
|
|
849
|
+
try {
|
|
850
|
+
survivors = new Map(
|
|
851
|
+
(await port.nodeImages(opts.cluster)).map(({ node, images }) => [node, new Set(images.map((i) => i.id))]),
|
|
852
|
+
);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
// The removals happened; what cannot be established is whether they took. Unverified goes in
|
|
855
|
+
// `failed`, which is the conservative half of the truth — it costs a re-plan next sweep, where
|
|
856
|
+
// claiming the bytes would cost the user their trust in the one number this prints.
|
|
857
|
+
const why = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
858
|
+
for (const a of attempted) result.failed.push(`${a.names[0]} (could not verify the removal: ${why})`);
|
|
859
|
+
return result;
|
|
860
|
+
}
|
|
861
|
+
for (const a of attempted) {
|
|
862
|
+
if (survivors.get(a.node)?.has(a.id)) {
|
|
863
|
+
result.failed.push(`${a.names[0]} (${a.error ?? "the node still holds it after the removal"})`);
|
|
864
|
+
} else {
|
|
865
|
+
result.removed.push(...a.names);
|
|
866
|
+
result.bytes += a.bytes;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** `swept 4 image(s) (2.1 GB)` — the narration is bytes, because disk is the quantity the user
|
|
873
|
+
* feels (ADR-0039). SI units, matching what docker and crictl print. */
|
|
874
|
+
export function formatBytes(bytes: number): string {
|
|
875
|
+
const units = ["B", "kB", "MB", "GB", "TB"];
|
|
876
|
+
let n = bytes;
|
|
877
|
+
let unit = 0;
|
|
878
|
+
while (n >= 1000 && unit < units.length - 1) {
|
|
879
|
+
n /= 1000;
|
|
880
|
+
unit += 1;
|
|
881
|
+
}
|
|
882
|
+
return `${unit === 0 ? n : n.toFixed(1)} ${units[unit]}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The floor a Sandbox Image owes (ADR-0037) is NOT proven here, and the absence is the decision.
|
|
887
|
+
* The floor is a HARNESS-SEAT obligation; a built context may equally be destined for the
|
|
888
|
+
* User Container seat, which owes no floor at all (ADR-0005) — and which seat a directory serves is
|
|
889
|
+
* workflow-internal and statically unrecoverable (ADR-0031, the same line that puts an unknown image
|
|
890
|
+
* name at provision). So a converge cannot know what to hold an image to. The probe lives at the one
|
|
891
|
+
* 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
|
|
893
|
+
* registry ref, which no converge sees at all — so one prover, not two that can disagree.
|
|
894
|
+
*/
|
|
895
|
+
|
|
896
|
+
// --- the bundle's install (ADR-0043) -----------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
/** One package manager's frozen, production install — the command that materializes the bundle. */
|
|
899
|
+
export type InstallCommand = { command: string; args: string[] };
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* The whole supported set, one row per package manager, keyed by the lockfile that selects it. The
|
|
903
|
+
* "dependency matrix" collapses to nothing (ADR-0043): a lockfile is a proprietary format, so
|
|
904
|
+
* supporting a package manager means invoking the binary that speaks its lockfile — and that
|
|
905
|
+
* binary's presence is guaranteed by the very thing that selects it, since the user wrote the
|
|
906
|
+
* lockfile with it. jr2 itself depends on no package manager.
|
|
907
|
+
*
|
|
908
|
+
* 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.
|
|
910
|
+
*
|
|
911
|
+
* bun's two spellings are ONE row — `bun.lockb` is the binary format `bun.lock` replaced — so an
|
|
912
|
+
* instance holding both is mid-migration, not ambiguous: one manager, one command, bun's own
|
|
913
|
+
* precedence.
|
|
914
|
+
*/
|
|
915
|
+
const LOCKFILE_INSTALLS: Array<{ manager: string; lockfiles: string[]; install: InstallCommand }> = [
|
|
916
|
+
{ manager: "npm", lockfiles: ["package-lock.json"], install: { command: "npm", args: ["ci", "--omit=dev"] } },
|
|
917
|
+
{
|
|
918
|
+
manager: "pnpm",
|
|
919
|
+
lockfiles: ["pnpm-lock.yaml"],
|
|
920
|
+
install: { command: "pnpm", args: ["install", "--prod", "--frozen-lockfile", "--config.node-linker=hoisted"] },
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
manager: "bun",
|
|
924
|
+
lockfiles: ["bun.lock", "bun.lockb"],
|
|
925
|
+
install: { command: "bun", args: ["install", "--production", "--frozen-lockfile"] },
|
|
926
|
+
},
|
|
927
|
+
];
|
|
928
|
+
|
|
929
|
+
/** Deliberately out for v1 (ADR-0043): one filename hides two incompatible generations (classic
|
|
930
|
+
* `--frozen-lockfile` vs berry `--immutable`), and berry defaults to PnP — no `node_modules` at
|
|
931
|
+
* all, which the image's resolution model cannot host. A named rejection, never a silent fallback;
|
|
932
|
+
* adding yarn later is one row above plus its tests. */
|
|
933
|
+
const YARN_LOCKFILE = "yarn.lock";
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Which install this Instance's committed bytes name. The lockfile — not the user's
|
|
937
|
+
* `node_modules/` — is the input, and that is forced rather than stylistic: the GitOps/CI path
|
|
938
|
+
* runs from a clean checkout where no `node_modules` exists, a copied tree bakes in accidents
|
|
939
|
+
* instead of declarations, and prod-pruning a copied tree means reimplementing resolution. It is
|
|
940
|
+
* ADR-0019's derivability rule applied to dependencies — the deployed bundle is a function of what
|
|
941
|
+
* `up` can see committed — so the lockfile is part of the instance contract: none is a hard error
|
|
942
|
+
* naming the supported three, and two managers is an ambiguity error rather than a guess.
|
|
943
|
+
*/
|
|
944
|
+
export async function lockfileInstall(instanceDir: string): Promise<InstallCommand> {
|
|
945
|
+
const present = new Set(await readdir(instanceDir));
|
|
946
|
+
const found = LOCKFILE_INSTALLS.filter((row) => row.lockfiles.some((f) => present.has(f)));
|
|
947
|
+
const names = [
|
|
948
|
+
...found.map((row) => `${row.manager}: ${row.lockfiles.filter((f) => present.has(f)).join(", ")}`),
|
|
949
|
+
...(present.has(YARN_LOCKFILE) ? [`yarn: ${YARN_LOCKFILE}`] : []),
|
|
950
|
+
];
|
|
951
|
+
// Ambiguity is asked FIRST, and about managers rather than files: two managers' lockfiles say two
|
|
952
|
+
// different dependency graphs, and picking one by precedence would deploy the graph the user
|
|
953
|
+
// stopped maintaining.
|
|
954
|
+
if (names.length > 1) {
|
|
955
|
+
throw new Error(
|
|
956
|
+
`${instanceDir} holds lockfiles from more than one package manager (${names.join("; ")}) — ` +
|
|
957
|
+
`delete the stale one, so the bundle installs the dependency graph the instance really uses`,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
if (found.length === 1) return found[0]!.install;
|
|
961
|
+
if (present.has(YARN_LOCKFILE)) {
|
|
962
|
+
throw new Error(`${instanceDir} has a ${YARN_LOCKFILE}, and yarn is not supported — use npm, pnpm, or bun`);
|
|
963
|
+
}
|
|
964
|
+
throw new Error(
|
|
965
|
+
`${instanceDir} has no lockfile — jr2 installs the instance's dependencies from ` +
|
|
966
|
+
`${LOCKFILE_INSTALLS.map((row) => `${row.manager} (${row.lockfiles.join(" or ")})`).join(", ")}; ` +
|
|
967
|
+
`run your package manager's install and commit the lockfile it writes`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* What a staged copy of the Instance leaves behind. `node_modules/` because the lockfile is the
|
|
973
|
+
* input (above); `.jr2/` because it is CLI-local state; `.git/` because history is not image
|
|
974
|
+
* content; `.env`/`.env.*` and `.npmrc` because those are the two files a user keeps credentials
|
|
975
|
+
* 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`
|
|
979
|
+
* filters through npm-packlist. A registry the frozen install needs reaches it the way a
|
|
980
|
+
* deployment-varying value should — the environment (`npm_config_registry`) or the user-level
|
|
981
|
+
* npmrc — neither of which is image content.
|
|
982
|
+
*
|
|
983
|
+
* Matched by name at any depth: a nested one of these is the same kind of thing.
|
|
984
|
+
*/
|
|
985
|
+
const BUNDLE_STAGE_EXCLUDE = new Set(["node_modules", ".jr2", ".git", ".env", ".npmrc"]);
|
|
986
|
+
|
|
987
|
+
function excludedFromStage(name: string): boolean {
|
|
988
|
+
return BUNDLE_STAGE_EXCLUDE.has(name) || name.startsWith(".env.");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Run one subprocess in `cwd`. The seam the dispatch is tested through: a unit test asserts the
|
|
992
|
+
* exact argv a lockfile selects without a package manager anywhere near it. */
|
|
993
|
+
export type RunCommand = (command: string, args: string[], cwd: string) => Promise<void>;
|
|
994
|
+
|
|
995
|
+
const execCommand: RunCommand = async (command, args, cwd) => {
|
|
996
|
+
await exec(command, args, { cwd, ...BIG });
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Materialize the Instance into `outDir` (ADR-0043, as amended there). Two shapes, and the key is
|
|
1001
|
+
* the INSTANCE's own: walk up for a `pnpm-workspace.yaml`, because that — not
|
|
1002
|
+
* {@link detectKitCheckout} — is what says whether `pnpm deploy` can run at all. A checkout CLI can
|
|
1003
|
+
* legitimately drive a standalone instance (the developer's `/tmp` folder), and keying on the CLI's
|
|
1004
|
+
* own provenance would send that instance down a path whose job — materializing workspace symlinks
|
|
1005
|
+
* — only exists in a workspace. The mirror holds too: an INSTALLED kit driving an instance nested
|
|
1006
|
+
* in the user's own pnpm monorepo takes `pnpm deploy`, because that instance carries no lockfile of
|
|
1007
|
+
* its own — the workspace root holds it.
|
|
1008
|
+
*
|
|
1009
|
+
* - Workspace member (the kit checkout's `templates/*` and `features/kind-instance`): `pnpm deploy
|
|
1010
|
+
* --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
|
|
1011
|
+
* product dependency.
|
|
1012
|
+
* - Standalone: stage a copy ({@link BUNDLE_STAGE_EXCLUDE}) and run a frozen production install
|
|
1013
|
+
* from the committed lockfile ({@link lockfileInstall}) inside it.
|
|
1014
|
+
*/
|
|
1015
|
+
export async function bundleInstance(
|
|
1016
|
+
instanceDir: string,
|
|
1017
|
+
outDir: string,
|
|
1018
|
+
run: RunCommand = execCommand,
|
|
1019
|
+
): Promise<void> {
|
|
1020
|
+
if (await pnpmWorkspaceRoot(instanceDir)) {
|
|
1021
|
+
const pkg = JSON.parse(await readFile(join(instanceDir, "package.json"), "utf8")) as { name?: string };
|
|
1022
|
+
if (!pkg.name) throw new Error(`${instanceDir}/package.json has no "name" — needed to bundle the instance`);
|
|
1023
|
+
// --legacy: materialize (copy) workspace deps into the bundle rather than linking them.
|
|
1024
|
+
await run("pnpm", ["--filter", pkg.name, "--prod", "deploy", "--legacy", outDir], instanceDir);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
// Asked before the copy: an instance with no lockfile must fail on the cheap half.
|
|
1028
|
+
const { command, args } = await lockfileInstall(instanceDir);
|
|
1029
|
+
await cp(instanceDir, outDir, {
|
|
1030
|
+
recursive: true,
|
|
1031
|
+
filter: (src) => src === instanceDir || !excludedFromStage(basename(src)),
|
|
1032
|
+
});
|
|
1033
|
+
await run(command, args, outDir);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** The nearest `pnpm-workspace.yaml` at or above `dir`, i.e. "is this instance a workspace member".
|
|
1037
|
+
* A file test, not a manifest parse: pnpm's own membership rule starts here, and a `packages:` glob
|
|
1038
|
+
* that excluded this directory would leave `pnpm deploy --filter` failing loudly by name. */
|
|
1039
|
+
async function pnpmWorkspaceRoot(dir: string): Promise<string | undefined> {
|
|
1040
|
+
let d = resolve(dir);
|
|
1041
|
+
for (;;) {
|
|
1042
|
+
try {
|
|
1043
|
+
await stat(join(d, "pnpm-workspace.yaml"));
|
|
1044
|
+
return d;
|
|
1045
|
+
} catch {
|
|
1046
|
+
const parent = dirname(d);
|
|
1047
|
+
if (parent === d) return undefined;
|
|
1048
|
+
d = parent;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// --- the real port ----------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
const BIG = { maxBuffer: 64 * 1024 * 1024 };
|
|
1056
|
+
|
|
1057
|
+
/** The real build port: pnpm + docker + kind + crictl subprocesses. */
|
|
1058
|
+
export const pnpmDockerBuild: BuildPort = {
|
|
1059
|
+
bundle: (instanceDir, outDir) => bundleInstance(instanceDir, outDir),
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* `--platform` is always explicit (ADR-0045), and the platform set picks the mechanism:
|
|
1063
|
+
*
|
|
1064
|
+
* - ONE platform: plain `docker build`, landing on the host daemon, delivered by the caller's
|
|
1065
|
+
* transport branch (`docker push` or `kind load`) exactly as before.
|
|
1066
|
+
* - MORE than one: `docker buildx build --push`, which is `just kit-push`'s mechanism. It PUSHES
|
|
1067
|
+
* ITSELF — a manifest list cannot live in the daemon and `kind load` cannot carry one — so the
|
|
1068
|
+
* caller's deliver() step must not push again. This path only ever runs where it can deliver, by
|
|
1069
|
+
* construction: a mixed-arch node set is never kind (kind nodes are containers on one host), and
|
|
1070
|
+
* the non-kind branch already requires a `registry`.
|
|
1071
|
+
*
|
|
1072
|
+
* The multi-platform build needs a builder of its own: the default `docker` driver builds only the
|
|
1073
|
+
* host's platform and cannot push a manifest list at all. It is the SAME named builder
|
|
1074
|
+
* `scripts/kit-push.sh` creates, so the two share one cache. `--provenance=false` keeps the pushed
|
|
1075
|
+
* index to the platforms asked for — attestation manifests ride an index as extra
|
|
1076
|
+
* `unknown/unknown` entries, read by nothing here.
|
|
1077
|
+
*/
|
|
1078
|
+
async build({ tag, platforms, context, dockerfile, dockerfileContent, labels }) {
|
|
1079
|
+
const args =
|
|
1080
|
+
platforms.length > 1
|
|
1081
|
+
? ["buildx", "build", "--builder", await ensureMultiArchBuilder(), "--provenance=false", "--push"]
|
|
1082
|
+
: ["build"];
|
|
1083
|
+
args.push("--platform", platforms.join(","), "-t", tag);
|
|
1084
|
+
for (const [k, v] of Object.entries(labels ?? {})) args.push("--label", `${k}=${v}`);
|
|
1085
|
+
if (dockerfile) args.push("-f", dockerfile);
|
|
1086
|
+
if (dockerfileContent) args.push("-f", "-");
|
|
1087
|
+
args.push(context);
|
|
1088
|
+
if (dockerfileContent) await execStdin(["docker", ...args], dockerfileContent);
|
|
1089
|
+
else await exec("docker", args, BIG);
|
|
1090
|
+
},
|
|
1091
|
+
|
|
1092
|
+
async imageUser(image, platforms) {
|
|
1093
|
+
// A multi-platform build went straight to the registry (`buildx --push`), so the daemon holds
|
|
1094
|
+
// nothing to inspect — `imagetools` reads the manifest list where it actually is (ADR-0045).
|
|
1095
|
+
if (platforms.length > 1) {
|
|
1096
|
+
const { stdout } = await exec(
|
|
1097
|
+
"docker",
|
|
1098
|
+
["buildx", "imagetools", "inspect", image, "--format", "{{json .Image}}"],
|
|
1099
|
+
BIG,
|
|
1100
|
+
);
|
|
1101
|
+
return manifestListUser(image, stdout);
|
|
1102
|
+
}
|
|
1103
|
+
// `docker inspect` answers `""` for an image that declares no USER, which is the exact fact
|
|
1104
|
+
// the fallback turns on — so the empty string is DATA here, never a missing value.
|
|
1105
|
+
const { stdout } = await exec("docker", ["image", "inspect", "--format", "{{.Config.User}}", image], BIG);
|
|
1106
|
+
return stdout.trim();
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
async buildablePlatforms() {
|
|
1110
|
+
// `docker buildx inspect` prints one `Platforms:` line per builder node, listing the host's own
|
|
1111
|
+
// platform and every foreign one binfmt registered. A `*` marks the preferred entry.
|
|
1112
|
+
const { stdout } = await exec("docker", ["buildx", "inspect"], BIG);
|
|
1113
|
+
return [...stdout.matchAll(/^Platforms:\s*(.+)$/gm)].flatMap((m) =>
|
|
1114
|
+
m[1]!
|
|
1115
|
+
.split(",")
|
|
1116
|
+
.map((p) => p.trim().replace(/\*$/, ""))
|
|
1117
|
+
.filter(Boolean),
|
|
1118
|
+
);
|
|
1119
|
+
},
|
|
1120
|
+
|
|
1121
|
+
async push(tag) {
|
|
1122
|
+
await exec("docker", ["push", tag], BIG);
|
|
1123
|
+
},
|
|
1124
|
+
|
|
1125
|
+
async kindLoad(tag, cluster) {
|
|
1126
|
+
await exec("kind", ["load", "docker-image", tag, "--name", cluster], BIG);
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
async hostImages() {
|
|
1130
|
+
// Two calls, and neither is optional: `docker image ls` reports no labels and formats its size
|
|
1131
|
+
// for humans ("4.45MB"), while `docker image inspect` gives exact bytes, every tag, and the
|
|
1132
|
+
// labels — but has no filter of its own. So the daemon narrows by label key, then inspect
|
|
1133
|
+
// answers about the survivors. `-q` prints one line per TAG, hence the de-duplication.
|
|
1134
|
+
const { stdout: idOut } = await exec(
|
|
1135
|
+
"docker",
|
|
1136
|
+
["image", "ls", "-q", "--no-trunc", "--filter", `label=${LABEL_IMAGE_KIND}`],
|
|
1137
|
+
BIG,
|
|
1138
|
+
);
|
|
1139
|
+
const ids = [
|
|
1140
|
+
...new Set(
|
|
1141
|
+
idOut
|
|
1142
|
+
.split("\n")
|
|
1143
|
+
.map((s) => s.trim())
|
|
1144
|
+
.filter(Boolean),
|
|
1145
|
+
),
|
|
1146
|
+
];
|
|
1147
|
+
if (ids.length === 0) return [];
|
|
1148
|
+
const { stdout } = await exec(
|
|
1149
|
+
"docker",
|
|
1150
|
+
["image", "inspect", "--format", "{{.Id}}\t{{.Size}}\t{{json .RepoTags}}\t{{json .Config.Labels}}", ...ids],
|
|
1151
|
+
BIG,
|
|
1152
|
+
);
|
|
1153
|
+
const images: ObservedImage[] = [];
|
|
1154
|
+
for (const line of stdout.split("\n").filter((l) => l.trim())) {
|
|
1155
|
+
const [id, size, tags, labels] = line.split("\t");
|
|
1156
|
+
const parsedLabels = (JSON.parse(labels ?? "null") ?? {}) as Record<string, string>;
|
|
1157
|
+
images.push({
|
|
1158
|
+
id: id!,
|
|
1159
|
+
// `<none>:<none>` is how a tagless image sometimes spells its absence of a name; it is not
|
|
1160
|
+
// a ref, and treating it as one would send `docker rmi <none>:<none>` at the daemon.
|
|
1161
|
+
tags: ((JSON.parse(tags ?? "[]") ?? []) as string[]).filter((t) => t && !t.startsWith("<none>")),
|
|
1162
|
+
bytes: Number(size) || 0,
|
|
1163
|
+
labeled: LABEL_IMAGE_KIND in parsedLabels,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
return images;
|
|
1167
|
+
},
|
|
1168
|
+
|
|
1169
|
+
async removeHostImage(ref) {
|
|
1170
|
+
await exec("docker", ["rmi", ref], BIG);
|
|
1171
|
+
},
|
|
1172
|
+
|
|
1173
|
+
async nodeImages(cluster) {
|
|
1174
|
+
const { stdout: nodeList } = await exec("kind", ["get", "nodes", "--name", cluster]);
|
|
1175
|
+
const nodes = nodeList
|
|
1176
|
+
.split("\n")
|
|
1177
|
+
.map((s) => s.trim())
|
|
1178
|
+
.filter(Boolean);
|
|
1179
|
+
const out: NodeImages[] = [];
|
|
1180
|
+
// The images live in each node's containerd, not the host daemon — `kind load` imported them
|
|
1181
|
+
// there — so the reach is `docker exec <node> crictl`, per node.
|
|
1182
|
+
for (const node of nodes) {
|
|
1183
|
+
const { stdout: raw } = await exec("docker", ["exec", node, "crictl", "images", "-o", "json"], BIG);
|
|
1184
|
+
const listed = (JSON.parse(raw) as { images?: CriImage[] }).images ?? [];
|
|
1185
|
+
// CRI's list is a VIEW, and it can outlive what containerd holds: a removal that goes
|
|
1186
|
+
// through `ctr` leaves the CRI image store still answering for an id whose refs and content
|
|
1187
|
+
// are gone. Such a row is not an image — nothing can run from it and nothing can be
|
|
1188
|
+
// reclaimed by taking it again — so containerd's own ref list, not CRI's, decides what is
|
|
1189
|
+
// here. Without this, every id the sweep took would come back on the next plan, forever.
|
|
1190
|
+
const held = await containerdRefs(node);
|
|
1191
|
+
const rows = listed.filter((r) => criRefs(r).some((ref) => held.has(normalizeRef(ref))));
|
|
1192
|
+
const labels = await crictlLabels(
|
|
1193
|
+
node,
|
|
1194
|
+
rows.map((r) => r.id),
|
|
1195
|
+
async (batch) => {
|
|
1196
|
+
const { stdout } = await exec("docker", ["exec", node, "crictl", "inspecti", "-o", "json", ...batch], BIG);
|
|
1197
|
+
return stdout;
|
|
1198
|
+
},
|
|
1199
|
+
);
|
|
1200
|
+
out.push({
|
|
1201
|
+
node,
|
|
1202
|
+
images: rows.map((r) => ({
|
|
1203
|
+
id: r.id,
|
|
1204
|
+
tags: r.repoTags ?? [],
|
|
1205
|
+
// containerd reports its byte count as a STRING.
|
|
1206
|
+
bytes: Number(r.size ?? 0) || 0,
|
|
1207
|
+
labeled: LABEL_IMAGE_KIND in (labels.get(r.id) ?? {}),
|
|
1208
|
+
})),
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
return out;
|
|
1212
|
+
},
|
|
1213
|
+
|
|
1214
|
+
async removeNodeImage(_cluster, node, id) {
|
|
1215
|
+
// The refs BEFORE the removal: `crictl rmi` reports none of them back, and afterwards the id
|
|
1216
|
+
// may no longer be answerable at all.
|
|
1217
|
+
const refs = await criRefsOf(node, id);
|
|
1218
|
+
// CRI first, because it is the one path that also updates crictl's own view of the node.
|
|
1219
|
+
await exec("docker", ["exec", node, "crictl", "rmi", id], BIG);
|
|
1220
|
+
// Then the names CRI never knew. `kind load docker-image` hands containerd an OCI archive, so
|
|
1221
|
+
// the image is held under `import-<date>@sha256:<digest>` as well as under its tag; `crictl
|
|
1222
|
+
// rmi` drops the tag, the import ref keeps the image alive, and the id stays on disk — which
|
|
1223
|
+
// is why removing by id alone reclaimed nothing on a kind node while exiting 0. `ctr` is the
|
|
1224
|
+
// only reach to those refs, and it is delete-if-present (a missing name warns and exits 0).
|
|
1225
|
+
// Both spellings go, because containerd holds a tag fully qualified and an import ref bare.
|
|
1226
|
+
const names = [...new Set(refs.flatMap((ref) => [ref, normalizeRef(ref)]))];
|
|
1227
|
+
if (names.length > 0)
|
|
1228
|
+
await exec("docker", ["exec", node, "ctr", "-n", CONTAINERD_K8S_NS, "images", "rm", ...names], BIG);
|
|
1229
|
+
},
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
/** The builder a multi-platform build runs on (ADR-0045). The default `docker` driver can build
|
|
1233
|
+
* only the host's own platform and cannot push a manifest list, so a container driver is required —
|
|
1234
|
+
* and it is `scripts/kit-push.sh`'s builder by name, so a converge and a release push share one
|
|
1235
|
+
* cache. `network=host` is what makes `localhost:<port>` mean the HOST's registry: buildkit runs in
|
|
1236
|
+
* a container of its own, where `localhost` would otherwise be that container. */
|
|
1237
|
+
const MULTI_ARCH_BUILDER = "jr2-kit";
|
|
1238
|
+
|
|
1239
|
+
/** Create-if-absent, because a converge that needed the builder and did not have one would fail
|
|
1240
|
+
* with buildx's own driver error — the class of manual step jr2 deletes. */
|
|
1241
|
+
async function ensureMultiArchBuilder(): Promise<string> {
|
|
1242
|
+
try {
|
|
1243
|
+
await exec("docker", ["buildx", "inspect", MULTI_ARCH_BUILDER], BIG);
|
|
1244
|
+
} catch {
|
|
1245
|
+
await exec(
|
|
1246
|
+
"docker",
|
|
1247
|
+
[
|
|
1248
|
+
"buildx",
|
|
1249
|
+
"create",
|
|
1250
|
+
"--name",
|
|
1251
|
+
MULTI_ARCH_BUILDER,
|
|
1252
|
+
"--driver",
|
|
1253
|
+
"docker-container",
|
|
1254
|
+
"--driver-opt",
|
|
1255
|
+
"network=host",
|
|
1256
|
+
],
|
|
1257
|
+
BIG,
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
return MULTI_ARCH_BUILDER;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The declared `USER` of a manifest list, out of `docker buildx imagetools inspect --format
|
|
1265
|
+
* "{{json .Image}}"`: an object keyed by platform, each value that platform's image config (a
|
|
1266
|
+
* single-platform index answers with the bare config instead).
|
|
1267
|
+
*
|
|
1268
|
+
* One image, one seat: the map a provision reads carries ONE `sandboxUser` per image (ADR-0037), so
|
|
1269
|
+
* two platforms declaring different users is not a value this layer may average — it is a fact the
|
|
1270
|
+
* record cannot hold, and it says so instead of picking the entry it happened to read first.
|
|
1271
|
+
*/
|
|
1272
|
+
export function manifestListUser(image: string, json: string): string {
|
|
1273
|
+
type Config = { config?: { User?: string } };
|
|
1274
|
+
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
1275
|
+
const byPlatform = "config" in parsed ? { "": parsed } : (parsed as Record<string, Config>);
|
|
1276
|
+
const users = Object.entries(byPlatform).map(
|
|
1277
|
+
([platform, entry]) => [platform, (entry as Config)?.config?.User ?? ""] as const,
|
|
1278
|
+
);
|
|
1279
|
+
const distinct = new Set(users.map(([, user]) => user));
|
|
1280
|
+
if (distinct.size > 1) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`${image} declares a different USER per platform (${users.map(([p, u]) => `${p}: ${u || "none"}`).join(", ")}) — ` +
|
|
1283
|
+
`a Sandbox Image's seat is one fact in the image map, so the same USER must hold for every platform built`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
return users[0]?.[1] ?? "";
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** One row of `crictl images -o json`. `repoDigests` matters as much as `repoTags` here: a
|
|
1290
|
+
* `kind load`ed image often has no tag left and is named only by its `import-<date>@<digest>`. */
|
|
1291
|
+
type CriImage = { id: string; repoTags?: string[]; repoDigests?: string[]; size?: string };
|
|
1292
|
+
|
|
1293
|
+
/** The containerd namespace Kubernetes' images live in — the one `crictl` talks to, and the one
|
|
1294
|
+
* `ctr` must be pointed at, since its default (`default`) holds nothing of ours. */
|
|
1295
|
+
const CONTAINERD_K8S_NS = "k8s.io";
|
|
1296
|
+
|
|
1297
|
+
/** Every name CRI knows one image by, its id included (containerd holds a `sha256:<id>` ref for
|
|
1298
|
+
* images it pulled itself). */
|
|
1299
|
+
function criRefs(image: CriImage): string[] {
|
|
1300
|
+
return [...(image.repoTags ?? []), ...(image.repoDigests ?? []), image.id];
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Every ref containerd itself holds on a node, normalized — the truth CRI's list only mirrors. */
|
|
1304
|
+
async function containerdRefs(node: string): Promise<Set<string>> {
|
|
1305
|
+
const { stdout } = await exec("docker", ["exec", node, "ctr", "-n", CONTAINERD_K8S_NS, "images", "ls", "-q"], BIG);
|
|
1306
|
+
return new Set(
|
|
1307
|
+
stdout
|
|
1308
|
+
.split("\n")
|
|
1309
|
+
.map((s) => normalizeRef(s.trim()))
|
|
1310
|
+
.filter(Boolean),
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** The refs one image is named by, asked of CRI. An id it cannot answer for is already gone, which
|
|
1315
|
+
* is the goal state: no refs to take. */
|
|
1316
|
+
async function criRefsOf(node: string, id: string): Promise<string[]> {
|
|
1317
|
+
try {
|
|
1318
|
+
const { stdout } = await exec("docker", ["exec", node, "crictl", "inspecti", "-o", "json", id], BIG);
|
|
1319
|
+
const parsed = JSON.parse(stdout) as { status?: { repoTags?: string[]; repoDigests?: string[] } };
|
|
1320
|
+
return [...(parsed.status?.repoTags ?? []), ...(parsed.status?.repoDigests ?? [])];
|
|
1321
|
+
} catch {
|
|
1322
|
+
return [];
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* The ownership read on a node: CRI's image LIST carries no labels and offers no label filter, so
|
|
1328
|
+
* provenance costs an `inspecti`, whose `-o json` puts it at `info.imageSpec.config.Labels`.
|
|
1329
|
+
* `inspecti` is variadic and answers a whole batch as one JSON array — one subprocess per node
|
|
1330
|
+
* rather than one per image — but it is fatal on the first id it cannot find, so a listing that
|
|
1331
|
+
* raced a removal falls back to asking one at a time. ONE id nobody answers for reads as unlabeled,
|
|
1332
|
+
* which is the safe direction: unlabeled is invisible, and invisible is never swept.
|
|
1333
|
+
*
|
|
1334
|
+
* NO id answered for is a different fact and gets a different answer: it means the label read
|
|
1335
|
+
* itself is broken (no `crictl` on this node image, an unreachable containerd socket, a `docker
|
|
1336
|
+
* exec` the daemon refused), and the safe-direction rule then turns the WHOLE node sweep into a
|
|
1337
|
+
* no-op that narrates success — a permanently dead collector indistinguishable from a clean
|
|
1338
|
+
* cluster. So it throws, like the roots read, and says which node and why.
|
|
1339
|
+
*
|
|
1340
|
+
* `inspect` is the batch read, injected so the failure shapes above are testable without a node.
|
|
1341
|
+
*/
|
|
1342
|
+
export async function crictlLabels(
|
|
1343
|
+
node: string,
|
|
1344
|
+
ids: string[],
|
|
1345
|
+
inspect: (batch: string[]) => Promise<string>,
|
|
1346
|
+
): Promise<Map<string, Record<string, string>>> {
|
|
1347
|
+
const out = new Map<string, Record<string, string>>();
|
|
1348
|
+
if (ids.length === 0) return out;
|
|
1349
|
+
type Inspected = {
|
|
1350
|
+
status?: { id?: string };
|
|
1351
|
+
info?: { imageSpec?: { config?: { Labels?: Record<string, string> } } };
|
|
1352
|
+
};
|
|
1353
|
+
const read = async (batch: string[]): Promise<void> => {
|
|
1354
|
+
const parsed = JSON.parse(await inspect(batch)) as Inspected | Inspected[];
|
|
1355
|
+
// One id answers with an object, several with an array.
|
|
1356
|
+
for (const entry of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
1357
|
+
const id = entry.status?.id;
|
|
1358
|
+
if (id) out.set(id, entry.info?.imageSpec?.config?.Labels ?? {});
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
let firstFailure: unknown;
|
|
1362
|
+
try {
|
|
1363
|
+
await read(ids);
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
firstFailure = err;
|
|
1366
|
+
for (const id of ids) {
|
|
1367
|
+
try {
|
|
1368
|
+
await read([id]);
|
|
1369
|
+
} catch {
|
|
1370
|
+
// Gone, or unreadable: leave it out of the map, which reads as unlabeled.
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
if (out.size === 0) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`no image on node "${node}" would say who built it ` +
|
|
1377
|
+
`(${(firstFailure instanceof Error ? firstFailure.message : String(firstFailure)).split("\n")[0]}) — ` +
|
|
1378
|
+
`every image would read as unlabeled, so the node sweep would take nothing and report success`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
return out;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** Run a command with a string on stdin (`docker build -f -`). */
|
|
1385
|
+
async function execStdin(argv: string[], stdin: string): Promise<void> {
|
|
1386
|
+
await new Promise<void>((ok, fail) => {
|
|
1387
|
+
const child = spawn(argv[0]!, argv.slice(1), { stdio: ["pipe", "inherit", "inherit"] });
|
|
1388
|
+
child.on("error", fail);
|
|
1389
|
+
child.on("close", (code) => (code === 0 ? ok() : fail(new Error(`${argv.join(" ")} exited ${code}`))));
|
|
1390
|
+
child.stdin.end(stdin);
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** Where the bundle really lives: the `WORKDIR` of {@link INSTANCE_DOCKERFILE}, which `COPY . .`
|
|
1395
|
+
* puts it at. The one path a staged bundle is allowed to name itself by. */
|
|
1396
|
+
const BUNDLE_WORKDIR = "/instance";
|
|
1397
|
+
|
|
1398
|
+
/** Fatal on purpose — a lenient decode would replace the bytes it could not read (see the seal). */
|
|
1399
|
+
const UTF8 = new TextDecoder("utf8", { fatal: true });
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Every substitution the seal makes, longest needle first. A `.bin` shim's `NODE_PATH` is a CHAIN —
|
|
1403
|
+
* the bundle's own `node_modules`, then the `node_modules` of each directory above it, up to the
|
|
1404
|
+
* root — and `pnpm deploy` writes the staging path into it in BOTH spellings it knows: the one
|
|
1405
|
+
* `mkdtemp` returned, and the one `realpath` resolves it to. The two coincide only where the temp
|
|
1406
|
+
* root is a real directory, so anchoring on the returned spelling alone is correct on Linux and
|
|
1407
|
+
* wrong on macOS, where `os.tmpdir()` is `/var/folders/…` and `/var` is a symlink to `/private/var`.
|
|
1408
|
+
* There the resolved spelling keeps the random `mkdtemp` component, the bundle stays a different
|
|
1409
|
+
* artifact every converge, and the tag it is addressed by never notices.
|
|
1410
|
+
*
|
|
1411
|
+
* The rungs ABOVE the bundle are the same failure one level up — macOS puts a per-boot random
|
|
1412
|
+
* segment there (`/var/folders/<xy>/<random>`) — so each is rewritten to the root's, which is
|
|
1413
|
+
* where `/instance`'s ancestors actually are. They are matched as `<ancestor>/node_modules` and
|
|
1414
|
+
* never as a bare directory: replacing every occurrence of `/tmp` would rewrite that string
|
|
1415
|
+
* wherever some dependency's own source happens to hold it.
|
|
1416
|
+
*/
|
|
1417
|
+
async function bundleRewrites(dir: string): Promise<Array<[string, string]>> {
|
|
1418
|
+
const spellings = new Set([dir, await realpath(dir)]);
|
|
1419
|
+
const rewrites: Array<[string, string]> = [];
|
|
1420
|
+
for (const form of spellings) {
|
|
1421
|
+
rewrites.push([form, BUNDLE_WORKDIR]);
|
|
1422
|
+
for (let up = dirname(form); up !== dirname(up); up = dirname(up)) {
|
|
1423
|
+
rewrites.push([`${up}/node_modules`, "/node_modules"]);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return rewrites.sort(([a], [b]) => b.length - a.length);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Seal the staged bundle (ADR-0038): after this, its bytes are a function of its inputs alone.
|
|
1431
|
+
* `pnpm deploy` writes the scratch directory into every `.bin` shim's `NODE_PATH`, and that
|
|
1432
|
+
* directory is a fresh `mkdtemp` per converge — so the same sources staged twice are two different
|
|
1433
|
+
* images under one content-addressed tag, which is "present implies current" broken for the one
|
|
1434
|
+
* image every Instance runs. The rewrite targets `/instance` rather than any stable placeholder
|
|
1435
|
+
* because that is where the image holds the bundle: the shims go from WRONG to CORRECT, and
|
|
1436
|
+
* determinism falls out of fixing them. What gets rewritten, and why it is more than one string,
|
|
1437
|
+
* is {@link bundleRewrites}.
|
|
1438
|
+
*
|
|
1439
|
+
* A file that holds the path but does not decode as UTF-8 stops the converge, named. `/instance` is
|
|
1440
|
+
* SHORTER than the scratch path, so rewriting inside a binary slides every offset after it — that
|
|
1441
|
+
* ships an Orchestrator image whose executable fails in the cluster, where nothing can attribute
|
|
1442
|
+
* it, instead of a converge that failed on the machine that built it.
|
|
1443
|
+
*/
|
|
1444
|
+
async function sealInstanceBundle(dir: string): Promise<void> {
|
|
1445
|
+
const rewrites = await bundleRewrites(dir);
|
|
1446
|
+
const needles = rewrites.map(([from]) => Buffer.from(from));
|
|
1447
|
+
const walk = async (d: string): Promise<void> => {
|
|
1448
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
1449
|
+
const p = join(d, e.name);
|
|
1450
|
+
// A symlink's CONTENT is its target, and `contentHash` skips symlinks too — so a link naming
|
|
1451
|
+
// the staging path would be neither sealed nor addressed: the bundle would vary at a tag that
|
|
1452
|
+
// never moved, which is the one failure this seal exists to make impossible, arriving
|
|
1453
|
+
// silently. pnpm writes relative targets (a real bundle: 276 links, none absolute), so this
|
|
1454
|
+
// throws rather than rewriting — nothing is known about what such a link would mean.
|
|
1455
|
+
if (e.isSymbolicLink()) {
|
|
1456
|
+
const target = await readlink(p);
|
|
1457
|
+
if (rewrites.some(([from]) => target.includes(from))) {
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`cannot seal the instance bundle: the symlink ${relative(dir, p)} points at the staging path ` +
|
|
1460
|
+
`("${target}"), which would leave the bundle naming a directory the image does not have`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
// Directories and files below are reached through `Dirent`, which is lstat-based, so the walk
|
|
1466
|
+
// cannot follow a link out of the bundle.
|
|
1467
|
+
if (e.isDirectory()) await walk(p);
|
|
1468
|
+
else if (e.isFile()) {
|
|
1469
|
+
const bytes = await readFile(p);
|
|
1470
|
+
if (!needles.some((needle) => bytes.includes(needle))) continue;
|
|
1471
|
+
let text: string;
|
|
1472
|
+
try {
|
|
1473
|
+
text = UTF8.decode(bytes);
|
|
1474
|
+
} catch {
|
|
1475
|
+
throw new Error(
|
|
1476
|
+
`cannot seal the instance bundle: ${relative(dir, p)} holds the staging path but is not valid UTF-8 — ` +
|
|
1477
|
+
`"${BUNDLE_WORKDIR}" is shorter than "${dir}", so rewriting it would corrupt every offset after it`,
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
for (const [from, to] of rewrites) text = text.replaceAll(from, to);
|
|
1481
|
+
await writeFile(p, text);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
await walk(dir);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** A materialized instance bundle: the exact bytes the image is built FROM, and their address. */
|
|
1489
|
+
export type StagedBundle = {
|
|
1490
|
+
/** The bundle directory — the build context. */
|
|
1491
|
+
dir: string;
|
|
1492
|
+
/** Content address of `dir` (+ the Dockerfile that bakes it): the image tag, and the staleness key. */
|
|
1493
|
+
hash: string;
|
|
1494
|
+
/** Remove the scratch dir. Always call it; the bundle is a few thousand files. */
|
|
1495
|
+
dispose(): Promise<void>;
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Materialize the bundle and address it by content (ADR-0019 — the deployed image must be derivable
|
|
1500
|
+
* from what `up` can see). The bundle, not the instance folder, is the thing hashed: `pnpm deploy`
|
|
1501
|
+
* resolves the kit into it, so a kit edit in a workspace checkout and a kit upgrade from the
|
|
1502
|
+
* registry both move the hash, by the same rule and with no knowledge of which world it is in.
|
|
1503
|
+
* Hashing the instance folder instead missed kit sources entirely, which is how `jr2 up` came to
|
|
1504
|
+
* skip builds it needed and report convergence on code it had not deployed.
|
|
1505
|
+
*
|
|
1506
|
+
* The bundle is SEALED before it is hashed, and NOTHING is excluded from that hash (ADR-0038): a
|
|
1507
|
+
* bundle that named its own scratch dir made one tag address many images, and the exclude set that
|
|
1508
|
+
* used to paper over it hid exactly the drift it was supposed to expose. With an empty exclude set
|
|
1509
|
+
* the failure inverts — anything that ever varies again re-tags on every converge, in the open.
|
|
1510
|
+
*
|
|
1511
|
+
* `images/` rides along with the rest, and MUST (ADR-0049). A Machine names a Sandbox Image context
|
|
1512
|
+
* by `file:` URL — `import.meta.resolve("../images/default")` — and the DEPLOYED Orchestrator reads
|
|
1513
|
+
* that folder back: it recomputes the context digest at provision to look up the ref `jr2 up`
|
|
1514
|
+
* published under it, which is what lets the two sides agree with no path table between them.
|
|
1515
|
+
* Dropping the folder here (it used to be dropped, as host-side-only) converged green and then
|
|
1516
|
+
* failed every provision of such a Machine on an ENOENT the converge could never see. So the
|
|
1517
|
+
* scaffolded Dockerfile is instance-image content: editing it re-tags the instance and rolls the
|
|
1518
|
+
* Orchestrator. ADR-0038's map still buys what it was taken for — the resolved REF stays out of the
|
|
1519
|
+
* pod template, so a rebuilt Sandbox Image alone rolls nothing.
|
|
1520
|
+
*
|
|
1521
|
+
* Staging precedes the staleness decision, so `pnpm deploy` (~1s) runs even on the skip path; the
|
|
1522
|
+
* docker build it guards is the expensive half. The Dockerfile salts the hash — it is image content
|
|
1523
|
+
* that never lands in the context (it rides `docker build -f -`).
|
|
1524
|
+
*/
|
|
1525
|
+
export async function stageInstanceBundle(port: BuildPort, instanceDir: string): Promise<StagedBundle> {
|
|
1526
|
+
const scratch = await mkdtemp(join(tmpdir(), "jr2-image-"));
|
|
1527
|
+
const dir = join(scratch, "bundle");
|
|
1528
|
+
try {
|
|
1529
|
+
await port.bundle(instanceDir, dir);
|
|
1530
|
+
// pnpm leaves two files that are nothing but a record of where and when this staging happened
|
|
1531
|
+
// — `.modules.yaml` (a `prunedAt` stamp and the scratch paths, written by `pnpm deploy`) and
|
|
1532
|
+
// `.pnpm-workspace-state.json` (a `lastValidatedTimestamp`, written by `pnpm install`) — and
|
|
1533
|
+
// the image reads neither. Both go — they are the one exception to "nothing is excluded",
|
|
1534
|
+
// earned because they are a record OF the staging rather than content of it: the timestamp
|
|
1535
|
+
// alone re-addressed every pnpm bundle on every converge, which is a pod-template change on a
|
|
1536
|
+
// no-op `up`. The rest of the where-and-when — the shims' baked `NODE_PATH` — the seal
|
|
1537
|
+
// corrects.
|
|
1538
|
+
for (const record of [".modules.yaml", ".pnpm-workspace-state.json"]) {
|
|
1539
|
+
await rm(join(dir, "node_modules", record), { force: true });
|
|
1540
|
+
}
|
|
1541
|
+
await sealInstanceBundle(dir);
|
|
1542
|
+
return {
|
|
1543
|
+
dir,
|
|
1544
|
+
hash: await contentHash([dir], INSTANCE_DOCKERFILE),
|
|
1545
|
+
dispose: () => rm(scratch, { recursive: true, force: true }),
|
|
1546
|
+
};
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
await rm(scratch, { recursive: true, force: true });
|
|
1549
|
+
throw err;
|
|
1550
|
+
}
|
|
1551
|
+
}
|