From 869f7a21744b85f9b565fb449d6769bb20323199 Mon Sep 17 00:00:00 2001 From: Jason Hernandez <7144515+jasonhernandez@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:12:00 -0700 Subject: [PATCH 1/5] cli: improve image-not-found error message Show build, pull, and list commands instead of only suggesting pull. Most custom images (ubuntu-dev, ubuntu-slim) need to be built from a Dockerfile, not pulled from a registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/vm.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/vm.rs b/src/cli/vm.rs index 83ea157..9baecbc 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -420,7 +420,11 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { .find_by_reference(&resolved.image)? .ok_or_else(|| { anyhow::anyhow!( - "image '{}' not found locally — pull it first with: ember image pull {}", + "image '{}' not found locally\n\ + \n Build from Dockerfile: ember image build {} -f \ + \n Pull from registry: ember image pull {}\ + \n List local images: ember image list", + resolved.image, resolved.image, resolved.image ) From b5ebb6c67e17473ea96802e07a5abccfd9d6a942 Mon Sep 17 00:00:00 2001 From: Jason Hernandez <7144515+jasonhernandez@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:40:51 -0700 Subject: [PATCH 2/5] fix: clean up VM on failed start during create If `ember vm create` succeeds but the subsequent start fails (e.g., ember-vz crash, missing binary), delete the created VM instead of leaving orphaned state behind. Previously, the start rollback only cleaned up network/process but left the VM metadata and disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/vm.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli/vm.rs b/src/cli/vm.rs index 9baecbc..853903c 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -465,12 +465,25 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { rollback.commit(); if !resolved.no_start { - start( + if let Err(e) = start( &StartArgs { name: resolved.name.clone(), }, state_dir, - )?; + ) { + // Start failed — clean up the created VM so we don't leave + // orphaned state behind. + eprintln!("Start failed, cleaning up VM '{}'...", resolved.name); + let _ = delete( + &DeleteArgs { + name: Some(resolved.name.clone()), + all: false, + force: true, + }, + state_dir, + ); + return Err(e); + } } Ok(()) From 37d7df5fe378564a11fb69790b5152f68d1f2fbf Mon Sep 17 00:00:00 2001 From: Jason Hernandez <7144515+jasonhernandez@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:45:37 -0700 Subject: [PATCH 3/5] feat: wait for SSH readiness after vm create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ember vm create` now waits up to 90s (configurable via --wait) for SSH to become reachable before reporting success. This means `ember exec` works immediately after create — no manual polling needed. Also add --wait flag to `ember exec` for configuring the SSH connect timeout (default: 30s, can be increased for heavy images). If the wait times out, the VM is still running — just SSH is slow. A hint is printed suggesting `ember exec --wait`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/exec.rs | 9 ++++++++- src/cli/vm.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/cli/exec.rs b/src/cli/exec.rs index 5681a48..171d88e 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::time::Duration; use clap::Args; @@ -15,6 +16,10 @@ pub struct ExecArgs { #[arg(long)] pub user: Option, + /// Wait up to N seconds for SSH to become available (default: 30) + #[arg(long, default_value = "30")] + pub wait: u64, + /// Command to execute (everything after --) #[arg(last = true, required = true)] pub command: Vec, @@ -27,13 +32,15 @@ pub fn run(args: &ExecArgs, state_dir: &Path) -> anyhow::Result<()> { let guest_ip = &network.guest_ip; let key_path = &metadata.ssh.key; let user = args.user.as_deref().unwrap_or(&metadata.ssh.user); + let timeout = Duration::from_secs(args.wait); // Build the remote command string from the argument vector. let command = shell_escape_join(&args.command); let rt = tokio::runtime::Runtime::new()?; let exit_code = rt.block_on(async { - let mut client = ssh::client::connect(guest_ip, user, key_path).await?; + let mut client = + ssh::client::connect_with_timeout(guest_ip, user, key_path, timeout).await?; let code = ssh::exec::exec(&mut client, &command).await?; let _ = client.close().await; Ok::(code) diff --git a/src/cli/vm.rs b/src/cli/vm.rs index 853903c..b4b43f4 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -109,6 +109,10 @@ pub struct CreateArgs { /// Don't start the VM after creation #[arg(long)] pub no_start: bool, + + /// Wait for VM to be SSH-reachable after start (seconds, 0 to skip) + #[arg(long, default_value = "90")] + pub wait: u64, } #[derive(Args)] @@ -281,6 +285,8 @@ struct ResolvedVmCreate { /// Network subnet from YAML config (used during `start`, not `create`). network: Option, no_start: bool, + /// Seconds to wait for SSH after start (0 = don't wait). + wait: u64, /// SSH user override from YAML config. ssh_user: Option, /// SSH private key override from YAML config. @@ -354,6 +360,7 @@ fn resolve_create_config( boot_args, network, no_start: args.no_start, + wait: args.wait, ssh_user, ssh_key, }) @@ -484,11 +491,48 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { ); return Err(e); } + + // Wait for SSH to become reachable (if --wait > 0). + if resolved.wait > 0 { + wait_for_ssh(&store, &resolved.name, resolved.wait)?; + } } Ok(()) } +/// Poll SSH until the VM responds or timeout is reached. +fn wait_for_ssh(store: &StateStore, vm_name: &str, timeout_secs: u64) -> anyhow::Result<()> { + use std::time::Duration; + + let (metadata, network) = load_running_with_ip(store, vm_name)?; + let guest_ip = &network.guest_ip; + let key_path = &metadata.ssh.key; + let user = &metadata.ssh.user; + + print!("Waiting for SSH"); + let rt = tokio::runtime::Runtime::new()?; + let timeout = Duration::from_secs(timeout_secs); + + match rt.block_on(async { + crate::ssh::client::connect_with_timeout(guest_ip, user, key_path, timeout).await + }) { + Ok(client) => { + rt.block_on(async { client.close().await }).ok(); + println!(" ready."); + Ok(()) + } + Err(_) => { + println!(" timeout ({timeout_secs}s)."); + eprintln!( + " hint: VM is running but SSH is slow. Try:\n\ + \x20 ember exec --wait {timeout_secs} {vm_name} -- echo hello" + ); + Ok(()) // Non-fatal — VM is running, SSH is just slow + } + } +} + /// Post-clone steps: grow disk, inject SSH key, save metadata. /// /// Separated from [`create`] so the caller can clean up storage on failure. From 41b309f8a2a3836f41e9ea9235aeaa721bf66b4a Mon Sep 17 00:00:00 2001 From: Jason Hernandez <7144515+jasonhernandez@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:04:04 -0700 Subject: [PATCH 4/5] fix: pass single-arg commands verbatim in ember exec When `ember exec vm -- "echo hi | tee /tmp/out"` has one argument after `--`, pass it directly to the SSH channel without quoting. The remote shell interprets pipes and redirects correctly. Previously, shell_escape_join would single-quote arguments containing `|` or `>`, preventing the remote shell from interpreting them. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/exec.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/exec.rs b/src/cli/exec.rs index 171d88e..92173ad 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -55,9 +55,15 @@ pub fn run(args: &ExecArgs, state_dir: &Path) -> anyhow::Result<()> { /// Join command arguments into a single shell command string. /// -/// Arguments containing spaces, quotes, or shell metacharacters are -/// single-quoted. This matches the behavior expected by remote shells. +/// If there's a single argument, pass it verbatim — the user composed +/// a shell command (e.g., `ember exec vm -- "echo hi | tee /tmp/out"`). +/// +/// If there are multiple arguments, quote any that contain shell +/// metacharacters so they're treated as literal arguments. fn shell_escape_join(args: &[String]) -> String { + if args.len() == 1 { + return args[0].clone(); + } args.iter() .map(|arg| { if arg.is_empty() From 9686ec2644d4897b04f1439bedf00a4a20213662 Mon Sep 17 00:00:00 2001 From: Jason Hernandez <7144515+jasonhernandez@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:23:46 -0700 Subject: [PATCH 5/5] feat: vm create --format json, progress to stderr - `ember vm create --format json` returns VM metadata as JSON on stdout - All progress messages (Cloning, Growing, Injecting, Starting, Waiting) now go to stderr so stdout is clean for JSON piping - `ember exec` also reformatted by cargo fmt This makes ember scriptable: `ember vm create foo --image bar --format json | jq .` outputs clean JSON while progress is visible on stderr. 201 tests pass (186 ember + 15 emberd), clippy clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/vm.rs | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/src/cli/vm.rs b/src/cli/vm.rs index b4b43f4..18c2998 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -113,6 +113,10 @@ pub struct CreateArgs { /// Wait for VM to be SSH-reachable after start (seconds, 0 to skip) #[arg(long, default_value = "90")] pub wait: u64, + + /// Output format (json prints VM metadata on success) + #[arg(long, default_value = "table")] + pub format: OutputFormat, } #[derive(Args)] @@ -287,6 +291,8 @@ struct ResolvedVmCreate { no_start: bool, /// Seconds to wait for SSH after start (0 = don't wait). wait: u64, + /// Output format. + format: OutputFormat, /// SSH user override from YAML config. ssh_user: Option, /// SSH private key override from YAML config. @@ -361,6 +367,7 @@ fn resolve_create_config( network, no_start: args.no_start, wait: args.wait, + format: args.format.clone(), ssh_user, ssh_key, }) @@ -407,7 +414,7 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { // Load YAML config if provided. let yaml_config = match &args.vm_config { Some(path) => { - println!("Loading VM config from {}...", path.display()); + eprintln!("Loading VM config from {}...", path.display()); Some(config::vm::load(path)?) } None => None, @@ -446,7 +453,7 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { let mut rollback = Rollback::new(); // Clone base image → per-VM disk (instant, copy-on-write). - println!("Cloning image for VM '{}'...", resolved.name); + eprintln!("Cloning image for VM '{}'...", resolved.name); let vm_disk_path = storage.clone_for_vm(&image_name, &resolved.name)?; let vm_disk = vm_disk_path.to_string_lossy().to_string(); { @@ -483,8 +490,7 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { eprintln!("Start failed, cleaning up VM '{}'...", resolved.name); let _ = delete( &DeleteArgs { - name: Some(resolved.name.clone()), - all: false, + name: resolved.name.clone(), force: true, }, state_dir, @@ -498,6 +504,17 @@ fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { } } + // Output result + match resolved.format { + OutputFormat::Json => { + let metadata = vm::load(&store, &resolved.name)?; + println!("{}", serde_json::to_string_pretty(&metadata)?); + } + OutputFormat::Table => { + eprintln!("VM '{}' ready.", resolved.name); + } + } + Ok(()) } @@ -510,20 +527,20 @@ fn wait_for_ssh(store: &StateStore, vm_name: &str, timeout_secs: u64) -> anyhow: let key_path = &metadata.ssh.key; let user = &metadata.ssh.user; - print!("Waiting for SSH"); + eprint!("Waiting for SSH"); let rt = tokio::runtime::Runtime::new()?; let timeout = Duration::from_secs(timeout_secs); match rt.block_on(async { - crate::ssh::client::connect_with_timeout(guest_ip, user, key_path, timeout).await + ember_core::ssh::client::connect_with_timeout(guest_ip, user, key_path, timeout).await }) { Ok(client) => { rt.block_on(async { client.close().await }).ok(); - println!(" ready."); + eprintln!(" ready."); Ok(()) } Err(_) => { - println!(" timeout ({timeout_secs}s)."); + eprintln!(" timeout ({timeout_secs}s)."); eprintln!( " hint: VM is running but SSH is slow. Try:\n\ \x20 ember exec --wait {timeout_secs} {vm_name} -- echo hello" @@ -549,7 +566,7 @@ fn create_post_clone( let requested_size_mib = resolved.disk_size as u64 * 1024; let needs_resize = requested_size_mib > image_size_mib; if needs_resize { - println!( + eprintln!( "Growing disk to {}...", format_bytes_binary(resolved.disk_size as u64 * GIB) ); @@ -569,7 +586,7 @@ fn create_post_clone( Hint: create one with: ssh-keygen -t ed25519" ) })?; - println!("Injecting SSH key from {}...", pubkey_path.display()); + eprintln!("Injecting SSH key from {}...", pubkey_path.display()); let detected_ssh_user = storage.inject_ssh_key(&dev_path, &pubkey_path)?; // Inject /etc/hosts with the VM hostname so sudo and other tools @@ -612,7 +629,7 @@ fn create_post_clone( vm::save(store, &metadata)?; - println!("VM '{}' created successfully.", resolved.name); + eprintln!("VM '{}' created successfully.", resolved.name); Ok(()) } @@ -781,9 +798,9 @@ fn start(args: &StartArgs, state_dir: &Path) -> anyhow::Result<()> { // ── Networking ──────────────────────────────────────────────── let net_backend = Network::new(store.clone()); - println!("Setting up network..."); + eprintln!("Setting up network..."); let net_info = net_backend.setup(&metadata, &config)?; - println!( + eprintln!( " Guest IP: {}, Host IP: {}", net_info.guest_ip, net_info.host_ip ); @@ -805,7 +822,7 @@ fn start(args: &StartArgs, state_dir: &Path) -> anyhow::Result<()> { // ── Hypervisor ──────────────────────────────────────────────── - println!("Starting VM..."); + eprintln!("Starting VM..."); let started = Vm::start(&metadata, &config)?; let pid = started.pid; { @@ -828,7 +845,7 @@ fn start(args: &StartArgs, state_dir: &Path) -> anyhow::Result<()> { // Everything succeeded — keep all resources. rollback.commit(); - println!("VM '{}' started (pid {}).", args.name, pid); + eprintln!("VM '{}' started (pid {}).", args.name, pid); Ok(()) }