@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/src/sweep.ts ADDED
@@ -0,0 +1,232 @@
1
+ // The reachability half of image garbage collection (ADR-0039). `build.ts` owns the two stores'
2
+ // physics — what the host daemon and a kind node's containerd hold, and the pure policy over them —
3
+ // and knows nothing about Kubernetes. This module is the other half: an image is needed iff a LIVE
4
+ // ROOT names it, and every root is a Kubernetes object.
5
+ //
6
+ // The three roots, all read CLUSTER-WIDE (not "my namespace" — a ref another instance's map names
7
+ // is not this instance's garbage):
8
+ // 1. the `jr2-images` ConfigMap of every jr2 instance (namespaces labeled `jr2.dev/instance`) — what
9
+ // FUTURE Sandboxes will be provisioned with;
10
+ // 2. every Sandbox CR's `spec.image` in those namespaces — a parked Workspace must survive a pod
11
+ // restart, and `imagePullPolicy: IfNotPresent` cannot re-pull a local tag;
12
+ // 3. every pod's container images in those namespaces plus `jr2-system` — what is actually running
13
+ // (orchestrator, Instance Harness, Adapter, Sandboxes, operator), mid-roll pods INCLUDED,
14
+ // without naming Deployments one by one.
15
+ // The keep set is their union. "Kit images are never pruned" is not a rule here: a kit ref is kept
16
+ // because some instance's map or pod names it, and collects like anything else when the last
17
+ // instance leaves the cluster.
18
+ //
19
+ // The roots read FAILS CLOSED. A partial root set is not a smaller keep set, it is a WRONG one —
20
+ // every ref it failed to see reads as garbage — so any error throws and the caller sweeps nothing.
21
+ // This is also why the `jr2-images` read goes through `listJson`, which throws, rather than
22
+ // `getJson`, which turns every failure into "absent". The single exception is not a degradation at
23
+ // all: a cluster with no Sandbox CRD holds no Sandbox CRs, so that read's "none" is complete (see
24
+ // `listSandboxes`).
25
+ //
26
+ // Accepted, and written down so nobody adds a name filter to "fix" it (ADR-0039): the host keep set
27
+ // only sees the CURRENT context's roots, so a second checkout converging to a different cluster can
28
+ // have its host kit generation swept. The rebuild is BuildKit-cached seconds. Re-introducing a name
29
+ // filter would resurrect the exact primitive this ADR deletes.
30
+
31
+ import { IMAGES_CONFIGMAP, IMAGES_KEY } from "@jr2/orchestrator";
32
+ import { formatBytes, mergeSweeps, sweepHost, sweepNodes, type BuildPort, type SweepResult } from "./build.ts";
33
+ import { LABEL_INSTANCE, OPERATOR_NAMESPACE } from "./deploy.ts";
34
+ import { isMissingResourceType, type KubeAdmin } from "./kube.ts";
35
+ import { activity, type Io } from "./output.ts";
36
+
37
+ /** The Sandbox CR, fully qualified so the read cannot collide with another `sandboxes` resource. */
38
+ const SANDBOX_KIND = "sandboxes.core.jr2.dev";
39
+
40
+ /** A kube context addresses a kind cluster by convention: `kind-<cluster>`. The node half of the
41
+ * sweep is kind-only — elsewhere the nodes pull from a registry, whose retention is the registry's
42
+ * business (ADR-0038's line, kept) — while the host half runs wherever the CLI does. */
43
+ const KIND_CONTEXT_PREFIX = "kind-";
44
+
45
+ /** The cluster `kind` knows this context by, or undefined when the context is not a kind one. */
46
+ export function kindCluster(context: string): string | undefined {
47
+ return context.startsWith(KIND_CONTEXT_PREFIX) ? context.slice(KIND_CONTEXT_PREFIX.length) : undefined;
48
+ }
49
+
50
+ /** What the cluster's live roots name. `namespaces` is the instance namespaces the read found —
51
+ * the scope every other root was narrowed to, worth reporting in a dry run. */
52
+ export type Roots = { keep: Set<string>; namespaces: string[] };
53
+
54
+ type NamespacedObject = { metadata: { name: string; namespace?: string } };
55
+ type ConfigMapObject = NamespacedObject & { data?: Record<string, string> };
56
+ type SandboxObject = NamespacedObject & { spec?: { image?: string; sidecars?: ContainerSpec[] } };
57
+ type ContainerSpec = { image?: string };
58
+ type PodObject = NamespacedObject & {
59
+ spec?: {
60
+ containers?: ContainerSpec[];
61
+ initContainers?: ContainerSpec[];
62
+ ephemeralContainers?: ContainerSpec[];
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Assemble the keep set from the cluster's three roots. Throws if any read fails — see the
68
+ * fail-closed rule at the top of this module.
69
+ *
70
+ * Terminating pods are deliberately NOT filtered out (the opposite of `up`'s `verifyRunningImage`,
71
+ * which asks what the cluster runs NOW). A keep set is asked a different question: for a mid-roll
72
+ * pod, "still holds this image" is the true and conservative answer, and the ref collects on the
73
+ * next sweep once the pod is gone.
74
+ */
75
+ export async function readRoots(kube: KubeAdmin, ctx: { context?: string } = {}): Promise<Roots> {
76
+ // A bare-key selector: every namespace that belongs to SOME instance, whichever they are.
77
+ const namespaces = (await kube.listJson({ kind: "namespace", selector: LABEL_INSTANCE, ...ctx })).map(
78
+ (n) => n.metadata.name,
79
+ );
80
+ const instanceNs = new Set(namespaces);
81
+ const keep = new Set<string>();
82
+
83
+ // 1. what future Sandboxes will run.
84
+ const maps = await kube.listJson<ConfigMapObject>({
85
+ kind: "configmap",
86
+ fieldSelector: `metadata.name=${IMAGES_CONFIGMAP}`,
87
+ allNamespaces: true,
88
+ ...ctx,
89
+ });
90
+ for (const cm of maps) {
91
+ if (!instanceNs.has(cm.metadata.namespace ?? "")) continue;
92
+ for (const ref of imageMapRefs(cm)) keep.add(ref);
93
+ }
94
+
95
+ // 2. what a parked Workspace's pod will be recreated with — the Sandbox Image AND every sidecar's
96
+ // ref. The CR always carries the Adapter as a sidecar (sandbox-kubectl.ts), and its ref is not
97
+ // covered by the other roots: a running Sandbox is deliberately never re-imaged, so an `up` that
98
+ // rebuilt the Adapter leaves the CR naming the OLD one while the map names the new. If that pod
99
+ // is then lost (node restart, eviction, drain), the recreated one pulls the CR's sidecar ref —
100
+ // and `IfNotPresent` cannot re-pull a local tag a sweep took.
101
+ const sandboxes = await listSandboxes(kube, ctx);
102
+ for (const sandbox of sandboxes) {
103
+ if (!instanceNs.has(sandbox.metadata.namespace ?? "")) continue;
104
+ if (sandbox.spec?.image) keep.add(sandbox.spec.image);
105
+ for (const sidecar of sandbox.spec?.sidecars ?? []) {
106
+ if (sidecar.image) keep.add(sidecar.image);
107
+ }
108
+ }
109
+
110
+ // 3. what is running right now — plus the operator, which lives in the shared `jr2-system`.
111
+ const pods = await kube.listJson<PodObject>({ kind: "pod", allNamespaces: true, ...ctx });
112
+ for (const pod of pods) {
113
+ const ns = pod.metadata.namespace ?? "";
114
+ if (!instanceNs.has(ns) && ns !== OPERATOR_NAMESPACE) continue;
115
+ const spec = pod.spec ?? {};
116
+ for (const c of [...(spec.containers ?? []), ...(spec.initContainers ?? []), ...(spec.ephemeralContainers ?? [])]) {
117
+ if (c.image) keep.add(c.image);
118
+ }
119
+ }
120
+
121
+ return { keep, namespaces };
122
+ }
123
+
124
+ /**
125
+ * Root #2's read, with the ONE degradation the fail-closed rule allows: a cluster that has no
126
+ * `sandboxes.core.jr2.dev` resource type can hold no Sandbox CRs, so "no Sandboxes" is the complete
127
+ * answer rather than a partial one, and the keep set it feeds is right.
128
+ *
129
+ * The case is the sweep's own doing, not a hypothetical: `jr2 down --all` deletes the operator
130
+ * manifest — the CRD with it — and only then sweeps, which is the very case ADR-0039 cites for
131
+ * collecting kit images. `jr2 gc` meets it too, on any cluster the operator never reached (recreate
132
+ * the kind cluster, then "disk is full now"). Every OTHER failure still throws: Forbidden and an
133
+ * unreachable API did hide Sandboxes that exist.
134
+ */
135
+ async function listSandboxes(kube: KubeAdmin, ctx: { context?: string }): Promise<SandboxObject[]> {
136
+ try {
137
+ return await kube.listJson<SandboxObject>({ kind: SANDBOX_KIND, allNamespaces: true, ...ctx });
138
+ } catch (err) {
139
+ if (isMissingResourceType(err)) return [];
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ /** Every ref one instance's image map names: the kit's own at the top level (`harness`, `adapter`,
145
+ * and the `operator` that rides the same JSON), and each Sandbox Image under `sandbox`. An
146
+ * unreadable map is a FAILED root, not an empty one — a hand-edited ConfigMap must not be read as
147
+ * "that instance needs nothing". */
148
+ function imageMapRefs(cm: ConfigMapObject): string[] {
149
+ const raw = cm.data?.[IMAGES_KEY];
150
+ if (raw === undefined) return [];
151
+ let parsed: unknown;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch (err) {
155
+ throw new Error(
156
+ `the ${IMAGES_CONFIGMAP} ConfigMap in namespace "${cm.metadata.namespace}" is not JSON ` +
157
+ `(${err instanceof Error ? err.message : err}) — the sweep cannot tell what that instance still ` +
158
+ `needs, so it takes nothing. \`jr2 up\` in that instance rewrites the map.`,
159
+ );
160
+ }
161
+ if (typeof parsed !== "object" || parsed === null) return [];
162
+ const refs: string[] = [];
163
+ for (const value of Object.values(parsed as Record<string, unknown>)) {
164
+ if (typeof value === "string") refs.push(value);
165
+ else if (typeof value === "object" && value !== null) {
166
+ for (const nested of Object.values(value as Record<string, unknown>)) {
167
+ if (typeof nested === "string") refs.push(nested);
168
+ }
169
+ }
170
+ }
171
+ return refs;
172
+ }
173
+
174
+ /**
175
+ * One sweep: read the roots, then take every LABELED image on the host daemon — and, on a kind
176
+ * context, on every node — that the keep set does not name. Narrates the result.
177
+ *
178
+ * `extraKeep` is what the caller just resolved and has not necessarily observed in the cluster yet;
179
+ * it protects both stores. `grace` is the NODE-only one-generation reprieve (ADR-0039): the refs the
180
+ * image map this converge REPLACED named, kept one more round so the ConfigMap's kubelet
181
+ * propagation window cannot provision a ref this sweep just took. The host needs no such window —
182
+ * nothing is provisioned from it.
183
+ */
184
+ export async function sweepImages(opts: {
185
+ io: Io;
186
+ build: BuildPort;
187
+ kube: KubeAdmin;
188
+ /** The kube context, both to address the reads and to decide whether nodes exist to sweep. */
189
+ context: string;
190
+ ctx: { context?: string };
191
+ extraKeep?: Iterable<string>;
192
+ grace?: Iterable<string>;
193
+ dryRun?: boolean;
194
+ }): Promise<SweepResult> {
195
+ const { io, build, kube, context, ctx, dryRun } = opts;
196
+ const roots = await readRoots(kube, ctx);
197
+ const keep = new Set([...roots.keep, ...(opts.extraKeep ?? [])]);
198
+ const cluster = kindCluster(context);
199
+ const host = await sweepHost(build, { keep, dryRun });
200
+ // The node keep set is the host's plus the grace generation — a superset, deliberately: the two
201
+ // stores answer different questions, and only one of them has a propagation window.
202
+ const nodes = cluster
203
+ ? await sweepNodes(build, { cluster, keep: new Set([...keep, ...(opts.grace ?? [])]), dryRun })
204
+ : undefined;
205
+ const report = nodes ? mergeSweeps(host, nodes) : host;
206
+ narrateSweep(io, report, { dryRun });
207
+ return report;
208
+ }
209
+
210
+ /** `swept 4 image(s) (2.1 GB)` — bytes, not counts, because disk is the quantity the user feels
211
+ * (ADR-0039). A silent collector plus one visible number is the entire intended interface, so the
212
+ * refs themselves are listed only for a dry run, where the plan IS the output. */
213
+ export function narrateSweep(io: Io, report: SweepResult, opts: { dryRun?: boolean } = {}): void {
214
+ const verb = opts.dryRun ? "would sweep" : "swept";
215
+ if (report.removed.length > 0) {
216
+ activity(io, `${verb} ${report.removed.length} image(s) (${formatBytes(report.bytes)})`);
217
+ if (opts.dryRun) for (const ref of report.removed) activity(io, ` ${ref}`);
218
+ } else {
219
+ // Said rather than silent: on a cluster whose images predate ADR-0039 nothing carries a stamp,
220
+ // so nothing is ever collectable, and a silent sweep would read as a broken one.
221
+ activity(io, `${verb} nothing — every labeled image is named by a live root`);
222
+ }
223
+ if (report.kept.length > 0) {
224
+ activity(
225
+ io,
226
+ `kept ${report.kept.length} tag(s) — their image id also carries a tag a live root names: ${report.kept.join(", ")}`,
227
+ );
228
+ }
229
+ if (report.failed.length > 0) {
230
+ activity(io, `failed to remove ${report.failed.length} image(s): ${report.failed.join(", ")}`);
231
+ }
232
+ }
@@ -0,0 +1,75 @@
1
+ // The Instance's own typecheck, run as a converge gate (ADR-0050). A Machine names its parts by
2
+ // string — an actor slot, a composed Machine, a `customize()` of either, a Repo Slot it binds
3
+ // (ADR-0051) — and since ADR-0049 those strings are typed by xstate's own `src` typing and by the
4
+ // Machine's own parts. A type error is therefore the EARLIEST place a wrong name can be caught,
5
+ // so `jr2 up` runs the compiler first: a mistyped slot is refused here, before a bundle, three
6
+ // image builds, and a rollout are spent on a Machine that would fail at invoke time mid-run.
7
+ // The one check that is converge-time and not compile-time is an OPEN Repo Slot nobody bound: a
8
+ // `workflows/` export has no type to hang it on, so `jr2 up`'s walk refuses it right after this gate.
9
+ //
10
+ // The compiler is the INSTANCE's, resolved from its own `node_modules` (ADR-0043): the instance's
11
+ // program includes @jr2/orchestrator's `.ts` sources (zero-build — `exports` point at source), so
12
+ // the checker is part of the kit contract and the scaffold pins it in `devDependencies`. A
13
+ // compiler the CLI carried would check the user's code with a version their editor and their own
14
+ // `npm run typecheck` never run, and would report the kit's sources against a compiler the kit
15
+ // never ran — the exact failure that put `typescript` in the scaffold in the first place.
16
+ //
17
+ // It runs the same two inputs the user's own `typecheck` script runs — `tsc --noEmit` against the
18
+ // folder's `tsconfig.json` — so `jr2 up`'s answer and the editor's are one answer.
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { createRequire } from "node:module";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { promisify } from "node:util";
25
+
26
+ const exec = promisify(execFile);
27
+
28
+ /** What the compiler said. `output` is empty exactly when `ok`. */
29
+ export type TypecheckResult = { ok: boolean; output: string };
30
+
31
+ /** Typecheck one Instance folder. Injectable through `io.typecheck` so the CLI's own unit tests
32
+ * drive the gate's two answers without spending a compiler on a fixture in the temp dir. */
33
+ export type TypecheckPort = (root: string) => Promise<TypecheckResult>;
34
+
35
+ /** The real port: the Instance's own `tsc`, in the Instance's own folder. */
36
+ export const tscTypecheck: TypecheckPort = async (root) => {
37
+ if (!existsSync(join(root, "tsconfig.json"))) {
38
+ throw new Error(
39
+ "this instance has no tsconfig.json — `jr2 init` scaffolds one that extends " +
40
+ "`@jr2/orchestrator/tsconfig.instance.json`, and `jr2 up` typechecks the folder before it builds anything",
41
+ );
42
+ }
43
+ const tsc = compilerBin(root);
44
+ try {
45
+ // `--pretty false` because this output is relayed, not drawn: one `file(line,col): error TSxxxx`
46
+ // per line survives a pipe, a CI log, and the `activity` prefix that carries it to stderr.
47
+ await exec(process.execPath, [tsc, "--noEmit", "--pretty", "false"], { cwd: root, maxBuffer: 16 * 1024 * 1024 });
48
+ return { ok: true, output: "" };
49
+ } catch (err) {
50
+ // tsc reports on STDOUT and exits non-zero; a compiler that failed to start reports on stderr.
51
+ // Both are the answer here — the gate's job is to show what the compiler said, not to classify it.
52
+ const failed = err as { stdout?: string; stderr?: string };
53
+ return { ok: false, output: `${failed.stdout ?? ""}${failed.stderr ?? ""}`.trim() };
54
+ }
55
+ };
56
+
57
+ /** `typescript`'s own `tsc`, as the package declares it, resolved from the Instance. */
58
+ function compilerBin(root: string): string {
59
+ // Resolution starts at a file INSIDE the instance (the root marker), so it walks the instance's
60
+ // own `node_modules` first and finds what the folder installed — never what the CLI carries.
61
+ const requireFrom = createRequire(join(root, "jr2.config.ts"));
62
+ let manifest: string;
63
+ try {
64
+ manifest = requireFrom.resolve("typescript/package.json");
65
+ } catch {
66
+ throw new Error(
67
+ "this instance's TypeScript compiler is missing — install its dependencies (npm, pnpm, or bun), then " +
68
+ "re-run `jr2 up`. The scaffold pins `typescript` in devDependencies because the instance's program " +
69
+ "includes the kit's own .ts sources, so the checker travels with the kit rather than floating (ADR-0043)",
70
+ );
71
+ }
72
+ const bin = (requireFrom(manifest) as { bin?: { tsc?: string } }).bin?.tsc;
73
+ if (!bin) throw new Error(`the \`typescript\` at ${dirname(manifest)} declares no \`tsc\` binary`);
74
+ return resolve(dirname(manifest), bin);
75
+ }