mirror of
https://github.com/CloudNebulaProject/vm-manager.git
synced 2026-04-10 13:20:41 +00:00
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:
parent
acdf43ae5a
commit
7cf207a9ec
3 changed files with 120 additions and 26 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> {
|
||||
let sftp = sess.sftp().map_err(|e| VmError::SshFailed {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue