@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.
- 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 +227 -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 +138 -7
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +440 -83
- 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 +409 -42
- package/src/instance-check.ts +57 -7
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/landlock.ts +129 -0
- 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
package/src/instance-check.ts
CHANGED
|
@@ -18,9 +18,15 @@ import {
|
|
|
18
18
|
CUSTOM_PROVIDER_APIS,
|
|
19
19
|
dispatchRefusals,
|
|
20
20
|
doorRefusals,
|
|
21
|
+
gateLookWarnings,
|
|
22
|
+
gateScopeChecks,
|
|
23
|
+
standingPoolLines,
|
|
24
|
+
standingPoolRefusals,
|
|
21
25
|
loadCharts,
|
|
22
26
|
loadWorkflows,
|
|
23
27
|
machineBodies,
|
|
28
|
+
mcpRefusals,
|
|
29
|
+
mcpUrlRefusals,
|
|
24
30
|
openPartRefusals,
|
|
25
31
|
partsOf,
|
|
26
32
|
placementWalkOf,
|
|
@@ -106,7 +112,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
106
112
|
// and the door's walk cannot drift.
|
|
107
113
|
// A zod `input` that no door reads (ADR-0033) is in the same listing, first: the door refuses it
|
|
108
114
|
// with the same line.
|
|
109
|
-
|
|
115
|
+
// A standing Pool that is not the top Machine of its Workflow (ADR-0017) is in the listing too.
|
|
116
|
+
// An Agent's `mcp` list against the config's `mcp` block (ADR-0079) is in the same listing: a
|
|
117
|
+
// server name the block does not declare is refused as an Open part is, with its customize() line,
|
|
118
|
+
// and the door refuses the same lines. The urls are the host's alone (`.env`).
|
|
119
|
+
const open = [
|
|
120
|
+
...doorRefusals(workflows),
|
|
121
|
+
...standingPoolRefusals(workflows),
|
|
122
|
+
...openPartRefusals(workflows),
|
|
123
|
+
...mcpRefusals(workflows, config),
|
|
124
|
+
...mcpUrlRefusals(config),
|
|
125
|
+
];
|
|
110
126
|
if (open.length) {
|
|
111
127
|
for (const line of open) activity(io, `refusing: ${line}`);
|
|
112
128
|
return undefined;
|
|
@@ -120,6 +136,15 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
120
136
|
for (const line of chartRefusals) activity(io, `refusing: ${line}`);
|
|
121
137
|
return undefined;
|
|
122
138
|
}
|
|
139
|
+
// Each `answer` item that names single Gates (ADR-0078) names Gates that the Workflow's Machines or
|
|
140
|
+
// its Chart files have: the door's own check, so a typo fails here and not at the first answer. A
|
|
141
|
+
// name that only a runtime Chart can have is a warning, because the host cannot see those Charts.
|
|
142
|
+
const gateScopes = gateScopeChecks(workflows, config, charts);
|
|
143
|
+
for (const line of gateScopes.warnings) activity(io, `warning: ${line}`);
|
|
144
|
+
if (gateScopes.errors.length) {
|
|
145
|
+
for (const line of gateScopes.errors) activity(io, `refusing: ${line}`);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
123
148
|
// What each same-Instance dispatch names resolves in this Assembly, and its slot schema matches
|
|
124
149
|
// the callee's door (ADR-0071): the door's own check, so the publish would refuse the same lines.
|
|
125
150
|
// The host cannot see the Orchestrator's volume, so a named Chart that is no Chart file here is a
|
|
@@ -130,8 +155,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
130
155
|
for (const line of dispatched.errors) activity(io, `refusing: ${line}`);
|
|
131
156
|
return undefined;
|
|
132
157
|
}
|
|
158
|
+
// A hooked Gate that a lost delivery can stop (ADR-0011, ADR-0054): no look and no `after` on its
|
|
159
|
+
// state, or a look that a package left Open and nobody bound. A warning, never a refusal: a person
|
|
160
|
+
// can still answer the Gate with `jr2 send`.
|
|
161
|
+
for (const line of gateLookWarnings(workflows)) activity(io, `warning: ${line}`);
|
|
162
|
+
// Each standing Pool (ADR-0017): the chain from each Hook that reaches it to its callee, and a
|
|
163
|
+
// warning when two of them dispatch to one Workflow, whose caps then add.
|
|
164
|
+
const standing = standingPoolLines(workflows, config.hooks);
|
|
165
|
+
for (const chain of standing.chains) activity(io, `standing: ${chain}`);
|
|
166
|
+
for (const line of standing.warnings) activity(io, `warning: ${line}`);
|
|
133
167
|
// What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
|
|
134
|
-
// the plain data it carries — a command's argv
|
|
168
|
+
// the plain data it carries — a command's argv and its timeout; an http
|
|
135
169
|
// Effect's method, its fixed target and the secrets it names — read, never run.
|
|
136
170
|
for (const w of workflows) {
|
|
137
171
|
const effects = partsOf([w.machine]).effects;
|
|
@@ -139,13 +173,10 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
139
173
|
activity(io, `effects: workflow "${w.name}" — what it runs outside itself (ADR-0067)`);
|
|
140
174
|
for (const e of effects) {
|
|
141
175
|
const where = e.path?.length ? ` (on ${e.path.map((k) => `"${k}"`).join(" → ")})` : "";
|
|
142
|
-
const slot = "slot" in e && e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
|
|
143
176
|
if (e.kind === "http") {
|
|
144
177
|
const names = e.secrets.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
|
|
145
178
|
// Its body decides where it runs (ADR-0067): a Worktree file needs its Workspace's Sandbox Effector.
|
|
146
|
-
const where2 = e.file
|
|
147
|
-
? `in the Sandbox Effector, a Worktree file as its body${slot}`
|
|
148
|
-
: "in the Instance Effector";
|
|
179
|
+
const where2 = e.file ? "in the Sandbox Effector, a Worktree file as its body" : "in the Instance Effector";
|
|
149
180
|
activity(
|
|
150
181
|
io,
|
|
151
182
|
` ${e.name}${where}: http ${e.method} ${e.target ? targetText({ target: e.target }) : "<open>"} — ${e.timeoutMs / 1000}s, ` +
|
|
@@ -165,8 +196,21 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
165
196
|
);
|
|
166
197
|
continue;
|
|
167
198
|
}
|
|
199
|
+
if (e.kind === "mcp") {
|
|
200
|
+
// The server's url and secret are the config's (ADR-0079): the line names the server, and
|
|
201
|
+
// its timeout is the server's unless the Effect sets one.
|
|
202
|
+
const asks =
|
|
203
|
+
e.op === "call" ? `call ${e.server ?? "<open>"}/${e.tool ?? "<open>"}` : `${e.op} ${e.server ?? "<open>"}`;
|
|
204
|
+
const timeout = e.timeoutMs > 0 ? `${e.timeoutMs / 1000}s` : "the server's timeout";
|
|
205
|
+
activity(
|
|
206
|
+
io,
|
|
207
|
+
` ${e.name}${where}: mcp ${asks} — ${timeout}, in the Instance Effector${e.idempotent ? ", idempotent" : ""}`,
|
|
208
|
+
);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
168
211
|
if (e.kind === "push") {
|
|
169
212
|
const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
|
|
213
|
+
const slot = e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
|
|
170
214
|
activity(
|
|
171
215
|
io,
|
|
172
216
|
` ${e.name}${where}: push${opts.length ? ` (${opts.join(", ")})` : ""} — ${e.timeoutMs / 1000}s, ` +
|
|
@@ -175,11 +219,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
|
|
|
175
219
|
continue;
|
|
176
220
|
}
|
|
177
221
|
if (!("argv" in e)) continue;
|
|
222
|
+
if (e.cut) {
|
|
223
|
+
// A cut is a kit program: its argv is the kit's business, and its one choice is `expect`.
|
|
224
|
+
const expect = e.cut.expect ? ` (expect ${e.cut.expect})` : "";
|
|
225
|
+
activity(io, ` ${e.name}${where}: worktree${expect} — ${e.timeoutMs / 1000}s, in the Harness`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
178
228
|
const names = e.secrets?.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
|
|
179
229
|
const seat = e.secrets?.length ? `the Sandbox Effector${e.pin ? ", pinned" : ""}` : "the Harness";
|
|
180
230
|
activity(
|
|
181
231
|
io,
|
|
182
|
-
` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${
|
|
232
|
+
` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${names}`,
|
|
183
233
|
);
|
|
184
234
|
}
|
|
185
235
|
}
|
package/src/instance.ts
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
// `config.name` > folder name): the Instance token is read from the in-cluster `jr2-instance`
|
|
12
12
|
// Secret and the `jr2-orchestrator` Service is port-forwarded for the life of the command —
|
|
13
13
|
// kube RBAC is the real gate, and no local state file can go stale (ADR-0019).
|
|
14
|
+
// - On the kube path the CLI says whom it acts for (ADR-0078): the user name a kube
|
|
15
|
+
// `SelfSubjectReview` gives for the context, sent as `jr2-on-behalf-of`. The Instance token is
|
|
16
|
+
// the Caller `token/instance`; the name says which person held it. With `--url` it says none.
|
|
14
17
|
// - Every resolution prints its target on stderr, so ambient-context drift stays visible.
|
|
15
18
|
|
|
16
19
|
import { basename } from "node:path";
|
|
17
|
-
import { loadConfig } from "@jr2/orchestrator/internal";
|
|
20
|
+
import { loadConfig, ON_BEHALF_OF_MAX } from "@jr2/orchestrator/internal";
|
|
18
21
|
import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
|
|
19
22
|
import { assertKitVersion } from "./kit-version.ts";
|
|
20
23
|
import { activity, type Io } from "./output.ts";
|
|
@@ -31,8 +34,9 @@ export function resolveRoot(cwd: string): string {
|
|
|
31
34
|
return root;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
/** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb).
|
|
35
|
-
|
|
37
|
+
/** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb).
|
|
38
|
+
* `onBehalfOf` is the party the CLI says it acts for (ADR-0078): on the kube path, the kube user. */
|
|
39
|
+
export type Target = { url: string; token?: string; onBehalfOf?: string; close?: () => void };
|
|
36
40
|
|
|
37
41
|
export type TargetOptions = {
|
|
38
42
|
/** Explicit orchestrator address (`--url`); with `JR2_URL` the escape hatch past the kube path. */
|
|
@@ -90,6 +94,13 @@ export async function resolveTarget(io: Io, opts: TargetOptions): Promise<Target
|
|
|
90
94
|
`not deployed here — right context? (context ${context}, namespace ${namespace}; \`jr2 up\` deploys)`,
|
|
91
95
|
);
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
// The kube user, read beside the forward so it costs no time of its own. A name past the
|
|
98
|
+
// header's bound is cut, never sent whole to a refusal. A name that is not printable ASCII is not
|
|
99
|
+
// sent: a header value cannot carry it, and the claim is not worth a failed verb.
|
|
100
|
+
const [fwd, user] = await Promise.all([
|
|
101
|
+
kube.portForward({ namespace, service: ORCHESTRATOR_SERVICE, port: ORCHESTRATOR_PORT, ...ctx }),
|
|
102
|
+
kube.selfSubject(ctx),
|
|
103
|
+
]);
|
|
104
|
+
const onBehalfOf = user && /^[\x20-\x7e]+$/.test(user) ? user.slice(0, ON_BEHALF_OF_MAX).trim() : undefined;
|
|
105
|
+
return { url: fwd.url, token, ...(onBehalfOf ? { onBehalfOf } : {}), close: fwd.close };
|
|
95
106
|
}
|
package/src/kube.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `KubePort` is what target resolution consumes (tests inject a fake), `kubectlKube` is the real
|
|
5
5
|
// one, shelling out to the same `kubectl` a human debugging the cluster would use.
|
|
6
6
|
|
|
7
|
-
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
|
|
@@ -41,6 +41,11 @@ export type KubePort = {
|
|
|
41
41
|
* cluster could not answer at all — the two are different faults (ADR-0019), and a caller that
|
|
42
42
|
* merged them would send a user with a dead VPN off to check a context that is correct. */
|
|
43
43
|
readSecret(opts: { namespace: string; name: string; key: string; context?: string }): Promise<string | undefined>;
|
|
44
|
+
/** The user name that the API server gives the current credentials, from a `SelfSubjectReview`
|
|
45
|
+
* (`kubectl auth whoami`): the party the CLI says it acts for (ADR-0078). Undefined when the
|
|
46
|
+
* server does not answer it. Never rejects: the name is a claim the CLI adds, and a verb does not
|
|
47
|
+
* fail for want of it. */
|
|
48
|
+
selfSubject(opts: { context?: string }): Promise<string | undefined>;
|
|
44
49
|
/** Forward a Service port to an ephemeral local port for the life of the command. */
|
|
45
50
|
portForward(opts: {
|
|
46
51
|
namespace: string;
|
|
@@ -50,6 +55,21 @@ export type KubePort = {
|
|
|
50
55
|
}): Promise<{ url: string; close: () => void }>;
|
|
51
56
|
};
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A long-lived kubectl child (a port-forward) ends with this process. Node does not end a child
|
|
60
|
+
* when it exits, so a verb that `process.exit`s, or that a signal handler ends, leaves the child
|
|
61
|
+
* alive, and it holds an API-server stream. Here the child is killed on this process's `exit`.
|
|
62
|
+
* Returns the kill, which is also what `close` calls; the hook goes when the child closes.
|
|
63
|
+
*/
|
|
64
|
+
export function endsWithProcess(child: ChildProcess): () => void {
|
|
65
|
+
const kill = () => {
|
|
66
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
67
|
+
};
|
|
68
|
+
process.once("exit", kill);
|
|
69
|
+
child.once("close", () => process.removeListener("exit", kill));
|
|
70
|
+
return kill;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
|
|
54
74
|
|
|
55
75
|
/** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
|
|
@@ -1139,6 +1159,19 @@ export const kubectlKube: KubePort = {
|
|
|
1139
1159
|
}
|
|
1140
1160
|
},
|
|
1141
1161
|
|
|
1162
|
+
async selfSubject({ context }) {
|
|
1163
|
+
try {
|
|
1164
|
+
const { stdout } = await exec(
|
|
1165
|
+
"kubectl",
|
|
1166
|
+
[...ctxArgs(context), "auth", "whoami", "-o", "jsonpath={.status.userInfo.username}", ...reachArgs],
|
|
1167
|
+
{ timeout: REACH_BUDGET_MS },
|
|
1168
|
+
);
|
|
1169
|
+
return stdout.trim() || undefined;
|
|
1170
|
+
} catch {
|
|
1171
|
+
return undefined; // a server without the API, or RBAC that refuses it: no claim, and no failure
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
|
|
1142
1175
|
// `:port` asks kubectl for an ephemeral local port, announced on its stdout as
|
|
1143
1176
|
// "Forwarding from 127.0.0.1:<local> -> <remote>" — parsed rather than raced.
|
|
1144
1177
|
portForward({ namespace, service, port, context }) {
|
|
@@ -1151,13 +1184,14 @@ export const kubectlKube: KubePort = {
|
|
|
1151
1184
|
`service/${service}`,
|
|
1152
1185
|
`:${port}`,
|
|
1153
1186
|
]);
|
|
1187
|
+
const kill = endsWithProcess(child);
|
|
1154
1188
|
let out = "";
|
|
1155
1189
|
let err = "";
|
|
1156
1190
|
// The forward is long-lived by design, so the bound is on its FIRST BYTE, not on the child:
|
|
1157
1191
|
// silence here is the same unreachable cluster the Secret read just survived, and a verb that
|
|
1158
1192
|
// waits forever for the announcement has only moved the hang one line down.
|
|
1159
1193
|
const gaveUp = setTimeout(() => {
|
|
1160
|
-
|
|
1194
|
+
kill();
|
|
1161
1195
|
reject(new Error(`kubectl port-forward: no answer after ${REACH_BUDGET_MS / 1000}s`));
|
|
1162
1196
|
}, REACH_BUDGET_MS);
|
|
1163
1197
|
gaveUp.unref?.();
|
|
@@ -1172,7 +1206,7 @@ export const kubectlKube: KubePort = {
|
|
|
1172
1206
|
child.stdout.on("data", (d: Buffer) => {
|
|
1173
1207
|
out += d.toString();
|
|
1174
1208
|
const m = /Forwarding from 127\.0\.0\.1:(\d+)/.exec(out);
|
|
1175
|
-
if (m) done({ url: `http://127.0.0.1:${m[1]}`, close:
|
|
1209
|
+
if (m) done({ url: `http://127.0.0.1:${m[1]}`, close: kill });
|
|
1176
1210
|
});
|
|
1177
1211
|
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
1178
1212
|
child.on("error", (e) => failed(new Error(`kubectl port-forward failed: ${e.message}`)));
|
package/src/landlock.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// jr2 requires Landlock on every Sandbox node (ADR-0077). The Repo cache agent is the detector: its
|
|
2
|
+
// DaemonSet has one pod on each Sandbox node, by the Sandboxes' own placement (ADR-0052), and on a
|
|
3
|
+
// node without Landlock, or with an ABI below 3, the agent exits at start with a termination
|
|
4
|
+
// message that names Landlock, the node and the probe's reason, which states the ABI it found
|
|
5
|
+
// (`operator/internal/repocache/landlock.go`). jr2's minimum is ABI 3: under ABI 1 no confined
|
|
6
|
+
// process can move a file into another folder, and under ABI 2 a confined process can truncate a
|
|
7
|
+
// file of jr2's that it may not write. That pod never gets Ready, so the cache agent's
|
|
8
|
+
// rollout never completes.
|
|
9
|
+
//
|
|
10
|
+
// `jr2 up` reads the DaemonSet's pods within the rollout wait it already makes, and refuses to
|
|
11
|
+
// converge while any of them reports no Landlock. It names each node, the reason, and the fix: keep
|
|
12
|
+
// the node out of the Sandbox nodes with `sandbox.nodeSelector` or a taint, or enable Landlock. There
|
|
13
|
+
// is no check pod, no node label and no flag to skip it. A run after the fix converges.
|
|
14
|
+
|
|
15
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
16
|
+
import type { KubeAdmin, RolloutTarget } from "./kube.ts";
|
|
17
|
+
|
|
18
|
+
/** The start of the cache agent's termination message on a node without Landlock — the Go side's
|
|
19
|
+
* `LandlockMessagePrefix`. The two change together. */
|
|
20
|
+
export const LANDLOCK_MESSAGE_PREFIX = "no Landlock on node ";
|
|
21
|
+
|
|
22
|
+
/** How often `awaitCacheAgent` reads the DaemonSet's pods while the rollout wait runs. */
|
|
23
|
+
const POLL_MS = 3000;
|
|
24
|
+
|
|
25
|
+
type Terminated = { message?: string };
|
|
26
|
+
type ContainerStatus = {
|
|
27
|
+
state?: { terminated?: Terminated };
|
|
28
|
+
lastState?: { terminated?: Terminated };
|
|
29
|
+
};
|
|
30
|
+
/** A cache agent pod, read for the node it runs on and its containers' termination messages. */
|
|
31
|
+
export type CacheAgentPod = {
|
|
32
|
+
metadata: { name: string; deletionTimestamp?: string };
|
|
33
|
+
spec?: { nodeName?: string };
|
|
34
|
+
status?: { containerStatuses?: ContainerStatus[] };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One Sandbox node whose cache agent reports no Landlock, and the probe's reason. */
|
|
38
|
+
export type NoLandlock = { node: string; reason: string };
|
|
39
|
+
|
|
40
|
+
// The node, then the reason up to the sentence the agent ends its message with. Not anchored: with
|
|
41
|
+
// `FallbackToLogsOnError` the message can be the agent's log line, which quotes it.
|
|
42
|
+
const MESSAGE = /no Landlock on node ([^\s:]+): (.+?) — jr2 needs Landlock/;
|
|
43
|
+
|
|
44
|
+
/** The Sandbox nodes whose cache agent reports no Landlock, each once, in node order. */
|
|
45
|
+
export function nodesWithoutLandlock(pods: readonly CacheAgentPod[]): NoLandlock[] {
|
|
46
|
+
const found = new Map<string, string>();
|
|
47
|
+
for (const pod of pods) {
|
|
48
|
+
if (pod.metadata.deletionTimestamp) continue;
|
|
49
|
+
for (const s of pod.status?.containerStatuses ?? []) {
|
|
50
|
+
const message = s.state?.terminated?.message ?? s.lastState?.terminated?.message ?? "";
|
|
51
|
+
const match = MESSAGE.exec(message);
|
|
52
|
+
if (!match) continue;
|
|
53
|
+
const node = pod.spec?.nodeName ?? match[1]!;
|
|
54
|
+
if (!found.has(node)) found.set(node, match[2]!);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([node, reason]) => ({ node, reason }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The refusal (ADR-0077, ADR-0019): each node, its reason, and the fix. */
|
|
61
|
+
export function landlockRefusal(nodes: readonly NoLandlock[]): Error {
|
|
62
|
+
return new Error(
|
|
63
|
+
[
|
|
64
|
+
`repo cache: ${nodes.length === 1 ? "a Sandbox node has" : `${nodes.length} Sandbox nodes have`} no Landlock, ` +
|
|
65
|
+
"and jr2 needs it on every Sandbox node (ADR-0077):",
|
|
66
|
+
...nodes.map((n) => ` ${n.node}: ${n.reason}`),
|
|
67
|
+
"Keep each such node out of the Sandbox nodes with sandbox.nodeSelector or a taint (ADR-0052), or enable " +
|
|
68
|
+
"Landlock ABI 3 or later in its kernel (Linux 6.2 or later, or a kernel with a backport, with landlock in " +
|
|
69
|
+
"its lsm= list). Then run `jr2 up` again.",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wait for the cache agent's rollout, and refuse as soon as one of its pods reports no Landlock:
|
|
76
|
+
* a pod that exits at start never gets Ready, so the wait alone would only time out. `rollout` is
|
|
77
|
+
* the converge's own rollout wait (with its own diagnosis on failure); while it runs, the
|
|
78
|
+
* DaemonSet's pods are read every few seconds, one list call each. A failed rollout is read once
|
|
79
|
+
* more, so a Landlock refusal wins over the generic diagnosis.
|
|
80
|
+
*/
|
|
81
|
+
export async function awaitCacheAgent(
|
|
82
|
+
kube: KubeAdmin,
|
|
83
|
+
target: RolloutTarget,
|
|
84
|
+
rollout: () => Promise<void>,
|
|
85
|
+
opts: { pollMs?: number } = {},
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const read = async (): Promise<NoLandlock[]> => {
|
|
88
|
+
try {
|
|
89
|
+
return nodesWithoutLandlock(
|
|
90
|
+
await kube.listJson<CacheAgentPod>({
|
|
91
|
+
kind: "pod",
|
|
92
|
+
selector: target.selector,
|
|
93
|
+
namespace: target.namespace,
|
|
94
|
+
...(target.context ? { context: target.context } : {}),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
// A read that fails is no evidence: the rollout wait still decides.
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const stop = new AbortController();
|
|
103
|
+
const waited = rollout().then(
|
|
104
|
+
() => ({ ok: true as const }),
|
|
105
|
+
(err: unknown) => ({ ok: false as const, err }),
|
|
106
|
+
);
|
|
107
|
+
const polled = (async (): Promise<NoLandlock[] | undefined> => {
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
await sleep(opts.pollMs ?? POLL_MS, undefined, { signal: stop.signal });
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const nodes = await read();
|
|
115
|
+
if (nodes.length > 0) return nodes;
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
const first = await Promise.race([waited, polled.then((nodes) => ({ nodes }))]);
|
|
119
|
+
stop.abort();
|
|
120
|
+
if ("nodes" in first) {
|
|
121
|
+
if (first.nodes) throw landlockRefusal(first.nodes);
|
|
122
|
+
// The poll stops only when aborted, so the rollout settled first.
|
|
123
|
+
}
|
|
124
|
+
const result = await waited;
|
|
125
|
+
if (result.ok) return;
|
|
126
|
+
const nodes = await read();
|
|
127
|
+
if (nodes.length > 0) throw landlockRefusal(nodes);
|
|
128
|
+
throw result.err;
|
|
129
|
+
}
|
|
@@ -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
|
|