crossbind 2.0.0-beta.62 → 2.0.0-beta.63
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 +2 -2
- package/package.json +6 -1
- package/src/actions/buildDependencies.js +15 -2
- package/src/actions/buildExternal.js +9 -1
- package/src/actions/buildLib.js +25 -8
- package/src/actions/buildNativeCommand.js +84 -0
- package/src/actions/buildNode.js +48 -7
- package/src/actions/buildTypes.js +8 -2
- package/src/actions/buildWasiCommand.js +0 -4
- package/src/actions/buildWasm.js +49 -29
- package/src/actions/cloudCommands.js +133 -0
- package/src/actions/createImportedBridges.js +64 -0
- package/src/actions/createInterface.js +247 -68
- package/src/actions/createLib.js +57 -41
- package/src/actions/getData.js +4 -1
- package/src/actions/getLinkInputs.js +12 -5
- package/src/actions/isSourceNewer.js +2 -0
- package/src/actions/licenses.js +143 -19
- package/src/actions/prepareConanDependencies.js +51 -0
- package/src/actions/publishBinary.js +32 -0
- package/src/actions/run.js +63 -66
- package/src/actions/runnerCommands.js +421 -0
- package/src/actions/writeRuntimeEntry.js +69 -0
- package/src/assets/cmake/command/CMakeLists.txt +21 -0
- package/src/assets/cmake/command/command.cpp +1 -0
- package/src/assets/cmake/dist.cmake +2 -1
- package/src/assets/configure/install.sh +22 -0
- package/src/assets/js-runtime/adapters/path-url.js +6 -3
- package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
- package/src/assets/js-runtime/adapters/worker-comlink.js +87 -60
- package/src/assets/js-runtime/core.js +11 -0
- package/src/assets/node-import-hooks.mjs +61 -0
- package/src/assets/toolchain-digests.json +19 -19
- package/src/assets/wasi-runtime/stubs.c +29 -0
- package/src/bin.js +257 -50
- package/src/index.js +3 -0
- package/src/integration/bindHeaderImports.js +18 -0
- package/src/integration/getCrossbindScript.js +46 -10
- package/src/integration/getDependFilePath.js +9 -0
- package/src/integration/resolveNativeImport.js +14 -0
- package/src/node/dev.mjs +19 -0
- package/src/runner/blobs.js +135 -0
- package/src/runner/files.js +193 -0
- package/src/runner/server.js +320 -0
- package/src/state/attachConanDependencies.js +56 -0
- package/src/state/index.js +17 -9
- package/src/state/loadConfig.js +33 -24
- package/src/state/refreshBuiltDependencies.js +20 -0
- package/src/state/refreshConanDependencies.js +17 -0
- package/src/utils/androidToolchain.js +4 -0
- package/src/utils/appRustCrates.js +15 -1
- package/src/utils/appSources.js +97 -6
- package/src/utils/archiveFlags.js +12 -0
- package/src/utils/boundHeaders.js +79 -0
- package/src/utils/bridgeExtras.js +486 -0
- package/src/utils/bridgeRuntimeFixes.js +48 -0
- package/src/utils/bundledLicenses.js +26 -0
- package/src/utils/cargoTarget.js +21 -3
- package/src/utils/cloudAccount.js +55 -0
- package/src/utils/conanDependencies.js +66 -0
- package/src/utils/conanImport.js +58 -0
- package/src/utils/conanInstall.js +154 -0
- package/src/utils/conanProfile.js +203 -0
- package/src/utils/conanStage.js +292 -0
- package/src/utils/copyToDistSource.js +13 -0
- package/src/utils/cppDts.js +58 -15
- package/src/utils/darwinToolchain.js +11 -0
- package/src/utils/dirLock.js +137 -28
- package/src/utils/downloadAndExtractFile.js +34 -7
- package/src/utils/embindArgumentGuards.js +41 -2
- package/src/utils/embindCallArguments.js +19 -0
- package/src/utils/embindJspiQueue.js +25 -0
- package/src/utils/execContainer.js +49 -0
- package/src/utils/headerImports.js +16 -5
- package/src/utils/hostPlatform.js +21 -0
- package/src/utils/iosToolchain.js +6 -0
- package/src/utils/isSourceCmakePackage.js +10 -0
- package/src/utils/licenseReport.js +11 -2
- package/src/utils/linuxToolchain.js +24 -0
- package/src/utils/makeTreeWritable.js +12 -0
- package/src/utils/nativeImports.js +23 -0
- package/src/utils/nativeSourceStamp.js +36 -0
- package/src/utils/nodeDevBuild.js +92 -0
- package/src/utils/packageLicense.js +101 -0
- package/src/utils/pthreadMainScript.js +14 -0
- package/src/utils/pullDockerImage.js +3 -1
- package/src/utils/relocatePrebuilt.js +119 -0
- package/src/utils/remoteClient.js +127 -0
- package/src/utils/remoteOutputs.js +39 -0
- package/src/utils/remoteRunner.js +97 -0
- package/src/utils/remoteUpload.js +134 -0
- package/src/utils/replaceFile.js +8 -0
- package/src/utils/runCargo.js +48 -51
- package/src/utils/runConan.js +257 -0
- package/src/utils/runtimeEntries.js +56 -0
- package/src/utils/rustBridgeGen.js +9 -4
- package/src/utils/rustMt.js +4 -0
- package/src/utils/scopedEmbind.js +22 -0
- package/src/utils/selectRunner.js +81 -0
- package/src/utils/sourceFingerprint.js +4 -3
- package/src/utils/swigInterface.js +69 -4
- package/src/utils/systemKeys.js +35 -3
- package/src/utils/targets.js +49 -4
- package/src/utils/toolchainNotices.js +5 -0
- package/src/utils/wasiToolchain.js +14 -1
- package/src/utils/windowsToolchain.js +18 -0
- package/vitest.config.js +11 -0
package/src/node/dev.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `node --import crossbind/node/dev app.mjs`: builds this machine's binary when the app's native sources or imports
|
|
2
|
+
// changed since the last build it ran, then serves the app's native imports through the hooks that build wrote.
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import loadConfig from '../state/loadConfig.js';
|
|
5
|
+
import { DEV_BUILD_ENV, ensureDevBuild, projectDirOf } from '../utils/nodeDevBuild.js';
|
|
6
|
+
|
|
7
|
+
if (!process.env[DEV_BUILD_ENV]) {
|
|
8
|
+
// node --watch restarts when a build rewrites the outputs the last start loaded. Its SIGTERM waits for the build
|
|
9
|
+
// and its record, then ends this start: the next one finds the build done instead of starting it over.
|
|
10
|
+
let restarting = false;
|
|
11
|
+
const restart = () => { restarting = true; };
|
|
12
|
+
process.on('SIGTERM', restart);
|
|
13
|
+
const hooks = ensureDevBuild(await loadConfig(projectDirOf(process.argv[1])));
|
|
14
|
+
// A signal that came during the build is handled on the next turn of the event loop.
|
|
15
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
16
|
+
process.off('SIGTERM', restart);
|
|
17
|
+
if (restarting) process.exit(143);
|
|
18
|
+
if (hooks) await import(pathToFileURL(hooks).href);
|
|
19
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { sha256Of } from './files.js';
|
|
6
|
+
|
|
7
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
8
|
+
const UPLOAD_ID = /^[\w-]{8,64}$/;
|
|
9
|
+
// A part nobody added to for an hour belongs to an upload that stopped; the disk may be memory (Cloud Run).
|
|
10
|
+
const STALE_PART_MS = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export const isHash = (value) => typeof value === 'string' && SHA256.test(value);
|
|
13
|
+
export const isUploadId = (value) => typeof value === 'string' && UPLOAD_ID.test(value);
|
|
14
|
+
|
|
15
|
+
// Content-addressed: a blob is stored under its sha256, and an upload whose bytes hash to something else is dropped.
|
|
16
|
+
export function createBlobStore(dir) {
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
const root = `${path.resolve(dir)}${path.sep}`;
|
|
19
|
+
|
|
20
|
+
const pathOf = (hash) => {
|
|
21
|
+
if (!isHash(hash)) throw new Error(`crossbind runner: "${hash}" is not a sha256 digest`);
|
|
22
|
+
return path.join(dir, hash);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const has = (hash) => isHash(hash) && fs.existsSync(path.join(dir, hash));
|
|
26
|
+
|
|
27
|
+
async function putStream(hash, stream) {
|
|
28
|
+
const temp = path.join(dir, `.${hash}.${process.pid}.${crypto.randomUUID()}`);
|
|
29
|
+
const digest = crypto.createHash('sha256');
|
|
30
|
+
const output = fs.createWriteStream(temp);
|
|
31
|
+
try {
|
|
32
|
+
await pipeline(stream, async function* hashing(source) {
|
|
33
|
+
for await (const chunk of source) {
|
|
34
|
+
digest.update(chunk);
|
|
35
|
+
yield chunk;
|
|
36
|
+
}
|
|
37
|
+
}, output);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// pipeline settles before a file that is still opening closes; removed earlier, it would come back.
|
|
40
|
+
if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
|
|
41
|
+
fs.rmSync(temp, { force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (digest.digest('hex') !== hash) {
|
|
45
|
+
fs.rmSync(temp, { force: true });
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
fs.renameSync(temp, pathOf(hash));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dropStaleParts() {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
for (const name of fs.readdirSync(dir).filter((file) => file.endsWith('.part'))) {
|
|
55
|
+
const stat = fs.statSync(path.join(dir, name), { throwIfNoEntry: false });
|
|
56
|
+
if (stat && now - stat.mtimeMs > STALE_PART_MS) fs.rmSync(path.join(dir, name), { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A request names the part file, so its path is checked to stay in the store.
|
|
61
|
+
const partOf = (hash, upload) => {
|
|
62
|
+
const file = path.resolve(root, `.${hash}.${upload}.part`);
|
|
63
|
+
if (!file.startsWith(root)) throw new Error(`crossbind runner: "${hash}" and "${upload}" name no part in the store`);
|
|
64
|
+
return file;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A blob too large for one request arrives in parts, in order and under the id of its upload; the last part
|
|
68
|
+
// checks the joined file against the hash. A part that breaks off leaves the ones before it, so the client sends
|
|
69
|
+
// that part again, and a part of a blob already joined answers as stored, since the last answer may have been lost.
|
|
70
|
+
async function writePart(hash, upload, { start, end, total }, stream) {
|
|
71
|
+
if (!isUploadId(upload)) throw new Error(`crossbind runner: "${upload}" is not an upload id`);
|
|
72
|
+
if (has(hash)) {
|
|
73
|
+
for await (const chunk of stream) void chunk;
|
|
74
|
+
return { status: 'stored' };
|
|
75
|
+
}
|
|
76
|
+
if (start === 0) dropStaleParts();
|
|
77
|
+
const partial = partOf(hash, upload);
|
|
78
|
+
const received = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
|
|
79
|
+
if (start !== received) return { status: 'gap', received };
|
|
80
|
+
const restore = () => {
|
|
81
|
+
if (start === 0) fs.rmSync(partial, { force: true });
|
|
82
|
+
else if (fs.existsSync(partial)) fs.truncateSync(partial, start);
|
|
83
|
+
};
|
|
84
|
+
let length = 0;
|
|
85
|
+
const output = fs.createWriteStream(partial, { flags: 'a' });
|
|
86
|
+
try {
|
|
87
|
+
await pipeline(stream, async function* counting(source) {
|
|
88
|
+
for await (const chunk of source) {
|
|
89
|
+
length += chunk.length;
|
|
90
|
+
yield chunk;
|
|
91
|
+
}
|
|
92
|
+
}, output);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
|
|
95
|
+
restore();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
if (length !== end - start + 1) {
|
|
99
|
+
restore();
|
|
100
|
+
return { status: 'short' };
|
|
101
|
+
}
|
|
102
|
+
if (end + 1 < total) return { status: 'partial', received: end + 1 };
|
|
103
|
+
const digest = crypto.createHash('sha256');
|
|
104
|
+
for await (const chunk of fs.createReadStream(partial)) digest.update(chunk);
|
|
105
|
+
const joined = digest.digest('hex');
|
|
106
|
+
if (joined !== hash) {
|
|
107
|
+
fs.rmSync(partial, { force: true });
|
|
108
|
+
return { status: 'mismatch' };
|
|
109
|
+
}
|
|
110
|
+
fs.renameSync(partial, pathOf(joined));
|
|
111
|
+
return { status: 'stored' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The parts of one upload go one at a time: a part whose connection broke off can still be writing when the client
|
|
115
|
+
// sends it again.
|
|
116
|
+
const writing = new Map();
|
|
117
|
+
function putPart(hash, upload, range, stream) {
|
|
118
|
+
const key = `${hash}.${upload}`;
|
|
119
|
+
const current = (writing.get(key) ?? Promise.resolve()).catch(() => {}).then(() => writePart(hash, upload, range, stream));
|
|
120
|
+
writing.set(key, current);
|
|
121
|
+
const forget = () => {
|
|
122
|
+
if (writing.get(key) === current) writing.delete(key);
|
|
123
|
+
};
|
|
124
|
+
current.then(forget, forget);
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function putBuffer(data) {
|
|
129
|
+
const hash = sha256Of(data);
|
|
130
|
+
if (!has(hash)) fs.writeFileSync(pathOf(hash), data);
|
|
131
|
+
return hash;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { pathOf, has, putStream, putPart, putBuffer };
|
|
135
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Shared by the client (utils/remoteClient.js) and the runner (server.js). The runner reaches the toolchain
|
|
6
|
+
// image as this folder alone, mounted or copied in, so nothing here may import from outside it.
|
|
7
|
+
|
|
8
|
+
export const sha256Of = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
9
|
+
|
|
10
|
+
// `name` against a file name pattern: a trailing `*` matches any rest (`.env.*`).
|
|
11
|
+
const matchesName = (name, pattern) => (pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
|
|
12
|
+
|
|
13
|
+
// `rel` is relative to the root being walked, so a root that itself sits in node_modules (an installed port) still counts.
|
|
14
|
+
export function isExcluded(rel, rules) {
|
|
15
|
+
const segments = rel.split('/');
|
|
16
|
+
if (segments.some((segment) => rules.dirs.includes(segment))) return true;
|
|
17
|
+
if (rules.files?.some((pattern) => matchesName(segments.at(-1), pattern))) return true;
|
|
18
|
+
return rules.extensions.includes(path.posix.extname(rel));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Segment by segment: `*` matches any one segment, `*.a` any segment with that ending.
|
|
22
|
+
export function matchesPattern(rel, pattern) {
|
|
23
|
+
const parts = rel.split('/');
|
|
24
|
+
const wanted = pattern.split('/');
|
|
25
|
+
return parts.length === wanted.length && wanted.every((want, i) => want === '*'
|
|
26
|
+
|| (want.startsWith('*.') ? parts[i].endsWith(want.slice(1)) : want === parts[i]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A build tree such as a cargo target folder stays on the runner: a sync walk skips it, so it is neither
|
|
30
|
+
// uploaded nor deleted, and an output walk returns only the artifacts its `keep` patterns name.
|
|
31
|
+
function isServerOnly(base, rel, rules) {
|
|
32
|
+
const serverOnly = rules.serverOnly;
|
|
33
|
+
if (!serverOnly?.names.includes(path.posix.basename(rel))) return false;
|
|
34
|
+
return fs.existsSync(path.join(base, path.posix.dirname(rel), serverOnly.marker));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isWithinReal = (real, parent) => real === parent || real.startsWith(`${parent}${path.sep}`);
|
|
38
|
+
|
|
39
|
+
// A link is followed only while it stays inside the base: one that leads out would dangle in a local container,
|
|
40
|
+
// and following it would send what it points at. A link back to a folder the walk is already inside never ends.
|
|
41
|
+
function statInside(full, realBase) {
|
|
42
|
+
const stat = fs.lstatSync(full);
|
|
43
|
+
if (!stat.isSymbolicLink()) return stat;
|
|
44
|
+
const real = fs.realpathSync(full);
|
|
45
|
+
if (!isWithinReal(real, realBase)) return null;
|
|
46
|
+
const target = fs.statSync(full);
|
|
47
|
+
return target.isDirectory() && isWithinReal(fs.realpathSync(path.dirname(full)), real) ? null : target;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Every file below the roots that the rules keep, keyed by its path relative to base, and every folder:
|
|
51
|
+
// the host creates folders before a step writes into them, empty ones included.
|
|
52
|
+
export function walk(base, roots, rules, { outputs = false } = {}) {
|
|
53
|
+
const files = new Map();
|
|
54
|
+
const dirs = [];
|
|
55
|
+
let realBase;
|
|
56
|
+
try {
|
|
57
|
+
realBase = fs.realpathSync(base);
|
|
58
|
+
} catch {
|
|
59
|
+
return { files, dirs };
|
|
60
|
+
}
|
|
61
|
+
const visit = (rel, inRoot, serverOnlyRoot) => {
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = statInside(path.join(base, rel), realBase);
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!stat) return;
|
|
69
|
+
if (stat.isFile()) {
|
|
70
|
+
const kept = serverOnlyRoot === null
|
|
71
|
+
|| rules.serverOnly.keep.some((pattern) => matchesPattern(rel.slice(serverOnlyRoot.length + 1), pattern));
|
|
72
|
+
if (kept) files.set(rel, { size: stat.size, mtimeMs: stat.mtimeMs, mode: stat.mode & 0o777 });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!stat.isDirectory()) return;
|
|
76
|
+
let insideServerOnly = serverOnlyRoot;
|
|
77
|
+
if (insideServerOnly === null && isServerOnly(base, rel, rules)) {
|
|
78
|
+
if (!outputs) return;
|
|
79
|
+
insideServerOnly = rel;
|
|
80
|
+
}
|
|
81
|
+
if (insideServerOnly === null) dirs.push(rel);
|
|
82
|
+
for (const name of fs.readdirSync(path.join(base, rel)).sort()) {
|
|
83
|
+
const childInRoot = inRoot ? `${inRoot}/${name}` : name;
|
|
84
|
+
if (!isExcluded(childInRoot, rules)) visit(rel === '.' ? name : `${rel}/${name}`, childInRoot, insideServerOnly);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
roots.forEach((root) => visit(root, '', null));
|
|
88
|
+
return { files, dirs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const snapshot = (base, roots, rules, options) => walk(base, roots, rules, options).files;
|
|
92
|
+
|
|
93
|
+
// Hashes reused while size and mtime are unchanged; `index` is updated in place and pruned to `files`.
|
|
94
|
+
export function hashIndex(base, files, index) {
|
|
95
|
+
const hashes = new Map();
|
|
96
|
+
for (const [rel, meta] of files) {
|
|
97
|
+
const cached = index.get(rel);
|
|
98
|
+
const sha256 = cached && cached.size === meta.size && cached.mtimeMs === meta.mtimeMs
|
|
99
|
+
? cached.sha256
|
|
100
|
+
: sha256Of(fs.readFileSync(path.join(base, rel)));
|
|
101
|
+
index.set(rel, { size: meta.size, mtimeMs: meta.mtimeMs, sha256 });
|
|
102
|
+
hashes.set(rel, sha256);
|
|
103
|
+
}
|
|
104
|
+
[...index.keys()].filter((rel) => !files.has(rel)).forEach((rel) => index.delete(rel));
|
|
105
|
+
return hashes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A `*` segment stands for every folder at that level, so `b/*/p` names each conan package folder that exists.
|
|
109
|
+
export function expandRoots(base, roots) {
|
|
110
|
+
const step = (partials, segment) => partials.flatMap((partial) => {
|
|
111
|
+
if (segment !== '*') return [partial ? `${partial}/${segment}` : segment];
|
|
112
|
+
const dir = path.join(base, partial);
|
|
113
|
+
if (!fs.existsSync(dir)) return [];
|
|
114
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
115
|
+
.filter((entry) => entry.isDirectory())
|
|
116
|
+
.map((entry) => (partial ? `${partial}/${entry.name}` : entry.name))
|
|
117
|
+
.sort();
|
|
118
|
+
});
|
|
119
|
+
return roots.flatMap((root) => root.split('/').reduce(step, ['']));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The store units a machine already holds (a crate version, a conan package folder). They never change once
|
|
123
|
+
// written, so the runner sends only the units missing there.
|
|
124
|
+
export function presentUnits(base, outputRoots) {
|
|
125
|
+
return expandRoots(base, outputRoots.filter((root) => root.includes('*'))).filter((unit) => {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readdirSync(path.join(base, unit)).length > 0;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Permission bits only: a synced file never becomes setuid, setgid or sticky.
|
|
135
|
+
export const modeOf = (mode) => (Number.isInteger(mode) ? mode & 0o777 : 0o644);
|
|
136
|
+
|
|
137
|
+
export function planSync(current, manifest) {
|
|
138
|
+
const write = Object.entries(manifest).filter(([rel, entry]) => current.get(rel) !== entry.sha256).map(([rel]) => rel);
|
|
139
|
+
const remove = [...current.keys()].filter((rel) => !(rel in manifest));
|
|
140
|
+
return { write, remove };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Written files get the current time as mtime, so make and ninja rebuild what depends on them.
|
|
144
|
+
export function applySync(base, plan, manifest, blobPathOf) {
|
|
145
|
+
plan.remove.forEach((rel) => fs.rmSync(path.join(base, rel), { force: true }));
|
|
146
|
+
plan.write.forEach((rel) => {
|
|
147
|
+
const dest = path.join(base, rel);
|
|
148
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
149
|
+
fs.rmSync(dest, { force: true });
|
|
150
|
+
fs.copyFileSync(blobPathOf(manifest[rel].sha256), dest);
|
|
151
|
+
fs.chmodSync(dest, modeOf(manifest[rel].mode));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function diffSnapshots(before, after) {
|
|
156
|
+
const changed = [...after].filter(([rel, meta]) => {
|
|
157
|
+
const old = before.get(rel);
|
|
158
|
+
return !old || old.size !== meta.size || old.mtimeMs !== meta.mtimeMs;
|
|
159
|
+
}).map(([rel]) => rel);
|
|
160
|
+
const removed = [...before.keys()].filter((rel) => !after.has(rel));
|
|
161
|
+
return { changed, removed };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Characters a host path would read as structure: a backslash is a separator on Windows, where a colon
|
|
165
|
+
// names a drive or a stream, and NUL ends a path anywhere.
|
|
166
|
+
const UNSAFE_PATH = process.platform === 'win32' ? /[\\:\0]/ : /[\\\0]/;
|
|
167
|
+
|
|
168
|
+
// The runner names files by container path; only a path inside one of the step's mounts maps back,
|
|
169
|
+
// so the runner cannot make the client write anywhere else.
|
|
170
|
+
export function hostPathOf(mounts, containerPath) {
|
|
171
|
+
const outside = () => new Error(`crossbind: the remote runner returned ${containerPath}, outside the mounts it was given.`);
|
|
172
|
+
const isClean = typeof containerPath === 'string' && !UNSAFE_PATH.test(containerPath)
|
|
173
|
+
&& path.posix.normalize(containerPath) === containerPath;
|
|
174
|
+
const mount = isClean && mounts.find((m) => containerPath.startsWith(`${m.container}/`));
|
|
175
|
+
if (!mount) throw outside();
|
|
176
|
+
const rel = containerPath.slice(mount.container.length + 1);
|
|
177
|
+
const file = path.join(mount.host, rel);
|
|
178
|
+
const back = path.relative(mount.host, file);
|
|
179
|
+
if (back === '..' || back.startsWith(`..${path.sep}`) || path.isAbsolute(back)) throw outside();
|
|
180
|
+
return { mount, rel, file };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Whether a step may hand this file back: it lies below one of the step's output roots, and below a store
|
|
184
|
+
// root (`registry/src/*/*`) only inside a unit the machine does not hold yet, as those never change.
|
|
185
|
+
export function isDeclaredOutput(rel, outputRoots, present = []) {
|
|
186
|
+
const parts = rel.split('/');
|
|
187
|
+
return outputRoots.some((root) => {
|
|
188
|
+
if (!root.includes('*')) return root === '.' || rel === root || rel.startsWith(`${root}/`);
|
|
189
|
+
const depth = root.split('/').length;
|
|
190
|
+
const unit = parts.slice(0, depth).join('/');
|
|
191
|
+
return parts.length > depth && matchesPattern(unit, root) && !present.includes(unit);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { createBlobStore, isHash, isUploadId } from './blobs.js';
|
|
10
|
+
import {
|
|
11
|
+
snapshot, hashIndex, planSync, applySync, diffSnapshots, expandRoots, sha256Of,
|
|
12
|
+
} from './files.js';
|
|
13
|
+
|
|
14
|
+
// The runner side of `RUNNER=REMOTE`: it runs inside a crossbind toolchain image and executes the
|
|
15
|
+
// toolchain steps a client would otherwise `docker run`, against folders it keeps in sync with the client.
|
|
16
|
+
|
|
17
|
+
// 2: a blob too large for one request arrives in parts.
|
|
18
|
+
export const PROTOCOL_VERSION = 2;
|
|
19
|
+
export const MIN_TOKEN_LENGTH = 16;
|
|
20
|
+
const MAX_JSON_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
const INLINE_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
const MAX_MOUNTS = 8;
|
|
23
|
+
const DEFAULT_PORT = 8787;
|
|
24
|
+
const CONTENT_RANGE = /^bytes (\d+)-(\d+)\/(\d+)$/;
|
|
25
|
+
// A proxy in front of a hosted runner (fly, cloudflare) closes a response that stays quiet, and a compile
|
|
26
|
+
// step can print nothing for minutes.
|
|
27
|
+
const HEARTBEAT_MS = 15_000;
|
|
28
|
+
// Where crossbind mounts things into its images: the project base, and the cargo and conan caches.
|
|
29
|
+
const DEFAULT_MOUNT_PREFIXES = ['/tmp/crossbind/live', '/var/cache/crossbind'];
|
|
30
|
+
// cargo runs from a directory outside every mount so its upward config search finds nothing.
|
|
31
|
+
const DEFAULT_SCRATCH_DIRS = ['/tmp/crossbind-cargo'];
|
|
32
|
+
|
|
33
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
34
|
+
const isSafeRel = (rel) => typeof rel === 'string' && rel.length > 0 && !path.posix.isAbsolute(rel)
|
|
35
|
+
&& path.posix.normalize(rel) === rel && !rel.split('/').includes('..');
|
|
36
|
+
const isCleanAbsolute = (value) => typeof value === 'string' && path.posix.isAbsolute(value)
|
|
37
|
+
&& path.posix.normalize(value) === value && (value === '/' || !value.endsWith('/'));
|
|
38
|
+
const isWithin = (child, parent) => child === parent || child.startsWith(`${parent}/`);
|
|
39
|
+
const isUnderRoot = (rel, root) => root === '.' || isWithin(rel, root);
|
|
40
|
+
const isPlainName = (value) => typeof value === 'string' && /^[\w.-]+$/.test(value) && value !== '.' && value !== '..';
|
|
41
|
+
const isNamePattern = (value) => isPlainName(typeof value === 'string' && value.endsWith('*') ? value.slice(0, -1) : value);
|
|
42
|
+
// A build and a runner name one toolchain by digest even when they pull it from different registries (a mirror).
|
|
43
|
+
const digestOf = (ref) => (typeof ref === 'string' && ref.includes('@') ? ref.slice(ref.indexOf('@') + 1) : ref);
|
|
44
|
+
|
|
45
|
+
class RequestError extends Error {
|
|
46
|
+
constructor(status, message, extra = {}) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.extra = extra;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function send(res, status, body) {
|
|
54
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
55
|
+
res.end(JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readJson(req) {
|
|
59
|
+
let size = 0;
|
|
60
|
+
const chunks = [];
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
size += chunk.length;
|
|
63
|
+
if (size > MAX_JSON_BYTES) throw new RequestError(413, 'request body too large');
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
throw new RequestError(400, 'request body is not JSON');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tokenMatches(header, token) {
|
|
74
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
75
|
+
const actual = Buffer.from(header ?? '');
|
|
76
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateRules(rules) {
|
|
80
|
+
if (!rules || !isStringArray(rules.dirs) || !isStringArray(rules.extensions)) throw new RequestError(400, 'rules must list dirs and extensions');
|
|
81
|
+
if (rules.files !== undefined && !(isStringArray(rules.files) && rules.files.every(isNamePattern))) {
|
|
82
|
+
throw new RequestError(400, 'rules.files must list file names, each optionally ending in *');
|
|
83
|
+
}
|
|
84
|
+
const { serverOnly } = rules;
|
|
85
|
+
if (serverOnly === undefined) return;
|
|
86
|
+
if (!isStringArray(serverOnly.names) || !serverOnly.names.every(isPlainName) || !isPlainName(serverOnly.marker) || !isStringArray(serverOnly.keep)) {
|
|
87
|
+
throw new RequestError(400, 'rules.serverOnly must name folders, a marker file and keep patterns');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateMount(mount, mountPrefixes) {
|
|
92
|
+
if (!mount || !isCleanAbsolute(mount.path) || !mountPrefixes.some((prefix) => isWithin(mount.path, prefix))) {
|
|
93
|
+
throw new RequestError(400, `mount ${mount?.path} is not one this runner allows`);
|
|
94
|
+
}
|
|
95
|
+
const { roots, outputRoots, manifest, dirs } = mount;
|
|
96
|
+
if (!isStringArray(roots) || !roots.every(isSafeRel)) throw new RequestError(400, 'roots must be relative paths inside the mount');
|
|
97
|
+
if (!isStringArray(outputRoots) || !outputRoots.every(isSafeRel)) throw new RequestError(400, 'outputRoots must be relative paths inside the mount');
|
|
98
|
+
if (!manifest || typeof manifest !== 'object') throw new RequestError(400, 'manifest must be an object');
|
|
99
|
+
for (const [rel, entry] of Object.entries(manifest)) {
|
|
100
|
+
if (!isSafeRel(rel) || !roots.some((root) => isUnderRoot(rel, root))) throw new RequestError(400, `manifest path ${rel} is outside the roots`);
|
|
101
|
+
if (!isHash(entry?.sha256)) throw new RequestError(400, `manifest entry ${rel} has no sha256`);
|
|
102
|
+
}
|
|
103
|
+
if (!isStringArray(dirs) || !dirs.every((rel) => isSafeRel(rel) && roots.some((root) => isUnderRoot(rel, root)))) {
|
|
104
|
+
throw new RequestError(400, 'dirs must be folders inside the roots');
|
|
105
|
+
}
|
|
106
|
+
if (mount.present !== undefined && !(isStringArray(mount.present) && mount.present.every(isSafeRel))) {
|
|
107
|
+
throw new RequestError(400, 'present must list relative folders');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateExec(body, { mountPrefixes, scratchDirs, image, role }) {
|
|
112
|
+
if (body.role !== role) throw new RequestError(409, `this runner serves the ${role} toolchain, not ${body.role}`);
|
|
113
|
+
if (image && digestOf(body.image) !== digestOf(image)) throw new RequestError(409, `toolchain mismatch: runner has ${image}, build pins ${body.image}`);
|
|
114
|
+
validateRules(body.rules);
|
|
115
|
+
const { mounts, cwd, argv, env } = body;
|
|
116
|
+
if (!Array.isArray(mounts) || mounts.length === 0 || mounts.length > MAX_MOUNTS) throw new RequestError(400, `mounts must list 1 to ${MAX_MOUNTS} folders`);
|
|
117
|
+
mounts.forEach((mount) => validateMount(mount, mountPrefixes));
|
|
118
|
+
if (mounts.some((a, i) => mounts.some((b, j) => i !== j && isWithin(a.path, b.path)))) throw new RequestError(400, 'mounts must not overlap');
|
|
119
|
+
const allowedCwd = isCleanAbsolute(cwd) && [...mounts.map((mount) => mount.path), ...scratchDirs].some((dir) => isWithin(cwd, dir));
|
|
120
|
+
if (!allowedCwd) throw new RequestError(400, 'cwd must be inside a mount or a scratch folder');
|
|
121
|
+
if (!isStringArray(argv) || argv.length === 0) throw new RequestError(400, 'argv must be a non-empty string array');
|
|
122
|
+
if (!env || typeof env !== 'object' || !Object.values(env).every((value) => typeof value === 'string')) throw new RequestError(400, 'env must map names to strings');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A command never sees the runner's own settings, its token above all.
|
|
126
|
+
const commandEnv = (env) => ({
|
|
127
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('CROSSBIND_RUNNER_'))),
|
|
128
|
+
...env,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The job count a client wrote for its own machine means nothing here: a step runs as many jobs as this
|
|
132
|
+
// runner has cores.
|
|
133
|
+
const withJobs = (argv, jobs) => argv.map((arg, i) => {
|
|
134
|
+
if (/^-j\d+$/.test(arg)) return `-j${jobs}`;
|
|
135
|
+
return /^\d+$/.test(arg) && argv[i - 1] === '-j' ? String(jobs) : arg;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function runCommand({ argv: given, cwd, env }, line, jobs) {
|
|
139
|
+
const argv = withJobs(given, jobs);
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, env: commandEnv(env) });
|
|
142
|
+
child.stdout.on('data', (data) => line({ stdout: data.toString() }));
|
|
143
|
+
child.stderr.on('data', (data) => line({ stderr: data.toString() }));
|
|
144
|
+
child.on('error', (error) => {
|
|
145
|
+
line({ stderr: `crossbind runner: ${error.message}\n` });
|
|
146
|
+
resolve(127);
|
|
147
|
+
});
|
|
148
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createRunnerServer({
|
|
153
|
+
mountPrefixes = DEFAULT_MOUNT_PREFIXES, scratchDirs = DEFAULT_SCRATCH_DIRS, blobDir, token, image, role, heartbeatMs = HEARTBEAT_MS,
|
|
154
|
+
jobs = os.availableParallelism(),
|
|
155
|
+
}) {
|
|
156
|
+
if (!token) throw new Error('crossbind runner: a token is required - the runner runs build commands for whoever holds it.');
|
|
157
|
+
if (token.length < MIN_TOKEN_LENGTH) throw new Error(`crossbind runner: the token needs at least ${MIN_TOKEN_LENGTH} characters.`);
|
|
158
|
+
const blobs = createBlobStore(blobDir);
|
|
159
|
+
const indexes = new Map();
|
|
160
|
+
const indexFor = (mountPath) => {
|
|
161
|
+
if (!indexes.has(mountPath)) indexes.set(mountPath, new Map());
|
|
162
|
+
return indexes.get(mountPath);
|
|
163
|
+
};
|
|
164
|
+
let queue = Promise.resolve();
|
|
165
|
+
|
|
166
|
+
function syncMount(mount, rules) {
|
|
167
|
+
fs.mkdirSync(mount.path, { recursive: true });
|
|
168
|
+
const current = hashIndex(mount.path, snapshot(mount.path, mount.roots, rules), indexFor(mount.path));
|
|
169
|
+
applySync(mount.path, planSync(current, mount.manifest), mount.manifest, blobs.pathOf);
|
|
170
|
+
// The host creates folders before a step writes into them (`-o build/bridge/x.cpp`), so even empty ones travel.
|
|
171
|
+
mount.dirs.forEach((rel) => fs.mkdirSync(path.join(mount.path, rel), { recursive: true }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// One command at a time: each mount is a single tree, as with a local docker run.
|
|
175
|
+
async function execute(body, line) {
|
|
176
|
+
body.mounts.forEach((mount) => syncMount(mount, body.rules));
|
|
177
|
+
// Outputs come back whatever their type: the glue JavaScript a link writes is the build's product.
|
|
178
|
+
const outputRules = { ...body.rules, extensions: [] };
|
|
179
|
+
const isStore = (root) => root.includes('*');
|
|
180
|
+
const outputsOf = (mount) => snapshot(mount.path, mount.outputRoots.filter((root) => !isStore(root)), outputRules, { outputs: true });
|
|
181
|
+
// A store unit (a crate version, a conan package folder) never changes once written, so every unit the
|
|
182
|
+
// client does not report travels whole, touched by this command or not, and the rest are not walked.
|
|
183
|
+
const storeFilesOf = (mount) => {
|
|
184
|
+
const present = new Set(mount.present ?? []);
|
|
185
|
+
const missing = expandRoots(mount.path, mount.outputRoots.filter(isStore)).filter((unit) => !present.has(unit));
|
|
186
|
+
return snapshot(mount.path, missing, outputRules, { outputs: true });
|
|
187
|
+
};
|
|
188
|
+
const before = body.mounts.map(outputsOf);
|
|
189
|
+
fs.mkdirSync(body.cwd, { recursive: true });
|
|
190
|
+
const exit = await runCommand(body, line, jobs);
|
|
191
|
+
|
|
192
|
+
const outputs = {};
|
|
193
|
+
const removed = [];
|
|
194
|
+
body.mounts.forEach((mount, i) => {
|
|
195
|
+
const diffed = outputsOf(mount);
|
|
196
|
+
const store = storeFilesOf(mount);
|
|
197
|
+
const after = new Map([...diffed, ...store]);
|
|
198
|
+
const diff = diffSnapshots(before[i], diffed);
|
|
199
|
+
new Set([...diff.changed, ...store.keys()]).forEach((rel) => {
|
|
200
|
+
const file = `${mount.path}/${rel}`;
|
|
201
|
+
const data = fs.readFileSync(file);
|
|
202
|
+
const entry = { sha256: blobs.putBuffer(data), mode: after.get(rel).mode, size: data.length };
|
|
203
|
+
outputs[file] = entry;
|
|
204
|
+
// Small outputs ride along in the stream; a larger one is fetched by its hash afterwards.
|
|
205
|
+
if (data.length <= INLINE_OUTPUT_BYTES) line({ file, ...entry, data: data.toString('base64') });
|
|
206
|
+
});
|
|
207
|
+
removed.push(...diff.removed.map((rel) => `${mount.path}/${rel}`));
|
|
208
|
+
});
|
|
209
|
+
return { exit, outputs, removed };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleExec(req, res) {
|
|
213
|
+
const body = await readJson(req);
|
|
214
|
+
validateExec(body, {
|
|
215
|
+
mountPrefixes, scratchDirs, image, role,
|
|
216
|
+
});
|
|
217
|
+
const hashes = body.mounts.flatMap((mount) => Object.values(mount.manifest).map((entry) => entry.sha256));
|
|
218
|
+
const missing = [...new Set(hashes)].filter((hash) => !blobs.has(hash));
|
|
219
|
+
if (missing.length > 0) throw new RequestError(409, 'missing blobs', { missing });
|
|
220
|
+
// The answer starts at once and heartbeats until the end: a step can wait behind another one longer than
|
|
221
|
+
// a proxy, or the client's fetch, waits for a response.
|
|
222
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
|
|
223
|
+
res.flushHeaders();
|
|
224
|
+
const line = (record) => res.write(`${JSON.stringify(record)}\n`);
|
|
225
|
+
const heartbeat = setInterval(() => line({ heartbeat: true }), heartbeatMs);
|
|
226
|
+
try {
|
|
227
|
+
const run = queue.then(() => execute(body, line));
|
|
228
|
+
queue = run.catch(() => {});
|
|
229
|
+
res.end(`${JSON.stringify(await run)}\n`);
|
|
230
|
+
} finally {
|
|
231
|
+
clearInterval(heartbeat);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// One JSON line per blob ({sha256, data: base64}), so a cold start uploads its inputs in a few requests.
|
|
236
|
+
async function handleBatchUpload(req, res) {
|
|
237
|
+
let stored = 0;
|
|
238
|
+
for await (const text of readline.createInterface({ input: req, crlfDelay: Infinity })) {
|
|
239
|
+
if (!text) continue;
|
|
240
|
+
const { sha256, data } = JSON.parse(text);
|
|
241
|
+
const bytes = Buffer.from(data ?? '', 'base64');
|
|
242
|
+
if (!isHash(sha256) || sha256Of(bytes) !== sha256) throw new RequestError(400, `content does not match ${sha256}`);
|
|
243
|
+
blobs.putBuffer(bytes);
|
|
244
|
+
stored += 1;
|
|
245
|
+
}
|
|
246
|
+
send(res, 201, { stored });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function route(req, res) {
|
|
250
|
+
const url = new URL(req.url, 'http://runner');
|
|
251
|
+
if (req.method === 'GET' && url.pathname === '/v1/health') return send(res, 200, { ok: true, protocol: PROTOCOL_VERSION, role, image });
|
|
252
|
+
if (!tokenMatches(req.headers.authorization, token)) return send(res, 401, { error: 'invalid token' });
|
|
253
|
+
|
|
254
|
+
if (req.method === 'POST' && url.pathname === '/v1/blobs') return handleBatchUpload(req, res);
|
|
255
|
+
const blobMatch = url.pathname.match(/^\/v1\/blobs\/([^/]+)$/);
|
|
256
|
+
if (blobMatch) {
|
|
257
|
+
const hash = decodeURIComponent(blobMatch[1]);
|
|
258
|
+
if (!isHash(hash)) return send(res, 400, { error: 'blob names are sha256 digests' });
|
|
259
|
+
if (req.method === 'PUT' && req.headers['content-range'] !== undefined) {
|
|
260
|
+
const range = CONTENT_RANGE.exec(req.headers['content-range']);
|
|
261
|
+
const [start, end, total] = range ? range.slice(1).map(Number) : [];
|
|
262
|
+
const upload = req.headers['x-crossbind-upload'];
|
|
263
|
+
if (!range || start > end || end >= total || !isUploadId(upload)) {
|
|
264
|
+
return send(res, 400, { error: 'a part needs a content-range of bytes start-end/total and an x-crossbind-upload id' });
|
|
265
|
+
}
|
|
266
|
+
const result = await blobs.putPart(hash, upload, { start, end, total }, req);
|
|
267
|
+
if (result.status === 'stored') return send(res, 201, { stored: hash });
|
|
268
|
+
if (result.status === 'partial') return send(res, 202, { received: result.received });
|
|
269
|
+
if (result.status === 'gap') return send(res, 409, { error: `the upload holds ${result.received} bytes, so its next part starts there`, received: result.received });
|
|
270
|
+
return send(res, 400, { error: result.status === 'short' ? 'the part is not as long as its content-range says' : 'content does not match the hash' });
|
|
271
|
+
}
|
|
272
|
+
if (req.method === 'PUT') {
|
|
273
|
+
return (await blobs.putStream(hash, req)) ? send(res, 201, { stored: hash }) : send(res, 400, { error: 'content does not match the hash' });
|
|
274
|
+
}
|
|
275
|
+
if (req.method === 'GET') {
|
|
276
|
+
if (!blobs.has(hash)) return send(res, 404, { error: 'unknown blob' });
|
|
277
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
278
|
+
return fs.createReadStream(blobs.pathOf(hash)).pipe(res);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (req.method === 'POST' && url.pathname === '/v1/missing') {
|
|
282
|
+
const { hashes } = await readJson(req);
|
|
283
|
+
if (!isStringArray(hashes) || !hashes.every(isHash)) return send(res, 400, { error: 'hashes must be sha256 digests' });
|
|
284
|
+
return send(res, 200, { missing: hashes.filter((hash) => !blobs.has(hash)) });
|
|
285
|
+
}
|
|
286
|
+
if (req.method === 'POST' && url.pathname === '/v1/exec') return handleExec(req, res);
|
|
287
|
+
return send(res, 404, { error: 'not found' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return http.createServer((req, res) => {
|
|
291
|
+
route(req, res).catch((error) => {
|
|
292
|
+
if (res.headersSent) {
|
|
293
|
+
res.end(`${JSON.stringify({ exit: 1, error: error.message })}\n`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
send(res, error.status ?? 500, { error: error.message, ...error.extra });
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const listFromEnv = (value, fallback) => (value ? value.split(',').map((item) => item.trim()).filter(Boolean) : fallback);
|
|
302
|
+
|
|
303
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
304
|
+
const token = process.env.CROSSBIND_RUNNER_TOKEN;
|
|
305
|
+
delete process.env.CROSSBIND_RUNNER_TOKEN;
|
|
306
|
+
if (!token || token.length < MIN_TOKEN_LENGTH) {
|
|
307
|
+
process.stderr.write(`crossbind runner: set CROSSBIND_RUNNER_TOKEN to at least ${MIN_TOKEN_LENGTH} characters - the runner runs build commands for whoever holds it.\n`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
const server = createRunnerServer({
|
|
311
|
+
mountPrefixes: listFromEnv(process.env.CROSSBIND_RUNNER_MOUNT_PREFIXES, DEFAULT_MOUNT_PREFIXES),
|
|
312
|
+
scratchDirs: listFromEnv(process.env.CROSSBIND_RUNNER_SCRATCH_DIRS, DEFAULT_SCRATCH_DIRS),
|
|
313
|
+
blobDir: process.env.CROSSBIND_RUNNER_BLOBS || '/tmp/crossbind-runner/blobs',
|
|
314
|
+
token,
|
|
315
|
+
image: process.env.CROSSBIND_RUNNER_IMAGE || '',
|
|
316
|
+
role: process.env.CROSSBIND_RUNNER_ROLE || 'web',
|
|
317
|
+
});
|
|
318
|
+
const port = Number(process.env.CROSSBIND_RUNNER_PORT || DEFAULT_PORT);
|
|
319
|
+
server.listen(port, '0.0.0.0', () => process.stdout.write(`crossbind runner (${process.env.CROSSBIND_RUNNER_ROLE || 'web'}) listening on ${port}\n`));
|
|
320
|
+
}
|