@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.
- package/dist/index.js +78 -11
- 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 = "
|
|
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: "
|
|
1268
|
+
stderr: "pipe",
|
|
1251
1269
|
...service.env && { env: service.env },
|
|
1252
1270
|
signal: servicesAbort.signal
|
|
1253
1271
|
});
|
|
1254
|
-
|
|
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 (
|
|
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
|
-
})
|
|
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
|
|
1280
|
-
|
|
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 (
|
|
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 (${
|
|
1348
|
+
message: `Projected guest service "${service.id}" did not become ready (${failure}); continuing without it`
|
|
1289
1349
|
});
|
|
1290
1350
|
}
|
|
1291
1351
|
}
|
|
1292
|
-
|
|
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.
|
|
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:
|
|
36
|
+
"type:sandbox",
|
|
37
37
|
"scope:agent",
|
|
38
|
-
"platform:
|
|
38
|
+
"platform:server"
|
|
39
39
|
],
|
|
40
40
|
"targets": {
|
|
41
41
|
"test-ci": {
|