@smoothbricks/nx-cache-server 0.0.0-stage → 0.1.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/README.md +131 -2
- package/dist/server/nx-cache-server.js +710 -0
- package/dist/ts/config.d.ts +58 -0
- package/dist/ts/config.d.ts.map +1 -0
- package/dist/ts/config.js +116 -0
- package/dist/ts/gc.d.ts +36 -0
- package/dist/ts/gc.d.ts.map +1 -0
- package/dist/ts/gc.js +135 -0
- package/dist/ts/index.d.ts +6 -0
- package/dist/ts/index.d.ts.map +1 -0
- package/dist/ts/index.js +5 -0
- package/dist/ts/log.d.ts +16 -0
- package/dist/ts/log.d.ts.map +1 -0
- package/dist/ts/log.js +22 -0
- package/dist/ts/main.d.ts +3 -0
- package/dist/ts/main.d.ts.map +1 -0
- package/dist/ts/main.js +86 -0
- package/dist/ts/root-lease.d.ts +7 -0
- package/dist/ts/root-lease.d.ts.map +1 -0
- package/dist/ts/root-lease.js +72 -0
- package/dist/ts/server.d.ts +71 -0
- package/dist/ts/server.d.ts.map +1 -0
- package/dist/ts/server.js +282 -0
- package/dist/ts/store.d.ts +74 -0
- package/dist/ts/store.d.ts.map +1 -0
- package/dist/ts/store.js +166 -0
- package/package.json +103 -4
- package/src/__tests__/supervisor.fixture.ts +15 -0
- package/src/config.ts +201 -0
- package/src/gc.ts +164 -0
- package/src/index.ts +5 -0
- package/src/log.ts +26 -0
- package/src/main.ts +97 -0
- package/src/root-lease.ts +71 -0
- package/src/server.ts +416 -0
- package/src/store.ts +215 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An nx self-hosted remote cache: `GET /v1/cache/<hash>` returns an artifact, `PUT /v1/cache/<hash>`
|
|
3
|
+
* publishes one, and a bearer token guards both.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists as our own process rather than a bucket: CVE-2025-36852 — CREEP, "Cache
|
|
6
|
+
* Race-condition Exploit Enables Poisoning", CVSS 9.4 — is a first-to-cache-wins race. A task hash is
|
|
7
|
+
* a function of the source state, so any environment that can write the cache can publish the artifact
|
|
8
|
+
* every later build with that same source state will consume, production included. The published
|
|
9
|
+
* mitigation is that untrusted environments must be able to read the cache and never write it, which is
|
|
10
|
+
* a property of the *server*: a bucket credential is read-write or nothing, so the trust boundary has
|
|
11
|
+
* nowhere to live. Here it does:
|
|
12
|
+
*
|
|
13
|
+
* - **Tokens are read-only-capable.** A principal in `NX_CACHE_SERVER_READ_TOKENS` fetches artifacts
|
|
14
|
+
* and is refused the write path with 403. A pull-request runner, a developer shed, or anything else
|
|
15
|
+
* whose code has not been reviewed gets one of those and cannot reach into a trusted build's
|
|
16
|
+
* lookup. Only `NX_CACHE_SERVER_WRITE_TOKENS` publishes.
|
|
17
|
+
* - **Writes never replace.** `store.ts` publishes by `link(2)`, so an existing key is answered 409
|
|
18
|
+
* and its bytes are never touched. A write token that leaks cannot go back and swap the artifact a
|
|
19
|
+
* trusted build already published, and a retried upload is idempotent rather than a second chance
|
|
20
|
+
* to change history.
|
|
21
|
+
* - **Every publication is attributable.** Tokens carry labels (`config.ts`), so the log line for a
|
|
22
|
+
* stored artifact names the principal that stored it. Forensics after a poisoning need that name.
|
|
23
|
+
*
|
|
24
|
+
* The status codes are not ours to choose; they are what the client already does with them
|
|
25
|
+
* (`packages/nx/src/native/cache/http_remote_cache.rs` in nx 23.2.0):
|
|
26
|
+
*
|
|
27
|
+
* - `store` accepts **200** as stored, treats **409** and **403** as "not stored, carry on", and
|
|
28
|
+
* turns *everything else* into a failed task. This server answers 200, the status nx's native
|
|
29
|
+
* `store` accepts, so the write succeeds.
|
|
30
|
+
* - `retrieve` accepts **200** and **404** and fails the task on anything else — so a bad token is a
|
|
31
|
+
* loud error rather than a silent stream of cache misses, which is what we want.
|
|
32
|
+
* - A refusal we want the operator to *read* must therefore be a 401, and `errors.rs` only reads a
|
|
33
|
+
* 401's body when the content type is exactly `text/plain`.
|
|
34
|
+
*
|
|
35
|
+
* Retention is filesystem SIEVE (`gc.ts`), serialized at startup and after successful publication.
|
|
36
|
+
* The project owner keeps backend credentials out of client environments; the server stores opaque
|
|
37
|
+
* Nx task archives, not workspace databases.
|
|
38
|
+
*/
|
|
39
|
+
import type { CacheServerConfig } from './config.js';
|
|
40
|
+
export interface RunningCacheServer {
|
|
41
|
+
/** The origin to hand nx as `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. */
|
|
42
|
+
readonly origin: string;
|
|
43
|
+
readonly port: number;
|
|
44
|
+
/** Staged uploads a previous process left behind, removed while opening the store. */
|
|
45
|
+
readonly reclaimedStagingFiles: number;
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/** The single stdout readiness record emitted by the foreground executable. */
|
|
49
|
+
export interface CacheServerReadyRecord {
|
|
50
|
+
readonly kind: 'ready';
|
|
51
|
+
readonly origin: string;
|
|
52
|
+
readonly port: number;
|
|
53
|
+
readonly reclaimedStagingFiles: number;
|
|
54
|
+
}
|
|
55
|
+
export type CacheServerStartResult = {
|
|
56
|
+
readonly ok: true;
|
|
57
|
+
readonly server: RunningCacheServer;
|
|
58
|
+
} | {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly stage: 'store' | 'gc' | 'listen';
|
|
61
|
+
readonly directory: string;
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
} | {
|
|
64
|
+
readonly ok: false;
|
|
65
|
+
readonly stage: 'store-locked';
|
|
66
|
+
readonly directory: string;
|
|
67
|
+
readonly path: string;
|
|
68
|
+
readonly reason: string;
|
|
69
|
+
};
|
|
70
|
+
export declare function startCacheServer(config: CacheServerConfig): Promise<CacheServerStartResult>;
|
|
71
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAkB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA4CrE,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sFAAsF;IACtF,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,+EAA+E;AAC/E,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;CACxC;AAED,MAAM,MAAM,sBAAsB,GAC9B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAA;CAAE,GAC1D;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,GAAG,QAAQ,CAAC;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,CAAC;AAMN,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA0FjG"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
import { sweepDirectory } from './gc.js';
|
|
3
|
+
import { logLine, logValue } from './log.js';
|
|
4
|
+
import { describe, isCacheKey, openCacheStore, publishEntry, readEntry, stageEntry, } from './store.js';
|
|
5
|
+
const CACHE_ROUTE = '/v1/cache/:hash';
|
|
6
|
+
/** nx sends and expects `application/octet-stream` for artifact bodies. */
|
|
7
|
+
const OCTET_STREAM = {
|
|
8
|
+
'content-type': 'application/octet-stream',
|
|
9
|
+
};
|
|
10
|
+
/** Exactly `text/plain`, no charset: see the note on `errors.rs` in the module header. */
|
|
11
|
+
const PLAIN = {
|
|
12
|
+
'content-type': 'text/plain',
|
|
13
|
+
};
|
|
14
|
+
const PLAIN_UNAUTHENTICATED = {
|
|
15
|
+
'content-type': 'text/plain',
|
|
16
|
+
'www-authenticate': 'Bearer',
|
|
17
|
+
};
|
|
18
|
+
const MISS_BODY = 'No cache entry for that key.\n';
|
|
19
|
+
/**
|
|
20
|
+
* Bun answers a body larger than this with a 413 the handler never sees — measured: it compares
|
|
21
|
+
* `content-length` before invoking the route, and it also trips mid-stream on a body that declared no
|
|
22
|
+
* length. Since nx turns any status but 200/409/403 into a failed task, the *policy* limits cannot live
|
|
23
|
+
* here. `handlePut` owns the bounds: a PUT with no declared length is refused 411, a declared length over
|
|
24
|
+
* `maxBytes` is refused 403 before a byte of body is read, and aggregate in-flight staging is reserved
|
|
25
|
+
* against a separate cap. The published archive cap is enforced by sweeps after publication. This
|
|
26
|
+
* ceiling is only a framing tripwire; it does not by itself bound bytes on disk or in flight.
|
|
27
|
+
*/
|
|
28
|
+
const FRAMING_CEILING = Number.MAX_SAFE_INTEGER;
|
|
29
|
+
export async function startCacheServer(config) {
|
|
30
|
+
let opened;
|
|
31
|
+
try {
|
|
32
|
+
opened = await openCacheStore(config.directory);
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
return { ok: false, stage: 'store', directory: config.directory, reason: describe(cause) };
|
|
36
|
+
}
|
|
37
|
+
if (!opened.ok) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
stage: 'store-locked',
|
|
41
|
+
directory: config.directory,
|
|
42
|
+
path: opened.path,
|
|
43
|
+
reason: `EWOULDBLOCK: another process holds the store lock at ${opened.path}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const store = opened.store;
|
|
47
|
+
const initial = await sweepDirectory(store, config.capBytes);
|
|
48
|
+
if (!initial.ok) {
|
|
49
|
+
await store.close();
|
|
50
|
+
return { ok: false, stage: 'gc', directory: config.directory, reason: initial.reason };
|
|
51
|
+
}
|
|
52
|
+
const admission = { reservedBytes: 0 };
|
|
53
|
+
let publishing = Promise.resolve();
|
|
54
|
+
function publish(upload) {
|
|
55
|
+
const next = publishing.then(async () => {
|
|
56
|
+
const outcome = await publishEntry(store, upload);
|
|
57
|
+
if (outcome.kind !== 'stored')
|
|
58
|
+
return outcome;
|
|
59
|
+
const retained = await sweepDirectory(store, config.capBytes, upload.key);
|
|
60
|
+
return retained.ok
|
|
61
|
+
? outcome
|
|
62
|
+
: {
|
|
63
|
+
kind: 'failed',
|
|
64
|
+
reason: retained.reason,
|
|
65
|
+
stagingRetained: outcome.stagingRetained,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
publishing = next.then(() => { });
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const server = Bun.serve({
|
|
73
|
+
hostname: config.host,
|
|
74
|
+
port: config.port,
|
|
75
|
+
maxRequestBodySize: FRAMING_CEILING,
|
|
76
|
+
routes: {
|
|
77
|
+
[CACHE_ROUTE]: {
|
|
78
|
+
GET: (request) => handleGet(store, config, request),
|
|
79
|
+
PUT: (request) => handlePut(store, config, request, admission, publish),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
fetch(request) {
|
|
83
|
+
const { pathname } = new URL(request.url);
|
|
84
|
+
logLine(`ev=unrouted method=${logValue(request.method)} path=${logValue(pathname)} status=404`);
|
|
85
|
+
return new Response('This server serves /v1/cache/<hash> only (GET, PUT). A path with a doubled slash means NX_SELF_HOSTED_REMOTE_CACHE_SERVER carries a trailing slash.\n', { status: 404, headers: PLAIN });
|
|
86
|
+
},
|
|
87
|
+
error(failure) {
|
|
88
|
+
logLine(`ev=server.error status=500 reason=${logValue(String(failure.message ?? failure))}`);
|
|
89
|
+
return new Response('The cache server failed to handle that request; see its log.\n', {
|
|
90
|
+
status: 500,
|
|
91
|
+
headers: PLAIN,
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const principals = config.principals.map((principal) => `${principal.label}:${principal.capability}`).join(',');
|
|
96
|
+
logLine(`ev=listening origin=${server.url.origin} dir=${logValue(config.directory)} principals=${principals} max_bytes=${config.maxBytes} cap_bytes=${config.capBytes} reclaimed_staging=${store.reclaimedStagingFiles}`);
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
server: {
|
|
100
|
+
origin: server.url.origin,
|
|
101
|
+
port: server.port ?? config.port,
|
|
102
|
+
reclaimedStagingFiles: store.reclaimedStagingFiles,
|
|
103
|
+
async stop() {
|
|
104
|
+
await server.stop(false);
|
|
105
|
+
await publishing;
|
|
106
|
+
await store.close();
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (cause) {
|
|
112
|
+
await store.close();
|
|
113
|
+
return { ok: false, stage: 'listen', directory: config.directory, reason: describe(cause) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function handleGet(store, config, request) {
|
|
117
|
+
const startedNs = Bun.nanoseconds();
|
|
118
|
+
const key = request.params.hash;
|
|
119
|
+
const principal = authorize(config.principals, request.headers.get('authorization'));
|
|
120
|
+
if (principal === undefined) {
|
|
121
|
+
record('get', key, undefined, 401, 0, startedNs, 'unauthenticated');
|
|
122
|
+
return unauthenticated();
|
|
123
|
+
}
|
|
124
|
+
if (!isCacheKey(key)) {
|
|
125
|
+
// A key outside the charset cannot name an entry, so a miss is the honest answer, and nx reads a
|
|
126
|
+
// 404 as "not cached" without failing the task.
|
|
127
|
+
record('get', key, principal, 404, 0, startedNs, 'invalid-key');
|
|
128
|
+
return new Response(MISS_BODY, { status: 404, headers: PLAIN });
|
|
129
|
+
}
|
|
130
|
+
const entry = await readEntry(store, key);
|
|
131
|
+
switch (entry.kind) {
|
|
132
|
+
case 'missing':
|
|
133
|
+
record('get', key, principal, 404, 0, startedNs, 'miss');
|
|
134
|
+
return new Response(MISS_BODY, { status: 404, headers: PLAIN });
|
|
135
|
+
case 'failed':
|
|
136
|
+
record('get', key, principal, 500, 0, startedNs, entry.reason);
|
|
137
|
+
return new Response('The cache server could not read that artifact; see its log.\n', {
|
|
138
|
+
status: 500,
|
|
139
|
+
headers: PLAIN,
|
|
140
|
+
});
|
|
141
|
+
case 'found':
|
|
142
|
+
record('get', key, principal, 200, entry.bytes, startedNs, undefined);
|
|
143
|
+
return new Response(entry.body, { status: 200, headers: OCTET_STREAM });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function handlePut(store, config, request, admission, publish) {
|
|
147
|
+
const startedNs = Bun.nanoseconds();
|
|
148
|
+
const key = request.params.hash;
|
|
149
|
+
const principal = authorize(config.principals, request.headers.get('authorization'));
|
|
150
|
+
if (principal === undefined) {
|
|
151
|
+
record('put', key, undefined, 401, 0, startedNs, 'unauthenticated');
|
|
152
|
+
return unauthenticated();
|
|
153
|
+
}
|
|
154
|
+
if (principal.capability !== 'write') {
|
|
155
|
+
// The CREEP boundary. nx reads 403 as "the server declined to store this" and the build carries on
|
|
156
|
+
// with its own outputs, so an untrusted context loses nothing but the ability to publish.
|
|
157
|
+
record('put', key, principal, 403, 0, startedNs, 'read-only-token');
|
|
158
|
+
return new Response('This token may read the cache but not publish into it.\n', { status: 403, headers: PLAIN });
|
|
159
|
+
}
|
|
160
|
+
if (!isCacheKey(key)) {
|
|
161
|
+
// nx cannot produce such a key, so whatever did is a broken client or an attempt at the filesystem
|
|
162
|
+
// underneath: 400 fails the caller loudly instead of pretending to have stored something.
|
|
163
|
+
record('put', key, principal, 400, 0, startedNs, 'invalid-key');
|
|
164
|
+
return new Response('A cache key is 1-128 characters of [A-Za-z0-9_-].\n', {
|
|
165
|
+
status: 400,
|
|
166
|
+
headers: PLAIN,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// A declared length is required, and every bound below is derived from it. That is what keeps the
|
|
170
|
+
// size limit total: nothing is streamed whose length is unknown, so no body can outrun the limit,
|
|
171
|
+
// and `stageEntry` can refuse a body that arrives short of what it promised. nx always
|
|
172
|
+
// sends `content-length` (it hands reqwest a fully built buffer), so a request without one is not
|
|
173
|
+
// nx and deserves the loud answer.
|
|
174
|
+
const header = request.headers.get('content-length');
|
|
175
|
+
if (header === null) {
|
|
176
|
+
record('put', key, principal, 411, 0, startedNs, 'no-declared-length');
|
|
177
|
+
return new Response('A cache artifact must arrive with a content-length.\n', { status: 411, headers: PLAIN });
|
|
178
|
+
}
|
|
179
|
+
const declared = Number(header);
|
|
180
|
+
if (!Number.isInteger(declared) || declared < 1) {
|
|
181
|
+
// Zero included: an empty artifact is one nx can never extract, and never-replace would make it a
|
|
182
|
+
// permanent failure for that task hash.
|
|
183
|
+
record('put', key, principal, 400, 0, startedNs, 'bad-declared-length');
|
|
184
|
+
return new Response('content-length must be a positive integer.\n', {
|
|
185
|
+
status: 400,
|
|
186
|
+
headers: PLAIN,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (declared > config.maxBytes) {
|
|
190
|
+
// 403 rather than 413 on purpose: an artifact too large to keep must not fail the build that
|
|
191
|
+
// produced it, and 403 is the one refusal nx absorbs silently. The log is where it is not silent.
|
|
192
|
+
record('put', key, principal, 403, declared, startedNs, 'over-max-bytes');
|
|
193
|
+
return new Response(`This artifact exceeds the server's ${config.maxBytes}-byte limit.\n`, {
|
|
194
|
+
status: 403,
|
|
195
|
+
headers: PLAIN,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (declared > config.capBytes - admission.reservedBytes) {
|
|
199
|
+
record('put', key, principal, 403, declared, startedNs, 'staging-capacity');
|
|
200
|
+
return new Response('The staging byte budget is full; nothing was stored.\n', {
|
|
201
|
+
status: 403,
|
|
202
|
+
headers: PLAIN,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
admission.reservedBytes += declared;
|
|
206
|
+
logLine(`ev=staging.reserved key=${logValue(key)} declared_bytes=${declared} reserved_bytes=${admission.reservedBytes} cap=${config.capBytes}`);
|
|
207
|
+
let stagingRetained = false;
|
|
208
|
+
try {
|
|
209
|
+
const staged = await stageEntry(store, key, request.body, declared);
|
|
210
|
+
if (staged.kind !== 'ready') {
|
|
211
|
+
stagingRetained = staged.stagingRetained;
|
|
212
|
+
if (staged.kind === 'incomplete') {
|
|
213
|
+
record('put', key, principal, 400, staged.bytes, startedNs, 'body-shorter-than-declared');
|
|
214
|
+
return new Response(`That body ended after ${staged.bytes} of the ${declared} bytes it declared; nothing was stored.\n`, { status: 400, headers: PLAIN });
|
|
215
|
+
}
|
|
216
|
+
record('put', key, principal, 500, 0, startedNs, staged.reason);
|
|
217
|
+
return new Response('The cache server could not stage that artifact; see its log.\n', {
|
|
218
|
+
status: 500,
|
|
219
|
+
headers: PLAIN,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const outcome = await publish(staged);
|
|
223
|
+
stagingRetained = outcome.stagingRetained;
|
|
224
|
+
if (outcome.kind === 'stored') {
|
|
225
|
+
record('put', key, principal, 200, outcome.bytes, startedNs, undefined);
|
|
226
|
+
return new Response('stored\n', { status: 200, headers: PLAIN });
|
|
227
|
+
}
|
|
228
|
+
if (outcome.kind === 'exists') {
|
|
229
|
+
record('put', key, principal, 409, 0, startedNs, 'already-stored');
|
|
230
|
+
return new Response('That key already holds an artifact; a cache entry is never replaced.\n', {
|
|
231
|
+
status: 409,
|
|
232
|
+
headers: PLAIN,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
record('put', key, principal, 500, 0, startedNs, outcome.reason);
|
|
236
|
+
return new Response('The cache server could not publish or retain that artifact; see its log.\n', {
|
|
237
|
+
status: 500,
|
|
238
|
+
headers: PLAIN,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
if (stagingRetained) {
|
|
243
|
+
logLine(`ev=staging.reservation-retained key=${logValue(key)} declared_bytes=${declared} reserved_bytes=${admission.reservedBytes} reason=cleanup-failed`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
admission.reservedBytes -= declared;
|
|
247
|
+
logLine(`ev=staging.released key=${logValue(key)} declared_bytes=${declared} reserved_bytes=${admission.reservedBytes}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the presented header to a principal, comparing every configured token with no early exit so
|
|
253
|
+
* the time taken says nothing about how much of a secret was correct.
|
|
254
|
+
*/
|
|
255
|
+
function authorize(principals, header) {
|
|
256
|
+
if (header === null)
|
|
257
|
+
return undefined;
|
|
258
|
+
let authorized;
|
|
259
|
+
for (const principal of principals) {
|
|
260
|
+
if (secretEquals(header, principal.bearer))
|
|
261
|
+
authorized = principal;
|
|
262
|
+
}
|
|
263
|
+
return authorized;
|
|
264
|
+
}
|
|
265
|
+
function secretEquals(received, expected) {
|
|
266
|
+
if (received.length !== expected.length)
|
|
267
|
+
return false;
|
|
268
|
+
let difference = 0;
|
|
269
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
270
|
+
difference |= received.charCodeAt(index) ^ expected.charCodeAt(index);
|
|
271
|
+
}
|
|
272
|
+
return difference === 0;
|
|
273
|
+
}
|
|
274
|
+
function unauthenticated() {
|
|
275
|
+
return new Response('Missing or unknown bearer token. Send `Authorization: Bearer <token>` with a token this server was started with (NX_CACHE_SERVER_WRITE_TOKENS or NX_CACHE_SERVER_READ_TOKENS); nx sends the one in NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN.\n', { status: 401, headers: PLAIN_UNAUTHENTICATED });
|
|
276
|
+
}
|
|
277
|
+
function record(event, key, principal, status, bytes, startedNs, reason) {
|
|
278
|
+
const microseconds = Math.round((Bun.nanoseconds() - startedNs) / 1000);
|
|
279
|
+
const attribution = principal === undefined ? '-' : principal.label;
|
|
280
|
+
const because = reason === undefined ? '' : ` reason=${logValue(reason)}`;
|
|
281
|
+
logLine(`ev=${event} key=${logValue(key)} principal=${attribution} status=${status} bytes=${bytes} us=${microseconds}${because}`);
|
|
282
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface CacheStore {
|
|
2
|
+
readonly directory: string;
|
|
3
|
+
readonly staging: string;
|
|
4
|
+
/** Staged uploads a previous process left behind, removed while opening. */
|
|
5
|
+
readonly reclaimedStagingFiles: number;
|
|
6
|
+
/** Releases the root lease. Call only after in-flight requests and publication have drained. */
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export type CacheStoreOpenResult = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly store: CacheStore;
|
|
12
|
+
} | {
|
|
13
|
+
readonly ok: false;
|
|
14
|
+
readonly kind: 'store-locked';
|
|
15
|
+
readonly path: string;
|
|
16
|
+
};
|
|
17
|
+
/** A whole, verified staged upload; only publication makes it visible to GET. */
|
|
18
|
+
export interface CompleteUpload {
|
|
19
|
+
readonly kind: 'ready';
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly stagedPath: string;
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
}
|
|
24
|
+
export type StageOutcome = CompleteUpload | {
|
|
25
|
+
readonly kind: 'incomplete';
|
|
26
|
+
readonly bytes: number;
|
|
27
|
+
readonly stagingRetained: boolean;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'failed';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
readonly stagingRetained: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type StoreOutcome = {
|
|
34
|
+
readonly kind: 'stored';
|
|
35
|
+
readonly bytes: number;
|
|
36
|
+
readonly stagingRetained: boolean;
|
|
37
|
+
} | {
|
|
38
|
+
readonly kind: 'exists';
|
|
39
|
+
readonly stagingRetained: boolean;
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: 'failed';
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
readonly stagingRetained: boolean;
|
|
44
|
+
};
|
|
45
|
+
export declare function isCacheKey(candidate: string): boolean;
|
|
46
|
+
export declare function entryPath(store: CacheStore, key: string): string;
|
|
47
|
+
export declare function openCacheStore(directory: string): Promise<CacheStoreOpenResult>;
|
|
48
|
+
export type ReadOutcome = {
|
|
49
|
+
readonly kind: 'found';
|
|
50
|
+
readonly bytes: number;
|
|
51
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
52
|
+
} | {
|
|
53
|
+
readonly kind: 'missing';
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: 'failed';
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
};
|
|
58
|
+
/** Pin an opened inode before answering: a concurrent sweep may unlink its path, not truncate GET. */
|
|
59
|
+
export declare function readEntry(store: CacheStore, key: string): Promise<ReadOutcome>;
|
|
60
|
+
/**
|
|
61
|
+
* Stage the body and verify its length. Publication is a separate atomic operation.
|
|
62
|
+
*
|
|
63
|
+
* WHY the length is checked before the link and not after: an entry is permanent, so publishing a
|
|
64
|
+
* truncated one poisons that task hash forever — every later build fetches an artifact nx cannot
|
|
65
|
+
* extract, and never-replace means nobody can correct it. A body that ends short of `declaredBytes`
|
|
66
|
+
* is therefore discarded while it is still only a staged file.
|
|
67
|
+
*/
|
|
68
|
+
export declare function stageEntry(store: CacheStore, key: string, body: ReadableStream<Uint8Array> | null, declaredBytes: number): Promise<StageOutcome>;
|
|
69
|
+
/** Publish by never-replace hard link. The server serializes this operation with its SIEVE sweep. */
|
|
70
|
+
export declare function publishEntry(store: CacheStore, upload: CompleteUpload): Promise<StoreOutcome>;
|
|
71
|
+
/** Read an errno off a thrown value without asserting its type. */
|
|
72
|
+
export declare function errnoOf(cause: unknown): string | undefined;
|
|
73
|
+
export declare function describe(cause: unknown): string;
|
|
74
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/store.ts"],"names":[],"mappings":"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"}
|
package/dist/ts/store.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
/**
|
|
3
|
+
* The cache directory: one file per key, and a write that cannot replace one.
|
|
4
|
+
*
|
|
5
|
+
* `link(2)` is the whole design. A staged upload becomes an entry by hard-linking it to
|
|
6
|
+
* `<directory>/<key>`, which the kernel either performs or refuses with `EEXIST` — atomically, with no
|
|
7
|
+
* window in which a reader sees a partial file and no window in which two writers of the same key both
|
|
8
|
+
* believe they won. `rename(2)` would be wrong here: it replaces silently, so the loser of a race
|
|
9
|
+
* would overwrite the winner's artifact and both would be told they stored it.
|
|
10
|
+
*
|
|
11
|
+
* Staging lives at `<directory>/.staging` for two reasons: it is on the same filesystem, which
|
|
12
|
+
* `link(2)` requires, and the leading dot puts it outside the cache-key charset, so no request can
|
|
13
|
+
* name it. Opening the store reclaims whatever a crash left there — a nonzero count in the startup log
|
|
14
|
+
* is how an operator learns a previous process died mid-upload.
|
|
15
|
+
*
|
|
16
|
+
* One process owns one directory at a time. `openCacheStore` takes a persistent kernel lock on
|
|
17
|
+
* `<directory>/.owner.lock` before it touches staging, so a second open is refused with `store-locked`
|
|
18
|
+
* and reclaims nothing until the first store is closed or its process dies.
|
|
19
|
+
*/
|
|
20
|
+
import { link, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { Readable } from 'node:stream';
|
|
23
|
+
import { logLine, logValue } from './log.js';
|
|
24
|
+
import { tryAcquireRootLease } from './root-lease.js';
|
|
25
|
+
const STAGING = '.staging';
|
|
26
|
+
/**
|
|
27
|
+
* The cache-key charset. nx sends a task hash and nothing else — twenty decimal digits today
|
|
28
|
+
* (`.nx/cache/10018087211778381691`) — and this accepts any hex, decimal or base64url rendering of
|
|
29
|
+
* one. What it rejects is what makes `join(directory, key)` safe by construction: no `/`, no `\`, and
|
|
30
|
+
* no `.`, so no key can name a parent directory, an absolute path, or the staging directory.
|
|
31
|
+
*/
|
|
32
|
+
const CACHE_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
33
|
+
/** The common conflict outcome carries no per-request data. */
|
|
34
|
+
const ALREADY_STORED = { kind: 'exists', stagingRetained: false };
|
|
35
|
+
export function isCacheKey(candidate) {
|
|
36
|
+
return CACHE_KEY.test(candidate);
|
|
37
|
+
}
|
|
38
|
+
export function entryPath(store, key) {
|
|
39
|
+
return join(store.directory, key);
|
|
40
|
+
}
|
|
41
|
+
export async function openCacheStore(directory) {
|
|
42
|
+
await mkdir(directory, { recursive: true });
|
|
43
|
+
const lockPath = join(directory, '.owner.lock');
|
|
44
|
+
const lease = await tryAcquireRootLease(lockPath);
|
|
45
|
+
if (!lease)
|
|
46
|
+
return { ok: false, kind: 'store-locked', path: lockPath };
|
|
47
|
+
try {
|
|
48
|
+
const staging = join(directory, STAGING);
|
|
49
|
+
await mkdir(staging, { recursive: true });
|
|
50
|
+
const abandoned = await readdir(staging);
|
|
51
|
+
for (const name of abandoned) {
|
|
52
|
+
await unlink(join(staging, name));
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
store: { directory, staging, reclaimedStagingFiles: abandoned.length, close: () => lease.close() },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (failure) {
|
|
60
|
+
await lease.close();
|
|
61
|
+
throw failure;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const MISSING = { kind: 'missing' };
|
|
65
|
+
/** Pin an opened inode before answering: a concurrent sweep may unlink its path, not truncate GET. */
|
|
66
|
+
export async function readEntry(store, key) {
|
|
67
|
+
try {
|
|
68
|
+
const file = await open(entryPath(store, key), 'r');
|
|
69
|
+
try {
|
|
70
|
+
const stats = await file.stat();
|
|
71
|
+
if (!stats.isFile() || stats.size === 0) {
|
|
72
|
+
await file.close();
|
|
73
|
+
return MISSING;
|
|
74
|
+
}
|
|
75
|
+
// APFS can leave atime unchanged on a real read. Mark SIEVE's visited bit explicitly on the
|
|
76
|
+
// pinned inode, and only when it is clear; the original modification time remains the cursor.
|
|
77
|
+
if (stats.atimeMs <= stats.mtimeMs) {
|
|
78
|
+
await file.utimes((stats.mtimeMs + 1) / 1000, stats.mtimeMs / 1000);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: 'found',
|
|
82
|
+
bytes: stats.size,
|
|
83
|
+
body: Readable.toWeb(file.createReadStream({ autoClose: true })),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (cause) {
|
|
87
|
+
await file.close();
|
|
88
|
+
return { kind: 'failed', reason: describe(cause) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
return errnoOf(cause) === 'ENOENT' ? MISSING : { kind: 'failed', reason: describe(cause) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stage the body and verify its length. Publication is a separate atomic operation.
|
|
97
|
+
*
|
|
98
|
+
* WHY the length is checked before the link and not after: an entry is permanent, so publishing a
|
|
99
|
+
* truncated one poisons that task hash forever — every later build fetches an artifact nx cannot
|
|
100
|
+
* extract, and never-replace means nobody can correct it. A body that ends short of `declaredBytes`
|
|
101
|
+
* is therefore discarded while it is still only a staged file.
|
|
102
|
+
*/
|
|
103
|
+
export async function stageEntry(store, key, body, declaredBytes) {
|
|
104
|
+
const staged = join(store.staging, `${key}.${crypto.randomUUID()}`);
|
|
105
|
+
let bytes;
|
|
106
|
+
try {
|
|
107
|
+
// `Response` is the streaming form of `Bun.write`: the body reaches the disk in chunks instead of
|
|
108
|
+
// being buffered whole in this process.
|
|
109
|
+
bytes = await Bun.write(staged, new Response(body));
|
|
110
|
+
}
|
|
111
|
+
catch (cause) {
|
|
112
|
+
const stagingRetained = !(await discard(staged));
|
|
113
|
+
return { kind: 'failed', reason: describe(cause), stagingRetained };
|
|
114
|
+
}
|
|
115
|
+
if (bytes !== declaredBytes) {
|
|
116
|
+
const stagingRetained = !(await discard(staged));
|
|
117
|
+
return { kind: 'incomplete', bytes, stagingRetained };
|
|
118
|
+
}
|
|
119
|
+
return { kind: 'ready', key, stagedPath: staged, bytes };
|
|
120
|
+
}
|
|
121
|
+
/** Publish by never-replace hard link. The server serializes this operation with its SIEVE sweep. */
|
|
122
|
+
export async function publishEntry(store, upload) {
|
|
123
|
+
try {
|
|
124
|
+
await link(upload.stagedPath, entryPath(store, upload.key));
|
|
125
|
+
}
|
|
126
|
+
catch (cause) {
|
|
127
|
+
const stagingRetained = !(await discard(upload.stagedPath));
|
|
128
|
+
if (errnoOf(cause) === 'EEXIST') {
|
|
129
|
+
return stagingRetained ? { kind: 'exists', stagingRetained } : ALREADY_STORED;
|
|
130
|
+
}
|
|
131
|
+
return { kind: 'failed', reason: describe(cause), stagingRetained };
|
|
132
|
+
}
|
|
133
|
+
const stagingRetained = !(await discard(upload.stagedPath));
|
|
134
|
+
return { kind: 'stored', bytes: upload.bytes, stagingRetained };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Drop a staged upload once it has been published or abandoned. A staging file that outlives its
|
|
138
|
+
* request is leaked disk, so a failure to remove one is reported rather than dropped; `ENOENT` is not
|
|
139
|
+
* a failure, it is the file already being gone.
|
|
140
|
+
*/
|
|
141
|
+
async function discard(staged) {
|
|
142
|
+
try {
|
|
143
|
+
await unlink(staged);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
if (errnoOf(cause) === 'ENOENT')
|
|
148
|
+
return true;
|
|
149
|
+
logLine(`ev=staging.leaked path=${logValue(staged)} reason=${logValue(describe(cause))}`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Read an errno off a thrown value without asserting its type. */
|
|
154
|
+
export function errnoOf(cause) {
|
|
155
|
+
if (typeof cause !== 'object' || cause === null || !('code' in cause))
|
|
156
|
+
return undefined;
|
|
157
|
+
const { code } = cause;
|
|
158
|
+
return typeof code === 'string' ? code : undefined;
|
|
159
|
+
}
|
|
160
|
+
export function describe(cause) {
|
|
161
|
+
if (cause instanceof Error) {
|
|
162
|
+
const errno = errnoOf(cause);
|
|
163
|
+
return errno === undefined ? cause.message : `${errno}: ${cause.message}`;
|
|
164
|
+
}
|
|
165
|
+
return String(cause);
|
|
166
|
+
}
|