mirror of
https://github.com/robcholz/vibebox.git
synced 2026-07-01 12:15:30 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423503b27d | |||
| 23726d7420 | |||
| e1d484ee9d | |||
| 1201c311e0 | |||
| 8669deb078 | |||
| 4d1529905e | |||
| a568295bd3 | |||
| b5cd1f2064 |
Generated
+18
-1
@@ -82,6 +82,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.101"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert_cmd"
|
name = "assert_cmd"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -144,6 +150,15 @@ version = "3.19.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytesize"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassowary"
|
name = "cassowary"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1303,10 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vibebox"
|
name = "vibebox"
|
||||||
version = "0.2.4"
|
version = "0.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"block2",
|
"block2",
|
||||||
|
"bytesize",
|
||||||
"clap",
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "vibebox"
|
name = "vibebox"
|
||||||
version = "0.2.4"
|
version = "0.3.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Finn Sheng"]
|
authors = ["Finn Sheng"]
|
||||||
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
|
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
|
||||||
@@ -42,6 +42,8 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
|
bytesize = {version = "2.3.1",features = ["serde"]}
|
||||||
|
anyhow = "1.0.101"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
|
|||||||
+14
-10
@@ -25,7 +25,7 @@
|
|||||||
5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
|
5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
|
||||||
6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
|
6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
|
||||||
7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
|
7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
|
||||||
8. [ ] Run tests and validate coverage for the new module.
|
8. [x] Run tests and validate coverage for the new module.
|
||||||
|
|
||||||
## TUI
|
## TUI
|
||||||
|
|
||||||
@@ -49,19 +49,23 @@
|
|||||||
4. [x] set up the cli.
|
4. [x] set up the cli.
|
||||||
9. [x] fix ui overlap, and consistency issue.
|
9. [x] fix ui overlap, and consistency issue.
|
||||||
10. [x] `purge-cache` to clear the cache.
|
10. [x] `purge-cache` to clear the cache.
|
||||||
11. [ ] intensive integration test.
|
11. [x] intensive integration test.
|
||||||
|
|
||||||
## Publish
|
## Publish
|
||||||
|
|
||||||
1. [ ] write the docs.
|
1. [x] write the docs.
|
||||||
2. [ ] setup quick install link.
|
2. [x] setup quick install link.
|
||||||
3. [ ] setup website.
|
3. [x] setup website.
|
||||||
|
|
||||||
## Stage 2
|
## Stage 2
|
||||||
|
|
||||||
1. [ ] retouch the cli ux.
|
1. [ ] retouch the cli ux.
|
||||||
2. [ ] refactor the code.
|
2. [x] refactor the code.
|
||||||
3. [ ] Redirect vm output to log.
|
3. [ ] refactor the mount system.
|
||||||
4. [ ] Redirect vm output to vibebox starting it.
|
4. [x] refactor the vm process lifetime.
|
||||||
5. [ ] use anyhow to sync api.
|
5. [x] Redirect vm output to log.
|
||||||
6. [ ] add support for ipv6.
|
6. [x] Redirect vm output to vibebox starting it.
|
||||||
|
7. [x] use anyhow to sync api.
|
||||||
|
8. [ ] add support for ipv6.
|
||||||
|
9. [x] use UnixStream instead of status file
|
||||||
|
10. [x] liveness check should also happen when waiting for ssh port
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ fn main() -> Result<()> {
|
|||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
tracing::info!("starting vm supervisor");
|
tracing::info!("starting vm supervisor");
|
||||||
let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
let config = config::load_config(&cwd);
|
let config = config::load_config(&project_dir)
|
||||||
let instance_dir = instance::ensure_instance_dir(&cwd)
|
|
||||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
let _ = instance::touch_last_active(&instance_dir);
|
let _ = instance::ensure_instance_dir(&project_dir)
|
||||||
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
let _ = instance::touch_last_active(&project_dir);
|
||||||
let args = vm::VmArg {
|
let args = vm::VmArg {
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
cpu_count: config.box_cfg.cpu_count,
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
ram_bytes: config.box_cfg.ram_size.as_u64(),
|
||||||
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
|
disk_bytes: config.box_cfg.disk_size.as_u64(),
|
||||||
no_default_mounts: false,
|
no_default_mounts: false,
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
mounts: config.box_cfg.mounts.clone(),
|
||||||
};
|
};
|
||||||
@@ -37,7 +38,7 @@ fn main() -> Result<()> {
|
|||||||
tracing::info!(auto_shutdown_ms, "vm supervisor config");
|
tracing::info!(auto_shutdown_ms, "vm supervisor config");
|
||||||
|
|
||||||
let result = vm_manager::run_manager(args, auto_shutdown_ms);
|
let result = vm_manager::run_manager(args, auto_shutdown_ms);
|
||||||
let _ = instance::touch_last_active(&instance_dir);
|
let _ = instance::touch_last_active(&project_dir);
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
tracing::error!(error = %err, "vm supervisor exited");
|
tracing::error!(error = %err, "vm supervisor exited");
|
||||||
return Err(color_eyre::eyre::eyre!(err.to_string()));
|
return Err(color_eyre::eyre::eyre!(err.to_string()));
|
||||||
|
|||||||
+55
-91
@@ -1,3 +1,7 @@
|
|||||||
|
use bytesize::ByteSize;
|
||||||
|
use clap::Parser;
|
||||||
|
use color_eyre::Result;
|
||||||
|
use dialoguer::Confirm;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
@@ -6,10 +10,6 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use color_eyre::Result;
|
|
||||||
use dialoguer::Confirm;
|
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
use tracing_subscriber::filter::LevelFilter;
|
use tracing_subscriber::filter::LevelFilter;
|
||||||
@@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry;
|
|||||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
|
||||||
|
|
||||||
use vibebox::tui::{AppState, VmInfo};
|
use vibebox::tui::{AppState, VmInfo};
|
||||||
|
use vibebox::utils::relative_to_home;
|
||||||
use vibebox::{
|
use vibebox::{
|
||||||
SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager,
|
SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -56,53 +57,26 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let config_override = cli.config.clone();
|
let config_override = cli.config.clone();
|
||||||
let raw_args: Vec<OsString> = env::args_os().collect();
|
let raw_args: Vec<OsString> = env::args_os().collect();
|
||||||
let config = config::load_config_with_path(&cwd, config_override.as_deref());
|
let config = config::load_config_with_path(&cwd, config_override.as_deref())
|
||||||
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
|
|
||||||
if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
|
|
||||||
tracing::info!("starting vm manager mode");
|
|
||||||
let args = vm::VmArg {
|
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
|
||||||
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
|
|
||||||
no_default_mounts: false,
|
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
|
||||||
};
|
|
||||||
let auto_shutdown_ms = config.supervisor.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(());
|
|
||||||
}
|
|
||||||
|
|
||||||
vm::ensure_signed();
|
|
||||||
|
|
||||||
let vm_args = vm::VmArg {
|
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
|
||||||
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
|
|
||||||
no_default_mounts: false,
|
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
|
||||||
};
|
|
||||||
let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
|
|
||||||
let vm_info = VmInfo {
|
let vm_info = VmInfo {
|
||||||
max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
|
max_memory: config.box_cfg.ram_size,
|
||||||
cpu_cores: vm_args.cpu_count,
|
cpu_cores: config.box_cfg.cpu_count,
|
||||||
max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0,
|
max_disk: config.box_cfg.disk_size,
|
||||||
system_name: "Debian".to_string(), // TODO: read system name from the VM.
|
system_name: "Debian".to_string(), // TODO: read system name from the VM.
|
||||||
auto_shutdown_ms,
|
auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
|
||||||
};
|
};
|
||||||
if let Ok(manager) = SessionManager::new() {
|
if let Ok(manager) = SessionManager::new() {
|
||||||
if let Err(err) = manager.update_global_sessions(&cwd) {
|
if let Err(err) = manager.update_global_sessions(&cwd) {
|
||||||
tracing::warn!(error = %err, "failed to update a global session list");
|
tracing::warn!(error = %err, "failed to update a global session list");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("failed to initialize session manager");
|
tracing::error!("failed to initialize session manager");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let commands = commands::build_commands();
|
let commands = commands::build_commands();
|
||||||
let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
|
let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut locked = app.lock().expect("app state poisoned");
|
let mut locked = app.lock().expect("app state poisoned");
|
||||||
tui::render_tui_once(&mut locked)?;
|
tui::render_tui_once(&mut locked)?;
|
||||||
@@ -112,17 +86,39 @@ fn main() -> Result<()> {
|
|||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
}
|
}
|
||||||
warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
|
warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
|
||||||
if let Some(handle) = stderr_handle {
|
if let Some(handle) = stderr_handle {
|
||||||
let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
|
let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(auto_shutdown_ms, "auto shutdown config");
|
tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
|
||||||
let manager_conn =
|
let manager_conn = vm_manager::ensure_manager(
|
||||||
vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
|
&raw_args,
|
||||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
config.supervisor.auto_shutdown_ms,
|
||||||
|
config_override.as_deref(),
|
||||||
|
)
|
||||||
|
.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()))?;
|
if let Err(err) = instance::run_with_ssh(manager_conn) {
|
||||||
|
if let Some(instance::InstanceError::UnexpectedDisconnection) =
|
||||||
|
err.downcast_ref::<instance::InstanceError>()
|
||||||
|
{
|
||||||
|
tracing::warn!("vm manager disconnected; exiting vibebox");
|
||||||
|
} else if let Some(instance::InstanceError::VMError(vm_error)) =
|
||||||
|
err.downcast_ref::<instance::InstanceError>()
|
||||||
|
{
|
||||||
|
tracing::error!("[vm]: {vm_error}");
|
||||||
|
tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
|
||||||
|
std::process::exit(1);
|
||||||
|
} else {
|
||||||
|
let message = err.to_string();
|
||||||
|
tracing::error!(error = %message, "vibebox exited: uncaught error");
|
||||||
|
return Err(color_eyre::eyre::eyre!(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
|
tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
|
||||||
|
|
||||||
@@ -205,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>)
|
|||||||
"Purged {} file{} totaling {} from {}",
|
"Purged {} file{} totaling {} from {}",
|
||||||
file_count,
|
file_count,
|
||||||
if file_count == 1 { "" } else { "s" },
|
if file_count == 1 { "" } else { "s" },
|
||||||
format_bytes(total_bytes),
|
ByteSize(total_bytes),
|
||||||
cache_dir.display()
|
cache_dir.display()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::Explain => {
|
Command::Explain => {
|
||||||
let config = config::load_config_with_path(cwd, config_override);
|
let config = config::load_config_with_path(cwd, config_override)
|
||||||
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
let mounts = explain::build_mount_rows(cwd, &config)
|
let mounts = explain::build_mount_rows(cwd, &config)
|
||||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
|
||||||
let networks = explain::build_network_rows(cwd)
|
let networks = explain::build_network_rows(cwd)
|
||||||
@@ -234,20 +231,6 @@ fn project_name(directory: &Path) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn relative_to_home(directory: &Path) -> String {
|
|
||||||
let Ok(home) = env::var("HOME") else {
|
|
||||||
return directory.display().to_string();
|
|
||||||
};
|
|
||||||
let home_path = PathBuf::from(home);
|
|
||||||
if let Ok(stripped) = directory.strip_prefix(&home_path) {
|
|
||||||
if stripped.components().next().is_none() {
|
|
||||||
return "~".to_string();
|
|
||||||
}
|
|
||||||
return format!("~/{}", stripped.display());
|
|
||||||
}
|
|
||||||
directory.display().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cache_dir() -> Result<PathBuf> {
|
fn cache_dir() -> Result<PathBuf> {
|
||||||
let home = env::var("HOME").map(PathBuf::from)?;
|
let home = env::var("HOME").map(PathBuf::from)?;
|
||||||
let cache_home = env::var("XDG_CACHE_HOME")
|
let cache_home = env::var("XDG_CACHE_HOME")
|
||||||
@@ -296,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
|
|||||||
Ok((file_count, total_bytes))
|
Ok((file_count, total_bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_bytes(bytes: u64) -> String {
|
|
||||||
const KB: f64 = 1024.0;
|
|
||||||
const MB: f64 = KB * 1024.0;
|
|
||||||
const GB: f64 = MB * 1024.0;
|
|
||||||
|
|
||||||
let b = bytes as f64;
|
|
||||||
if b >= GB {
|
|
||||||
format!("{:.2} GB", b / GB)
|
|
||||||
} else if b >= MB {
|
|
||||||
format!("{:.1} MB", b / MB)
|
|
||||||
} else if b >= KB {
|
|
||||||
format!("{:.1} KB", b / KB)
|
|
||||||
} else {
|
|
||||||
format!("{} B", bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_last_active(value: Option<&str>) -> String {
|
fn format_last_active(value: Option<&str>) -> String {
|
||||||
let Some(raw) = value else {
|
let Some(raw) = value else {
|
||||||
return "-".to_string();
|
return "-".to_string();
|
||||||
@@ -354,24 +320,22 @@ fn format_last_active(value: Option<&str>) -> String {
|
|||||||
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
|
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn warn_disk_size_mismatch(cwd: &Path, configured_bytes: u64) {
|
fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
|
||||||
let instance_raw = cwd
|
let instance_raw = cwd
|
||||||
.join(session_manager::INSTANCE_DIR_NAME)
|
.join(session_manager::INSTANCE_DIR_NAME)
|
||||||
.join("instance.raw");
|
.join("instance.raw");
|
||||||
let Ok(meta) = fs::metadata(&instance_raw) else {
|
let Ok(meta) = fs::metadata(&instance_raw) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let current_bytes = meta.len();
|
let current_size = ByteSize::b(meta.len());
|
||||||
if current_bytes == configured_bytes {
|
if current_size == configured_size {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let current_gb = current_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
|
||||||
let target_gb = configured_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"instance disk size does not match config (current {:.2} GB, config {:.2} GB). \
|
"instance disk size does not match config (current {}, config {}). \
|
||||||
disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
|
disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
|
||||||
current_gb,
|
current_size,
|
||||||
target_gb
|
configured_size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +344,13 @@ type StderrHandle = reload::Handle<LevelFilter, Registry>;
|
|||||||
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
|
||||||
let file_filter = filter.clone();
|
let file_filter = filter.clone();
|
||||||
let stderr_is_tty = std::io::stderr().is_terminal();
|
let stderr_is_tty = io::stderr().is_terminal();
|
||||||
let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
|
let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
|
||||||
let file = instance::ensure_instance_dir(cwd)
|
let file = instance::ensure_instance_dir(cwd)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|instance_dir| {
|
.and_then(|instance_dir| {
|
||||||
let log_path = instance_dir.join("cli.log");
|
let log_path = instance_dir.join("cli.log");
|
||||||
std::fs::OpenOptions::new()
|
fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
@@ -400,7 +364,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
|||||||
.with_target(false)
|
.with_target(false)
|
||||||
.with_ansi(ansi)
|
.with_ansi(ansi)
|
||||||
.without_time()
|
.without_time()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(io::stderr)
|
||||||
.with_filter(stderr_filter);
|
.with_filter(stderr_filter);
|
||||||
let subscriber = tracing_subscriber::registry().with(stderr_layer);
|
let subscriber = tracing_subscriber::registry().with(stderr_layer);
|
||||||
if let Some(file) = file {
|
if let Some(file) = file {
|
||||||
@@ -418,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
|||||||
let stderr_layer = fmt::layer()
|
let stderr_layer = fmt::layer()
|
||||||
.with_target(false)
|
.with_target(false)
|
||||||
.with_ansi(ansi)
|
.with_ansi(ansi)
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(io::stderr)
|
||||||
.with_filter(filter);
|
.with_filter(filter);
|
||||||
let subscriber = tracing_subscriber::registry().with(stderr_layer);
|
let subscriber = tracing_subscriber::registry().with(stderr_layer);
|
||||||
if let Some(file) = file {
|
if let Some(file) = file {
|
||||||
|
|||||||
+467
-77
@@ -1,13 +1,14 @@
|
|||||||
|
use anyhow::{Context, Error, Result, bail};
|
||||||
|
use bytesize::ByteSize;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs, io,
|
env, fs, io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::vm::DirectoryShare;
|
use crate::vm::DirectoryShare;
|
||||||
|
|
||||||
pub const CONFIG_FILENAME: &str = "vibebox.toml";
|
const CONFIG_FILENAME: &str = "vibebox.toml";
|
||||||
pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
|
pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
|
||||||
|
|
||||||
const DEFAULT_CPU_COUNT: usize = 2;
|
const DEFAULT_CPU_COUNT: usize = 2;
|
||||||
@@ -22,20 +23,83 @@ pub struct Config {
|
|||||||
pub supervisor: SupervisorConfig,
|
pub supervisor: SupervisorConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MI_B: u64 = 1024 * 1024;
|
||||||
|
const GI_B: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
mod serde_mb {
|
||||||
|
use super::{ByteSize, MI_B};
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let bytes = v.0;
|
||||||
|
if !bytes.is_multiple_of(MI_B) {
|
||||||
|
return Err(serde::ser::Error::custom(
|
||||||
|
"ram_mb must be an integer number of MB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.serialize_u64(bytes / MI_B)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let mb = u64::deserialize(d)?;
|
||||||
|
let bytes = mb
|
||||||
|
.checked_mul(MI_B)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?;
|
||||||
|
Ok(ByteSize(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod serde_gb {
|
||||||
|
use super::{ByteSize, GI_B};
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let bytes = v.0;
|
||||||
|
if !bytes.is_multiple_of(GI_B) {
|
||||||
|
return Err(serde::ser::Error::custom(
|
||||||
|
"disk_gb must be an integer number of GB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.serialize_u64(bytes / GI_B)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let gb = u64::deserialize(d)?;
|
||||||
|
let bytes = gb
|
||||||
|
.checked_mul(GI_B)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?;
|
||||||
|
Ok(ByteSize(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BoxConfig {
|
pub struct BoxConfig {
|
||||||
pub cpu_count: usize,
|
pub cpu_count: usize,
|
||||||
pub ram_mb: u64,
|
#[serde(rename = "ram_mb", with = "serde_mb")]
|
||||||
pub disk_gb: u64,
|
pub ram_size: ByteSize,
|
||||||
|
#[serde(rename = "disk_gb", with = "serde_gb")]
|
||||||
|
pub disk_size: ByteSize,
|
||||||
pub mounts: Vec<String>,
|
pub mounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BoxConfig {
|
impl Default for BoxConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cpu_count: default_cpu_count(),
|
cpu_count: DEFAULT_CPU_COUNT,
|
||||||
ram_mb: default_ram_mb(),
|
ram_size: ByteSize::mib(DEFAULT_RAM_MB),
|
||||||
disk_gb: default_disk_gb(),
|
disk_size: ByteSize::gib(DEFAULT_DISK_GB),
|
||||||
mounts: default_mounts(),
|
mounts: default_mounts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,23 +113,11 @@ pub struct SupervisorConfig {
|
|||||||
impl Default for SupervisorConfig {
|
impl Default for SupervisorConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
auto_shutdown_ms: default_auto_shutdown_ms(),
|
auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_cpu_count() -> usize {
|
|
||||||
DEFAULT_CPU_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_ram_mb() -> u64 {
|
|
||||||
DEFAULT_RAM_MB
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auto_shutdown_ms() -> u64 {
|
|
||||||
DEFAULT_AUTO_SHUTDOWN_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_mounts() -> Vec<String> {
|
fn default_mounts() -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
"~/.codex:~/.codex:read-write".into(),
|
"~/.codex:~/.codex:read-write".into(),
|
||||||
@@ -73,19 +125,12 @@ fn default_mounts() -> Vec<String> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_disk_gb() -> u64 {
|
|
||||||
DEFAULT_DISK_GB
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config_path(project_root: &Path) -> PathBuf {
|
pub fn config_path(project_root: &Path) -> PathBuf {
|
||||||
project_root.join(CONFIG_FILENAME)
|
project_root.join(CONFIG_FILENAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_config_file(
|
pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
|
||||||
project_root: &Path,
|
let path = resolve_config_path(project_root, override_path)?;
|
||||||
override_path: Option<&Path>,
|
|
||||||
) -> Result<PathBuf, io::Error> {
|
|
||||||
let path = resolve_config_path(project_root, override_path);
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
let default_config = Config::default();
|
let default_config = Config::default();
|
||||||
let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
|
let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
|
||||||
@@ -95,32 +140,24 @@ pub fn ensure_config_file(
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_config(project_root: &Path) -> Config {
|
pub fn load_config(project_root: &Path) -> Result<Config> {
|
||||||
load_config_with_path(project_root, None)
|
load_config_with_path(project_root, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
|
pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
|
||||||
let path = match ensure_config_file(project_root, override_path) {
|
let path =
|
||||||
Ok(path) => path,
|
ensure_config_file(project_root, override_path).context("failed to create config")?;
|
||||||
Err(err) => die(&format!("failed to create config: {err}")),
|
let raw = fs::read_to_string(&path).context("failed to read config")?;
|
||||||
};
|
|
||||||
let raw = match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => raw,
|
|
||||||
Err(err) => die(&format!("failed to read config: {err}")),
|
|
||||||
};
|
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
|
tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
die(&format!(
|
bail!(format!(
|
||||||
"config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
|
"config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: toml::Value = match toml::from_str(trimmed) {
|
let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => die(&format!("invalid config: {err}")),
|
|
||||||
};
|
|
||||||
let schema_errors = validate_schema(&value);
|
let schema_errors = validate_schema(&value);
|
||||||
if !schema_errors.is_empty() {
|
if !schema_errors.is_empty() {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
@@ -128,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
|
|||||||
path.display(),
|
path.display(),
|
||||||
schema_errors.join("\n- ")
|
schema_errors.join("\n- ")
|
||||||
);
|
);
|
||||||
die(&message);
|
bail!(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config: Config = match toml::from_str(trimmed) {
|
let config: Config = toml::from_str(trimmed).context("invalid config")?;
|
||||||
Ok(config) => config,
|
validate_config(&config).map_err(Error::msg)?;
|
||||||
Err(err) => die(&format!("invalid config: {err}")),
|
Ok(config)
|
||||||
};
|
|
||||||
validate_or_exit(&config);
|
|
||||||
config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
|
fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
|
||||||
let root = match fs::canonicalize(project_root) {
|
let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
|
||||||
Ok(root) => root,
|
resolve_config_path_inner(project_root, override_path, env_override)
|
||||||
Err(err) => die(&format!("failed to resolve project root: {err}")),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let override_path = override_path
|
fn resolve_config_path_inner(
|
||||||
.map(PathBuf::from)
|
project_root: &Path,
|
||||||
.or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
|
override_path: Option<&Path>,
|
||||||
let raw_path = if let Some(path) = override_path {
|
env_override: Option<PathBuf>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
|
||||||
|
|
||||||
|
let selected_path = override_path.map(PathBuf::from).or(env_override);
|
||||||
|
let raw_path = if let Some(path) = selected_path {
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
path
|
path
|
||||||
} else {
|
} else {
|
||||||
@@ -159,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
|
|||||||
};
|
};
|
||||||
|
|
||||||
let normalized = normalize_path(&raw_path);
|
let normalized = normalize_path(&raw_path);
|
||||||
if !normalized.starts_with(&root) {
|
let resolved =
|
||||||
die(&format!(
|
resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
|
||||||
|
if !resolved.starts_with(&root) {
|
||||||
|
bail!(
|
||||||
"config path must be within {}: {}",
|
"config path must be within {}: {}",
|
||||||
root.display(),
|
root.display(),
|
||||||
normalized.display()
|
resolved.display()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
normalized
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
|
||||||
|
if path.exists() {
|
||||||
|
return fs::canonicalize(path);
|
||||||
|
}
|
||||||
|
let (ancestor, missing) = nearest_existing_ancestor(path)?;
|
||||||
|
let mut resolved = fs::canonicalize(ancestor)?;
|
||||||
|
for part in missing {
|
||||||
|
resolved.push(part);
|
||||||
|
}
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
|
||||||
|
let mut current = path;
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
while !current.exists() {
|
||||||
|
let name = current.file_name().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!("path has no existing ancestor: {}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
missing.push(name.to_os_string());
|
||||||
|
current = current.parent().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!("path has no parent: {}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
missing.reverse();
|
||||||
|
Ok((current, missing))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: &Path) -> PathBuf {
|
fn normalize_path(path: &Path) -> PathBuf {
|
||||||
@@ -263,27 +337,343 @@ fn validate_string_array(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_or_exit(config: &Config) {
|
fn validate_config(config: &Config) -> Result<(), String> {
|
||||||
if config.box_cfg.cpu_count == 0 {
|
if config.box_cfg.cpu_count == 0 {
|
||||||
die("box.cpu_count must be >= 1");
|
return Err("box.cpu_count must be >= 1".to_string());
|
||||||
}
|
}
|
||||||
if config.box_cfg.ram_mb == 0 {
|
if config.box_cfg.ram_size.as_mib() == 0.0 {
|
||||||
die("box.ram_mb must be >= 1");
|
return Err("box.ram_mb must be >= 1".to_string());
|
||||||
}
|
}
|
||||||
if config.box_cfg.disk_gb == 0 {
|
if config.box_cfg.disk_size.as_gib() == 0.0 {
|
||||||
die("box.disk_gb must be >= 1");
|
return Err("box.disk_gb must be >= 1".to_string());
|
||||||
}
|
}
|
||||||
if config.supervisor.auto_shutdown_ms == 0 {
|
if config.supervisor.auto_shutdown_ms == 0 {
|
||||||
die("supervisor.auto_shutdown_ms must be >= 1");
|
return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
|
||||||
}
|
}
|
||||||
for spec in &config.box_cfg.mounts {
|
for spec in &config.box_cfg.mounts {
|
||||||
if let Err(err) = DirectoryShare::from_mount_spec(spec) {
|
if let Err(err) = DirectoryShare::from_mount_spec(spec) {
|
||||||
die(&format!("invalid mount spec '{spec}': {err}"));
|
return Err(format!("invalid mount spec '{spec}': {err}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn die(message: &str) -> ! {
|
#[cfg(test)]
|
||||||
tracing::error!("{message}");
|
mod tests {
|
||||||
std::process::exit(1);
|
use super::*;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
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 default_config_serializes_with_legacy_keys() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
let serialized = toml::to_string(&cfg).expect("default config should serialize");
|
||||||
|
|
||||||
|
assert!(serialized.contains("ram_mb = 2048"));
|
||||||
|
assert!(serialized.contains("disk_gb = 5"));
|
||||||
|
assert!(!serialized.contains("ram_size"));
|
||||||
|
assert!(!serialized.contains("disk_size"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_deserializes_sizes_from_mb_and_gb() {
|
||||||
|
let raw = r#"
|
||||||
|
[box]
|
||||||
|
cpu_count = 4
|
||||||
|
ram_mb = 3072
|
||||||
|
disk_gb = 12
|
||||||
|
mounts = ["~/src:~/src:read-write"]
|
||||||
|
|
||||||
|
[supervisor]
|
||||||
|
auto_shutdown_ms = 15000
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(raw).expect("config should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(cfg.box_cfg.cpu_count, 4);
|
||||||
|
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
|
||||||
|
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
|
||||||
|
assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_rejects_non_integral_mb_or_gb() {
|
||||||
|
let cfg = Config {
|
||||||
|
box_cfg: BoxConfig {
|
||||||
|
cpu_count: 2,
|
||||||
|
ram_size: ByteSize::b((2 * MI_B) + 1),
|
||||||
|
disk_size: ByteSize::gib(5),
|
||||||
|
mounts: default_mounts(),
|
||||||
|
},
|
||||||
|
supervisor: SupervisorConfig::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("ram_mb must be an integer number of MB")
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg = Config {
|
||||||
|
box_cfg: BoxConfig {
|
||||||
|
cpu_count: 2,
|
||||||
|
ram_size: ByteSize::mib(2048),
|
||||||
|
disk_size: ByteSize::b((5 * GI_B) + 1),
|
||||||
|
mounts: default_mounts(),
|
||||||
|
},
|
||||||
|
supervisor: SupervisorConfig::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("disk_gb must be an integer number of GB")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_path_removes_dot_and_parent_components() {
|
||||||
|
let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
|
||||||
|
assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_schema_returns_errors_for_missing_required_fields() {
|
||||||
|
let value: toml::Value = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[box]
|
||||||
|
cpu_count = 2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("toml should parse");
|
||||||
|
|
||||||
|
let errors = validate_schema(&value);
|
||||||
|
|
||||||
|
assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
|
||||||
|
assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
|
||||||
|
assert!(
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e == "missing [box].disk_gb (integer)")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e == "missing [box].mounts (array of strings)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_schema_errors_when_supervisor_is_not_table() {
|
||||||
|
let value: toml::Value = toml::from_str(
|
||||||
|
r#"
|
||||||
|
supervisor = 123
|
||||||
|
|
||||||
|
[box]
|
||||||
|
cpu_count = 2
|
||||||
|
ram_mb = 2048
|
||||||
|
disk_gb = 5
|
||||||
|
mounts = []
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("toml should parse");
|
||||||
|
|
||||||
|
let errors = validate_schema(&value);
|
||||||
|
assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_config_file_creates_default_config_if_absent() {
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
||||||
|
|
||||||
|
let path = ensure_config_file(&root, None).expect("config should be created");
|
||||||
|
let raw = fs::read_to_string(&path).expect("created config should be readable");
|
||||||
|
let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
|
||||||
|
|
||||||
|
assert_eq!(path, root.join("vibebox.toml"));
|
||||||
|
assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.box_cfg.ram_size.as_u64(),
|
||||||
|
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.box_cfg.disk_size.as_u64(),
|
||||||
|
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_creates_and_loads_default_config() {
|
||||||
|
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
||||||
|
let home = root.join("home");
|
||||||
|
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
|
||||||
|
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
|
||||||
|
let _home_guard = EnvGuard::set("HOME", &home);
|
||||||
|
|
||||||
|
let cfg = load_config(&root).expect("load_config should succeed");
|
||||||
|
|
||||||
|
assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.box_cfg.ram_size.as_u64(),
|
||||||
|
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.box_cfg.disk_size.as_u64(),
|
||||||
|
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
|
||||||
|
);
|
||||||
|
assert!(root.join("vibebox.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_with_path_uses_override_path() {
|
||||||
|
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
||||||
|
let home = root.join("home");
|
||||||
|
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
|
||||||
|
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
|
||||||
|
let _home_guard = EnvGuard::set("HOME", &home);
|
||||||
|
let override_path = root.join("custom.toml");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&override_path,
|
||||||
|
r#"
|
||||||
|
[box]
|
||||||
|
cpu_count = 6
|
||||||
|
ram_mb = 4096
|
||||||
|
disk_gb = 9
|
||||||
|
mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
|
||||||
|
|
||||||
|
[supervisor]
|
||||||
|
auto_shutdown_ms = 12345
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("override config should be written");
|
||||||
|
|
||||||
|
let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
|
||||||
|
.expect("load_config_with_path should succeed");
|
||||||
|
|
||||||
|
assert_eq!(cfg.box_cfg.cpu_count, 6);
|
||||||
|
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
|
||||||
|
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
|
||||||
|
assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
|
||||||
|
assert!(!root.join("vibebox.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_path_uses_env_override_when_cli_override_missing() {
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
||||||
|
|
||||||
|
let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
|
||||||
|
.expect("env override path should resolve");
|
||||||
|
|
||||||
|
assert_eq!(resolved, root.join("custom.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_path_rejects_env_override_outside_project() {
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
|
||||||
|
|
||||||
|
let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
|
||||||
|
.expect_err("outside-project path should be rejected");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("config path must be within"),
|
||||||
|
"expected bounds-check error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_config_rejects_invalid_values() {
|
||||||
|
let cfg = Config {
|
||||||
|
box_cfg: BoxConfig {
|
||||||
|
cpu_count: 0,
|
||||||
|
ram_size: ByteSize::mib(2048),
|
||||||
|
disk_size: ByteSize::gib(5),
|
||||||
|
mounts: vec![],
|
||||||
|
},
|
||||||
|
supervisor: SupervisorConfig::default(),
|
||||||
|
};
|
||||||
|
let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
|
||||||
|
assert_eq!(err, "box.cpu_count must be >= 1");
|
||||||
|
|
||||||
|
let cfg = Config {
|
||||||
|
box_cfg: BoxConfig {
|
||||||
|
cpu_count: 2,
|
||||||
|
ram_size: ByteSize::mib(2048),
|
||||||
|
disk_size: ByteSize::gib(5),
|
||||||
|
mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
|
||||||
|
},
|
||||||
|
supervisor: SupervisorConfig::default(),
|
||||||
|
};
|
||||||
|
let err = validate_config(&cfg).expect_err("invalid mount should fail");
|
||||||
|
assert!(err.starts_with("invalid mount spec"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_path_accepts_symlinked_project_root() {
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let actual_root = temp.path().join("actual");
|
||||||
|
let link_root = temp.path().join("linked");
|
||||||
|
fs::create_dir_all(&actual_root).expect("actual root should exist");
|
||||||
|
std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
|
||||||
|
|
||||||
|
let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
|
||||||
|
.expect("symlinked project root should resolve");
|
||||||
|
|
||||||
|
assert_eq!(resolved, link_root.join("vibebox.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_path_rejects_symlink_escape() {
|
||||||
|
let temp = TempDir::new().expect("temp dir should be created");
|
||||||
|
let project_root = temp.path().join("project");
|
||||||
|
let outside_root = temp.path().join("outside");
|
||||||
|
fs::create_dir_all(&project_root).expect("project root should exist");
|
||||||
|
fs::create_dir_all(&outside_root).expect("outside root should exist");
|
||||||
|
std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
|
||||||
|
.expect("escape symlink should be created");
|
||||||
|
|
||||||
|
let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
|
||||||
|
.expect_err("symlink escape should be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("config path must be within"),
|
||||||
|
"expected bounds-check error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
__vibebox_err_reported=0
|
||||||
|
__vibebox_report_error() {
|
||||||
|
local rc="$1"
|
||||||
|
local line="$2"
|
||||||
|
local msg="${3:-}"
|
||||||
|
if [ "$__vibebox_err_reported" -eq 0 ]; then
|
||||||
|
msg="${msg//$'\n'/ }"
|
||||||
|
msg="${msg//$'\r'/ }"
|
||||||
|
if [ -n "$msg" ]; then
|
||||||
|
echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}"
|
||||||
|
else
|
||||||
|
echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}"
|
||||||
|
fi
|
||||||
|
__vibebox_err_reported=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
vibebox_fail() {
|
||||||
|
local msg="${1:-script failed}"
|
||||||
|
local rc="${2:-1}"
|
||||||
|
__vibebox_report_error "$rc" "${LINENO}" "$msg"
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR
|
||||||
|
trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT
|
||||||
|
|
||||||
|
__SCRIPT_BODY__
|
||||||
+31
-46
@@ -1,32 +1,32 @@
|
|||||||
|
use crate::instance::InstanceConfig;
|
||||||
|
use crate::utils::relative_to_home;
|
||||||
|
use crate::{config, instance, session_manager, tui};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
error::Error,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{config, instance, session_manager, tui};
|
pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
|
||||||
|
|
||||||
pub fn build_mount_rows(
|
|
||||||
cwd: &Path,
|
|
||||||
config: &config::Config,
|
|
||||||
) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
rows.extend(default_mounts(cwd)?);
|
rows.extend(default_mounts(project)?);
|
||||||
let guest_home = resolve_guest_home(cwd)?;
|
let guest_home = resolve_guest_home(project);
|
||||||
for spec in &config.box_cfg.mounts {
|
for spec in &config.box_cfg.mounts {
|
||||||
rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?);
|
rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
|
||||||
}
|
}
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_network_rows(
|
pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
|
||||||
cwd: &Path,
|
|
||||||
) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
|
|
||||||
let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
|
|
||||||
let mut vm_ip = "-".to_string();
|
let mut vm_ip = "-".to_string();
|
||||||
if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
|
if let Ok(config) = instance::read_instance_config(project_dir) {
|
||||||
|
match config.vm_ipv4 {
|
||||||
|
None => {}
|
||||||
|
Some(ip) => {
|
||||||
vm_ip = ip;
|
vm_ip = ip;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let host_to_vm = if vm_ip == "-" {
|
let host_to_vm = if vm_ip == "-" {
|
||||||
"ssh: <pending>:22".to_string()
|
"ssh: <pending>:22".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -41,13 +41,13 @@ pub fn build_network_rows(
|
|||||||
Ok(vec![row])
|
Ok(vec![row])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
|
fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
|
||||||
let project_name = cwd
|
let project_name = cwd
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.unwrap_or("project");
|
.with_context(|| "failed to get project name")?;
|
||||||
let project_guest = format!("~/{project_name}");
|
let project_guest = format!("~/{project_name}");
|
||||||
let project_host = display_path(cwd);
|
let project_host = relative_to_home(cwd);
|
||||||
let mut rows = vec![tui::MountListRow {
|
let mut rows = vec![tui::MountListRow {
|
||||||
host: project_host,
|
host: project_host,
|
||||||
guest: project_guest,
|
guest: project_guest,
|
||||||
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
|
|||||||
|
|
||||||
let home = env::var("HOME")
|
let home = env::var("HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| PathBuf::from("/"));
|
.with_context(|| "failed to get home directory")?;
|
||||||
let cache_home = env::var("XDG_CACHE_HOME")
|
let cache_home = env::var("XDG_CACHE_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| home.join(".cache"));
|
.unwrap_or_else(|_| home.join(".cache"));
|
||||||
let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
|
let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
|
||||||
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
|
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
|
||||||
rows.push(tui::MountListRow {
|
rows.push(tui::MountListRow {
|
||||||
host: display_path(&guest_mise_cache),
|
host: relative_to_home(&guest_mise_cache),
|
||||||
guest: "/root/.local/share/mise".to_string(),
|
guest: "/root/.local/share/mise".to_string(),
|
||||||
mode: "read-write".to_string(),
|
mode: "read-write".to_string(),
|
||||||
default_mount: "yes".to_string(),
|
default_mount: "yes".to_string(),
|
||||||
@@ -77,10 +77,10 @@ fn parse_mount_spec(
|
|||||||
spec: &str,
|
spec: &str,
|
||||||
default_mount: bool,
|
default_mount: bool,
|
||||||
guest_home: &str,
|
guest_home: &str,
|
||||||
) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
|
) -> Result<tui::MountListRow> {
|
||||||
let parts: Vec<&str> = spec.split(':').collect();
|
let parts: Vec<&str> = spec.split(':').collect();
|
||||||
if parts.len() < 2 || parts.len() > 3 {
|
if parts.len() < 2 || parts.len() > 3 {
|
||||||
return Err(format!("invalid mount spec: {spec}").into());
|
bail!["invalid mount spec: {spec}"];
|
||||||
}
|
}
|
||||||
let host_part = parts[0];
|
let host_part = parts[0];
|
||||||
let guest_part = parts[1];
|
let guest_part = parts[1];
|
||||||
@@ -89,11 +89,10 @@ fn parse_mount_spec(
|
|||||||
"read-only" => "read-only",
|
"read-only" => "read-only",
|
||||||
"read-write" => "read-write",
|
"read-write" => "read-write",
|
||||||
other => {
|
other => {
|
||||||
return Err(format!(
|
bail![format!(
|
||||||
"invalid mount mode '{}'; expected read-only or read-write",
|
"invalid mount mode '{}'; expected read-only or read-write",
|
||||||
other
|
other
|
||||||
)
|
)];
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
|
|||||||
}
|
}
|
||||||
let host_path = PathBuf::from(host);
|
let host_path = PathBuf::from(host);
|
||||||
if host_path.is_absolute() {
|
if host_path.is_absolute() {
|
||||||
return display_path(&host_path);
|
return relative_to_home(&host_path);
|
||||||
}
|
}
|
||||||
let candidate = cwd.join(&host_path);
|
let candidate = cwd.join(&host_path);
|
||||||
if candidate.is_absolute() {
|
if candidate.is_absolute() {
|
||||||
display_path(&candidate)
|
relative_to_home(&candidate)
|
||||||
} else {
|
} else {
|
||||||
host.to_string()
|
host.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
fn resolve_guest_home(project_dir: &Path) -> String {
|
||||||
let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
|
let config = instance::read_instance_config(project_dir);
|
||||||
if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
|
match config {
|
||||||
return Ok(format!("/home/{user}"));
|
Ok(config) => format!("/home/{}", config.ssh_user),
|
||||||
|
Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
|
||||||
}
|
}
|
||||||
Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
|
fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
|
||||||
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
|
|||||||
format!("/root/{guest}")
|
format!("/root/{guest}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_path(path: &Path) -> String {
|
|
||||||
let Ok(home) = env::var("HOME") else {
|
|
||||||
return path.display().to_string();
|
|
||||||
};
|
|
||||||
let home_path = PathBuf::from(home);
|
|
||||||
if let Ok(stripped) = path.strip_prefix(&home_path) {
|
|
||||||
if stripped.components().next().is_none() {
|
|
||||||
return "~".to_string();
|
|
||||||
}
|
|
||||||
return format!("~/{}", stripped.display());
|
|
||||||
}
|
|
||||||
path.display().to_string()
|
|
||||||
}
|
|
||||||
|
|||||||
+284
-144
@@ -1,82 +1,140 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::os::unix::fs::FileTypeExt;
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
io::{self},
|
io::{self, IsTerminal, Read},
|
||||||
net::{SocketAddr, TcpStream},
|
net::{SocketAddr, TcpStream},
|
||||||
os::unix::{fs::PermissionsExt, net::UnixStream},
|
os::unix::{fs::PermissionsExt, net::UnixStream},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
sync::{Arc, Mutex},
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc,
|
||||||
|
},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands,
|
commands,
|
||||||
session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
|
session_manager::INSTANCE_DIR_NAME,
|
||||||
|
session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
|
||||||
vm::{self, LoginAction},
|
vm::{self, LoginAction},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
|
||||||
|
|
||||||
const SSH_KEY_NAME: &str = "ssh_key";
|
const SSH_KEY_NAME: &str = "ssh_key";
|
||||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
const INSTANCE_FILENAME: &str = "instance.toml";
|
||||||
pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
|
const DEFAULT_SSH_USER: &str = "vibecoder";
|
||||||
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
|
const SSH_CONNECT_RETRIES: usize = 10;
|
||||||
pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
|
|
||||||
const SSH_CONNECT_RETRIES: usize = 30;
|
|
||||||
const SSH_CONNECT_DELAY_MS: u64 = 500;
|
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");
|
||||||
|
const STATUS_PREFIX: &str = "status:";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) struct InstanceConfig {
|
pub enum VmLiveness {
|
||||||
#[serde(default)]
|
RunningWithSocket { pid: u32 },
|
||||||
id: String,
|
RunningWithoutSocket { pid: u32 },
|
||||||
#[serde(default = "default_ssh_user")]
|
NotRunningOrMissing,
|
||||||
ssh_user: String,
|
|
||||||
#[serde(default)]
|
|
||||||
sudo_password: String,
|
|
||||||
#[serde(default)]
|
|
||||||
last_active: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) vm_ipv4: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstanceConfig {
|
pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
|
||||||
pub(crate) fn ssh_user_display(&self) -> String {
|
let instance_dir = ensure_instance_dir(project_root)?;
|
||||||
if self.ssh_user.trim().is_empty() {
|
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
||||||
DEFAULT_SSH_USER.to_string()
|
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
||||||
|
fn pid_is_alive(pid: u32) -> bool {
|
||||||
|
let pid = pid as libc::pid_t;
|
||||||
|
let result = unsafe { libc::kill(pid, 0) };
|
||||||
|
if result == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match io::Error::last_os_error().raw_os_error() {
|
||||||
|
Some(code) if code == libc::EPERM => true,
|
||||||
|
Some(code) if code == libc::ESRCH => false,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Ok(content) = fs::read_to_string(pid_path) else {
|
||||||
|
return Ok(VmLiveness::NotRunningOrMissing);
|
||||||
|
};
|
||||||
|
let Ok(pid) = content.trim().parse::<u32>() else {
|
||||||
|
return Ok(VmLiveness::NotRunningOrMissing);
|
||||||
|
};
|
||||||
|
if !pid_is_alive(pid) {
|
||||||
|
return Ok(VmLiveness::NotRunningOrMissing);
|
||||||
|
}
|
||||||
|
let has_socket = fs::metadata(socket_path)
|
||||||
|
.map(|meta| meta.file_type().is_socket())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_socket {
|
||||||
|
Ok(VmLiveness::RunningWithSocket { pid })
|
||||||
} else {
|
} else {
|
||||||
self.ssh_user.clone()
|
Ok(VmLiveness::RunningWithoutSocket { pid })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum InstanceError {
|
||||||
|
#[error("unexpected disconnection from vm manager")]
|
||||||
|
UnexpectedDisconnection,
|
||||||
|
#[error("{0}")]
|
||||||
|
VMError(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(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InstanceConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "default_ssh_user")]
|
||||||
|
pub ssh_user: String,
|
||||||
|
#[serde(default)]
|
||||||
|
sudo_password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_active: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vm_ipv4: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InstanceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::now_v7().to_string(),
|
||||||
|
ssh_user: DEFAULT_SSH_USER.to_string(),
|
||||||
|
sudo_password: Uuid::now_v7().simple().to_string(),
|
||||||
|
last_active: None,
|
||||||
|
vm_ipv4: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
|
||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
tracing::info!(root = %project_root.display(), "starting ssh session");
|
tracing::info!(root = %project_root.display(), "starting ssh session");
|
||||||
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");
|
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 config = load_or_create_instance_config(&instance_dir)?;
|
let config = load_or_create_instance_config(&project_root)?;
|
||||||
let ssh_user = config.ssh_user.clone();
|
let ssh_user = config.ssh_user.clone();
|
||||||
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
|
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
|
||||||
|
|
||||||
let _manager_conn = manager_conn;
|
wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
|
||||||
wait_for_vm_ipv4(&instance_dir, Duration::from_secs(480))?;
|
|
||||||
|
|
||||||
let ip = load_or_create_instance_config(&instance_dir)?
|
let ip = load_or_create_instance_config(&project_root)?
|
||||||
.vm_ipv4
|
.vm_ipv4
|
||||||
.ok_or("VM IPv4 not available")?;
|
.with_context(|| "failed to load instance IP address")?;
|
||||||
tracing::info!(ip = %ip, "vm ipv4 ready");
|
tracing::info!(ip = %ip, "vm ipv4 ready");
|
||||||
|
|
||||||
run_ssh_session(ssh_key, ssh_user, ip)
|
run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
|
pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
|
||||||
@@ -85,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
|
|||||||
Ok(instance_dir)
|
Ok(instance_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ensure_ssh_keypair(
|
pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
|
||||||
instance_dir: &Path,
|
|
||||||
) -> 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);
|
||||||
let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
|
let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
|
||||||
|
|
||||||
@@ -109,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
|
|||||||
"-N",
|
"-N",
|
||||||
"",
|
"",
|
||||||
"-f",
|
"-f",
|
||||||
private_key.to_str().ok_or("ssh key path not utf-8")?,
|
private_key
|
||||||
|
.to_str()
|
||||||
|
.with_context(|| "ssh key path not utf-8")?,
|
||||||
"-C",
|
"-C",
|
||||||
"vibebox",
|
"vibebox",
|
||||||
])
|
])
|
||||||
@@ -119,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
|
|||||||
.status()?;
|
.status()?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("ssh-keygen failed".into());
|
bail!("ssh-keygen failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
|
fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
|
||||||
@@ -128,98 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
|
|||||||
Ok((private_key, public_key))
|
Ok((private_key, public_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_or_create_instance_config(
|
pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
|
||||||
instance_dir: &Path,
|
let mut exist = true;
|
||||||
) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
|
let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
|
||||||
let config_path = instance_dir.join(INSTANCE_FILENAME);
|
exist = false;
|
||||||
let mut config = if config_path.exists() {
|
InstanceConfig::default()
|
||||||
let raw = fs::read_to_string(&config_path)?;
|
});
|
||||||
toml::from_str::<InstanceConfig>(&raw)?
|
|
||||||
} else {
|
|
||||||
InstanceConfig {
|
|
||||||
id: String::new(),
|
|
||||||
ssh_user: default_ssh_user(),
|
|
||||||
sudo_password: String::new(),
|
|
||||||
last_active: None,
|
|
||||||
vm_ipv4: None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
if config.ssh_user.trim().is_empty() {
|
if config.ssh_user.trim().is_empty() {
|
||||||
config.ssh_user = default_ssh_user();
|
config.ssh_user = InstanceConfig::default().ssh_user;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.id.trim().is_empty() {
|
if config.id.trim().is_empty() {
|
||||||
config.id = Uuid::now_v7().to_string();
|
config.id = InstanceConfig::default().id;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.sudo_password.trim().is_empty() {
|
if config.sudo_password.trim().is_empty() {
|
||||||
config.sudo_password = generate_password();
|
config.sudo_password = InstanceConfig::default().sudo_password;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config_path.exists() || changed {
|
if !exist || changed {
|
||||||
write_instance_config(&config_path, &config)?;
|
write_instance_config(project_dir, &config)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_instance_config(
|
pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
|
||||||
instance_dir: &Path,
|
// todo maybe verify schema?
|
||||||
) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
|
let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
|
||||||
let config_path = instance_dir.join(INSTANCE_FILENAME);
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
return Ok(None);
|
bail!("instance config file does not exist");
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(&config_path)?;
|
let raw = fs::read_to_string(&config_path)?;
|
||||||
let config = toml::from_str::<InstanceConfig>(&raw)?;
|
let config = toml::from_str::<InstanceConfig>(&raw)?;
|
||||||
Ok(Some(config))
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_instance_vm_ip(
|
pub fn touch_last_active(project_dir: &Path) -> Result<()> {
|
||||||
instance_dir: &Path,
|
let mut config = load_or_create_instance_config(project_dir)?;
|
||||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
||||||
let config = read_instance_config(instance_dir)?;
|
|
||||||
Ok(config.and_then(|cfg| cfg.vm_ipv4))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_instance_ssh_user(
|
|
||||||
instance_dir: &Path,
|
|
||||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
||||||
let config = read_instance_config(instance_dir)?;
|
|
||||||
Ok(config
|
|
||||||
.map(|cfg| cfg.ssh_user)
|
|
||||||
.filter(|user| !user.trim().is_empty()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mut config = load_or_create_instance_config(instance_dir)?;
|
|
||||||
let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
|
let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
|
||||||
config.last_active = Some(now);
|
config.last_active = Some(now);
|
||||||
write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
|
write_instance_config(project_dir, &config)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn write_instance_config(
|
pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
|
||||||
path: &Path,
|
let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
|
||||||
config: &InstanceConfig,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let data = toml::to_string_pretty(config)?;
|
let data = toml::to_string_pretty(config)?;
|
||||||
fs::write(path, data)?;
|
fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
|
||||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
fs::write(&path, data)?;
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_password() -> String {
|
|
||||||
Uuid::now_v7().simple().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
pub 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;
|
||||||
|
|
||||||
@@ -238,46 +264,68 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
|||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
|
||||||
|
if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
|
||||||
|
let status = status.trim();
|
||||||
|
if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
|
||||||
|
let message = message.trim();
|
||||||
|
if message.is_empty() {
|
||||||
|
bail!("vm manager reported startup failure");
|
||||||
|
}
|
||||||
|
return Err(InstanceError::VMError(message.to_string()).into());
|
||||||
|
}
|
||||||
|
if !status.is_empty() && last_status.as_deref() != Some(status) {
|
||||||
|
tracing::info!("[background]: {}", status);
|
||||||
|
*last_status = Some(status.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn wait_for_vm_ipv4(
|
fn wait_for_vm_ipv4(
|
||||||
instance_dir: &Path,
|
project_dir: &Path,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
manager_conn: &UnixStream,
|
||||||
|
) -> Result<()> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut next_log_at = start + Duration::from_secs(10);
|
let mut next_log_at = start + Duration::from_secs(10);
|
||||||
let mut next_status_check = start;
|
let mut stream = manager_conn.try_clone()?;
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
|
||||||
|
let mut read_buf = [0u8; 1024];
|
||||||
|
let mut pending = String::new();
|
||||||
tracing::info!("waiting for vm ipv4");
|
tracing::info!("waiting for vm ipv4");
|
||||||
let status_path = instance_dir.join(STATUS_FILE_NAME);
|
|
||||||
let mut last_status: Option<String> = None;
|
let mut last_status: Option<String> = None;
|
||||||
let mut status_missing = true;
|
|
||||||
let mut once_hint = false;
|
let mut once_hint = false;
|
||||||
loop {
|
loop {
|
||||||
let config = load_or_create_instance_config(instance_dir)?;
|
match stream.read(&mut read_buf) {
|
||||||
|
Ok(0) => {
|
||||||
|
bail!("vm manager disconnected before VM became ready");
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
|
||||||
|
while let Some(pos) = pending.find('\n') {
|
||||||
|
let line = pending[..pos].trim().to_string();
|
||||||
|
pending.drain(..=pos);
|
||||||
|
handle_manager_line(&line, &mut last_status)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(error = %err, "failed to read vm manager status stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = load_or_create_instance_config(project_dir)?;
|
||||||
if config.vm_ipv4.is_some() {
|
if config.vm_ipv4.is_some() {
|
||||||
let _ = fs::remove_file(&status_path);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if start.elapsed() > timeout {
|
if start.elapsed() > timeout {
|
||||||
let _ = fs::remove_file(&status_path);
|
bail!("timed out waiting for VM IPv4");
|
||||||
return Err("Timed out waiting for VM IPv4".into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
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.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 {
|
if now >= next_log_at {
|
||||||
let waited = start.elapsed();
|
let waited = start.elapsed();
|
||||||
if waited.as_secs() > 15 && !once_hint {
|
if waited.as_secs() > 15 && !once_hint {
|
||||||
@@ -286,12 +334,9 @@ fn wait_for_vm_ipv4(
|
|||||||
);
|
);
|
||||||
once_hint = true;
|
once_hint = true;
|
||||||
}
|
}
|
||||||
if status_missing {
|
|
||||||
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
|
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
|
||||||
}
|
|
||||||
next_log_at += Duration::from_secs(20);
|
next_log_at += Duration::from_secs(20);
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(200));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,9 +344,14 @@ fn run_ssh_session(
|
|||||||
ssh_key: PathBuf,
|
ssh_key: PathBuf,
|
||||||
ssh_user: String,
|
ssh_user: String,
|
||||||
ip: String,
|
ip: String,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
manager_conn: UnixStream,
|
||||||
|
project_root: PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
let mut attempts = 0usize;
|
let mut attempts = 0usize;
|
||||||
loop {
|
loop {
|
||||||
|
if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
|
||||||
|
return Err(InstanceError::UnexpectedDisconnection.into());
|
||||||
|
}
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if !ssh_port_open(&ip) {
|
if !ssh_port_open(&ip) {
|
||||||
tracing::debug!(attempts, "ssh port doesn't open yet");
|
tracing::debug!(attempts, "ssh port doesn't open yet");
|
||||||
@@ -313,9 +363,7 @@ fn run_ssh_session(
|
|||||||
SSH_CONNECT_RETRIES
|
SSH_CONNECT_RETRIES
|
||||||
);
|
);
|
||||||
if attempts >= SSH_CONNECT_RETRIES {
|
if attempts >= SSH_CONNECT_RETRIES {
|
||||||
return Err(
|
bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
|
||||||
format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
|
thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
|
||||||
continue;
|
continue;
|
||||||
@@ -329,10 +377,10 @@ fn run_ssh_session(
|
|||||||
attempts,
|
attempts,
|
||||||
SSH_CONNECT_RETRIES
|
SSH_CONNECT_RETRIES
|
||||||
);
|
);
|
||||||
let status = Command::new("ssh")
|
let child = Command::new("ssh")
|
||||||
.args([
|
.args([
|
||||||
"-i",
|
"-i",
|
||||||
ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
|
ssh_key.to_str().with_context(|| "invalid path")?,
|
||||||
"-o",
|
"-o",
|
||||||
"IdentitiesOnly=yes",
|
"IdentitiesOnly=yes",
|
||||||
"-o",
|
"-o",
|
||||||
@@ -357,27 +405,67 @@ fn run_ssh_session(
|
|||||||
.stdin(Stdio::inherit())
|
.stdin(Stdio::inherit())
|
||||||
.stdout(Stdio::inherit())
|
.stdout(Stdio::inherit())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.status();
|
.spawn();
|
||||||
|
|
||||||
match status {
|
match child {
|
||||||
Ok(status) if status.success() => {
|
Ok(mut child) => {
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
let done_for_monitor = done.clone();
|
||||||
|
let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
|
||||||
|
let mut manager_stream = manager_conn.try_clone()?;
|
||||||
|
let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
while !done_for_monitor.load(Ordering::Relaxed) {
|
||||||
|
match manager_stream.read(&mut buf) {
|
||||||
|
Ok(0) => {
|
||||||
|
let _ = disconnect_tx.send(());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = disconnect_tx.send(());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = loop {
|
||||||
|
if disconnect_rx.try_recv().is_ok() {
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
terminate_ssh_child(&mut child);
|
||||||
|
restore_terminal_after_disconnect();
|
||||||
|
return Err(InstanceError::UnexpectedDisconnection.into());
|
||||||
|
}
|
||||||
|
if let Some(status) = child.try_wait()? {
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
break status;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
};
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
tracing::info!(status = %status, "ssh exited");
|
tracing::info!(status = %status, "ssh exited");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(status) if status.code() == Some(255) => {
|
if status.code() == Some(255) {
|
||||||
tracing::warn!(status = %status, "ssh connection failed");
|
tracing::warn!(status = %status, "ssh connection failed");
|
||||||
if attempts >= SSH_CONNECT_RETRIES {
|
if attempts >= SSH_CONNECT_RETRIES {
|
||||||
return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
|
bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
Ok(status) => {
|
|
||||||
tracing::info!(status = %status, "ssh exited");
|
tracing::info!(status = %status, "ssh exited");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(error = %err, "failed to start ssh");
|
tracing::error!(error = %err, "failed to start ssh");
|
||||||
return Err(format!("failed to start ssh: {err}").into());
|
bail!("failed to start ssh: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,6 +473,30 @@ fn run_ssh_session(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn terminate_ssh_child(child: &mut Child) {
|
||||||
|
let pid = child.id() as i32;
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(700);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => return,
|
||||||
|
Ok(None) => thread::sleep(Duration::from_millis(50)),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_terminal_after_disconnect() {
|
||||||
|
if io::stdin().is_terminal() {
|
||||||
|
let _ = Command::new("stty").arg("sane").status();
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
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();
|
||||||
@@ -407,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool {
|
|||||||
Ok(addr) => addr,
|
Ok(addr) => addr,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
|
TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_ssh_login_actions(
|
pub fn build_ssh_login_actions(
|
||||||
config: &Arc<Mutex<InstanceConfig>>,
|
config: &Arc<Mutex<InstanceConfig>>,
|
||||||
project_name: &str,
|
project_name: &str,
|
||||||
project_guest_dir: &str,
|
project_guest_dir: &str,
|
||||||
@@ -425,7 +537,7 @@ pub(crate) fn build_ssh_login_actions(
|
|||||||
|
|
||||||
let key_path = format!("{guest_dir}/{key_name}.pub");
|
let key_path = format!("{guest_dir}/{key_name}.pub");
|
||||||
|
|
||||||
let setup_script = SSH_SETUP_SCRIPT
|
let ssh_script = SSH_SETUP_SCRIPT
|
||||||
.replace("__SSH_USER__", &ssh_user)
|
.replace("__SSH_USER__", &ssh_user)
|
||||||
.replace("__SUDO_PASSWORD__", &sudo_password)
|
.replace("__SUDO_PASSWORD__", &sudo_password)
|
||||||
.replace("__PROJECT_NAME__", project_name)
|
.replace("__PROJECT_NAME__", project_name)
|
||||||
@@ -433,8 +545,36 @@ pub(crate) fn build_ssh_login_actions(
|
|||||||
.replace("__KEY_PATH__", &key_path)
|
.replace("__KEY_PATH__", &key_path)
|
||||||
.replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
|
.replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
|
||||||
.replace("__VIBEBOX_HOME_LINKS__", home_links_script);
|
.replace("__VIBEBOX_HOME_LINKS__", home_links_script);
|
||||||
let setup = vm::script_command_from_content("ssh_setup", &setup_script)
|
let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
|
||||||
.expect("ssh setup script contained invalid marker");
|
.expect("ssh setup script contained invalid marker");
|
||||||
|
|
||||||
vec![LoginAction::Send(setup)]
|
vec![LoginAction::Send(setup)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_manager_line_updates_status() {
|
||||||
|
let mut last_status = None;
|
||||||
|
handle_manager_line("status: preparing VM image...", &mut last_status)
|
||||||
|
.expect("status should be accepted");
|
||||||
|
assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_manager_line_ignores_non_status_lines() {
|
||||||
|
let mut last_status = None;
|
||||||
|
handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored");
|
||||||
|
assert!(last_status.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_manager_line_surfaces_error_status() {
|
||||||
|
let mut last_status = None;
|
||||||
|
let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
|
||||||
|
.expect_err("error status should fail");
|
||||||
|
assert_eq!(err.to_string(), "vm failed to boot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub mod vm_manager;
|
|||||||
|
|
||||||
pub use session_manager::{SessionError, SessionManager, SessionRecord};
|
pub use session_manager::{SessionError, SessionManager, SessionRecord};
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod utils;
|
||||||
|
|||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -eEux
|
set -eEux
|
||||||
|
|
||||||
trap 'echo "[vibebox][error] provisioning failed"; echo "VIBEBOX_PROVISION_FAILED"; systemctl poweroff || true; exit 1' ERR
|
trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; printf "%s%s\n" VIBEBOX_PROVISION_ FAILED; systemctl poweroff || true; exit 1' ERR
|
||||||
|
|
||||||
# Wait for network + DNS before apt-get to avoid early boot flakiness.
|
# Wait for network + DNS before apt-get to avoid early boot flakiness.
|
||||||
wait_for_network() {
|
wait_for_network() {
|
||||||
echo "[vibebox] waiting for network/DNS readiness"
|
echo "[vibebox] waiting for network/DNS readiness"
|
||||||
local deadline=$((SECONDS + 60))
|
local deadline=$((SECONDS + 180))
|
||||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
local has_route=0
|
local has_route=0
|
||||||
if ip -4 route show default >/dev/null 2>&1; then
|
if ip -4 route show default >/dev/null 2>&1; then
|
||||||
@@ -21,7 +21,7 @@ wait_for_network() {
|
|||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "[vibebox][warn] network/DNS still not ready after 60s; continuing" >&2
|
echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
|
||||||
echo "[vibebox][warn] /etc/resolv.conf:" >&2
|
echo "[vibebox][warn] /etc/resolv.conf:" >&2
|
||||||
cat /etc/resolv.conf >&2 || true
|
cat /etc/resolv.conf >&2 || true
|
||||||
ip -br addr >&2 || true
|
ip -br addr >&2 || true
|
||||||
@@ -44,9 +44,9 @@ apt_update_with_retries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Don't wait too long for slow mirrors.
|
# Don't wait too long for slow mirrors.
|
||||||
echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
|
echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
|
||||||
echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
|
echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||||
echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
|
echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
|
||||||
|
|
||||||
wait_for_network
|
wait_for_network
|
||||||
apt_update_with_retries
|
apt_update_with_retries
|
||||||
@@ -98,5 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Done provisioning, power off the VM
|
# Done provisioning, power off the VM
|
||||||
echo "VIBEBOX_PROVISION_OK"
|
printf "%s%s\n" VIBEBOX_PROVISION_ OK
|
||||||
systemctl poweroff
|
systemctl poweroff
|
||||||
|
|||||||
+42
-79
@@ -1,19 +1,18 @@
|
|||||||
|
use anyhow::Result;
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
os::unix::fs::FileTypeExt,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::config::config_path;
|
||||||
|
use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::CONFIG_FILENAME;
|
|
||||||
|
|
||||||
pub const INSTANCE_DIR_NAME: &str = ".vibebox";
|
pub const INSTANCE_DIR_NAME: &str = ".vibebox";
|
||||||
pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
|
pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
|
||||||
pub const GLOBAL_DIR_NAME: &str = ".vibebox";
|
pub const GLOBAL_DIR_NAME: &str = ".vibebox";
|
||||||
pub const INSTANCE_FILENAME: &str = "instance.toml";
|
const SESSION_TOML_SUFFIX: &str = ".toml";
|
||||||
pub const SESSION_TOML_SUFFIX: &str = ".toml";
|
|
||||||
pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
|
pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
|
||||||
pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
|
pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
|
||||||
const SESSIONS_DIR_NAME: &str = "sessions";
|
const SESSIONS_DIR_NAME: &str = "sessions";
|
||||||
@@ -32,14 +31,6 @@ struct SessionEntry {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct InstanceMetadata {
|
|
||||||
#[serde(default)]
|
|
||||||
id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
last_active: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionManager {
|
pub struct SessionManager {
|
||||||
sessions_dir: PathBuf,
|
sessions_dir: PathBuf,
|
||||||
@@ -61,7 +52,7 @@ pub enum SessionError {
|
|||||||
#[error("Session directory does not exist: {0}")]
|
#[error("Session directory does not exist: {0}")]
|
||||||
MissingDirectory(PathBuf),
|
MissingDirectory(PathBuf),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
TomlDe(#[from] toml::de::Error),
|
TomlDe(#[from] toml::de::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -94,8 +85,8 @@ impl SessionManager {
|
|||||||
let mut added = false;
|
let mut added = false;
|
||||||
|
|
||||||
if has_config {
|
if has_config {
|
||||||
let meta = read_instance_metadata(&directory)?;
|
let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
|
||||||
if let Some(id) = meta.id {
|
if let Some(id) = id {
|
||||||
let record = SessionEntry {
|
let record = SessionEntry {
|
||||||
directory: directory.clone(),
|
directory: directory.clone(),
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -110,7 +101,6 @@ impl SessionManager {
|
|||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
directory = %directory.display(),
|
directory = %directory.display(),
|
||||||
file = INSTANCE_FILENAME,
|
|
||||||
"missing session id in instance file"
|
"missing session id in instance file"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,12 +138,13 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
let mut records = Vec::with_capacity(sessions.len());
|
let mut records = Vec::with_capacity(sessions.len());
|
||||||
for session in sessions {
|
for session in sessions {
|
||||||
let meta = read_instance_metadata(&session.directory)?;
|
let last_active =
|
||||||
|
read_instance_config(&session.directory).map_or(None, |option| option.last_active);
|
||||||
let active = is_session_active(&session.directory);
|
let active = is_session_active(&session.directory);
|
||||||
records.push(SessionRecord {
|
records.push(SessionRecord {
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
id: session.id,
|
id: session.id,
|
||||||
last_active: meta.last_active,
|
last_active,
|
||||||
active,
|
active,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
|
|||||||
if !directory.is_absolute() {
|
if !directory.is_absolute() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
directory.join(CONFIG_FILENAME).is_file()
|
config_path(directory).is_file()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_session_active(directory: &Path) -> bool {
|
fn is_session_active(directory: &Path) -> bool {
|
||||||
let instance_dir = directory.join(INSTANCE_DIR_NAME);
|
let instance_dir = directory.join(INSTANCE_DIR_NAME);
|
||||||
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
||||||
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
match vm_liveness(directory) {
|
||||||
|
Ok(liveness) => match liveness {
|
||||||
let pid = read_pid(&pid_path);
|
VmLiveness::RunningWithSocket { .. } => true,
|
||||||
let is_alive = pid.map(pid_is_alive).unwrap_or(false);
|
VmLiveness::RunningWithoutSocket { .. } => true,
|
||||||
if !is_alive {
|
VmLiveness::NotRunningOrMissing => {
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
return false;
|
false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if let Ok(metadata) = fs::metadata(&socket_path) {
|
Err(_) => false,
|
||||||
return metadata.file_type().is_socket();
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_pid(path: &Path) -> Option<u32> {
|
|
||||||
let content = fs::read_to_string(path).ok()?;
|
|
||||||
content.trim().parse::<u32>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pid_is_alive(pid: u32) -> bool {
|
|
||||||
let pid = pid as libc::pid_t;
|
|
||||||
let result = unsafe { libc::kill(pid, 0) };
|
|
||||||
if result == 0 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
match std::io::Error::last_os_error().raw_os_error() {
|
|
||||||
Some(code) if code == libc::EPERM => true,
|
|
||||||
Some(code) if code == libc::ESRCH => false,
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
|
|||||||
let raw = fs::read_to_string(path)?;
|
let raw = fs::read_to_string(path)?;
|
||||||
let record: SessionEntry = toml::from_str(&raw)?;
|
let record: SessionEntry = toml::from_str(&raw)?;
|
||||||
if record.id.trim().is_empty() {
|
if record.id.trim().is_empty() {
|
||||||
return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
|
||||||
}
|
}
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
|
|
||||||
let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
|
|
||||||
if !instance_path.exists() {
|
|
||||||
return Ok(InstanceMetadata::default());
|
|
||||||
}
|
|
||||||
let raw = fs::read_to_string(&instance_path)?;
|
|
||||||
let mut meta: InstanceMetadata = toml::from_str(&raw)?;
|
|
||||||
if let Some(id) = &meta.id
|
|
||||||
&& id.trim().is_empty()
|
|
||||||
{
|
|
||||||
meta.id = None;
|
|
||||||
}
|
|
||||||
if let Some(last_active) = &meta.last_active
|
|
||||||
&& last_active.trim().is_empty()
|
|
||||||
{
|
|
||||||
meta.last_active = None;
|
|
||||||
}
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
|
fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
|
||||||
let Some(parent) = path.parent() else {
|
let Some(parent) = path.parent() else {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::instance::{InstanceConfig, write_instance_config};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -375,11 +326,14 @@ mod tests {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
|
fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
|
||||||
let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
|
fs::create_dir_all(project_dir)?;
|
||||||
fs::create_dir_all(&instance_dir).unwrap();
|
|
||||||
let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
|
let mut config = InstanceConfig::default();
|
||||||
fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
|
config.id = id.to_string();
|
||||||
|
config.last_active = Some(last_active.to_string());
|
||||||
|
|
||||||
|
write_instance_config(project_dir, &config)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -387,11 +341,14 @@ mod tests {
|
|||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let mgr = manager(&temp);
|
let mgr = manager(&temp);
|
||||||
let project_dir = create_project_dir(&temp);
|
let project_dir = create_project_dir(&temp);
|
||||||
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
|
fs::write(config_path(project_dir.as_path()), "").unwrap();
|
||||||
|
assert!(
|
||||||
write_instance(
|
write_instance(
|
||||||
&project_dir,
|
&project_dir,
|
||||||
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
||||||
"2026-02-07T05:00:00Z",
|
"2026-02-07T05:00:00Z",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
|
|
||||||
let dirs = mgr.update_global_sessions(&project_dir).unwrap();
|
let dirs = mgr.update_global_sessions(&project_dir).unwrap();
|
||||||
@@ -412,15 +369,18 @@ mod tests {
|
|||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let mgr = manager(&temp);
|
let mgr = manager(&temp);
|
||||||
let project_dir = create_project_dir(&temp);
|
let project_dir = create_project_dir(&temp);
|
||||||
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
|
fs::write(config_path(project_dir.as_path()), "").unwrap();
|
||||||
|
assert!(
|
||||||
write_instance(
|
write_instance(
|
||||||
&project_dir,
|
&project_dir,
|
||||||
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
||||||
"2026-02-07T05:00:00Z",
|
"2026-02-07T05:00:00Z",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
let _ = mgr.update_global_sessions(&project_dir).unwrap();
|
let _ = mgr.update_global_sessions(&project_dir).unwrap();
|
||||||
|
|
||||||
fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
|
fs::remove_file(config_path(project_dir.as_path())).unwrap();
|
||||||
let sessions = mgr.list_sessions().unwrap();
|
let sessions = mgr.list_sessions().unwrap();
|
||||||
assert!(sessions.is_empty());
|
assert!(sessions.is_empty());
|
||||||
|
|
||||||
@@ -451,11 +411,14 @@ mod tests {
|
|||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let mgr = manager(&temp);
|
let mgr = manager(&temp);
|
||||||
let project_dir = create_project_dir(&temp);
|
let project_dir = create_project_dir(&temp);
|
||||||
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
|
fs::write(config_path(project_dir.as_path()), "").unwrap();
|
||||||
|
assert!(
|
||||||
write_instance(
|
write_instance(
|
||||||
&project_dir,
|
&project_dir,
|
||||||
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
|
||||||
"2026-02-07T05:00:00Z",
|
"2026-02-07T05:00:00Z",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
let _ = mgr.update_global_sessions(&project_dir).unwrap();
|
let _ = mgr.update_global_sessions(&project_dir).unwrap();
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -2,6 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SSH_USER="__SSH_USER__"
|
SSH_USER="__SSH_USER__"
|
||||||
|
SUDO_PASSWORD="__SUDO_PASSWORD__"
|
||||||
PROJECT_NAME="__PROJECT_NAME__"
|
PROJECT_NAME="__PROJECT_NAME__"
|
||||||
PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
|
PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
|
||||||
KEY_PATH="__KEY_PATH__"
|
KEY_PATH="__KEY_PATH__"
|
||||||
@@ -61,6 +62,10 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
|
|||||||
usermod -aG sudo "$SSH_USER" || true
|
usermod -aG sudo "$SSH_USER" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SUDO_PASSWORD" ]; then
|
||||||
|
echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
|
||||||
|
fi
|
||||||
|
|
||||||
install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
|
install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
|
||||||
install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
|
install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
|
||||||
|
|
||||||
@@ -109,8 +114,8 @@ mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
|
|||||||
mise_install() {
|
mise_install() {
|
||||||
if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
|
if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
|
||||||
if ! curl https://mise.run | HOME="$USER_HOME" sh; then
|
if ! curl https://mise.run | HOME="$USER_HOME" sh; then
|
||||||
mise_warn "mise install script failed (continuing)"
|
mise_warn "mise install script failed"
|
||||||
return 0
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
|
echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
|
||||||
@@ -136,18 +141,21 @@ MISE
|
|||||||
touch "${USER_HOME}/.config/mise/mise.lock"
|
touch "${USER_HOME}/.config/mise/mise.lock"
|
||||||
if [ -x "$MISE_BIN" ]; then
|
if [ -x "$MISE_BIN" ]; then
|
||||||
if ! HOME="$USER_HOME" "$MISE_BIN" install; then
|
if ! HOME="$USER_HOME" "$MISE_BIN" install; then
|
||||||
mise_warn "mise install failed (continuing)"
|
mise_warn "mise install failed"
|
||||||
return 0
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if ! HOME="$USER_HOME" mise install; then
|
if ! HOME="$USER_HOME" mise install; then
|
||||||
mise_warn "mise install failed (continuing)"
|
mise_warn "mise install failed"
|
||||||
return 0
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
mise_install || true
|
if ! mise_install; then
|
||||||
|
diag "mise installation failed"
|
||||||
|
vibebox_fail "mise installation failed" 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 3) start ssh (don't swallow failures)
|
# 3) start ssh (don't swallow failures)
|
||||||
# If ssh is already active, don't force start/restart.
|
# If ssh is already active, don't force start/restart.
|
||||||
@@ -155,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
|
|||||||
if ! systemctl start ssh; then
|
if ! systemctl start ssh; then
|
||||||
diag "systemctl start ssh failed"
|
diag "systemctl start ssh failed"
|
||||||
dump_diag
|
dump_diag
|
||||||
exit 1
|
vibebox_fail "failed to start ssh service" 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -193,7 +201,7 @@ done
|
|||||||
if [ -z "$dev" ] || [ -z "$ip" ]; then
|
if [ -z "$dev" ] || [ -z "$ip" ]; then
|
||||||
diag "no stable IPv4 on default route interface"
|
diag "no stable IPv4 on default route interface"
|
||||||
dump_diag
|
dump_diag
|
||||||
exit 1
|
vibebox_fail "no stable ipv4 route on default interface" 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
|
# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
|
||||||
@@ -212,7 +220,7 @@ done
|
|||||||
if ! listens_ok; then
|
if ! listens_ok; then
|
||||||
diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
|
diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
|
||||||
dump_diag
|
dump_diag
|
||||||
exit 1
|
vibebox_fail "sshd is not listening on the expected address" 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ip a
|
ip a
|
||||||
|
|||||||
+31
-31
@@ -1,10 +1,4 @@
|
|||||||
use std::{
|
use bytesize::ByteSize;
|
||||||
io::{self, Write},
|
|
||||||
os::unix::io::OwnedFd,
|
|
||||||
path::PathBuf,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::{MoveTo, Show},
|
cursor::{MoveTo, Show},
|
||||||
@@ -22,6 +16,12 @@ use ratatui::{
|
|||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
|
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
|
||||||
};
|
};
|
||||||
|
use std::{
|
||||||
|
io::{self, Write},
|
||||||
|
os::unix::io::OwnedFd,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::vm;
|
use crate::vm;
|
||||||
|
|
||||||
@@ -39,9 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VmInfo {
|
pub struct VmInfo {
|
||||||
pub max_memory_mb: u64,
|
pub max_memory: ByteSize,
|
||||||
pub cpu_cores: usize,
|
pub cpu_cores: usize,
|
||||||
pub max_disk_gb: f32,
|
pub max_disk: ByteSize,
|
||||||
pub system_name: String,
|
pub system_name: String,
|
||||||
pub auto_shutdown_ms: u64,
|
pub auto_shutdown_ms: u64,
|
||||||
}
|
}
|
||||||
@@ -474,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_color(color: ratatui::style::Color) -> CrosstermColor {
|
fn map_color(color: Color) -> CrosstermColor {
|
||||||
match color {
|
match color {
|
||||||
ratatui::style::Color::Reset => CrosstermColor::Reset,
|
Color::Reset => CrosstermColor::Reset,
|
||||||
ratatui::style::Color::Black => CrosstermColor::Black,
|
Color::Black => CrosstermColor::Black,
|
||||||
ratatui::style::Color::Red => CrosstermColor::DarkRed,
|
Color::Red => CrosstermColor::DarkRed,
|
||||||
ratatui::style::Color::Green => CrosstermColor::DarkGreen,
|
Color::Green => CrosstermColor::DarkGreen,
|
||||||
ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
|
Color::Yellow => CrosstermColor::DarkYellow,
|
||||||
ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
|
Color::Blue => CrosstermColor::DarkBlue,
|
||||||
ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
|
Color::Magenta => CrosstermColor::DarkMagenta,
|
||||||
ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
|
Color::Cyan => CrosstermColor::DarkCyan,
|
||||||
ratatui::style::Color::Gray => CrosstermColor::Grey,
|
Color::Gray => CrosstermColor::Grey,
|
||||||
ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
|
Color::DarkGray => CrosstermColor::DarkGrey,
|
||||||
ratatui::style::Color::LightRed => CrosstermColor::Red,
|
Color::LightRed => CrosstermColor::Red,
|
||||||
ratatui::style::Color::LightGreen => CrosstermColor::Green,
|
Color::LightGreen => CrosstermColor::Green,
|
||||||
ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
|
Color::LightYellow => CrosstermColor::Yellow,
|
||||||
ratatui::style::Color::LightBlue => CrosstermColor::Blue,
|
Color::LightBlue => CrosstermColor::Blue,
|
||||||
ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
|
Color::LightMagenta => CrosstermColor::Magenta,
|
||||||
ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
|
Color::LightCyan => CrosstermColor::Cyan,
|
||||||
ratatui::style::Color::White => CrosstermColor::White,
|
Color::White => CrosstermColor::White,
|
||||||
ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
|
Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
|
||||||
ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
|
Color::Indexed(i) => CrosstermColor::AnsiValue(i),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,8 +574,8 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
|
|||||||
Span::raw("CPU / Memory / Disk: "),
|
Span::raw("CPU / Memory / Disk: "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(
|
format!(
|
||||||
"{} cores / {} MB / {} GB",
|
"{} cores / {} / {}",
|
||||||
app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
|
app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
|
||||||
),
|
),
|
||||||
Style::default().fg(Color::Green),
|
Style::default().fg(Color::Green),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn relative_to_home(directory: &Path) -> String {
|
||||||
|
let Ok(home) = env::var("HOME") else {
|
||||||
|
return directory.display().to_string();
|
||||||
|
};
|
||||||
|
let home_path = PathBuf::from(home);
|
||||||
|
if let Ok(stripped) = directory.strip_prefix(&home_path) {
|
||||||
|
if stripped.components().next().is_none() {
|
||||||
|
return "~".to_string();
|
||||||
|
}
|
||||||
|
return format!("~/{}", stripped.display());
|
||||||
|
}
|
||||||
|
directory.display().to_string()
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::instance::STATUS_FILE_NAME;
|
|
||||||
use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
|
use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
|
||||||
|
use anyhow::{Context, Error, Result, bail};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
@@ -23,6 +23,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use block2::RcBlock;
|
use block2::RcBlock;
|
||||||
|
use bytesize::ByteSize;
|
||||||
use dispatch2::DispatchQueue;
|
use dispatch2::DispatchQueue;
|
||||||
use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
|
use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
|
||||||
use objc2_foundation::*;
|
use objc2_foundation::*;
|
||||||
@@ -34,49 +35,19 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
|
|||||||
const SHARED_DIRECTORIES_TAG: &str = "shared";
|
const SHARED_DIRECTORIES_TAG: &str = "shared";
|
||||||
pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
|
pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
|
||||||
|
|
||||||
const BYTES_PER_MB: u64 = 1024 * 1024;
|
|
||||||
const DEFAULT_CPU_COUNT: usize = 2;
|
|
||||||
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 START_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
|
const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
|
const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
|
||||||
|
|
||||||
struct StatusFile {
|
const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
|
||||||
path: PathBuf,
|
|
||||||
cleared: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StatusFile {
|
|
||||||
fn new(path: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
cleared: AtomicBool::new(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&self, message: &str) {
|
|
||||||
let _ = fs::write(&self.path, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for StatusFile {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if !self.cleared.load(Ordering::SeqCst) {
|
|
||||||
let _ = fs::remove_file(&self.path);
|
|
||||||
self.cleared.store(true, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const PROVISION_SCRIPT: &str = include_str!("provision.sh");
|
const PROVISION_SCRIPT: &str = include_str!("provision.sh");
|
||||||
const PROVISION_SCRIPT_NAME: &str = "provision.sh";
|
|
||||||
const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
|
const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
|
||||||
const DEFAULT_RAW_NAME: &str = "default.raw";
|
const DEFAULT_RAW_NAME: &str = "default.raw";
|
||||||
const INSTANCE_RAW_NAME: &str = "instance.raw";
|
const INSTANCE_RAW_NAME: &str = "instance.raw";
|
||||||
const BASE_DISK_RAW_NAME: &str = "disk.raw";
|
const BASE_DISK_RAW_NAME: &str = "disk.raw";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) enum LoginAction {
|
pub enum LoginAction {
|
||||||
Expect {
|
Expect {
|
||||||
text: String,
|
text: String,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -88,23 +59,20 @@ pub(crate) enum LoginAction {
|
|||||||
},
|
},
|
||||||
Send(String),
|
Send(String),
|
||||||
}
|
}
|
||||||
|
use crate::config::BoxConfig;
|
||||||
use LoginAction::*;
|
use LoginAction::*;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct DirectoryShare {
|
pub struct DirectoryShare {
|
||||||
host: PathBuf,
|
host: PathBuf,
|
||||||
guest: PathBuf,
|
guest: PathBuf,
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DirectoryShare {
|
impl DirectoryShare {
|
||||||
pub(crate) fn new(
|
pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
|
||||||
host: PathBuf,
|
|
||||||
mut guest: PathBuf,
|
|
||||||
read_only: bool,
|
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
if !host.exists() {
|
if !host.exists() {
|
||||||
return Err(format!("Host path does not exist: {}", host.display()).into());
|
bail!(format!("host path does not exist: {}", host.display()));
|
||||||
}
|
}
|
||||||
if !guest.is_absolute() {
|
if !guest.is_absolute() {
|
||||||
guest = PathBuf::from("/root").join(guest);
|
guest = PathBuf::from("/root").join(guest);
|
||||||
@@ -116,10 +84,10 @@ impl DirectoryShare {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn from_mount_spec(spec: &str) -> Result<Self> {
|
||||||
let parts: Vec<&str> = spec.split(':').collect();
|
let parts: Vec<&str> = spec.split(':').collect();
|
||||||
if parts.len() < 2 || parts.len() > 3 {
|
if parts.len() < 2 || parts.len() > 3 {
|
||||||
return Err(format!("Invalid mount spec: {spec}").into());
|
bail!(format!("invalid mount spec: {spec}"));
|
||||||
}
|
}
|
||||||
let host = expand_tilde_path(parts[0]);
|
let host = expand_tilde_path(parts[0]);
|
||||||
let guest = PathBuf::from(parts[1]);
|
let guest = PathBuf::from(parts[1]);
|
||||||
@@ -128,11 +96,10 @@ impl DirectoryShare {
|
|||||||
"read-only" => true,
|
"read-only" => true,
|
||||||
"read-write" => false,
|
"read-write" => false,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
bail!(format!(
|
||||||
"Invalid mount mode '{}'; expected read-only or read-write",
|
"Invalid mount mode '{}'; expected read-only or read-write",
|
||||||
parts[2]
|
parts[2]
|
||||||
)
|
));
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -176,19 +143,28 @@ pub struct VmArg {
|
|||||||
pub mounts: Vec<String>,
|
pub mounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
|
type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
|
||||||
|
|
||||||
|
fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
|
||||||
|
if let Some(status) = status {
|
||||||
|
status(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
||||||
{
|
{
|
||||||
run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
|
run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run_with_args_and_extras<F>(
|
pub fn run_with_args_and_extras<F>(
|
||||||
args: VmArg,
|
args: VmArg,
|
||||||
io_handler: F,
|
io_handler: F,
|
||||||
extra_login_actions: Vec<LoginAction>,
|
extra_login_actions: Vec<LoginAction>,
|
||||||
extra_directory_shares: Vec<DirectoryShare>,
|
extra_directory_shares: Vec<DirectoryShare>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>>
|
status: Option<&StatusEmitter<'_>>,
|
||||||
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
||||||
{
|
{
|
||||||
@@ -197,7 +173,7 @@ where
|
|||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
let project_name = project_root
|
let project_name = project_root
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or("Project directory has no name")?
|
.with_context(|| "Project directory has no name")?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
|
|
||||||
@@ -209,8 +185,10 @@ where
|
|||||||
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
|
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
|
||||||
|
|
||||||
let instance_dir = project_root.join(INSTANCE_DIR_NAME);
|
let instance_dir = project_root.join(INSTANCE_DIR_NAME);
|
||||||
let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
|
fs::create_dir_all(&instance_dir)?;
|
||||||
status_file.update("preparing VM image...");
|
emit_status(status, "preparing VM image...");
|
||||||
|
tracing::info!("preparing VM image...");
|
||||||
|
let provision_log = instance_dir.join("provision.log");
|
||||||
|
|
||||||
let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
|
let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
|
||||||
let base_compressed = cache_dir.join(basename_compressed);
|
let base_compressed = cache_dir.join(basename_compressed);
|
||||||
@@ -234,13 +212,14 @@ where
|
|||||||
&base_compressed,
|
&base_compressed,
|
||||||
&default_raw,
|
&default_raw,
|
||||||
std::slice::from_ref(&mise_directory_share),
|
std::slice::from_ref(&mise_directory_share),
|
||||||
Some(&status_file),
|
Some(&provision_log),
|
||||||
|
status,
|
||||||
)?;
|
)?;
|
||||||
let _ = ensure_instance_disk(
|
let _ = ensure_instance_disk(
|
||||||
&instance_raw,
|
&instance_raw,
|
||||||
&default_raw,
|
&default_raw,
|
||||||
args.disk_bytes,
|
ByteSize(args.disk_bytes),
|
||||||
Some(&status_file),
|
status,
|
||||||
)?;
|
)?;
|
||||||
let base_size = fs::metadata(&default_raw)?.len();
|
let base_size = fs::metadata(&default_raw)?.len();
|
||||||
let instance_size = fs::metadata(&instance_raw)?.len();
|
let instance_size = fs::metadata(&instance_raw)?.len();
|
||||||
@@ -270,7 +249,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
if needs_resize {
|
if needs_resize {
|
||||||
let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?;
|
let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
|
||||||
login_actions.push(Send(resize_cmd));
|
login_actions.push(Send(resize_cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,25 +265,29 @@ where
|
|||||||
&directory_shares[..],
|
&directory_shares[..],
|
||||||
args.cpu_count,
|
args.cpu_count,
|
||||||
args.ram_bytes,
|
args.ram_bytes,
|
||||||
Some(&status_file),
|
status,
|
||||||
io_handler,
|
io_handler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn script_command_from_content(
|
pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
|
||||||
label: &str,
|
|
||||||
script: &str,
|
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let marker = "VIBE_SCRIPT_EOF";
|
let marker = "VIBE_SCRIPT_EOF";
|
||||||
let guest_dir = "/tmp/vibe-scripts";
|
let guest_dir = "/tmp/vibe-scripts";
|
||||||
let guest_path = format!("{guest_dir}/{label}.sh");
|
let guest_path = format!("{guest_dir}/{label}.sh");
|
||||||
|
let script_body = match script.split_once('\n') {
|
||||||
|
Some((first, rest)) if first.starts_with("#!") => rest,
|
||||||
|
_ => script,
|
||||||
|
};
|
||||||
|
let wrapped_script = ERROR_REPORT_SCRIPT
|
||||||
|
.replace("__LABEL__", label)
|
||||||
|
.replace("__SCRIPT_BODY__", script_body);
|
||||||
let command = format!(
|
let command = format!(
|
||||||
"mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
|
"mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
|
||||||
);
|
);
|
||||||
if script.contains(marker) {
|
if script.contains(marker) {
|
||||||
return Err(
|
bail!(format!(
|
||||||
format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
|
"Script '{label}' contains marker '{marker}', cannot safely upload"
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
Ok(command)
|
Ok(command)
|
||||||
}
|
}
|
||||||
@@ -501,18 +484,16 @@ impl IoControl {
|
|||||||
fn ensure_base_image(
|
fn ensure_base_image(
|
||||||
base_raw: &Path,
|
base_raw: &Path,
|
||||||
base_compressed: &Path,
|
base_compressed: &Path,
|
||||||
status: Option<&StatusFile>,
|
status: Option<&StatusEmitter<'_>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
if base_raw.exists() {
|
if base_raw.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !base_compressed.exists()
|
if !base_compressed.exists()
|
||||||
|| std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
|
|| fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
|
||||||
{
|
{
|
||||||
if let Some(status) = status {
|
emit_status(status, "downloading base image...");
|
||||||
status.update("downloading base image...");
|
|
||||||
}
|
|
||||||
tracing::info!("downloading base image");
|
tracing::info!("downloading base image");
|
||||||
let status = Command::new("curl")
|
let status = Command::new("curl")
|
||||||
.args([
|
.args([
|
||||||
@@ -527,15 +508,14 @@ fn ensure_base_image(
|
|||||||
])
|
])
|
||||||
.status()?;
|
.status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Failed to download base image".into());
|
bail!("failed to download base image");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check SHA
|
// Check SHA
|
||||||
{
|
{
|
||||||
if let Some(status) = status {
|
emit_status(status, "verifying base image...");
|
||||||
status.update("verifying base image...");
|
tracing::info!("verifying base image...");
|
||||||
}
|
|
||||||
let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
|
let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
|
||||||
|
|
||||||
let mut child = Command::new("/usr/bin/shasum")
|
let mut child = Command::new("/usr/bin/shasum")
|
||||||
@@ -553,25 +533,25 @@ fn ensure_base_image(
|
|||||||
|
|
||||||
let status = child.wait().expect("failed to wait on child");
|
let status = child.wait().expect("failed to wait on child");
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
|
bail!(format!(
|
||||||
|
"SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(status) = status {
|
emit_status(status, "decompressing base image...");
|
||||||
status.update("decompressing base image...");
|
tracing::info!("decompressing base image...");
|
||||||
}
|
|
||||||
tracing::info!("decompressing base image");
|
|
||||||
let status = Command::new("tar")
|
let status = Command::new("tar")
|
||||||
.args([
|
.args([
|
||||||
"-xOf",
|
"-xOf",
|
||||||
&base_compressed.to_string_lossy(),
|
&base_compressed.to_string_lossy(),
|
||||||
BASE_DISK_RAW_NAME,
|
BASE_DISK_RAW_NAME,
|
||||||
])
|
])
|
||||||
.stdout(std::fs::File::create(base_raw)?)
|
.stdout(fs::File::create(base_raw)?)
|
||||||
.status()?;
|
.status()?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Failed to decompress base image".into());
|
bail!("Failed to decompress base image");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -582,21 +562,20 @@ fn ensure_default_image(
|
|||||||
base_compressed: &Path,
|
base_compressed: &Path,
|
||||||
default_raw: &Path,
|
default_raw: &Path,
|
||||||
directory_shares: &[DirectoryShare],
|
directory_shares: &[DirectoryShare],
|
||||||
status: Option<&StatusFile>,
|
provision_log: Option<&Path>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
status: Option<&StatusEmitter<'_>>,
|
||||||
|
) -> Result<()> {
|
||||||
if default_raw.exists() {
|
if default_raw.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_base_image(base_raw, base_compressed, status)?;
|
ensure_base_image(base_raw, base_compressed, status)?;
|
||||||
|
|
||||||
if let Some(status) = status {
|
emit_status(status, "configuring base image...");
|
||||||
status.update("configuring base image...");
|
tracing::info!("configuring base image...");
|
||||||
}
|
|
||||||
tracing::info!("configuring base image");
|
|
||||||
fs::copy(base_raw, default_raw)?;
|
fs::copy(base_raw, default_raw)?;
|
||||||
|
|
||||||
let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
|
let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
|
||||||
let provision_actions = [
|
let provision_actions = [
|
||||||
Send(provision_command),
|
Send(provision_command),
|
||||||
ExpectEither {
|
ExpectEither {
|
||||||
@@ -605,14 +584,31 @@ fn ensure_default_image(
|
|||||||
timeout: PROVISION_EXPECT_TIMEOUT,
|
timeout: PROVISION_EXPECT_TIMEOUT,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if let Err(err) = run_vm(
|
let provision_result = if let Some(log_path) = provision_log {
|
||||||
|
let log_path = log_path.to_path_buf();
|
||||||
|
run_vm_with_io(
|
||||||
default_raw,
|
default_raw,
|
||||||
&provision_actions,
|
&provision_actions,
|
||||||
directory_shares,
|
directory_shares,
|
||||||
DEFAULT_CPU_COUNT,
|
BoxConfig::default().cpu_count,
|
||||||
DEFAULT_RAM_BYTES,
|
BoxConfig::default().ram_size.as_u64(),
|
||||||
None,
|
status,
|
||||||
) {
|
move |output_monitor, vm_output_fd, vm_input_fd| {
|
||||||
|
spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
run_vm(
|
||||||
|
default_raw,
|
||||||
|
&provision_actions,
|
||||||
|
directory_shares,
|
||||||
|
BoxConfig::default().cpu_count,
|
||||||
|
BoxConfig::default().ram_size.as_u64(),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = provision_result {
|
||||||
let _ = fs::remove_file(default_raw);
|
let _ = fs::remove_file(default_raw);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -623,18 +619,16 @@ fn ensure_default_image(
|
|||||||
fn ensure_instance_disk(
|
fn ensure_instance_disk(
|
||||||
instance_raw: &Path,
|
instance_raw: &Path,
|
||||||
template_raw: &Path,
|
template_raw: &Path,
|
||||||
target_bytes: u64,
|
target_bytes: ByteSize,
|
||||||
status: Option<&StatusFile>,
|
status: Option<&StatusEmitter<'_>>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool> {
|
||||||
if instance_raw.exists() {
|
if instance_raw.exists() {
|
||||||
let current_size = fs::metadata(instance_raw)?.len();
|
let current_size = ByteSize(fs::metadata(instance_raw)?.len());
|
||||||
if current_size != target_bytes {
|
if current_size != target_bytes {
|
||||||
let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
|
let current_gb = current_size;
|
||||||
let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
let target_gb = target_bytes;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
current_bytes = current_size,
|
"instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
|
||||||
target_bytes,
|
|
||||||
"instance disk size does not match config (current {:.2} GB, config {:.2} GB); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
|
|
||||||
current_gb,
|
current_gb,
|
||||||
target_gb
|
target_gb
|
||||||
);
|
);
|
||||||
@@ -642,30 +636,28 @@ fn ensure_instance_disk(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let template_size = fs::metadata(template_raw)?.len();
|
let template_size = ByteSize(fs::metadata(template_raw)?.len());
|
||||||
if target_bytes < template_size {
|
if target_bytes < template_size {
|
||||||
return Err(format!(
|
bail!(format!(
|
||||||
"Requested disk size {} bytes is smaller than base image size {} bytes",
|
"Requested disk size {} is smaller than base image size {}",
|
||||||
target_bytes, template_size
|
target_bytes, template_size
|
||||||
)
|
));
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
let target_size = target_bytes;
|
let target_size = target_bytes;
|
||||||
let needs_resize = target_size > template_size;
|
let needs_resize = target_size > template_size;
|
||||||
|
|
||||||
if let Some(status) = status {
|
emit_status(status, "creating instance disk...");
|
||||||
status.update("creating instance disk...");
|
tracing::info!("creating instance disk...");
|
||||||
}
|
|
||||||
tracing::info!(path = %template_raw.display(), "creating instance disk");
|
tracing::info!(path = %template_raw.display(), "creating instance disk");
|
||||||
std::fs::create_dir_all(instance_raw.parent().unwrap())?;
|
fs::create_dir_all(instance_raw.parent().unwrap())?;
|
||||||
if target_size == template_size {
|
if target_size == template_size {
|
||||||
fs::copy(template_raw, instance_raw)?;
|
fs::copy(template_raw, instance_raw)?;
|
||||||
return Ok(needs_resize);
|
return Ok(needs_resize);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dst = std::fs::File::create(instance_raw)?;
|
let mut dst = fs::File::create(instance_raw)?;
|
||||||
dst.set_len(target_size)?;
|
dst.set_len(target_size.as_u64())?;
|
||||||
let mut src = std::fs::File::open(template_raw)?;
|
let mut src = fs::File::open(template_raw)?;
|
||||||
std::io::copy(&mut src, &mut dst)?;
|
std::io::copy(&mut src, &mut dst)?;
|
||||||
Ok(needs_resize)
|
Ok(needs_resize)
|
||||||
}
|
}
|
||||||
@@ -683,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
|
|||||||
(read_stream.into(), write_stream.into())
|
(read_stream.into(), write_stream.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_vm_io_with_hooks<F, G>(
|
pub fn spawn_vm_io_with_hooks<
|
||||||
|
F: FnMut(&str) -> bool + std::marker::Send + 'static,
|
||||||
|
G: FnMut(&[u8]) + std::marker::Send + 'static,
|
||||||
|
>(
|
||||||
output_monitor: Arc<OutputMonitor>,
|
output_monitor: Arc<OutputMonitor>,
|
||||||
vm_output_fd: OwnedFd,
|
vm_output_fd: OwnedFd,
|
||||||
vm_input_fd: OwnedFd,
|
vm_input_fd: OwnedFd,
|
||||||
io_control: Arc<IoControl>,
|
io_control: Arc<IoControl>,
|
||||||
mut on_line: F,
|
mut on_line: F,
|
||||||
mut on_output: G,
|
mut on_output: G,
|
||||||
) -> IoContext
|
) -> IoContext {
|
||||||
where
|
|
||||||
F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
|
|
||||||
G: FnMut(&[u8]) + ::std::marker::Send + 'static,
|
|
||||||
{
|
|
||||||
let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
|
let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
|
||||||
|
|
||||||
// raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
|
// raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
|
||||||
@@ -842,14 +833,14 @@ where
|
|||||||
PollResult::Spurious => continue,
|
PollResult::Spurious => continue,
|
||||||
PollResult::Ready(bytes) => {
|
PollResult::Ready(bytes) => {
|
||||||
if io_control.forward_output() {
|
if io_control.forward_output() {
|
||||||
// enable raw mode, if we haven't already
|
// enable raw mode if we haven't already
|
||||||
if raw_guard.lock().unwrap().is_none()
|
if raw_guard.lock().unwrap().is_none()
|
||||||
&& let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
|
&& let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
|
||||||
{
|
{
|
||||||
*raw_guard.lock().unwrap() = Some(guard);
|
*raw_guard.lock().unwrap() = Some(guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stdout = std::io::stdout().lock();
|
let mut stdout = io::stdout().lock();
|
||||||
if stdout.write_all(bytes).is_err() {
|
if stdout.write_all(bytes).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -865,7 +856,7 @@ where
|
|||||||
|
|
||||||
// Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
|
// Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
|
||||||
let mux_thread = thread::spawn(move || {
|
let mux_thread = thread::spawn(move || {
|
||||||
let mut vm_writer = std::fs::File::from(vm_input_fd);
|
let mut vm_writer = fs::File::from(vm_input_fd);
|
||||||
loop {
|
loop {
|
||||||
match input_rx.recv() {
|
match input_rx.recv() {
|
||||||
Ok(VmInput::Bytes(data)) => {
|
Ok(VmInput::Bytes(data)) => {
|
||||||
@@ -895,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
|
|||||||
on_line: F,
|
on_line: F,
|
||||||
) -> IoContext
|
) -> IoContext
|
||||||
where
|
where
|
||||||
F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
|
F: FnMut(&str) -> bool + std::marker::Send + 'static,
|
||||||
{
|
{
|
||||||
spawn_vm_io_with_hooks(
|
spawn_vm_io_with_hooks(
|
||||||
output_monitor,
|
output_monitor,
|
||||||
@@ -915,6 +906,36 @@ pub fn spawn_vm_io(
|
|||||||
spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
|
spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_vm_io_with_log(
|
||||||
|
output_monitor: Arc<OutputMonitor>,
|
||||||
|
vm_output_fd: OwnedFd,
|
||||||
|
vm_input_fd: OwnedFd,
|
||||||
|
log_path: PathBuf,
|
||||||
|
) -> IoContext {
|
||||||
|
let log_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| Arc::new(Mutex::new(file)));
|
||||||
|
|
||||||
|
spawn_vm_io_with_hooks(
|
||||||
|
output_monitor,
|
||||||
|
vm_output_fd,
|
||||||
|
vm_input_fd,
|
||||||
|
IoControl::new(),
|
||||||
|
|_| false,
|
||||||
|
move |bytes| {
|
||||||
|
if let Some(log) = &log_file
|
||||||
|
&& let Ok(mut file) = log.lock()
|
||||||
|
{
|
||||||
|
let _ = file.write_all(bytes);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
impl IoContext {
|
impl IoContext {
|
||||||
pub fn shutdown(self) {
|
pub fn shutdown(self) {
|
||||||
let _ = self.input_tx.send(VmInput::Shutdown);
|
let _ = self.input_tx.send(VmInput::Shutdown);
|
||||||
@@ -932,7 +953,7 @@ fn create_vm_configuration(
|
|||||||
vm_writes_to_fd: OwnedFd,
|
vm_writes_to_fd: OwnedFd,
|
||||||
cpu_count: usize,
|
cpu_count: usize,
|
||||||
ram_bytes: u64,
|
ram_bytes: u64,
|
||||||
) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
|
) -> Result<Retained<VZVirtualMachineConfiguration>> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let platform =
|
let platform =
|
||||||
VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
|
VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
|
||||||
@@ -966,7 +987,7 @@ fn create_vm_configuration(
|
|||||||
false,
|
false,
|
||||||
VZDiskImageCachingMode::Automatic,
|
VZDiskImageCachingMode::Automatic,
|
||||||
VZDiskImageSynchronizationMode::Full,
|
VZDiskImageSynchronizationMode::Full,
|
||||||
).unwrap();
|
)?;
|
||||||
|
|
||||||
let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
|
let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
|
||||||
VZVirtioBlockDeviceConfiguration::alloc(),
|
VZVirtioBlockDeviceConfiguration::alloc(),
|
||||||
@@ -1053,7 +1074,7 @@ fn create_vm_configuration(
|
|||||||
// Validate
|
// Validate
|
||||||
config.validateWithError().map_err(|e| {
|
config.validateWithError().map_err(|e| {
|
||||||
io::Error::other(format!(
|
io::Error::other(format!(
|
||||||
"Invalid VM configuration: {:?}",
|
"invalid VM configuration: {:?}",
|
||||||
e.localizedDescription()
|
e.localizedDescription()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -1062,9 +1083,9 @@ fn create_vm_configuration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
|
fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = env::temp_dir();
|
||||||
let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
|
let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
|
||||||
let url = nsurl_from_path(&temp_path)?;
|
let url = nsurl_from_path(&temp_path)?;
|
||||||
let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
|
let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
|
||||||
@@ -1080,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
|
|||||||
fn spawn_login_actions_thread(
|
fn spawn_login_actions_thread(
|
||||||
login_actions: Vec<LoginAction>,
|
login_actions: Vec<LoginAction>,
|
||||||
output_monitor: Arc<OutputMonitor>,
|
output_monitor: Arc<OutputMonitor>,
|
||||||
input_tx: mpsc::Sender<VmInput>,
|
input_tx: Sender<VmInput>,
|
||||||
vm_output_tx: mpsc::Sender<VmOutput>,
|
vm_output_tx: Sender<VmOutput>,
|
||||||
) -> thread::JoinHandle<()> {
|
) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for a in login_actions {
|
for a in login_actions {
|
||||||
@@ -1131,9 +1152,9 @@ fn run_vm_with_io<F>(
|
|||||||
directory_shares: &[DirectoryShare],
|
directory_shares: &[DirectoryShare],
|
||||||
cpu_count: usize,
|
cpu_count: usize,
|
||||||
ram_bytes: u64,
|
ram_bytes: u64,
|
||||||
status: Option<&StatusFile>,
|
status: Option<&StatusEmitter<'_>>,
|
||||||
io_handler: F,
|
io_handler: F,
|
||||||
) -> Result<(), Box<dyn std::error::Error>>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
|
||||||
{
|
{
|
||||||
@@ -1180,23 +1201,22 @@ where
|
|||||||
|
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
result.map_err(|e| format!("Failed to start VM: {}", e))?;
|
result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(mpsc::TryRecvError::Empty) => continue,
|
Err(mpsc::TryRecvError::Empty) => continue,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
return Err("VM start channel disconnected".into());
|
bail!("VM start channel disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Instant::now() >= start_deadline {
|
if Instant::now() >= start_deadline {
|
||||||
return Err("Timed out waiting for VM to start".into());
|
bail!("Timed out waiting for VM to start");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(status) = status {
|
emit_status(status, "vm booting... go vibecoder!");
|
||||||
status.update("vm booting... go vibecoder!");
|
tracing::info!("vm booting... go vibecoder!");
|
||||||
}
|
|
||||||
tracing::info!("vm booting");
|
tracing::info!("vm booting");
|
||||||
|
|
||||||
let output_monitor = Arc::new(OutputMonitor::default());
|
let output_monitor = Arc::new(OutputMonitor::default());
|
||||||
@@ -1242,7 +1262,7 @@ where
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut last_state = None;
|
let mut last_state = None;
|
||||||
let mut exit_result = Ok(());
|
let mut exit_result: Result<(), String> = Ok(());
|
||||||
loop {
|
loop {
|
||||||
unsafe {
|
unsafe {
|
||||||
NSRunLoop::mainRunLoop().runMode_beforeDate(
|
NSRunLoop::mainRunLoop().runMode_beforeDate(
|
||||||
@@ -1261,8 +1281,7 @@ where
|
|||||||
exit_result = Err(format!(
|
exit_result = Err(format!(
|
||||||
"Login action ({}) timed out after {:?}; shutting down.",
|
"Login action ({}) timed out after {:?}; shutting down.",
|
||||||
action, timeout
|
action, timeout
|
||||||
)
|
));
|
||||||
.into());
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if vm.canRequestStop() {
|
if vm.canRequestStop() {
|
||||||
if let Err(err) = vm.requestStopWithError() {
|
if let Err(err) = vm.requestStopWithError() {
|
||||||
@@ -1279,8 +1298,7 @@ where
|
|||||||
exit_result = Err(format!(
|
exit_result = Err(format!(
|
||||||
"Login action ({}) failed: {}; shutting down.",
|
"Login action ({}) failed: {}; shutting down.",
|
||||||
action, reason
|
action, reason
|
||||||
)
|
));
|
||||||
.into());
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if vm.canRequestStop() {
|
if vm.canRequestStop() {
|
||||||
if let Err(err) = vm.requestStopWithError() {
|
if let Err(err) = vm.requestStopWithError() {
|
||||||
@@ -1296,7 +1314,7 @@ where
|
|||||||
Err(mpsc::TryRecvError::Empty) => {}
|
Err(mpsc::TryRecvError::Empty) => {}
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {}
|
Err(mpsc::TryRecvError::Disconnected) => {}
|
||||||
}
|
}
|
||||||
if state != objc2_virtualization::VZVirtualMachineState::Running {
|
if state != VZVirtualMachineState::Running {
|
||||||
//eprintln!("VM stopped with state: {:?}", state);
|
//eprintln!("VM stopped with state: {:?}", state);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1324,7 @@ where
|
|||||||
|
|
||||||
io_ctx.shutdown();
|
io_ctx.shutdown();
|
||||||
|
|
||||||
exit_result
|
exit_result.map_err(Error::msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_vm(
|
fn run_vm(
|
||||||
@@ -1315,8 +1333,8 @@ fn run_vm(
|
|||||||
directory_shares: &[DirectoryShare],
|
directory_shares: &[DirectoryShare],
|
||||||
cpu_count: usize,
|
cpu_count: usize,
|
||||||
ram_bytes: u64,
|
ram_bytes: u64,
|
||||||
status: Option<&StatusFile>,
|
status: Option<&StatusEmitter<'_>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
run_vm_with_io(
|
run_vm_with_io(
|
||||||
disk_path,
|
disk_path,
|
||||||
login_actions,
|
login_actions,
|
||||||
@@ -1328,7 +1346,7 @@ fn run_vm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
|
fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
|
||||||
let abs_path = if path.is_absolute() {
|
let abs_path = if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
@@ -1337,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
|
|||||||
let ns_path = NSString::from_str(
|
let ns_path = NSString::from_str(
|
||||||
abs_path
|
abs_path
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or("Non-UTF8 path encountered while building NSURL")?,
|
.with_context(|| "non-UTF8 path encountered while building NSURL")?,
|
||||||
);
|
);
|
||||||
Ok(NSURL::fileURLWithPath(&ns_path))
|
Ok(NSURL::fileURLWithPath(&ns_path))
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
|
|||||||
let original = attributes;
|
let original = attributes;
|
||||||
|
|
||||||
// Disable translation of carriage return to newline on input
|
// Disable translation of carriage return to newline on input
|
||||||
attributes.c_iflag &= !(libc::ICRNL);
|
attributes.c_iflag &= !libc::ICRNL;
|
||||||
// Disable canonical mode (line buffering), echo, and signal generation
|
// Disable canonical mode (line buffering), echo, and signal generation
|
||||||
attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
|
attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
|
||||||
attributes.c_cc[libc::VMIN] = 0;
|
attributes.c_cc[libc::VMIN] = 0;
|
||||||
@@ -1380,10 +1398,10 @@ 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") {
|
if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let exe = std::env::current_exe().expect("failed to get current exe path");
|
let exe = 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");
|
||||||
|
|
||||||
let has_required_entitlements = {
|
let has_required_entitlements = {
|
||||||
@@ -1405,8 +1423,8 @@ pub fn ensure_signed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ENTITLEMENTS: &str = include_str!("entitlements.plist");
|
const ENTITLEMENTS: &str = include_str!("entitlements.plist");
|
||||||
let entitlements_path = std::env::temp_dir().join("entitlements.plist");
|
let entitlements_path = env::temp_dir().join("entitlements.plist");
|
||||||
std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
|
fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
|
||||||
|
|
||||||
let output = Command::new("codesign")
|
let output = Command::new("codesign")
|
||||||
.args([
|
.args([
|
||||||
@@ -1419,7 +1437,7 @@ pub fn ensure_signed() {
|
|||||||
])
|
])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&entitlements_path);
|
let _ = fs::remove_file(&entitlements_path);
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => {
|
||||||
@@ -1427,7 +1445,7 @@ pub fn ensure_signed() {
|
|||||||
if !stderr.trim().is_empty() {
|
if !stderr.trim().is_empty() {
|
||||||
tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
|
tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
|
||||||
}
|
}
|
||||||
let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
|
let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
|
||||||
tracing::error!(error = %err, "failed to re-exec after signing");
|
tracing::error!(error = %err, "failed to re-exec after signing");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-106
@@ -1,12 +1,22 @@
|
|||||||
|
use crate::instance::{VmLiveness, vm_liveness};
|
||||||
|
use crate::session_manager::INSTANCE_DIR_NAME;
|
||||||
|
use crate::{
|
||||||
|
config::CONFIG_PATH_ENV,
|
||||||
|
instance::{
|
||||||
|
InstanceConfig, STATUS_VM_ERROR_PREFIX, 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_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
|
||||||
|
vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result, anyhow, bail};
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
os::unix::{
|
os::unix::{
|
||||||
fs::FileTypeExt,
|
|
||||||
fs::PermissionsExt,
|
fs::PermissionsExt,
|
||||||
io::AsRawFd,
|
io::AsRawFd,
|
||||||
net::{UnixListener, UnixStream},
|
net::{UnixListener, UnixStream},
|
||||||
process::CommandExt,
|
|
||||||
},
|
},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
@@ -15,19 +25,8 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
config::CONFIG_PATH_ENV,
|
const VM_ROOT_LOG_NAME: &str = "vm_root.log";
|
||||||
instance::VM_ROOT_LOG_NAME,
|
|
||||||
instance::{
|
|
||||||
DEFAULT_SSH_USER, 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, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
|
|
||||||
},
|
|
||||||
vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
|
|
||||||
};
|
|
||||||
|
|
||||||
const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
|
const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
|
||||||
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
|
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
|
||||||
const SHUTDOWN_RETRY_MS: u64 = 500;
|
const SHUTDOWN_RETRY_MS: u64 = 500;
|
||||||
@@ -35,6 +34,10 @@ const SHUTDOWN_RETRY_MS: u64 = 500;
|
|||||||
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
|
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
|
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
|
||||||
|
const STATUS_PREFIX: &str = "status:";
|
||||||
|
|
||||||
|
type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
|
||||||
|
type SharedStatus = Arc<Mutex<String>>;
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
fn force_exit(_reason: &str) -> ! {
|
fn force_exit(_reason: &str) -> ! {
|
||||||
@@ -50,16 +53,16 @@ pub fn ensure_manager(
|
|||||||
raw_args: &[std::ffi::OsString],
|
raw_args: &[std::ffi::OsString],
|
||||||
auto_shutdown_ms: u64,
|
auto_shutdown_ms: u64,
|
||||||
config_path: Option<&Path>,
|
config_path: Option<&Path>,
|
||||||
) -> Result<UnixStream, Box<dyn std::error::Error>> {
|
) -> Result<UnixStream> {
|
||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
tracing::debug!(root = %project_root.display(), "ensure vm manager");
|
tracing::debug!(root = %project_root.display(), "ensure vm manager");
|
||||||
let instance_dir = ensure_instance_dir(&project_root)?;
|
let instance_dir = ensure_instance_dir(&project_root)?;
|
||||||
cleanup_stale_manager(&instance_dir);
|
cleanup_stale_manager(&project_root)?;
|
||||||
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
||||||
|
|
||||||
if let Ok(stream) = UnixStream::connect(&socket_path) {
|
if let Ok(stream) = UnixStream::connect(&socket_path) {
|
||||||
send_client_pid(&stream);
|
send_client_pid(&stream);
|
||||||
tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
|
tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
|
||||||
return Ok(stream);
|
return Ok(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +100,11 @@ pub fn ensure_manager(
|
|||||||
drop(lock_file.take());
|
drop(lock_file.take());
|
||||||
let _ = fs::remove_file(&lock_path);
|
let _ = fs::remove_file(&lock_path);
|
||||||
}
|
}
|
||||||
return Err(format!(
|
bail!(format!(
|
||||||
"Timed out waiting for vm manager socket: {} ({})",
|
"timed out waiting for vm manager socket: {} ({})",
|
||||||
socket_path.display(),
|
socket_path.display(),
|
||||||
err
|
err
|
||||||
)
|
));
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
@@ -110,12 +112,19 @@ pub fn ensure_manager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_manager(
|
pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
|
||||||
args: vm::VmArg,
|
|
||||||
auto_shutdown_ms: u64,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
tracing::info!(root = %project_root.display(), "vm manager starting");
|
tracing::info!(root = %project_root.display(), "vm manager starting");
|
||||||
|
#[cfg(not(feature = "mock-vm"))]
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
env::remove_var("VIBEBOX_SKIP_CODESIGN");
|
||||||
|
}
|
||||||
|
vm::ensure_signed();
|
||||||
|
unsafe {
|
||||||
|
env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
let _pid_guard = ensure_pid_file(&project_root)?;
|
let _pid_guard = ensure_pid_file(&project_root)?;
|
||||||
#[cfg(feature = "mock-vm")]
|
#[cfg(feature = "mock-vm")]
|
||||||
tracing::info!("vm manager using mock executor");
|
tracing::info!("vm manager using mock executor");
|
||||||
@@ -133,7 +142,6 @@ pub fn run_manager(
|
|||||||
#[cfg(feature = "mock-vm")]
|
#[cfg(feature = "mock-vm")]
|
||||||
{
|
{
|
||||||
ManagerOptions {
|
ManagerOptions {
|
||||||
ensure_signed: false,
|
|
||||||
detach: true,
|
detach: true,
|
||||||
prepare_vm: false,
|
prepare_vm: false,
|
||||||
}
|
}
|
||||||
@@ -141,7 +149,6 @@ pub fn run_manager(
|
|||||||
#[cfg(not(feature = "mock-vm"))]
|
#[cfg(not(feature = "mock-vm"))]
|
||||||
{
|
{
|
||||||
ManagerOptions {
|
ManagerOptions {
|
||||||
ensure_signed: true,
|
|
||||||
detach: true,
|
detach: true,
|
||||||
prepare_vm: true,
|
prepare_vm: true,
|
||||||
}
|
}
|
||||||
@@ -155,25 +162,22 @@ fn spawn_manager_process(
|
|||||||
auto_shutdown_ms: u64,
|
auto_shutdown_ms: u64,
|
||||||
instance_dir: &Path,
|
instance_dir: &Path,
|
||||||
config_path: Option<&Path>,
|
config_path: Option<&Path>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
let exe = env::current_exe()?;
|
let exe = env::current_exe()?;
|
||||||
let mut supervisor_exe = exe.clone();
|
let mut supervisor_exe = exe.clone();
|
||||||
supervisor_exe.set_file_name("vibebox-supervisor");
|
supervisor_exe.set_file_name("vibebox-supervisor");
|
||||||
let use_supervisor = supervisor_exe.exists();
|
// intentional
|
||||||
let mut cmd = if use_supervisor {
|
if !supervisor_exe.exists() {
|
||||||
Command::new(supervisor_exe)
|
bail!(format!(
|
||||||
} else {
|
"vibebox-supervisor not found at {}",
|
||||||
let mut cmd = Command::new(exe);
|
supervisor_exe.display()
|
||||||
cmd.arg0("vibebox-supervisor");
|
));
|
||||||
cmd
|
}
|
||||||
};
|
let mut cmd = Command::new(supervisor_exe);
|
||||||
if raw_args.len() > 1 {
|
if raw_args.len() > 1 {
|
||||||
cmd.args(&raw_args[1..]);
|
cmd.args(&raw_args[1..]);
|
||||||
}
|
}
|
||||||
cmd.env("VIBEBOX_INTERNAL", "1");
|
cmd.env("VIBEBOX_INTERNAL", "1");
|
||||||
if !use_supervisor {
|
|
||||||
cmd.env("VIBEBOX_VM_MANAGER", "1");
|
|
||||||
}
|
|
||||||
cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
|
cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
|
||||||
cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
|
cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
|
||||||
if let Some(path) = config_path {
|
if let Some(path) = config_path {
|
||||||
@@ -201,38 +205,41 @@ fn spawn_manager_process(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
|
fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
|
||||||
let instance_dir = ensure_instance_dir(project_root)?;
|
let instance_dir = ensure_instance_dir(project_root)?;
|
||||||
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
||||||
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
|
||||||
if let Ok(content) = fs::read_to_string(&pid_path)
|
match vm_liveness(project_root)? {
|
||||||
&& let Ok(pid) = content.trim().parse::<u32>()
|
VmLiveness::RunningWithSocket { pid } => {
|
||||||
&& pid_is_alive(pid)
|
bail!("vm manager already running (pid {pid})");
|
||||||
{
|
|
||||||
if is_socket_path(&socket_path) {
|
|
||||||
return Err(format!("vm manager already running (pid {pid})").into());
|
|
||||||
}
|
}
|
||||||
|
VmLiveness::RunningWithoutSocket { pid } => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
pid,
|
pid,
|
||||||
path = %socket_path.display(),
|
path = %socket_path.display(),
|
||||||
"stale pid file detected with missing socket"
|
"stale pid file detected with missing socket"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
VmLiveness::NotRunningOrMissing => {}
|
||||||
|
}
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
fs::write(&pid_path, format!("{}\n", std::process::id()))?;
|
fs::write(&pid_path, format!("{}\n", std::process::id()))?;
|
||||||
let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
|
let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
|
||||||
Ok(PidFileGuard { path: pid_path })
|
Ok(PidFileGuard { path: pid_path })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_stale_manager(instance_dir: &Path) {
|
fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
|
||||||
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
|
let pid_path = project_root
|
||||||
if let Ok(content) = fs::read_to_string(&pid_path)
|
.join(INSTANCE_DIR_NAME)
|
||||||
&& let Ok(pid) = content.trim().parse::<u32>()
|
.join(VM_MANAGER_PID_NAME);
|
||||||
&& pid_is_alive(pid)
|
if matches!(
|
||||||
{
|
vm_liveness(project_root)?,
|
||||||
return;
|
VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
|
||||||
|
) {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_project_mount(
|
fn inject_project_mount(
|
||||||
@@ -259,12 +266,6 @@ fn inject_project_mount(
|
|||||||
mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
|
mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_socket_path(path: &Path) -> bool {
|
|
||||||
fs::metadata(path)
|
|
||||||
.map(|meta| meta.file_type().is_socket())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
|
fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
let mut mounts = Vec::with_capacity(args.mounts.len());
|
let mut mounts = Vec::with_capacity(args.mounts.len());
|
||||||
@@ -408,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
|
||||||
|
if let Ok(mut clients) = streams.lock() {
|
||||||
|
clients.retain(|stream| stream.as_raw_fd() != fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
|
||||||
|
let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
|
||||||
|
payload.push_str(STATUS_PREFIX);
|
||||||
|
payload.push_str(status);
|
||||||
|
payload.push('\n');
|
||||||
|
stream.write_all(payload.as_bytes()).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
|
fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
|
||||||
|
if let Ok(mut current) = latest_status.lock() {
|
||||||
|
*current = status.to_string();
|
||||||
|
}
|
||||||
|
if let Ok(mut clients) = streams.lock() {
|
||||||
|
clients.retain_mut(|stream| send_status_line(stream, status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send_client_pid(stream: &UnixStream) {
|
fn send_client_pid(stream: &UnixStream) {
|
||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
let payload = format!("pid={pid}\n");
|
let payload = format!("pid={pid}\n");
|
||||||
@@ -417,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
|
fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
|
||||||
match fs::OpenOptions::new()
|
match fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.create_new(true)
|
.create_new(true)
|
||||||
@@ -451,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_lock_pid(lock_path: &Path) -> Option<u32> {
|
||||||
|
let content = fs::read_to_string(lock_path).ok()?;
|
||||||
|
let line = content.lines().next()?;
|
||||||
|
line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn pid_is_alive(pid: u32) -> bool {
|
fn pid_is_alive(pid: u32) -> bool {
|
||||||
let pid = pid as libc::pid_t;
|
let pid = pid as libc::pid_t;
|
||||||
let result = unsafe { libc::kill(pid, 0) };
|
let result = unsafe { libc::kill(pid, 0) };
|
||||||
@@ -464,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_lock_pid(lock_path: &Path) -> Option<u32> {
|
|
||||||
let content = fs::read_to_string(lock_path).ok()?;
|
|
||||||
let line = content.lines().next()?;
|
|
||||||
line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_client_pid(stream: &UnixStream) -> Option<u32> {
|
fn read_client_pid(stream: &UnixStream) -> Option<u32> {
|
||||||
let mut stream = stream.try_clone().ok()?;
|
let mut stream = stream.try_clone().ok()?;
|
||||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
|
||||||
@@ -505,12 +530,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
|
|||||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
fn spawn_manager_io(
|
fn spawn_manager_io(
|
||||||
config: Arc<Mutex<InstanceConfig>>,
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
instance_dir: PathBuf,
|
project_dir: PathBuf,
|
||||||
|
clients: ClientStreams,
|
||||||
|
latest_status: SharedStatus,
|
||||||
output_monitor: Arc<vm::OutputMonitor>,
|
output_monitor: Arc<vm::OutputMonitor>,
|
||||||
vm_output_fd: std::os::unix::io::OwnedFd,
|
vm_output_fd: std::os::unix::io::OwnedFd,
|
||||||
vm_input_fd: std::os::unix::io::OwnedFd,
|
vm_input_fd: std::os::unix::io::OwnedFd,
|
||||||
) -> vm::IoContext {
|
) -> vm::IoContext {
|
||||||
let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
|
let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
|
||||||
let log_file = fs::OpenOptions::new()
|
let log_file = fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
@@ -519,7 +546,6 @@ fn spawn_manager_io(
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|file| Arc::new(Mutex::new(file)));
|
.map(|file| Arc::new(Mutex::new(file)));
|
||||||
|
|
||||||
let instance_path = instance_dir.join(INSTANCE_FILENAME);
|
|
||||||
let config_for_output = config.clone();
|
let config_for_output = config.clone();
|
||||||
let log_for_output = log_file.clone();
|
let log_for_output = log_file.clone();
|
||||||
let mut line_buf = String::new();
|
let mut line_buf = String::new();
|
||||||
@@ -542,6 +568,17 @@ fn spawn_manager_io(
|
|||||||
line_buf.drain(..=pos);
|
line_buf.drain(..=pos);
|
||||||
|
|
||||||
let cleaned = line.trim_start_matches(['\r', ' ']);
|
let cleaned = line.trim_start_matches(['\r', ' ']);
|
||||||
|
if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
|
||||||
|
let failure = script_failure.trim();
|
||||||
|
if !failure.is_empty() {
|
||||||
|
tracing::error!(script_failure = %failure, "[vm] script reported failure");
|
||||||
|
broadcast_status(
|
||||||
|
&clients,
|
||||||
|
&latest_status,
|
||||||
|
&format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
|
if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
|
||||||
let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
|
let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
|
||||||
let ip = extract_ipv4(ip_raw).unwrap_or_default();
|
let ip = extract_ipv4(ip_raw).unwrap_or_default();
|
||||||
@@ -550,7 +587,7 @@ fn spawn_manager_io(
|
|||||||
&& cfg.vm_ipv4.as_deref() != Some(ip.as_str())
|
&& cfg.vm_ipv4.as_deref() != Some(ip.as_str())
|
||||||
{
|
{
|
||||||
cfg.vm_ipv4 = Some(ip.clone());
|
cfg.vm_ipv4 = Some(ip.clone());
|
||||||
let _ = write_instance_config(&instance_path, &cfg);
|
let _ = write_instance_config(&project_dir, &cfg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,12 +610,12 @@ enum ManagerEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ManagerOptions {
|
struct ManagerOptions {
|
||||||
ensure_signed: bool,
|
|
||||||
detach: bool,
|
detach: bool,
|
||||||
prepare_vm: bool,
|
prepare_vm: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
trait VmExecutor {
|
trait VmExecutor {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_vm(
|
fn run_vm(
|
||||||
&self,
|
&self,
|
||||||
args: vm::VmArg,
|
args: vm::VmArg,
|
||||||
@@ -586,8 +623,10 @@ trait VmExecutor {
|
|||||||
extra_shares: Vec<DirectoryShare>,
|
extra_shares: Vec<DirectoryShare>,
|
||||||
config: Arc<Mutex<InstanceConfig>>,
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
instance_dir: PathBuf,
|
instance_dir: PathBuf,
|
||||||
|
clients: ClientStreams,
|
||||||
|
latest_status: SharedStatus,
|
||||||
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>>;
|
) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
@@ -600,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
|
|||||||
extra_login_actions: Vec<LoginAction>,
|
extra_login_actions: Vec<LoginAction>,
|
||||||
extra_shares: Vec<DirectoryShare>,
|
extra_shares: Vec<DirectoryShare>,
|
||||||
config: Arc<Mutex<InstanceConfig>>,
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
instance_dir: PathBuf,
|
project_dir: PathBuf,
|
||||||
|
clients: ClientStreams,
|
||||||
|
latest_status: SharedStatus,
|
||||||
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
|
let status_callback = |status: &str| {
|
||||||
|
broadcast_status(&clients, &latest_status, status);
|
||||||
|
};
|
||||||
vm::run_with_args_and_extras(
|
vm::run_with_args_and_extras(
|
||||||
args,
|
args,
|
||||||
|output_monitor, vm_output_fd, vm_input_fd| {
|
|output_monitor, vm_output_fd, vm_input_fd| {
|
||||||
let io_ctx = spawn_manager_io(
|
let io_ctx = spawn_manager_io(
|
||||||
config.clone(),
|
config.clone(),
|
||||||
instance_dir.clone(),
|
project_dir.clone(),
|
||||||
|
clients.clone(),
|
||||||
|
latest_status.clone(),
|
||||||
output_monitor,
|
output_monitor,
|
||||||
vm_output_fd,
|
vm_output_fd,
|
||||||
vm_input_fd,
|
vm_input_fd,
|
||||||
@@ -618,6 +664,7 @@ impl VmExecutor for RealVmExecutor {
|
|||||||
},
|
},
|
||||||
extra_login_actions,
|
extra_login_actions,
|
||||||
extra_shares,
|
extra_shares,
|
||||||
|
Some(&status_callback),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,8 +681,10 @@ impl VmExecutor for MockVmExecutor {
|
|||||||
_extra_shares: Vec<DirectoryShare>,
|
_extra_shares: Vec<DirectoryShare>,
|
||||||
_config: Arc<Mutex<InstanceConfig>>,
|
_config: Arc<Mutex<InstanceConfig>>,
|
||||||
_instance_dir: PathBuf,
|
_instance_dir: PathBuf,
|
||||||
|
_clients: ClientStreams,
|
||||||
|
_latest_status: SharedStatus,
|
||||||
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
let (tx, rx) = mpsc::channel::<VmInput>();
|
let (tx, rx) = mpsc::channel::<VmInput>();
|
||||||
*vm_input_tx.lock().unwrap() = Some(tx);
|
*vm_input_tx.lock().unwrap() = Some(tx);
|
||||||
tracing::info!("mock vm executor running");
|
tracing::info!("mock vm executor running");
|
||||||
@@ -661,24 +710,14 @@ fn run_manager_with(
|
|||||||
auto_shutdown_ms: u64,
|
auto_shutdown_ms: u64,
|
||||||
executor: &dyn VmExecutor,
|
executor: &dyn VmExecutor,
|
||||||
options: ManagerOptions,
|
options: ManagerOptions,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<()> {
|
||||||
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 {
|
if options.detach {
|
||||||
detach_from_terminal();
|
detach_from_terminal();
|
||||||
}
|
}
|
||||||
|
|
||||||
let project_name = project_root
|
let project_name = project_root
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or("Project directory has no name")?
|
.ok_or_else(|| anyhow!("Project directory has no name"))?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
let instance_dir = ensure_instance_dir(project_root)?;
|
let instance_dir = ensure_instance_dir(project_root)?;
|
||||||
@@ -686,16 +725,16 @@ fn run_manager_with(
|
|||||||
let _ = ensure_ssh_keypair(&instance_dir)?;
|
let _ = ensure_ssh_keypair(&instance_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut config = load_or_create_instance_config(&instance_dir)?;
|
let mut config = load_or_create_instance_config(project_root)?;
|
||||||
if config.vm_ipv4.is_some() {
|
if config.vm_ipv4.is_some() {
|
||||||
config.vm_ipv4 = None;
|
config.vm_ipv4 = None;
|
||||||
write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
|
write_instance_config(project_root, &config)?;
|
||||||
}
|
}
|
||||||
let config = Arc::new(Mutex::new(config));
|
let config = Arc::new(Mutex::new(config));
|
||||||
let ssh_user = config
|
let ssh_user = config
|
||||||
.lock()
|
.lock()
|
||||||
.map(|cfg| cfg.ssh_user_display())
|
.map(|cfg| cfg.ssh_user.clone())
|
||||||
.unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
|
.map_err(|_| anyhow!("failed to acquire ssh user display"))?;
|
||||||
if !args.no_default_mounts {
|
if !args.no_default_mounts {
|
||||||
inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
|
inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
|
||||||
}
|
}
|
||||||
@@ -703,11 +742,10 @@ fn run_manager_with(
|
|||||||
|
|
||||||
let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
|
let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
|
||||||
let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
|
let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
|
||||||
let extra_shares = vec![DirectoryShare::new(
|
let extra_shares = vec![
|
||||||
instance_dir.clone(),
|
DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
|
||||||
ssh_guest_dir.clone().into(),
|
.map_err(|err| anyhow!(err.to_string()))?,
|
||||||
true,
|
];
|
||||||
)?];
|
|
||||||
let extra_login_actions = build_ssh_login_actions(
|
let extra_login_actions = build_ssh_login_actions(
|
||||||
&config,
|
&config,
|
||||||
&project_name,
|
&project_name,
|
||||||
@@ -730,17 +768,43 @@ fn run_manager_with(
|
|||||||
let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
|
let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
|
||||||
tracing::info!(path = %socket_path.display(), "vm manager socket bound");
|
tracing::info!(path = %socket_path.display(), "vm manager socket bound");
|
||||||
|
|
||||||
|
let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
|
||||||
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
|
||||||
let event_tx_accept = event_tx.clone();
|
let event_tx_accept = event_tx.clone();
|
||||||
|
let clients_accept = clients.clone();
|
||||||
|
let latest_status_accept = latest_status.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for stream in listener.incoming() {
|
for stream in listener.incoming() {
|
||||||
match stream {
|
match stream {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
|
let mut client_fd: Option<std::os::fd::RawFd> = None;
|
||||||
|
let latest_status_snapshot = latest_status_accept
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|status| status.clone());
|
||||||
|
if let Ok(writer) = stream.try_clone() {
|
||||||
|
let writer_fd = writer.as_raw_fd();
|
||||||
|
if let Ok(mut connected) = clients_accept.lock() {
|
||||||
|
connected.push(writer);
|
||||||
|
client_fd = Some(writer_fd);
|
||||||
|
if let Some(last) = connected.last_mut()
|
||||||
|
&& let Some(status) = latest_status_snapshot.as_deref()
|
||||||
|
&& !status.is_empty()
|
||||||
|
{
|
||||||
|
let _ = send_status_line(last, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let event_tx_conn = event_tx_accept.clone();
|
let event_tx_conn = event_tx_accept.clone();
|
||||||
|
let clients_conn = clients_accept.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let pid = read_client_pid(&stream);
|
let pid = read_client_pid(&stream);
|
||||||
let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
|
let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
|
||||||
wait_for_disconnect(stream);
|
wait_for_disconnect(stream);
|
||||||
|
if let Some(fd) = client_fd {
|
||||||
|
remove_client(&clients_conn, fd);
|
||||||
|
}
|
||||||
let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
|
let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -760,34 +824,43 @@ fn run_manager_with(
|
|||||||
extra_login_actions,
|
extra_login_actions,
|
||||||
extra_shares,
|
extra_shares,
|
||||||
config.clone(),
|
config.clone(),
|
||||||
instance_dir.clone(),
|
project_root.to_path_buf(),
|
||||||
|
clients.clone(),
|
||||||
|
latest_status.clone(),
|
||||||
vm_input_tx.clone(),
|
vm_input_tx.clone(),
|
||||||
);
|
);
|
||||||
tracing::info!("vm manager vm run completed");
|
tracing::info!("vm manager vm run completed");
|
||||||
let vm_err = vm_result.err().map(|e| e.to_string());
|
let vm_err = vm_result.err().map(|e| e.to_string());
|
||||||
|
if let Some(err) = &vm_err {
|
||||||
|
broadcast_status(
|
||||||
|
&clients,
|
||||||
|
&latest_status,
|
||||||
|
&format!("{STATUS_VM_ERROR_PREFIX} {err}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
|
let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
|
||||||
let event_loop_result: Result<(), String> = event_loop_handle
|
let event_loop_result = event_loop_handle
|
||||||
.join()
|
.join()
|
||||||
.unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
|
.unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
|
||||||
.map_err(|err| err.to_string());
|
.map_err(|err| err.to_string());
|
||||||
let _ = fs::remove_file(&socket_path);
|
let _ = fs::remove_file(&socket_path);
|
||||||
if let Err(err) = &event_loop_result {
|
if let Err(err) = &event_loop_result {
|
||||||
tracing::error!(error = %err, "vm manager exiting due to event loop error");
|
tracing::error!(error = %err, "vm manager exiting due to event loop error");
|
||||||
return Err(err.to_string().into());
|
bail!(err.to_string());
|
||||||
}
|
}
|
||||||
if let Some(err) = vm_err {
|
if let Some(err) = vm_err {
|
||||||
tracing::error!(error = %err, "vm manager exiting due to vm error");
|
tracing::error!(error = %err, "vm manager exiting due to vm error");
|
||||||
return Err(err.into());
|
bail!(err);
|
||||||
}
|
}
|
||||||
tracing::info!("vm manager exiting");
|
tracing::info!("vm manager exiting");
|
||||||
Ok(event_loop_result?)
|
event_loop_result.map_err(Error::msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manager_event_loop(
|
fn manager_event_loop(
|
||||||
event_rx: mpsc::Receiver<ManagerEvent>,
|
event_rx: mpsc::Receiver<ManagerEvent>,
|
||||||
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
|
||||||
auto_shutdown_ms: u64,
|
auto_shutdown_ms: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<()> {
|
||||||
let mut ref_count: usize = 0;
|
let mut ref_count: usize = 0;
|
||||||
let mut shutdown_deadline: Option<Instant> = None;
|
let mut shutdown_deadline: Option<Instant> = None;
|
||||||
let mut shutdown_sent = false;
|
let mut shutdown_sent = false;
|
||||||
@@ -897,7 +970,7 @@ fn manager_event_loop(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::{sync::mpsc, thread, time::Duration};
|
use std::{fs, sync::mpsc, thread, time::Duration};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manager_powers_off_after_grace_when_no_refs() {
|
fn manager_powers_off_after_grace_when_no_refs() {
|
||||||
@@ -979,4 +1052,26 @@ mod tests {
|
|||||||
let _ = event_tx.send(ManagerEvent::VmExited(None));
|
let _ = event_tx.send(ManagerEvent::VmExited(None));
|
||||||
let _ = manager_thread.join();
|
let _ = manager_thread.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_is_not_stale_when_owner_pid_is_alive() {
|
||||||
|
let temp = tempfile::Builder::new()
|
||||||
|
.prefix("vb")
|
||||||
|
.tempdir_in("/tmp")
|
||||||
|
.expect("tempdir");
|
||||||
|
let lock_path = temp.path().join("vm.lock");
|
||||||
|
fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
|
||||||
|
assert!(!is_lock_stale(&lock_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_is_stale_when_owner_pid_is_missing() {
|
||||||
|
let temp = tempfile::Builder::new()
|
||||||
|
.prefix("vb")
|
||||||
|
.tempdir_in("/tmp")
|
||||||
|
.expect("tempdir");
|
||||||
|
let lock_path = temp.path().join("vm.lock");
|
||||||
|
fs::write(&lock_path, "pid=999999\n").expect("write lock");
|
||||||
|
assert!(is_lock_stale(&lock_path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user