@jr2/orchestrator 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- 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 +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- 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 +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- 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 +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- 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 +285 -89
- 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 +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// Where each secret of the `secrets` list goes (ADR-0068, ADR-0059): placement is READ, never
|
|
2
|
+
// declared, and naming is placement on both sides. A pod gets the secrets something in it names:
|
|
3
|
+
//
|
|
4
|
+
// - a Workspace's pod: the Held secrets its `workspace()` lists under `secrets: [...]`, and the
|
|
5
|
+
// model keys of the Agents whose Turns run there — a model key is claimed by the PROVIDER;
|
|
6
|
+
// - the Instance Harness: the model keys of the `"none"` Agents it hosts, and nothing else (they
|
|
7
|
+
// cannot execute code, ADR-0031, so nothing else is theirs to use);
|
|
8
|
+
// - an Effector: what its Effects name with `secret("name")` — the Instance Effector what its
|
|
9
|
+
// `http` Effects name; a Workspace's Sandbox Effector what its file-body `http`s and
|
|
10
|
+
// credentialed `command`s name. The push credential its `push`es spend is not placed here: the provision
|
|
11
|
+
// names the entry the slot's url matches on the Sandbox, by reference (git-push.ts, ADR-0051).
|
|
12
|
+
//
|
|
13
|
+
// `hosts`, `headers` and `paths` are an entry's BINDING — where a Custodian may put the value — and
|
|
14
|
+
// never its placement. Both is both, and `jr2 up` reports it per pod; an entry nothing names goes
|
|
15
|
+
// nowhere, and `jr2 up` warns.
|
|
16
|
+
//
|
|
17
|
+
// Pure and host-side, like `heldSecretsOf`: only `jr2 up` has the `.env` values. It refuses a name
|
|
18
|
+
// the list does not hold, and an Effect secret shorter than 8 characters (ADR-0068): the Effector
|
|
19
|
+
// replaces every match of a secret in what it returns, and a short one would match ordinary text.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
providerClaims,
|
|
23
|
+
sourceOf,
|
|
24
|
+
workspaceHeldPod,
|
|
25
|
+
PROVIDER_KEY_NAME,
|
|
26
|
+
type HeldBinding,
|
|
27
|
+
type HeldLookups,
|
|
28
|
+
} from "./held-secrets.ts";
|
|
29
|
+
import type { JR2Config } from "./config.ts";
|
|
30
|
+
import { declaredPushKinds, pushRefusal } from "./git-push.ts";
|
|
31
|
+
import { INSTANCE_HARNESS_HELD_POD } from "./names.ts";
|
|
32
|
+
import { routeText, type CarriedEffect, type WorkspacePod } from "./parts.ts";
|
|
33
|
+
|
|
34
|
+
/** The shortest Effect secret jr2 accepts (ADR-0068) — the Effector refuses a shorter file at start. */
|
|
35
|
+
export const EFFECT_SECRET_MIN_LENGTH = 8;
|
|
36
|
+
|
|
37
|
+
/** One Effect secret in one placement: a literal `jr2 up` writes, or a key of the user's Secret. */
|
|
38
|
+
export type EffectSecret = { name: string; source: HeldBinding["source"] };
|
|
39
|
+
|
|
40
|
+
/** One target an Effect placed in an Effector may call, with the secrets it names there. */
|
|
41
|
+
export type EffectorTargetConfig = { scheme: "http" | "https"; host: string; port: number; secrets: string[] };
|
|
42
|
+
|
|
43
|
+
/** What one Effector placement holds and allows — what `jr2 up` converges from. */
|
|
44
|
+
export type EffectorPlan = {
|
|
45
|
+
placement: "instance";
|
|
46
|
+
/** Every Effect secret placed there, in list order. */
|
|
47
|
+
secrets: EffectSecret[];
|
|
48
|
+
/** The literal values, by name — host-side only, for the placement's Secret. */
|
|
49
|
+
values: Record<string, string>;
|
|
50
|
+
/** `effector.json`'s targets: one per distinct (target, secrets) an Effect placed there names. */
|
|
51
|
+
targets: EffectorTargetConfig[];
|
|
52
|
+
/** The longest timeout among the Effects placed there, in milliseconds. */
|
|
53
|
+
longestTimeoutMs: number;
|
|
54
|
+
/** The Effects placed there, as `<workflow>/<slot>`. */
|
|
55
|
+
effects: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** One registered Machine's Effect, as `jr2 up` walked it. */
|
|
59
|
+
export type PlacedEffect = { workflow: string; effect: CarriedEffect };
|
|
60
|
+
|
|
61
|
+
/** One registered Machine's Workspace, as `jr2 up` walked it. */
|
|
62
|
+
export type PlacedWorkspace = { workflow: string; pod: WorkspacePod };
|
|
63
|
+
|
|
64
|
+
/** What the registered Machines name, as the walk read it. */
|
|
65
|
+
export type PlacementWalk = {
|
|
66
|
+
effects: readonly PlacedEffect[];
|
|
67
|
+
workspaces: readonly PlacedWorkspace[];
|
|
68
|
+
/** The models of the `"none"` Agents — present when the Instance Harness is converged. */
|
|
69
|
+
instanceHarness?: { models: readonly string[] };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** One Harness pod's Held secrets: what its Custodian holds. */
|
|
73
|
+
export type HeldPodPlan = {
|
|
74
|
+
/** The pod key its held objects are keyed by (`INSTANCE_HARNESS_HELD_POD`, `workspaceHeldPod`). */
|
|
75
|
+
pod: string;
|
|
76
|
+
/** For a human: `the Instance Harness`, `Workspace of workflow "task" → "review"`. */
|
|
77
|
+
label: string;
|
|
78
|
+
/** The names it holds, in list order; the custom provider's key last. */
|
|
79
|
+
names: string[];
|
|
80
|
+
/** Of `names`, those a provider claims (the rest the `workspace()` lists). */
|
|
81
|
+
byProvider: string[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** One push of a Workspace (ADR-0067, ADR-0051): its Repo Slot, whether it signs, and — for a slot the
|
|
85
|
+
* Machine binds to a url — that url and the `git.credentials` entry it matches. A slot the run or a
|
|
86
|
+
* `customize()` binds has no url here: the provision matches it. */
|
|
87
|
+
export type SandboxPush = { slot: string; sign: boolean; squash?: true; url?: string; match?: string };
|
|
88
|
+
|
|
89
|
+
/** A Workspace's Sandbox Effector (ADR-0068): what `jr2 up` writes for it. */
|
|
90
|
+
export type SandboxEffectorPlan = {
|
|
91
|
+
placement: "sandbox";
|
|
92
|
+
/** The pod key its objects are keyed by (`workspaceHeldPod`). */
|
|
93
|
+
pod: string;
|
|
94
|
+
label: string;
|
|
95
|
+
/** The `workspace()`'s `effector: { image }`, as written; absent → the Sandbox Image. */
|
|
96
|
+
image?: string;
|
|
97
|
+
/** The `workspace()`'s Sandbox Image, as written; absent → `images/default`, then the stock
|
|
98
|
+
* Harness. What the Effector runs in when it names no image of its own. */
|
|
99
|
+
sandboxImage?: string;
|
|
100
|
+
/** Every Effect secret its Effects name, in list order. */
|
|
101
|
+
secrets: EffectSecret[];
|
|
102
|
+
/** The literal values, by name — host-side only. */
|
|
103
|
+
values: Record<string, string>;
|
|
104
|
+
/** The targets its file-body `http`s name, with their secrets. */
|
|
105
|
+
targets: EffectorTargetConfig[];
|
|
106
|
+
/** Its pushes, by Repo Slot: what the provision resolves to an entry's push Secret. */
|
|
107
|
+
pushes: SandboxPush[];
|
|
108
|
+
/** Whether it holds a credentialed `command`: a node whose kernel cannot confine the child
|
|
109
|
+
* (Landlock) stops the Effector's start, and the provision fails (ADR-0068). */
|
|
110
|
+
commands: boolean;
|
|
111
|
+
/** Its Effects, as `<workflow>/<slot>`. */
|
|
112
|
+
effects: string[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type SecretPlacements = {
|
|
116
|
+
/** Every Harness pod the walk found — each Workspace, then the Instance Harness — with what its
|
|
117
|
+
* Custodian holds. A pod that holds nothing is here too: its Custodian still runs. */
|
|
118
|
+
held: HeldPodPlan[];
|
|
119
|
+
/** Every name some pod holds — what `heldSecretsOf` normalizes. */
|
|
120
|
+
claimed: Set<string>;
|
|
121
|
+
/** The Instance Effector, when an Effect is placed there; absent otherwise. */
|
|
122
|
+
instance?: EffectorPlan;
|
|
123
|
+
/** Each Workspace whose body places an Effect in its Sandbox Effector. */
|
|
124
|
+
sandbox: SandboxEffectorPlan[];
|
|
125
|
+
/** One line per secret, then one per pod: where each goes, what each holds. */
|
|
126
|
+
report: string[];
|
|
127
|
+
warnings: string[];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The refusals that need only the config and the walk, never the cluster: a name listed twice, a
|
|
132
|
+
* name an Effect or a `workspace()` names that the list does not hold, a literal Effect secret
|
|
133
|
+
* shorter than 8 characters. `jr2 up` runs it right after the walk, before anything is built or
|
|
134
|
+
* applied.
|
|
135
|
+
*/
|
|
136
|
+
export function refuseEffectSecrets(config: JR2Config, walk: PlacementWalk): void {
|
|
137
|
+
const { entries, namedBy } = namesOf(config, walk);
|
|
138
|
+
heldPodsOf(config, walk, new Set(entries.map((e) => e.name)));
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
// An empty value is refused later with the list's own rule (`sourceOf`): it is an unset variable.
|
|
141
|
+
if (namedBy.has(e.name) && e.value) shortRefusal(e, e.value.length);
|
|
142
|
+
}
|
|
143
|
+
for (const ws of walk.workspaces) pushesOf(config, ws);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read where each secret goes, or throw the first refusal. `walk` is what the registered Machines
|
|
148
|
+
* name; `lookups.secretKeys` is R10's read of the user's Secrets, and `lookups.valueLength` the
|
|
149
|
+
* length of a `valueFrom` Effect secret's value there, which is refused when short as a literal is.
|
|
150
|
+
*/
|
|
151
|
+
export function secretPlacementsOf(
|
|
152
|
+
config: JR2Config,
|
|
153
|
+
walk: PlacementWalk,
|
|
154
|
+
lookups: Pick<HeldLookups, "secretKeys" | "valueLength"> = {},
|
|
155
|
+
): SecretPlacements {
|
|
156
|
+
const { entries, namedBy, http } = namesOf(config, walk);
|
|
157
|
+
const { pods, redundant } = heldPodsOf(config, walk, new Set(entries.map((e) => e.name)));
|
|
158
|
+
const claimed = new Set(pods.flatMap((p) => p.names));
|
|
159
|
+
|
|
160
|
+
const report: string[] = [];
|
|
161
|
+
const warnings: string[] = [];
|
|
162
|
+
const secrets: EffectSecret[] = [];
|
|
163
|
+
const values: Record<string, string> = {};
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
const named = namedBy.get(e.name) ?? [];
|
|
166
|
+
if (named.length > 0) {
|
|
167
|
+
const source = sourceOf(e, lookups);
|
|
168
|
+
if (source.kind === "literal") {
|
|
169
|
+
const value = e.value as string;
|
|
170
|
+
shortRefusal(e, value.length);
|
|
171
|
+
values[e.name] = value;
|
|
172
|
+
} else {
|
|
173
|
+
// The user's Secret holds the value; `jr2 up` measures it there, and this check reads its length.
|
|
174
|
+
const length = lookups.valueLength?.(source.secret, source.key);
|
|
175
|
+
if (length !== undefined) shortRefusal(e, length);
|
|
176
|
+
}
|
|
177
|
+
secrets.push({ name: e.name, source });
|
|
178
|
+
}
|
|
179
|
+
const heldIn = pods.filter((p) => p.names.includes(e.name)).map((p) => p.label);
|
|
180
|
+
const effectIn = [...new Set(named.map((n) => n.where))].map(
|
|
181
|
+
(where) =>
|
|
182
|
+
`an Effect secret in ${where} (${[...new Set(named.filter((n) => n.where === where).map((n) => n.label))].join(
|
|
183
|
+
", ",
|
|
184
|
+
)})`,
|
|
185
|
+
);
|
|
186
|
+
const where = [...(heldIn.length ? [`Held in ${heldIn.join(", ")}`] : []), ...effectIn];
|
|
187
|
+
if (where.length === 0) {
|
|
188
|
+
warnings.push(
|
|
189
|
+
`${e.at} (${e.name}) is named by nothing; goes nowhere — no workspace() lists it, no Agent's provider ` +
|
|
190
|
+
"reads it, and no Effect names it (ADR-0059, ADR-0068)",
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
report.push(`secret ${e.name}: ${where.join(" and ")}`);
|
|
195
|
+
}
|
|
196
|
+
const custom = config.harness?.provider;
|
|
197
|
+
if (custom?.apiKey && !claimed.has(PROVIDER_KEY_NAME)) {
|
|
198
|
+
warnings.push(
|
|
199
|
+
`harness.provider.apiKey is named by nothing; goes nowhere — no Agent names a "${custom.id}/…" model (ADR-0059)`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
for (const p of pods) {
|
|
203
|
+
const holds = p.names.map((n) => (p.byProvider.includes(n) ? `${n} (provider)` : n));
|
|
204
|
+
report.push(`${p.label}: Custodian holds ${holds.length ? holds.join(", ") : "nothing"}`);
|
|
205
|
+
}
|
|
206
|
+
for (const { label, name } of redundant) {
|
|
207
|
+
report.push(`${label}: secrets lists ${name}, which its Agents' provider claims already — redundant`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const instanceSecrets = new Set(http.flatMap((p) => p.effect.secrets));
|
|
211
|
+
const instance =
|
|
212
|
+
http.length === 0
|
|
213
|
+
? undefined
|
|
214
|
+
: effectorPlan(
|
|
215
|
+
http,
|
|
216
|
+
secrets.filter((s) => instanceSecrets.has(s.name)),
|
|
217
|
+
values,
|
|
218
|
+
);
|
|
219
|
+
if (instance) {
|
|
220
|
+
report.push(
|
|
221
|
+
`the Instance Effector: holds ${instance.secrets.length ? instance.secrets.map((s) => s.name).join(", ") : "nothing"}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const sandbox: SandboxEffectorPlan[] = [];
|
|
225
|
+
for (const ws of walk.workspaces) {
|
|
226
|
+
if (ws.pod.effects.length === 0) continue;
|
|
227
|
+
const plan = sandboxPlanOf(config, ws, secrets, values);
|
|
228
|
+
// Two plans of one pod key would give a Workspace's Effector another's secrets (ADR-0059).
|
|
229
|
+
if (sandbox.some((p) => p.pod === plan.pod)) throw sharedPodKey(plan.pod, plan.label);
|
|
230
|
+
sandbox.push(plan);
|
|
231
|
+
const holds = plan.secrets.map((s) => s.name);
|
|
232
|
+
// Exact for a slot the Machine binds; for one the run or a customize() binds, the entry is
|
|
233
|
+
// the one its url matches at provision (ADR-0051).
|
|
234
|
+
const pushes = plan.pushes.map(
|
|
235
|
+
(p) =>
|
|
236
|
+
`Repo Slot "${p.slot}" with ` +
|
|
237
|
+
(p.match !== undefined
|
|
238
|
+
? `git.credentials "${p.match}"`
|
|
239
|
+
: "the git.credentials entry its url matches at provision"),
|
|
240
|
+
);
|
|
241
|
+
report.push(
|
|
242
|
+
`${plan.label}: Sandbox Effector (image ${plan.image ?? "the Sandbox Image"}) holds ` +
|
|
243
|
+
`${holds.length ? holds.join(", ") : "no Effect secret"}` +
|
|
244
|
+
`${pushes.length ? `; pushes ${pushes.join(", ")}` : ""}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return { held: pods, claimed, ...(instance ? { instance } : {}), sandbox, report, warnings };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A Workspace's Sandbox Effector: the secrets its Effects name, the targets its file-body https call, its
|
|
251
|
+
* pushes by Repo Slot, and whether it holds a credentialed command. */
|
|
252
|
+
function sandboxPlanOf(
|
|
253
|
+
config: JR2Config,
|
|
254
|
+
ws: PlacedWorkspace,
|
|
255
|
+
secrets: EffectSecret[],
|
|
256
|
+
values: Record<string, string>,
|
|
257
|
+
): SandboxEffectorPlan {
|
|
258
|
+
const named = new Set(ws.pod.effects.flatMap((e) => e.secrets ?? []));
|
|
259
|
+
const own = secrets.filter((s) => named.has(s.name));
|
|
260
|
+
const targets: EffectorTargetConfig[] = [];
|
|
261
|
+
for (const effect of ws.pod.effects) {
|
|
262
|
+
if (effect.kind !== "http") continue;
|
|
263
|
+
// An Open url has no target, so it allows nothing: `jr2 up` refuses it before this (ADR-0054).
|
|
264
|
+
if (effect.target === undefined) continue;
|
|
265
|
+
const t = { ...effect.target, secrets: [...effect.secrets].sort() };
|
|
266
|
+
const same = (x: EffectorTargetConfig) =>
|
|
267
|
+
x.scheme === t.scheme && x.host === t.host && x.port === t.port && x.secrets.join() === t.secrets.join();
|
|
268
|
+
if (!targets.some(same)) targets.push(t);
|
|
269
|
+
}
|
|
270
|
+
const pushes = pushesOf(config, ws);
|
|
271
|
+
const commands = ws.pod.effects.some((e) => e.kind === "command" && (e.secrets?.length ?? 0) > 0);
|
|
272
|
+
return {
|
|
273
|
+
placement: "sandbox",
|
|
274
|
+
pod: workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route }),
|
|
275
|
+
label: workspaceLabel(ws),
|
|
276
|
+
...(ws.pod.effector ? { image: ws.pod.effector.image } : {}),
|
|
277
|
+
...(ws.pod.image !== undefined ? { sandboxImage: ws.pod.image } : {}),
|
|
278
|
+
secrets: own,
|
|
279
|
+
values: Object.fromEntries(Object.entries(values).filter(([name]) => named.has(name))),
|
|
280
|
+
targets,
|
|
281
|
+
pushes,
|
|
282
|
+
commands,
|
|
283
|
+
effects: ws.pod.effects.map((e) => `${ws.workflow}/${e.name}`),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Each push of a Workspace, by Repo Slot (ADR-0051), or the refusal with the fix — for a
|
|
289
|
+
* slot the Machine binds to a url, whose entry `jr2 up` can see: a url no entry matches, a local
|
|
290
|
+
* path or `git://`, an `http://` url (a push credential goes over https or ssh only), an entry that
|
|
291
|
+
* holds no credential the url's scheme can spend (anonymous admits a clone, never a push), and
|
|
292
|
+
* `sign: true` on an entry with no `sign` field. A slot the run or a `customize()` binds is checked
|
|
293
|
+
* the same way at provision, against the url it then has.
|
|
294
|
+
*/
|
|
295
|
+
function pushesOf(config: JR2Config, ws: PlacedWorkspace): SandboxPush[] {
|
|
296
|
+
const out: SandboxPush[] = [];
|
|
297
|
+
const slots = Object.keys(ws.pod.bindings);
|
|
298
|
+
const list = config.git?.credentials ?? [];
|
|
299
|
+
for (const effect of ws.pod.effects) {
|
|
300
|
+
if (effect.kind !== "push") continue;
|
|
301
|
+
const slot = effect.slot ?? (slots.length === 1 ? slots[0] : undefined);
|
|
302
|
+
if (slot === undefined) continue;
|
|
303
|
+
const sign = effect.sign === true;
|
|
304
|
+
// An unsigned squash needs the entry's committer (ADR-0067); a signed one has the signer's.
|
|
305
|
+
const squash = effect.squash === true && !sign ? ({ squash: true } as const) : {};
|
|
306
|
+
const same = (p: SandboxPush) => p.slot === slot && p.sign === sign && (p.squash === true) === "squash" in squash;
|
|
307
|
+
const url = ws.pod.bindings[slot];
|
|
308
|
+
if (url === undefined) {
|
|
309
|
+
if (!out.some(same)) out.push({ slot, sign, ...squash });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const at = `workflow "${ws.workflow}": push "${effect.name}" pushes Repo Slot "${slot}" (${url})`;
|
|
313
|
+
const checked = pushRefusal(
|
|
314
|
+
{ at, url, sign, squash: "squash" in squash },
|
|
315
|
+
list,
|
|
316
|
+
declaredPushKinds,
|
|
317
|
+
(entry) => entry.sign !== undefined,
|
|
318
|
+
);
|
|
319
|
+
if ("refusal" in checked) throw new Error(checked.refusal);
|
|
320
|
+
if (!out.some(same)) out.push({ slot, sign, ...squash, url, match: checked.entry.match });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function effectorPlan(http: HttpPlaced[], secrets: EffectSecret[], values: Record<string, string>): EffectorPlan {
|
|
326
|
+
const targets: EffectorTargetConfig[] = [];
|
|
327
|
+
for (const { effect } of http) {
|
|
328
|
+
// An Open url has no target, so it allows nothing: `jr2 up` refuses it before this (ADR-0054).
|
|
329
|
+
if (effect.target === undefined) continue;
|
|
330
|
+
const t = { ...effect.target, secrets: [...effect.secrets].sort() };
|
|
331
|
+
const same = (x: EffectorTargetConfig) =>
|
|
332
|
+
x.scheme === t.scheme && x.host === t.host && x.port === t.port && x.secrets.join() === t.secrets.join();
|
|
333
|
+
if (!targets.some(same)) targets.push(t);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
placement: "instance",
|
|
337
|
+
secrets,
|
|
338
|
+
values,
|
|
339
|
+
targets,
|
|
340
|
+
longestTimeoutMs: Math.max(...http.map((p) => p.effect.timeoutMs)),
|
|
341
|
+
effects: http.map((p) => `${p.workflow}/${p.effect.name}`),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
type HttpPlaced = { workflow: string; effect: Extract<CarriedEffect, { method: string }> };
|
|
346
|
+
|
|
347
|
+
/** A Workspace as a human reads it: its workflow and the placement route to it, an inline hop as
|
|
348
|
+
* `invoked inline in state <id>`. */
|
|
349
|
+
function workspaceLabel({ workflow, pod }: PlacedWorkspace): string {
|
|
350
|
+
return `Workspace of workflow "${workflow}"${routeText(pod.route)}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The refusal of two Workspaces that share a pod key (ADR-0059). A placement route skips a
|
|
355
|
+
* wrapper's body, so a `workspace()` reached through another `workspace()`'s body — its body, or a
|
|
356
|
+
* `pool()` that is its body — has the outer one's route and its pod key: their Held and Effect
|
|
357
|
+
* secrets could not be kept apart, so `jr2 up` stops on the Machine, with or without a secret. Under
|
|
358
|
+
* a key of a plain Machine's `actors` the inner one has a route of its own.
|
|
359
|
+
*/
|
|
360
|
+
function sharedPodKey(pod: string, label: string): Error {
|
|
361
|
+
return new Error(
|
|
362
|
+
`jr2: two Workspaces share the pod key ${pod} (${label}): one is reached through the other's body — ` +
|
|
363
|
+
"workspace(workspace(...)) or workspace(pool(workspace(...))) — and a wrapper's body adds no hop to a " +
|
|
364
|
+
"placement route, so jr2 up cannot keep their secrets apart. Compose the inner workspace() under a key " +
|
|
365
|
+
"of a plain Machine's actors (actors: { <name>: workspace(...) }) and make that Machine the outer " +
|
|
366
|
+
"workspace()'s body (ADR-0059)",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Each Harness pod and the Held secrets it gets: a Workspace's `secrets` plus its Agents' providers'
|
|
372
|
+
* claims, the Instance Harness's `"none"` Agents' claims. A `workspace()` that lists a name the list
|
|
373
|
+
* does not hold is refused with the line to add. Two pods of one key are refused
|
|
374
|
+
* ({@link sharedPodKey}).
|
|
375
|
+
*/
|
|
376
|
+
function heldPodsOf(
|
|
377
|
+
config: JR2Config,
|
|
378
|
+
walk: PlacementWalk,
|
|
379
|
+
listed: ReadonlySet<string>,
|
|
380
|
+
): { pods: HeldPodPlan[]; redundant: Array<{ label: string; name: string }> } {
|
|
381
|
+
const order = (config.secrets ?? []).map((s) => s.name);
|
|
382
|
+
const ordered = (names: Iterable<string>): string[] => {
|
|
383
|
+
const set = new Set(names);
|
|
384
|
+
return [...order.filter((n) => set.has(n)), ...(set.has(PROVIDER_KEY_NAME) ? [PROVIDER_KEY_NAME] : [])];
|
|
385
|
+
};
|
|
386
|
+
const pods: HeldPodPlan[] = [];
|
|
387
|
+
const redundant: Array<{ label: string; name: string }> = [];
|
|
388
|
+
for (const ws of walk.workspaces) {
|
|
389
|
+
const label = workspaceLabel(ws);
|
|
390
|
+
for (const name of ws.pod.secrets) {
|
|
391
|
+
if (listed.has(name)) continue;
|
|
392
|
+
throw new Error(
|
|
393
|
+
`workflow "${ws.workflow}": a workspace() lists secrets: ["${name}"], which the \`secrets\` list does not ` +
|
|
394
|
+
`hold — add it: secrets: [{ name: "${name}", value: process.env.${envish(name)}, hosts: ["<host>"] }] (ADR-0059)`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const byProvider = providerClaims(config, ws.pod.models);
|
|
398
|
+
for (const name of ws.pod.secrets) if (byProvider.includes(name)) redundant.push({ label, name });
|
|
399
|
+
const plan: HeldPodPlan = {
|
|
400
|
+
pod: workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route }),
|
|
401
|
+
label,
|
|
402
|
+
names: ordered([...ws.pod.secrets, ...byProvider]),
|
|
403
|
+
byProvider: ordered(byProvider),
|
|
404
|
+
};
|
|
405
|
+
if (pods.some((p) => p.pod === plan.pod)) throw sharedPodKey(plan.pod, label);
|
|
406
|
+
pods.push(plan);
|
|
407
|
+
}
|
|
408
|
+
if (walk.instanceHarness) {
|
|
409
|
+
const byProvider = ordered(providerClaims(config, walk.instanceHarness.models));
|
|
410
|
+
pods.push({ pod: INSTANCE_HARNESS_HELD_POD, label: "the Instance Harness", names: byProvider, byProvider });
|
|
411
|
+
}
|
|
412
|
+
return { pods, redundant };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The list by name, refused when a name is listed twice; which Effects name which secret, refused
|
|
416
|
+
* when an Effect names one the list does not hold. */
|
|
417
|
+
function namesOf(config: JR2Config, walk: PlacementWalk) {
|
|
418
|
+
const entries = (config.secrets ?? []).map((entry, i) => ({ at: `secrets[${i}]`, ...entry }));
|
|
419
|
+
const byName = new Map<string, (typeof entries)[number]>();
|
|
420
|
+
for (const e of entries) {
|
|
421
|
+
if (byName.has(e.name)) {
|
|
422
|
+
throw new Error(`${e.at}.name ${e.name} is listed twice — one name, one secret (ADR-0068)`);
|
|
423
|
+
}
|
|
424
|
+
byName.set(e.name, e);
|
|
425
|
+
}
|
|
426
|
+
// Every Effect that names a secret names it, whatever its kind: an `http` one in the Instance
|
|
427
|
+
// Effector, a file-body `http` and a credentialed `command` in their Workspace's Sandbox Effector.
|
|
428
|
+
const namedBy = new Map<string, Array<{ label: string; where: string }>>();
|
|
429
|
+
const http = walk.effects.filter(
|
|
430
|
+
(p): p is HttpPlaced => p.effect.kind === "http" && p.effect.placement === "instance",
|
|
431
|
+
);
|
|
432
|
+
// Keyed by the Effect's placement route and slot name, as workspacePodsOf keys a pod's Effects:
|
|
433
|
+
// two slots of one name in one workflow — one in a workspace(), one outside it — are two Effects,
|
|
434
|
+
// and so is one Machine's slot reached by two routes (ADR-0068).
|
|
435
|
+
const keyOf = (workflow: string, effect: CarriedEffect) => JSON.stringify([workflow, effect.route, effect.name]);
|
|
436
|
+
const sandboxOf = new Map<string, string>();
|
|
437
|
+
for (const ws of walk.workspaces) {
|
|
438
|
+
for (const e of ws.pod.effects)
|
|
439
|
+
sandboxOf.set(keyOf(ws.workflow, e), `the Sandbox Effector of ${workspaceLabel(ws)}`);
|
|
440
|
+
}
|
|
441
|
+
// A Worktree file body needs a Worktree (ADR-0068): one no workspace() invokes has none to read.
|
|
442
|
+
for (const { workflow, effect } of walk.effects) {
|
|
443
|
+
if (effect.kind !== "http" || !effect.file || sandboxOf.has(keyOf(workflow, effect))) continue;
|
|
444
|
+
throw new Error(
|
|
445
|
+
`workflow "${workflow}": the http Effect "${effect.name}" sends a Worktree file as its body, and no ` +
|
|
446
|
+
"workspace() invokes it — outside a Workspace there is no Worktree to read; invoke it from a " +
|
|
447
|
+
"workspace()'s body, or send a json or text body (ADR-0068)",
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
for (const { workflow, effect } of walk.effects) {
|
|
451
|
+
const label = `${workflow}/${effect.name}`;
|
|
452
|
+
const where =
|
|
453
|
+
effect.placement === "instance"
|
|
454
|
+
? "the Instance Effector"
|
|
455
|
+
: (sandboxOf.get(keyOf(workflow, effect)) ?? "the Sandbox Effector of the Workspace that invokes it");
|
|
456
|
+
for (const name of effect.secrets ?? []) {
|
|
457
|
+
if (!byName.has(name)) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`workflow "${workflow}": the ${effect.kind} Effect "${effect.name}" names secret("${name}"), which the ` +
|
|
460
|
+
`\`secrets\` list does not hold — add it: secrets: [{ name: "${name}", value: process.env.${envish(name)} }] (ADR-0068)`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
namedBy.set(name, [...(namedBy.get(name) ?? []), { label, where }]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return { entries, namedBy, http };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** An Effect secret shorter than 8 characters is refused, and the refusal says why. */
|
|
470
|
+
/**
|
|
471
|
+
* The length of a value a Kubernetes Secret holds (its `data`, base64), measured as the Effector
|
|
472
|
+
* measures the file it mounts: in bytes, with one trailing "\n" and then one "\r" taken off. Only the
|
|
473
|
+
* length leaves; `jr2 up` keeps no value of the user's Secret (ADR-0068).
|
|
474
|
+
*/
|
|
475
|
+
export function storedValueLength(base64: string): number {
|
|
476
|
+
let bytes = Buffer.from(base64, "base64");
|
|
477
|
+
if (bytes.at(-1) === 0x0a) bytes = bytes.subarray(0, -1);
|
|
478
|
+
if (bytes.at(-1) === 0x0d) bytes = bytes.subarray(0, -1);
|
|
479
|
+
return bytes.length;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function shortRefusal(e: { at: string; name: string }, length: number): void {
|
|
483
|
+
if (length >= EFFECT_SECRET_MIN_LENGTH) return;
|
|
484
|
+
throw new Error(
|
|
485
|
+
`${e.at} (${e.name}) is an Effect secret shorter than ${EFFECT_SECRET_MIN_LENGTH} characters — the ` +
|
|
486
|
+
"Effector replaces every match of a secret in what it returns, and a value this short would match " +
|
|
487
|
+
"ordinary text. Use the whole credential; the value itself is not shown (ADR-0068)",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A name as the env var a `.env` line would most likely hold it under — for the fix line only. */
|
|
492
|
+
function envish(name: string): string {
|
|
493
|
+
return name.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
494
|
+
}
|