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.
|
/// 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 {
|
|
||||||
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 {
|
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,
|
||||||
vm: vm_name.into(),
|
&run_cmd,
|
||||||
step,
|
std::io::stdout(),
|
||||||
detail: format!("script exec: {e}"),
|
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 {
|
if let Some(dir) = log_dir {
|
||||||
append_provision_log(dir, step, script_raw, &stdout, &stderr);
|
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))
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue