use futures_util::StreamExt;
use tracing::{debug, warn};
use std::collections::{HashMap, HashSet};
use crate::compose::types::{ComposeFile, Service};
use crate::error::{ComposeError, Result};
use crate::libpod::types::image::ImagePullProgress;
use crate::libpod::{urlencoded, API_PREFIX};
use super::super::Engine;
#[derive(Default)]
pub struct PullOptions {
pub ignore_failures: bool,
pub include_deps: bool,
}
const MAX_PULL_CONCURRENCY: usize = 16;
async fn bounded_join_all<F, T>(futs: impl IntoIterator<Item = F>, limit: usize) -> Vec<T>
where
F: std::future::Future<Output = T>,
{
futures_util::stream::iter(futs)
.buffer_unordered(limit)
.collect()
.await
}
impl Engine {
pub async fn pull(&self, file: &ComposeFile) -> Result<()> {
self.pull_services(file, &[]).await
}
pub async fn pull_services(&self, file: &ComposeFile, services: &[String]) -> Result<()> {
self.pull_services_with_options(file, services, PullOptions::default())
.await
}
pub async fn pull_services_with_options(
&self,
file: &ComposeFile,
services: &[String],
opts: PullOptions,
) -> Result<()> {
for name in services {
if !file.services.contains_key(name) {
return Err(ComposeError::ServiceNotFound(name.clone()));
}
}
let wanted: Option<HashSet<String>> = match (services.is_empty(), opts.include_deps) {
(true, _) => None,
(false, true) => Some(pull_dep_closure(file, services)),
(false, false) => Some(services.iter().cloned().collect()),
};
type PullKey<'a> = (&'a str, &'static str, Option<&'a str>);
let candidates: Vec<(&str, &Service, PullKey)> = file
.services
.iter()
.filter(|(name, s)| {
s.image.is_some()
&& wanted
.as_ref()
.is_none_or(|set| set.contains(name.as_str()))
})
.map(|(name, s)| {
let image = s.image.as_deref().unwrap_or_default();
let key = (image, self.resolved_pull_policy(s), s.platform.as_deref());
(name.as_str(), s, key)
})
.collect();
let mut representative: HashMap<PullKey, &Service> = HashMap::new();
for (_, service, key) in &candidates {
representative.entry(*key).or_insert(service);
}
let futs = representative.into_iter().map(|(key, service)| async move {
let pull_err = self
.pull_image_with_policy(service, key.1)
.await
.err()
.map(|e| e.to_string());
let present = self.image_present(key.0).await;
(key, present, pull_err)
});
let outcomes: HashMap<PullKey, (bool, Option<String>)> =
bounded_join_all(futs, MAX_PULL_CONCURRENCY)
.await
.into_iter()
.map(|(key, present, err)| (key, (present, err)))
.collect();
for (name, _service, key) in candidates {
let image = key.0;
let (present, pull_err) = outcomes.get(&key).cloned().unwrap_or((false, None));
if present {
continue;
}
if opts.ignore_failures {
match &pull_err {
Some(e) => tracing::warn!("pull {name} ({image}) failed — ignored: {e}"),
None => tracing::warn!("pull {name} ({image}) failed — ignored"),
}
} else {
let detail = pull_err.map(|e| format!(": {e}")).unwrap_or_default();
return Err(ComposeError::Build(format!(
"failed to pull image {image} for service {name}{detail}"
)));
}
}
Ok(())
}
pub(in crate::engine) async fn pull_image(&self, service: &Service) -> Result<()> {
let pull_policy = self.resolved_pull_policy(service);
self.pull_image_with_policy(service, pull_policy).await
}
fn resolved_pull_policy(&self, service: &Service) -> &'static str {
let requested = self
.pull_policy_override
.as_deref()
.or(service.pull_policy.as_deref());
libpod_pull_policy(requested).unwrap_or_else(|| {
warn!(
"unknown pull policy '{}', defaulting to 'missing'",
requested.unwrap_or_default()
);
"missing"
})
}
async fn pull_image_with_policy(&self, service: &Service, pull_policy: &str) -> Result<()> {
let image = match &service.image {
Some(img) => img.clone(),
None => return Ok(()),
};
if self.quiet_pull {
debug!("pulling {image}");
} else {
eprintln!("Pulling {image}");
}
let mut query = format!("reference={}&policy={}", urlencoded(&image), pull_policy);
if let Some(platform) = &service.platform {
query.push_str(&format!("&platform={}", urlencoded(platform)));
}
let path = format!("{API_PREFIX}/images/pull?{query}");
let resp = self
.client
.post_empty_stream(&path)
.await
.map_err(ComposeError::Podman)?;
let mut stream = crate::libpod::parse_json_lines::<ImagePullProgress>(resp.into_body());
while let Some(result) = stream.next().await {
match result {
Ok(progress) => {
if !progress.stream.is_empty() {
debug!("{}", progress.stream.trim_end());
}
if !progress.error.is_empty() {
warn!("pull error: {}", progress.error);
}
}
Err(e) => warn!("pull warning: {e}"),
}
}
Ok(())
}
pub(in crate::engine) async fn image_present(&self, image: &str) -> bool {
let path = format!("{API_PREFIX}/images/{}/json", urlencoded(image));
self.client
.get_json::<crate::libpod::types::image::ImageInspect>(&path)
.await
.is_ok()
}
}
fn pull_dep_closure(file: &ComposeFile, services: &[String]) -> HashSet<String> {
let mut set = HashSet::new();
let mut stack: Vec<String> = services.to_vec();
while let Some(name) = stack.pop() {
if !set.insert(name.clone()) {
continue;
}
if let Some(svc) = file.services.get(&name) {
for dep in svc.depends_on.service_names() {
if !set.contains(&dep) {
stack.push(dep);
}
}
}
}
set
}
pub(in crate::engine) fn libpod_pull_policy(policy: Option<&str>) -> Option<&'static str> {
match policy {
Some("always") => Some("always"),
Some("newer") => Some("newer"),
Some("never") => Some("never"),
None | Some("missing") | Some("if_not_present") | Some("build") => Some("missing"),
Some(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::{libpod_pull_policy, pull_dep_closure};
#[test]
fn dep_closure_includes_transitive_dependencies() {
let file = crate::parse_str(
"services:\n web:\n image: a\n depends_on:\n - api\n api:\n image: b\n depends_on:\n - db\n db:\n image: c\n lone:\n image: d\n",
)
.unwrap();
let mut got: Vec<String> = pull_dep_closure(&file, &["web".to_string()])
.into_iter()
.collect();
got.sort();
assert_eq!(got, vec!["api", "db", "web"]);
}
#[test]
fn dep_closure_of_leaf_is_just_itself() {
let file = crate::parse_str("services:\n db:\n image: c\n").unwrap();
let got: Vec<String> = pull_dep_closure(&file, &["db".to_string()])
.into_iter()
.collect();
assert_eq!(got, vec!["db"]);
}
#[tokio::test]
async fn pull_unknown_service_is_rejected() {
let file = crate::parse_str("services:\n web:\n image: a\n").unwrap();
let e = crate::engine::Engine::new(
crate::libpod::Client::new("/nonexistent.sock"),
"proj".into(),
);
let err = e
.pull_services(&file, &["nope".to_string()])
.await
.expect_err("unknown service must be rejected");
assert!(
matches!(err, crate::error::ComposeError::ServiceNotFound(_)),
"unexpected error: {err:?}"
);
}
#[test]
fn pull_policy_maps_every_spec_value() {
assert_eq!(libpod_pull_policy(Some("always")), Some("always"));
assert_eq!(libpod_pull_policy(Some("newer")), Some("newer"));
assert_eq!(libpod_pull_policy(Some("never")), Some("never"));
assert_eq!(libpod_pull_policy(Some("missing")), Some("missing"));
assert_eq!(libpod_pull_policy(Some("if_not_present")), Some("missing"));
assert_eq!(libpod_pull_policy(Some("build")), Some("missing"));
assert_eq!(libpod_pull_policy(None), Some("missing"));
assert_eq!(libpod_pull_policy(Some("bogus")), None);
}
#[cfg(unix)]
use crate::engine::fake_podman;
#[tokio::test]
#[cfg(unix)]
async fn pull_dedupes_a_shared_image_into_a_single_pull() {
let fake = fake_podman::start(|method, target| {
if method == "POST" && target.contains("/images/pull") {
(200, String::new())
} else if method == "GET" && target.contains("/images/") && target.contains("/json") {
(200, "{}".to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = crate::engine::Engine::new(fake.client(), "proj".into());
let file =
crate::parse_str("services:\n a:\n image: shared\n b:\n image: shared\n")
.unwrap();
e.pull_services(&file, &[])
.await
.expect("pulling two services that share an image must succeed");
let seen = fake.requests.lock().unwrap();
let pulls = seen
.iter()
.filter(|r| r.contains("/images/pull") && r.contains("reference=shared"))
.count();
assert_eq!(
pulls, 1,
"two services sharing one image must issue a single pull: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn pull_issues_separate_requests_for_same_image_different_policy() {
let fake = fake_podman::start(|method, target| {
if method == "POST" && target.contains("/images/pull") {
(200, String::new())
} else if method == "GET" && target.contains("/images/") && target.contains("/json") {
(200, "{}".to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = crate::engine::Engine::new(fake.client(), "proj".into());
let file = crate::parse_str(
"services:\n a:\n image: shared\n pull_policy: never\n b:\n image: shared\n pull_policy: always\n",
)
.unwrap();
e.pull_services(&file, &[])
.await
.expect("differing per-service pull_policy must not fail the pull");
let seen = fake.requests.lock().unwrap();
let pulls: Vec<&String> = seen
.iter()
.filter(|r| r.contains("/images/pull") && r.contains("reference=shared"))
.collect();
assert_eq!(
pulls.len(),
2,
"same image with different resolved policies must issue two pulls, not one: {seen:?}"
);
assert!(
pulls.iter().any(|r| r.contains("policy=never")),
"missing the never-policy pull: {seen:?}"
);
assert!(
pulls.iter().any(|r| r.contains("policy=always")),
"missing the always-policy pull: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn pull_failure_on_a_shared_image_is_still_only_pulled_once() {
let fake = fake_podman::start(|method, target| {
if method == "POST" && target.contains("/images/pull") {
(500, r#"{"message":"registry unreachable"}"#.to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = crate::engine::Engine::new(fake.client(), "proj".into());
let file =
crate::parse_str("services:\n a:\n image: shared\n b:\n image: shared\n")
.unwrap();
let opts = super::PullOptions {
ignore_failures: true,
include_deps: false,
};
e.pull_services_with_options(&file, &[], opts)
.await
.expect("ignore_failures must not error even though the shared pull failed");
let seen = fake.requests.lock().unwrap();
let pulls = seen
.iter()
.filter(|r| r.contains("/images/pull") && r.contains("reference=shared"))
.count();
assert_eq!(
pulls, 1,
"a failing shared image must still be pulled once, not once per service: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn pull_failure_on_a_shared_image_aborts_without_ignore_failures() {
let fake = fake_podman::start(|method, target| {
if method == "POST" && target.contains("/images/pull") {
(500, r#"{"message":"registry unreachable"}"#.to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = crate::engine::Engine::new(fake.client(), "proj".into());
let file =
crate::parse_str("services:\n a:\n image: shared\n b:\n image: shared\n")
.unwrap();
let err = e
.pull_services(&file, &[])
.await
.expect_err("a shared image that fails to pull must abort the pull");
assert!(
matches!(err, crate::error::ComposeError::Build(ref msg) if msg.contains("shared")),
"unexpected error: {err:?}"
);
}
}