@moldesk/core 0.0.4 → 0.0.6

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.
Files changed (52) hide show
  1. package/dist/cloud/accounts.d.ts +69 -0
  2. package/dist/cloud/accounts.js +113 -0
  3. package/dist/cloud/accounts.test.d.ts +1 -0
  4. package/dist/cloud/accounts.test.js +188 -0
  5. package/dist/cloud/backend.test.d.ts +1 -0
  6. package/dist/cloud/backend.test.js +339 -0
  7. package/dist/cloud/deps.d.ts +15 -0
  8. package/dist/cloud/deps.js +30 -0
  9. package/dist/cloud/manage.d.ts +57 -0
  10. package/dist/cloud/manage.js +210 -0
  11. package/dist/cloud/manage.test.d.ts +1 -0
  12. package/dist/cloud/manage.test.js +105 -0
  13. package/dist/cloud/quote.d.ts +131 -0
  14. package/dist/cloud/quote.js +225 -0
  15. package/dist/cloud/quote.test.d.ts +1 -0
  16. package/dist/cloud/quote.test.js +264 -0
  17. package/dist/cloud/run.d.ts +114 -0
  18. package/dist/cloud/run.js +498 -0
  19. package/dist/cloud/run.test.d.ts +1 -0
  20. package/dist/cloud/run.test.js +597 -0
  21. package/dist/cloud/state.d.ts +159 -0
  22. package/dist/cloud/state.js +395 -0
  23. package/dist/cloud/state.test.d.ts +1 -0
  24. package/dist/cloud/state.test.js +199 -0
  25. package/dist/cloud/worker/cache.d.ts +75 -0
  26. package/dist/cloud/worker/cache.js +152 -0
  27. package/dist/cloud/worker/cache.test.d.ts +1 -0
  28. package/dist/cloud/worker/cache.test.js +123 -0
  29. package/dist/cloud/worker/main.d.ts +2 -0
  30. package/dist/cloud/worker/main.js +34 -0
  31. package/dist/cloud/worker/pipeline.d.ts +26 -0
  32. package/dist/cloud/worker/pipeline.js +160 -0
  33. package/dist/cloud/worker/pipeline.test.d.ts +1 -0
  34. package/dist/cloud/worker/pipeline.test.js +298 -0
  35. package/dist/cloud/worker/server.d.ts +38 -0
  36. package/dist/cloud/worker/server.js +287 -0
  37. package/dist/cloud/worker/server.test.d.ts +1 -0
  38. package/dist/cloud/worker/server.test.js +391 -0
  39. package/dist/cloud/worker/watchdog.d.ts +27 -0
  40. package/dist/cloud/worker/watchdog.js +46 -0
  41. package/dist/cloud/worker-release.d.ts +12 -0
  42. package/dist/cloud/worker-release.js +25 -0
  43. package/dist/cloud/worker-release.test.d.ts +1 -0
  44. package/dist/cloud/worker-release.test.js +21 -0
  45. package/dist/index.d.ts +17 -3
  46. package/dist/index.js +9 -2
  47. package/dist/run-lifecycle.d.ts +74 -0
  48. package/dist/run-lifecycle.js +178 -0
  49. package/dist/run.d.ts +61 -1
  50. package/dist/run.js +54 -125
  51. package/dist/run.test.js +47 -1
  52. package/package.json +10 -6
