@mastra/docker 0.11.1-alpha.1 → 0.12.0-alpha.2

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 CHANGED
@@ -141,6 +141,43 @@ const template = new DockerTemplate({ secrets: { GITHUB_TOKEN: token } })
141
141
  `dispose()` removes the image; Docker refuses while a container still references
142
142
  it, so destroy the template's sandboxes first.
143
143
 
144
+ #### Build progress
145
+
146
+ Pass `onProgress` to `build()` (or `onTemplateBuildProgress` to `DockerSandbox`)
147
+ to follow a long preparation while it runs:
148
+
149
+ ```typescript
150
+ const result = await template.build({
151
+ onProgress: event => {
152
+ if (event.type === 'step-start') console.log(`> ${event.step.name}${event.step.cached ? ' (cached)' : ''}`);
153
+ if (event.type === 'output') process.stdout.write(redact(event.text));
154
+ },
155
+ maxOutputBytes: 32 * 1024, // default 64 KiB
156
+ });
157
+ if (result.status === 'failed') {
158
+ console.error(result.error, result.failedStep?.name, result.output, result.outputTruncated);
159
+ }
160
+ ```
161
+
162
+ Events are `reused` (the image already exists, nothing is built),
163
+ `build-start` (with `builder: 'legacy' | 'buildkit'`), `step-start`,
164
+ `step-complete` (with `cached` when the builder reports a cache hit), `output`,
165
+ and `build-complete`. Plain builds use the legacy builder, which reports
166
+ `Step N/M` steps; builds with secrets use BuildKit, which reports build
167
+ vertices. The callback contract:
168
+
169
+ - Delivery is synchronous, in arrival order, with no backpressure — keep the
170
+ callback fast and hand heavy work off elsewhere.
171
+ - Errors thrown by the callback are ignored and never fail the build.
172
+ - Concurrent callers sharing a build each receive its events from the moment
173
+ they join (no replay). A caller that aborts stops receiving events; the build
174
+ continues for the remaining callers.
175
+ - Only the most recent `maxOutputBytes` of output are retained, and only to
176
+ populate `output` on failed results; `outputTruncated` reports whether older
177
+ output was dropped.
178
+ - `output` text is raw command output. Commands run with `runWithSecrets` can
179
+ echo secret values into it, so redact before logging or persisting.
180
+
144
181
  #### Repository templates
145
182
 
146
183
  `createDockerRepoTemplate` prepares a repository checkout plus setup commands,
package/dist/index.cjs CHANGED
@@ -607,6 +607,7 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
607
607
  /** Image the container boots from; rewritten by a template resolution on `start()`. */
608
608
  _image;
609
609
  _templateSpec;
610
+ _onTemplateBuildProgress;
610
611
  _workingDirectoryWasSet;
611
612
  _command;
612
613
  _env;
@@ -650,6 +651,7 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
650
651
  this._containerName = sanitizeContainerName(options.name ?? this.id);
651
652
  if (options.image !== void 0 && options.template !== void 0) throw new TypeError("DockerSandbox: `image` and `template` are mutually exclusive");
652
653
  this._templateSpec = options.template;
654
+ this._onTemplateBuildProgress = options.onTemplateBuildProgress;
653
655
  this._image = options.image ?? "node:22-slim";
654
656
  this._workingDirectoryWasSet = options.workingDirectory !== void 0 || options.workingDir !== void 0;
655
657
  this._command = options.command ?? ["sleep", "infinity"];
@@ -1009,7 +1011,8 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
1009
1011
  throwIfAborted(abortSignal, "start Docker sandbox");
1010
1012
  const result = await template.build({
1011
1013
  docker: this._docker,
1012
- abortSignal
1014
+ abortSignal,
1015
+ ...this._onTemplateBuildProgress && { onProgress: this._onTemplateBuildProgress }
1013
1016
  });
