@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,333 @@
|
|
|
1
|
+
// The Orchestrator's Kubernetes client (ADR-0063): plain REST on Node's built-in `fetch`, with no
|
|
2
|
+
// dependency. The Orchestrator is always in-cluster (ADR-0019), so the whole of "configuration" is
|
|
3
|
+
// the ServiceAccount the kubelet mounts and the Service the API server publishes:
|
|
4
|
+
//
|
|
5
|
+
// base URL https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT
|
|
6
|
+
// bearer the SA token FILE, read on every request (below)
|
|
7
|
+
// trust the SA `ca.crt`, through NODE_EXTRA_CA_CERTS on the Deployment (deploy.ts) — so
|
|
8
|
+
// this module holds no TLS code at all
|
|
9
|
+
//
|
|
10
|
+
// It speaks for jr2's own kinds alone — Sandbox, Repo, Secret — and every call stays behind the
|
|
11
|
+
// Sandbox and Repo ports, so swapping it for a client library is a contained change if jr2 ever
|
|
12
|
+
// needs exec or many more kinds (ADR-0063's considered options).
|
|
13
|
+
//
|
|
14
|
+
// Its rules, each observed on a real cluster (ADR-0063):
|
|
15
|
+
//
|
|
16
|
+
// - The token is never cached. The kubelet replaced a 10-minute token only about 50 seconds
|
|
17
|
+
// before it expired, so a cached one fails in production and never in a test. A `401` re-reads
|
|
18
|
+
// the file and retries ONCE — a rotation between the read and the request.
|
|
19
|
+
// - Writes are capped (8–16 at once). 200 parallel writes each opened a TLS connection and cost
|
|
20
|
+
// about 130% CPU; a provision burst queues here instead.
|
|
21
|
+
// - A watch asks for bookmarks and `timeoutSeconds=240`: built-in `fetch` aborts a body silent
|
|
22
|
+
// for 300s (`UND_ERR_BODY_TIMEOUT`) and that cannot be changed without undici's dispatcher, so
|
|
23
|
+
// the server closing first — and bookmarks about every 60s — keep a healthy watch clear of it.
|
|
24
|
+
// The watch LOOP that reads this stream lives in sandbox-watch.ts.
|
|
25
|
+
|
|
26
|
+
import { readFile } from "node:fs/promises";
|
|
27
|
+
|
|
28
|
+
// The API server's CA (`SERVICE_ACCOUNT_CA`, names.ts) is not read here: the Deployment points
|
|
29
|
+
// NODE_EXTRA_CA_CERTS at it, and built-in `fetch` trusts it from there.
|
|
30
|
+
|
|
31
|
+
/** Where the kubelet mounts the Pod's ServiceAccount. */
|
|
32
|
+
export const SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
33
|
+
/** The bearer — rotated by the kubelet, so read per request. */
|
|
34
|
+
export const SERVICE_ACCOUNT_TOKEN = `${SERVICE_ACCOUNT_DIR}/token`;
|
|
35
|
+
|
|
36
|
+
/** The field manager every server-side apply names — the Orchestrator's one identity as a writer. */
|
|
37
|
+
export const FIELD_MANAGER = "jr2";
|
|
38
|
+
|
|
39
|
+
/** The write cap when the caller names none: inside ADR-0063's 8–16. */
|
|
40
|
+
const DEFAULT_MAX_WRITES = 12;
|
|
41
|
+
|
|
42
|
+
/** One kind, as its REST path spells it. The core group is `""` (`/api/v1`). */
|
|
43
|
+
export type KubeKind = { group: string; version: string; plural: string };
|
|
44
|
+
|
|
45
|
+
export const SANDBOXES: KubeKind = { group: "core.jr2.dev", version: "v1alpha1", plural: "sandboxes" };
|
|
46
|
+
export const REPOS: KubeKind = { group: "core.jr2.dev", version: "v1alpha1", plural: "repos" };
|
|
47
|
+
export const SECRETS: KubeKind = { group: "", version: "v1", plural: "secrets" };
|
|
48
|
+
export const CONFIGMAPS: KubeKind = { group: "", version: "v1", plural: "configmaps" };
|
|
49
|
+
export const SERVICES: KubeKind = { group: "", version: "v1", plural: "services" };
|
|
50
|
+
|
|
51
|
+
/** The object fields every kind shares — what the client and the watch read off any of them. */
|
|
52
|
+
export type KubeObject = {
|
|
53
|
+
apiVersion?: string;
|
|
54
|
+
kind?: string;
|
|
55
|
+
metadata?: {
|
|
56
|
+
name?: string;
|
|
57
|
+
namespace?: string;
|
|
58
|
+
uid?: string;
|
|
59
|
+
resourceVersion?: string;
|
|
60
|
+
labels?: Record<string, string>;
|
|
61
|
+
annotations?: Record<string, string>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** One event off a watch stream. `ERROR` carries a `Status` (code 410 for a resourceVersion too
|
|
66
|
+
* old) INSIDE an HTTP 200 — the caller re-lists on any of them. */
|
|
67
|
+
export type WatchEvent<T extends KubeObject = KubeObject> =
|
|
68
|
+
| { type: "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK"; object: T }
|
|
69
|
+
| { type: "ERROR"; object: { kind?: "Status"; code?: number; reason?: string; message?: string } };
|
|
70
|
+
|
|
71
|
+
/** A refusal from the API server: the HTTP status and the `Status` body's own reason and words. */
|
|
72
|
+
export class KubeError extends Error {
|
|
73
|
+
readonly status: number;
|
|
74
|
+
readonly reason: string | undefined;
|
|
75
|
+
constructor(status: number, reason: string | undefined, message: string) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = "KubeError";
|
|
78
|
+
this.status = status;
|
|
79
|
+
this.reason = reason;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type KubeClientOptions = {
|
|
84
|
+
/** The API server. Default: the in-cluster Service from `KUBERNETES_SERVICE_HOST`/`_PORT`. */
|
|
85
|
+
baseUrl?: string;
|
|
86
|
+
/** The environment the default base URL is read from. Default `process.env`. */
|
|
87
|
+
env?: Record<string, string | undefined>;
|
|
88
|
+
/** The bearer, asked for on EVERY request — never cached (see the header). Default: the SA
|
|
89
|
+
* token file. */
|
|
90
|
+
token?: () => Promise<string>;
|
|
91
|
+
/** Injectable transport: the tests' fake API server. Default: global `fetch`. */
|
|
92
|
+
fetch?: typeof fetch;
|
|
93
|
+
/** How many writes may be in flight at once. Default 12. */
|
|
94
|
+
maxWrites?: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The client the Sandbox, Repo and fetch ports share: one process, one write cap. */
|
|
98
|
+
export interface KubeClient {
|
|
99
|
+
/** The object, or `undefined` when there is none (404). */
|
|
100
|
+
get<T extends KubeObject>(kind: KubeKind, namespace: string, name: string): Promise<T | undefined>;
|
|
101
|
+
/** Every object of the kind in the namespace, with the list's resourceVersion — where a watch
|
|
102
|
+
* starts. A kind the cluster does not serve (no CRD) is a {@link KubeError} with status 404. */
|
|
103
|
+
list<T extends KubeObject>(
|
|
104
|
+
kind: KubeKind,
|
|
105
|
+
namespace: string,
|
|
106
|
+
opts?: { labelSelector?: string },
|
|
107
|
+
): Promise<{ items: T[]; resourceVersion: string }>;
|
|
108
|
+
/** Create; an existing object is a {@link KubeError} with status 409. */
|
|
109
|
+
create<T extends KubeObject>(kind: KubeKind, namespace: string, body: T): Promise<T & KubeObject>;
|
|
110
|
+
/** Delete; true when there was one to delete, false when it was already gone. */
|
|
111
|
+
delete(kind: KubeKind, namespace: string, name: string): Promise<boolean>;
|
|
112
|
+
/** A JSON merge patch — `null` removes a key. `status` patches the status subresource. Answers
|
|
113
|
+
* the object as it stands after the patch. */
|
|
114
|
+
patch<T extends KubeObject>(
|
|
115
|
+
kind: KubeKind,
|
|
116
|
+
namespace: string,
|
|
117
|
+
name: string,
|
|
118
|
+
body: object,
|
|
119
|
+
opts?: { status?: boolean },
|
|
120
|
+
): Promise<T>;
|
|
121
|
+
/** Server-side apply as field manager `jr2`, forced: create-or-update of every field the body
|
|
122
|
+
* names. Answers the object as it stands afterwards (its `uid` included). */
|
|
123
|
+
apply<T extends KubeObject>(kind: KubeKind, namespace: string, body: T): Promise<T & KubeObject>;
|
|
124
|
+
/** One watch request's events, from `resourceVersion`, until the server closes it. Throws on a
|
|
125
|
+
* refused request or a broken stream — the caller decides what each means (sandbox-watch.ts). */
|
|
126
|
+
watch<T extends KubeObject>(
|
|
127
|
+
kind: KubeKind,
|
|
128
|
+
namespace: string,
|
|
129
|
+
opts: { resourceVersion: string; labelSelector?: string; timeoutSeconds: number; signal?: AbortSignal },
|
|
130
|
+
): AsyncIterable<WatchEvent<T>>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The in-cluster base URL, from the Service env the kubelet injects into every pod. */
|
|
134
|
+
export function inClusterBaseUrl(env: Record<string, string | undefined> = process.env): string {
|
|
135
|
+
const host = env.KUBERNETES_SERVICE_HOST;
|
|
136
|
+
const port = env.KUBERNETES_SERVICE_PORT ?? "443";
|
|
137
|
+
if (!host) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"no Kubernetes API server: KUBERNETES_SERVICE_HOST is unset. The Orchestrator drives the cluster " +
|
|
140
|
+
"only from inside it (ADR-0019, ADR-0063) — `jr2 up` deploys it there.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// An IPv6 address needs brackets in a URL.
|
|
144
|
+
return `https://${host.includes(":") ? `[${host}]` : host}:${port}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Build the client. Nothing is read until the first request: a boot never fails on the cluster. */
|
|
148
|
+
export function kubeClient(opts: KubeClientOptions = {}): KubeClient {
|
|
149
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
150
|
+
const token = opts.token ?? (async () => (await readFile(SERVICE_ACCOUNT_TOKEN, "utf8")).trim());
|
|
151
|
+
const writes = semaphore(opts.maxWrites ?? DEFAULT_MAX_WRITES);
|
|
152
|
+
let base: string | undefined = opts.baseUrl;
|
|
153
|
+
const baseUrl = () => (base ??= inClusterBaseUrl(opts.env));
|
|
154
|
+
|
|
155
|
+
/** One request, with the bearer read fresh and a 401 re-read and retried once. */
|
|
156
|
+
const send = async (
|
|
157
|
+
method: string,
|
|
158
|
+
path: string,
|
|
159
|
+
init: { query?: Record<string, string>; body?: string; contentType?: string; signal?: AbortSignal } = {},
|
|
160
|
+
): Promise<Response> => {
|
|
161
|
+
const url = new URL(path, baseUrl());
|
|
162
|
+
for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v);
|
|
163
|
+
for (let attempt = 0; ; attempt++) {
|
|
164
|
+
const res = await fetchImpl(url, {
|
|
165
|
+
method,
|
|
166
|
+
headers: {
|
|
167
|
+
authorization: `Bearer ${await token()}`,
|
|
168
|
+
accept: "application/json",
|
|
169
|
+
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
170
|
+
},
|
|
171
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
172
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
173
|
+
});
|
|
174
|
+
// A rotation landed between the read and the request: the file already holds the new one.
|
|
175
|
+
if (res.status === 401 && attempt === 0) {
|
|
176
|
+
await res.body?.cancel();
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
return res;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** The answer as JSON, or a {@link KubeError} carrying the Status body's reason and words. */
|
|
184
|
+
const json = async <T>(res: Response, what: string): Promise<T> => {
|
|
185
|
+
const text = await res.text();
|
|
186
|
+
if (res.ok) return (text ? JSON.parse(text) : {}) as T;
|
|
187
|
+
throw errorOf(res.status, text, what);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** A write: inside the cap until its answer is read, so the cap bounds connections, not calls. */
|
|
191
|
+
const write = <T>(method: string, path: string, what: string, init: Parameters<typeof send>[2]) =>
|
|
192
|
+
writes(async () => json<T>(await send(method, path, init), what));
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
async get<T extends KubeObject>(kind: KubeKind, ns: string, name: string) {
|
|
196
|
+
const res = await send("GET", pathOf(kind, ns, name));
|
|
197
|
+
if (res.status === 404) {
|
|
198
|
+
await res.body?.cancel();
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return json<T>(res, `get ${kind.plural}/${name}`);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async list<T extends KubeObject>(kind: KubeKind, ns: string, opts: { labelSelector?: string } = {}) {
|
|
205
|
+
const res = await send("GET", pathOf(kind, ns), {
|
|
206
|
+
query: opts.labelSelector ? { labelSelector: opts.labelSelector } : {},
|
|
207
|
+
});
|
|
208
|
+
const out = await json<{ items?: T[]; metadata?: { resourceVersion?: string } }>(res, `list ${kind.plural}`);
|
|
209
|
+
return { items: out.items ?? [], resourceVersion: out.metadata?.resourceVersion ?? "" };
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
create<T extends KubeObject>(kind: KubeKind, ns: string, body: T) {
|
|
213
|
+
return write<T & KubeObject>("POST", pathOf(kind, ns), `create ${kind.plural}/${body.metadata?.name ?? "?"}`, {
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
contentType: "application/json",
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
delete(kind: KubeKind, ns: string, name: string) {
|
|
220
|
+
return writes(async () => {
|
|
221
|
+
const res = await send("DELETE", pathOf(kind, ns, name));
|
|
222
|
+
if (res.status === 404) {
|
|
223
|
+
await res.body?.cancel();
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
await json(res, `delete ${kind.plural}/${name}`);
|
|
227
|
+
return true;
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
patch<T extends KubeObject>(kind: KubeKind, ns: string, name: string, body: object, o: { status?: boolean } = {}) {
|
|
232
|
+
return write<T>(
|
|
233
|
+
"PATCH",
|
|
234
|
+
pathOf(kind, ns, name, o.status ? "status" : undefined),
|
|
235
|
+
`patch ${kind.plural}/${name}`,
|
|
236
|
+
{
|
|
237
|
+
body: JSON.stringify(body),
|
|
238
|
+
contentType: "application/merge-patch+json",
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
apply<T extends KubeObject>(kind: KubeKind, ns: string, body: T) {
|
|
244
|
+
const name = body.metadata?.name;
|
|
245
|
+
if (!name) throw new Error(`apply ${kind.plural}: the object names no metadata.name`);
|
|
246
|
+
// JSON is YAML, so the apply content type takes the object as it is.
|
|
247
|
+
return write<T & KubeObject>("PATCH", pathOf(kind, ns, name), `apply ${kind.plural}/${name}`, {
|
|
248
|
+
query: { fieldManager: FIELD_MANAGER, force: "true" },
|
|
249
|
+
body: JSON.stringify(body),
|
|
250
|
+
contentType: "application/apply-patch+yaml",
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async *watch<T extends KubeObject>(
|
|
255
|
+
kind: KubeKind,
|
|
256
|
+
ns: string,
|
|
257
|
+
o: { resourceVersion: string; labelSelector?: string; timeoutSeconds: number; signal?: AbortSignal },
|
|
258
|
+
): AsyncIterable<WatchEvent<T>> {
|
|
259
|
+
const res = await send("GET", pathOf(kind, ns), {
|
|
260
|
+
query: {
|
|
261
|
+
watch: "true",
|
|
262
|
+
allowWatchBookmarks: "true",
|
|
263
|
+
timeoutSeconds: String(o.timeoutSeconds),
|
|
264
|
+
resourceVersion: o.resourceVersion,
|
|
265
|
+
...(o.labelSelector ? { labelSelector: o.labelSelector } : {}),
|
|
266
|
+
},
|
|
267
|
+
...(o.signal ? { signal: o.signal } : {}),
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok) throw errorOf(res.status, await res.text(), `watch ${kind.plural}`);
|
|
270
|
+
if (!res.body) return;
|
|
271
|
+
// One JSON object per line. A line can straddle two chunks, so the tail waits for the next.
|
|
272
|
+
let buf = "";
|
|
273
|
+
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
|
|
274
|
+
buf += chunk;
|
|
275
|
+
let nl: number;
|
|
276
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
277
|
+
const line = buf.slice(0, nl).trim();
|
|
278
|
+
buf = buf.slice(nl + 1);
|
|
279
|
+
if (line) yield JSON.parse(line) as WatchEvent<T>;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (buf.trim()) yield JSON.parse(buf) as WatchEvent<T>;
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A `Status` body read into a {@link KubeError}; a body that is not one keeps its raw text. */
|
|
288
|
+
function errorOf(status: number, text: string, what: string): KubeError {
|
|
289
|
+
let reason: string | undefined;
|
|
290
|
+
let message = text;
|
|
291
|
+
try {
|
|
292
|
+
const parsed = JSON.parse(text) as { reason?: string; message?: string };
|
|
293
|
+
reason = parsed.reason;
|
|
294
|
+
message = parsed.message ?? text;
|
|
295
|
+
} catch {
|
|
296
|
+
// not a Status: the raw text is the most there is to say
|
|
297
|
+
}
|
|
298
|
+
return new KubeError(status, reason, `${what} failed (${status}${reason ? ` ${reason}` : ""}): ${message}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The REST path of a kind in a namespace, optionally one object and one subresource. */
|
|
302
|
+
function pathOf(kind: KubeKind, ns: string, name?: string, sub?: string): string {
|
|
303
|
+
const root = kind.group ? `/apis/${kind.group}/${kind.version}` : `/api/${kind.version}`;
|
|
304
|
+
return (
|
|
305
|
+
`${root}/namespaces/${encodeURIComponent(ns)}/${kind.plural}` +
|
|
306
|
+
(name !== undefined ? `/${encodeURIComponent(name)}` : "") +
|
|
307
|
+
(sub !== undefined ? `/${sub}` : "")
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** At most `n` of the wrapped calls at once; the rest queue in arrival order. */
|
|
312
|
+
function semaphore(n: number): <T>(fn: () => Promise<T>) => Promise<T> {
|
|
313
|
+
let active = 0;
|
|
314
|
+
const queue: Array<() => void> = [];
|
|
315
|
+
return async (fn) => {
|
|
316
|
+
// A released slot passes straight to the next waiter, so a caller arriving in between can
|
|
317
|
+
// never slip past the cap.
|
|
318
|
+
if (active >= n) await new Promise<void>((resolve) => queue.push(resolve));
|
|
319
|
+
else active++;
|
|
320
|
+
try {
|
|
321
|
+
return await fn();
|
|
322
|
+
} finally {
|
|
323
|
+
const next = queue.shift();
|
|
324
|
+
if (next) next();
|
|
325
|
+
else active--;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** True for the API server's "no such object" — the one refusal most callers read as an answer. */
|
|
331
|
+
export const isNotFound = (err: unknown): boolean => err instanceof KubeError && err.status === 404;
|
|
332
|
+
/** True for "already exists" on a create. */
|
|
333
|
+
export const isAlreadyExists = (err: unknown): boolean => err instanceof KubeError && err.status === 409;
|
package/src/machine-doc.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
import type { AnyStateMachine, StateNode, TransitionDefinition } from "xstate";
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import type { EventAudience } from "@jr2/agent-protocol";
|
|
21
|
-
import {
|
|
21
|
+
import { argvText, isEffect, targetText, type EffectParts } from "./effect.ts";
|
|
22
|
+
import { loadWarningsOf, seatsOf, vocabularyOf, type MachineSeat } from "./vocabulary.ts";
|
|
22
23
|
|
|
23
24
|
/** One transition of the Machine, id-addressed at both ends. */
|
|
24
25
|
export type MachineTransitionDoc = {
|
|
@@ -32,9 +33,30 @@ export type MachineTransitionDoc = {
|
|
|
32
33
|
label: string;
|
|
33
34
|
/** Normalized guard name, if guarded. */
|
|
34
35
|
guard?: string;
|
|
36
|
+
/** xstate's own transition `description`, as authored: what this writing does, where one name
|
|
37
|
+
* has writings that differ (ADR-0015's "one def, one meaning"). */
|
|
38
|
+
description?: string;
|
|
35
39
|
kind: "event" | "always" | "after" | "done" | "error";
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
/** An Effect slot's parts, as the doc carries them (ADR-0067): a command's argv (an argv slot reads
|
|
43
|
+
* `<name>`), its timeout, and the Repo Slot whose Worktree is its cwd, when it names one. */
|
|
44
|
+
export type EffectDoc =
|
|
45
|
+
| { kind: "command"; argv: string[]; timeoutMs: number; slot?: string; secrets?: string[]; pin?: true }
|
|
46
|
+
/** An http Effect (ADR-0068): its method, its fixed target, the secrets it names — never a value
|
|
47
|
+
* — and `file` when its body is a Worktree file (ADR-0067). */
|
|
48
|
+
| {
|
|
49
|
+
kind: "http";
|
|
50
|
+
method: string;
|
|
51
|
+
target: string;
|
|
52
|
+
secrets: string[];
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
idempotent: boolean;
|
|
55
|
+
file?: true;
|
|
56
|
+
}
|
|
57
|
+
/** A push (ADR-0067): which options the author set. */
|
|
58
|
+
| { kind: "push"; sign: boolean; squash: boolean; force: boolean; pin: boolean; timeoutMs: number; slot?: string };
|
|
59
|
+
|
|
38
60
|
/** One state of the Machine; `states` nests in document order. */
|
|
39
61
|
export type MachineStateDoc = {
|
|
40
62
|
/** Unique state id (custom `id:` respected). */
|
|
@@ -44,7 +66,9 @@ export type MachineStateDoc = {
|
|
|
44
66
|
type: "atomic" | "compound" | "parallel" | "final" | "history";
|
|
45
67
|
/** Initial child state id (compound only). */
|
|
46
68
|
initial?: string;
|
|
47
|
-
invoke
|
|
69
|
+
/** Each invoke's id and slot; an Effect's carries its plain-data parts (ADR-0067) — what it runs,
|
|
70
|
+
* where, and for how long, read without running anything. */
|
|
71
|
+
invoke: Array<{ id: string; src: string; effect?: EffectDoc }>;
|
|
48
72
|
tags: string[];
|
|
49
73
|
description?: string;
|
|
50
74
|
states: MachineStateDoc[];
|
|
@@ -98,7 +122,8 @@ export type ChildMachineDoc = {
|
|
|
98
122
|
export type MachineEventDoc = {
|
|
99
123
|
name: string;
|
|
100
124
|
description?: string;
|
|
101
|
-
|
|
125
|
+
/** Absent: each writing's state decides who may send it (ADR-0015) — see the body's `seats`. */
|
|
126
|
+
audience?: EventAudience;
|
|
102
127
|
/** The def's input schema as JSON Schema — what a form or a `jr2 send` prompt generates from. */
|
|
103
128
|
input: unknown;
|
|
104
129
|
};
|
|
@@ -112,6 +137,13 @@ export type MachineBodyDoc = {
|
|
|
112
137
|
/** The events THIS Machine declares, and only this one (ADR-0049): a nested Machine's ride its
|
|
113
138
|
* own `MachineBodyDoc`. Empty for a Machine not built by `jr2Setup`. */
|
|
114
139
|
events: MachineEventDoc[];
|
|
140
|
+
/** Who may send what, per state (ADR-0015): one entry per party on each state that invokes one —
|
|
141
|
+
* the audience rule's answer for this Machine, as `jr2 up` prints it. Empty for a Machine not
|
|
142
|
+
* built by `jr2Setup`. */
|
|
143
|
+
seats: MachineSeat[];
|
|
144
|
+
/** What the build found legal and suspect (ADR-0015) — a mode nothing enters, a pick a Gate above
|
|
145
|
+
* takes from the Agent below, one name written for two parties on one path. */
|
|
146
|
+
warnings: string[];
|
|
115
147
|
};
|
|
116
148
|
|
|
117
149
|
/** The serialized structure of a workflow's Machine. */
|
|
@@ -122,6 +154,10 @@ export type MachineDoc = MachineBodyDoc & {
|
|
|
122
154
|
* to discover the diagram is lying. Absent on the nested `MachineBodyDoc`s: it is a whole-doc
|
|
123
155
|
* question, and the root's walk already covers them. */
|
|
124
156
|
opaqueStates?: string[];
|
|
157
|
+
/** A run's doc only (`runMachine`, ADR-0070): each bound Chart the doc could not draw, so its Open
|
|
158
|
+
* body shows the slot's own subgraph — the body's path in the run, its slot, and why (a stored
|
|
159
|
+
* Chart the door no longer admits is drift, D-B). Absent when every bound Chart is drawn. */
|
|
160
|
+
chartsNotDrawn?: Array<{ path: string; src: string; reason: string }>;
|
|
125
161
|
};
|
|
126
162
|
|
|
127
163
|
/** Normalize a guard to a display name: setup() name, parameterized type, or `"inline"`. */
|
|
@@ -172,6 +208,7 @@ function serializeTransition(t: TransitionDefinition<any, any>): MachineTransiti
|
|
|
172
208
|
kind,
|
|
173
209
|
};
|
|
174
210
|
if (guard !== undefined) doc.guard = guard;
|
|
211
|
+
if (t.description !== undefined) doc.description = t.description;
|
|
175
212
|
return doc;
|
|
176
213
|
}
|
|
177
214
|
|
|
@@ -287,7 +324,11 @@ function serializeState(node: StateNode<any, any>, into: MachineTransitionDoc[],
|
|
|
287
324
|
id: node.id,
|
|
288
325
|
key: node.key,
|
|
289
326
|
type: node.type,
|
|
290
|
-
invoke: node.invoke.map((inv) =>
|
|
327
|
+
invoke: node.invoke.map((inv) => {
|
|
328
|
+
const logic = typeof inv.src === "string" ? scope.actors[inv.src] : undefined;
|
|
329
|
+
if (!isEffect(logic)) return { id: inv.id, src: srcName(inv.src) };
|
|
330
|
+
return { id: inv.id, src: srcName(inv.src), effect: effectDoc(logic.effect) };
|
|
331
|
+
}),
|
|
291
332
|
tags: [...node.tags],
|
|
292
333
|
states: children.map((child) => serializeState(child, into, scope)),
|
|
293
334
|
children: childMachines(node, scope),
|
|
@@ -311,6 +352,18 @@ export function opaqueStates(doc: MachineDoc): string[] {
|
|
|
311
352
|
return ids;
|
|
312
353
|
}
|
|
313
354
|
|
|
355
|
+
/** Every Machine body in a doc — the root's, then each child Machine's, depth-first in state order.
|
|
356
|
+
* A recursive reference is not repeated: its body is already above it. */
|
|
357
|
+
export function machineBodies(doc: MachineBodyDoc): MachineBodyDoc[] {
|
|
358
|
+
const out: MachineBodyDoc[] = [doc];
|
|
359
|
+
const walk = (state: MachineStateDoc): void => {
|
|
360
|
+
for (const child of state.children) if (child.machine) out.push(...machineBodies(child.machine));
|
|
361
|
+
for (const nested of state.states) walk(nested);
|
|
362
|
+
};
|
|
363
|
+
walk(doc.root);
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
314
367
|
/** Serialize one Machine — its own state tree and its own transitions. Each level carries its own
|
|
315
368
|
* actor registry (a child machine resolves `src` names against ITS `setup`, not its parent's). */
|
|
316
369
|
function serializeBody(machine: AnyStateMachine, path: Set<AnyStateMachine>): MachineBodyDoc {
|
|
@@ -321,10 +374,10 @@ function serializeBody(machine: AnyStateMachine, path: Set<AnyStateMachine>): Ma
|
|
|
321
374
|
const events = [...(vocabularyOf(machine)?.values() ?? [])].map((def) => ({
|
|
322
375
|
name: def.name,
|
|
323
376
|
...(def.description !== undefined ? { description: def.description } : {}),
|
|
324
|
-
audience: def.audience,
|
|
377
|
+
...(def.audience !== undefined ? { audience: def.audience } : {}),
|
|
325
378
|
input: z.toJSONSchema(def.input),
|
|
326
379
|
}));
|
|
327
|
-
return { id: machine.id, root, transitions, events };
|
|
380
|
+
return { id: machine.id, root, transitions, events, seats: seatsOf(machine), warnings: loadWarningsOf(machine) };
|
|
328
381
|
}
|
|
329
382
|
|
|
330
383
|
/** Serialize a workflow's template Machine into the Console's DTO, child machines and all. */
|
|
@@ -332,3 +385,32 @@ export function serializeMachine(workflow: string, machine: AnyStateMachine): Ma
|
|
|
332
385
|
const doc: MachineDoc = { workflow, ...serializeBody(machine, new Set()) };
|
|
333
386
|
return { ...doc, opaqueStates: opaqueStates(doc) };
|
|
334
387
|
}
|
|
388
|
+
|
|
389
|
+
/** An Effect's plain data as the doc carries it: what it runs, read without running anything. */
|
|
390
|
+
function effectDoc(parts: EffectParts): EffectDoc {
|
|
391
|
+
if (parts.kind === "http") {
|
|
392
|
+
const { method, secrets, timeoutMs, idempotent } = parts;
|
|
393
|
+
return {
|
|
394
|
+
kind: "http",
|
|
395
|
+
method,
|
|
396
|
+
target: targetText(parts),
|
|
397
|
+
secrets: [...secrets],
|
|
398
|
+
timeoutMs,
|
|
399
|
+
idempotent,
|
|
400
|
+
...(parts.file ? { file: true as const } : {}),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (parts.kind === "push") {
|
|
404
|
+
const { sign, squash, force, pin, timeoutMs, slot } = parts;
|
|
405
|
+
return { kind: "push", sign, squash, force, pin, timeoutMs, ...(slot ? { slot } : {}) };
|
|
406
|
+
}
|
|
407
|
+
const { argv, timeoutMs, slot, secrets, pin } = parts;
|
|
408
|
+
return {
|
|
409
|
+
kind: "command",
|
|
410
|
+
argv: argvText(argv),
|
|
411
|
+
timeoutMs,
|
|
412
|
+
...(slot ? { slot } : {}),
|
|
413
|
+
...(secrets?.length ? { secrets: [...secrets] } : {}),
|
|
414
|
+
...(pin ? { pin } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What a mounted ConfigMap holds, as one digest (ADR-0038, ADR-0069). A publish rolls no pod, so
|
|
2
|
+
// the Orchestrator reads `jr2-images` and `jr2-held` through mounts the kubelet refreshes on its
|
|
3
|
+
// own period. `jr2 up` digests the data it applied, `GET /assembly` reports the digest of what the
|
|
4
|
+
// process reads, and the publish waits until the two agree. Then the door's checks, and the
|
|
5
|
+
// provisions that follow the converge, read the data this converge wrote.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { HELD_CONFIGMAP, HELD_MOUNT, IMAGES_CONFIGMAP, IMAGES_MOUNT } from "./names.ts";
|
|
11
|
+
|
|
12
|
+
/** The ConfigMaps the Orchestrator reads through a mount, by name, and where each is mounted. */
|
|
13
|
+
export const MOUNTED_CONFIGMAPS: Readonly<Record<string, string>> = {
|
|
14
|
+
[IMAGES_CONFIGMAP]: IMAGES_MOUNT,
|
|
15
|
+
[HELD_CONFIGMAP]: HELD_MOUNT,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** One digest of a ConfigMap's `data`: the same bytes give the same digest at both ends. */
|
|
19
|
+
export function configMapDigest(data: Readonly<Record<string, string>>): string {
|
|
20
|
+
const entries = Object.keys(data)
|
|
21
|
+
.sort()
|
|
22
|
+
.map((key) => [key, data[key]]);
|
|
23
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The digest of a mounted ConfigMap, read from its mount (ADR-0069). The kubelet writes each key
|
|
28
|
+
* as a file and keeps its own entries under names that start with `.`, which are not data. An
|
|
29
|
+
* absent mount reads as `undefined`.
|
|
30
|
+
*/
|
|
31
|
+
export async function readMountDigest(dir: string): Promise<string | undefined> {
|
|
32
|
+
let names: string[];
|
|
33
|
+
try {
|
|
34
|
+
names = await readdir(dir);
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const data: Record<string, string> = {};
|
|
39
|
+
for (const name of names.filter((n) => !n.startsWith("."))) data[name] = await readFile(join(dir, name), "utf8");
|
|
40
|
+
return configMapDigest(data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every mounted ConfigMap's digest, by name; a mount that is absent is left out. */
|
|
44
|
+
export async function readMountDigests(
|
|
45
|
+
mounts: Readonly<Record<string, string>> = MOUNTED_CONFIGMAPS,
|
|
46
|
+
): Promise<Record<string, string>> {
|
|
47
|
+
const out: Record<string, string> = {};
|
|
48
|
+
for (const [name, dir] of Object.entries(mounts)) {
|
|
49
|
+
const digest = await readMountDigest(dir);
|
|
50
|
+
if (digest !== undefined) out[name] = digest;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
package/src/names.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const ORCHESTRATOR_PORT = 4000;
|
|
|
9
9
|
/** The instance-owned Secret: Instance token + signing key (+ orchestrator-side creds). */
|
|
10
10
|
export const INSTANCE_SECRET = "jr2-instance";
|
|
11
11
|
|
|
12
|
-
/** The Instance Harness's
|
|
12
|
+
/** The Instance Harness's StatefulSet + Service name (ADR-0031): converged by `jr2 up` whenever any
|
|
13
13
|
* Agent definition declares `workspace: "none"`, and the deterministic Service DNS the Agent actor
|
|
14
14
|
* resolves such a Turn to. Doubles as the delivery scope a Menu-only registration records — the
|
|
15
15
|
* name the placement's Sandbox token is signed for (tokens.ts, ADR-0013), which its Custodian holds. The port is the
|
|
@@ -17,6 +17,19 @@ export const INSTANCE_SECRET = "jr2-instance";
|
|
|
17
17
|
export const INSTANCE_HARNESS_SERVICE = "jr2-instance-harness";
|
|
18
18
|
export const INSTANCE_HARNESS_PORT = 8080;
|
|
19
19
|
|
|
20
|
+
/** The Instance Effector (ADR-0068): its Deployment, headless Service, NetworkPolicy and Secret all
|
|
21
|
+
* carry this name. `jr2 up` converges them when a registered Machine places an Effect there. The
|
|
22
|
+
* port is the Effector's own (`JR2_EFFECTOR_PORT` default) — none of the Harness pod's ports. */
|
|
23
|
+
export const INSTANCE_EFFECTOR = "jr2-instance-effector";
|
|
24
|
+
export const EFFECTOR_PORT = 8082;
|
|
25
|
+
|
|
26
|
+
/** Where a Harness persists its conversations, and the env var that tells it so (ADR-0031): one
|
|
27
|
+
* fixed path in both placements — the Instance Harness's PersistentVolumeClaim (deploy.ts) and a
|
|
28
|
+
* Sandbox's emptyDir (sandbox-kube.ts), each mounted into the Harness container alone. */
|
|
29
|
+
export const CONVERSATIONS_DIR = "/conversations";
|
|
30
|
+
export const CONVERSATIONS_DIR_ENV = "JR2_CONVERSATIONS_DIR";
|
|
31
|
+
export const CONVERSATIONS_VOLUME = "conversations";
|
|
32
|
+
|
|
20
33
|
/** The harness config ConfigMap the stock Harness boots from (ADR-0018): what this instance can
|
|
21
34
|
* REACH — the custom model provider minus its key, and the catalog providers' gateways (ADR-0059). No Agents ride it: a Machine carries its own
|
|
22
35
|
* (ADR-0049), and the definition rides each admission, so this holds deployment facts alone
|
|
@@ -50,17 +63,40 @@ export const CA_CONFIGMAP = "jr2-ca";
|
|
|
50
63
|
/** Held secrets (ADR-0059). The per-Instance CA — its key included — that `jr2 up` alone reads over
|
|
51
64
|
* the kube API, and that NO pod mounts: every leaf is issued before a pod starts. */
|
|
52
65
|
export const HELD_CA_SECRET = "jr2-held-ca";
|
|
53
|
-
/** The literal held values
|
|
66
|
+
/** The prefix of each pod's literal held values (`heldSecretsSecret`): one Secret per pod, one key
|
|
67
|
+
* per name it holds — mounted into that pod's Custodian and nothing else. */
|
|
54
68
|
export const HELD_SECRETS_SECRET = "jr2-held-secrets";
|
|
55
|
-
/**
|
|
69
|
+
/** The literal held values of one pod (ADR-0059): `jr2-held-secrets-<pod>`. */
|
|
70
|
+
export function heldSecretsSecret(pod: string): string {
|
|
71
|
+
return `${HELD_SECRETS_SECRET}-${pod}`;
|
|
72
|
+
}
|
|
73
|
+
/** One leaf per bound `host:port` (`<stem>.crt`, `<stem>.key`) — the Custodians' alone. */
|
|
56
74
|
export const HELD_TLS_SECRET = "jr2-held-tls";
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
75
|
+
/** What `jr2 up` DECLARES about held secrets: each pod's held manifest (`heldManifestKey` — names,
|
|
76
|
+
* bindings, headers; never a value) and the Custodian's program once (`CUSTODIAN_PROGRAM_KEY`).
|
|
77
|
+
* Nothing rendered per pod lives here: a shared object stops at 1 MiB, and Envoy's files are a
|
|
78
|
+
* pod's own, rendered where the pod is made (`sandboxCustodianConfigMap`,
|
|
79
|
+
* `INSTANCE_CUSTODIAN_CONFIGMAP`). Always applied, possibly holding no secret: every Harness pod
|
|
80
|
+
* runs a Custodian. */
|
|
59
81
|
export const HELD_CONFIGMAP = "jr2-held";
|
|
60
|
-
/** Where the Orchestrator reads
|
|
61
|
-
* future Sandboxes without rolling the Orchestrator (the `jr2-images`
|
|
82
|
+
/** Where the Orchestrator reads a Workspace's held manifest, per provision — a mount, so a
|
|
83
|
+
* held-secret edit reaches future Sandboxes without rolling the Orchestrator (the `jr2-images`
|
|
84
|
+
* pattern, ADR-0038). */
|
|
62
85
|
export const HELD_MOUNT = "/etc/jr2/held";
|
|
63
|
-
|
|
86
|
+
/** The `jr2-held` key of one pod's manifest. */
|
|
87
|
+
export const heldManifestKey = (pod: string): string => `held-${pod}.json`;
|
|
88
|
+
/** The `jr2-held` key of the Custodian's fixed Lua — `custodian.lua` as written, once per Instance;
|
|
89
|
+
* each pod's own script loads it by this name from its config mount. */
|
|
90
|
+
export const CUSTODIAN_PROGRAM_KEY = "program.lua";
|
|
91
|
+
/** The ConfigMap with the Instance Harness's Custodian's two rendered files (`envoy.json`,
|
|
92
|
+
* `custodian.lua`), written by `jr2 up`: the one pod it makes. */
|
|
93
|
+
export const INSTANCE_CUSTODIAN_CONFIGMAP = "jr2-held-custodian";
|
|
94
|
+
/** The ConfigMap with one Sandbox's Custodian's two rendered files, written by the Orchestrator at
|
|
95
|
+
* provision and owned by the Sandbox CR — the `<name>-token` family: it goes with the pod. */
|
|
96
|
+
export const sandboxCustodianConfigMap = (sandbox: string): string => `${sandbox}-custodian`;
|
|
97
|
+
/** The Instance Harness's pod, as its held objects are keyed. A Workspace's is `ws-<digest>`
|
|
98
|
+
* (`workspaceHeldPod`), so the two cannot collide. */
|
|
99
|
+
export const INSTANCE_HARNESS_HELD_POD = "instance";
|
|
64
100
|
|
|
65
101
|
/** The Custodian's listeners (ADR-0059), all taken on every Harness pod (ADR-0005): the Harness's
|
|
66
102
|
* HTTPS proxy on loopback, the kubelet's probe, and the control listener the Menu and the ask ride
|
|
@@ -100,3 +136,25 @@ export const ANNOTATION_REPO_LAST_ATTACHED = "jr2.dev/last-attached";
|
|
|
100
136
|
* that started before the ask does not satisfy it. The Lease's shape: an annotation, written by
|
|
101
137
|
* the Orchestrator, read by the operator. */
|
|
102
138
|
export const askedAnnotation = (key: string): string => `jr2.dev/asked-${key}`;
|
|
139
|
+
|
|
140
|
+
/** The two PriorityClasses `jr2 up` creates, cluster-scoped, beside the CRDs (ADR-0060) — unless
|
|
141
|
+
* `priorityClasses` in jr2.config.ts names existing ones. `jr2-control` (100000,
|
|
142
|
+
* PreemptLowerPriority) is for the Orchestrator, the operator and the Repo cache agent: small, fixed,
|
|
143
|
+
* and needed by every Sandbox. `jr2-sandbox` (1000, preemptionPolicy Never) is for Sandboxes and the
|
|
144
|
+
* Instance Harness: an ordinary pod cannot preempt a live Workspace, and a waiting Sandbox evicts
|
|
145
|
+
* nobody. Both stay far below the system classes. */
|
|
146
|
+
export const PRIORITY_CLASS_CONTROL = "jr2-control";
|
|
147
|
+
export const PRIORITY_CLASS_SANDBOX = "jr2-sandbox";
|
|
148
|
+
|
|
149
|
+
/** The voluntary-disruption opt-outs (ADR-0060) a Sandbox pod and the Instance Harness pod carry:
|
|
150
|
+
* moving either loses work (`/work`; live conversations). No PodDisruptionBudget — `maxUnavailable:
|
|
151
|
+
* 0` blocks node upgrades without end. */
|
|
152
|
+
export const NO_DISRUPT_ANNOTATIONS = {
|
|
153
|
+
"cluster-autoscaler.kubernetes.io/safe-to-evict": "false",
|
|
154
|
+
"karpenter.sh/do-not-disrupt": "true",
|
|
155
|
+
} as const;
|
|
156
|
+
|
|
157
|
+
/** The API server's CA as the kubelet mounts it with the Pod's ServiceAccount. The Orchestrator
|
|
158
|
+
* Deployment points `NODE_EXTRA_CA_CERTS` at it, so the Orchestrator's own Kubernetes client
|
|
159
|
+
* (kube-client.ts, ADR-0063) trusts the API server with built-in `fetch` and no TLS code. */
|
|
160
|
+
export const SERVICE_ACCOUNT_CA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
|