use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use eyre::{Result, bail, eyre};
use indexmap::{IndexMap, IndexSet};
use serde::{Deserialize, Serialize};
use crate::config::{Config, ConfigMap};
use crate::system::resources::{ResourceAction, ResourceId, ResourceOrigin, ResourcePlan};
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComposeState {
#[default]
Running,
Stopped,
Absent,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComposePullPolicy {
Always,
#[default]
Missing,
Never,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComposeBuildPolicy {
#[default]
Auto,
Always,
Never,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComposeRecreatePolicy {
#[default]
Auto,
Always,
Never,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComposeRemoveImages {
Local,
All,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct ComposeTomlConfig {
pub project_dir: PathBuf,
#[serde(default)]
pub files: Vec<PathBuf>,
#[serde(default)]
pub env_files: Vec<PathBuf>,
#[serde(default)]
pub project_name: Option<String>,
#[serde(default)]
pub profiles: Vec<String>,
#[serde(default)]
pub services: Vec<String>,
#[serde(default)]
pub oneshot: Vec<String>,
#[serde(default)]
pub state: ComposeState,
#[serde(default)]
pub pull: ComposePullPolicy,
#[serde(default)]
pub build: ComposeBuildPolicy,
#[serde(default)]
pub recreate: ComposeRecreatePolicy,
#[serde(default = "default_true")]
pub wait: bool,
#[serde(default)]
pub wait_timeout: Option<u64>,
#[serde(default)]
pub timeout: Option<u64>,
#[serde(default = "default_true")]
pub remove_orphans: bool,
#[serde(default)]
pub renew_anonymous_volumes: bool,
#[serde(default)]
pub down_volumes: bool,
#[serde(default)]
pub down_images: Option<ComposeRemoveImages>,
#[serde(default)]
pub sudo: bool,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub engine_command: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct ComposeRequest {
pub name: String,
project_dir: PathBuf,
files: Vec<PathBuf>,
env_files: Vec<PathBuf>,
project_name: Option<String>,
profiles: Vec<String>,
services: Vec<String>,
oneshot: IndexSet<String>,
state: ComposeState,
pull: ComposePullPolicy,
build: ComposeBuildPolicy,
recreate: ComposeRecreatePolicy,
wait: bool,
wait_timeout: Option<u64>,
timeout: Option<u64>,
remove_orphans: bool,
renew_anonymous_volumes: bool,
down_volumes: bool,
down_images: Option<ComposeRemoveImages>,
sudo: bool,
command: Vec<String>,
engine_command: Vec<String>,
explicit_dependencies: Vec<ResourceId>,
path_dependencies: Vec<ResourceId>,
origin: Option<ResourceOrigin>,
inspection: Option<ComposeInspection>,
}
#[derive(Clone, Debug)]
enum ComposeInspection {
Unavailable(String),
Present {
configured_services: IndexSet<String>,
target_services: IndexSet<String>,
containers: Vec<ComposeContainer>,
config_hashes: HashMap<String, String>,
},
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ComposeContainer {
#[serde(rename = "ID")]
id: String,
service: String,
state: String,
#[serde(default)]
health: String,
#[serde(default)]
exit_code: i64,
#[serde(skip)]
config_hash: Option<String>,
}
pub fn prepare_requests_from_config(config: &Config) -> Result<Vec<ComposeRequest>> {
let mut composed: IndexMap<String, (ComposeTomlConfig, ResourceOrigin)> = IndexMap::new();
for config_files in config.bootstrap_config_maps() {
for (name, declaration) in compose_from_config_files(config_files) {
if let Some(existing) = composed.get(&name) {
if existing.0 == declaration.0 {
continue;
}
bail!(
"conflicting bootstrap compose declarations for {name}\n\n first:\n {}\n\n second:\n {}",
existing.1.conflict_description(),
declaration.1.conflict_description(),
);
}
composed.insert(name, declaration);
}
}
composed
.into_iter()
.map(|(name, (config, origin))| {
ComposeRequest::from_toml_with_origin(name, config, Some(origin))
})
.collect()
}
fn compose_from_config_files(
config_files: &ConfigMap,
) -> IndexMap<String, (ComposeTomlConfig, ResourceOrigin)> {
let mut merged = IndexMap::new();
for (path, cf) in config_files {
if let Some(bootstrap) = cf.bootstrap_config() {
let origin = ResourceOrigin {
config: path.clone(),
config_root: cf.config_root(),
environment: crate::config::environments_for_config_path(path),
source: None,
};
for (name, project) in bootstrap.compose {
merged
.entry(name)
.or_insert_with(|| (project, origin.clone()));
}
}
}
merged
}
pub fn requests_from_config(config: &Config) -> Result<Vec<ComposeRequest>> {
let mut requests = prepare_requests_from_config(config)?;
inspect_requests(&mut requests);
Ok(requests)
}
pub fn inspect_requests(requests: &mut [ComposeRequest]) {
for request in requests {
request.inspection = Some(
request
.inspect()
.unwrap_or_else(|error| ComposeInspection::Unavailable(error.to_string())),
);
}
}
pub fn plans(requests: &[ComposeRequest], dependency_changed: bool) -> Vec<ResourcePlan> {
requests
.iter()
.map(|request| request.plan_with_dependency_change(dependency_changed))
.collect()
}
pub fn apply(requests: &[ComposeRequest], dry_run: bool, yes: bool) -> Result<()> {
apply_with_dry_run_actions(requests, &HashMap::new(), dry_run, yes)
}
pub fn apply_with_dry_run_actions(
requests: &[ComposeRequest],
dry_run_actions: &HashMap<String, ResourceAction>,
dry_run: bool,
yes: bool,
) -> Result<()> {
let mut changes = vec![];
for request in requests {
let action = apply_action(request, dry_run_actions, dry_run);
match action {
ResourceAction::Unknown => bail!(
"refusing unsafe change to bootstrap compose project '{}'; inspect `mise bootstrap plan`",
request.name
),
ResourceAction::Noop => {}
ResourceAction::Create | ResourceAction::Update | ResourceAction::Remove => {
changes.push(request)
}
}
}
if changes.is_empty() {
info!("compose projects: already converged");
return Ok(());
}
if dry_run {
for request in changes {
for argv in request.dry_run_action_argvs()? {
miseprintln!("would run {}", shell_words::join(argv));
}
}
return Ok(());
}
if !yes
&& console::user_attended_stderr()
&& !crate::ui::prompt::confirm(format!(
"compose projects: apply {} change(s)?",
changes.len()
))?
{
info!("compose projects: skipped");
return Ok(());
}
for request in changes {
request.run_action()?;
}
info!("compose projects: applied changes");
Ok(())
}
fn apply_action(
request: &ComposeRequest,
dry_run_actions: &HashMap<String, ResourceAction>,
dry_run: bool,
) -> ResourceAction {
let action = request.plan().action;
if !dry_run {
return action;
}
dry_run_actions
.get(&request.name)
.copied()
.filter(|action| {
matches!(
action,
ResourceAction::Create | ResourceAction::Update | ResourceAction::Remove
)
})
.unwrap_or(action)
}
impl ComposeRequest {
#[cfg(test)]
fn from_toml(name: String, config: ComposeTomlConfig) -> Result<Self> {
Self::from_toml_with_origin(name, config, None)
}
fn from_toml_with_origin(
name: String,
config: ComposeTomlConfig,
origin: Option<ResourceOrigin>,
) -> Result<Self> {
if name.is_empty() {
bail!("bootstrap compose project names cannot be empty");
}
if !config.project_dir.is_absolute() {
bail!(
"bootstrap compose project '{name}' project_dir must be absolute: {}",
config.project_dir.display()
);
}
validate_project_name(config.project_name.as_deref())?;
validate_values("profile", &config.profiles)?;
validate_values("service", &config.services)?;
validate_values("oneshot service", &config.oneshot)?;
validate_command("command", &config.command)?;
validate_command("engine_command", &config.engine_command)?;
if config.state == ComposeState::Absent && !config.services.is_empty() {
bail!(
"bootstrap compose project '{name}' services cannot be combined with state = \"absent\" because compose down removes the entire project"
);
}
if !config.wait && config.wait_timeout.is_some() {
bail!("bootstrap compose project '{name}' wait_timeout requires wait = true");
}
if config.wait_timeout == Some(0) {
bail!("bootstrap compose project '{name}' wait_timeout must be greater than zero");
}
if config.state != ComposeState::Absent
&& (config.down_volumes || config.down_images.is_some())
{
bail!(
"bootstrap compose project '{name}' down_volumes and down_images require state = \"absent\""
);
}
if config.state != ComposeState::Running && config.renew_anonymous_volumes {
bail!(
"bootstrap compose project '{name}' renew_anonymous_volumes requires state = \"running\""
);
}
let explicit_dependencies = config
.depends_on
.iter()
.map(|dependency| parse_dependency(dependency))
.collect::<Result<Vec<_>>>()?;
let services = dedupe(config.services);
let oneshot: IndexSet<String> = dedupe(config.oneshot).into_iter().collect();
if !services.is_empty() && oneshot.iter().any(|service| !services.contains(service)) {
bail!(
"bootstrap compose project '{name}' oneshot services must also appear in services"
);
}
let files = resolve_paths(&config.project_dir, config.files);
let env_files = resolve_paths(&config.project_dir, config.env_files);
let path_dependencies = std::iter::once(ResourceId::new(
"directory",
config.project_dir.to_string_lossy(),
))
.chain(
files
.iter()
.chain(&env_files)
.map(|path| ResourceId::new("file", path.to_string_lossy())),
)
.collect::<IndexSet<_>>()
.into_iter()
.collect();
Ok(Self {
name,
files,
env_files,
project_dir: config.project_dir,
project_name: config.project_name,
profiles: dedupe(config.profiles),
services,
oneshot,
state: config.state,
pull: config.pull,
build: config.build,
recreate: config.recreate,
wait: config.wait,
wait_timeout: config.wait_timeout,
timeout: config.timeout,
remove_orphans: config.remove_orphans,
renew_anonymous_volumes: config.renew_anonymous_volumes,
down_volumes: config.down_volumes,
down_images: config.down_images,
sudo: config.sudo,
command: config.command,
engine_command: config.engine_command,
explicit_dependencies,
path_dependencies,
origin,
inspection: None,
})
}
pub fn explicit_dependencies(&self) -> &[ResourceId] {
&self.explicit_dependencies
}
pub fn path_dependencies(&self) -> &[ResourceId] {
&self.path_dependencies
}
pub fn plan(&self) -> ResourcePlan {
let id = ResourceId::new("compose", &self.name);
let desired = self.desired();
let Some(inspection) = &self.inspection else {
return self.with_origin(ResourcePlan::new(
id,
"not inspected",
desired,
ResourceAction::Unknown,
));
};
let plan = match inspection {
ComposeInspection::Unavailable(reason) => ResourcePlan::new(
id,
format!("unavailable: {reason}"),
desired,
ResourceAction::Unknown,
),
ComposeInspection::Present {
configured_services,
target_services,
containers,
config_hashes,
} => {
let selected = containers
.iter()
.filter(|container| target_services.contains(&container.service))
.collect::<Vec<_>>();
let action = match self.state {
ComposeState::Running if selected.is_empty() => ResourceAction::Create,
ComposeState::Running
if self.running_is_converged(
configured_services,
target_services,
containers,
config_hashes,
) =>
{
ResourceAction::Noop
}
ComposeState::Running => ResourceAction::Update,
ComposeState::Stopped
if selected
.iter()
.any(|container| container_is_active(container))
|| (self.remove_orphans
&& containers.iter().any(|container| {
!configured_services.contains(&container.service)
})) =>
{
ResourceAction::Update
}
ComposeState::Stopped => ResourceAction::Noop,
ComposeState::Absent
if selected.is_empty()
&& (containers.is_empty() || !self.remove_orphans) =>
{
ResourceAction::Noop
}
ComposeState::Absent => ResourceAction::Remove,
};
ResourcePlan::new(
id,
describe_current(target_services, containers),
desired,
action,
)
}
};
self.with_origin(plan)
}
fn with_origin(&self, plan: ResourcePlan) -> ResourcePlan {
match &self.origin {
Some(origin) => plan.with_origin(origin.clone()),
None => plan,
}
}
pub fn plan_with_dependency_change(&self, dependency_changed: bool) -> ResourcePlan {
let mut plan = self.plan();
if dependency_changed && self.state == ComposeState::Running {
match plan.action {
ResourceAction::Noop => plan.action = ResourceAction::Update,
ResourceAction::Unknown => {
plan.current = format!("{}; pending bootstrap dependency change", plan.current);
plan.action = ResourceAction::Create;
}
_ => {}
}
}
plan
}
fn inspect(&self) -> Result<ComposeInspection> {
let configured_services = self
.compose_output(&["config".to_string(), "--services".to_string()])?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect::<IndexSet<_>>();
let target_services = if self.services.is_empty() {
configured_services.clone()
} else {
let missing = self
.services
.iter()
.filter(|service| !configured_services.contains(*service))
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
bail!(
"services not found in compose config: {}",
missing.join(", ")
);
}
self.services.iter().cloned().collect()
};
let config_hashes = if self.state == ComposeState::Running {
parse_config_hashes(
&self.compose_output(&["config".to_string(), "--hash=*".to_string()])?,
)?
} else {
HashMap::new()
};
let mut containers = parse_ps(&self.compose_output(&[
"ps".to_string(),
"--all".to_string(),
"--format".to_string(),
"json".to_string(),
])?)?;
if self.state == ComposeState::Running {
self.add_container_hashes(&mut containers)?;
}
Ok(ComposeInspection::Present {
configured_services,
target_services,
containers,
config_hashes,
})
}
fn running_is_converged(
&self,
configured_services: &IndexSet<String>,
target_services: &IndexSet<String>,
containers: &[ComposeContainer],
config_hashes: &HashMap<String, String>,
) -> bool {
if self.remove_orphans
&& containers
.iter()
.any(|container| !configured_services.contains(&container.service))
{
return false;
}
target_services.iter().all(|service| {
let service_containers = containers
.iter()
.filter(|container| &container.service == service)
.collect::<Vec<_>>();
!service_containers.is_empty()
&& service_containers.iter().all(|container| {
let runtime_ok = if self.oneshot.contains(service) {
(container.state == "exited" && container.exit_code == 0)
|| container_is_ready(container)
} else {
container_is_ready(container)
};
runtime_ok
&& config_hashes.get(service).is_some_and(|desired_hash| {
container.config_hash.as_ref() == Some(desired_hash)
})
})
})
}
fn add_container_hashes(&self, containers: &mut [ComposeContainer]) -> Result<()> {
if containers.is_empty() {
return Ok(());
}
let ids = containers
.iter()
.map(|container| container.id.clone())
.collect::<Vec<_>>();
let mut args = vec![
"inspect".to_string(),
"--format".to_string(),
"{{json .Config.Labels}}".to_string(),
];
args.extend(ids);
let output = self.engine_output(&args)?;
let lines = output.lines().collect::<Vec<_>>();
if lines.len() != containers.len() {
bail!("container inspection returned an unexpected number of rows");
}
for (container, line) in containers.iter_mut().zip(lines) {
let labels: HashMap<String, String> = serde_json::from_str(line)?;
container.config_hash = labels.get("com.docker.compose.config-hash").cloned();
}
Ok(())
}
fn desired(&self) -> String {
match self.state {
ComposeState::Running => format!(
"running{}; pull {:?}; build {:?}; recreate {:?}; wait {}",
selected_suffix(&self.services),
self.pull,
self.build,
self.recreate,
self.wait
)
.to_lowercase(),
ComposeState::Stopped => format!("stopped{}", selected_suffix(&self.services)),
ComposeState::Absent => format!("absent{}", selected_suffix(&self.services)),
}
}
fn dry_run_action_argvs(&self) -> Result<Vec<Vec<String>>> {
Ok(self.commands_to_argvs(self.dry_run_action_commands()?))
}
fn commands_to_argvs(&self, commands: Vec<(String, Vec<String>)>) -> Vec<Vec<String>> {
commands
.into_iter()
.map(|(program, args)| {
if self.sudo {
crate::system::sudo::argv(&program, &args)
} else {
std::iter::once(program).chain(args).collect()
}
})
.collect()
}
fn run_action(&self) -> Result<()> {
for (program, args) in self.action_commands()? {
self.run_command(&program, &args)?;
}
Ok(())
}
fn action_commands(&self) -> Result<Vec<(String, Vec<String>)>> {
let (program, args) = self.compose_command()?;
Ok(self.action_commands_with(program, args, self.stopped_orphan_removal_command()?))
}
fn dry_run_action_commands(&self) -> Result<Vec<(String, Vec<String>)>> {
let (program, args) = self.dry_run_compose_command()?;
Ok(self.action_commands_with(
program,
args,
self.dry_run_stopped_orphan_removal_command()?,
))
}
fn action_commands_with(
&self,
program: String,
mut args: Vec<String>,
orphan_removal: Option<(String, Vec<String>)>,
) -> Vec<(String, Vec<String>)> {
args.extend(self.action_args());
let mut commands = vec![(program, args)];
if let Some(command) = orphan_removal {
commands.push(command);
}
commands
}
fn run_command(&self, program: &str, args: &[String]) -> Result<()> {
if self.sudo {
crate::system::sudo::run(program, args, &compose_env())
} else {
info!("$ {} {}", program, shell_words::join(args));
let status = Command::new(program)
.args(args)
.envs(compose_env())
.status()?;
if !status.success() {
bail!("compose command failed with {status}");
}
Ok(())
}
}
fn stopped_orphan_removal_command(&self) -> Result<Option<(String, Vec<String>)>> {
let Some(args) = self.stopped_orphan_removal_args() else {
return Ok(None);
};
let command = self.resolved_engine_command()?;
command_with_args(command, args).map(Some)
}
fn dry_run_stopped_orphan_removal_command(&self) -> Result<Option<(String, Vec<String>)>> {
let Some(args) = self.stopped_orphan_removal_args() else {
return Ok(None);
};
command_with_args(self.configured_engine_command(), args).map(Some)
}
fn stopped_orphan_removal_args(&self) -> Option<Vec<String>> {
if self.state != ComposeState::Stopped || !self.remove_orphans {
return None;
}
let Some(ComposeInspection::Present {
configured_services,
containers,
..
}) = &self.inspection
else {
return None;
};
let orphan_ids = containers
.iter()
.filter(|container| !configured_services.contains(&container.service))
.map(|container| container.id.clone())
.collect::<Vec<_>>();
if orphan_ids.is_empty() {
return None;
}
let mut args = vec![];
args.extend(["rm".to_string(), "--force".to_string()]);
args.extend(orphan_ids);
Some(args)
}
fn action_args(&self) -> Vec<String> {
let mut args = match self.state {
ComposeState::Running => {
let mut args = vec![
"up".to_string(),
"--detach".to_string(),
"--pull".to_string(),
match self.pull {
ComposePullPolicy::Always => "always",
ComposePullPolicy::Missing => "missing",
ComposePullPolicy::Never => "never",
}
.to_string(),
];
match self.build {
ComposeBuildPolicy::Auto => {}
ComposeBuildPolicy::Always => args.push("--build".to_string()),
ComposeBuildPolicy::Never => args.push("--no-build".to_string()),
}
match self.recreate {
ComposeRecreatePolicy::Auto => {}
ComposeRecreatePolicy::Always => args.push("--force-recreate".to_string()),
ComposeRecreatePolicy::Never => args.push("--no-recreate".to_string()),
}
if self.wait {
args.push("--wait".to_string());
}
if let Some(timeout) = self.wait_timeout {
args.extend(["--wait-timeout".to_string(), timeout.to_string()]);
}
if let Some(timeout) = self.timeout {
args.extend(["--timeout".to_string(), timeout.to_string()]);
}
if self.remove_orphans {
args.push("--remove-orphans".to_string());
}
if self.renew_anonymous_volumes {
args.push("--renew-anon-volumes".to_string());
}
args
}
ComposeState::Stopped => {
let mut args = vec!["stop".to_string()];
if let Some(timeout) = self.timeout {
args.extend(["--timeout".to_string(), timeout.to_string()]);
}
args
}
ComposeState::Absent => {
let mut args = vec!["down".to_string()];
if let Some(timeout) = self.timeout {
args.extend(["--timeout".to_string(), timeout.to_string()]);
}
if self.remove_orphans {
args.push("--remove-orphans".to_string());
}
if self.down_volumes {
args.push("--volumes".to_string());
}
if let Some(images) = self.down_images {
args.extend([
"--rmi".to_string(),
match images {
ComposeRemoveImages::Local => "local",
ComposeRemoveImages::All => "all",
}
.to_string(),
]);
}
args
}
};
if self.state != ComposeState::Absent {
args.extend(self.services.iter().cloned());
}
args
}
fn compose_output(&self, command_args: &[String]) -> Result<String> {
let (program, mut args) = self.compose_command()?;
args.extend(command_args.iter().cloned());
command_output(&program, &args, self.sudo)
}
fn engine_output(&self, args: &[String]) -> Result<String> {
let command = self.resolved_engine_command()?;
let (program, prefix) = command
.split_first()
.ok_or_else(|| eyre!("resolved engine command is empty"))?;
let mut command_args = prefix.to_vec();
command_args.extend(args.iter().cloned());
command_output(program, &command_args, self.sudo)
}
fn compose_command(&self) -> Result<(String, Vec<String>)> {
let command = self.resolved_compose_command()?;
self.compose_command_from(command)
}
fn dry_run_compose_command(&self) -> Result<(String, Vec<String>)> {
let command = if self.command.is_empty() {
self.detected_compose_command()?
.unwrap_or_else(default_compose_command)
} else {
resolve_command(&self.command)?
};
self.compose_command_from(command)
}
fn compose_command_from(&self, command: Vec<String>) -> Result<(String, Vec<String>)> {
let (program, prefix) = command
.split_first()
.ok_or_else(|| eyre!("resolved compose command is empty"))?;
let mut args = prefix.to_vec();
args.extend([
"--project-directory".to_string(),
self.project_dir.to_string_lossy().to_string(),
]);
if let Some(project_name) = &self.project_name {
args.extend(["--project-name".to_string(), project_name.clone()]);
}
for file in &self.files {
args.extend(["--file".to_string(), file.to_string_lossy().to_string()]);
}
for env_file in &self.env_files {
args.extend([
"--env-file".to_string(),
env_file.to_string_lossy().to_string(),
]);
}
for profile in &self.profiles {
args.extend(["--profile".to_string(), profile.clone()]);
}
Ok((program.clone(), args))
}
fn resolved_compose_command(&self) -> Result<Vec<String>> {
if !self.command.is_empty() {
return resolve_command(&self.command);
}
self.detected_compose_command()?
.ok_or_else(|| eyre!("neither 'docker compose' nor 'docker-compose' was found"))
}
fn detected_compose_command(&self) -> Result<Option<Vec<String>>> {
if let Some(docker) = crate::file::which("docker") {
let docker = docker.to_string_lossy().to_string();
if compose_plugin_available(&docker) {
return Ok(Some(vec![docker, "compose".to_string()]));
}
}
if let Some(compose) = crate::file::which("docker-compose") {
let compose = compose.to_string_lossy().to_string();
if standalone_compose_v2_available(&compose) {
return Ok(Some(vec![compose]));
}
bail!(
"legacy 'docker-compose' v1 is unsupported; install Docker Compose v2 or set command to a compatible frontend"
);
}
Ok(None)
}
fn resolved_engine_command(&self) -> Result<Vec<String>> {
if !self.engine_command.is_empty() {
return resolve_command(&self.engine_command);
}
if let Some(command) = engine_command_from_compose_command(&self.command) {
return resolve_command(command);
}
if let Some(docker) = crate::file::which("docker") {
return Ok(vec![docker.to_string_lossy().to_string()]);
}
bail!("container engine command 'docker' was not found; set engine_command explicitly")
}
fn configured_engine_command(&self) -> Vec<String> {
if !self.engine_command.is_empty() {
return self.engine_command.clone();
}
engine_command_from_compose_command(&self.command)
.map(<[String]>::to_vec)
.unwrap_or_else(|| vec!["docker".to_string()])
}
}
fn compose_plugin_available(docker: &str) -> bool {
Command::new(docker)
.args(["compose", "version"])
.envs(compose_env())
.output()
.is_ok_and(|output| output.status.success())
}
fn standalone_compose_v2_available(compose: &str) -> bool {
Command::new(compose)
.args(["version", "--short"])
.envs(compose_env())
.output()
.is_ok_and(|output| {
output.status.success()
&& String::from_utf8(output.stdout)
.ok()
.is_some_and(|version| is_compose_v2_version(&version))
})
}
fn is_compose_v2_version(version: &str) -> bool {
version.split_whitespace().find_map(|part| {
let version = part.trim_start_matches('v');
version.split_once('.').map(|(major, _)| major)
}) == Some("2")
}
fn engine_command_from_compose_command(command: &[String]) -> Option<&[String]> {
let compose = command.iter().position(|part| part == "compose")?;
(compose > 0).then_some(&command[..compose])
}
fn command_output(program: &str, args: &[String], sudo: bool) -> Result<String> {
let env = compose_env();
let output = if sudo {
crate::system::sudo::output(program, args, &env)?
} else {
info!("$ {} {}", program, shell_words::join(args));
Command::new(program).args(args).envs(env).output()?
};
checked_stdout(output, program, args)
}
fn checked_stdout(output: Output, program: &str, args: &[String]) -> Result<String> {
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"{} {} failed: {}",
program,
shell_words::join(args),
if error.is_empty() {
output.status.to_string()
} else {
error
}
);
}
Ok(String::from_utf8(output.stdout)?)
}
fn command_with_args(command: Vec<String>, args: Vec<String>) -> Result<(String, Vec<String>)> {
let (program, prefix) = command
.split_first()
.ok_or_else(|| eyre!("resolved engine command is empty"))?;
Ok((
program.clone(),
prefix.iter().cloned().chain(args).collect(),
))
}
fn parse_ps(output: &str) -> Result<Vec<ComposeContainer>> {
let output = output.trim();
if output.is_empty() {
return Ok(vec![]);
}
if output.starts_with('[') {
return Ok(serde_json::from_str(output)?);
}
output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| Ok(serde_json::from_str(line)?))
.collect()
}
fn parse_config_hashes(output: &str) -> Result<HashMap<String, String>> {
output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut fields = line.split_whitespace();
let service = fields
.next()
.ok_or_else(|| eyre!("compose config hash row is missing a service"))?;
let hash = fields.next().ok_or_else(|| {
eyre!("compose config hash row for '{service}' is missing a hash")
})?;
if fields.next().is_some() {
bail!("unexpected compose config hash row: {line}");
}
Ok((service.to_string(), hash.to_string()))
})
.collect()
}
fn container_is_ready(container: &ComposeContainer) -> bool {
container.state == "running" && matches!(container.health.as_str(), "" | "healthy")
}
fn container_is_active(container: &ComposeContainer) -> bool {
matches!(
container.state.as_str(),
"running" | "restarting" | "paused"
)
}
fn describe_current(target_services: &IndexSet<String>, containers: &[ComposeContainer]) -> String {
let selected = containers
.iter()
.filter(|container| target_services.contains(&container.service))
.collect::<Vec<_>>();
let running = selected
.iter()
.filter(|container| container.state == "running")
.count();
let healthy = selected
.iter()
.filter(|container| container.health == "healthy")
.count();
let unhealthy = selected
.iter()
.filter(|container| container.health == "unhealthy")
.count();
format!(
"{} container(s); {running} running; {healthy} healthy; {unhealthy} unhealthy",
selected.len()
)
}
fn selected_suffix(services: &[String]) -> String {
if services.is_empty() {
String::new()
} else {
format!(" ({})", services.join(", "))
}
}
fn resolve_paths(project_dir: &Path, paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths
.into_iter()
.map(|path| {
if path.is_absolute() {
path
} else {
project_dir.join(path)
}
})
.collect()
}
fn resolve_command(command: &[String]) -> Result<Vec<String>> {
let (program, args) = command
.split_first()
.ok_or_else(|| eyre!("command cannot be empty"))?;
let resolved = if Path::new(program).is_absolute() {
let path = PathBuf::from(program);
if !path.is_file() {
bail!("command does not exist: {}", path.display());
}
path
} else {
crate::file::which(program).ok_or_else(|| eyre!("command not found: {program}"))?
};
Ok(std::iter::once(resolved.to_string_lossy().to_string())
.chain(args.iter().cloned())
.collect())
}
fn validate_project_name(name: Option<&str>) -> Result<()> {
let Some(name) = name else {
return Ok(());
};
if name.is_empty()
|| !name
.chars()
.next()
.is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
|| !name.chars().all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || "-_".contains(character)
})
{
bail!(
"invalid compose project_name '{name}': use lowercase letters, digits, dashes, and underscores"
);
}
Ok(())
}
fn parse_dependency(value: &str) -> Result<ResourceId> {
let Some((kind, name)) = value.split_once(':') else {
bail!("invalid compose dependency '{value}': expected '<kind>:<name>'");
};
if name.is_empty()
|| !matches!(
kind,
"package" | "file" | "directory" | "service" | "user" | "group"
)
{
bail!(
"invalid compose dependency '{value}': supported kinds are package, file, directory, service, user, and group"
);
}
Ok(ResourceId::new(kind, name))
}
fn validate_values(kind: &str, values: &[String]) -> Result<()> {
if let Some(value) = values
.iter()
.find(|value| value.is_empty() || value.starts_with('-') || value.contains('\0'))
{
bail!("invalid compose {kind} value '{value}'");
}
Ok(())
}
fn validate_command(kind: &str, command: &[String]) -> Result<()> {
if command
.iter()
.any(|part| part.is_empty() || part.contains('\0'))
{
bail!("compose {kind} cannot contain empty values or NUL bytes");
}
Ok(())
}
fn dedupe(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.collect::<IndexSet<_>>()
.into_iter()
.collect()
}
fn compose_env() -> Vec<(String, String)> {
vec![
("COMPOSE_ANSI".to_string(), "never".to_string()),
("COMPOSE_PROGRESS".to_string(), "plain".to_string()),
]
}
fn default_true() -> bool {
true
}
fn default_compose_command() -> Vec<String> {
vec!["docker".to_string(), "compose".to_string()]
}
#[cfg(test)]
mod tests {
use super::*;
fn request(state: ComposeState) -> ComposeRequest {
ComposeRequest::from_toml(
"cache".to_string(),
ComposeTomlConfig {
project_dir: std::env::temp_dir().join("mise-cache"),
files: vec![PathBuf::from("compose.yaml")],
env_files: vec![],
project_name: Some("mise-cache".to_string()),
profiles: vec![],
services: vec![],
oneshot: vec![],
state,
pull: ComposePullPolicy::Missing,
build: ComposeBuildPolicy::Auto,
recreate: ComposeRecreatePolicy::Auto,
wait: true,
wait_timeout: Some(120),
timeout: Some(30),
remove_orphans: true,
renew_anonymous_volumes: false,
down_volumes: false,
down_images: None,
sudo: true,
command: vec!["docker".to_string(), "compose".to_string()],
engine_command: vec!["docker".to_string()],
depends_on: vec![],
},
)
.unwrap()
}
#[test]
fn parses_json_lines_and_arrays() {
let line =
r#"{"ID":"abc","Service":"api","State":"running","Health":"healthy","ExitCode":0}"#;
assert_eq!(parse_ps(line).unwrap().len(), 1);
assert_eq!(parse_ps(&format!("[{line}]")).unwrap().len(), 1);
}
#[test]
fn plans_runtime_and_config_hash_drift() {
let mut request = request(ComposeState::Running);
let container = ComposeContainer {
id: "abc".to_string(),
service: "api".to_string(),
state: "running".to_string(),
health: "healthy".to_string(),
exit_code: 0,
config_hash: Some("same".to_string()),
};
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![container.clone()],
config_hashes: HashMap::from([("api".to_string(), "same".to_string())]),
});
assert_eq!(request.plan().action, ResourceAction::Noop);
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![container],
config_hashes: HashMap::from([("api".to_string(), "changed".to_string())]),
});
assert_eq!(request.plan().action, ResourceAction::Update);
}
#[test]
fn dry_run_uses_dependency_driven_changes_for_converged_projects() {
let mut request = request(ComposeState::Running);
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![ComposeContainer {
id: "abc".to_string(),
service: "api".to_string(),
state: "running".to_string(),
health: "healthy".to_string(),
exit_code: 0,
config_hash: Some("same".to_string()),
}],
config_hashes: HashMap::from([("api".to_string(), "same".to_string())]),
});
assert_eq!(request.plan().action, ResourceAction::Noop);
assert_eq!(
apply_action(
&request,
&HashMap::from([("cache".to_string(), ResourceAction::Update)]),
true,
),
ResourceAction::Update,
);
}
#[test]
fn removes_orphans_from_absent_projects() {
let mut request = request(ComposeState::Absent);
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![ComposeContainer {
id: "old".to_string(),
service: "removed".to_string(),
state: "running".to_string(),
health: String::new(),
exit_code: 0,
config_hash: None,
}],
config_hashes: HashMap::new(),
});
assert_eq!(request.plan().action, ResourceAction::Remove);
}
#[test]
fn removes_orphans_from_stopped_projects() {
let mut request = request(ComposeState::Stopped);
let executable = std::env::current_exe()
.unwrap()
.to_string_lossy()
.to_string();
request.command = vec![executable.clone(), "compose".to_string()];
request.engine_command = vec![executable.clone()];
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![
ComposeContainer {
id: "api-1".to_string(),
service: "api".to_string(),
state: "exited".to_string(),
health: String::new(),
exit_code: 0,
config_hash: None,
},
ComposeContainer {
id: "old-1".to_string(),
service: "removed".to_string(),
state: "running".to_string(),
health: String::new(),
exit_code: 0,
config_hash: None,
},
],
config_hashes: HashMap::new(),
});
assert_eq!(request.plan().action, ResourceAction::Update);
let commands = request.action_commands().unwrap();
assert_eq!(commands[1].0, executable);
assert_eq!(
commands[1].1,
vec!["rm".to_string(), "--force".to_string(), "old-1".to_string()]
);
}
#[test]
fn dry_run_removes_orphans_before_the_engine_is_installed() {
let mut request = request(ComposeState::Stopped);
let executable = std::env::current_exe()
.unwrap()
.to_string_lossy()
.to_string();
request.command = vec![executable, "compose".to_string()];
request.engine_command = vec!["engine-not-installed-yet".to_string()];
request.inspection = Some(ComposeInspection::Present {
configured_services: IndexSet::from(["api".to_string()]),
target_services: IndexSet::from(["api".to_string()]),
containers: vec![ComposeContainer {
id: "old-1".to_string(),
service: "removed".to_string(),
state: "running".to_string(),
health: String::new(),
exit_code: 0,
config_hash: None,
}],
config_hashes: HashMap::new(),
});
let commands = request.dry_run_action_commands().unwrap();
assert_eq!(commands[1].0, "engine-not-installed-yet");
assert_eq!(commands[1].1, ["rm", "--force", "old-1"]);
}
#[test]
fn builds_full_lifecycle_commands() {
let running = request(ComposeState::Running);
let args = running.action_args();
assert!(args.starts_with(&["up".to_string(), "--detach".to_string()]));
assert!(args.contains(&"--wait".to_string()));
assert!(args.contains(&"--remove-orphans".to_string()));
let mut request = request(ComposeState::Absent);
request.down_volumes = true;
request.down_images = Some(ComposeRemoveImages::Local);
assert_eq!(
request.action_args(),
vec![
"down",
"--timeout",
"30",
"--remove-orphans",
"--volumes",
"--rmi",
"local",
]
);
request.services = vec!["api".to_string()];
assert!(!request.action_args().contains(&"api".to_string()));
}
#[test]
fn derives_engine_command_with_global_options() {
let command = [
"docker".to_string(),
"--context".to_string(),
"remote".to_string(),
"compose".to_string(),
];
assert_eq!(
engine_command_from_compose_command(&command),
Some(&command[..3])
);
assert_eq!(engine_command_from_compose_command(&command[..3]), None);
}
#[test]
fn recognizes_only_standalone_compose_v2() {
assert!(is_compose_v2_version("2.40.3\n"));
assert!(is_compose_v2_version("v2.40.3\n"));
assert!(is_compose_v2_version("Docker Compose version v2.40.3"));
assert!(!is_compose_v2_version("1.29.2\n"));
assert!(!is_compose_v2_version("Docker Compose version 1.29.2"));
assert!(!is_compose_v2_version("unknown"));
}
#[test]
fn dry_run_prints_default_compose_command_before_it_is_installed() {
let mut request = request(ComposeState::Running);
request.command.clear();
request.engine_command.clear();
request.inspection = Some(ComposeInspection::Unavailable(
"neither 'docker compose' nor 'docker-compose' was found".to_string(),
));
assert_eq!(default_compose_command(), ["docker", "compose"]);
request.command = vec!["mise-compose-command-that-does-not-exist".to_string()];
assert!(
request
.dry_run_action_commands()
.unwrap_err()
.to_string()
.contains("command not found")
);
}
}