mod commands;
mod scale;
mod targets;
use std::collections::{HashMap, HashSet};
use tracing::info;
use crate::compose::types::{ComposeFile, ServiceCondition};
use crate::error::Result;
use crate::libpod::API_PREFIX;
use targets::{expand_targets, filter_services, in_started_set};
use super::container::config_hash;
use super::network::resolve_network_name;
use super::profiles::{active_profiles_set, service_in_profiles};
use super::Engine;
#[derive(Default)]
pub struct RunOptions {
pub cmd: Vec<String>,
pub rm: bool,
pub detach: bool,
pub env_overrides: Vec<String>,
pub name_override: Option<String>,
pub service_ports: bool,
}
#[derive(Default, Clone)]
pub struct RunOverrides {
pub user: Option<String>,
pub workdir: Option<String>,
pub entrypoint: Option<String>,
pub volumes: Vec<String>,
pub publish: Vec<String>,
pub interactive: bool,
pub no_deps: bool,
}
impl Engine {
pub async fn up(&self, file: &ComposeFile) -> Result<()> {
self.up_with_options(file, false, &[], &[], false, false, false)
.await
}
async fn ensure_started(&self, container_name: &str) {
let path = format!(
"{API_PREFIX}/containers/{}/start",
crate::libpod::urlencoded(container_name)
);
let _ = self.client.post_empty_ok(&path).await;
}
#[allow(clippy::too_many_arguments)]
pub async fn up_with_options(
&self,
file: &ComposeFile,
_detach: bool,
active_profiles: &[String],
target_services: &[String],
no_recreate: bool,
force_recreate: bool,
no_deps: bool,
) -> Result<()> {
self.run_up(
file,
active_profiles,
target_services,
no_recreate,
force_recreate,
no_deps,
true,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_with_options(
&self,
file: &ComposeFile,
active_profiles: &[String],
target_services: &[String],
no_recreate: bool,
force_recreate: bool,
no_deps: bool,
) -> Result<()> {
self.run_up(
file,
active_profiles,
target_services,
no_recreate,
force_recreate,
no_deps,
false,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_up(
&self,
file: &ComposeFile,
active_profiles: &[String],
target_services: &[String],
no_recreate: bool,
force_recreate: bool,
no_deps: bool,
start: bool,
) -> Result<()> {
async {
let levels = crate::compose::resolve_levels(file)?;
let active = active_profiles_set(active_profiles);
let target_set = expand_targets(file, target_services, no_deps);
let mut present: HashSet<String> = HashSet::new();
let mut existing_hash: HashMap<String, String> = HashMap::new();
if !force_recreate {
let filters = serde_json::json!({
"label": [format!("podup.project={}", self.project)],
});
let path = format!(
"{API_PREFIX}/containers/json?all=true&filters={}",
crate::libpod::urlencoded(&filters.to_string()),
);
let entries = self
.client
.get_json::<Vec<crate::libpod::types::container::ContainerListEntry>>(&path)
.await
.map_err(crate::error::ComposeError::Podman)?;
for entry in entries {
if let Some(hash) = entry.labels.get("podup.config-hash") {
for raw in &entry.names {
existing_hash
.insert(raw.trim_start_matches('/').to_string(), hash.clone());
}
}
for raw in entry.names {
present.insert(raw.trim_start_matches('/').to_string());
}
}
}
self.create_networks(file).await?;
self.create_volumes(file).await?;
for level in &levels {
let started = level.iter().map(|name| {
self.up_one_service(
name,
file,
&active,
&target_set,
&present,
&existing_hash,
no_recreate,
force_recreate,
start,
)
});
futures_util::future::try_join_all(started).await?;
}
Ok(())
}
.await
}
#[allow(clippy::too_many_arguments)]
async fn up_one_service(
&self,
name: &str,
file: &ComposeFile,
active: &HashSet<String>,
target_set: &Option<HashSet<String>>,
present: &HashSet<String>,
existing_hash: &HashMap<String, String>,
no_recreate: bool,
force_recreate: bool,
start: bool,
) -> Result<()> {
if let Some(set) = target_set {
if !set.contains(name) {
return Ok(());
}
}
let service = &file.services[name];
if !service_in_profiles(service, active) {
tracing::debug!("skipping {name}: no active profile match");
return Ok(());
}
for dep in service
.depends_on
.service_names()
.into_iter()
.filter(|_| start)
{
if !in_started_set(target_set, &dep) {
tracing::debug!("{dep} not in started target set — skipping {name} readiness wait");
continue;
}
let condition = service.depends_on.condition_for(&dep);
let required = service.depends_on.required_for(&dep);
let dep_service = match file.services.get(&dep) {
Some(s) => s,
None => continue,
};
if !service_in_profiles(dep_service, active) {
continue;
}
let dep_container = self.container_name(&dep, dep_service);
let wait = match condition {
ServiceCondition::ServiceStarted => Ok(()),
ServiceCondition::ServiceHealthy => {
let disabled = dep_service
.healthcheck
.as_ref()
.is_some_and(|h| h.is_disabled());
if disabled {
tracing::debug!(
"{dep} healthcheck disabled — skipping service_healthy wait"
);
Ok(())
} else {
self.wait_healthy(&dep_container, dep_service).await
}
}
ServiceCondition::ServiceCompletedSuccessfully => {
self.wait_completed(&dep_container).await
}
};
match wait {
Ok(()) => {}
Err(e) if !required => {
tracing::debug!(
"optional dependency {dep} (required: false) did not satisfy its condition: {e}"
);
}
Err(e) => return Err(e),
}
}
let policy = self
.pull_policy_override
.as_deref()
.or(service.pull_policy.as_deref())
.unwrap_or("missing");
match (service.build.is_some() && !self.no_build, policy) {
(true, _) => {
self.build_service(name, service, file, &crate::engine::BuildOptions::default())
.await?
}
(false, "never") => {}
(false, _) => self.pull_image(service).await?,
}
let replicas = self.resolve_replicas(name, service);
scale::check_scale_port_conflict(name, service, replicas)?;
let new_hash = config_hash(service, file)?;
for i in 1..=replicas {
let container_name = if replicas <= 1 {
self.container_name(name, service)
} else {
format!("{}-{i}", self.container_name(name, service))
};
if !force_recreate {
if no_recreate && present.contains(&container_name) {
info!("{container_name} already exists — skipping recreate");
if start {
self.ensure_started(&container_name).await;
}
continue;
}
if service.build.is_none() && existing_hash.get(&container_name) == Some(&new_hash)
{
info!("{container_name} is up to date — skipping recreate");
if start {
self.ensure_started(&container_name).await;
}
continue;
}
}
self.create_and_start(&container_name, name, service, file, start)
.await?;
info!(
"{} {container_name}",
if start { "started" } else { "created" }
);
if start {
for hook in &service.post_start {
self.run_lifecycle_hook(&container_name, hook).await?;
}
}
}
Ok(())
}
pub async fn down(&self, file: &ComposeFile) -> Result<()> {
self.down_with_options(file, false).await
}
pub async fn down_with_options(&self, file: &ComposeFile, remove_volumes: bool) -> Result<()> {
let mut order = crate::compose::resolve_order(file)?;
order.reverse();
for name in &order {
let service = &file.services[name];
for container_name in self.replica_names(name, service) {
for hook in &service.pre_stop {
if let Err(e) = self.run_lifecycle_hook(&container_name, hook).await {
tracing::debug!("pre_stop hook {container_name}: {e}");
}
}
let grace = self.grace_period_secs(service);
let stop_path = format!(
"{API_PREFIX}/containers/{}/stop?t={grace}",
crate::libpod::urlencoded(&container_name),
);
if let Err(e) = self.client.post_empty_ok(&stop_path).await {
tracing::debug!("stop {container_name}: {e}");
}
let rm_path = format!(
"{API_PREFIX}/containers/{}?force=true",
crate::libpod::urlencoded(&container_name),
);
if let Err(e) = self.client.delete_ok(&rm_path).await {
tracing::debug!("down delete {container_name}: {e}");
}
info!("removed {container_name}");
}
}
let grace = self.stop_timeout.unwrap_or(10);
for name in self.list_project_container_names(None).await? {
self.stop_and_remove(&name, grace).await;
}
for (key, config) in &file.networks {
let external = config.as_ref().and_then(|c| c.external).unwrap_or(false);
if external {
continue;
}
let network_name = resolve_network_name(key, file, &self.project);
let net_path = format!(
"{API_PREFIX}/networks/{}",
crate::libpod::urlencoded(&network_name),
);
match self.client.delete_ok(&net_path).await {
Ok(_) => info!("removed network {network_name}"),
Err(e) if e.is_status(404) => {}
Err(e) => tracing::warn!("could not remove network {network_name}: {e}"),
}
}
if remove_volumes {
for (key, config) in &file.volumes {
let external = config.as_ref().and_then(|c| c.external).unwrap_or(false);
if external {
continue;
}
let volume_name = config
.as_ref()
.and_then(|c| c.name.as_deref())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}_{}", self.project, key));
let vol_path = format!(
"{API_PREFIX}/volumes/{}",
crate::libpod::urlencoded(&volume_name),
);
match self.client.delete_ok(&vol_path).await {
Ok(_) => info!("removed volume {volume_name}"),
Err(e) if e.is_status(404) => {}
Err(e) => tracing::warn!("could not remove volume {volume_name}: {e}"),
}
}
}
self.remove_internal_secrets(file).await?;
Ok(())
}
pub async fn remove_service_images(&self, file: &ComposeFile, local_only: bool) -> Result<()> {
for (name, service) in &file.services {
let builds_locally = service.build.is_some();
if local_only && !builds_locally {
continue;
}
let image = match &service.image {
Some(img) => img.clone(),
None if builds_locally => format!("{name}:latest"),
None => continue,
};
let path = format!(
"{API_PREFIX}/images/{}?force=true",
crate::libpod::urlencoded(&image),
);
match self.client.delete_ok(&path).await {
Ok(_) => info!("removed image {image}"),
Err(e) if e.is_status(404) => {}
Err(e) => tracing::warn!("could not remove image {image}: {e}"),
}
}
Ok(())
}
}