use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use super::Engine;
pub fn next_job() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComposeTarget {
pub name: Option<String>,
pub files: Vec<PathBuf>,
pub dir: PathBuf,
}
impl ComposeTarget {
pub fn from_file(file: &Path) -> Self {
Self {
name: None,
files: vec![file.to_owned()],
dir: file.parent().map(Path::to_owned).unwrap_or_default(),
}
}
pub fn label(&self) -> String {
match &self.name {
Some(name) => name.clone(),
None => self
.dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComposeAction {
Up,
Rebuild(Option<String>),
PullRecreate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComposeOp {
Run {
job: u64,
target: ComposeTarget,
action: ComposeAction,
},
Logs { job: u64, project: String },
Cancel { job: u64 },
}
pub fn steps(target: &ComposeTarget, action: &ComposeAction) -> Vec<Vec<String>> {
let mut base = Vec::new();
if let Some(name) = &target.name {
base.extend(["-p".into(), name.clone()]);
}
for file in &target.files {
base.extend(["-f".into(), file.display().to_string()]);
}
let with = |rest: &[&str]| {
let mut args = base.clone();
args.extend(rest.iter().map(|s| (*s).to_owned()));
args
};
match action {
ComposeAction::Up => vec![with(&["up", "-d", "--remove-orphans"])],
ComposeAction::Rebuild(Some(service)) => {
vec![with(&["up", "-d", "--build", "--no-deps", service])]
}
ComposeAction::Rebuild(None) => vec![with(&["up", "-d", "--build"])],
ComposeAction::PullRecreate => vec![
with(&["pull"]),
with(&["up", "-d", "--force-recreate", "--remove-orphans"]),
],
}
}
pub fn logs_args(project: &str) -> Vec<String> {
["-p", project, "logs", "-f", "--tail", "200", "--no-color"]
.map(str::to_owned)
.to_vec()
}
pub fn command(engine: Option<&Engine>, dir: Option<&Path>, args: &[String]) -> Command {
let mut cmd = Command::new("docker");
cmd.arg("compose").args(args);
if let Some(dir) = dir.filter(|d| d.is_dir()) {
cmd.current_dir(dir);
}
if let Some(engine) = engine {
cmd.env_remove("DOCKER_CONTEXT")
.env("DOCKER_HOST", &engine.host);
if let Some((ca, _, _)) = &engine.tls {
cmd.env("DOCKER_TLS_VERIFY", "1");
if let Some(certs) = ca.parent() {
cmd.env("DOCKER_CERT_PATH", certs);
}
}
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
cmd
}
pub async fn stream(mut cmd: Command, mut line: impl FnMut(String)) -> Result<bool, String> {
let mut child: Child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"the docker command isn't installed (Compose needs it)".to_owned()
} else {
e.to_string()
}
})?;
let mut out = BufReader::new(child.stdout.take().ok_or("no output")?).lines();
let mut err = BufReader::new(child.stderr.take().ok_or("no output")?).lines();
let (mut out_open, mut err_open) = (true, true);
while out_open || err_open {
tokio::select! {
next = out.next_line(), if out_open => match next {
Ok(Some(text)) => line(text),
_ => out_open = false,
},
next = err.next_line(), if err_open => match next {
Ok(Some(text)) => line(text),
_ => err_open = false,
},
}
}
let status = child.wait().await.map_err(|e| e.to_string())?;
Ok(status.success())
}
pub fn split_log_line(line: &str) -> (&str, &str) {
match line.split_once(" | ") {
Some((prefix, text)) => {
let container = prefix.trim();
let service = match container.rsplit_once('-') {
Some((service, n)) if n.chars().all(|c| c.is_ascii_digit()) => service,
_ => container,
};
(service, text)
}
None => ("", line),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target() -> ComposeTarget {
ComposeTarget {
name: Some("shop".into()),
files: vec![PathBuf::from("/src/shop/compose.yaml")],
dir: PathBuf::from("/src/shop"),
}
}
#[test]
fn builds_the_commands() {
let up = steps(&target(), &ComposeAction::Up);
assert_eq!(
up[0].join(" "),
"-p shop -f /src/shop/compose.yaml up -d --remove-orphans"
);
let rebuild = steps(&target(), &ComposeAction::Rebuild(Some("web".into())));
assert!(
rebuild[0]
.join(" ")
.ends_with("up -d --build --no-deps web")
);
let pull = steps(&target(), &ComposeAction::PullRecreate);
assert_eq!(pull.len(), 2);
assert!(pull[0].ends_with(&["pull".to_owned()]));
assert_eq!(logs_args("shop")[..4], ["-p", "shop", "logs", "-f"]);
}
#[test]
fn names_projects_after_their_folder() {
let t = ComposeTarget::from_file(Path::new("/src/blog/compose.yaml"));
assert_eq!(t.label(), "blog");
assert!(steps(&t, &ComposeAction::Up)[0][0] == "-f");
}
#[test]
fn splits_log_lines() {
assert_eq!(split_log_line("web-1 | listening"), ("web", "listening"));
assert_eq!(split_log_line("db-worker-12 | ok"), ("db-worker", "ok"));
assert_eq!(split_log_line("plain"), ("", "plain"));
}
#[tokio::test]
#[ignore]
async fn live_compose_up_and_logs() {
let client = super::super::DockerClient::connect().unwrap();
let Some(image) = client.list_images().await.unwrap().into_iter().next() else {
return;
};
let tag = image.tags.first().cloned().unwrap_or(image.id);
let dir = std::env::temp_dir().join(format!("ferrodock-compose-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("compose.yaml");
std::fs::write(
&file,
format!(
"services:\n hello:\n image: {tag}\n command: sh -c 'echo hello from compose; sleep 300'\n"
),
)
.unwrap();
let target = ComposeTarget {
name: Some("ferrodock-compose-test".into()),
..ComposeTarget::from_file(&file)
};
let mut output = Vec::new();
let mut ok = true;
for args in steps(&target, &ComposeAction::Up) {
ok &= stream(command(None, Some(&dir), &args), |l| output.push(l))
.await
.unwrap();
}
let mut logs = Vec::new();
let mut follow = command(None, None, &logs_args("ferrodock-compose-test"));
follow.kill_on_drop(true);
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
stream(follow, |l| logs.push(l)),
)
.await;
let down = ["-p", "ferrodock-compose-test", "down", "-t", "1"].map(str::to_owned);
stream(command(None, None, &down), |_| {}).await.unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(ok, "up failed: {output:?}");
assert!(
logs.iter()
.any(|l| split_log_line(l) == ("hello", "hello from compose")),
"{logs:?}"
);
}
}