Skip to main content

isb_apps/app/
image.rs

1//! The image an app runs, looked up in its registry: when it is saved
2//! (refused when the registry does not have it) and at each deploy (where
3//! the answer is also the digest it is pinned to).
4
5use super::deploy::DeployLog;
6use super::{AppSpec, Apps, Source};
7use crate::error::{Error, Result};
8use crate::image_check::{self, Probe};
9
10impl Apps {
11    /// An image reference pinned to its current digest, when one is found.
12    /// An image its registry does not have fails the deployment here,
13    /// before the stack is touched: deployed, it would only crash-loop.
14    pub(super) fn resolve(&self, i: &str, log: &mut DeployLog) -> Result<(String, Option<String>)> {
15        let Some((_, registry)) = image_check::remote(i) else {
16            log.line("not a registry image; deploying by name");
17            return Ok((i.to_string(), None));
18        };
19        match (self.inner.probe)(i, image_check::DEPLOY_TIMEOUT) {
20            Probe::Found(Some(d)) => {
21                let pinned = super::pin(i, &d).unwrap_or_else(|| i.to_string());
22                log.line(&format!("resolved to {pinned}"));
23                Ok((pinned, Some(d)))
24            }
25            Probe::Found(None) => {
26                log.line("no digest found; deploying by tag");
27                Ok((i.to_string(), None))
28            }
29            Probe::NotFound(why) => {
30                let better = image_check::suggestion(i).filter(|s| {
31                    matches!(
32                        (self.inner.probe)(s, image_check::CHECK_TIMEOUT),
33                        Probe::Found(_)
34                    )
35                });
36                Err(Error::invalid(image_check::not_found(
37                    i, &registry, &why, better,
38                )))
39            }
40            Probe::Denied(why) | Probe::Unknown(why) => {
41                log.line(&format!(
42                    "could not check the image on {registry} ({why}); deploying by tag"
43                ));
44                Ok((i.to_string(), None))
45            }
46        }
47    }
48
49    /// Check the remote image `spec` names (an image source, or a
50    /// database's) before it is saved: `Err` when its registry does not
51    /// have it, a warning when that could not be confirmed. With `before`
52    /// (an update), an image that did not change is not asked about again.
53    pub fn check_image(&self, before: Option<&AppSpec>, spec: &AppSpec) -> Result<Option<String>> {
54        let Some(image) = image_of(spec) else {
55            return Ok(None);
56        };
57        if before.and_then(image_of).as_deref() == Some(image.as_str()) {
58            return Ok(None);
59        }
60        let probe = |i: &str| (self.inner.probe)(i, image_check::CHECK_TIMEOUT);
61        image_check::check(&image, &probe)
62    }
63}
64
65/// The image an app's settings name: its image source, or its database's.
66pub(super) fn image_of(spec: &AppSpec) -> Option<String> {
67    match &spec.source {
68        Source::Image(i) => Some(i.clone()),
69        Source::Database(db) => Some(db.image()),
70        Source::Git(_) => None,
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use std::sync::{Arc, Mutex};
77    use std::time::Duration;
78
79    use serde_json::json;
80
81    use super::super::deploy::tests::apps;
82    use super::super::deploy::{Status, Trigger};
83    use crate::org::OrgId;
84
85    fn spec(v: serde_json::Value) -> super::AppSpec {
86        serde_json::from_value(v).unwrap()
87    }
88
89    #[test]
90    fn an_image_its_registry_lacks_is_refused_and_never_deployed() {
91        let dir = tempfile::tempdir().unwrap();
92        let gate = Arc::new((Mutex::new(true), std::sync::Condvar::new()));
93        let ap = apps(dir.path(), gate);
94        let org = OrgId::new("acme").unwrap();
95        ap.project_create(&org, "shop", "", &[]).unwrap();
96        let typo = spec(json!({
97            "name": "web", "project": "shop", "source": {"image": "docker:traefik:whoami"},
98        }));
99        let e = ap.check_image(None, &typo).unwrap_err().to_string();
100        assert!(e.contains("did you mean docker:traefik/whoami?"), "{e}");
101        let fine = spec(json!({
102            "name": "web", "project": "shop", "source": {"image": "docker:traefik/whoami"},
103        }));
104        assert_eq!(ap.check_image(None, &fine).unwrap(), None);
105        // An update that keeps the image does not ask again.
106        assert_eq!(ap.check_image(Some(&typo), &typo).unwrap(), None);
107
108        // Saved some other way (before this check existed, or a restore),
109        // its deploy fails at the image, before the stack is touched.
110        ap.create(&org, typo).unwrap();
111        ap.deploy(&org, "web", Trigger::Api, "t", None).unwrap();
112        let d = ap.wait(&org, "web", 1, Duration::from_secs(30)).unwrap();
113        assert_eq!(d.status, Status::Failed, "{d:?}");
114        let err = d.error.clone().unwrap_or_default();
115        assert!(
116            err.contains("image docker:traefik:whoami not found on Docker Hub (manifest unknown)"),
117            "{err}"
118        );
119        assert!(d.rendered.is_none(), "the stack was never deployed: {d:?}");
120    }
121}