Skip to main content

isb_core/
image_check.rs

1//! Does a remote image exist? Asked before an app or a stack service names
2//! one, so a typo is refused up front instead of deployed into a replica
3//! that fails to pull, and asked again at each deploy, where its answer is
4//! also the digest the image is pinned to.
5//!
6//! The registry is asked the way incus will ask it when it pulls: through
7//! `skopeo inspect` (which incus itself runs for OCI images), anonymously
8//! unless the daemon's user has registry credentials configured for
9//! skopeo. `skopeo inspect` picks the host's platform from a multi-arch
10//! index, so an image without one for this machine is "not found" too.
11//!
12//! Only a registry that answers "no such manifest" refuses. A registry that
13//! cannot be reached, or that wants credentials (a private image, or on
14//! Docker Hub a repository that does not exist), is reported as a warning:
15//! being offline must not stop anyone from changing an app.
16
17use std::io::Read;
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant};
20
21use crate::error::{Error, Result};
22use crate::plan::ImageSource;
23
24/// How long a check at create or update waits for the registry.
25pub const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
26/// How long the check at deploy waits (it also resolves the digest).
27pub const DEPLOY_TIMEOUT: Duration = Duration::from_secs(60);
28
29/// What a registry said about an image.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Probe {
32    /// It exists; its digest when the registry gave one.
33    Found(Option<String>),
34    /// The registry answered that there is no such image (or none for this
35    /// platform): why, in the registry's words.
36    NotFound(String),
37    /// The registry wants credentials: a private image, or (Docker Hub,
38    /// quay) a repository that does not exist.
39    Denied(String),
40    /// No answer: the registry is unreachable, skopeo is missing or timed
41    /// out. Why.
42    Unknown(String),
43}
44
45impl Probe {
46    /// The digest, when the image was found with one.
47    pub fn digest(&self) -> Option<&str> {
48        match self {
49            Probe::Found(d) => d.as_deref(),
50            _ => None,
51        }
52    }
53}
54
55/// The image an isb reference names on its registry, as skopeo writes it
56/// (`docker://docker.io/library/nginx:1.27`), and the registry's name for
57/// people. `None` for what is not a remote OCI image: a local alias, an
58/// incus simplestreams image, or `registry:` (the org's own, which the
59/// stack controller resolves).
60pub fn remote(image: &str) -> Option<(String, String)> {
61    let src = ImageSource::parse(image).ok()?;
62    if !src.is_oci() {
63        return None;
64    }
65    let host = src.server.as_deref()?.strip_prefix("https://")?;
66    let name = match host {
67        "docker.io" => "Docker Hub".to_string(),
68        "ghcr.io" => "GitHub Container Registry".to_string(),
69        h => h.to_string(),
70    };
71    Some((format!("docker://{host}/{}", src.alias), name))
72}
73
74/// Ask the image's registry, giving up after `timeout`. An image that is
75/// not a remote OCI image is `Found(None)`: there is nothing to ask.
76pub fn probe(image: &str, timeout: Duration) -> Probe {
77    let Some((r, _)) = remote(image) else {
78        return Probe::Found(None);
79    };
80    let child = Command::new("skopeo")
81        .args(["inspect", "--no-tags", "--format", "{{.Digest}}", &r])
82        .stdin(Stdio::null())
83        .stdout(Stdio::piped())
84        .stderr(Stdio::piped())
85        .spawn();
86    let mut child = match child {
87        Ok(c) => c,
88        Err(e) => return Probe::Unknown(format!("cannot run skopeo: {e}")),
89    };
90    // Read both pipes on threads: a full pipe would stall skopeo.
91    let mut out = child.stdout.take();
92    let mut err = child.stderr.take();
93    let o = std::thread::spawn(move || {
94        let mut s = String::new();
95        if let Some(p) = out.as_mut() {
96            let _ = p.read_to_string(&mut s);
97        }
98        s
99    });
100    let e = std::thread::spawn(move || {
101        let mut s = String::new();
102        if let Some(p) = err.as_mut() {
103            let _ = p.read_to_string(&mut s);
104        }
105        s
106    });
107    let started = Instant::now();
108    let ok = loop {
109        match child.try_wait() {
110            Ok(Some(s)) => break s.success(),
111            Ok(None) if started.elapsed() > timeout => {
112                let _ = child.kill();
113                let _ = child.wait();
114                return Probe::Unknown(format!("no answer within {}s", timeout.as_secs()));
115            }
116            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
117            Err(e) => return Probe::Unknown(format!("skopeo: {e}")),
118        }
119    };
120    let stdout = o.join().unwrap_or_default();
121    let stderr = e.join().unwrap_or_default();
122    if ok {
123        let d = stdout.trim();
124        let digest = (d.starts_with("sha256:") && d.len() == 71).then(|| d.to_string());
125        return Probe::Found(digest);
126    }
127    classify(&stderr)
128}
129
130/// What a failed `skopeo inspect` said, sorted by what it means.
131pub fn classify(stderr: &str) -> Probe {
132    let why = reason(stderr);
133    let l = stderr.to_ascii_lowercase();
134    if [
135        "manifest unknown",
136        "name unknown",
137        "no image found in manifest list",
138        "not found: manifest",
139    ]
140    .iter()
141    .any(|k| l.contains(k))
142    {
143        Probe::NotFound(why)
144    } else if [
145        "denied",
146        "unauthorized",
147        "authentication required",
148        "401",
149        "403 forbidden",
150    ]
151    .iter()
152    .any(|k| l.contains(k))
153    {
154        Probe::Denied(why)
155    } else {
156        Probe::Unknown(why)
157    }
158}
159
160/// The useful end of skopeo's message: after its last `": "` wrapper, so
161/// `reading manifest whoami in docker.io/library/traefik: manifest unknown`
162/// becomes `manifest unknown`.
163fn reason(stderr: &str) -> String {
164    let line = stderr
165        .lines()
166        .rev()
167        .find(|l| !l.trim().is_empty())
168        .unwrap_or("");
169    let msg = match line.split_once("msg=\"") {
170        Some((_, m)) => quoted(m),
171        None => line.trim().to_string(),
172    };
173    let tail = msg.rsplit(": ").next().unwrap_or(&msg).trim();
174    if tail.is_empty() {
175        "no reason given".to_string()
176    } else {
177        tail.to_string()
178    }
179}
180
181/// The text of a quoted logfmt value, up to its closing quote (incus wraps
182/// skopeo's line in more text), with `\"` unescaped.
183fn quoted(m: &str) -> String {
184    let mut out = String::new();
185    let mut chars = m.chars();
186    while let Some(c) = chars.next() {
187        match c {
188            '\\' => out.extend(chars.next()),
189            '"' => break,
190            c => out.push(c),
191        }
192    }
193    out
194}
195
196/// The image someone more likely meant, for the commonest slip: a Docker
197/// Hub `owner:name` written for `owner/name` (`docker:traefik:whoami`, for
198/// `docker:traefik/whoami`). Only a reference with no `/` and one tag qualifies.
199pub fn suggestion(image: &str) -> Option<String> {
200    let rest = image.strip_prefix("docker:")?;
201    if rest.contains('/') || rest.contains('@') {
202        return None;
203    }
204    let (owner, name) = rest.split_once(':')?;
205    if owner.is_empty() || name.is_empty() || name.contains(':') {
206        return None;
207    }
208    Some(format!("docker:{owner}/{name}"))
209}
210
211/// Check an image before it is saved. `Err` when its registry says it does
212/// not exist; `Ok(Some(warning))` when that could not be confirmed;
213/// `Ok(None)` when it exists or is not a remote image.
214pub fn check(image: &str, probe: &dyn Fn(&str) -> Probe) -> Result<Option<String>> {
215    let Some((_, registry)) = remote(image) else {
216        return Ok(None);
217    };
218    let found = |p: &Probe| matches!(p, Probe::Found(_));
219    let better = || suggestion(image).filter(|s| found(&probe(s)));
220    match probe(image) {
221        Probe::Found(_) => Ok(None),
222        Probe::NotFound(why) => Err(Error::invalid(not_found(image, &registry, &why, better()))),
223        Probe::Denied(why) => Ok(Some(match better() {
224            Some(s) => format!(
225                "{registry} refused to show image {image} without credentials ({why}): it is private or does not exist. Did you mean {s}?"
226            ),
227            None => format!(
228                "{registry} refused to show image {image} without credentials ({why}): if it is private, the host needs credentials to pull it; if it does not exist, the deploy will fail"
229            ),
230        })),
231        Probe::Unknown(why) => Ok(Some(format!(
232            "could not check image {image} on {registry} ({why}); saved unchecked"
233        ))),
234    }
235}
236
237/// The refusal for an image its registry does not have.
238pub fn not_found(image: &str, registry: &str, why: &str, better: Option<String>) -> String {
239    let mut m = format!("image {image} not found on {registry} ({why})");
240    match better {
241        Some(s) => m.push_str(&format!(": did you mean {s}?")),
242        None => m.push_str(
243            ": check the name and tag (Docker Hub images are docker:NAME[:TAG] or docker:OWNER/NAME[:TAG], e.g. docker:nginx:1.27 or docker:traefik/whoami)",
244        ),
245    }
246    m
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    const UNKNOWN: &str = r#"time="2026-10-05T04:38:08Z" level=fatal msg="Error parsing image name \"docker://docker.io/library/traefik:whoami\": reading manifest whoami in docker.io/library/traefik: manifest unknown""#;
254    const DENIED: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://docker.io/library/ngnixx:latest\": reading manifest latest in docker.io/library/ngnixx: requested access to the resource is denied""#;
255    const OFFLINE: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://nosuch.invalid/a/b:1\": pinging container registry nosuch.invalid: Get \"https://nosuch.invalid/v2/\": dial tcp: lookup nosuch.invalid on 127.0.0.53:53: no such host""#;
256    const GHCR: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://ghcr.io/o/a:v1\": Requesting bearer token: received unexpected HTTP status: 403 Forbidden""#;
257    const PLATFORM: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://docker.io/o/a:1\": choosing image instance: no image found in manifest list for architecture \"amd64\", variant \"\", OS \"linux\"""#;
258
259    #[test]
260    fn skopeo_errors_are_sorted_by_what_they_mean() {
261        assert_eq!(
262            classify(UNKNOWN),
263            Probe::NotFound("manifest unknown".into())
264        );
265        assert!(matches!(classify(PLATFORM), Probe::NotFound(w) if w.ends_with("OS \"linux\"")));
266        assert_eq!(
267            classify(DENIED),
268            Probe::Denied("requested access to the resource is denied".into())
269        );
270        assert!(matches!(classify(GHCR), Probe::Denied(_)));
271        assert!(matches!(classify(OFFLINE), Probe::Unknown(w) if w == "no such host"));
272        assert!(matches!(classify(""), Probe::Unknown(_)));
273    }
274
275    #[test]
276    fn only_remote_oci_images_are_asked_about() {
277        assert_eq!(
278            remote("docker:traefik:whoami").unwrap(),
279            (
280                "docker://docker.io/library/traefik:whoami".to_string(),
281                "Docker Hub".to_string()
282            )
283        );
284        assert_eq!(
285            remote("docker:traefik/whoami").unwrap().0,
286            "docker://docker.io/traefik/whoami:latest"
287        );
288        assert_eq!(
289            remote("ghcr:org/app:v1").unwrap(),
290            (
291                "docker://ghcr.io/org/app:v1".to_string(),
292                "GitHub Container Registry".to_string()
293            )
294        );
295        assert_eq!(
296            remote("oci:reg.example.com:5000/a/b:1").unwrap().1,
297            "reg.example.com:5000"
298        );
299        for local in [
300            "dev-base",
301            "images:debian/12",
302            "registry:web:v1",
303            "nonsense:x",
304        ] {
305            assert!(remote(local).is_none(), "{local}");
306        }
307    }
308
309    #[test]
310    fn a_colon_for_a_slash_is_suggested_back() {
311        assert_eq!(
312            suggestion("docker:traefik:whoami").as_deref(),
313            Some("docker:traefik/whoami")
314        );
315        for none in [
316            "docker:traefik/whoami",
317            "docker:nginx",
318            "docker:a:b:c",
319            "docker:a@sha256:x",
320            "ghcr:a:b",
321        ] {
322            assert!(suggestion(none).is_none(), "{none}");
323        }
324    }
325
326    fn registry(found: &'static [&'static str], answer: Probe) -> impl Fn(&str) -> Probe {
327        move |i: &str| {
328            if found.contains(&i) {
329                Probe::Found(Some(format!("sha256:{}", "a".repeat(64))))
330            } else {
331                answer.clone()
332            }
333        }
334    }
335
336    #[test]
337    fn a_missing_image_is_refused_with_what_was_meant() {
338        let p = registry(
339            &["docker:traefik/whoami"],
340            Probe::NotFound("manifest unknown".into()),
341        );
342        let e = check("docker:traefik:whoami", &p).unwrap_err().to_string();
343        assert!(
344            e.contains("image docker:traefik:whoami not found on Docker Hub (manifest unknown): did you mean docker:traefik/whoami?"),
345            "{e}"
346        );
347        let e = check("docker:nginx:nosuchtag", &p).unwrap_err().to_string();
348        assert!(
349            e.contains("not found on Docker Hub") && e.contains("docker:nginx:1.27"),
350            "{e}"
351        );
352        assert_eq!(check("docker:traefik/whoami", &p).unwrap(), None);
353    }
354
355    #[test]
356    fn what_cannot_be_checked_warns_and_never_blocks() {
357        let p = registry(&[], Probe::Unknown("no such host".into()));
358        let w = check("oci:nosuch.invalid/a/b:1", &p).unwrap().unwrap();
359        assert!(
360            w.contains("could not check") && w.contains("no such host"),
361            "{w}"
362        );
363        let p = registry(&[], Probe::Denied("denied".into()));
364        let w = check("ghcr:o/private:v1", &p).unwrap().unwrap();
365        assert!(w.contains("without credentials"), "{w}");
366        let p = registry(&["docker:traefik/whoami"], Probe::Denied("denied".into()));
367        let w = check("docker:traefik:whoami", &p).unwrap().unwrap();
368        assert!(w.contains("Did you mean docker:traefik/whoami?"), "{w}");
369        // Local and incus images are never asked about.
370        let never = |_: &str| -> Probe { panic!("asked") };
371        assert_eq!(check("dev-base", &never).unwrap(), None);
372        assert_eq!(check("registry:web:v1", &never).unwrap(), None);
373    }
374}