use std::process::{Command, Stdio};
use super::error::HostError;
use super::schema::HostConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DistroFamily {
Debian,
RedHat,
Alpine,
Arch,
Suse,
Unknown(String),
}
impl std::fmt::Display for DistroFamily {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DistroFamily::Debian => write!(f, "Debian/Ubuntu"),
DistroFamily::RedHat => write!(f, "RHEL/Amazon Linux"),
DistroFamily::Alpine => write!(f, "Alpine"),
DistroFamily::Arch => write!(f, "Arch"),
DistroFamily::Suse => write!(f, "SUSE"),
DistroFamily::Unknown(id) => write!(f, "Unknown ({id})"),
}
}
}
#[derive(Debug, Clone)]
pub struct DistroInfo {
pub family: DistroFamily,
pub id: String,
pub pretty_name: String,
pub version_id: Option<String>,
}
pub fn detect_distro(host: &HostConfig) -> Result<DistroInfo, HostError> {
let output = run_ssh_command(host, "cat /etc/os-release")?;
parse_os_release(&output)
}
fn parse_os_release(content: &str) -> Result<DistroInfo, HostError> {
let mut id = String::new();
let mut id_like = String::new();
let mut pretty_name = String::new();
let mut version_id = None;
for line in content.lines() {
if let Some((key, value)) = line.split_once('=') {
let value = value.trim_matches('"');
match key {
"ID" => id = value.to_lowercase(),
"ID_LIKE" => id_like = value.to_lowercase(),
"PRETTY_NAME" => pretty_name = value.to_string(),
"VERSION_ID" => version_id = Some(value.to_string()),
_ => {}
}
}
}
if id.is_empty() {
return Err(HostError::ConnectionFailed(
"Could not detect Linux distribution".to_string(),
));
}
let family = match id.as_str() {
"ubuntu" | "debian" | "linuxmint" | "pop" | "elementary" | "raspbian" => {
DistroFamily::Debian
}
"amzn" | "rhel" | "centos" | "fedora" | "rocky" | "almalinux" | "ol" => {
DistroFamily::RedHat
}
"alpine" => DistroFamily::Alpine,
"arch" | "manjaro" | "endeavouros" => DistroFamily::Arch,
"opensuse" | "sles" | "opensuse-leap" | "opensuse-tumbleweed" => DistroFamily::Suse,
_ => {
if id_like.contains("debian") || id_like.contains("ubuntu") {
DistroFamily::Debian
} else if id_like.contains("rhel")
|| id_like.contains("fedora")
|| id_like.contains("centos")
{
DistroFamily::RedHat
} else if id_like.contains("arch") {
DistroFamily::Arch
} else if id_like.contains("suse") {
DistroFamily::Suse
} else {
DistroFamily::Unknown(id.clone())
}
}
};
Ok(DistroInfo {
family,
id,
pretty_name,
version_id,
})
}
pub fn get_docker_install_commands(distro: &DistroInfo) -> Result<Vec<&'static str>, HostError> {
match &distro.family {
DistroFamily::Debian => Ok(vec![
"sudo apt-get update",
"sudo apt-get install -y ca-certificates curl gnupg",
"sudo install -m 0755 -d /etc/apt/keyrings",
"curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\")/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg",
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
"echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\") $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null",
"sudo apt-get update",
"sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
"sudo systemctl enable docker",
"sudo systemctl start docker",
"sudo usermod -aG docker $USER",
]),
DistroFamily::RedHat => {
Ok(vec![
"sudo yum install -y docker || sudo dnf install -y docker",
"sudo systemctl enable docker",
"sudo systemctl start docker",
"sudo usermod -aG docker $USER",
])
}
DistroFamily::Alpine => Ok(vec![
"sudo apk add docker docker-cli-compose",
"sudo rc-update add docker boot",
"sudo service docker start",
"sudo addgroup $USER docker",
]),
DistroFamily::Arch => Ok(vec![
"sudo pacman -Sy --noconfirm docker docker-compose",
"sudo systemctl enable docker",
"sudo systemctl start docker",
"sudo usermod -aG docker $USER",
]),
DistroFamily::Suse => Ok(vec![
"sudo zypper install -y docker docker-compose",
"sudo systemctl enable docker",
"sudo systemctl start docker",
"sudo usermod -aG docker $USER",
]),
DistroFamily::Unknown(id) => Err(HostError::ConnectionFailed(format!(
"Unsupported Linux distribution: {id}. Please install Docker manually."
))),
}
}
pub fn install_docker(
host: &HostConfig,
distro: &DistroInfo,
on_output: impl Fn(&str),
) -> Result<(), HostError> {
let commands = get_docker_install_commands(distro)?;
let combined = commands.join(" && ");
on_output(&format!("Installing Docker on {} host...", distro.family));
run_ssh_command_with_output(host, &combined, on_output)?;
Ok(())
}
fn run_ssh_command(host: &HostConfig, command: &str) -> Result<String, HostError> {
let mut cmd = build_ssh_command(host);
cmd.arg(command);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
} else {
HostError::SshSpawn(e.to_string())
}
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(HostError::ConnectionFailed(stderr.to_string()))
}
}
fn run_ssh_command_with_output(
host: &HostConfig,
command: &str,
on_output: impl Fn(&str),
) -> Result<(), HostError> {
use std::io::{BufRead, BufReader};
let mut cmd = build_ssh_command(host);
cmd.arg("-t").arg("-t");
cmd.arg(command);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
} else {
HostError::SshSpawn(e.to_string())
}
})?;
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
on_output(&line);
}
}
let status = child
.wait()
.map_err(|e| HostError::SshSpawn(e.to_string()))?;
if status.success() {
Ok(())
} else {
Err(HostError::ConnectionFailed(
"Docker installation failed".to_string(),
))
}
}
fn build_ssh_command(host: &HostConfig) -> Command {
let mut cmd = Command::new("ssh");
cmd.arg("-o")
.arg("BatchMode=yes")
.arg("-o")
.arg("ConnectTimeout=30")
.arg("-o")
.arg("StrictHostKeyChecking=accept-new");
cmd.args(host.ssh_args());
cmd
}
pub fn verify_docker_installed(host: &HostConfig) -> Result<String, HostError> {
let output = run_ssh_command(
host,
"docker version --format '{{.Server.Version}}' 2>/dev/null || sudo docker version --format '{{.Server.Version}}'",
);
match output {
Ok(version) => Ok(version.trim().to_string()),
Err(_) => Err(HostError::RemoteDockerUnavailable(
"Docker installed but not accessible. You may need to reconnect for group membership to take effect.".to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_os_release_ubuntu() {
let content = r#"
PRETTY_NAME="Ubuntu 24.04 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
"#;
let info = parse_os_release(content).unwrap();
assert_eq!(info.family, DistroFamily::Debian);
assert_eq!(info.id, "ubuntu");
assert_eq!(info.version_id, Some("24.04".to_string()));
}
#[test]
fn test_parse_os_release_amazon_linux() {
let content = r#"
NAME="Amazon Linux"
VERSION="2023"
ID="amzn"
ID_LIKE="fedora"
VERSION_ID="2023"
PRETTY_NAME="Amazon Linux 2023"
"#;
let info = parse_os_release(content).unwrap();
assert_eq!(info.family, DistroFamily::RedHat);
assert_eq!(info.id, "amzn");
}
#[test]
fn test_parse_os_release_debian() {
let content = r#"
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
ID=debian
"#;
let info = parse_os_release(content).unwrap();
assert_eq!(info.family, DistroFamily::Debian);
assert_eq!(info.id, "debian");
}
#[test]
fn test_get_docker_install_commands() {
let debian_info = DistroInfo {
family: DistroFamily::Debian,
id: "ubuntu".to_string(),
pretty_name: "Ubuntu 24.04".to_string(),
version_id: Some("24.04".to_string()),
};
let commands = get_docker_install_commands(&debian_info).unwrap();
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("docker")));
let redhat_info = DistroInfo {
family: DistroFamily::RedHat,
id: "amzn".to_string(),
pretty_name: "Amazon Linux 2023".to_string(),
version_id: Some("2023".to_string()),
};
let commands = get_docker_install_commands(&redhat_info).unwrap();
assert!(!commands.is_empty());
}
}