use super::container_rm_path;
#[cfg(unix)]
use super::Engine;
#[cfg(unix)]
use crate::compose::types::{ComposeFile, Service};
#[cfg(unix)]
use crate::engine::fake_podman;
#[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())
}
#[tokio::test]
#[cfg(unix)]
async fn ensure_started_propagates_a_real_start_failure() {
let fake = fake_podman::start(|method, target| {
if method == "POST" && target.contains("/proj-web-1/start") {
(500, r#"{"message":"address already in use"}"#.to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let err = e
.ensure_started("proj-web-1")
.await
.expect_err("a real start failure must propagate, not exit 0 silently");
match err {
ComposeError::Podman(pe) => assert!(pe.is_status(500), "got {pe}"),
other => panic!("expected a Podman error, got {other:?}"),
}
assert!(
fake.requests
.lock()
.unwrap()
.iter()
.any(|r| r.contains("/proj-web-1/start")),
"expected the fake socket to have received the start request"
);
}
#[tokio::test]
#[cfg(unix)]
async fn ensure_started_tolerates_404_and_304() {
let fake = fake_podman::start(|_, _| (404, r#"{"message":"no such container"}"#.to_string()));
let e = engine_with(fake.client(), "proj");
e.ensure_started("proj-web-1")
.await
.expect("404 must be an idempotent no-op");
let fake = fake_podman::start(|_, _| (304, String::new()));
let e = engine_with(fake.client(), "proj");
e.ensure_started("proj-web-1")
.await
.expect("304 must be an idempotent no-op");
}
#[tokio::test]
#[cfg(unix)]
async fn down_propagates_a_real_removal_failure_after_completing_the_rest() {
let containers = r#"[
{"Names":["/proj-web-1"],"Labels":{"podup.service":"web"}},
{"Names":["/proj-db-1"],"Labels":{"podup.service":"db"}}
]"#;
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 mut file = ComposeFile::default();
file.services.insert("web".into(), Service::default());
file.services.insert("db".into(), Service::default());
let err = e
.down_with_options(&file, 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_tolerates_a_stalled_stop_when_the_force_remove_succeeds() {
let containers = r#"[{"Names":["/proj-web-1"],"Labels":{"podup.service":"web"}}]"#;
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") {
(
500,
r#"{"message":"timed out waiting for container to exit"}"#.to_string(),
)
} else if method == "DELETE" && target.contains("/proj-web-1?force=true") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let mut file = ComposeFile::default();
file.services.insert("web".into(), Service::default());
e.down_with_options(&file, false)
.await
.expect("a stalled/failed stop superseded by a successful force-remove must not fail down");
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("POST") && r.contains("/stop")),
"expected the stop to have been attempted: {seen:?}"
);
assert!(
seen.iter()
.any(|r| r.contains("DELETE") && r.contains("/proj-web-1?force=true")),
"expected the force-remove to have been attempted: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn down_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");
let mut file = ComposeFile::default();
file.services.insert("web".into(), Service::default());
e.down_with_options(&file, false)
.await
.expect("a re-run down on a torn-down project must still exit 0");
}
#[tokio::test]
#[cfg(unix)]
async fn down_tears_down_dependent_levels_before_their_dependencies() {
let containers = r#"[
{"Names":["/proj-web-1"],"Labels":{"podup.service":"web"}},
{"Names":["/proj-db-1"],"Labels":{"podup.service":"db"}}
]"#;
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"))
|| (method == "DELETE" && target.contains("force=true"))
{
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str(
"services:\n db:\n image: x\n web:\n image: x\n depends_on:\n - db\n",
)
.unwrap();
e.down_with_options(&file, false)
.await
.expect("a healthy two-level teardown must succeed");
let seen = fake.requests.lock().unwrap();
let web_rm = seen
.iter()
.position(|r| r.contains("DELETE") && r.contains("proj-web-1?force=true"))
.expect("web must have been removed");
let db_stop = seen
.iter()
.position(|r| r.contains("POST") && r.contains("proj-db-1") && r.contains("stop"))
.expect("db must have been stopped");
assert!(
web_rm < db_stop,
"expected web's level to fully complete before db's level's stop begins: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn down_targets_every_independent_service_within_one_level() {
let containers = r#"[
{"Names":["/proj-web-1"],"Labels":{"podup.service":"web"}},
{"Names":["/proj-cache-1"],"Labels":{"podup.service":"cache"}}
]"#;
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"))
|| (method == "DELETE" && target.contains("force=true"))
{
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let mut file = ComposeFile::default();
file.services.insert("web".into(), Service::default());
file.services.insert("cache".into(), Service::default());
e.down_with_options(&file, false)
.await
.expect("a healthy single-level teardown of two independent services must succeed");
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("DELETE") && r.contains("proj-web-1?force=true")),
"expected web to have been targeted: {seen:?}"
);
assert!(
seen.iter()
.any(|r| r.contains("DELETE") && r.contains("proj-cache-1?force=true")),
"expected cache to have been targeted: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn down_first_error_is_deterministic_across_levels() {
let containers = r#"[
{"Names":["/proj-web-1"],"Labels":{"podup.service":"web"}},
{"Names":["/proj-db-1"],"Labels":{"podup.service":"db"}}
]"#;
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":"web busy"}"#.to_string())
} else if method == "DELETE" && target.contains("proj-db-1?force=true") {
(503, r#"{"message":"db unavailable"}"#.to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str(
"services:\n db:\n image: x\n web:\n image: x\n depends_on:\n - db\n",
)
.unwrap();
let err = e
.down_with_options(&file, false)
.await
.expect_err("both levels fail to remove their container");
assert!(
matches!(err, ComposeError::Podman(ref pe) if pe.is_status(500)),
"expected web's (first, post-reversal) level failure, not db's: {err:?}"
);
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("DELETE") && r.contains("proj-db-1?force=true")),
"expected db's level to still be attempted after web's level failed: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn up_prefetches_every_levels_image_before_any_container_create() {
let fake = fake_podman::start(|method, target| {
if method == "GET" && target.contains("/containers/json") {
(200, "[]".to_string())
} else if method == "POST" && target.contains("/images/pull") {
(200, String::new())
} else if method == "POST" && target.contains("/containers/create") {
(200, "{}".to_string())
} else if method == "POST" && target.contains("/start") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str(
"services:\n db:\n image: img-db\n web:\n image: img-web\n depends_on:\n - db\n",
)
.unwrap();
e.up_with_options(&file, false, &[], &[], false, false, false)
.await
.expect("a healthy two-level up must succeed");
let seen = fake.requests.lock().unwrap();
let first_create = seen
.iter()
.position(|r| r.contains("/containers/create"))
.expect("expected at least one container create");
let db_pull = seen
.iter()
.position(|r| r.contains("/images/pull") && r.contains("img-db"))
.expect("expected db's image to be pulled");
let web_pull = seen
.iter()
.position(|r| r.contains("/images/pull") && r.contains("img-web"))
.expect("expected web's image to be pulled");
assert!(
db_pull < first_create,
"db's image must be prefetched before any container is created: {seen:?}"
);
assert!(
web_pull < first_create,
"web's (level 2) image must be prefetched up front too, not deferred behind level 1's barrier: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn up_creates_and_starts_every_scaled_replica() {
let fake = fake_podman::start(|method, target| {
if method == "GET" && target.contains("/containers/json") {
(200, "[]".to_string())
} else if method == "POST" && target.contains("/images/pull") {
(200, String::new())
} else if method == "POST" && target.contains("/containers/create") {
(200, "{}".to_string())
} else if method == "POST" && target.contains("/start") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str("services:\n web:\n image: img\n scale: 3\n").unwrap();
e.up_with_options(&file, false, &[], &[], false, false, false)
.await
.expect("a healthy scaled-up must succeed");
let seen = fake.requests.lock().unwrap();
let creates = seen
.iter()
.filter(|r| r.contains("POST") && r.contains("/containers/create"))
.count();
assert_eq!(creates, 3, "every replica must be created: {seen:?}");
for i in 1..=3 {
assert!(
seen.iter()
.any(|r| r.contains(&format!("proj-web-{i}/start"))),
"expected replica {i} to be started: {seen:?}"
);
}
}
#[tokio::test]
#[cfg(unix)]
async fn up_replica_fanout_surfaces_deterministic_first_error_after_attempting_the_rest() {
let fake = fake_podman::start(|method, target| {
if method == "GET" && target.contains("/containers/json") {
(200, "[]".to_string())
} else if method == "POST" && target.contains("/images/pull") {
(200, String::new())
} else if method == "POST" && target.contains("/containers/create") {
(200, "{}".to_string())
} else if method == "POST" && target.contains("proj-web-1/start") {
(500, r#"{"message":"replica 1 boom"}"#.to_string())
} else if method == "POST" && target.contains("proj-web-3/start") {
(503, r#"{"message":"replica 3 boom"}"#.to_string())
} else if method == "POST" && target.contains("/start") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str("services:\n web:\n image: img\n scale: 3\n").unwrap();
let err = e
.up_with_options(&file, false, &[], &[], false, false, false)
.await
.expect_err("a genuine replica start failure must propagate, not exit 0");
assert!(
matches!(err, ComposeError::Podman(ref pe) if pe.is_status(500)),
"expected replica 1's (first, index order) failure, not replica 3's: {err:?}"
);
let seen = fake.requests.lock().unwrap();
for i in 1..=3 {
assert!(
seen.iter()
.any(|r| r.contains(&format!("proj-web-{i}/start"))),
"expected replica {i}'s start to have been attempted: {seen:?}"
);
}
}
#[tokio::test]
#[cfg(unix)]
async fn up_replica_fanout_preserves_the_no_recreate_skip_per_replica() {
let containers = r#"[{"Names":["/proj-web-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("/images/pull") {
(200, String::new())
} else if method == "POST" && target.contains("/containers/create") {
(200, "{}".to_string())
} else if method == "POST" && target.contains("/start") {
(200, String::new())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = crate::parse_str("services:\n web:\n image: img\n scale: 2\n").unwrap();
e.up_with_options(&file, false, &[], &[], true, false, false)
.await
.expect("a partially-existing scaled up must succeed");
let seen = fake.requests.lock().unwrap();
let creates = seen
.iter()
.filter(|r| r.contains("POST") && r.contains("/containers/create"))
.count();
assert_eq!(
creates, 1,
"only replica 2 (not yet present) should be created; replica 1 is skipped: {seen:?}"
);
assert!(
seen.iter().any(|r| r.contains("proj-web-1/start")),
"the already-present replica 1 must still be ensured started: {seen:?}"
);
assert!(
seen.iter().any(|r| r.contains("proj-web-2/start")),
"the newly-created replica 2 must be started: {seen:?}"
);
}
#[test]
fn rm_path_omits_volume_flag_by_default() {
let path = container_rm_path("proj-web-1", false);
assert!(path.ends_with("/proj-web-1?force=true"), "got: {path}");
assert!(!path.contains("v=true"), "got: {path}");
}
#[test]
fn rm_path_requests_anonymous_volume_removal() {
let path = container_rm_path("proj-web-1", true);
assert!(path.contains("force=true"), "got: {path}");
assert!(path.contains("&v=true"), "got: {path}");
}
#[test]
fn rm_path_url_encodes_container_name() {
let path = container_rm_path("weird/name", true);
assert!(!path.contains("weird/name"), "got: {path}");
assert!(path.contains("weird%2Fname"), "got: {path}");
}