@skyramp/mcp 0.5.0-rc.2 → 0.5.0-rc.4
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/build/index.js +15 -1
- package/build/services/FleetExecutionService.d.ts +87 -16
- package/build/services/FleetExecutionService.js +487 -48
- package/build/services/FleetRunRegistry.d.ts +49 -0
- package/build/services/FleetRunRegistry.js +178 -0
- package/build/services/fleetLimitConfirmation.d.ts +19 -0
- package/build/services/fleetLimitConfirmation.js +126 -0
- package/build/tool-phases.js +5 -0
- package/build/tools/executeFleetTool.d.ts +6 -4
- package/build/tools/executeFleetTool.js +67 -23
- package/build/tools/fleetLifecycleTools.d.ts +20 -0
- package/build/tools/fleetLifecycleTools.js +124 -0
- package/build/tools/generate-tests/generateUILoadRestTool.d.ts +19 -1
- package/build/tools/generate-tests/generateUILoadRestTool.js +316 -50
- package/package.json +2 -2
- package/plugin/skills/setup-testbot-target-sut/SKILL.md +6 -4
- package/plugin/skills/setup-testbot-target-sut/reference/deployed-environment.md +56 -0
|
@@ -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
|
-
/**
|
|
72
|
-
export const
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
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
|
|
82
|
-
if (!
|
|
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
|
-
.
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
326
|
+
export function fleetDaemons(fleetFile) {
|
|
123
327
|
const source = fs.readFileSync(fleetFile, "utf8");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
/**
|
|
142
|
-
|
|
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
|
-
/**
|
|
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
|
|
195
|
-
const
|
|
196
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
}
|