@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/kube.ts
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// The CLI's kube transport (ADR-0019): run-verbs reach a deployed orchestrator by port-forwarding
|
|
2
|
+
// its Service via the kube API for the duration of the command, authenticating with the Instance
|
|
3
|
+
// token read from its in-cluster Secret — kube RBAC is the real gate. This module is the seam:
|
|
4
|
+
// `KubePort` is what target resolution consumes (tests inject a fake), `kubectlKube` is the real
|
|
5
|
+
// one, shelling out to the same `kubectl` a human debugging the cluster would use.
|
|
6
|
+
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
/** How long a RUN-VERB waits to find out whether the cluster is there (ADR-0019). Matches the
|
|
13
|
+
* Harness client's connect bound (`harness-client.ts`), so the two seats that dial across a
|
|
14
|
+
* network agree on what "too long" means. It bounds resolution only: `jr2 up` waits on rollouts
|
|
15
|
+
* for minutes, and says so with its own `--timeout`.
|
|
16
|
+
*
|
|
17
|
+
* Spent TWICE, because `--request-timeout` bounds one server request and kubectl retries API
|
|
18
|
+
* discovery behind it — a 5s flag was measured buying a 25s command. The flag makes each attempt
|
|
19
|
+
* give up promptly; `timeout` (execFile kills the child) is what bounds the command. */
|
|
20
|
+
export const REACH_BUDGET_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
const reachArgs = [`--request-timeout=${REACH_BUDGET_MS}ms`];
|
|
23
|
+
|
|
24
|
+
/** Why a bounded kubectl did not answer, in a phrase a verb can put after a dash. */
|
|
25
|
+
function unreachable(e: unknown): string {
|
|
26
|
+
const err = e as { killed?: boolean; stderr?: string; message?: string };
|
|
27
|
+
if (err.killed) return `no answer after ${REACH_BUDGET_MS / 1000}s`;
|
|
28
|
+
const said = (err.stderr ?? "").trim().split("\n").filter(Boolean).pop();
|
|
29
|
+
return said ?? err.message ?? "kubectl failed";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The fixed in-namespace object names live with the orchestrator (its entrypoint consumes them
|
|
33
|
+
// too); re-exported here for the CLI's own modules.
|
|
34
|
+
export { INSTANCE_SECRET, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "@jr2/orchestrator";
|
|
35
|
+
|
|
36
|
+
export type KubePort = {
|
|
37
|
+
/** The current kubectl context, or undefined when there is none configured. */
|
|
38
|
+
currentContext(): Promise<string | undefined>;
|
|
39
|
+
/** One decoded key of a Secret; undefined when the Secret (or key) is ABSENT. Rejects when the
|
|
40
|
+
* cluster could not answer at all — the two are different faults (ADR-0019), and a caller that
|
|
41
|
+
* merged them would send a user with a dead VPN off to check a context that is correct. */
|
|
42
|
+
readSecret(opts: { namespace: string; name: string; key: string; context?: string }): Promise<string | undefined>;
|
|
43
|
+
/** Forward a Service port to an ephemeral local port for the life of the command. */
|
|
44
|
+
portForward(opts: {
|
|
45
|
+
namespace: string;
|
|
46
|
+
service: string;
|
|
47
|
+
port: number;
|
|
48
|
+
context?: string;
|
|
49
|
+
}): Promise<{ url: string; close: () => void }>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
|
|
53
|
+
|
|
54
|
+
/** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
|
|
55
|
+
* identity and the instance image's content hash; annotations carry the converged image map, which
|
|
56
|
+
* is too long for a label value (ADR-0038). */
|
|
57
|
+
export type KubeObject = {
|
|
58
|
+
metadata: {
|
|
59
|
+
name: string;
|
|
60
|
+
/** Only ever populated by a cluster-wide read. It is how the sweep's roots (ADR-0039) narrow a
|
|
61
|
+
* `--all-namespaces` listing back to the namespaces that belong to a jr2 instance — the objects
|
|
62
|
+
* are found cluster-wide precisely because no instance's images are only its own business. */
|
|
63
|
+
namespace?: string;
|
|
64
|
+
labels?: Record<string, string>;
|
|
65
|
+
annotations?: Record<string, string>;
|
|
66
|
+
};
|
|
67
|
+
} & Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
/** What `jr2 up`/`jr2 down` converge through — admin-shaped, next to the transport-shaped KubePort.
|
|
70
|
+
* Injected via `io.kubeAdmin` in tests; `kubectlAdmin` is the real one. */
|
|
71
|
+
export type KubeAdmin = {
|
|
72
|
+
/** The current kubectl context, or undefined when there is none configured. */
|
|
73
|
+
context(): Promise<string | undefined>;
|
|
74
|
+
/** One object as JSON; undefined when absent. Cluster-scoped kinds pass no namespace. */
|
|
75
|
+
getJson<T = KubeObject>(opts: {
|
|
76
|
+
kind: string;
|
|
77
|
+
name: string;
|
|
78
|
+
namespace?: string;
|
|
79
|
+
context?: string;
|
|
80
|
+
}): Promise<T | undefined>;
|
|
81
|
+
/** Objects matching a label selector. Unlike `getJson`, a failed query THROWS rather than
|
|
82
|
+
* reading as "absent" — this is what converge claims are checked against, and a check that
|
|
83
|
+
* silently passes when it could not look is the defect it exists to catch. */
|
|
84
|
+
listJson<T = KubeObject>(opts: {
|
|
85
|
+
kind: string;
|
|
86
|
+
/** `-l`. A bare key (no `=`) is an EXISTENCE selector, which is how the sweep asks for "every
|
|
87
|
+
* namespace some instance owns" without knowing any of their names. */
|
|
88
|
+
selector?: string;
|
|
89
|
+
/** `--field-selector`. The one root addressed by NAME rather than by label — the `jr2-images`
|
|
90
|
+
* ConfigMap (ADR-0039) — is read this way instead of with `getJson`, because `getJson` reads
|
|
91
|
+
* every failure as "absent", and a root that reads as absent when the API could not be reached
|
|
92
|
+
* is a keep set that deletes another instance's images. */
|
|
93
|
+
fieldSelector?: string;
|
|
94
|
+
namespace?: string;
|
|
95
|
+
/** `--all-namespaces`. The sweep's roots are CLUSTER-WIDE (ADR-0039): a ref that any instance's
|
|
96
|
+
* image map, Sandbox, or pod names is not garbage, so "my namespace" is the wrong scope for a
|
|
97
|
+
* keep set. Ignored when `namespace` is set. */
|
|
98
|
+
allNamespaces?: boolean;
|
|
99
|
+
context?: string;
|
|
100
|
+
}): Promise<T[]>;
|
|
101
|
+
/** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string. */
|
|
102
|
+
apply(opts: { manifest: string; context?: string }): Promise<void>;
|
|
103
|
+
/** `kubectl label --overwrite`. */
|
|
104
|
+
label(opts: {
|
|
105
|
+
kind: string;
|
|
106
|
+
name: string;
|
|
107
|
+
namespace?: string;
|
|
108
|
+
labels: Record<string, string>;
|
|
109
|
+
context?: string;
|
|
110
|
+
}): Promise<void>;
|
|
111
|
+
/** `kubectl delete --ignore-not-found` of one object. */
|
|
112
|
+
deleteObject(opts: {
|
|
113
|
+
kind: string;
|
|
114
|
+
name: string;
|
|
115
|
+
namespace?: string;
|
|
116
|
+
context?: string;
|
|
117
|
+
wait?: boolean;
|
|
118
|
+
}): Promise<void>;
|
|
119
|
+
/** `kubectl delete --ignore-not-found -f -` of a manifest string (the operator uninstall). */
|
|
120
|
+
deleteManifest(opts: { manifest: string; context?: string }): Promise<void>;
|
|
121
|
+
/** `kubectl rollout status <kind>/<name>` — converge isn't done until the pods are. A Deployment
|
|
122
|
+
* by default; the cache agent's DaemonSet (ADR-0051) is the one other rollout `jr2 up` waits on. */
|
|
123
|
+
waitRollout(opts: RolloutRequest): Promise<void>;
|
|
124
|
+
/** The tail of one container's output (`kubectl logs --tail`) — evidence, not a claim: a read
|
|
125
|
+
* that fails (no such pod, a container that never started, no RBAC) answers `""`, because this
|
|
126
|
+
* is only ever called to explain a failure that already happened (ADR-0046) and a diagnosis
|
|
127
|
+
* that can itself fail is a second failure on top of the first. `previous` asks for the
|
|
128
|
+
* container instance BEFORE the current one, which is the only place a CrashLoopBackOff pod's
|
|
129
|
+
* crash is still written down. */
|
|
130
|
+
logs(opts: {
|
|
131
|
+
namespace: string;
|
|
132
|
+
pod: string;
|
|
133
|
+
container?: string;
|
|
134
|
+
tailLines?: number;
|
|
135
|
+
previous?: boolean;
|
|
136
|
+
context?: string;
|
|
137
|
+
}): Promise<string>;
|
|
138
|
+
/** Run a one-shot node script IN the cluster (`kubectl run --rm`) and return its output — the
|
|
139
|
+
* provider-preflight seam (ADR-0019: reachability must be probed from where pods live).
|
|
140
|
+
* `caPem` makes the probe trust a private CA the same way the Harness does (ADR-0020):
|
|
141
|
+
* `NODE_EXTRA_CA_CERTS`, which must be process env at node start — it cannot be set from
|
|
142
|
+
* inside the script. */
|
|
143
|
+
runOneShot(opts: {
|
|
144
|
+
namespace: string;
|
|
145
|
+
name: string;
|
|
146
|
+
script: string;
|
|
147
|
+
caPem?: string;
|
|
148
|
+
context?: string;
|
|
149
|
+
}): Promise<string>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const nsArgs = (namespace?: string): string[] => (namespace ? ["--namespace", namespace] : []);
|
|
153
|
+
|
|
154
|
+
/** The two workload kinds `jr2 up` rolls out and waits on. */
|
|
155
|
+
export type RolloutKind = "deployment" | "daemonset";
|
|
156
|
+
|
|
157
|
+
/** One rollout to wait for: the object, by kind and name, in its namespace. */
|
|
158
|
+
export type RolloutRequest = {
|
|
159
|
+
/** Default `deployment`. */
|
|
160
|
+
kind?: RolloutKind;
|
|
161
|
+
name: string;
|
|
162
|
+
namespace: string;
|
|
163
|
+
context?: string;
|
|
164
|
+
timeoutSeconds?: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The argv of one rollout wait — pure, so the kind → `kubectl rollout status <kind>/<name>` mapping
|
|
168
|
+
* is checkable without a cluster. */
|
|
169
|
+
export function rolloutStatusArgs({
|
|
170
|
+
kind = "deployment",
|
|
171
|
+
name,
|
|
172
|
+
namespace,
|
|
173
|
+
context,
|
|
174
|
+
timeoutSeconds = 180,
|
|
175
|
+
}: RolloutRequest): string[] {
|
|
176
|
+
return [
|
|
177
|
+
...ctxArgs(context),
|
|
178
|
+
...nsArgs(namespace),
|
|
179
|
+
"rollout",
|
|
180
|
+
"status",
|
|
181
|
+
`${kind}/${name}`,
|
|
182
|
+
`--timeout=${timeoutSeconds}s`,
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Did this read fail because the cluster has no such RESOURCE TYPE (`kubectl get sandboxes… ` on a
|
|
188
|
+
* cluster with no jr2 CRD)? kubectl exits 1 with `the server doesn't have a resource type "…"`, and
|
|
189
|
+
* that single failure means something no other one does: the kind cannot exist, so neither can any
|
|
190
|
+
* object of it. Narrow on purpose — Forbidden and "connection refused" DID hide objects, and a
|
|
191
|
+
* caller that degraded on those would build a keep set that deletes another instance's images.
|
|
192
|
+
*/
|
|
193
|
+
export function isMissingResourceType(err: unknown): boolean {
|
|
194
|
+
return /doesn't have a resource type/i.test(err instanceof Error ? err.message : String(err));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The real admin port, over `kubectl` subprocesses. */
|
|
198
|
+
export const kubectlAdmin: KubeAdmin = {
|
|
199
|
+
context: () => kubectlKube.currentContext(),
|
|
200
|
+
|
|
201
|
+
async getJson({ kind, name, namespace, context }) {
|
|
202
|
+
try {
|
|
203
|
+
const { stdout } = await exec("kubectl", [
|
|
204
|
+
...ctxArgs(context),
|
|
205
|
+
...nsArgs(namespace),
|
|
206
|
+
"get",
|
|
207
|
+
kind,
|
|
208
|
+
name,
|
|
209
|
+
"-o",
|
|
210
|
+
"json",
|
|
211
|
+
]);
|
|
212
|
+
return JSON.parse(stdout);
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async listJson({ kind, selector, fieldSelector, namespace, allNamespaces, context }) {
|
|
219
|
+
const { stdout } = await exec(
|
|
220
|
+
"kubectl",
|
|
221
|
+
[
|
|
222
|
+
...ctxArgs(context),
|
|
223
|
+
...nsArgs(namespace),
|
|
224
|
+
"get",
|
|
225
|
+
kind,
|
|
226
|
+
// AFTER the kind, unlike `--namespace`: kubectl reads a `--all-namespaces` that precedes
|
|
227
|
+
// `get` as a plugin invocation and fails with "flags cannot be placed before plugin name".
|
|
228
|
+
...(!namespace && allNamespaces ? ["--all-namespaces"] : []),
|
|
229
|
+
...(selector ? ["-l", selector] : []),
|
|
230
|
+
...(fieldSelector ? ["--field-selector", fieldSelector] : []),
|
|
231
|
+
"-o",
|
|
232
|
+
"json",
|
|
233
|
+
],
|
|
234
|
+
// A cluster-wide pod list (the sweep's third root, ADR-0039) blows past execFile's 1 MB
|
|
235
|
+
// default on any cluster with real workloads, and a truncated read is a SMALLER keep set.
|
|
236
|
+
{ maxBuffer: 64 * 1024 * 1024 },
|
|
237
|
+
);
|
|
238
|
+
return (JSON.parse(stdout) as { items?: never[] }).items ?? [];
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async apply({ manifest, context }) {
|
|
242
|
+
await execStdin(["kubectl", ...ctxArgs(context), "apply", "-f", "-"], manifest);
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async label({ kind, name, namespace, labels, context }) {
|
|
246
|
+
const pairs = Object.entries(labels).map(([k, v]) => `${k}=${v}`);
|
|
247
|
+
await exec("kubectl", [...ctxArgs(context), ...nsArgs(namespace), "label", "--overwrite", kind, name, ...pairs]);
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async deleteObject({ kind, name, namespace, context, wait }) {
|
|
251
|
+
await exec("kubectl", [
|
|
252
|
+
...ctxArgs(context),
|
|
253
|
+
...nsArgs(namespace),
|
|
254
|
+
"delete",
|
|
255
|
+
kind,
|
|
256
|
+
name,
|
|
257
|
+
"--ignore-not-found",
|
|
258
|
+
`--wait=${wait !== false}`,
|
|
259
|
+
]);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async deleteManifest({ manifest, context }) {
|
|
263
|
+
await execStdin(["kubectl", ...ctxArgs(context), "delete", "--ignore-not-found", "-f", "-"], manifest);
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async waitRollout(req) {
|
|
267
|
+
await exec("kubectl", rolloutStatusArgs(req));
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async logs({ namespace, pod, container, tailLines = 20, previous, context }) {
|
|
271
|
+
try {
|
|
272
|
+
const { stdout } = await exec("kubectl", [
|
|
273
|
+
...ctxArgs(context),
|
|
274
|
+
...nsArgs(namespace),
|
|
275
|
+
"logs",
|
|
276
|
+
pod,
|
|
277
|
+
...(container ? ["-c", container] : []),
|
|
278
|
+
`--tail=${tailLines}`,
|
|
279
|
+
...(previous ? ["--previous"] : []),
|
|
280
|
+
]);
|
|
281
|
+
return stdout;
|
|
282
|
+
} catch {
|
|
283
|
+
return ""; // "the container wrote nothing readable" IS the answer here — see the port's doc
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async runOneShot({ namespace, name, script, caPem, context }) {
|
|
288
|
+
// With a CA bundle: write the PEM to a file BEFORE node starts, because NODE_EXTRA_CA_CERTS
|
|
289
|
+
// is only honored as process-start env. The PEM rides an env var base64'd (multiline values
|
|
290
|
+
// and `--env` don't mix); `"$1"` keeps the script out of shell parsing entirely (execFile
|
|
291
|
+
// passes argv verbatim, no host shell either).
|
|
292
|
+
const command = caPem
|
|
293
|
+
? ["sh", "-ec", 'echo "$JR2_CA_B64" | base64 -d > /tmp/jr2-ca.crt && exec node -e "$1"', "sh", script]
|
|
294
|
+
: ["node", "-e", script];
|
|
295
|
+
try {
|
|
296
|
+
const { stdout } = await exec(
|
|
297
|
+
"kubectl",
|
|
298
|
+
[
|
|
299
|
+
...ctxArgs(context),
|
|
300
|
+
...nsArgs(namespace),
|
|
301
|
+
"run",
|
|
302
|
+
name,
|
|
303
|
+
"--rm",
|
|
304
|
+
"--attach",
|
|
305
|
+
"--restart=Never",
|
|
306
|
+
"--quiet",
|
|
307
|
+
"--image=node:24-slim",
|
|
308
|
+
...(caPem
|
|
309
|
+
? [`--env=JR2_CA_B64=${Buffer.from(caPem).toString("base64")}`, "--env=NODE_EXTRA_CA_CERTS=/tmp/jr2-ca.crt"]
|
|
310
|
+
: []),
|
|
311
|
+
"--command",
|
|
312
|
+
"--",
|
|
313
|
+
...command,
|
|
314
|
+
],
|
|
315
|
+
{ timeout: 180_000 },
|
|
316
|
+
);
|
|
317
|
+
return stdout;
|
|
318
|
+
} catch (err) {
|
|
319
|
+
throw oneShotFailure(err);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A one-shot probe's failure, made readable. `kubectl run --attach` streams the POD's own output
|
|
326
|
+
* on ITS stdout, and kubectl writes only its verdict ("pod … terminated (Error)") to stderr — so
|
|
327
|
+
* the one line that says WHY (a stack, a DNS error, an HTTP status) is exactly what execFile's
|
|
328
|
+
* error message drops, since that message carries stderr alone. The provider preflight (ADR-0019)
|
|
329
|
+
* exists to be the cheapest diagnosis available; without the pod's own words it can only report
|
|
330
|
+
* that something in the cluster exited non-zero, and the caller is back to reproducing the probe
|
|
331
|
+
* by hand. So the attached output leads, and the kubectl verdict follows it.
|
|
332
|
+
*/
|
|
333
|
+
export function oneShotFailure(err: unknown): Error {
|
|
334
|
+
const attached = typeof (err as { stdout?: unknown })?.stdout === "string" ? (err as { stdout: string }).stdout : "";
|
|
335
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
336
|
+
const trimmed = attached.trim();
|
|
337
|
+
return trimmed ? new Error(`${trimmed}\n${base}`) : err instanceof Error ? err : new Error(base);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** What a rollout wait needs to know to go looking: the workload that did not come up (a Deployment
|
|
341
|
+
* unless `kind` says otherwise), and the label selector its pods carry (each converge layer already
|
|
342
|
+
* knows its own — it verifies the running image through the same selector). */
|
|
343
|
+
export type RolloutTarget = {
|
|
344
|
+
kind?: RolloutKind;
|
|
345
|
+
name: string;
|
|
346
|
+
namespace: string;
|
|
347
|
+
selector: string;
|
|
348
|
+
context?: string;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** One container's state, as the kubelet tells it. `reason`/`message` are its words verbatim — the
|
|
352
|
+
* diagnosis table below may annotate them, never restate them (ADR-0046).
|
|
353
|
+
*
|
|
354
|
+
* `lastExit` is carried BESIDE them and never instead: a container in CrashLoopBackOff is WAITING,
|
|
355
|
+
* and its waiting message is the uninformative "back-off 40s restarting failed container" while the
|
|
356
|
+
* sentence that says why ("exec format error") sits in the exit it is backing off from. Keeping
|
|
357
|
+
* only the current state is exactly how the ADR-0045 failure stayed invisible. */
|
|
358
|
+
type ContainerEvidence = {
|
|
359
|
+
name: string;
|
|
360
|
+
image?: string;
|
|
361
|
+
ready?: boolean;
|
|
362
|
+
restarts?: number;
|
|
363
|
+
reason?: string;
|
|
364
|
+
message?: string;
|
|
365
|
+
lastExit?: string;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** One pod's whole story: what it is, where it landed, what its containers say, what the cluster
|
|
369
|
+
* said about it, and the tail of the container that is not running. */
|
|
370
|
+
type PodEvidence = {
|
|
371
|
+
name: string;
|
|
372
|
+
phase?: string;
|
|
373
|
+
node?: string;
|
|
374
|
+
nodeArch?: string;
|
|
375
|
+
containers: ContainerEvidence[];
|
|
376
|
+
events: string[];
|
|
377
|
+
logs?: { container: string; text: string };
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** Everything the failed rollout could be seen to say. `notes` carries the reads that could NOT be
|
|
381
|
+
* made, said out loud: a silent gap here reads as "the cluster had nothing to say", which is the
|
|
382
|
+
* exact lie this diagnosis exists to stop telling. */
|
|
383
|
+
type RolloutEvidence = { pods: PodEvidence[]; notes: string[] };
|
|
384
|
+
|
|
385
|
+
/** How many pods are worth printing. A failed rollout is usually one pod saying one thing; past a
|
|
386
|
+
* handful the message stops being read at all. */
|
|
387
|
+
const EVIDENCE_PODS = 3;
|
|
388
|
+
|
|
389
|
+
type PodStatusObject = {
|
|
390
|
+
metadata: { name: string; deletionTimestamp?: string };
|
|
391
|
+
spec?: { nodeName?: string; containers?: Array<{ name?: string; image?: string }> };
|
|
392
|
+
status?: {
|
|
393
|
+
phase?: string;
|
|
394
|
+
containerStatuses?: ContainerStatusObject[];
|
|
395
|
+
initContainerStatuses?: ContainerStatusObject[];
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
type TerminatedState = { reason?: string; exitCode?: number; message?: string };
|
|
400
|
+
|
|
401
|
+
type ContainerStatusObject = {
|
|
402
|
+
name?: string;
|
|
403
|
+
image?: string;
|
|
404
|
+
ready?: boolean;
|
|
405
|
+
restartCount?: number;
|
|
406
|
+
state?: { waiting?: { reason?: string; message?: string }; terminated?: TerminatedState };
|
|
407
|
+
lastState?: { terminated?: TerminatedState };
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
type EventObject = {
|
|
411
|
+
metadata: { name: string; creationTimestamp?: string };
|
|
412
|
+
type?: string;
|
|
413
|
+
reason?: string;
|
|
414
|
+
message?: string;
|
|
415
|
+
lastTimestamp?: string;
|
|
416
|
+
eventTime?: string;
|
|
417
|
+
involvedObject?: { kind?: string; name?: string };
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A rollout's failure, made readable (ADR-0046). `kubectl rollout status` reports its verdict and
|
|
422
|
+
* nothing else — `error: timed out waiting for the condition` — so the fact that says WHY (an
|
|
423
|
+
* `exec format error`, a pull the kubelet gave up on, a Secret that is not there) lives in the
|
|
424
|
+
* pods, which the caller then goes and reads by hand. This is `oneShotFailure()` one layer up: the
|
|
425
|
+
* evidence leads, kubectl's verdict follows, and `jr2 up`'s three rollout waits share the one pair
|
|
426
|
+
* of eyes.
|
|
427
|
+
*
|
|
428
|
+
* Every read here is best-effort by construction. It runs only after a failure has already
|
|
429
|
+
* happened, so a diagnosis that can itself throw would replace a bad error with a worse one.
|
|
430
|
+
*/
|
|
431
|
+
export async function rolloutFailure(kube: KubeAdmin, err: unknown, target: RolloutTarget): Promise<Error> {
|
|
432
|
+
const verdict = (err instanceof Error ? err.message : String(err)).trim();
|
|
433
|
+
let evidence: RolloutEvidence;
|
|
434
|
+
try {
|
|
435
|
+
evidence = await gatherRolloutEvidence(kube, target);
|
|
436
|
+
} catch (gatherErr) {
|
|
437
|
+
evidence = { pods: [], notes: [`the pods could not be read (${errText(gatherErr)})`] };
|
|
438
|
+
}
|
|
439
|
+
const lines = [
|
|
440
|
+
`${target.name}: rollout did not complete in namespace ${target.namespace} — the pods say:`,
|
|
441
|
+
"",
|
|
442
|
+
...renderEvidence(evidence, target),
|
|
443
|
+
];
|
|
444
|
+
// The named diagnoses sit BETWEEN the evidence and the verdict, and only ever point at what is
|
|
445
|
+
// printed above them: a table that swallowed the evidence would turn a wrong match into a lie,
|
|
446
|
+
// while one that annotates it costs nothing when wrong (ADR-0046).
|
|
447
|
+
const diagnosis = diagnoseRollout(evidence, target);
|
|
448
|
+
if (diagnosis.length > 0) lines.push("", ...diagnosis);
|
|
449
|
+
if (verdict) lines.push("", verdict);
|
|
450
|
+
return new Error(lines.join("\n"));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** One error's words, whatever was thrown — every gathering read reports its own failure this way. */
|
|
454
|
+
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
455
|
+
|
|
456
|
+
/** Read once, degrade per read: a listing that fails becomes a note, never an exception. */
|
|
457
|
+
async function gatherRolloutEvidence(kube: KubeAdmin, target: RolloutTarget): Promise<RolloutEvidence> {
|
|
458
|
+
const { namespace, selector } = target;
|
|
459
|
+
const ctx = target.context ? { context: target.context } : {};
|
|
460
|
+
const notes: string[] = [];
|
|
461
|
+
|
|
462
|
+
let pods: PodStatusObject[] = [];
|
|
463
|
+
try {
|
|
464
|
+
pods = await kube.listJson<PodStatusObject>({ kind: "pod", selector, namespace, ...ctx });
|
|
465
|
+
} catch (err) {
|
|
466
|
+
notes.push(`pods matching ${selector} could not be listed (${errText(err)})`);
|
|
467
|
+
}
|
|
468
|
+
// Pods on the way out belong to the OUTGOING ReplicaSet: they are what the cluster is done
|
|
469
|
+
// running, not what refused to come up.
|
|
470
|
+
const live = pods.filter((p) => !p.metadata.deletionTimestamp);
|
|
471
|
+
if (pods.length > 0 && live.length === 0) notes.push(`every pod matching ${selector} is terminating`);
|
|
472
|
+
if (pods.length === 0 && notes.length === 0) {
|
|
473
|
+
const maker = target.kind === "daemonset" ? "the DaemonSet" : "the ReplicaSet";
|
|
474
|
+
notes.push(`no pod matches ${selector} — ${maker} made none (check quota, node taints, and the selector)`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let events: EventObject[] = [];
|
|
478
|
+
try {
|
|
479
|
+
events = await kube.listJson<EventObject>({ kind: "event", namespace, ...ctx });
|
|
480
|
+
} catch (err) {
|
|
481
|
+
notes.push(`events could not be listed (${errText(err)})`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const chosen = [...live].sort(byTrouble).slice(0, EVIDENCE_PODS);
|
|
485
|
+
const out: PodEvidence[] = [];
|
|
486
|
+
for (const pod of chosen) {
|
|
487
|
+
const statuses = [...(pod.status?.initContainerStatuses ?? []), ...(pod.status?.containerStatuses ?? [])];
|
|
488
|
+
// A pod that never got as far as a container status (unschedulable, still Pending) still has a
|
|
489
|
+
// spec, and the refs it names are evidence — the empty case must not print an empty pod.
|
|
490
|
+
const containers: ContainerEvidence[] =
|
|
491
|
+
statuses.length > 0
|
|
492
|
+
? statuses.map((s) => containerEvidence(s, pod))
|
|
493
|
+
: (pod.spec?.containers ?? []).map((c) => ({
|
|
494
|
+
name: c.name ?? "(unnamed)",
|
|
495
|
+
...(c.image ? { image: c.image } : {}),
|
|
496
|
+
}));
|
|
497
|
+
// The container to quote is the one that is NOT running: an init container that failed blocks
|
|
498
|
+
// everything behind it, so it is read first.
|
|
499
|
+
const troubled = statuses.find((s) => s.ready !== true) ?? statuses[0];
|
|
500
|
+
const evidence: PodEvidence = {
|
|
501
|
+
name: pod.metadata.name,
|
|
502
|
+
phase: pod.status?.phase,
|
|
503
|
+
node: pod.spec?.nodeName,
|
|
504
|
+
containers,
|
|
505
|
+
events: podEvents(events, pod.metadata.name),
|
|
506
|
+
};
|
|
507
|
+
// The node's architecture is what an `exec format error` is measured against (ADR-0045), and
|
|
508
|
+
// it is one cheap read of an object the pod already names.
|
|
509
|
+
if (pod.spec?.nodeName) {
|
|
510
|
+
const node = await kube.getJson<{ status?: { nodeInfo?: { architecture?: string } } }>({
|
|
511
|
+
kind: "node",
|
|
512
|
+
name: pod.spec.nodeName,
|
|
513
|
+
...ctx,
|
|
514
|
+
});
|
|
515
|
+
const arch = node?.status?.nodeInfo?.architecture;
|
|
516
|
+
if (arch) evidence.nodeArch = arch;
|
|
517
|
+
}
|
|
518
|
+
if (troubled?.name) {
|
|
519
|
+
// The current instance first, then the one before it: a container in CrashLoopBackOff has
|
|
520
|
+
// already been torn down, so its own words survive only under `--previous`.
|
|
521
|
+
const text =
|
|
522
|
+
(await kube.logs({ namespace, pod: pod.metadata.name, container: troubled.name, tailLines: 20, ...ctx })) ||
|
|
523
|
+
(await kube.logs({
|
|
524
|
+
namespace,
|
|
525
|
+
pod: pod.metadata.name,
|
|
526
|
+
container: troubled.name,
|
|
527
|
+
tailLines: 20,
|
|
528
|
+
previous: true,
|
|
529
|
+
...ctx,
|
|
530
|
+
}));
|
|
531
|
+
if (text.trim()) evidence.logs = { container: troubled.name, text: text.trim() };
|
|
532
|
+
}
|
|
533
|
+
out.push(evidence);
|
|
534
|
+
}
|
|
535
|
+
if (live.length > out.length) notes.push(`${live.length - out.length} further pod(s) not shown`);
|
|
536
|
+
return { pods: out, notes };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Not-ready pods first — the ones that failed are the ones worth the space. A pod with NO container
|
|
540
|
+
* statuses at all is trouble too, not the absence of it: that is a pod the kubelet never got as far
|
|
541
|
+
* as starting (unschedulable, still Pending), which is exactly the one a failed rollout is about —
|
|
542
|
+
* and `every` on an empty list would otherwise sort it last, behind the pods that came up. */
|
|
543
|
+
function byTrouble(a: PodStatusObject, b: PodStatusObject): number {
|
|
544
|
+
const ready = (p: PodStatusObject): number => {
|
|
545
|
+
const statuses = p.status?.containerStatuses ?? [];
|
|
546
|
+
return statuses.length > 0 && statuses.every((c) => c.ready === true) ? 1 : 0;
|
|
547
|
+
};
|
|
548
|
+
return ready(a) - ready(b);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function containerEvidence(status: ContainerStatusObject, pod: PodStatusObject): ContainerEvidence {
|
|
552
|
+
const waiting = status.state?.waiting;
|
|
553
|
+
const ended = status.state?.terminated;
|
|
554
|
+
const before = status.lastState?.terminated;
|
|
555
|
+
const out: ContainerEvidence = { name: status.name ?? "(unnamed)" };
|
|
556
|
+
// The SPEC's ref, not the status's: the spec carries the ref this converge asked for, while the
|
|
557
|
+
// status may carry whatever the kubelet resolved it to (or nothing, on a pull that never landed).
|
|
558
|
+
const image = pod.spec?.containers?.find((c) => c.name === status.name)?.image ?? status.image;
|
|
559
|
+
if (image) out.image = image;
|
|
560
|
+
if (status.ready !== undefined) out.ready = status.ready;
|
|
561
|
+
if (status.restartCount) out.restarts = status.restartCount;
|
|
562
|
+
if (waiting?.reason) out.reason = waiting.reason;
|
|
563
|
+
else if (ended) out.reason = `${ended.reason ?? "Terminated"} (exit ${ended.exitCode ?? "?"})`;
|
|
564
|
+
const msg = waiting?.message ?? ended?.message;
|
|
565
|
+
if (msg) out.message = msg.trim();
|
|
566
|
+
// A WAITING container is backing off from an exit that already happened, and that exit is where
|
|
567
|
+
// the cause is written (see {@link ContainerEvidence}) — so it is carried beside the waiting
|
|
568
|
+
// words, never in place of them.
|
|
569
|
+
if (waiting && before) {
|
|
570
|
+
out.lastExit = [
|
|
571
|
+
`last exit ${before.exitCode ?? "?"}`,
|
|
572
|
+
...(before.reason ? [`(${before.reason})`] : []),
|
|
573
|
+
...(before.message ? [`— ${before.message.trim()}`] : []),
|
|
574
|
+
].join(" ");
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The events about one pod, oldest first, last few only — the kubelet's own narration. */
|
|
580
|
+
function podEvents(events: EventObject[], pod: string): string[] {
|
|
581
|
+
const when = (e: EventObject): string => e.lastTimestamp ?? e.eventTime ?? e.metadata.creationTimestamp ?? "";
|
|
582
|
+
return events
|
|
583
|
+
.filter((e) => e.involvedObject?.name === pod)
|
|
584
|
+
.sort((a, b) => when(a).localeCompare(when(b)))
|
|
585
|
+
.slice(-4)
|
|
586
|
+
.map((e) => `${e.type ?? "Normal"} ${e.reason ?? "?"}: ${(e.message ?? "").trim()}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The evidence, printed raw — the thing every named diagnosis is only allowed to annotate. */
|
|
590
|
+
function renderEvidence(evidence: RolloutEvidence, target: RolloutTarget): string[] {
|
|
591
|
+
const lines: string[] = [];
|
|
592
|
+
for (const pod of evidence.pods) {
|
|
593
|
+
const where = pod.node ? ` on node ${pod.node}${pod.nodeArch ? ` (${pod.nodeArch})` : ""}` : "";
|
|
594
|
+
lines.push(` pod ${pod.name} (${pod.phase ?? "phase unknown"})${where}`);
|
|
595
|
+
for (const c of pod.containers) {
|
|
596
|
+
const restarts = c.restarts ? `, ${c.restarts} restart(s)` : "";
|
|
597
|
+
lines.push(` container ${c.name}: ${c.reason ?? (c.ready ? "ready" : "not ready")}${restarts}`);
|
|
598
|
+
if (c.image) lines.push(` image ${c.image}`);
|
|
599
|
+
if (c.message) for (const l of c.message.split("\n")) lines.push(` ${l}`);
|
|
600
|
+
if (c.lastExit) for (const l of c.lastExit.split("\n")) lines.push(` ${l}`);
|
|
601
|
+
}
|
|
602
|
+
for (const e of pod.events) lines.push(` event ${e}`);
|
|
603
|
+
if (pod.logs) {
|
|
604
|
+
lines.push(` logs (${pod.logs.container}, last lines):`);
|
|
605
|
+
for (const l of pod.logs.text.split("\n")) lines.push(` | ${l}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
for (const note of evidence.notes) lines.push(` ${note}`);
|
|
609
|
+
if (lines.length === 0) lines.push(` (nothing readable matched ${target.selector})`);
|
|
610
|
+
return lines;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The named diagnoses (ADR-0046): four causes someone actually hit, each naming the way back. They
|
|
615
|
+
* ANNOTATE the evidence above them and never replace it — every claim here is about text the
|
|
616
|
+
* message already printed raw, so a wrong match costs a wrong sentence and no facts.
|
|
617
|
+
*/
|
|
618
|
+
function diagnoseRollout(evidence: RolloutEvidence, target: RolloutTarget): string[] {
|
|
619
|
+
const lines: string[] = [];
|
|
620
|
+
for (const pod of evidence.pods) {
|
|
621
|
+
const said = [
|
|
622
|
+
...pod.containers.map((c) => `${c.reason ?? ""} ${c.message ?? ""} ${c.lastExit ?? ""}`),
|
|
623
|
+
...pod.events,
|
|
624
|
+
pod.logs?.text ?? "",
|
|
625
|
+
].join("\n");
|
|
626
|
+
const ref = pod.containers.find((c) => c.ready !== true)?.image ?? pod.containers[0]?.image;
|
|
627
|
+
|
|
628
|
+
// 1. The failure that produced ADR-0045, still named here: belt-and-braces, and the only trace
|
|
629
|
+
// left for a pre-0045 image or a registry-ref Sandbox Image jr2 never built.
|
|
630
|
+
const wrongPlatform = /exec format error/i.test(said);
|
|
631
|
+
if (wrongPlatform) {
|
|
632
|
+
const built = taggedPlatforms(ref);
|
|
633
|
+
lines.push(
|
|
634
|
+
`diagnosis: ${pod.name} carries an image built for another platform — the node cannot run its binaries.`,
|
|
635
|
+
` image ${ref ?? "(unknown)"}${built ? ` — its tag names ${built}` : " — its tag names no platform"}`,
|
|
636
|
+
` node ${pod.node ?? "(unknown)"}${pod.nodeArch ? ` runs ${pod.nodeArch}` : ""}`,
|
|
637
|
+
` \`jr2 up\` builds for the platforms the cluster's nodes report (ADR-0045); re-run it with --force to` +
|
|
638
|
+
` rebuild this image, or name the set with \`platforms\` in jr2.config.ts.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 2. A ref the kubelet could not fetch. The trap is the bare ref: no registry host means Docker
|
|
643
|
+
// Hub, which is never where a jr2-built image is.
|
|
644
|
+
if (/ImagePullBackOff|ErrImagePull/i.test(said)) {
|
|
645
|
+
lines.push(
|
|
646
|
+
`diagnosis: the kubelet could not pull ${ref ?? "the image"}.`,
|
|
647
|
+
` it resolves to ${resolveRef(ref)}`,
|
|
648
|
+
...(isBareRef(ref)
|
|
649
|
+
? [
|
|
650
|
+
` a ref with no registry host resolves to Docker Hub, so an image jr2 built locally must have been` +
|
|
651
|
+
` delivered to this cluster (kind load, or a push to the configured \`registry\`) — \`jr2 up --force\`` +
|
|
652
|
+
` rebuilds and re-delivers it.`,
|
|
653
|
+
]
|
|
654
|
+
: [` check the push landed there and that this cluster may pull from it (credentials, network, mirror).`]),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 3. The container ran, so its own last words are the diagnosis; this entry exists to say
|
|
659
|
+
// "read the tail above", not to interpret it. Yielded to the entry above when the crash it
|
|
660
|
+
// backs off from is a platform mismatch: that is the same crash with a cause attached, and two
|
|
661
|
+
// names for one failure is how a table starts lying.
|
|
662
|
+
if (!wrongPlatform && /CrashLoopBackOff/i.test(said)) {
|
|
663
|
+
lines.push(
|
|
664
|
+
pod.logs
|
|
665
|
+
? `diagnosis: ${pod.name} starts and exits — the log tail above is what it said on its way out.`
|
|
666
|
+
: `diagnosis: ${pod.name} starts and exits, and wrote nothing readable — ` +
|
|
667
|
+
`\`kubectl -n ${target.namespace} logs ${pod.name} --previous\` once it restarts again.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 4. What slipped past the converge's own `envFrom` preflight (ADR-0019): the kubelet names the
|
|
672
|
+
// object it could not find, so the diagnosis is to repeat that name where it can be seen.
|
|
673
|
+
if (/CreateContainerConfigError/i.test(said)) {
|
|
674
|
+
const missing = /(secret|configmap)\s+"([^"]+)"\s+not found/i.exec(said);
|
|
675
|
+
lines.push(
|
|
676
|
+
missing
|
|
677
|
+
? `diagnosis: the pod's env references ${missing[1]!.toLowerCase()} "${missing[2]}", which this namespace` +
|
|
678
|
+
` does not hold — create it, then re-run \`jr2 up\`.`
|
|
679
|
+
: `diagnosis: the container's configuration cannot be built from what the namespace holds — the object it` +
|
|
680
|
+
` names is in the message above.`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** The platforms a content-addressed tag names (ADR-0045: `<hash>-<arch>[-<arch>]`), or undefined
|
|
688
|
+
* for a tag that names none — a pre-0045 image, or a ref jr2 never built. */
|
|
689
|
+
function taggedPlatforms(ref?: string): string | undefined {
|
|
690
|
+
if (!ref) return undefined;
|
|
691
|
+
const tag = ref.slice(ref.lastIndexOf(":") + 1);
|
|
692
|
+
const suffix = /(?:-(?:amd64|arm64|arm|386|s390x|ppc64le|riscv64))+$/.exec(tag);
|
|
693
|
+
return suffix ? suffix[0].slice(1).split("-").join(" + ") : undefined;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Does this ref name a registry at all? Docker's own rule: only a FIRST PATH SEGMENT — there must
|
|
697
|
+
* be a `/` — that carries a dot, a port, or is `localhost` is a registry host. Everything else is a
|
|
698
|
+
* Docker Hub path, and `jr2-instance-demo:4a77b1-amd64` is the trap this exists to name: the colon
|
|
699
|
+
* belongs to the tag, so the ref has no host and the kubelet asks Docker Hub for it. */
|
|
700
|
+
function isBareRef(ref?: string): boolean {
|
|
701
|
+
if (!ref) return false;
|
|
702
|
+
const slash = ref.indexOf("/");
|
|
703
|
+
if (slash === -1) return true;
|
|
704
|
+
const first = ref.slice(0, slash);
|
|
705
|
+
return !(first.includes(".") || first.includes(":") || first === "localhost");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** What the kubelet will actually ask for — the same normalization docker applies, spelled out. */
|
|
709
|
+
function resolveRef(ref?: string): string {
|
|
710
|
+
if (!ref) return "(unknown)";
|
|
711
|
+
if (!isBareRef(ref)) return ref;
|
|
712
|
+
return `docker.io/${ref.includes("/") ? ref : `library/${ref}`}`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Run a command feeding `stdin`, surfacing stderr in the thrown error (kubectl's messages are the
|
|
716
|
+
* useful part of an apply failure). */
|
|
717
|
+
function execStdin(cmd: string[], stdin: string): Promise<void> {
|
|
718
|
+
return new Promise((resolve, reject) => {
|
|
719
|
+
const child = spawn(cmd[0]!, cmd.slice(1));
|
|
720
|
+
let err = "";
|
|
721
|
+
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
722
|
+
child.on("error", reject);
|
|
723
|
+
child.on("close", (code) =>
|
|
724
|
+
code === 0 ? resolve() : reject(new Error(`${cmd.join(" ")} exited (${code}): ${err.trim()}`)),
|
|
725
|
+
);
|
|
726
|
+
child.stdin.write(stdin);
|
|
727
|
+
child.stdin.end();
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The real kube port, over `kubectl` subprocesses. */
|
|
732
|
+
export const kubectlKube: KubePort = {
|
|
733
|
+
async currentContext() {
|
|
734
|
+
try {
|
|
735
|
+
const { stdout } = await exec("kubectl", ["config", "current-context"]);
|
|
736
|
+
return stdout.trim() || undefined;
|
|
737
|
+
} catch {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
// `--ignore-not-found` is what separates the two faults BY EXIT CODE rather than by matching on
|
|
743
|
+
// kubectl's English: an absent Secret exits 0 with empty stdout, and everything else — no route,
|
|
744
|
+
// no credentials, RBAC refusing the read — exits non-zero and is reported rather than swallowed.
|
|
745
|
+
async readSecret({ namespace, name, key, context }) {
|
|
746
|
+
try {
|
|
747
|
+
const { stdout } = await exec(
|
|
748
|
+
"kubectl",
|
|
749
|
+
[
|
|
750
|
+
...ctxArgs(context),
|
|
751
|
+
"--namespace",
|
|
752
|
+
namespace,
|
|
753
|
+
"get",
|
|
754
|
+
"secret",
|
|
755
|
+
name,
|
|
756
|
+
"--ignore-not-found",
|
|
757
|
+
"-o",
|
|
758
|
+
`jsonpath={.data.${key}}`,
|
|
759
|
+
...reachArgs,
|
|
760
|
+
],
|
|
761
|
+
{ timeout: REACH_BUDGET_MS },
|
|
762
|
+
);
|
|
763
|
+
return stdout ? Buffer.from(stdout, "base64").toString("utf8") : undefined;
|
|
764
|
+
} catch (e) {
|
|
765
|
+
throw new Error(unreachable(e));
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
// `:port` asks kubectl for an ephemeral local port, announced on its stdout as
|
|
770
|
+
// "Forwarding from 127.0.0.1:<local> -> <remote>" — parsed rather than raced.
|
|
771
|
+
portForward({ namespace, service, port, context }) {
|
|
772
|
+
return new Promise((resolve, reject) => {
|
|
773
|
+
const child = spawn("kubectl", [
|
|
774
|
+
...ctxArgs(context),
|
|
775
|
+
"--namespace",
|
|
776
|
+
namespace,
|
|
777
|
+
"port-forward",
|
|
778
|
+
`service/${service}`,
|
|
779
|
+
`:${port}`,
|
|
780
|
+
]);
|
|
781
|
+
let out = "";
|
|
782
|
+
let err = "";
|
|
783
|
+
// The forward is long-lived by design, so the bound is on its FIRST BYTE, not on the child:
|
|
784
|
+
// silence here is the same unreachable cluster the Secret read just survived, and a verb that
|
|
785
|
+
// waits forever for the announcement has only moved the hang one line down.
|
|
786
|
+
const gaveUp = setTimeout(() => {
|
|
787
|
+
child.kill();
|
|
788
|
+
reject(new Error(`kubectl port-forward: no answer after ${REACH_BUDGET_MS / 1000}s`));
|
|
789
|
+
}, REACH_BUDGET_MS);
|
|
790
|
+
gaveUp.unref?.();
|
|
791
|
+
const settle =
|
|
792
|
+
<T>(f: (v: T) => void) =>
|
|
793
|
+
(v: T) => {
|
|
794
|
+
clearTimeout(gaveUp);
|
|
795
|
+
f(v);
|
|
796
|
+
};
|
|
797
|
+
const done = settle(resolve);
|
|
798
|
+
const failed = settle(reject);
|
|
799
|
+
child.stdout.on("data", (d: Buffer) => {
|
|
800
|
+
out += d.toString();
|
|
801
|
+
const m = /Forwarding from 127\.0\.0\.1:(\d+)/.exec(out);
|
|
802
|
+
if (m) done({ url: `http://127.0.0.1:${m[1]}`, close: () => child.kill() });
|
|
803
|
+
});
|
|
804
|
+
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
805
|
+
child.on("error", (e) => failed(new Error(`kubectl port-forward failed: ${e.message}`)));
|
|
806
|
+
child.on("close", (code) => failed(new Error(`kubectl port-forward exited (${code}): ${err.trim()}`)));
|
|
807
|
+
});
|
|
808
|
+
},
|
|
809
|
+
};
|