@jr2/cli 0.6.0 → 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.
- package/package.json +3 -3
- package/src/build.ts +4 -4
- package/src/caller-tokens.ts +108 -0
- package/src/cli.ts +33 -2
- package/src/client.ts +224 -23
- package/src/command-table.ts +269 -20
- package/src/commands/caller.ts +115 -0
- package/src/commands/completion.ts +236 -0
- package/src/commands/gates.ts +23 -0
- package/src/commands/hooks.ts +31 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/logs.ts +107 -31
- package/src/commands/run.ts +77 -27
- package/src/commands/runs.ts +17 -78
- package/src/commands/send.ts +1 -1
- package/src/commands/status.ts +121 -6
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +373 -70
- package/src/commands/watch.ts +57 -0
- package/src/commands/whoami.ts +22 -0
- package/src/commands/workflows.ts +58 -0
- package/src/commands/workspace.ts +316 -0
- package/src/deploy.ts +233 -12
- package/src/instance-check.ts +47 -1
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/mount-wait.ts +82 -0
- package/src/output.ts +3 -0
- package/src/sse.ts +1 -1
- package/src/workspaces.ts +307 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The wait for the kubelet (ADR-0069, ADR-0078). The Orchestrator reads `jr2-images`, `jr2-held`
|
|
2
|
+
// and the two digests Secrets through mounts, and the kubelet refreshes a mount on its own period,
|
|
3
|
+
// about a minute. A write to one of them rolls no pod. So `GET /assembly` reports a digest of what
|
|
4
|
+
// the process reads from each, and a command that wrote one waits until the Orchestrator reports
|
|
5
|
+
// the digest of what the command wrote. Then the door's checks and the next provision read the new
|
|
6
|
+
// data, a new Caller token or Hook secret works, and a rotated value has stopped working.
|
|
7
|
+
|
|
8
|
+
import { configMapDigest, DIGEST_SECRETS, MOUNTED_CONFIGMAPS } from "@jr2/orchestrator/internal";
|
|
9
|
+
import { activity, type Io } from "./output.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How long a command waits for the Orchestrator to read what it wrote, and how often it asks. The
|
|
13
|
+
* kubelet's period is about a minute by default. Exported so a test can shorten it.
|
|
14
|
+
*/
|
|
15
|
+
export const MAP_PROPAGATION = { budgetMs: 150_000, stepMs: 5_000 };
|
|
16
|
+
|
|
17
|
+
/** The part of `GET /assembly` that this wait reads: the digest of each mount, by object name. A
|
|
18
|
+
* process that is not deployed reports no `mounts`, and has nothing to wait for. */
|
|
19
|
+
export type MountsView = { mounts?: Record<string, string> };
|
|
20
|
+
|
|
21
|
+
/** An applied object, read loosely: the fields that `mountedDigests` reads. */
|
|
22
|
+
export type AppliedObject = {
|
|
23
|
+
kind?: string;
|
|
24
|
+
metadata?: { name?: string };
|
|
25
|
+
data?: Record<string, string>;
|
|
26
|
+
stringData?: Record<string, string>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The digest of each object in `items` that the Orchestrator reads through a mount, by name: the
|
|
31
|
+
* mounted ConfigMaps by their `data`, and the digests Secrets by their `stringData`, which is the
|
|
32
|
+
* file content the kubelet writes.
|
|
33
|
+
*/
|
|
34
|
+
export function mountedDigests(items: readonly AppliedObject[]): Record<string, string> {
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const name = item.metadata?.name;
|
|
38
|
+
if (name === undefined) continue;
|
|
39
|
+
if (item.kind === "ConfigMap" && name in MOUNTED_CONFIGMAPS) out[name] = configMapDigest(item.data ?? {});
|
|
40
|
+
if (item.kind === "Secret" && name in DIGEST_SECRETS) out[name] = configMapDigest(item.stringData ?? {});
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The names in `want` that are digests Secrets: what a new token or Hook secret waits for. */
|
|
46
|
+
export function digestSecretsOf(want: Readonly<Record<string, string>>): Record<string, string> {
|
|
47
|
+
return Object.fromEntries(Object.entries(want).filter(([name]) => name in DIGEST_SECRETS));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wait until the Orchestrator reports each digest in `want`, asking again each step until the
|
|
52
|
+
* budget ends. It says once, under `prefix`, which objects it waits for. Returns the last view and
|
|
53
|
+
* the names that were still older at the end: none when the wait succeeded.
|
|
54
|
+
*/
|
|
55
|
+
export async function awaitMounts<V extends MountsView>(
|
|
56
|
+
io: Io,
|
|
57
|
+
opts: { seen: V; view: () => Promise<V>; want: Readonly<Record<string, string>>; prefix: string },
|
|
58
|
+
): Promise<{ seen: V; stale: string[] }> {
|
|
59
|
+
const { want } = opts;
|
|
60
|
+
const stale = (v: V): string[] =>
|
|
61
|
+
v.mounts ? Object.keys(want).filter((name) => v.mounts![name] !== want[name]) : [];
|
|
62
|
+
const deadline = Date.now() + MAP_PROPAGATION.budgetMs;
|
|
63
|
+
let seen = opts.seen;
|
|
64
|
+
let said = false;
|
|
65
|
+
for (let names = stale(seen); names.length; names = stale(seen)) {
|
|
66
|
+
if (Date.now() >= deadline) return { seen, stale: names };
|
|
67
|
+
if (!said)
|
|
68
|
+
activity(io, `${opts.prefix}: waiting for the kubelet to deliver ${names.join(", ")} to the Orchestrator`);
|
|
69
|
+
said = true;
|
|
70
|
+
await new Promise((ok) => setTimeout(ok, MAP_PROPAGATION.stepMs));
|
|
71
|
+
seen = await opts.view();
|
|
72
|
+
}
|
|
73
|
+
return { seen, stale: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What a wait that ran out says, before the command's own consequence. */
|
|
77
|
+
export function staleLine(names: readonly string[]): string {
|
|
78
|
+
return (
|
|
79
|
+
`after ${Math.round(MAP_PROPAGATION.budgetMs / 1000)}s the Orchestrator still reads an older ` +
|
|
80
|
+
`${names.join(", ")} from its mount — the kubelet has not refreshed it`
|
|
81
|
+
);
|
|
82
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { BuildPort } from "./build.ts";
|
|
10
10
|
import type { FetchLike } from "./client.ts";
|
|
11
11
|
import type { KubeAdmin, KubePort } from "./kube.ts";
|
|
12
|
+
import type { WorkspaceKube } from "./workspaces.ts";
|
|
12
13
|
import type { TypecheckPort } from "./typecheck.ts";
|
|
13
14
|
|
|
14
15
|
export type Io = {
|
|
@@ -20,6 +21,8 @@ export type Io = {
|
|
|
20
21
|
fetch?: FetchLike;
|
|
21
22
|
/** Override the kube transport (tests inject a fake); default → `kubectl` subprocesses. */
|
|
22
23
|
kube?: KubePort;
|
|
24
|
+
/** Override the cluster side of `jr2 workspace` (ADR-0009); default → `kubectl`. */
|
|
25
|
+
workspaces?: WorkspaceKube;
|
|
23
26
|
/** Override the kube admin surface `jr2 up`/`down` converge through; default → `kubectl`. */
|
|
24
27
|
kubeAdmin?: KubeAdmin;
|
|
25
28
|
/** Override the image build port; default → pnpm + docker + kind subprocesses. */
|
package/src/sse.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// more `data:` lines form the payload. We don't need ids, retry, or comments.
|
|
5
5
|
//
|
|
6
6
|
// Cancelling the reader in `finally` means a consumer that `break`s out of `for await` closes the
|
|
7
|
-
// underlying HTTP connection — that is how `jr2 run` / `jr2
|
|
7
|
+
// underlying HTTP connection — that is how `jr2 run` / `jr2 watch` detach without killing the run.
|
|
8
8
|
|
|
9
9
|
export type SSEFrame = { event: string; data: string };
|
|
10
10
|
|
|
@@ -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
|
+
};
|