@jr2/orchestrator 0.3.0 → 0.4.1
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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -4
- package/src/actor.ts +502 -49
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +281 -84
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
// The canonical SandboxPort (ADR-0012 / GAP(3)): drives the operator's Sandbox CRD through the
|
|
2
|
+
// Orchestrator's own Kubernetes client (kube-client.ts, ADR-0063) — plain REST on built-in
|
|
3
|
+
// `fetch`, no dependency — and reads it back through the one watch (sandbox-watch.ts). Every wait
|
|
4
|
+
// that polled is now a watch event: the placing's node (ADR-0064), the provision's Ready,
|
|
5
|
+
// Continuity and the Harness's restarts (ADR-0021). The Orchestrator never reads a Pod: the
|
|
6
|
+
// operator owns it (ADR-0001) and publishes what this port needs on the Sandbox's status — the
|
|
7
|
+
// scheduler's word or a quota's refusal, `Lost` with the pod's reason, the Harness container's
|
|
8
|
+
// restarts and last end. The client and the watch are injectable, so the mapping is unit-testable against a
|
|
9
|
+
// fake API server; the kind e2e tier exercises the real thing.
|
|
10
|
+
//
|
|
11
|
+
// Reachability: the orchestrator always runs in-cluster (ADR-0019), so it dials
|
|
12
|
+
// `status.endpoint` (`http://<name>.<ns>.svc:…`) directly — stable across orchestrator
|
|
13
|
+
// restarts by nature, which is what ADR-0012's "same endpoint" re-attach promise rides on.
|
|
14
|
+
//
|
|
15
|
+
// Every operation is idempotent (SandboxPort contract): apply is create-or-update, the attach is
|
|
16
|
+
// the Harness's own `POST /attach`, which guards every clone and worktree (ADR-0063), delete
|
|
17
|
+
// ignores absent.
|
|
18
|
+
//
|
|
19
|
+
// WHICH REPOS a Sandbox attaches arrive resolved from the `workspace()`'s Repo Slots (ADR-0051):
|
|
20
|
+
// the CR names each by its cache key, the operator mounts the node's cache read-only at
|
|
21
|
+
// `/repos/<key>` and gates Ready on it, and the Harness's attach clones off that mount. The one judgement
|
|
22
|
+
// made here is the FENCE: a per-run url must match a `git.credentials` entry, or it is refused
|
|
23
|
+
// before anything is applied.
|
|
24
|
+
//
|
|
25
|
+
// WHICH IMAGE a Sandbox runs is not an option here (ADR-0037/0038/0049). The request carries what
|
|
26
|
+
// the `workspace()` wrapper statically declared — a `file:` docker context or a registry ref — and
|
|
27
|
+
// the resolved key→ref map arrives as a mounted ConfigMap read on EVERY provision, so a `jr2 up`
|
|
28
|
+
// that rebuilds an image reaches future Sandboxes without rolling this process.
|
|
29
|
+
//
|
|
30
|
+
// The pod's primary container is the Sandbox Image BYTE-FOR-BYTE (ADR-0037): no appended layers,
|
|
31
|
+
// no rewritten Dockerfile, no jr2 knowledge inside it. jr2's runtime arrives at POD time instead —
|
|
32
|
+
// an `image` volume of the kit's Harness image, its `/opt/jr2` mounted read-only at `/opt/jr2` —
|
|
33
|
+
// and the container's COMMAND is overridden to start the Harness from that volume. The kubelet
|
|
34
|
+
// mounts the image's own layers, so every Sandbox on a node reads one set of runtime pages. The
|
|
35
|
+
// image's own `USER` and `HOME` are respected (the human who execs in lands in the environment its
|
|
36
|
+
// author built); only its `ENTRYPOINT`/`CMD` do not run, because a container has one command and the
|
|
37
|
+
// Harness must own it — its death must be the container's death, which is what the operator's
|
|
38
|
+
// Ready probe and restart semantics at `:8080` mean. A process the image WANTS running is not
|
|
39
|
+
// lost: it has its own seat, the User Container (ADR-0005), composed here when the wrapper's static
|
|
40
|
+
// `user` option names an image (ADR-0049).
|
|
41
|
+
//
|
|
42
|
+
// So this module composes the whole pod — one volume from the kit, one init step, and up to three
|
|
43
|
+
// containers:
|
|
44
|
+
//
|
|
45
|
+
// volume runtime the kit's Harness image, as an `image` volume → /opt/jr2
|
|
46
|
+
// volume conversations an emptyDir → /conversations, the Harness container's alone
|
|
47
|
+
// (ADR-0031: a Harness restart inside the pod keeps its conversations)
|
|
48
|
+
// initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
|
|
49
|
+
// proves a registry ref, whose first appearance is this provision
|
|
50
|
+
// container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
|
|
51
|
+
// container custodian jr2-composed, the pod's only credential holder (below)
|
|
52
|
+
// container user optional, the image's own entrypoint, the checkouts (/work, plus
|
|
53
|
+
// /repos and /opt/jr2 read-only) and NOTHING else
|
|
54
|
+
//
|
|
55
|
+
// and SIZES it (ADR-0060): the Workspace's Size, resolved down its chain, is the pod-level ceiling;
|
|
56
|
+
// the Custodian takes a fixed slice, the User Container its split if the Machine states one, and
|
|
57
|
+
// the Harness container the rest as its own limit. Every request equals its limit, so the pod is
|
|
58
|
+
// Guaranteed and the CPU limit is enforced.
|
|
59
|
+
//
|
|
60
|
+
// This is also where the CUSTODIAN is composed (ADR-0013, ADR-0059). The operator needs no change
|
|
61
|
+
// to carry it: ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT
|
|
62
|
+
// understanding, so the Custodian is exactly that — a container with an image, its mounts, and a
|
|
63
|
+
// readiness probe the pod's Ready waits on. What this module builds is the pod's asymmetry:
|
|
64
|
+
//
|
|
65
|
+
// harness container JR2_CUSTODIAN_URL + the Stand-in of every credential (no credential)
|
|
66
|
+
// custodian container the Sandbox token and the pod's held secrets, as files (the credentials)
|
|
67
|
+
//
|
|
68
|
+
// The Agent has code execution in the first and none in the second. The token is minted here — a
|
|
69
|
+
// signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
|
|
70
|
+
// the Secret re-applies as a no-op. What else the Custodian holds is `jr2 up`'s placement for THIS
|
|
71
|
+
// Workspace — the Held secrets its `workspace()` lists and its Agents' providers claim (ADR-0059) —
|
|
72
|
+
// read per provision from the `jr2-held` mount (custodian.ts builds both placements' pods alike).
|
|
73
|
+
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
import {
|
|
76
|
+
matchCredential,
|
|
77
|
+
type GitCredential,
|
|
78
|
+
type HarnessEnvFromSource,
|
|
79
|
+
type HarnessEnvVar,
|
|
80
|
+
type SandboxPlacement,
|
|
81
|
+
} from "./config.ts";
|
|
82
|
+
import {
|
|
83
|
+
custodianBootstrap,
|
|
84
|
+
custodianComposition,
|
|
85
|
+
custodianScript,
|
|
86
|
+
CUSTODIAN_BOOTSTRAP_KEY,
|
|
87
|
+
CUSTODIAN_SCRIPT_KEY,
|
|
88
|
+
type CustodianComposition,
|
|
89
|
+
} from "./custodian.ts";
|
|
90
|
+
import { readHeldManifest, workspaceHeldPod, type HeldManifest } from "./held-secrets.ts";
|
|
91
|
+
import {
|
|
92
|
+
effectorManifestKey,
|
|
93
|
+
readSandboxEffectorManifest,
|
|
94
|
+
SANDBOX_EFFECTOR_GIT_DIR,
|
|
95
|
+
sandboxEffectorKeySecret,
|
|
96
|
+
sandboxEffectorService,
|
|
97
|
+
type SandboxEffectorManifest,
|
|
98
|
+
} from "./sandbox-effector.ts";
|
|
99
|
+
import { GIT_PUSH_MANIFEST_KEY, placedPushRefusal, readGitPushManifest } from "./git-push.ts";
|
|
100
|
+
import { readImageRefs, resolveSandboxImage, resolveUserImage, type HeldImageScope, type ImageRefs } from "./images.ts";
|
|
101
|
+
import {
|
|
102
|
+
CONVERSATIONS_DIR,
|
|
103
|
+
CONVERSATIONS_DIR_ENV,
|
|
104
|
+
CONVERSATIONS_VOLUME,
|
|
105
|
+
HELD_MOUNT,
|
|
106
|
+
CA_CONFIGMAP,
|
|
107
|
+
heldManifestKey,
|
|
108
|
+
IMAGES_KEY,
|
|
109
|
+
IMAGES_MOUNT,
|
|
110
|
+
ORCHESTRATOR_PORT,
|
|
111
|
+
ORCHESTRATOR_SERVICE,
|
|
112
|
+
PRIORITY_CLASS_SANDBOX,
|
|
113
|
+
REPOS_MOUNT,
|
|
114
|
+
sandboxCustodianConfigMap,
|
|
115
|
+
} from "./names.ts";
|
|
116
|
+
import {
|
|
117
|
+
CONFIGMAPS,
|
|
118
|
+
SANDBOXES,
|
|
119
|
+
SECRETS,
|
|
120
|
+
SERVICES,
|
|
121
|
+
isNotFound,
|
|
122
|
+
kubeClient,
|
|
123
|
+
type KubeClient,
|
|
124
|
+
type KubeObject,
|
|
125
|
+
} from "./kube-client.ts";
|
|
126
|
+
import { repoIdentity } from "./repo-identity.ts";
|
|
127
|
+
import type { RepoResources } from "./repos.ts";
|
|
128
|
+
import { resolveSize, splitSize, type Size, type SizeSplit } from "./size.ts";
|
|
129
|
+
import {
|
|
130
|
+
watchSandboxes,
|
|
131
|
+
type Condition,
|
|
132
|
+
type ContainerWaiting,
|
|
133
|
+
type SandboxObject,
|
|
134
|
+
type SandboxWatch,
|
|
135
|
+
} from "./sandbox-watch.ts";
|
|
136
|
+
import { effectorKey, harnessToken, harnessTokenDigest, sandboxToken } from "./tokens.ts";
|
|
137
|
+
import type { AttachError, AttachRequest, AttachResponse } from "./wire.ts";
|
|
138
|
+
import type { Continuity, MemoryKill, PlacingWait, ProvisionedRepo, SandboxLoss, SandboxPort } from "./workspace.ts";
|
|
139
|
+
|
|
140
|
+
/** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
|
|
141
|
+
* because a stranger's base may already use `/app`, and one layout must serve both the stock
|
|
142
|
+
* Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
|
|
143
|
+
* (node's rpath is `$ORIGIN/../lib`), `src/main.ts`, `node_modules/`. */
|
|
144
|
+
export const RUNTIME_MOUNT = "/opt/jr2";
|
|
145
|
+
|
|
146
|
+
/** How every seat that holds the runtime mounts it (ADR-0037). The volume is the WHOLE harness
|
|
147
|
+
* image, so `subPath` narrows it to that image's own `/opt/jr2` — the published surface, laid out
|
|
148
|
+
* exactly as the Harness image runs it, so node's `$ORIGIN/../lib` holds with nothing rearranged.
|
|
149
|
+
* Read-only in every seat: an image volume is read-only anyway, and the Agent has code execution
|
|
150
|
+
* in the Harness container, so the CR says so rather than leaving it to the volume type. The
|
|
151
|
+
* layers stay owned by root and the Sandbox Image may run any uid, so the image's MODE is the
|
|
152
|
+
* guarantee that any uid reads it — the harness Dockerfile's build-time check holds it there. */
|
|
153
|
+
const RUNTIME_VOLUME = "runtime";
|
|
154
|
+
const RUNTIME_VOLUME_MOUNT = { name: RUNTIME_VOLUME, mountPath: RUNTIME_MOUNT, subPath: "opt/jr2", readOnly: true };
|
|
155
|
+
|
|
156
|
+
/** The primary container's command (ADR-0037). Absolute, so it never depends on the image's
|
|
157
|
+
* `WORKDIR`, and identical to the stock Harness image's own `CMD` — one runtime, two placements.
|
|
158
|
+
* `tini` is PID 1 (ADR-0061): the Agent's processes are the Harness's children, and a tree the
|
|
159
|
+
* memory guard kills must leave no zombies behind. */
|
|
160
|
+
const HARNESS_COMMAND = [
|
|
161
|
+
`${RUNTIME_MOUNT}/bin/tini`,
|
|
162
|
+
"--",
|
|
163
|
+
`${RUNTIME_MOUNT}/bin/node`,
|
|
164
|
+
`${RUNTIME_MOUNT}/src/main.ts`,
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/** Where the Harness attaches (ADR-0004): the `work` volume's mount in every seat that holds the
|
|
168
|
+
* checkouts. Fixed, because the Harness's attach writes exactly here (ADR-0063). */
|
|
169
|
+
const WORK_ROOT = "/work";
|
|
170
|
+
|
|
171
|
+
/** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
|
|
172
|
+
* container as a supplemental group, so the Harness writes `/work` whatever the number and no
|
|
173
|
+
* image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
|
|
174
|
+
const DEFAULT_WORK_GROUP = 2000;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The isolation baseline for a jr2-owned seat, spelled out HERE for the init container because the
|
|
178
|
+
* operator's hardened default covers the primary container and the sidecars only (ADR-0001/0005) —
|
|
179
|
+
* init steps pass through verbatim, which is what keeps the operator agent-agnostic. Deliberately
|
|
180
|
+
* not applied to the `user` container: that seat's identity is "what jr2 does not own".
|
|
181
|
+
*/
|
|
182
|
+
const HARDENED = {
|
|
183
|
+
runAsNonRoot: true,
|
|
184
|
+
allowPrivilegeEscalation: false,
|
|
185
|
+
capabilities: { drop: ["ALL"] },
|
|
186
|
+
seccompProfile: { type: "RuntimeDefault" },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** The Sandbox Effector's port in its pod (ADR-0068's default). */
|
|
190
|
+
export const SANDBOX_EFFECTOR_PORT = 8082;
|
|
191
|
+
|
|
192
|
+
export { sandboxEffectorSecret } from "./sandbox-effector.ts";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The Sandbox Effector's container (ADR-0067, ADR-0068), for the Sandbox CR's `sidecars` beside the
|
|
196
|
+
* Custodian: the operator schedules it as it does any sidecar, and projects the CR's `gitCredentials`
|
|
197
|
+
* into it alone, by its name `effector`. The provision adds it for a `workspace()` whose body
|
|
198
|
+
* holds an Effect placed there (`push`, a file-body `http`, a credentialed `command`) — which `jr2 up` read
|
|
199
|
+
* off the Machines and declared in the pod's manifest. Its image is the Sandbox Image unless the
|
|
200
|
+
* `workspace()` names `effector: { image }`; its command is jr2's binary on the runtime volume; it
|
|
201
|
+
* runs as its image's own `USER`. Its mounts: `/work`, `/repos/<key>` and `/opt/jr2` read-only,
|
|
202
|
+
* the secrets its Effects name and the credentials its pushes spend at `/run/jr2/secrets` (0400),
|
|
203
|
+
* its per-pod key (derived from the Sandbox's name, like the Sandbox token) and `effector.json` at
|
|
204
|
+
* `/run/jr2/effector`, and an emptyDir scratch that is its `$HOME`. The push credentials are not
|
|
205
|
+
* the provision's to mount: the operator projects each entry Secret the CR's `gitCredentials`
|
|
206
|
+
* names into `/run/jr2/secrets/@git/<name>/` (ADR-0051). No Custodian, no `harness.env`, and no env
|
|
207
|
+
* beyond where its mounts are and the downward API's name, address and node — the node is what
|
|
208
|
+
* its Landlock refusal names. It says why it ended in its termination message, which the operator
|
|
209
|
+
* publishes on the Sandbox's status (`status.effector`).
|
|
210
|
+
*/
|
|
211
|
+
export function sandboxEffectorContainer(opts: {
|
|
212
|
+
image: string;
|
|
213
|
+
keys: readonly string[];
|
|
214
|
+
securityContext: Record<string, unknown>;
|
|
215
|
+
secretVolume: string;
|
|
216
|
+
keyVolume: string;
|
|
217
|
+
scratchVolume: string;
|
|
218
|
+
/** The instance's trust ConfigMap's volume, when it ships a caBundle (ADR-0020). */
|
|
219
|
+
caVolume?: string;
|
|
220
|
+
}) {
|
|
221
|
+
const env = (name: string, value: string) => ({ name, value });
|
|
222
|
+
return {
|
|
223
|
+
name: "effector",
|
|
224
|
+
image: opts.image,
|
|
225
|
+
command: [`${RUNTIME_MOUNT}/bin/effector`],
|
|
226
|
+
env: [
|
|
227
|
+
env("JR2_EFFECTOR_PORT", String(SANDBOX_EFFECTOR_PORT)),
|
|
228
|
+
env("JR2_EFFECTOR_WORK", WORK_ROOT),
|
|
229
|
+
env("JR2_EFFECTOR_REPOS", REPOS_MOUNT),
|
|
230
|
+
env("JR2_EFFECTOR_KEY", "/run/jr2/effector/key"),
|
|
231
|
+
env("JR2_EFFECTOR_CONFIG", "/run/jr2/effector/effector.json"),
|
|
232
|
+
env("JR2_EFFECTOR_SECRETS", "/run/jr2/secrets"),
|
|
233
|
+
env("JR2_EFFECTOR_GIT", `/run/jr2/secrets/${SANDBOX_EFFECTOR_GIT_DIR}`),
|
|
234
|
+
env("JR2_EFFECTOR_SCRATCH", "/run/jr2/scratch"),
|
|
235
|
+
env("HOME", "/run/jr2/scratch"),
|
|
236
|
+
...(opts.caVolume ? [env("JR2_CA_FILE", `${EFFECTOR_CA_MOUNT}/ca.crt`)] : []),
|
|
237
|
+
{ name: "POD_NAME", valueFrom: { fieldRef: { fieldPath: "metadata.name" } } },
|
|
238
|
+
{ name: "POD_IP", valueFrom: { fieldRef: { fieldPath: "status.podIP" } } },
|
|
239
|
+
{ name: "NODE_NAME", valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } } },
|
|
240
|
+
],
|
|
241
|
+
ports: [{ name: "effector", containerPort: SANDBOX_EFFECTOR_PORT }],
|
|
242
|
+
// Pod Ready waits on it, and the Sandbox's Ready on pod Ready (ADR-0063): no Effect request is
|
|
243
|
+
// signed before the Effector process that verifies it started, which it would refuse, unsigned
|
|
244
|
+
// (ADR-0068). It answers only once it holds its key and listens. Every second, as the Harness's.
|
|
245
|
+
readinessProbe: { httpGet: { path: "/healthz", port: SANDBOX_EFFECTOR_PORT }, periodSeconds: 1 },
|
|
246
|
+
terminationMessagePolicy: "FallbackToLogsOnError",
|
|
247
|
+
volumeMounts: [
|
|
248
|
+
{ name: "work", mountPath: WORK_ROOT, readOnly: true },
|
|
249
|
+
RUNTIME_VOLUME_MOUNT,
|
|
250
|
+
...opts.keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
|
|
251
|
+
{ name: opts.secretVolume, mountPath: "/run/jr2/secrets", readOnly: true },
|
|
252
|
+
{ name: opts.keyVolume, mountPath: "/run/jr2/effector", readOnly: true },
|
|
253
|
+
{ name: opts.scratchVolume, mountPath: "/run/jr2/scratch" },
|
|
254
|
+
...(opts.caVolume ? [{ name: opts.caVolume, mountPath: EFFECTOR_CA_MOUNT, readOnly: true }] : []),
|
|
255
|
+
],
|
|
256
|
+
securityContext: opts.securityContext,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const EFFECTOR_CA_MOUNT = "/etc/jr2/effector-ca";
|
|
261
|
+
|
|
262
|
+
/** The Sandbox Effector's volumes, by the names its container mounts. */
|
|
263
|
+
export function sandboxEffectorVolumes(opts: {
|
|
264
|
+
sandbox: string;
|
|
265
|
+
manifest: SandboxEffectorManifest;
|
|
266
|
+
caBundle: boolean;
|
|
267
|
+
}) {
|
|
268
|
+
return [
|
|
269
|
+
{ name: "effector-secrets", projected: { defaultMode: 0o400, sources: opts.manifest.sources } },
|
|
270
|
+
{
|
|
271
|
+
name: "effector-own",
|
|
272
|
+
projected: {
|
|
273
|
+
defaultMode: 0o400,
|
|
274
|
+
sources: [
|
|
275
|
+
{ secret: { name: sandboxEffectorKeySecret(opts.sandbox), items: [{ key: "key", path: "key" }] } },
|
|
276
|
+
{ secret: { name: opts.manifest.secret, items: [{ key: "effector.json", path: "effector.json" }] } },
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{ name: "effector-scratch", emptyDir: {} },
|
|
281
|
+
...(opts.caBundle ? [{ name: "effector-ca", configMap: { name: CA_CONFIGMAP } }] : []),
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* ADR-0037's fallback seat, for a BUILT image that declares no `USER` (the converge's `docker
|
|
287
|
+
* inspect` is what saw that; images.ts holds the record). jr2 sets `runAsUser` nowhere else — an
|
|
288
|
+
* image's own `USER` decides its seat's uid (ADR-0005) — so this applies only where the image
|
|
289
|
+
* chose nothing and the alternative is root, which the hardened context refuses.
|
|
290
|
+
*
|
|
291
|
+
* The home is a POD volume, not a directory in the image: uid 1000 on a stranger's base has no
|
|
292
|
+
* home at all, and the floor needs a writable one (`git config --global` writes `$HOME/.gitconfig`
|
|
293
|
+
* on every attach; a real toolchain wants `~/.npm`, `~/.cargo`, `~/.cache`). An emptyDir lands
|
|
294
|
+
* group-writable under the pod's fsGroup, so uid 1000 owns it in practice without jr2 chown-ing
|
|
295
|
+
* anything. `/home/jr2` and not `/home/node`: the number is jr2's choice here, so the path is too.
|
|
296
|
+
*/
|
|
297
|
+
const FALLBACK_UID = 1000;
|
|
298
|
+
const FALLBACK_HOME = "/home/jr2";
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The CPU hints (ADR-0060), each the Harness container's own `limits.cpu` through the Downward API
|
|
302
|
+
* (divisor 1, so a fractional limit rounds UP to whole cpus). For the tools that read neither the
|
|
303
|
+
* cgroup's limit nor their affinity: `OMP_NUM_THREADS` also makes `nproc` answer N, and
|
|
304
|
+
* `PYTHON_CPU_COUNT` and `GOMAXPROCS` speak for their runtimes. `os.cpus()` still reports the node —
|
|
305
|
+
* a Machine that runs Playwright passes `--workers=$JR2_CPUS`.
|
|
306
|
+
*
|
|
307
|
+
* The Agent is told its CPU count and Size by the Briefing's standing part, never by the Frame
|
|
308
|
+
* (ADR-0060, ADR-0062) — and that needs no wire: the Harness reads `JR2_CPUS` from this env and its
|
|
309
|
+
* Size from its own cgroup, so the Orchestrator sends neither.
|
|
310
|
+
*/
|
|
311
|
+
const CPU_HINTS = ["JR2_CPUS", "OMP_NUM_THREADS", "PYTHON_CPU_COUNT", "GOMAXPROCS"] as const;
|
|
312
|
+
const cpuHintEnv = (): HarnessEnvVar[] =>
|
|
313
|
+
CPU_HINTS.map((name) => ({ name, valueFrom: { resourceFieldRef: { resource: "limits.cpu", divisor: "1" } } }));
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* How many times the Harness container may end before the provision stops waiting for it
|
|
317
|
+
* (ADR-0063). The operator publishes its restarts and last end on the Sandbox's status, so a
|
|
318
|
+
* Harness that dies on start — a Sandbox Image that misses ADR-0037's floor in a way the preflight
|
|
319
|
+
* did not catch, a runtime the image cannot run — fails the provision by NAME, with the kubelet's
|
|
320
|
+
* reason and exit code, instead of burning the whole Ready budget. Two, not one: a single restart
|
|
321
|
+
* on the way up (a memory kill during a heavy start, ADR-0061) is a pod that may still come up.
|
|
322
|
+
*/
|
|
323
|
+
const CRASH_LOOP_RESTARTS = 2;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The kubelet's verdict on a container whose image resolves to root, or to a non-numeric user,
|
|
327
|
+
* under `runAsNonRoot` (ADR-0005). A BUILT image is judged at converge from its recorded USER; a
|
|
328
|
+
* brought registry ref is never inspected (ADR-0037), so this waiting reason is the only place
|
|
329
|
+
* its USER is known. Reason AND message: the reason alone also covers a missing Secret or
|
|
330
|
+
* ConfigMap key, a different fault with a different fix.
|
|
331
|
+
*/
|
|
332
|
+
const ROOT_IMAGE_REASON = "CreateContainerConfigError";
|
|
333
|
+
const ROOT_IMAGE_MESSAGE = /runAsNonRoot/i;
|
|
334
|
+
|
|
335
|
+
/** Waiting reasons that no retry mends: the kubelet will never start the container. A failing
|
|
336
|
+
* pull is not here — it may recover (a registry blip, node credentials), so the pod budget
|
|
337
|
+
* decides, and its expiry names it. */
|
|
338
|
+
const TERMINAL_WAITING = new Set(["InvalidImageName", "ErrImageNeverPull"]);
|
|
339
|
+
|
|
340
|
+
/** The kubelet's reason for a container the kernel killed at its memory limit (ADR-0061). */
|
|
341
|
+
const OOM_KILLED = "OOMKilled";
|
|
342
|
+
|
|
343
|
+
/** How long a lost conversation waits for the operator's word on a memory kill (ADR-0061). The
|
|
344
|
+
* Harness can be serving again — and answering 404 — a moment before the kubelet's status for its
|
|
345
|
+
* last run has reached the Sandbox, and this port never reads the Pod to learn it sooner. */
|
|
346
|
+
const MEMORY_FAULT_GRACE_MS = 5_000;
|
|
347
|
+
|
|
348
|
+
/** Clock skew allowed between a node's kubelet stamping `finishedAt` and this process's `since`. */
|
|
349
|
+
const MEMORY_FAULT_SKEW_MS = 10_000;
|
|
350
|
+
|
|
351
|
+
/** How long an attach keeps re-sending a request that never reached the Harness (ADR-0042): the
|
|
352
|
+
* attach is now the FIRST thing that dials a Sandbox's Service, and Ready is not routable. The
|
|
353
|
+
* same measured window the admission uses (harness-client.ts). */
|
|
354
|
+
const ATTACH_WINDOW_MS = 90_000;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* ADR-0037's preflight, VERBATIM: git present · `$HOME` writable · glibc new enough for jr2's node
|
|
358
|
+
* (with the relocated `libstdc++`) · the vendored ripgrep, reached through the mounted `/opt/jr2`.
|
|
359
|
+
*
|
|
360
|
+
* The three commands are the floor, one each: `git config --global` proves git is on the system
|
|
361
|
+
* PATH AND that `$HOME` is writable for the image's user; `node -e ""` proves the glibc is no
|
|
362
|
+
* older than the one jr2's node was built against (this is where musl dies); `rg` UNQUALIFIED
|
|
363
|
+
* proves the vendored static binary resolves THROUGH PATH, which is what the Harness's own append
|
|
364
|
+
* buys at runtime.
|
|
365
|
+
*
|
|
366
|
+
* ONE prover, and this is it (ADR-0037/0041). A converge cannot hold an image to this floor: the
|
|
367
|
+
* floor is a HARNESS-SEAT obligation, a built context may equally be destined for the User
|
|
368
|
+
* Container seat — which owes no floor at all (ADR-0005) — and which seat a directory serves is
|
|
369
|
+
* workflow-internal and statically unrecoverable (ADR-0031). Here the seat is known, and here is
|
|
370
|
+
* also the only moment a registry ref exists at all, since jr2 never builds or inspects one.
|
|
371
|
+
*/
|
|
372
|
+
const SANDBOX_PREFLIGHT = `git config --global safe.directory "*" && ${RUNTIME_MOUNT}/bin/node -e "" && rg --version`;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The probe as a shell line. The PATH append is mechanism, not part of the claim, and it is not
|
|
376
|
+
* optional: nothing bakes `/opt/jr2/bin` into the user's image any more, so a probe that skipped it
|
|
377
|
+
* would report `rg: not found` for every image on earth. APPENDED, never prepended — a toolchain
|
|
378
|
+
* the image pinned wins, which is as much the property being proved as `rg`'s presence (ADR-0037).
|
|
379
|
+
* The seat gets no login shell at either end, so `$PATH` is whatever the image itself set.
|
|
380
|
+
*/
|
|
381
|
+
function preflightShell(): string {
|
|
382
|
+
return `export PATH="$PATH:${RUNTIME_MOUNT}/bin"; ${SANDBOX_PREFLIGHT}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The preflight as an init step IN THE USER'S IMAGE with `/opt/jr2` mounted. It fails the pod
|
|
387
|
+
* before the Harness starts, instead of surfacing as a tool failure mid-turn on a pod nobody is
|
|
388
|
+
* watching.
|
|
389
|
+
*
|
|
390
|
+
* It PROVES, it does not set up: the `.gitconfig` it writes lives in this container's own
|
|
391
|
+
* ephemeral filesystem, so the attach script still runs the same line in the Harness container.
|
|
392
|
+
*
|
|
393
|
+
* `sh -c`, not `sh -ec`: the failure is handled below, because "node did not execute" is not
|
|
394
|
+
* actionable and the message has to name the fix.
|
|
395
|
+
*/
|
|
396
|
+
const PREFLIGHT_SCRIPT = [
|
|
397
|
+
`${preflightShell()} && exit 0`,
|
|
398
|
+
`echo "jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than ` +
|
|
399
|
+
`jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's ` +
|
|
400
|
+
`USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest." >&2`,
|
|
401
|
+
`exit 1`,
|
|
402
|
+
].join("\n");
|
|
403
|
+
|
|
404
|
+
export type KubeSandboxOptions = {
|
|
405
|
+
/** The mounted image map (ADR-0037/0038) — every ref this port can name, written by `jr2 up`.
|
|
406
|
+
* Default: the `jr2-images` ConfigMap's mount. No image option here: which image a Sandbox runs
|
|
407
|
+
* is the `workspace()` wrapper's static `image` option (ADR-0049) — carried on the Machine, read
|
|
408
|
+
* off it at invoke time, handed to `provision()` as a string — and resolved against this map at
|
|
409
|
+
* provision. The per-run spec never names one (ADR-0051). */
|
|
410
|
+
imagesPath?: string;
|
|
411
|
+
/** Where each Workspace's held manifest is (ADR-0059): `held-<pod>.json` per pod `jr2 up`
|
|
412
|
+
* placed, read per provision like the image map, so a held-secret edit reaches future Sandboxes
|
|
413
|
+
* without rolling this process. Default: the `jr2-held` ConfigMap's mount. */
|
|
414
|
+
heldDir?: string;
|
|
415
|
+
/** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
|
|
416
|
+
* mechanism-owned vars, which win on collision. */
|
|
417
|
+
env?: HarnessEnvVar[];
|
|
418
|
+
/** Whole-Secret/ConfigMap env for the Harness container (`harness.envFrom`) — how a real
|
|
419
|
+
* Harness gets its model API key without the value ever touching jr2 config. */
|
|
420
|
+
envFrom?: HarnessEnvFromSource[];
|
|
421
|
+
/** Where a Sandbox may land (ADR-0052): the Instance's `sandbox.nodeSelector` and
|
|
422
|
+
* `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
|
|
423
|
+
* merges nothing with them. Absent → wherever an ordinary pod lands. */
|
|
424
|
+
placement?: SandboxPlacement;
|
|
425
|
+
/** The Instance's default Size (`sandbox.resources`, ADR-0060): applied only to a Workspace
|
|
426
|
+
* that states no Size, never under or over a stated one. Absent → the kit default. */
|
|
427
|
+
defaultSize?: Size;
|
|
428
|
+
/** The Sandbox pod's PriorityClass (ADR-0060): `priorityClasses.sandbox`, or the `jr2-sandbox`
|
|
429
|
+
* class `jr2 up` creates. */
|
|
430
|
+
priorityClassName?: string;
|
|
431
|
+
/** The instance ships a private-CA bundle (ADR-0020): the HARNESS container trusts it, and the
|
|
432
|
+
* Custodian verifies a bound host with it (custodian.ts). */
|
|
433
|
+
caBundle?: boolean;
|
|
434
|
+
/** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
|
|
435
|
+
signingKey?: Buffer;
|
|
436
|
+
/** Kube namespace for Sandbox CRs. Default `default`. */
|
|
437
|
+
namespace?: string;
|
|
438
|
+
/** CR `spec.idleTimeout` — the operator's abandoned-Sandbox GC backstop (ADR-0001). Default `30m`. */
|
|
439
|
+
idleTimeout?: string;
|
|
440
|
+
/** How often a workspace's lease actor renews (ADR-0001/0021): the cadence at which
|
|
441
|
+
* `jr2.dev/keepalive` is re-stamped, ±20%. Must be ≪ idleTimeout, since a lapsed lease is what
|
|
442
|
+
* lets the operator reap. Default 5m. */
|
|
443
|
+
leaseIntervalMs?: number;
|
|
444
|
+
/** Await-Ready budget for the POD: from the pod's scheduling until the operator reports it Ready
|
|
445
|
+
* (ADR-0064) — the wait for a node is `placing`'s, with no deadline, and spends none of it.
|
|
446
|
+
* Default 120s, measured against watch events. A pod that never comes up (an image that misses
|
|
447
|
+
* ADR-0037's floor) is what this bounds; a pod that is up and waiting on its Repos is
|
|
448
|
+
* `repoTimeoutMs`'s. */
|
|
449
|
+
readyTimeoutMs?: number;
|
|
450
|
+
/**
|
|
451
|
+
* Await-Ready budget for the REPOS (ADR-0051): once the operator holds a Sandbox whose pod is
|
|
452
|
+
* Ready on a Repo reason — the node's cache agent is cloning a cold node, or fetching before the
|
|
453
|
+
* attach — the wait is measured against this, from the same instant as the pod's. Sized for a
|
|
454
|
+
* clone, not a pod: the agent's clone budget is 20m, and this must exceed it so a clone that
|
|
455
|
+
* runs out of time fails BY NAME (`RepoCloneFailed`, git's words) instead of as this port's
|
|
456
|
+
* timeout. The Lease renews from the CR's write (ADR-0064), so the idle timeout does not bound
|
|
457
|
+
* it. Default 25m.
|
|
458
|
+
*/
|
|
459
|
+
repoTimeoutMs?: number;
|
|
460
|
+
/**
|
|
461
|
+
* The Repo-resource port (ADR-0051, repos.ts): every Repo a provision names must exist as a
|
|
462
|
+
* `Repo` resource before the CR names it, or the operator reports it missing and the Sandbox
|
|
463
|
+
* never reaches Ready. So `provision` ensures each one here — a per-run url's resource is
|
|
464
|
+
* created at first attach, a bound one is found as the boot stated it — and REFUSES to run
|
|
465
|
+
* without the port: a Sandbox whose Repos nobody creates parks on `RepoMissing` for the whole
|
|
466
|
+
* Ready budget. The other operations (attach, renew, destroy) need no port, so it is optional
|
|
467
|
+
* at construction.
|
|
468
|
+
*/
|
|
469
|
+
repos?: RepoResources;
|
|
470
|
+
/**
|
|
471
|
+
* The instance's `git.credentials` (ADR-0051) — THE FENCE. A per-run url is run input, a
|
|
472
|
+
* ticket field, and otherwise a way to spend the cluster's credential against any host: one
|
|
473
|
+
* whose identity matches no entry is refused here, before a Secret or a CR exists, naming the
|
|
474
|
+
* list. A bound url is code the instance typechecked and deployed, admitted without a match.
|
|
475
|
+
* Default: no entries, so every per-run url is refused.
|
|
476
|
+
*/
|
|
477
|
+
credentials?: readonly GitCredential[];
|
|
478
|
+
/** The Kubernetes client (ADR-0063), shared with the Repo ports so one write cap covers the
|
|
479
|
+
* process. Default: the in-cluster client. */
|
|
480
|
+
client?: KubeClient;
|
|
481
|
+
/** The one Sandbox watch (ADR-0063), shared with the fetch ask. Default: one started on first
|
|
482
|
+
* use, over `client`, in `namespace`. */
|
|
483
|
+
watch?: SandboxWatch;
|
|
484
|
+
/** The transport the attach dials the Harness with (ADR-0063). Injectable for tests. Default:
|
|
485
|
+
* global `fetch`. */
|
|
486
|
+
harnessFetch?: typeof fetch;
|
|
487
|
+
/** How long an attach re-sends a request that never reached the Harness. Default 90s. */
|
|
488
|
+
attachWindowMs?: number;
|
|
489
|
+
/**
|
|
490
|
+
* The config-derived options, read on every use instead of the static ones above (ADR-0069). The
|
|
491
|
+
* config that applies is always the current Assembly's, and a publish swaps it without a new
|
|
492
|
+
* port. So the fence, the Harness env, the CA bundle, the placement, the default Size and the
|
|
493
|
+
* PriorityClass that a provision reads are the ones current when it runs.
|
|
494
|
+
*/
|
|
495
|
+
settings?: () => KubeSandboxSettings;
|
|
496
|
+
/** The current Assembly's hash, read per provision (ADR-0069). A provision for a run under
|
|
497
|
+
* another Assembly (a held run) whose `file:` context is not in the image map fails naming both
|
|
498
|
+
* hashes, because the map holds the current Assembly's contexts only. Absent → no current
|
|
499
|
+
* Assembly is known, and such a failure names none. */
|
|
500
|
+
currentAssembly?: () => string | undefined;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/** The options of {@link KubeSandboxOptions} that come from the Instance's config. */
|
|
504
|
+
export type KubeSandboxSettings = Pick<
|
|
505
|
+
KubeSandboxOptions,
|
|
506
|
+
"credentials" | "env" | "envFrom" | "caBundle" | "placement" | "defaultSize" | "priorityClassName"
|
|
507
|
+
>;
|
|
508
|
+
|
|
509
|
+
export function kubeSandbox(opts: KubeSandboxOptions = {}): SandboxPort {
|
|
510
|
+
// The config-derived options, read per use (ADR-0069): `cfg().credentials`, never a copy.
|
|
511
|
+
const cfg = (): KubeSandboxSettings => (opts.settings ? opts.settings() : opts);
|
|
512
|
+
const ns = opts.namespace ?? "default";
|
|
513
|
+
const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
|
|
514
|
+
const heldDir = opts.heldDir ?? HELD_MOUNT;
|
|
515
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
|
|
516
|
+
const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
|
|
517
|
+
const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
|
|
518
|
+
const harnessFetch = opts.harnessFetch ?? fetch;
|
|
519
|
+
const attachWindowMs = opts.attachWindowMs ?? ATTACH_WINDOW_MS;
|
|
520
|
+
|
|
521
|
+
// Built on first use, never at construction: a boot that builds this port has not yet reached
|
|
522
|
+
// the cluster, and must not need to (ADR-0048's stance for the Repos, held for the Sandboxes).
|
|
523
|
+
let client: KubeClient | undefined = opts.client;
|
|
524
|
+
const kube = () => (client ??= kubeClient());
|
|
525
|
+
let watch: SandboxWatch | undefined = opts.watch;
|
|
526
|
+
const sandboxes = () => (watch ??= watchSandboxes(kube(), { namespace: ns }));
|
|
527
|
+
|
|
528
|
+
/** The Sandbox's token Secret — mounted into the Custodian container, and nothing else in the pod. */
|
|
529
|
+
const secretName = (name: string) => `${name}-token`;
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
|
|
533
|
+
* `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
|
|
534
|
+
* nothing, overrides nothing. So: no `command` (its own entrypoint runs, untouched), no `env`,
|
|
535
|
+
* no `envFrom`, no CA bundle, no ports, no resources. Every key jr2 forwarded would be a crack in
|
|
536
|
+
* "jr2 puts nothing in it", and widening the one authoring string to an object stays compatible
|
|
537
|
+
* if a concrete need ever argues its own way in. (Git's dubious-ownership guard is the line's
|
|
538
|
+
* cost, accepted with
|
|
539
|
+
* eyes open — ADR-0005: safe.directory is honored only from files this seat's image owns, so an
|
|
540
|
+
* image whose sessions run git carries its own line.)
|
|
541
|
+
*
|
|
542
|
+
* `/work` read-write plus the checkouts' two read-only halves are the single exception, and they
|
|
543
|
+
* are not an injection but the point: this seat and the Harness mount ONE worktree, so the human
|
|
544
|
+
* and the Agent see identical files — which is also why ADR-0005's cross-uid pair (the pod's
|
|
545
|
+
* `fsGroup`, the attach's default ACL) exists at all. The caches ride along because they are
|
|
546
|
+
* half of the same files: the worktrees are `--shared` clones whose alternates resolve objects
|
|
547
|
+
* from `/repos/<key>` (ADR-0004/0051), so a seat with `/work` alone holds checkouts whose every
|
|
548
|
+
* borrowed object is missing ("unable to normalize alternate object path"). `/opt/jr2` is the
|
|
549
|
+
* other half (ADR-0053): `origin`'s fetch url is a program on that volume, so a seat without it
|
|
550
|
+
* holds checkouts whose `git fetch` dies — and with it the human gets the same fetch as the
|
|
551
|
+
* Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
|
|
552
|
+
* by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
|
|
553
|
+
* are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
|
|
554
|
+
* them by name. The Custodian is deliberately not given any of the three: it reads no worktree, and it is the
|
|
555
|
+
* container holding the pod's credentials, so it gets the narrowest mount set that works.
|
|
556
|
+
* It also carries no `securityContext`, which the operator reads as the exemption — root is
|
|
557
|
+
* ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
|
|
558
|
+
* and the standard managed-access shape (a root sshd that setuids sessions down) must run
|
|
559
|
+
* unmodified.
|
|
560
|
+
*/
|
|
561
|
+
const userSidecar = async (
|
|
562
|
+
refs: ImageRefs,
|
|
563
|
+
image: string,
|
|
564
|
+
keys: string[],
|
|
565
|
+
split: SizeSplit,
|
|
566
|
+
held: HeldImageScope | undefined,
|
|
567
|
+
) => ({
|
|
568
|
+
name: "user",
|
|
569
|
+
image: await resolveUserImage(refs, image, held),
|
|
570
|
+
volumeMounts: [
|
|
571
|
+
{ name: "work", mountPath: WORK_ROOT },
|
|
572
|
+
RUNTIME_VOLUME_MOUNT,
|
|
573
|
+
...keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
|
|
574
|
+
],
|
|
575
|
+
// Its split of the Size, only when the Machine states one (ADR-0060) — a fact the Machine
|
|
576
|
+
// wrote, not an injection. Otherwise the seat has no limit of its own and shares the pod's.
|
|
577
|
+
...(split.user ? { resources: split.user } : {}),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
/** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
|
|
581
|
+
* Custodian is ALWAYS here: a Sandbox without one is a pod that comes up Ready and then parks its
|
|
582
|
+
* Machine forever on a Menu it cannot read (ADR-0013). The User Container joins it only when the
|
|
583
|
+
* spec named one. */
|
|
584
|
+
const sidecarsFor = async (
|
|
585
|
+
custodian: CustodianComposition,
|
|
586
|
+
refs: ImageRefs,
|
|
587
|
+
keys: string[],
|
|
588
|
+
split: SizeSplit,
|
|
589
|
+
held: HeldImageScope | undefined,
|
|
590
|
+
user?: string,
|
|
591
|
+
effector?: Record<string, unknown>,
|
|
592
|
+
) => [
|
|
593
|
+
custodian.custodianContainer,
|
|
594
|
+
...(user !== undefined ? [await userSidecar(refs, user, keys, split, held)] : []),
|
|
595
|
+
...(effector !== undefined ? [effector] : []),
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The Sandbox Effector (ADR-0068), when `jr2 up` placed an Effect there: its
|
|
600
|
+
* container and volumes. Its image is the one the `workspace()` names, resolved like the User
|
|
601
|
+
* Container's (a `file:` context to its built ref), or the Sandbox Image; it runs as that image's
|
|
602
|
+
* own `USER` — the seat rule every jr2 seat follows, ADR-0037's fallback included — hardened.
|
|
603
|
+
*/
|
|
604
|
+
const effectorFor = async (
|
|
605
|
+
req: { name: string; effector?: string },
|
|
606
|
+
refs: ImageRefs,
|
|
607
|
+
keys: string[],
|
|
608
|
+
seat: Seat,
|
|
609
|
+
manifest: SandboxEffectorManifest,
|
|
610
|
+
held: HeldImageScope | undefined,
|
|
611
|
+
) => {
|
|
612
|
+
let image = seat.image;
|
|
613
|
+
let securityContext: Record<string, unknown> = seat.securityContext;
|
|
614
|
+
if (req.effector !== undefined) {
|
|
615
|
+
const named = await resolveSandboxImage(refs, req.effector, held);
|
|
616
|
+
if (named.refusedUser !== undefined) {
|
|
617
|
+
throw new Error(
|
|
618
|
+
`the Sandbox Effector's image "${req.effector}" declares \`USER ${named.refusedUser}\`, which cannot run a jr2 ` +
|
|
619
|
+
"seat: every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
|
|
620
|
+
"non-zero uid (ADR-0005). Change the Dockerfile's last `USER` to that uid, then re-run `jr2 up`.",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
image = named.ref;
|
|
624
|
+
securityContext = named.fallbackSeat ? { ...HARDENED, runAsUser: FALLBACK_UID } : HARDENED;
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
container: sandboxEffectorContainer({
|
|
628
|
+
image,
|
|
629
|
+
keys,
|
|
630
|
+
securityContext,
|
|
631
|
+
secretVolume: "effector-secrets",
|
|
632
|
+
keyVolume: "effector-own",
|
|
633
|
+
scratchVolume: "effector-scratch",
|
|
634
|
+
...(cfg().caBundle ? { caVolume: "effector-ca" } : {}),
|
|
635
|
+
}),
|
|
636
|
+
volumes: sandboxEffectorVolumes({ sandbox: req.name, manifest, caBundle: cfg().caBundle === true }),
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
|
|
641
|
+
// config) first, then the mechanism-owned vars (the Custodian's address and every Stand-in, the
|
|
642
|
+
// proxy and the trust bundles — custodian.ts), which win on collision. Note the asymmetry stands
|
|
643
|
+
// (ADR-0013): user env/envFrom land on the HARNESS container only — never on the Custodian, whose
|
|
644
|
+
// mounts are composed here and carry the pod's credentials.
|
|
645
|
+
//
|
|
646
|
+
// The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
|
|
647
|
+
// Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
|
|
648
|
+
// same name must not be able to choose the Harness's credential.
|
|
649
|
+
const harnessEnv = (name: string, custodian: CustodianComposition, hints: HarnessEnvVar[]): HarnessEnvVar[] => {
|
|
650
|
+
if (!opts.signingKey) throw new Error("kubeSandbox: a Harness needs a signingKey to check its bearer");
|
|
651
|
+
return [
|
|
652
|
+
...(cfg().env ?? []),
|
|
653
|
+
...custodian.harnessEnv,
|
|
654
|
+
// The CPU hints (ADR-0060) are mechanism: they say what the Size gives, so they win over a
|
|
655
|
+
// `harness.env` entry of the same name.
|
|
656
|
+
...hints,
|
|
657
|
+
// Where the Harness persists its conversations (ADR-0031) — the volume below, mechanism, so
|
|
658
|
+
// no `harness.env` entry chooses it.
|
|
659
|
+
{ name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
|
|
660
|
+
{ name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
|
|
661
|
+
];
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The one init step (ADR-0037): `preflight`, the USER'S image with the runtime volume mounted,
|
|
666
|
+
* running the probe. It is a plain container fragment the operator schedules without
|
|
667
|
+
* understanding, exactly like a sidecar — the operator stays agent-agnostic (ADR-0001), so "how a
|
|
668
|
+
* Sandbox proves its image" is composed here, not reconciled there. Nothing populates the volume
|
|
669
|
+
* first: it is the kit's image, mounted (see `runtimeVolume`).
|
|
670
|
+
*
|
|
671
|
+
* It carries jr2's hardened context explicitly, and runs the probe in the SAME seat the Harness
|
|
672
|
+
* will get — the image's own user, or ADR-0037's fallback — because a probe that proved a
|
|
673
|
+
* different uid's `$HOME` proved nothing.
|
|
674
|
+
*/
|
|
675
|
+
const initContainersFor = (seat: Seat, split: SizeSplit) => [
|
|
676
|
+
{
|
|
677
|
+
name: "preflight",
|
|
678
|
+
image: seat.image,
|
|
679
|
+
command: ["/bin/sh", "-c", PREFLIGHT_SCRIPT],
|
|
680
|
+
...(seat.env.length ? { env: seat.env } : {}),
|
|
681
|
+
volumeMounts: [RUNTIME_VOLUME_MOUNT, ...seat.homeMount],
|
|
682
|
+
// Small and fixed, inside the pod's budget (ADR-0060): it runs before the others start.
|
|
683
|
+
resources: split.preflight,
|
|
684
|
+
securityContext: seat.securityContext,
|
|
685
|
+
},
|
|
686
|
+
];
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* jr2's runtime (ADR-0037): the kit's Harness image as an `image` volume. This is what makes the
|
|
690
|
+
* runtime's version ride the VOLUME rather than the image: a kit edit moves the harness image's
|
|
691
|
+
* own tag and re-images future pods without touching a single Sandbox Image tag, which is the
|
|
692
|
+
* only way a registry-ref image could ever follow a kit update. A live pod keeps the image it
|
|
693
|
+
* started with — the same create-if-absent stance ADR-0038 takes for images. No `pullPolicy`:
|
|
694
|
+
* the kubelet's default for a volume is its default for a container, so the ref is pulled
|
|
695
|
+
* exactly as the cluster already pulls the Harness image, and the volume adds nothing to deliver.
|
|
696
|
+
*/
|
|
697
|
+
const runtimeVolume = (refs: ImageRefs) => ({ name: RUNTIME_VOLUME, image: { reference: refs.harness } });
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The primary container's seat: which image runs, as whom, and with what home. One value, built
|
|
701
|
+
* once per provision and shared by the Harness container and the preflight, so the probe cannot
|
|
702
|
+
* drift from the thing it proves.
|
|
703
|
+
*/
|
|
704
|
+
type Seat = {
|
|
705
|
+
image: string;
|
|
706
|
+
env: HarnessEnvVar[];
|
|
707
|
+
homeVolume: Array<{ name: string; emptyDir: Record<string, never> }>;
|
|
708
|
+
homeMount: Array<{ name: string; mountPath: string }>;
|
|
709
|
+
securityContext: typeof HARDENED & { runAsUser?: number };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const seatFor = async (
|
|
713
|
+
refs: ImageRefs,
|
|
714
|
+
name: string | undefined,
|
|
715
|
+
held: HeldImageScope | undefined,
|
|
716
|
+
): Promise<Seat> => {
|
|
717
|
+
// ONE resolution, so the ref and the two seat facts can never come off different legs of
|
|
718
|
+
// ADR-0037's chain (images.ts). It is async because a `file:` context is keyed by its content
|
|
719
|
+
// digest, which is a directory walk — the price of the host and the pod agreeing about an
|
|
720
|
+
// image without a path table (ADR-0049).
|
|
721
|
+
const { ref: image, fallbackSeat, refusedUser } = await resolveSandboxImage(refs, name, held);
|
|
722
|
+
// Fail HERE, before a Secret or a CR exists, on a `USER` the kubelet will refuse (images.ts).
|
|
723
|
+
// The alternative is the worst shape a failure has: the preflight container never starts, so
|
|
724
|
+
// it has no logs, and the whole 120s Ready budget burns before anything is said. The converge
|
|
725
|
+
// already inspected the image, so this is knowable at zero cost — and the message names the
|
|
726
|
+
// edit, because the fix is one line of the caller's own Dockerfile.
|
|
727
|
+
if (refusedUser !== undefined) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`the Sandbox Image "${name ?? "default"}" declares \`USER ${refusedUser}\`, which cannot run a jr2 seat: ` +
|
|
730
|
+
"every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
|
|
731
|
+
"non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last " +
|
|
732
|
+
"`USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a " +
|
|
733
|
+
"writable HOME — ADR-0037), then re-run `jr2 up`.",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
// The common case, and the one ADR-0037 is written around: the image chose its `USER` and its
|
|
737
|
+
// `HOME`, and jr2 touches neither — the human who execs in lands in the environment the
|
|
738
|
+
// image's author built, dotfiles included.
|
|
739
|
+
if (!fallbackSeat) {
|
|
740
|
+
return { image, env: [], homeVolume: [], homeMount: [], securityContext: HARDENED };
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
image,
|
|
744
|
+
env: [{ name: "HOME", value: FALLBACK_HOME }],
|
|
745
|
+
homeVolume: [{ name: "home", emptyDir: {} }],
|
|
746
|
+
homeMount: [{ name: "home", mountPath: FALLBACK_HOME }],
|
|
747
|
+
securityContext: { ...HARDENED, runAsUser: FALLBACK_UID },
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const crFor = async (
|
|
752
|
+
req: {
|
|
753
|
+
name: string;
|
|
754
|
+
runId: string;
|
|
755
|
+
workflow: string;
|
|
756
|
+
image?: string;
|
|
757
|
+
user?: string;
|
|
758
|
+
workGroup?: number;
|
|
759
|
+
userResources?: Size;
|
|
760
|
+
effector?: string;
|
|
761
|
+
assembly?: string;
|
|
762
|
+
},
|
|
763
|
+
refs: ImageRefs,
|
|
764
|
+
repos: FencedRepo[],
|
|
765
|
+
custodian: CustodianComposition,
|
|
766
|
+
split: SizeSplit,
|
|
767
|
+
effectorManifest?: SandboxEffectorManifest,
|
|
768
|
+
gitCredentials: readonly string[] = [],
|
|
769
|
+
) => {
|
|
770
|
+
const placement = cfg().placement;
|
|
771
|
+
// A run under a held Assembly (ADR-0069): an image this map lacks is that case, not a stale
|
|
772
|
+
// deployment, and the failure says so, naming both hashes.
|
|
773
|
+
const current = opts.currentAssembly?.();
|
|
774
|
+
const held: HeldImageScope | undefined =
|
|
775
|
+
req.assembly !== undefined && req.assembly !== current
|
|
776
|
+
? { runId: req.runId, assembly: req.assembly, current }
|
|
777
|
+
: undefined;
|
|
778
|
+
const seat = await seatFor(refs, req.image, held);
|
|
779
|
+
const keys = repos.map((r) => r.key);
|
|
780
|
+
const effector = effectorManifest ? await effectorFor(req, refs, keys, seat, effectorManifest, held) : undefined;
|
|
781
|
+
const sidecars = await sidecarsFor(custodian, refs, keys, split, held, req.user, effector?.container);
|
|
782
|
+
return {
|
|
783
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
784
|
+
kind: "Sandbox",
|
|
785
|
+
metadata: {
|
|
786
|
+
name: req.name,
|
|
787
|
+
namespace: ns,
|
|
788
|
+
// The run↔workspace link `jr2 ls` groups by (ADR-0009/0012) — readable without the host.
|
|
789
|
+
labels: { "jr2.dev/run": req.runId, "jr2.dev/workflow": req.workflow },
|
|
790
|
+
},
|
|
791
|
+
spec: {
|
|
792
|
+
// The Sandbox Image, unmodified (ADR-0037) — the user's tools, its own USER and HOME, and
|
|
793
|
+
// jr2's runtime arriving beside it on a volume. This container is both the Harness and the
|
|
794
|
+
// human's `exec` shell.
|
|
795
|
+
image: seat.image,
|
|
796
|
+
// The one thing jr2 takes from the image: its command. A container has exactly one, and it
|
|
797
|
+
// must be the Harness's — a pod whose main process is the user's entrypoint keeps
|
|
798
|
+
// "Running" through a Harness death, which makes the operator's Ready probe a lie.
|
|
799
|
+
command: HARNESS_COMMAND,
|
|
800
|
+
// Hardened, and the ONLY place jr2 ever names a uid: ADR-0037's fallback for an image that
|
|
801
|
+
// declared none. Stating the whole context here rather than leaving it to the operator's
|
|
802
|
+
// default is what makes that possible — the operator hardens only what says nothing.
|
|
803
|
+
securityContext: seat.securityContext,
|
|
804
|
+
idleTimeout: opts.idleTimeout ?? "30m",
|
|
805
|
+
// Placement (ADR-0052): the Instance's word on which nodes are Sandbox nodes, raw pod-spec
|
|
806
|
+
// shapes the operator copies verbatim. Absent keys are absent here too — the CR says
|
|
807
|
+
// nothing, and the pod lands wherever an ordinary pod lands. The operator's own soft
|
|
808
|
+
// affinity toward nodes holding this Sandbox's caches sits beside these untouched: a
|
|
809
|
+
// preference never conflicts with a requirement.
|
|
810
|
+
...(placement?.nodeSelector ? { nodeSelector: placement.nodeSelector } : {}),
|
|
811
|
+
...(placement?.tolerations?.length ? { tolerations: placement.tolerations } : {}),
|
|
812
|
+
// Priority (ADR-0060): a pod of ordinary priority cannot preempt a live Workspace, and a
|
|
813
|
+
// waiting Sandbox evicts nobody (the class's preemptionPolicy is Never).
|
|
814
|
+
priorityClassName: cfg().priorityClassName ?? PRIORITY_CLASS_SANDBOX,
|
|
815
|
+
// The Size (ADR-0060). `resources` is the HARNESS container's (the operator's contract):
|
|
816
|
+
// the rest of the Size after the Custodian and the User Container, requests = limits, plus
|
|
817
|
+
// the `/work` disk as an ephemeral-storage request with no limit. `podResources` is the
|
|
818
|
+
// whole Size, pod-level (KEP-2837) — the ceiling the pod never passes.
|
|
819
|
+
resources: split.harness,
|
|
820
|
+
podResources: split.pod,
|
|
821
|
+
// `/dev/shm` (ADR-0060): the operator mounts a memory-backed emptyDir there, in the Harness
|
|
822
|
+
// container only, with this `sizeLimit` — a quarter of the Harness share, minimum 64Mi.
|
|
823
|
+
// Charged inside the Harness container's limit, so it reserves nothing extra; full, it is
|
|
824
|
+
// ENOSPC or SIGBUS — a tool error, never a memory kill.
|
|
825
|
+
shmSize: split.shmSizeLimit,
|
|
826
|
+
// The work group (ADR-0005), the ownership half of cross-uid sharing on `/work`.
|
|
827
|
+
// Kubernetes grants it as a supplemental group to every container, and puts a setgid
|
|
828
|
+
// group on the volume root that propagates down; the WRITABILITY half is the default ACL
|
|
829
|
+
// the Harness's attach stamps on each repo root (ADR-0063), without which fsGroup gives
|
|
830
|
+
// group-READ, which is the trap. Both are inert when the uids match.
|
|
831
|
+
fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
|
|
832
|
+
// Before any container starts: prove the image on the mounted `/opt/jr2`.
|
|
833
|
+
initContainers: initContainersFor(seat, split),
|
|
834
|
+
// Never empty: the Custodian's address is unconditional. The seat's own vars (the fallback
|
|
835
|
+
// `HOME`) come FIRST, so the instance's `harness.env` can still override them the way it
|
|
836
|
+
// overrides anything the image set.
|
|
837
|
+
env: [...seat.env, ...harnessEnv(req.name, custodian, cpuHintEnv())],
|
|
838
|
+
...(cfg().envFrom?.length ? { envFrom: cfg().envFrom } : {}),
|
|
839
|
+
// What the AGENT gets: an address on its own loopback and a Stand-in for every credential,
|
|
840
|
+
// and no credential anywhere. The Custodian beside it holds them (ADR-0013, ADR-0059).
|
|
841
|
+
sidecars,
|
|
842
|
+
// The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
|
|
843
|
+
// `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
|
|
844
|
+
// container at `/repos/<key>` — a soft affinity toward nodes already holding them, and
|
|
845
|
+
// `Ready` only once every one is present on the pod's node and fetched since this CR
|
|
846
|
+
// asked. Read-only is load-bearing twice (ADR-0004): no write contention, and nothing in
|
|
847
|
+
// a Sandbox can `gc` the object store its `--shared` clones borrow from.
|
|
848
|
+
repos: repos.map(({ key, url }) => ({ key, url })),
|
|
849
|
+
// The push Secret of each `git.credentials` entry this Sandbox's pushes spend (ADR-0051), by
|
|
850
|
+
// name, as a Repo CR's `secretRef` names one: the operator projects each into the Sandbox
|
|
851
|
+
// Effector alone, at `/run/jr2/secrets/@git/<name>/`. No value passes through here.
|
|
852
|
+
...(gitCredentials.length ? { gitCredentials: gitCredentials.map((name) => ({ name })) } : {}),
|
|
853
|
+
volumes: [
|
|
854
|
+
// The worktree root is a POD volume, not a directory baked into the image. Two reasons,
|
|
855
|
+
// both load-bearing: every jr2-owned seat runs as an unprivileged uid, which cannot mkdir
|
|
856
|
+
// under `/` — so an image-owned `/work` would make every attach fail — and `/work` is
|
|
857
|
+
// the one thing all three containers share (ADR-0005), so human and Agent see identical
|
|
858
|
+
// files. An emptyDir lands group-writable under the pod's fsGroup, so it is writable
|
|
859
|
+
// whatever uid the Sandbox Image runs as: `/work` unclaimed is the only image contract.
|
|
860
|
+
{ name: "work", emptyDir: {} },
|
|
861
|
+
// jr2's runtime (ADR-0037): the kit's Harness image, mounted.
|
|
862
|
+
runtimeVolume(refs),
|
|
863
|
+
// The conversations' directory (ADR-0031): a Harness container that restarts inside a
|
|
864
|
+
// living pod rebuilds its conversations from here. An emptyDir, so it goes with the pod —
|
|
865
|
+
// that Workspace is `Lost` (ADR-0021) — and mounted below into the Harness container
|
|
866
|
+
// ALONE: the User Container must not read an Agent's history.
|
|
867
|
+
{ name: CONVERSATIONS_VOLUME, emptyDir: {} },
|
|
868
|
+
// Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
|
|
869
|
+
...seat.homeVolume,
|
|
870
|
+
// The Custodian's (its values, leaves and config), and the trust ConfigMap (ADR-0020).
|
|
871
|
+
...custodian.volumes,
|
|
872
|
+
// The Sandbox Effector's (ADR-0068): its secrets, its key and config, its scratch.
|
|
873
|
+
...(effector?.volumes ?? []),
|
|
874
|
+
],
|
|
875
|
+
// CR-level volumeMounts land on the HARNESS container only (the operator's contract): the
|
|
876
|
+
// trust bundles, and none of the Custodian's volumes. The Repo caches are not listed: the
|
|
877
|
+
// operator mounts each `repo-<key>` into this container itself.
|
|
878
|
+
volumeMounts: [
|
|
879
|
+
{ name: "work", mountPath: WORK_ROOT },
|
|
880
|
+
RUNTIME_VOLUME_MOUNT,
|
|
881
|
+
{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR },
|
|
882
|
+
...seat.homeMount,
|
|
883
|
+
...custodian.harnessMounts,
|
|
884
|
+
],
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const conditionOf = (sandbox: SandboxObject | undefined, type: string): Condition | undefined =>
|
|
890
|
+
sandbox?.status?.conditions?.find((c) => c.type === type);
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Mint this Sandbox's token into a Secret, AFTER the CR exists and owned by it from birth
|
|
894
|
+
* (ADR-0001): the owner is the uid the CR's apply answered with, so Kubernetes reaps the Secret
|
|
895
|
+
* whenever the CR goes — the operator's idle-timeout GC, a `kubectl delete sandbox` by hand — and
|
|
896
|
+
* no path (an owner patch that fails, an Orchestrator that dies between two writes) leaves a
|
|
897
|
+
* token behind. The operator creates the pod the moment it sees the CR, so the pod may reach its
|
|
898
|
+
* mount first; kubelet retries the mount, and the Secret lands one API round trip after the CR,
|
|
899
|
+
* well before the scheduler and kubelet get there. Idempotent by construction: the token is the
|
|
900
|
+
* Sandbox's name, signed (tokens.ts), so a re-provision after an Orchestrator restart re-applies
|
|
901
|
+
* the SAME value and owner, and the Custodian that has been holding it all along stays valid.
|
|
902
|
+
* `data`, not `stringData`: a server-side apply owns the fields it names, and `stringData` is
|
|
903
|
+
* write-only — it never reads back as the field applied.
|
|
904
|
+
*/
|
|
905
|
+
const applyTokenSecret = async (name: string, owner: KubeObject): Promise<void> => {
|
|
906
|
+
if (!opts.signingKey) throw new Error("kubeSandbox: a Custodian needs a signingKey to mint its Sandbox token");
|
|
907
|
+
const uid = owner.metadata?.uid;
|
|
908
|
+
if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its token Secret`);
|
|
909
|
+
await kube().apply(SECRETS, ns, {
|
|
910
|
+
apiVersion: "v1",
|
|
911
|
+
kind: "Secret",
|
|
912
|
+
metadata: {
|
|
913
|
+
name: secretName(name),
|
|
914
|
+
namespace: ns,
|
|
915
|
+
labels: { "jr2.dev/sandbox": name },
|
|
916
|
+
ownerReferences: [
|
|
917
|
+
{
|
|
918
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
919
|
+
kind: "Sandbox",
|
|
920
|
+
name,
|
|
921
|
+
uid,
|
|
922
|
+
controller: true,
|
|
923
|
+
blockOwnerDeletion: false,
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
},
|
|
927
|
+
type: "Opaque",
|
|
928
|
+
data: { JR2_SANDBOX_TOKEN: Buffer.from(sandboxToken(opts.signingKey, name)).toString("base64") },
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* This Sandbox's Effector key (ADR-0068): `effectorKey(signing key, <Sandbox name>)`,
|
|
934
|
+
* derived like the Sandbox token, so a re-provision re-applies the same value, and written into a
|
|
935
|
+
* Secret the CR owns, which only the `effector` container mounts.
|
|
936
|
+
*/
|
|
937
|
+
const applyEffectorKey = async (name: string, owner: KubeObject): Promise<void> => {
|
|
938
|
+
if (!opts.signingKey) throw new Error("kubeSandbox: a Sandbox Effector needs a signingKey to derive its key");
|
|
939
|
+
const uid = owner.metadata?.uid;
|
|
940
|
+
if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Effector key`);
|
|
941
|
+
await kube().apply(SECRETS, ns, {
|
|
942
|
+
apiVersion: "v1",
|
|
943
|
+
kind: "Secret",
|
|
944
|
+
metadata: {
|
|
945
|
+
name: sandboxEffectorKeySecret(name),
|
|
946
|
+
namespace: ns,
|
|
947
|
+
labels: { "jr2.dev/sandbox": name },
|
|
948
|
+
ownerReferences: [
|
|
949
|
+
{
|
|
950
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
951
|
+
kind: "Sandbox",
|
|
952
|
+
name,
|
|
953
|
+
uid,
|
|
954
|
+
controller: true,
|
|
955
|
+
blockOwnerDeletion: false,
|
|
956
|
+
},
|
|
957
|
+
],
|
|
958
|
+
},
|
|
959
|
+
type: "Opaque",
|
|
960
|
+
data: { key: Buffer.from(effectorKey(opts.signingKey, name)).toString("base64") },
|
|
961
|
+
});
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* This Sandbox's Effector Service (ADR-0068): headless, so its name resolves to the pod's own
|
|
966
|
+
* address — the one each request is signed for — and publishing an address that is not ready, so
|
|
967
|
+
* a restore asks the Effector for an Effect while the Harness beside it restarts. Owned by the
|
|
968
|
+
* CR, so it goes with the pod. Ingress is `jr2-sandbox-ingress`'s: the Orchestrator's alone.
|
|
969
|
+
*/
|
|
970
|
+
const applyEffectorService = async (name: string, owner: KubeObject): Promise<void> => {
|
|
971
|
+
const uid = owner.metadata?.uid;
|
|
972
|
+
if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Effector Service`);
|
|
973
|
+
await kube().apply(SERVICES, ns, {
|
|
974
|
+
apiVersion: "v1",
|
|
975
|
+
kind: "Service",
|
|
976
|
+
metadata: {
|
|
977
|
+
name: sandboxEffectorService(name),
|
|
978
|
+
namespace: ns,
|
|
979
|
+
labels: { "jr2.dev/sandbox": name },
|
|
980
|
+
ownerReferences: [
|
|
981
|
+
{
|
|
982
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
983
|
+
kind: "Sandbox",
|
|
984
|
+
name,
|
|
985
|
+
uid,
|
|
986
|
+
controller: true,
|
|
987
|
+
blockOwnerDeletion: false,
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
},
|
|
991
|
+
spec: {
|
|
992
|
+
clusterIP: "None",
|
|
993
|
+
publishNotReadyAddresses: true,
|
|
994
|
+
// The operator's label on every Sandbox pod (operator helpers.go `sandboxLabels`).
|
|
995
|
+
selector: { "sandbox.jr2.dev/name": name },
|
|
996
|
+
ports: [{ name: "effector", port: SANDBOX_EFFECTOR_PORT, targetPort: SANDBOX_EFFECTOR_PORT }],
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* This Sandbox's Custodian files — Envoy's bootstrap and the `HELD` table — rendered HERE, from
|
|
1003
|
+
* the pod's manifest, into a ConfigMap owned by the CR like its token (ADR-0059). Who makes the
|
|
1004
|
+
* pod renders its files: `jr2 up` declares what each pod holds and nothing per Workspace lands in
|
|
1005
|
+
* `jr2-held`, so an Instance with many Workspaces never grows one object toward its 1 MiB limit.
|
|
1006
|
+
* Names, hosts, headers — never a value — so a ConfigMap is the right kind. Same owner, same
|
|
1007
|
+
* idempotence and same race as the token: the kubelet retries the mount until this lands.
|
|
1008
|
+
*/
|
|
1009
|
+
const applyCustodianConfig = async (name: string, owner: KubeObject, manifest: HeldManifest): Promise<void> => {
|
|
1010
|
+
const uid = owner.metadata?.uid;
|
|
1011
|
+
if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Custodian ConfigMap`);
|
|
1012
|
+
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${ns}.svc`, port: ORCHESTRATOR_PORT };
|
|
1013
|
+
await kube().apply(CONFIGMAPS, ns, {
|
|
1014
|
+
apiVersion: "v1",
|
|
1015
|
+
kind: "ConfigMap",
|
|
1016
|
+
metadata: {
|
|
1017
|
+
name: sandboxCustodianConfigMap(name),
|
|
1018
|
+
namespace: ns,
|
|
1019
|
+
labels: { "jr2.dev/sandbox": name },
|
|
1020
|
+
ownerReferences: [
|
|
1021
|
+
{
|
|
1022
|
+
apiVersion: "core.jr2.dev/v1alpha1",
|
|
1023
|
+
kind: "Sandbox",
|
|
1024
|
+
name,
|
|
1025
|
+
uid,
|
|
1026
|
+
controller: true,
|
|
1027
|
+
blockOwnerDeletion: false,
|
|
1028
|
+
},
|
|
1029
|
+
],
|
|
1030
|
+
},
|
|
1031
|
+
data: {
|
|
1032
|
+
[CUSTODIAN_BOOTSTRAP_KEY]: JSON.stringify(custodianBootstrap(manifest, orchestrator)),
|
|
1033
|
+
[CUSTODIAN_SCRIPT_KEY]: custodianScript(manifest),
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Every Repo the provision names, resolved to its identity and key — and FENCED (ADR-0051). A
|
|
1040
|
+
* per-run url is the run's input; one no `git.credentials` entry admits is refused here, before
|
|
1041
|
+
* anything is read or applied, naming the list. A bound url is admitted without a match: it is
|
|
1042
|
+
* code the instance typechecked and deployed. Two slots spelling one repository collapse to one
|
|
1043
|
+
* CR entry (first spelling wins) — one cache, however many slots borrow from it — and the
|
|
1044
|
+
* resource is BOUND when any of those slots is the Machine's: a per-run slot alone leaves it on
|
|
1045
|
+
* `jr2 gc`'s clock.
|
|
1046
|
+
*/
|
|
1047
|
+
const fencedRepos = (name: string, repos: ProvisionedRepo[]): FencedRepo[] => {
|
|
1048
|
+
const byKey = new Map<string, FencedRepo>();
|
|
1049
|
+
for (const repo of repos) {
|
|
1050
|
+
const { identity, key } = repoIdentity(repo.url);
|
|
1051
|
+
if (repo.perRun && !matchCredential(identity, cfg().credentials ?? [])) {
|
|
1052
|
+
const entries = (cfg().credentials ?? []).map((c) => c.match).join(", ") || "none";
|
|
1053
|
+
throw new Error(
|
|
1054
|
+
`Sandbox "${name}" refuses the per-run repo ${repo.url} for slot "${repo.slot}": no git.credentials ` +
|
|
1055
|
+
`entry matches "${identity}" (entries: ${entries}). A per-run url can spend the cluster's credential ` +
|
|
1056
|
+
"against any host, so jr2.config.ts must admit it by prefix (ADR-0051).",
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
const seen = byKey.get(key);
|
|
1060
|
+
if (seen === undefined) byKey.set(key, { key, url: repo.url, identity, bound: !repo.perRun });
|
|
1061
|
+
else seen.bound ||= !repo.perRun;
|
|
1062
|
+
}
|
|
1063
|
+
return [...byKey.values()];
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* The push Secrets a Sandbox's pushes spend (ADR-0051): for each pushed Repo Slot, the url
|
|
1068
|
+
* this provision attaches — static, per-run or `customize()`-bound alike — matched to its
|
|
1069
|
+
* `git.credentials` entry (longest prefix, the attach match), and that entry's Secret as `jr2 up`
|
|
1070
|
+
* wrote it (`git-push.json`: names only). Refused with the fix, by the same rule `jr2 up` applies
|
|
1071
|
+
* to a static Binding: no entry, a local path or `git://`, `http://`, an entry whose credentials
|
|
1072
|
+
* the url's scheme cannot spend or that `jr2 up` placed none for, `sign` with no `sign` field, an
|
|
1073
|
+
* unsigned squash on an entry with neither `sign` nor `committer`.
|
|
1074
|
+
*/
|
|
1075
|
+
const gitCredentialsFor = async (
|
|
1076
|
+
name: string,
|
|
1077
|
+
repos: readonly ProvisionedRepo[],
|
|
1078
|
+
pushes: ReadonlyArray<{ slot: string; sign: boolean; squash?: true }>,
|
|
1079
|
+
): Promise<string[]> => {
|
|
1080
|
+
if (pushes.length === 0) return [];
|
|
1081
|
+
const manifest = await readGitPushManifest(join(heldDir, GIT_PUSH_MANIFEST_KEY));
|
|
1082
|
+
const names: string[] = [];
|
|
1083
|
+
for (const { slot, sign, squash } of pushes) {
|
|
1084
|
+
const repo = repos.find((r) => r.slot === slot);
|
|
1085
|
+
if (repo === undefined) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`Sandbox "${name}" cannot be provisioned: its Workspace pushes Repo Slot "${slot}", which this ` +
|
|
1088
|
+
"provision does not attach",
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
const at = `Sandbox "${name}" refuses the push of Repo Slot "${slot}" (${repo.url})`;
|
|
1092
|
+
const out = placedPushRefusal(
|
|
1093
|
+
{ at, url: repo.url, sign, squash: squash === true },
|
|
1094
|
+
cfg().credentials ?? [],
|
|
1095
|
+
manifest,
|
|
1096
|
+
);
|
|
1097
|
+
if ("refusal" in out) throw new Error(out.refusal);
|
|
1098
|
+
if (!names.includes(out.entry.secret)) names.push(out.entry.secret);
|
|
1099
|
+
}
|
|
1100
|
+
return names;
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* What the operator will hold this Sandbox's Ready on, and what an attach reads afterwards
|
|
1105
|
+
* (ADR-0051). `Ready` with reason `RepoCloneFailed` is terminal for the provision — the cache
|
|
1106
|
+
* agent could not clone onto the node the pod landed on, and the reason names it — so the wait
|
|
1107
|
+
* fails on it by name rather than burning the budget. `ReposFresh=False` is the other verdict:
|
|
1108
|
+
* the caches are there but a fetch since this CR asked failed, so the attach proceeds STALE and
|
|
1109
|
+
* says so. Remembered per name until the Sandbox is destroyed, because the attach is a separate
|
|
1110
|
+
* call. The operator takes that verdict once per pod and keeps it, so a provision re-run on
|
|
1111
|
+
* snapshot restore reads the same one — unless the pod was replaced, which is the lease's news.
|
|
1112
|
+
*/
|
|
1113
|
+
const staleByName = new Map<string, string>();
|
|
1114
|
+
|
|
1115
|
+
/** The Harness's `POST /attach` (ADR-0063), re-sent while it demonstrably never reached the
|
|
1116
|
+
* Harness. Idempotent on the Harness's side (every step guarded, calls serialized), so a re-send
|
|
1117
|
+
* after a request that DID land is harmless too — but only a transport failure is retried: an
|
|
1118
|
+
* answer is an answer. */
|
|
1119
|
+
const postAttach = async (name: string, endpoint: string, body: AttachRequest): Promise<AttachResponse> => {
|
|
1120
|
+
if (!opts.signingKey) throw new Error("kubeSandbox: the attach needs a signingKey for the Harness bearer");
|
|
1121
|
+
const url = new URL("/attach", endpoint).toString();
|
|
1122
|
+
const headers = {
|
|
1123
|
+
"content-type": "application/json",
|
|
1124
|
+
authorization: `Bearer ${harnessToken(opts.signingKey, name)}`,
|
|
1125
|
+
};
|
|
1126
|
+
const until = Date.now() + attachWindowMs;
|
|
1127
|
+
let backoff = 250;
|
|
1128
|
+
for (;;) {
|
|
1129
|
+
let res: Response;
|
|
1130
|
+
try {
|
|
1131
|
+
res = await harnessFetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
// Ready is not routable (ADR-0042): the EndpointSlice behind the Service is programmed after
|
|
1134
|
+
// the pod passes its probe, and the attach is now the first thing that dials it.
|
|
1135
|
+
if (Date.now() >= until) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
`Sandbox "${name}": the attach never reached its Harness at ${endpoint}: ${(err as Error).message}`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
await sleep(backoff);
|
|
1141
|
+
backoff = Math.min(backoff * 2, 5_000);
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
const text = await res.text();
|
|
1145
|
+
if (res.ok) return JSON.parse(text) as AttachResponse;
|
|
1146
|
+
// The Harness's own words: the slot, the step, and git's stderr.
|
|
1147
|
+
let error = text;
|
|
1148
|
+
try {
|
|
1149
|
+
error = (JSON.parse(text) as AttachError).error ?? text;
|
|
1150
|
+
} catch {
|
|
1151
|
+
// not an AttachError: the raw body is the most there is
|
|
1152
|
+
}
|
|
1153
|
+
throw new Error(`Sandbox "${name}": the attach failed (${res.status}): ${error}`);
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* A wait, as watch events (ADR-0063): every change the operator publishes on this Sandbox is
|
|
1159
|
+
* judged as it lands. `judge` answers a result, throws a named failure, or answers undefined to
|
|
1160
|
+
* keep waiting. `budget`, when given, is a timer re-read after every judgement, because the Repo
|
|
1161
|
+
* budget replaces the pod's the moment the operator holds the Sandbox on its Repos; without one
|
|
1162
|
+
* the wait has no deadline (ADR-0064's `placing`). `signal` ends it early, rejecting with its
|
|
1163
|
+
* reason.
|
|
1164
|
+
*/
|
|
1165
|
+
const waitFor = <T>(
|
|
1166
|
+
name: string,
|
|
1167
|
+
judge: (sandbox: SandboxObject | undefined) => T | undefined,
|
|
1168
|
+
budget?: () => { deadline: number; expire: (last: SandboxObject | undefined) => Error },
|
|
1169
|
+
signal?: AbortSignal,
|
|
1170
|
+
): Promise<T> =>
|
|
1171
|
+
new Promise<T>((resolve, reject) => {
|
|
1172
|
+
let settled = false;
|
|
1173
|
+
let last: SandboxObject | undefined;
|
|
1174
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1175
|
+
const finish = (fn: () => void) => {
|
|
1176
|
+
if (settled) return;
|
|
1177
|
+
settled = true;
|
|
1178
|
+
clearTimeout(timer);
|
|
1179
|
+
unsubscribe();
|
|
1180
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1181
|
+
fn();
|
|
1182
|
+
};
|
|
1183
|
+
const onAbort = () => finish(() => reject(signal!.reason));
|
|
1184
|
+
const arm = () => {
|
|
1185
|
+
if (!budget) return;
|
|
1186
|
+
clearTimeout(timer);
|
|
1187
|
+
const { deadline, expire } = budget();
|
|
1188
|
+
timer = setTimeout(() => finish(() => reject(expire(last))), Math.max(0, deadline - Date.now()));
|
|
1189
|
+
};
|
|
1190
|
+
const unsubscribe = sandboxes().subscribe(name, (sandbox) => {
|
|
1191
|
+
last = sandbox;
|
|
1192
|
+
try {
|
|
1193
|
+
const out = judge(sandbox);
|
|
1194
|
+
if (out !== undefined) return finish(() => resolve(out));
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
return finish(() => reject(err));
|
|
1197
|
+
}
|
|
1198
|
+
arm();
|
|
1199
|
+
});
|
|
1200
|
+
arm();
|
|
1201
|
+
if (signal?.aborted) onAbort();
|
|
1202
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* A Sandbox the operator made Lost, or one the watch saw and then lost, fails the wait at once and
|
|
1207
|
+
* by name (ADR-0021): it never comes back. `seen` is the caller's: a wait that began with the
|
|
1208
|
+
* write may hear nothing before the watch lists the new CR, and that is not a deletion.
|
|
1209
|
+
*/
|
|
1210
|
+
const judgeLoss = (name: string, sandbox: SandboxObject | undefined, seen: boolean, doing: string): void => {
|
|
1211
|
+
if (sandbox === undefined) {
|
|
1212
|
+
if (seen) throw new Error(`Sandbox "${name}" was deleted while it ${doing} (ADR-0021)`);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const lost = lossOf(sandbox);
|
|
1216
|
+
if (lost) {
|
|
1217
|
+
throw new Error(
|
|
1218
|
+
`Sandbox "${name}" is Lost while it ${doing}: ${lost.reason}: ${lost.message} — a Sandbox has one pod ` +
|
|
1219
|
+
"for its life and the operator never gives it another (ADR-0021)",
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
return {
|
|
1225
|
+
async place(req, { onWait, signal }) {
|
|
1226
|
+
// The fence first: a refused url costs nothing — no map read, no Secret, no CR.
|
|
1227
|
+
const repos = fencedRepos(req.name, req.repos);
|
|
1228
|
+
// The Size next (ADR-0060), for the same price: the Machine's, then the Instance default, then
|
|
1229
|
+
// the kit's, split inside the pod — or refused before anything exists. `jr2 up` makes the
|
|
1230
|
+
// same refusal at the converge; this one covers a Machine it never walked.
|
|
1231
|
+
let split: SizeSplit;
|
|
1232
|
+
try {
|
|
1233
|
+
split = splitSize(resolveSize(req.resources, cfg().defaultSize), req.userResources);
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
throw new Error(`Sandbox "${req.name}" cannot be provisioned: ${(err as Error).message}`);
|
|
1236
|
+
}
|
|
1237
|
+
if (opts.repos === undefined) {
|
|
1238
|
+
throw new Error(
|
|
1239
|
+
`Sandbox "${req.name}" cannot be provisioned: this port has no Repo-resource port (ADR-0051). The ` +
|
|
1240
|
+
"operator holds a Sandbox's Ready until every Repo it names exists as a resource, and creating " +
|
|
1241
|
+
"them is this provision's job — build the port with `repos: kubeRepos(...)`.",
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
// Read PER PROVISION, and next (ADR-0038). Not hoisted into `kubeSandbox()`: a boot-time
|
|
1245
|
+
// read would freeze the map for the process lifetime, which is precisely the Deployment-env
|
|
1246
|
+
// behavior the ConfigMap mount was chosen over — the point of the mount is that a `jr2 up`
|
|
1247
|
+
// reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
|
|
1248
|
+
// also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
|
|
1249
|
+
const refs = await readImageRefs(imagesPath);
|
|
1250
|
+
// The same stance for what the Custodian holds (ADR-0059): `jr2 up` placed it host-side,
|
|
1251
|
+
// where `.env` exists, per Workspace — the one its workflow and its route name — and this
|
|
1252
|
+
// process reads that pod's manifest, never its own config, whose `.env` values are absent
|
|
1253
|
+
// in-cluster. A Workspace `jr2 up` never walked has none, and is refused by name.
|
|
1254
|
+
const pod = workspaceHeldPod({ workflow: req.workflow, route: req.route });
|
|
1255
|
+
const manifest = await readHeldManifest(join(heldDir, heldManifestKey(pod)));
|
|
1256
|
+
const custodian = custodianComposition(manifest, {
|
|
1257
|
+
image: refs.custodian,
|
|
1258
|
+
pod,
|
|
1259
|
+
config: sandboxCustodianConfigMap(req.name),
|
|
1260
|
+
token: { secret: secretName(req.name), key: "JR2_SANDBOX_TOKEN" },
|
|
1261
|
+
sandbox: req.name,
|
|
1262
|
+
caBundle: cfg().caBundle === true,
|
|
1263
|
+
resources: split.custodian,
|
|
1264
|
+
});
|
|
1265
|
+
// The Sandbox Effector (ADR-0068): present when `jr2 up` placed an Effect of this Workspace
|
|
1266
|
+
// there, and declared it in the pod's manifest beside the Held one; absent otherwise.
|
|
1267
|
+
const effectorManifest = await readSandboxEffectorManifest(join(heldDir, effectorManifestKey(pod)));
|
|
1268
|
+
// Its pushes' credentials (ADR-0051): each pushed slot's url, as this run binds it, is
|
|
1269
|
+
// matched against the fence, and the entry's push Secret is named on the CR — or the push is
|
|
1270
|
+
// refused here, before anything is written, as a per-run url outside the fence is.
|
|
1271
|
+
const gitCredentials = await gitCredentialsFor(req.name, req.repos, effectorManifest?.pushes ?? []);
|
|
1272
|
+
const cr = await crFor(req, refs, repos, custodian, split, effectorManifest, gitCredentials);
|
|
1273
|
+
|
|
1274
|
+
// The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
|
|
1275
|
+
// the boot and only its eviction clock moves — this run's spelling never rewrites the spec
|
|
1276
|
+
// the boot stated; a per-run one is created here, at its first attach, and every later
|
|
1277
|
+
// attach anywhere finds it and restates its credential, so the `git.credentials` fix
|
|
1278
|
+
// `repoCloneError` names reaches the cache at the next run. After the image resolution, so
|
|
1279
|
+
// a refused image still costs nothing; before the CR and its token Secret, so neither exists
|
|
1280
|
+
// for a Sandbox whose Repo could not be recorded.
|
|
1281
|
+
for (const repo of repos) await opts.repos.ensure(repo).catch(noOperator(req.name, "Repo"));
|
|
1282
|
+
|
|
1283
|
+
// The watch is started before the write (on first use), and it is level-based: whatever it
|
|
1284
|
+
// holds when the wait below subscribes is the first thing judged, so no event about this
|
|
1285
|
+
// Sandbox can fall between the write and the wait. Not awaited: an API server that cannot
|
|
1286
|
+
// be reached fails the apply below at once, rather than hanging on a list.
|
|
1287
|
+
sandboxes();
|
|
1288
|
+
const applied = await kube().apply(SANDBOXES, ns, cr).catch(noOperator(req.name, "Sandbox"));
|
|
1289
|
+
// After the CR: its uid owns both, so they go when it goes.
|
|
1290
|
+
await applyTokenSecret(req.name, applied);
|
|
1291
|
+
await applyCustodianConfig(req.name, applied, manifest);
|
|
1292
|
+
if (effectorManifest) {
|
|
1293
|
+
await applyEffectorKey(req.name, applied);
|
|
1294
|
+
await applyEffectorService(req.name, applied);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// The wait for a node, with no deadline (ADR-0064): jr2 cannot tell "full now" from "never
|
|
1298
|
+
// fits", so a bound is the Machine's `after`. Its reason — the scheduler's, or a quota's
|
|
1299
|
+
// refusal of the pod create, which the operator keeps retrying — is said each time it changes.
|
|
1300
|
+
let seen = false;
|
|
1301
|
+
let said: PlacingWait | undefined;
|
|
1302
|
+
await waitFor<true>(
|
|
1303
|
+
req.name,
|
|
1304
|
+
(sandbox) => {
|
|
1305
|
+
judgeLoss(req.name, sandbox, seen, "waited for a node");
|
|
1306
|
+
if (sandbox === undefined) return undefined;
|
|
1307
|
+
seen = true;
|
|
1308
|
+
// Scheduled, or an operator too old to say so that already reports Ready.
|
|
1309
|
+
if (conditionOf(sandbox, "Scheduled")?.status === "True" || sandbox.status?.phase === "Ready") return true;
|
|
1310
|
+
const wait = placingWaitOf(sandbox);
|
|
1311
|
+
if (wait && (wait.on !== said?.on || wait.message !== said.message)) {
|
|
1312
|
+
said = wait;
|
|
1313
|
+
onWait(wait);
|
|
1314
|
+
}
|
|
1315
|
+
return undefined;
|
|
1316
|
+
},
|
|
1317
|
+
undefined,
|
|
1318
|
+
signal,
|
|
1319
|
+
);
|
|
1320
|
+
},
|
|
1321
|
+
|
|
1322
|
+
async provision(name) {
|
|
1323
|
+
// Two budgets from one instant — NOW, the pod scheduled (ADR-0064), so neither is spent on a
|
|
1324
|
+
// wait for capacity: the pod's until the operator has seen the pod Ready, the Repos' from the
|
|
1325
|
+
// first event that finds the Sandbox held on a Repo reason — which the operator reports only
|
|
1326
|
+
// once the pod IS Ready, so the preflight has already passed and what remains is a clone or a
|
|
1327
|
+
// fetch on the node. Sticky: a pod that came up once is not a pod that will never come up,
|
|
1328
|
+
// whatever it does afterwards.
|
|
1329
|
+
const at = Date.now();
|
|
1330
|
+
let held = false;
|
|
1331
|
+
return waitFor(
|
|
1332
|
+
name,
|
|
1333
|
+
(sandbox) => {
|
|
1334
|
+
// The pod was placed, so the watch has held this Sandbox: absent now is deleted.
|
|
1335
|
+
judgeLoss(name, sandbox, true, "provisioned");
|
|
1336
|
+
const status = sandbox?.status;
|
|
1337
|
+
const ready = conditionOf(sandbox, "Ready");
|
|
1338
|
+
if (status?.phase !== "Ready") {
|
|
1339
|
+
// A container the kubelet will never start (ADR-0063): no log, no termination — its
|
|
1340
|
+
// waiting reason, published by the operator, is the only evidence. Named at once.
|
|
1341
|
+
const fault = waitingFault(name, status?.waiting);
|
|
1342
|
+
if (fault) throw new Error(fault);
|
|
1343
|
+
}
|
|
1344
|
+
// Terminal for THIS provision: the cache agent tried to clone onto the pod's node and git
|
|
1345
|
+
// refused. The operator's message carries the key, the node, and git's own words; the
|
|
1346
|
+
// agent keeps retrying on its own, so `jr2 status` will show the same error until it is fixed.
|
|
1347
|
+
if (status?.phase !== "Ready" && ready?.reason === REPO_CLONE_FAILED) {
|
|
1348
|
+
throw new Error(repoCloneError(name, ready.message ?? ready.reason));
|
|
1349
|
+
}
|
|
1350
|
+
// A Harness that keeps dying before it is ever Ready (ADR-0063): the kubelet's reason and
|
|
1351
|
+
// exit code, published by the operator, instead of the rest of the budget.
|
|
1352
|
+
const harness = status?.harness;
|
|
1353
|
+
if (status?.phase !== "Ready" && (harness?.restartCount ?? 0) >= CRASH_LOOP_RESTARTS) {
|
|
1354
|
+
throw new Error(crashLoopError(name, harness!));
|
|
1355
|
+
}
|
|
1356
|
+
// A Sandbox Effector that ended before the pod was ever Ready (ADR-0068): it ends only
|
|
1357
|
+
// for a start it cannot serve — a node whose kernel cannot confine a credentialed
|
|
1358
|
+
// command's child, a mount it cannot read — and says so in its termination message,
|
|
1359
|
+
// which no retry mends. Named at once, before any Turn.
|
|
1360
|
+
const effector = status?.effector?.lastTerminated;
|
|
1361
|
+
if (status?.phase !== "Ready" && effector !== undefined) throw new Error(effectorStartError(name, effector));
|
|
1362
|
+
if (ready?.reason !== undefined && REPO_HELD.has(ready.reason)) held = true;
|
|
1363
|
+
if (status?.phase !== "Ready") return undefined;
|
|
1364
|
+
// Only `phase: Ready` means serving — status.endpoint appears earlier (ADR-0001).
|
|
1365
|
+
if (!status.endpoint) throw new Error(`Sandbox "${name}" is Ready but reports no endpoint`);
|
|
1366
|
+
// Freshness degrades, absence does not (ADR-0051): Ready with `ReposFresh=False` is a
|
|
1367
|
+
// Sandbox whose caches exist but could not be fetched since it asked. Remembered for the
|
|
1368
|
+
// attach, which is where a slot can be named; forgotten when the caches are fresh.
|
|
1369
|
+
const fresh = conditionOf(sandbox, "ReposFresh");
|
|
1370
|
+
if (fresh?.status === "False" && fresh.message) staleByName.set(name, fresh.message);
|
|
1371
|
+
else staleByName.delete(name);
|
|
1372
|
+
return { endpoint: status.endpoint };
|
|
1373
|
+
},
|
|
1374
|
+
() => ({
|
|
1375
|
+
deadline: at + (held ? repoTimeoutMs : readyTimeoutMs),
|
|
1376
|
+
// A Sandbox the operator held on its Repos ran out the Repo budget: the pod is up and the
|
|
1377
|
+
// preflight passed, so the hint about the image would be a lie. What is true is the
|
|
1378
|
+
// operator's own verdict — which Repo, on which node — and that the agent is still at it.
|
|
1379
|
+
expire: (last) =>
|
|
1380
|
+
new Error(
|
|
1381
|
+
held ? repoWaitError(name, repoTimeoutMs, conditionOf(last, "Ready")) : notReadyError(name, last),
|
|
1382
|
+
),
|
|
1383
|
+
}),
|
|
1384
|
+
);
|
|
1385
|
+
},
|
|
1386
|
+
|
|
1387
|
+
async attach(req) {
|
|
1388
|
+
// The Harness's own route (ADR-0063): the same bearer as every Harness call (ADR-0058), and
|
|
1389
|
+
// the Repo's identity and cache key resolved HERE, so the cache the Harness clones from is
|
|
1390
|
+
// the one the operator mounted, by construction (ADR-0051). The address is the Sandbox's
|
|
1391
|
+
// own, off the watch — the one provision just waited on, or a restore's first list — or the
|
|
1392
|
+
// API server's, for one the watch has not listed yet.
|
|
1393
|
+
const sandbox = sandboxes().get(req.name) ?? (await kube().get<SandboxObject>(SANDBOXES, ns, req.name));
|
|
1394
|
+
const endpoint = sandbox?.status?.endpoint;
|
|
1395
|
+
if (!endpoint) throw new Error(`Sandbox "${req.name}" has no endpoint to attach through — is it gone?`);
|
|
1396
|
+
if (req.repos.length === 0) {
|
|
1397
|
+
throw new Error("the attach names no Repo Slot — nothing to attach (a workspace() declares at least one)");
|
|
1398
|
+
}
|
|
1399
|
+
const body: AttachRequest = {
|
|
1400
|
+
slots: req.repos.map((r) => {
|
|
1401
|
+
const { identity, key } = repoIdentity(r.url);
|
|
1402
|
+
return { slot: r.slot, url: r.url, identity, key, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
|
|
1403
|
+
}),
|
|
1404
|
+
branch: req.spec.branch,
|
|
1405
|
+
...(req.spec.reviewSha ? { reviewSha: req.spec.reviewSha } : {}),
|
|
1406
|
+
};
|
|
1407
|
+
const { repos, review } = await postAttach(req.name, endpoint, body);
|
|
1408
|
+
const stale = staleSlots(req.repos, staleByName.get(req.name));
|
|
1409
|
+
return { repos, ...(review ? { review } : {}), ...(stale ? { stale } : {}) };
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
leaseIntervalMs,
|
|
1413
|
+
|
|
1414
|
+
async renew(name) {
|
|
1415
|
+
// A write, and only that (ADR-0021): the stamp the operator's idle GC reads. What the
|
|
1416
|
+
// workspace IS comes from the watch, never from this answer.
|
|
1417
|
+
await kube().patch(SANDBOXES, ns, name, {
|
|
1418
|
+
metadata: { annotations: { "jr2.dev/keepalive": new Date().toISOString() } },
|
|
1419
|
+
});
|
|
1420
|
+
},
|
|
1421
|
+
|
|
1422
|
+
continuity(name, listener) {
|
|
1423
|
+
// The watch's word on this name (ADR-0063): the first list, then every event. A dropped
|
|
1424
|
+
// watch says nothing, so this says nothing either — unknown is never loss.
|
|
1425
|
+
return sandboxes().subscribe(name, (sandbox) => listener(continuityOf(sandbox)));
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1428
|
+
harnessRestarts(name, listener) {
|
|
1429
|
+
// The same watch (ADR-0021): the operator publishes the Harness container's restarts and
|
|
1430
|
+
// its last end on the Sandbox's status. A name the watch does not hold says nothing — that is
|
|
1431
|
+
// Continuity's news, or the Instance Harness, which is not a Sandbox.
|
|
1432
|
+
return sandboxes().subscribe(name, (sandbox) => {
|
|
1433
|
+
const harness = sandbox?.status?.harness;
|
|
1434
|
+
if (sandbox === undefined) return;
|
|
1435
|
+
listener({
|
|
1436
|
+
restartCount: harness?.restartCount ?? 0,
|
|
1437
|
+
...(harness?.lastTerminated ? { lastTerminated: harness.lastTerminated } : {}),
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
},
|
|
1441
|
+
|
|
1442
|
+
async memoryFault(name, since) {
|
|
1443
|
+
// Only a Sandbox the watch knows: the Instance Harness is a StatefulSet, not a Sandbox, and
|
|
1444
|
+
// waiting on a name that will never appear would only delay its fault (ADR-0031).
|
|
1445
|
+
if (!sandboxes().get(name)) return undefined;
|
|
1446
|
+
const judge = (sandbox: SandboxObject | undefined) => memoryFaultOf(sandbox, since);
|
|
1447
|
+
const now = judge(sandboxes().get(name));
|
|
1448
|
+
if (now !== undefined) return now;
|
|
1449
|
+
// The operator's word can trail the Harness's restart; give it a moment, then call it lost.
|
|
1450
|
+
return waitFor<MemoryKill>(name, judge, () => ({
|
|
1451
|
+
deadline: Date.now() + MEMORY_FAULT_GRACE_MS,
|
|
1452
|
+
expire: () => new Error("no memory kill"),
|
|
1453
|
+
})).catch(() => undefined);
|
|
1454
|
+
},
|
|
1455
|
+
|
|
1456
|
+
async destroy(name) {
|
|
1457
|
+
staleByName.delete(name);
|
|
1458
|
+
// The token Secret is the CR's child from birth, so deleting the CR reaps it (ADR-0001).
|
|
1459
|
+
await kube().delete(SANDBOXES, ns, name);
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** A Sandbox as Continuity (ADR-0021): gone, or present — and Lost, when the operator says so. */
|
|
1465
|
+
function continuityOf(sandbox: SandboxObject | undefined): Continuity {
|
|
1466
|
+
if (!sandbox) return { present: false };
|
|
1467
|
+
const lost = lossOf(sandbox);
|
|
1468
|
+
return lost ? { present: true, lost } : { present: true };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* The operator's verdict that this Sandbox's one pod is gone or terminal (ADR-0021): phase `Lost`,
|
|
1473
|
+
* with the `Lost` condition's reason and the pod's own words. Terminal — the operator never gives
|
|
1474
|
+
* the Sandbox another pod, so nothing here waits for one.
|
|
1475
|
+
*/
|
|
1476
|
+
export function lossOf(sandbox: SandboxObject | undefined): SandboxLoss | undefined {
|
|
1477
|
+
if (sandbox?.status?.phase !== "Lost") return undefined;
|
|
1478
|
+
const condition = sandbox.status.conditions?.find((c) => c.type === "Lost");
|
|
1479
|
+
return {
|
|
1480
|
+
reason: condition?.reason || "Lost",
|
|
1481
|
+
message: condition?.message || "the operator reports its pod gone or ended",
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Why a Sandbox with no node waits (ADR-0064), off the operator's `Scheduled` condition: `False`
|
|
1487
|
+
* with `QuotaExceeded` is a ResourceQuota refusing the pod create, any other `False` the scheduler
|
|
1488
|
+
* finding no node. `Unknown` (the scheduler has not looked yet) is no reason at all: most pods are
|
|
1489
|
+
* placed in milliseconds, and a wait worth a feed line is one the cluster has explained.
|
|
1490
|
+
*/
|
|
1491
|
+
export function placingWaitOf(sandbox: SandboxObject | undefined): PlacingWait | undefined {
|
|
1492
|
+
const scheduled = sandbox?.status?.conditions?.find((c) => c.type === "Scheduled");
|
|
1493
|
+
if (scheduled?.status !== "False") return undefined;
|
|
1494
|
+
return {
|
|
1495
|
+
on: scheduled.reason === QUOTA_EXCEEDED ? "quota" : "node",
|
|
1496
|
+
message: scheduled.message || scheduled.reason || "no reason given",
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** The operator's `Scheduled` reason while a ResourceQuota refuses the pod create (ADR-0064). */
|
|
1501
|
+
const QUOTA_EXCEEDED = "QuotaExceeded";
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* The memory kill (ADR-0061), or undefined: the Harness container's last run ended
|
|
1505
|
+
* `OOMKilled`, at or after `since` (less the skew a node's clock may carry). The prefix is FIXED —
|
|
1506
|
+
* `memory limit` — so a Machine, `jr2 status` and the feed can tell a memory kill from a Turn lost
|
|
1507
|
+
* to any other restart (ADR-0021) and from a conversation lost (a 404); the limit named is the
|
|
1508
|
+
* Harness container's own, the one the kernel enforced. `limit` and `at` ride beside the reason as data: the next Turn's notice is made
|
|
1509
|
+
* of them (ADR-0062), and `at` tells two Turns ended by one kill that it was one.
|
|
1510
|
+
*/
|
|
1511
|
+
export function memoryFaultOf(sandbox: SandboxObject | undefined, since: Date): MemoryKill | undefined {
|
|
1512
|
+
const last = sandbox?.status?.harness?.lastTerminated;
|
|
1513
|
+
if (last?.reason !== OOM_KILLED) return undefined;
|
|
1514
|
+
const finished = last.finishedAt === undefined ? NaN : Date.parse(last.finishedAt);
|
|
1515
|
+
if (Number.isNaN(finished) || finished < since.getTime() - MEMORY_FAULT_SKEW_MS) return undefined;
|
|
1516
|
+
const limit = sandbox?.spec?.resources?.limits?.memory;
|
|
1517
|
+
const reason =
|
|
1518
|
+
`memory limit (${OOM_KILLED}${limit ? `, limit ${limit}` : ""}): the Workspace's processes passed the ` +
|
|
1519
|
+
"Harness container's memory limit and the kernel killed the container, the conversation with it. " +
|
|
1520
|
+
"Use fewer workers, or give the Workspace a larger Size (ADR-0060, ADR-0061).";
|
|
1521
|
+
return { reason, ...(limit ? { limit } : {}), at: last.finishedAt! };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** The pod volume the operator defines for one Repo's node cache, and where it lands in the
|
|
1525
|
+
* primary container (ADR-0051). Two halves of one contract with the operator, spelled here so the
|
|
1526
|
+
* User Container's mounts and the attach's clone source agree with it by construction. */
|
|
1527
|
+
export const repoVolumeName = (key: string): string => `repo-${key}`;
|
|
1528
|
+
export const repoMountPath = (key: string): string => `${REPOS_MOUNT}/${key}`;
|
|
1529
|
+
|
|
1530
|
+
/** One Repo as the provision names it: the CR entry, plus what its resource records — the
|
|
1531
|
+
* identity, and whether a Machine's slot (not only the run's) binds it. */
|
|
1532
|
+
type FencedRepo = { key: string; url: string; identity: string; bound: boolean };
|
|
1533
|
+
|
|
1534
|
+
/** The operator's Ready reason when the cache agent could not clone onto the pod's node
|
|
1535
|
+
* (sandbox_controller.go) — the one Ready verdict a provision cannot wait out. */
|
|
1536
|
+
const REPO_CLONE_FAILED = "RepoCloneFailed";
|
|
1537
|
+
|
|
1538
|
+
/** The operator's Ready reasons that hold a Sandbox whose POD is Ready on its Repos
|
|
1539
|
+
* (sandbox_controller.go, `reposReadiness`): the resource not yet seen, or the node's cache
|
|
1540
|
+
* agent still cloning or fetching. The wait against them is the Repo budget, not the pod's. */
|
|
1541
|
+
const REPO_HELD: ReadonlySet<string> = new Set(["RepoMissing", "RepoPending"]);
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* The Repo budget ran out with the operator still holding the Sandbox. The pod is up, so the
|
|
1545
|
+
* preflight is not the question; the verdict names the Repo and the node, and the port adds
|
|
1546
|
+
* what the operator cannot say: the agent is still working, `jr2 status` shows it per node, and
|
|
1547
|
+
* the budget is the port's, not the clone's.
|
|
1548
|
+
*/
|
|
1549
|
+
function repoWaitError(name: string, budgetMs: number, ready: Condition | undefined): string {
|
|
1550
|
+
const verdict = ready?.message ? `${ready.reason ?? "?"}: ${ready.message}` : "no Ready condition reported";
|
|
1551
|
+
return (
|
|
1552
|
+
`Sandbox "${name}" waited ${Math.round(budgetMs / 60_000)}m for its Repos and the operator still holds it — ` +
|
|
1553
|
+
`${verdict} (ADR-0051). The node's cache agent clones a cold node once and fetches before every attach; ` +
|
|
1554
|
+
"`jr2 status` reports each Repo per node. Start the run again once the cache is present, or raise " +
|
|
1555
|
+
"the port's `repoTimeoutMs` for a repository whose clone outlasts it."
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* The fix beside the symptom. The operator's message carries the Repo, the node, and git's own
|
|
1561
|
+
* words; what it cannot say is that the cache agent keeps retrying, that `jr2 status` reports the
|
|
1562
|
+
* same line per node, or where a credential is configured (ADR-0047/0051).
|
|
1563
|
+
*/
|
|
1564
|
+
function repoCloneError(name: string, verdict: string): string {
|
|
1565
|
+
return (
|
|
1566
|
+
`Sandbox "${name}" cannot start: ${verdict} (ADR-0051). The node's cache agent keeps retrying on its own — ` +
|
|
1567
|
+
"fix the url or its git.credentials entry (an ssh url needs its deploy key registered with the host, " +
|
|
1568
|
+
"ADR-0047), then start the run again; `jr2 status` reports the same error per node until it clears."
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* A Harness that never came up (ADR-0063): the kubelet's reason and exit code for its last run,
|
|
1574
|
+
* as the operator published them, and where the rest is — the container's own log. A preflight
|
|
1575
|
+
* that passes and a Harness that still dies is a Sandbox Image the floor did not fully prove, or
|
|
1576
|
+
* a Harness that ran out of memory on the way up (ADR-0061).
|
|
1577
|
+
*/
|
|
1578
|
+
function crashLoopError(name: string, harness: NonNullable<NonNullable<SandboxObject["status"]>["harness"]>): string {
|
|
1579
|
+
const last = harness.lastTerminated;
|
|
1580
|
+
const how = last ? `${last.reason ?? "?"}, exit ${last.exitCode ?? "?"}` : "no reason reported";
|
|
1581
|
+
// The Harness's own last words (ADR-0063): its termination message falls back to its last log
|
|
1582
|
+
// lines (`FallbackToLogsOnError`, set by the operator), so the fault quotes them rather than
|
|
1583
|
+
// sending the reader to a log a gone pod no longer has. Quoted, never interpreted.
|
|
1584
|
+
const words = last?.message?.trim();
|
|
1585
|
+
return (
|
|
1586
|
+
`Sandbox "${name}" cannot start: its Harness has ended ${harness.restartCount} times before it was ever ` +
|
|
1587
|
+
`Ready (last: ${how}).` +
|
|
1588
|
+
(words
|
|
1589
|
+
? ` Its last output:\n${words
|
|
1590
|
+
.split("\n")
|
|
1591
|
+
.map((line) => ` ${line}`)
|
|
1592
|
+
.join("\n")}\n`
|
|
1593
|
+
: " ") +
|
|
1594
|
+
`See its output: \`kubectl logs ${name} -c harness --previous\`` +
|
|
1595
|
+
(last?.reason === OOM_KILLED
|
|
1596
|
+
? " — it passed its memory limit, so give the Workspace a larger Size (ADR-0060)."
|
|
1597
|
+
: ".")
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** A Sandbox Effector that could not start, in its own words (its termination message). */
|
|
1602
|
+
function effectorStartError(
|
|
1603
|
+
name: string,
|
|
1604
|
+
last: NonNullable<NonNullable<NonNullable<SandboxObject["status"]>["effector"]>["lastTerminated"]>,
|
|
1605
|
+
): string {
|
|
1606
|
+
const words = last.message?.trim();
|
|
1607
|
+
return (
|
|
1608
|
+
`Sandbox "${name}" cannot start: its Sandbox Effector ended before the pod was Ready ` +
|
|
1609
|
+
`(${last.reason ?? "?"}, exit ${last.exitCode ?? "?"})` +
|
|
1610
|
+
(words ? `: ${words}` : "") +
|
|
1611
|
+
` — see \`kubectl logs ${name} -c effector --previous\` (ADR-0068)`
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* The provision's verdict on a waiting container, or undefined to keep waiting (ADR-0063). Init
|
|
1617
|
+
* containers come first in the list, and they run first: the `preflight` step runs the Sandbox
|
|
1618
|
+
* Image before the Harness container exists, so that is where a root image dies.
|
|
1619
|
+
*/
|
|
1620
|
+
function waitingFault(name: string, waiting: ContainerWaiting[] | undefined): string | undefined {
|
|
1621
|
+
for (const w of waiting ?? []) {
|
|
1622
|
+
if (w.reason === ROOT_IMAGE_REASON && w.message && ROOT_IMAGE_MESSAGE.test(w.message)) {
|
|
1623
|
+
return rootImageError(name, `container "${w.container}": ${w.message}`);
|
|
1624
|
+
}
|
|
1625
|
+
if (w.reason !== undefined && TERMINAL_WAITING.has(w.reason)) {
|
|
1626
|
+
return `Sandbox "${name}" cannot start: container "${w.container}" ${w.reason}${w.message ? `: ${w.message}` : ""}`;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return undefined;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* The fix, not the symptom. The kubelet's message says what it refused; it cannot say that the
|
|
1634
|
+
* image is a Sandbox Image, that jr2 declined to patch a uid onto it, or where the one-line edit
|
|
1635
|
+
* goes. Only a brought ref reaches here: a built image's USER was judged at converge, and one that
|
|
1636
|
+
* declares none gets the uid fallback.
|
|
1637
|
+
*/
|
|
1638
|
+
function rootImageError(name: string, fault: string): string {
|
|
1639
|
+
return (
|
|
1640
|
+
`Sandbox "${name}" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened ` +
|
|
1641
|
+
`with runAsNonRoot (ADR-0005). The kubelet refused it — ${fault}\n` +
|
|
1642
|
+
` - the fix is one line in the image: a NUMERIC non-root \`USER <uid>\` (e.g. \`USER 1000\`)\n` +
|
|
1643
|
+
` - numeric because the kubelet does not read the image's /etc/passwd, so \`USER app\` is ` +
|
|
1644
|
+
`refused too — it cannot prove that name is non-root\n` +
|
|
1645
|
+
` - jr2 does not supply a uid for a brought registry ref: it is never inspected and never ` +
|
|
1646
|
+
`modified, which is what "bring your own image" means (ADR-0037). Only an image jr2 BUILDS, ` +
|
|
1647
|
+
`and only one that declares no USER at all, gets the uid-${FALLBACK_UID} fallback.`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* The pod budget ran out. The most likely cause is an image that misses ADR-0037's floor, and that
|
|
1653
|
+
* failure is an INIT container's — invisible in the phase alone. A musl or git-less base dies
|
|
1654
|
+
* INSIDE the preflight, on jr2's own message; a root image never starts it. The budget starts once
|
|
1655
|
+
* the pod is scheduled (ADR-0064), so the scheduler is never the answer here.
|
|
1656
|
+
*/
|
|
1657
|
+
function notReadyError(name: string, last: SandboxObject | undefined): string {
|
|
1658
|
+
const said = (c: Condition | undefined) => (c?.message ? `${c.reason ?? "?"}: ${c.message}` : undefined);
|
|
1659
|
+
const ready = said(last?.status?.conditions?.find((c) => c.type === "Ready"));
|
|
1660
|
+
// Routine waits (the init step still running) say nothing; anything else is what the kubelet
|
|
1661
|
+
// was stuck on — a failing pull, a config error.
|
|
1662
|
+
const stuck = (last?.status?.waiting ?? [])
|
|
1663
|
+
.filter((w) => w.reason && w.reason !== "PodInitializing" && w.reason !== "ContainerCreating")
|
|
1664
|
+
.map((w) => `\n container "${w.container}" is waiting: ${w.reason}${w.message ? `: ${w.message}` : ""}`)
|
|
1665
|
+
.join("");
|
|
1666
|
+
return (
|
|
1667
|
+
`Sandbox "${name}" never reached Ready (last phase: ${last?.status?.phase ?? "absent"}) — if its ` +
|
|
1668
|
+
"Sandbox Image is new, check the preflight: `kubectl logs " +
|
|
1669
|
+
name +
|
|
1670
|
+
" -c preflight` (ADR-0037's floor: glibc, git, a writable HOME, a numeric non-root USER — an image " +
|
|
1671
|
+
"that runs as root never starts it, and `kubectl describe pod` says so)." +
|
|
1672
|
+
stuck +
|
|
1673
|
+
(ready ? `\n the operator's Ready condition says: ${ready}` : "")
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* The `ReposFresh=False` message, keyed back to SLOTS for the attach's `stale`. The operator
|
|
1679
|
+
* writes one clause per stale Repo — `Repo "<key>" on node <n> is stale: <error>` — joined by
|
|
1680
|
+
* `; `; each slot whose key a clause names gets that clause. A message that names no key at all
|
|
1681
|
+
* lands on every slot: a verdict with no address is still a verdict.
|
|
1682
|
+
*/
|
|
1683
|
+
function staleSlots(
|
|
1684
|
+
repos: Array<{ slot: string; url: string }>,
|
|
1685
|
+
message: string | undefined,
|
|
1686
|
+
): Record<string, string> | undefined {
|
|
1687
|
+
if (!message) return undefined;
|
|
1688
|
+
const byKey = new Map<string, string>();
|
|
1689
|
+
for (const clause of message.split("; ")) {
|
|
1690
|
+
const key = /^Repo "([^"]+)"/.exec(clause)?.[1];
|
|
1691
|
+
if (key !== undefined) byKey.set(key, clause);
|
|
1692
|
+
}
|
|
1693
|
+
const stale: Record<string, string> = {};
|
|
1694
|
+
for (const repo of repos) {
|
|
1695
|
+
const clause = byKey.size === 0 ? message : byKey.get(repoIdentity(repo.url).key);
|
|
1696
|
+
if (clause !== undefined) stale[repo.slot] = clause;
|
|
1697
|
+
}
|
|
1698
|
+
return Object.keys(stale).length ? stale : undefined;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* The refusal of a provision on a cluster with no operator (ADR-0069). The data plane is always
|
|
1705
|
+
* wired, but the Repo and Sandbox kinds are the operator's to install: with none, the API server
|
|
1706
|
+
* answers 404 on the first create of either, so the first Repo write fails before the Sandbox CR
|
|
1707
|
+
* does. `jr2 up` deploys the operator on every converge unless `operator.manage: false`, so that
|
|
1708
|
+
* setting is the one way to reach this, and the fix names it. Any other error passes through.
|
|
1709
|
+
*/
|
|
1710
|
+
function noOperator(sandbox: string, kind: "Repo" | "Sandbox"): (err: unknown) => never {
|
|
1711
|
+
return (err) => {
|
|
1712
|
+
if (!isNotFound(err)) throw err;
|
|
1713
|
+
throw new Error(
|
|
1714
|
+
`Sandbox "${sandbox}" cannot be provisioned: no operator on this cluster — install it, or remove ` +
|
|
1715
|
+
`\`operator.manage: false\` so \`jr2 up\` deploys it (the API server does not serve the ${kind} kind: ` +
|
|
1716
|
+
`${(err as Error).message})`,
|
|
1717
|
+
);
|
|
1718
|
+
};
|
|
1719
|
+
}
|