@jr2/orchestrator 0.2.1 → 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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -0,0 +1,260 @@
1
+ // The one watch (ADR-0063): every Sandbox of this Instance, held in memory and pushed to whoever
2
+ // subscribes to a name. It replaces every poll the Orchestrator made — the provision's Ready wait,
3
+ // the fetch ask's wait on its landing (ADR-0053), and Continuity (ADR-0021) — and the first list
4
+ // after start IS the restore reconcile (ADR-0012).
5
+ //
6
+ // The loop is list → watch → watch → … and it is honest about what it does not know:
7
+ //
8
+ // - list: the cache becomes the list, and every name whose object changed (or went) is told.
9
+ // - watch from the last resourceVersion, with bookmarks and `timeoutSeconds=240`. EVERY
10
+ // bookmark's resourceVersion is kept, so a resume after a quiet period starts from a recent
11
+ // point rather than one the API server may have compacted away.
12
+ // - the server closes the watch → resume from the last resourceVersion. So does
13
+ // `UND_ERR_BODY_TIMEOUT`: fetch's 300s body timeout is a quiet stream, not a failure.
14
+ // - ANY `ERROR` event → re-list. A resume that is too old arrives as `ERROR` with code 410
15
+ // INSIDE an HTTP 200; no other ERROR leaves the resourceVersion usable either.
16
+ // - anything else (the API server unreachable, a refused request) → back off, then resume.
17
+ //
18
+ // A subscriber hears only what the cluster said: a Sandbox that is there, or one a list or a
19
+ // DELETED event says is gone. A dropped watch says nothing, so it tells nobody anything — unknown
20
+ // is never loss (ADR-0021).
21
+
22
+ import { SANDBOXES, KubeError, type KubeClient, type KubeObject } from "./kube-client.ts";
23
+
24
+ /** A Sandbox CR as the Orchestrator reads it — the fields its ports use. */
25
+ export type SandboxObject = KubeObject & {
26
+ spec?: {
27
+ repos?: Array<{ key?: string; url?: string }>;
28
+ /** The Harness container's share of the Size (ADR-0060): what a memory kill hit. */
29
+ resources?: { limits?: { memory?: string; cpu?: string } };
30
+ };
31
+ status?: SandboxStatus;
32
+ };
33
+
34
+ /** The status the operator publishes (ADR-0001, ADR-0063) — pod facts included, so the
35
+ * Orchestrator never reads a Pod. */
36
+ export type SandboxStatus = {
37
+ /** `Pending`, `Ready`, `Terminating` — or `Lost` (ADR-0021): the Sandbox's one pod is gone or
38
+ * terminal, and the operator never creates another. Terminal: a Lost Sandbox is never Pending or
39
+ * Ready again. */
40
+ phase?: SandboxPhase;
41
+ endpoint?: string;
42
+ /** The pod the operator created for this Sandbox — its once-only record that it did. The
43
+ * Orchestrator judges no identity from it: Lost is the operator's verdict (ADR-0021). */
44
+ podUID?: string;
45
+ /** `Ready`, `ReposFresh`; `Scheduled` — the pod's scheduling restated, `False` with the
46
+ * scheduler's reason (`Unschedulable`) or a quota's refusal of the create (`QuotaExceeded`) and
47
+ * their own words (ADR-0063, ADR-0064); and `Lost`, `True` once the phase is Lost, with the pod's
48
+ * reason (`Evicted`, `NodeShutdown`, ...) or the operator's (`PodDeleted`, `NodeLost`,
49
+ * `PodFailed`, `PodSucceeded`) and the pod's message. */
50
+ conditions?: Condition[];
51
+ /** Per Repo key, what the node cache did about the latest ask (ADR-0053). */
52
+ repos?: SandboxRepoStatus[];
53
+ /** The Harness container's restarts and last end on the current pod (ADR-0061, ADR-0063). */
54
+ harness?: {
55
+ restartCount?: number;
56
+ /** `message` is the kubelet's termination message — the Harness's last log lines, by the
57
+ * operator's `FallbackToLogsOnError` (ADR-0063). */
58
+ lastTerminated?: { reason?: string; exitCode?: number; finishedAt?: string; message?: string };
59
+ };
60
+ /** Every waiting container of the current pod, init containers first, in the kubelet's words
61
+ * (ADR-0063) — the only evidence of a container that never starts. */
62
+ waiting?: ContainerWaiting[];
63
+ /** The Sandbox Effector's restarts and last end (ADR-0068): `message` is its termination
64
+ * message — why it could not serve, `landlock: unavailable on node <node> (...)` among them. */
65
+ effector?: {
66
+ restartCount?: number;
67
+ lastTerminated?: { reason?: string; exitCode?: number; finishedAt?: string; message?: string };
68
+ };
69
+ };
70
+
71
+ /** A Sandbox's phase as the operator publishes it. `Lost` is terminal (ADR-0021). */
72
+ export type SandboxPhase = "Pending" | "Ready" | "Terminating" | "Lost" | (string & {});
73
+
74
+ /** One waiting container, as the operator copies it off the pod. */
75
+ export type ContainerWaiting = { container: string; reason?: string; message?: string };
76
+
77
+ /** One entry of a Sandbox CR's `status.conditions`, as the operator writes it. */
78
+ export type Condition = { type: string; status: string; reason?: string; message?: string };
79
+
80
+ /** The operator's standing per-key Repo entry on a Sandbox (ADR-0053). */
81
+ export type SandboxRepoStatus = {
82
+ key: string;
83
+ asked?: string;
84
+ fetched?: string;
85
+ attempted?: string;
86
+ error?: string;
87
+ };
88
+
89
+ /** What a subscriber is handed: the Sandbox as the cluster now says it is, or `undefined` for
90
+ * one the cluster says is gone. */
91
+ export type SandboxListener = (sandbox: SandboxObject | undefined) => void;
92
+
93
+ export interface SandboxWatch {
94
+ /** Resolves once the first list has landed — before it, the cache knows nothing. */
95
+ synced(): Promise<void>;
96
+ /** The Sandbox as last seen, or `undefined` for none. Meaningful only once synced. */
97
+ get(name: string): SandboxObject | undefined;
98
+ /** Hear every change to one name. Once synced, the listener is called at once with what the
99
+ * cache holds (on a later tick), then on every event and every re-list that changes it. */
100
+ subscribe(name: string, listener: SandboxListener): () => void;
101
+ /** End the loop. */
102
+ stop(): void;
103
+ }
104
+
105
+ /** The ask a watch makes (ADR-0063): the API server closes it well inside fetch's 300s body
106
+ * timeout. */
107
+ export const WATCH_TIMEOUT_SECONDS = 240;
108
+
109
+ export type SandboxWatchOptions = {
110
+ namespace: string;
111
+ /** This Instance's Sandboxes. Default: every CR this Orchestrator provisions carries `jr2.dev/run`;
112
+ * the namespace is the Instance's own (ADR-0019), so no Instance label is needed to tell them apart. */
113
+ labelSelector?: string;
114
+ /** Back-off after a failed list or watch request: doubling from this, capped at 30s. Default 500ms. */
115
+ backoffMs?: number;
116
+ /** Where a failure line goes. Default `console.warn`. */
117
+ log?: (line: string) => void;
118
+ };
119
+
120
+ /** Start the loop. It runs until `stop()`; nothing it meets ends it. */
121
+ export function watchSandboxes(client: KubeClient, opts: SandboxWatchOptions): SandboxWatch {
122
+ const labelSelector = opts.labelSelector ?? "jr2.dev/run";
123
+ const backoffFloor = opts.backoffMs ?? 500;
124
+ const log = opts.log ?? ((line: string) => console.warn(line));
125
+ const cache = new Map<string, SandboxObject>();
126
+ const listeners = new Map<string, Set<SandboxListener>>();
127
+ const controller = new AbortController();
128
+ let isSynced = false;
129
+ let onSynced!: () => void;
130
+ const syncedP = new Promise<void>((resolve) => (onSynced = resolve));
131
+
132
+ const tell = (name: string) => {
133
+ const object = cache.get(name);
134
+ for (const l of [...(listeners.get(name) ?? [])]) {
135
+ try {
136
+ l(object);
137
+ } catch (err) {
138
+ log(`jr2: a Sandbox listener for "${name}" threw: ${(err as Error).message}`);
139
+ }
140
+ }
141
+ };
142
+
143
+ /** The cache becomes the list. Told: every name whose object changed or went — and, on the
144
+ * FIRST list, every name anyone is waiting on, present or not: that is the reconcile. */
145
+ const replace = (items: SandboxObject[]) => {
146
+ const before = new Map(cache);
147
+ cache.clear();
148
+ for (const item of items) {
149
+ const name = item.metadata?.name;
150
+ if (name) cache.set(name, item);
151
+ }
152
+ const first = !isSynced;
153
+ isSynced = true;
154
+ const names = new Set([...before.keys(), ...cache.keys(), ...(first ? listeners.keys() : [])]);
155
+ for (const name of names) {
156
+ const was = before.get(name)?.metadata?.resourceVersion;
157
+ const now = cache.get(name)?.metadata?.resourceVersion;
158
+ if (first || was !== now) tell(name);
159
+ }
160
+ if (first) onSynced();
161
+ };
162
+
163
+ const loop = async () => {
164
+ let rv: string | undefined;
165
+ let backoff = backoffFloor;
166
+ while (!controller.signal.aborted) {
167
+ try {
168
+ if (rv === undefined) {
169
+ const list = await client.list<SandboxObject>(SANDBOXES, opts.namespace, { labelSelector });
170
+ replace(list.items);
171
+ rv = list.resourceVersion;
172
+ }
173
+ const events = client.watch<SandboxObject>(SANDBOXES, opts.namespace, {
174
+ resourceVersion: rv,
175
+ labelSelector,
176
+ timeoutSeconds: WATCH_TIMEOUT_SECONDS,
177
+ signal: controller.signal,
178
+ });
179
+ let heard = false;
180
+ for await (const event of events) {
181
+ if (event.type === "ERROR") {
182
+ // 410 Gone (too old) or anything else: this resourceVersion cannot be trusted.
183
+ rv = undefined;
184
+ break;
185
+ }
186
+ heard = true;
187
+ backoff = backoffFloor; // the stream is healthy
188
+ const object = event.object;
189
+ const next = object.metadata?.resourceVersion;
190
+ if (next) rv = next;
191
+ if (event.type === "BOOKMARK") continue;
192
+ const name = object.metadata?.name;
193
+ if (!name) continue;
194
+ if (event.type === "DELETED") cache.delete(name);
195
+ else cache.set(name, object);
196
+ tell(name);
197
+ }
198
+ // The server closed the watch (its timeout, or after an ERROR): resume at once — unless it
199
+ // said nothing but an ERROR, or nothing at all, which a healthy server does not do twice:
200
+ // a beat keeps a misbehaving one from being asked in a hot loop.
201
+ if (!heard) await sleep(backoffFloor, controller.signal);
202
+ } catch (err) {
203
+ if (controller.signal.aborted) return;
204
+ // fetch's body timeout: the stream was quiet for 300s. A resume, not a failure.
205
+ if (isBodyTimeout(err)) continue;
206
+ // A 410 on the request itself (not inside the stream) is the same verdict as the event.
207
+ if (err instanceof KubeError && err.status === 410) rv = undefined;
208
+ log(`jr2: the Sandbox watch failed, retrying in ${backoff}ms: ${(err as Error).message}`);
209
+ await sleep(backoff, controller.signal);
210
+ backoff = Math.min(backoff * 2, 30_000);
211
+ }
212
+ }
213
+ };
214
+ void loop();
215
+
216
+ return {
217
+ synced: () => syncedP,
218
+ get: (name) => cache.get(name),
219
+ subscribe(name, listener) {
220
+ let set = listeners.get(name);
221
+ if (!set) listeners.set(name, (set = new Set()));
222
+ set.add(listener);
223
+ // Already synced: what the cache holds now is this subscriber's first answer. Later, so a
224
+ // caller can finish wiring before it hears anything.
225
+ if (isSynced) {
226
+ queueMicrotask(() => {
227
+ if (listeners.get(name)?.has(listener)) listener(cache.get(name));
228
+ });
229
+ }
230
+ return () => {
231
+ set.delete(listener);
232
+ if (set.size === 0 && listeners.get(name) === set) listeners.delete(name);
233
+ };
234
+ },
235
+ stop: () => controller.abort(),
236
+ };
237
+ }
238
+
239
+ /** Undici's code for a body that sent nothing for its `bodyTimeout` — on the error or its cause. */
240
+ function isBodyTimeout(err: unknown): boolean {
241
+ const code = (e: unknown) => (e as { code?: unknown } | null)?.code;
242
+ return (
243
+ code(err) === "UND_ERR_BODY_TIMEOUT" || code((err as { cause?: unknown } | null)?.cause) === "UND_ERR_BODY_TIMEOUT"
244
+ );
245
+ }
246
+
247
+ /** Wait `ms`, or less if the loop stops. The abort listener goes when the timer fires: the loop's
248
+ * signal lives as long as the process, and one listener per back-off would pile up on it. */
249
+ const sleep = (ms: number, signal: AbortSignal) =>
250
+ new Promise<void>((resolve) => {
251
+ const onAbort = () => {
252
+ clearTimeout(timer);
253
+ resolve();
254
+ };
255
+ const timer = setTimeout(() => {
256
+ signal.removeEventListener("abort", onAbort);
257
+ resolve();
258
+ }, ms);
259
+ signal.addEventListener("abort", onAbort, { once: true });
260
+ });