@skyramp/mcp 0.5.0-rc.2 → 0.5.0-rc.3

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.
@@ -2,6 +2,7 @@ import Docker from "dockerode";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { Writable } from "stream";
5
+ import { StringDecoder } from "string_decoder";
5
6
  import { stripVTControlCharacters } from "util";
6
7
  import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
7
8
  import { logger } from "../utils/logger.js";
@@ -22,6 +23,8 @@ export const LAYOUT = {
22
23
  work: "/work",
23
24
  certs: "/certs",
24
25
  overrideDir: "/opt/skyramp-override",
26
+ nativeLib: "/opt/skyramp-libs/skyramp-linux-amd64.so",
27
+ npmOverride: "/tmp/skyramp-sdk",
25
28
  poolDir: "/pool",
26
29
  imageClasses: "/app/classes",
27
30
  imageLibDir: "/app/lib",
@@ -32,6 +35,8 @@ export const LAYOUT = {
32
35
  work: "C:\\work",
33
36
  certs: "C:\\certs",
34
37
  overrideDir: "C:\\opt\\skyramp-override",
38
+ nativeLib: "C:/opt/skyramp-libs/skyramp-windows-amd64.dll",
39
+ npmOverride: "C:/skyramp-sdk",
35
40
  poolDir: "C:\\pool",
36
41
  imageClasses: "C:/app/classes",
37
42
  imageLibDir: "C:/app/lib",
@@ -68,34 +73,137 @@ export async function resolvePlatform(docker, explicit) {
68
73
  }
69
74
  return os;
70
75
  }
71
- /** A simple (unqualified) Java class name. */
72
- export const JAVA_CLASS_NAME = /^[A-Za-z_$][\w$]*$/;
76
+ /** How long an interrupted fleet gets to cancel its workers before it is killed. */
77
+ export const CANCEL_GRACE_MS = 20_000;
78
+ /** A pending partial line longer than this is passed on as it is. */
79
+ const MAX_PENDING_LINE = 1024 * 1024;
80
+ /** The most run output kept in memory; the rest is streamed to outputLog as it arrives. */
81
+ const MAX_BUFFERED_OUTPUT = 64 * 1024;
73
82
  /**
74
- * The generated fleet manager: the one class in the directory that JUnit runs. The journey
75
- * and the load class travel with it because they sit beside it, and they are compiled but
76
- * never selected -- the journey is an input the fleet ships to the worker, and the load
77
- * class only supplies the scenario.
83
+ * A sink for one of the container's streams that passes on whole lines only. Docker
84
+ * interleaves stdout and stderr frames, so a line is only whole within its own stream:
85
+ * one shared sink could split a SKYRAMP_LIMIT line around a stderr frame.
86
+ */
87
+ export function lineSink(emit) {
88
+ const decoder = new StringDecoder("utf8");
89
+ let pending = "";
90
+ const push = (text) => {
91
+ pending += text;
92
+ const lines = pending.split("\n");
93
+ pending = lines.pop() ?? "";
94
+ for (const line of lines)
95
+ emit(stripVTControlCharacters(line) + "\n");
96
+ if (pending.length > MAX_PENDING_LINE) {
97
+ emit(stripVTControlCharacters(pending));
98
+ pending = "";
99
+ }
100
+ };
101
+ return {
102
+ sink: new Writable({
103
+ write(chunk, _enc, cb) {
104
+ push(decoder.write(chunk));
105
+ cb();
106
+ },
107
+ }),
108
+ flush: () => {
109
+ push(decoder.end());
110
+ if (pending)
111
+ emit(stripVTControlCharacters(pending) + "\n");
112
+ pending = "";
113
+ },
114
+ };
115
+ }
116
+ /**
117
+ * Asks a running fleet to stop the way a user's Ctrl-C would, so an SDK that cancels on
118
+ * interrupt can tell its workers (DELETE) before it exits. The signal goes to the
119
+ * container's first process and, through exec, to every other one, because a multi-line
120
+ * `sh -c` script stays first and does not pass it on. Windows has no SIGINT for a
121
+ * container: `docker stop` is its graceful form, and it kills after the grace itself.
122
+ */
123
+ async function interruptContainer(container, platform) {
124
+ if (platform === "windows") {
125
+ await container.stop({ t: Math.ceil(CANCEL_GRACE_MS / 1000) });
126
+ return;
127
+ }
128
+ await container.kill({ signal: "SIGINT" }).catch((e) => {
129
+ logger.warning(`could not interrupt the fleet container: ${e}`);
130
+ });
131
+ const exec = await container.exec({
132
+ Cmd: ["sh", "-c", "kill -INT -1"],
133
+ AttachStdout: false,
134
+ AttachStderr: false,
135
+ });
136
+ await exec.start({ Detach: true });
137
+ }
138
+ /**
139
+ * A simple Java class name, or a Python, TypeScript or JavaScript fleet script's file name.
140
+ *
141
+ * No `$`: the name is interpolated unquoted into the `sh`/PowerShell command built below, and
142
+ * `$` there is a variable sigil, not a literal character.
143
+ */
144
+ export const FLEET_ENTRY_NAME = /^[A-Za-z_]\w*(\.(py|ts|js))?$/;
145
+ /** The language a fleet entry is written in: a script by its extension, else a Java class. */
146
+ export function fleetLanguage(entry) {
147
+ if (entry.endsWith(".py"))
148
+ return "python";
149
+ if (entry.endsWith(".ts"))
150
+ return "typescript";
151
+ if (entry.endsWith(".js"))
152
+ return "javascript";
153
+ return "java";
154
+ }
155
+ /**
156
+ * The SDK a branch-built package replaces: a pip sdist or wheel for Python, an npm tarball
157
+ * for TypeScript and JavaScript. Java takes a jar through libraryJar instead.
158
+ */
159
+ export function packageLanguages(pkg) {
160
+ if (/\.(whl|tar\.gz)$/.test(pkg))
161
+ return ["python"];
162
+ if (/\.tgz$/.test(pkg))
163
+ return ["typescript", "javascript"];
164
+ return [];
165
+ }
166
+ /** The file an entry lives in, relative to the fleet directory. */
167
+ export function fleetFileName(entry) {
168
+ return fleetLanguage(entry) === "java" ? `${entry}.java` : entry;
169
+ }
170
+ /**
171
+ * The generated fleet manager: the JUnit class (Java) or the script (Python, TypeScript)
172
+ * the run starts. The journey and the load file travel with it because they sit beside it
173
+ * -- the journey is an input the fleet ships to the worker, and the load file only supplies
174
+ * the scenario.
78
175
  */
79
176
  export function findEntryClass(directory, explicit) {
80
177
  if (explicit) {
81
- // It reaches a shell script and a file path, so it must be a class name and nothing else.
82
- if (!JAVA_CLASS_NAME.test(explicit)) {
83
- throw new Error(`entryClass ${explicit} is not a Java class name`);
178
+ // It reaches a shell script and a file path, so it must be a plain name and nothing else.
179
+ if (!FLEET_ENTRY_NAME.test(explicit)) {
180
+ throw new Error(`entryClass ${explicit} is not a Java class name or a .py/.ts/.js fleet file name`);
84
181
  }
85
182
  return explicit;
86
183
  }
87
184
  const candidates = fs
88
185
  .readdirSync(directory)
89
- .filter((f) => f.endsWith("FleetTest.java"))
90
- .map((f) => f.replace(/\.java$/, ""))
186
+ .flatMap((f) => f.endsWith("FleetTest.java")
187
+ ? [f.replace(/\.java$/, "")]
188
+ : /_fleet\.(py|ts|js)$/.test(f)
189
+ ? [f]
190
+ : [])
91
191
  .sort();
92
192
  if (candidates.length === 0) {
93
- throw new Error(`no *FleetTest.java in ${directory}: generate a ui-load trio there, or name the class with entryClass`);
193
+ throw new Error(`no *FleetTest.java, *_fleet.py, *_fleet.ts or *_fleet.js in ${directory}: generate a ui-load trio there, or name the entry with entryClass`);
94
194
  }
95
195
  if (candidates.length > 1) {
96
196
  throw new Error(`several fleet classes in ${directory} (${candidates.join(", ")}): name one with entryClass`);
97
197
  }
98
- return candidates[0];
198
+ const entry = candidates[0];
199
+ // Discovered off disk, so it reaches the same unquoted shell/PowerShell interpolation as
200
+ // an explicit entryClass and needs the same guard.
201
+ if (!FLEET_ENTRY_NAME.test(entry)) {
202
+ throw new Error(`found fleet entry ${entry} in ${directory}, but its name is not a plain Java class ` +
203
+ "name or .py/.ts/.js file name safe to run in a shell command; rename it without " +
204
+ "$, spaces or other shell characters");
205
+ }
206
+ return entry;
99
207
  }
100
208
  /**
101
209
  * A fleet file that pins its worker image is refused.
@@ -106,27 +214,141 @@ export function findEntryClass(directory, explicit) {
106
214
  */
107
215
  export function assertNoPinnedImage(fleetFile) {
108
216
  const source = fs.readFileSync(fleetFile, "utf8");
109
- // The builder call, not the word: a comment that mentions the option pins nothing.
110
- const pinned = ["workerImage", "workerTag"].filter((k) => new RegExp(`\\.\\s*${k}\\s*\\(`).test(source));
217
+ const syntax = HOST_SYNTAX[fleetLanguage(path.basename(fleetFile))];
218
+ // The option as set, not the word: a comment that mentions the option pins nothing.
219
+ const pinned = ["workerImage", "workerTag"].filter((k) => syntax.option(k).test(source));
111
220
  if (pinned.length > 0) {
112
221
  throw new Error(`${path.basename(fleetFile)} pins ${pinned.join(" and ")}: the run would not exercise the ` +
113
222
  `library's own image choice. Regenerate with a plain host.`);
114
223
  }
115
224
  }
225
+ /** The Docker daemon's socket, on the host and inside a Linux executor. */
226
+ const DOCKER_SOCKET = "/var/run/docker.sock";
116
227
  /**
117
- * The fleet reaches the worker it deployed at an address derived from the Docker daemon:
118
- * the daemon's host for a remote engine, `localhost` for a local socket. Inside this
119
- * container `localhost` is the container, so a local-socket fleet cannot reach the worker it
120
- * just deployed and would hang until the timeout instead of failing.
228
+ * How each language spells a host and its options: the Java builder
229
+ * (`Host.builder("x").dockerHost("tcp://…")`), Python keywords
230
+ * (`skyramp.Host(docker_host="tcp://…")`) and TypeScript and JavaScript fields (`new Host({ dockerHost: "tcp://…" })`). Options are named in
231
+ * Java's camelCase.
232
+ */
233
+ const HOST_SYNTAX = {
234
+ java: {
235
+ host: /Host\s*\.\s*builder\s*\(/,
236
+ option: (k) => new RegExp(`\\.\\s*${k}\\s*\\(\\s*`),
237
+ },
238
+ python: {
239
+ host: /\bHost\s*\(/,
240
+ option: (k) => new RegExp(`\\b${k.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase())}\\s*=\\s*`),
241
+ },
242
+ typescript: {
243
+ host: /\bnew\s+Host\s*\(/,
244
+ option: (k) => new RegExp(`\\b${k}\\s*:\\s*`),
245
+ },
246
+ javascript: {
247
+ host: /\bnew\s+Host\s*\(/,
248
+ option: (k) => new RegExp(`\\b${k}\\s*:\\s*`),
249
+ },
250
+ };
251
+ /** Index of the `)` closing the call whose arguments start at `from`, skipping strings. */
252
+ function closingParen(source, from) {
253
+ let depth = 1;
254
+ for (let i = from; i < source.length; i++) {
255
+ const c = source[i];
256
+ if (c === '"' || c === "'" || c === "`") {
257
+ for (i++; i < source.length && source[i] !== c; i++) {
258
+ if (source[i] === "\\")
259
+ i++;
260
+ }
261
+ }
262
+ else if (c === "(")
263
+ depth++;
264
+ else if (c === ")" && --depth === 0)
265
+ return i;
266
+ }
267
+ return -1;
268
+ }
269
+ /** Each host's own text: its Host(...) call, or for Java its builder chain up to .build(). */
270
+ function hostSpans(source, language) {
271
+ const host = new RegExp(HOST_SYNTAX[language].host.source, "g");
272
+ const build = /\.\s*build\s*\(/g;
273
+ return [...source.matchAll(host)].map((m) => {
274
+ const from = m.index + m[0].length;
275
+ build.lastIndex = from;
276
+ const end = language === "java"
277
+ ? (build.exec(source)?.index ?? -1)
278
+ : closingParen(source, from);
279
+ if (end < 0) {
280
+ throw new Error("a host in the fleet file has no end this tool can find; regenerate it");
281
+ }
282
+ return source.slice(from, end);
283
+ });
284
+ }
285
+ /** A Docker Engine reached over the network, which is mTLS. */
286
+ function remoteDockerHost(language) {
287
+ const prefix = HOST_SYNTAX[language].option("dockerHost").source;
288
+ return new RegExp(`${prefix}["'](tcp|https?):\\/\\/`);
289
+ }
290
+ /**
291
+ * A host whose options this tool cannot read at all -- built from a spread or passed in as a
292
+ * bare variable -- rather than written inline, so a hidden `dockerHost` would read as absent
293
+ * and the host would default to local. Java's builder chain has no equivalent spread syntax,
294
+ * so it is left to the existing per-option check.
295
+ */
296
+ function opaqueHost(h, language) {
297
+ const trimmed = h.trim();
298
+ if (trimmed === "")
299
+ return false; // no options at all: genuinely local
300
+ switch (language) {
301
+ case "typescript":
302
+ case "javascript":
303
+ return /\.\.\.\s*[A-Za-z_$]/.test(trimmed) || !trimmed.startsWith("{");
304
+ case "python":
305
+ return /\*\*\s*[A-Za-z_]/.test(trimmed) || !/[:=]/.test(trimmed);
306
+ case "java":
307
+ return false;
308
+ }
309
+ }
310
+ /**
311
+ * Which Docker daemons the fleet deploys its workers through, read off its hosts.
312
+ *
313
+ * `remote`: a host names a tcp or http(s) engine. That connection is mTLS, so the run needs
314
+ * the client certificates.
315
+ *
316
+ * `local`: a host deploys with no remote engine, which is the daemon this tool runs the
317
+ * executor on. The fleet reaches it through the daemon's socket and needs no certificates:
318
+ * the way to try a fleet on one's own machine.
319
+ *
320
+ * A host that carries an address drives a worker that already runs and deploys nothing, so
321
+ * it counts as neither. Throws when no host, a non-literal dockerHost, or a host built from a
322
+ * spread or a bare variable leaves the engine unknown -- the last of those would otherwise
323
+ * read as local with no dockerHost found, mounting the daemon's socket for a host that may
324
+ * actually be remote.
121
325
  */
122
- export function assertRemoteDaemon(fleetFile) {
326
+ export function fleetDaemons(fleetFile) {
123
327
  const source = fs.readFileSync(fleetFile, "utf8");
124
- if (!/dockerHost\(\s*"(tcp|http|https):\/\//.test(source)) {
125
- throw new Error(`${path.basename(fleetFile)} deploys through a local Docker socket, which this tool cannot ` +
126
- `run: the fleet would look for its worker on localhost, which inside the executor ` +
127
- `container is the container itself. Point the host at a remote engine ` +
128
- `(dockerHost tcp://…:2376 with tls), or run this trio with gradle on the host.`);
328
+ const language = fleetLanguage(path.basename(fleetFile));
329
+ const syntax = HOST_SYNTAX[language];
330
+ const remoteHost = remoteDockerHost(language);
331
+ const all = hostSpans(source, language);
332
+ if (all.length === 0) {
333
+ throw new Error(`${path.basename(fleetFile)} has no host this tool can read, so it cannot tell which ` +
334
+ "Docker engine the fleet deploys on; regenerate it");
129
335
  }
336
+ // A host with an address deploys nothing, whichever engine it names.
337
+ const deploying = all.filter((h) => !syntax.option("address").test(h));
338
+ const dockerHost = syntax.option("dockerHost");
339
+ const literal = new RegExp(`${dockerHost.source}["']`);
340
+ if (deploying.some((h) => dockerHost.test(h) && !literal.test(h))) {
341
+ throw new Error(`${path.basename(fleetFile)} names a dockerHost that is not a string literal, so this ` +
342
+ 'tool cannot tell whether it is remote; write it as a literal ("tcp://…:2376")');
343
+ }
344
+ if (deploying.some((h) => opaqueHost(h, language))) {
345
+ throw new Error(`${path.basename(fleetFile)} builds a host's options from a variable or a spread, so ` +
346
+ "this tool cannot tell whether it names a dockerHost; write the host's options " +
347
+ 'inline, with dockerHost (if any) as a string literal ("tcp://…:2376")');
348
+ }
349
+ const remote = deploying.some((h) => remoteHost.test(h));
350
+ const local = deploying.some((h) => !remoteHost.test(h));
351
+ return { remote, local };
130
352
  }
131
353
  /**
132
354
  * The image the run happens in.
@@ -138,9 +360,45 @@ export function assertRemoteDaemon(fleetFile) {
138
360
  export function resolveExecutorImage(explicit) {
139
361
  return (explicit || process.env.SKYRAMP_EXECUTOR_IMAGE || EXECUTOR_DOCKER_IMAGE);
140
362
  }
141
- /** javac, then the JUnit console runner, both against the image's classpath. */
142
- export function buildCommand(entryClass, overrideJar, platform = "linux") {
363
+ /**
364
+ * Java: javac, then the JUnit console runner, both against the image's classpath. Python,
365
+ * TypeScript and JavaScript: the fleet script, run from the work directory so its imports
366
+ * of the load file resolve beside it -- after installing a branch-built SDK package over
367
+ * the image's own, when one is mounted.
368
+ */
369
+ export function buildCommand(entryClass, overrideJar, platform = "linux", overridePackage) {
143
370
  const l = LAYOUT[platform];
371
+ const language = fleetLanguage(entryClass);
372
+ if (language !== "java") {
373
+ const entry = workPath(l, entryClass);
374
+ if (platform === "windows") {
375
+ const run = {
376
+ python: "python.exe",
377
+ typescript: "tsx.cmd",
378
+ javascript: "node.exe",
379
+ }[language];
380
+ const script = [
381
+ "$ErrorActionPreference = 'Stop'",
382
+ ...(overridePackage
383
+ ? installPackage(language, overridePackage, l, platform)
384
+ : []),
385
+ `& ${run} ${entry}`,
386
+ "exit $LASTEXITCODE",
387
+ ].join("\n");
388
+ return {
389
+ argv: ["powershell", "-NoLogo", "-NonInteractive", "-Command", script],
390
+ script,
391
+ };
392
+ }
393
+ const run = { python: "python3", typescript: "tsx", javascript: "node" }[language];
394
+ const script = [
395
+ ...(overridePackage
396
+ ? ["set -e", ...installPackage(language, overridePackage, l, platform)]
397
+ : []),
398
+ `${run} ${entry}`,
399
+ ].join("\n");
400
+ return { argv: ["sh", "-c", script], script };
401
+ }
144
402
  const sep = l.classpathSep;
145
403
  const classes = workPath(l, "build", "classes");
146
404
  const junitRunnerGlob = `${l.imageLibDir}/junit-platform-console-standalone-*.jar`;
@@ -189,11 +447,88 @@ export function buildCommand(entryClass, overrideJar, platform = "linux") {
189
447
  ].join("\n");
190
448
  return { argv: ["sh", "-c", script], script };
191
449
  }
192
- /** The lines the fleet printed, which is what a reader wants out of the JUnit noise. */
450
+ /**
451
+ * Installs a branch-built SDK in place of the image's own, keeping the image's native
452
+ * library. pip reinstalls over the image's package and leaves the library, which it did not
453
+ * install, in place. npm installs into a directory of its own put first on NODE_PATH: on a
454
+ * Windows container npm cannot replace a package directory that is part of the image.
455
+ */
456
+ /** The image already carries @playwright/test, so its peer dependency is not fetched again. */
457
+ const NPM_INSTALL_FLAGS = "--prefer-offline --no-audit --no-fund --legacy-peer-deps";
458
+ /** Single-quotes a value for PowerShell, doubling any embedded quote. */
459
+ function psQuote(value) {
460
+ return `'${value.replace(/'/g, "''")}'`;
461
+ }
462
+ /** Single-quotes a value for POSIX sh, closing and reopening the quote around any `'`. */
463
+ function shQuote(value) {
464
+ return `'${value.replace(/'/g, "'\\''")}'`;
465
+ }
466
+ function installPackage(language, pkg, l, platform) {
467
+ if (platform === "windows") {
468
+ const check = "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }";
469
+ if (language === "python") {
470
+ return [
471
+ "& python.exe -m pip install -q --no-deps --force-reinstall " +
472
+ `--disable-pip-version-check ${psQuote(pkg)}`,
473
+ check,
474
+ ];
475
+ }
476
+ const modules = `${l.npmOverride}/node_modules`;
477
+ const lib = `${modules}/@skyramp/skyramp/lib`;
478
+ return [
479
+ `& npm.cmd install --prefix ${psQuote(l.npmOverride)} ${NPM_INSTALL_FLAGS} ${psQuote(pkg)}`,
480
+ check,
481
+ `New-Item -ItemType Directory -Force ${psQuote(lib)} | Out-Null`,
482
+ `Copy-Item ${psQuote(l.nativeLib)} ${psQuote(`${lib}/`)} -Force`,
483
+ `$env:NODE_PATH = ${psQuote(`${modules};`)} + $env:NODE_PATH`,
484
+ ];
485
+ }
486
+ if (language === "python") {
487
+ return [
488
+ `python3 -m pip install -q --no-deps --force-reinstall ${shQuote(pkg)}`,
489
+ ];
490
+ }
491
+ const modules = `${l.npmOverride}/node_modules`;
492
+ return [
493
+ `npm install --prefix ${shQuote(l.npmOverride)} ${NPM_INSTALL_FLAGS} ${shQuote(pkg)}`,
494
+ `mkdir -p ${shQuote(`${modules}/@skyramp/skyramp/lib`)} && ` +
495
+ `ln -sf ${shQuote(l.nativeLib)} ${shQuote(`${modules}/@skyramp/skyramp/lib/`)}`,
496
+ `export NODE_PATH="${modules}:$NODE_PATH"`,
497
+ ];
498
+ }
499
+ /**
500
+ * The lines the fleet printed, which is what a reader wants out of the JUnit noise: the
501
+ * one-line digest, or the report blocks (name in column one, continuation lines indented).
502
+ */
193
503
  export function extractReport(output) {
194
- const keep = /^\s*(verdict|counts|rate|latency|vitals|per-region|step|action|generator|host error|warning):/;
195
- const lines = output.split("\n").filter((l) => keep.test(l));
196
- return lines.map((l) => l.trim()).join("\n");
504
+ const digest = /^\s*(verdict|counts|rate|latency|vitals|per-region|step|action|generator|host error|warning):/;
505
+ const block = /^(RUN|VERDICT|LOAD|BACKEND|FRONTEND|SLOWEST|REQUESTS|STEPS|TREND|REGIONS|HOSTS|OVER TIME|HEALTH|LIMITS|WARNINGS)\s/;
506
+ const kept = [];
507
+ let inBlock = false;
508
+ for (const line of output.split("\n")) {
509
+ if (block.test(line) || (inBlock && /^ {10}\s*\S/.test(line))) {
510
+ inBlock = true;
511
+ kept.push(line.trimEnd());
512
+ continue;
513
+ }
514
+ inBlock = false;
515
+ if (digest.test(line))
516
+ kept.push(line.trim());
517
+ }
518
+ return kept.join("\n");
519
+ }
520
+ /**
521
+ * The report the library rendered itself, carried in the structured result as `report`.
522
+ * Preferred over the stdout filter: it is the complete text, not whatever survived the
523
+ * JUnit runner's console, and it is the same text every SDK prints.
524
+ */
525
+ export function embeddedReport(result) {
526
+ if (result && typeof result === "object" && "report" in result) {
527
+ const report = result.report;
528
+ if (typeof report === "string" && report.trim() !== "")
529
+ return report.trimEnd();
530
+ }
531
+ return undefined;
197
532
  }
198
533
  /**
199
534
  * Where a path inside the container lands on the host.
@@ -231,6 +566,13 @@ function mount(source, target, readOnly = false) {
231
566
  ReadOnly: readOnly,
232
567
  };
233
568
  }
569
+ /** The file's name goes into the executor's quoted shell command. */
570
+ function assertCommandSafeName(file) {
571
+ if (/['"`$\r\n]/.test(path.basename(file))) {
572
+ throw new Error(`${path.basename(file)}: rename the file without quotes, $ or backticks; its name ` +
573
+ "goes into the executor's shell command");
574
+ }
575
+ }
234
576
  /**
235
577
  * Mounts a host file by its directory, and returns where the file lands in the container.
236
578
  *
@@ -252,12 +594,16 @@ export async function executeFleet(options, docker = new Docker()) {
252
594
  throw new Error(`directory not found: ${directory}`);
253
595
  }
254
596
  const entryClass = findEntryClass(directory, options.entryClass);
255
- const fleetFile = path.join(directory, `${entryClass}.java`);
597
+ const fleetFile = path.join(directory, fleetFileName(entryClass));
256
598
  if (!fs.existsSync(fleetFile)) {
257
599
  throw new Error(`entry class ${entryClass} has no file at ${fleetFile}`);
258
600
  }
259
601
  assertNoPinnedImage(fleetFile);
260
- assertRemoteDaemon(fleetFile);
602
+ const daemons = fleetDaemons(fleetFile);
603
+ if (daemons.remote && !options.certsDir) {
604
+ throw new Error(`certsDir is required: ${path.basename(fleetFile)} deploys on a remote Docker engine, ` +
605
+ "which is mTLS. Pass the directory holding ca.pem, cert.pem and key.pem.");
606
+ }
261
607
  const credentials = options.credentials
262
608
  ? path.resolve(options.credentials)
263
609
  : path.join(directory, "credentials.csv");
@@ -267,6 +613,17 @@ export async function executeFleet(options, docker = new Docker()) {
267
613
  const executorImage = resolveExecutorImage(options.executorImage);
268
614
  const platform = await resolvePlatform(docker, options.platform);
269
615
  const l = LAYOUT[platform];
616
+ if (daemons.local && platform !== "linux") {
617
+ throw new Error(`${path.basename(fleetFile)} deploys on the local Docker daemon, which this tool ` +
618
+ `reaches through ${DOCKER_SOCKET}: a ${platform} daemon has no such socket. Point ` +
619
+ "the host at the engine (dockerHost tcp://…:2376 with tls).");
620
+ }
621
+ if (daemons.local && !options.localDocker) {
622
+ throw new Error(`${path.basename(fleetFile)} deploys on the local Docker daemon. The run would mount ` +
623
+ `${DOCKER_SOCKET} into the executor, which gives the fleet code root-level access ` +
624
+ "to this host. Pass localDocker: true to allow it, or point the hosts at a remote " +
625
+ "engine (dockerHost tcp://…:2376 with tls).");
626
+ }
270
627
  const mounts = [mount(directory, l.work)];
271
628
  const derived = {
272
629
  SKYRAMP_FLEET_REPORT_JSON: workPath(l, "build", "fleet-result.json"),
@@ -277,18 +634,45 @@ export async function executeFleet(options, docker = new Docker()) {
277
634
  ? workPath(l, path.basename(credentials))
278
635
  : mountFileDir(mounts, l, credentials, l.poolDir);
279
636
  }
280
- const certs = path.resolve(options.certsDir);
281
- if (!fs.existsSync(certs)) {
282
- throw new Error(`certificate directory not found: ${certs}`);
637
+ if (options.certsDir) {
638
+ const certs = path.resolve(options.certsDir);
639
+ if (!fs.existsSync(certs)) {
640
+ throw new Error(`certificate directory not found: ${certs}`);
641
+ }
642
+ mounts.push(mount(certs, l.certs, true));
643
+ derived.SKYRAMP_DOCKER_CERTS = l.certs;
644
+ }
645
+ if (daemons.local) {
646
+ // The fleet deploys its worker through the daemon's own socket. The worker's port is
647
+ // then published on the Docker host, which from inside this container is
648
+ // host.docker.internal and not localhost: SKYRAMP_IN_DOCKER tells the library so.
649
+ mounts.push(mount(DOCKER_SOCKET, DOCKER_SOCKET));
650
+ derived.SKYRAMP_IN_DOCKER = "true";
283
651
  }
284
- mounts.push(mount(certs, l.certs, true));
285
- derived.SKYRAMP_DOCKER_CERTS = l.certs;
652
+ const language = fleetLanguage(entryClass);
286
653
  let overrideJar;
654
+ if (options.libraryJar && language !== "java") {
655
+ throw new Error(`libraryJar applies to a Java fleet; for ${entryClass} pass the SDK package as libraryPackage`);
656
+ }
657
+ let overridePackage;
658
+ if (options.libraryPackage) {
659
+ const pkg = path.resolve(options.libraryPackage);
660
+ if (!packageLanguages(pkg).includes(language)) {
661
+ throw new Error(`libraryPackage ${path.basename(pkg)} does not fit ${entryClass}: python takes a pip ` +
662
+ ".whl or .tar.gz, typescript and javascript an npm .tgz, java a jar through libraryJar");
663
+ }
664
+ if (!fs.existsSync(pkg)) {
665
+ throw new Error(`library package not found: ${pkg}`);
666
+ }
667
+ assertCommandSafeName(pkg);
668
+ overridePackage = mountFileDir(mounts, l, pkg, l.overrideDir);
669
+ }
287
670
  if (options.libraryJar) {
288
671
  const jar = path.resolve(options.libraryJar);
289
672
  if (!fs.existsSync(jar)) {
290
673
  throw new Error(`library jar not found: ${jar}`);
291
674
  }
675
+ assertCommandSafeName(jar);
292
676
  overrideJar = mountFileDir(mounts, l, jar, l.overrideDir);
293
677
  }
294
678
  // One rule for every variable the tool derives: the caller's env wins.
@@ -316,33 +700,80 @@ export async function executeFleet(options, docker = new Docker()) {
316
700
  `set executorImage (or SKYRAMP_EXECUTOR_IMAGE) to one built for it.`, { cause: err });
317
701
  }
318
702
  }
703
+ if (options.signal?.aborted) {
704
+ throw new Error("fleet run cancelled before its container started");
705
+ }
319
706
  const container = await docker.createContainer({
320
707
  Image: executorImage,
321
- Cmd: buildCommand(entryClass, overrideJar, platform).argv,
708
+ Cmd: buildCommand(entryClass, overrideJar, platform, overridePackage).argv,
322
709
  WorkingDir: l.work,
323
710
  Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
324
711
  Tty: false,
325
712
  platform: imagePlatform,
326
- HostConfig: { Mounts: mounts, AutoRemove: false },
713
+ HostConfig: {
714
+ Mounts: mounts,
715
+ AutoRemove: false,
716
+ // Docker Desktop resolves the name by itself; a Linux engine needs it mapped.
717
+ ...(daemons.local
718
+ ? { ExtraHosts: ["host.docker.internal:host-gateway"] }
719
+ : {}),
720
+ },
327
721
  });
328
722
  let output = "";
329
723
  let exitCode = null;
330
724
  let timedOut = false;
725
+ let cancelled = false;
726
+ let escalate;
727
+ // Written to as the run streams, so a noisy multi-hour fleet never buffers its full
728
+ // output in memory; only a fixed-size tail is kept here, below.
729
+ let outputLog = path.join(directory, "build", "fleet-output.log");
730
+ let logStream;
331
731
  try {
332
- const sink = new Writable({
333
- write(chunk, _enc, cb) {
334
- output += stripVTControlCharacters(chunk.toString());
335
- cb();
336
- },
732
+ fs.mkdirSync(path.dirname(outputLog), { recursive: true });
733
+ logStream = fs.createWriteStream(outputLog);
734
+ }
735
+ catch (e) {
736
+ logger.warning(`could not open the fleet output log at ${outputLog}: ${e}`);
737
+ outputLog = undefined;
738
+ }
739
+ logStream?.on("error", (e) => {
740
+ logger.warning(`could not write the fleet output to ${outputLog}: ${e}`);
741
+ outputLog = undefined;
742
+ });
743
+ const onAbort = () => {
744
+ if (cancelled)
745
+ return;
746
+ cancelled = true;
747
+ interruptContainer(container, platform).catch((e) => {
748
+ logger.warning(`could not interrupt the fleet run gracefully: ${e}`);
337
749
  });
750
+ escalate = setTimeout(() => {
751
+ container.kill().catch(() => {
752
+ // Already gone: it stopped within the grace.
753
+ });
754
+ }, CANCEL_GRACE_MS);
755
+ };
756
+ const emit = (line) => {
757
+ logStream?.write(line);
758
+ output += line;
759
+ if (output.length > MAX_BUFFERED_OUTPUT)
760
+ output = output.slice(-MAX_BUFFERED_OUTPUT);
761
+ options.onOutput?.(line);
762
+ };
763
+ const stdout = lineSink(emit);
764
+ const stderr = lineSink(emit);
765
+ try {
338
766
  const stream = await container.attach({
339
767
  stream: true,
340
768
  stdout: true,
341
769
  stderr: true,
342
770
  });
343
- container.modem.demuxStream(stream, sink, sink);
771
+ container.modem.demuxStream(stream, stdout.sink, stderr.sink);
344
772
  const timeoutMs = (options.timeoutSec ?? 600) * 1000;
345
773
  await container.start();
774
+ if (options.signal?.aborted)
775
+ onAbort();
776
+ options.signal?.addEventListener("abort", onAbort, { once: true });
346
777
  const timer = setTimeout(() => {
347
778
  timedOut = true;
348
779
  container.kill().catch((e) => {
@@ -359,9 +790,15 @@ export async function executeFleet(options, docker = new Docker()) {
359
790
  }
360
791
  }
361
792
  finally {
793
+ options.signal?.removeEventListener("abort", onAbort);
794
+ clearTimeout(escalate);
795
+ stdout.flush();
796
+ stderr.flush();
362
797
  await container.remove({ force: true }).catch((e) => {
363
798
  logger.error(`could not remove the fleet container: ${e}`);
364
799
  });
800
+ if (logStream)
801
+ await new Promise((resolve) => logStream.end(() => resolve()));
365
802
  }
366
803
  let result;
367
804
  if (jsonPath === undefined) {
@@ -378,14 +815,16 @@ export async function executeFleet(options, docker = new Docker()) {
378
815
  }
379
816
  }
380
817
  return {
381
- passed: exitCode === 0 && !timedOut,
818
+ passed: exitCode === 0 && !timedOut && !cancelled,
382
819
  executorImage,
383
820
  platform,
384
821
  entryClass,
385
822
  exitCode,
386
- report: extractReport(output),
823
+ report: embeddedReport(result) ?? extractReport(output),
387
824
  output,
388
825
  result,
389
826
  timedOut: timedOut || undefined,
827
+ cancelled: cancelled || undefined,
828
+ outputLog,
390
829
  };
391
830
  }