Stream provision output live and fix generated key permissions

- Add ssh::exec_streaming() that prints stdout/stderr as data arrives
  instead of buffering until command completion. Provision scripts now
  show output in real time without tracing annotation.
- Set 0600 permissions on generated SSH private keys so OpenSSH
  accepts them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-02-15 12:06:06 +01:00
parent acdf43ae5a
commit 7cf207a9ec
No known key found for this signature in database
3 changed files with 120 additions and 26 deletions

View file

@ -11,8 +11,8 @@ use crate::vmfile::{FileProvision, ProvisionDef, ShellProvision, resolve_path};
/// Run all provision steps on an established SSH session. /// Run all provision steps on an established SSH session.
/// ///
/// If `log_dir` is provided, all stdout/stderr from provision steps is appended to /// Output from shell provisioners is streamed to stdout/stderr in real time.
/// `provision.log` in that directory. /// If `log_dir` is provided, output is also appended to `provision.log`.
pub fn run_provisions( pub fn run_provisions(
sess: &Session, sess: &Session,
provisions: &[ProvisionDef], provisions: &[ProvisionDef],
@ -34,16 +34,6 @@ pub fn run_provisions(
Ok(()) 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. /// 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) { pub fn append_provision_log(log_dir: &Path, step: usize, label: &str, stdout: &str, stderr: &str) {
let log_path = log_dir.join("provision.log"); let log_path = log_dir.join("provision.log");
@ -77,14 +67,19 @@ fn run_shell(
) -> Result<()> { ) -> Result<()> {
if let Some(ref cmd) = shell.inline { if let Some(ref cmd) = shell.inline {
info!(vm = %vm_name, step, cmd = %cmd, "running inline shell provision"); 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 { 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(), vm: vm_name.into(),
step, step,
detail: format!("shell exec: {e}"), detail: format!("shell exec: {e}"),
})?; })?;
log_output(vm_name, step, cmd, &stdout, &stderr);
if let Some(dir) = log_dir { if let Some(dir) = log_dir {
append_provision_log(dir, step, cmd, &stdout, &stderr); append_provision_log(dir, step, cmd, &stdout, &stderr);
} }
@ -115,14 +110,18 @@ fn run_shell(
// Make executable and run // Make executable and run
let run_cmd = format!("chmod +x {remote_path_str} && {remote_path_str}"); let run_cmd = format!("chmod +x {remote_path_str} && {remote_path_str}");
let (stdout, stderr, exit_code) = let (stdout, stderr, exit_code) = ssh::exec_streaming(
ssh::exec(sess, &run_cmd).map_err(|e| VmError::ProvisionFailed { sess,
&run_cmd,
std::io::stdout(),
std::io::stderr(),
)
.map_err(|e| VmError::ProvisionFailed {
vm: vm_name.into(), vm: vm_name.into(),
step, step,
detail: format!("script exec: {e}"), detail: format!("script exec: {e}"),
})?; })?;
log_output(vm_name, step, script_raw, &stdout, &stderr);
if let Some(dir) = log_dir { if let Some(dir) = log_dir {
append_provision_log(dir, step, script_raw, &stdout, &stderr); append_provision_log(dir, step, script_raw, &stdout, &stderr);
} }

View file

@ -91,6 +91,94 @@ pub fn exec(sess: &Session, cmd: &str) -> Result<(String, String, i32)> {
Ok((stdout, stderr, exit_code)) 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<W1: std::io::Write, W2: std::io::Write>(
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. /// Upload a local file to a remote path via SFTP.
pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> { pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> {
let sftp = sess.sftp().map_err(|e| VmError::SshFailed { let sftp = sess.sftp().map_err(|e| VmError::SshFailed {

View file

@ -97,16 +97,23 @@ fn ssh_port_for_handle(handle: &VmHandle) -> u16 {
const GENERATED_KEY_FILE: &str = "id_ed25519_generated"; const GENERATED_KEY_FILE: &str = "id_ed25519_generated";
/// Persist a generated SSH private key PEM to the VM's work directory (if present). /// 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( async fn save_generated_ssh_key(
spec: &vm_manager::VmSpec, spec: &vm_manager::VmSpec,
handle: &VmHandle, handle: &VmHandle,
) -> miette::Result<()> { ) -> miette::Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(ref ssh) = spec.ssh { if let Some(ref ssh) = spec.ssh {
if let Some(ref pem) = ssh.private_key_pem { if let Some(ref pem) = ssh.private_key_pem {
let key_path = handle.work_dir.join(GENERATED_KEY_FILE); let key_path = handle.work_dir.join(GENERATED_KEY_FILE);
tokio::fs::write(&key_path, pem) tokio::fs::write(&key_path, pem)
.await .await
.map_err(|e| miette::miette!("failed to save generated SSH key: {e}"))?; .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(()) Ok(())