@namzu/sandbox 18.1.1 → 20.0.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/CHANGELOG.md +93 -0
- package/README.md +35 -5
- package/dist/backends/docker/index.d.ts +46 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +187 -51
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/egress/index.d.ts +1 -0
- package/dist/egress/index.d.ts.map +1 -1
- package/dist/egress/index.js +1 -0
- package/dist/egress/index.js.map +1 -1
- package/dist/egress/profile-wiring.d.ts +40 -0
- package/dist/egress/profile-wiring.d.ts.map +1 -0
- package/dist/egress/profile-wiring.js +75 -0
- package/dist/egress/profile-wiring.js.map +1 -0
- package/dist/egress/profile.d.ts +153 -0
- package/dist/egress/profile.d.ts.map +1 -0
- package/dist/egress/profile.js +244 -0
- package/dist/egress/profile.js.map +1 -0
- package/dist/egress/proxy.d.ts +16 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +37 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/seed/index.d.ts +165 -0
- package/dist/seed/index.d.ts.map +1 -0
- package/dist/seed/index.js +505 -0
- package/dist/seed/index.js.map +1 -0
- package/dist/testing/sandbox-conformance.d.ts +30 -1
- package/dist/testing/sandbox-conformance.d.ts.map +1 -1
- package/dist/testing/sandbox-conformance.js +18 -0
- package/dist/testing/sandbox-conformance.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/docker/index.ts +246 -51
- package/src/egress/index.ts +1 -0
- package/src/egress/profile-wiring.ts +125 -0
- package/src/egress/profile.ts +380 -0
- package/src/egress/proxy.ts +53 -3
- package/src/index.ts +54 -5
- package/src/seed/index.ts +710 -0
- package/src/testing/sandbox-conformance.ts +52 -6
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox seeds: git repositories a host wants present inside a sandbox,
|
|
3
|
+
* prepared by an idempotent step the caller runs, and prepared once on storage
|
|
4
|
+
* that outlives the sandbox (a kubernetes workspace's disk, a docker scratch
|
|
5
|
+
* bind the host reuses).
|
|
6
|
+
*
|
|
7
|
+
* The idea is ax's workspace repositories, prepared once with a marker on a
|
|
8
|
+
* durable disk; the code is this package's own. It is level-triggered in the
|
|
9
|
+
* sense that matters: every call looks at what is there and does only what is
|
|
10
|
+
* missing, so running it after every create or resume is the intended use.
|
|
11
|
+
*
|
|
12
|
+
* Everything runs through `Sandbox.exec`, so a file-root jail on the backend
|
|
13
|
+
* does not matter, and the guest needs `sh`, `git`, `find`, `mkdir`, `mktemp`,
|
|
14
|
+
* `rm` and a `mv` that takes `-T` (GNU coreutils; checked by a probe, not by
|
|
15
|
+
* name).
|
|
16
|
+
*
|
|
17
|
+
* WHAT IS TRUSTED, AND WHAT IS NOT. The seed marker lives in guest-writable
|
|
18
|
+
* storage, so an agent can forge it. It is never the reason a repository is
|
|
19
|
+
* skipped: every call checks each repository directly (it exists, its origin is
|
|
20
|
+
* the seed's URL, and the pinned or recorded commit is an ancestor of its
|
|
21
|
+
* HEAD), and the marker only remembers which commit a `ref` resolved to, and
|
|
22
|
+
* under which `ref`. A marker value that is not a commit id is ignored rather
|
|
23
|
+
* than passed to git.
|
|
24
|
+
*
|
|
25
|
+
* A CHANGED `ref` IS NOT THE SAME SEED. The recorded commit is used only when
|
|
26
|
+
* it was recorded under what the seed names now: the same `ref`, the default
|
|
27
|
+
* branch for none, or a pin (a pin's record never stands in for a `ref` or the
|
|
28
|
+
* default branch once the pin is dropped). Otherwise the check looks in the
|
|
29
|
+
* repository itself: the ref must exist there (as a remote-tracking branch or
|
|
30
|
+
* a tag; `refs/remotes/origin/HEAD` for the default branch) and name exactly
|
|
31
|
+
* HEAD. Sharing a line of history with HEAD is not enough, because the ref is
|
|
32
|
+
* often there without having been checked out: a clone deeper than 1 carries
|
|
33
|
+
* the tags in its history, and a pinned commit's full clone carries every
|
|
34
|
+
* remote branch. So a checkout of another branch, tag or commit is drift like
|
|
35
|
+
* any other, and a seed whose `ref` moved never reports the old checkout as
|
|
36
|
+
* `present`. The cost is on the safe side: with no record under the ref, local
|
|
37
|
+
* commits on top of it, or a `--branch` clone whose `ref` is then dropped
|
|
38
|
+
* (such a clone has no `origin/HEAD`), are drift too. A digest match is never
|
|
39
|
+
* a reason to skip a check either.
|
|
40
|
+
*
|
|
41
|
+
* NO CREDENTIAL ENTERS THE GUEST. URLs with a user name or password, `ssh://`
|
|
42
|
+
* and `git@host:path` are refused, since each would put a secret or a private
|
|
43
|
+
* key inside the sandbox. `https://` is for public repositories. `http://` is
|
|
44
|
+
* for one documented path: on the docker backend the egress proxy upgrades a
|
|
45
|
+
* plain request to HTTPS and stamps `brokeredCredentials` for the host, so a
|
|
46
|
+
* private repository can be cloned with no token in the guest. On every other
|
|
47
|
+
* backend private repositories are out of scope.
|
|
48
|
+
*
|
|
49
|
+
* NOTHING IS EVER DELETED OR RE-CLONED BUT THIS CALL'S OWN WORK. A repository
|
|
50
|
+
* whose origin or history no longer matches is drift: refused by default, or
|
|
51
|
+
* reported. A clone lands in `<dir>.namzu-partial-<nonce>` and is moved into
|
|
52
|
+
* place with `mv -T`, so two hosts preparing the same disk never see each
|
|
53
|
+
* other's half-written clone; the loser of that race removes only its own
|
|
54
|
+
* partial. Partials older than an hour, which only a crashed call leaves, are
|
|
55
|
+
* removed at the end, after this call's own clones have finished, so a peer's
|
|
56
|
+
* clone that is still running is left alone.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
60
|
+
|
|
61
|
+
import type { Sandbox, SandboxExecResult } from '@namzu/sdk'
|
|
62
|
+
|
|
63
|
+
/** One repository of a {@link SandboxSeed}. */
|
|
64
|
+
export interface SandboxSeedRepository {
|
|
65
|
+
/** A DNS-1123 label, unique in the seed. The default directory under the seed root. */
|
|
66
|
+
readonly name: string
|
|
67
|
+
/**
|
|
68
|
+
* `https://` for a public repository, or `http://` for a host the docker
|
|
69
|
+
* egress proxy brokers a credential for (see the module doc). No user name
|
|
70
|
+
* or password, no `ssh://`, no `git@host:path`.
|
|
71
|
+
*/
|
|
72
|
+
readonly url: string
|
|
73
|
+
/**
|
|
74
|
+
* A branch or tag. Resolved once, when cloned, and the commit recorded with
|
|
75
|
+
* the ref. Changing it later is drift unless HEAD on disk is exactly the
|
|
76
|
+
* commit the new ref names in the repository (see the module doc).
|
|
77
|
+
*/
|
|
78
|
+
readonly ref?: string
|
|
79
|
+
/** A full commit id to pin. Wins over `ref`, and is checked on every call. */
|
|
80
|
+
readonly commit?: string
|
|
81
|
+
/**
|
|
82
|
+
* Directory relative to the seed root. Default `name`. No `..`, not
|
|
83
|
+
* absolute, not under `.namzu/` (the marker's directory), and neither
|
|
84
|
+
* inside nor containing another repository's directory.
|
|
85
|
+
*/
|
|
86
|
+
readonly dir?: string
|
|
87
|
+
/**
|
|
88
|
+
* Clone depth. Default 1. Refused together with `commit`, because a pinned
|
|
89
|
+
* commit is fetched with its history so it can be checked out and later
|
|
90
|
+
* proved an ancestor of HEAD.
|
|
91
|
+
*/
|
|
92
|
+
readonly depth?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A named set of repositories. */
|
|
96
|
+
export interface SandboxSeed {
|
|
97
|
+
/** A DNS-1123 label. Names the marker file under `<root>/.namzu/seed/`. */
|
|
98
|
+
readonly name: string
|
|
99
|
+
readonly repositories: readonly SandboxSeedRepository[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What one repository was found or left as. */
|
|
103
|
+
export interface SandboxSeedRepositoryReport {
|
|
104
|
+
readonly name: string
|
|
105
|
+
/**
|
|
106
|
+
* `cloned` by this call; `present` and matching the seed; `drifted`, its
|
|
107
|
+
* origin, `ref` or history no longer matching, reported under
|
|
108
|
+
* `onDrift: 'report'` and left exactly as it was.
|
|
109
|
+
*/
|
|
110
|
+
readonly status: 'cloned' | 'present' | 'drifted'
|
|
111
|
+
/** The repository's HEAD commit. */
|
|
112
|
+
readonly commit: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What {@link ensureSandboxSeed} found and did. */
|
|
116
|
+
export interface SandboxSeedReport {
|
|
117
|
+
/** {@link sandboxSeedDigest} of the seed. */
|
|
118
|
+
readonly digest: string
|
|
119
|
+
readonly repositories: readonly SandboxSeedRepositoryReport[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Options for {@link ensureSandboxSeed}. */
|
|
123
|
+
export interface EnsureSandboxSeedOptions {
|
|
124
|
+
/**
|
|
125
|
+
* Absolute directory inside the sandbox the repositories go under.
|
|
126
|
+
* Required, because the right place depends on the backend: on docker the
|
|
127
|
+
* sandbox's working root is the outputs bind the host collects, so a
|
|
128
|
+
* default there would put repositories in the user's outputs. Use the
|
|
129
|
+
* kubernetes workspace template's disk mount, or `layout.scratch` on docker.
|
|
130
|
+
*/
|
|
131
|
+
readonly root: string
|
|
132
|
+
/** Cancels the call; handed to every `exec`. */
|
|
133
|
+
readonly signal?: AbortSignal
|
|
134
|
+
/**
|
|
135
|
+
* Per-`exec` timeout in milliseconds, handed to the backend. A clone of a
|
|
136
|
+
* large repository needs one sized for it; unset leaves the backend's own.
|
|
137
|
+
*/
|
|
138
|
+
readonly timeoutMs?: number
|
|
139
|
+
/** `'refuse'` (the default) throws on drift before cloning anything; `'report'` records it. */
|
|
140
|
+
readonly onDrift?: 'refuse' | 'report'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Why a seed could not be prepared. */
|
|
144
|
+
export type SandboxSeedErrorCode = 'invalid' | 'tool-missing' | 'clone-failed' | 'drift'
|
|
145
|
+
|
|
146
|
+
/** A seed refused, or a step that failed. `repository` names the repository when there is one. */
|
|
147
|
+
export class SandboxSeedError extends Error {
|
|
148
|
+
override readonly name = 'SandboxSeedError'
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
readonly code: SandboxSeedErrorCode,
|
|
152
|
+
message: string,
|
|
153
|
+
readonly repository?: string,
|
|
154
|
+
) {
|
|
155
|
+
super(`sandbox seed: ${message}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const DNS_1123_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/
|
|
160
|
+
const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
161
|
+
const REF = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/
|
|
162
|
+
const PATH_SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
163
|
+
|
|
164
|
+
/** The check's drift states, and what each says about the repository. */
|
|
165
|
+
const DRIFT_STATES: ReadonlyMap<string, string> = new Map([
|
|
166
|
+
['origin', 'has a different origin URL'],
|
|
167
|
+
['history', 'no longer contains its pinned or recorded commit'],
|
|
168
|
+
[
|
|
169
|
+
'ref',
|
|
170
|
+
"does not hold the seed's ref (the default branch when it names none): with no record under that ref, HEAD must be exactly the commit the ref names in the repository, and it is another branch, tag or commit",
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
'pin',
|
|
174
|
+
'is not checked out at its pinned commit: with no record of that pin, HEAD must be exactly the pinned commit, and it is another commit',
|
|
175
|
+
],
|
|
176
|
+
['occupied', 'is a directory that is not a git repository'],
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What CHECK looks up for a seed that names no `ref` and has no record: the
|
|
181
|
+
* remote's default branch, as `refs/remotes/origin/HEAD` (which a clone with
|
|
182
|
+
* no `--branch` leaves). It starts with ':', which no `ref` the seed accepts
|
|
183
|
+
* can, so it never collides with a branch or tag name.
|
|
184
|
+
*/
|
|
185
|
+
const DEFAULT_BRANCH = ':default'
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The marker's `refs` entry for a repository: what its recorded commit was
|
|
189
|
+
* resolved from. A pinned repository records `:commit`, so dropping the pin
|
|
190
|
+
* never lets the pinned commit stand in for the default branch or a `ref`.
|
|
191
|
+
*/
|
|
192
|
+
function recordedFrom(repo: SandboxSeedRepository): string {
|
|
193
|
+
return repo.commit !== undefined ? ':commit' : (repo.ref ?? '')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** How old a partial clone must be before a later call removes it. */
|
|
197
|
+
const STALE_PARTIAL_MINUTES = 60
|
|
198
|
+
|
|
199
|
+
function refuseUrl(url: unknown, where: string): string {
|
|
200
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
201
|
+
throw new SandboxSeedError('invalid', `${where} is not a URL`)
|
|
202
|
+
}
|
|
203
|
+
if (/^[^/]*@[^/]*:/.test(url) && !url.includes('://')) {
|
|
204
|
+
throw new SandboxSeedError(
|
|
205
|
+
'invalid',
|
|
206
|
+
`${where} is an scp-style SSH address; SSH would need a private key inside the sandbox, so only https:// (and http:// for a proxy-brokered host) is accepted`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
let parsed: URL
|
|
210
|
+
try {
|
|
211
|
+
parsed = new URL(url)
|
|
212
|
+
} catch {
|
|
213
|
+
throw new SandboxSeedError('invalid', `${where} is not a URL`)
|
|
214
|
+
}
|
|
215
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
216
|
+
throw new SandboxSeedError(
|
|
217
|
+
'invalid',
|
|
218
|
+
`${where} uses ${parsed.protocol.replace(/:$/, '')}; only https:// (and http:// for a proxy-brokered host) is accepted, because other transports would need a credential or a key inside the sandbox`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
222
|
+
throw new SandboxSeedError(
|
|
223
|
+
'invalid',
|
|
224
|
+
`${where} carries a user name or password, which would put a credential inside the sandbox; on docker, broker it with brokeredCredentials and an http:// URL instead`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (/\s/.test(url)) throw new SandboxSeedError('invalid', `${where} contains whitespace`)
|
|
228
|
+
return url
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function refuseDir(dir: string, where: string): string {
|
|
232
|
+
const segments = dir.split('/')
|
|
233
|
+
if (
|
|
234
|
+
dir.startsWith('/') ||
|
|
235
|
+
segments.some((segment) => segment === '..' || segment === '.' || !PATH_SEGMENT.test(segment))
|
|
236
|
+
) {
|
|
237
|
+
throw new SandboxSeedError(
|
|
238
|
+
'invalid',
|
|
239
|
+
`${where} ${JSON.stringify(dir)} must be a relative path of plain segments (letters, digits, '.', '_', '-'), with no '..'`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
if (segments[0] === '.namzu' || segments.some((segment) => segment.includes('.namzu-partial-'))) {
|
|
243
|
+
throw new SandboxSeedError(
|
|
244
|
+
'invalid',
|
|
245
|
+
`${where} ${JSON.stringify(dir)} is reserved: .namzu/ holds the seed marker, and '.namzu-partial-' names a clone in progress`,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
return dir
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Validate a seed and return it frozen. Refused with `SandboxSeedError`
|
|
253
|
+
* (`code: 'invalid'`): a name that is not a DNS-1123 label, no repositories,
|
|
254
|
+
* a repository name repeated, a URL outside the rules in the module doc, a
|
|
255
|
+
* `ref` that is not a plain branch or tag name, a `commit` that is not a full
|
|
256
|
+
* commit id, a `dir` that is absolute, climbs or is under `.namzu/`, two
|
|
257
|
+
* repositories in one directory or one inside the other, and `depth` that is not a positive integer or is set beside
|
|
258
|
+
* `commit`.
|
|
259
|
+
*/
|
|
260
|
+
export function defineSandboxSeed(input: SandboxSeed): SandboxSeed {
|
|
261
|
+
if (typeof input?.name !== 'string' || !DNS_1123_LABEL.test(input.name)) {
|
|
262
|
+
throw new SandboxSeedError(
|
|
263
|
+
'invalid',
|
|
264
|
+
`name ${JSON.stringify(input?.name)} is not a DNS-1123 label`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(input.repositories) || input.repositories.length === 0) {
|
|
268
|
+
throw new SandboxSeedError('invalid', 'repositories must list at least one repository')
|
|
269
|
+
}
|
|
270
|
+
const names = new Set<string>()
|
|
271
|
+
const dirs: string[] = []
|
|
272
|
+
const repositories = input.repositories.map((repo, index): SandboxSeedRepository => {
|
|
273
|
+
const where = `repositories[${index}]`
|
|
274
|
+
if (typeof repo?.name !== 'string' || !DNS_1123_LABEL.test(repo.name)) {
|
|
275
|
+
throw new SandboxSeedError(
|
|
276
|
+
'invalid',
|
|
277
|
+
`${where}.name ${JSON.stringify(repo?.name)} is not a DNS-1123 label`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
if (names.has(repo.name)) {
|
|
281
|
+
throw new SandboxSeedError(
|
|
282
|
+
'invalid',
|
|
283
|
+
`${where}.name ${JSON.stringify(repo.name)} is listed twice`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
names.add(repo.name)
|
|
287
|
+
const url = refuseUrl(repo.url, `${where}.url`)
|
|
288
|
+
if (
|
|
289
|
+
repo.ref !== undefined &&
|
|
290
|
+
(typeof repo.ref !== 'string' || !REF.test(repo.ref) || repo.ref.includes('..'))
|
|
291
|
+
) {
|
|
292
|
+
throw new SandboxSeedError(
|
|
293
|
+
'invalid',
|
|
294
|
+
`${where}.ref ${JSON.stringify(repo.ref)} is not a branch or tag name`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
repo.commit !== undefined &&
|
|
299
|
+
(typeof repo.commit !== 'string' || !COMMIT_ID.test(repo.commit))
|
|
300
|
+
) {
|
|
301
|
+
throw new SandboxSeedError(
|
|
302
|
+
'invalid',
|
|
303
|
+
`${where}.commit ${JSON.stringify(repo.commit)} is not a full lowercase commit id`,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
if (repo.depth !== undefined) {
|
|
307
|
+
if (!Number.isInteger(repo.depth) || repo.depth < 1) {
|
|
308
|
+
throw new SandboxSeedError(
|
|
309
|
+
'invalid',
|
|
310
|
+
`${where}.depth ${JSON.stringify(repo.depth)} is not a positive integer`,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
if (repo.commit !== undefined) {
|
|
314
|
+
throw new SandboxSeedError(
|
|
315
|
+
'invalid',
|
|
316
|
+
`${where}.depth is set beside commit; a pinned commit is fetched with its history`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const dir = refuseDir(repo.dir ?? repo.name, `${where}.dir`)
|
|
321
|
+
if (dirs.includes(dir)) {
|
|
322
|
+
throw new SandboxSeedError(
|
|
323
|
+
'invalid',
|
|
324
|
+
`${where}.dir ${JSON.stringify(dir)} is used by another repository`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
// A clone creates its parent directories, so a repository inside
|
|
328
|
+
// another's directory would occupy it before that one is cloned.
|
|
329
|
+
const nested = dirs.find((other) => other.startsWith(`${dir}/`) || dir.startsWith(`${other}/`))
|
|
330
|
+
if (nested !== undefined) {
|
|
331
|
+
throw new SandboxSeedError(
|
|
332
|
+
'invalid',
|
|
333
|
+
`${where}.dir ${JSON.stringify(dir)} and ${JSON.stringify(nested)} are nested; each repository needs a directory of its own, neither inside the other`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
dirs.push(dir)
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
name: repo.name,
|
|
339
|
+
url,
|
|
340
|
+
...(repo.ref !== undefined ? { ref: repo.ref } : {}),
|
|
341
|
+
...(repo.commit !== undefined ? { commit: repo.commit } : {}),
|
|
342
|
+
...(repo.dir !== undefined ? { dir: repo.dir } : {}),
|
|
343
|
+
...(repo.depth !== undefined ? { depth: repo.depth } : {}),
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
return Object.freeze({ name: input.name, repositories: Object.freeze(repositories) })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A stable digest of the seed's content: SHA-256 over its normalised form,
|
|
351
|
+
* hex. Two seeds with the same repositories in the same order have the same
|
|
352
|
+
* digest whatever key order they were written in.
|
|
353
|
+
*/
|
|
354
|
+
export function sandboxSeedDigest(seed: SandboxSeed): string {
|
|
355
|
+
const defined = defineSandboxSeed(seed)
|
|
356
|
+
const canonical = {
|
|
357
|
+
name: defined.name,
|
|
358
|
+
repositories: defined.repositories.map((repo) => [
|
|
359
|
+
repo.name,
|
|
360
|
+
repo.url,
|
|
361
|
+
repo.ref ?? null,
|
|
362
|
+
repo.commit ?? null,
|
|
363
|
+
repo.dir ?? repo.name,
|
|
364
|
+
repo.depth ?? null,
|
|
365
|
+
]),
|
|
366
|
+
}
|
|
367
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type Exec = (script: string, args: readonly string[]) => Promise<SandboxExecResult>
|
|
371
|
+
|
|
372
|
+
function tail(text: string): string {
|
|
373
|
+
const trimmed = text.trim()
|
|
374
|
+
return trimmed.length > 800 ? `…${trimmed.slice(-800)}` : trimmed
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const PREFLIGHT = `
|
|
378
|
+
for tool in git find mkdir mktemp rm; do
|
|
379
|
+
command -v "$tool" >/dev/null 2>&1 || { printf 'missing %s\\n' "$tool"; exit 3; }
|
|
380
|
+
done
|
|
381
|
+
mkdir -p "$1/.namzu/seed" || { printf 'unwritable %s\\n' "$1"; exit 4; }
|
|
382
|
+
probe=$(mktemp -d "$1/.namzu/seed/probe.XXXXXX") || { printf 'unwritable %s\\n' "$1"; exit 4; }
|
|
383
|
+
mkdir "$probe/a"
|
|
384
|
+
if mv -T "$probe/a" "$probe/b" 2>/dev/null; then ok=1; else ok=0; fi
|
|
385
|
+
rm -rf "$probe"
|
|
386
|
+
[ "$ok" = 1 ] || { printf 'missing mv -T\\n'; exit 3; }
|
|
387
|
+
`
|
|
388
|
+
|
|
389
|
+
// Arguments, per repository: dir, url, expected commit ('' for none), ref to
|
|
390
|
+
// find in the repository when there is no expected commit ('' for none,
|
|
391
|
+
// DEFAULT_BRANCH for the remote's default branch), and '1' when the expected
|
|
392
|
+
// commit is a pin the marker has no record of, which HEAD must then equal
|
|
393
|
+
// exactly (the ancestor rule holds only for a pin this call recorded). A ref found that way must
|
|
394
|
+
// be exactly HEAD: sharing a line of history with HEAD is not holding it, since
|
|
395
|
+
// a clone deeper than 1 carries the tags in its history and a pinned commit's
|
|
396
|
+
// full clone carries every remote branch. Prints '<state> <head>', and for
|
|
397
|
+
// 'present' the commit it held HEAD to ('-' for none).
|
|
398
|
+
const CHECK = `
|
|
399
|
+
while [ "$#" -ge 5 ]; do
|
|
400
|
+
dir=$1; url=$2; want=$3; ref=$4; exact=$5; shift 5
|
|
401
|
+
if [ ! -e "$dir" ]; then printf 'missing -\\n'; continue; fi
|
|
402
|
+
if [ ! -e "$dir/.git" ]; then printf 'occupied -\\n'; continue; fi
|
|
403
|
+
origin=$(git -C "$dir" config --get remote.origin.url 2>/dev/null || true)
|
|
404
|
+
head=$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || printf -- '-')
|
|
405
|
+
if [ "$origin" != "$url" ]; then printf 'origin %s\\n' "$head"; continue; fi
|
|
406
|
+
if [ -z "$want" ] && [ -n "$ref" ]; then
|
|
407
|
+
if [ "$ref" = "${DEFAULT_BRANCH}" ]; then
|
|
408
|
+
tip=$(git -C "$dir" rev-parse --verify --quiet "refs/remotes/origin/HEAD^{commit}" 2>/dev/null || true)
|
|
409
|
+
else
|
|
410
|
+
tip=$(git -C "$dir" rev-parse --verify --quiet "refs/remotes/origin/$ref^{commit}" 2>/dev/null ||
|
|
411
|
+
git -C "$dir" rev-parse --verify --quiet "refs/tags/$ref^{commit}" 2>/dev/null || true)
|
|
412
|
+
fi
|
|
413
|
+
if [ -z "$tip" ] || [ "$tip" != "$head" ]; then printf 'ref %s\\n' "$head"; continue; fi
|
|
414
|
+
want=$tip
|
|
415
|
+
elif [ -n "$want" ] && [ "$exact" = 1 ]; then
|
|
416
|
+
if [ "$want" != "$head" ]; then printf 'pin %s\\n' "$head"; continue; fi
|
|
417
|
+
elif [ -n "$want" ] && ! git -C "$dir" merge-base --is-ancestor "$want" HEAD 2>/dev/null; then
|
|
418
|
+
printf 'history %s\\n' "$head"; continue
|
|
419
|
+
fi
|
|
420
|
+
printf 'present %s %s\\n' "$head" "\${want:--}"
|
|
421
|
+
done
|
|
422
|
+
`
|
|
423
|
+
|
|
424
|
+
// Arguments: dir, url, ref ('' for default), commit ('' for none), depth ('' for full), nonce.
|
|
425
|
+
const CLONE = `
|
|
426
|
+
set -e
|
|
427
|
+
dir=$1; url=$2; ref=$3; commit=$4; depth=$5; nonce=$6
|
|
428
|
+
mkdir -p "$(dirname "$dir")"
|
|
429
|
+
partial="$dir.namzu-partial-$nonce"
|
|
430
|
+
set -- clone --quiet
|
|
431
|
+
[ -n "$depth" ] && set -- "$@" --depth "$depth"
|
|
432
|
+
[ -n "$ref" ] && set -- "$@" --branch "$ref"
|
|
433
|
+
git -c credential.helper= "$@" -- "$url" "$partial"
|
|
434
|
+
if [ -n "$commit" ]; then git -C "$partial" -c advice.detachedHead=false checkout --quiet --detach "$commit"; fi
|
|
435
|
+
head=$(git -C "$partial" rev-parse HEAD)
|
|
436
|
+
if mv -T "$partial" "$dir" 2>/dev/null; then printf 'cloned %s\\n' "$head"; else rm -rf -- "$partial"; printf 'raced %s\\n' "$head"; fi
|
|
437
|
+
`
|
|
438
|
+
|
|
439
|
+
// Arguments: marker path, content, this call's nonce, then the parent
|
|
440
|
+
// directories to sweep. The temporary name carries the nonce, not the shell's
|
|
441
|
+
// PID: two sandboxes sharing one disk often run this as the same PID, each in
|
|
442
|
+
// its own PID namespace.
|
|
443
|
+
const FINISH = `
|
|
444
|
+
set -e
|
|
445
|
+
marker=$1; content=$2; nonce=$3; shift 3
|
|
446
|
+
tmp="$marker.tmp.$nonce"
|
|
447
|
+
if ! { printf '%s' "$content" > "$tmp" && mv -f -- "$tmp" "$marker"; }; then
|
|
448
|
+
rm -f -- "$tmp"
|
|
449
|
+
exit 1
|
|
450
|
+
fi
|
|
451
|
+
for parent in "$@"; do
|
|
452
|
+
[ -d "$parent" ] || continue
|
|
453
|
+
find "$parent" -mindepth 1 -maxdepth 1 -type d -name '*.namzu-partial-*' -mmin +${STALE_PARTIAL_MINUTES} -exec rm -rf -- {} + 2>/dev/null || true
|
|
454
|
+
done
|
|
455
|
+
`
|
|
456
|
+
|
|
457
|
+
interface Marker {
|
|
458
|
+
readonly commits: Readonly<Record<string, string>>
|
|
459
|
+
/** What each commit was resolved from: the `ref`, `''` for the default branch, `:commit` for a pin. */
|
|
460
|
+
readonly refs: Readonly<Record<string, string>>
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The recorded commit of each repository whose record was made under the ref
|
|
465
|
+
* the seed names now. A record made under another ref says nothing about this
|
|
466
|
+
* one, so it is dropped and the repository is checked against the ref itself.
|
|
467
|
+
*/
|
|
468
|
+
function readMarker(raw: string, seed: SandboxSeed): Map<string, string> {
|
|
469
|
+
const commits = new Map<string, string>()
|
|
470
|
+
let parsed: unknown
|
|
471
|
+
try {
|
|
472
|
+
parsed = JSON.parse(raw)
|
|
473
|
+
} catch {
|
|
474
|
+
return commits
|
|
475
|
+
}
|
|
476
|
+
const recorded = (parsed as Partial<Marker> | null)?.commits
|
|
477
|
+
const refs = (parsed as Partial<Marker> | null)?.refs
|
|
478
|
+
if (recorded === null || typeof recorded !== 'object') return commits
|
|
479
|
+
if (refs === null || typeof refs !== 'object') return commits
|
|
480
|
+
for (const repo of seed.repositories) {
|
|
481
|
+
const value = (recorded as Record<string, unknown>)[repo.name]
|
|
482
|
+
const ref = (refs as Record<string, unknown>)[repo.name]
|
|
483
|
+
if (ref !== recordedFrom(repo)) continue
|
|
484
|
+
// Guest-writable: a value that is not a commit id is ignored rather
|
|
485
|
+
// than handed to git, where it could be read as an option.
|
|
486
|
+
if (typeof value === 'string' && COMMIT_ID.test(value)) commits.set(repo.name, value)
|
|
487
|
+
}
|
|
488
|
+
return commits
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The ref CHECK looks for in the repository: none when a commit is pinned or
|
|
493
|
+
* recorded, otherwise the seed's `ref` or, with none, the default branch.
|
|
494
|
+
*/
|
|
495
|
+
function refToFind(repo: SandboxSeedRepository, want: string): string {
|
|
496
|
+
return want === '' ? (repo.ref ?? DEFAULT_BRANCH) : ''
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function joinPath(root: string, dir: string): string {
|
|
500
|
+
return `${root.replace(/\/+$/, '')}/${dir}`
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function parentOf(path: string): string {
|
|
504
|
+
return path.slice(0, path.lastIndexOf('/')) || '/'
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Make the seed's repositories present under `options.root`, doing only what
|
|
509
|
+
* is missing, and report what was found. See the module doc for what is
|
|
510
|
+
* trusted, what is refused and what is never deleted.
|
|
511
|
+
*/
|
|
512
|
+
export async function ensureSandboxSeed(
|
|
513
|
+
sandbox: Sandbox,
|
|
514
|
+
seed: SandboxSeed,
|
|
515
|
+
options: EnsureSandboxSeedOptions,
|
|
516
|
+
): Promise<SandboxSeedReport> {
|
|
517
|
+
const defined = defineSandboxSeed(seed)
|
|
518
|
+
const root = options?.root
|
|
519
|
+
if (
|
|
520
|
+
typeof root !== 'string' ||
|
|
521
|
+
!root.startsWith('/') ||
|
|
522
|
+
root.split('/').includes('..') ||
|
|
523
|
+
/\s/.test(root)
|
|
524
|
+
) {
|
|
525
|
+
throw new SandboxSeedError(
|
|
526
|
+
'invalid',
|
|
527
|
+
`root ${JSON.stringify(root)} must be an absolute path inside the sandbox with no '..' and no whitespace; it is required because the right place depends on the backend (a kubernetes workspace's disk mount, layout.scratch on docker)`,
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
const onDrift = options.onDrift ?? 'refuse'
|
|
531
|
+
const digest = sandboxSeedDigest(defined)
|
|
532
|
+
const markerPath = joinPath(root, `.namzu/seed/${defined.name}.json`)
|
|
533
|
+
|
|
534
|
+
const exec: Exec = async (script, args) => {
|
|
535
|
+
options.signal?.throwIfAborted()
|
|
536
|
+
return await sandbox.exec('sh', ['-c', script, 'namzu-seed', ...args], {
|
|
537
|
+
env: { GIT_TERMINAL_PROMPT: '0' },
|
|
538
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
539
|
+
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 1. The tools, by probe. A guest with no `sh` at all fails the exec itself.
|
|
544
|
+
let preflight: SandboxExecResult
|
|
545
|
+
try {
|
|
546
|
+
preflight = await exec(PREFLIGHT, [root])
|
|
547
|
+
} catch (error) {
|
|
548
|
+
options.signal?.throwIfAborted()
|
|
549
|
+
throw new SandboxSeedError(
|
|
550
|
+
'tool-missing',
|
|
551
|
+
`could not run sh in the sandbox (${error instanceof Error ? error.message : String(error)}); the image needs sh, git, find, mkdir, mktemp, rm and GNU mv`,
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
if (preflight.exitCode !== 0) {
|
|
555
|
+
const line = preflight.stdout.trim()
|
|
556
|
+
const missing = line.startsWith('missing ') ? line.slice('missing '.length) : undefined
|
|
557
|
+
if (missing !== undefined || preflight.exitCode === 127) {
|
|
558
|
+
throw new SandboxSeedError(
|
|
559
|
+
'tool-missing',
|
|
560
|
+
`the sandbox image has no ${missing ?? 'sh'}; it needs sh, git, find, mkdir, mktemp, rm and a mv that takes -T (GNU coreutils)`,
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
throw new SandboxSeedError(
|
|
564
|
+
'invalid',
|
|
565
|
+
`root ${JSON.stringify(root)} is not writable in the sandbox: ${tail(preflight.stdout + preflight.stderr)}`,
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 2. What is there. The marker is read for the commits refs resolved to, and
|
|
570
|
+
// only for those; every repository is then checked directly.
|
|
571
|
+
const markerRead = await exec('cat -- "$1" 2>/dev/null || true', [markerPath])
|
|
572
|
+
const recorded = readMarker(markerRead.stdout, defined)
|
|
573
|
+
const paths = defined.repositories.map((repo) => joinPath(root, repo.dir ?? repo.name))
|
|
574
|
+
const expected = defined.repositories.map((repo) => repo.commit ?? recorded.get(repo.name) ?? '')
|
|
575
|
+
const checkArgs: string[] = []
|
|
576
|
+
defined.repositories.forEach((repo, index) => {
|
|
577
|
+
const want = expected[index] as string
|
|
578
|
+
const unrecordedPin = repo.commit !== undefined && recorded.get(repo.name) !== repo.commit
|
|
579
|
+
checkArgs.push(
|
|
580
|
+
paths[index] as string,
|
|
581
|
+
repo.url,
|
|
582
|
+
want,
|
|
583
|
+
refToFind(repo, want),
|
|
584
|
+
unrecordedPin ? '1' : '',
|
|
585
|
+
)
|
|
586
|
+
})
|
|
587
|
+
const checked = await exec(CHECK, checkArgs)
|
|
588
|
+
if (checked.exitCode !== 0) {
|
|
589
|
+
throw new SandboxSeedError(
|
|
590
|
+
'invalid',
|
|
591
|
+
`could not inspect the seed root: ${tail(checked.stderr)}`,
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
const lines = checked.stdout.trim().split('\n')
|
|
595
|
+
const states = defined.repositories.map((repo, index) => {
|
|
596
|
+
const [state, head, held] = (lines[index] ?? '').split(' ')
|
|
597
|
+
if (!DRIFT_STATES.has(state ?? '') && state !== 'missing' && state !== 'present') {
|
|
598
|
+
throw new SandboxSeedError('invalid', `unreadable check result for ${repo.name}`, repo.name)
|
|
599
|
+
}
|
|
600
|
+
return { state, head: head ?? '-', held: held !== undefined && held !== '-' ? held : undefined }
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// 3. Drift is refused before anything is cloned, so a refusal leaves the
|
|
604
|
+
// root exactly as it was found.
|
|
605
|
+
const drifted = defined.repositories.filter((_, index) =>
|
|
606
|
+
DRIFT_STATES.has(states[index]?.state ?? ''),
|
|
607
|
+
)
|
|
608
|
+
if (drifted.length > 0 && onDrift === 'refuse') {
|
|
609
|
+
throw new SandboxSeedError(
|
|
610
|
+
'drift',
|
|
611
|
+
`${drifted
|
|
612
|
+
.map((repo) => {
|
|
613
|
+
const state = states[defined.repositories.indexOf(repo)]?.state ?? ''
|
|
614
|
+
return `${repo.name} ${DRIFT_STATES.get(state)}`
|
|
615
|
+
})
|
|
616
|
+
.join('; ')}. Nothing was changed; resolve it by hand, or pass onDrift: 'report'`,
|
|
617
|
+
drifted[0]?.name,
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 4. Clone what is missing, each into its own partial directory.
|
|
622
|
+
const reports: SandboxSeedRepositoryReport[] = []
|
|
623
|
+
const commits: Record<string, string> = {}
|
|
624
|
+
const refs: Record<string, string> = {}
|
|
625
|
+
const record = (repo: SandboxSeedRepository, commit: string): void => {
|
|
626
|
+
commits[repo.name] = commit
|
|
627
|
+
refs[repo.name] = recordedFrom(repo)
|
|
628
|
+
}
|
|
629
|
+
for (const [index, repo] of defined.repositories.entries()) {
|
|
630
|
+
const state = states[index] as { state: string; head: string; held: string | undefined }
|
|
631
|
+
const path = paths[index] as string
|
|
632
|
+
if (state.state === 'present') {
|
|
633
|
+
reports.push({ name: repo.name, status: 'present', commit: state.head })
|
|
634
|
+
record(repo, state.held ?? state.head)
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
if (DRIFT_STATES.has(state.state)) {
|
|
638
|
+
reports.push({ name: repo.name, status: 'drifted', commit: state.head })
|
|
639
|
+
const known = recorded.get(repo.name)
|
|
640
|
+
if (known !== undefined) record(repo, known)
|
|
641
|
+
continue
|
|
642
|
+
}
|
|
643
|
+
const nonce = randomBytes(6).toString('hex')
|
|
644
|
+
const depth = repo.commit !== undefined ? '' : String(repo.depth ?? 1)
|
|
645
|
+
const cloned = await exec(CLONE, [
|
|
646
|
+
path,
|
|
647
|
+
repo.url,
|
|
648
|
+
repo.ref ?? '',
|
|
649
|
+
repo.commit ?? '',
|
|
650
|
+
depth,
|
|
651
|
+
nonce,
|
|
652
|
+
])
|
|
653
|
+
const [outcome, head] = cloned.stdout.trim().split('\n').at(-1)?.split(' ') ?? []
|
|
654
|
+
if (
|
|
655
|
+
cloned.exitCode !== 0 ||
|
|
656
|
+
(outcome !== 'cloned' && outcome !== 'raced') ||
|
|
657
|
+
head === undefined
|
|
658
|
+
) {
|
|
659
|
+
throw new SandboxSeedError(
|
|
660
|
+
'clone-failed',
|
|
661
|
+
`cloning ${repo.name} from ${repo.url} failed (exit ${cloned.exitCode}${cloned.timedOut ? ', timed out' : ''}): ${tail(cloned.stderr)}`,
|
|
662
|
+
repo.name,
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
if (outcome === 'cloned') {
|
|
666
|
+
reports.push({ name: repo.name, status: 'cloned', commit: head })
|
|
667
|
+
record(repo, repo.commit ?? head)
|
|
668
|
+
continue
|
|
669
|
+
}
|
|
670
|
+
// Something took the directory while this call cloned: a peer moved
|
|
671
|
+
// its clone into place first. Ours is gone; check what is there the
|
|
672
|
+
// same way any present repository is checked, and say what it is.
|
|
673
|
+
const want = repo.commit ?? ''
|
|
674
|
+
const recheck = await exec(CHECK, [
|
|
675
|
+
path,
|
|
676
|
+
repo.url,
|
|
677
|
+
want,
|
|
678
|
+
refToFind(repo, want),
|
|
679
|
+
repo.commit !== undefined ? '1' : '',
|
|
680
|
+
])
|
|
681
|
+
const [again, peerHead, held] = recheck.stdout.trim().split(' ')
|
|
682
|
+
if (again !== 'present') {
|
|
683
|
+
const what = DRIFT_STATES.get(again ?? '') ?? 'could not be checked'
|
|
684
|
+
if (onDrift === 'refuse') {
|
|
685
|
+
throw new SandboxSeedError(
|
|
686
|
+
'drift',
|
|
687
|
+
`${repo.name} appeared while this call was cloning it, and ${what}. This call's own clone was removed; nothing else was changed`,
|
|
688
|
+
repo.name,
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
reports.push({ name: repo.name, status: 'drifted', commit: peerHead ?? '-' })
|
|
692
|
+
continue
|
|
693
|
+
}
|
|
694
|
+
reports.push({ name: repo.name, status: 'present', commit: peerHead ?? '-' })
|
|
695
|
+
record(repo, repo.commit ?? (held !== undefined && held !== '-' ? held : (peerHead ?? head)))
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 5. The marker, written whole and moved into place; then stale partials.
|
|
699
|
+
const content = JSON.stringify({ seed: defined.name, digest, commits, refs })
|
|
700
|
+
const parents = [...new Set(paths.map(parentOf))]
|
|
701
|
+
const finishNonce = randomBytes(6).toString('hex')
|
|
702
|
+
const finished = await exec(FINISH, [markerPath, content, finishNonce, ...parents])
|
|
703
|
+
if (finished.exitCode !== 0) {
|
|
704
|
+
throw new SandboxSeedError(
|
|
705
|
+
'invalid',
|
|
706
|
+
`could not write the seed marker: ${tail(finished.stderr)}`,
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
return { digest, repositories: reports }
|
|
710
|
+
}
|