@themoltnet/sandbox-gondolin 0.5.0 → 0.5.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.
Files changed (2) hide show
  1. package/dist/index.js +78 -11
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -249,7 +249,7 @@ var GONDOLIN_BASE_EXECUTABLES = Object.freeze([
249
249
  /** gh CLI version installed in every snapshot. */
250
250
  var GH_VERSION = "2.74.0";
251
251
  /** MoltNet CLI version — downloaded as a binary, no Node needed. */
252
- var MOLTNET_CLI_VERSION = "1.37.0";
252
+ var MOLTNET_CLI_VERSION = "3.5.0";
253
253
  /**
254
254
  * Resolve guest architecture from host (Gondolin VMs match host arch).
255
255
  *
@@ -291,12 +291,24 @@ function getCacheDir() {
291
291
  const base = process.env.XDG_CACHE_HOME ?? path.join(process.env.HOME ?? "/tmp", ".cache");
292
292
  return path.join(base, "moltnet", "gondolin");
293
293
  }
294
+ /**
295
+ * The VM backend the snapshot will be built with. Gondolin checkpoints
296
+ * record `compatibleVmm` and refuse to resume under a backend the build
297
+ * did not produce assets for, so the backend MUST be part of the cache
298
+ * key: a qemu-built snapshot resumed under `GONDOLIN_VMM=krun` (the
299
+ * signed bundle's default) would otherwise fail every run until the
300
+ * cache is wiped by hand.
301
+ */
302
+ function resolveSnapshotVmm(env = process.env) {
303
+ return env.GONDOLIN_VMM?.trim().toLowerCase() === "krun" ? "krun" : "qemu";
304
+ }
294
305
  function computeConfigHash(config) {
295
306
  const h = createHash("sha256");
296
307
  h.update(JSON.stringify({
297
308
  baseAlpine: BASE_ALPINE_PACKAGES,
298
309
  ghVersion: GH_VERSION,
299
310
  cliVersion: MOLTNET_CLI_VERSION,
311
+ vmm: resolveSnapshotVmm(),
300
312
  config
301
313
  }));
302
314
  return h.digest("hex").slice(0, 12);
@@ -398,6 +410,7 @@ async function buildSnapshot(vm, config, log) {
398
410
  mv /tmp/package/bin/moltnet /usr/local/bin/moltnet
399
411
  chmod +x /usr/local/bin/moltnet
400
412
  rm -rf /tmp/moltnet.tgz /tmp/package
413
+ moltnet capability serve --help >/dev/null
401
414
  '`);
402
415
  await run(vm, log, "creating agent user...", `sh -eu -c '
403
416
  addgroup -g 501 agent 2>/dev/null || true
@@ -550,6 +563,9 @@ var GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
550
563
  /** Guest directory holding one PID file per projected service. */
551
564
  var GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
552
565
  var GUEST_SERVICE_ID_RE = /^[a-z][a-z0-9-]{0,62}$/;
566
+ function stderrTail(previous, next) {
567
+ return Buffer.concat([Buffer.from(previous), Buffer.from(next)]).subarray(-4096).toString("utf8");
568
+ }
553
569
  function assertGuestServiceId(id) {
554
570
  if (!GUEST_SERVICE_ID_RE.test(id)) throw new Error(`Invalid guest service id "${id}": expected ${GUEST_SERVICE_ID_RE}`);
555
571
  }
@@ -1172,6 +1188,8 @@ async function resumeVm(config) {
1172
1188
  const servicesAbort = new AbortController();
1173
1189
  const serviceHandles = [];
1174
1190
  const serviceIds = [];
1191
+ const serviceExits = /* @__PURE__ */ new Map();
1192
+ const serviceExitResults = /* @__PURE__ */ new Map();
1175
1193
  const services = { async stop() {
1176
1194
  if (serviceIds.length > 0) {
1177
1195
  const pidFiles = serviceIds.map((id) => `${GUEST_SERVICE_PID_DIR}/${id}.pid`).join(" ");
@@ -1247,21 +1265,48 @@ async function resumeVm(config) {
1247
1265
  ...service.command
1248
1266
  ], {
1249
1267
  stdout: "ignore",
1250
- stderr: "ignore",
1268
+ stderr: "pipe",
1251
1269
  ...service.env && { env: service.env },
1252
1270
  signal: servicesAbort.signal
1253
1271
  });
1254
- serviceHandles.push(Promise.resolve(handle).catch(() => void 0));
1272
+ let stderr = "";
1273
+ const exited = (async () => {
1274
+ try {
1275
+ if ("output" in handle && typeof handle.output === "function") {
1276
+ for await (const chunk of handle.output()) if (chunk.stream === "stderr") stderr = stderrTail(stderr, chunk.data);
1277
+ }
1278
+ const result = await handle;
1279
+ stderr = stderrTail(stderr, String(result.stderr ?? ""));
1280
+ return {
1281
+ exitCode: result.exitCode,
1282
+ stderr
1283
+ };
1284
+ } catch (error) {
1285
+ return {
1286
+ exitCode: void 0,
1287
+ stderr: stderrTail(stderr, String(error))
1288
+ };
1289
+ }
1290
+ })();
1291
+ serviceExits.set(service.id, exited.then((result) => {
1292
+ serviceExitResults.set(service.id, result);
1293
+ return result;
1294
+ }));
1295
+ serviceHandles.push(exited);
1255
1296
  serviceIds.push(service.id);
1256
1297
  }
1257
1298
  async function awaitServiceReady(service) {
1258
1299
  if (!service.readiness) return;
1259
1300
  const deadline = Date.now() + (service.readiness.timeoutMs ?? 1e4);
1260
1301
  const pidFile = `${GUEST_SERVICE_PID_DIR}/${service.id}.pid`;
1302
+ const exited = serviceExits.get(service.id);
1303
+ if (!exited) throw new Error(`Service "${service.id}" was not started`);
1261
1304
  let ready = false;
1305
+ let failure;
1262
1306
  while (Date.now() < deadline) {
1263
1307
  throwIfAborted(config.signal, `service "${service.id}" readiness`);
1264
- if ((await vm.exec([
1308
+ if (servicesAbort.signal.aborted) return;
1309
+ const probe = await vm.exec([
1265
1310
  "sh",
1266
1311
  "-c",
1267
1312
  "[ -e \"$1\" ] && [ -r \"$2\" ] && kill -0 \"$(cat \"$2\" 2>/dev/null)\" 2>/dev/null",
@@ -1272,24 +1317,46 @@ async function resumeVm(config) {
1272
1317
  stdout: "ignore",
1273
1318
  stderr: "ignore",
1274
1319
  signal: config.signal
1275
- })).exitCode === 0) {
1320
+ });
1321
+ const alreadyExited = serviceExitResults.get(service.id);
1322
+ if (alreadyExited) {
1323
+ failure = `exited (code ${alreadyExited.exitCode ?? "unknown"})`;
1324
+ if (alreadyExited.stderr) failure += `; stderr tail: ${alreadyExited.stderr}`;
1325
+ break;
1326
+ }
1327
+ if (probe.exitCode === 0) {
1276
1328
  ready = true;
1277
1329
  break;
1278
1330
  }
1279
- await new Promise((resolve) => {
1280
- setTimeout(resolve, 200);
1281
- });
1331
+ const outcome = await Promise.race([exited.then((result) => ({
1332
+ kind: "exit",
1333
+ result
1334
+ })), delay(Math.min(200, Math.max(0, deadline - Date.now())), servicesAbort.signal, `service "${service.id}" readiness`).then(() => ({ kind: "retry" }))]);
1335
+ if (outcome.kind === "exit") {
1336
+ failure = `exited (code ${outcome.result.exitCode ?? "unknown"})`;
1337
+ if (outcome.result.stderr) failure += `; stderr tail: ${outcome.result.stderr}`;
1338
+ break;
1339
+ }
1282
1340
  }
1283
1341
  if (!ready) {
1284
- if (service.readiness.required) throw new Error(`Projected guest service "${service.id}" did not become ready: ${service.readiness.path} absent or its process exited`);
1342
+ if (servicesAbort.signal.aborted) return;
1343
+ failure ??= `${service.readiness.path} absent after ${service.readiness.timeoutMs ?? 1e4} ms`;
1344
+ if (service.readiness.required) throw new Error(`Projected guest service "${service.id}" did not become ready: ${failure}`);
1285
1345
  config.onDiagnostic?.({
1286
1346
  event: "vm.guest_service.not_ready",
1287
1347
  level: "warning",
1288
- message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
1348
+ message: `Projected guest service "${service.id}" did not become ready (${failure}); continuing without it`
1289
1349
  });
1290
1350
  }
1291
1351
  }
1292
- await Promise.all(projectedServices.map((service) => awaitServiceReady(service)));
1352
+ for (const service of projectedServices.filter((item) => item.readiness && !item.readiness.required)) awaitServiceReady(service).catch((error) => {
1353
+ if (!servicesAbort.signal.aborted) config.onDiagnostic?.({
1354
+ event: "vm.guest_service.not_ready",
1355
+ level: "warning",
1356
+ message: `Projected guest service "${service.id}" readiness failed: ${String(error)}`
1357
+ });
1358
+ });
1359
+ await Promise.all(projectedServices.filter((service) => service.readiness?.required).map((service) => awaitServiceReady(service)));
1293
1360
  if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
1294
1361
  event: "vm.guest_projection.applied",
1295
1362
  level: "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/sandbox-gondolin",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Gondolin microVM sandbox lifecycle for MoltNet runtimes: checkpoint resume, egress policy, VFS shadowing, guest credential boundary, host-brokered secrets",
5
5
  "keywords": [
6
6
  "moltnet",
@@ -33,9 +33,9 @@
33
33
  },
34
34
  "nx": {
35
35
  "tags": [
36
- "type:runtime",
36
+ "type:sandbox",
37
37
  "scope:agent",
38
- "platform:extension"
38
+ "platform:server"
39
39
  ],
40
40
  "targets": {
41
41
  "test-ci": {