diff --git a/src/bin/vibebox-supervisor.rs b/src/bin/vibebox-supervisor.rs index 2c03f9c..218a4c4 100644 --- a/src/bin/vibebox-supervisor.rs +++ b/src/bin/vibebox-supervisor.rs @@ -29,6 +29,7 @@ fn main() -> Result<()> { 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(), }; diff --git a/src/bin/vibebox.rs b/src/bin/vibebox.rs index 0ddc113..f3eba2c 100644 --- a/src/bin/vibebox.rs +++ b/src/bin/vibebox.rs @@ -63,6 +63,7 @@ fn main() -> Result<()> { 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(), }; @@ -80,6 +81,7 @@ fn main() -> Result<()> { 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(), }; @@ -88,6 +90,7 @@ fn main() -> Result<()> { let vm_info = VmInfo { max_memory_mb: vm_args.ram_bytes / (1024 * 1024), cpu_cores: vm_args.cpu_count, + max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0, system_name: "Debian".to_string(), // TODO: read system name from the VM. auto_shutdown_ms, }; diff --git a/src/config.rs b/src/config.rs index 014a00b..dec26ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH"; const DEFAULT_CPU_COUNT: usize = 2; const DEFAULT_RAM_MB: u64 = 2048; const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000; +const DEFAULT_DISK_GB: u64 = 5; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { @@ -25,6 +26,7 @@ pub struct Config { pub struct BoxConfig { pub cpu_count: usize, pub ram_mb: u64, + pub disk_gb: u64, pub mounts: Vec, } @@ -33,6 +35,7 @@ impl Default for BoxConfig { Self { cpu_count: default_cpu_count(), ram_mb: default_ram_mb(), + disk_gb: default_disk_gb(), mounts: default_mounts(), } } @@ -67,6 +70,10 @@ fn default_mounts() -> Vec { Vec::new() } +fn default_disk_gb() -> u64 { + DEFAULT_DISK_GB +} + pub fn config_path(project_root: &Path) -> PathBuf { project_root.join(CONFIG_FILENAME) } @@ -102,7 +109,7 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config"); if trimmed.is_empty() { die(&format!( - "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (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() )); } @@ -193,6 +200,7 @@ fn validate_schema(value: &toml::Value) -> Vec { Some(table) => { validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors); validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors); + validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors); validate_string_array( table, "mounts", @@ -259,6 +267,9 @@ fn validate_or_exit(config: &Config) { if config.box_cfg.ram_mb == 0 { die("box.ram_mb must be >= 1"); } + if config.box_cfg.disk_gb == 0 { + die("box.disk_gb must be >= 1"); + } if config.supervisor.auto_shutdown_ms == 0 { die("supervisor.auto_shutdown_ms must be >= 1"); } diff --git a/src/provision.sh b/src/provision.sh index 733a2f7..215f71e 100644 --- a/src/provision.sh +++ b/src/provision.sh @@ -14,6 +14,7 @@ apt-get install -y --no-install-recommends \ curl \ git \ ripgrep \ + cloud-guest-utils \ openssh-server \ sudo diff --git a/src/resize_disk.sh b/src/resize_disk.sh new file mode 100644 index 0000000..ac9f6af --- /dev/null +++ b/src/resize_disk.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DEV="$(findmnt / -n -o SOURCE || true)" +ROOT_FSTYPE="$(findmnt / -n -o FSTYPE || true)" + +if [ -z "$ROOT_DEV" ]; then + exit 0 +fi + +DISK_DEV="" +PART_NUM="" + +if command -v lsblk >/dev/null 2>&1; then + DISK_DEV="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null | head -n1 || true)" + PART_NUM="$(lsblk -no PARTNUM "$ROOT_DEV" 2>/dev/null | head -n1 || true)" +fi + +if [ -z "$DISK_DEV" ] || [ -z "$PART_NUM" ]; then + ROOT_BASENAME="$(basename "$ROOT_DEV")" + if echo "$ROOT_BASENAME" | grep -Eq '^nvme.+p[0-9]+$'; then + DISK_DEV="/dev/${ROOT_BASENAME%p[0-9]*}" + PART_NUM="${ROOT_BASENAME##*p}" + elif echo "$ROOT_BASENAME" | grep -Eq '^[a-z]+[0-9]+$'; then + DISK_DEV="/dev/${ROOT_BASENAME%%[0-9]*}" + PART_NUM="${ROOT_BASENAME##*[a-z]}" + fi +fi + +if [ -n "$DISK_DEV" ] && [ -n "$PART_NUM" ]; then + if command -v growpart >/dev/null 2>&1; then + growpart "$DISK_DEV" "$PART_NUM" || true + elif command -v sfdisk >/dev/null 2>&1; then + sfdisk -N "$PART_NUM" --force "$DISK_DEV" <<'EOF' || true +,, +EOF + fi +fi + +if command -v partprobe >/dev/null 2>&1; then + partprobe "$DISK_DEV" || true +fi + +case "$ROOT_FSTYPE" in + ext4|ext3|ext2) + if command -v resize2fs >/dev/null 2>&1; then + resize2fs "$ROOT_DEV" || true + fi + ;; + xfs) + if command -v xfs_growfs >/dev/null 2>&1; then + xfs_growfs / || true + fi + ;; +esac diff --git a/src/tui.rs b/src/tui.rs index 319fb2c..c43baa0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -41,6 +41,7 @@ const INFO_LINE_COUNT: u16 = 5; pub struct VmInfo { pub max_memory_mb: u64, pub cpu_cores: usize, + pub max_disk_gb: f32, pub system_name: String, pub auto_shutdown_ms: u64, } @@ -570,11 +571,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) { Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)), ]), Line::from(vec![ - Span::raw("CPU / Memory: "), + Span::raw("CPU / Memory / Disk: "), Span::styled( format!( - "{} cores / {} MB", - app.vm_info.cpu_cores, app.vm_info.max_memory_mb + "{} cores / {} MB / {} GB", + app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb ), Style::default().fg(Color::Green), ), diff --git a/src/vm.rs b/src/vm.rs index 89a4ffb..bbade9d 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -74,6 +74,7 @@ impl Drop for StatusFile { } 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 DEFAULT_RAW_NAME: &str = "default.raw"; const INSTANCE_RAW_NAME: &str = "instance.raw"; const BASE_DISK_RAW_NAME: &str = "disk.raw"; @@ -166,6 +167,7 @@ fn expand_tilde_path(value: &str) -> PathBuf { pub struct VmArg { pub cpu_count: usize, pub ram_bytes: u64, + pub disk_bytes: u64, pub no_default_mounts: bool, pub mounts: Vec, } @@ -230,7 +232,15 @@ where std::slice::from_ref(&mise_directory_share), Some(&status_file), )?; - ensure_instance_disk(&instance_raw, &default_raw, Some(&status_file))?; + let _ = ensure_instance_disk( + &instance_raw, + &default_raw, + args.disk_bytes, + Some(&status_file), + )?; + let base_size = fs::metadata(&default_raw)?.len(); + let instance_size = fs::metadata(&instance_raw)?.len(); + let needs_resize = instance_size > base_size; let disk_path = instance_raw; let mut login_actions = Vec::new(); @@ -263,6 +273,11 @@ where directory_shares.push(DirectoryShare::from_mount_spec(spec)?); } + if needs_resize { + let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?; + login_actions.push(Send(resize_cmd)); + } + if let Some(motd_action) = motd_login_action(&directory_shares) { login_actions.push(motd_action); } @@ -559,19 +574,51 @@ fn ensure_default_image( fn ensure_instance_disk( instance_raw: &Path, template_raw: &Path, + target_bytes: u64, status: Option<&StatusFile>, -) -> Result<(), Box> { +) -> Result> { if instance_raw.exists() { - return Ok(()); + let current_size = fs::metadata(instance_raw)?.len(); + if current_size != target_bytes { + let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0); + let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + tracing::warn!( + current_bytes = current_size, + 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, + target_gb + ); + } + return Ok(false); } + let template_size = fs::metadata(template_raw)?.len(); + if target_bytes < template_size { + return Err(format!( + "Requested disk size {} bytes is smaller than base image size {} bytes", + target_bytes, template_size + ) + .into()); + } + let target_size = target_bytes; + let needs_resize = target_size > template_size; + if let Some(status) = status { status.update("creating instance disk..."); } tracing::info!(path = %template_raw.display(), "creating instance disk"); std::fs::create_dir_all(instance_raw.parent().unwrap())?; - fs::copy(template_raw, instance_raw)?; - Ok(()) + if target_size == template_size { + fs::copy(template_raw, instance_raw)?; + return Ok(needs_resize); + } + + let mut dst = std::fs::File::create(instance_raw)?; + dst.set_len(target_size)?; + let mut src = std::fs::File::open(template_raw)?; + std::io::copy(&mut src, &mut dst)?; + Ok(needs_resize) } pub struct IoContext { diff --git a/vibebox.toml b/vibebox.toml index 94d5bf9..6cd156c 100644 --- a/vibebox.toml +++ b/vibebox.toml @@ -1,6 +1,7 @@ [box] cpu_count = 2 ram_mb = 2048 +disk_gb = 5 mounts = [ "~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write",