@jr2/orchestrator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
package/src/repos.ts ADDED
@@ -0,0 +1,330 @@
1
+ // The Repo-resource port (ADR-0051): how the Orchestrator creates `Repo` custom resources and reads
2
+ // their state back. The Orchestrator CREATES Repos and never syncs them — at boot, one per identity
3
+ // its registered Machines bind (so statically known repositories are known — probed — before a
4
+ // run asks; a node clones on first demand), and
5
+ // at first attach for a per-run url — and the operator's cache agent does the cloning and fetching
6
+ // on every node that needs the repository. What comes back is that agent's per-node status, which
7
+ // is what `jr2 status` reports and what a provision waits on through the Sandbox's `Ready`.
8
+ //
9
+ // The port's kubectl implementation (`kubectlRepos`) drives the CRD the same way the Sandbox port
10
+ // does (sandbox-kubectl.ts): shelling to `kubectl`, honoring the current kube context, with the
11
+ // process seam injectable so the mapping is unit-testable without a cluster.
12
+ //
13
+ // TWO SPELLINGS, ONE RESOURCE: the resource is named by the cache key, and its `spec.url` and
14
+ // `secretRef` are ONE statement — the boot's. The walk collapses every Machine binding an
15
+ // identity to the first spelling it meets (parts.ts), and `bind` states that spelling: created on
16
+ // the first deploy, restated on every later one, so a config that moved the url or the credential
17
+ // reaches the cache at the next boot. A provision only `ensure`s: created if absent, otherwise the
18
+ // eviction clock — never the url. So a Machine that binds over ssh what another bound over https
19
+ // borrows the cache the boot stated, and its runs never flip the resource between the two (each
20
+ // flip is a generation the cache agent re-points origin and refetches on). The push url is each
21
+ // Binding's own (attachScript), so nothing about the run is wrong; only the cache's transport is
22
+ // shared.
23
+ //
24
+ // A resource NOTHING binds has no boot to restate it, so the provision is its one writer: an
25
+ // `ensure` of an existing unbound resource re-resolves the `secretRef` against the url that
26
+ // stands — the first attach's spelling, which the run's own spelling never replaces — so a
27
+ // `git.credentials` entry fixed after a failed clone reaches the cache at the next run, the path
28
+ // the clone error and `jr2 status` name (ADR-0048).
29
+ //
30
+ // The eviction clock is the RUN's: only an `ensure` stamps `last-attached`. The boot's `bind`
31
+ // touches the label and the spec, never the clock, so a Repo the boot created and no run attached
32
+ // carries no stamp and `jr2 gc` dates it from its `creationTimestamp` — a boot is not an attach.
33
+
34
+ import { credentialSecretFor, matchCredential, type GitCredential } from "./config.ts";
35
+ import { ANNOTATION_REPO_IDENTITY, ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "./names.ts";
36
+ import { defaultKubectlExec, type KubectlExec } from "./sandbox-kubectl.ts";
37
+
38
+ /** One node's view of a Repo, as the cache agent reports it on the resource's status. */
39
+ export type RepoNodeState = {
40
+ node: string;
41
+ /** A clone exists on this node. */
42
+ present: boolean;
43
+ /** The last attempt — a probe, a clone, or a fetch — succeeded. */
44
+ synced: boolean;
45
+ /** Which of the three the last attempt was. A failed Probe is `jr2 status`'s signal for a Repo
46
+ * no pod on the node mounts yet; only a failed Clone fails a Sandbox waiting on that node. */
47
+ attempted?: "Probe" | "Clone" | "Fetch";
48
+ lastAttempt?: string;
49
+ /** The last successful clone or fetch. */
50
+ lastFetched?: string;
51
+ /** git's own words; absent when synced. */
52
+ lastError?: string;
53
+ };
54
+
55
+ /** One `Repo` resource as the instance reports it (`GET /repos`). */
56
+ export type RepoStatus = {
57
+ /** The cache key (repo-identity.ts): the resource's name, the hostPath leaf, `/repos/<key>`. */
58
+ key: string;
59
+ /** The Binding's own spelling — what the cache clones. */
60
+ url: string;
61
+ identity?: string;
62
+ /** A registered Machine binds it — never evicted by `jr2 gc`. */
63
+ bound: boolean;
64
+ /** When a run last attached it — the eviction clock for a Repo nothing binds. Absent while only
65
+ * the boot has stated it: a bound Repo no run has attached yet. */
66
+ lastAttached?: string;
67
+ nodes: RepoNodeState[];
68
+ };
69
+
70
+ /** The port a deployed Orchestrator drives its Repo resources through. */
71
+ export interface RepoResources {
72
+ /**
73
+ * The boot's statement of a Machine's resolution (ADR-0051): create the resource if absent,
74
+ * otherwise restate its url, `secretRef`, and the bound label `jr2 gc` honors — so a redeploy
75
+ * that moved the url or the credential reaches the cache. The only caller that writes a BOUND
76
+ * resource's spec, and never the eviction clock: a boot is not an attach. Resolves the
77
+ * `git.credentials` entry for the identity into the `secretRef` the cache agent reads: an https
78
+ * entry's token materializes as a Secret in Flux's shape, an ssh entry names its deploy-key
79
+ * Secret and the Orchestrator never reads it.
80
+ */
81
+ bind(repo: { url: string; identity: string; key: string }): Promise<void>;
82
+ /**
83
+ * A provision's: create the resource if absent — labeled bound when a Machine's slot names
84
+ * it, so one born before the boot recorded it is not on `jr2 gc`'s clock — and annotate it
85
+ * attached now. An existing resource gets the clock; its url stands (the boot's statement, or
86
+ * the first attach's), and a run of a Machine spelling the identity differently must not
87
+ * rewrite it. One a Machine binds gets nothing else — the boot restates its credential. One
88
+ * nothing binds has no boot, so the provision restates its `secretRef`, resolved against the
89
+ * url that stands: the fix the clone error names reaches the cache at the next run.
90
+ */
91
+ ensure(repo: { url: string; identity: string; key: string; bound: boolean }): Promise<void>;
92
+ /** Drop the bound label from every resource whose key is not in `keys` — a slot unbound since
93
+ * the last deploy is a Repo `jr2 gc` may now evict. */
94
+ reconcileBound(keys: Iterable<string>): Promise<void>;
95
+ list(): Promise<RepoStatus[]>;
96
+ }
97
+
98
+ /** The CRD's fully qualified plural — unambiguous to kubectl whatever else calls itself a repo. */
99
+ const REPO_RESOURCE = "repos.core.jr2.dev";
100
+
101
+ export type KubectlReposOptions = {
102
+ /** The instance's namespace — the Repo resources live beside the Sandboxes that name them. */
103
+ namespace: string;
104
+ /** kubectl `--context` override. Default: the current context (ADR-0009). */
105
+ context?: string;
106
+ /** The instance's `git.credentials`, matched by prefix on the identity (config.ts). */
107
+ credentials: readonly GitCredential[];
108
+ /** Where a token entry's env var is read from (deployed: `process.env`, which the Instance
109
+ * Secret's `envFrom` populated at `jr2 up`). Unset → the resource carries no `secretRef` and the
110
+ * clone is anonymous. */
111
+ env: Record<string, string | undefined>;
112
+ /** CR `spec.refreshInterval` — how often the cache agent fetches a warm cache. Default `5m`. */
113
+ refreshInterval?: string;
114
+ /** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
115
+ exec?: KubectlExec;
116
+ /** The clock `last-attached` is stamped from. Injectable for tests. */
117
+ now?: () => Date;
118
+ };
119
+
120
+ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
121
+ const exec = opts.exec ?? defaultKubectlExec;
122
+ const now = opts.now ?? (() => new Date());
123
+ const base = ["--namespace", opts.namespace, ...(opts.context ? ["--context", opts.context] : [])];
124
+
125
+ /**
126
+ * The Secret the cache agent spends for an https url, in Flux's shape (`username`/`password`),
127
+ * so a Flux or Argo user reuses the Secret they have. Its name is derived from the entry's
128
+ * `match`, so a redeploy finds its own and two entries never share one. Applied (create-or-
129
+ * update) on every ensure: a token rotated by `jr2 up` reaches the Secret at the next boot.
130
+ */
131
+ const applyTokenSecret = async (name: string, password: string): Promise<void> => {
132
+ const secret = {
133
+ apiVersion: "v1",
134
+ kind: "Secret",
135
+ metadata: { name, namespace: opts.namespace, labels: { "app.kubernetes.io/managed-by": "jr2" } },
136
+ type: "Opaque",
137
+ stringData: { username: "x-access-token", password },
138
+ };
139
+ await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(secret) });
140
+ };
141
+
142
+ /**
143
+ * The `secretRef` for one url: the entry the identity matches, then the url's scheme picks
144
+ * which of its fields applies (config.ts). A token entry whose env var is unset writes no ref —
145
+ * the clone is anonymous, and `jr2 status` will show git's refusal if the host wanted one.
146
+ */
147
+ const secretRefFor = async (url: string, identity: string): Promise<{ name: string } | undefined> => {
148
+ const cred = credentialSecretFor(url, matchCredential(identity, opts.credentials));
149
+ if (cred === undefined) return undefined;
150
+ if (cred.kind === "ssh") return { name: cred.secret };
151
+ const value = opts.env[cred.env];
152
+ if (value === undefined || value === "") return undefined;
153
+ await applyTokenSecret(cred.secret, value);
154
+ return { name: cred.secret };
155
+ };
156
+
157
+ /** The resource as one statement writes it: `create` needs the whole, `bind` its spec again.
158
+ * `attached` is the run's clock — a provision passes it, the boot does not. */
159
+ const resourceFor = async (
160
+ repo: { url: string; identity: string; key: string; bound: boolean },
161
+ attached?: string,
162
+ ) => {
163
+ const secretRef = await secretRefFor(repo.url, repo.identity);
164
+ return {
165
+ secretRef,
166
+ cr: {
167
+ apiVersion: "core.jr2.dev/v1alpha1",
168
+ kind: "Repo",
169
+ metadata: {
170
+ name: repo.key,
171
+ namespace: opts.namespace,
172
+ ...(repo.bound ? { labels: { [LABEL_REPO_BOUND]: "true" } } : {}),
173
+ annotations: {
174
+ [ANNOTATION_REPO_IDENTITY]: repo.identity,
175
+ ...(attached !== undefined ? { [ANNOTATION_REPO_LAST_ATTACHED]: attached } : {}),
176
+ },
177
+ },
178
+ spec: {
179
+ url: repo.url,
180
+ ...(secretRef ? { secretRef } : {}),
181
+ refreshInterval: opts.refreshInterval ?? "5m",
182
+ },
183
+ },
184
+ };
185
+ };
186
+
187
+ /** Create, never apply — an existing resource keeps its spec. True when this call created it. */
188
+ const create = async (cr: object): Promise<boolean> => {
189
+ try {
190
+ await exec(["create", ...base, "-f", "-"], { input: JSON.stringify(cr) });
191
+ return true;
192
+ } catch (err) {
193
+ if (!isAlreadyExists(err)) throw err;
194
+ return false;
195
+ }
196
+ };
197
+
198
+ const patch = async (key: string, body: object): Promise<void> => {
199
+ await exec(["patch", REPO_RESOURCE, key, ...base, "--type", "merge", "-p", JSON.stringify(body)]);
200
+ };
201
+
202
+ /** One resource as it stands — what an unbound `ensure` restates its credential against. */
203
+ const get = async (key: string): Promise<RepoItem> => {
204
+ const { stdout } = await exec(["get", REPO_RESOURCE, key, ...base, "-o", "json"]);
205
+ return JSON.parse(stdout) as RepoItem;
206
+ };
207
+
208
+ return {
209
+ async bind(repo) {
210
+ const { secretRef, cr } = await resourceFor({ ...repo, bound: true });
211
+ if (await create(cr)) return;
212
+ // Already there from an earlier deploy: ONE merge patch says what the Machine resolves now —
213
+ // url, credential, and the label `jr2 gc` honors. `secretRef: null` clears a credential the
214
+ // config no longer names, so a dropped entry does not linger. The clock is not touched: it
215
+ // records attaches, and a boot is not one.
216
+ await patch(repo.key, {
217
+ metadata: {
218
+ labels: { [LABEL_REPO_BOUND]: "true" },
219
+ annotations: { [ANNOTATION_REPO_IDENTITY]: repo.identity },
220
+ },
221
+ spec: { url: repo.url, secretRef: secretRef ?? null },
222
+ });
223
+ },
224
+
225
+ async ensure(repo) {
226
+ const attached = now().toISOString();
227
+ const { cr } = await resourceFor(repo, attached);
228
+ if (await create(cr)) return;
229
+ // Already there — the boot's statement, or an earlier run's. The eviction clock moves: the
230
+ // run attached it now. The url stays as stated, whatever spelling this run brought.
231
+ const clock = { metadata: { annotations: { [ANNOTATION_REPO_LAST_ATTACHED]: attached } } };
232
+ // A Machine's slot binds it: the boot is its writer, and restates the credential at the next
233
+ // deploy. Read nothing.
234
+ if (repo.bound) return patch(repo.key, clock);
235
+ // This run's slot is per-run, but another registered Machine may bind the identity — the
236
+ // label says so, and then the boot is still the writer.
237
+ const standing = await get(repo.key);
238
+ if (standing.metadata?.labels?.[LABEL_REPO_BOUND] === "true") return patch(repo.key, clock);
239
+ // Nothing binds it: no boot ever restates it, so this attach does — the credential resolved
240
+ // against the url that STANDS, since the scheme picks the Secret's kind (config.ts) and the
241
+ // cache clones that url, not this run's spelling of it. `secretRef: null` clears an entry
242
+ // the config no longer names.
243
+ const secretRef = await secretRefFor(standing.spec?.url ?? repo.url, repo.identity);
244
+ await patch(repo.key, { ...clock, spec: { secretRef: secretRef ?? null } });
245
+ },
246
+
247
+ async reconcileBound(keys) {
248
+ const keep = new Set(keys);
249
+ let stdout: string;
250
+ try {
251
+ ({ stdout } = await exec(["get", REPO_RESOURCE, ...base, "-l", `${LABEL_REPO_BOUND}=true`, "-o", "json"]));
252
+ } catch (err) {
253
+ // A cluster with no `repos.core.jr2.dev` resource type holds no Repos, so "nothing to
254
+ // unlabel" is the complete answer — the one read that may answer none. An instance that
255
+ // binds nothing runs this reconcile on every boot (server.ts), and `operator.manage: false`
256
+ // without the operator is exactly the cluster where the type is absent: an error line there
257
+ // would report a failure that is not one. Any other refusal still throws.
258
+ if (!isMissingResourceType(err)) throw err;
259
+ return;
260
+ }
261
+ for (const item of itemsOf(stdout)) {
262
+ const name = item.metadata?.name;
263
+ if (name === undefined || keep.has(name)) continue;
264
+ // `<label>-` is kubectl's spelling for "remove the label".
265
+ await exec(["label", REPO_RESOURCE, name, ...base, `${LABEL_REPO_BOUND}-`]);
266
+ }
267
+ },
268
+
269
+ async list() {
270
+ const { stdout } = await exec(["get", REPO_RESOURCE, ...base, "-o", "json"]);
271
+ return itemsOf(stdout).map(repoStatusOf);
272
+ },
273
+ };
274
+ }
275
+
276
+ /** A `Repo` resource as `kubectl get -o json` prints it — the fields this port reads. */
277
+ type RepoItem = {
278
+ metadata?: { name?: string; labels?: Record<string, string>; annotations?: Record<string, string> };
279
+ spec?: { url?: string };
280
+ status?: {
281
+ nodes?: Array<{
282
+ node: string;
283
+ present?: boolean;
284
+ synced?: boolean;
285
+ attempted?: "Probe" | "Clone" | "Fetch";
286
+ lastAttempt?: string;
287
+ lastFetched?: string;
288
+ lastError?: string;
289
+ }>;
290
+ };
291
+ };
292
+
293
+ function itemsOf(stdout: string): RepoItem[] {
294
+ const parsed = JSON.parse(stdout) as { items?: RepoItem[] };
295
+ return parsed.items ?? [];
296
+ }
297
+
298
+ /** The resource → what `GET /repos` reports: the Orchestrator's own metadata read back, and the
299
+ * cache agent's per-node entries verbatim. Absent optional fields stay absent, not `undefined`
300
+ * keys, so the JSON a client sees is exactly the type. */
301
+ export function repoStatusOf(item: RepoItem): RepoStatus {
302
+ const identity = item.metadata?.annotations?.[ANNOTATION_REPO_IDENTITY];
303
+ const lastAttached = item.metadata?.annotations?.[ANNOTATION_REPO_LAST_ATTACHED];
304
+ return {
305
+ key: item.metadata?.name ?? "",
306
+ url: item.spec?.url ?? "",
307
+ ...(identity !== undefined ? { identity } : {}),
308
+ bound: item.metadata?.labels?.[LABEL_REPO_BOUND] === "true",
309
+ ...(lastAttached !== undefined ? { lastAttached } : {}),
310
+ nodes: (item.status?.nodes ?? []).map((n) => ({
311
+ node: n.node,
312
+ present: n.present ?? false,
313
+ synced: n.synced ?? false,
314
+ ...(n.attempted !== undefined ? { attempted: n.attempted } : {}),
315
+ ...(n.lastAttempt !== undefined ? { lastAttempt: n.lastAttempt } : {}),
316
+ ...(n.lastFetched !== undefined ? { lastFetched: n.lastFetched } : {}),
317
+ ...(n.lastError !== undefined && n.lastError !== "" ? { lastError: n.lastError } : {}),
318
+ })),
319
+ };
320
+ }
321
+
322
+ function isAlreadyExists(err: unknown): boolean {
323
+ return err instanceof Error && /AlreadyExists|already exists/i.test(err.message);
324
+ }
325
+
326
+ /** kubectl's words for "this cluster has no such CRD" — the Repo type is the operator's to install
327
+ * (ADR-0051), and an instance may be deployed where nothing installed it. */
328
+ function isMissingResourceType(err: unknown): boolean {
329
+ return /doesn't have a resource type/i.test(err instanceof Error ? err.message : String(err));
330
+ }