use anyhow::Result;
use async_trait::async_trait;
use super::{alert, metric_int, ServiceProvider};
use crate::event::{Alert, AlertSeverity, DetectedService, ServiceKind, ServiceStatus};
use crate::ssh::probe::ProbeOutput;
use crate::ssh::session::SshSession;
pub struct DockerProvider;
#[async_trait]
impl ServiceProvider for DockerProvider {
fn kind(&self) -> ServiceKind {
ServiceKind::Docker
}
fn detect(&self, probe_output: &ProbeOutput) -> bool {
probe_output.has_section("DOCKER")
}
fn quick_metrics(&self, probe_output: &ProbeOutput) -> Vec<super::ServiceMetric> {
let mut metrics = Vec::new();
if let Some(docker_output) = probe_output.get_section("DOCKER") {
let lines: Vec<&str> = docker_output.lines().collect();
let total = lines.len() as i64;
let running = lines
.iter()
.filter(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
parts[2].contains("Up")
} else {
false
}
})
.count() as i64;
metrics.push(metric_int("containers_total", total, ""));
metrics.push(metric_int("containers_running", running, ""));
}
metrics
}
async fn collect_metrics(&self, session: &SshSession) -> Result<DetectedService> {
let docker_info = session
.run_command(
r#"
echo "===CONTAINERS==="
docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.State}}\t{{.Image}}' 2>/dev/null
echo "===STATS==="
docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' 2>/dev/null | head -20
echo "===COMPOSE==="
docker compose ls --format json 2>/dev/null || echo "[]"
echo "===RESTARTS==="
docker inspect --format '{{.Name}}\t{{.RestartCount}}' $(docker ps -aq) 2>/dev/null
"#,
)
.await?;
let (metrics, alerts, status) = parse_docker_output(&docker_info);
Ok(DetectedService {
kind: ServiceKind::Docker,
version: None, status,
metrics,
alerts,
suggested_snippets: vec![
"docker ps -a".to_string(),
"docker compose restart".to_string(),
"docker logs <container>".to_string(),
"docker system prune".to_string(),
],
})
}
}
fn parse_docker_output(output: &str) -> (Vec<super::ServiceMetric>, Vec<Alert>, ServiceStatus) {
let mut metrics = Vec::new();
let mut alerts = Vec::new();
let mut current_section = None;
let mut containers_data = Vec::new();
let mut restart_counts: Vec<(String, i64)> = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("===") {
current_section = if trimmed.contains("CONTAINERS") {
Some("containers")
} else if trimmed.contains("STATS") {
Some("stats")
} else if trimmed.contains("RESTARTS") {
Some("restarts")
} else {
None
};
continue;
}
if trimmed.is_empty() {
continue;
}
match current_section {
Some("containers") => {
let parts: Vec<&str> = trimmed.split('\t').collect();
if parts.len() >= 4 {
containers_data.push((
parts[1].to_string(), parts[2].to_string(), parts[3].to_string(), ));
}
}
Some("restarts") => {
let parts: Vec<&str> = trimmed.split('\t').collect();
if parts.len() >= 2 {
if let Ok(count) = parts[1].parse::<i64>() {
restart_counts.push((parts[0].trim_start_matches('/').to_string(), count));
}
}
}
_ => {}
}
}
let total_containers = containers_data.len() as i64;
let running = containers_data
.iter()
.filter(|(_, _, state)| state.to_lowercase() == "running")
.count() as i64;
let stopped = containers_data
.iter()
.filter(|(_, _, state)| state.to_lowercase() == "exited")
.count() as i64;
let restarting = containers_data
.iter()
.filter(|(_, status, _)| status.to_lowercase().contains("restarting"))
.count() as i64;
metrics.push(metric_int("containers_total", total_containers, ""));
metrics.push(metric_int("containers_running", running, ""));
metrics.push(metric_int("containers_stopped", stopped, ""));
metrics.push(metric_int("containers_restarting", restarting, ""));
let mut critical_issues = Vec::new();
for (name, count) in &restart_counts {
if *count > 3 {
alerts.push(alert(
AlertSeverity::Critical,
ServiceKind::Docker,
format!("Container {} in restart loop ({} restarts)", name, count),
Some(format!("docker logs {} --tail 100", name)),
));
critical_issues.push(format!("{} restarting", name));
}
}
for (name, status, state) in &containers_data {
if state.to_lowercase() == "exited" && !status.to_lowercase().contains("exited (0)") {
alerts.push(alert(
AlertSeverity::Warning,
ServiceKind::Docker,
format!("Container {} stopped unexpectedly: {}", name, status),
Some(format!("docker start {}", name)),
));
}
}
if restarting > 0 {
alerts.push(alert(
AlertSeverity::Critical,
ServiceKind::Docker,
format!("{} container(s) in restarting state", restarting),
Some("docker compose restart".to_string()),
));
critical_issues.push("containers restarting".to_string());
}
let status = if !critical_issues.is_empty() {
ServiceStatus::Critical(critical_issues.join(", "))
} else if stopped > 0 {
ServiceStatus::Degraded(format!("{} container(s) stopped", stopped))
} else if total_containers > 0 {
ServiceStatus::Healthy
} else {
ServiceStatus::Unknown
};
(metrics, alerts, status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docker_detect_from_probe() {
let probe_output = "===OMNYSSH:DOCKER===\nabc123\tnginx\tUp 2 hours\tnginx:latest\n";
let parsed = ProbeOutput::parse(probe_output).expect("should parse");
let provider = DockerProvider;
assert!(provider.detect(&parsed));
}
#[test]
fn test_docker_not_detected_when_absent() {
let probe_output = "===OMNYSSH:OS===\nUbuntu\n";
let parsed = ProbeOutput::parse(probe_output).expect("should parse");
let provider = DockerProvider;
assert!(!provider.detect(&parsed));
}
#[test]
fn test_parse_healthy_containers() {
let output = r#"===CONTAINERS===
abc123 nginx-proxy Up 2 hours running nginx:latest
def456 db-master Up 5 days running postgres:15
===STATS===
===RESTARTS===
/nginx-proxy 0
/db-master 1
"#;
let (metrics, alerts, status) = parse_docker_output(output);
assert_eq!(metrics.len(), 4);
assert!(alerts.is_empty());
assert!(matches!(status, ServiceStatus::Healthy));
let running_metric = metrics.iter().find(|m| m.name == "containers_running");
assert!(running_metric.is_some());
}
#[test]
fn test_parse_restart_loop() {
let output = r#"===CONTAINERS===
abc123 nginx-proxy Restarting (5) About a minute ago restarting nginx:latest
===STATS===
===RESTARTS===
/nginx-proxy 5
"#;
let (_metrics, alerts, status) = parse_docker_output(output);
assert!(!alerts.is_empty());
assert!(matches!(status, ServiceStatus::Critical(_)));
let restart_alert = alerts.iter().find(|a| a.message.contains("restart loop"));
assert!(restart_alert.is_some());
assert_eq!(restart_alert.unwrap().severity, AlertSeverity::Critical);
}
#[test]
fn test_parse_exited_containers() {
let output = r#"===CONTAINERS===
abc123 nginx-proxy Exited (1) 5 minutes ago exited nginx:latest
def456 db-master Up 2 days running postgres:15
===STATS===
===RESTARTS===
/nginx-proxy 0
/db-master 0
"#;
let (metrics, alerts, status) = parse_docker_output(output);
assert!(matches!(status, ServiceStatus::Degraded(_)));
let stopped_metric = metrics.iter().find(|m| m.name == "containers_stopped");
assert!(stopped_metric.is_some());
assert!(alerts.iter().any(|a| a.severity == AlertSeverity::Warning));
}
#[test]
fn test_parse_permission_denied() {
let output = "Got permission denied while trying to connect to the Docker daemon socket";
let (metrics, _alerts, _status) = parse_docker_output(output);
assert_eq!(metrics.len(), 4); }
}