@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,525 @@
|
|
|
1
|
+
// The Effector's wire, from the Orchestrator's side (ADR-0068): the request it encodes, the
|
|
2
|
+
// answers it decodes, the signatures on both, and the client that speaks it.
|
|
3
|
+
//
|
|
4
|
+
// The types are written by hand, here and in Go (`effector/internal/wire`). Here each zod shape is
|
|
5
|
+
// the one definition, and each type is inferred from it.
|
|
6
|
+
// The golden fixtures in `effector/testdata/wire/` keep the two equal: these tests encode each
|
|
7
|
+
// request fixture's shape and decode each answer; the Go tests do the reverse. An answer that does
|
|
8
|
+
// not decode strictly is a jr2 defect, never an outcome.
|
|
9
|
+
//
|
|
10
|
+
// No bearer: every request is signed with the placement's key (`effectorKey`), and every answer
|
|
11
|
+
// must carry the Effector's signature over its status, the request's method, path and nonce, and
|
|
12
|
+
// its body — an answer bound to this request. An unsigned or mis-signed answer is not the Effector's.
|
|
13
|
+
|
|
14
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { lookup } from "node:dns/promises";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
// --- The request (POST /effects/<id>) ------------------------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// Each zod shape is the one definition of its part of the wire; each type is inferred from it.
|
|
21
|
+
|
|
22
|
+
export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
|
|
23
|
+
export const HttpMethodShape = z.enum(HTTP_METHODS);
|
|
24
|
+
export type HttpMethod = z.infer<typeof HttpMethodShape>;
|
|
25
|
+
|
|
26
|
+
/** One part of a string the Effector builds: literal text, or a secret it holds, by name. A
|
|
27
|
+
* secret's value is never on the wire. */
|
|
28
|
+
export const TextPartShape = z.union([z.strictObject({ text: z.string() }), z.strictObject({ secret: z.string() })]);
|
|
29
|
+
export type TextPart = z.infer<typeof TextPartShape>;
|
|
30
|
+
|
|
31
|
+
export const WireAuthShape = z.union([
|
|
32
|
+
z.strictObject({ bearer: z.strictObject({ secret: z.string() }) }),
|
|
33
|
+
z.strictObject({ basic: z.strictObject({ user: z.array(TextPartShape), password: z.array(TextPartShape) }) }),
|
|
34
|
+
z.strictObject({ text: z.array(TextPartShape) }),
|
|
35
|
+
]);
|
|
36
|
+
export type WireAuth = z.infer<typeof WireAuthShape>;
|
|
37
|
+
|
|
38
|
+
/** A Worktree file (a file-body `http`, ADR-0067): the Worktree, relative to the work
|
|
39
|
+
* root, and the file, relative to the Worktree. The Sandbox Effector opens both beneath, with no
|
|
40
|
+
* symlink, and refuses a FIFO and a file with more than one link — output, with nothing sent. */
|
|
41
|
+
export const FileRefShape = z.strictObject({
|
|
42
|
+
worktree: z.string(),
|
|
43
|
+
path: z.string(),
|
|
44
|
+
contentType: z.string().optional(),
|
|
45
|
+
});
|
|
46
|
+
export type FileRef = z.infer<typeof FileRefShape>;
|
|
47
|
+
|
|
48
|
+
export const WireBodyShape = z.union([
|
|
49
|
+
z.strictObject({ json: z.unknown() }),
|
|
50
|
+
z.strictObject({ text: z.string() }),
|
|
51
|
+
z.strictObject({ file: FileRefShape }),
|
|
52
|
+
]);
|
|
53
|
+
export type WireBody = z.infer<typeof WireBodyShape>;
|
|
54
|
+
|
|
55
|
+
/** The headers the Effector sets itself, which a definition may not: they frame or route the
|
|
56
|
+
* request. A `proxy-*` name is one too. The Go side's `framing` is the same list; the reject
|
|
57
|
+
* fixtures hold one start per name, which each side refuses. */
|
|
58
|
+
export const FRAMING_HEADERS: ReadonlySet<string> = new Set([
|
|
59
|
+
"host",
|
|
60
|
+
"content-length",
|
|
61
|
+
"transfer-encoding",
|
|
62
|
+
"connection",
|
|
63
|
+
"upgrade",
|
|
64
|
+
"te",
|
|
65
|
+
"trailer",
|
|
66
|
+
"keep-alive",
|
|
67
|
+
]);
|
|
68
|
+
const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
69
|
+
|
|
70
|
+
/** A header an Effect may set: a lowercase name that does not frame or route the request. */
|
|
71
|
+
export const isEffectHeader = (name: string): boolean =>
|
|
72
|
+
HEADER_NAME.test(name) && !FRAMING_HEADERS.has(name) && !name.startsWith("proxy-");
|
|
73
|
+
|
|
74
|
+
/** An `http` start; its body may be a Worktree file (ADR-0067), which only a Sandbox Effector serves. */
|
|
75
|
+
export const HttpStartShape = z.strictObject({
|
|
76
|
+
kind: z.literal("http"),
|
|
77
|
+
method: HttpMethodShape,
|
|
78
|
+
url: z.array(TextPartShape),
|
|
79
|
+
path: z.array(z.string()).optional(),
|
|
80
|
+
headers: z.record(z.string().refine(isEffectHeader), z.array(TextPartShape)).optional(),
|
|
81
|
+
auth: WireAuthShape.optional(),
|
|
82
|
+
body: WireBodyShape.optional(),
|
|
83
|
+
timeoutMs: z.number().int(),
|
|
84
|
+
maxBody: z.number().int(),
|
|
85
|
+
});
|
|
86
|
+
export type HttpStartRequest = z.infer<typeof HttpStartShape>;
|
|
87
|
+
|
|
88
|
+
/** Where one Repo Slot's repositories are in a Sandbox pod (ADR-0004, ADR-0051): the Agent's
|
|
89
|
+
* common git dir and Worktree, relative to `/work`, and the node cache's key under `/repos`. */
|
|
90
|
+
export const RepoRefShape = z.strictObject({ gitDir: z.string(), worktree: z.string(), cache: z.string() });
|
|
91
|
+
export type RepoRef = z.infer<typeof RepoRefShape>;
|
|
92
|
+
|
|
93
|
+
/** One push's published commit and the Agent's commit it was made from: a ledger push row's pair,
|
|
94
|
+
* with that push's own `squashed` — what its published commit is, which a later push that maps the
|
|
95
|
+
* same commit names (ADR-0067). */
|
|
96
|
+
export const PairShape = z.strictObject({
|
|
97
|
+
published: z.string(),
|
|
98
|
+
source: z.string(),
|
|
99
|
+
squashed: z.enum(["signed", "unsigned"]).optional(),
|
|
100
|
+
});
|
|
101
|
+
export type Pair = z.infer<typeof PairShape>;
|
|
102
|
+
|
|
103
|
+
/** A `push` start: the Workspace's branch to the Repo Slot's Binding, with the push credential of
|
|
104
|
+
* the `git.credentials` entry the url falls under — the Effector's config holds the entries, and
|
|
105
|
+
* matches the url as the fence does (`credential` names one by its `match` instead). `pin` is the
|
|
106
|
+
* commit; absent, the Effector reads the Agent's branch tip and its answer carries it (`pinned`). */
|
|
107
|
+
export const PushStartShape = z.strictObject({
|
|
108
|
+
kind: z.literal("push"),
|
|
109
|
+
push: z.strictObject({
|
|
110
|
+
repo: RepoRefShape,
|
|
111
|
+
url: z.string(),
|
|
112
|
+
branch: z.string(),
|
|
113
|
+
credential: z.string().optional(),
|
|
114
|
+
pin: z.string().optional(),
|
|
115
|
+
sign: z.boolean().optional(),
|
|
116
|
+
squash: z.string().optional(),
|
|
117
|
+
force: z.boolean().optional(),
|
|
118
|
+
/** Every pair the ledger holds for the Workspace's Repo Slot, oldest push first (ADR-0067):
|
|
119
|
+
* omitted when there is none, never empty. */
|
|
120
|
+
pairs: z.array(PairShape).min(1).optional(),
|
|
121
|
+
}),
|
|
122
|
+
timeoutMs: z.number().int(),
|
|
123
|
+
});
|
|
124
|
+
export type PushStartRequest = z.infer<typeof PushStartShape>;
|
|
125
|
+
|
|
126
|
+
/** One variable of a credentialed command's env: a secret's value, or (`path`) its file's path. */
|
|
127
|
+
export const EnvRefShape = z.strictObject({ secret: z.string(), path: z.boolean().optional() });
|
|
128
|
+
export type EnvRef = z.infer<typeof EnvRefShape>;
|
|
129
|
+
|
|
130
|
+
/** A credentialed `command` start: argv against the pinned commit, with only the env named. */
|
|
131
|
+
export const CommandStartShape = z.strictObject({
|
|
132
|
+
kind: z.literal("command"),
|
|
133
|
+
command: z.strictObject({
|
|
134
|
+
repo: RepoRefShape,
|
|
135
|
+
branch: z.string(),
|
|
136
|
+
pin: z.string().optional(),
|
|
137
|
+
argv: z.array(z.string()).min(1),
|
|
138
|
+
env: z.record(z.string(), EnvRefShape),
|
|
139
|
+
tail: z.number().int(),
|
|
140
|
+
}),
|
|
141
|
+
timeoutMs: z.number().int(),
|
|
142
|
+
});
|
|
143
|
+
export type CommandStartRequest = z.infer<typeof CommandStartShape>;
|
|
144
|
+
|
|
145
|
+
/** Every start: the http kinds, a push, a credentialed command. */
|
|
146
|
+
export const StartRequestShape = z.union([HttpStartShape, PushStartShape, CommandStartShape]);
|
|
147
|
+
export type StartRequest = z.infer<typeof StartRequestShape>;
|
|
148
|
+
|
|
149
|
+
/** The most bytes of a start's body the Effector reads (its `maxRequestBody`). A longer body
|
|
150
|
+
* cannot be verified, so the Effector refuses it as unsigned; the Orchestrator never sends one. */
|
|
151
|
+
export const MAX_START_BYTES = 4 << 20;
|
|
152
|
+
|
|
153
|
+
// --- The answers ---------------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** The `response` outcome: a status came back. The body is there when it is text, bounded and
|
|
156
|
+
* scrubbed; a body that is not text is its size and content type only. */
|
|
157
|
+
export const HttpResponseShape = z.strictObject({
|
|
158
|
+
outcome: z.literal("response"),
|
|
159
|
+
status: z.number().int(),
|
|
160
|
+
headers: z.record(z.string(), z.string()),
|
|
161
|
+
contentType: z.string(),
|
|
162
|
+
size: z.number().int().nonnegative(),
|
|
163
|
+
body: z.string().optional(),
|
|
164
|
+
truncated: z.boolean().optional(),
|
|
165
|
+
});
|
|
166
|
+
export type HttpResponse = z.infer<typeof HttpResponseShape>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An `http` Effect's output. Every outcome is output (ADR-0067): `response`; `unreachable` (nothing
|
|
170
|
+
* was sent); `refused` (the Effector refused it and sent nothing — a path segment that is `.`, `..`
|
|
171
|
+
* or empty, a host no Effect placed there names); `unknown` (it may have been sent, and no answer
|
|
172
|
+
* came — or jr2 cannot tell, after a restart or a state exit).
|
|
173
|
+
*/
|
|
174
|
+
export const HttpOutputShape = z.union([
|
|
175
|
+
HttpResponseShape,
|
|
176
|
+
z.strictObject({ outcome: z.enum(["unreachable", "refused", "unknown"]), reason: z.string() }),
|
|
177
|
+
]);
|
|
178
|
+
export type HttpOutput = z.infer<typeof HttpOutputShape>;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A `push` Effect's output (ADR-0067). `pushed`: the published commit, its source (the Agent's
|
|
182
|
+
* commit), what the remote held before, how the new commits were found after a signed push
|
|
183
|
+
* (`ancestry | pair | cherry | force`), `unchanged` when the remote held it already, `squashed`
|
|
184
|
+
* when the published commit is one squashed commit (`signed | unsigned`; absent for signed
|
|
185
|
+
* copies; what the published commit is, an earlier push's included, never only this push's
|
|
186
|
+
* options), and — when the published commit is not the Agent's own — the published commits as a
|
|
187
|
+
* bundle for the sync. `rejected`: a stage and git's words. `diverged`: the
|
|
188
|
+
* published commit is not in the Agent's history and nothing paired it; nothing was pushed.
|
|
189
|
+
*/
|
|
190
|
+
export const PushOutputShape = z.union([
|
|
191
|
+
z.strictObject({
|
|
192
|
+
outcome: z.literal("pushed"),
|
|
193
|
+
published: z.string(),
|
|
194
|
+
source: z.string(),
|
|
195
|
+
previous: z.string().optional(),
|
|
196
|
+
via: z.string().optional(),
|
|
197
|
+
unchanged: z.boolean().optional(),
|
|
198
|
+
squashed: z.enum(["signed", "unsigned"]).optional(),
|
|
199
|
+
bundle: z.string().optional(),
|
|
200
|
+
}),
|
|
201
|
+
z.strictObject({
|
|
202
|
+
outcome: z.literal("rejected"),
|
|
203
|
+
stage: z.string(),
|
|
204
|
+
reason: z.string(),
|
|
205
|
+
detail: z.string().optional(),
|
|
206
|
+
}),
|
|
207
|
+
z.strictObject({ outcome: z.literal("diverged"), reason: z.string(), published: z.string(), source: z.string() }),
|
|
208
|
+
z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
|
|
209
|
+
]);
|
|
210
|
+
export type PushWireOutput = z.infer<typeof PushOutputShape>;
|
|
211
|
+
|
|
212
|
+
/** A credentialed command's output, as the command kind's (ADR-0067), and `dirty` when the
|
|
213
|
+
* Worktree held uncommitted edits it did not see. A stopped command's (`DELETE`, ADR-0067) is
|
|
214
|
+
* `exited` with the signal the stop sent last, and the code when the program caught SIGTERM and
|
|
215
|
+
* exited with one. */
|
|
216
|
+
export const CommandOutputShape = z.union([
|
|
217
|
+
z.strictObject({
|
|
218
|
+
outcome: z.literal("exited"),
|
|
219
|
+
code: z.number().int(),
|
|
220
|
+
tail: z.string(),
|
|
221
|
+
dirty: z.boolean().optional(),
|
|
222
|
+
}),
|
|
223
|
+
z.strictObject({
|
|
224
|
+
outcome: z.literal("exited"),
|
|
225
|
+
signal: z.string(),
|
|
226
|
+
code: z.number().int().optional(),
|
|
227
|
+
tail: z.string(),
|
|
228
|
+
dirty: z.boolean().optional(),
|
|
229
|
+
}),
|
|
230
|
+
z.strictObject({ outcome: z.literal("timeout"), tail: z.string(), dirty: z.boolean().optional() }),
|
|
231
|
+
z.strictObject({ outcome: z.literal("killed"), signal: z.string(), tail: z.string(), dirty: z.boolean().optional() }),
|
|
232
|
+
z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
|
|
233
|
+
]);
|
|
234
|
+
export type CommandWireOutput = z.infer<typeof CommandOutputShape>;
|
|
235
|
+
|
|
236
|
+
/** The replica that holds an Effect, and where to poll it. */
|
|
237
|
+
export const ReplicaShape = z.strictObject({ name: z.string(), address: z.string() });
|
|
238
|
+
export type Replica = z.infer<typeof ReplicaShape>;
|
|
239
|
+
|
|
240
|
+
/** Every answer of `POST` and `GET /effects/<id>`. */
|
|
241
|
+
export const EffectStateShape = z.strictObject({
|
|
242
|
+
effect: z.string(),
|
|
243
|
+
replica: ReplicaShape,
|
|
244
|
+
state: z.enum(["running", "settled", "unknown"]),
|
|
245
|
+
/** The commit a push or a credentialed command runs against, read when it started: the
|
|
246
|
+
* Orchestrator writes it to the ledger's intent row. */
|
|
247
|
+
pinned: z.string().optional(),
|
|
248
|
+
output: z.union([HttpOutputShape, PushOutputShape, CommandOutputShape]).optional(),
|
|
249
|
+
});
|
|
250
|
+
export type EffectState = z.infer<typeof EffectStateShape>;
|
|
251
|
+
|
|
252
|
+
/** A refusal's body: the reason, and the replica when it names one. */
|
|
253
|
+
export const RefusalShape = z.strictObject({ error: z.string(), replica: ReplicaShape.optional() });
|
|
254
|
+
|
|
255
|
+
// --- Signatures ----------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export const HEADER_TIME = "x-jr2-time";
|
|
258
|
+
export const HEADER_NONCE = "x-jr2-nonce";
|
|
259
|
+
export const HEADER_SIGNATURE = "x-jr2-signature";
|
|
260
|
+
|
|
261
|
+
const digest = (body: string): string => createHash("sha256").update(body, "utf8").digest("hex");
|
|
262
|
+
const mac = (key: Buffer | string, parts: string[]): string =>
|
|
263
|
+
createHmac("sha256", key).update(parts.join("\n"), "utf8").digest("base64url");
|
|
264
|
+
|
|
265
|
+
/** A request's signature: over its method, the address it is sent to (the replica's `host:port`),
|
|
266
|
+
* its path, time, nonce and the sha-256 of its body. */
|
|
267
|
+
export function requestMac(
|
|
268
|
+
key: Buffer | string,
|
|
269
|
+
method: string,
|
|
270
|
+
address: string,
|
|
271
|
+
path: string,
|
|
272
|
+
time: string,
|
|
273
|
+
nonce: string,
|
|
274
|
+
body: string,
|
|
275
|
+
): string {
|
|
276
|
+
return mac(key, ["jr2-effector-request", method, address, path, time, nonce, digest(body)]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An answer's signature: over its status, the request's method, path and nonce, and its body. */
|
|
280
|
+
export function responseMac(
|
|
281
|
+
key: Buffer | string,
|
|
282
|
+
status: number,
|
|
283
|
+
method: string,
|
|
284
|
+
path: string,
|
|
285
|
+
nonce: string,
|
|
286
|
+
body: string,
|
|
287
|
+
): string {
|
|
288
|
+
return mac(key, ["jr2-effector-response", String(status), method, path, nonce, digest(body)]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- The transport -------------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/** One raw exchange with one replica. */
|
|
294
|
+
export type RawAnswer = { status: number; headers: Record<string, string>; body: string };
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How the client reaches a placement's replicas — the seam the unit tests inject (ADR-0010): the
|
|
298
|
+
* real one dials the headless Service's addresses; a test's answers in-process.
|
|
299
|
+
*/
|
|
300
|
+
export interface EffectorTransport {
|
|
301
|
+
/** Every replica's address now (`host:port`). */
|
|
302
|
+
replicas(signal?: AbortSignal): Promise<string[]>;
|
|
303
|
+
/** One request to one replica. Rejects on a transport failure; a status is an answer. */
|
|
304
|
+
send(
|
|
305
|
+
address: string,
|
|
306
|
+
method: "POST" | "GET" | "DELETE",
|
|
307
|
+
path: string,
|
|
308
|
+
headers: Record<string, string>,
|
|
309
|
+
body: string,
|
|
310
|
+
signal?: AbortSignal,
|
|
311
|
+
): Promise<RawAnswer>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The request certainly never reached a replica (no address, the connection was refused, the name
|
|
315
|
+
* did not resolve), or the replica that got it refused to start it because it is stopping: sending
|
|
316
|
+
* it again starts it at most once. */
|
|
317
|
+
export class EffectorNotSent extends Error {}
|
|
318
|
+
|
|
319
|
+
/** The Effector refused the request as malformed (400): a jr2 or config defect, never retried. */
|
|
320
|
+
export class EffectorRefused extends Error {}
|
|
321
|
+
|
|
322
|
+
/** An answer that is not the Effector's: unsigned, or signed with another key. No retry changes the
|
|
323
|
+
* key it is checked with, so the Effect that gets one settles `unknown` (ADR-0068). */
|
|
324
|
+
export class EffectorForged extends Error {}
|
|
325
|
+
|
|
326
|
+
/** An answer the placement's key verifies that is not the wire's shape: a jr2 defect, never an
|
|
327
|
+
* outcome, never retried. */
|
|
328
|
+
export class EffectorMalformed extends Error {}
|
|
329
|
+
|
|
330
|
+
/** The Effector's wire, as an Effect drives it. */
|
|
331
|
+
export interface EffectorPort {
|
|
332
|
+
/** POST: start the Effect on some replica, or attach to it. Rejects with `EffectorNotSent` only
|
|
333
|
+
* when no replica can have started it; any other rejection may follow a start. */
|
|
334
|
+
start(id: string, req: StartRequest, signal: AbortSignal): Promise<EffectState>;
|
|
335
|
+
/** GET on the replica that holds it: settled, still running after the hold, or `unknown`. */
|
|
336
|
+
poll(replica: Replica, id: string, signal: AbortSignal): Promise<EffectState>;
|
|
337
|
+
/** Ask every replica: the state of the one that holds it, or undefined when every replica
|
|
338
|
+
* answered that it holds no such Effect. Rejects when a replica could not answer. */
|
|
339
|
+
find(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
|
|
340
|
+
/** DELETE: stop a credentialed command whose state exited (ADR-0067), on the replica that holds
|
|
341
|
+
* it — its state once stopped, or undefined when no replica holds it. Sent once to each replica,
|
|
342
|
+
* signed like every request. A push and an http Effect have no stop. */
|
|
343
|
+
stop(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The signed client over a transport. `key` is the placement's (`effectorKey`). */
|
|
347
|
+
export function createEffectorClient(opts: {
|
|
348
|
+
key: Buffer | string;
|
|
349
|
+
transport: EffectorTransport;
|
|
350
|
+
now?: () => number;
|
|
351
|
+
}): EffectorPort {
|
|
352
|
+
const { key, transport } = opts;
|
|
353
|
+
const now = opts.now ?? Date.now;
|
|
354
|
+
|
|
355
|
+
const exchange = async (
|
|
356
|
+
address: string,
|
|
357
|
+
method: "POST" | "GET" | "DELETE",
|
|
358
|
+
id: string,
|
|
359
|
+
body: string,
|
|
360
|
+
signal: AbortSignal,
|
|
361
|
+
): Promise<RawAnswer> => {
|
|
362
|
+
const path = `/effects/${encodeURIComponent(id)}`;
|
|
363
|
+
const time = String(now());
|
|
364
|
+
const nonce = randomBytes(16).toString("hex");
|
|
365
|
+
const headers = {
|
|
366
|
+
"content-type": "application/json",
|
|
367
|
+
[HEADER_TIME]: time,
|
|
368
|
+
[HEADER_NONCE]: nonce,
|
|
369
|
+
// Signed for the replica it is sent to: another replica, or this one after a restart, refuses
|
|
370
|
+
// it sent again (ADR-0068).
|
|
371
|
+
[HEADER_SIGNATURE]: requestMac(key, method, address, path, time, nonce, body),
|
|
372
|
+
};
|
|
373
|
+
const answer = await transport.send(address, method, path, headers, body, signal);
|
|
374
|
+
const signature = answer.headers[HEADER_SIGNATURE] ?? "";
|
|
375
|
+
const want = responseMac(key, answer.status, method, path, nonce, answer.body);
|
|
376
|
+
if (!sameString(signature, want)) {
|
|
377
|
+
throw new EffectorForged(
|
|
378
|
+
`the answer to ${method} ${path} from ${address} (${answer.status}) does not carry the Effector's signature`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
return answer;
|
|
382
|
+
};
|
|
383
|
+
const state = (answer: RawAnswer, what: string): EffectState => {
|
|
384
|
+
const parsed = EffectStateShape.safeParse(parseJson(answer.body));
|
|
385
|
+
if (!parsed.success) throw new EffectorMalformed(`the Effector's answer to ${what} is not the wire's shape`);
|
|
386
|
+
return parsed.data;
|
|
387
|
+
};
|
|
388
|
+
const refusal = (answer: RawAnswer): string => {
|
|
389
|
+
const parsed = RefusalShape.safeParse(parseJson(answer.body));
|
|
390
|
+
return parsed.success ? parsed.data.error : `status ${answer.status}`;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const ask = async (address: string, id: string, signal: AbortSignal): Promise<EffectState> => {
|
|
394
|
+
const answer = await exchange(address, "GET", id, "", signal);
|
|
395
|
+
if (answer.status === 200 || answer.status === 404) return state(answer, "a poll");
|
|
396
|
+
throw new Error(`the Effector at ${address} answered a poll with ${answer.status}: ${refusal(answer)}`);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
start: async (id, req, signal) => {
|
|
401
|
+
const body = JSON.stringify(req);
|
|
402
|
+
const addresses = shuffle(await transport.replicas(signal).catch(notSent));
|
|
403
|
+
if (addresses.length === 0) throw new EffectorNotSent("no Effector replica is serving");
|
|
404
|
+
let last: unknown;
|
|
405
|
+
for (const address of addresses) {
|
|
406
|
+
let answer: RawAnswer;
|
|
407
|
+
try {
|
|
408
|
+
answer = await exchange(address, "POST", id, body, signal);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
// A replica that was never reached is the one failure the next replica may follow.
|
|
411
|
+
if (err instanceof EffectorNotSent || (!signal.aborted && neverReached(err))) {
|
|
412
|
+
last = err;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
if (answer.status === 202 || answer.status === 200) return state(answer, "a start");
|
|
418
|
+
if (answer.status === 503) {
|
|
419
|
+
// A stopping replica started nothing: another may take it.
|
|
420
|
+
last = new EffectorNotSent(`the Effector replica at ${address} is stopping`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (answer.status === 400) throw new EffectorRefused(`the Effector refused the request: ${refusal(answer)}`);
|
|
424
|
+
throw new Error(`the Effector answered a start with ${answer.status}: ${refusal(answer)}`);
|
|
425
|
+
}
|
|
426
|
+
throw last instanceof EffectorNotSent
|
|
427
|
+
? last
|
|
428
|
+
: new EffectorNotSent(`no Effector replica took it: ${(last as Error | undefined)?.message ?? "none"}`);
|
|
429
|
+
},
|
|
430
|
+
poll: (replica, id, signal) => ask(replica.address, id, signal),
|
|
431
|
+
find: async (id, signal) => {
|
|
432
|
+
const addresses = await transport.replicas(signal);
|
|
433
|
+
const states = await Promise.all(addresses.map((address) => ask(address, id, signal)));
|
|
434
|
+
return states.find((s) => s.state !== "unknown");
|
|
435
|
+
},
|
|
436
|
+
stop: async (id, signal) => {
|
|
437
|
+
for (const address of await transport.replicas(signal)) {
|
|
438
|
+
const answer = await exchange(address, "DELETE", id, "", signal);
|
|
439
|
+
if (answer.status === 404) continue;
|
|
440
|
+
if (answer.status === 200) return state(answer, "a stop");
|
|
441
|
+
throw new Error(`the Effector at ${address} answered a stop with ${answer.status}: ${refusal(answer)}`);
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A transport rejection that certainly never reached the replica becomes `EffectorNotSent`. */
|
|
449
|
+
function notSent(err: unknown): never {
|
|
450
|
+
throw new EffectorNotSent(`the Effector's replicas could not be listed: ${(err as Error)?.message ?? err}`, {
|
|
451
|
+
cause: err,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Errnos that mean no connection was ever made (the same reading as the Harness client's). */
|
|
456
|
+
const NEVER_REACHED_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"]);
|
|
457
|
+
|
|
458
|
+
export function neverReached(err: unknown, depth = 0): boolean {
|
|
459
|
+
if (depth > 4 || typeof err !== "object" || err === null) return false;
|
|
460
|
+
const { code, syscall, cause, errors } = err as {
|
|
461
|
+
code?: unknown;
|
|
462
|
+
syscall?: unknown;
|
|
463
|
+
cause?: unknown;
|
|
464
|
+
errors?: unknown;
|
|
465
|
+
};
|
|
466
|
+
if (syscall === "connect" || syscall === "getaddrinfo") return true;
|
|
467
|
+
if (typeof code === "string" && (code === "UND_ERR_CONNECT_TIMEOUT" || NEVER_REACHED_CODES.has(code))) return true;
|
|
468
|
+
if (Array.isArray(errors) && errors.some((nested) => neverReached(nested, depth + 1))) return true;
|
|
469
|
+
return neverReached(cause, depth + 1);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parseJson(body: string): unknown {
|
|
473
|
+
try {
|
|
474
|
+
return JSON.parse(body);
|
|
475
|
+
} catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sameString(a: string, b: string): boolean {
|
|
481
|
+
const left = Buffer.from(a);
|
|
482
|
+
const right = Buffer.from(b);
|
|
483
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function shuffle<T>(items: T[]): T[] {
|
|
487
|
+
const out = [...items];
|
|
488
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
489
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
490
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The real transport: the placement's headless Service, `host:port`. Its replicas are the
|
|
497
|
+
* addresses its name resolves to — every pod, a stopping one included, because the Service
|
|
498
|
+
* publishes addresses that are not ready (a stopping replica still answers for the Effects
|
|
499
|
+
* it finishes). A request goes to one pod's address over plain HTTP: the signature, not TLS, is
|
|
500
|
+
* what an answer is trusted by.
|
|
501
|
+
*/
|
|
502
|
+
export function nodeEffectorTransport(service: string, fetchImpl: typeof fetch = fetch): EffectorTransport {
|
|
503
|
+
const cut = service.lastIndexOf(":");
|
|
504
|
+
const host = service.slice(0, cut);
|
|
505
|
+
const port = service.slice(cut + 1);
|
|
506
|
+
return {
|
|
507
|
+
// `lookup` (getaddrinfo), not `resolve4`: it reads the pod's resolv.conf search list, so the
|
|
508
|
+
// namespace-relative name resolves as every other in-cluster address the Orchestrator dials.
|
|
509
|
+
replicas: async () => (await lookup(host, { all: true, family: 4 })).map((a) => `${a.address}:${port}`),
|
|
510
|
+
send: async (address, method, path, headers, body, signal) => {
|
|
511
|
+
const res = await fetchImpl(`http://${address}${path}`, {
|
|
512
|
+
method,
|
|
513
|
+
headers,
|
|
514
|
+
...(method === "POST" ? { body } : {}),
|
|
515
|
+
...(signal ? { signal } : {}),
|
|
516
|
+
});
|
|
517
|
+
const text = await res.text();
|
|
518
|
+
const out: Record<string, string> = {};
|
|
519
|
+
res.headers.forEach((value, name) => {
|
|
520
|
+
out[name.toLowerCase()] = value;
|
|
521
|
+
});
|
|
522
|
+
return { status: res.status, headers: out, body: text };
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
package/src/fingerprint.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The content address of a Machine's SHAPE (ADR-0030). A persisted snapshot names the Machine it
|
|
2
|
-
// was written under; restore compares and refuses a mismatch
|
|
3
|
-
//
|
|
4
|
-
// a
|
|
2
|
+
// was written under; restore compares and refuses a mismatch. A run restores inside the Assembly
|
|
3
|
+
// its snapshot names (ADR-0069), and the same bytes give the same Machine, so inside one Assembly
|
|
4
|
+
// a mismatch means a hash collision or a hand-edited volume. An unstamped blob is still matched to
|
|
5
|
+
// its Machine by workflow name alone, and there the check is the only guard.
|
|
5
6
|
//
|
|
6
7
|
// What it hashes is RESTORABILITY, not behavior: the facts that decide whether an old snapshot can
|
|
7
8
|
// still be interpreted — state ids and nesting, which state is initial, what each state invokes and
|
package/src/gate.ts
CHANGED
|
@@ -70,6 +70,14 @@ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input
|
|
|
70
70
|
path,
|
|
71
71
|
defs,
|
|
72
72
|
meta: input.meta,
|
|
73
|
+
// A Gate's caller sends as the Gate's invoke id (ADR-0015): static in the config (authored, or
|
|
74
|
+
// named by the walk), the same after a restore, and checkable at load — unlike the
|
|
75
|
+
// caller-facing `id`, which may be computed at run time.
|
|
76
|
+
sender: { party: "gate", id: self.id },
|
|
77
|
+
invokeId: self.id,
|
|
78
|
+
// The Machine that invoked the Gate: the one whose selection says which writing a caller's
|
|
79
|
+
// event would run (ADR-0029's question, asked for the Gate's caller too).
|
|
80
|
+
invoker: self._parent,
|
|
73
81
|
deliver: (event) => sendBack(event),
|
|
74
82
|
});
|
|
75
83
|
return dispose;
|
package/src/git-clone.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The clone credentials (ADR-0051, ADR-0068): the token a Repo's cache clone spends. `jr2 up`
|
|
2
|
+
// declares and writes every Secret that holds a git credential; the Orchestrator matches and names,
|
|
3
|
+
// and holds no git credential.
|
|
4
|
+
//
|
|
5
|
+
// - `jr2 up` writes each `git.credentials` entry's token, read from `.env`, into that entry's
|
|
6
|
+
// own Secret, `jr2-git-<hash of match>` (`gitTokenSecretName`), in Flux's key names
|
|
7
|
+
// (`username`/`password`), as it writes the entry's push Secret. An entry whose token variable
|
|
8
|
+
// is unset gets no Secret. And it writes `git-clone.json` in `jr2-held`: which clone Secrets it
|
|
9
|
+
// wrote, names only.
|
|
10
|
+
// - The Orchestrator resolves a Repo's url against `git.credentials` and names the matched
|
|
11
|
+
// entry's Secret as the Repo CR's `secretRef` when `git-clone.json` says it exists. Otherwise
|
|
12
|
+
// the CR carries no `secretRef` and the clone is anonymous.
|
|
13
|
+
// - The operator's cache agent reads the Secret the `secretRef` names, and spends it.
|
|
14
|
+
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { gitTokenSecretName, type GitCredential } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
/** The `jr2-held` key of the clone manifest. */
|
|
19
|
+
export const GIT_CLONE_MANIFEST_KEY = "git-clone.json";
|
|
20
|
+
/** The label every entry's clone Secret carries, so a stale one is found. */
|
|
21
|
+
export const LABEL_GIT_CLONE = "jr2.dev/git-clone";
|
|
22
|
+
|
|
23
|
+
/** `git-clone.json`: the clone Secrets `jr2 up` wrote, by name. */
|
|
24
|
+
export type GitCloneManifest = { version: 1; secrets: string[] };
|
|
25
|
+
|
|
26
|
+
/** One entry's clone Secret, as `jr2 up` writes it. */
|
|
27
|
+
export type GitCloneSecret = { match: string; name: string; data: { username: string; password: string } };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Each `git.credentials` entry's clone Secret: the entries with a `token` whose variable is set.
|
|
31
|
+
* The values are plain strings; the caller encodes them. Two entries never share a name.
|
|
32
|
+
*/
|
|
33
|
+
export function gitCloneSecretsOf(
|
|
34
|
+
list: readonly GitCredential[],
|
|
35
|
+
env: (name: string) => string | undefined,
|
|
36
|
+
): GitCloneSecret[] {
|
|
37
|
+
const out: GitCloneSecret[] = [];
|
|
38
|
+
for (const entry of list) {
|
|
39
|
+
if (entry.token === undefined) continue;
|
|
40
|
+
const value = env(entry.token);
|
|
41
|
+
if (!value) continue;
|
|
42
|
+
out.push({
|
|
43
|
+
match: entry.match,
|
|
44
|
+
name: gitTokenSecretName(entry.match),
|
|
45
|
+
data: { username: "x-access-token", password: value },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The manifest the Orchestrator reads, or an empty one when `jr2 up` wrote no clone Secret. */
|
|
52
|
+
export async function readGitCloneManifest(path: string): Promise<GitCloneManifest> {
|
|
53
|
+
let raw: string;
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(path, "utf8");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, secrets: [] };
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
const parsed = JSON.parse(raw) as GitCloneManifest;
|
|
61
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.secrets)) {
|
|
62
|
+
throw new Error(`${path} is not a clone manifest jr2 reads (version 1)`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|