@jr2/cli 0.5.1 → 0.7.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.
@@ -0,0 +1,307 @@
1
+ // The `jr2 workspace` verbs' cluster side (ADR-0009, ADR-0012, ADR-0021): the instance's Sandbox CRs
2
+ // over the kube API, never the Orchestrator, so the verbs work when it is down. Kube RBAC is the
3
+ // gate; no Caller scope opens them. `WorkspaceKube` is the seam (tests inject a fake through
4
+ // `io.workspaces`), and `kubectlWorkspaces` is the real one, which runs the `kubectl` that a person
5
+ // runs.
6
+ //
7
+ // A Workspace's handle is `<run-prefix>/<workspace path>`: a prefix of its run's id (the label
8
+ // `jr2.dev/run`) and its exact path (the annotation `jr2.dev/workspace-path`). A label selector
9
+ // matches only a whole value, so the CLI lists the CRs and compares on the client. The CR's name is
10
+ // a handle too, and `<name>.jr2` is the CR's name for an ssh `Host *.jr2` (`workspace forward
11
+ // --stdio`).
12
+ //
13
+ // Whether a run holds a Sandbox is the hold mark that the Lease writes on the CR (ADR-0021):
14
+ // `held` from its first write, `released` when it stops for the body's final state, a fault, a
15
+ // cancel or a parent that leaves the Workspace's state. A park and a process shutdown write nothing, so the CR of a run that an Orchestrator crash
16
+ // left stays `held`. A keepalive older than the CR's idle timeout is a Sandbox that no Lease renews
17
+ // (the operator reaps it). Its age is read on the API server's clock, never the local one.
18
+
19
+ import { execFile, spawn } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ import {
22
+ ANNOTATION_HOLD,
23
+ ANNOTATION_KEEPALIVE,
24
+ ANNOTATION_WORKSPACE_PATH,
25
+ HOLD_HELD,
26
+ HOLD_RELEASED,
27
+ LABEL_SANDBOX_RUN,
28
+ LABEL_SANDBOX_WORKFLOW,
29
+ } from "@jr2/orchestrator/internal";
30
+ import { endsWithProcess, REACH_BUDGET_MS } from "./kube.ts";
31
+
32
+ const exec = promisify(execFile);
33
+
34
+ /** The Sandbox CR as these verbs read it. */
35
+ export type SandboxCR = {
36
+ metadata: {
37
+ name: string;
38
+ resourceVersion?: string;
39
+ creationTimestamp?: string;
40
+ labels?: Record<string, string>;
41
+ annotations?: Record<string, string>;
42
+ };
43
+ spec?: { idleTimeout?: string; sidecars?: { name: string }[] };
44
+ status?: { phase?: string };
45
+ };
46
+
47
+ /** The cluster side of the verbs. Every method takes the deployment's namespace and context. */
48
+ export type WorkspaceKube = {
49
+ /** The current kubectl context, or undefined. */
50
+ context(): Promise<string | undefined>;
51
+ /** The instance's Sandbox CRs (those with `jr2.dev/run`), and the API server's clock when it
52
+ * answered (its `Date` header), or undefined when the answer did not say. */
53
+ list(opts: { namespace: string; context?: string }): Promise<{ items: SandboxCR[]; serverNow?: number }>;
54
+ /** Delete one Sandbox CR. Its pod and its token Secret go with it. With `resourceVersion`, the
55
+ * API server deletes it only when the CR is still at that version, else it throws
56
+ * `WorkspaceChangedError`. */
57
+ delete(opts: { namespace: string; name: string; resourceVersion?: string; context?: string }): Promise<void>;
58
+ /** Run `argv` in one container of the pod, with this terminal: `kubectl exec -i [-t]`. Resolves
59
+ * with its exit code. */
60
+ exec(opts: {
61
+ namespace: string;
62
+ pod: string;
63
+ container: string;
64
+ argv: readonly string[];
65
+ tty: boolean;
66
+ context?: string;
67
+ }): Promise<number>;
68
+ /** Forward a local port to `port` in the pod; `local` 0 asks for a free one. */
69
+ forward(opts: {
70
+ namespace: string;
71
+ pod: string;
72
+ port: number;
73
+ local?: number;
74
+ context?: string;
75
+ }): Promise<{ port: number; close: () => void; closed: Promise<number> }>;
76
+ };
77
+
78
+ /** The CR changed after `rm` read it (a run placed it again), so it is not deleted. */
79
+ export class WorkspaceChangedError extends Error {
80
+ constructor(name: string) {
81
+ super(`Sandbox ${name} changed after it was read`);
82
+ this.name = "WorkspaceChangedError";
83
+ }
84
+ }
85
+
86
+ /** A Sandbox CR as one Workspace. */
87
+ export type WorkspaceRow = {
88
+ /** `<run id's first 8>/<path>` — the handle that `ls` prints. */
89
+ handle: string;
90
+ /** The CR's name: a handle too, and the pod's name. */
91
+ name: string;
92
+ /** The CR's version when it was read: `rm` deletes only that version. */
93
+ resourceVersion?: string;
94
+ runId: string;
95
+ workflow: string;
96
+ /** The Workspace's path in the run's Machine (ADR-0012). Empty for a CR that predates the label. */
97
+ path: string;
98
+ phase: string;
99
+ created?: string;
100
+ /** Whether a run holds it: `held`, `released`, `idle` (no Lease renews it), or `unknown` (no mark). */
101
+ hold: "held" | "released" | "idle" | "unknown";
102
+ };
103
+
104
+ const DURATION_UNITS: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
105
+
106
+ /** A duration of the CR's idle timeout in milliseconds, or undefined. The value is a Go duration
107
+ * (metav1.Duration): one or more `<number><unit>`, with the units `h`, `m`, `s` and `ms` (`30m`,
108
+ * `1h30m`, `1h0m0s`, `1.5h`). */
109
+ export function idleTimeoutMs(text: string | undefined): number | undefined {
110
+ const value = text ?? "30m";
111
+ if (!/^(?:\d+(?:\.\d+)?(?:ms|h|m|s))+$/.test(value)) return undefined;
112
+ let ms = 0;
113
+ for (const m of value.matchAll(/(\d+(?:\.\d+)?)(ms|h|m|s)/g)) ms += Number(m[1]) * DURATION_UNITS[m[2]!]!;
114
+ return ms;
115
+ }
116
+
117
+ /** One CR as its row. `serverNow` is the API server's clock; without it no keepalive is read. */
118
+ export function rowOf(cr: SandboxCR, serverNow: number | undefined): WorkspaceRow {
119
+ const labels = cr.metadata.labels ?? {};
120
+ const notes = cr.metadata.annotations ?? {};
121
+ const runId = labels[LABEL_SANDBOX_RUN] ?? "";
122
+ const path = notes[ANNOTATION_WORKSPACE_PATH] ?? "";
123
+ const mark = notes[ANNOTATION_HOLD];
124
+ const keepalive = Date.parse(notes[ANNOTATION_KEEPALIVE] ?? "");
125
+ const idle = idleTimeoutMs(cr.spec?.idleTimeout);
126
+ const lapsed =
127
+ serverNow !== undefined && idle !== undefined && Number.isFinite(keepalive) && serverNow - keepalive > idle;
128
+ const hold: WorkspaceRow["hold"] =
129
+ mark === HOLD_RELEASED ? "released" : lapsed ? "idle" : mark === HOLD_HELD ? "held" : "unknown";
130
+ return {
131
+ handle: `${runId.slice(0, 8)}/${path || cr.metadata.name}`,
132
+ name: cr.metadata.name,
133
+ ...(cr.metadata.resourceVersion ? { resourceVersion: cr.metadata.resourceVersion } : {}),
134
+ runId,
135
+ workflow: labels[LABEL_SANDBOX_WORKFLOW] ?? "",
136
+ path,
137
+ phase: cr.status?.phase ?? "Pending",
138
+ ...(cr.metadata.creationTimestamp ? { created: cr.metadata.creationTimestamp } : {}),
139
+ hold,
140
+ };
141
+ }
142
+
143
+ /** The rows a handle names: the CR's name (or `<name>.jr2`), else `<run-prefix>/<path>`. */
144
+ export function matchHandle(rows: readonly WorkspaceRow[], handle: string): WorkspaceRow[] {
145
+ const bare = handle.endsWith(".jr2") ? handle.slice(0, -".jr2".length) : handle;
146
+ const byName = rows.filter((r) => r.name === bare);
147
+ if (byName.length) return byName;
148
+ const slash = bare.indexOf("/");
149
+ if (slash <= 0) return [];
150
+ const prefix = bare.slice(0, slash);
151
+ const path = bare.slice(slash + 1);
152
+ // A CR with no path prints `<run-prefix>/<name>` (`rowOf`), so that handle names it too.
153
+ return rows.filter((r) => r.runId.startsWith(prefix) && (r.path ? r.path === path : r.name === path));
154
+ }
155
+
156
+ /** May `rm` remove it? Only a Sandbox that no run holds: `released`, or `idle`. */
157
+ export function removable(row: WorkspaceRow): boolean {
158
+ return row.hold === "released" || row.hold === "idle";
159
+ }
160
+
161
+ /** `30s`, `5m`, `3h`, `2d` — an age as `kubectl get` prints one. */
162
+ export function age(created: string | undefined, now: number): string {
163
+ const t = Date.parse(created ?? "");
164
+ if (!Number.isFinite(t)) return "-";
165
+ const s = Math.max(0, Math.floor((now - t) / 1000));
166
+ if (s < 120) return `${s}s`;
167
+ if (s < 7200) return `${Math.floor(s / 60)}m`;
168
+ if (s < 172_800) return `${Math.floor(s / 3600)}h`;
169
+ return `${Math.floor(s / 86_400)}d`;
170
+ }
171
+
172
+ const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
173
+ const SANDBOX_RESOURCE = "sandboxes.core.jr2.dev";
174
+ /** The Sandbox CRs' API path, for a delete with a precondition (`kubectl delete --raw`). */
175
+ const SANDBOX_API = "/apis/core.jr2.dev/v1alpha1";
176
+ /** The `Date` header in kubectl's `-v=8` trace: the API server's clock when it answered. */
177
+ const DATE_HEADER = /Date: ([A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT)/;
178
+
179
+ export const kubectlWorkspaces: WorkspaceKube = {
180
+ async context() {
181
+ try {
182
+ const { stdout } = await exec("kubectl", ["config", "current-context"]);
183
+ return stdout.trim() || undefined;
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ },
188
+
189
+ async list({ namespace, context }) {
190
+ // `-v=8` traces the answer's headers on stderr: the one place kubectl shows the server's clock.
191
+ const { stdout, stderr } = await exec(
192
+ "kubectl",
193
+ [
194
+ ...ctxArgs(context),
195
+ "--namespace",
196
+ namespace,
197
+ "get",
198
+ SANDBOX_RESOURCE,
199
+ "-l",
200
+ LABEL_SANDBOX_RUN,
201
+ "-o",
202
+ "json",
203
+ "-v=8",
204
+ `--request-timeout=${REACH_BUDGET_MS}ms`,
205
+ ],
206
+ { timeout: REACH_BUDGET_MS, maxBuffer: 64 * 1024 * 1024 },
207
+ );
208
+ const date = DATE_HEADER.exec(stderr)?.[1];
209
+ const serverNow = date ? Date.parse(date) : undefined;
210
+ const items = (JSON.parse(stdout) as { items?: SandboxCR[] }).items ?? [];
211
+ return { items, ...(serverNow !== undefined && Number.isFinite(serverNow) ? { serverNow } : {}) };
212
+ },
213
+
214
+ async delete({ namespace, name, resourceVersion, context }) {
215
+ if (resourceVersion === undefined) {
216
+ await exec(
217
+ "kubectl",
218
+ [...ctxArgs(context), "--namespace", namespace, "delete", SANDBOX_RESOURCE, name, "--wait=false"],
219
+ { timeout: REACH_BUDGET_MS },
220
+ );
221
+ return;
222
+ }
223
+ // `kubectl delete` has no precondition flag: the raw DELETE carries DeleteOptions on stdin, and
224
+ // the API server answers 409 Conflict when the CR is no longer at the version that was read.
225
+ const body = JSON.stringify({ kind: "DeleteOptions", apiVersion: "v1", preconditions: { resourceVersion } });
226
+ const uri = `${SANDBOX_API}/namespaces/${encodeURIComponent(namespace)}/sandboxes/${encodeURIComponent(name)}`;
227
+ await new Promise<void>((resolve, reject) => {
228
+ const child = execFile(
229
+ "kubectl",
230
+ [...ctxArgs(context), "delete", "--raw", uri, "-f", "-"],
231
+ { timeout: REACH_BUDGET_MS },
232
+ (err, _stdout, stderr) => {
233
+ if (!err) return resolve();
234
+ if (/\(Conflict\)|does not match the ResourceVersion/.test(stderr)) reject(new WorkspaceChangedError(name));
235
+ else reject(new Error(`kubectl delete failed: ${stderr.trim() || err.message}`));
236
+ },
237
+ );
238
+ child.stdin?.end(body);
239
+ });
240
+ },
241
+
242
+ exec({ namespace, pod, container, argv, tty, context }) {
243
+ return new Promise((resolve, reject) => {
244
+ const child = spawn(
245
+ "kubectl",
246
+ [
247
+ ...ctxArgs(context),
248
+ "--namespace",
249
+ namespace,
250
+ "exec",
251
+ tty ? "-it" : "-i",
252
+ "-c",
253
+ container,
254
+ pod,
255
+ "--",
256
+ ...argv,
257
+ ],
258
+ { stdio: "inherit" },
259
+ );
260
+ child.on("error", (e) => reject(new Error(`kubectl exec failed: ${e.message}`)));
261
+ child.on("close", (code) => resolve(code ?? 1));
262
+ });
263
+ },
264
+
265
+ forward({ namespace, pod, port, local = 0, context }) {
266
+ return new Promise((resolve, reject) => {
267
+ const child = spawn("kubectl", [
268
+ ...ctxArgs(context),
269
+ "--namespace",
270
+ namespace,
271
+ "port-forward",
272
+ `pod/${pod}`,
273
+ `${local === 0 ? "" : local}:${port}`,
274
+ "--address",
275
+ "127.0.0.1",
276
+ ]);
277
+ const kill = endsWithProcess(child);
278
+ let out = "";
279
+ let err = "";
280
+ let ready = false;
281
+ const closed = new Promise<number>((r) => child.on("close", (code) => r(code ?? 1)));
282
+ const gaveUp = setTimeout(() => {
283
+ kill();
284
+ reject(new Error(`kubectl port-forward: no answer after ${REACH_BUDGET_MS / 1000}s`));
285
+ }, REACH_BUDGET_MS);
286
+ gaveUp.unref?.();
287
+ child.stdout.on("data", (d: Buffer) => {
288
+ out += d.toString();
289
+ const m = /Forwarding from 127\.0\.0\.1:(\d+)/.exec(out);
290
+ if (m && !ready) {
291
+ ready = true;
292
+ clearTimeout(gaveUp);
293
+ resolve({ port: Number(m[1]), close: kill, closed });
294
+ }
295
+ });
296
+ child.stderr.on("data", (d: Buffer) => (err += d.toString()));
297
+ child.on("error", (e) => {
298
+ clearTimeout(gaveUp);
299
+ reject(new Error(`kubectl port-forward failed: ${e.message}`));
300
+ });
301
+ child.on("close", (code) => {
302
+ clearTimeout(gaveUp);
303
+ if (!ready) reject(new Error(`kubectl port-forward exited (${code}): ${err.trim()}`));
304
+ });
305
+ });
306
+ },
307
+ };