mirror of
https://github.com/hanzoai/vm.git
synced 2026-10-01 00:47:56 +00:00
feat: add Bun SDK and stdio mode for programmatic VM control
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
sdk-unit:
|
||||
name: SDK unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
working-directory: packages/sdk
|
||||
- run: bun test test/unit/
|
||||
working-directory: packages/sdk
|
||||
|
||||
sdk-lint:
|
||||
name: SDK lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
working-directory: packages/sdk
|
||||
- run: bunx --bun @biomejs/biome check src/
|
||||
working-directory: packages/sdk
|
||||
|
||||
rust-check:
|
||||
name: Rust check
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo check -p shuru-cli
|
||||
|
||||
sdk-integration:
|
||||
name: SDK integration tests
|
||||
if: github.event_name == 'push'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-musl
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install musl cross-compiler
|
||||
run: brew install messense/macos-cross-toolchains/aarch64-unknown-linux-musl
|
||||
|
||||
- name: Build shuru-guest
|
||||
run: cargo build -p shuru-guest --target aarch64-unknown-linux-musl --release
|
||||
|
||||
- name: Build shuru CLI
|
||||
run: cargo build -p shuru-cli
|
||||
|
||||
- name: Codesign
|
||||
run: codesign --entitlements shuru.entitlements --force -s - target/debug/shuru
|
||||
|
||||
- name: Download assets
|
||||
run: target/debug/shuru init
|
||||
|
||||
- name: Install SDK deps
|
||||
run: bun install
|
||||
working-directory: packages/sdk
|
||||
|
||||
- name: Run integration tests
|
||||
run: bun test test/integration/
|
||||
working-directory: packages/sdk
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish SDK
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref_name, 'sdk-v')
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun install
|
||||
working-directory: packages/sdk
|
||||
|
||||
- run: bun test test/unit/
|
||||
working-directory: packages/sdk
|
||||
|
||||
- run: bunx --bun @biomejs/biome check src/
|
||||
working-directory: packages/sdk
|
||||
|
||||
- run: npm publish --access public --provenance
|
||||
working-directory: packages/sdk
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -12,7 +12,6 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ── Build OS image (kernel + initramfs + rootfs) on Linux ───────────
|
||||
build-os-image:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
@@ -49,7 +48,6 @@ jobs:
|
||||
path: shuru-os-*.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
# ── Build CLI binary on macOS (Apple Silicon) ───────────────────────
|
||||
build-cli:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
@@ -78,7 +76,6 @@ jobs:
|
||||
path: shuru-*.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
# ── Create GitHub Release ───────────────────────────────────────────
|
||||
release:
|
||||
needs: [build-os-image, build-cli]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,12 @@ struct GithubRelease {
|
||||
|
||||
/// Check for a newer release and upgrade the CLI binary + OS image.
|
||||
pub fn upgrade(data_dir: &str) -> Result<()> {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if exe.to_string_lossy().contains("/Cellar/") {
|
||||
bail!("This copy was installed via Homebrew. Please run `brew upgrade shuru` instead");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("shuru: checking for updates...");
|
||||
|
||||
let api_url = format!(
|
||||
|
||||
@@ -65,6 +65,10 @@ pub(crate) enum Commands {
|
||||
#[arg(long)]
|
||||
console: bool,
|
||||
|
||||
/// Run in stdio mode (JSON-lines protocol over stdin/stdout)
|
||||
#[arg(long, hide = true)]
|
||||
stdio: bool,
|
||||
|
||||
/// Command and arguments to run inside the VM
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
command: Vec<String>,
|
||||
|
||||
@@ -2,6 +2,7 @@ mod assets;
|
||||
mod checkpoint;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod stdio;
|
||||
mod vm;
|
||||
|
||||
use std::process;
|
||||
@@ -29,6 +30,7 @@ fn main() -> Result<()> {
|
||||
vm,
|
||||
from,
|
||||
console,
|
||||
stdio,
|
||||
command,
|
||||
} => {
|
||||
let cfg = load_config(vm.config.as_deref())?;
|
||||
@@ -44,7 +46,9 @@ fn main() -> Result<()> {
|
||||
|
||||
let prepared = vm::prepare_vm(&vm, &cfg, from.as_deref())?;
|
||||
|
||||
let exit_code = if console {
|
||||
let exit_code = if stdio {
|
||||
stdio::run_stdio(&prepared)?
|
||||
} else if console {
|
||||
run_console(&prepared)?
|
||||
} else {
|
||||
vm::run_command(&prepared, &command)?
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shuru_vm::Sandbox;
|
||||
|
||||
use crate::vm::{self, PreparedVm};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Request {
|
||||
id: String,
|
||||
#[serde(flatten)]
|
||||
cmd: Command,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum Command {
|
||||
Exec { argv: Vec<String> },
|
||||
Checkpoint { name: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ReadyMsg {
|
||||
r#type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExecResponse {
|
||||
id: String,
|
||||
r#type: &'static str,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
exit_code: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckpointResponse {
|
||||
id: String,
|
||||
r#type: &'static str,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
id: String,
|
||||
r#type: &'static str,
|
||||
error: String,
|
||||
}
|
||||
|
||||
fn send_json(w: &mut impl Write, value: &impl Serialize) -> Result<()> {
|
||||
let line = serde_json::to_string(value)?;
|
||||
writeln!(w, "{}", line)?;
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
|
||||
let mut out = io::stdout().lock();
|
||||
|
||||
let sandbox = build_sandbox(prepared)?;
|
||||
sandbox.start()?;
|
||||
|
||||
let _fwd = if !prepared.forwards.is_empty() {
|
||||
Some(sandbox.start_port_forwarding(&prepared.forwards)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
send_json(&mut out, &ReadyMsg { r#type: "ready" })?;
|
||||
|
||||
let stdin = io::stdin().lock();
|
||||
for line in stdin.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let req: Request = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = ErrorResponse {
|
||||
id: String::new(),
|
||||
r#type: "error",
|
||||
error: format!("invalid request: {}", e),
|
||||
};
|
||||
send_json(&mut out, &resp)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match req.cmd {
|
||||
Command::Exec { argv } => {
|
||||
handle_exec(&sandbox, &req.id, &argv, &mut out)?;
|
||||
}
|
||||
Command::Checkpoint { name } => {
|
||||
handle_checkpoint(&sandbox, prepared, &req.id, &name, &mut out)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sandbox.stop();
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn build_sandbox(prepared: &PreparedVm) -> Result<Sandbox> {
|
||||
let mut builder = Sandbox::builder()
|
||||
.kernel(&prepared.kernel_path)
|
||||
.rootfs(&prepared.work_rootfs)
|
||||
.cpus(prepared.cpus)
|
||||
.memory_mb(prepared.memory)
|
||||
.allow_net(prepared.allow_net)
|
||||
.console(false);
|
||||
|
||||
if let Some(initrd) = &prepared.initrd_path {
|
||||
builder = builder.initrd(initrd);
|
||||
}
|
||||
|
||||
for m in &prepared.mounts {
|
||||
builder = builder.mount(m.clone());
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
fn handle_exec(
|
||||
sandbox: &Sandbox,
|
||||
id: &str,
|
||||
argv: &[String],
|
||||
out: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
|
||||
let exit_code = match sandbox.exec(argv, &mut stdout_buf, &mut stderr_buf) {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
return send_json(
|
||||
out,
|
||||
&ErrorResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "error",
|
||||
error: format!("exec failed: {}", e),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
send_json(
|
||||
out,
|
||||
&ExecResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "exec",
|
||||
stdout: String::from_utf8_lossy(&stdout_buf).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
|
||||
exit_code,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_checkpoint(
|
||||
sandbox: &Sandbox,
|
||||
prepared: &PreparedVm,
|
||||
id: &str,
|
||||
name: &str,
|
||||
out: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
let mut discard_out = Vec::new();
|
||||
let mut discard_err = Vec::new();
|
||||
if let Err(e) = sandbox.exec(&["sync"], &mut discard_out, &mut discard_err) {
|
||||
return send_json(
|
||||
out,
|
||||
&ErrorResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "error",
|
||||
error: format!("checkpoint sync failed: {}", e),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let data_dir = shuru_vm::default_data_dir();
|
||||
let checkpoints_dir = format!("{}/checkpoints", data_dir);
|
||||
let checkpoint_path = format!("{}/{}.ext4", checkpoints_dir, name);
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&checkpoints_dir) {
|
||||
return send_json(
|
||||
out,
|
||||
&ErrorResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "error",
|
||||
error: format!("failed to create checkpoints dir: {}", e),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = vm::clone_file(&prepared.work_rootfs, &checkpoint_path) {
|
||||
return send_json(
|
||||
out,
|
||||
&ErrorResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "error",
|
||||
error: format!("checkpoint clone failed: {}", e),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
send_json(
|
||||
out,
|
||||
&CheckpointResponse {
|
||||
id: id.to_string(),
|
||||
r#type: "checkpoint",
|
||||
ok: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
bun.lock
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@superhq/shuru",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"test": "bun test test/unit/",
|
||||
"test:integration": "bun test test/integration/",
|
||||
"check": "biome check src/",
|
||||
"format": "biome check --write src/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@types/bun": "^1.3.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sandbox } from "./sandbox";
|
||||
export type { ExecResult, StartOptions, StdioResponse } from "./types";
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Subprocess } from "bun";
|
||||
import type { StdioResponse } from "./types";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: StdioResponse) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
|
||||
export class ShuruProcess {
|
||||
private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private idCounter = 0;
|
||||
|
||||
async start(args: string[]): Promise<void> {
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
this.readLoop();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("shuru: timed out waiting for ready signal (30s)"));
|
||||
}, 30_000);
|
||||
|
||||
this.pending.set("__ready__", {
|
||||
resolve: () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
reject: (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async send(msg: Record<string, unknown>): Promise<StdioResponse> {
|
||||
if (!this.proc) throw new Error("shuru process not started");
|
||||
|
||||
const id = String(++this.idCounter);
|
||||
const line = `${JSON.stringify({ id, ...msg })}\n`;
|
||||
const proc = this.proc;
|
||||
|
||||
return new Promise<StdioResponse>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
proc.stdin.write(line);
|
||||
proc.stdin.flush();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.proc) return;
|
||||
|
||||
try {
|
||||
this.proc.stdin.end();
|
||||
} catch {
|
||||
// stdin may already be closed
|
||||
}
|
||||
|
||||
const exited = this.proc.exited;
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("shuru: shutdown timed out (5s)")),
|
||||
5_000,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([exited, timeout]);
|
||||
} catch {
|
||||
this.proc.kill();
|
||||
await this.proc.exited;
|
||||
}
|
||||
|
||||
for (const [, req] of this.pending) {
|
||||
req.reject(new Error("shuru process stopped"));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.proc = null;
|
||||
}
|
||||
|
||||
private async readLoop(): Promise<void> {
|
||||
if (!this.proc?.stdout) return;
|
||||
|
||||
const reader = this.proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
this.dispatch(JSON.parse(line) as StdioResponse);
|
||||
} catch {
|
||||
// malformed line
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// stream closed
|
||||
}
|
||||
|
||||
for (const [, req] of this.pending) {
|
||||
req.reject(new Error("shuru process exited unexpectedly"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private dispatch(msg: StdioResponse): void {
|
||||
if (msg.type === "ready") {
|
||||
const req = this.pending.get("__ready__");
|
||||
if (req) {
|
||||
this.pending.delete("__ready__");
|
||||
req.resolve(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = msg;
|
||||
if (!id) return;
|
||||
|
||||
const req = this.pending.get(id);
|
||||
if (!req) return;
|
||||
this.pending.delete(id);
|
||||
|
||||
if (msg.type === "error") {
|
||||
req.reject(new Error(msg.error));
|
||||
} else {
|
||||
req.resolve(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ShuruProcess } from "./process";
|
||||
import type { ExecResult, StartOptions } from "./types";
|
||||
|
||||
export class Sandbox {
|
||||
private proc: ShuruProcess;
|
||||
|
||||
private constructor(proc: ShuruProcess) {
|
||||
this.proc = proc;
|
||||
}
|
||||
|
||||
static async start(opts: StartOptions = {}): Promise<Sandbox> {
|
||||
const bin = opts.shuruBin ?? "shuru";
|
||||
const args = buildArgs(bin, opts);
|
||||
|
||||
const proc = new ShuruProcess();
|
||||
await proc.start(args);
|
||||
|
||||
return new Sandbox(proc);
|
||||
}
|
||||
|
||||
async exec(command: string): Promise<ExecResult> {
|
||||
const resp = await this.proc.send({
|
||||
type: "exec",
|
||||
argv: ["sh", "-c", command],
|
||||
});
|
||||
if (resp.type !== "exec") {
|
||||
throw new Error(`unexpected response type: ${resp.type}`);
|
||||
}
|
||||
return {
|
||||
stdout: resp.stdout,
|
||||
stderr: resp.stderr,
|
||||
exitCode: resp.exit_code,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const resp = await this.proc.send({
|
||||
type: "exec",
|
||||
argv: ["cat", path],
|
||||
});
|
||||
if (resp.type !== "exec") {
|
||||
throw new Error(`unexpected response type: ${resp.type}`);
|
||||
}
|
||||
if (resp.exit_code !== 0) {
|
||||
throw new Error(
|
||||
`readFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
||||
);
|
||||
}
|
||||
return resp.stdout;
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
const b64 = btoa(content);
|
||||
const resp = await this.proc.send({
|
||||
type: "exec",
|
||||
argv: ["sh", "-c", `echo '${b64}' | base64 -d > "$1"`, "--", path],
|
||||
});
|
||||
if (resp.type !== "exec") {
|
||||
throw new Error(`unexpected response type: ${resp.type}`);
|
||||
}
|
||||
if (resp.exit_code !== 0) {
|
||||
throw new Error(
|
||||
`writeFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async checkpoint(name: string): Promise<void> {
|
||||
const resp = await this.proc.send({ type: "checkpoint", name });
|
||||
if (resp.type !== "checkpoint") {
|
||||
throw new Error(`unexpected response type: ${resp.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.proc.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal exported for testing */
|
||||
export function buildArgs(bin: string, opts: StartOptions): string[] {
|
||||
const args = [...bin.split(/\s+/), "run", "--stdio"];
|
||||
|
||||
if (opts.from) args.push("--from", opts.from);
|
||||
if (opts.cpus) args.push("--cpus", String(opts.cpus));
|
||||
if (opts.memory) args.push("--memory", String(opts.memory));
|
||||
if (opts.diskSize) args.push("--disk-size", String(opts.diskSize));
|
||||
if (opts.allowNet) args.push("--allow-net");
|
||||
|
||||
if (opts.ports) {
|
||||
for (const p of opts.ports) {
|
||||
args.push("-p", p);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.mounts) {
|
||||
for (const [host, guest] of Object.entries(opts.mounts)) {
|
||||
args.push("--mount", `${host}:${guest}`);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface StartOptions {
|
||||
from?: string;
|
||||
cpus?: number;
|
||||
memory?: number;
|
||||
diskSize?: number;
|
||||
allowNet?: boolean;
|
||||
ports?: string[];
|
||||
mounts?: Record<string, string>;
|
||||
shuruBin?: string;
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export type StdioResponse =
|
||||
| { type: "ready" }
|
||||
| {
|
||||
type: "exec";
|
||||
id: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number;
|
||||
}
|
||||
| { type: "checkpoint"; id: string; ok: boolean }
|
||||
| { type: "error"; id: string; error: string };
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Integration tests that run against the real shuru binary + VM.
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. cargo build -p shuru-cli
|
||||
* 2. codesign --entitlements shuru.entitlements --force -s - target/debug/shuru
|
||||
* 3. shuru init
|
||||
*
|
||||
* Run: bun test test/integration/
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { Sandbox } from "../../src/sandbox";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
||||
const SHURU_BIN = resolve(REPO_ROOT, "target/debug/shuru");
|
||||
|
||||
const canRun = existsSync(SHURU_BIN);
|
||||
|
||||
describe.if(canRun)("sandbox lifecycle", () => {
|
||||
let sb: Sandbox;
|
||||
|
||||
beforeAll(async () => {
|
||||
sb = await Sandbox.start({ shuruBin: SHURU_BIN });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await sb?.stop();
|
||||
}, 15_000);
|
||||
|
||||
test("exec echo", async () => {
|
||||
const result = await sb.exec("echo hello");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("hello");
|
||||
expect(result.stderr).toBe("");
|
||||
}, 30_000);
|
||||
|
||||
test("exec captures exit code", async () => {
|
||||
const result = await sb.exec("exit 42");
|
||||
expect(result.exitCode).toBe(42);
|
||||
}, 30_000);
|
||||
|
||||
test("exec captures stderr", async () => {
|
||||
const result = await sb.exec("echo err >&2");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr.trim()).toBe("err");
|
||||
}, 30_000);
|
||||
|
||||
test("writeFile and readFile roundtrip", async () => {
|
||||
const content = "hello from integration test\nline 2\n";
|
||||
await sb.writeFile("/tmp/sdk-test.txt", content);
|
||||
const read = await sb.readFile("/tmp/sdk-test.txt");
|
||||
expect(read).toBe(content);
|
||||
}, 30_000);
|
||||
|
||||
test("multiple sequential execs", async () => {
|
||||
await sb.exec("echo a > /tmp/seq.txt");
|
||||
await sb.exec("echo b >> /tmp/seq.txt");
|
||||
const result = await sb.exec("cat /tmp/seq.txt");
|
||||
expect(result.stdout).toBe("a\nb\n");
|
||||
}, 30_000);
|
||||
|
||||
test("checkpoint creates disk snapshot", async () => {
|
||||
const cpPath = resolve(
|
||||
process.env.HOME ?? "/tmp",
|
||||
".local/share/shuru/checkpoints/sdk-integration-test.ext4",
|
||||
);
|
||||
|
||||
try {
|
||||
unlinkSync(cpPath);
|
||||
} catch {
|
||||
// no leftover
|
||||
}
|
||||
|
||||
await sb.exec("echo checkpoint-data > /tmp/cp-test.txt");
|
||||
await sb.checkpoint("sdk-integration-test");
|
||||
expect(existsSync(cpPath)).toBe(true);
|
||||
|
||||
try {
|
||||
unlinkSync(cpPath);
|
||||
} catch {
|
||||
// cleanup
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe.if(!canRun)("sandbox lifecycle (skipped)", () => {
|
||||
test("shuru binary not found", () => {
|
||||
console.log(
|
||||
"Build with: cargo build -p shuru-cli && codesign --entitlements shuru.entitlements --force -s - target/debug/shuru",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildArgs } from "../../src/sandbox";
|
||||
|
||||
describe("buildArgs", () => {
|
||||
test("minimal options", () => {
|
||||
const args = buildArgs("shuru", {});
|
||||
expect(args).toEqual(["shuru", "run", "--stdio"]);
|
||||
});
|
||||
|
||||
test("custom binary path", () => {
|
||||
const args = buildArgs("/usr/local/bin/shuru", {});
|
||||
expect(args).toEqual(["/usr/local/bin/shuru", "run", "--stdio"]);
|
||||
});
|
||||
|
||||
test("multi-word binary", () => {
|
||||
const args = buildArgs("bun mock-shuru.ts", {});
|
||||
expect(args).toEqual(["bun", "mock-shuru.ts", "run", "--stdio"]);
|
||||
});
|
||||
|
||||
test("from checkpoint", () => {
|
||||
const args = buildArgs("shuru", { from: "my-checkpoint" });
|
||||
expect(args).toEqual([
|
||||
"shuru",
|
||||
"run",
|
||||
"--stdio",
|
||||
"--from",
|
||||
"my-checkpoint",
|
||||
]);
|
||||
});
|
||||
|
||||
test("cpus and memory", () => {
|
||||
const args = buildArgs("shuru", { cpus: 4, memory: 4096 });
|
||||
expect(args).toEqual([
|
||||
"shuru",
|
||||
"run",
|
||||
"--stdio",
|
||||
"--cpus",
|
||||
"4",
|
||||
"--memory",
|
||||
"4096",
|
||||
]);
|
||||
});
|
||||
|
||||
test("disk size", () => {
|
||||
const args = buildArgs("shuru", { diskSize: 8192 });
|
||||
expect(args).toEqual(["shuru", "run", "--stdio", "--disk-size", "8192"]);
|
||||
});
|
||||
|
||||
test("allow net", () => {
|
||||
const args = buildArgs("shuru", { allowNet: true });
|
||||
expect(args).toEqual(["shuru", "run", "--stdio", "--allow-net"]);
|
||||
});
|
||||
|
||||
test("allowNet false is omitted", () => {
|
||||
const args = buildArgs("shuru", { allowNet: false });
|
||||
expect(args).toEqual(["shuru", "run", "--stdio"]);
|
||||
});
|
||||
|
||||
test("port forwards", () => {
|
||||
const args = buildArgs("shuru", { ports: ["8080:80", "3000:3000"] });
|
||||
expect(args).toEqual([
|
||||
"shuru",
|
||||
"run",
|
||||
"--stdio",
|
||||
"-p",
|
||||
"8080:80",
|
||||
"-p",
|
||||
"3000:3000",
|
||||
]);
|
||||
});
|
||||
|
||||
test("mounts", () => {
|
||||
const args = buildArgs("shuru", {
|
||||
mounts: { "./src": "/workspace", "./data": "/data" },
|
||||
});
|
||||
expect(args).toEqual([
|
||||
"shuru",
|
||||
"run",
|
||||
"--stdio",
|
||||
"--mount",
|
||||
"./src:/workspace",
|
||||
"--mount",
|
||||
"./data:/data",
|
||||
]);
|
||||
});
|
||||
|
||||
test("all options combined", () => {
|
||||
const args = buildArgs("shuru", {
|
||||
from: "base",
|
||||
cpus: 2,
|
||||
memory: 2048,
|
||||
diskSize: 4096,
|
||||
allowNet: true,
|
||||
ports: ["8080:80"],
|
||||
mounts: { "./src": "/workspace" },
|
||||
});
|
||||
expect(args).toEqual([
|
||||
"shuru",
|
||||
"run",
|
||||
"--stdio",
|
||||
"--from",
|
||||
"base",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048",
|
||||
"--disk-size",
|
||||
"4096",
|
||||
"--allow-net",
|
||||
"-p",
|
||||
"8080:80",
|
||||
"--mount",
|
||||
"./src:/workspace",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user