@jr2/orchestrator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The ask, and the wait on it (ADR-0053): how a fetch INSIDE a Sandbox reaches the remote.
|
|
2
|
+
//
|
|
3
|
+
// Nothing in the pod holds a git credential, and nothing in it may talk to a remote — the node's
|
|
4
|
+
// cache agent is the only thing that fetches (ADR-0051). So a `git fetch` in a pod is a program on
|
|
5
|
+
// the runtime volume (`jr2-upload-pack`, operator/cmd) asking the Adapter on localhost, the Adapter
|
|
6
|
+
// asking the Orchestrator with the Sandbox token it already holds, and this port marking the
|
|
7
|
+
// Sandbox CR: one annotation per Repo key, timestamp value. The operator copies the mark onto the
|
|
8
|
+
// pod, the agent reads its demand off pods alone, and the landing comes back on the Sandbox's
|
|
9
|
+
// status — one standing entry per key, computed against the later of the CR's creation and the
|
|
10
|
+
// ask. This port writes the mark and waits for that entry. That is the whole route home.
|
|
11
|
+
//
|
|
12
|
+
// The mark is a COALESCER, which is what makes the timestamps load-bearing: however many fetches
|
|
13
|
+
// are in flight before one lands, the remote is fetched once, and a fetch that STARTED before the
|
|
14
|
+
// ask does not satisfy it (the agent stamps `lastFetched` with the attempt's start). So the
|
|
15
|
+
// comparison here is `>= asked` on the entry, never "an entry exists" — and an ask is raised to
|
|
16
|
+
// the next whole second on every side, because a bar spelled finer than the stamps that answer it
|
|
17
|
+
// would count a fetch that began BEFORE the ask. The same coalescing happens one hop earlier, in
|
|
18
|
+
// this process: asks that raise the same bar for one Sandbox and key share one mark and one wait,
|
|
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 kubectl per
|
|
21
|
+
// second per iteration.
|
|
22
|
+
//
|
|
23
|
+
// Freshness degrades, absence does not (ADR-0051/0053): a remote fetch that failed, or one that
|
|
24
|
+
// outran the budget, is answered as `stale` with what the cache holds — never as an error. The
|
|
25
|
+
// program writes one warning line and serves the cache anyway, so the caller is told and the fetch
|
|
26
|
+
// still succeeds. Only a Repo the Sandbox does not mount is a refusal: the Sandbox token's scope
|
|
27
|
+
// is the caches its own pod mounts (ADR-0013).
|
|
28
|
+
//
|
|
29
|
+
// Drives the Sandbox CR through `kubectl` exactly as the provision's Ready wait does
|
|
30
|
+
// (sandbox-kubectl.ts) — same process seam, injectable, so the mapping is unit-testable without a
|
|
31
|
+
// cluster; the kind e2e tier exercises the real thing.
|
|
32
|
+
|
|
33
|
+
import { askedAnnotation } from "./names.ts";
|
|
34
|
+
import { repoKeyOfIdentity } from "./repo-identity.ts";
|
|
35
|
+
import { defaultKubectlExec, type KubectlExec } from "./sandbox-kubectl.ts";
|
|
36
|
+
|
|
37
|
+
/** What one ask settles as. `fetched` is the landing's timestamp; `stale` is git's own words (or
|
|
38
|
+
* this port's, when the budget ran out) beside the time the cache's objects are as of — `null`
|
|
39
|
+
* when nothing has ever fetched it, which the program prints as "an unknown time". */
|
|
40
|
+
export type FetchAnswer = { fetched: string } | { stale: string; asOf: string | null };
|
|
41
|
+
|
|
42
|
+
/** The ask a Sandbox may make: a fetch of one Repo it mounts (ADR-0053). */
|
|
43
|
+
export interface RepoFetches {
|
|
44
|
+
/** Ask the node cache to fetch `identity` for `sandbox`, and wait for the landing. Answers
|
|
45
|
+
* `fetched` or `stale`; throws {@link UnmountedRepoError} when the Sandbox mounts no such Repo. */
|
|
46
|
+
fetch(sandbox: string, identity: string): Promise<FetchAnswer>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The one refusal: this Sandbox does not mount that Repo (or is gone). The scope check, not a
|
|
50
|
+
* failure of the fetch — a caller may ask only for the caches its own pod holds. */
|
|
51
|
+
export class UnmountedRepoError extends Error {}
|
|
52
|
+
|
|
53
|
+
/** The cache agent's one on-demand number — what the agent gives a fetch before it gives up. */
|
|
54
|
+
const CACHE_BUDGET_MS = 60_000;
|
|
55
|
+
/** How many asks this Orchestrator will hold open at once, counting one per Sandbox and key. The
|
|
56
|
+
* 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 `kubectl` per second in the one process
|
|
58
|
+
* that serves every run in the instance. Past the cap an ask is answered the way a failed one is —
|
|
59
|
+
* the cache, said out loud — so a pod that floods degrades its own fetches and nobody else's. */
|
|
60
|
+
const MAX_IN_FLIGHT = 16;
|
|
61
|
+
/** 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 this port has to read it. The ADR's "nothing else owns a
|
|
63
|
+
* timeout" holds — this is the agent's budget plus the round trip, not a second policy. */
|
|
64
|
+
const WATCH_SLACK_MS = 15_000;
|
|
65
|
+
|
|
66
|
+
export type KubectlRepoFetchesOptions = {
|
|
67
|
+
/** The instance's namespace — the Sandboxes are here. */
|
|
68
|
+
namespace: string;
|
|
69
|
+
/** kubectl `--context` override. Default: the current context (ADR-0009). */
|
|
70
|
+
context?: string;
|
|
71
|
+
/** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
|
|
72
|
+
exec?: KubectlExec;
|
|
73
|
+
/** The clock the ask is stamped from, and the budget measured with. Injectable for tests. */
|
|
74
|
+
now?: () => Date;
|
|
75
|
+
/** How often the Sandbox's status is re-read while waiting. Default 1s. */
|
|
76
|
+
pollMs?: number;
|
|
77
|
+
/** The whole wait. Default: the cache agent's on-demand budget plus watch slack. */
|
|
78
|
+
budgetMs?: number;
|
|
79
|
+
/** How many asks may be open at once. Default {@link MAX_IN_FLIGHT}. */
|
|
80
|
+
maxInFlight?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches {
|
|
84
|
+
const exec = opts.exec ?? defaultKubectlExec;
|
|
85
|
+
const now = opts.now ?? (() => new Date());
|
|
86
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
87
|
+
const budgetMs = opts.budgetMs ?? CACHE_BUDGET_MS + WATCH_SLACK_MS;
|
|
88
|
+
const maxInFlight = opts.maxInFlight ?? MAX_IN_FLIGHT;
|
|
89
|
+
const base = ["--namespace", opts.namespace, ...(opts.context ? ["--context", opts.context] : [])];
|
|
90
|
+
// The asks this port is holding open, one per Sandbox and key, each with the second its mark was
|
|
91
|
+
// raised to. The mark is a COALESCER on the node; this is the same coalescing one hop earlier,
|
|
92
|
+
// and it is exact rather than approximate: every stamp that can answer an ask is kept at the
|
|
93
|
+
// 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 poll.
|
|
95
|
+
const openAsks = new Map<string, { until: number; answer: Promise<FetchAnswer> }>();
|
|
96
|
+
|
|
97
|
+
const get = async (sandbox: string): Promise<SandboxItem | undefined> => {
|
|
98
|
+
try {
|
|
99
|
+
const { stdout } = await exec(["get", "sandbox", sandbox, ...base, "-o", "json"]);
|
|
100
|
+
return JSON.parse(stdout) as SandboxItem;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (isNotFound(err)) return undefined;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async fetch(sandbox, identity) {
|
|
109
|
+
// The scope check FIRST, against `spec.repos` — the keys this pod actually mounts. The ask
|
|
110
|
+
// names the identity, so the key is derived here (repo-identity.ts) and never sent; a
|
|
111
|
+
// spelling that is not an identity derives a key nothing mounts, which is the same refusal.
|
|
112
|
+
const key = repoKeyOfIdentity(identity);
|
|
113
|
+
const found = await get(sandbox);
|
|
114
|
+
if (!found) throw new UnmountedRepoError(`no Sandbox "${sandbox}" in namespace "${opts.namespace}"`);
|
|
115
|
+
if (!(found.spec?.repos ?? []).some((r) => r.key === key)) {
|
|
116
|
+
throw new UnmountedRepoError(`Sandbox "${sandbox}" mounts no Repo for "${identity}"`);
|
|
117
|
+
}
|
|
118
|
+
const asked = now().toISOString();
|
|
119
|
+
const until = askedAt(asked);
|
|
120
|
+
const seat = `${sandbox}\u0000${key}`;
|
|
121
|
+
// An ask whose bar is already being waited on is the same ask: one mark, one wait, one
|
|
122
|
+
// answer. A LATER bar is not — it is a fetch that must begin after this ask — so it opens
|
|
123
|
+
// its own wait and takes the seat, and the wait it displaced still answers whoever joined
|
|
124
|
+
// it.
|
|
125
|
+
const held = openAsks.get(seat);
|
|
126
|
+
if (held && held.until >= until) return held.answer;
|
|
127
|
+
if (!held && openAsks.size >= maxInFlight) {
|
|
128
|
+
// Nothing is marked and nothing is waited on: the cache as it stands is the honest answer,
|
|
129
|
+
// and the interval keeps refreshing it (ADR-0051). Freshness degrades, absence does not.
|
|
130
|
+
return { stale: "the orchestrator is holding too many fetches at once", asOf: fetchedOf(found, key) };
|
|
131
|
+
}
|
|
132
|
+
const mine: { until: number; answer: Promise<FetchAnswer> } = {
|
|
133
|
+
until,
|
|
134
|
+
answer: undefined as unknown as Promise<FetchAnswer>,
|
|
135
|
+
};
|
|
136
|
+
mine.answer = wait(sandbox, key, asked).finally(() => {
|
|
137
|
+
if (openAsks.get(seat) === mine) openAsks.delete(seat);
|
|
138
|
+
});
|
|
139
|
+
openAsks.set(seat, mine);
|
|
140
|
+
return mine.answer;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The mark and the wait on it, for one Sandbox and key. Every ask that shares the mark's second
|
|
145
|
+
* shares this one promise. */
|
|
146
|
+
async function wait(sandbox: string, key: string, asked: string): Promise<FetchAnswer> {
|
|
147
|
+
// The mark. ONE call writes it and prints the object as it stands afterwards (the lease's
|
|
148
|
+
// trick, sandbox-kubectl.ts), so the first look at the status costs no second round trip —
|
|
149
|
+
// and a fetch that already landed since an earlier ask answers immediately.
|
|
150
|
+
const { stdout } = await exec([
|
|
151
|
+
"annotate",
|
|
152
|
+
"sandbox",
|
|
153
|
+
sandbox,
|
|
154
|
+
...base,
|
|
155
|
+
`${askedAnnotation(key)}=${asked}`,
|
|
156
|
+
"--overwrite",
|
|
157
|
+
"-o",
|
|
158
|
+
"json",
|
|
159
|
+
]);
|
|
160
|
+
const deadline = now().getTime() + budgetMs;
|
|
161
|
+
let item = JSON.parse(stdout) as SandboxItem;
|
|
162
|
+
for (;;) {
|
|
163
|
+
const answer = verdict(item, key, asked);
|
|
164
|
+
if (answer) return answer;
|
|
165
|
+
if (now().getTime() >= deadline) {
|
|
166
|
+
// The budget is the cache agent's, and this is what running past it looks like from
|
|
167
|
+
// here: the agent may still be at it (its next interval will land), so what the caller
|
|
168
|
+
// gets is the cache, said out loud.
|
|
169
|
+
return { stale: "timed out waiting for the node cache", asOf: fetchedOf(item, key) };
|
|
170
|
+
}
|
|
171
|
+
await sleep(pollMs);
|
|
172
|
+
const next = await get(sandbox);
|
|
173
|
+
// The Sandbox went while we waited: the pod that asked is gone, so there is nothing left
|
|
174
|
+
// to answer for. The caller is inside that pod, so this is nearly unreachable — and a
|
|
175
|
+
// refusal beats inventing a verdict on a resource that no longer exists.
|
|
176
|
+
if (!next) throw new UnmountedRepoError(`Sandbox "${sandbox}" is gone`);
|
|
177
|
+
item = next;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One Sandbox status entry read as an answer, or `undefined` while the ask is still outstanding.
|
|
184
|
+
*
|
|
185
|
+
* Two comparisons, and both are load-bearing. First the entry must have been computed with THIS
|
|
186
|
+
* ask in hand: the operator republishes `asked` as the later of the pod's creation and the mark,
|
|
187
|
+
* so an entry whose `asked` predates ours is a reconcile from before the mark was seen, and
|
|
188
|
+
* anything it reports settles an older ask, not this one. Then the landing is read against the
|
|
189
|
+
* ENTRY's own ask, which is the same bar raised the same way — the operator and the node both
|
|
190
|
+
* raise an ask to the next whole second, because that is the granularity every stamp that can
|
|
191
|
+
* answer one is kept at, and rounding the other way would count a fetch that BEGAN before the ask
|
|
192
|
+
* as answering it. That is the coalescer's one rule, and it is why the stamps are the attempt's
|
|
193
|
+
* start.
|
|
194
|
+
*
|
|
195
|
+
* `error` is the other verdict, and the operator scopes it to an attempt made for that same ask.
|
|
196
|
+
*/
|
|
197
|
+
function verdict(item: SandboxItem, key: string, asked: string): FetchAnswer | undefined {
|
|
198
|
+
const entry = entryOf(item, key);
|
|
199
|
+
if (!entry) return undefined;
|
|
200
|
+
const entryAsked = at(entry.asked);
|
|
201
|
+
if (entryAsked === undefined || entryAsked < askedAt(asked)) return undefined;
|
|
202
|
+
const fetched = entry.fetched;
|
|
203
|
+
if (fetched !== undefined && (at(fetched) ?? -Infinity) >= entryAsked) return { fetched };
|
|
204
|
+
const attempted = at(entry.attempted);
|
|
205
|
+
if (entry.error && attempted !== undefined && attempted >= entryAsked) {
|
|
206
|
+
return { stale: entry.error, asOf: fetchedOf(item, key) };
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** This ask raised to the next whole second — the bar a fetch must clear to answer it, spelled
|
|
212
|
+
* exactly as the operator and the cache agent spell it (`AskedAt`, operator/api). The mark carries
|
|
213
|
+
* milliseconds so two asks in one second stay distinguishable to a human reading the CR; nothing
|
|
214
|
+
* that answers one records them, so the comparison rounds UP: a fetch that began under a second
|
|
215
|
+
* BEFORE the ask cannot hold what the ask is about. */
|
|
216
|
+
function askedAt(stamp: string): number {
|
|
217
|
+
return Math.ceil(Date.parse(stamp) / 1000) * 1000;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What the cache holds, whenever it last landed anything — `null` when it has never landed a
|
|
221
|
+
* fetch at all, which is what the program prints as "an unknown time". This is the `asOf` of a
|
|
222
|
+
* stale answer: the fetch it names did NOT answer the ask, and saying when the objects are from is
|
|
223
|
+
* the whole of what a degraded answer can offer (ADR-0053). */
|
|
224
|
+
function fetchedOf(item: SandboxItem, key: string): string | null {
|
|
225
|
+
return entryOf(item, key)?.fetched ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function entryOf(item: SandboxItem, key: string): SandboxRepoStatus | undefined {
|
|
229
|
+
return (item.status?.repos ?? []).find((r) => r.key === key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** An RFC3339 stamp as a number; `undefined` for absent or unparseable — a value nothing can be
|
|
233
|
+
* concluded from is the same as no value. */
|
|
234
|
+
function at(stamp: string | undefined): number | undefined {
|
|
235
|
+
if (stamp === undefined) return undefined;
|
|
236
|
+
const ms = Date.parse(stamp);
|
|
237
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
238
|
+
}
|
|
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));
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// A Repo's identity and its cache key (ADR-0051). The url IS the identity — the string a Machine
|
|
2
|
+
// writes on its Repo Slot. Two spellings of one repository — `https://`, `git@…:`, `ssh://`, with or without
|
|
3
|
+
// `.git`, a trailing `/`, a default port, an upper-case host — resolve to ONE identity (host plus
|
|
4
|
+
// path, scheme and user dropped) and therefore one node cache. The key is the identity made into a
|
|
5
|
+
// DNS-1123 label: the Repo CR's `metadata.name`, the hostPath leaf, the in-pod mount `/repos/<key>`,
|
|
6
|
+
// and the pod volume name `repo-<key>`.
|
|
7
|
+
//
|
|
8
|
+
// Pure, no I/O. Shared by the Orchestrator (the Machine walk's bound urls, the CR it creates, the
|
|
9
|
+
// cache it mounts, and the credentials fence a per-run url meets at provision), the CLI (which
|
|
10
|
+
// groups the walk's bound ssh urls by the credential Secret each identity matches, for the
|
|
11
|
+
// ADR-0047 prompt — it judges nothing), and the e2e steps (the mount path they assert) — every
|
|
12
|
+
// side derives the same key from the same string, and nothing ever writes one down.
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export type RepoIdentity = {
|
|
17
|
+
/** `host/path` for a remote (host lower-cased, a non-default port kept as `host:port`), or the
|
|
18
|
+
* absolute path for a local repository. Path case is KEPT: hosts are case-insensitive, paths on
|
|
19
|
+
* most forges are not. */
|
|
20
|
+
identity: string;
|
|
21
|
+
/** `<slug>-<sha256(identity)[:8]>` — a DNS-1123 label of at most 49 characters. */
|
|
22
|
+
key: string;
|
|
23
|
+
scheme: "https" | "http" | "ssh" | "git" | "local";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The schemes git clones from, as spelled → as named; `git+ssh` is ssh. */
|
|
27
|
+
const SCHEME: Record<string, RepoIdentity["scheme"]> = {
|
|
28
|
+
https: "https",
|
|
29
|
+
http: "http",
|
|
30
|
+
ssh: "ssh",
|
|
31
|
+
"git+ssh": "ssh",
|
|
32
|
+
git: "git",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Default ports, dropped from the identity — `ssh://host:22/x` and `ssh://host/x` are one Repo. */
|
|
36
|
+
const DEFAULT_PORT: Record<string, string> = { ssh: "22", https: "443", http: "80", git: "9418" };
|
|
37
|
+
|
|
38
|
+
/** `git@host:org/repo.git` — the scp-style ssh spelling: an optional user, a host, a colon, a
|
|
39
|
+
* path that is NOT `//` (that would be a scheme). */
|
|
40
|
+
const SCP_STYLE = /^(?:[^@/:]+@)?([^:/@]+):(?!\/\/)(.+)$/;
|
|
41
|
+
|
|
42
|
+
/** Resolve a url to its identity and cache key. Throws on an empty, relative, or unparseable url,
|
|
43
|
+
* a scheme git cannot clone from, or a url that begins with `-` — the caller's spelling is the
|
|
44
|
+
* error's subject. */
|
|
45
|
+
export function repoIdentity(url: string): RepoIdentity {
|
|
46
|
+
const raw = url.trim();
|
|
47
|
+
if (raw === "") throw new Error("repo url is empty");
|
|
48
|
+
// No repository is spelled with a leading `-`, and git reads such an argv element as an OPTION
|
|
49
|
+
// (`--upload-pack=<command>` runs a shell). A per-run url is run input, so the identity — the
|
|
50
|
+
// one thing the credentials fence inspects — refuses the shape; the cache agent's `--` is the
|
|
51
|
+
// second lock.
|
|
52
|
+
if (raw.startsWith("-")) throw new Error(`repo url begins with "-", which git reads as an option: "${raw}"`);
|
|
53
|
+
const resolved = resolveRemote(raw) ?? resolveLocal(raw);
|
|
54
|
+
return { ...resolved, key: keyOf(resolved.identity) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The cache key alone — `repoIdentity(url).key`. */
|
|
58
|
+
export function repoKey(url: string): string {
|
|
59
|
+
return repoIdentity(url).key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The cache key of an identity that is ALREADY resolved — what `repoIdentity` produced, carried as
|
|
64
|
+
* a string and handed back later. The ask a pod makes names the Repo's IDENTITY (ADR-0053: a key
|
|
65
|
+
* is a derived directory name, never the name a human reads in `git remote -v`), so the route that
|
|
66
|
+
* answers it turns that identity into the key the Sandbox mounts. `repoKey` cannot: it takes a
|
|
67
|
+
* URL, and `github.com/acme/app` is not one — no scheme, no colon, not absolute.
|
|
68
|
+
*
|
|
69
|
+
* Exact, never lenient: a spelling that is not the identity yields a key nothing mounts, which is
|
|
70
|
+
* the caller's answer.
|
|
71
|
+
*/
|
|
72
|
+
export function repoKeyOfIdentity(identity: string): string {
|
|
73
|
+
return keyOf(identity);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveRemote(raw: string): Omit<RepoIdentity, "key"> | undefined {
|
|
77
|
+
if (!raw.includes("://")) {
|
|
78
|
+
const scp = SCP_STYLE.exec(raw);
|
|
79
|
+
if (!scp) return undefined;
|
|
80
|
+
const [, host, path] = scp as unknown as [string, string, string];
|
|
81
|
+
return { scheme: "ssh", identity: remoteIdentity(host.toLowerCase(), path) };
|
|
82
|
+
}
|
|
83
|
+
let parsed: URL;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`repo url is not parseable: "${raw}"`);
|
|
88
|
+
}
|
|
89
|
+
const spelled = parsed.protocol.slice(0, -1);
|
|
90
|
+
if (spelled === "file") return undefined;
|
|
91
|
+
const scheme = SCHEME[spelled];
|
|
92
|
+
if (scheme === undefined)
|
|
93
|
+
throw new Error(
|
|
94
|
+
`repo url "${raw}" has an unsupported scheme "${spelled}" — use https, http, ssh, git, or a local path`,
|
|
95
|
+
);
|
|
96
|
+
if (parsed.hostname === "") throw new Error(`repo url "${raw}" has no host`);
|
|
97
|
+
const host = parsed.hostname.toLowerCase();
|
|
98
|
+
const port = parsed.port !== "" && parsed.port !== DEFAULT_PORT[scheme] ? `:${parsed.port}` : "";
|
|
99
|
+
return { scheme, identity: remoteIdentity(host + port, parsed.pathname) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveLocal(raw: string): Omit<RepoIdentity, "key"> {
|
|
103
|
+
const path = raw.startsWith("file://") ? new URL(raw).pathname : raw;
|
|
104
|
+
if (!path.startsWith("/")) throw new Error(`repo url must be absolute or remote: "${raw}"`);
|
|
105
|
+
const identity = "/" + normalizePath(path);
|
|
106
|
+
return { scheme: "local", identity };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function remoteIdentity(host: string, path: string): string {
|
|
110
|
+
const normalized = normalizePath(path);
|
|
111
|
+
if (normalized === "") throw new Error(`repo url has no path: "${host}" alone names no repository`);
|
|
112
|
+
return `${host}/${normalized}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Leading `/` off, `//` collapsed, `.` and `..` segments resolved (as `new URL()` resolves them,
|
|
116
|
+
* so the hand-parsed scp form lands where the URL forms do), trailing `/` off, ONE trailing
|
|
117
|
+
* `.git` off; case kept. The `.git` comes off the LAST SEGMENT — as a suffix (`repo.git`) or as the
|
|
118
|
+
* whole segment (`repo/.git`, the git dir git itself clones from) — so no `/` survives it. */
|
|
119
|
+
function normalizePath(path: string): string {
|
|
120
|
+
const segments: string[] = [];
|
|
121
|
+
for (const segment of path.split("/")) {
|
|
122
|
+
if (segment === "" || segment === ".") continue;
|
|
123
|
+
if (segment === "..") segments.pop();
|
|
124
|
+
else segments.push(segment);
|
|
125
|
+
}
|
|
126
|
+
const last = segments.pop();
|
|
127
|
+
if (last !== undefined && last !== ".git") segments.push(last.replace(/\.git$/, ""));
|
|
128
|
+
return segments.join("/");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function keyOf(identity: string): string {
|
|
132
|
+
const last =
|
|
133
|
+
identity
|
|
134
|
+
.split("/")
|
|
135
|
+
.filter((s) => s !== "")
|
|
136
|
+
.pop() ?? "";
|
|
137
|
+
const slug =
|
|
138
|
+
last
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
141
|
+
.replace(/^-+|-+$/g, "")
|
|
142
|
+
.slice(0, 40)
|
|
143
|
+
.replace(/-+$/, "") || "repo";
|
|
144
|
+
return `${slug}-${createHash("sha256").update(identity).digest("hex").slice(0, 8)}`;
|
|
145
|
+
}
|