@jr2/cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +265 -23
- package/package.json +5 -5
- package/src/assembly.ts +432 -0
- package/src/build.ts +125 -287
- package/src/cli.ts +6 -2
- package/src/client.ts +126 -33
- package/src/commands/down.ts +2 -1
- package/src/commands/init.ts +9 -2
- package/src/commands/kit.ts +3 -3
- package/src/commands/logs.ts +9 -0
- package/src/commands/run.ts +58 -4
- package/src/commands/runs.ts +96 -4
- package/src/commands/send.ts +20 -7
- package/src/commands/status.ts +90 -14
- package/src/commands/up.ts +1058 -207
- package/src/commands/version.ts +17 -12
- package/src/deploy.ts +657 -78
- package/src/effector-image.ts +133 -0
- package/src/kube.ts +107 -5
- package/src/nodes.ts +27 -2
- package/src/sweep.ts +24 -10
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// What a push owes the author's Effector image (ADR-0068). The Sandbox Effector runs in the
|
|
2
|
+
// author's image — the Sandbox Image by default — and runs jr2's own programs from one fixed PATH
|
|
3
|
+
// (`/usr/local/bin:/usr/bin:/bin`, never the image's own). So, per push form the Workspace holds,
|
|
4
|
+
// `jr2 up` reads the image it built — or, for the stock Harness image, which the kit builds, knows
|
|
5
|
+
// what it holds — and refuses, with the fix, an image that lacks what that form needs:
|
|
6
|
+
//
|
|
7
|
+
// - ssh transport (`ssh://`, `git@`): `ssh`, and a passwd entry for the uid the Effector runs as —
|
|
8
|
+
// OpenSSH's client calls `getpwuid(getuid())` before any option, and exits "No user exists for
|
|
9
|
+
// uid N" without one;
|
|
10
|
+
// - ssh signing (`sign: { sshKey }`): `ssh-keygen` (`-Y sign` does no passwd lookup);
|
|
11
|
+
// - GPG signing (`sign: { gpgKey }`): `gpg` and `gpg-agent`;
|
|
12
|
+
// - a token over https: nothing beyond `git`, the Sandbox Image floor (the helper is jr2's binary).
|
|
13
|
+
//
|
|
14
|
+
// The uid is the image's: its `USER` line by name or number, else 1000 (ADR-0037's fallback).
|
|
15
|
+
// `http` (a Worktree file body included) and the credentialed command owe nothing: the first is
|
|
16
|
+
// Go, the last is the author's argv.
|
|
17
|
+
|
|
18
|
+
/** Where the Effector looks for jr2's own programs: effector/internal/gitclean's Path. */
|
|
19
|
+
export const EFFECTOR_TOOL_DIRS = ["/usr/local/bin", "/usr/bin", "/bin"] as const;
|
|
20
|
+
|
|
21
|
+
/** One form a push of a Workspace takes, by what it owes its image. */
|
|
22
|
+
export type PushForm = "ssh-transport" | "ssh-sign" | "gpg-sign";
|
|
23
|
+
|
|
24
|
+
/** The programs each form needs, by name. */
|
|
25
|
+
const TOOLS: Record<PushForm, readonly string[]> = {
|
|
26
|
+
"ssh-transport": ["ssh"],
|
|
27
|
+
"ssh-sign": ["ssh-keygen"],
|
|
28
|
+
"gpg-sign": ["gpg", "gpg-agent"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What to install, by program: the package name on Debian and on Alpine, where they differ. */
|
|
32
|
+
const PACKAGES: Record<string, string> = {
|
|
33
|
+
ssh: "openssh-client",
|
|
34
|
+
"ssh-keygen": "openssh-client (Alpine: openssh-keygen)",
|
|
35
|
+
gpg: "gnupg",
|
|
36
|
+
"gpg-agent": "gnupg",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the stock Harness image holds of the forms' programs, and its /etc/passwd: the fallback
|
|
41
|
+
* Sandbox Image, which the Sandbox Effector runs in when the Workspace names no image and the
|
|
42
|
+
* instance has no `images/default`. The kit builds it (deploy/harness/Dockerfile, its last stage:
|
|
43
|
+
* `node:24-slim` with `git` and `ca-certificates`, `USER 1000`, node's uid), so `jr2 up` knows it in both worlds
|
|
44
|
+
* without reading it — a published one is never pulled to the host for this. A test holds the
|
|
45
|
+
* Dockerfile to it.
|
|
46
|
+
*/
|
|
47
|
+
export const STOCK_HARNESS_IMAGE = {
|
|
48
|
+
present: ["/usr/bin/git"],
|
|
49
|
+
passwd: "root:x:0:0:root:/root:/bin/bash\nnode:x:1000:1000::/home/node:/bin/bash\n",
|
|
50
|
+
user: "1000",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** Every path the forms' programs may be at: each program in each of the Effector's tool dirs. */
|
|
54
|
+
export function effectorImagePaths(forms: readonly PushForm[]): string[] {
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const form of new Set(forms)) {
|
|
57
|
+
for (const tool of TOOLS[form]) for (const dir of EFFECTOR_TOOL_DIRS) out.push(`${dir}/${tool}`);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The uid the Effector runs as, from the image's recorded `USER`: a name, a number, or — none
|
|
63
|
+
* declared — ADR-0037's 1000. A `user:group` line decides by its user half. */
|
|
64
|
+
export function effectorSeat(user: string): { name: string } | { uid: number } {
|
|
65
|
+
const head = user.split(":")[0]!.trim();
|
|
66
|
+
if (head === "") return { uid: 1000 };
|
|
67
|
+
if (/^\d+$/.test(head)) return { uid: Number(head) };
|
|
68
|
+
return { name: head };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether /etc/passwd's text holds an entry for the seat. */
|
|
72
|
+
export function passwdHolds(passwd: string, seat: { name: string } | { uid: number }): boolean {
|
|
73
|
+
for (const line of passwd.split("\n")) {
|
|
74
|
+
const fields = line.split(":");
|
|
75
|
+
if (fields.length < 3) continue;
|
|
76
|
+
if ("name" in seat ? fields[0] === seat.name : fields[2] === String(seat.uid)) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Why the image cannot run the forms, with the fix, or undefined. `present` is which of
|
|
83
|
+
* {@link effectorImagePaths} the image holds (links followed); `passwd` its `/etc/passwd`, "" when
|
|
84
|
+
* it has none; `user` its recorded `USER`.
|
|
85
|
+
*/
|
|
86
|
+
export function effectorImageRefusal(input: {
|
|
87
|
+
/** For the message: `Workspace of workflow "w"`. */
|
|
88
|
+
label: string;
|
|
89
|
+
ref: string;
|
|
90
|
+
user: string;
|
|
91
|
+
/** Each form, and the push it is for: `the push of Repo Slot "app"`. */
|
|
92
|
+
forms: ReadonlyArray<{ form: PushForm; push: string }>;
|
|
93
|
+
present: ReadonlySet<string>;
|
|
94
|
+
passwd: string;
|
|
95
|
+
/** The image is the stock Harness image: the fix is an image of the author's, not an install. */
|
|
96
|
+
stock?: boolean;
|
|
97
|
+
}): string | undefined {
|
|
98
|
+
const { label, ref } = input;
|
|
99
|
+
for (const { form, push } of input.forms) {
|
|
100
|
+
for (const tool of TOOLS[form]) {
|
|
101
|
+
if (EFFECTOR_TOOL_DIRS.some((dir) => input.present.has(`${dir}/${tool}`))) continue;
|
|
102
|
+
const why =
|
|
103
|
+
form === "ssh-transport" ? "over ssh" : form === "ssh-sign" ? "signed with an ssh key" : "signed with GPG";
|
|
104
|
+
if (input.stock) {
|
|
105
|
+
return (
|
|
106
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector runs in the stock Harness image ${ref}, ` +
|
|
107
|
+
`which holds git alone — the Workspace names no image and the instance has no images/default; give it an ` +
|
|
108
|
+
`image that holds ${tool} (${PACKAGES[tool]}): an images/default Sandbox Image, the workspace()'s image, ` +
|
|
109
|
+
`or its effector: { image } (ADR-0068)`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector's image ${ref} holds none in ` +
|
|
114
|
+
`${EFFECTOR_TOOL_DIRS.join(", ")} — the Effector runs jr2's programs from there alone; install it in the ` +
|
|
115
|
+
`image (${PACKAGES[tool]}) (ADR-0068)`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (form !== "ssh-transport") continue;
|
|
119
|
+
const seat = effectorSeat(input.user);
|
|
120
|
+
if (passwdHolds(input.passwd, seat)) continue;
|
|
121
|
+
const who = "name" in seat ? `user ${seat.name}` : `uid ${seat.uid}`;
|
|
122
|
+
const fix =
|
|
123
|
+
"name" in seat
|
|
124
|
+
? `a USER line naming a user the image's /etc/passwd holds`
|
|
125
|
+
: `RUN adduser -D -u ${seat.uid} <name> in the image` +
|
|
126
|
+
(input.user === "" ? " (the image declares no USER, so the Effector runs as uid 1000)" : "");
|
|
127
|
+
return (
|
|
128
|
+
`${label}: ${push} over ssh needs a passwd entry for the ${who} the Sandbox Effector runs as, and its image ` +
|
|
129
|
+
`${ref} has none — ssh exits "No user exists for uid" before it connects; add ${fix} (ADR-0068)`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
package/src/kube.ts
CHANGED
|
@@ -52,8 +52,8 @@ export type KubePort = {
|
|
|
52
52
|
const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
|
|
53
53
|
|
|
54
54
|
/** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
|
|
55
|
-
* identity
|
|
56
|
-
*
|
|
55
|
+
* identity; annotations carry the converged image map, which is too long for a label value
|
|
56
|
+
* (ADR-0038). */
|
|
57
57
|
export type KubeObject = {
|
|
58
58
|
metadata: {
|
|
59
59
|
name: string;
|
|
@@ -138,6 +138,13 @@ export type KubeAdmin = {
|
|
|
138
138
|
previous?: boolean;
|
|
139
139
|
context?: string;
|
|
140
140
|
}): Promise<string>;
|
|
141
|
+
/** The API server's `gitVersion` (`kubectl get --raw /version`), e.g. `v1.35.0-gke.1200` — what
|
|
142
|
+
* ADR-0037's Kubernetes floor is judged against. THROWS when the server does not answer, like
|
|
143
|
+
* `listJson`: a floor check that passed because it could not look is no check at all. */
|
|
144
|
+
serverVersion(opts: { context?: string }): Promise<string>;
|
|
145
|
+
/** `kubectl create --dry-run=server -o json -f -`: the object as admission would store it,
|
|
146
|
+
* stored nowhere. THROWS on a refusal, with kubectl's reason. */
|
|
147
|
+
dryRunCreate(opts: { manifest: string; context?: string }): Promise<{ spec?: { resources?: unknown } }>;
|
|
141
148
|
/** Run a one-shot node script IN the cluster (`kubectl run --rm`) and return its output — the
|
|
142
149
|
* provider-preflight seam (ADR-0019: reachability must be probed from where pods live).
|
|
143
150
|
* `caPem` makes the probe trust a private CA the same way the Harness does (ADR-0020):
|
|
@@ -154,8 +161,8 @@ export type KubeAdmin = {
|
|
|
154
161
|
|
|
155
162
|
const nsArgs = (namespace?: string): string[] => (namespace ? ["--namespace", namespace] : []);
|
|
156
163
|
|
|
157
|
-
/** The
|
|
158
|
-
export type RolloutKind = "deployment" | "daemonset";
|
|
164
|
+
/** The workload kinds `jr2 up` rolls out and waits on. */
|
|
165
|
+
export type RolloutKind = "deployment" | "daemonset" | "statefulset";
|
|
159
166
|
|
|
160
167
|
/** One rollout to wait for: the object, by kind and name, in its namespace. */
|
|
161
168
|
export type RolloutRequest = {
|
|
@@ -186,6 +193,28 @@ export function rolloutStatusArgs({
|
|
|
186
193
|
];
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
/** ADR-0037's Kubernetes floor: every Sandbox's `/opt/jr2` is an `image` volume of the harness
|
|
197
|
+
* image, and image volumes are on by default from 1.35 (GA in 1.36). There is no fallback below it
|
|
198
|
+
* — a second pod shape kept alive for clusters no Instance runs on — so `jr2 up` refuses there. */
|
|
199
|
+
export const KUBE_FLOOR = { major: 1, minor: 35 } as const;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Why this server version cannot host an Instance, or undefined when it can. Pure, so the floor is
|
|
203
|
+
* checkable without a cluster. Judged on the leading `v<major>.<minor>` alone: distributions append
|
|
204
|
+
* their own suffixes (`-gke.1200`, `+k3s1`, `-eks-…`), and none of them moves the feature gate. A
|
|
205
|
+
* version with no such prefix is refused BY NAME rather than passed — a floor that waves through
|
|
206
|
+
* what it cannot read is the timeout ADR-0046 exists to replace.
|
|
207
|
+
*/
|
|
208
|
+
export function kubeFloorRefusal(gitVersion: string): string | undefined {
|
|
209
|
+
const floor = `${KUBE_FLOOR.major}.${KUBE_FLOOR.minor}`;
|
|
210
|
+
const why = `jr2 needs Kubernetes ${floor} or newer (every Sandbox mounts jr2's runtime as an image volume, ADR-0037)`;
|
|
211
|
+
const m = /^v?(\d+)\.(\d+)/.exec(gitVersion);
|
|
212
|
+
if (!m) return `reports version "${gitVersion}", which names no major.minor; ${why}`;
|
|
213
|
+
const [major, minor] = [Number(m[1]), Number(m[2])];
|
|
214
|
+
if (major > KUBE_FLOOR.major || (major === KUBE_FLOOR.major && minor >= KUBE_FLOOR.minor)) return undefined;
|
|
215
|
+
return `runs Kubernetes ${gitVersion}; ${why}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
189
218
|
/**
|
|
190
219
|
* Did this read fail because the cluster has no such RESOURCE TYPE (`kubectl get sandboxes… ` on a
|
|
191
220
|
* cluster with no jr2 CRD)? kubectl exits 1 with `the server doesn't have a resource type "…"`, and
|
|
@@ -288,6 +317,28 @@ export const kubectlAdmin: KubeAdmin = {
|
|
|
288
317
|
}
|
|
289
318
|
},
|
|
290
319
|
|
|
320
|
+
async serverVersion({ context }) {
|
|
321
|
+
// `get --raw /version`, not `kubectl version`: that one reports the CLIENT too; this asks the
|
|
322
|
+
// server alone, and answers in the server's own JSON. Bounded like every first contact: it is
|
|
323
|
+
// the first thing `jr2 up` asks, so a dead context fails here, fast, with kubectl's own reason.
|
|
324
|
+
try {
|
|
325
|
+
const { stdout } = await exec("kubectl", [...ctxArgs(context), "get", "--raw", "/version", ...reachArgs], {
|
|
326
|
+
timeout: REACH_BUDGET_MS,
|
|
327
|
+
});
|
|
328
|
+
return (JSON.parse(stdout) as { gitVersion?: string }).gitVersion ?? "";
|
|
329
|
+
} catch (e) {
|
|
330
|
+
throw new Error(unreachable(e));
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async dryRunCreate({ manifest, context }) {
|
|
335
|
+
const out = await execStdinOut(
|
|
336
|
+
["kubectl", ...ctxArgs(context), "create", "--dry-run=server", "-o", "json", "-f", "-"],
|
|
337
|
+
manifest,
|
|
338
|
+
);
|
|
339
|
+
return JSON.parse(out) as { spec?: { resources?: unknown } };
|
|
340
|
+
},
|
|
341
|
+
|
|
291
342
|
async runOneShot({ namespace, name, script, caPem, context }) {
|
|
292
343
|
// With a CA bundle: write the PEM to a file BEFORE node starts, because NODE_EXTRA_CA_CERTS
|
|
293
344
|
// is only honored as process-start env. The PEM rides an env var base64'd (multiline values
|
|
@@ -350,6 +401,8 @@ export type RolloutTarget = {
|
|
|
350
401
|
namespace: string;
|
|
351
402
|
selector: string;
|
|
352
403
|
context?: string;
|
|
404
|
+
/** Default 180s (see `rolloutStatusArgs`). */
|
|
405
|
+
timeoutSeconds?: number;
|
|
353
406
|
};
|
|
354
407
|
|
|
355
408
|
/** One container's state, as the kubelet tells it. `reason`/`message` are its words verbatim — the
|
|
@@ -474,7 +527,12 @@ async function gatherRolloutEvidence(kube: KubeAdmin, target: RolloutTarget): Pr
|
|
|
474
527
|
const live = pods.filter((p) => !p.metadata.deletionTimestamp);
|
|
475
528
|
if (pods.length > 0 && live.length === 0) notes.push(`every pod matching ${selector} is terminating`);
|
|
476
529
|
if (pods.length === 0 && notes.length === 0) {
|
|
477
|
-
const maker =
|
|
530
|
+
const maker =
|
|
531
|
+
target.kind === "daemonset"
|
|
532
|
+
? "the DaemonSet"
|
|
533
|
+
: target.kind === "statefulset"
|
|
534
|
+
? "the StatefulSet"
|
|
535
|
+
: "the ReplicaSet";
|
|
478
536
|
notes.push(`no pod matches ${selector} — ${maker} made none (check quota, node taints, and the selector)`);
|
|
479
537
|
}
|
|
480
538
|
|
|
@@ -732,6 +790,50 @@ function execStdin(cmd: string[], stdin: string): Promise<void> {
|
|
|
732
790
|
});
|
|
733
791
|
}
|
|
734
792
|
|
|
793
|
+
/** `execStdin`, keeping stdout. */
|
|
794
|
+
function execStdinOut(cmd: string[], stdin: string): Promise<string> {
|
|
795
|
+
return new Promise((resolve, reject) => {
|
|
796
|
+
const child = spawn(cmd[0]!, cmd.slice(1));
|
|
797
|
+
let out = "";
|
|
798
|
+
let err = "";
|
|
799
|
+
child.stdout.on("data", (d: Buffer) => (out += d.toString()));
|
|
800
|
+
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
801
|
+
child.on("error", reject);
|
|
802
|
+
child.on("close", (code) =>
|
|
803
|
+
code === 0 ? resolve(out) : reject(new Error(`${cmd.join(" ")} exited (${code}): ${err.trim()}`)),
|
|
804
|
+
);
|
|
805
|
+
child.stdin.write(stdin);
|
|
806
|
+
child.stdin.end();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The pod ADR-0060's check dry-runs: one that states pod-level resources (KEP-2837). A server
|
|
812
|
+
* whose PodLevelResources gate is off drops `spec.resources` at admission without a word, and
|
|
813
|
+
* every Sandbox then runs Burstable — a User Container with no split unbounded, against "jr2
|
|
814
|
+
* reserves the whole Size". On by default from 1.34, below KUBE_FLOOR, so only an owner who turned
|
|
815
|
+
* it off trips this. Shaped for the `restricted` Pod Security level, so a namespace that enforces
|
|
816
|
+
* it does not refuse the probe for another reason. Never scheduled, never pulled.
|
|
817
|
+
*/
|
|
818
|
+
export function podResourcesProbe(namespace: string): object {
|
|
819
|
+
return {
|
|
820
|
+
apiVersion: "v1",
|
|
821
|
+
kind: "Pod",
|
|
822
|
+
metadata: { name: "jr2-pod-resources-probe", namespace },
|
|
823
|
+
spec: {
|
|
824
|
+
resources: { requests: { cpu: "10m", memory: "16Mi" }, limits: { cpu: "10m", memory: "16Mi" } },
|
|
825
|
+
securityContext: { runAsNonRoot: true, runAsUser: 65532, seccompProfile: { type: "RuntimeDefault" } },
|
|
826
|
+
containers: [
|
|
827
|
+
{
|
|
828
|
+
name: "probe",
|
|
829
|
+
image: "registry.k8s.io/pause:3.10",
|
|
830
|
+
securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
735
837
|
/** The real kube port, over `kubectl` subprocesses. */
|
|
736
838
|
export const kubectlKube: KubePort = {
|
|
737
839
|
async currentContext() {
|
package/src/nodes.ts
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
// at the moment of the read. The set moves (a pool autoscales, a node is cordoned), which is why an
|
|
8
8
|
// empty set is reported and never refused.
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import { cpuMillis, memoryBytes, type ResolvedSize, type SandboxPlacement, type Toleration } from "@jr2/orchestrator";
|
|
11
11
|
|
|
12
12
|
/** A cluster node, read for the facts that decide placement and platform (ADR-0045/0052). */
|
|
13
13
|
export type NodeObject = {
|
|
14
14
|
metadata: { name: string; labels?: Record<string, string> };
|
|
15
15
|
spec?: { unschedulable?: boolean; taints?: readonly Taint[] };
|
|
16
|
-
status?: { nodeInfo?: { architecture?: string } };
|
|
16
|
+
status?: { nodeInfo?: { architecture?: string }; allocatable?: Record<string, string> };
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
export type Taint = { key: string; value?: string; effect: string };
|
|
@@ -72,3 +72,28 @@ export function tolerates(t: Toleration, taint: Taint): boolean {
|
|
|
72
72
|
if (t.operator === "Exists") return true;
|
|
73
73
|
return (t.value ?? "") === (taint.value ?? "");
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether any of `nodes` could hold `size` (ADR-0060): its allocatable memory and cpu both at least
|
|
78
|
+
* the whole Size — what the scheduler compares a Guaranteed pod against on an empty node. `undefined`
|
|
79
|
+
* when no node reports allocatable, which is an unknown, never a no. A WARNING at `jr2 up`, never a
|
|
80
|
+
* refusal: the node set moves (ADR-0052), and a pool may scale up a bigger node tomorrow.
|
|
81
|
+
*/
|
|
82
|
+
export function anyNodeHolds(nodes: readonly NodeObject[], size: ResolvedSize): boolean | undefined {
|
|
83
|
+
let known = false;
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
const alloc = node.status?.allocatable;
|
|
86
|
+
if (alloc?.memory === undefined || alloc.cpu === undefined) continue;
|
|
87
|
+
let memory: number;
|
|
88
|
+
let cpu: number;
|
|
89
|
+
try {
|
|
90
|
+
memory = memoryBytes(alloc.memory);
|
|
91
|
+
cpu = cpuMillis(alloc.cpu);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
known = true;
|
|
96
|
+
if (memory >= size.memory.bytes && cpu >= size.cpu.millis) return true;
|
|
97
|
+
}
|
|
98
|
+
return known ? false : undefined;
|
|
99
|
+
}
|
package/src/sweep.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// is not this instance's garbage):
|
|
8
8
|
// 1. the `jr2-images` ConfigMap of every jr2 instance (namespaces labeled `jr2.dev/instance`) — what
|
|
9
9
|
// FUTURE Sandboxes will be provisioned with;
|
|
10
|
-
// 2. every Sandbox CR's `spec.image
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// 2. every Sandbox CR's `spec.image`, sidecar, init container and `image` volume in those
|
|
11
|
+
// namespaces — a parked Workspace must survive a pod restart, and `imagePullPolicy:
|
|
12
|
+
// IfNotPresent` cannot re-pull a local tag;
|
|
13
|
+
// 3. every pod's container and `image` volume refs in those namespaces plus `jr2-system` — what is
|
|
14
|
+
// actually running (orchestrator, Instance Harness, Custodians, Sandboxes, operator), mid-roll
|
|
15
|
+
// pods INCLUDED, without naming Deployments one by one.
|
|
15
16
|
// The keep set is their union. "Kit images are never pruned" is not a rule here: a kit ref is kept
|
|
16
17
|
// because some instance's map or pod names it, and collects like anything else when the last
|
|
17
18
|
// instance leaves the cluster.
|
|
@@ -53,13 +54,18 @@ export type Roots = { keep: Set<string>; namespaces: string[] };
|
|
|
53
54
|
|
|
54
55
|
type NamespacedObject = { metadata: { name: string; namespace?: string } };
|
|
55
56
|
type ConfigMapObject = NamespacedObject & { data?: Record<string, string> };
|
|
56
|
-
type SandboxObject = NamespacedObject & {
|
|
57
|
+
type SandboxObject = NamespacedObject & {
|
|
58
|
+
spec?: { image?: string; sidecars?: ContainerSpec[]; initContainers?: ContainerSpec[]; volumes?: VolumeSpec[] };
|
|
59
|
+
};
|
|
57
60
|
type ContainerSpec = { image?: string };
|
|
61
|
+
/** Only an `image` volume names a ref: every Sandbox's `/opt/jr2` is one, of the harness image (ADR-0037). */
|
|
62
|
+
type VolumeSpec = { image?: { reference?: string } };
|
|
58
63
|
type PodObject = NamespacedObject & {
|
|
59
64
|
spec?: {
|
|
60
65
|
containers?: ContainerSpec[];
|
|
61
66
|
initContainers?: ContainerSpec[];
|
|
62
67
|
ephemeralContainers?: ContainerSpec[];
|
|
68
|
+
volumes?: VolumeSpec[];
|
|
63
69
|
};
|
|
64
70
|
};
|
|
65
71
|
|
|
@@ -93,18 +99,20 @@ export async function readRoots(kube: KubeAdmin, ctx: { context?: string } = {})
|
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
// 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 Custodian as a sidecar (sandbox-
|
|
102
|
+
// ref. The CR always carries the Custodian as a sidecar (sandbox-kube.ts), and its ref is not
|
|
97
103
|
// covered by the other roots: a running Sandbox is deliberately never re-imaged, so an `up` that
|
|
98
104
|
// moved the Custodian's pin leaves the CR naming the OLD one while the map names the new. If that pod
|
|
99
105
|
// 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.
|
|
106
|
+
// and `IfNotPresent` cannot re-pull a local tag a sweep took. The same holds for the harness ref: it
|
|
107
|
+
// reaches the pod only as the `runtime` image volume (ADR-0037), never as a container's image.
|
|
101
108
|
const sandboxes = await listSandboxes(kube, ctx);
|
|
102
109
|
for (const sandbox of sandboxes) {
|
|
103
110
|
if (!instanceNs.has(sandbox.metadata.namespace ?? "")) continue;
|
|
104
111
|
if (sandbox.spec?.image) keep.add(sandbox.spec.image);
|
|
105
|
-
for (const
|
|
106
|
-
if (
|
|
112
|
+
for (const c of [...(sandbox.spec?.sidecars ?? []), ...(sandbox.spec?.initContainers ?? [])]) {
|
|
113
|
+
if (c.image) keep.add(c.image);
|
|
107
114
|
}
|
|
115
|
+
for (const ref of volumeRefs(sandbox.spec?.volumes)) keep.add(ref);
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
// 3. what is running right now — plus the operator, which lives in the shared `jr2-system`.
|
|
@@ -116,11 +124,17 @@ export async function readRoots(kube: KubeAdmin, ctx: { context?: string } = {})
|
|
|
116
124
|
for (const c of [...(spec.containers ?? []), ...(spec.initContainers ?? []), ...(spec.ephemeralContainers ?? [])]) {
|
|
117
125
|
if (c.image) keep.add(c.image);
|
|
118
126
|
}
|
|
127
|
+
for (const ref of volumeRefs(spec.volumes)) keep.add(ref);
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
return { keep, namespaces };
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
/** The refs a volume list mounts as `image` volumes — invisible to a read of containers alone. */
|
|
134
|
+
function volumeRefs(volumes: VolumeSpec[] | undefined): string[] {
|
|
135
|
+
return (volumes ?? []).flatMap((v) => (v.image?.reference ? [v.image.reference] : []));
|
|
136
|
+
}
|
|
137
|
+
|
|
124
138
|
/**
|
|
125
139
|
* Root #2's read, with the ONE degradation the fail-closed rule allows: a cluster that has no
|
|
126
140
|
* `sandboxes.core.jr2.dev` resource type can hold no Sandbox CRs, so "no Sandboxes" is the complete
|