mirror of
https://github.com/robcholz/vibebox.git
synced 2026-07-01 12:15:30 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1529905e | |||
| a568295bd3 | |||
| b5cd1f2064 | |||
| b425ae4b77 | |||
| b433d3ef93 | |||
| ecfce7acf7 | |||
| 7065144e6f | |||
| 5e95c09c75 | |||
| b1680e54fb | |||
| 0e4c4c7f53 | |||
| 65bfc0b34d | |||
| f6678e7069 | |||
| eafa229542 | |||
| 34d0fb965e | |||
| a3764a361e | |||
| 51c3eff6e5 |
@@ -10,7 +10,6 @@ on:
|
||||
- "README.zh.md"
|
||||
- "docs/**"
|
||||
- "install"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -20,6 +19,8 @@ jobs:
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUST_BACKTRACE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
@@ -34,6 +35,8 @@ jobs:
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUST_BACKTRACE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
@@ -48,6 +51,8 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUST_BACKTRACE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
@@ -60,6 +65,8 @@ jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUST_BACKTRACE: "full"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
@@ -68,3 +75,24 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: cargo test
|
||||
run: cargo test --locked
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: macos-latest
|
||||
needs: [fmt, clippy, build, test]
|
||||
env:
|
||||
RUST_BACKTRACE: "full"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
- name: cargo llvm-cov
|
||||
run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture
|
||||
|
||||
Generated
+76
-1
@@ -82,6 +82,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"bstr",
|
||||
"libc",
|
||||
"predicates",
|
||||
"predicates-core",
|
||||
"predicates-tree",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
@@ -112,6 +127,17 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
@@ -321,6 +347,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
@@ -737,6 +769,33 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"difflib",
|
||||
"predicates-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates-core"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
|
||||
|
||||
[[package]]
|
||||
name = "predicates-tree"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
|
||||
dependencies = [
|
||||
"predicates-core",
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -1007,6 +1066,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -1238,8 +1303,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vibebox"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"block2",
|
||||
"clap",
|
||||
"color-eyre",
|
||||
@@ -1261,6 +1327,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "vibebox"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
edition = "2024"
|
||||
authors = ["Finn Sheng"]
|
||||
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
|
||||
@@ -42,3 +42,10 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dialoguer = "0.12.0"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
mock-vm = []
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">Your ultrafast open source AI sandbox.</p>
|
||||
<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://crates.io/crates/vibebox">
|
||||
@@ -24,33 +24,101 @@
|
||||
<a href="README.zh.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
[](https://vibebox.robcholz.com)
|
||||
**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
|
||||
It’s optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
|
||||
|
||||
**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
|
||||
|
||||
**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
|
||||
provisions a Debian base image (network dependent).
|
||||
|
||||
**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
|
||||
opt-in).
|
||||
|
||||
- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
|
||||
- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
|
||||
- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
|
||||
|
||||
### Quick Demo
|
||||
|
||||
```bash
|
||||
# from any repo
|
||||
cd my-project
|
||||
vibebox
|
||||
```
|
||||
|
||||
What you should see (roughly):
|
||||
|
||||
```text
|
||||
vibebox: starting (session: my-project)
|
||||
vibebox: attaching...
|
||||
vibecoder@vibebox:~/my-project$
|
||||
```
|
||||
|
||||
[](https://vibebox.robcholz.com)
|
||||
|
||||
---
|
||||
|
||||
### Why I built VibeBox
|
||||
|
||||
I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
|
||||
Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
|
||||
or wandering outside the repo.
|
||||
|
||||
VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. It’s built
|
||||
to be “always on” for agent work without turning safety into a chore.
|
||||
|
||||
### Why a micro-VM (vs containers)?
|
||||
|
||||
Containers are great. VibeBox isn’t trying to replace Docker/devcontainers for building services.
|
||||
|
||||
I specifically wanted a VM-shaped default for agent workflows on macOS:
|
||||
|
||||
- **guest-kernel isolation boundary by default:** when I’m letting an agent run arbitrary commands, I want “safe mode”
|
||||
to
|
||||
be a Linux guest, not my host.
|
||||
- **sessions as a first-class workflow:** attach/reuse per repo, multiple terminals into the same sandbox, reliable
|
||||
cleanup to avoid orphan environments.
|
||||
- **explicit mount allowlists as the primary UX:** repo-scoped by default; anything else is an explicit decision.
|
||||
- **minimal per-repo setup:** you *can* reproduce parts of this with compose/devcontainers, but I wanted a single
|
||||
command
|
||||
that works repo-to-repo without maintaining container configs for the basic “safe shell” workflow.
|
||||
|
||||
### Comparison
|
||||
|
||||
Here’s why I didn’t just use existing options:
|
||||
|
||||
- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
|
||||
config + sessions + multi-instance lifecycle.
|
||||
- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
|
||||
go” — it’s a project on its own.
|
||||
- **Docker / devcontainers / devpods**: great ecosystem. My friction wasn’t raw startup time, it was the day-to-day
|
||||
overhead of keeping per-repo agent sandboxes *safe-by-default* (mount allowlists, secrets exposure, attach/reuse,
|
||||
cleanup) without maintaining container configs per repo for the basic workflow.
|
||||
|
||||
That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
|
||||
supports real configuration + sessions, and keeps a hard isolation boundary.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
# install script
|
||||
curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
|
||||
|
||||
# Package managers
|
||||
# package managers
|
||||
cargo install vibebox
|
||||
|
||||
# Or manually (bad)
|
||||
# manual install
|
||||
curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
|
||||
unzip vibebox-macos-arm64.zip
|
||||
mkdir -p ~/.local/bin
|
||||
mv vibe ~/.local/bin
|
||||
mv vibebox ~/.local/bin
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> We truly recommend you to use `YOLO` to install.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- macOS on Apple Silicon (Vibebox uses Apple's virtualization APIs).
|
||||
- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
|
||||
|
||||
**First Run**
|
||||
|
||||
@@ -66,7 +134,7 @@ cd /path/to/your/project
|
||||
vibebox
|
||||
```
|
||||
|
||||
On first run, Vibebox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
|
||||
On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
|
||||
|
||||
**Configuration (`vibebox.toml`)**
|
||||
|
||||
@@ -117,7 +185,7 @@ vibebox explain # show mounts and network info
|
||||
- Default SSH user: `vibecoder`
|
||||
- Hostname: `vibebox`
|
||||
- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
|
||||
- On first login, Vibebox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
|
||||
- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
|
||||
`@anthropic-ai/claude-code` (best-effort).
|
||||
- Shell aliases: `:help` and `:exit`.
|
||||
|
||||
@@ -132,27 +200,24 @@ vibebox explain # show mounts and network info
|
||||
If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
|
||||
submitting a pull request.
|
||||
|
||||
### Using VibeBox
|
||||
|
||||
Feel free to use, but remember to promote VibeBox as well!
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How is this different from other Sandboxes?
|
||||
#### How is this different from other sandboxes?
|
||||
|
||||
Vibebox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
|
||||
VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
|
||||
|
||||
- Warm startup is typically under **6 seconds** on my M3, so you can jump back in quickly.
|
||||
- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
|
||||
- One simple command — `vibebox` — drops you into the sandbox from your project.
|
||||
- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
|
||||
- Sessions are first-class: reuse, multiple terminals, cleanup.
|
||||
|
||||
### Special Thank
|
||||
### Special thanks
|
||||
|
||||
[vibe](https://github.com/lynaghk/vibe) by lynaghk.
|
||||
|
||||
And amazing Rust community, without your rich crates and fantastic toolchain like [crates.io](https://crates.io), this
|
||||
wouldn't be possible!
|
||||
And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
|
||||
be possible!
|
||||
|
||||
---
|
||||
|
||||
**Follow me on X** [X.com](https://x.com/robcholz)
|
||||
**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
|
||||
|
||||
+67
-14
@@ -5,7 +5,7 @@
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">超高速、开源的 AI 沙盒。</p>
|
||||
<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://crates.io/crates/vibebox">
|
||||
@@ -24,30 +24,86 @@
|
||||
<a href="README.md">English</a>
|
||||
</p>
|
||||
|
||||
[](https://vibebox.robcholz.com)
|
||||
**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
|
||||
它面向 *日常使用* 工作流优化:快速热启动、显式挂载、可复用会话。
|
||||
|
||||
**适合谁:** 在 macOS 上使用 coding agents,并且既想要真实隔离又不想牺牲日常效率的人。
|
||||
|
||||
**快速事实:** 在我的 M3 上,热启动通常 **<5s**(因机器/缓存而异);首次运行会下载并初始化 Debian 基础镜像(受网络影响)。
|
||||
|
||||
**安全模型:** Linux 来宾 VM + `vibebox.toml` 显式挂载白名单(默认仅项目目录,其它均需显式允许)。
|
||||
|
||||
- **几秒进入/附加:** `vibebox` 直接进入当前仓库的可复用沙盒
|
||||
- **默认按项目范围:** 显式挂载 + 改动限制在仓库内(repo 优先,其他需 allowlist)
|
||||
- **会话化:** 多实例 + 会话管理(复用、多终端、清理)
|
||||
|
||||
### 快速演示
|
||||
|
||||
```bash
|
||||
# 在任意仓库内
|
||||
cd my-project
|
||||
vibebox
|
||||
```
|
||||
|
||||
你大致会看到:
|
||||
|
||||
```text
|
||||
vibebox: starting (session: my-project)
|
||||
vibebox: attaching...
|
||||
vibecoder@vibebox:~/my-project$
|
||||
```
|
||||
|
||||
[](https://vibebox.robcholz.com)
|
||||
|
||||
---
|
||||
|
||||
### 我为什么做 VibeBox
|
||||
|
||||
我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
|
||||
权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
|
||||
|
||||
VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
|
||||
|
||||
### 为什么是 micro-VM(而不是容器)?
|
||||
|
||||
容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
|
||||
|
||||
我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
|
||||
|
||||
- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
|
||||
- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
|
||||
- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
|
||||
- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
|
||||
shell”体验。
|
||||
|
||||
### 对比
|
||||
|
||||
下面是我为什么没有直接用现成方案的原因:
|
||||
|
||||
- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
|
||||
- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
|
||||
- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
|
||||
safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
|
||||
|
||||
这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# YOLO:一键安装(推荐)
|
||||
# YOLO:一键安装
|
||||
curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
|
||||
|
||||
# Cargo
|
||||
cargo install vibebox
|
||||
|
||||
# 或者手动安装(不推荐)
|
||||
# 或者手动安装
|
||||
curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
|
||||
unzip vibebox-macos-arm64.zip
|
||||
mkdir -p ~/.local/bin
|
||||
mv vibe ~/.local/bin
|
||||
mv vibebox ~/.local/bin
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 强烈建议直接用 `YOLO` 方式安装,省事且更不容易踩坑。
|
||||
|
||||
**系统要求**
|
||||
|
||||
- Apple Silicon 的 macOS(VibeBox 使用了 Apple 的虚拟化 API)。
|
||||
@@ -130,19 +186,16 @@ vibebox explain # 显示挂载与网络信息
|
||||
|
||||
如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
|
||||
|
||||
### 使用 VibeBox
|
||||
|
||||
欢迎使用,也别忘了顺手帮 VibeBox 做点宣传!
|
||||
|
||||
### FAQ
|
||||
|
||||
#### 它和其它 Sandboxes 有什么不同?
|
||||
|
||||
Vibebox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
|
||||
VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
|
||||
|
||||
- 在我的 M3 上,热启动通常 **6 秒以内**,可以非常快地回到工作状态。
|
||||
- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
|
||||
- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
|
||||
- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
|
||||
- 会话是第一等公民:复用、多终端、清理。
|
||||
|
||||
### 特别鸣谢
|
||||
|
||||
|
||||
@@ -64,3 +64,4 @@
|
||||
3. [ ] Redirect vm output to log.
|
||||
4. [ ] Redirect vm output to vibebox starting it.
|
||||
5. [ ] use anyhow to sync api.
|
||||
6. [ ] add support for ipv6.
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo fmt --all
|
||||
cargo build --all-targets
|
||||
+8
-2
@@ -120,9 +120,15 @@ fn main() -> Result<()> {
|
||||
tracing::debug!(auto_shutdown_ms, "auto shutdown config");
|
||||
let manager_conn =
|
||||
vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
|
||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to ensure vm manager");
|
||||
color_eyre::eyre::eyre!(err.to_string())
|
||||
})?;
|
||||
|
||||
instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||
instance::run_with_ssh(manager_conn).map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to ensure vm manager");
|
||||
color_eyre::eyre::eyre!(err.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
|
||||
|
||||
|
||||
+34
-10
@@ -21,6 +21,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const SSH_KEY_NAME: &str = "ssh_key";
|
||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||
pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
|
||||
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
|
||||
pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
|
||||
@@ -68,7 +69,7 @@ pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::
|
||||
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
|
||||
|
||||
let _manager_conn = manager_conn;
|
||||
wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
|
||||
wait_for_vm_ipv4(&instance_dir, Duration::from_secs(480))?;
|
||||
|
||||
let ip = load_or_create_instance_config(&instance_dir)?
|
||||
.vm_ipv4
|
||||
@@ -217,6 +218,7 @@ fn generate_password() -> String {
|
||||
Uuid::now_v7().simple().to_string()
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
||||
let mut current = String::new();
|
||||
let mut best: Option<String> = None;
|
||||
@@ -242,19 +244,46 @@ fn wait_for_vm_ipv4(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = Instant::now();
|
||||
let mut next_log_at = start + Duration::from_secs(10);
|
||||
let mut next_status_check = start;
|
||||
tracing::info!("waiting for vm ipv4");
|
||||
let status_path = instance_dir.join(STATUS_FILE_NAME);
|
||||
let mut last_status: Option<String> = None;
|
||||
let mut status_missing = true;
|
||||
let mut once_hint = false;
|
||||
loop {
|
||||
let config = load_or_create_instance_config(instance_dir)?;
|
||||
if config.vm_ipv4.is_some() {
|
||||
let _ = fs::remove_file(&status_path);
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let _ = fs::remove_file(&status_path);
|
||||
return Err("Timed out waiting for VM IPv4".into());
|
||||
}
|
||||
if Instant::now() >= next_log_at {
|
||||
let now = Instant::now();
|
||||
if now >= next_status_check {
|
||||
match fs::read_to_string(&status_path) {
|
||||
Ok(status) => {
|
||||
status_missing = false;
|
||||
let status = status.trim().to_string();
|
||||
if status.starts_with("error:") {
|
||||
let _ = fs::remove_file(&status_path);
|
||||
let message = status.trim_start_matches("error:").trim().to_string();
|
||||
return Err(message.into());
|
||||
}
|
||||
if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
|
||||
tracing::info!("[background]: {}", status);
|
||||
last_status = Some(status);
|
||||
next_log_at = now + Duration::from_secs(20);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
status_missing = true;
|
||||
}
|
||||
}
|
||||
next_status_check = now + Duration::from_millis(500);
|
||||
}
|
||||
if now >= next_log_at {
|
||||
let waited = start.elapsed();
|
||||
if waited.as_secs() > 15 && !once_hint {
|
||||
tracing::info!(
|
||||
@@ -262,16 +291,10 @@ fn wait_for_vm_ipv4(
|
||||
);
|
||||
once_hint = true;
|
||||
}
|
||||
if let Ok(status) = fs::read_to_string(&status_path) {
|
||||
let status = status.trim().to_string();
|
||||
if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
|
||||
tracing::info!("[background]: {}", status);
|
||||
last_status = Some(status);
|
||||
}
|
||||
} else {
|
||||
if status_missing {
|
||||
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
|
||||
}
|
||||
next_log_at += Duration::from_secs(10);
|
||||
next_log_at += Duration::from_secs(20);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
@@ -367,6 +390,7 @@ fn run_ssh_session(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||
fn is_ipv4_candidate(candidate: &str) -> bool {
|
||||
let parts: Vec<&str> = candidate.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
|
||||
+49
-5
@@ -1,12 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -eux
|
||||
set -eEux
|
||||
|
||||
trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; echo "VIBEBOX_PROVISION_FAILED"; systemctl poweroff || true; exit 1' ERR
|
||||
|
||||
# Wait for network + DNS before apt-get to avoid early boot flakiness.
|
||||
wait_for_network() {
|
||||
echo "[vibebox] waiting for network/DNS readiness"
|
||||
local deadline=$((SECONDS + 180))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
local has_route=0
|
||||
if ip -4 route show default >/dev/null 2>&1; then
|
||||
has_route=1
|
||||
elif ip -6 route show default >/dev/null 2>&1; then
|
||||
has_route=1
|
||||
fi
|
||||
if [ "$has_route" -eq 1 ]; then
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
|
||||
echo "[vibebox][warn] /etc/resolv.conf:" >&2
|
||||
cat /etc/resolv.conf >&2 || true
|
||||
ip -br addr >&2 || true
|
||||
ip route >&2 || true
|
||||
ip -6 route >&2 || true
|
||||
return 0
|
||||
}
|
||||
|
||||
apt_update_with_retries() {
|
||||
local attempt=1
|
||||
while [ "$attempt" -le 5 ]; do
|
||||
if apt-get update; then
|
||||
return 0
|
||||
fi
|
||||
echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Don't wait too long for slow mirrors.
|
||||
echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
|
||||
echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||
echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||
echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
|
||||
echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||
echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||
|
||||
apt-get update
|
||||
wait_for_network
|
||||
apt_update_with_retries
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
@@ -55,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
|
||||
EOF
|
||||
|
||||
# Done provisioning, power off the VM
|
||||
echo "VIBEBOX_PROVISION_OK"
|
||||
systemctl poweroff
|
||||
|
||||
@@ -40,6 +40,7 @@ const DEFAULT_RAM_MB: u64 = 2048;
|
||||
const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
struct StatusFile {
|
||||
path: PathBuf,
|
||||
@@ -57,12 +58,6 @@ impl StatusFile {
|
||||
fn update(&self, message: &str) {
|
||||
let _ = fs::write(&self.path, message);
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
if !self.cleared.swap(true, Ordering::SeqCst) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StatusFile {
|
||||
@@ -82,7 +77,15 @@ const BASE_DISK_RAW_NAME: &str = "disk.raw";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum LoginAction {
|
||||
Expect { text: String, timeout: Duration },
|
||||
Expect {
|
||||
text: String,
|
||||
timeout: Duration,
|
||||
},
|
||||
ExpectEither {
|
||||
success: String,
|
||||
failure: String,
|
||||
timeout: Duration,
|
||||
},
|
||||
Send(String),
|
||||
}
|
||||
use LoginAction::*;
|
||||
@@ -365,6 +368,12 @@ enum WaitResult {
|
||||
Found,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum WaitAnyResult {
|
||||
Timeout,
|
||||
Found(usize),
|
||||
}
|
||||
|
||||
pub enum VmInput {
|
||||
Bytes(Vec<u8>),
|
||||
Shutdown,
|
||||
@@ -372,6 +381,7 @@ pub enum VmInput {
|
||||
|
||||
enum VmOutput {
|
||||
LoginActionTimeout { action: String, timeout: Duration },
|
||||
LoginActionFailed { action: String, reason: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -408,6 +418,41 @@ impl OutputMonitor {
|
||||
WaitResult::Found
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
|
||||
let mut found: Option<usize> = None;
|
||||
let (_unused, timeout_result) = self
|
||||
.condvar
|
||||
.wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
|
||||
if let Some((pos, idx, len)) = find_any(buf, needles) {
|
||||
*buf = buf[(pos + len)..].to_string();
|
||||
found = Some(idx);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
if timeout_result.timed_out() {
|
||||
WaitAnyResult::Timeout
|
||||
} else {
|
||||
WaitAnyResult::Found(found.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
|
||||
let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
|
||||
for (idx, needle) in needles.iter().enumerate() {
|
||||
if let Some(pos) = buf.find(needle) {
|
||||
let candidate = (pos, idx, needle.len());
|
||||
if best.is_none_or(|b| candidate.0 < b.0) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -552,14 +597,25 @@ fn ensure_default_image(
|
||||
fs::copy(base_raw, default_raw)?;
|
||||
|
||||
let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
|
||||
run_vm(
|
||||
let provision_actions = [
|
||||
Send(provision_command),
|
||||
ExpectEither {
|
||||
success: "VIBEBOX_PROVISION_OK".to_string(),
|
||||
failure: "VIBEBOX_PROVISION_FAILED".to_string(),
|
||||
timeout: PROVISION_EXPECT_TIMEOUT,
|
||||
},
|
||||
];
|
||||
if let Err(err) = run_vm(
|
||||
default_raw,
|
||||
&[Send(provision_command)],
|
||||
&provision_actions,
|
||||
directory_shares,
|
||||
DEFAULT_CPU_COUNT,
|
||||
DEFAULT_RAM_BYTES,
|
||||
None,
|
||||
)?;
|
||||
) {
|
||||
let _ = fs::remove_file(default_raw);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1039,6 +1095,27 @@ fn spawn_login_actions_thread(
|
||||
return;
|
||||
}
|
||||
}
|
||||
ExpectEither {
|
||||
success,
|
||||
failure,
|
||||
timeout,
|
||||
} => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
|
||||
WaitAnyResult::Found(0) => {}
|
||||
WaitAnyResult::Found(_) => {
|
||||
let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
|
||||
action: format!("expect '{}'", success),
|
||||
reason: format!("saw failure marker '{}'", failure),
|
||||
});
|
||||
return;
|
||||
}
|
||||
WaitAnyResult::Timeout => {
|
||||
let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
|
||||
action: format!("expect '{}'", success),
|
||||
timeout,
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
Send(mut text) => {
|
||||
text.push('\n'); // Type the newline so the command is actually submitted.
|
||||
input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
|
||||
@@ -1119,7 +1196,6 @@ where
|
||||
|
||||
if let Some(status) = status {
|
||||
status.update("vm booting... go vibecoder!");
|
||||
status.clear();
|
||||
}
|
||||
tracing::info!("vm booting");
|
||||
|
||||
@@ -1199,6 +1275,24 @@ where
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(VmOutput::LoginActionFailed { action, reason }) => {
|
||||
exit_result = Err(format!(
|
||||
"Login action ({}) failed: {}; shutting down.",
|
||||
action, reason
|
||||
)
|
||||
.into());
|
||||
unsafe {
|
||||
if vm.canRequestStop() {
|
||||
if let Err(err) = vm.requestStopWithError() {
|
||||
tracing::error!(error = ?err, "failed to request VM stop");
|
||||
}
|
||||
} else if vm.canStop() {
|
||||
let handler = RcBlock::new(|_error: *mut NSError| {});
|
||||
vm.stopWithCompletionHandler(&handler);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {}
|
||||
}
|
||||
|
||||
+160
-22
@@ -17,6 +17,7 @@ use std::{
|
||||
|
||||
use crate::{
|
||||
config::CONFIG_PATH_ENV,
|
||||
instance::STATUS_FILE_NAME,
|
||||
instance::VM_ROOT_LOG_NAME,
|
||||
instance::{
|
||||
DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
|
||||
@@ -30,8 +31,22 @@ use crate::{
|
||||
|
||||
const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
|
||||
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
|
||||
const SHUTDOWN_RETRY_MS: u64 = 500;
|
||||
#[cfg(test)]
|
||||
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
|
||||
#[cfg(not(test))]
|
||||
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn force_exit(_reason: &str) -> ! {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn force_exit(reason: &str) -> ! {
|
||||
panic!("{reason}");
|
||||
}
|
||||
|
||||
pub fn ensure_manager(
|
||||
raw_args: &[std::ffi::OsString],
|
||||
auto_shutdown_ms: u64,
|
||||
@@ -103,17 +118,37 @@ pub fn run_manager(
|
||||
let project_root = env::current_dir()?;
|
||||
tracing::info!(root = %project_root.display(), "vm manager starting");
|
||||
let _pid_guard = ensure_pid_file(&project_root)?;
|
||||
run_manager_with(
|
||||
&project_root,
|
||||
args,
|
||||
auto_shutdown_ms,
|
||||
&RealVmExecutor,
|
||||
ManagerOptions {
|
||||
ensure_signed: true,
|
||||
detach: true,
|
||||
prepare_vm: true,
|
||||
},
|
||||
)
|
||||
#[cfg(feature = "mock-vm")]
|
||||
tracing::info!("vm manager using mock executor");
|
||||
let executor: &dyn VmExecutor = {
|
||||
#[cfg(feature = "mock-vm")]
|
||||
{
|
||||
&MockVmExecutor
|
||||
}
|
||||
#[cfg(not(feature = "mock-vm"))]
|
||||
{
|
||||
&RealVmExecutor
|
||||
}
|
||||
};
|
||||
let options = {
|
||||
#[cfg(feature = "mock-vm")]
|
||||
{
|
||||
ManagerOptions {
|
||||
ensure_signed: false,
|
||||
detach: true,
|
||||
prepare_vm: false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "mock-vm"))]
|
||||
{
|
||||
ManagerOptions {
|
||||
ensure_signed: true,
|
||||
detach: true,
|
||||
prepare_vm: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
|
||||
}
|
||||
|
||||
fn spawn_manager_process(
|
||||
@@ -468,6 +503,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||
fn spawn_manager_io(
|
||||
config: Arc<Mutex<InstanceConfig>>,
|
||||
instance_dir: PathBuf,
|
||||
@@ -555,6 +591,7 @@ trait VmExecutor {
|
||||
) -> Result<(), Box<dyn std::error::Error>>;
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||
struct RealVmExecutor;
|
||||
|
||||
impl VmExecutor for RealVmExecutor {
|
||||
@@ -586,6 +623,39 @@ impl VmExecutor for RealVmExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mock-vm")]
|
||||
struct MockVmExecutor;
|
||||
|
||||
#[cfg(feature = "mock-vm")]
|
||||
impl VmExecutor for MockVmExecutor {
|
||||
fn run_vm(
|
||||
&self,
|
||||
_args: vm::VmArg,
|
||||
_extra_login_actions: Vec<LoginAction>,
|
||||
_extra_shares: Vec<DirectoryShare>,
|
||||
_config: Arc<Mutex<InstanceConfig>>,
|
||||
_instance_dir: PathBuf,
|
||||
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (tx, rx) = mpsc::channel::<VmInput>();
|
||||
*vm_input_tx.lock().unwrap() = Some(tx);
|
||||
tracing::info!("mock vm executor running");
|
||||
while let Ok(input) = rx.recv() {
|
||||
match input {
|
||||
VmInput::Shutdown => break,
|
||||
VmInput::Bytes(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
if text.contains("systemctl poweroff") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("mock vm executor exiting");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_manager_with(
|
||||
project_root: &Path,
|
||||
mut args: vm::VmArg,
|
||||
@@ -696,6 +766,10 @@ fn run_manager_with(
|
||||
);
|
||||
tracing::info!("vm manager vm run completed");
|
||||
let vm_err = vm_result.err().map(|e| e.to_string());
|
||||
if let Some(err) = &vm_err {
|
||||
let status_path = instance_dir.join(STATUS_FILE_NAME);
|
||||
let _ = fs::write(&status_path, format!("error: {err}"));
|
||||
}
|
||||
let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
|
||||
let event_loop_result: Result<(), String> = event_loop_handle
|
||||
.join()
|
||||
@@ -727,9 +801,14 @@ fn manager_event_loop(
|
||||
let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
|
||||
|
||||
loop {
|
||||
let timeout = match shutdown_deadline {
|
||||
Some(deadline) => deadline.saturating_duration_since(Instant::now()),
|
||||
None => Duration::from_secs(1),
|
||||
let timeout = match (shutdown_deadline, hard_deadline) {
|
||||
(Some(shutdown), Some(hard)) => {
|
||||
let next = if shutdown <= hard { shutdown } else { hard };
|
||||
next.saturating_duration_since(Instant::now())
|
||||
}
|
||||
(Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
|
||||
(None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
|
||||
(None, None) => Duration::from_secs(1),
|
||||
};
|
||||
|
||||
match event_rx.recv_timeout(timeout) {
|
||||
@@ -769,6 +848,9 @@ fn manager_event_loop(
|
||||
&& Instant::now() >= deadline
|
||||
&& !shutdown_sent
|
||||
{
|
||||
if hard_deadline.is_none() {
|
||||
hard_deadline = Some(Instant::now() + hard_timeout);
|
||||
}
|
||||
let mut sent = false;
|
||||
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
|
||||
if tx
|
||||
@@ -788,19 +870,26 @@ fn manager_event_loop(
|
||||
shutdown_deadline = None;
|
||||
hard_deadline = Some(Instant::now() + hard_timeout);
|
||||
} else {
|
||||
shutdown_deadline = Some(Instant::now() + Duration::from_millis(500));
|
||||
shutdown_deadline =
|
||||
Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
|
||||
}
|
||||
}
|
||||
if ref_count == 0
|
||||
&& shutdown_sent
|
||||
&& let Some(deadline) = hard_deadline
|
||||
&& Instant::now() >= deadline
|
||||
{
|
||||
tracing::warn!(
|
||||
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
|
||||
"force exiting: VM did not stop after shutdown timeout"
|
||||
);
|
||||
std::process::exit(1);
|
||||
if shutdown_sent {
|
||||
tracing::warn!(
|
||||
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
|
||||
"force exiting: VM did not stop after shutdown timeout"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
|
||||
"force exiting: VM input not ready after shutdown timeout"
|
||||
);
|
||||
}
|
||||
force_exit("vm manager forced exit");
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
@@ -813,7 +902,7 @@ fn manager_event_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
use std::{sync::mpsc, thread, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn manager_powers_off_after_grace_when_no_refs() {
|
||||
@@ -846,4 +935,53 @@ mod tests {
|
||||
let _ = event_tx.send(ManagerEvent::VmExited(None));
|
||||
let _ = manager_thread.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_force_exits_when_vm_input_never_ready() {
|
||||
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
||||
let vm_input_tx = Arc::new(Mutex::new(None));
|
||||
|
||||
let manager_thread = thread::spawn(move || {
|
||||
let _ = manager_event_loop(event_rx, vm_input_tx, 10);
|
||||
});
|
||||
|
||||
event_tx.send(ManagerEvent::Inc(None)).unwrap();
|
||||
event_tx.send(ManagerEvent::Dec(None)).unwrap();
|
||||
|
||||
let join_result = manager_thread.join();
|
||||
assert!(
|
||||
join_result.is_err(),
|
||||
"expected manager to force-exit when vm input never becomes ready"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_sends_shutdown_after_vm_input_becomes_ready() {
|
||||
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
||||
let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
|
||||
let vm_input_tx = Arc::new(Mutex::new(None));
|
||||
let vm_input_for_thread = vm_input_tx.clone();
|
||||
|
||||
let manager_thread = thread::spawn(move || {
|
||||
manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
|
||||
});
|
||||
|
||||
event_tx.send(ManagerEvent::Inc(None)).unwrap();
|
||||
event_tx.send(ManagerEvent::Dec(None)).unwrap();
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
*vm_input_tx.lock().unwrap() = Some(vm_tx);
|
||||
|
||||
let msg = vm_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("poweroff");
|
||||
match msg {
|
||||
VmInput::Bytes(data) => {
|
||||
assert_eq!(data, b"systemctl poweroff\n");
|
||||
}
|
||||
_ => panic!("unexpected vm input"),
|
||||
}
|
||||
let _ = event_tx.send(ManagerEvent::VmExited(None));
|
||||
let _ = manager_thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use assert_cmd::cargo::cargo_bin_cmd;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn cli_version_shows_binary_name() {
|
||||
let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
|
||||
print_output("e2e_cli", &output);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"expected success, got status: {}",
|
||||
output.status
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("vibebox"),
|
||||
"expected --version output to contain 'vibebox', got: {}",
|
||||
stdout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_no_sessions_when_empty() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
let project = temp.path().join("project");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
|
||||
let output = cargo_bin_cmd!("vibebox")
|
||||
.current_dir(&project)
|
||||
.env("HOME", &home)
|
||||
.arg("list")
|
||||
.output()
|
||||
.unwrap();
|
||||
print_output("e2e_cli", &output);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"expected success, got status: {}",
|
||||
output.status
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("No sessions were found."),
|
||||
"expected empty sessions message, got: {}",
|
||||
stdout
|
||||
);
|
||||
}
|
||||
|
||||
fn print_output(prefix: &str, output: &std::process::Output) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
println!("[{}] {}", prefix, line);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for line in stderr.lines() {
|
||||
eprintln!("[{}] {}", prefix, line);
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, Read},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn vm_boots_and_runs_command() {
|
||||
if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
|
||||
eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
|
||||
return;
|
||||
}
|
||||
if !virtualization_available() {
|
||||
eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
|
||||
return;
|
||||
}
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
let cache_home = temp.path().join("cache");
|
||||
let project = temp.path().join("project");
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
fs::create_dir_all(&cache_home).unwrap();
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
|
||||
write_config(&project);
|
||||
|
||||
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
|
||||
.current_dir(&project)
|
||||
.env("HOME", &home)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("VIBEBOX_INTERNAL", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
spawn_prefix_reader("e2e_vm", "stdout", stdout);
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
spawn_prefix_reader("e2e_vm", "stderr", stderr);
|
||||
}
|
||||
let _child_guard = ChildGuard::new(child);
|
||||
|
||||
log_line("e2e_vm", "waiting for vm manager socket");
|
||||
let socket_path = project.join(".vibebox").join("vm.sock");
|
||||
let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
|
||||
log_line("e2e_vm", "vm manager socket ready");
|
||||
|
||||
log_line("e2e_vm", "waiting for vm ipv4");
|
||||
let instance_path = project.join(".vibebox").join("instance.toml");
|
||||
let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
|
||||
log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
|
||||
|
||||
let ssh_key = project.join(".vibebox").join("ssh_key");
|
||||
wait_for_file(&ssh_key, Duration::from_secs(30));
|
||||
log_line("e2e_vm", "ssh key ready");
|
||||
|
||||
let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
|
||||
print_output("e2e_vm", &output);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("Linux"),
|
||||
"expected ssh command output to contain 'Linux', got: {}",
|
||||
stdout
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn vm_boots_and_runs_command() {
|
||||
eprintln!("skipping: vm e2e test requires macOS virtualization");
|
||||
}
|
||||
|
||||
struct ChildGuard {
|
||||
child: Option<Child>,
|
||||
}
|
||||
|
||||
impl ChildGuard {
|
||||
fn new(child: Child) -> Self {
|
||||
Self { child: Some(child) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = &mut self.child {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SocketGuard {
|
||||
_path: PathBuf,
|
||||
_stream: std::os::unix::net::UnixStream,
|
||||
}
|
||||
|
||||
fn write_config(project: &Path) {
|
||||
let config = r#"[box]
|
||||
cpu_count = 2
|
||||
ram_mb = 2048
|
||||
disk_gb = 5
|
||||
mounts = []
|
||||
|
||||
[supervisor]
|
||||
auto_shutdown_ms = 120000
|
||||
"#;
|
||||
fs::write(project.join("vibebox.toml"), config).unwrap();
|
||||
}
|
||||
|
||||
fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
|
||||
return SocketGuard {
|
||||
_path: path.to_path_buf(),
|
||||
_stream: stream,
|
||||
};
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!(
|
||||
"timed out waiting for vm manager socket at {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Ok(raw) = fs::read_to_string(instance_path)
|
||||
&& let Ok(value) = toml::from_str::<toml::Value>(&raw)
|
||||
{
|
||||
let ip = value
|
||||
.get("vm_ipv4")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(ip) = ip {
|
||||
let user = value
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "vibecoder".to_string());
|
||||
return (ip, user);
|
||||
}
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!(
|
||||
"timed out waiting for vm_ipv4 in {}",
|
||||
instance_path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_file(path: &Path, timeout: Duration) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if path.exists() {
|
||||
return;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!("timed out waiting for file {}", path.display());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_ssh_command(
|
||||
ssh_key: &Path,
|
||||
user: &str,
|
||||
ip: &str,
|
||||
timeout: Duration,
|
||||
) -> std::process::Output {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let output = Command::new("ssh")
|
||||
.args([
|
||||
"-i",
|
||||
ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"GlobalKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
&format!("{user}@{ip}"),
|
||||
"uname -s",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
if output.status.success() {
|
||||
return output;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
panic!("ssh command failed and timed out: {}", stderr);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1000));
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_prefix_reader(
|
||||
label: &'static str,
|
||||
stream: &'static str,
|
||||
reader: impl Read + Send + 'static,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let buf = BufReader::new(reader);
|
||||
for line in buf.lines() {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
println!("[{}][{}] {}", label, stream, line);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[{}][{}] read error: {}", label, stream, err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn log_line(prefix: &str, message: &str) {
|
||||
println!("[{}] {}", prefix, message);
|
||||
}
|
||||
|
||||
fn print_output(prefix: &str, output: &std::process::Output) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
println!("[{}] {}", prefix, line);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for line in stderr.lines() {
|
||||
eprintln!("[{}] {}", prefix, line);
|
||||
}
|
||||
}
|
||||
|
||||
fn virtualization_available() -> bool {
|
||||
let output = Command::new("sysctl")
|
||||
.args(["-n", "kern.hv_support"])
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let value = String::from_utf8_lossy(&output.stdout);
|
||||
match value.trim() {
|
||||
"1" => true,
|
||||
"0" => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
#![cfg(all(feature = "mock-vm", target_os = "macos"))]
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, Read},
|
||||
os::unix::net::UnixStream,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn mock_vm_allows_refcount_concurrency() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
|
||||
|
||||
supervisor.clients = connect_clients(
|
||||
&supervisor.socket_path,
|
||||
12,
|
||||
Duration::from_secs(2),
|
||||
true,
|
||||
"e2e_vm_mock",
|
||||
);
|
||||
log_line("e2e_vm_mock", "connected 12 clients");
|
||||
|
||||
assert_manager_alive_for(
|
||||
&mut supervisor.child,
|
||||
Duration::from_millis(900),
|
||||
"vm manager exited while clients active",
|
||||
);
|
||||
|
||||
let remaining = supervisor.clients.split_off(6);
|
||||
supervisor.clients = remaining;
|
||||
log_line("e2e_vm_mock", "dropped 6 clients");
|
||||
assert_manager_alive_for(
|
||||
&mut supervisor.child,
|
||||
Duration::from_millis(900),
|
||||
"vm manager exited while clients active",
|
||||
);
|
||||
|
||||
supervisor.clients.clear();
|
||||
log_line("e2e_vm_mock", "dropped remaining clients");
|
||||
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
|
||||
let status = supervisor.child.wait().unwrap();
|
||||
assert!(status.success(), "vm manager exited with {status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_vm_many_managers_many_clients() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut supervisors = Vec::new();
|
||||
|
||||
for idx in 0..3 {
|
||||
supervisors.push(spawn_supervisor(
|
||||
&temp,
|
||||
idx + 1,
|
||||
1400,
|
||||
format!("e2e_vm_mock_{idx}"),
|
||||
));
|
||||
}
|
||||
|
||||
for supervisor in &mut supervisors {
|
||||
supervisor.clients = connect_clients(
|
||||
&supervisor.socket_path,
|
||||
8,
|
||||
Duration::from_secs(2),
|
||||
true,
|
||||
"e2e_vm_mock",
|
||||
);
|
||||
}
|
||||
log_line("e2e_vm_mock", "connected 8 clients per manager");
|
||||
|
||||
for supervisor in &mut supervisors {
|
||||
assert_manager_alive_for(
|
||||
&mut supervisor.child,
|
||||
Duration::from_millis(900),
|
||||
"manager exited while clients active",
|
||||
);
|
||||
}
|
||||
|
||||
supervisors[0].clients.clear();
|
||||
log_line("e2e_vm_mock", "dropped all clients for manager 0");
|
||||
wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
|
||||
let status = supervisors[0].child.wait().unwrap();
|
||||
assert!(status.success(), "manager 0 exited with {status}");
|
||||
|
||||
for supervisor in supervisors.iter_mut().skip(1) {
|
||||
assert_manager_alive_for(
|
||||
&mut supervisor.child,
|
||||
Duration::from_millis(900),
|
||||
"another manager exited early",
|
||||
);
|
||||
}
|
||||
|
||||
for supervisor in supervisors.iter_mut().skip(1) {
|
||||
supervisor.clients.clear();
|
||||
}
|
||||
log_line("e2e_vm_mock", "dropped remaining clients");
|
||||
for supervisor in supervisors.iter_mut().skip(1) {
|
||||
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
|
||||
let status = supervisor.child.wait().unwrap();
|
||||
assert!(status.success(), "manager exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_vm_monkey_processes() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
|
||||
let mut supervisors = Vec::new();
|
||||
let mut next_id = 0usize;
|
||||
let max_supervisors = 4usize;
|
||||
let steps = 25usize;
|
||||
|
||||
supervisors.push(spawn_supervisor(
|
||||
&temp,
|
||||
next_id,
|
||||
1200,
|
||||
format!("e2e_vm_monkey_{next_id}"),
|
||||
));
|
||||
next_id += 1;
|
||||
|
||||
for step in 0..steps {
|
||||
prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
|
||||
let roll = rng.gen_range(100);
|
||||
log_line(
|
||||
"e2e_vm_monkey",
|
||||
&format!("step {step} roll={roll} supervisors={}", supervisors.len()),
|
||||
);
|
||||
if roll < 20 && supervisors.len() < max_supervisors {
|
||||
supervisors.push(spawn_supervisor(
|
||||
&temp,
|
||||
next_id,
|
||||
1200,
|
||||
format!("e2e_vm_monkey_{next_id}"),
|
||||
));
|
||||
log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
|
||||
next_id += 1;
|
||||
} else if roll < 45 && !supervisors.is_empty() {
|
||||
let idx = rng.gen_range(supervisors.len());
|
||||
let mut supervisor = supervisors.swap_remove(idx);
|
||||
log_line(
|
||||
"e2e_vm_monkey",
|
||||
&format!("killing supervisor {}", supervisor.label),
|
||||
);
|
||||
kill_supervisor(&mut supervisor, Duration::from_secs(5));
|
||||
} else if roll < 80 && !supervisors.is_empty() {
|
||||
let idx = rng.gen_range(supervisors.len());
|
||||
let burst = 1 + rng.gen_range(3);
|
||||
let new_clients = connect_clients(
|
||||
&supervisors[idx].socket_path,
|
||||
burst,
|
||||
Duration::from_secs(1),
|
||||
false,
|
||||
"e2e_vm_monkey",
|
||||
);
|
||||
supervisors[idx].clients.extend(new_clients);
|
||||
log_line(
|
||||
"e2e_vm_monkey",
|
||||
&format!("connected {burst} clients to {}", supervisors[idx].label),
|
||||
);
|
||||
} else if !supervisors.is_empty() {
|
||||
let idx = rng.gen_range(supervisors.len());
|
||||
if !supervisors[idx].clients.is_empty() {
|
||||
let len = supervisors[idx].clients.len();
|
||||
let drop_count = 1 + rng.gen_range(len);
|
||||
supervisors[idx].clients.drain(0..drop_count.min(len));
|
||||
log_line(
|
||||
"e2e_vm_monkey",
|
||||
&format!(
|
||||
"dropped {drop_count} clients from {}",
|
||||
supervisors[idx].label
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
log_line("e2e_vm_monkey", "final cleanup");
|
||||
for supervisor in supervisors.iter_mut() {
|
||||
shutdown_supervisor(supervisor, Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_vm_exits_without_clients() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
|
||||
wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
|
||||
let status = supervisor.child.wait().unwrap();
|
||||
assert!(status.success(), "vm manager exited with {status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_vm_reconnect_resets_shutdown() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
|
||||
|
||||
supervisor.clients = connect_clients(
|
||||
&supervisor.socket_path,
|
||||
1,
|
||||
Duration::from_secs(2),
|
||||
true,
|
||||
"e2e_vm_reconnect",
|
||||
);
|
||||
supervisor.clients.clear();
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
|
||||
supervisor.clients = connect_clients(
|
||||
&supervisor.socket_path,
|
||||
1,
|
||||
Duration::from_secs(2),
|
||||
true,
|
||||
"e2e_vm_reconnect",
|
||||
);
|
||||
assert_manager_alive_for(
|
||||
&mut supervisor.child,
|
||||
Duration::from_millis(600),
|
||||
"vm manager exited despite reconnect",
|
||||
);
|
||||
|
||||
supervisor.clients.clear();
|
||||
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
|
||||
let status = supervisor.child.wait().unwrap();
|
||||
assert!(status.success(), "vm manager exited with {status}");
|
||||
}
|
||||
|
||||
struct Supervisor {
|
||||
child: Child,
|
||||
socket_path: PathBuf,
|
||||
clients: Vec<UnixStream>,
|
||||
label: String,
|
||||
}
|
||||
|
||||
fn write_config(project: &Path, auto_shutdown_ms: u64) {
|
||||
let config = format!(
|
||||
r#"[box]
|
||||
cpu_count = 2
|
||||
ram_mb = 2048
|
||||
disk_gb = 5
|
||||
mounts = []
|
||||
|
||||
[supervisor]
|
||||
auto_shutdown_ms = {auto_shutdown_ms}
|
||||
"#
|
||||
);
|
||||
fs::write(project.join("vibebox.toml"), config).unwrap();
|
||||
}
|
||||
|
||||
fn spawn_supervisor(
|
||||
temp: &TempDir,
|
||||
idx: usize,
|
||||
auto_shutdown_ms: u64,
|
||||
label: String,
|
||||
) -> Supervisor {
|
||||
let home = temp.path().join(format!("home-{idx}"));
|
||||
let cache_home = temp.path().join(format!("cache-{idx}"));
|
||||
let project = temp.path().join(format!("project-{idx}"));
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
fs::create_dir_all(&cache_home).unwrap();
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
write_config(&project, auto_shutdown_ms);
|
||||
|
||||
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
|
||||
.current_dir(&project)
|
||||
.env("HOME", &home)
|
||||
.env("XDG_CACHE_HOME", &cache_home)
|
||||
.env("VIBEBOX_INTERNAL", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
spawn_prefix_reader(label.clone(), "stdout", stdout);
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
spawn_prefix_reader(label.clone(), "stderr", stderr);
|
||||
}
|
||||
|
||||
let socket_path = project.join(".vibebox").join("vm.sock");
|
||||
wait_for_socket(&socket_path, Duration::from_secs(10));
|
||||
|
||||
Supervisor {
|
||||
child,
|
||||
socket_path,
|
||||
clients: Vec::new(),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_clients(
|
||||
socket_path: &Path,
|
||||
count: usize,
|
||||
timeout: Duration,
|
||||
require_all: bool,
|
||||
label: &str,
|
||||
) -> Vec<UnixStream> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut handles = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let path = socket_path.to_path_buf();
|
||||
let tx = tx.clone();
|
||||
handles.push(thread::spawn(move || {
|
||||
let stream = connect_client_with_retry(&path, timeout);
|
||||
let _ = tx.send(stream);
|
||||
}));
|
||||
}
|
||||
drop(tx);
|
||||
let mut clients = Vec::with_capacity(count);
|
||||
for stream in rx.into_iter().flatten() {
|
||||
clients.push(stream);
|
||||
}
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
if require_all && clients.len() != count {
|
||||
panic!(
|
||||
"client count mismatch: expected {count} got {}",
|
||||
clients.len()
|
||||
);
|
||||
}
|
||||
if !require_all && clients.len() != count {
|
||||
log_line(
|
||||
label,
|
||||
&format!("connected {} of {count} clients", clients.len()),
|
||||
);
|
||||
}
|
||||
clients
|
||||
}
|
||||
|
||||
fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match UnixStream::connect(path) {
|
||||
Ok(stream) => return Some(stream),
|
||||
Err(_) => {
|
||||
if start.elapsed() > timeout {
|
||||
return None;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_socket(path: &Path, timeout: Duration) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if UnixStream::connect(path).is_ok() {
|
||||
return;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!("timed out waiting for socket {}", path.display());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_manager_alive(child: &mut Child, message: &str) {
|
||||
assert!(child.try_wait().unwrap().is_none(), "{message}");
|
||||
}
|
||||
|
||||
fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < duration {
|
||||
assert_manager_alive(child, message);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
|
||||
supervisor.clients.clear();
|
||||
let _ = supervisor.child.kill();
|
||||
wait_for_exit(&mut supervisor.child, timeout);
|
||||
let _ = supervisor.child.wait();
|
||||
}
|
||||
|
||||
fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
|
||||
supervisors.retain_mut(|supervisor| {
|
||||
if supervisor.child.try_wait().unwrap().is_some() {
|
||||
log_line(label, &format!("removed exited {}", supervisor.label));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
|
||||
supervisor.clients.clear();
|
||||
wait_for_exit(&mut supervisor.child, timeout);
|
||||
if supervisor.child.try_wait().unwrap().is_none() {
|
||||
let _ = supervisor.child.kill();
|
||||
let _ = supervisor.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
return;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!("timed out waiting for mock vm supervisor exit");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
|
||||
thread::spawn(move || {
|
||||
let buf = BufReader::new(reader);
|
||||
for line in buf.lines() {
|
||||
match line {
|
||||
Ok(line) => println!("[{}][{}] {}", label, stream, line),
|
||||
Err(err) => {
|
||||
eprintln!("[{}][{}] read error: {}", label, stream, err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn log_line(prefix: &str, message: &str) {
|
||||
println!("[{}] {}", prefix, message);
|
||||
}
|
||||
|
||||
struct Lcg {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Lcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(self.state >> 32) as u32
|
||||
}
|
||||
|
||||
fn gen_range(&mut self, upper: usize) -> usize {
|
||||
if upper == 0 {
|
||||
return 0;
|
||||
}
|
||||
(self.next_u32() as usize) % upper
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use vibebox::session_manager::INSTANCE_DIR_NAME;
|
||||
use vibebox::{config, explain};
|
||||
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = env::var_os(key);
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(self.key, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(self.key);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mount_rows_includes_defaults_and_custom_mounts() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
let project = home.join("project");
|
||||
let cache_home = home.join("cache");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
fs::create_dir_all(&cache_home).unwrap();
|
||||
|
||||
let _home_guard = EnvGuard::set("HOME", &home);
|
||||
let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
|
||||
|
||||
let box_cfg = config::BoxConfig {
|
||||
mounts: vec!["data:~/data:read-only".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let cfg = config::Config {
|
||||
box_cfg,
|
||||
supervisor: config::SupervisorConfig::default(),
|
||||
};
|
||||
|
||||
let rows = explain::build_mount_rows(&project, &cfg).unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].host, "~/project");
|
||||
assert_eq!(rows[0].guest, "~/project");
|
||||
assert_eq!(rows[0].mode, "read-write");
|
||||
assert_eq!(rows[0].default_mount, "yes");
|
||||
|
||||
assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
|
||||
assert_eq!(rows[1].guest, "/root/.local/share/mise");
|
||||
assert_eq!(rows[1].mode, "read-write");
|
||||
assert_eq!(rows[1].default_mount, "yes");
|
||||
|
||||
assert_eq!(rows[2].host, "~/project/data");
|
||||
assert_eq!(rows[2].guest, "~/data");
|
||||
assert_eq!(rows[2].mode, "read-only");
|
||||
assert_eq!(rows[2].default_mount, "no");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_network_rows_pending_without_instance_file() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
|
||||
let rows = explain::build_network_rows(&project).unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].network_type, "NAT");
|
||||
assert_eq!(rows[0].vm_ip, "-");
|
||||
assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
|
||||
assert_eq!(rows[0].vm_to_host, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_network_rows_uses_instance_vm_ip() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let instance_dir = project.join(INSTANCE_DIR_NAME);
|
||||
fs::create_dir_all(&instance_dir).unwrap();
|
||||
fs::write(
|
||||
instance_dir.join("instance.toml"),
|
||||
"vm_ipv4 = \"10.1.2.3\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let rows = explain::build_network_rows(&project).unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].network_type, "NAT");
|
||||
assert_eq!(rows[0].vm_ip, "10.1.2.3");
|
||||
assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
|
||||
assert_eq!(rows[0].vm_to_host, "none");
|
||||
}
|
||||
Reference in New Issue
Block a user