@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
package/src/repo-fetch.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// would count a fetch that began BEFORE the ask. The same coalescing happens one hop earlier, in
|
|
18
18
|
// this process: asks that raise the same bar for one Sandbox and key share one mark and one wait,
|
|
19
19
|
// and past a cap on how many waits may be open the answer is the cache — the caller is inside an
|
|
20
|
-
// untrusted pod (ADR-0013), and a `git fetch` loop must not cost this process a
|
|
21
|
-
//
|
|
20
|
+
// untrusted pod (ADR-0013), and a `git fetch` loop must not cost this process a write per
|
|
21
|
+
// iteration.
|
|
22
22
|
//
|
|
23
23
|
// Freshness degrades, absence does not (ADR-0051/0053): a remote fetch that failed, or one that
|
|
24
24
|
// outran the budget, is answered as `stale` with what the cache holds — never as an error. The
|
|
@@ -26,13 +26,15 @@
|
|
|
26
26
|
// still succeeds. Only a Repo the Sandbox does not mount is a refusal: the Sandbox token's scope
|
|
27
27
|
// is the caches its own pod mounts (ADR-0013).
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
// (
|
|
31
|
-
//
|
|
29
|
+
// The mark is a merge patch through the Orchestrator's own client, and the wait is the one
|
|
30
|
+
// Sandbox watch (ADR-0063): the watch event that carries the landing answers the ask. Both are
|
|
31
|
+
// injectable, so the mapping is unit-testable against a fake API server; the kind e2e tier
|
|
32
|
+
// exercises the real thing.
|
|
32
33
|
|
|
34
|
+
import { SANDBOXES, kubeClient, type KubeClient } from "./kube-client.ts";
|
|
33
35
|
import { askedAnnotation } from "./names.ts";
|
|
34
36
|
import { repoKeyOfIdentity } from "./repo-identity.ts";
|
|
35
|
-
import {
|
|
37
|
+
import { watchSandboxes, type SandboxObject, type SandboxRepoStatus, type SandboxWatch } from "./sandbox-watch.ts";
|
|
36
38
|
|
|
37
39
|
/** What one ask settles as. `fetched` is the landing's timestamp; `stale` is git's own words (or
|
|
38
40
|
* this port's, when the budget ran out) beside the time the cache's objects are as of — `null`
|
|
@@ -54,54 +56,49 @@ export class UnmountedRepoError extends Error {}
|
|
|
54
56
|
const CACHE_BUDGET_MS = 60_000;
|
|
55
57
|
/** How many asks this Orchestrator will hold open at once, counting one per Sandbox and key. The
|
|
56
58
|
* caller is inside a pod (ADR-0013: untrusted), an Agent that loops on `git fetch` is a mode this
|
|
57
|
-
* codebase has already seen, and every open ask costs a
|
|
59
|
+
* codebase has already seen, and every open ask costs a write and a waiter in the one process
|
|
58
60
|
* that serves every run in the instance. Past the cap an ask is answered the way a failed one is —
|
|
59
61
|
* the cache, said out loud — so a pod that floods degrades its own fetches and nobody else's. */
|
|
60
62
|
const MAX_IN_FLIGHT = 16;
|
|
61
63
|
/** Watch slack on top of it: the agent's verdict has to reach the Repo CR, the operator has to
|
|
62
|
-
* compute the Sandbox entry from it, and
|
|
64
|
+
* compute the Sandbox entry from it, and the watch has to carry it here. The ADR's "nothing else owns a
|
|
63
65
|
* timeout" holds — this is the agent's budget plus the round trip, not a second policy. */
|
|
64
66
|
const WATCH_SLACK_MS = 15_000;
|
|
65
67
|
|
|
66
|
-
export type
|
|
68
|
+
export type KubeRepoFetchesOptions = {
|
|
67
69
|
/** The instance's namespace — the Sandboxes are here. */
|
|
68
70
|
namespace: string;
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
/** The clock the ask is stamped from
|
|
71
|
+
/** The Kubernetes client (ADR-0063), shared with the Sandbox port. Default: the in-cluster one. */
|
|
72
|
+
client?: KubeClient;
|
|
73
|
+
/** The one Sandbox watch, shared with the Sandbox port. Default: one started on first use. */
|
|
74
|
+
watch?: SandboxWatch;
|
|
75
|
+
/** The clock the ask is stamped from. Injectable for tests. */
|
|
74
76
|
now?: () => Date;
|
|
75
|
-
/** How often the Sandbox's status is re-read while waiting. Default 1s. */
|
|
76
|
-
pollMs?: number;
|
|
77
77
|
/** The whole wait. Default: the cache agent's on-demand budget plus watch slack. */
|
|
78
78
|
budgetMs?: number;
|
|
79
79
|
/** How many asks may be open at once. Default {@link MAX_IN_FLIGHT}. */
|
|
80
80
|
maxInFlight?: number;
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
export function
|
|
84
|
-
const exec = opts.exec ?? defaultKubectlExec;
|
|
83
|
+
export function kubeRepoFetches(opts: KubeRepoFetchesOptions): RepoFetches {
|
|
85
84
|
const now = opts.now ?? (() => new Date());
|
|
86
|
-
const pollMs = opts.pollMs ?? 1_000;
|
|
87
85
|
const budgetMs = opts.budgetMs ?? CACHE_BUDGET_MS + WATCH_SLACK_MS;
|
|
88
86
|
const maxInFlight = opts.maxInFlight ?? MAX_IN_FLIGHT;
|
|
89
|
-
|
|
87
|
+
let client: KubeClient | undefined = opts.client;
|
|
88
|
+
const kube = () => (client ??= kubeClient());
|
|
89
|
+
let watch: SandboxWatch | undefined = opts.watch;
|
|
90
|
+
const sandboxes = () => (watch ??= watchSandboxes(kube(), { namespace: opts.namespace }));
|
|
90
91
|
// The asks this port is holding open, one per Sandbox and key, each with the second its mark was
|
|
91
92
|
// raised to. The mark is a COALESCER on the node; this is the same coalescing one hop earlier,
|
|
92
93
|
// and it is exact rather than approximate: every stamp that can answer an ask is kept at the
|
|
93
94
|
// second (askedAt below), so two asks that raise the same bar CANNOT get different answers.
|
|
94
|
-
// Sharing one wait between them spares the CR a second write and this process a second
|
|
95
|
+
// Sharing one wait between them spares the CR a second write and this process a second waiter.
|
|
95
96
|
const openAsks = new Map<string, { until: number; answer: Promise<FetchAnswer> }>();
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} catch (err) {
|
|
102
|
-
if (isNotFound(err)) return undefined;
|
|
103
|
-
throw err;
|
|
104
|
-
}
|
|
98
|
+
/** The Sandbox as the watch holds it; the API server itself for one the watch has not seen (or
|
|
99
|
+
* before its first list has landed). */
|
|
100
|
+
const get = async (sandbox: string): Promise<SandboxObject | undefined> => {
|
|
101
|
+
return sandboxes().get(sandbox) ?? (await kube().get<SandboxObject>(SANDBOXES, opts.namespace, sandbox));
|
|
105
102
|
};
|
|
106
103
|
|
|
107
104
|
return {
|
|
@@ -144,38 +141,37 @@ export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches
|
|
|
144
141
|
/** The mark and the wait on it, for one Sandbox and key. Every ask that shares the mark's second
|
|
145
142
|
* shares this one promise. */
|
|
146
143
|
async function wait(sandbox: string, key: string, asked: string): Promise<FetchAnswer> {
|
|
147
|
-
// The mark.
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
144
|
+
// The mark. The merge patch answers the object as it stands afterwards, so a fetch that
|
|
145
|
+
// already landed since an earlier ask answers at once, with no event to wait for.
|
|
146
|
+
const marked = await kube().patch<SandboxObject>(SANDBOXES, opts.namespace, sandbox, {
|
|
147
|
+
metadata: { annotations: { [askedAnnotation(key)]: asked } },
|
|
148
|
+
});
|
|
149
|
+
const first = verdict(marked, key, asked);
|
|
150
|
+
if (first) return first;
|
|
151
|
+
// Then the watch: the event that carries the landing is the answer (ADR-0063). The budget is
|
|
152
|
+
// the cache agent's, and running past it looks like this from here: the agent may still be at
|
|
153
|
+
// it (its next interval will land), so what the caller gets is the cache, said out loud.
|
|
154
|
+
return new Promise<FetchAnswer>((resolve, reject) => {
|
|
155
|
+
let last: SandboxObject = marked;
|
|
156
|
+
const done = (fn: () => void) => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
unsubscribe();
|
|
159
|
+
fn();
|
|
160
|
+
};
|
|
161
|
+
const timer = setTimeout(
|
|
162
|
+
() => done(() => resolve({ stale: "timed out waiting for the node cache", asOf: fetchedOf(last, key) })),
|
|
163
|
+
budgetMs,
|
|
164
|
+
);
|
|
165
|
+
const unsubscribe = sandboxes().subscribe(sandbox, (item) => {
|
|
166
|
+
// The Sandbox went while we waited: the pod that asked is gone, so there is nothing left
|
|
167
|
+
// to answer for. The caller is inside that pod, so this is nearly unreachable — and a
|
|
168
|
+
// refusal beats inventing a verdict on a resource that no longer exists.
|
|
169
|
+
if (!item) return done(() => reject(new UnmountedRepoError(`Sandbox "${sandbox}" is gone`)));
|
|
170
|
+
last = item;
|
|
171
|
+
const answer = verdict(item, key, asked);
|
|
172
|
+
if (answer) done(() => resolve(answer));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
179
175
|
}
|
|
180
176
|
}
|
|
181
177
|
|
|
@@ -194,7 +190,7 @@ export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches
|
|
|
194
190
|
*
|
|
195
191
|
* `error` is the other verdict, and the operator scopes it to an attempt made for that same ask.
|
|
196
192
|
*/
|
|
197
|
-
function verdict(item:
|
|
193
|
+
function verdict(item: SandboxObject, key: string, asked: string): FetchAnswer | undefined {
|
|
198
194
|
const entry = entryOf(item, key);
|
|
199
195
|
if (!entry) return undefined;
|
|
200
196
|
const entryAsked = at(entry.asked);
|
|
@@ -221,11 +217,11 @@ function askedAt(stamp: string): number {
|
|
|
221
217
|
* fetch at all, which is what the program prints as "an unknown time". This is the `asOf` of a
|
|
222
218
|
* stale answer: the fetch it names did NOT answer the ask, and saying when the objects are from is
|
|
223
219
|
* the whole of what a degraded answer can offer (ADR-0053). */
|
|
224
|
-
function fetchedOf(item:
|
|
220
|
+
function fetchedOf(item: SandboxObject, key: string): string | null {
|
|
225
221
|
return entryOf(item, key)?.fetched ?? null;
|
|
226
222
|
}
|
|
227
223
|
|
|
228
|
-
function entryOf(item:
|
|
224
|
+
function entryOf(item: SandboxObject, key: string): SandboxRepoStatus | undefined {
|
|
229
225
|
return (item.status?.repos ?? []).find((r) => r.key === key);
|
|
230
226
|
}
|
|
231
227
|
|
|
@@ -236,24 +232,3 @@ function at(stamp: string | undefined): number | undefined {
|
|
|
236
232
|
const ms = Date.parse(stamp);
|
|
237
233
|
return Number.isNaN(ms) ? undefined : ms;
|
|
238
234
|
}
|
|
239
|
-
|
|
240
|
-
/** A `Sandbox` resource as `kubectl get -o json` prints it — the fields this port reads. */
|
|
241
|
-
type SandboxItem = {
|
|
242
|
-
spec?: { repos?: Array<{ key?: string }> };
|
|
243
|
-
status?: { repos?: SandboxRepoStatus[] };
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
/** The operator's standing per-key Repo entry on a Sandbox (ADR-0053). */
|
|
247
|
-
type SandboxRepoStatus = {
|
|
248
|
-
key: string;
|
|
249
|
-
asked?: string;
|
|
250
|
-
fetched?: string;
|
|
251
|
-
attempted?: string;
|
|
252
|
-
error?: string;
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
function isNotFound(err: unknown): boolean {
|
|
256
|
-
return err instanceof Error && /NotFound|not found/i.test(err.message);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
package/src/repos.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// on every node that needs the repository. What comes back is that agent's per-node status, which
|
|
7
7
|
// is what `jr2 status` reports and what a provision waits on through the Sandbox's `Ready`.
|
|
8
8
|
//
|
|
9
|
-
// The port's
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// The port's implementation (`kubeRepos`) drives the CRD the same way the Sandbox port does
|
|
10
|
+
// (sandbox-kube.ts): through the Orchestrator's own Kubernetes client (kube-client.ts, ADR-0063),
|
|
11
|
+
// injectable so the mapping is unit-testable against a fake API server.
|
|
12
12
|
//
|
|
13
13
|
// TWO SPELLINGS, ONE RESOURCE: the resource is named by the cache key, and its `spec.url` and
|
|
14
14
|
// `secretRef` are ONE statement — the boot's. The walk collapses every Machine binding an
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// eviction clock — never the url. So a Machine that binds over ssh what another bound over https
|
|
19
19
|
// borrows the cache the boot stated, and its runs never flip the resource between the two (each
|
|
20
20
|
// flip is a generation the cache agent re-points origin and refetches on). The push url is each
|
|
21
|
-
// Binding's own (
|
|
21
|
+
// Binding's own (the Harness's attach, ADR-0063), so nothing about the run is wrong; only the cache's transport is
|
|
22
22
|
// shared.
|
|
23
23
|
//
|
|
24
24
|
// A resource NOTHING binds has no boot to restate it, so the provision is its one writer: an
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { credentialSecretFor, matchCredential, type GitCredential } from "./config.ts";
|
|
35
35
|
import { ANNOTATION_REPO_IDENTITY, ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "./names.ts";
|
|
36
|
-
import {
|
|
36
|
+
import { REPOS, isAlreadyExists, kubeClient, KubeError, type KubeClient } from "./kube-client.ts";
|
|
37
37
|
|
|
38
38
|
/** One node's view of a Repo, as the cache agent reports it on the resource's status. */
|
|
39
39
|
export type RepoNodeState = {
|
|
@@ -75,8 +75,8 @@ export interface RepoResources {
|
|
|
75
75
|
* that moved the url or the credential reaches the cache. The only caller that writes a BOUND
|
|
76
76
|
* resource's spec, and never the eviction clock: a boot is not an attach. Resolves the
|
|
77
77
|
* `git.credentials` entry for the identity into the `secretRef` the cache agent reads: an https
|
|
78
|
-
* entry
|
|
79
|
-
* Secret
|
|
78
|
+
* entry names the clone Secret `jr2 up` wrote from its token, an ssh entry names its deploy-key
|
|
79
|
+
* Secret. The Orchestrator writes and reads neither (ADR-0068).
|
|
80
80
|
*/
|
|
81
81
|
bind(repo: { url: string; identity: string; key: string }): Promise<void>;
|
|
82
82
|
/**
|
|
@@ -95,63 +95,48 @@ export interface RepoResources {
|
|
|
95
95
|
list(): Promise<RepoStatus[]>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
const REPO_RESOURCE = "repos.core.jr2.dev";
|
|
100
|
-
|
|
101
|
-
export type KubectlReposOptions = {
|
|
98
|
+
export type KubeReposOptions = {
|
|
102
99
|
/** The instance's namespace — the Repo resources live beside the Sandboxes that name them. */
|
|
103
100
|
namespace: string;
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
credentials: readonly GitCredential[];
|
|
108
|
-
/**
|
|
109
|
-
* Secret
|
|
110
|
-
* clone is anonymous. */
|
|
111
|
-
|
|
101
|
+
/** The instance's `git.credentials`, matched by prefix on the identity (config.ts). A function
|
|
102
|
+
* is read on every call: the config that applies is the current Assembly's, and a publish
|
|
103
|
+
* swaps it without a new port (ADR-0069). */
|
|
104
|
+
credentials: readonly GitCredential[] | (() => readonly GitCredential[]);
|
|
105
|
+
/** The clone Secrets `jr2 up` wrote (deployed: `git-clone.json` in `jr2-held`), by name. A token
|
|
106
|
+
* entry whose Secret is not among them — its variable was unset at `jr2 up` — gives the resource
|
|
107
|
+
* no `secretRef`, and the clone is anonymous. Default: none. */
|
|
108
|
+
cloneSecrets?: () => Promise<ReadonlySet<string>>;
|
|
112
109
|
/** CR `spec.refreshInterval` — how often the cache agent fetches a warm cache. Default `5m`. */
|
|
113
110
|
refreshInterval?: string;
|
|
114
|
-
/**
|
|
115
|
-
|
|
111
|
+
/** The Kubernetes client (ADR-0063), shared with the Sandbox port. Default: the in-cluster one. */
|
|
112
|
+
client?: KubeClient;
|
|
116
113
|
/** The clock `last-attached` is stamped from. Injectable for tests. */
|
|
117
114
|
now?: () => Date;
|
|
118
115
|
};
|
|
119
116
|
|
|
120
|
-
export function
|
|
121
|
-
const exec = opts.exec ?? defaultKubectlExec;
|
|
117
|
+
export function kubeRepos(opts: KubeReposOptions): RepoResources {
|
|
122
118
|
const now = opts.now ?? (() => new Date());
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
};
|
|
119
|
+
const ns = opts.namespace;
|
|
120
|
+
// Built on first use: a boot never needs the cluster to construct this port (ADR-0048).
|
|
121
|
+
let client: KubeClient | undefined = opts.client;
|
|
122
|
+
const kube = () => (client ??= kubeClient());
|
|
141
123
|
|
|
142
124
|
/**
|
|
143
125
|
* The `secretRef` for one url: the entry the identity matches, then the url's scheme picks
|
|
144
|
-
* which of its fields applies (config.ts).
|
|
145
|
-
*
|
|
126
|
+
* which of its fields applies (config.ts). It only names the Secret (ADR-0068): `jr2 up` wrote the
|
|
127
|
+
* token's, in Flux's shape, and the cache agent reads it. A token entry whose Secret `jr2 up` did
|
|
128
|
+
* not write — its variable was unset — names none: the clone is anonymous, and `jr2 status` will
|
|
129
|
+
* show git's refusal if the host wanted one.
|
|
146
130
|
*/
|
|
147
131
|
const secretRefFor = async (url: string, identity: string): Promise<{ name: string } | undefined> => {
|
|
148
|
-
const cred = credentialSecretFor(
|
|
132
|
+
const cred = credentialSecretFor(
|
|
133
|
+
url,
|
|
134
|
+
matchCredential(identity, typeof opts.credentials === "function" ? opts.credentials() : opts.credentials),
|
|
135
|
+
);
|
|
149
136
|
if (cred === undefined) return undefined;
|
|
150
137
|
if (cred.kind === "ssh") return { name: cred.secret };
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
await applyTokenSecret(cred.secret, value);
|
|
154
|
-
return { name: cred.secret };
|
|
138
|
+
const written = (await opts.cloneSecrets?.()) ?? new Set<string>();
|
|
139
|
+
return written.has(cred.secret) ? { name: cred.secret } : undefined;
|
|
155
140
|
};
|
|
156
141
|
|
|
157
142
|
/** The resource as one statement writes it: `create` needs the whole, `bind` its spec again.
|
|
@@ -168,7 +153,7 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
|
|
|
168
153
|
kind: "Repo",
|
|
169
154
|
metadata: {
|
|
170
155
|
name: repo.key,
|
|
171
|
-
namespace:
|
|
156
|
+
namespace: ns,
|
|
172
157
|
...(repo.bound ? { labels: { [LABEL_REPO_BOUND]: "true" } } : {}),
|
|
173
158
|
annotations: {
|
|
174
159
|
[ANNOTATION_REPO_IDENTITY]: repo.identity,
|
|
@@ -185,9 +170,9 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
|
|
|
185
170
|
};
|
|
186
171
|
|
|
187
172
|
/** Create, never apply — an existing resource keeps its spec. True when this call created it. */
|
|
188
|
-
const create = async (cr:
|
|
173
|
+
const create = async (cr: RepoItem & { metadata: { name: string } }): Promise<boolean> => {
|
|
189
174
|
try {
|
|
190
|
-
await
|
|
175
|
+
await kube().create(REPOS, ns, cr);
|
|
191
176
|
return true;
|
|
192
177
|
} catch (err) {
|
|
193
178
|
if (!isAlreadyExists(err)) throw err;
|
|
@@ -196,13 +181,14 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
|
|
|
196
181
|
};
|
|
197
182
|
|
|
198
183
|
const patch = async (key: string, body: object): Promise<void> => {
|
|
199
|
-
await
|
|
184
|
+
await kube().patch(REPOS, ns, key, body);
|
|
200
185
|
};
|
|
201
186
|
|
|
202
187
|
/** One resource as it stands — what an unbound `ensure` restates its credential against. */
|
|
203
188
|
const get = async (key: string): Promise<RepoItem> => {
|
|
204
|
-
const
|
|
205
|
-
|
|
189
|
+
const item = await kube().get<RepoItem>(REPOS, ns, key);
|
|
190
|
+
if (!item) throw new Error(`Repo "${key}" vanished between its create and its read`);
|
|
191
|
+
return item;
|
|
206
192
|
};
|
|
207
193
|
|
|
208
194
|
return {
|
|
@@ -246,9 +232,9 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
|
|
|
246
232
|
|
|
247
233
|
async reconcileBound(keys) {
|
|
248
234
|
const keep = new Set(keys);
|
|
249
|
-
let
|
|
235
|
+
let items: RepoItem[];
|
|
250
236
|
try {
|
|
251
|
-
({
|
|
237
|
+
({ items } = await kube().list<RepoItem>(REPOS, ns, { labelSelector: `${LABEL_REPO_BOUND}=true` }));
|
|
252
238
|
} catch (err) {
|
|
253
239
|
// A cluster with no `repos.core.jr2.dev` resource type holds no Repos, so "nothing to
|
|
254
240
|
// unlabel" is the complete answer — the one read that may answer none. An instance that
|
|
@@ -258,23 +244,34 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
|
|
|
258
244
|
if (!isMissingResourceType(err)) throw err;
|
|
259
245
|
return;
|
|
260
246
|
}
|
|
261
|
-
for (const item of
|
|
247
|
+
for (const item of items) {
|
|
262
248
|
const name = item.metadata?.name;
|
|
263
249
|
if (name === undefined || keep.has(name)) continue;
|
|
264
|
-
//
|
|
265
|
-
await
|
|
250
|
+
// A merge patch's `null` removes the label.
|
|
251
|
+
await patch(name, { metadata: { labels: { [LABEL_REPO_BOUND]: null } } });
|
|
266
252
|
}
|
|
267
253
|
},
|
|
268
254
|
|
|
269
255
|
async list() {
|
|
270
|
-
|
|
271
|
-
|
|
256
|
+
let items: RepoItem[];
|
|
257
|
+
try {
|
|
258
|
+
({ items } = await kube().list<RepoItem>(REPOS, ns));
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// The data plane is wired on every deployed boot (ADR-0069), so `GET /repos` asks here on
|
|
261
|
+
// a cluster with no operator too. No Repo type holds no Repos: an empty list, the same
|
|
262
|
+
// answer `reconcileBound` gives. Any other refusal still throws.
|
|
263
|
+
if (!isMissingResourceType(err)) throw err;
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
return items.map(repoStatusOf);
|
|
272
267
|
},
|
|
273
268
|
};
|
|
274
269
|
}
|
|
275
270
|
|
|
276
|
-
/** A `Repo` resource as
|
|
271
|
+
/** A `Repo` resource as the API server answers it — the fields this port reads. */
|
|
277
272
|
type RepoItem = {
|
|
273
|
+
apiVersion?: string;
|
|
274
|
+
kind?: string;
|
|
278
275
|
metadata?: { name?: string; labels?: Record<string, string>; annotations?: Record<string, string> };
|
|
279
276
|
spec?: { url?: string };
|
|
280
277
|
status?: {
|
|
@@ -290,11 +287,6 @@ type RepoItem = {
|
|
|
290
287
|
};
|
|
291
288
|
};
|
|
292
289
|
|
|
293
|
-
function itemsOf(stdout: string): RepoItem[] {
|
|
294
|
-
const parsed = JSON.parse(stdout) as { items?: RepoItem[] };
|
|
295
|
-
return parsed.items ?? [];
|
|
296
|
-
}
|
|
297
|
-
|
|
298
290
|
/** The resource → what `GET /repos` reports: the Orchestrator's own metadata read back, and the
|
|
299
291
|
* cache agent's per-node entries verbatim. Absent optional fields stay absent, not `undefined`
|
|
300
292
|
* keys, so the JSON a client sees is exactly the type. */
|
|
@@ -319,12 +311,9 @@ export function repoStatusOf(item: RepoItem): RepoStatus {
|
|
|
319
311
|
};
|
|
320
312
|
}
|
|
321
313
|
|
|
322
|
-
|
|
323
|
-
|
|
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. */
|
|
314
|
+
/** The API server's answer for a kind it does not serve: 404 on the collection itself — the Repo
|
|
315
|
+
* type is the operator's to install (ADR-0051), and an instance may be deployed where nothing
|
|
316
|
+
* installed it. (An object that is absent is a 404 on the OBJECT, which a list never asks for.) */
|
|
328
317
|
function isMissingResourceType(err: unknown): boolean {
|
|
329
|
-
return
|
|
318
|
+
return err instanceof KubeError && err.status === 404;
|
|
330
319
|
}
|
package/src/rest.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Rest (ADR-0024, ADR-0029): the condition of an Agent's state under which a pick is answered.
|
|
2
|
+
//
|
|
3
|
+
// A pick that moves the workflow to a state inside the Agent's state is answered when the
|
|
4
|
+
// workflow is at rest there: no state below the Agent's state is running something (an Effect, a
|
|
5
|
+
// Gate, another Agent, a child Machine), and no pending timer will move the workflow to another
|
|
6
|
+
// state inside the Agent's state.
|
|
7
|
+
//
|
|
8
|
+
// It is read from the Machine, never kept: the snapshot (which states are active, which children
|
|
9
|
+
// run) and the timer table (`timers.ts`, the same table a restore re-arms). So a restored run gives
|
|
10
|
+
// the same answer as the run before the restart. Read it AFTER the mailbox drains — a microtask
|
|
11
|
+
// after a snapshot, or in a request of its own — never inside a subscriber: an event already queued
|
|
12
|
+
// (a `sendTo(self)` with no delay) moves the Machine before the next tick.
|
|
13
|
+
//
|
|
14
|
+
// Everything below is computed over the SET of active states below the Agent's state. Today that set
|
|
15
|
+
// is one path (a parallel region below an Agent's state is refused at load, ADR-0015), and nothing
|
|
16
|
+
// here assumes it.
|
|
17
|
+
//
|
|
18
|
+
// What the rule does not know: what an Effect is. It knows what waiting is — a live invoked child, a
|
|
19
|
+
// timer that holds. A settled child with no `onDone` is rest; a state named `rest` means nothing.
|
|
20
|
+
// Known limit: a delayed `sendTo` to a child whose reply then moves the Machine is not counted.
|
|
21
|
+
|
|
22
|
+
import type { AnyActorRef, AnyMachineSnapshot, AnyStateMachine } from "xstate";
|
|
23
|
+
import { seatOf, type PartySeat } from "./party.ts";
|
|
24
|
+
import { scheduledFor } from "./timers.ts";
|
|
25
|
+
|
|
26
|
+
type Node = AnyStateMachine["root"];
|
|
27
|
+
|
|
28
|
+
/** Is `node` active in `snapshot`? Read off the state value, a public shape. */
|
|
29
|
+
function isActive(snapshot: AnyMachineSnapshot, node: Node): boolean {
|
|
30
|
+
let value: unknown = snapshot.value;
|
|
31
|
+
for (const key of node.path) {
|
|
32
|
+
if (typeof value === "string") return value === key && node.path.at(-1) === key;
|
|
33
|
+
if (!value || typeof value !== "object" || !(key in value)) return false;
|
|
34
|
+
value = (value as Record<string, unknown>)[key];
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every active state strictly below `s`: a set, which is one path today and may be more. */
|
|
40
|
+
export function activeBelow(snapshot: AnyMachineSnapshot, s: Node): Node[] {
|
|
41
|
+
const out: Node[] = [];
|
|
42
|
+
const visit = (node: Node, value: unknown): void => {
|
|
43
|
+
if (node.type === "parallel") {
|
|
44
|
+
for (const child of Object.values(node.states)) {
|
|
45
|
+
out.push(child);
|
|
46
|
+
visit(child, value && typeof value === "object" ? (value as Record<string, unknown>)[child.key] : undefined);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const child = node.states[value];
|
|
52
|
+
if (child) {
|
|
53
|
+
out.push(child);
|
|
54
|
+
visit(child, undefined);
|
|
55
|
+
}
|
|
56
|
+
} else if (value && typeof value === "object") {
|
|
57
|
+
for (const [key, sub] of Object.entries(value)) {
|
|
58
|
+
const child = node.states[key];
|
|
59
|
+
if (!child) continue;
|
|
60
|
+
out.push(child);
|
|
61
|
+
visit(child, sub);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
let value: unknown = snapshot.value;
|
|
66
|
+
for (const key of s.path)
|
|
67
|
+
value = value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
|
68
|
+
visit(s, value);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isInside = (node: Node, s: Node): boolean => {
|
|
73
|
+
for (let n: Node | undefined = node; n; n = n.parent) if (n === s) return true;
|
|
74
|
+
return false;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The pending timers that hold Rest: a timer whose event, delivered to this Machine, is handled by a
|
|
79
|
+
* transition of an active state at or below `s` whose target is inside `s` (or `s` itself, from `s`,
|
|
80
|
+
* without re-entering it — re-entering ends the Turn). Guards are not run: a guarded handler holds
|
|
81
|
+
* until the timer fires, and then nothing is pending. A timer whose handler leaves `s` (a Turn
|
|
82
|
+
* timeout) does not hold: when it fires, the Turn ends (ADR-0024).
|
|
83
|
+
*/
|
|
84
|
+
export function holdingTimers(machine: AnyActorRef, s: Node): string[] {
|
|
85
|
+
const snapshot = machine.getSnapshot() as AnyMachineSnapshot;
|
|
86
|
+
const active = [s, ...activeBelow(snapshot, s)];
|
|
87
|
+
return scheduledFor(machine)
|
|
88
|
+
.filter((t) =>
|
|
89
|
+
active.some((node) =>
|
|
90
|
+
(node.transitions.get(t.event.type) ?? []).some((tr) =>
|
|
91
|
+
((tr.target ?? []) as Node[]).some((target) =>
|
|
92
|
+
target === s ? node === s && !tr.reenter : isInside(target, s),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
.map((t) => t.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The invoke ids of the live children of active states strictly below `s`. The Agent's own invoke
|
|
101
|
+
* is on `s` and does not count. */
|
|
102
|
+
export function runningBelow(machine: AnyActorRef, s: Node): string[] {
|
|
103
|
+
const snapshot = machine.getSnapshot() as AnyMachineSnapshot;
|
|
104
|
+
const children = snapshot.children as Record<string, AnyActorRef | undefined>;
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
for (const node of activeBelow(snapshot, s))
|
|
107
|
+
for (const inv of node.invoke) if (children[inv.id]?.getSnapshot().status === "active") out.push(inv.id);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Is the Machine at Rest in `s`? Nothing below it runs, and no pending timer holds. */
|
|
112
|
+
export function atRest(machine: AnyActorRef, s: Node): boolean {
|
|
113
|
+
return runningBelow(machine, s).length === 0 && holdingTimers(machine, s).length === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where an Agent's registration stands: `rest`, `busy` (its state is active and not at Rest), or
|
|
118
|
+
* `gone` (its state is no longer active: the Turn is over, ADR-0024). A registration with no seat to
|
|
119
|
+
* read (a plain `setup()` tree, a bare test entry) reads `rest` — today's behavior, never a false
|
|
120
|
+
* hold.
|
|
121
|
+
*/
|
|
122
|
+
export type RestReading = "rest" | "busy" | "gone";
|
|
123
|
+
|
|
124
|
+
export function restOf(reg: PartySeat): RestReading {
|
|
125
|
+
if (!reg.invoker || !reg.invokeId) return "rest";
|
|
126
|
+
const snapshot = reg.invoker.getSnapshot() as AnyMachineSnapshot | undefined;
|
|
127
|
+
if (!snapshot || !(snapshot.machine as AnyStateMachine | undefined)?.root) return "rest";
|
|
128
|
+
if (snapshot.status !== "active") return "gone";
|
|
129
|
+
const seat = seatOf(snapshot, reg.invokeId);
|
|
130
|
+
if (!seat) return "rest";
|
|
131
|
+
if (!isActive(snapshot, seat)) return "gone";
|
|
132
|
+
return atRest(reg.invoker, seat) ? "rest" : "busy";
|
|
133
|
+
}
|