use super::convergence_runner::{ConvergenceResult, ConvergenceTarget};
pub fn detect_container_runtime() -> Option<String> {
for rt in &["docker", "podman"] {
if std::process::Command::new(rt)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return Some(rt.to_string());
}
}
None
}
fn container_exec(runtime: &str, container_name: &str, script: &str) -> Result<String, String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new(runtime)
.args(["exec", "-i", container_name, "bash"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("container exec failed: {e}"))?;
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(script.as_bytes())
.map_err(|e| format!("stdin write: {e}"))?;
}
let output = child.wait_with_output().map_err(|e| format!("wait: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"exit {}: {}",
output.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn run_convergence_test_container(target: &ConvergenceTarget) -> ConvergenceResult {
use std::process::Command;
let start = std::time::Instant::now();
let runtime = match detect_container_runtime() {
Some(rt) => rt,
None => return err_result(target, start, "no container runtime available"),
};
let container_name = format!("forjar-conv-{}", &target.resource_id);
let run = Command::new(&runtime)
.args([
"run",
"-d",
"--rm",
"--name",
&container_name,
"debian:bookworm-slim",
"sleep",
"300",
])
.output();
match run {
Ok(o) if !o.status.success() => {
let stderr = String::from_utf8_lossy(&o.stderr);
return err_result(
target,
start,
&format!("container start failed: {}", stderr.trim()),
);
}
Err(e) => return err_result(target, start, &format!("container start: {e}")),
_ => {}
}
let first_apply = container_exec(&runtime, &container_name, &target.apply_script);
if let Err(e) = first_apply {
let _ = Command::new(&runtime)
.args(["rm", "-f", &container_name])
.output();
return err_result(target, start, &format!("first apply: {e}"));
}
let state_after_first = container_exec(&runtime, &container_name, &target.state_query_script);
let first_hash = state_after_first.as_ref().map(|s| {
let refs = [s.as_str()];
crate::tripwire::hasher::composite_hash(&refs)
});
let converged = first_hash
.as_ref()
.map(|h| h == &target.expected_hash)
.unwrap_or(false);
let second_apply = container_exec(&runtime, &container_name, &target.apply_script);
let idempotent = second_apply.is_ok();
let state_after_second = container_exec(&runtime, &container_name, &target.state_query_script);
let second_hash = state_after_second.as_ref().ok().map(|s| {
let refs = [s.as_str()];
crate::tripwire::hasher::composite_hash(&refs)
});
let preserved = match (&first_hash, &second_hash) {
(Ok(h1), Some(h2)) => h1 == h2,
_ => false,
};
let _ = Command::new(&runtime)
.args(["rm", "-f", &container_name])
.output();
ConvergenceResult {
resource_id: target.resource_id.clone(),
resource_type: target.resource_type.clone(),
converged,
idempotent,
preserved,
duration_ms: start.elapsed().as_millis() as u64,
error: None,
}
}
fn err_result(
target: &ConvergenceTarget,
start: std::time::Instant,
msg: &str,
) -> ConvergenceResult {
ConvergenceResult {
resource_id: target.resource_id.clone(),
resource_type: target.resource_type.clone(),
converged: false,
idempotent: false,
preserved: false,
duration_ms: start.elapsed().as_millis() as u64,
error: Some(msg.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_runtime_returns_option() {
let result = detect_container_runtime();
if let Some(ref rt) = result {
assert!(rt == "docker" || rt == "podman");
}
}
#[test]
fn err_result_populates_fields() {
let target = ConvergenceTarget {
resource_id: "test-res".into(),
resource_type: "file".into(),
apply_script: "echo apply".into(),
state_query_script: "echo state".into(),
expected_hash: String::new(),
};
let start = std::time::Instant::now();
let r = err_result(&target, start, "test error");
assert_eq!(r.resource_id, "test-res");
assert!(!r.converged);
assert!(!r.idempotent);
assert!(!r.preserved);
assert_eq!(r.error.as_deref(), Some("test error"));
}
#[test]
#[ignore] fn convergence_test_container_echo() {
let target = ConvergenceTarget {
resource_id: "echo-test".into(),
resource_type: "file".into(),
apply_script: "echo hello".into(),
state_query_script: "echo hello".into(),
expected_hash: String::new(),
};
let result = run_convergence_test_container(&target);
assert!(result.idempotent);
}
}