use crate::compose::types::ComposeFile;
use crate::error::Result;
use crate::libpod::{urlencoded, API_PREFIX};
use super::Engine;
const DEFAULT_STOP_GRACE_SECS: i32 = 10;
impl Engine {
pub async fn down_by_label(&self, remove_volumes: bool) -> Result<()> {
let grace = self.stop_timeout.unwrap_or(DEFAULT_STOP_GRACE_SECS);
let mut first_err: Option<crate::error::ComposeError> = None;
let containers = self.list_project_container_names(None).await?;
let futs = containers.iter().map(|container_name| {
self.teardown_one_container(container_name, grace, &[], remove_volumes)
});
if let Some(e) = super::parallel::first_error(super::parallel::join_bounded(futs).await) {
first_err.get_or_insert(e);
}
if let Some(e) = self.remove_project_networks_by_label().await {
first_err.get_or_insert(e);
}
if remove_volumes {
if let Some(e) = self.remove_project_volumes_by_label().await {
first_err.get_or_insert(e);
}
}
self.remove_internal_secrets(&ComposeFile::default())
.await?;
if let Some(e) = first_err {
return Err(e);
}
Ok(())
}
pub(super) async fn remove_project_networks_by_label(
&self,
) -> Option<crate::error::ComposeError> {
let net_filters =
serde_json::json!({ "label": [format!("podup.project={}", self.project)] });
let list_path = format!(
"{API_PREFIX}/networks/json?filters={}",
urlencoded(&net_filters.to_string()),
);
let Ok(nets) = self
.client
.get_json::<Vec<serde_json::Value>>(&list_path)
.await
else {
return None;
};
let mut first_err = None;
for net in nets {
let Some(net_name) = net.get("name").and_then(|n| n.as_str()) else {
continue;
};
let del = format!("{API_PREFIX}/networks/{}", urlencoded(net_name));
match self.client.delete_ok(&del).await {
Ok(_) => crate::ui::progress_line("Network", net_name, "Removed"),
Err(e) if e.is_status(404) => {}
Err(e) => {
tracing::warn!("could not remove network {net_name}: {e}");
first_err.get_or_insert(crate::error::ComposeError::Podman(e));
}
}
}
first_err
}
pub(super) async fn remove_project_volumes_by_label(
&self,
) -> Option<crate::error::ComposeError> {
let path = format!("{API_PREFIX}/volumes/json");
let Ok(vols) = self.client.get_json::<Vec<serde_json::Value>>(&path).await else {
return None;
};
let mut first_err = None;
for vol in vols {
if !volume_owned_by(&vol, &self.project) {
continue;
}
let Some(name) = vol.get("Name").and_then(|n| n.as_str()) else {
continue;
};
let del = format!("{API_PREFIX}/volumes/{}", urlencoded(name));
match self.client.delete_ok(&del).await {
Ok(_) => crate::ui::progress_line("Volume", name, "Removed"),
Err(e) if e.is_status(404) => {}
Err(e) => {
tracing::warn!("could not remove volume {name}: {e}");
first_err.get_or_insert(crate::error::ComposeError::Podman(e));
}
}
}
first_err
}
}
fn volume_owned_by(vol: &serde_json::Value, project: &str) -> bool {
vol.get("Labels")
.and_then(|l| l.get("podup.project"))
.and_then(|v| v.as_str())
== Some(project)
}
#[cfg(test)]
mod tests {
use super::volume_owned_by;
#[cfg(unix)]
use crate::engine::fake_podman;
#[cfg(unix)]
use crate::engine::Engine;
#[cfg(unix)]
use crate::error::ComposeError;
#[cfg(unix)]
fn engine_with(client: crate::libpod::Client, project: &str) -> Engine {
Engine::with_base_dir(client, project.into(), std::env::temp_dir())
}
#[test]
fn volume_owned_by_matches_project_label() {
let vol = serde_json::json!({
"Name": "proj_data",
"Labels": { "podup.project": "proj", "extra": "1" },
});
assert!(volume_owned_by(&vol, "proj"));
assert!(!volume_owned_by(&vol, "other"));
let unlabelled = serde_json::json!({ "Name": "loose", "Labels": {} });
assert!(!volume_owned_by(&unlabelled, "proj"));
let no_labels = serde_json::json!({ "Name": "loose" });
assert!(!volume_owned_by(&no_labels, "proj"));
}
#[tokio::test]
#[cfg(unix)]
async fn down_by_label_propagates_a_real_removal_failure_after_completing_the_rest() {
let containers = r#"[{"Names":["/proj-web-1"]},{"Names":["/proj-db-1"]}]"#;
let fake = fake_podman::start(move |method, target| {
if method == "GET" && target.contains("/containers/json") {
(200, containers.to_string())
} else if method == "POST" && target.contains("/stop") {
(200, String::new())
} else if method == "DELETE" && target.contains("/proj-web-1?force=true") {
(500, r#"{"message":"device or resource busy"}"#.to_string())
} else if method == "DELETE" && target.contains("/proj-db-1?force=true") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let err = e
.down_by_label(false)
.await
.expect_err("a real container-removal failure must propagate");
assert!(
matches!(err, ComposeError::Podman(ref pe) if pe.is_status(500)),
"got {err:?}"
);
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("DELETE") && r.contains("/proj-db-1?force=true")),
"expected proj-db-1 to be removed despite proj-web-1 failing: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn down_by_label_on_an_already_torn_down_project_is_still_ok() {
let fake = fake_podman::start(|method, target| {
if method == "GET" && target.contains("/containers/json") {
(200, "[]".to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
e.down_by_label(false)
.await
.expect("a re-run down_by_label on a torn-down project must still exit 0");
}
}