diff --git a/crates/vm-manager/src/provision.rs b/crates/vm-manager/src/provision.rs index a16b09e..0f29b89 100644 --- a/crates/vm-manager/src/provision.rs +++ b/crates/vm-manager/src/provision.rs @@ -11,8 +11,8 @@ use crate::vmfile::{FileProvision, ProvisionDef, ShellProvision, resolve_path}; /// Run all provision steps on an established SSH session. /// -/// If `log_dir` is provided, all stdout/stderr from provision steps is appended to -/// `provision.log` in that directory. +/// Output from shell provisioners is streamed to stdout/stderr in real time. +/// If `log_dir` is provided, output is also appended to `provision.log`. pub fn run_provisions( sess: &Session, provisions: &[ProvisionDef], @@ -34,16 +34,6 @@ pub fn run_provisions( Ok(()) } -/// Log provision output to tracing and optionally to a file. -fn log_output(vm_name: &str, step: usize, label: &str, stdout: &str, stderr: &str) { - for line in stdout.lines() { - info!(vm = %vm_name, step, "[{label}:stdout] {line}"); - } - for line in stderr.lines() { - info!(vm = %vm_name, step, "[{label}:stderr] {line}"); - } -} - /// Append provision output to a log file in the given directory. pub fn append_provision_log(log_dir: &Path, step: usize, label: &str, stdout: &str, stderr: &str) { let log_path = log_dir.join("provision.log"); @@ -77,14 +67,19 @@ fn run_shell( ) -> Result<()> { if let Some(ref cmd) = shell.inline { info!(vm = %vm_name, step, cmd = %cmd, "running inline shell provision"); - let (stdout, stderr, exit_code) = - ssh::exec(sess, cmd).map_err(|e| VmError::ProvisionFailed { - vm: vm_name.into(), - step, - detail: format!("shell exec: {e}"), - })?; - log_output(vm_name, step, cmd, &stdout, &stderr); + let (stdout, stderr, exit_code) = ssh::exec_streaming( + sess, + cmd, + std::io::stdout(), + std::io::stderr(), + ) + .map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("shell exec: {e}"), + })?; + if let Some(dir) = log_dir { append_provision_log(dir, step, cmd, &stdout, &stderr); } @@ -115,14 +110,18 @@ fn run_shell( // Make executable and run let run_cmd = format!("chmod +x {remote_path_str} && {remote_path_str}"); - let (stdout, stderr, exit_code) = - ssh::exec(sess, &run_cmd).map_err(|e| VmError::ProvisionFailed { - vm: vm_name.into(), - step, - detail: format!("script exec: {e}"), - })?; + let (stdout, stderr, exit_code) = ssh::exec_streaming( + sess, + &run_cmd, + std::io::stdout(), + std::io::stderr(), + ) + .map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("script exec: {e}"), + })?; - log_output(vm_name, step, script_raw, &stdout, &stderr); if let Some(dir) = log_dir { append_provision_log(dir, step, script_raw, &stdout, &stderr); } diff --git a/crates/vm-manager/src/ssh.rs b/crates/vm-manager/src/ssh.rs index 03be690..3f13818 100644 --- a/crates/vm-manager/src/ssh.rs +++ b/crates/vm-manager/src/ssh.rs @@ -91,6 +91,94 @@ pub fn exec(sess: &Session, cmd: &str) -> Result<(String, String, i32)> { Ok((stdout, stderr, exit_code)) } +/// Execute a command and stream stdout/stderr to the provided writers as data arrives. +/// +/// Returns `(stdout_collected, stderr_collected, exit_code)`. +pub fn exec_streaming( + sess: &Session, + cmd: &str, + mut out: W1, + mut err: W2, +) -> Result<(String, String, i32)> { + let mut channel = sess.channel_session().map_err(|e| VmError::SshFailed { + detail: format!("channel session: {e}"), + })?; + + // Non-blocking mode so we can interleave stdout and stderr reads + sess.set_blocking(false); + + channel.exec(cmd).map_err(|e| { + sess.set_blocking(true); + VmError::SshFailed { + detail: format!("exec '{cmd}': {e}"), + } + })?; + + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + let mut buf = [0u8; 8192]; + + loop { + let mut progress = false; + + // Read stdout + match channel.read(&mut buf) { + Ok(0) => {} + Ok(n) => { + let _ = out.write_all(&buf[..n]); + let _ = out.flush(); + stdout_buf.extend_from_slice(&buf[..n]); + progress = true; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => { + sess.set_blocking(true); + return Err(VmError::SshFailed { + detail: format!("read stdout: {e}"), + }); + } + } + + // Read stderr + match channel.stderr().read(&mut buf) { + Ok(0) => {} + Ok(n) => { + let _ = err.write_all(&buf[..n]); + let _ = err.flush(); + stderr_buf.extend_from_slice(&buf[..n]); + progress = true; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => { + sess.set_blocking(true); + return Err(VmError::SshFailed { + detail: format!("read stderr: {e}"), + }); + } + } + + if channel.eof() && !progress { + break; + } + + if !progress { + std::thread::sleep(Duration::from_millis(50)); + } + } + + sess.set_blocking(true); + + channel.wait_close().map_err(|e| VmError::SshFailed { + detail: format!("wait close: {e}"), + })?; + let exit_code = channel.exit_status().unwrap_or(1); + + let stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); + let stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); + + Ok((stdout, stderr, exit_code)) +} + /// Upload a local file to a remote path via SFTP. pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> { let sftp = sess.sftp().map_err(|e| VmError::SshFailed { diff --git a/crates/vmctl/src/commands/mod.rs b/crates/vmctl/src/commands/mod.rs index de9abd3..ad33846 100644 --- a/crates/vmctl/src/commands/mod.rs +++ b/crates/vmctl/src/commands/mod.rs @@ -97,16 +97,23 @@ fn ssh_port_for_handle(handle: &VmHandle) -> u16 { const GENERATED_KEY_FILE: &str = "id_ed25519_generated"; /// Persist a generated SSH private key PEM to the VM's work directory (if present). +/// +/// The file is written with 0600 permissions so that OpenSSH accepts it. async fn save_generated_ssh_key( spec: &vm_manager::VmSpec, handle: &VmHandle, ) -> miette::Result<()> { + use std::os::unix::fs::PermissionsExt; + if let Some(ref ssh) = spec.ssh { if let Some(ref pem) = ssh.private_key_pem { let key_path = handle.work_dir.join(GENERATED_KEY_FILE); tokio::fs::write(&key_path, pem) .await .map_err(|e| miette::miette!("failed to save generated SSH key: {e}"))?; + tokio::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|e| miette::miette!("failed to set SSH key permissions: {e}"))?; } } Ok(())