use std::path::{Path, PathBuf};
use bollard::{API_DEFAULT_VERSION, Docker};
use serde_json::Value;
const TIMEOUT: u64 = 120;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Engine {
pub name: String,
pub host: String,
pub tls: Option<(PathBuf, PathBuf, PathBuf)>,
pub current: bool,
}
impl Engine {
pub fn supported(&self) -> bool {
let host = self.host.as_str();
host.starts_with("unix://") && cfg!(unix)
|| host.starts_with("npipe://") && cfg!(windows)
|| host.starts_with("tcp://")
|| host.starts_with("http://")
|| host.starts_with("https://")
|| host.starts_with("ssh://") && cfg!(unix)
}
pub fn connect(&self) -> Result<Docker, String> {
let error = |e: bollard::errors::Error| format!("Can't use {}: {e}", self.host);
match &self.tls {
Some((ca, cert, key)) => {
Docker::connect_with_ssl(&self.host, key, cert, ca, TIMEOUT, API_DEFAULT_VERSION)
.map_err(error)
}
#[cfg(unix)]
None if self.host.starts_with("ssh://") => {
Docker::connect_with_ssh(&self.host, TIMEOUT, API_DEFAULT_VERSION, None)
.map_err(error)
}
None => Docker::connect_with_host(&self.host).map_err(error),
}
}
}
fn docker_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
return Some(PathBuf::from(dir));
}
home().map(|h| h.join(".docker"))
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn default_host() -> String {
std::env::var("DOCKER_HOST").unwrap_or_else(|_| {
if cfg!(windows) {
"npipe:////./pipe/docker_engine".into()
} else {
"unix:///var/run/docker.sock".into()
}
})
}
fn contexts(dir: &Path, current: &str) -> Vec<Engine> {
let Ok(entries) = std::fs::read_dir(dir.join("contexts").join("meta")) else {
return Vec::new();
};
let mut list: Vec<Engine> = entries
.flatten()
.filter_map(|entry| {
let text = std::fs::read_to_string(entry.path().join("meta.json")).ok()?;
let meta: Value = serde_json::from_str(&text).ok()?;
let name = meta["Name"].as_str()?.to_owned();
let endpoint = &meta["Endpoints"]["docker"];
let host = endpoint["Host"].as_str()?.to_owned();
let tls_dir = dir
.join("contexts")
.join("tls")
.join(entry.file_name())
.join("docker");
let tls = [
tls_dir.join("ca.pem"),
tls_dir.join("cert.pem"),
tls_dir.join("key.pem"),
];
let tls = tls
.iter()
.all(|f| f.exists())
.then(|| (tls[0].clone(), tls[1].clone(), tls[2].clone()));
Some(Engine {
current: name == current,
name,
host,
tls,
})
})
.collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list
}
fn detected(home: &Path) -> Vec<Engine> {
let mut candidates: Vec<(String, PathBuf)> = vec![
(
"Docker Desktop".into(),
home.join(".docker/run/docker.sock"),
),
("Rancher Desktop".into(), home.join(".rd/docker.sock")),
(
"Podman".into(),
home.join(".local/share/containers/podman/machine/podman.sock"),
),
];
if let Ok(profiles) = std::fs::read_dir(home.join(".colima")) {
for profile in profiles.flatten() {
let name = profile.file_name().to_string_lossy().into_owned();
let label = if name == "default" {
"Colima".to_owned()
} else {
format!("Colima ({name})")
};
candidates.push((label, profile.path().join("docker.sock")));
}
}
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
candidates.push((
"Podman".into(),
PathBuf::from(runtime).join("podman/podman.sock"),
));
}
candidates
.into_iter()
.filter(|(_, path)| path.exists())
.map(|(name, path)| Engine {
name,
host: format!("unix://{}", path.display()),
tls: None,
current: false,
})
.collect()
}
pub fn engines() -> Vec<Engine> {
let dir = docker_dir();
let config: Value = dir
.as_ref()
.and_then(|d| std::fs::read_to_string(d.join("config.json")).ok())
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(Value::Null);
let current = std::env::var("DOCKER_CONTEXT")
.ok()
.or_else(|| config["currentContext"].as_str().map(str::to_owned))
.unwrap_or_else(|| "default".into());
let mut list = vec![Engine {
name: "default".into(),
host: default_host(),
tls: None,
current: current == "default",
}];
if let Some(dir) = &dir {
list.extend(contexts(dir, ¤t));
}
if let Some(home) = home() {
for engine in detected(&home) {
if list.iter().all(|e| e.host != engine.host) {
list.push(engine);
}
}
}
list
}
pub fn pick(list: &[Engine], name: Option<&str>) -> Option<Engine> {
name.and_then(|n| list.iter().find(|e| e.name == n))
.or_else(|| list.iter().find(|e| e.current))
.or_else(|| list.first())
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ferrodock-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reads_contexts() {
let dir = temp("contexts");
let meta = dir.join("contexts/meta/abc123");
std::fs::create_dir_all(&meta).unwrap();
std::fs::write(
meta.join("meta.json"),
r#"{"Name":"colima","Endpoints":{"docker":{"Host":"unix:///x/docker.sock"}}}"#,
)
.unwrap();
let remote = dir.join("contexts/meta/def456");
std::fs::create_dir_all(&remote).unwrap();
std::fs::write(
remote.join("meta.json"),
r#"{"Name":"prod","Endpoints":{"docker":{"Host":"tcp://10.0.0.5:2376"}}}"#,
)
.unwrap();
let tls = dir.join("contexts/tls/def456/docker");
std::fs::create_dir_all(&tls).unwrap();
for f in ["ca.pem", "cert.pem", "key.pem"] {
std::fs::write(tls.join(f), "").unwrap();
}
let list = contexts(&dir, "prod");
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "colima");
assert!(list[0].tls.is_none() && !list[0].current);
assert!(list[1].tls.is_some() && list[1].current);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn detects_sockets() {
let home = temp("home");
std::fs::create_dir_all(home.join(".colima/work")).unwrap();
std::fs::write(home.join(".colima/work/docker.sock"), "").unwrap();
let found = detected(&home);
assert_eq!(found.len(), 1);
assert_eq!(found[0].name, "Colima (work)");
assert!(found[0].host.ends_with(".colima/work/docker.sock"));
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn picks_an_engine() {
let engine = |name: &str, current| Engine {
name: name.into(),
host: "unix:///x".into(),
tls: None,
current,
};
let list = vec![engine("default", false), engine("colima", true)];
assert_eq!(pick(&list, Some("default")).unwrap().name, "default");
assert_eq!(pick(&list, Some("gone")).unwrap().name, "colima");
assert_eq!(pick(&list, None).unwrap().name, "colima");
assert!(engine("x", false).supported() == cfg!(unix));
}
}