use cmd_proc::*;
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum Selection {
Auto,
Docker,
Podman,
}
impl Selection {
pub async fn resolve(&self) -> resolve::Result {
match self {
Self::Auto => resolve::auto().await,
Self::Docker => resolve::docker().await,
Self::Podman => resolve::podman().await,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Backend {
Docker { version: semver::Version },
Podman { version: semver::Version },
}
impl Backend {
const DOCKER_EXECUTABLE: &'static str = "docker";
const PODMAN_EXECUTABLE: &'static str = "podman";
#[must_use]
pub fn command(&self) -> Command {
match self {
Self::Docker { .. } => Command::new(Self::DOCKER_EXECUTABLE),
Self::Podman { .. } => Command::new(Self::PODMAN_EXECUTABLE),
}
}
pub async fn is_image_present(&self, reference: &crate::image::Reference) -> bool {
let reference_string = reference.to_string();
match self {
Backend::Docker { .. } => self
.command()
.arguments(["inspect", "--type", "image", &reference_string])
.stdout_capture()
.bytes()
.await
.is_ok(),
Backend::Podman { .. } => {
self.command()
.arguments(["image", "exists", &reference_string])
.status()
.await
.is_ok()
}
}
}
pub async fn tag_image(
&self,
source: &crate::image::Reference,
target: &crate::image::Reference,
) {
self.command()
.arguments(["tag", &source.to_string(), &target.to_string()])
.status()
.await
.unwrap();
}
pub async fn pull_image(&self, reference: &crate::image::Reference) {
self.command()
.arguments(["pull", &reference.to_string()])
.status()
.await
.unwrap();
}
pub async fn pull_image_if_absent(&self, reference: &crate::image::Reference) {
if !self.is_image_present(reference).await {
self.pull_image(reference).await;
}
}
pub async fn push_image(&self, reference: &crate::image::Reference) {
self.command()
.arguments(["push", &reference.to_string()])
.status()
.await
.unwrap();
}
pub async fn remove_image(&self, reference: &crate::image::Reference) {
self.do_remove_image(reference, false).await;
}
pub async fn remove_image_force(&self, reference: &crate::image::Reference) {
self.do_remove_image(reference, true).await;
}
async fn do_remove_image(&self, reference: &crate::image::Reference, force: bool) {
let command = self.command().arguments(["image", "rm"]);
let command = if force {
command.argument("--force")
} else {
command
};
command
.argument(reference.to_string())
.status()
.await
.unwrap();
}
pub async fn image_references_by_name(
&self,
name: &crate::reference::Name,
) -> std::collections::BTreeSet<crate::image::Reference> {
let output = self
.command()
.arguments([
"images",
"--format",
"{{.Repository}}:{{.Tag}}",
"--filter",
&format!("reference={name}:*"),
])
.stdout_capture()
.string()
.await
.unwrap();
output
.lines()
.filter(|line| !line.is_empty())
.map(|line| line.parse::<crate::image::Reference>().unwrap())
.filter(|reference| reference.name.path == name.path)
.collect()
}
#[must_use]
pub fn container_resolver(&self) -> ContainerHostnameResolver {
ContainerHostnameResolver::new(self.clone())
}
pub async fn resolve_container_host(&self) -> Result<std::net::IpAddr, ResolveHostnameError> {
match self {
Backend::Podman { .. } => {
self.container_resolver()
.resolve("host.containers.internal")
.await
}
Backend::Docker { .. } => {
self.container_resolver()
.add_host("host.docker.internal:host-gateway")
.resolve("host.docker.internal")
.await
}
}
}
pub async fn bridge_subnets(&self) -> Result<Vec<ipnet::IpNet>, BridgeSubnetError> {
let stdout = self
.command()
.arguments(match self {
Self::Docker { .. } => ["network", "inspect", "bridge"],
Self::Podman { .. } => ["network", "inspect", "podman"],
})
.stdout_capture()
.bytes()
.await
.map_err(BridgeSubnetError::CommandFailed)?;
match self {
Self::Docker { .. } => {
let networks: Vec<DockerNetworkInspect> =
serde_json::from_slice(&stdout).map_err(BridgeSubnetError::JsonParseFailed)?;
Ok(networks
.into_iter()
.flat_map(|n| n.ipam.config)
.map(|c| c.subnet)
.collect())
}
Self::Podman { .. } => {
let networks: Vec<PodmanNetworkInspect> =
serde_json::from_slice(&stdout).map_err(BridgeSubnetError::JsonParseFailed)?;
Ok(networks
.into_iter()
.flat_map(|n| n.subnets)
.map(|s| s.subnet)
.collect())
}
}
}
}
#[derive(serde::Deserialize)]
struct DockerNetworkInspect {
#[serde(rename = "IPAM")]
ipam: DockerIpam,
}
#[derive(serde::Deserialize)]
struct DockerIpam {
#[serde(rename = "Config")]
config: Vec<DockerIpamConfig>,
}
#[derive(serde::Deserialize)]
struct DockerIpamConfig {
#[serde(rename = "Subnet")]
subnet: ipnet::IpNet,
}
#[derive(serde::Deserialize)]
struct PodmanNetworkInspect {
subnets: Vec<PodmanSubnet>,
}
#[derive(serde::Deserialize)]
struct PodmanSubnet {
subnet: ipnet::IpNet,
}
#[derive(Debug, thiserror::Error)]
pub enum BridgeSubnetError {
#[error("network inspect command failed")]
CommandFailed(#[source] cmd_proc::CommandError),
#[error("failed to parse network inspect JSON")]
JsonParseFailed(#[source] serde_json::Error),
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum ResolveHostnameError {
#[error("hostname resolution command failed: {0}")]
CommandFailed(String),
#[error("Invalid UTF-8 in resolution output")]
InvalidUtf8,
#[error("No IP address found in resolution output for hostname: {0}")]
NoIpAddressFound(String),
#[error("Failed to parse IP address from resolution output: {source}")]
ParseError {
output: String,
#[source]
source: std::net::AddrParseError,
},
}
pub struct ContainerHostnameResolver {
backend: Backend,
container_arguments: Vec<String>,
}
impl ContainerHostnameResolver {
fn new(backend: Backend) -> Self {
Self {
backend,
container_arguments: vec![],
}
}
pub fn add_host(mut self, mapping: impl Into<String>) -> Self {
self.container_arguments.push("--add-host".to_string());
self.container_arguments.push(mapping.into());
self
}
pub fn network(mut self, network: impl Into<String>) -> Self {
self.container_arguments.push("--network".to_string());
self.container_arguments.push(network.into());
self
}
pub fn argument(mut self, argument: impl Into<String>) -> Self {
self.container_arguments.push(argument.into());
self
}
pub fn arguments(mut self, arguments: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.container_arguments
.extend(arguments.into_iter().map(Into::into));
self
}
pub async fn resolve(self, hostname: &str) -> Result<std::net::IpAddr, ResolveHostnameError> {
const ALPINE_IMAGE: &str = "alpine:latest";
let output = self
.backend
.command()
.argument("run")
.argument("--rm")
.arguments(&self.container_arguments)
.argument(ALPINE_IMAGE)
.argument("getent")
.argument("hosts")
.argument(hostname)
.stdout_capture()
.bytes()
.await
.map_err(|error| ResolveHostnameError::CommandFailed(error.to_string()))?;
let output_str =
std::str::from_utf8(&output).map_err(|_| ResolveHostnameError::InvalidUtf8)?;
let ip_str = output_str
.split_whitespace()
.next()
.ok_or_else(|| ResolveHostnameError::NoIpAddressFound(hostname.to_string()))?;
ip_str
.parse()
.map_err(|parse_error| ResolveHostnameError::ParseError {
output: output_str.to_string(),
source: parse_error,
})
}
}
pub mod resolve {
use super::{Backend, Command};
const ENV_VARIABLE_NAME: &str = "OCIMAN_BACKEND";
pub type Result = std::result::Result<Backend, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Failed to load config")]
ConfigLoad(#[source] crate::config::Error),
#[error(
"Invalid env variable for {ENV_VARIABLE_NAME}, expected \"podman\" or \"docker\", got: {0}"
)]
InvalidEnvVariable(String),
#[error("No container tool detected in $PATH, searched for podman and docker")]
NoContainerToolDetected,
#[error("Failed to detect {executable} version: {message}")]
VersionDetectionFailed {
executable: &'static str,
message: String,
},
#[error("Failed to parse {executable} version from output '{output}': {message}")]
VersionParseFailed {
executable: &'static str,
output: String,
message: String,
},
}
pub async fn auto() -> Result {
match std::env::var(ENV_VARIABLE_NAME) {
Err(std::env::VarError::NotPresent) => {
let config = crate::config::Config::load().map_err(Error::ConfigLoad)?;
from_present_tool(config.default_backend).await
}
Err(std::env::VarError::NotUnicode(_)) => {
panic!("{ENV_VARIABLE_NAME} env variable exist but is not unicode!")
}
Ok(value) => from_env_value(&value).await,
}
}
pub async fn docker() -> Result {
detect_version(Backend::DOCKER_EXECUTABLE, |version| Backend::Docker {
version,
})
.await
}
pub async fn podman() -> Result {
detect_version(Backend::PODMAN_EXECUTABLE, |version| Backend::Podman {
version,
})
.await
}
async fn from_env_value(value: &str) -> Result {
match value {
"docker" => docker().await,
"podman" => podman().await,
_ => Err(Error::InvalidEnvVariable(value.to_string())),
}
}
async fn from_present_tool(preferred: super::Selection) -> Result {
match preferred {
super::Selection::Podman => match podman().await {
Ok(backend) => Ok(backend),
Err(_) => docker().await.map_err(|_| Error::NoContainerToolDetected),
},
super::Selection::Docker | super::Selection::Auto => match docker().await {
Ok(backend) => Ok(backend),
Err(_) => podman().await.map_err(|_| Error::NoContainerToolDetected),
},
}
}
async fn detect_version(
executable: &'static str,
constructor: impl FnOnce(semver::Version) -> Backend,
) -> Result {
let output = Command::new(executable)
.argument("--version")
.stdout_capture()
.bytes()
.await
.map_err(|error| Error::VersionDetectionFailed {
executable,
message: error.to_string(),
})?;
let output_str =
std::str::from_utf8(&output).map_err(|_| Error::VersionDetectionFailed {
executable,
message: "invalid UTF-8 in version output".to_string(),
})?;
let version = parse_version(executable, output_str)?;
log::debug!("ociman using: {executable} {version}");
Ok(constructor(version))
}
fn parse_version(
executable: &'static str,
output: &str,
) -> std::result::Result<semver::Version, Error> {
let version_str = output
.split_whitespace()
.find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
.map(|s| s.trim_end_matches(','))
.ok_or_else(|| Error::VersionDetectionFailed {
executable,
message: format!("no version found in output: {output}"),
})?;
semver::Version::parse(version_str).map_err(|error| Error::VersionParseFailed {
executable,
output: output.to_string(),
message: error.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_container_resolver_localhost() {
let backend = crate::test_backend_setup!();
let ip = backend
.container_resolver()
.resolve("localhost")
.await
.unwrap();
assert!(ip.is_loopback());
}
#[tokio::test]
async fn test_container_resolver_with_add_host() {
let backend = crate::test_backend_setup!();
let ip = backend
.container_resolver()
.add_host("host.docker.internal:host-gateway")
.resolve("host.docker.internal")
.await
.unwrap();
assert!(ip.is_ipv4() || ip.is_ipv6());
}
#[tokio::test]
async fn test_container_resolver_nonexistent() {
let backend = crate::test_backend_setup!();
let result = backend
.container_resolver()
.resolve("this-definitely-does-not-exist-12345.local")
.await;
assert!(result.is_err());
match result {
Err(ResolveHostnameError::CommandFailed(_)) => {
}
other => panic!("Expected CommandFailed error, got: {other:?}"),
}
}
#[tokio::test]
async fn test_container_resolver_with_multiple_arguments() {
let backend = crate::test_backend_setup!();
let ip = backend
.container_resolver()
.add_host("custom-host:192.168.1.100")
.resolve("custom-host")
.await
.unwrap();
assert_eq!(
ip,
std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 100))
);
}
#[tokio::test]
async fn test_container_resolver_builder_pattern() {
let backend = crate::test_backend_setup!();
let resolver = backend
.container_resolver()
.argument("--add-host")
.argument("test-host:10.0.0.1");
let ip = resolver.resolve("test-host").await.unwrap();
assert_eq!(
ip,
std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1))
);
}
#[tokio::test]
async fn test_resolve_container_host() {
let backend = crate::test_backend_setup!();
let ip = backend.resolve_container_host().await.unwrap();
assert!(ip.is_ipv4() || ip.is_ipv6());
}
#[test]
fn test_docker_bridge_json_parsing() {
let json = r#"[{
"Name": "bridge",
"IPAM": {
"Config": [{"Subnet": "172.17.0.0/16", "Gateway": "172.17.0.1"}]
}
}]"#;
let networks: Vec<DockerNetworkInspect> = serde_json::from_str(json).unwrap();
assert_eq!(
networks[0].ipam.config[0].subnet.to_string(),
"172.17.0.0/16"
);
}
#[test]
fn test_podman_bridge_json_parsing() {
let json = r#"[{
"name": "podman",
"subnets": [{"subnet": "10.88.0.0/16", "gateway": "10.88.0.1"}]
}]"#;
let networks: Vec<PodmanNetworkInspect> = serde_json::from_str(json).unwrap();
assert_eq!(networks[0].subnets[0].subnet.to_string(), "10.88.0.0/16");
}
#[tokio::test]
async fn test_image_references_by_name() {
use std::collections::BTreeSet;
let backend = crate::test_backend_setup!();
let name: crate::reference::Name = "localhost/ociman-test/image-references-by-name"
.parse()
.unwrap();
for image in backend.image_references_by_name(&name).await {
backend.remove_image_force(&image).await;
}
let source = crate::testing::ALPINE_LATEST_IMAGE.clone();
backend.pull_image_if_absent(&source).await;
let target_a: crate::image::Reference = "localhost/ociman-test/image-references-by-name:a"
.parse()
.unwrap();
let target_b: crate::image::Reference = "localhost/ociman-test/image-references-by-name:b"
.parse()
.unwrap();
backend.tag_image(&source, &target_a).await;
backend.tag_image(&source, &target_b).await;
assert_eq!(
backend.image_references_by_name(&name).await,
BTreeSet::from([target_a.clone(), target_b.clone()])
);
backend.remove_image_force(&target_a).await;
backend.remove_image_force(&target_b).await;
}
}