vmsan 0.1.0-alpha.15 → 0.1.0-alpha.16

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.
@@ -1,6 +1,196 @@
1
1
  import { createGzip } from "node:zlib";
2
2
  import { Readable } from "node:stream";
3
3
  import { pack } from "tar-stream";
4
+ /**
5
+ * Lightweight async iterable queue for pushing events and yielding them to consumers.
6
+ */
7
+ var AsyncQueue = class {
8
+ _buffer = [];
9
+ _waiting = [];
10
+ _closed = false;
11
+ push(item) {
12
+ if (this._closed) return;
13
+ if (this._waiting.length > 0) this._waiting.shift()({
14
+ value: item,
15
+ done: false
16
+ });
17
+ else this._buffer.push(item);
18
+ }
19
+ close() {
20
+ this._closed = true;
21
+ for (const resolve of this._waiting) resolve({
22
+ value: void 0,
23
+ done: true
24
+ });
25
+ this._waiting.length = 0;
26
+ }
27
+ async *[Symbol.asyncIterator]() {
28
+ while (true) if (this._buffer.length > 0) yield this._buffer.shift();
29
+ else if (this._closed) return;
30
+ else {
31
+ const item = await new Promise((resolve) => {
32
+ this._waiting.push(resolve);
33
+ });
34
+ if (item.done) return;
35
+ yield item.value;
36
+ }
37
+ }
38
+ };
39
+ var CommandFinished = class {
40
+ cmdId;
41
+ exitCode;
42
+ stdout;
43
+ stderr;
44
+ output;
45
+ timedOut;
46
+ startedAt;
47
+ constructor(opts) {
48
+ this.cmdId = opts.cmdId;
49
+ this.exitCode = opts.exitCode;
50
+ this.stdout = opts.stdout;
51
+ this.stderr = opts.stderr;
52
+ this.output = opts.output;
53
+ this.timedOut = opts.timedOut;
54
+ this.startedAt = opts.startedAt;
55
+ }
56
+ get ok() {
57
+ return this.exitCode === 0;
58
+ }
59
+ };
60
+ const MAX_LOG_ENTRIES = 1e5;
61
+ var Command = class {
62
+ cmdId;
63
+ _startedAt;
64
+ _exitCode = null;
65
+ _timedOut = false;
66
+ _logEntries = [];
67
+ _logTruncated = false;
68
+ _eventQueue = new AsyncQueue();
69
+ _completion;
70
+ _stdoutPromise = null;
71
+ _stderrPromise = null;
72
+ _outputPromise = null;
73
+ _agent;
74
+ constructor(init) {
75
+ const { agent, cmdId, startedAt, stream, signal, onStdout, onStderr } = init;
76
+ this._agent = agent;
77
+ this.cmdId = cmdId;
78
+ this._startedAt = startedAt;
79
+ let resolveCompletion;
80
+ let rejectCompletion;
81
+ this._completion = new Promise((resolve, reject) => {
82
+ resolveCompletion = resolve;
83
+ rejectCompletion = reject;
84
+ });
85
+ let onAbort;
86
+ if (signal) {
87
+ onAbort = () => {
88
+ this.kill().catch(() => {});
89
+ };
90
+ if (signal.aborted) this.kill().catch(() => {});
91
+ else signal.addEventListener("abort", onAbort, { once: true });
92
+ }
93
+ (async () => {
94
+ try {
95
+ for await (const event of stream) {
96
+ this._eventQueue.push(event);
97
+ switch (event.type) {
98
+ case "stdout":
99
+ case "stderr":
100
+ if (event.data !== void 0) {
101
+ if (this._logEntries.length < MAX_LOG_ENTRIES) this._logEntries.push({
102
+ stream: event.type,
103
+ data: event.data
104
+ });
105
+ else this._logTruncated = true;
106
+ (event.type === "stdout" ? onStdout : onStderr)?.(event.data);
107
+ }
108
+ break;
109
+ case "exit":
110
+ this._exitCode = event.exitCode ?? 1;
111
+ break;
112
+ case "timeout":
113
+ this._timedOut = true;
114
+ this._exitCode = 124;
115
+ break;
116
+ case "error":
117
+ this._exitCode = 1;
118
+ break;
119
+ }
120
+ }
121
+ this._eventQueue.close();
122
+ resolveCompletion(new CommandFinished({
123
+ cmdId,
124
+ exitCode: this._exitCode ?? 1,
125
+ stdout: this._logEntries.filter((e) => e.stream === "stdout").map((e) => e.data).join("\n"),
126
+ stderr: this._logEntries.filter((e) => e.stream === "stderr").map((e) => e.data).join("\n"),
127
+ output: this._logEntries.map((e) => e.data).join("\n"),
128
+ timedOut: this._timedOut,
129
+ startedAt
130
+ }));
131
+ this._logEntries.length = 0;
132
+ } catch (err) {
133
+ this._eventQueue.close();
134
+ const error = err instanceof Error ? err : new Error(String(err));
135
+ rejectCompletion(error);
136
+ } finally {
137
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
138
+ }
139
+ })();
140
+ }
141
+ get startedAt() {
142
+ return this._startedAt;
143
+ }
144
+ get exitCode() {
145
+ return this._exitCode;
146
+ }
147
+ async *logs(opts) {
148
+ const signal = opts?.signal;
149
+ if (signal?.aborted) return;
150
+ for await (const event of this._eventQueue) {
151
+ if (signal?.aborted) return;
152
+ if (event.type === "stdout" || event.type === "stderr") yield {
153
+ stream: event.type,
154
+ data: event.data ?? ""
155
+ };
156
+ }
157
+ }
158
+ stdout(opts) {
159
+ if (!this._stdoutPromise) this._stdoutPromise = this._completion.then((r) => r.stdout);
160
+ return this._withSignal(this._stdoutPromise, opts?.signal);
161
+ }
162
+ stderr(opts) {
163
+ if (!this._stderrPromise) this._stderrPromise = this._completion.then((r) => r.stderr);
164
+ return this._withSignal(this._stderrPromise, opts?.signal);
165
+ }
166
+ output(stream, opts) {
167
+ if (stream === "stdout") return this.stdout(opts);
168
+ if (stream === "stderr") return this.stderr(opts);
169
+ if (!this._outputPromise) this._outputPromise = this._completion.then((r) => r.output);
170
+ return this._withSignal(this._outputPromise, opts?.signal);
171
+ }
172
+ wait(opts) {
173
+ return this._withSignal(this._completion, opts?.signal);
174
+ }
175
+ async kill(signal, opts) {
176
+ await this._agent.killCommand(this.cmdId, signal, opts?.abortSignal);
177
+ }
178
+ _withSignal(promise, signal) {
179
+ if (!signal) return promise;
180
+ if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted.", "AbortError"));
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => reject(new DOMException("The operation was aborted.", "AbortError"));
183
+ signal.addEventListener("abort", onAbort, { once: true });
184
+ promise.then((value) => {
185
+ signal.removeEventListener("abort", onAbort);
186
+ resolve(value);
187
+ }, (err) => {
188
+ signal.removeEventListener("abort", onAbort);
189
+ reject(err);
190
+ });
191
+ });
192
+ }
193
+ };
4
194
  var AgentClient = class {
5
195
  constructor(baseUrl, token) {
6
196
  this.baseUrl = baseUrl;
@@ -11,14 +201,15 @@ var AgentClient = class {
11
201
  if (!res.ok) throw new Error(`Agent health check failed: ${res.status}`);
12
202
  return res.json();
13
203
  }
14
- async *run(params) {
204
+ async *run(params, signal) {
15
205
  const res = await fetch(`${this.baseUrl}/exec`, {
16
206
  method: "POST",
17
207
  headers: {
18
208
  "Content-Type": "application/json",
19
209
  Authorization: `Bearer ${this.token}`
20
210
  },
21
- body: JSON.stringify(params)
211
+ body: JSON.stringify(params),
212
+ signal
22
213
  });
23
214
  if (!res.ok) {
24
215
  const text = await res.text();
@@ -41,7 +232,7 @@ var AgentClient = class {
41
232
  }
42
233
  if (buffer.trim()) yield JSON.parse(buffer.trim());
43
234
  }
44
- async killCommand(cmdId, signal) {
235
+ async killCommand(cmdId, signal, abortSignal) {
45
236
  const url = `${this.baseUrl}/exec/${cmdId}/kill`;
46
237
  const res = await fetch(url, {
47
238
  method: "POST",
@@ -49,7 +240,8 @@ var AgentClient = class {
49
240
  "Content-Type": "application/json",
50
241
  Authorization: `Bearer ${this.token}`
51
242
  },
52
- body: signal ? JSON.stringify({ signal }) : void 0
243
+ body: signal ? JSON.stringify({ signal }) : void 0,
244
+ signal: abortSignal
53
245
  });
54
246
  if (!res.ok) {
55
247
  const text = await res.text();
@@ -94,6 +286,41 @@ var AgentClient = class {
94
286
  throw new Error(`Agent killShellSession failed (${res.status}): ${text}`);
95
287
  }
96
288
  }
289
+ async exec(params, opts) {
290
+ const stream = this.run(params, opts?.signal);
291
+ const first = await stream.next();
292
+ if (first.done) throw new Error("Stream ended without 'started' event");
293
+ if (first.value.type !== "started") throw new Error(`Expected 'started' event, got '${first.value.type}'`);
294
+ const cmdId = first.value.id;
295
+ if (!cmdId) throw new Error("'started' event missing 'id' field");
296
+ const startedAt = first.value.ts ? new Date(first.value.ts) : /* @__PURE__ */ new Date();
297
+ return new Command({
298
+ agent: this,
299
+ cmdId,
300
+ startedAt,
301
+ stream,
302
+ signal: opts?.signal,
303
+ onStdout: opts?.onStdout,
304
+ onStderr: opts?.onStderr
305
+ });
306
+ }
307
+ async runCommand(cmdOrParams, args, opts) {
308
+ let params;
309
+ if (typeof cmdOrParams === "string") params = {
310
+ cmd: cmdOrParams,
311
+ args,
312
+ signal: opts?.signal
313
+ };
314
+ else params = cmdOrParams;
315
+ const { signal, onStdout, onStderr, ...runParams } = params;
316
+ const command = await this.exec(runParams, {
317
+ signal,
318
+ onStdout,
319
+ onStderr
320
+ });
321
+ if (params.detached) return command;
322
+ return command.wait({ signal });
323
+ }
97
324
  async readFile(path) {
98
325
  const res = await fetch(`${this.baseUrl}/files/read`, {
99
326
  method: "POST",
@@ -2,9 +2,10 @@ import { n as vmsanPaths } from "./paths.mjs";
2
2
  import { B as mutuallyExclusiveFlagsError, C as vmNotStoppedError, S as vmNotRunningError, U as VmsanError, _ as chrootNotFoundError, a as noKernelDirError, d as socketTimeoutError, i as noExt4RootfsError, o as noKernelError, p as defaultInterfaceNotFoundError, r as missingBinaryError, s as noRootfsDirError, u as lockTimeoutError, x as vmNotFoundError, y as snapshotNotFoundError } from "./errors.mjs";
3
3
  import { c as safeKill, f as toError, i as generateVmId, t as FileVmStateStore } from "./vm-state.mjs";
4
4
  import { t as FirecrackerClient } from "./firecracker.mjs";
5
+ import { t as spawnTimeoutKiller } from "./timeout-killer.mjs";
5
6
  import { createHooks } from "hookable";
6
7
  import { dirname, join } from "node:path";
7
- import { execFileSync, execSync, spawn } from "node:child_process";
8
+ import { execFileSync, execSync } from "node:child_process";
8
9
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
9
10
  import { consola } from "consola";
10
11
  import { randomBytes } from "node:crypto";
@@ -1825,37 +1826,6 @@ function ensureSeccompFilter(paths) {
1825
1826
  return null;
1826
1827
  }
1827
1828
  }
1828
- /**
1829
- * Spawn a detached bash process that kills the VM after timeout.
1830
- * The process sleeps for the timeout duration, then verifies the VM
1831
- * is still running with the expected PID before sending SIGTERM.
1832
- */
1833
- function spawnTimeoutKiller(opts) {
1834
- const { vmId, pid, timeoutMs, stateFile } = opts;
1835
- const timeoutSec = String(Math.ceil(timeoutMs / 1e3));
1836
- const killer = spawn("bash", [
1837
- "-c",
1838
- [
1839
- "sleep \"$1\"",
1840
- "STATE=$(cat -- \"$2\" 2>/dev/null) || exit 0",
1841
- "echo \"$STATE\" | grep -q '\"status\":\"running\"' || exit 0",
1842
- "echo \"$STATE\" | grep -q \"\"pid\":$3\" || exit 0",
1843
- "[ -d \"/proc/$3\" ] || exit 0",
1844
- "grep -aq -- \"$4\" \"/proc/$3/cmdline\" 2>/dev/null || exit 0",
1845
- "kill -- \"$3\" 2>/dev/null"
1846
- ].join(" && "),
1847
- "bash",
1848
- timeoutSec,
1849
- stateFile,
1850
- String(pid),
1851
- vmId
1852
- ], {
1853
- detached: true,
1854
- stdio: "ignore"
1855
- });
1856
- killer.unref();
1857
- return killer;
1858
- }
1859
1829
  var VMService = class {
1860
1830
  paths;
1861
1831
  store;
@@ -2394,4 +2364,4 @@ async function createVmsan(options) {
2394
2364
  if (options?.plugins) for (const plugin of options.plugins) await plugin.setup(ctx);
2395
2365
  return vmsan;
2396
2366
  }
2397
- export { createDefaultLogger as C, NetworkManager as S, validateEnvironment as _, ensureSeccompFilter as a, detectCgroupVersion as b, cleanupChroot as c, markVmAsError as d, assertSnapshotExists as f, getVmPid as g, getVmJailerPid as h, compileSeccompFilter as i, cleanupNetwork as l, findRootfs as m, VMService as n, resolveImageRootfs as o, findKernel as p, spawnTimeoutKiller as r, buildInitialVmState as s, createVmsan as t, killOrphanVmProcess as u, waitForSocket as v, createSilentLogger as w, FileLock as x, Jailer as y };
2367
+ export { createSilentLogger as C, createDefaultLogger as S, waitForSocket as _, resolveImageRootfs as a, FileLock as b, cleanupNetwork as c, assertSnapshotExists as d, findKernel as f, validateEnvironment as g, getVmPid as h, ensureSeccompFilter as i, killOrphanVmProcess as l, getVmJailerPid as m, VMService as n, buildInitialVmState as o, findRootfs as p, compileSeccompFilter as r, cleanupChroot as s, createVmsan as t, markVmAsError as u, Jailer as v, NetworkManager as x, detectCgroupVersion as y };
@@ -4,6 +4,7 @@ import { i as initVmsanLogger, n as createScopedLogger, t as createCommandLogger
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { n as waitForAgent } from "./vm-context.mjs";
8
9
  import { t as ShellSession } from "./shell.mjs";
9
10
  import { a as parseImageReference, t as parseBandwidth } from "./validation.mjs";
@@ -0,0 +1,193 @@
1
+ import { n as vmsanPaths } from "./paths.mjs";
2
+ import { t as handleCommandError } from "./errors.mjs";
3
+ import { t as createCommandLogger } from "./logger.mjs";
4
+ import "./vm-state.mjs";
5
+ import "./timeout-killer.mjs";
6
+ import { t as AgentClient } from "./agent.mjs";
7
+ import { t as TimeoutExtender } from "./timeout-extender.mjs";
8
+ import { n as waitForAgent, t as resolveVmState } from "./vm-context.mjs";
9
+ import { t as ShellSession } from "./shell.mjs";
10
+ import { consola } from "consola";
11
+ import { defineCommand } from "citty";
12
+ import { isatty } from "node:tty";
13
+ function shellEscape(s) {
14
+ if (/^[a-zA-Z0-9._\-/=:@]+$/.test(s)) return s;
15
+ return "'" + s.replace(/'/g, "'\\''") + "'";
16
+ }
17
+ function parseEnvFlags(targetCommand) {
18
+ const env = {};
19
+ const argv = process.argv;
20
+ let positionalCount = 0;
21
+ for (let i = 2; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ if (arg === "--") break;
24
+ if (!arg.startsWith("-")) {
25
+ positionalCount++;
26
+ if (positionalCount >= 3) break;
27
+ continue;
28
+ }
29
+ let value;
30
+ if (arg === "--env" || arg === "-e") {
31
+ value = argv[i + 1];
32
+ if (value !== void 0) i++;
33
+ } else if (arg.startsWith("--env=")) value = arg.slice(6);
34
+ else if (arg.startsWith("-e=")) value = arg.slice(3);
35
+ if (value !== void 0) {
36
+ const eqIdx = value.indexOf("=");
37
+ if (eqIdx > 0) env[value.slice(0, eqIdx)] = value.slice(eqIdx + 1);
38
+ }
39
+ }
40
+ return env;
41
+ }
42
+ function buildVmsanPrompt(vmId) {
43
+ return `\\[\\033[1;32m\\]vmsan:${vmId.slice(0, 8)}\\[\\033[0m\\]:\\[\\033[1;34m\\]\\w\\[\\033[0m\\]\\$ `;
44
+ }
45
+ const execCommand = defineCommand({
46
+ meta: {
47
+ name: "exec",
48
+ description: "Execute a command inside a running VM"
49
+ },
50
+ args: {
51
+ vmId: {
52
+ type: "positional",
53
+ description: "VM ID, followed by command and arguments",
54
+ required: true
55
+ },
56
+ sudo: {
57
+ type: "boolean",
58
+ default: false,
59
+ description: "Run with extended privileges (sudo)"
60
+ },
61
+ interactive: {
62
+ type: "boolean",
63
+ alias: "i",
64
+ default: false,
65
+ description: "Interactive shell mode (PTY)"
66
+ },
67
+ "no-extend-timeout": {
68
+ type: "boolean",
69
+ default: false,
70
+ description: "Skip timeout extension (interactive only)"
71
+ },
72
+ tty: {
73
+ type: "boolean",
74
+ alias: "t",
75
+ default: false,
76
+ description: "Allocate a pseudo-TTY (accepted for compatibility)"
77
+ },
78
+ workdir: {
79
+ type: "string",
80
+ alias: "w",
81
+ description: "Working directory inside the VM"
82
+ },
83
+ env: {
84
+ type: "string",
85
+ alias: "e",
86
+ description: "Environment variable (KEY=VAL), repeatable"
87
+ }
88
+ },
89
+ async run({ args }) {
90
+ const cmdLog = createCommandLogger("exec");
91
+ const paths = vmsanPaths();
92
+ try {
93
+ const command = args._[1];
94
+ const commandArgs = args._.slice(2);
95
+ if (!command) {
96
+ consola.error("No command provided. Usage: vmsan exec <vm_id> <command> [...args]");
97
+ cmdLog.emit();
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ if (args.interactive && !isatty(1)) {
102
+ consola.error("--interactive requires a terminal (TTY).");
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ const envVars = parseEnvFlags(command);
107
+ const { state, guestIp, port, store } = resolveVmState(args.vmId, paths);
108
+ consola.debug(`Agent endpoint: ${guestIp}:${port}`);
109
+ const log = consola.withTag(args.vmId);
110
+ log.start("Waiting for agent...");
111
+ await waitForAgent(guestIp, port);
112
+ if (args.interactive) {
113
+ const parts = [];
114
+ parts.push(`export PS1=${shellEscape(buildVmsanPrompt(args.vmId))} TERM=xterm-256color &&`);
115
+ if (args.workdir) parts.push(`cd ${shellEscape(args.workdir)} &&`);
116
+ for (const [key, val] of Object.entries(envVars)) parts.push(`${key}=${shellEscape(val)}`);
117
+ if (args.sudo) parts.push("sudo");
118
+ parts.push(shellEscape(command));
119
+ for (const a of commandArgs) parts.push(shellEscape(a));
120
+ const injectedCmd = parts.join(" ") + "; exit $?\n";
121
+ log.success("Agent is ready. Connecting interactive shell...");
122
+ let extender = null;
123
+ if (!args["no-extend-timeout"] && state.timeoutMs) {
124
+ extender = new TimeoutExtender({
125
+ vmId: args.vmId,
126
+ store,
127
+ paths
128
+ });
129
+ extender.start();
130
+ }
131
+ try {
132
+ await new ShellSession({
133
+ host: guestIp,
134
+ port,
135
+ token: state.agentToken,
136
+ initialCommand: injectedCmd
137
+ }).connect();
138
+ } finally {
139
+ extender?.stop();
140
+ }
141
+ cmdLog.set({
142
+ vmId: args.vmId,
143
+ mode: "interactive",
144
+ command,
145
+ args: commandArgs,
146
+ ...args["no-extend-timeout"] && { noExtendTimeout: true }
147
+ });
148
+ cmdLog.emit();
149
+ } else {
150
+ consola.debug(`exec: ${command} ${commandArgs.join(" ")}`);
151
+ const agent = new AgentClient(`http://${guestIp}:${port}`, state.agentToken);
152
+ const cmd = args.sudo ? "sudo" : command;
153
+ const runArgs = args.sudo ? [command, ...commandArgs] : commandArgs;
154
+ const params = {
155
+ cmd,
156
+ args: runArgs.length > 0 ? runArgs : void 0,
157
+ cwd: args.workdir || void 0,
158
+ env: Object.keys(envVars).length > 0 ? envVars : void 0
159
+ };
160
+ const ac = new AbortController();
161
+ const onSignal = () => ac.abort();
162
+ process.on("SIGINT", onSignal);
163
+ process.on("SIGTERM", onSignal);
164
+ try {
165
+ const result = await (await agent.exec(params, {
166
+ signal: ac.signal,
167
+ onStdout: (line) => process.stdout.write(line + "\n"),
168
+ onStderr: (line) => process.stderr.write(line + "\n")
169
+ })).wait();
170
+ process.exitCode = result.exitCode;
171
+ if (result.timedOut) consola.error("Command timed out.");
172
+ } catch (err) {
173
+ if (!ac.signal.aborted) throw err;
174
+ process.exitCode = 130;
175
+ } finally {
176
+ process.removeListener("SIGINT", onSignal);
177
+ process.removeListener("SIGTERM", onSignal);
178
+ }
179
+ cmdLog.set({
180
+ vmId: args.vmId,
181
+ mode: "non-interactive",
182
+ command,
183
+ args: commandArgs
184
+ });
185
+ cmdLog.emit();
186
+ }
187
+ } catch (error) {
188
+ handleCommandError(error, cmdLog);
189
+ process.exitCode = 1;
190
+ }
191
+ }
192
+ });
193
+ export { execCommand as default };
@@ -4,6 +4,7 @@ import { r as getOutputMode, t as createCommandLogger } from "./logger.mjs";
4
4
  import { d as timeRemaining, l as table, u as timeAgo } from "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { consola } from "consola";
8
9
  import { defineCommand } from "citty";
9
10
  const STATUS_COLORS = {
@@ -4,6 +4,7 @@ import { r as getOutputMode, t as createCommandLogger } from "./logger.mjs";
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { d as validateCidr, i as parseDomains, n as parseCidrList, s as parseNetworkPolicy } from "./validation.mjs";
8
9
  import { consola } from "consola";
9
10
  import { defineCommand } from "citty";
@@ -4,6 +4,7 @@ import { t as createCommandLogger } from "./logger.mjs";
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { consola } from "consola";
8
9
  import { defineCommand } from "citty";
9
10
  const removeCommand = defineCommand({
@@ -100,6 +100,7 @@ var ShellSession = class {
100
100
  process.stdin.resume();
101
101
  this.ws.send(serializeReady());
102
102
  if (process.stdout.columns && process.stdout.rows) this.ws.send(serializeResize(process.stdout.columns, process.stdout.rows));
103
+ if (this.opts.initialCommand) this.ws.send(serializeData(Buffer.from(this.opts.initialCommand)));
103
104
  process.stdin.on("data", onStdinData);
104
105
  process.stdout.on("resize", onResize);
105
106
  });
@@ -4,6 +4,7 @@ import { t as createCommandLogger } from "./logger.mjs";
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { defineCommand } from "citty";
8
9
  const startCommand = defineCommand({
9
10
  meta: {
@@ -4,6 +4,7 @@ import { t as createCommandLogger } from "./logger.mjs";
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { consola } from "consola";
8
9
  import { defineCommand } from "citty";
9
10
  const stopCommand = defineCommand({
@@ -1,6 +1,6 @@
1
1
  import { V as policyConflictError } from "./errors.mjs";
2
2
  import { s as parseDuration } from "./vm-state.mjs";
3
- import { f as assertSnapshotExists } from "./context.mjs";
3
+ import { d as assertSnapshotExists } from "./context.mjs";
4
4
  import { c as parsePublishedPorts, d as validateCidr, f as validatePublishedPortsAvailable, i as parseDomains, l as parseRuntime, n as parseCidrList, o as parseMemoryMib, r as parseDiskSizeGb, s as parseNetworkPolicy, u as parseVcpuCount } from "./validation.mjs";
5
5
  function parseCreateInput(args, paths) {
6
6
  const vcpus = parseVcpuCount(args.vcpus);
@@ -0,0 +1,66 @@
1
+ import { c as safeKill } from "./vm-state.mjs";
2
+ import { t as spawnTimeoutKiller } from "./timeout-killer.mjs";
3
+ import { join } from "node:path";
4
+ const DEFAULT_INTERVAL_MS = 300 * 1e3;
5
+ var TimeoutExtender = class {
6
+ _timer = null;
7
+ _previousKillerPid = null;
8
+ _vmId;
9
+ _store;
10
+ _paths;
11
+ _intervalMs;
12
+ _signal;
13
+ constructor(opts) {
14
+ this._vmId = opts.vmId;
15
+ this._store = opts.store;
16
+ this._paths = opts.paths;
17
+ this._intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
18
+ this._signal = opts.signal;
19
+ }
20
+ start() {
21
+ if (this._timer) return;
22
+ if (this._signal) {
23
+ this._signal.addEventListener("abort", () => this.stop(), { once: true });
24
+ if (this._signal.aborted) {
25
+ this.stop();
26
+ return;
27
+ }
28
+ }
29
+ this._extendSafe();
30
+ this._timer = setInterval(() => this._extendSafe(), this._intervalMs);
31
+ }
32
+ stop() {
33
+ if (this._timer) {
34
+ clearInterval(this._timer);
35
+ this._timer = null;
36
+ }
37
+ if (this._previousKillerPid !== null) {
38
+ safeKill(this._previousKillerPid);
39
+ this._previousKillerPid = null;
40
+ }
41
+ }
42
+ _extendSafe() {
43
+ try {
44
+ this._extend();
45
+ } catch {
46
+ this.stop();
47
+ }
48
+ }
49
+ _extend() {
50
+ const state = this._store.load(this._vmId);
51
+ if (!state || state.status !== "running" || !state.timeoutMs) return;
52
+ const timeoutAt = new Date(Date.now() + state.timeoutMs).toISOString();
53
+ this._store.update(this._vmId, { timeoutAt });
54
+ if (this._previousKillerPid !== null) {
55
+ safeKill(this._previousKillerPid);
56
+ this._previousKillerPid = null;
57
+ }
58
+ if (state.pid) this._previousKillerPid = spawnTimeoutKiller({
59
+ vmId: this._vmId,
60
+ pid: state.pid,
61
+ timeoutMs: state.timeoutMs,
62
+ stateFile: join(this._paths.vmsDir, `${this._vmId}.json`)
63
+ }).pid ?? null;
64
+ }
65
+ };
66
+ export { TimeoutExtender as t };
@@ -0,0 +1,33 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Spawn a detached bash process that kills the VM after timeout.
4
+ * The process sleeps for the timeout duration, then verifies the VM
5
+ * is still running with the expected PID before sending SIGTERM.
6
+ */
7
+ function spawnTimeoutKiller(opts) {
8
+ const { vmId, pid, timeoutMs, stateFile } = opts;
9
+ const timeoutSec = String(Math.ceil(timeoutMs / 1e3));
10
+ const killer = spawn("bash", [
11
+ "-c",
12
+ [
13
+ "sleep \"$1\"",
14
+ "STATE=$(cat -- \"$2\" 2>/dev/null) || exit 0",
15
+ "echo \"$STATE\" | grep -q '\"status\":\"running\"' || exit 0",
16
+ "echo \"$STATE\" | grep -q '\"pid\":'\"$3\" || exit 0",
17
+ "[ -d \"/proc/$3\" ] || exit 0",
18
+ "grep -aq -- \"$4\" \"/proc/$3/cmdline\" 2>/dev/null || exit 0",
19
+ "kill -- \"$3\" 2>/dev/null"
20
+ ].join(" && "),
21
+ "bash",
22
+ timeoutSec,
23
+ stateFile,
24
+ String(pid),
25
+ vmId
26
+ ], {
27
+ detached: true,
28
+ stdio: "ignore"
29
+ });
30
+ killer.unref();
31
+ return killer;
32
+ }
33
+ export { spawnTimeoutKiller as t };
package/dist/bin/cli.mjs CHANGED
@@ -59,6 +59,7 @@ runMain(defineCommand({
59
59
  connect: () => import("../_chunks/connect.mjs").then((m) => m.default),
60
60
  upload: () => import("../_chunks/upload.mjs").then((m) => m.default),
61
61
  download: () => import("../_chunks/download.mjs").then((m) => m.default),
62
+ exec: () => import("../_chunks/exec.mjs").then((m) => m.default),
62
63
  network: () => import("../_chunks/network.mjs").then((m) => m.default)
63
64
  }
64
65
  }));
package/dist/index.d.mts CHANGED
@@ -2779,6 +2779,76 @@ declare class FirecrackerClient {
2779
2779
  static getVersion(baseDir: string): Promise<string | undefined>;
2780
2780
  }
2781
2781
  //#endregion
2782
+ //#region src/lib/command.d.ts
2783
+ interface LogEntry {
2784
+ stream: "stdout" | "stderr";
2785
+ data: string;
2786
+ }
2787
+ interface CommandInit {
2788
+ agent: AgentClient;
2789
+ cmdId: string;
2790
+ startedAt: Date;
2791
+ stream: AsyncIterable<RunEvent>;
2792
+ signal?: AbortSignal;
2793
+ onStdout?: (line: string) => void;
2794
+ onStderr?: (line: string) => void;
2795
+ }
2796
+ declare class CommandFinished {
2797
+ readonly cmdId: string;
2798
+ readonly exitCode: number;
2799
+ readonly stdout: string;
2800
+ readonly stderr: string;
2801
+ readonly output: string;
2802
+ readonly timedOut: boolean;
2803
+ readonly startedAt: Date;
2804
+ constructor(opts: {
2805
+ cmdId: string;
2806
+ exitCode: number;
2807
+ stdout: string;
2808
+ stderr: string;
2809
+ output: string;
2810
+ timedOut: boolean;
2811
+ startedAt: Date;
2812
+ });
2813
+ get ok(): boolean;
2814
+ }
2815
+ declare class Command {
2816
+ readonly cmdId: string;
2817
+ private _startedAt;
2818
+ private _exitCode;
2819
+ private _timedOut;
2820
+ private _logEntries;
2821
+ private _logTruncated;
2822
+ private _eventQueue;
2823
+ private _completion;
2824
+ private _stdoutPromise;
2825
+ private _stderrPromise;
2826
+ private _outputPromise;
2827
+ private _agent;
2828
+ constructor(init: CommandInit);
2829
+ get startedAt(): Date;
2830
+ get exitCode(): number | null;
2831
+ logs(opts?: {
2832
+ signal?: AbortSignal;
2833
+ }): AsyncGenerator<LogEntry>;
2834
+ stdout(opts?: {
2835
+ signal?: AbortSignal;
2836
+ }): Promise<string>;
2837
+ stderr(opts?: {
2838
+ signal?: AbortSignal;
2839
+ }): Promise<string>;
2840
+ output(stream?: "stdout" | "stderr" | "both", opts?: {
2841
+ signal?: AbortSignal;
2842
+ }): Promise<string>;
2843
+ wait(opts?: {
2844
+ signal?: AbortSignal;
2845
+ }): Promise<CommandFinished>;
2846
+ kill(signal?: string, opts?: {
2847
+ abortSignal?: AbortSignal;
2848
+ }): Promise<void>;
2849
+ private _withSignal;
2850
+ }
2851
+ //#endregion
2782
2852
  //#region src/services/agent.d.ts
2783
2853
  interface RunParams {
2784
2854
  cmd: string;
@@ -2808,6 +2878,11 @@ interface SessionInfo {
2808
2878
  createdAt: string;
2809
2879
  subscriberCount: number;
2810
2880
  }
2881
+ interface RunCommandParams extends RunParams {
2882
+ signal?: AbortSignal;
2883
+ onStdout?: (line: string) => void;
2884
+ onStderr?: (line: string) => void;
2885
+ }
2811
2886
  declare class AgentClient {
2812
2887
  private baseUrl;
2813
2888
  private token;
@@ -2816,11 +2891,23 @@ declare class AgentClient {
2816
2891
  status: string;
2817
2892
  version: string;
2818
2893
  }>;
2819
- run(params: RunParams): AsyncGenerator<RunEvent>;
2820
- killCommand(cmdId: string, signal?: string): Promise<void>;
2894
+ run(params: RunParams, signal?: AbortSignal): AsyncGenerator<RunEvent>;
2895
+ killCommand(cmdId: string, signal?: string, abortSignal?: AbortSignal): Promise<void>;
2821
2896
  writeFiles(files: WriteFileEntry[], extractDir?: string): Promise<void>;
2822
2897
  listShellSessions(): Promise<SessionInfo[]>;
2823
2898
  killShellSession(sessionId: string): Promise<void>;
2899
+ exec(params: RunParams, opts?: {
2900
+ signal?: AbortSignal;
2901
+ onStdout?: (line: string) => void;
2902
+ onStderr?: (line: string) => void;
2903
+ }): Promise<Command>;
2904
+ runCommand(cmd: string, args?: string[], opts?: {
2905
+ signal?: AbortSignal;
2906
+ }): Promise<CommandFinished>;
2907
+ runCommand(params: RunCommandParams & {
2908
+ detached: true;
2909
+ }): Promise<Command>;
2910
+ runCommand(params: RunCommandParams): Promise<CommandFinished>;
2824
2911
  readFile(path: string): Promise<Buffer | null>;
2825
2912
  }
2826
2913
  //#endregion
@@ -2843,6 +2930,7 @@ declare class TimeoutExtender {
2843
2930
  constructor(opts: TimeoutExtenderOptions);
2844
2931
  start(): void;
2845
2932
  stop(): void;
2933
+ private _extendSafe;
2846
2934
  private _extend;
2847
2935
  }
2848
2936
  //#endregion
@@ -2954,6 +3042,7 @@ interface ShellSessionOptions {
2954
3042
  token: string;
2955
3043
  shell?: string;
2956
3044
  sessionId?: string;
3045
+ initialCommand?: string;
2957
3046
  }
2958
3047
  interface ShellCloseInfo {
2959
3048
  /** true when the shell process exited (e.g. user typed `exit`) */
package/dist/index.mjs CHANGED
@@ -2,73 +2,19 @@ import { n as vmsanPaths } from "./_chunks/paths.mjs";
2
2
  import { A as invalidDiskSizeRangeError, B as mutuallyExclusiveFlagsError, C as vmNotStoppedError, D as invalidCidrOctetError, E as invalidCidrFormatError, F as invalidImageRefTagError, H as portConflictError, I as invalidIntegerFlagError, L as invalidNetworkPolicyError, M as invalidDomainPatternError, N as invalidDurationError, O as invalidCidrPrefixError, P as invalidImageRefEmptyError, R as invalidPortError, S as vmNotRunningError, T as ValidationError, U as VmsanError, V as policyConflictError, _ as chrootNotFoundError, a as noKernelDirError, b as vmNoAgentTokenError, c as TimeoutError, d as socketTimeoutError, f as NetworkError, g as VmError, h as firecrackerApiError, i as noExt4RootfsError, j as invalidDomainError, k as invalidDiskSizeFormatError, l as agentTimeoutError, m as FirecrackerApiError, n as SetupError, o as noKernelError, p as defaultInterfaceNotFoundError, r as missingBinaryError, s as noRootfsDirError, t as handleCommandError, u as lockTimeoutError, v as networkSlotsExhaustedError, w as vmStateNotFoundError, x as vmNotFoundError, y as snapshotNotFoundError, z as invalidRuntimeError } from "./_chunks/errors.mjs";
3
3
  import { i as initVmsanLogger, n as createScopedLogger, r as getOutputMode, t as createCommandLogger } from "./_chunks/logger.mjs";
4
4
  import { a as isProcessAlive, c as safeKill, d as timeRemaining, f as toError, i as generateVmId, l as table, n as findFreeNetworkSlot, o as mkdirSecure, p as writeSecure, r as getActiveTapSlots, s as parseDuration, t as FileVmStateStore, u as timeAgo } from "./_chunks/vm-state.mjs";
5
- import { C as createDefaultLogger, S as NetworkManager, _ as validateEnvironment, a as ensureSeccompFilter, b as detectCgroupVersion, c as cleanupChroot, d as markVmAsError, g as getVmPid, h as getVmJailerPid, i as compileSeccompFilter, l as cleanupNetwork, m as findRootfs, n as VMService, o as resolveImageRootfs, p as findKernel, r as spawnTimeoutKiller, s as buildInitialVmState, t as createVmsan, u as killOrphanVmProcess, v as waitForSocket, w as createSilentLogger, x as FileLock, y as Jailer } from "./_chunks/context.mjs";
5
+ import { C as createSilentLogger, S as createDefaultLogger, _ as waitForSocket, a as resolveImageRootfs, b as FileLock, c as cleanupNetwork, f as findKernel, g as validateEnvironment, h as getVmPid, i as ensureSeccompFilter, l as killOrphanVmProcess, m as getVmJailerPid, n as VMService, o as buildInitialVmState, p as findRootfs, r as compileSeccompFilter, s as cleanupChroot, t as createVmsan, u as markVmAsError, v as Jailer, x as NetworkManager, y as detectCgroupVersion } from "./_chunks/context.mjs";
6
6
  import { n as firecrackerFetch, t as FirecrackerClient } from "./_chunks/firecracker.mjs";
7
+ import { t as spawnTimeoutKiller } from "./_chunks/timeout-killer.mjs";
7
8
  import { t as AgentClient } from "./_chunks/agent.mjs";
9
+ import { t as TimeoutExtender } from "./_chunks/timeout-extender.mjs";
8
10
  import { n as waitForAgent, t as resolveVmState } from "./_chunks/vm-context.mjs";
9
11
  import { n as connectShell, t as ShellSession } from "./_chunks/shell.mjs";
10
12
  import { a as parseImageReference, c as parsePublishedPorts, d as validateCidr, f as validatePublishedPortsAvailable, i as parseDomains, l as parseRuntime, n as parseCidrList, o as parseMemoryMib, r as parseDiskSizeGb, s as parseNetworkPolicy, t as parseBandwidth, u as parseVcpuCount } from "./_chunks/validation.mjs";
11
13
  import { n as parseCreateInput, t as buildCreateSummaryLines } from "./_chunks/summary.mjs";
12
- import { join } from "node:path";
13
14
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
15
  function definePlugin(plugin) {
15
16
  return plugin;
16
17
  }
17
- const DEFAULT_INTERVAL_MS = 300 * 1e3;
18
- var TimeoutExtender = class {
19
- _timer = null;
20
- _previousKillerPid = null;
21
- _vmId;
22
- _store;
23
- _paths;
24
- _intervalMs;
25
- _signal;
26
- constructor(opts) {
27
- this._vmId = opts.vmId;
28
- this._store = opts.store;
29
- this._paths = opts.paths;
30
- this._intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
31
- this._signal = opts.signal;
32
- }
33
- start() {
34
- if (this._timer) return;
35
- if (this._signal) {
36
- this._signal.addEventListener("abort", () => this.stop(), { once: true });
37
- if (this._signal.aborted) {
38
- this.stop();
39
- return;
40
- }
41
- }
42
- this._extend();
43
- this._timer = setInterval(() => this._extend(), this._intervalMs);
44
- }
45
- stop() {
46
- if (this._timer) {
47
- clearInterval(this._timer);
48
- this._timer = null;
49
- }
50
- if (this._previousKillerPid !== null) {
51
- safeKill(this._previousKillerPid);
52
- this._previousKillerPid = null;
53
- }
54
- }
55
- _extend() {
56
- const state = this._store.load(this._vmId);
57
- if (!state || state.status !== "running" || !state.timeoutMs) return;
58
- const timeoutAt = new Date(Date.now() + state.timeoutMs).toISOString();
59
- this._store.update(this._vmId, { timeoutAt });
60
- if (this._previousKillerPid !== null) {
61
- safeKill(this._previousKillerPid);
62
- this._previousKillerPid = null;
63
- }
64
- if (state.pid) this._previousKillerPid = spawnTimeoutKiller({
65
- vmId: this._vmId,
66
- pid: state.pid,
67
- timeoutMs: state.timeoutMs,
68
- stateFile: join(this._paths.vmsDir, `${this._vmId}.json`)
69
- }).pid ?? null;
70
- }
71
- };
72
18
  var MemoryVmStateStore = class {
73
19
  states = /* @__PURE__ */ new Map();
74
20
  save(state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vmsan",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "Firecracker microVM sandbox toolkit",
5
5
  "homepage": "https://github.com/angelorc/vmsan",
6
6
  "bugs": "https://github.com/angelorc/vmsan/issues",