@mastra/docker 0.8.0-alpha.2 → 0.9.0-alpha.0
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 +99 -0
- package/dist/index.cjs +922 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +917 -5
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +25 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/template/build-session.d.ts +22 -0
- package/dist/template/build-session.d.ts.map +1 -0
- package/dist/template/dockerfile.d.ts +81 -0
- package/dist/template/dockerfile.d.ts.map +1 -0
- package/dist/template/index.d.ts +4 -0
- package/dist/template/index.d.ts.map +1 -0
- package/dist/template/repo-template.d.ts +115 -0
- package/dist/template/repo-template.d.ts.map +1 -0
- package/dist/template/template.d.ts +174 -0
- package/dist/template/template.d.ts.map +1 -0
- package/package.json +10 -8
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { posix } from "path";
|
|
2
|
-
import { isDeepStrictEqual } from "util";
|
|
2
|
+
import { isDeepStrictEqual, promisify } from "util";
|
|
3
3
|
import { MastraSandbox, ProcessHandle, SandboxAbortError, SandboxError, SandboxNotReadyError, SandboxProcessManager, validateSandboxFileMode } from "@mastra/core/workspace";
|
|
4
4
|
import Docker from "dockerode";
|
|
5
5
|
import { pack } from "tar-stream";
|
|
6
|
-
import { randomUUID } from "crypto";
|
|
6
|
+
import { createHash, randomUUID } from "crypto";
|
|
7
|
+
import posixPath from "path/posix";
|
|
8
|
+
import { Server, ServerCredentials } from "@grpc/grpc-js";
|
|
9
|
+
import { execFile } from "child_process";
|
|
7
10
|
//#region src/sandbox/process-manager.ts
|
|
8
11
|
/**
|
|
9
12
|
* Docker Process Manager
|
|
@@ -427,7 +430,10 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
|
|
|
427
430
|
_container = null;
|
|
428
431
|
/** Configuration */
|
|
429
432
|
_containerName;
|
|
433
|
+
/** Image the container boots from; rewritten by a template resolution on `start()`. */
|
|
430
434
|
_image;
|
|
435
|
+
_templateSpec;
|
|
436
|
+
_workingDirectoryWasSet;
|
|
431
437
|
_command;
|
|
432
438
|
_env;
|
|
433
439
|
_volumes;
|
|
@@ -468,7 +474,10 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
|
|
|
468
474
|
});
|
|
469
475
|
this.id = options.id ?? this._generateId();
|
|
470
476
|
this._containerName = sanitizeContainerName(options.name ?? this.id);
|
|
477
|
+
if (options.image !== void 0 && options.template !== void 0) throw new TypeError("DockerSandbox: `image` and `template` are mutually exclusive");
|
|
478
|
+
this._templateSpec = options.template;
|
|
471
479
|
this._image = options.image ?? "node:22-slim";
|
|
480
|
+
this._workingDirectoryWasSet = options.workingDirectory !== void 0 || options.workingDir !== void 0;
|
|
472
481
|
this._command = options.command ?? ["sleep", "infinity"];
|
|
473
482
|
this._env = options.env ?? {};
|
|
474
483
|
this._volumes = options.volumes ?? {};
|
|
@@ -544,11 +553,14 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
|
|
|
544
553
|
this.logger.debug(`${LOG_PREFIX} Container exists but not running (${actualState}), starting...`);
|
|
545
554
|
await this._container.start();
|
|
546
555
|
}
|
|
556
|
+
const reconnectedWorkingDir = info.Config?.WorkingDir;
|
|
557
|
+
if (!this._workingDirectoryWasSet && reconnectedWorkingDir) this.setWorkingDirectory(reconnectedWorkingDir);
|
|
547
558
|
this.processes.setContainer(this._container);
|
|
548
559
|
this.logger.debug(`${LOG_PREFIX} Reconnected to container ${existing.Id}`);
|
|
549
560
|
return;
|
|
550
561
|
}
|
|
551
562
|
this._warnOnPrivilegedHardeningConflict(this._privileged);
|
|
563
|
+
await this._resolveTemplate();
|
|
552
564
|
await this._ensureImage();
|
|
553
565
|
const envArray = Object.entries(this._env).map(([k, v]) => `${k}=${v}`);
|
|
554
566
|
const binds = Object.entries(this._volumes).map(([host, container]) => `${host}:${container}`);
|
|
@@ -803,6 +815,23 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
|
|
|
803
815
|
}
|
|
804
816
|
}
|
|
805
817
|
/**
|
|
818
|
+
* Resolve the `template` option (if any) into the image to boot from,
|
|
819
|
+
* building it when no cached image exists. Runs on every `start()` that
|
|
820
|
+
* creates a container, so a resolver-form template re-resolves each time.
|
|
821
|
+
* Adopts the template's workdir unless the sandbox was given one explicitly.
|
|
822
|
+
*/
|
|
823
|
+
async _resolveTemplate() {
|
|
824
|
+
if (!this._templateSpec) return;
|
|
825
|
+
const template = typeof this._templateSpec === "function" ? await this._templateSpec() : this._templateSpec;
|
|
826
|
+
const result = await template.build({ docker: this._docker });
|
|
827
|
+
if (result.status !== "ready") throw new SandboxError(`Docker template build failed: ${result.error ?? "unknown error"}`, "START_FAILED", {
|
|
828
|
+
templateId: result.templateId,
|
|
829
|
+
reason: "template_build_failed"
|
|
830
|
+
});
|
|
831
|
+
this._image = result.templateId;
|
|
832
|
+
if (!this._workingDirectoryWasSet && template.workdir !== void 0) this.setWorkingDirectory(template.workdir);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
806
835
|
* Ensure the Docker image is available locally. Pulls if needed.
|
|
807
836
|
*/
|
|
808
837
|
async _ensureImage() {
|
|
@@ -810,7 +839,7 @@ var DockerSandbox = class DockerSandbox extends MastraSandbox {
|
|
|
810
839
|
await this._docker.getImage(this._image).inspect();
|
|
811
840
|
this.logger.debug(`${LOG_PREFIX} Image ${this._image} available locally`);
|
|
812
841
|
} catch (error) {
|
|
813
|
-
if (!isImageNotFoundError(error)) throw error;
|
|
842
|
+
if (!isImageNotFoundError$1(error)) throw error;
|
|
814
843
|
this.logger.debug(`${LOG_PREFIX} Pulling image ${this._image}...`);
|
|
815
844
|
try {
|
|
816
845
|
const stream = await this._docker.pull(this._image);
|
|
@@ -846,7 +875,7 @@ function isContainerNotFoundError(error) {
|
|
|
846
875
|
}
|
|
847
876
|
return false;
|
|
848
877
|
}
|
|
849
|
-
function isImageNotFoundError(error) {
|
|
878
|
+
function isImageNotFoundError$1(error) {
|
|
850
879
|
if (error instanceof Error) return error.message.toLowerCase().includes("no such image");
|
|
851
880
|
return false;
|
|
852
881
|
}
|
|
@@ -1146,6 +1175,889 @@ const dockerSandboxProvider = {
|
|
|
1146
1175
|
createSandbox: (config) => new DockerSandbox(config)
|
|
1147
1176
|
};
|
|
1148
1177
|
//#endregion
|
|
1149
|
-
|
|
1178
|
+
//#region src/template/build-session.ts
|
|
1179
|
+
/**
|
|
1180
|
+
* BuildKit session that serves build secrets to the daemon.
|
|
1181
|
+
*
|
|
1182
|
+
* `docker build` with BuildKit (`version=2`) can attach a long-lived gRPC
|
|
1183
|
+
* "session" that the daemon calls back into for things it needs mid-build.
|
|
1184
|
+
* `RUN --mount=type=secret,id=X` is resolved by calling
|
|
1185
|
+
* `/moby.buildkit.secrets.v1.Secrets/GetSecret` on that session, so the value
|
|
1186
|
+
* exists only in the tmpfs mount for the duration of that one RUN — never as a
|
|
1187
|
+
* build arg, layer, history entry, or cache metadata.
|
|
1188
|
+
*
|
|
1189
|
+
* dockerode's own session helper only registers the registry-auth service and
|
|
1190
|
+
* overwrites any `session` id passed to `buildImage`, so this module dials the
|
|
1191
|
+
* hijacked `/session` endpoint itself and registers both services.
|
|
1192
|
+
*/
|
|
1193
|
+
const SECRETS_GET_METHOD = "/moby.buildkit.secrets.v1.Secrets/GetSecret";
|
|
1194
|
+
const AUTH_CREDENTIALS_METHOD = "/moby.filesync.v1.Auth/Credentials";
|
|
1195
|
+
/**
|
|
1196
|
+
* Open a session that answers `GetSecret` from `secrets`. The returned id must
|
|
1197
|
+
* be sent as the `session` query parameter of the build request.
|
|
1198
|
+
*/
|
|
1199
|
+
function openBuildSession(docker, secrets) {
|
|
1200
|
+
const id = randomUUID();
|
|
1201
|
+
return new Promise((resolve, reject) => {
|
|
1202
|
+
docker.modem.dial({
|
|
1203
|
+
method: "POST",
|
|
1204
|
+
path: "/session",
|
|
1205
|
+
hijack: true,
|
|
1206
|
+
headers: {
|
|
1207
|
+
Upgrade: "h2c",
|
|
1208
|
+
"X-Docker-Expose-Session-Uuid": id,
|
|
1209
|
+
"X-Docker-Expose-Session-Name": "mastra-docker-template",
|
|
1210
|
+
"X-Docker-Expose-Session-Grpc-Method": [SECRETS_GET_METHOD, AUTH_CREDENTIALS_METHOD]
|
|
1211
|
+
},
|
|
1212
|
+
statusCodes: {
|
|
1213
|
+
200: true,
|
|
1214
|
+
500: "server error"
|
|
1215
|
+
}
|
|
1216
|
+
}, (err, socket) => {
|
|
1217
|
+
if (err) {
|
|
1218
|
+
reject(err);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const server = new Server();
|
|
1222
|
+
server.createConnectionInjector(ServerCredentials.createInsecure()).injectConnection(socket);
|
|
1223
|
+
server.addService(secretsService, { GetSecret(call, callback) {
|
|
1224
|
+
const value = Object.hasOwn(secrets, call.request.id) ? secrets[call.request.id] : void 0;
|
|
1225
|
+
if (value === void 0) {
|
|
1226
|
+
callback(Object.assign(/* @__PURE__ */ new Error(`no build secret registered with id '${call.request.id}'`), { code: 5 }));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
callback(null, { data: Buffer.from(value, "utf8") });
|
|
1230
|
+
} });
|
|
1231
|
+
server.addService(authService, { Credentials(_call, callback) {
|
|
1232
|
+
callback(null, {});
|
|
1233
|
+
} });
|
|
1234
|
+
resolve({
|
|
1235
|
+
id,
|
|
1236
|
+
close() {
|
|
1237
|
+
server.forceShutdown();
|
|
1238
|
+
socket.end();
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/** @internal exported for tests */
|
|
1245
|
+
function decodeGetSecretRequest(buffer) {
|
|
1246
|
+
let offset = 0;
|
|
1247
|
+
let id = "";
|
|
1248
|
+
const readVarint = () => {
|
|
1249
|
+
let result = 0;
|
|
1250
|
+
let shift = 0;
|
|
1251
|
+
for (;;) {
|
|
1252
|
+
if (offset >= buffer.length) throw new Error("truncated varint");
|
|
1253
|
+
const byte = buffer[offset++];
|
|
1254
|
+
result += (byte & 127) * 2 ** shift;
|
|
1255
|
+
if ((byte & 128) === 0) return result;
|
|
1256
|
+
shift += 7;
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
while (offset < buffer.length) {
|
|
1260
|
+
const key = readVarint();
|
|
1261
|
+
const field = Math.floor(key / 8);
|
|
1262
|
+
const wireType = key % 8;
|
|
1263
|
+
switch (wireType) {
|
|
1264
|
+
case 0:
|
|
1265
|
+
readVarint();
|
|
1266
|
+
break;
|
|
1267
|
+
case 1:
|
|
1268
|
+
offset += 8;
|
|
1269
|
+
break;
|
|
1270
|
+
case 5:
|
|
1271
|
+
offset += 4;
|
|
1272
|
+
break;
|
|
1273
|
+
case 2: {
|
|
1274
|
+
const length = readVarint();
|
|
1275
|
+
const value = buffer.subarray(offset, offset + length);
|
|
1276
|
+
offset += length;
|
|
1277
|
+
if (field === 1) id = value.toString("utf8");
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
default: throw new Error(`unsupported protobuf wire type ${wireType}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return { id };
|
|
1284
|
+
}
|
|
1285
|
+
/** @internal exported for tests */
|
|
1286
|
+
function encodeGetSecretResponse(response) {
|
|
1287
|
+
return Buffer.concat([
|
|
1288
|
+
Buffer.from([10]),
|
|
1289
|
+
encodeVarint(response.data.length),
|
|
1290
|
+
response.data
|
|
1291
|
+
]);
|
|
1292
|
+
}
|
|
1293
|
+
function encodeVarint(value) {
|
|
1294
|
+
const bytes = [];
|
|
1295
|
+
let remaining = value;
|
|
1296
|
+
while (remaining >= 128) {
|
|
1297
|
+
bytes.push(remaining % 128 | 128);
|
|
1298
|
+
remaining = Math.floor(remaining / 128);
|
|
1299
|
+
}
|
|
1300
|
+
bytes.push(remaining);
|
|
1301
|
+
return Buffer.from(bytes);
|
|
1302
|
+
}
|
|
1303
|
+
const secretsService = { GetSecret: {
|
|
1304
|
+
path: SECRETS_GET_METHOD,
|
|
1305
|
+
requestStream: false,
|
|
1306
|
+
responseStream: false,
|
|
1307
|
+
requestSerialize: () => {
|
|
1308
|
+
throw new Error("server does not serialize requests");
|
|
1309
|
+
},
|
|
1310
|
+
requestDeserialize: decodeGetSecretRequest,
|
|
1311
|
+
responseSerialize: encodeGetSecretResponse,
|
|
1312
|
+
responseDeserialize: () => {
|
|
1313
|
+
throw new Error("server does not deserialize responses");
|
|
1314
|
+
}
|
|
1315
|
+
} };
|
|
1316
|
+
const authService = { Credentials: {
|
|
1317
|
+
path: AUTH_CREDENTIALS_METHOD,
|
|
1318
|
+
requestStream: false,
|
|
1319
|
+
responseStream: false,
|
|
1320
|
+
requestSerialize: () => {
|
|
1321
|
+
throw new Error("server does not serialize requests");
|
|
1322
|
+
},
|
|
1323
|
+
requestDeserialize: () => ({}),
|
|
1324
|
+
responseSerialize: () => Buffer.alloc(0),
|
|
1325
|
+
responseDeserialize: () => {
|
|
1326
|
+
throw new Error("server does not deserialize responses");
|
|
1327
|
+
}
|
|
1328
|
+
} };
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region src/template/dockerfile.ts
|
|
1331
|
+
/**
|
|
1332
|
+
* Pure Dockerfile synthesis and content-addressed identity for DockerTemplate.
|
|
1333
|
+
*
|
|
1334
|
+
* A `DockerTemplate` records an ordered list of operations (setWorkdir, setEnvs,
|
|
1335
|
+
* runCmd, runWithSecrets, aptInstall, pipInstall, npmInstall) over a base image. This module
|
|
1336
|
+
* turns that ordered list into a deterministic Dockerfile string and a stable
|
|
1337
|
+
* content hash. It performs no I/O, so it is fully unit-testable without a
|
|
1338
|
+
* Docker daemon.
|
|
1339
|
+
*/
|
|
1340
|
+
function toCommandList(command) {
|
|
1341
|
+
return Array.isArray(command) ? command : [command];
|
|
1342
|
+
}
|
|
1343
|
+
function sortedEntries(record) {
|
|
1344
|
+
return Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1345
|
+
}
|
|
1346
|
+
function renderEnvLine(envs) {
|
|
1347
|
+
const pairs = sortedEntries(envs);
|
|
1348
|
+
if (pairs.length === 0) return void 0;
|
|
1349
|
+
return `ENV ${pairs.map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ")}`;
|
|
1350
|
+
}
|
|
1351
|
+
function renderAptInstall(packages, options) {
|
|
1352
|
+
const flags = [];
|
|
1353
|
+
if (options?.noInstallRecommends) flags.push("--no-install-recommends");
|
|
1354
|
+
if (options?.fixMissing) flags.push("--fix-missing");
|
|
1355
|
+
return `RUN apt-get update && apt-get install -y ${flags.length > 0 ? `${flags.join(" ")} ` : ""}${toCommandList(packages).join(" ")} && rm -rf /var/lib/apt/lists/*`;
|
|
1356
|
+
}
|
|
1357
|
+
function renderPipInstall(packages, options) {
|
|
1358
|
+
return `RUN pip install${options?.g === false ? " --user" : ""} ${packages === void 0 ? "." : toCommandList(packages).join(" ")}`;
|
|
1359
|
+
}
|
|
1360
|
+
function renderNpmInstall(packages, options) {
|
|
1361
|
+
const flags = [];
|
|
1362
|
+
if (options?.g) flags.push("-g");
|
|
1363
|
+
if (packages === void 0) {
|
|
1364
|
+
if (options?.dev) flags.push("--include=dev");
|
|
1365
|
+
return `RUN npm install${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`;
|
|
1366
|
+
}
|
|
1367
|
+
return `RUN npm install${flags.length > 0 ? ` ${flags.join(" ")}` : ""} ${toCommandList(packages).join(" ")}`;
|
|
1368
|
+
}
|
|
1369
|
+
function secretStageName(index) {
|
|
1370
|
+
return `mastra-secret-${index}`;
|
|
1371
|
+
}
|
|
1372
|
+
function mainStageName(index) {
|
|
1373
|
+
return `mastra-main-${index}`;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Render a deterministic Dockerfile from a template definition.
|
|
1377
|
+
*
|
|
1378
|
+
* Operations accumulate in a chain of "main" stages. Every `runWithSecrets`
|
|
1379
|
+
* operation forks a throwaway stage from the main stage as it stands at that
|
|
1380
|
+
* point (so it sees earlier WORKDIR/ENV/installs) and runs the command with
|
|
1381
|
+
* each secret exposed through a BuildKit secret mount, exported into the
|
|
1382
|
+
* command's environment for that single RUN. The next main stage then starts
|
|
1383
|
+
* from the same pre-secret snapshot and `COPY --from`s only the declared
|
|
1384
|
+
* output path. Secret mounts are tmpfs-backed and never part of a layer, its
|
|
1385
|
+
* history, or the build cache; the throwaway stage additionally keeps anything
|
|
1386
|
+
* else the command wrote (caches, logs) out of the template image.
|
|
1387
|
+
*/
|
|
1388
|
+
/**
|
|
1389
|
+
* `RUN --mount=type=secret,id=X,mode=0444 ... export X="$(cat /run/secrets/X)" && <command>`.
|
|
1390
|
+
* Reading the file into a shell variable keeps the value visible only to this
|
|
1391
|
+
* RUN's process tree, without depending on the newer `env=` mount option.
|
|
1392
|
+
* `mode=0444` because BuildKit's default is `0400 root`, which a base image
|
|
1393
|
+
* with a non-root `USER` cannot read; world-readable within the RUN is no
|
|
1394
|
+
* wider than the exported variable already is.
|
|
1395
|
+
*/
|
|
1396
|
+
function renderSecretRun(command, secrets) {
|
|
1397
|
+
const names = [...secrets].sort();
|
|
1398
|
+
const mounts = names.map((name) => `--mount=type=secret,id=${name},mode=0444`);
|
|
1399
|
+
const exports = names.map((name) => `${name}="$(cat /run/secrets/${name})"`);
|
|
1400
|
+
const prefix = names.length > 0 ? [`export ${exports.join(" ")}`] : [];
|
|
1401
|
+
return `RUN ${[...mounts, ""].join(" ")}${[...prefix, ...toCommandList(command)].join(" && ")}`;
|
|
1402
|
+
}
|
|
1403
|
+
function synthesizeDockerfile(definition) {
|
|
1404
|
+
const lines = [];
|
|
1405
|
+
let mainIndex = 0;
|
|
1406
|
+
const openMain = (from) => {
|
|
1407
|
+
lines.push(`FROM ${from} AS ${mainStageName(mainIndex)}`);
|
|
1408
|
+
};
|
|
1409
|
+
openMain(definition.baseImage);
|
|
1410
|
+
definition.operations.forEach((operation, index) => {
|
|
1411
|
+
switch (operation.method) {
|
|
1412
|
+
case "setWorkdir":
|
|
1413
|
+
lines.push(`WORKDIR ${operation.args[0]}`);
|
|
1414
|
+
break;
|
|
1415
|
+
case "setEnvs": {
|
|
1416
|
+
const line = renderEnvLine(operation.args[0]);
|
|
1417
|
+
if (line) lines.push(line);
|
|
1418
|
+
break;
|
|
1419
|
+
}
|
|
1420
|
+
case "runCmd":
|
|
1421
|
+
lines.push(`RUN ${toCommandList(operation.args[0]).join(" && ")}`);
|
|
1422
|
+
break;
|
|
1423
|
+
case "runWithSecrets": {
|
|
1424
|
+
const [command, { secrets, output }] = operation.args;
|
|
1425
|
+
const snapshot = mainStageName(mainIndex);
|
|
1426
|
+
const stage = secretStageName(index);
|
|
1427
|
+
lines.push(`FROM ${snapshot} AS ${stage}`, renderSecretRun(command, secrets));
|
|
1428
|
+
mainIndex += 1;
|
|
1429
|
+
openMain(snapshot);
|
|
1430
|
+
lines.push(`COPY --from=${stage} ${output} ${output}`);
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
case "aptInstall":
|
|
1434
|
+
lines.push(renderAptInstall(operation.args[0], operation.args[1]));
|
|
1435
|
+
break;
|
|
1436
|
+
case "pipInstall":
|
|
1437
|
+
lines.push(renderPipInstall(operation.args[0], operation.args[1]));
|
|
1438
|
+
break;
|
|
1439
|
+
case "npmInstall":
|
|
1440
|
+
lines.push(renderNpmInstall(operation.args[0], operation.args[1]));
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
return `${lines.join("\n")}\n`;
|
|
1445
|
+
}
|
|
1446
|
+
/** Names of every secret referenced by `runWithSecrets` operations, deduplicated and sorted. */
|
|
1447
|
+
function secretNames(definition) {
|
|
1448
|
+
const names = /* @__PURE__ */ new Set();
|
|
1449
|
+
for (const operation of definition.operations) if (operation.method === "runWithSecrets") for (const name of operation.args[1].secrets) names.add(name);
|
|
1450
|
+
return [...names].sort();
|
|
1451
|
+
}
|
|
1452
|
+
/** Prefix for template image tags built locally. */
|
|
1453
|
+
const TEMPLATE_IMAGE_REPO = "mastra-template";
|
|
1454
|
+
function canonicalOperation(operation) {
|
|
1455
|
+
switch (operation.method) {
|
|
1456
|
+
case "setEnvs": return {
|
|
1457
|
+
method: "setEnvs",
|
|
1458
|
+
args: [sortedEntries(operation.args[0])]
|
|
1459
|
+
};
|
|
1460
|
+
case "runWithSecrets": {
|
|
1461
|
+
const [command, { secrets, output }] = operation.args;
|
|
1462
|
+
return {
|
|
1463
|
+
method: "runWithSecrets",
|
|
1464
|
+
args: [command, {
|
|
1465
|
+
secrets: [...secrets].sort(),
|
|
1466
|
+
output
|
|
1467
|
+
}]
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
default: return operation;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Stable content hash over the base image and ordered operations. Env records
|
|
1475
|
+
* and secret name lists are canonicalized so insertion order does not change
|
|
1476
|
+
* the identity. Secret *values* never enter the definition, so the same
|
|
1477
|
+
* definition built with different credentials resolves to the same image tag.
|
|
1478
|
+
*/
|
|
1479
|
+
function templateIdentity(definition) {
|
|
1480
|
+
const canonical = JSON.stringify({
|
|
1481
|
+
schemaVersion: 1,
|
|
1482
|
+
baseImage: definition.baseImage,
|
|
1483
|
+
operations: definition.operations.map(canonicalOperation)
|
|
1484
|
+
});
|
|
1485
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
|
|
1486
|
+
}
|
|
1487
|
+
/** Full `mastra-template:<hash>` tag for a definition. */
|
|
1488
|
+
function templateImageTag(definition) {
|
|
1489
|
+
return `${TEMPLATE_IMAGE_REPO}:${templateIdentity(definition)}`;
|
|
1490
|
+
}
|
|
1491
|
+
//#endregion
|
|
1492
|
+
//#region src/template/template.ts
|
|
1493
|
+
/**
|
|
1494
|
+
* DockerTemplate — a reusable prepared baseline for the local Docker sandbox.
|
|
1495
|
+
*
|
|
1496
|
+
* Prepare an environment once (base image + ordered setup commands + env +
|
|
1497
|
+
* package installs), `build()` it into a content-addressed local image, then
|
|
1498
|
+
* spawn multiple disposable `DockerSandbox`es from that image. Each sandbox is a
|
|
1499
|
+
* fresh container with its own writable layer over the shared read-only image,
|
|
1500
|
+
* so their filesystems are independent. The built image's lifecycle is
|
|
1501
|
+
* controlled by `dispose()`, independent of any sandbox's `destroy()`.
|
|
1502
|
+
*
|
|
1503
|
+
* Unlike `docker commit` (which cannot capture mounted volumes and skips layer
|
|
1504
|
+
* caching), the baseline is produced by synthesizing a Dockerfile and running
|
|
1505
|
+
* `docker build`, so repo/setup content is baked into reproducible, cached
|
|
1506
|
+
* image layers.
|
|
1507
|
+
*
|
|
1508
|
+
* @example Prepare once, spawn many
|
|
1509
|
+
* ```typescript
|
|
1510
|
+
* import { DockerTemplate } from '@mastra/docker';
|
|
1511
|
+
*
|
|
1512
|
+
* const template = new DockerTemplate({ baseImage: 'node:22-slim' })
|
|
1513
|
+
* .runCmd('git clone --depth=1 https://example.com/repo /workspace/app')
|
|
1514
|
+
* .setWorkdir('/workspace/app')
|
|
1515
|
+
* .runCmd('npm ci');
|
|
1516
|
+
*
|
|
1517
|
+
* const result = await template.build();
|
|
1518
|
+
* if (result.status !== 'ready') throw new Error(result.error);
|
|
1519
|
+
*
|
|
1520
|
+
* const a = await template.createSandbox();
|
|
1521
|
+
* const b = await template.createSandbox(); // independent writable filesystem
|
|
1522
|
+
* ```
|
|
1523
|
+
*/
|
|
1524
|
+
const MAX_OPERATIONS = 256;
|
|
1525
|
+
const MAX_STRING_LENGTH = 32 * 1024;
|
|
1526
|
+
const MAX_COLLECTION_ITEMS = 512;
|
|
1527
|
+
/**
|
|
1528
|
+
* Immutable, chainable builder + build/lifecycle for a local Docker template.
|
|
1529
|
+
* Operation methods return a new instance (like the platform `Template()`
|
|
1530
|
+
* builder); `build`/`createSandbox`/`dispose` operate against the daemon.
|
|
1531
|
+
*/
|
|
1532
|
+
var DockerTemplate = class DockerTemplate {
|
|
1533
|
+
#baseImage;
|
|
1534
|
+
#operations;
|
|
1535
|
+
#dockerOptions;
|
|
1536
|
+
#secrets;
|
|
1537
|
+
#docker;
|
|
1538
|
+
#built = false;
|
|
1539
|
+
#inFlight;
|
|
1540
|
+
constructor(options = {}, state) {
|
|
1541
|
+
this.#baseImage = state ? state.baseImage : validateString(options.baseImage ?? "node:22-slim", "baseImage");
|
|
1542
|
+
this.#operations = state?.operations ?? [];
|
|
1543
|
+
this.#dockerOptions = options.dockerOptions;
|
|
1544
|
+
this.#secrets = options.secrets;
|
|
1545
|
+
}
|
|
1546
|
+
#clone(next) {
|
|
1547
|
+
return new DockerTemplate({
|
|
1548
|
+
dockerOptions: this.#dockerOptions,
|
|
1549
|
+
secrets: this.#secrets
|
|
1550
|
+
}, {
|
|
1551
|
+
baseImage: next.baseImage ?? this.#baseImage,
|
|
1552
|
+
operations: next.operations ?? this.#operations
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
#append(operation) {
|
|
1556
|
+
if (this.#operations.length >= MAX_OPERATIONS) throw new RangeError(`Docker template cannot contain more than ${MAX_OPERATIONS} operations`);
|
|
1557
|
+
return this.#clone({ operations: [...this.#operations, operation] });
|
|
1558
|
+
}
|
|
1559
|
+
/** Set the base image. */
|
|
1560
|
+
from(image) {
|
|
1561
|
+
return this.#clone({ baseImage: validateString(image, "image") });
|
|
1562
|
+
}
|
|
1563
|
+
/** Set the working directory for subsequent steps and the runtime container. */
|
|
1564
|
+
setWorkdir(path) {
|
|
1565
|
+
return this.#append({
|
|
1566
|
+
method: "setWorkdir",
|
|
1567
|
+
args: [validateString(path, "path")]
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Set environment variables. They are baked into the image via `ENV` and
|
|
1572
|
+
* participate in the template identity, so never put secrets here — use
|
|
1573
|
+
* {@link runWithSecrets} for anything that must not persist in the image.
|
|
1574
|
+
*/
|
|
1575
|
+
setEnvs(envs) {
|
|
1576
|
+
return this.#append({
|
|
1577
|
+
method: "setEnvs",
|
|
1578
|
+
args: [validateStringRecord(envs, "envs")]
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
/** Run a command (or `&&`-joined list of commands) as a build step. */
|
|
1582
|
+
runCmd(command) {
|
|
1583
|
+
return this.#append({
|
|
1584
|
+
method: "runCmd",
|
|
1585
|
+
args: [validateStringOrStrings(command, "command")]
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Run a command that needs build-time secrets, without persisting them.
|
|
1590
|
+
*
|
|
1591
|
+
* The command runs in a throwaway build stage forked from the template as it
|
|
1592
|
+
* stands at that point, so earlier WORKDIR/ENV/installs apply and later
|
|
1593
|
+
* steps see the copied `output`. The named secrets are resolved when
|
|
1594
|
+
* `build()` runs — from `build({ secrets })`, then the template's `secrets`
|
|
1595
|
+
* option, then `process.env` — and exposed to the command as environment
|
|
1596
|
+
* variables. Values reach the daemon through a BuildKit secret mount
|
|
1597
|
+
* (`RUN --mount=type=secret`), which is tmpfs-backed and scoped to that one
|
|
1598
|
+
* RUN — never a build arg, layer, history entry, or cache metadata. Only
|
|
1599
|
+
* `output` is copied into the template image. Builds that use secrets
|
|
1600
|
+
* require a BuildKit-capable daemon (Docker 20.10+).
|
|
1601
|
+
*/
|
|
1602
|
+
runWithSecrets(command, options) {
|
|
1603
|
+
if (!Array.isArray(options.secrets)) throw new TypeError("secrets must be an array of strings");
|
|
1604
|
+
if (options.secrets.length > MAX_COLLECTION_ITEMS) throw new RangeError(`secrets cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
|
|
1605
|
+
const secrets = options.secrets.map((name) => {
|
|
1606
|
+
if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new TypeError(`secrets must be environment variable names, got ${JSON.stringify(name)}`);
|
|
1607
|
+
return name;
|
|
1608
|
+
});
|
|
1609
|
+
const output = validateString(options.output, "output");
|
|
1610
|
+
if (!output.startsWith("/")) throw new TypeError("output must be an absolute path");
|
|
1611
|
+
return this.#append({
|
|
1612
|
+
method: "runWithSecrets",
|
|
1613
|
+
args: [validateStringOrStrings(command, "command"), {
|
|
1614
|
+
secrets,
|
|
1615
|
+
output
|
|
1616
|
+
}]
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
/** Install apt packages. */
|
|
1620
|
+
aptInstall(packages, options) {
|
|
1621
|
+
return this.#append({
|
|
1622
|
+
method: "aptInstall",
|
|
1623
|
+
args: [validateStringOrStrings(packages, "packages"), options]
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
/** Install pip packages (or `pip install .` for the current workdir when omitted). */
|
|
1627
|
+
pipInstall(packages, options) {
|
|
1628
|
+
const validated = packages === void 0 ? void 0 : validateStringOrStrings(packages, "packages");
|
|
1629
|
+
return this.#append({
|
|
1630
|
+
method: "pipInstall",
|
|
1631
|
+
args: [validated, options]
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/** Install npm packages (or run `npm install` for the current workdir when omitted). */
|
|
1635
|
+
npmInstall(packages, options) {
|
|
1636
|
+
const validated = packages === void 0 ? void 0 : validateStringOrStrings(packages, "packages");
|
|
1637
|
+
return this.#append({
|
|
1638
|
+
method: "npmInstall",
|
|
1639
|
+
args: [validated, options]
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
/** The resolved definition (base image + ordered operations). */
|
|
1643
|
+
get definition() {
|
|
1644
|
+
return {
|
|
1645
|
+
baseImage: this.#baseImage,
|
|
1646
|
+
operations: this.#operations
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
/** The synthesized Dockerfile for this template. */
|
|
1650
|
+
get dockerfile() {
|
|
1651
|
+
return synthesizeDockerfile(this.definition);
|
|
1652
|
+
}
|
|
1653
|
+
/** The content-addressed local image tag this template resolves to. */
|
|
1654
|
+
get templateId() {
|
|
1655
|
+
return templateImageTag(this.definition);
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* The working directory the built image ends up with, i.e. the last
|
|
1659
|
+
* `setWorkdir` in the chain (resolved against earlier ones when relative).
|
|
1660
|
+
* `undefined` when the template never sets one, in which case the base
|
|
1661
|
+
* image's `WORKDIR` (or the sandbox default) applies.
|
|
1662
|
+
*/
|
|
1663
|
+
get workdir() {
|
|
1664
|
+
let current;
|
|
1665
|
+
for (const op of this.#operations) {
|
|
1666
|
+
if (op.method !== "setWorkdir") continue;
|
|
1667
|
+
const next = op.args[0];
|
|
1668
|
+
current = next.startsWith("/") || current === void 0 ? next : posixPath.join(current, next);
|
|
1669
|
+
}
|
|
1670
|
+
return current;
|
|
1671
|
+
}
|
|
1672
|
+
#getDocker() {
|
|
1673
|
+
if (!this.#docker) this.#docker = new Docker(this.#dockerOptions);
|
|
1674
|
+
return this.#docker;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Build (or reuse) the template's image. Idempotent: if an image with the
|
|
1678
|
+
* computed tag already exists locally and `force` is not set, returns
|
|
1679
|
+
* `ready` without rebuilding. Otherwise synthesizes a Dockerfile, runs
|
|
1680
|
+
* `docker build`, and surfaces any build-step failure as `status: 'failed'`.
|
|
1681
|
+
*
|
|
1682
|
+
* @throws if a secret named by `runWithSecrets` cannot be resolved from
|
|
1683
|
+
* `options.secrets`, the template's `secrets` option, or `process.env`.
|
|
1684
|
+
*/
|
|
1685
|
+
async build(options = {}) {
|
|
1686
|
+
if (!options.force && options.secrets === void 0 && options.docker === void 0 && this.#inFlight) return this.#inFlight;
|
|
1687
|
+
const run = (this.#inFlight?.catch(() => void 0) ?? Promise.resolve()).then(() => this.#build(options));
|
|
1688
|
+
this.#inFlight = run;
|
|
1689
|
+
run.finally(() => {
|
|
1690
|
+
if (this.#inFlight === run) this.#inFlight = void 0;
|
|
1691
|
+
}).catch(() => void 0);
|
|
1692
|
+
return run;
|
|
1693
|
+
}
|
|
1694
|
+
async #build(options) {
|
|
1695
|
+
const docker = options.docker ?? this.#getDocker();
|
|
1696
|
+
const tag = this.templateId;
|
|
1697
|
+
if (!options.force) try {
|
|
1698
|
+
await docker.getImage(tag).inspect();
|
|
1699
|
+
this.#built = true;
|
|
1700
|
+
return {
|
|
1701
|
+
status: "ready",
|
|
1702
|
+
templateId: tag
|
|
1703
|
+
};
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
if (!isImageNotFoundError(error)) throw error;
|
|
1706
|
+
}
|
|
1707
|
+
const secrets = await this.#resolveSecrets(options.secrets);
|
|
1708
|
+
const context = pack();
|
|
1709
|
+
context.entry({ name: "Dockerfile" }, this.dockerfile);
|
|
1710
|
+
context.finalize();
|
|
1711
|
+
try {
|
|
1712
|
+
const nocache = options.force === true;
|
|
1713
|
+
if (secrets) {
|
|
1714
|
+
const { stream, session } = await this.#buildWithSecrets(docker, context, tag, secrets, nocache);
|
|
1715
|
+
try {
|
|
1716
|
+
await this.#followBuild(docker, stream);
|
|
1717
|
+
} finally {
|
|
1718
|
+
session.close();
|
|
1719
|
+
}
|
|
1720
|
+
} else await this.#followBuild(docker, await docker.buildImage(context, {
|
|
1721
|
+
t: tag,
|
|
1722
|
+
nocache
|
|
1723
|
+
}));
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
this.#built = false;
|
|
1726
|
+
return {
|
|
1727
|
+
status: "failed",
|
|
1728
|
+
templateId: tag,
|
|
1729
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
this.#built = true;
|
|
1733
|
+
return {
|
|
1734
|
+
status: "ready",
|
|
1735
|
+
templateId: tag
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
async #resolveSecrets(override) {
|
|
1739
|
+
const names = secretNames(this.definition);
|
|
1740
|
+
if (names.length === 0) return void 0;
|
|
1741
|
+
const fromBuild = await readSecrets(override);
|
|
1742
|
+
const fromTemplate = await readSecrets(this.#secrets);
|
|
1743
|
+
const resolved = {};
|
|
1744
|
+
for (const name of names) {
|
|
1745
|
+
const value = fromBuild[name] ?? fromTemplate[name] ?? process.env[name];
|
|
1746
|
+
if (value === void 0) throw new Error(`Docker template secret ${name} was not provided (secrets option) and is not set in the environment`);
|
|
1747
|
+
resolved[name] = value;
|
|
1748
|
+
}
|
|
1749
|
+
return resolved;
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* BuildKit build with a session serving the secret mounts. Dials `/build`
|
|
1753
|
+
* directly because `docker.buildImage` replaces any session id with its own
|
|
1754
|
+
* auth-only session when `version` is `'2'`.
|
|
1755
|
+
*/
|
|
1756
|
+
async #buildWithSecrets(docker, context, tag, secrets, nocache) {
|
|
1757
|
+
const session = await openBuildSession(docker, secrets);
|
|
1758
|
+
let stream;
|
|
1759
|
+
try {
|
|
1760
|
+
stream = await new Promise((resolve, reject) => {
|
|
1761
|
+
docker.modem.dial({
|
|
1762
|
+
path: "/build?",
|
|
1763
|
+
method: "POST",
|
|
1764
|
+
file: context,
|
|
1765
|
+
options: {
|
|
1766
|
+
t: tag,
|
|
1767
|
+
version: "2",
|
|
1768
|
+
session: session.id,
|
|
1769
|
+
nocache
|
|
1770
|
+
},
|
|
1771
|
+
isStream: true,
|
|
1772
|
+
statusCodes: {
|
|
1773
|
+
200: true,
|
|
1774
|
+
500: "server error"
|
|
1775
|
+
}
|
|
1776
|
+
}, (err, data) => err ? reject(err) : resolve(data));
|
|
1777
|
+
});
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
session.close();
|
|
1780
|
+
throw error;
|
|
1781
|
+
}
|
|
1782
|
+
return {
|
|
1783
|
+
stream,
|
|
1784
|
+
session
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
#followBuild(docker, stream) {
|
|
1788
|
+
return new Promise((resolve, reject) => {
|
|
1789
|
+
docker.modem.followProgress(stream, (err, output) => {
|
|
1790
|
+
if (err) {
|
|
1791
|
+
reject(err);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const failure = output?.find((entry) => entry && (entry.error !== void 0 || entry.errorDetail !== void 0));
|
|
1795
|
+
if (failure) {
|
|
1796
|
+
const detail = failure.errorDetail;
|
|
1797
|
+
reject(new Error(String(detail?.message ?? failure.error ?? "docker build failed")));
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
resolve();
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Create a `DockerSandbox` bound to the built image. Lazily builds the
|
|
1806
|
+
* template if it has not been built yet. Each call returns a fresh sandbox
|
|
1807
|
+
* with an independent writable layer. Invocation-specific `env`/config passed
|
|
1808
|
+
* via `options` reaches only the container and is never baked into the image.
|
|
1809
|
+
*
|
|
1810
|
+
* The sandbox's working directory follows the template's last `setWorkdir`
|
|
1811
|
+
* unless `options.workingDirectory` overrides it, so relative paths resolve
|
|
1812
|
+
* against the same directory the image was prepared in.
|
|
1813
|
+
*
|
|
1814
|
+
* @throws if the (lazy) build fails.
|
|
1815
|
+
*/
|
|
1816
|
+
async createSandbox(options = {}) {
|
|
1817
|
+
if (!this.#built) {
|
|
1818
|
+
const result = await this.build();
|
|
1819
|
+
if (result.status !== "ready") throw new Error(`Docker template build failed: ${result.error ?? "unknown error"}`);
|
|
1820
|
+
}
|
|
1821
|
+
const workingDirectory = options.workingDirectory ?? options.workingDir ?? this.workdir;
|
|
1822
|
+
return new DockerSandbox({
|
|
1823
|
+
...options,
|
|
1824
|
+
...workingDirectory !== void 0 && { workingDirectory },
|
|
1825
|
+
template: this,
|
|
1826
|
+
dockerOptions: options.dockerOptions ?? this.#dockerOptions
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Remove the built image (`docker rmi`). Tolerant of an already-removed
|
|
1831
|
+
* image. Independent of any sandbox created from this template, but the
|
|
1832
|
+
* daemon refuses to remove an image that a container (running or stopped)
|
|
1833
|
+
* still references, so destroy those sandboxes first.
|
|
1834
|
+
*/
|
|
1835
|
+
async dispose() {
|
|
1836
|
+
const docker = this.#getDocker();
|
|
1837
|
+
try {
|
|
1838
|
+
await docker.getImage(this.templateId).remove();
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
if (!isImageNotFoundError(error)) throw error;
|
|
1841
|
+
}
|
|
1842
|
+
this.#built = false;
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
function validateString(value, name) {
|
|
1846
|
+
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
|
|
1847
|
+
if (value.length === 0) throw new TypeError(`${name} must not be empty`);
|
|
1848
|
+
if (value.length > MAX_STRING_LENGTH) throw new RangeError(`${name} cannot exceed ${MAX_STRING_LENGTH} characters`);
|
|
1849
|
+
return value;
|
|
1850
|
+
}
|
|
1851
|
+
function validateStringOrStrings(value, name) {
|
|
1852
|
+
if (typeof value === "string") return validateString(value, name);
|
|
1853
|
+
if (!Array.isArray(value)) throw new TypeError(`${name} must be a string or an array of strings`);
|
|
1854
|
+
if (value.length === 0) throw new TypeError(`${name} must not be empty`);
|
|
1855
|
+
if (value.length > MAX_COLLECTION_ITEMS) throw new RangeError(`${name} cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
|
|
1856
|
+
return value.map((item, index) => validateString(item, `${name}[${index}]`));
|
|
1857
|
+
}
|
|
1858
|
+
function validateStringRecord(value, name) {
|
|
1859
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${name} must be a plain object`);
|
|
1860
|
+
const entries = Object.entries(value);
|
|
1861
|
+
if (entries.length > MAX_COLLECTION_ITEMS) throw new RangeError(`${name} cannot contain more than ${MAX_COLLECTION_ITEMS} items`);
|
|
1862
|
+
return Object.fromEntries(entries.map(([key, item]) => {
|
|
1863
|
+
if (typeof item !== "string") throw new TypeError(`${name}.${key} must be a string`);
|
|
1864
|
+
return [validateString(key, `${name} key`), item];
|
|
1865
|
+
}));
|
|
1866
|
+
}
|
|
1867
|
+
async function readSecrets(source) {
|
|
1868
|
+
if (!source) return {};
|
|
1869
|
+
return typeof source === "function" ? await source() : source;
|
|
1870
|
+
}
|
|
1871
|
+
function isImageNotFoundError(error) {
|
|
1872
|
+
if (error instanceof Error) {
|
|
1873
|
+
const msg = error.message.toLowerCase();
|
|
1874
|
+
return msg.includes("no such image") || msg.includes("404");
|
|
1875
|
+
}
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region ../../packages/_internals/workspace/dist/index.js
|
|
1880
|
+
/**
|
|
1881
|
+
* Setup completion marker shared by repo templates and their consumers.
|
|
1882
|
+
*
|
|
1883
|
+
* A repo template writes this file beside the checkout as its last build
|
|
1884
|
+
* step, so it exists only in images where every setup command succeeded. Its
|
|
1885
|
+
* content is a digest of the setup commands the image ran, letting a sandbox
|
|
1886
|
+
* booted from the image tell whether the setup it is about to run already
|
|
1887
|
+
* happened. Relative to the template's build cwd, which is also the runtime
|
|
1888
|
+
* working directory the repo was cloned into.
|
|
1889
|
+
*/
|
|
1890
|
+
const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
|
|
1891
|
+
/** Blank entries never become build steps, so they never count toward the digest either. */
|
|
1892
|
+
function normalizeSetupCommands(setupCommand) {
|
|
1893
|
+
return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
|
|
1894
|
+
}
|
|
1895
|
+
/** The marker content for a setup command list: `sha256:<hex>` over the commands joined by newlines. */
|
|
1896
|
+
function setupMarkerContent(setupCommand) {
|
|
1897
|
+
return `sha256:${createHash("sha256").update(normalizeSetupCommands(setupCommand).join("\n")).digest("hex")}`;
|
|
1898
|
+
}
|
|
1899
|
+
/** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
|
|
1900
|
+
function setupMarkerCommand(content) {
|
|
1901
|
+
return `mkdir -p "$(dirname "${SETUP_MARKER_PATH}")" && printf '%s' '${content}' > "${SETUP_MARKER_PATH}"`;
|
|
1902
|
+
}
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/template/repo-template.ts
|
|
1905
|
+
/**
|
|
1906
|
+
* createDockerRepoTemplate — a repo checkout plus setup commands as a
|
|
1907
|
+
* reusable, content-addressed local image, with the same contract as the
|
|
1908
|
+
* E2B and platform repo templates (`getRepositoryAccess`, `setupCommand`,
|
|
1909
|
+
* `buildEnv`, `workingDirectory`).
|
|
1910
|
+
*
|
|
1911
|
+
* Returns a template RESOLVER for `DockerSandbox`'s `template` option rather
|
|
1912
|
+
* than a fixed template: each resolution calls `getRepositoryAccess`, looks up
|
|
1913
|
+
* the current head of `ref` (`git ls-remote`, no clone) and pins that sha into
|
|
1914
|
+
* the template identity. A moved branch therefore yields a fresh image on the
|
|
1915
|
+
* next new sandbox, and an unmoved one reuses the cached image. When the head
|
|
1916
|
+
* cannot be resolved the resolver rejects: an unpinned clone cached under a
|
|
1917
|
+
* stable tag would otherwise serve stale repository state forever.
|
|
1918
|
+
*
|
|
1919
|
+
* The clone runs in a throwaway build stage; the credential is passed by value
|
|
1920
|
+
* to that stage only and never enters the template identity or the image.
|
|
1921
|
+
*
|
|
1922
|
+
* @example
|
|
1923
|
+
* ```typescript
|
|
1924
|
+
* const sandbox = new DockerSandbox({
|
|
1925
|
+
* template: createDockerRepoTemplate({
|
|
1926
|
+
* getRepositoryAccess: async () => ({ cloneUrl: 'https://github.com/acme/app.git' }),
|
|
1927
|
+
* setupCommand: ['npm ci', 'npm run build'],
|
|
1928
|
+
* }),
|
|
1929
|
+
* });
|
|
1930
|
+
* ```
|
|
1931
|
+
*/
|
|
1932
|
+
const execFileAsync = promisify(execFile);
|
|
1933
|
+
/** Env var the build's clone reads the credential from (see `cloneFull`). */
|
|
1934
|
+
const BUILD_TOKEN_ENV = "GH_TOKEN";
|
|
1935
|
+
const DEFAULT_BASE_IMAGE = "node:22-slim";
|
|
1936
|
+
const DEFAULT_WORKING_DIRECTORY = "/workspace";
|
|
1937
|
+
const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
1938
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
1939
|
+
const CLONE_URL_ALLOWED_CHARS = /^[a-z0-9:/._-]+$/i;
|
|
1940
|
+
const CLONE_URL_HOST_PATTERN = /^[a-z0-9.-]+$/i;
|
|
1941
|
+
const CLONE_URL_SEGMENT_PATTERN = /^[\w.-]+$/;
|
|
1942
|
+
/** Refs interpolate into shell too; git ref names are already restricted, so allowlist tightly. */
|
|
1943
|
+
const REF_PATTERN = /^[\w./-]+$/;
|
|
1944
|
+
function createDockerRepoTemplate(options) {
|
|
1945
|
+
if (!options.getRepositoryAccess) return void 0;
|
|
1946
|
+
if (options.ref !== void 0 && !REF_PATTERN.test(options.ref)) throw new Error(`Invalid ref '${options.ref}': expected a git ref name`);
|
|
1947
|
+
const workingDirectory = trimTrailingSlashes(options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY);
|
|
1948
|
+
if (!workingDirectory.startsWith("/")) throw new Error(`workingDirectory must be an absolute path, got '${options.workingDirectory}'`);
|
|
1949
|
+
return () => resolveRepoTemplate(options, workingDirectory);
|
|
1950
|
+
}
|
|
1951
|
+
async function resolveRepoTemplate(options, workingDirectory) {
|
|
1952
|
+
const access = await options.getRepositoryAccess();
|
|
1953
|
+
const cloneUrl = access?.cloneUrl;
|
|
1954
|
+
if (!cloneUrl) throw new Error("Repo template has no clone URL: repository access returned none.");
|
|
1955
|
+
assertCloneUrl(cloneUrl);
|
|
1956
|
+
const token = access?.authorization?.token;
|
|
1957
|
+
const buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv() : options.buildEnv;
|
|
1958
|
+
const sha = await resolveHead(cloneUrl, options.ref, token);
|
|
1959
|
+
if (!sha) throw new Error(`Could not resolve ${options.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
|
|
1960
|
+
return buildRepoTemplate({
|
|
1961
|
+
cloneUrl,
|
|
1962
|
+
sha,
|
|
1963
|
+
token,
|
|
1964
|
+
buildEnv,
|
|
1965
|
+
setupCommand: options.setupCommand,
|
|
1966
|
+
workingDirectory,
|
|
1967
|
+
baseImage: options.baseImage,
|
|
1968
|
+
dockerOptions: options.dockerOptions
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Pure assembly of the template from already-resolved inputs. Exported for
|
|
1973
|
+
* tests so the Dockerfile can be asserted without a network head lookup.
|
|
1974
|
+
* @internal
|
|
1975
|
+
*/
|
|
1976
|
+
function buildRepoTemplate(inputs) {
|
|
1977
|
+
const { cloneUrl, sha, token, buildEnv } = inputs;
|
|
1978
|
+
const destination = `${trimTrailingSlashes(inputs.workingDirectory)}/${repoDirName(cloneUrl)}`;
|
|
1979
|
+
let template = new DockerTemplate({
|
|
1980
|
+
baseImage: inputs.baseImage ?? DEFAULT_BASE_IMAGE,
|
|
1981
|
+
dockerOptions: inputs.dockerOptions,
|
|
1982
|
+
...token ? { secrets: { [BUILD_TOKEN_ENV]: token } } : {}
|
|
1983
|
+
});
|
|
1984
|
+
if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
|
|
1985
|
+
if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
|
|
1986
|
+
const tokenEnv = token ? BUILD_TOKEN_ENV : void 0;
|
|
1987
|
+
const clone = [cloneFull({
|
|
1988
|
+
cloneUrl,
|
|
1989
|
+
destination,
|
|
1990
|
+
tokenEnv
|
|
1991
|
+
}), `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
|
|
1992
|
+
template = template.runWithSecrets(clone, {
|
|
1993
|
+
secrets: tokenEnv ? [tokenEnv] : [],
|
|
1994
|
+
output: destination
|
|
1995
|
+
}).setWorkdir(destination);
|
|
1996
|
+
const setupCommands = normalizeSetupCommands(inputs.setupCommand);
|
|
1997
|
+
for (const command of setupCommands) template = template.runCmd(command);
|
|
1998
|
+
if (setupCommands.length > 0) template = template.runCmd(setupMarkerCommand(setupMarkerContent(setupCommands)));
|
|
1999
|
+
return template;
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Resolve `ref` (or the default branch) to a commit sha with `git ls-remote`
|
|
2003
|
+
* on the host, without cloning. A full sha is returned as is. Any failure
|
|
2004
|
+
* yields undefined; the caller decides how to surface it.
|
|
2005
|
+
* @internal exported for tests.
|
|
2006
|
+
*/
|
|
2007
|
+
async function resolveHead(cloneUrl, ref, token) {
|
|
2008
|
+
if (ref && FULL_SHA_PATTERN.test(ref)) return ref.toLowerCase();
|
|
2009
|
+
try {
|
|
2010
|
+
const authEnv = token ? {
|
|
2011
|
+
GIT_CONFIG_COUNT: "1",
|
|
2012
|
+
GIT_CONFIG_KEY_0: "http.extraheader",
|
|
2013
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`
|
|
2014
|
+
} : {};
|
|
2015
|
+
const { stdout } = await execFileAsync("git", [
|
|
2016
|
+
"ls-remote",
|
|
2017
|
+
"--",
|
|
2018
|
+
cloneUrl,
|
|
2019
|
+
ref ?? "HEAD"
|
|
2020
|
+
], {
|
|
2021
|
+
timeout: 1e4,
|
|
2022
|
+
env: {
|
|
2023
|
+
...process.env,
|
|
2024
|
+
...authEnv,
|
|
2025
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
const lines = stdout.split("\n").filter(Boolean).map((line) => line.split(" "));
|
|
2029
|
+
const sha = (lines.find(([, name]) => name?.endsWith("^{}")) ?? lines[0])?.[0]?.trim();
|
|
2030
|
+
return sha && SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
|
|
2031
|
+
} catch {
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
function cloneFull({ cloneUrl, destination, tokenEnv }) {
|
|
2036
|
+
return `git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
|
|
2037
|
+
}
|
|
2038
|
+
function assertCloneUrl(cloneUrl) {
|
|
2039
|
+
if (cloneUrl.length > 2048 || !CLONE_URL_ALLOWED_CHARS.test(cloneUrl)) throw new Error(`Invalid cloneUrl '${cloneUrl}': expected an https URL with a plain host and path`);
|
|
2040
|
+
let url;
|
|
2041
|
+
try {
|
|
2042
|
+
url = new URL(cloneUrl);
|
|
2043
|
+
} catch {
|
|
2044
|
+
throw new Error(`Invalid cloneUrl '${cloneUrl}': not a URL`);
|
|
2045
|
+
}
|
|
2046
|
+
const segments = url.pathname.split("/").slice(1);
|
|
2047
|
+
if (!(url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && CLONE_URL_HOST_PATTERN.test(url.hostname) && segments.length > 0 && segments.every((segment) => CLONE_URL_SEGMENT_PATTERN.test(segment)))) throw new Error(`Invalid cloneUrl '${cloneUrl}': expected an https URL such as https://host/owner/repo.git`);
|
|
2048
|
+
}
|
|
2049
|
+
function repoDirName(cloneUrl) {
|
|
2050
|
+
return (trimTrailingSlashes(cloneUrl).split("/").at(-1) ?? "").replace(/\.git$/i, "").replace(/[^\w.-]/g, "-").replace(/^\.+/, "") || "repo";
|
|
2051
|
+
}
|
|
2052
|
+
function trimTrailingSlashes(path) {
|
|
2053
|
+
let end = path.length;
|
|
2054
|
+
while (end > 1 && path[end - 1] === "/") end--;
|
|
2055
|
+
return path.slice(0, end);
|
|
2056
|
+
}
|
|
2057
|
+
function shellQuote(value) {
|
|
2058
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2059
|
+
}
|
|
2060
|
+
//#endregion
|
|
2061
|
+
export { DockerProcessManager, DockerSandbox, DockerTemplate, TEMPLATE_IMAGE_REPO, createDockerRepoTemplate, dockerSandboxProvider, secretNames, synthesizeDockerfile, templateIdentity, templateImageTag };
|
|
1150
2062
|
|
|
1151
2063
|
//# sourceMappingURL=index.js.map
|