1014
1017
  if (result.status !== "ready") throw new _mastra_core_workspace.SandboxError(`Docker template build failed: ${result.error ?? "unknown error"}`, "START_FAILED", {
1015
1018
  templateId: result.templateId,
@@ -1362,6 +1365,287 @@ const dockerSandboxProvider = {
1362
1365
  createSandbox: (config) => new DockerSandbox(config)
1363
1366
  };
1364
1367
  //#endregion
1368
+ //#region src/template/build-progress.ts
1369
+ /**
1370
+ * Build progress for `DockerTemplate.build()`.
1371
+ *
1372
+ * The daemon reports build progress as newline-delimited JSON. The legacy
1373
+ * builder sends plain text (`{stream: "Step 2/5 : RUN ..."}`); BuildKit sends
1374
+ * `{id: 'moby.buildkit.trace', aux: <base64 StatusResponse protobuf>}`. This
1375
+ * module normalizes both into {@link DockerTemplateBuildEvent}s and keeps a
1376
+ * bounded tail of raw output for failure diagnostics.
1377
+ */
1378
+ const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
1379
+ /** Keeps at most `maxBytes` (UTF-8) of the most recent text. */
1380
+ var BoundedTail = class {
1381
+ #maxBytes;
1382
+ #chunks = [];
1383
+ #bytes = 0;
1384
+ #truncated = false;
1385
+ constructor(maxBytes) {
1386
+ this.#maxBytes = maxBytes;
1387
+ }
1388
+ get truncated() {
1389
+ return this.#truncated;
1390
+ }
1391
+ push(text) {
1392
+ if (text.length === 0) return;
1393
+ let chunk = Buffer.from(text, "utf8");
1394
+ if (chunk.length > this.#maxBytes) {
1395
+ chunk = chunk.subarray(chunk.length - this.#maxBytes);
1396
+ this.#chunks = [];
1397
+ this.#bytes = 0;
1398
+ this.#truncated = true;
1399
+ }
1400
+ this.#chunks.push(chunk);
1401
+ this.#bytes += chunk.length;
1402
+ while (this.#bytes > this.#maxBytes) {
1403
+ const head = this.#chunks[0];
1404
+ const excess = this.#bytes - this.#maxBytes;
1405
+ this.#truncated = true;
1406
+ if (head.length <= excess) {
1407
+ this.#chunks.shift();
1408
+ this.#bytes -= head.length;
1409
+ } else {
1410
+ this.#chunks[0] = head.subarray(excess);
1411
+ this.#bytes -= excess;
1412
+ }
1413
+ }
1414
+ }
1415
+ toString() {
1416
+ let text = Buffer.concat(this.#chunks).toString("utf8");
1417
+ if (this.#truncated) text = text.replace(/^\uFFFD+/, "");
1418
+ return text;
1419
+ }
1420
+ };
1421
+ function readFields(buffer) {
1422
+ const fields = [];
1423
+ let offset = 0;
1424
+ const readVarint = () => {
1425
+ let result = 0;
1426
+ let shift = 0;
1427
+ for (;;) {
1428
+ if (offset >= buffer.length) throw new Error("truncated varint");
1429
+ const byte = buffer[offset++];
1430
+ result += (byte & 127) * 2 ** shift;
1431
+ if ((byte & 128) === 0) return result;
1432
+ shift += 7;
1433
+ }
1434
+ };
1435
+ while (offset < buffer.length) {
1436
+ const key = readVarint();
1437
+ const field = Math.floor(key / 8);
1438
+ const wireType = key % 8;
1439
+ switch (wireType) {
1440
+ case 0:
1441
+ fields.push({
1442
+ field,
1443
+ value: readVarint()
1444
+ });
1445
+ break;
1446
+ case 1:
1447
+ offset += 8;
1448
+ break;
1449
+ case 5:
1450
+ offset += 4;
1451
+ break;
1452
+ case 2: {
1453
+ const length = readVarint();
1454
+ if (offset + length > buffer.length) throw new Error("truncated field");
1455
+ fields.push({
1456
+ field,
1457
+ value: buffer.subarray(offset, offset + length)
1458
+ });
1459
+ offset += length;
1460
+ break;
1461
+ }
1462
+ default: throw new Error(`unsupported protobuf wire type ${wireType}`);
1463
+ }
1464
+ }
1465
+ return fields;
1466
+ }
1467
+ /** @internal exported for tests. Throws on malformed input. */
1468
+ function decodeBuildkitStatus(aux) {
1469
+ const vertexes = [];
1470
+ const logs = [];
1471
+ for (const { field, value } of readFields(Buffer.from(aux, "base64"))) {
1472
+ if (!Buffer.isBuffer(value)) continue;
1473
+ if (field === 1) {
1474
+ const vertex = {
1475
+ digest: "",
1476
+ cached: false,
1477
+ started: false,
1478
+ completed: false
1479
+ };
1480
+ for (const f of readFields(value)) if (f.field === 1 && Buffer.isBuffer(f.value)) vertex.digest = f.value.toString("utf8");
1481
+ else if (f.field === 3 && Buffer.isBuffer(f.value)) vertex.name = f.value.toString("utf8");
1482
+ else if (f.field === 4) vertex.cached = f.value !== 0;
1483
+ else if (f.field === 5) vertex.started = true;
1484
+ else if (f.field === 6) vertex.completed = true;
1485
+ else if (f.field === 7 && Buffer.isBuffer(f.value) && f.value.length > 0) vertex.error = f.value.toString("utf8");
1486
+ if (vertex.digest) vertexes.push(vertex);
1487
+ } else if (field === 3) {
1488
+ let vertex = "";
1489
+ let data = Buffer.alloc(0);
1490
+ for (const f of readFields(value)) if (f.field === 1 && Buffer.isBuffer(f.value)) vertex = f.value.toString("utf8");
1491
+ else if (f.field === 4 && Buffer.isBuffer(f.value)) data = f.value;
1492
+ logs.push({
1493
+ vertex,
1494
+ data
1495
+ });
1496
+ }
1497
+ }
1498
+ return {
1499
+ vertexes,
1500
+ logs
1501
+ };
1502
+ }
1503
+ const LEGACY_STEP = /^Step (\d+)\/(\d+) : (.*)$/;
1504
+ function createBuildEventNormalizer(emit, tail) {
1505
+ let failure;
1506
+ let failedStep;
1507
+ let legacyStep;
1508
+ let legacyCached;
1509
+ let pending = "";
1510
+ const vertexes = /* @__PURE__ */ new Map();
1511
+ const decoders = /* @__PURE__ */ new Map();
1512
+ const output = (text, stepId) => {
1513
+ if (!text) return;
1514
+ tail.push(text);
1515
+ emit({
1516
+ type: "output",
1517
+ ...stepId !== void 0 && { stepId },
1518
+ text
1519
+ });
1520
+ };
1521
+ const completeLegacyStep = (error) => {
1522
+ if (!legacyStep) return;
1523
+ const step = {
1524
+ ...legacyStep,
1525
+ ...legacyCached !== void 0 && { cached: legacyCached }
1526
+ };
1527
+ if (error !== void 0) failedStep = step;
1528
+ emit({
1529
+ type: "step-complete",
1530
+ step,
1531
+ ...error !== void 0 && { error }
1532
+ });
1533
+ legacyStep = void 0;
1534
+ legacyCached = void 0;
1535
+ };
1536
+ const handleLegacyLine = (line) => {
1537
+ const match = LEGACY_STEP.exec(line.replace(/\r?\n$/, ""));
1538
+ if (match) {
1539
+ completeLegacyStep();
1540
+ legacyStep = {
1541
+ id: match[1],
1542
+ name: match[3],
1543
+ index: Number(match[1]),
1544
+ total: Number(match[2])
1545
+ };
1546
+ emit({
1547
+ type: "step-start",
1548
+ step: { ...legacyStep }
1549
+ });
1550
+ return;
1551
+ }
1552
+ if (legacyStep) {
1553
+ const trimmed = line.trim();
1554
+ if (trimmed === "---> Using cache") legacyCached = true;
1555
+ else if (trimmed.startsWith("---> Running in ") && legacyCached === void 0) legacyCached = false;
1556
+ }
1557
+ output(line, legacyStep?.id);
1558
+ };
1559
+ const handleLegacyStream = (text) => {
1560
+ pending += text;
1561
+ let newline = pending.indexOf("\n");
1562
+ while (newline !== -1) {
1563
+ handleLegacyLine(pending.slice(0, newline + 1));
1564
+ pending = pending.slice(newline + 1);
1565
+ newline = pending.indexOf("\n");
1566
+ }
1567
+ };
1568
+ const flushLegacy = () => {
1569
+ if (pending) {
1570
+ handleLegacyLine(pending);
1571
+ pending = "";
1572
+ }
1573
+ };
1574
+ const handleBuildkit = (aux) => {
1575
+ const { vertexes: updates, logs } = decodeBuildkitStatus(aux);
1576
+ for (const update of updates) {
1577
+ let state = vertexes.get(update.digest);
1578
+ if (!state) {
1579
+ state = {
1580
+ step: {
1581
+ id: update.digest,
1582
+ name: update.name ?? ""
1583
+ },
1584
+ started: false,
1585
+ completed: false
1586
+ };
1587
+ vertexes.set(update.digest, state);
1588
+ }
1589
+ if (update.name && !state.step.name) state.step.name = update.name;
1590
+ if (update.cached) state.step.cached = true;
1591
+ if (state.step.name.startsWith("[internal]")) continue;
1592
+ if (!state.started && (update.started || update.completed || update.cached || update.error)) {
1593
+ state.started = true;
1594
+ emit({
1595
+ type: "step-start",
1596
+ step: { ...state.step }
1597
+ });
1598
+ }
1599
+ if (!state.completed && (update.completed || update.error)) {
1600
+ state.completed = true;
1601
+ if (state.step.cached === void 0) state.step.cached = false;
1602
+ const step = { ...state.step };
1603
+ if (update.error) failedStep = step;
1604
+ emit({
1605
+ type: "step-complete",
1606
+ step,
1607
+ ...update.error && { error: update.error }
1608
+ });
1609
+ }
1610
+ }
1611
+ for (const log of logs) {
1612
+ if (vertexes.get(log.vertex)?.step.name.startsWith("[internal]")) continue;
1613
+ let decoder = decoders.get(log.vertex);
1614
+ if (!decoder) {
1615
+ decoder = new string_decoder.StringDecoder("utf8");
1616
+ decoders.set(log.vertex, decoder);
1617
+ }
1618
+ output(decoder.write(log.data), log.vertex || void 0);
1619
+ }
1620
+ };
1621
+ const handleError = (raw) => {
1622
+ const detail = raw.errorDetail;
1623
+ const message = String(detail?.message ?? raw.error ?? "docker build failed");
1624
+ flushLegacy();
1625
+ completeLegacyStep(message);
1626
+ if (!failure) failure = {
1627
+ message,
1628
+ ...failedStep && { step: failedStep }
1629
+ };
1630
+ };
1631
+ return {
1632
+ handle(raw) {
1633
+ if (!raw || typeof raw !== "object") return;
1634
+ const event = raw;
1635
+ try {
1636
+ if (event.error !== void 0 || event.errorDetail !== void 0) handleError(event);
1637
+ else if (event.id === "moby.buildkit.trace" && typeof event.aux === "string") handleBuildkit(event.aux);
1638
+ else if (typeof event.stream === "string") handleLegacyStream(event.stream);
1639
+ } catch {}
1640
+ },
1641
+ finish() {
1642
+ flushLegacy();
1643
+ completeLegacyStep();
1644
+ },
1645
+ failure: () => failure
1646
+ };
1647
+ }
1648
+ //#endregion
1365
1649
  //#region src/template/build-session.ts
1366
1650
  /**
1367
1651
  * BuildKit session that serves build secrets to the daemon.
@@ -1898,20 +2182,29 @@ var DockerTemplate = class DockerTemplate {
1898
2182
  * `options.secrets`, the template's `secrets` option, or `process.env`.
1899
2183
  */
1900
2184
  async build(options = {}) {
2185
+ const { maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES } = options;
2186
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) throw new RangeError("maxOutputBytes must be a positive integer");
1901
2187
  throwIfAborted(options.abortSignal, "build Docker template");
1902
2188
  const isPlain = !options.force && options.secrets === void 0 && options.docker === void 0;
1903
- if (isPlain && this.#plainBuild) return this.#waitForBuild(this.#plainBuild, options.abortSignal);
2189
+ if (isPlain && this.#plainBuild) return this.#waitForBuild(this.#plainBuild, options.abortSignal, options.onProgress);
1904
2190
  const previous = this.#queueTail?.promise.catch(() => void 0) ?? Promise.resolve();
1905
2191
  const controller = new AbortController();
2192
+ const listeners = /* @__PURE__ */ new Set();
2193
+ const emit = (event) => {
2194
+ for (const listener of [...listeners]) try {
2195
+ listener(event);
2196
+ } catch {}
2197
+ };
1906
2198
  const run = {
1907
2199
  controller,
1908
2200
  waiters: 0,
2201
+ listeners,
1909
2202
  promise: previous.then(() => {
1910
2203
  throwIfAborted(controller.signal, "build Docker template");
1911
2204
  return this.#build({
1912
2205
  ...options,
1913
2206
  abortSignal: controller.signal
1914
- });
2207
+ }, emit, maxOutputBytes);
1915
2208
  })
1916
2209
  };
1917
2210
  this.#queueTail = run;
@@ -1920,20 +2213,23 @@ var DockerTemplate = class DockerTemplate {
1920
2213
  if (this.#queueTail === run) this.#queueTail = void 0;
1921
2214
  if (this.#plainBuild === run) this.#plainBuild = void 0;
1922
2215
  }).catch(() => void 0);
1923
- return this.#waitForBuild(run, options.abortSignal);
2216
+ return this.#waitForBuild(run, options.abortSignal, options.onProgress);
1924
2217
  }
1925
- #waitForBuild(run, abortSignal) {
2218
+ #waitForBuild(run, abortSignal, onProgress) {
1926
2219
  run.waiters += 1;
2220
+ const listener = onProgress && ((event) => onProgress(event));
2221
+ if (listener) run.listeners.add(listener);
1927
2222
  let settled = false;
1928
2223
  const release = (aborted) => {
1929
2224
  if (settled) return;
1930
2225
  settled = true;
2226
+ if (listener) run.listeners.delete(listener);
1931
2227
  run.waiters -= 1;
1932
2228
  if (aborted && run.waiters === 0) run.controller.abort();
1933
2229
  };
1934
2230
  return waitForAbortable(run.promise, abortSignal, "build Docker template", () => release(true)).finally(() => release(false));
1935
2231
  }
1936
- async #build(options) {
2232
+ async #build(options, emit, maxOutputBytes) {
1937
2233
  const docker = options.docker ?? this.#getDocker();
1938
2234
  const tag = this.templateId;
1939
2235
  const { abortSignal } = options;
@@ -1942,6 +2238,10 @@ var DockerTemplate = class DockerTemplate {
1942
2238
  await docker.getImage(tag).inspect();
1943
2239
  throwIfAborted(abortSignal, "build Docker template");
1944
2240
  this.#built = true;
2241
+ emit({
2242
+ type: "reused",
2243
+ templateId: tag
2244
+ });
1945
2245
  return {
1946
2246
  status: "ready",
1947
2247
  templateId: tag
@@ -1954,12 +2254,19 @@ var DockerTemplate = class DockerTemplate {
1954
2254
  const context = (0, tar_stream.pack)();
1955
2255
  context.entry({ name: "Dockerfile" }, this.dockerfile);
1956
2256
  context.finalize();
2257
+ const tail = new BoundedTail(maxOutputBytes);
2258
+ const normalizer = createBuildEventNormalizer(emit, tail);
2259
+ emit({
2260
+ type: "build-start",
2261
+ templateId: tag,
2262
+ builder: secrets ? "buildkit" : "legacy"
2263
+ });
1957
2264
  try {
1958
2265
  const nocache = options.force === true;
1959
2266
  if (secrets) {
1960
2267
  const { stream, session } = await this.#buildWithSecrets(docker, context, tag, secrets, nocache, abortSignal);
1961
2268
  try {
1962
- await this.#followBuild(docker, stream, abortSignal);
2269
+ await this.#followBuild(docker, stream, normalizer, abortSignal);
1963
2270
  } finally {
1964
2271
  session.close();
1965
2272
  }
@@ -1977,18 +2284,31 @@ var DockerTemplate = class DockerTemplate {
1977
2284
  }), abortSignal, "build Docker template", () => {
1978
2285
  context.destroy?.();
1979
2286
  });
1980
- await this.#followBuild(docker, stream, abortSignal);
2287
+ await this.#followBuild(docker, stream, normalizer, abortSignal);
1981
2288
  }
1982
2289
  } catch (error) {
1983
2290
  this.#built = false;
1984
2291
  if (abortSignal?.aborted) throw normalizeAbortError(error, "build Docker template");
2292
+ emit({
2293
+ type: "build-complete",
2294
+ status: "failed"
2295
+ });
2296
+ const failedStep = normalizer.failure()?.step;
1985
2297
  return {
1986
2298
  status: "failed",
1987
2299
  templateId: tag,
1988
- error: error instanceof Error ? error.message : String(error)
2300
+ error: error instanceof Error ? error.message : String(error),
2301
+ ...failedStep && { failedStep },
2302
+ output: tail.toString(),
2303
+ outputTruncated: tail.truncated
1989
2304
  };
1990
2305
  }
2306
+ normalizer.finish();
1991
2307
  this.#built = true;
2308
+ emit({
2309
+ type: "build-complete",
2310
+ status: "ready"
2311
+ });
1992
2312
  return {
1993
2313
  status: "ready",
1994
2314
  templateId: tag
@@ -2066,7 +2386,7 @@ var DockerTemplate = class DockerTemplate {
2066
2386
  session
2067
2387
  };
2068
2388
  }
2069
- #followBuild(docker, stream, abortSignal) {
2389
+ #followBuild(docker, stream, normalizer, abortSignal) {
2070
2390
  return new Promise((resolve, reject) => {
2071
2391
  let settled = false;
2072
2392
  const finish = (error) => {
@@ -2089,6 +2409,11 @@ var DockerTemplate = class DockerTemplate {
2089
2409
  finish(err);
2090
2410
  return;
2091
2411
  }
2412
+ const reported = normalizer.failure();
2413
+ if (reported) {
2414
+ finish(new Error(reported.message));
2415
+ return;
2416
+ }
2092
2417
  const failure = output?.find((entry) => entry && (entry.error !== void 0 || entry.errorDetail !== void 0));
2093
2418
  if (failure) {
2094
2419
  const detail = failure.errorDetail;
@@ -2096,6 +2421,8 @@ var DockerTemplate = class DockerTemplate {
2096
2421
  return;
2097
2422
  }
2098
2423
  finish();
2424
+ }, (event) => {
2425
+ if (!settled) normalizer.handle(event);
2099
2426
  });
2100
2427
  });
2101
2428
  }