diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index 0b4ac62..ce7695c 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -6,6 +6,7 @@ mod install; mod listmonk; pub mod package_install; mod reset; +mod resize; mod start; mod stop; mod uninstall; @@ -74,7 +75,9 @@ pub enum LocalCommands { /// Reset local Colima Kubernetes state Reset, /// Start local k8s cluster with Crossplane and providers - Start, + Start(start::StartArgs), + /// Resize the local Colima VM without destroying cluster state + Resize(resize::ResizeArgs), /// Check what `hops local start` set up and report drift Doctor, /// Configure crossplane-contrib provider-family-aws and AWS ProviderConfig @@ -104,7 +107,8 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { match &args.command { LocalCommands::Install => install::run(), LocalCommands::Reset => reset::run(), - LocalCommands::Start => start::run(), + LocalCommands::Start(start_args) => start::run(start_args), + LocalCommands::Resize(resize_args) => resize::run(resize_args), LocalCommands::Doctor => doctor::run(), LocalCommands::Aws(aws_args) => aws::run(aws_args), LocalCommands::Github(github_args) => github::run(github_args), diff --git a/src/commands/local/resize.rs b/src/commands/local/resize.rs new file mode 100644 index 0000000..19a987a --- /dev/null +++ b/src/commands/local/resize.rs @@ -0,0 +1,15 @@ +use super::start::{resize_colima, ColimaSizeArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug, Clone)] +pub struct ResizeArgs { + #[command(flatten)] + pub size: ColimaSizeArgs, +} + +pub fn run(args: &ResizeArgs) -> Result<(), Box> { + resize_colima(&args.size)?; + log::info!("Colima resize complete"); + Ok(()) +} diff --git a/src/commands/local/start.rs b/src/commands/local/start.rs index d0a4d8c..55bac80 100644 --- a/src/commands/local/start.rs +++ b/src/commands/local/start.rs @@ -1,6 +1,9 @@ use super::{kubectl_apply_stdin, run_cmd, run_cmd_output, sync_registry_hosts_entry}; +use clap::Args; +use dialoguer::Confirm; +use serde::Deserialize; use std::error::Error; -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; @@ -19,22 +22,93 @@ const REGISTRY: &str = include_str!("../../../bootstrap/registry/registry.yaml") const REGISTRY_HOST: &str = "registry.crossplane-system.svc.cluster.local:5000"; const REGISTRY_HOSTNAME: &str = "registry.crossplane-system.svc.cluster.local"; -pub fn run() -> Result<(), Box> { +const DEFAULT_CPUS: u32 = 8; +const DEFAULT_MEMORY_GIB: u32 = 16; +const DEFAULT_DISK_GIB: u32 = 60; +const GIB: u64 = 1024 * 1024 * 1024; + +#[derive(Args, Debug, Clone)] +pub struct StartArgs { + #[command(flatten)] + pub size: ColimaSizeArgs, + + /// Stop and restart a running Colima VM without prompting when requested size differs. + #[arg(long)] + pub yes: bool, +} + +#[derive(Args, Debug, Clone, Default, PartialEq, Eq)] +pub struct ColimaSizeArgs { + /// Number of CPUs to allocate to the Colima VM. + #[arg(long = "cpus", visible_alias = "cpu", value_name = "N")] + pub cpus: Option, + + /// Memory to allocate to the Colima VM, in GiB. + #[arg(long, value_name = "GIB")] + pub memory: Option, + + /// Disk size to allocate to the Colima VM, in GiB. + #[arg(long, value_name = "GIB")] + pub disk: Option, +} + +impl ColimaSizeArgs { + pub fn any_set(&self) -> bool { + self.cpus.is_some() || self.memory.is_some() || self.disk.is_some() + } + + pub fn command_suffix(&self) -> String { + let mut parts = Vec::new(); + if let Some(cpus) = self.cpus { + parts.push(format!("--cpus {}", cpus)); + } + if let Some(memory) = self.memory { + parts.push(format!("--memory {}", memory)); + } + if let Some(disk) = self.disk { + parts.push(format!("--disk {}", disk)); + } + + if parts.is_empty() { + String::new() + } else { + format!(" {}", parts.join(" ")) + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct ColimaInstance { + #[serde(default)] + status: String, + #[serde(default)] + cpus: Option, + #[serde(default)] + memory: Option, + #[serde(default)] + disk: Option, +} + +impl ColimaInstance { + fn is_running(&self) -> bool { + self.status.eq_ignore_ascii_case("running") + } + + fn memory_gib(&self) -> Option { + self.memory.map(bytes_to_gib) + } + + fn disk_gib(&self) -> Option { + self.disk.map(bytes_to_gib) + } +} + +pub fn run(args: &StartArgs) -> Result<(), Box> { + let instance = colima_instance()?; + validate_requested_size(&args.size, instance.as_ref())?; + // 1. Start Colima with Kubernetes - log::info!("Starting Colima with Kubernetes..."); - run_cmd( - "colima", - &[ - "start", - "--kubernetes", - "--cpu", - "8", - "--memory", - "16", - "--disk", - "60", - ], - )?; + start_or_resize_colima(args, instance.as_ref())?; // 2. Wait for the Kubernetes API to become reachable. // Colima may return immediately ("already running") before the @@ -114,6 +188,208 @@ pub fn run() -> Result<(), Box> { Ok(()) } +fn start_or_resize_colima( + args: &StartArgs, + instance: Option<&ColimaInstance>, +) -> Result<(), Box> { + let is_running = instance.map(ColimaInstance::is_running).unwrap_or(false); + + if is_running && args.size.any_set() { + let changes = requested_size_changes(&args.size, instance.expect("checked is_running")); + if !changes.is_empty() { + confirm_running_resize(args, &changes)?; + resize_existing_colima(&args.size, instance)?; + return Ok(()); + } + + log::info!("Requested Colima size already matches the running VM"); + } + + log::info!("Starting Colima with Kubernetes..."); + + let size = if is_running { + ColimaSizeArgs::default() + } else { + args.size.clone() + }; + let include_defaults = instance.is_none(); + start_colima(&size, include_defaults) +} + +pub(crate) fn resize_colima(size: &ColimaSizeArgs) -> Result<(), Box> { + if !size.any_set() { + return Err("Specify at least one of --cpus, --memory, or --disk".into()); + } + + let instance = colima_instance()?; + let instance = instance + .as_ref() + .ok_or("No Colima instance exists yet; use `hops local start` to create one")?; + + validate_requested_size(size, Some(instance))?; + resize_existing_colima(size, Some(instance)) +} + +fn resize_existing_colima( + size: &ColimaSizeArgs, + instance: Option<&ColimaInstance>, +) -> Result<(), Box> { + if instance.map(ColimaInstance::is_running).unwrap_or(false) { + log::info!("Stopping Colima to apply requested size..."); + run_cmd("colima", &["stop"])?; + } + + log::info!("Starting Colima with requested size..."); + start_colima(size, false) +} + +fn start_colima(size: &ColimaSizeArgs, include_defaults: bool) -> Result<(), Box> { + let args = colima_start_args(size, include_defaults); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_cmd("colima", &refs) +} + +fn colima_start_args(size: &ColimaSizeArgs, include_defaults: bool) -> Vec { + let mut args = vec!["start".to_string(), "--kubernetes".to_string()]; + + if let Some(cpus) = size.cpus.or(include_defaults.then_some(DEFAULT_CPUS)) { + args.push("--cpus".to_string()); + args.push(cpus.to_string()); + } + if let Some(memory) = size + .memory + .or(include_defaults.then_some(DEFAULT_MEMORY_GIB)) + { + args.push("--memory".to_string()); + args.push(memory.to_string()); + } + if let Some(disk) = size.disk.or(include_defaults.then_some(DEFAULT_DISK_GIB)) { + args.push("--disk".to_string()); + args.push(disk.to_string()); + } + + args +} + +fn confirm_running_resize(args: &StartArgs, changes: &[String]) -> Result<(), Box> { + if args.yes { + return Ok(()); + } + + let change_text = changes.join(", "); + let resize_command = format!("hops local resize{}", args.size.command_suffix()); + let start_command = format!("hops local start{} --yes", args.size.command_suffix()); + + if !std::io::stdin().is_terminal() { + return Err(format!( + "Colima is already running with different size ({change_text}). Run `{resize_command}` first, or rerun `{start_command}` to stop and resize automatically." + ) + .into()); + } + + let confirmed = Confirm::new() + .with_prompt(format!( + "Colima is already running with different size ({change_text}). Stop and restart it now?" + )) + .default(false) + .interact()?; + + if confirmed { + Ok(()) + } else { + Err(format!( + "Colima size was not changed. Run `{resize_command}` first, then rerun `hops local start`." + ) + .into()) + } +} + +fn validate_requested_size( + size: &ColimaSizeArgs, + instance: Option<&ColimaInstance>, +) -> Result<(), Box> { + if let (Some(requested), Some(current)) = + (size.disk, instance.and_then(ColimaInstance::disk_gib)) + { + if requested < current { + return Err(format!( + "Colima disk cannot be shrunk from {current}GiB to {requested}GiB. Use --disk {current} or larger, or destroy and recreate the VM." + ) + .into()); + } + } + + Ok(()) +} + +fn requested_size_changes(size: &ColimaSizeArgs, instance: &ColimaInstance) -> Vec { + let mut changes = Vec::new(); + + if let Some(requested) = size.cpus { + match instance.cpus { + Some(current) if requested == current => {} + Some(current) => changes.push(format!("cpus {current} -> {requested}")), + None => changes.push(format!("cpus unknown -> {requested}")), + } + } + if let Some(requested) = size.memory { + match instance.memory_gib() { + Some(current) if requested == current => {} + Some(current) => changes.push(format!("memory {current}GiB -> {requested}GiB")), + None => changes.push(format!("memory unknown -> {requested}GiB")), + } + } + if let Some(requested) = size.disk { + match instance.disk_gib() { + Some(current) if requested == current => {} + Some(current) => changes.push(format!("disk {current}GiB -> {requested}GiB")), + None => changes.push(format!("disk unknown -> {requested}GiB")), + } + } + + changes +} + +pub(crate) fn colima_instance() -> Result, Box> { + let output = match run_cmd_output("colima", &["list", "--json"]) { + Ok(output) => output, + Err(_) => return Ok(None), + }; + + parse_colima_list(&output) +} + +fn parse_colima_list(output: &str) -> Result, Box> { + let trimmed = output.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + if let Ok(instance) = serde_json::from_str::(trimmed) { + return Ok(Some(instance)); + } + + if let Ok(instances) = serde_json::from_str::>(trimmed) { + return Ok(instances.into_iter().next()); + } + + for line in trimmed + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if let Ok(instance) = serde_json::from_str::(line) { + return Ok(Some(instance)); + } + } + + Err("Unable to parse `colima list --json` output".into()) +} + +fn bytes_to_gib(bytes: u64) -> u32 { + (bytes / GIB) as u32 +} + /// Add the cluster-internal registry to Docker's insecure-registries list /// inside the Colima VM. Docker defaults to HTTPS for non-localhost registries; /// our in-cluster registry speaks plain HTTP. @@ -221,3 +497,125 @@ fn wait_for_crd(crd: &str) -> Result<(), Box> { } Err(format!("Timed out waiting for CRD {}", crd).into()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn instance(status: &str, cpus: u32, memory_gib: u32, disk_gib: u32) -> ColimaInstance { + ColimaInstance { + status: status.to_string(), + cpus: Some(cpus), + memory: Some(memory_gib as u64 * GIB), + disk: Some(disk_gib as u64 * GIB), + } + } + + #[test] + fn colima_start_args_use_hops_defaults_for_new_profiles() { + let args = colima_start_args(&ColimaSizeArgs::default(), true); + + assert_eq!( + args, + vec![ + "start", + "--kubernetes", + "--cpus", + "8", + "--memory", + "16", + "--disk", + "60" + ] + ); + } + + #[test] + fn colima_start_args_pass_only_requested_size_for_existing_profiles() { + let size = ColimaSizeArgs { + cpus: Some(12), + memory: Some(32), + disk: None, + }; + + let args = colima_start_args(&size, false); + + assert_eq!( + args, + vec!["start", "--kubernetes", "--cpus", "12", "--memory", "32"] + ); + } + + #[test] + fn requested_size_changes_compare_only_explicit_fields() { + let current = instance("Running", 8, 16, 60); + let size = ColimaSizeArgs { + cpus: None, + memory: Some(32), + disk: None, + }; + + assert_eq!( + requested_size_changes(&size, ¤t), + vec!["memory 16GiB -> 32GiB"] + ); + } + + #[test] + fn requested_size_changes_treat_missing_current_value_as_change() { + let current = ColimaInstance { + status: "Running".to_string(), + cpus: None, + memory: None, + disk: None, + }; + let size = ColimaSizeArgs { + cpus: Some(12), + memory: None, + disk: None, + }; + + assert_eq!( + requested_size_changes(&size, ¤t), + vec!["cpus unknown -> 12"] + ); + } + + #[test] + fn parse_colima_list_accepts_single_object() { + let output = r#"{"name":"default","status":"Stopped","arch":"aarch64","cpus":8,"memory":17179869184,"disk":64424509440,"runtime":"docker+k3s"}"#; + + let parsed = parse_colima_list(output).expect("parse").expect("instance"); + + assert_eq!(parsed.status, "Stopped"); + assert_eq!(parsed.cpus, Some(8)); + assert_eq!(parsed.memory_gib(), Some(16)); + assert_eq!(parsed.disk_gib(), Some(60)); + } + + #[test] + fn parse_colima_list_accepts_array_output() { + let output = r#"[{"status":"Running","cpus":12,"memory":34359738368,"disk":107374182400}]"#; + + let parsed = parse_colima_list(output).expect("parse").expect("instance"); + + assert!(parsed.is_running()); + assert_eq!(parsed.cpus, Some(12)); + assert_eq!(parsed.memory_gib(), Some(32)); + assert_eq!(parsed.disk_gib(), Some(100)); + } + + #[test] + fn validate_requested_size_rejects_disk_shrink() { + let current = instance("Stopped", 8, 16, 100); + let size = ColimaSizeArgs { + cpus: None, + memory: None, + disk: Some(60), + }; + + let err = validate_requested_size(&size, Some(¤t)).expect_err("disk shrink"); + + assert!(err.to_string().contains("cannot be shrunk")); + } +}