@@ -0,0 +1,287 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { pipeline as streamPipeline } from "node:stream/promises";
6
+ import { EXECUTION_CONTRACT_VERSION, isSafeRelativePath, redactSecrets, } from "@moldesk/runtime";
7
+ export const WORKER_VERSION = "1";
8
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ const MAX_JSON_BYTES = 1024 * 1024;
10
+ const MAX_INPUT_BYTES = 2 * 1024 ** 3;
11
+ export const DEFAULT_MAX_LOG_BYTES = 4 * 1024 * 1024;
12
+ function constantTimeEqual(a, b) {
13
+ const left = Buffer.from(a);
14
+ const right = Buffer.from(b);
15
+ return left.length === right.length && timingSafeEqual(left, right);
16
+ }
17
+ function sendJson(response, status, body) {
18
+ const text = JSON.stringify(body);
19
+ response.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(text) });
20
+ response.end(text);
21
+ }
22
+ async function readJson(request) {
23
+ const chunks = [];
24
+ let total = 0;
25
+ for await (const chunk of request) {
26
+ total += chunk.length;
27
+ if (total > MAX_JSON_BYTES)
28
+ throw Object.assign(new Error("request body too large"), { status: 413 });
29
+ chunks.push(chunk);
30
+ }
31
+ try {
32
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
33
+ }
34
+ catch {
35
+ throw Object.assign(new Error("malformed JSON"), { status: 400 });
36
+ }
37
+ }
38
+ /** Real path of `target` must stay inside `root`; symlinks are refused. */
39
+ function confined(root, relative) {
40
+ if (!isSafeRelativePath(relative))
41
+ return undefined;
42
+ const resolvedRoot = path.resolve(root);
43
+ const target = path.resolve(resolvedRoot, ...relative.split("/"));
44
+ return target.startsWith(`${resolvedRoot}${path.sep}`) ? target : undefined;
45
+ }
46
+ async function sha256Of(file) {
47
+ const hash = createHash("sha256");
48
+ for await (const chunk of fs.createReadStream(file))
49
+ hash.update(chunk);
50
+ return hash.digest("hex");
51
+ }
52
+ async function buildInventory(outputDir) {
53
+ const files = [];
54
+ const walk = async (dir, prefix) => {
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ const full = path.join(dir, entry.name);
57
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
58
+ const stat = fs.lstatSync(full);
59
+ if (stat.isSymbolicLink())
60
+ continue;
61
+ if (stat.isDirectory())
62
+ await walk(full, relative);
63
+ else if (stat.isFile())
64
+ files.push({ path: relative, sizeBytes: stat.size, sha256: await sha256Of(full) });
65
+ }
66
+ };
67
+ if (fs.existsSync(outputDir))
68
+ await walk(outputDir, "");
69
+ files.sort((a, b) => a.path.localeCompare(b.path));
70
+ return { version: EXECUTION_CONTRACT_VERSION, files };
71
+ }
72
+ export function createWorkerServer(options) {
73
+ const secrets = [options.token, ...(options.secrets ?? [])];
74
+ const maxLogBytes = options.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
75
+ let current;
76
+ const pushLog = (state, stream, raw) => {
77
+ const text = redactSecrets(raw, secrets);
78
+ const bytes = Buffer.byteLength(text);
79
+ state.seq += 1;
80
+ state.logs.push({ seq: state.seq, stream, text, bytes });
81
+ state.logBytes += bytes;
82
+ while (state.logBytes > maxLogBytes && state.logs.length > 1) {
83
+ const evicted = state.logs.shift();
84
+ state.logBytes -= evicted.bytes;
85
+ state.evictedSeq = evicted.seq;
86
+ state.evictedBytes += evicted.bytes;
87
+ }
88
+ };
89
+ const snapshot = (state, after) => {
90
+ const fresh = state.logs.filter((entry) => entry.seq > after);
91
+ return {
92
+ stage: state.stage,
93
+ state: state.state,
94
+ ...(state.exitCode !== undefined ? { exitCode: state.exitCode } : {}),
95
+ ...(state.error ? { error: state.error } : {}),
96
+ logs: {
97
+ cursor: state.seq,
98
+ // Chunks the caller never saw and the buffer no longer holds.
99
+ ...(state.evictedSeq > after ? { lostEntries: state.evictedSeq - after, evictedBytes: state.evictedBytes } : {}),
100
+ stdout: fresh.filter((entry) => entry.stream === "stdout").map((entry) => entry.text).join(""),
101
+ stderr: fresh.filter((entry) => entry.stream === "stderr").map((entry) => entry.text).join(""),
102
+ },
103
+ ...(state.runtime ? { runtime: state.runtime } : {}),
104
+ };
105
+ };
106
+ const startJob = (job) => {
107
+ const jobDir = path.join(options.rootDir, job.runId);
108
+ const state = { job, stage: "booting", state: "running", logs: [], logBytes: 0, seq: 0, evictedSeq: 0, evictedBytes: 0, controller: new AbortController() };
109
+ current = { runId: job.runId, state };
110
+ fs.mkdirSync(path.join(jobDir, "output"), { recursive: true });
111
+ void (async () => {
112
+ try {
113
+ const result = await options.pipeline({
114
+ job,
115
+ jobDir,
116
+ inputDir: path.join(jobDir, "input"),
117
+ outputDir: path.join(jobDir, "output"),
118
+ signal: state.controller.signal,
119
+ setStage: (stage) => {
120
+ state.stage = stage;
121
+ },
122
+ log: (stream, text) => pushLog(state, stream, text),
123
+ });
124
+ state.exitCode = result.exitCode;
125
+ state.runtime = result.runtime;
126
+ state.state = state.controller.signal.aborted ? "cancelled" : result.exitCode === 0 ? "succeeded" : "failed";
127
+ }
128
+ catch (error) {
129
+ const message = redactSecrets(error instanceof Error ? error.message : String(error), secrets);
130
+ const code = error?.code;
131
+ state.error = { code: typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "WORKER_PIPELINE_FAILED", message };
132
+ state.state = state.controller.signal.aborted ? "cancelled" : "failed";
133
+ }
134
+ })();
135
+ };
136
+ const handle = async (request, response) => {
137
+ const url = new URL(request.url ?? "/", "http://worker");
138
+ const bearer = /^Bearer (.+)$/.exec(request.headers.authorization ?? "")?.[1];
139
+ if (!bearer || !constantTimeEqual(bearer, options.token))
140
+ return sendJson(response, 401, { message: "unauthorized" });
141
+ const method = request.method ?? "GET";
142
+ if (method === "GET" && url.pathname === "/v1/health") {
143
+ return sendJson(response, 200, { contract: EXECUTION_CONTRACT_VERSION, workerVersion: WORKER_VERSION });
144
+ }
145
+ const match = /^\/v1\/jobs\/([^/]+)\/(input|start|status|cancel|inventory|files)(?:\/(.*))?$/.exec(url.pathname);
146
+ if (!match)
147
+ return sendJson(response, 404, { message: "not found" });
148
+ const runId = decodeURIComponent(match[1]);
149
+ const action = match[2];
150
+ const rest = match[3] !== undefined ? match[3].split("/").map(decodeURIComponent).join("/") : undefined;
151
+ if (!RUN_ID_PATTERN.test(runId))
152
+ return sendJson(response, 400, { message: "invalid run id" });
153
+ if (current && current.runId !== runId)
154
+ return sendJson(response, 409, { message: "this worker serves a different job" });
155
+ const jobDir = path.join(options.rootDir, runId);
156
+ if (action === "input" && method === "PUT") {
157
+ if (current)
158
+ return sendJson(response, 409, { message: "job already started" });
159
+ const target = rest ? confined(path.join(jobDir, "input"), rest) : undefined;
160
+ if (!target)
161
+ return sendJson(response, 400, { message: "unsafe input path" });
162
+ const declaredSha = String(request.headers["x-moldesk-sha256"] ?? "").toLowerCase();
163
+ const declaredSize = Number(request.headers["content-length"]);
164
+ if (!/^[0-9a-f]{64}$/.test(declaredSha) || !Number.isInteger(declaredSize) || declaredSize < 0 || declaredSize > MAX_INPUT_BYTES) {
165
+ return sendJson(response, 400, { message: "missing or invalid sha256/length" });
166
+ }
167
+ fs.mkdirSync(path.dirname(target), { recursive: true });
168
+ const temporary = `${target}.part-${process.pid}`;
169
+ const hash = createHash("sha256");
170
+ let received = 0;
171
+ request.on("data", (chunk) => {
172
+ received += chunk.length;
173
+ hash.update(chunk);
174
+ });
175
+ try {
176
+ await streamPipeline(request, fs.createWriteStream(temporary, { mode: 0o600 }));
177
+ }
178
+ catch {
179
+ fs.rmSync(temporary, { force: true });
180
+ return sendJson(response, 400, { message: "upload interrupted" });
181
+ }
182
+ if (received !== declaredSize || hash.digest("hex") !== declaredSha) {
183
+ fs.rmSync(temporary, { force: true });
184
+ return sendJson(response, 422, { message: "upload failed size/sha256 verification" });
185
+ }
186
+ fs.renameSync(temporary, target);
187
+ return sendJson(response, 200, { ok: true });
188
+ }
189
+ if (action === "start" && method === "POST") {
190
+ if (current)
191
+ return sendJson(response, 409, { message: "job already started" });
192
+ const body = (await readJson(request));
193
+ if (body?.version !== EXECUTION_CONTRACT_VERSION ||
194
+ body.runId !== runId ||
195
+ (body.mode !== "execute" && body.mode !== "prepare-assets") ||
196
+ !Array.isArray(body.inputs) ||
197
+ (body.mode === "execute" && body.inputs.length === 0)) {
198
+ return sendJson(response, 400, { message: "invalid job" });
199
+ }
200
+ for (const input of body.inputs) {
201
+ const file = confined(path.join(jobDir, "input"), input.relativePath);
202
+ if (!file || !fs.existsSync(file) || fs.statSync(file).size !== input.sizeBytes) {
203
+ return sendJson(response, 422, { message: `input ${JSON.stringify(input.relativePath)} was not uploaded or does not match` });
204
+ }
205
+ }
206
+ startJob(body);
207
+ return sendJson(response, 200, { ok: true });
208
+ }
209
+ if (!current)
210
+ return sendJson(response, 404, { message: "no such job" });
211
+ const state = current.state;
212
+ if (action === "status" && method === "GET") {
213
+ const after = Number(url.searchParams.get("after") ?? "0");
214
+ return sendJson(response, 200, snapshot(state, Number.isInteger(after) && after >= 0 ? after : 0));
215
+ }
216
+ if (action === "cancel" && method === "POST") {
217
+ state.controller.abort();
218
+ return sendJson(response, 200, { ok: true });
219
+ }
220
+ const finished = state.state !== "running" && state.state !== "pending";
221
+ if (action === "inventory" && method === "GET") {
222
+ if (!finished)
223
+ return sendJson(response, 409, { message: "job has not finished" });
224
+ return sendJson(response, 200, await buildInventory(path.join(jobDir, "output")));
225
+ }
226
+ if (action === "files" && method === "GET") {
227
+ if (!finished)
228
+ return sendJson(response, 409, { message: "job has not finished" });
229
+ const outputDir = path.join(jobDir, "output");
230
+ const target = rest ? confined(outputDir, rest) : undefined;
231
+ if (!target)
232
+ return sendJson(response, 400, { message: "unsafe path" });
233
+ let stat;
234
+ try {
235
+ stat = fs.lstatSync(target);
236
+ }
237
+ catch {
238
+ return sendJson(response, 404, { message: "no such file" });
239
+ }
240
+ if (!stat.isFile() || fs.realpathSync(target) !== path.join(fs.realpathSync(outputDir), ...rest.split("/")))
241
+ return sendJson(response, 404, { message: "no such file" });
242
+ const range = /^bytes=(\d+)-(\d*)$/.exec(String(request.headers.range ?? ""));
243
+ let start = 0;
244
+ let end = stat.size - 1;
245
+ let status = 200;
246
+ if (range) {
247
+ start = Number(range[1]);
248
+ end = range[2] ? Math.min(Number(range[2]), stat.size - 1) : stat.size - 1;
249
+ if (start > end || start >= stat.size) {
250
+ response.writeHead(416, { "Content-Range": `bytes */${stat.size}` });
251
+ return void response.end();
252
+ }
253
+ status = 206;
254
+ }
255
+ response.writeHead(status, {
256
+ "Content-Type": "application/octet-stream",
257
+ "Content-Length": stat.size === 0 ? 0 : end - start + 1,
258
+ ...(status === 206 ? { "Content-Range": `bytes ${start}-${end}/${stat.size}` } : {}),
259
+ });
260
+ if (stat.size === 0)
261
+ return void response.end();
262
+ await streamPipeline(fs.createReadStream(target, { start, end }), response).catch(() => undefined);
263
+ return;
264
+ }
265
+ return sendJson(response, 405, { message: "method not allowed" });
266
+ };
267
+ const server = http.createServer((request, response) => {
268
+ handle(request, response).catch((error) => {
269
+ if (response.headersSent)
270
+ return void response.destroy();
271
+ sendJson(response, error.status ?? 500, { message: redactSecrets(error.message ?? "internal error", secrets) });
272
+ });
273
+ });
274
+ return {
275
+ server,
276
+ listen: (port, host = "0.0.0.0") => new Promise((resolve, reject) => {
277
+ server.once("error", reject);
278
+ server.listen(port, host, () => resolve(server.address().port));
279
+ }),
280
+ close: () => new Promise((resolve) => {
281
+ current?.state.controller.abort();
282
+ server.close(() => resolve());
283
+ server.closeAllConnections?.();
284
+ }),
285
+ isFinished: () => current !== undefined && current.state.state !== "running" && current.state.state !== "pending",
286
+ };
287
+ }
@@ -0,0 +1 @@
1
+ export {};