use std::path::{Path, PathBuf};
use std::process::Command;
use super::contract::DeploymentContract;
use super::generate::generate_dockerfile;
#[derive(Debug, Clone)]
pub struct SmokeResult {
pub image_tag: String,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub build_context: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum SmokeError {
#[error("docker daemon not available — `docker info` failed: {0}")]
DockerUnavailable(String),
#[error("source binary not found at {path}")]
BinaryMissing {
path: PathBuf,
},
#[error("I/O error during smoke test: {0}")]
Io(#[from] std::io::Error),
#[error("docker build failed (exit {exit_code}): {stderr}")]
BuildFailed {
exit_code: i32,
stderr: String,
},
}
#[must_use]
pub fn docker_available() -> bool {
Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn smoke_test_build(
contract: &DeploymentContract,
binary_path: &Path,
run_args: &[&str],
) -> Result<SmokeResult, SmokeError> {
if !docker_available() {
return Err(SmokeError::DockerUnavailable(
"`docker info` did not exit 0".into(),
));
}
if !binary_path.exists() {
return Err(SmokeError::BinaryMissing {
path: binary_path.to_path_buf(),
});
}
let context = tempdir_for_smoke(contract)?;
let dst = context.join(contract.binary());
std::fs::copy(binary_path, &dst)?;
set_executable(&dst)?;
let dockerfile = generate_dockerfile(contract);
std::fs::write(context.join("Dockerfile"), dockerfile)?;
let image_tag = format!("{}:smoke-test", contract.app_name);
let build = Command::new("docker")
.arg("build")
.arg("-t")
.arg(&image_tag)
.arg(&context)
.output()?;
if !build.status.success() {
return Err(SmokeError::BuildFailed {
exit_code: build.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&build.stderr).into_owned(),
});
}
let mut run = Command::new("docker");
run.arg("run").arg("--rm").arg(&image_tag);
for arg in run_args {
run.arg(arg);
}
let output = run.output()?;
let _ = Command::new("docker")
.arg("image")
.arg("rm")
.arg("-f")
.arg(&image_tag)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
Ok(SmokeResult {
image_tag,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code().unwrap_or(-1),
build_context: context,
})
}
fn tempdir_for_smoke(contract: &DeploymentContract) -> std::io::Result<PathBuf> {
let base = std::env::temp_dir().join(format!("hyperi-smoke-{}", contract.app_name));
if base.exists() {
std::fs::remove_dir_all(&base)?;
}
std::fs::create_dir_all(&base)?;
Ok(base)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docker_available_returns_bool() {
let _ = docker_available();
}
#[test]
fn binary_missing_error() {
let contract = make_test_contract();
let r = smoke_test_build(
&contract,
Path::new("/nonexistent/binary/that/does/not/exist"),
&[],
);
assert!(r.is_err());
}
fn make_test_contract() -> DeploymentContract {
DeploymentContract {
schema_version: 2,
app_name: "smoke-test".into(),
binary_name: "smoke-test".into(),
description: "Smoke test".into(),
metrics_port: 9090,
health: super::super::HealthContract::default(),
env_prefix: "SMOKE".into(),
metric_prefix: "smoke".into(),
config_mount_path: "/etc/smoke/config.yaml".into(),
image_registry: super::super::DEFAULT_IMAGE_REGISTRY.to_string(),
extra_ports: vec![],
entrypoint_args: vec![],
secrets: vec![],
default_config: None,
depends_on: vec![],
keda: None,
base_image: super::super::DEFAULT_BASE_IMAGE.to_string(),
native_deps: super::super::NativeDepsContract::default(),
image_profile: super::super::ImageProfile::Production,
oci_labels: super::super::OciLabels::default(),
}
}
}