use std::io;
use std::path::Path;
use std::process::{ExitStatus, Output};
use crate::error::{CloudError, CloudResult};
mod command;
pub use command::{CommandRunner, CommandSpec, SystemCommandRunner};
fn configured_creds_store() -> String {
let Some(config) = dirs::home_dir().map(|home| home.join(".docker").join("config.json")) else {
return "unknown (no home directory)".to_owned();
};
let Ok(raw) = std::fs::read_to_string(&config) else {
return format!("none ({} absent)", config.display());
};
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(value) => value
.get("credsStore")
.and_then(serde_json::Value::as_str)
.map_or_else(|| "none".to_owned(), ToOwned::to_owned),
Err(e) => format!("unreadable ({}: {e})", config.display()),
}
}
pub struct DockerCli {
runner: Box<dyn CommandRunner>,
}
impl std::fmt::Debug for DockerCli {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockerCli").finish_non_exhaustive()
}
}
impl Default for DockerCli {
fn default() -> Self {
Self::new()
}
}
impl DockerCli {
#[must_use]
pub fn new() -> Self {
Self {
runner: Box::new(SystemCommandRunner),
}
}
#[must_use]
pub const fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
Self { runner }
}
pub fn preflight_daemon(&self) -> CloudResult<String> {
let spec = CommandSpec::docker(["version", "--format", "{{.Server.Version}}"]);
let outcome = self.runner.output(&spec);
let failure = match outcome {
Ok(output) if output.status.success() => {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
},
Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_owned(),
Err(e) => e.to_string(),
};
Err(CloudError::docker(format!(
"Docker daemon unreachable via `{}`: {}\n docker binary: {}\n credsStore: {}",
spec.rendered(),
if failure.is_empty() {
"no output".to_owned()
} else {
failure
},
self.resolved_docker_binary(),
configured_creds_store(),
)))
}
fn resolved_docker_binary(&self) -> String {
let spec = if cfg!(windows) {
CommandSpec {
program: "where".to_owned(),
args: vec!["docker".to_owned()],
current_dir: None,
}
} else {
CommandSpec {
program: "sh".to_owned(),
args: vec!["-c".to_owned(), "command -v docker".to_owned()],
current_dir: None,
}
};
match self.runner.output(&spec) {
Ok(output) if output.status.success() => {
let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if path.is_empty() {
"not on PATH".to_owned()
} else {
path
}
},
Ok(_) => "not on PATH".to_owned(),
Err(e) => format!("unresolved ({e})"),
}
}
pub fn build_image(
&self,
context_dir: &Path,
dockerfile: &Path,
image: &str,
) -> CloudResult<()> {
self.preflight_daemon()?;
let dockerfile_arg = dockerfile.to_string_lossy().into_owned();
let mut spec = CommandSpec::docker([
"build",
"--no-cache",
"-f",
dockerfile_arg.as_str(),
"-t",
image,
".",
]);
spec.current_dir = Some(context_dir.to_path_buf());
let status = self.runner.status(&spec).map_err(|e| {
CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
})?;
if !status.success() {
return Err(CloudError::docker(format!(
"Command failed: {}",
spec.rendered()
)));
}
Ok(())
}
pub fn login(&self, registry: &str, username: &str, token: &str) -> CloudResult<()> {
let spec = CommandSpec::docker(["login", registry, "-u", username, "--password-stdin"]);
let status = self
.runner
.status_with_stdin(&spec, token.as_bytes())
.map_err(|e| {
CloudError::docker_with(format!("failed to spawn `docker login {registry}`"), e)
})?;
if !status.success() {
return Err(CloudError::docker("Docker login failed"));
}
Ok(())
}
pub fn push(&self, image: &str) -> CloudResult<()> {
let spec = CommandSpec::docker(["push", image]);
let status = self.runner.status(&spec).map_err(|e| {
CloudError::docker_with(format!("failed to spawn `docker push {image}`"), e)
})?;
if !status.success() {
return Err(CloudError::docker(format!(
"Docker push failed for image: {}",
image
)));
}
Ok(())
}
pub fn repo_digest(&self, image: &str) -> CloudResult<String> {
let spec = CommandSpec::docker([
"image",
"inspect",
"--format",
"{{json .RepoDigests}}",
image,
]);
let output = self.runner.output(&spec).map_err(|e| {
CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
})?;
if !output.status.success() {
return Err(CloudError::docker(format!(
"Command failed: {}: {}",
spec.rendered(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let digests: Vec<String> = serde_json::from_slice(&output.stdout)?;
select_repo_digest(image, &digests).ok_or_else(|| {
CloudError::docker(format!(
"Image {image} has no registry digest; push it before resolving the digest"
))
})
}
pub fn output(&self, args: &[&str]) -> io::Result<Output> {
self.runner
.output(&CommandSpec::docker(args.iter().copied()))
}
pub fn status(&self, args: &[&str]) -> io::Result<ExitStatus> {
self.runner
.status(&CommandSpec::docker(args.iter().copied()))
}
}
#[must_use]
pub fn select_repo_digest(image: &str, repo_digests: &[String]) -> Option<String> {
let repository = image_repository(image);
repo_digests.iter().find_map(|entry| {
let (name, digest) = entry.split_once('@')?;
(name == repository).then(|| digest.to_owned())
})
}
#[must_use]
pub fn image_repository(image: &str) -> &str {
let without_digest = image.split_once('@').map_or(image, |(name, _)| name);
match without_digest.rsplit_once(':') {
Some((name, tag)) if !tag.contains('/') => name,
_ => without_digest,
}
}