@jr2/orchestrator 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 +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
// The canonical SandboxPort (ADR-0012 / GAP(3)): drives the operator's Sandbox CRD through
|
|
2
|
+
// `kubectl`, honoring the current kube context (ADR-0009: the kube target IS the kubectl
|
|
3
|
+
// context; `--context` overrides). Shelling to kubectl instead of a client library keeps the
|
|
4
|
+
// dependency surface at zero and the behavior identical to what a human debugging the cluster
|
|
5
|
+
// would type; the `exec` process seam is injectable so the mapping logic is unit-testable
|
|
6
|
+
// without a cluster. The kind e2e tier exercises the real thing.
|
|
7
|
+
//
|
|
8
|
+
// Reachability: the orchestrator always runs in-cluster (ADR-0019), so it dials
|
|
9
|
+
// `status.endpoint` (`http://<name>.<ns>.svc:…`) directly — stable across orchestrator
|
|
10
|
+
// restarts by nature, which is what ADR-0012's "same endpoint" re-attach promise rides on.
|
|
11
|
+
//
|
|
12
|
+
// All four operations are idempotent (SandboxPort contract): apply is create-or-update, attach
|
|
13
|
+
// guards every clone/worktree, delete ignores absent.
|
|
14
|
+
//
|
|
15
|
+
// WHICH REPOS a Sandbox attaches arrive resolved from the `workspace()`'s Repo Slots (ADR-0051):
|
|
16
|
+
// the CR names each by its cache key, the operator mounts the node's cache read-only at
|
|
17
|
+
// `/repos/<key>` and gates Ready on it, and the attach clones off that mount. The one judgement
|
|
18
|
+
// made here is the FENCE: a per-run url must match a `git.credentials` entry, or it is refused
|
|
19
|
+
// before anything is applied.
|
|
20
|
+
//
|
|
21
|
+
// WHICH IMAGE a Sandbox runs is not an option here (ADR-0037/0038/0049). The request carries what
|
|
22
|
+
// the `workspace()` wrapper statically declared — a `file:` docker context or a registry ref — and
|
|
23
|
+
// the resolved key→ref map arrives as a mounted ConfigMap read on EVERY provision, so a `jr2 up`
|
|
24
|
+
// that rebuilds an image reaches future Sandboxes without rolling this process.
|
|
25
|
+
//
|
|
26
|
+
// The pod's primary container is the Sandbox Image BYTE-FOR-BYTE (ADR-0037): no appended layers,
|
|
27
|
+
// no rewritten Dockerfile, no jr2 knowledge inside it. jr2's runtime arrives at POD time instead —
|
|
28
|
+
// an emptyDir at `/opt/jr2`, populated by an init container running the kit's Harness image — and
|
|
29
|
+
// the container's COMMAND is overridden to start the Harness from that volume. The image's own
|
|
30
|
+
// `USER` and `HOME` are respected (the human who execs in lands in the environment its author
|
|
31
|
+
// built); only its `ENTRYPOINT`/`CMD` do not run, because a container has one command and the
|
|
32
|
+
// Harness must own it — its death must be the container's death, which is what the operator's
|
|
33
|
+
// Ready probe and restart semantics at `:8080` mean. A process the image WANTS running is not
|
|
34
|
+
// lost: it has its own seat, the User Container (ADR-0005), composed here when the wrapper's static
|
|
35
|
+
// `user` option names an image (ADR-0049).
|
|
36
|
+
//
|
|
37
|
+
// So this module composes the whole pod — two init steps and up to three containers:
|
|
38
|
+
//
|
|
39
|
+
// initContainer runtime the kit's Harness image → copies /opt/jr2 into the volume
|
|
40
|
+
// initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
|
|
41
|
+
// proves a registry ref, whose first appearance is this provision
|
|
42
|
+
// container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
|
|
43
|
+
// container adapter jr2-owned, the pod's only credential holder (below)
|
|
44
|
+
// container user optional, the image's own entrypoint, the checkouts (/work, plus
|
|
45
|
+
// /repos and /opt/jr2 read-only) and NOTHING else
|
|
46
|
+
//
|
|
47
|
+
// This is also where the ADAPTER is injected (ADR-0013). The operator needs no change to carry it:
|
|
48
|
+
// ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT understanding, so the
|
|
49
|
+
// Adapter is exactly that — a container with an image, an env, and a Secret. What this module
|
|
50
|
+
// builds is the pod's asymmetry:
|
|
51
|
+
//
|
|
52
|
+
// harness container JR2_ADAPTER_URL=http://127.0.0.1:8081 (an address, no credential)
|
|
53
|
+
// adapter container JR2_ORCHESTRATOR_URL + JR2_SANDBOX_TOKEN (the credential, via envFrom)
|
|
54
|
+
//
|
|
55
|
+
// The Agent has code execution in the first and none in the second. The token is minted here — a
|
|
56
|
+
// signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
|
|
57
|
+
// the Secret re-applies as a no-op.
|
|
58
|
+
|
|
59
|
+
import { execFile } from "node:child_process";
|
|
60
|
+
import { join } from "node:path";
|
|
61
|
+
import {
|
|
62
|
+
matchCredential,
|
|
63
|
+
type GitCredential,
|
|
64
|
+
type HarnessEnvFromSource,
|
|
65
|
+
type HarnessEnvVar,
|
|
66
|
+
type SandboxPlacement,
|
|
67
|
+
} from "./config.ts";
|
|
68
|
+
import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs } from "./images.ts";
|
|
69
|
+
import { CA_CONFIGMAP, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
|
|
70
|
+
import { repoIdentity } from "./repo-identity.ts";
|
|
71
|
+
import type { RepoResources } from "./repos.ts";
|
|
72
|
+
import { sandboxToken } from "./tokens.ts";
|
|
73
|
+
import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
|
|
74
|
+
|
|
75
|
+
/** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
|
|
76
|
+
export type KubectlExec = (args: string[], opts?: { input?: string }) => Promise<{ stdout: string; stderr: string }>;
|
|
77
|
+
|
|
78
|
+
/** Where the Harness container sees the instance's CA bundle (ADR-0020). */
|
|
79
|
+
const CA_MOUNT = "/etc/jr2/ca";
|
|
80
|
+
|
|
81
|
+
/** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
|
|
82
|
+
* because a stranger's base may already use `/app`, and one layout must serve both the stock
|
|
83
|
+
* Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
|
|
84
|
+
* (node's rpath is `$ORIGIN/../lib`), `src/main.ts`, `node_modules/`. */
|
|
85
|
+
export const RUNTIME_MOUNT = "/opt/jr2";
|
|
86
|
+
|
|
87
|
+
/** Where the populate init container writes the runtime. NOT `/opt/jr2`: mounting the volume there
|
|
88
|
+
* would shadow the very directory being copied out of the Harness image. */
|
|
89
|
+
const RUNTIME_STAGE = "/mnt/jr2";
|
|
90
|
+
|
|
91
|
+
/** The primary container's command (ADR-0037). Absolute, so it never depends on the image's
|
|
92
|
+
* `WORKDIR`, and identical to the stock Harness image's own `CMD` — one runtime, two placements. */
|
|
93
|
+
const HARNESS_COMMAND = [`${RUNTIME_MOUNT}/bin/node`, `${RUNTIME_MOUNT}/src/main.ts`];
|
|
94
|
+
|
|
95
|
+
/** The program `origin`'s fetch url runs (ADR-0053), on the runtime volume beside `work-acl`. It
|
|
96
|
+
* asks the node cache for a fetch and then serves the cache, so every seat that holds the
|
|
97
|
+
* checkouts must hold this volume — which is why the User Container mounts it (ADR-0005). */
|
|
98
|
+
const UPLOAD_PACK = `${RUNTIME_MOUNT}/bin/jr2-upload-pack`;
|
|
99
|
+
|
|
100
|
+
/** The Adapter's port on the pod's loopback. The program defaults to this address too, so the
|
|
101
|
+
* fetch url names it only when the composition moved it (attachScript). */
|
|
102
|
+
const DEFAULT_ADAPTER_PORT = 8081;
|
|
103
|
+
|
|
104
|
+
/** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
|
|
105
|
+
* container as a supplemental group, so the Harness writes `/work` whatever the number and no
|
|
106
|
+
* image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
|
|
107
|
+
const DEFAULT_WORK_GROUP = 2000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The isolation baseline for a jr2-owned seat, spelled out HERE for the init containers because the
|
|
111
|
+
* operator's hardened default covers the primary container and the sidecars only (ADR-0001/0005) —
|
|
112
|
+
* init steps pass through verbatim, which is what keeps the operator agent-agnostic. Deliberately
|
|
113
|
+
* not applied to the `user` container: that seat's identity is "what jr2 does not own".
|
|
114
|
+
*/
|
|
115
|
+
const HARDENED = {
|
|
116
|
+
runAsNonRoot: true,
|
|
117
|
+
allowPrivilegeEscalation: false,
|
|
118
|
+
capabilities: { drop: ["ALL"] },
|
|
119
|
+
seccompProfile: { type: "RuntimeDefault" },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ADR-0037's fallback seat, for a BUILT image that declares no `USER` (the converge's `docker
|
|
124
|
+
* inspect` is what saw that; images.ts holds the record). jr2 sets `runAsUser` nowhere else — an
|
|
125
|
+
* image's own `USER` decides its seat's uid (ADR-0005) — so this applies only where the image
|
|
126
|
+
* chose nothing and the alternative is root, which the hardened context refuses.
|
|
127
|
+
*
|
|
128
|
+
* The home is a POD volume, not a directory in the image: uid 1000 on a stranger's base has no
|
|
129
|
+
* home at all, and the floor needs a writable one (`git config --global` writes `$HOME/.gitconfig`
|
|
130
|
+
* on every attach; a real toolchain wants `~/.npm`, `~/.cargo`, `~/.cache`). An emptyDir lands
|
|
131
|
+
* group-writable under the pod's fsGroup, so uid 1000 owns it in practice without jr2 chown-ing
|
|
132
|
+
* anything. `/home/jr2` and not `/home/node`: the number is jr2's choice here, so the path is too.
|
|
133
|
+
*/
|
|
134
|
+
const FALLBACK_UID = 1000;
|
|
135
|
+
const FALLBACK_HOME = "/home/jr2";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The kubelet's verdict on a container whose image resolves to root under `runAsNonRoot: true`.
|
|
139
|
+
* Matched, not merely reported, because it is the ONE provision failure with no evidence anywhere
|
|
140
|
+
* else: the container never starts, so it has no logs, and the pod sits in this waiting state until
|
|
141
|
+
* the provision times out — which then blames the preflight for a container the preflight never got
|
|
142
|
+
* to run. A BUILT image is caught earlier and cheaper (the converge's `docker inspect` recorded the
|
|
143
|
+
* string; `resolveSandboxImage` in images.ts judges it into `refusedUser` before anything is
|
|
144
|
+
* applied), so this is the brought ref's path: never inspected, never given the uid-1000 fallback,
|
|
145
|
+
* knowable only from the cluster.
|
|
146
|
+
*
|
|
147
|
+
* Reason and message are BOTH required. The reason alone covers a missing Secret or ConfigMap key
|
|
148
|
+
* too — a different fault with a different fix — and only the message distinguishes them.
|
|
149
|
+
*/
|
|
150
|
+
const ROOT_IMAGE_REASON = "CreateContainerConfigError";
|
|
151
|
+
const ROOT_IMAGE_MESSAGE = /runAsNonRoot/i;
|
|
152
|
+
|
|
153
|
+
/** How many CR polls pass between two pod reads. See the provision loop for why it is not 1. */
|
|
154
|
+
const POD_CHECK_EVERY = 5;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The kubelet's own words when a container's image resolves to root under `runAsNonRoot`, or
|
|
158
|
+
* undefined for every other pod shape. A pure read of pod status: the caller supplies the parsed
|
|
159
|
+
* `kubectl get pod -o json`, so the claim is testable without a cluster and the fault detection
|
|
160
|
+
* cannot drift from the message the provision prints.
|
|
161
|
+
*
|
|
162
|
+
* Init containers are searched FIRST because they run first: the `preflight` step runs the user's
|
|
163
|
+
* image before the Harness container ever exists, so that is where a root image dies. The primary
|
|
164
|
+
* containers are searched too — the same image sits in the `harness` seat, and the `user` seat is
|
|
165
|
+
* deliberately un-hardened (ADR-0005), so a fault there would mean something else entirely.
|
|
166
|
+
*
|
|
167
|
+
* Both the reason AND the message must match. `CreateContainerConfigError` is also what an absent
|
|
168
|
+
* Secret key produces, and that fault has a different fix; a name with no evidence behind it is
|
|
169
|
+
* worse than the timeout it replaces.
|
|
170
|
+
*/
|
|
171
|
+
export function rootImageFault(pod: unknown): string | undefined {
|
|
172
|
+
const status = (pod as { status?: Record<string, unknown> } | null)?.status;
|
|
173
|
+
if (!status) return undefined;
|
|
174
|
+
type Waiting = { name?: string; state?: { waiting?: { reason?: string; message?: string } } };
|
|
175
|
+
const groups = [status["initContainerStatuses"], status["containerStatuses"]];
|
|
176
|
+
for (const group of groups) {
|
|
177
|
+
if (!Array.isArray(group)) continue;
|
|
178
|
+
for (const cs of group as Waiting[]) {
|
|
179
|
+
const waiting = cs?.state?.waiting;
|
|
180
|
+
if (!waiting || waiting.reason !== ROOT_IMAGE_REASON) continue;
|
|
181
|
+
if (!waiting.message || !ROOT_IMAGE_MESSAGE.test(waiting.message)) continue;
|
|
182
|
+
return `container "${cs.name ?? "?"}": ${waiting.message}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The fix, not the symptom. The kubelet's message says what it refused; it cannot say that the
|
|
190
|
+
* image is a Sandbox Image, that jr2 declined to patch a uid onto it, or where the one-line edit
|
|
191
|
+
* goes — and without those three the reader has a Kubernetes error and no next step.
|
|
192
|
+
*
|
|
193
|
+
* It names the BROUGHT case specifically because that is the only one that reaches here: a built
|
|
194
|
+
* image's `USER` was inspected at converge and judged before anything was applied (images.ts), and
|
|
195
|
+
* an image declaring none gets the uid-1000 fallback. A ref is never inspected — that is the point
|
|
196
|
+
* of refs (ADR-0037) — so it must declare a numeric non-root `USER` itself.
|
|
197
|
+
*/
|
|
198
|
+
function rootImageError(name: string, fault: string): string {
|
|
199
|
+
return (
|
|
200
|
+
`Sandbox "${name}" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened ` +
|
|
201
|
+
`with runAsNonRoot (ADR-0005). The kubelet refused it — ${fault}\n` +
|
|
202
|
+
` - the fix is one line in the image: a NUMERIC non-root \`USER <uid>\` (e.g. \`USER 1000\`)\n` +
|
|
203
|
+
` - numeric because the kubelet does not read the image's /etc/passwd, so \`USER app\` is ` +
|
|
204
|
+
`refused too — it cannot prove that name is non-root\n` +
|
|
205
|
+
` - jr2 does not supply a uid for a brought registry ref: it is never inspected and never ` +
|
|
206
|
+
`modified, which is what "bring your own image" means (ADR-0037). Only an image jr2 BUILDS, ` +
|
|
207
|
+
`and only one that declares no USER at all, gets the uid-${FALLBACK_UID} fallback.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ADR-0037's preflight, VERBATIM: git present · `$HOME` writable · glibc new enough for jr2's node
|
|
213
|
+
* (with the relocated `libstdc++`) · the vendored ripgrep, reached through the mounted `/opt/jr2`.
|
|
214
|
+
*
|
|
215
|
+
* The three commands are the floor, one each: `git config --global` proves git is on the system
|
|
216
|
+
* PATH AND that `$HOME` is writable for the image's user; `node -e ""` proves the glibc is no
|
|
217
|
+
* older than the one jr2's node was built against (this is where musl dies); `rg` UNQUALIFIED
|
|
218
|
+
* proves the vendored static binary resolves THROUGH PATH, which is what the Harness's own append
|
|
219
|
+
* buys at runtime.
|
|
220
|
+
*
|
|
221
|
+
* ONE prover, and this is it (ADR-0037/0041). A converge cannot hold an image to this floor: the
|
|
222
|
+
* floor is a HARNESS-SEAT obligation, a built context may equally be destined for the User
|
|
223
|
+
* Container seat — which owes no floor at all (ADR-0005) — and which seat a directory serves is
|
|
224
|
+
* workflow-internal and statically unrecoverable (ADR-0031). Here the seat is known, and here is
|
|
225
|
+
* also the only moment a registry ref exists at all, since jr2 never builds or inspects one.
|
|
226
|
+
*/
|
|
227
|
+
const SANDBOX_PREFLIGHT = `git config --global safe.directory "*" && ${RUNTIME_MOUNT}/bin/node -e "" && rg --version`;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The probe as a shell line. The PATH append is mechanism, not part of the claim, and it is not
|
|
231
|
+
* optional: nothing bakes `/opt/jr2/bin` into the user's image any more, so a probe that skipped it
|
|
232
|
+
* would report `rg: not found` for every image on earth. APPENDED, never prepended — a toolchain
|
|
233
|
+
* the image pinned wins, which is as much the property being proved as `rg`'s presence (ADR-0037).
|
|
234
|
+
* The seat gets no login shell at either end, so `$PATH` is whatever the image itself set.
|
|
235
|
+
*/
|
|
236
|
+
function preflightShell(): string {
|
|
237
|
+
return `export PATH="$PATH:${RUNTIME_MOUNT}/bin"; ${SANDBOX_PREFLIGHT}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The preflight as an init step IN THE USER'S IMAGE with `/opt/jr2` mounted. It fails the pod
|
|
242
|
+
* before the Harness starts, instead of surfacing as a tool failure mid-turn on a pod nobody is
|
|
243
|
+
* watching.
|
|
244
|
+
*
|
|
245
|
+
* It PROVES, it does not set up: the `.gitconfig` it writes lives in this container's own
|
|
246
|
+
* ephemeral filesystem, so the attach script still runs the same line in the Harness container.
|
|
247
|
+
*
|
|
248
|
+
* `sh -c`, not `sh -ec`: the failure is handled below, because "node did not execute" is not
|
|
249
|
+
* actionable and the message has to name the fix.
|
|
250
|
+
*/
|
|
251
|
+
const PREFLIGHT_SCRIPT = [
|
|
252
|
+
`${preflightShell()} && exit 0`,
|
|
253
|
+
`echo "jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than ` +
|
|
254
|
+
`jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's ` +
|
|
255
|
+
`USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest." >&2`,
|
|
256
|
+
`exit 1`,
|
|
257
|
+
].join("\n");
|
|
258
|
+
|
|
259
|
+
export type KubectlSandboxOptions = {
|
|
260
|
+
/** The mounted image map (ADR-0037/0038) — every ref this port can name, written by `jr2 up`.
|
|
261
|
+
* Default: the `jr2-images` ConfigMap's mount. No image option here: which image a Sandbox runs
|
|
262
|
+
* is the `workspace()` wrapper's static `image` option (ADR-0049) — carried on the Machine, read
|
|
263
|
+
* off it at invoke time, handed to `provision()` as a string — and resolved against this map at
|
|
264
|
+
* provision. The per-run spec never names one (ADR-0051). */
|
|
265
|
+
imagesPath?: string;
|
|
266
|
+
/** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
|
|
267
|
+
* mechanism-owned vars, which win on collision. */
|
|
268
|
+
env?: HarnessEnvVar[];
|
|
269
|
+
/** Whole-Secret/ConfigMap env for the Harness container (`harness.envFrom`) — how a real
|
|
270
|
+
* Harness gets its model API key without the value ever touching jr2 config. */
|
|
271
|
+
envFrom?: HarnessEnvFromSource[];
|
|
272
|
+
/** Where a Sandbox may land (ADR-0052): the Instance's `sandbox.nodeSelector` and
|
|
273
|
+
* `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
|
|
274
|
+
* merges nothing with them. Absent → wherever an ordinary pod lands. */
|
|
275
|
+
placement?: SandboxPlacement;
|
|
276
|
+
/** The instance ships a private-CA bundle (ADR-0020): mount the `jr2-ca` ConfigMap into the
|
|
277
|
+
* HARNESS container and point NODE_EXTRA_CA_CERTS at it — never the Adapter, which speaks plain
|
|
278
|
+
* HTTP to the Orchestrator's Service (the same asymmetry as env/envFrom above). */
|
|
279
|
+
caBundle?: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Where the Adapter reaches the Orchestrator FROM INSIDE THE CLUSTER — the orchestrator's own
|
|
282
|
+
* Service DNS (derived from JR2_NAMESPACE by the entrypoint). The Agent is never told it.
|
|
283
|
+
* A thunk is still accepted for callers that resolve their address late.
|
|
284
|
+
*/
|
|
285
|
+
orchestratorUrl?: string | (() => string | undefined);
|
|
286
|
+
/** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
|
|
287
|
+
signingKey?: Buffer;
|
|
288
|
+
/** The Adapter's port on the pod's loopback. Default 8081. */
|
|
289
|
+
adapterPort?: number;
|
|
290
|
+
/** Kube namespace for Sandbox CRs. Default `default`. */
|
|
291
|
+
namespace?: string;
|
|
292
|
+
/** kubectl `--context` override. Default: the current context (ADR-0009). */
|
|
293
|
+
context?: string;
|
|
294
|
+
/** In-pod root for the pod-local clones + worktrees (ADR-0004 layout). Default `/work`. */
|
|
295
|
+
workRoot?: string;
|
|
296
|
+
/** CR `spec.idleTimeout` — the operator's abandoned-Sandbox GC backstop (ADR-0001). Default `30m`. */
|
|
297
|
+
idleTimeout?: string;
|
|
298
|
+
/** How often a workspace's lease actor renews (ADR-0001/0021): the cadence at which
|
|
299
|
+
* `jr2.dev/keepalive` is re-stamped AND continuity is read back. Must be ≪ idleTimeout, since
|
|
300
|
+
* a lapsed lease is what lets the operator reap. Default 5m. */
|
|
301
|
+
leaseIntervalMs?: number;
|
|
302
|
+
/** Await-Ready budget for the POD: from the CR apply until the operator reports the pod Ready.
|
|
303
|
+
* Default 120s, polled every second. A pod that never comes up (an image that misses ADR-0037's
|
|
304
|
+
* floor) is what this bounds; a pod that is up and waiting on its Repos is `repoTimeoutMs`'s. */
|
|
305
|
+
readyTimeoutMs?: number;
|
|
306
|
+
/**
|
|
307
|
+
* Await-Ready budget for the REPOS (ADR-0051): once the operator holds a Sandbox whose pod is
|
|
308
|
+
* Ready on a Repo reason — the node's cache agent is cloning a cold node, or fetching before the
|
|
309
|
+
* attach — the wait is measured against this, from the same CR apply. Sized for a clone, not a
|
|
310
|
+
* pod: the agent's clone budget is 20m, and this must exceed it so a clone that runs out of
|
|
311
|
+
* time fails BY NAME (`RepoCloneFailed`, git's words) instead of as this port's timeout; and it
|
|
312
|
+
* must stay inside `idleTimeout` (30m), because the lease starts after provision, so the operator
|
|
313
|
+
* reaps a Sandbox that waits longer than that. Default 25m.
|
|
314
|
+
*/
|
|
315
|
+
repoTimeoutMs?: number;
|
|
316
|
+
pollMs?: number;
|
|
317
|
+
/**
|
|
318
|
+
* The Repo-resource port (ADR-0051, repos.ts): every Repo a provision names must exist as a
|
|
319
|
+
* `Repo` resource before the CR names it, or the operator reports it missing and the Sandbox
|
|
320
|
+
* never reaches Ready. So `provision` ensures each one here — a per-run url's resource is
|
|
321
|
+
* created at first attach, a bound one is found as the boot stated it — and REFUSES to run
|
|
322
|
+
* without the port: a Sandbox whose Repos nobody creates parks on `RepoMissing` for the whole
|
|
323
|
+
* Ready budget. The other operations (attach, renew, destroy) need no port, so it is optional
|
|
324
|
+
* at construction.
|
|
325
|
+
*/
|
|
326
|
+
repos?: RepoResources;
|
|
327
|
+
/**
|
|
328
|
+
* The instance's `git.credentials` (ADR-0051) — THE FENCE. A per-run url is run input, a
|
|
329
|
+
* ticket field, and otherwise a way to spend the cluster's credential against any host: one
|
|
330
|
+
* whose identity matches no entry is refused here, before a Secret or a CR exists, naming the
|
|
331
|
+
* list. A bound url is code the instance typechecked and deployed, admitted without a match.
|
|
332
|
+
* Default: no entries, so every per-run url is refused.
|
|
333
|
+
*/
|
|
334
|
+
credentials?: readonly GitCredential[];
|
|
335
|
+
/** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
|
|
336
|
+
exec?: KubectlExec;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
340
|
+
const ns = opts.namespace ?? "default";
|
|
341
|
+
const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
|
|
342
|
+
const workRoot = opts.workRoot ?? "/work";
|
|
343
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
|
|
344
|
+
const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
|
|
345
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
346
|
+
const exec = opts.exec ?? defaultKubectlExec;
|
|
347
|
+
const credentials = opts.credentials ?? [];
|
|
348
|
+
|
|
349
|
+
const adapterPort = opts.adapterPort ?? DEFAULT_ADAPTER_PORT;
|
|
350
|
+
const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
|
|
351
|
+
const base = ["--namespace", ns, ...(opts.context ? ["--context", opts.context] : [])];
|
|
352
|
+
|
|
353
|
+
/** The Sandbox's token Secret — read by the Adapter container, and by nothing else in the pod. */
|
|
354
|
+
const secretName = (name: string) => `${name}-token`;
|
|
355
|
+
|
|
356
|
+
/** Resolved at provision time (see the option's doc): the Orchestrator's in-cluster address. */
|
|
357
|
+
const orchestratorUrl = (): string | undefined =>
|
|
358
|
+
typeof opts.orchestratorUrl === "function" ? opts.orchestratorUrl() : opts.orchestratorUrl;
|
|
359
|
+
|
|
360
|
+
/** The Adapter, as the operator sees it: an opaque container fragment (ADR-0001). */
|
|
361
|
+
const adapterSidecar = (name: string, refs: ImageRefs) => ({
|
|
362
|
+
name: "adapter",
|
|
363
|
+
image: refs.adapter,
|
|
364
|
+
env: [
|
|
365
|
+
{ name: "JR2_ORCHESTRATOR_URL", value: orchestratorUrl() },
|
|
366
|
+
{ name: "JR2_SANDBOX", value: name },
|
|
367
|
+
{ name: "JR2_ADAPTER_PORT", value: String(adapterPort) },
|
|
368
|
+
],
|
|
369
|
+
// The credential, and the reason this is a separate container: `local()` tools give the Agent
|
|
370
|
+
// code execution in the HARNESS container, so anything mounted there is the Agent's. Here, it
|
|
371
|
+
// is out of reach — different container, no shared process namespace.
|
|
372
|
+
envFrom: [{ secretRef: { name: secretName(name) } }],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
|
|
377
|
+
* `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
|
|
378
|
+
* nothing, overrides nothing. So: no `command` (its own entrypoint runs, untouched), no `env`,
|
|
379
|
+
* no `envFrom`, no CA bundle, no ports, no resources. Every key jr2 forwarded would be a crack in
|
|
380
|
+
* "jr2 puts nothing in it", and widening the one authoring string to an object stays compatible
|
|
381
|
+
* if a concrete need ever argues its own way in. (Git's dubious-ownership guard is the line's
|
|
382
|
+
* cost, accepted with
|
|
383
|
+
* eyes open — ADR-0005: safe.directory is honored only from files this seat's image owns, so an
|
|
384
|
+
* image whose sessions run git carries its own line.)
|
|
385
|
+
*
|
|
386
|
+
* `/work` read-write plus the checkouts' two read-only halves are the single exception, and they
|
|
387
|
+
* are not an injection but the point: this seat and the Harness mount ONE worktree, so the human
|
|
388
|
+
* and the Agent see identical files — which is also why ADR-0005's cross-uid pair (the pod's
|
|
389
|
+
* `fsGroup`, the attach's default ACL) exists at all. The caches ride along because they are
|
|
390
|
+
* half of the same files: the worktrees are `--shared` clones whose alternates resolve objects
|
|
391
|
+
* from `/repos/<key>` (ADR-0004/0051), so a seat with `/work` alone holds checkouts whose every
|
|
392
|
+
* borrowed object is missing ("unable to normalize alternate object path"). `/opt/jr2` is the
|
|
393
|
+
* other half (ADR-0053): `origin`'s fetch url is a program on that volume, so a seat without it
|
|
394
|
+
* holds checkouts whose `git fetch` dies — and with it the human gets the same fetch as the
|
|
395
|
+
* Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
|
|
396
|
+
* by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
|
|
397
|
+
* are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
|
|
398
|
+
* them by name. The Adapter is deliberately not given any of the three: it reads no worktree, and it is the
|
|
399
|
+
* container holding the pod's only credential, so it gets the narrowest mount set that works.
|
|
400
|
+
* It also carries no `securityContext`, which the operator reads as the exemption — root is
|
|
401
|
+
* ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
|
|
402
|
+
* and the standard managed-access shape (a root sshd that setuids sessions down) must run
|
|
403
|
+
* unmodified.
|
|
404
|
+
*/
|
|
405
|
+
const userSidecar = async (refs: ImageRefs, image: string, keys: string[]) => ({
|
|
406
|
+
name: "user",
|
|
407
|
+
image: await resolveUserImage(refs, image),
|
|
408
|
+
volumeMounts: [
|
|
409
|
+
{ name: "work", mountPath: workRoot },
|
|
410
|
+
{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
|
|
411
|
+
...keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
|
|
416
|
+
* Adapter is ALWAYS here: with its ref in the image map there is no "no adapter configured"
|
|
417
|
+
* state left to branch on, and a Sandbox without one is a pod that comes up Ready and then parks
|
|
418
|
+
* its Machine forever on a tool call it cannot make (ADR-0013). A map with no `adapter` fails the
|
|
419
|
+
* read instead (images.ts). The User Container joins it only when the spec named one. */
|
|
420
|
+
const sidecarsFor = async (name: string, refs: ImageRefs, keys: string[], user?: string) => [
|
|
421
|
+
adapterSidecar(name, refs),
|
|
422
|
+
...(user !== undefined ? [await userSidecar(refs, user, keys)] : []),
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
// The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
|
|
426
|
+
// config) first, then the mechanism-owned vars (the Adapter address, the CA trust path), which
|
|
427
|
+
// win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom land on the HARNESS
|
|
428
|
+
// container only — never on the Adapter, whose env is minted here and carries the pod's only
|
|
429
|
+
// credential.
|
|
430
|
+
const harnessEnv = (): HarnessEnvVar[] => [
|
|
431
|
+
...(opts.env ?? []),
|
|
432
|
+
{ name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
|
|
433
|
+
...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
|
|
434
|
+
];
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The two init steps, in order (ADR-0037). They are plain container fragments the operator
|
|
438
|
+
* schedules without understanding, exactly like sidecars — the operator stays agent-agnostic
|
|
439
|
+
* (ADR-0001), so "how a Sandbox gets its runtime" is composed here, not reconciled there.
|
|
440
|
+
*
|
|
441
|
+
* 1. `runtime` — the kit's Harness image, copying its `/opt/jr2` into the shared emptyDir. This
|
|
442
|
+
* is what makes the runtime's version ride the VOLUME rather than the image: a kit edit moves
|
|
443
|
+
* the harness image's own tag and re-images future pods without touching a single Sandbox
|
|
444
|
+
* Image tag, which is the only way a registry-ref image could ever follow a kit update.
|
|
445
|
+
* 2. `preflight` — the USER'S image with that volume mounted, running the probe. Ordered second
|
|
446
|
+
* because it needs what the first one wrote.
|
|
447
|
+
*
|
|
448
|
+
* Both carry jr2's hardened context explicitly, and `preflight` runs the probe in the SAME seat
|
|
449
|
+
* the Harness will get — the image's own user, or ADR-0037's fallback — because a probe that
|
|
450
|
+
* proved a different uid's `$HOME` proved nothing.
|
|
451
|
+
*/
|
|
452
|
+
const initContainersFor = (refs: ImageRefs, seat: Seat) => [
|
|
453
|
+
{
|
|
454
|
+
name: "runtime",
|
|
455
|
+
image: refs.harness,
|
|
456
|
+
// The copy's rules live beside the tree they copy (`deploy/harness/init-copy`), not in a
|
|
457
|
+
// string here: `/opt/jr2` is a published surface whose SHAPE is load-bearing — node's rpath
|
|
458
|
+
// is `$ORIGIN/../lib`, so `bin/` and `lib/` must land as siblings — and the script proves
|
|
459
|
+
// its own result by running the copied node before the pod moves on.
|
|
460
|
+
command: [`${RUNTIME_MOUNT}/bin/init-copy`, RUNTIME_STAGE],
|
|
461
|
+
volumeMounts: [{ name: "runtime", mountPath: RUNTIME_STAGE }],
|
|
462
|
+
securityContext: HARDENED,
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "preflight",
|
|
466
|
+
image: seat.image,
|
|
467
|
+
command: ["/bin/sh", "-c", PREFLIGHT_SCRIPT],
|
|
468
|
+
...(seat.env.length ? { env: seat.env } : {}),
|
|
469
|
+
volumeMounts: [{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true }, ...seat.homeMount],
|
|
470
|
+
securityContext: seat.securityContext,
|
|
471
|
+
},
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The primary container's seat: which image runs, as whom, and with what home. One value, built
|
|
476
|
+
* once per provision and shared by the Harness container and the preflight, so the probe cannot
|
|
477
|
+
* drift from the thing it proves.
|
|
478
|
+
*/
|
|
479
|
+
type Seat = {
|
|
480
|
+
image: string;
|
|
481
|
+
env: HarnessEnvVar[];
|
|
482
|
+
homeVolume: Array<{ name: string; emptyDir: Record<string, never> }>;
|
|
483
|
+
homeMount: Array<{ name: string; mountPath: string }>;
|
|
484
|
+
securityContext: typeof HARDENED & { runAsUser?: number };
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const seatFor = async (refs: ImageRefs, name?: string): Promise<Seat> => {
|
|
488
|
+
// ONE resolution, so the ref and the two seat facts can never come off different legs of
|
|
489
|
+
// ADR-0037's chain (images.ts). It is async because a `file:` context is keyed by its content
|
|
490
|
+
// digest, which is a directory walk — the price of the host and the pod agreeing about an
|
|
491
|
+
// image without a path table (ADR-0049).
|
|
492
|
+
const { ref: image, fallbackSeat, refusedUser } = await resolveSandboxImage(refs, name);
|
|
493
|
+
// Fail HERE, before a Secret or a CR exists, on a `USER` the kubelet will refuse (images.ts).
|
|
494
|
+
// The alternative is the worst shape a failure has: the preflight container never starts, so
|
|
495
|
+
// it has no logs, and the whole 120s Ready budget burns before anything is said. The converge
|
|
496
|
+
// already inspected the image, so this is knowable at zero cost — and the message names the
|
|
497
|
+
// edit, because the fix is one line of the caller's own Dockerfile.
|
|
498
|
+
if (refusedUser !== undefined) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`the Sandbox Image "${name ?? "default"}" declares \`USER ${refusedUser}\`, which cannot run a jr2 seat: ` +
|
|
501
|
+
"every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
|
|
502
|
+
"non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last " +
|
|
503
|
+
"`USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a " +
|
|
504
|
+
"writable HOME — ADR-0037), then re-run `jr2 up`.",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
// The common case, and the one ADR-0037 is written around: the image chose its `USER` and its
|
|
508
|
+
// `HOME`, and jr2 touches neither — the human who execs in lands in the environment the
|
|
509
|
+
// image's author built, dotfiles included.
|
|
510
|
+
if (!fallbackSeat) {
|
|
511
|
+
return { image, env: [], homeVolume: [], homeMount: [], securityContext: HARDENED };
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
image,
|
|
515
|
+
env: [{ name: "HOME", value: FALLBACK_HOME }],
|
|
516
|
+
homeVolume: [{ name: "home", emptyDir: {} }],
|
|
517
|
+
homeMount: [{ name: "home", mountPath: FALLBACK_HOME }],
|
|
518
|
+
securityContext: { ...HARDENED, runAsUser: FALLBACK_UID },
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const crFor = async (
|
|
523
|
+
req: { name: string; runId: string; workflow: string; image?: string; user?: string; workGroup?: number },
|
|
524
|
+
refs: ImageRefs,
|
|
525
|
+
repos: FencedRepo[],
|
|
526
|
+
) => {
|
|
527
|
+
const seat = await seatFor(refs, req.image);
|
|
528
|
+
const sidecars = await sidecarsFor(
|
|
529
|
+
req.name,
|
|
530
|
+
refs,
|
|
531
|
+
repos.map((r) => r.key),
|
|
532
|
+
req.user,
|
|
533
|
+
);
|
|
534
|
+
return {
|
|
535
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
536
|
+
kind: "Sandbox",
|
|
537
|
+
metadata: {
|
|
538
|
+
name: req.name,
|
|
539
|
+
namespace: ns,
|
|
540
|
+
// The run↔workspace link `jr2 ls` groups by (ADR-0009/0012) — readable without the host.
|
|
541
|
+
labels: { "jr2.dev/run": req.runId, "jr2.dev/workflow": req.workflow },
|
|
542
|
+
},
|
|
543
|
+
spec: {
|
|
544
|
+
// The Sandbox Image, unmodified (ADR-0037) — the user's tools, its own USER and HOME, and
|
|
545
|
+
// jr2's runtime arriving beside it on a volume. This container is both the Harness and the
|
|
546
|
+
// human's `exec` shell.
|
|
547
|
+
image: seat.image,
|
|
548
|
+
// The one thing jr2 takes from the image: its command. A container has exactly one, and it
|
|
549
|
+
// must be the Harness's — a pod whose main process is the user's entrypoint keeps
|
|
550
|
+
// "Running" through a Harness death, which makes the operator's Ready probe a lie.
|
|
551
|
+
command: HARNESS_COMMAND,
|
|
552
|
+
// Hardened, and the ONLY place jr2 ever names a uid: ADR-0037's fallback for an image that
|
|
553
|
+
// declared none. Stating the whole context here rather than leaving it to the operator's
|
|
554
|
+
// default is what makes that possible — the operator hardens only what says nothing.
|
|
555
|
+
securityContext: seat.securityContext,
|
|
556
|
+
idleTimeout: opts.idleTimeout ?? "30m",
|
|
557
|
+
// Placement (ADR-0052): the Instance's word on which nodes are Sandbox nodes, raw pod-spec
|
|
558
|
+
// shapes the operator copies verbatim. Absent keys are absent here too — the CR says
|
|
559
|
+
// nothing, and the pod lands wherever an ordinary pod lands. The operator's own soft
|
|
560
|
+
// affinity toward nodes holding this Sandbox's caches sits beside these untouched: a
|
|
561
|
+
// preference never conflicts with a requirement.
|
|
562
|
+
...(opts.placement?.nodeSelector ? { nodeSelector: opts.placement.nodeSelector } : {}),
|
|
563
|
+
...(opts.placement?.tolerations?.length ? { tolerations: opts.placement.tolerations } : {}),
|
|
564
|
+
// The work group (ADR-0005), the ownership half of cross-uid sharing on `/work`.
|
|
565
|
+
// Kubernetes grants it as a supplemental group to every container, and puts a setgid
|
|
566
|
+
// group on the volume root that propagates down; the WRITABILITY half is the default ACL
|
|
567
|
+
// the attach stamps on each repo root (attachScript below), without which fsGroup gives
|
|
568
|
+
// group-READ, which is the trap. Both are inert when the uids match.
|
|
569
|
+
fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
|
|
570
|
+
// Ordered, and before any container starts: populate `/opt/jr2`, then prove the image on it.
|
|
571
|
+
initContainers: initContainersFor(refs, seat),
|
|
572
|
+
// Never empty any more: JR2_ADAPTER_URL is unconditional, so the "omit an empty env" branch
|
|
573
|
+
// this used to carry was unreachable. The seat's own vars (the fallback `HOME`) come
|
|
574
|
+
// FIRST, so the instance's `harness.env` can still override them the way it overrides
|
|
575
|
+
// anything the image set.
|
|
576
|
+
env: [...seat.env, ...harnessEnv()],
|
|
577
|
+
...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
|
|
578
|
+
// What the AGENT gets: an address on its own loopback, and no credential anywhere. This is
|
|
579
|
+
// the only thing in the pod that tells it how to reach its Machine (ADR-0013).
|
|
580
|
+
sidecars,
|
|
581
|
+
// The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
|
|
582
|
+
// `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
|
|
583
|
+
// container at `/repos/<key>` — a soft affinity toward nodes already holding them, and
|
|
584
|
+
// `Ready` only once every one is present on the pod's node and fetched since this CR
|
|
585
|
+
// asked. Read-only is load-bearing twice (ADR-0004): no write contention, and nothing in
|
|
586
|
+
// a Sandbox can `gc` the object store its `--shared` clones borrow from.
|
|
587
|
+
repos: repos.map(({ key, url }) => ({ key, url })),
|
|
588
|
+
volumes: [
|
|
589
|
+
// The worktree root is a POD volume, not a directory baked into the image. Two reasons,
|
|
590
|
+
// both load-bearing: every jr2-owned seat runs as an unprivileged uid, which cannot mkdir
|
|
591
|
+
// under `/` — so an image-owned `/work` would make every attach fail — and `/work` is
|
|
592
|
+
// the one thing all three containers share (ADR-0005), so human and Agent see identical
|
|
593
|
+
// files. An emptyDir lands group-writable under the pod's fsGroup, so it is writable
|
|
594
|
+
// whatever uid the Sandbox Image runs as: `/work` unclaimed is the only image contract.
|
|
595
|
+
{ name: "work", emptyDir: {} },
|
|
596
|
+
// jr2's runtime (ADR-0037). An emptyDir, so it lives and dies with the pod and carries
|
|
597
|
+
// the version the pod STARTED with — a live Sandbox keeps its runtime across a kit
|
|
598
|
+
// update, the same create-if-absent stance ADR-0038 takes for images.
|
|
599
|
+
{ name: "runtime", emptyDir: {} },
|
|
600
|
+
// Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
|
|
601
|
+
...seat.homeVolume,
|
|
602
|
+
...(opts.caBundle ? [{ name: "ca", configMap: { name: CA_CONFIGMAP } }] : []),
|
|
603
|
+
],
|
|
604
|
+
// CR-level volumeMounts land on the HARNESS container only (the operator's contract) —
|
|
605
|
+
// exactly the CA-trust asymmetry ADR-0020 wants: the Adapter never inherits it. The Repo
|
|
606
|
+
// caches are not listed: the operator mounts each `repo-<key>` into this container itself.
|
|
607
|
+
volumeMounts: [
|
|
608
|
+
{ name: "work", mountPath: workRoot },
|
|
609
|
+
// Read-only: nothing writes under `/opt/jr2` at runtime, and the Agent has code execution
|
|
610
|
+
// in this container — leaving its own runtime writable would let a turn edit it.
|
|
611
|
+
{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
|
|
612
|
+
...seat.homeMount,
|
|
613
|
+
...(opts.caBundle ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : []),
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
type SandboxStatus = {
|
|
620
|
+
phase?: string;
|
|
621
|
+
endpoint?: string;
|
|
622
|
+
podUID?: string;
|
|
623
|
+
uid?: string;
|
|
624
|
+
conditions?: Condition[];
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/** Parse a Sandbox CR off any kubectl call that printed one (`get -o json`, and the lease's
|
|
628
|
+
* `annotate -o json` — which returns the object AFTER the patch, status included). */
|
|
629
|
+
const readSandbox = (stdout: string): SandboxStatus => {
|
|
630
|
+
const parsed = JSON.parse(stdout) as {
|
|
631
|
+
metadata?: { uid?: string };
|
|
632
|
+
status?: { phase?: string; endpoint?: string; podUID?: string; conditions?: Condition[] };
|
|
633
|
+
};
|
|
634
|
+
return { ...(parsed.status ?? {}), uid: parsed.metadata?.uid };
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const conditionOf = (status: SandboxStatus | undefined, type: string): Condition | undefined =>
|
|
638
|
+
status?.conditions?.find((c) => c.type === type);
|
|
639
|
+
|
|
640
|
+
const getSandbox = async (name: string): Promise<SandboxStatus | undefined> => {
|
|
641
|
+
try {
|
|
642
|
+
const { stdout } = await exec(["get", "sandbox", name, ...base, "-o", "json"]);
|
|
643
|
+
return readSandbox(stdout);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
if (isNotFound(err)) return undefined;
|
|
646
|
+
throw err;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Ask the POD whether it is stuck on a fault the Sandbox's phase cannot express (see
|
|
652
|
+
* {@link rootImageFault}). The CR is the port's normal window on a provision; this is the one
|
|
653
|
+
* question it cannot answer, because the operator reports "not Ready yet" for a pod that will
|
|
654
|
+
* never be Ready and one that simply has not started.
|
|
655
|
+
*
|
|
656
|
+
* Absent or unreadable answers undefined: the pod trails the CR by a moment at every provision,
|
|
657
|
+
* and a missing pod is a normal early poll, never evidence of a fault. This may only ever CONVERT
|
|
658
|
+
* a failure that was already going to happen into a named one.
|
|
659
|
+
*/
|
|
660
|
+
const podFault = async (name: string): Promise<string | undefined> => {
|
|
661
|
+
try {
|
|
662
|
+
const { stdout } = await exec(["get", "pod", name, ...base, "-o", "json"]);
|
|
663
|
+
return rootImageFault(JSON.parse(stdout));
|
|
664
|
+
} catch {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Mint this Sandbox's token into a Secret, BEFORE the CR exists — the operator creates the pod
|
|
671
|
+
* the moment it sees the CR, and a pod whose `envFrom` names an absent Secret sits in
|
|
672
|
+
* CreateContainerConfigError. Idempotent by construction: the token is the Sandbox's name, signed
|
|
673
|
+
* (tokens.ts), so a re-provision after an orchestrator restart re-applies the SAME value, and the
|
|
674
|
+
* Adapter that has been holding it all along stays valid.
|
|
675
|
+
*/
|
|
676
|
+
const applyTokenSecret = async (name: string): Promise<void> => {
|
|
677
|
+
if (!opts.signingKey) throw new Error("kubectlSandbox: an Adapter needs a signingKey to mint its Sandbox token");
|
|
678
|
+
// Fail the provision rather than ship an Adapter that cannot reach the Orchestrator. A mute
|
|
679
|
+
// Adapter is the worst possible outcome: the pod comes up Ready, the Agent is admitted, its
|
|
680
|
+
// tool call dies on `localhost`, and the Machine simply parks forever — a hang with no error.
|
|
681
|
+
if (!orchestratorUrl()) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
"kubectlSandbox: the Adapter has no route to the Orchestrator (no orchestratorUrl — deployed " +
|
|
684
|
+
"instances derive Service DNS from JR2_NAMESPACE). An Agent with no Adapter cannot drive its " +
|
|
685
|
+
"Machine at all (ADR-0013).",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
const secret = {
|
|
689
|
+
apiVersion: "v1",
|
|
690
|
+
kind: "Secret",
|
|
691
|
+
metadata: { name: secretName(name), namespace: ns, labels: { "jr2.dev/sandbox": name } },
|
|
692
|
+
type: "Opaque",
|
|
693
|
+
stringData: { JR2_SANDBOX_TOKEN: sandboxToken(opts.signingKey, name) },
|
|
694
|
+
};
|
|
695
|
+
await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(secret) });
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Make the Secret a child of the Sandbox CR, so Kubernetes reaps it whenever the CR goes — including
|
|
700
|
+
* the paths no jr2 code observes (the operator's idle-timeout GC, a `kubectl delete sandbox` by hand).
|
|
701
|
+
* Needs the CR's uid, so it can only happen after the apply; a failure here leaks a Secret, never a
|
|
702
|
+
* pod, so it is not worth failing the provision over.
|
|
703
|
+
*/
|
|
704
|
+
const ownSecret = async (name: string, uid: string | undefined): Promise<void> => {
|
|
705
|
+
if (!uid) return;
|
|
706
|
+
const ownerRef = [
|
|
707
|
+
{ apiVersion: "core.jr2.dev/v1alpha1", kind: "Sandbox", name, uid, controller: true, blockOwnerDeletion: false },
|
|
708
|
+
];
|
|
709
|
+
await exec([
|
|
710
|
+
"patch",
|
|
711
|
+
"secret",
|
|
712
|
+
secretName(name),
|
|
713
|
+
...base,
|
|
714
|
+
"--type",
|
|
715
|
+
"merge",
|
|
716
|
+
"-p",
|
|
717
|
+
JSON.stringify({ metadata: { ownerReferences: ownerRef } }),
|
|
718
|
+
]).catch(() => {});
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Every Repo the provision names, resolved to its identity and key — and FENCED (ADR-0051). A
|
|
723
|
+
* per-run url is the run's input; one no `git.credentials` entry admits is refused here, before
|
|
724
|
+
* anything is read or applied, naming the list. A bound url is admitted without a match: it is
|
|
725
|
+
* code the instance typechecked and deployed. Two slots spelling one repository collapse to one
|
|
726
|
+
* CR entry (first spelling wins) — one cache, however many slots borrow from it — and the
|
|
727
|
+
* resource is BOUND when any of those slots is the Machine's: a per-run slot alone leaves it on
|
|
728
|
+
* `jr2 gc`'s clock.
|
|
729
|
+
*/
|
|
730
|
+
const fencedRepos = (name: string, repos: ProvisionedRepo[]): FencedRepo[] => {
|
|
731
|
+
const byKey = new Map<string, FencedRepo>();
|
|
732
|
+
for (const repo of repos) {
|
|
733
|
+
const { identity, key } = repoIdentity(repo.url);
|
|
734
|
+
if (repo.perRun && !matchCredential(identity, credentials)) {
|
|
735
|
+
const entries = credentials.map((c) => c.match).join(", ") || "none";
|
|
736
|
+
throw new Error(
|
|
737
|
+
`Sandbox "${name}" refuses the per-run repo ${repo.url} for slot "${repo.slot}": no git.credentials ` +
|
|
738
|
+
`entry matches "${identity}" (entries: ${entries}). A per-run url can spend the cluster's credential ` +
|
|
739
|
+
"against any host, so jr2.config.ts must admit it by prefix (ADR-0051).",
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
const seen = byKey.get(key);
|
|
743
|
+
if (seen === undefined) byKey.set(key, { key, url: repo.url, identity, bound: !repo.perRun });
|
|
744
|
+
else seen.bound ||= !repo.perRun;
|
|
745
|
+
}
|
|
746
|
+
return [...byKey.values()];
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* What the operator will hold this Sandbox's Ready on, and what an attach reads afterwards
|
|
751
|
+
* (ADR-0051). `Ready` with reason `RepoCloneFailed` is terminal for the provision — the cache
|
|
752
|
+
* agent could not clone onto the node the pod landed on, and the reason names it — so the loop
|
|
753
|
+
* fails on it by name rather than burning the budget. `ReposFresh=False` is the other verdict:
|
|
754
|
+
* the caches are there but a fetch since this CR asked failed, so the attach proceeds STALE and
|
|
755
|
+
* says so. Remembered per name until the Sandbox is destroyed, because the attach is a separate
|
|
756
|
+
* call. The operator takes that verdict once per pod and keeps it, so a provision re-run on
|
|
757
|
+
* snapshot restore reads the same one — unless the pod was replaced, which is the lease's news.
|
|
758
|
+
*/
|
|
759
|
+
const staleByName = new Map<string, string>();
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
async provision(req) {
|
|
763
|
+
// The fence first: a refused url costs nothing — no map read, no Secret, no CR.
|
|
764
|
+
const repos = fencedRepos(req.name, req.repos);
|
|
765
|
+
if (opts.repos === undefined) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`Sandbox "${req.name}" cannot be provisioned: this port has no Repo-resource port (ADR-0051). The ` +
|
|
768
|
+
"operator holds a Sandbox's Ready until every Repo it names exists as a resource, and creating " +
|
|
769
|
+
"them is this provision's job — build the port with `repos: kubectlRepos(...)`.",
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
// Read PER PROVISION, and next (ADR-0038). Not hoisted into `kubectlSandbox()`: a boot-time
|
|
773
|
+
// read would freeze the map for the process lifetime, which is precisely the Deployment-env
|
|
774
|
+
// behavior the ConfigMap mount was chosen over — the point of the mount is that a `jr2 up`
|
|
775
|
+
// reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
|
|
776
|
+
// also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
|
|
777
|
+
const refs = await readImageRefs(imagesPath);
|
|
778
|
+
const cr = await crFor(req, refs, repos);
|
|
779
|
+
|
|
780
|
+
// The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
|
|
781
|
+
// the boot and only its eviction clock moves — this run's spelling never rewrites the spec
|
|
782
|
+
// the boot stated; a per-run one is created here, at its first attach, and every later
|
|
783
|
+
// attach anywhere finds it and restates its credential, so the `git.credentials` fix
|
|
784
|
+
// `repoCloneError` names reaches the cache at the next run. After the image resolution, so
|
|
785
|
+
// a refused image still costs nothing; before the token Secret, so no Secret is minted for
|
|
786
|
+
// a Sandbox whose Repo could not be recorded.
|
|
787
|
+
for (const repo of repos) await opts.repos.ensure(repo);
|
|
788
|
+
|
|
789
|
+
await applyTokenSecret(req.name); // before the CR: the pod's Adapter mounts it at start
|
|
790
|
+
await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(cr) });
|
|
791
|
+
|
|
792
|
+
const applied = Date.now();
|
|
793
|
+
// Two budgets from one instant (see the options): the pod's until the operator has seen the
|
|
794
|
+
// pod Ready, the Repos' from the first poll that finds the Sandbox held on a Repo reason —
|
|
795
|
+
// which the operator reports only once the pod IS Ready, so the preflight has already passed
|
|
796
|
+
// and what remains is a clone or a fetch on the node. Sticky: a pod that came up once is not
|
|
797
|
+
// a pod that will never come up, whatever it does afterwards.
|
|
798
|
+
let held = false;
|
|
799
|
+
let owned = false;
|
|
800
|
+
let polls = 0;
|
|
801
|
+
for (;;) {
|
|
802
|
+
const status = await getSandbox(req.name);
|
|
803
|
+
if (!owned && status?.uid) ((owned = true), await ownSecret(req.name, status.uid));
|
|
804
|
+
// The pod is read at a COARSER cadence than the CR, and only while not Ready. The fault it
|
|
805
|
+
// looks for is terminal — the kubelet never retries out of it — so learning about it a few
|
|
806
|
+
// seconds late costs nothing, while reading the pod on every poll would double this port's
|
|
807
|
+
// API traffic for every healthy provision in the cluster.
|
|
808
|
+
if (status?.phase !== "Ready" && polls++ % POD_CHECK_EVERY === 0) {
|
|
809
|
+
const fault = await podFault(req.name);
|
|
810
|
+
if (fault) throw new Error(rootImageError(req.name, fault));
|
|
811
|
+
}
|
|
812
|
+
// Terminal for THIS provision: the cache agent tried to clone onto the pod's node and git
|
|
813
|
+
// refused. The operator's message carries the key, the node, and git's own words; the
|
|
814
|
+
// agent keeps retrying on its own, so `jr2 status` will show the same error until it is fixed.
|
|
815
|
+
const ready = conditionOf(status, "Ready");
|
|
816
|
+
if (status?.phase !== "Ready" && ready?.reason === REPO_CLONE_FAILED) {
|
|
817
|
+
throw new Error(repoCloneError(req.name, ready.message ?? ready.reason));
|
|
818
|
+
}
|
|
819
|
+
if (ready?.reason !== undefined && REPO_HELD.has(ready.reason)) held = true;
|
|
820
|
+
if (status?.phase === "Ready") {
|
|
821
|
+
// Only `phase: Ready` means serving — status.endpoint appears earlier (ADR-0001).
|
|
822
|
+
if (!status.endpoint) throw new Error(`Sandbox "${req.name}" is Ready but reports no endpoint`);
|
|
823
|
+
// Freshness degrades, absence does not (ADR-0051): Ready with `ReposFresh=False` is a
|
|
824
|
+
// Sandbox whose caches exist but could not be fetched since it asked. Remembered for the
|
|
825
|
+
// attach, which is where a slot can be named; forgotten when the caches are fresh.
|
|
826
|
+
const fresh = conditionOf(status, "ReposFresh");
|
|
827
|
+
if (fresh?.status === "False" && fresh.message) staleByName.set(req.name, fresh.message);
|
|
828
|
+
else staleByName.delete(req.name);
|
|
829
|
+
// The identity the lease will hold this workspace to (ADR-0021). Ready means the pod
|
|
830
|
+
// is up, so the operator has published it; an operator too old to do so leaves it
|
|
831
|
+
// undefined and the lease falls back to presence.
|
|
832
|
+
return { endpoint: status.endpoint, identity: status.podUID };
|
|
833
|
+
}
|
|
834
|
+
if (Date.now() >= applied + (held ? repoTimeoutMs : readyTimeoutMs)) {
|
|
835
|
+
// A Sandbox the operator held on its Repos ran out the Repo budget: the pod is up and the
|
|
836
|
+
// preflight passed, so the hint about the image would be a lie. What is true is the
|
|
837
|
+
// operator's own verdict — which Repo, on which node — and that the agent is still at it.
|
|
838
|
+
if (held) throw new Error(repoWaitError(req.name, repoTimeoutMs, ready));
|
|
839
|
+
// One last look before falling back to the hint: the fault may have appeared inside the
|
|
840
|
+
// final interval, and a named error beats a timeout in every case where both are true.
|
|
841
|
+
const fault = await podFault(req.name);
|
|
842
|
+
if (fault) throw new Error(rootImageError(req.name, fault));
|
|
843
|
+
// Otherwise name where to look, because the next most likely cause is an image that
|
|
844
|
+
// misses ADR-0037's floor, and that failure is an INIT container's — invisible in the
|
|
845
|
+
// phase alone. A musl or git-less base dies INSIDE the preflight, on jr2's own message.
|
|
846
|
+
// The operator's own verdict rides along when it has one: a Repo still pending on the
|
|
847
|
+
// node (a slow clone) reads very differently from a preflight death.
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Sandbox "${req.name}" never reached Ready (last phase: ${status?.phase ?? "absent"}) — if its ` +
|
|
850
|
+
"Sandbox Image is new, check the preflight: `kubectl logs " +
|
|
851
|
+
req.name +
|
|
852
|
+
" -c preflight` (ADR-0037's floor: glibc, git, a writable HOME, a numeric non-root USER)." +
|
|
853
|
+
(ready?.message
|
|
854
|
+
? `\n the operator's Ready condition says: ${ready.reason ?? "?"}: ${ready.message}`
|
|
855
|
+
: ""),
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
await sleep(pollMs);
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
async attach(req) {
|
|
863
|
+
const { script, repos, review } = attachScript(req.spec, req.repos, {
|
|
864
|
+
reposMount: REPOS_MOUNT,
|
|
865
|
+
workRoot,
|
|
866
|
+
// The fetch url names the Adapter only when it is somewhere unexpected (ADR-0053).
|
|
867
|
+
...(adapterPort !== DEFAULT_ADAPTER_PORT ? { adapterUrl: `http://127.0.0.1:${adapterPort}` } : {}),
|
|
868
|
+
});
|
|
869
|
+
// `-c harness` is unchanged and still correct after ADR-0037: the primary container runs the
|
|
870
|
+
// Sandbox Image, so `git` here is the git the user chose. Never `-c user` — that seat is
|
|
871
|
+
// zero-contract, may hold no git at all, and jr2 commands nothing in it (ADR-0005).
|
|
872
|
+
await exec(["exec", `pod/${req.name}`, ...base, "-c", "harness", "--", "sh", "-ec", script]);
|
|
873
|
+
const stale = staleSlots(req.repos, staleByName.get(req.name));
|
|
874
|
+
return { repos, ...(review ? { review } : {}), ...(stale ? { stale } : {}) };
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
leaseIntervalMs,
|
|
878
|
+
|
|
879
|
+
async renew(name) {
|
|
880
|
+
// ONE call, both directions: `annotate --overwrite -o json` writes the stamp and prints the
|
|
881
|
+
// object as it stands afterwards, status included. So asserting liveness and learning
|
|
882
|
+
// whether the workspace survived cost exactly one API round trip (ADR-0021).
|
|
883
|
+
try {
|
|
884
|
+
const { stdout } = await exec([
|
|
885
|
+
"annotate",
|
|
886
|
+
"sandbox",
|
|
887
|
+
name,
|
|
888
|
+
...base,
|
|
889
|
+
`jr2.dev/keepalive=${new Date().toISOString()}`,
|
|
890
|
+
"--overwrite",
|
|
891
|
+
"-o",
|
|
892
|
+
"json",
|
|
893
|
+
]);
|
|
894
|
+
return { present: true, identity: readSandbox(stdout).podUID };
|
|
895
|
+
} catch (err) {
|
|
896
|
+
if (isNotFound(err)) return { present: false };
|
|
897
|
+
throw err; // anything else is UNKNOWN — the caller must not read it as loss
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
async destroy(name) {
|
|
902
|
+
staleByName.delete(name);
|
|
903
|
+
// The Secret is an owned child of the CR, so deleting the CR reaps it — this is belt and
|
|
904
|
+
// braces for the case where the ownerRef patch didn't land.
|
|
905
|
+
await exec(["delete", "sandbox", name, ...base, "--ignore-not-found"]);
|
|
906
|
+
await exec(["delete", "secret", secretName(name), ...base, "--ignore-not-found"]).catch(() => {});
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** The pod volume the operator defines for one Repo's node cache, and where it lands in the
|
|
912
|
+
* primary container (ADR-0051). Two halves of one contract with the operator, spelled here so the
|
|
913
|
+
* User Container's mounts and the attach's clone source agree with it by construction. */
|
|
914
|
+
export const repoVolumeName = (key: string): string => `repo-${key}`;
|
|
915
|
+
export const repoMountPath = (key: string): string => `${REPOS_MOUNT}/${key}`;
|
|
916
|
+
|
|
917
|
+
/** One Repo as the provision names it: the CR entry, plus what its resource records — the
|
|
918
|
+
* identity, and whether a Machine's slot (not only the run's) binds it. */
|
|
919
|
+
type FencedRepo = { key: string; url: string; identity: string; bound: boolean };
|
|
920
|
+
|
|
921
|
+
/** One entry of a Sandbox CR's `status.conditions`, as the operator writes it. */
|
|
922
|
+
type Condition = { type: string; status: string; reason?: string; message?: string };
|
|
923
|
+
|
|
924
|
+
/** The operator's Ready reason when the cache agent could not clone onto the pod's node
|
|
925
|
+
* (sandbox_controller.go) — the one Ready verdict a provision cannot wait out. */
|
|
926
|
+
const REPO_CLONE_FAILED = "RepoCloneFailed";
|
|
927
|
+
|
|
928
|
+
/** The operator's Ready reasons that hold a Sandbox whose POD is Ready on its Repos
|
|
929
|
+
* (sandbox_controller.go, `reposReadiness`): the resource not yet seen, or the node's cache
|
|
930
|
+
* agent still cloning or fetching. The wait against them is the Repo budget, not the pod's. */
|
|
931
|
+
const REPO_HELD: ReadonlySet<string> = new Set(["RepoMissing", "RepoPending"]);
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The Repo budget ran out with the operator still holding the Sandbox. The pod is up, so the
|
|
935
|
+
* preflight is not the question; the verdict names the Repo and the node, and the port adds
|
|
936
|
+
* what the operator cannot say: the agent is still working, `jr2 status` shows it per node, and
|
|
937
|
+
* the budget is the port's, not the clone's.
|
|
938
|
+
*/
|
|
939
|
+
function repoWaitError(name: string, budgetMs: number, ready: Condition | undefined): string {
|
|
940
|
+
const verdict = ready?.message ? `${ready.reason ?? "?"}: ${ready.message}` : "no Ready condition reported";
|
|
941
|
+
return (
|
|
942
|
+
`Sandbox "${name}" waited ${Math.round(budgetMs / 60_000)}m for its Repos and the operator still holds it — ` +
|
|
943
|
+
`${verdict} (ADR-0051). The node's cache agent clones a cold node once and fetches before every attach; ` +
|
|
944
|
+
"`jr2 status` reports each Repo per node. Start the run again once the cache is present, or raise " +
|
|
945
|
+
"the port's `repoTimeoutMs` for a repository whose clone outlasts it."
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The fix beside the symptom. The operator's message carries the Repo, the node, and git's own
|
|
951
|
+
* words; what it cannot say is that the cache agent keeps retrying, that `jr2 status` reports the
|
|
952
|
+
* same line per node, or where a credential is configured (ADR-0047/0051).
|
|
953
|
+
*/
|
|
954
|
+
function repoCloneError(name: string, verdict: string): string {
|
|
955
|
+
return (
|
|
956
|
+
`Sandbox "${name}" cannot start: ${verdict} (ADR-0051). The node's cache agent keeps retrying on its own — ` +
|
|
957
|
+
"fix the url or its git.credentials entry (an ssh url needs its deploy key registered with the host, " +
|
|
958
|
+
"ADR-0047), then start the run again; `jr2 status` reports the same error per node until it clears."
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The `ReposFresh=False` message, keyed back to SLOTS for the attach's `stale`. The operator
|
|
964
|
+
* writes one clause per stale Repo — `Repo "<key>" on node <n> is stale: <error>` — joined by
|
|
965
|
+
* `; `; each slot whose key a clause names gets that clause. A message that names no key at all
|
|
966
|
+
* lands on every slot: a verdict with no address is still a verdict.
|
|
967
|
+
*/
|
|
968
|
+
function staleSlots(
|
|
969
|
+
repos: Array<{ slot: string; url: string }>,
|
|
970
|
+
message: string | undefined,
|
|
971
|
+
): Record<string, string> | undefined {
|
|
972
|
+
if (!message) return undefined;
|
|
973
|
+
const byKey = new Map<string, string>();
|
|
974
|
+
for (const clause of message.split("; ")) {
|
|
975
|
+
const key = /^Repo "([^"]+)"/.exec(clause)?.[1];
|
|
976
|
+
if (key !== undefined) byKey.set(key, clause);
|
|
977
|
+
}
|
|
978
|
+
const stale: Record<string, string> = {};
|
|
979
|
+
for (const repo of repos) {
|
|
980
|
+
const clause = byKey.size === 0 ? message : byKey.get(repoIdentity(repo.url).key);
|
|
981
|
+
if (clause !== undefined) stale[repo.slot] = clause;
|
|
982
|
+
}
|
|
983
|
+
return Object.keys(stale).length ? stale : undefined;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The post-Ready attach step as one idempotent in-pod script (ADR-0004, ADR-0051): per Repo Slot
|
|
988
|
+
* in declaration order, a pod-local `git clone --shared` borrowing objects from the node's
|
|
989
|
+
* read-only cache at `/repos/<key>` — `<slot>/default/`, a checkout of the Repo's default branch
|
|
990
|
+
* — then the branch worktree as a sibling (gwtmux layout: `<slot>/default/` + `<slot>/<branch>/`). With a `reviewSha`, also the detached review worktree
|
|
991
|
+
* (ADR-0028) — another sibling. `repos` keeps the slots' declaration order. Exported for the port's
|
|
992
|
+
* tests; the workflow never sees it.
|
|
993
|
+
*/
|
|
994
|
+
export function attachScript(
|
|
995
|
+
spec: WorkspaceSpec,
|
|
996
|
+
repos: Array<{ slot: string; url: string; ref?: string }>,
|
|
997
|
+
paths: { reposMount: string; workRoot: string; adapterUrl?: string },
|
|
998
|
+
): { script: string; repos: Record<string, string>; review?: Record<string, string> } {
|
|
999
|
+
const worktrees: Record<string, string> = {};
|
|
1000
|
+
const review: Record<string, string> = {};
|
|
1001
|
+
// The cache is written by the node's cache agent and read here as the Harness's unprivileged
|
|
1002
|
+
// uid (ADR-0001/0004/0051), so git's dubious-ownership guard would refuse the clone source.
|
|
1003
|
+
// safe.directory is only honored from global/system config (never `-c`), and inside the pod
|
|
1004
|
+
// every path is jr2-owned — trusting them all is the honest scope.
|
|
1005
|
+
// The attach runs via exec, not as a child of the Harness process, so it does NOT inherit the
|
|
1006
|
+
// Harness's `umask 002` — without its own, the repo roots it mkdirs land 755 and the work group
|
|
1007
|
+
// could never create a file at a tree's top. INSIDE the trees the umask stops mattering: the
|
|
1008
|
+
// default ACL stamped below governs everything created beneath a repo root (ADR-0005).
|
|
1009
|
+
const lines: string[] = [`umask 002`, `git config --global safe.directory '*'`];
|
|
1010
|
+
const branchDir = spec.branch.replace(/\//g, "-");
|
|
1011
|
+
for (const repo of repos) {
|
|
1012
|
+
const slotDir = `${paths.workRoot}/${repo.slot}`;
|
|
1013
|
+
const dflt = `${slotDir}/default`;
|
|
1014
|
+
const worktree = `${slotDir}/${branchDir}`;
|
|
1015
|
+
const { identity, key } = repoIdentity(repo.url);
|
|
1016
|
+
const cache = `${paths.reposMount}/${key}`;
|
|
1017
|
+
worktrees[repo.slot] = worktree;
|
|
1018
|
+
lines.push(
|
|
1019
|
+
`mkdir -p ${sq(slotDir)}`,
|
|
1020
|
+
// BEFORE the clone fills it: a default ACL is inherited at creation, never retrofitted, so
|
|
1021
|
+
// the stamp must exist while the tree is still empty. From here down, both seats' files land
|
|
1022
|
+
// group-writable with zero umask lines in any image (ADR-0005); on a filesystem without
|
|
1023
|
+
// POSIX ACLs the helper warns and exits 0, degrading to the umask sharing above.
|
|
1024
|
+
`/opt/jr2/bin/work-acl ${sq(slotDir)}`,
|
|
1025
|
+
`[ -d ${sq(`${dflt}/.git`)} ] || git clone --shared ${sq(cache)} ${sq(dflt)}`,
|
|
1026
|
+
// No ref → the Repo's own default branch: this clone's `origin/HEAD` tracks the cache's
|
|
1027
|
+
// HEAD, which the cache agent's clone pointed at the remote's default (ADR-0004).
|
|
1028
|
+
`[ -d ${sq(worktree)} ] || git -C ${sq(dflt)} worktree add ${sq(worktree)} -b ${sq(spec.branch)} ${repo.ref === undefined ? sq("origin/HEAD") : baseOf(dflt, repo.ref)}`,
|
|
1029
|
+
// Fetch/push split (ADR-0005). The FETCH url is a command, not a path (ADR-0053): git's
|
|
1030
|
+
// built-in `ext::` transport runs the program on the runtime volume, which asks the node
|
|
1031
|
+
// cache to fetch the remote, waits for the landing, then serves the cache — so every fetch
|
|
1032
|
+
// inside the pod is a fetch of the remote's now, and a stale attach is stale only until the
|
|
1033
|
+
// next fetch anyone in the pod runs. Git substitutes `%S` with the service it wants
|
|
1034
|
+
// (`git-upload-pack`), and splits the rest on spaces with no quoting of its own, so the
|
|
1035
|
+
// url's arguments carry none: the Repo's IDENTITY, never the cache key — that is the name a
|
|
1036
|
+
// human reads in `git remote -v`, and a key is a derived directory name (ADR-0004). The
|
|
1037
|
+
// program discovers the cache from the checkout's alternates, so the url says nothing about
|
|
1038
|
+
// where the objects are. `git push` goes to the REAL remote — the Binding's own spelling, so
|
|
1039
|
+
// a Machine that bound over ssh pushes over ssh even when the cache was cloned over https
|
|
1040
|
+
// (ADR-0051). Push still succeeds only with a caller-supplied credential (a forwarded agent
|
|
1041
|
+
// in the User Container); the pod itself holds none. `--` keeps the url an operand, never an
|
|
1042
|
+
// option.
|
|
1043
|
+
`git -C ${sq(dflt)} remote set-url origin -- ${sq(fetchUrl(identity, paths.adapterUrl))}`,
|
|
1044
|
+
`git -C ${sq(dflt)} remote set-url --push origin -- ${sq(repo.url)}`,
|
|
1045
|
+
// `ext` is on git's own "known scary" list, so its built-in default is `never` and the url
|
|
1046
|
+
// above would die with `fatal: transport 'ext' not allowed` before the program ever ran.
|
|
1047
|
+
// `user` is the policy ADR-0053 argues for, said out loud: a fetch A PERSON OR THE AGENT
|
|
1048
|
+
// runs is allowed, and a recursive one git makes for itself (a submodule url, anything with
|
|
1049
|
+
// `GIT_PROTOCOL_FROM_USER=0`) is still refused — so a repository cannot smuggle a program
|
|
1050
|
+
// into this pod through a url jr2 did not write. Repo-level, on the pod-local clone: the
|
|
1051
|
+
// linked worktrees share this config, so the branch worktree and the review worktree inherit
|
|
1052
|
+
// it with no env and no `--global`.
|
|
1053
|
+
`git -C ${sq(dflt)} config protocol.ext.allow user`,
|
|
1054
|
+
);
|
|
1055
|
+
if (spec.reviewSha) {
|
|
1056
|
+
// The reviewer's seat (ADR-0028): a DETACHED HEAD at the sha under review, so a rogue write
|
|
1057
|
+
// cannot move the branch and a rogue commit evaporates with the checkout. Forced checkout
|
|
1058
|
+
// AND clean on every attach: a previous round's rogue edits (tracked) and leftovers
|
|
1059
|
+
// (untracked) must not survive into this round — the review worktree's contents are the
|
|
1060
|
+
// sha under review, period.
|
|
1061
|
+
const reviewDir = `${worktree}-review`;
|
|
1062
|
+
review[repo.slot] = reviewDir;
|
|
1063
|
+
lines.push(
|
|
1064
|
+
`[ -d ${sq(reviewDir)} ] || git -C ${sq(dflt)} worktree add --detach ${sq(reviewDir)} ${sq(spec.reviewSha)}`,
|
|
1065
|
+
`git -C ${sq(reviewDir)} checkout --detach -f ${sq(spec.reviewSha)}`,
|
|
1066
|
+
`git -C ${sq(reviewDir)} clean -fd`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (repos.length === 0)
|
|
1071
|
+
throw new Error("the attach names no Repo Slot — nothing to attach (a workspace() declares at least one)");
|
|
1072
|
+
return {
|
|
1073
|
+
script: lines.join("\n"),
|
|
1074
|
+
repos: worktrees,
|
|
1075
|
+
...(spec.reviewSha ? { review } : {}),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* `origin`'s fetch url for one Repo (ADR-0053): the `ext::` transport, the program's absolute path
|
|
1081
|
+
* on the runtime volume, the service git asks for, and the Repo's identity. The Adapter's address
|
|
1082
|
+
* rides as a third argument only when the composition moved the Adapter off its default port —
|
|
1083
|
+
* the program reads `$JR2_ADAPTER_URL` and then falls back to that same address, so spelling it out
|
|
1084
|
+
* unconditionally would put a number in every `git remote -v` that says nothing.
|
|
1085
|
+
*/
|
|
1086
|
+
function fetchUrl(identity: string, adapterUrl?: string): string {
|
|
1087
|
+
return `ext::${UPLOAD_PACK} %S ${extArg(identity)}${adapterUrl !== undefined ? ` ${extArg(adapterUrl)}` : ""}`;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* One argument of an `ext::` url, in git's own escaping. Git splits the url on spaces and reads
|
|
1092
|
+
* `%` as a placeholder introducer — `%S` is the service it substitutes — so it DIES on a `%` it
|
|
1093
|
+
* does not recognize (`fatal: Bad remote-ext placeholder '%2'`) and silently splits an argument
|
|
1094
|
+
* that holds a space. An identity carries both: a forge path may be percent-encoded
|
|
1095
|
+
* (`dev.azure.com/org/My%20Project/_git/repo`) and an scp-style url may hold a literal space. Git
|
|
1096
|
+
* spells those two `%%` and `% `, and the program receives the identity back exactly as written —
|
|
1097
|
+
* which it must, because the Orchestrator derives the cache key from that same string. The
|
|
1098
|
+
* placeholder jr2 writes itself (`%S`) is not escaped: it is git's, not an argument's.
|
|
1099
|
+
*/
|
|
1100
|
+
function extArg(value: string): string {
|
|
1101
|
+
return value.replace(/%/g, "%%").replace(/ /g, "% ");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The commit-ish a Binding's `ref` names inside the pod-local clone, as a shell expression: the
|
|
1106
|
+
* remote-tracking branch `refs/remotes/origin/<ref>` when the clone has one, else `<ref>` as
|
|
1107
|
+
* written (a tag, a sha). A fresh clone holds ONE local branch — the default — so a bare
|
|
1108
|
+
* branch name is never a local ref here, and git's "worktree add" DWIM would then create the BASE
|
|
1109
|
+
* branch tracking `origin/<ref>` and discard `-b`: the Agent would commit on, and push to, the base
|
|
1110
|
+
* it was meant to branch FROM. Naming the remote-tracking ref outright leaves nothing to guess.
|
|
1111
|
+
*/
|
|
1112
|
+
function baseOf(dflt: string, ref: string): string {
|
|
1113
|
+
const remote = sq(`refs/remotes/origin/${ref}`);
|
|
1114
|
+
return `"$(git -C ${sq(dflt)} rev-parse --verify -q ${remote} >/dev/null && printf %s ${remote} || printf %s ${sq(ref)})"`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** POSIX single-quote an argument for the in-pod `sh -ec` script. */
|
|
1118
|
+
function sq(s: string): string {
|
|
1119
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function isNotFound(err: unknown): boolean {
|
|
1123
|
+
return err instanceof Error && /NotFound|not found/i.test(err.message);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
1127
|
+
|
|
1128
|
+
/** The `kubectl` on PATH, as every kubectl-driven port shells to it (this one and repos.ts). */
|
|
1129
|
+
export const defaultKubectlExec: KubectlExec = (args, opts) =>
|
|
1130
|
+
new Promise((resolve, reject) => {
|
|
1131
|
+
const child = execFile("kubectl", args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1132
|
+
if (err) reject(new Error(`kubectl ${args[0]} failed: ${stderr || err.message}`));
|
|
1133
|
+
else resolve({ stdout, stderr });
|
|
1134
|
+
});
|
|
1135
|
+
if (opts?.input !== undefined) child.stdin?.end(opts.input);
|
|
1136
|
+
});
|