use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerState {
Absent,
Running,
Stopped,
}
impl ContainerState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Absent => "absent",
Self::Running => "running",
Self::Stopped => "stopped",
}
}
}
#[derive(Debug, Clone)]
pub struct RunSpec {
pub name: String,
pub image: String,
pub port: u16,
pub mounts: Vec<(String, String, bool)>,
pub env: Vec<(String, String)>,
pub label: String,
}
pub trait ContainerRuntime: Send + Sync {
fn available(&self) -> Result<String, String>;
fn state(&self, name: &str) -> Result<ContainerState, String>;
fn run(&self, spec: &RunSpec) -> Result<(), String>;
fn start(&self, name: &str) -> Result<(), String>;
fn remove(&self, name: &str) -> Result<(), String>;
fn image_present(&self, image: &str) -> Result<bool, String>;
fn build(&self, image: &str, context: &str) -> Result<(), String>;
fn pull(&self, image: &str) -> Result<(), String>;
fn container_image(&self, name: &str) -> Result<Option<String>, String>;
fn health(&self, port: u16) -> bool;
fn listeners_on(&self, port: u16) -> Result<Vec<String>, String>;
fn exec(&self, name: &str, arguments: &[&str]) -> Result<String, String>;
}
#[derive(Debug, Default)]
pub struct Docker;
impl Docker {
fn docker(arguments: &[&str]) -> Result<String, String> {
let output = Command::new("docker").args(arguments).output();
let output = match output {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err("Docker is not installed".to_string());
}
Err(error) => return Err(error.to_string()),
};
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
Err(compact(&String::from_utf8_lossy(&output.stderr)))
}
}
fn compact(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn explain_unavailable(error: String) -> String {
let lowered = error.to_ascii_lowercase();
if lowered.contains("permission denied") {
"permission denied while connecting to Docker; add this user to the Docker group"
.to_string()
} else if lowered.contains("not installed") {
error
} else {
format!("the Docker daemon is not running or unreachable: {error}")
}
}
fn interpret_state(answer: Result<String, String>) -> Result<ContainerState, String> {
match answer {
Ok(rendered) if rendered.trim() == "running" => Ok(ContainerState::Running),
Ok(_) => Ok(ContainerState::Stopped),
Err(error) if is_absent(&error) => Ok(ContainerState::Absent),
Err(error) => Err(error),
}
}
fn parse_names(rendered: &str) -> Vec<String> {
rendered
.lines()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
}
fn run_arguments(spec: &RunSpec) -> Vec<String> {
let mut arguments: Vec<String> = vec![
"run".into(),
"-d".into(),
"--name".into(),
spec.name.clone(),
"--label".into(),
spec.label.clone(),
"-p".into(),
format!("127.0.0.1:{}:8080", spec.port),
];
for (host, container, read_only) in &spec.mounts {
arguments.push("-v".into());
arguments.push(if *read_only {
format!("{host}:{container}:ro")
} else {
format!("{host}:{container}")
});
}
for (key, _) in &spec.env {
arguments.push("-e".into());
arguments.push(key.clone());
}
arguments.push(spec.image.clone());
arguments.push("serve".into());
arguments
}
impl ContainerRuntime for Docker {
fn available(&self) -> Result<String, String> {
Self::docker(&["info", "--format", "{{.ServerVersion}}"]).map_err(explain_unavailable)
}
fn state(&self, name: &str) -> Result<ContainerState, String> {
interpret_state(Self::docker(&[
"inspect",
"--format",
"{{if .State.Running}}running{{else}}stopped{{end}}",
name,
]))
}
fn run(&self, spec: &RunSpec) -> Result<(), String> {
let mut command = Command::new("docker");
command.args(run_arguments(spec));
for (key, value) in &spec.env {
command.env(key, value);
}
let output = command.output().map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(compact(&String::from_utf8_lossy(&output.stderr)))
}
}
fn start(&self, name: &str) -> Result<(), String> {
Self::docker(&["start", name]).map(|_| ())
}
fn remove(&self, name: &str) -> Result<(), String> {
Self::docker(&["rm", "-f", name]).map(|_| ())
}
fn image_present(&self, image: &str) -> Result<bool, String> {
match Self::docker(&["image", "inspect", "--format", "{{.Id}}", image]) {
Ok(id) => Ok(!id.is_empty()),
Err(error) if is_absent(&error) => Ok(false),
Err(error) => Err(error),
}
}
fn build(&self, image: &str, context: &str) -> Result<(), String> {
Self::docker(&["build", "-t", image, context]).map(|_| ())
}
fn pull(&self, image: &str) -> Result<(), String> {
Self::docker(&["pull", image]).map(|_| ())
}
fn container_image(&self, name: &str) -> Result<Option<String>, String> {
match Self::docker(&["inspect", "--format", "{{.Image}}", name]) {
Ok(id) => Ok(Some(id)),
Err(error) if is_absent(&error) => Ok(None),
Err(error) => Err(error),
}
}
fn health(&self, port: u16) -> bool {
use std::io::{Read as _, Write as _};
let Ok(mut stream) = std::net::TcpStream::connect(("127.0.0.1", port)) else {
return false;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
if stream
.write_all(b"GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.is_err()
{
return false;
}
let mut response = String::new();
stream.read_to_string(&mut response).is_ok() && response.starts_with("HTTP/1.1 200")
}
fn listeners_on(&self, port: u16) -> Result<Vec<String>, String> {
let rendered = Self::docker(&[
"ps",
"--filter",
&format!("publish={port}"),
"--format",
"{{.Names}}",
])?;
Ok(parse_names(&rendered))
}
fn exec(&self, name: &str, arguments: &[&str]) -> Result<String, String> {
let mut full = vec!["exec", name];
full.extend_from_slice(arguments);
Self::docker(&full)
}
}
fn is_absent(error: &str) -> bool {
let lowered = error.to_ascii_lowercase();
lowered.contains("no such object")
|| lowered.contains("no such container")
|| lowered.contains("no such image")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docker_absence_messages_are_recognised_in_either_spelling() {
assert!(is_absent("Error: No such object: router-deploy"));
assert!(is_absent("error: no such container: router-deploy"));
assert!(is_absent("No such image: ghcr.io/x:1"));
assert!(!is_absent("Cannot connect to the Docker daemon"));
}
#[test]
fn multiline_command_errors_collapse_to_one_line() {
assert_eq!(
compact("failed:\n because\n reasons"),
"failed: because reasons"
);
}
fn spec() -> RunSpec {
RunSpec {
name: "router-deploy".into(),
image: "ghcr.io/link-assistant/router:1.9.0".into(),
port: 18080,
mounts: vec![
("/host/creds".into(), "/data/claude".into(), true),
("/host/data".into(), "/data/router".into(), false),
],
env: vec![
("TOKEN_SECRET".into(), "a-real-signing-secret".into()),
("STORAGE_POLICY".into(), "text".into()),
],
label: "com.link-assistant.router.deploy=1".into(),
}
}
#[test]
fn the_credential_mount_is_read_only_and_the_data_mount_is_not() {
let arguments = run_arguments(&spec());
assert!(
arguments.contains(&"/host/creds:/data/claude:ro".to_string()),
"{arguments:?}"
);
assert!(
arguments.contains(&"/host/data:/data/router".to_string()),
"{arguments:?}"
);
assert!(
!arguments.contains(&"/host/data:/data/router:ro".to_string()),
"the data mount stays writable: {arguments:?}"
);
}
#[test]
fn a_secret_value_never_becomes_a_command_line_argument() {
let spec = spec();
let arguments = run_arguments(&spec);
assert!(
arguments.contains(&"TOKEN_SECRET".to_string()),
"the name is passed: {arguments:?}"
);
assert!(
!arguments
.iter()
.any(|argument| argument.contains("a-real-signing-secret")),
"the value is not: {arguments:?}"
);
assert!(
spec.env
.iter()
.any(|(key, value)| key == "TOKEN_SECRET" && value == "a-real-signing-secret")
);
}
#[test]
fn the_port_is_published_on_loopback_only() {
let arguments = run_arguments(&spec());
assert!(
arguments.contains(&"127.0.0.1:18080:8080".to_string()),
"{arguments:?}"
);
}
#[test]
fn the_container_is_labelled_and_named_and_serves() {
let arguments = run_arguments(&spec());
assert!(arguments.contains(&"com.link-assistant.router.deploy=1".to_string()));
assert!(arguments.contains(&"router-deploy".to_string()));
assert_eq!(
arguments.last().map(String::as_str),
Some("serve"),
"the image's command comes last: {arguments:?}"
);
assert_eq!(
arguments.first().map(String::as_str),
Some("run"),
"{arguments:?}"
);
}
fn serve_once(status_line: &'static str) -> u16 {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("address").port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut scratch = [0_u8; 1024];
let _ = stream.read(&mut scratch);
let _ = stream.write_all(status_line.as_bytes());
}
});
port
}
#[test]
fn health_is_a_200_from_the_health_route_and_nothing_else() {
let runtime = Docker;
assert!(
runtime.health(serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")),
"a 200 is healthy"
);
assert!(
!runtime.health(serve_once(
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"
)),
"a 503 is not healthy"
);
}
#[test]
fn a_port_nobody_listens_on_is_not_healthy() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind")
.local_addr()
.expect("address")
.port();
assert!(!Docker.health(port));
}
#[test]
fn an_unusable_runtime_is_explained_by_its_actual_cause() {
assert!(
explain_unavailable("Got permission denied while trying to connect".into())
.contains("Docker group")
);
assert_eq!(
explain_unavailable("Docker is not installed".into()),
"Docker is not installed",
"the precise message is kept rather than wrapped"
);
let down = explain_unavailable("Cannot connect to the Docker daemon".into());
assert!(down.contains("not running or unreachable"), "{down}");
assert!(
down.contains("Cannot connect"),
"the daemon's own words survive: {down}"
);
}
#[test]
fn a_missing_container_reads_as_absent_rather_than_as_an_error() {
assert_eq!(
interpret_state(Err("Error: No such object: router-deploy".into())),
Ok(ContainerState::Absent)
);
assert_eq!(
interpret_state(Ok("running".into())),
Ok(ContainerState::Running)
);
assert_eq!(
interpret_state(Ok("stopped".into())),
Ok(ContainerState::Stopped)
);
assert_eq!(
interpret_state(Ok("running\n".into())),
Ok(ContainerState::Running)
);
assert!(interpret_state(Err("daemon went away".into())).is_err());
}
#[test]
fn a_listing_yields_one_name_per_line_and_ignores_blanks() {
assert_eq!(
parse_names("router-deploy\nsomething-else\n"),
vec!["router-deploy".to_string(), "something-else".to_string()]
);
assert!(parse_names("").is_empty());
assert!(parse_names("\n \n").is_empty());
}
#[test]
fn container_states_have_stable_spellings() {
assert_eq!(ContainerState::Absent.as_str(), "absent");
assert_eq!(ContainerState::Running.as_str(), "running");
assert_eq!(ContainerState::Stopped.as_str(), "stopped");
}
}