mirror of
https://github.com/robcholz/vibebox.git
synced 2026-10-12 01:18:41 +02:00
feat: allow multi vibebox to connect to the same vm.
This commit is contained in:
1 parent
61d3105832
commit
ca33633d28
10 files changed
+884
-59
No files matched your search
Generated
+76
@@ -17,6 +17,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -506,6 +515,15 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -533,6 +551,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -726,6 +753,23 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.27"
|
version = "0.1.27"
|
||||||
@@ -1069,9 +1113,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -1092,15 +1148,33 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.22"
|
version = "0.3.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1187,6 +1261,8 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"tui-textarea",
|
"tui-textarea",
|
||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -34,3 +34,5 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
|
|||||||
tokio = { version = "1.40.0", features = ["full"] }
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
|
tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
|
||||||
unicode-width = "0.2"
|
unicode-width = "0.2"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
flowchart TD
|
||||||
|
VB[Vibebox] --> SM[Session Manager]
|
||||||
|
|
||||||
|
SM --> VMM1[VM Manager #1]
|
||||||
|
SM --> VMM2[VM Manager #2]
|
||||||
|
SM --> VMM3[VM Manager #N]
|
||||||
|
|
||||||
|
VMM1 --> VMI1[VM Instance #1]
|
||||||
|
VMM2 --> VMI2[VM Instance #2]
|
||||||
|
VMM3 --> VMIN[VM Instance #N]
|
||||||
|
|
||||||
|
VMI1 --> VM1[VM #1]
|
||||||
|
VMI2 --> VM2[VM #2]
|
||||||
|
VMIN --> VMN[VM #N]
|
||||||
+16
-3
@@ -33,9 +33,22 @@
|
|||||||
2. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
|
2. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
|
||||||
inputfield it should not be scrollable.
|
inputfield it should not be scrollable.
|
||||||
|
|
||||||
## Integration
|
## Stage 1
|
||||||
|
|
||||||
1. [x] Wire up the vm and tui.
|
1. [x] Wire up the vm and tui.
|
||||||
2. [x] Use ssh to connect to vm.
|
2. [x] Use ssh to connect to vm.
|
||||||
3. [ ] wire up SessionManager.
|
3. [x] allow multi vibebox to connect to the same vm.
|
||||||
4. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
|
4. [ ] use vm.lock to ensure process concurrency safety.
|
||||||
|
5. [ ] wire up SessionManager.
|
||||||
|
6. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
|
||||||
|
7. [ ] setup vibebox commands
|
||||||
|
8. [ ] setup cli commands.
|
||||||
|
9. [ ] fix ui overlap.
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
1. [ ] write the docs
|
||||||
|
2. [ ] setup quick link.
|
||||||
|
3. [ ] setup website.
|
||||||
|
|
||||||
|
[ ]
|
||||||
+63
-4
@@ -1,18 +1,49 @@
|
|||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
io::{self, Write},
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
io::{self, IsTerminal, Write},
|
||||||
|
path::Path,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use vibebox::tui::{AppState, VmInfo};
|
use vibebox::tui::{AppState, VmInfo};
|
||||||
use vibebox::{instance, tui, vm};
|
use vibebox::{instance, tui, vm, vm_manager};
|
||||||
|
|
||||||
|
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ProjectConfig {
|
||||||
|
auto_shutdown_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
init_tracing();
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
let raw_args: Vec<OsString> = env::args_os().collect();
|
||||||
|
|
||||||
|
if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
|
||||||
|
tracing::info!("starting vm manager mode");
|
||||||
|
let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
|
||||||
|
tracing::info!(auto_shutdown_ms, "vm manager config");
|
||||||
|
if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
|
||||||
|
tracing::error!(error = %err, "vm manager exited");
|
||||||
|
return Err(color_eyre::eyre::eyre!(err.to_string()));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
tracing::debug!("parsed cli args");
|
||||||
if args.version() {
|
if args.version() {
|
||||||
vm::print_version();
|
vm::print_version();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -22,13 +53,16 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vm::ensure_signed();
|
||||||
|
|
||||||
let vm_info = VmInfo {
|
let vm_info = VmInfo {
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
max_memory_mb: args.ram_mb(),
|
max_memory_mb: args.ram_mb(),
|
||||||
cpu_cores: args.cpu_count(),
|
cpu_cores: args.cpu_count(),
|
||||||
};
|
};
|
||||||
let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
let app = Arc::new(Mutex::new(AppState::new(cwd, vm_info)));
|
tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
|
||||||
|
let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info)));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut locked = app.lock().expect("app state poisoned");
|
let mut locked = app.lock().expect("app state poisoned");
|
||||||
@@ -40,8 +74,33 @@ fn main() -> Result<()> {
|
|||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
instance::run_with_ssh(args, app.clone())
|
let auto_shutdown_ms = load_auto_shutdown_ms(&cwd)?;
|
||||||
|
tracing::info!(auto_shutdown_ms, "auto shutdown config");
|
||||||
|
let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
|
||||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
|
||||||
|
instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_auto_shutdown_ms(project_root: &Path) -> Result<u64> {
|
||||||
|
let path = project_root.join("vibebox.toml");
|
||||||
|
let config = match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => toml::from_str::<ProjectConfig>(&raw)?,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => ProjectConfig::default(),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
Ok(config.auto_shutdown_ms.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(ansi)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
io::{self, IsTerminal},
|
||||||
|
};
|
||||||
|
|
||||||
|
use color_eyre::Result;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use vibebox::{vm, vm_manager};
|
||||||
|
|
||||||
|
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
init_tracing();
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
tracing::info!("starting vm supervisor");
|
||||||
|
let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
|
||||||
|
tracing::info!(auto_shutdown_ms, "vm supervisor config");
|
||||||
|
|
||||||
|
if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
|
||||||
|
tracing::error!(error = %err, "vm supervisor exited");
|
||||||
|
return Err(color_eyre::eyre::eyre!(err.to_string()));
|
||||||
|
}
|
||||||
|
tracing::info!("vm supervisor exited");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(ansi)
|
||||||
|
.with_writer(io::stderr)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
+128
-52
@@ -2,7 +2,7 @@ use std::{
|
|||||||
env, fs,
|
env, fs,
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
net::{SocketAddr, TcpStream},
|
net::{SocketAddr, TcpStream},
|
||||||
os::unix::{fs::PermissionsExt, io::OwnedFd},
|
os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
sync::{
|
sync::{
|
||||||
@@ -10,6 +10,7 @@ use std::{
|
|||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -17,13 +18,14 @@ use std::sync::mpsc::Sender;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
session_manager::{GLOBAL_DIR_NAME, INSTANCE_DIR_NAME},
|
session_manager::INSTANCE_DIR_NAME,
|
||||||
tui::{self, AppState},
|
tui::{self, AppState},
|
||||||
vm::{self, DirectoryShare, LoginAction, VmInput},
|
vm::{self, LoginAction, VmInput},
|
||||||
};
|
};
|
||||||
|
|
||||||
const INSTANCE_TOML: &str = "instance.toml";
|
const INSTANCE_TOML: &str = "instance.toml";
|
||||||
const SSH_KEY_NAME: &str = "ssh_key";
|
const SSH_KEY_NAME: &str = "ssh_key";
|
||||||
|
#[allow(dead_code)]
|
||||||
const SERIAL_LOG_NAME: &str = "serial.log";
|
const SERIAL_LOG_NAME: &str = "serial.log";
|
||||||
const DEFAULT_SSH_USER: &str = "vibecoder";
|
const DEFAULT_SSH_USER: &str = "vibecoder";
|
||||||
const SSH_CONNECT_RETRIES: usize = 30;
|
const SSH_CONNECT_RETRIES: usize = 30;
|
||||||
@@ -31,76 +33,49 @@ const SSH_CONNECT_DELAY_MS: u64 = 500;
|
|||||||
const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
|
const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct InstanceConfig {
|
pub(crate) struct InstanceConfig {
|
||||||
#[serde(default = "default_ssh_user")]
|
#[serde(default = "default_ssh_user")]
|
||||||
ssh_user: String,
|
ssh_user: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
sudo_password: String,
|
sudo_password: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
vm_ipv4: Option<String>,
|
pub(crate) vm_ipv4: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ssh_user() -> String {
|
fn default_ssh_user() -> String {
|
||||||
DEFAULT_SSH_USER.to_string()
|
DEFAULT_SSH_USER.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_with_ssh(
|
pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
args: vm::CliArgs,
|
|
||||||
app: Arc<Mutex<AppState>>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
let project_name = project_root
|
tracing::info!(root = %project_root.display(), "starting ssh session");
|
||||||
.file_name()
|
|
||||||
.ok_or("Project directory has no name")?
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
let instance_dir = ensure_instance_dir(&project_root)?;
|
let instance_dir = ensure_instance_dir(&project_root)?;
|
||||||
|
tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
|
||||||
let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
|
let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
|
||||||
|
|
||||||
let mut config = load_or_create_instance_config(&instance_dir)?;
|
let config = load_or_create_instance_config(&instance_dir)?;
|
||||||
// Clear cached IP to avoid reusing a stale address on startup.
|
let ssh_user = config.ssh_user.clone();
|
||||||
if config.vm_ipv4.is_some() {
|
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
|
||||||
config.vm_ipv4 = None;
|
|
||||||
write_instance_config(&instance_dir.join(INSTANCE_TOML), &config)?;
|
|
||||||
}
|
|
||||||
let config = Arc::new(Mutex::new(config));
|
|
||||||
|
|
||||||
let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
|
let _manager_conn = manager_conn;
|
||||||
|
tracing::debug!("waiting for vm ipv4");
|
||||||
|
wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
|
||||||
|
|
||||||
let extra_shares = vec![DirectoryShare::new(
|
let ip = load_or_create_instance_config(&instance_dir)?
|
||||||
instance_dir.clone(),
|
.vm_ipv4
|
||||||
ssh_guest_dir.clone().into(),
|
.ok_or("VM IPv4 not available")?;
|
||||||
true,
|
tracing::info!(ip = %ip, "vm ipv4 ready");
|
||||||
)?];
|
|
||||||
|
|
||||||
let extra_login_actions =
|
run_ssh_session(ssh_key, ssh_user, ip)
|
||||||
build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), SSH_KEY_NAME);
|
|
||||||
|
|
||||||
vm::run_with_args_and_extras(
|
|
||||||
args,
|
|
||||||
|output_monitor, vm_output_fd, vm_input_fd| {
|
|
||||||
spawn_ssh_io(
|
|
||||||
app.clone(),
|
|
||||||
config.clone(),
|
|
||||||
instance_dir.clone(),
|
|
||||||
ssh_key.clone(),
|
|
||||||
output_monitor,
|
|
||||||
vm_output_fd,
|
|
||||||
vm_input_fd,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
extra_login_actions,
|
|
||||||
extra_shares,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
|
pub(crate) fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
|
||||||
let instance_dir = project_root.join(INSTANCE_DIR_NAME);
|
let instance_dir = project_root.join(INSTANCE_DIR_NAME);
|
||||||
fs::create_dir_all(&instance_dir)?;
|
fs::create_dir_all(&instance_dir)?;
|
||||||
Ok(instance_dir)
|
Ok(instance_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_ssh_keypair(
|
pub(crate) fn ensure_ssh_keypair(
|
||||||
instance_dir: &Path,
|
instance_dir: &Path,
|
||||||
) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
|
) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
|
||||||
let private_key = instance_dir.join(SSH_KEY_NAME);
|
let private_key = instance_dir.join(SSH_KEY_NAME);
|
||||||
@@ -143,7 +118,7 @@ fn ensure_ssh_keypair(
|
|||||||
Ok((private_key, public_key))
|
Ok((private_key, public_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_or_create_instance_config(
|
pub(crate) fn load_or_create_instance_config(
|
||||||
instance_dir: &Path,
|
instance_dir: &Path,
|
||||||
) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
|
) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
|
||||||
let config_path = instance_dir.join(INSTANCE_TOML);
|
let config_path = instance_dir.join(INSTANCE_TOML);
|
||||||
@@ -176,7 +151,7 @@ fn load_or_create_instance_config(
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_instance_config(
|
pub(crate) fn write_instance_config(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
config: &InstanceConfig,
|
config: &InstanceConfig,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -190,7 +165,7 @@ fn generate_password() -> String {
|
|||||||
Uuid::now_v7().simple().to_string()
|
Uuid::now_v7().simple().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_ipv4(line: &str) -> Option<String> {
|
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
let mut best: Option<String> = None;
|
let mut best: Option<String> = None;
|
||||||
|
|
||||||
@@ -209,6 +184,106 @@ fn extract_ipv4(line: &str) -> Option<String> {
|
|||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wait_for_vm_ipv4(
|
||||||
|
instance_dir: &Path,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
let config = load_or_create_instance_config(instance_dir)?;
|
||||||
|
if config.vm_ipv4.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() > timeout {
|
||||||
|
return Err("Timed out waiting for VM IPv4".into());
|
||||||
|
}
|
||||||
|
if start.elapsed().as_secs() % 5 == 0 {
|
||||||
|
tracing::debug!("still waiting for vm ipv4");
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ssh_session(
|
||||||
|
ssh_key: PathBuf,
|
||||||
|
ssh_user: String,
|
||||||
|
ip: String,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut attempts = 0usize;
|
||||||
|
loop {
|
||||||
|
attempts += 1;
|
||||||
|
if !ssh_port_open(&ip) {
|
||||||
|
tracing::debug!(attempts, "ssh port not open yet");
|
||||||
|
eprintln!(
|
||||||
|
"[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
|
||||||
|
);
|
||||||
|
if attempts >= SSH_CONNECT_RETRIES {
|
||||||
|
return Err(
|
||||||
|
format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
|
||||||
|
);
|
||||||
|
tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
|
||||||
|
let status = 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",
|
||||||
|
])
|
||||||
|
.env_remove("LC_CTYPE")
|
||||||
|
.env_remove("LC_ALL")
|
||||||
|
.env_remove("LANG")
|
||||||
|
.arg(format!("{ssh_user}@{ip}"))
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(status) if status.success() => {
|
||||||
|
eprintln!("[vibebox] ssh exited with status: {status}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(status) if status.code() == Some(255) => {
|
||||||
|
eprintln!("[vibebox] ssh connection failed: {status}");
|
||||||
|
if attempts >= SSH_CONNECT_RETRIES {
|
||||||
|
return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
Ok(status) => {
|
||||||
|
return Err(format!("ssh exited with status: {status}").into());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(format!("failed to start ssh: {err}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn is_ipv4_candidate(candidate: &str) -> bool {
|
fn is_ipv4_candidate(candidate: &str) -> bool {
|
||||||
let parts: Vec<&str> = candidate.split('.').collect();
|
let parts: Vec<&str> = candidate.split('.').collect();
|
||||||
if parts.len() != 4 {
|
if parts.len() != 4 {
|
||||||
@@ -233,7 +308,7 @@ fn ssh_port_open(ip: &str) -> bool {
|
|||||||
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
|
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ssh_login_actions(
|
pub(crate) fn build_ssh_login_actions(
|
||||||
config: &Arc<Mutex<InstanceConfig>>,
|
config: &Arc<Mutex<InstanceConfig>>,
|
||||||
project_name: &str,
|
project_name: &str,
|
||||||
guest_dir: &str,
|
guest_dir: &str,
|
||||||
@@ -257,6 +332,7 @@ fn build_ssh_login_actions(
|
|||||||
vec![LoginAction::Send(setup)]
|
vec![LoginAction::Send(setup)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn spawn_ssh_io(
|
fn spawn_ssh_io(
|
||||||
app: Arc<Mutex<AppState>>,
|
app: Arc<Mutex<AppState>>,
|
||||||
config: Arc<Mutex<InstanceConfig>>,
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ pub mod instance;
|
|||||||
pub mod session_manager;
|
pub mod session_manager;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
pub mod vm;
|
pub mod vm;
|
||||||
|
pub mod vm_manager;
|
||||||
|
|
||||||
pub use session_manager::{SessionError, SessionManager, SessionRecord};
|
pub use session_manager::{SessionError, SessionManager, SessionRecord};
|
||||||
@@ -1358,6 +1358,9 @@ impl Drop for RawModeGuard {
|
|||||||
|
|
||||||
// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
|
// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
|
||||||
pub fn ensure_signed() {
|
pub fn ensure_signed() {
|
||||||
|
if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let exe = std::env::current_exe().expect("failed to get current exe path");
|
let exe = std::env::current_exe().expect("failed to get current exe path");
|
||||||
let exe_str = exe.to_str().expect("exe path not valid utf-8");
|
let exe_str = exe.to_str().expect("exe path not valid utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
io::{Read, Write},
|
||||||
|
os::unix::{
|
||||||
|
fs::PermissionsExt,
|
||||||
|
io::AsRawFd,
|
||||||
|
net::{UnixListener, UnixStream},
|
||||||
|
process::CommandExt,
|
||||||
|
},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
sync::{Arc, Mutex, mpsc},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
instance::{
|
||||||
|
InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
|
||||||
|
extract_ipv4, load_or_create_instance_config, write_instance_config,
|
||||||
|
},
|
||||||
|
session_manager::GLOBAL_DIR_NAME,
|
||||||
|
vm::{self, DirectoryShare, LoginAction, VmInput},
|
||||||
|
};
|
||||||
|
|
||||||
|
const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
|
||||||
|
|
||||||
|
pub fn ensure_manager(
|
||||||
|
raw_args: &[std::ffi::OsString],
|
||||||
|
auto_shutdown_ms: u64,
|
||||||
|
) -> Result<UnixStream, Box<dyn std::error::Error>> {
|
||||||
|
let project_root = env::current_dir()?;
|
||||||
|
tracing::debug!(root = %project_root.display(), "ensure vm manager");
|
||||||
|
let instance_dir = ensure_instance_dir(&project_root)?;
|
||||||
|
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
||||||
|
|
||||||
|
if let Ok(stream) = UnixStream::connect(&socket_path) {
|
||||||
|
send_client_pid(&stream);
|
||||||
|
tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
|
||||||
|
return Ok(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(path = %socket_path.display(), "spawning vm manager");
|
||||||
|
spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
match UnixStream::connect(&socket_path) {
|
||||||
|
Ok(stream) => {
|
||||||
|
send_client_pid(&stream);
|
||||||
|
tracing::info!(path = %socket_path.display(), "connected to vm manager");
|
||||||
|
return Ok(stream);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::debug!(error = %err, "waiting for vm manager socket");
|
||||||
|
if start.elapsed() > timeout {
|
||||||
|
return Err(format!(
|
||||||
|
"Timed out waiting for vm manager socket: {} ({})",
|
||||||
|
socket_path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_manager(
|
||||||
|
args: vm::CliArgs,
|
||||||
|
auto_shutdown_ms: u64,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let project_root = env::current_dir()?;
|
||||||
|
tracing::info!(root = %project_root.display(), "vm manager starting");
|
||||||
|
run_manager_with(
|
||||||
|
&project_root,
|
||||||
|
args,
|
||||||
|
auto_shutdown_ms,
|
||||||
|
&RealVmExecutor,
|
||||||
|
ManagerOptions {
|
||||||
|
ensure_signed: true,
|
||||||
|
detach: true,
|
||||||
|
prepare_vm: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_manager_process(
|
||||||
|
raw_args: &[std::ffi::OsString],
|
||||||
|
auto_shutdown_ms: u64,
|
||||||
|
instance_dir: &Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let exe = env::current_exe()?;
|
||||||
|
let mut supervisor_exe = exe.clone();
|
||||||
|
supervisor_exe.set_file_name("vibebox-supervisor");
|
||||||
|
let use_supervisor = supervisor_exe.exists();
|
||||||
|
let mut cmd = if use_supervisor {
|
||||||
|
Command::new(supervisor_exe)
|
||||||
|
} else {
|
||||||
|
let mut cmd = Command::new(exe);
|
||||||
|
cmd.arg0("vibebox-supervisor");
|
||||||
|
cmd
|
||||||
|
};
|
||||||
|
if raw_args.len() > 1 {
|
||||||
|
cmd.args(&raw_args[1..]);
|
||||||
|
}
|
||||||
|
if !use_supervisor {
|
||||||
|
cmd.env("VIBEBOX_VM_MANAGER", "1");
|
||||||
|
}
|
||||||
|
cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
|
||||||
|
cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
|
||||||
|
tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
|
||||||
|
let log_path = instance_dir.join("vm_manager.log");
|
||||||
|
let log_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
if let Some(file) = log_file {
|
||||||
|
let stderr = Stdio::from(file);
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(stderr);
|
||||||
|
} else {
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
}
|
||||||
|
let _child = cmd.spawn()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detach_from_terminal() {
|
||||||
|
unsafe {
|
||||||
|
libc::setsid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(devnull) = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open("/dev/null")
|
||||||
|
{
|
||||||
|
let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDIN_FILENO) };
|
||||||
|
let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDOUT_FILENO) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_disconnect(mut stream: UnixStream) {
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_client_pid(stream: &UnixStream) {
|
||||||
|
let pid = std::process::id();
|
||||||
|
let payload = format!("pid={pid}\n");
|
||||||
|
if let Ok(mut stream) = stream.try_clone() {
|
||||||
|
let _ = stream.write_all(payload.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_client_pid(stream: &UnixStream) -> Option<u32> {
|
||||||
|
let mut stream = stream.try_clone().ok()?;
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let mut len = 0usize;
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buf[len..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
len += n;
|
||||||
|
if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stream.set_read_timeout(None);
|
||||||
|
if len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let line = String::from_utf8_lossy(&buf[..len]);
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(value) = trimmed.strip_prefix("pid=") {
|
||||||
|
value.parse::<u32>().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_manager_io(
|
||||||
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
|
instance_dir: PathBuf,
|
||||||
|
output_monitor: Arc<vm::OutputMonitor>,
|
||||||
|
vm_output_fd: std::os::unix::io::OwnedFd,
|
||||||
|
vm_input_fd: std::os::unix::io::OwnedFd,
|
||||||
|
) -> vm::IoContext {
|
||||||
|
let log_path = instance_dir.join("serial.log");
|
||||||
|
let log_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| Arc::new(Mutex::new(file)));
|
||||||
|
|
||||||
|
let instance_path = instance_dir.join("instance.toml");
|
||||||
|
let config_for_output = config.clone();
|
||||||
|
let log_for_output = log_file.clone();
|
||||||
|
let mut line_buf = String::new();
|
||||||
|
|
||||||
|
let on_output = move |bytes: &[u8]| {
|
||||||
|
if let Some(log) = &log_for_output {
|
||||||
|
if let Ok(mut file) = log.lock() {
|
||||||
|
let _ = file.write_all(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
line_buf.push_str(&text);
|
||||||
|
|
||||||
|
while let Some(pos) = line_buf.find('\n') {
|
||||||
|
let mut line = line_buf[..pos].to_string();
|
||||||
|
if line.ends_with('\r') {
|
||||||
|
line.pop();
|
||||||
|
}
|
||||||
|
line_buf.drain(..=pos);
|
||||||
|
|
||||||
|
let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
|
||||||
|
if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
|
||||||
|
let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
|
||||||
|
let ip = extract_ipv4(ip_raw).unwrap_or_default();
|
||||||
|
if !ip.is_empty() {
|
||||||
|
if let Ok(mut cfg) = config_for_output.lock() {
|
||||||
|
if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
|
||||||
|
cfg.vm_ipv4 = Some(ip.clone());
|
||||||
|
let _ = write_instance_config(&instance_path, &cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vm::spawn_vm_io_with_hooks(
|
||||||
|
output_monitor,
|
||||||
|
vm_output_fd,
|
||||||
|
vm_input_fd,
|
||||||
|
vm::IoControl::new(),
|
||||||
|
|_| false,
|
||||||
|
on_output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ManagerEvent {
|
||||||
|
Inc(Option<u32>),
|
||||||
|
Dec(Option<u32>),
|
||||||
|
VmExited(Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{sync::mpsc, time::Duration};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_powers_off_after_grace_when_no_refs() {
|
||||||
|
let _temp = tempfile::Builder::new()
|
||||||
|
.prefix("vb")
|
||||||
|
.tempdir_in("/tmp")
|
||||||
|
.expect("tempdir");
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
||||||
|
let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
|
||||||
|
let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
|
||||||
|
|
||||||
|
let manager_thread = thread::spawn(move || {
|
||||||
|
manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
|
||||||
|
});
|
||||||
|
|
||||||
|
event_tx.send(ManagerEvent::Inc(None)).unwrap();
|
||||||
|
assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||||
|
|
||||||
|
event_tx.send(ManagerEvent::Dec(None)).unwrap();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ManagerOptions {
|
||||||
|
ensure_signed: bool,
|
||||||
|
detach: bool,
|
||||||
|
prepare_vm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait VmExecutor {
|
||||||
|
fn run_vm(
|
||||||
|
&self,
|
||||||
|
args: vm::CliArgs,
|
||||||
|
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>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RealVmExecutor;
|
||||||
|
|
||||||
|
impl VmExecutor for RealVmExecutor {
|
||||||
|
fn run_vm(
|
||||||
|
&self,
|
||||||
|
args: vm::CliArgs,
|
||||||
|
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 result = vm::run_with_args_and_extras(
|
||||||
|
args,
|
||||||
|
|output_monitor, vm_output_fd, vm_input_fd| {
|
||||||
|
let io_ctx = spawn_manager_io(
|
||||||
|
config.clone(),
|
||||||
|
instance_dir.clone(),
|
||||||
|
output_monitor,
|
||||||
|
vm_output_fd,
|
||||||
|
vm_input_fd,
|
||||||
|
);
|
||||||
|
*vm_input_tx.lock().unwrap() = Some(io_ctx.input_tx.clone());
|
||||||
|
io_ctx
|
||||||
|
},
|
||||||
|
extra_login_actions,
|
||||||
|
extra_shares,
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_manager_with(
|
||||||
|
project_root: &Path,
|
||||||
|
args: vm::CliArgs,
|
||||||
|
auto_shutdown_ms: u64,
|
||||||
|
executor: &dyn VmExecutor,
|
||||||
|
options: ManagerOptions,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if options.ensure_signed {
|
||||||
|
let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
|
||||||
|
unsafe {
|
||||||
|
env::remove_var("VIBEBOX_SKIP_CODESIGN");
|
||||||
|
}
|
||||||
|
vm::ensure_signed();
|
||||||
|
unsafe {
|
||||||
|
env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if options.detach {
|
||||||
|
detach_from_terminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_name = project_root
|
||||||
|
.file_name()
|
||||||
|
.ok_or("Project directory has no name")?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let instance_dir = ensure_instance_dir(project_root)?;
|
||||||
|
if options.prepare_vm {
|
||||||
|
let _ = ensure_ssh_keypair(&instance_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config = load_or_create_instance_config(&instance_dir)?;
|
||||||
|
if config.vm_ipv4.is_some() {
|
||||||
|
config.vm_ipv4 = None;
|
||||||
|
write_instance_config(&instance_dir.join("instance.toml"), &config)?;
|
||||||
|
}
|
||||||
|
let config = Arc::new(Mutex::new(config));
|
||||||
|
|
||||||
|
let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
|
||||||
|
let extra_shares = vec![DirectoryShare::new(
|
||||||
|
instance_dir.clone(),
|
||||||
|
ssh_guest_dir.clone().into(),
|
||||||
|
true,
|
||||||
|
)?];
|
||||||
|
let extra_login_actions =
|
||||||
|
build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
|
||||||
|
|
||||||
|
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
||||||
|
if let Ok(stream) = UnixStream::connect(&socket_path) {
|
||||||
|
drop(stream);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if socket_path.exists() {
|
||||||
|
let _ = fs::remove_file(&socket_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&socket_path)?;
|
||||||
|
let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
|
||||||
|
tracing::info!(path = %socket_path.display(), "vm manager socket bound");
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
||||||
|
let event_tx_accept = event_tx.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let event_tx_conn = event_tx_accept.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let pid = read_client_pid(&stream);
|
||||||
|
let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
|
||||||
|
wait_for_disconnect(stream);
|
||||||
|
let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>> = Arc::new(Mutex::new(None));
|
||||||
|
let vm_input_for_loop = vm_input_tx.clone();
|
||||||
|
let event_loop_handle =
|
||||||
|
thread::spawn(move || manager_event_loop(event_rx, vm_input_for_loop, auto_shutdown_ms));
|
||||||
|
|
||||||
|
tracing::info!("vm manager launching vm");
|
||||||
|
let vm_result = executor.run_vm(
|
||||||
|
args,
|
||||||
|
extra_login_actions,
|
||||||
|
extra_shares,
|
||||||
|
config.clone(),
|
||||||
|
instance_dir.clone(),
|
||||||
|
vm_input_tx.clone(),
|
||||||
|
);
|
||||||
|
tracing::info!("vm manager vm run completed");
|
||||||
|
let vm_err = vm_result.err().map(|e| e.to_string());
|
||||||
|
let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
|
||||||
|
let event_loop_result: Result<(), String> = event_loop_handle
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
|
||||||
|
.map_err(|err| err.to_string());
|
||||||
|
let _ = fs::remove_file(&socket_path);
|
||||||
|
if let Err(err) = &event_loop_result {
|
||||||
|
tracing::error!(error = %err, "vm manager exiting due to event loop error");
|
||||||
|
return Err(err.to_string().into());
|
||||||
|
}
|
||||||
|
if let Some(err) = vm_err {
|
||||||
|
tracing::error!(error = %err, "vm manager exiting due to vm error");
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
tracing::info!("vm manager exiting");
|
||||||
|
Ok(event_loop_result?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager_event_loop(
|
||||||
|
event_rx: mpsc::Receiver<ManagerEvent>,
|
||||||
|
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||||
|
auto_shutdown_ms: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut ref_count: usize = 0;
|
||||||
|
let mut shutdown_deadline: Option<Instant> = None;
|
||||||
|
let mut shutdown_sent = false;
|
||||||
|
let grace = Duration::from_millis(auto_shutdown_ms.max(1));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let timeout = match shutdown_deadline {
|
||||||
|
Some(deadline) => deadline.saturating_duration_since(Instant::now()),
|
||||||
|
None => Duration::from_secs(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
match event_rx.recv_timeout(timeout) {
|
||||||
|
Ok(ManagerEvent::Inc(pid)) => {
|
||||||
|
ref_count = ref_count.saturating_add(1);
|
||||||
|
tracing::info!(
|
||||||
|
ref_count,
|
||||||
|
pid = pid.unwrap_or(0),
|
||||||
|
pid_known = pid.is_some(),
|
||||||
|
"vm manager refcount increment"
|
||||||
|
);
|
||||||
|
shutdown_deadline = None;
|
||||||
|
shutdown_sent = false;
|
||||||
|
}
|
||||||
|
Ok(ManagerEvent::Dec(pid)) => {
|
||||||
|
if ref_count > 0 {
|
||||||
|
ref_count -= 1;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
ref_count,
|
||||||
|
pid = pid.unwrap_or(0),
|
||||||
|
pid_known = pid.is_some(),
|
||||||
|
"vm manager refcount decrement"
|
||||||
|
);
|
||||||
|
if ref_count == 0 {
|
||||||
|
shutdown_deadline = Some(Instant::now() + grace);
|
||||||
|
tracing::info!(grace_ms = auto_shutdown_ms, "shutdown scheduled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ManagerEvent::VmExited(err)) => {
|
||||||
|
if let Some(err) = err {
|
||||||
|
tracing::error!(error = %err, "vm exited with error");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
if let Some(deadline) = shutdown_deadline {
|
||||||
|
if Instant::now() >= deadline && !shutdown_sent {
|
||||||
|
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
|
||||||
|
let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
|
||||||
|
}
|
||||||
|
tracing::info!("shutdown command sent");
|
||||||
|
shutdown_sent = true;
|
||||||
|
shutdown_deadline = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user