dofigen_lib/
context.rs

1use colored::{Color, Colorize};
2use serde::Deserialize;
3
4use crate::{
5    lock::{DockerTag, ResourceVersion, DEFAULT_NAMESPACE, DOCKER_HUB_HOST},
6    Dofigen, DofigenPatch, Error, Extend, ImageName, ImageVersion, Resource, Result,
7};
8use std::{
9    collections::{HashMap, HashSet},
10    fs,
11    io::Read,
12    str::FromStr,
13};
14
15const MAX_LOAD_STACK_SIZE: usize = 10;
16
17/// The representation of the Dofigen execution context
18pub struct DofigenContext {
19    pub offline: bool,
20    pub update_file_resources: bool,
21    pub update_url_resources: bool,
22    pub update_docker_tags: bool,
23    pub display_updates: bool,
24    pub no_default_labels: bool,
25
26    // Load resources
27    load_resource_stack: Vec<Resource>,
28    resources: HashMap<Resource, ResourceVersion>,
29    used_resources: HashSet<Resource>,
30
31    // Images tags
32    images: HashMap<ImageName, DockerTag>,
33    used_images: HashSet<ImageName>,
34}
35
36impl DofigenContext {
37    //////////  Resource management  //////////
38
39    pub(crate) fn current_resource(&self) -> Option<&Resource> {
40        self.load_resource_stack.last()
41    }
42
43    pub(crate) fn push_resource_stack(&mut self, resource: Resource) -> Result<()> {
44        let present = self.load_resource_stack.contains(&resource);
45        self.load_resource_stack.push(resource);
46
47        // check for circular dependencies
48        if present {
49            return Err(Error::Custom(format!(
50                "Circular dependency detected while loading resource {}",
51                self.load_resource_stack
52                    .iter()
53                    .map(|r| format!("{:?}", r))
54                    .collect::<Vec<_>>()
55                    .join(" -> "),
56            )));
57        }
58
59        // check the stack size
60        if self.load_resource_stack.len() > MAX_LOAD_STACK_SIZE {
61            return Err(Error::Custom(format!(
62                "Max load stack size exceeded while loading resource {}",
63                self.load_resource_stack
64                    .iter()
65                    .map(|r| format!("{:?}", r))
66                    .collect::<Vec<_>>()
67                    .join(" -> "),
68            )));
69        }
70        Ok(())
71    }
72
73    pub(crate) fn pop_resource_stack(&mut self) -> Option<Resource> {
74        self.load_resource_stack.pop()
75    }
76
77    /// Get the content of a resource from cache if possible
78    pub(crate) fn get_resource_content(&mut self, resource: Resource) -> Result<String> {
79        let load = match resource {
80            Resource::File(_) => self.update_file_resources,
81            Resource::Url(_) => self.update_url_resources,
82        } || !self.resources.contains_key(&resource);
83
84        let version = if load {
85            let version = self.load_resource_version(&resource)?;
86            let previous = self.resources.insert(resource.clone(), version.clone());
87
88            // display update
89            if self.display_updates {
90                let resource_name = resource.to_string();
91                if let Some(previous) = previous {
92                    if previous.hash != version.hash {
93                        println!(
94                            "{:>20} {} {} -> {}",
95                            "Update resource".color(Color::Green).bold(),
96                            resource_name,
97                            previous.hash,
98                            version.hash
99                        );
100                    }
101                } else {
102                    println!(
103                        "{:>20} {} {}",
104                        "Add resource".color(Color::Blue).bold(),
105                        resource_name,
106                        version.hash
107                    );
108                }
109            }
110
111            version
112        } else {
113            self.resources[&resource].clone()
114        };
115
116        let content = version.content.clone();
117        self.used_resources.insert(resource);
118        Ok(content)
119    }
120
121    /// Load the content of a resource
122    fn load_resource_version(&self, resource: &Resource) -> Result<ResourceVersion> {
123        let content = match resource.clone() {
124            Resource::File(path) => fs::read_to_string(path.clone())
125                .map_err(|err| Error::Custom(format!("Could not read file {:?}: {}", path, err)))?,
126            Resource::Url(url) => {
127                if self.offline {
128                    return Err(Error::Custom(
129                        "Offline mode can't load URL resources".to_string(),
130                    ));
131                }
132                reqwest::blocking::get(url.as_ref())
133                    .map_err(Error::from)?
134                    .error_for_status()?
135                    .text()
136                    .map_err(Error::from)?
137            }
138        };
139        let version = ResourceVersion {
140            hash: sha256::digest(content.clone()),
141            content: content.clone(),
142        };
143        Ok(version)
144    }
145
146    fn clean_unused_resources(&mut self) {
147        for resource in self.resources.clone().keys() {
148            if !self.used_resources.contains(resource) {
149                let version = self.resources.remove(resource).unwrap();
150                if self.display_updates {
151                    println!(
152                        "{:>20} {} {}",
153                        "Remove image".color(Color::Red).bold(),
154                        resource.to_string(),
155                        version.hash
156                    );
157                }
158            }
159        }
160    }
161
162    //////////  Image management  //////////
163
164    pub(crate) fn get_image_tag(&mut self, image: &ImageName) -> Result<DockerTag> {
165        let image = image.fill();
166
167        let tag = if self.update_docker_tags || !self.images.contains_key(&image) {
168            let tag = self.load_image_tag(&image)?;
169            let previous = self.images.insert(image.clone(), tag.clone());
170
171            // display update
172            if self.display_updates {
173                let image_name = image.to_string();
174                if let Some(previous) = previous {
175                    if previous.digest != tag.digest {
176                        println!(
177                            "{:>20} {} {} -> {}",
178                            "Update image".color(Color::Green).bold(),
179                            image_name,
180                            previous.digest,
181                            tag.digest
182                        );
183                    }
184                } else {
185                    println!(
186                        "{:>20} {} {}",
187                        "Add image".color(Color::Blue).bold(),
188                        image_name,
189                        tag.digest
190                    );
191                }
192            }
193
194            tag
195        } else {
196            self.images[&image].clone()
197        };
198
199        self.used_images.insert(image.clone());
200        Ok(tag)
201    }
202
203    fn load_image_tag(&mut self, image: &ImageName) -> Result<DockerTag> {
204        if self.offline {
205            return Err(Error::Custom(
206                "Offline mode can't load image tag".to_string(),
207            ));
208        }
209
210        let tag = match image
211            .version
212            .clone()
213            .ok_or(Error::Custom("No version found for image".into()))?
214        {
215            ImageVersion::Tag(tag) => tag,
216            _ => {
217                return Err(Error::Custom("Image version is not a tag".to_string()));
218            }
219        };
220
221        let host = image
222            .host
223            .clone()
224            .ok_or(Error::Custom("No host found for image".into()))?;
225        let port = image
226            .port
227            .clone()
228            .ok_or(Error::Custom("No port found for image".into()))?;
229        let port = if port == 443 {
230            String::new()
231        } else {
232            format!(":{port}")
233        };
234
235        let client = reqwest::blocking::Client::new();
236
237        let docker_tag = if self.load_from_api(host.as_str()) {
238            let mut repo = image.path.clone();
239            let namespace = if repo.contains("/") {
240                let mut parts = image.path.split("/");
241                let ret = parts.next().unwrap();
242                repo = parts.collect::<Vec<&str>>().join("/");
243                ret
244            } else {
245                DEFAULT_NAMESPACE
246            };
247            let request_url = format!(
248                "https://{DOCKER_HUB_HOST}/v2/namespaces/{namespace}/repositories/{repo}/tags/{tag}",
249                namespace = namespace,
250                repo = repo,
251                tag = tag
252            );
253            let response = client.get(&request_url).send().map_err(Error::from)?;
254
255            let response: DockerHubTagResponse = response.json().map_err(Error::from)?;
256            DockerTag {
257                digest: response
258                    .digest
259                    .or(response.images.get(0).map(|img| img.digest.clone()))
260                    .ok_or(Error::Custom("No digest found in response".to_string()))?,
261            }
262        } else {
263            let request_url = format!(
264                "https://{host}{port}/v2/{path}/manifests/{tag}",
265                path = image.path,
266                tag = tag
267            );
268            let response = client.head(&request_url).send().map_err(Error::from)?;
269
270            let digest = response
271                .headers()
272                .get("Docker-Content-Digest")
273                .ok_or(Error::Custom("No digest found in response".to_string()))?;
274            let digest = digest
275                .to_str()
276                .map_err(|err| Error::display(err))?
277                .to_string();
278
279            DockerTag { digest }
280        };
281
282        Ok(docker_tag)
283    }
284
285    fn load_from_api(&self, host: &str) -> bool {
286        host == DOCKER_HUB_HOST || host == "docker.io"
287    }
288
289    fn clean_unused_images(&mut self) {
290        for image in self.images.clone().keys() {
291            if !self.used_images.contains(image) {
292                let tag = self.images.remove(image).unwrap();
293                if self.display_updates {
294                    println!(
295                        "{:>20} {} {}",
296                        "Remove image".color(Color::Red).bold(),
297                        image.to_string(),
298                        tag.digest
299                    );
300                }
301            }
302        }
303    }
304
305    //////////  Getters  //////////
306
307    pub(crate) fn used_resource_contents(&self) -> HashMap<Resource, ResourceVersion> {
308        self.used_resources
309            .iter()
310            .map(|res| (res.clone(), self.resources[res].clone()))
311            .collect()
312    }
313
314    pub(crate) fn used_image_tags(&self) -> HashMap<ImageName, DockerTag> {
315        self.used_images
316            .iter()
317            .map(|image| (image.clone(), self.images[image].clone()))
318            .collect()
319    }
320
321    //////////  Comparison  //////////
322
323    pub fn image_updates(
324        &self,
325        previous: &DofigenContext,
326    ) -> Vec<UpdateCommand<ImageName, DockerTag>> {
327        let mut updates = vec![];
328
329        let mut previous_images = previous.images.clone();
330        let current_images = self.used_image_tags();
331
332        for (image, tag) in current_images {
333            if let Some(previous_tag) = previous_images.remove(&image) {
334                if tag.digest != previous_tag.digest {
335                    updates.push(UpdateCommand::Update(image, tag, previous_tag))
336                }
337            } else {
338                updates.push(UpdateCommand::Add(image, tag))
339            }
340        }
341
342        for (image, tag) in previous_images {
343            updates.push(UpdateCommand::Remove(image, tag));
344        }
345
346        updates.sort();
347
348        updates
349    }
350
351    pub fn resource_updates(
352        &self,
353        previous: &DofigenContext,
354    ) -> Vec<UpdateCommand<Resource, ResourceVersion>> {
355        let mut updates = vec![];
356
357        let mut previous_resources = previous.resources.clone();
358        let current_resources = self.used_resource_contents();
359
360        for (resource, version) in current_resources
361            .into_iter()
362            .filter(|(r, _)| matches!(r, Resource::Url(_)))
363        {
364            if let Some(previous_version) = previous_resources.remove(&resource) {
365                if version.hash != previous_version.hash {
366                    updates.push(UpdateCommand::Update(resource, version, previous_version))
367                }
368            } else {
369                updates.push(UpdateCommand::Add(resource, version))
370            }
371        }
372
373        for (resource, version) in previous_resources
374            .into_iter()
375            .filter(|(r, _)| matches!(r, Resource::Url(_)))
376        {
377            updates.push(UpdateCommand::Remove(resource, version));
378        }
379
380        updates.sort();
381
382        updates
383    }
384
385    //////////  Dofigen parsing  //////////
386
387    /// Parse an Dofigen from a string.
388    ///
389    /// # Examples
390    ///
391    /// Basic struct
392    ///
393    /// ```
394    /// use dofigen_lib::*;
395    /// use pretty_assertions_sorted::assert_eq_sorted;
396    ///
397    /// let yaml = "
398    /// fromImage:
399    ///   path: ubuntu
400    /// ";
401    /// let image: Dofigen = DofigenContext::new().parse_from_string(yaml).unwrap();
402    /// assert_eq_sorted!(
403    ///     image,
404    ///     Dofigen {
405    ///       stage: Stage {
406    ///         from: ImageName {
407    ///             path: String::from("ubuntu"),
408    ///             ..Default::default()
409    ///         }.into(),
410    ///         ..Default::default()
411    ///       },
412    ///      ..Default::default()
413    ///     }
414    /// );
415    /// ```
416    ///
417    /// Advanced image with builders and artifacts
418    ///
419    /// ```
420    /// use dofigen_lib::*;
421    /// use pretty_assertions_sorted::assert_eq_sorted;
422    /// use std::collections::HashMap;
423    ///
424    /// let yaml = r#"
425    /// builders:
426    ///   builder:
427    ///     fromImage:
428    ///       path: ekidd/rust-musl-builder
429    ///     copy:
430    ///       - paths: ["*"]
431    ///     run:
432    ///       - cargo build --release
433    /// fromImage:
434    ///     path: ubuntu
435    /// copy:
436    ///   - fromBuilder: builder
437    ///     paths:
438    ///       - /home/rust/src/target/x86_64-unknown-linux-musl/release/template-rust
439    ///     target: /app
440    /// "#;
441    /// let image: Dofigen = DofigenContext::new().parse_from_string(yaml).unwrap();
442    /// assert_eq_sorted!(
443    ///     image,
444    ///     Dofigen {
445    ///         builders: HashMap::from([
446    ///             ("builder".to_string(), Stage {
447    ///                 from: ImageName { path: "ekidd/rust-musl-builder".into(), ..Default::default() }.into(),
448    ///                 copy: vec![CopyResource::Copy(Copy{paths: vec!["*".into()].into(), ..Default::default()}).into()].into(),
449    ///                 run: Run {
450    ///                     run: vec!["cargo build --release".parse().unwrap()].into(),
451    ///                     ..Default::default()
452    ///                 },
453    ///                 ..Default::default()
454    ///             }),
455    ///         ]),
456    ///         stage: Stage {
457    ///             from: ImageName {
458    ///                 path: "ubuntu".into(),
459    ///                 ..Default::default()
460    ///             }.into(),
461    ///             copy: vec![CopyResource::Copy(Copy{
462    ///                 from: FromContext::FromBuilder(String::from("builder")),
463    ///                 paths: vec![String::from(
464    ///                     "/home/rust/src/target/x86_64-unknown-linux-musl/release/template-rust"
465    ///                 )],
466    ///                 options: CopyOptions {
467    ///                     target: Some(String::from("/app")),
468    ///                     ..Default::default()
469    ///                 },
470    ///                 ..Default::default()
471    ///             })].into(),
472    ///             ..Default::default()
473    ///         },
474    ///         ..Default::default()
475    ///     }
476    /// );
477    /// ```
478    pub fn parse_from_string(&mut self, input: &str) -> Result<Dofigen> {
479        self.merge_extended_image(
480            serde_yaml::from_str(input).map_err(|err| Error::Deserialize(err))?,
481        )
482    }
483
484    /// Parse an Dofigen from an IO stream.
485    ///
486    /// # Examples
487    ///
488    /// Basic struct
489    ///
490    /// ```
491    /// use dofigen_lib::*;
492    /// use pretty_assertions_sorted::assert_eq_sorted;
493    ///
494    /// let yaml = "
495    /// fromImage:
496    ///   path: ubuntu
497    /// ";
498    ///
499    /// let image: Dofigen = DofigenContext::new().parse_from_reader(yaml.as_bytes()).unwrap();
500    /// assert_eq_sorted!(
501    ///     image,
502    ///     Dofigen {
503    ///         stage: Stage {
504    ///             from: ImageName {
505    ///                 path: String::from("ubuntu"),
506    ///                 ..Default::default()
507    ///             }.into(),
508    ///             ..Default::default()
509    ///         },
510    ///         ..Default::default()
511    ///     }
512    /// );
513    /// ```
514    ///
515    /// Advanced image with builders and artifacts
516    ///
517    /// ```
518    /// use dofigen_lib::*;
519    /// use pretty_assertions_sorted::assert_eq_sorted;
520    /// use std::collections::HashMap;
521    ///
522    /// let yaml = r#"
523    /// builders:
524    ///   builder:
525    ///     fromImage:
526    ///       path: ekidd/rust-musl-builder
527    ///     copy:
528    ///       - paths: ["*"]
529    ///     run:
530    ///       - cargo build --release
531    /// fromImage:
532    ///     path: ubuntu
533    /// copy:
534    ///   - fromBuilder: builder
535    ///     paths:
536    ///       - /home/rust/src/target/x86_64-unknown-linux-musl/release/template-rust
537    ///     target: /app
538    /// "#;
539    /// let image: Dofigen = DofigenContext::new().parse_from_reader(yaml.as_bytes()).unwrap();
540    /// assert_eq_sorted!(
541    ///     image,
542    ///     Dofigen {
543    ///         builders: HashMap::from([
544    ///             ("builder".to_string(), Stage {
545    ///                 from: ImageName { path: "ekidd/rust-musl-builder".into(), ..Default::default() }.into(),
546    ///                 copy: vec![CopyResource::Copy(Copy{paths: vec!["*".into()].into(), ..Default::default()}).into()].into(),
547    ///                 run: Run {
548    ///                     run: vec!["cargo build --release".parse().unwrap()].into(),
549    ///                     ..Default::default()
550    ///                 },
551    ///                 ..Default::default()
552    ///             }),
553    ///         ]),
554    ///         stage: Stage {
555    ///             from: ImageName {
556    ///                 path: String::from("ubuntu"),
557    ///                 ..Default::default()
558    ///             }.into(),
559    ///             copy: vec![CopyResource::Copy(Copy {
560    ///                 from: FromContext::FromBuilder(String::from("builder")),
561    ///                 paths: vec![String::from(
562    ///                     "/home/rust/src/target/x86_64-unknown-linux-musl/release/template-rust"
563    ///                 )],
564    ///                 options: CopyOptions {
565    ///                     target: Some(String::from("/app")),
566    ///                     ..Default::default()
567    ///                 },
568    ///                 ..Default::default()
569    ///             })].into(),
570    ///             ..Default::default()
571    ///         },
572    ///         ..Default::default()
573    ///     }
574    /// );
575    /// ```
576    pub fn parse_from_reader<R: Read>(&mut self, reader: R) -> Result<Dofigen> {
577        self.merge_extended_image(
578            serde_yaml::from_reader(reader).map_err(|err| Error::Deserialize(err))?,
579        )
580    }
581
582    /// Parse an Dofigen from a Resource (File or Url)
583    ///
584    /// # Example
585    ///
586    /// ```
587    /// use dofigen_lib::*;
588    /// use pretty_assertions_sorted::assert_eq_sorted;
589    /// use std::path::PathBuf;
590    ///
591    /// let dofigen: Dofigen = DofigenContext::new().parse_from_resource(Resource::File(PathBuf::from("tests/cases/simple.yml"))).unwrap();
592    /// assert_eq_sorted!(
593    ///     dofigen,
594    ///     Dofigen {
595    ///         stage: Stage {
596    ///             from: ImageName {
597    ///                 path: String::from("alpine"),
598    ///                 ..Default::default()
599    ///             }.into(),
600    ///             ..Default::default()
601    ///         },
602    ///         ..Default::default()
603    ///     }
604    /// );
605    /// ```
606    pub fn parse_from_resource(&mut self, resource: Resource) -> Result<Dofigen> {
607        let dofigen = resource.load(self)?;
608        self.merge_extended_image(dofigen)
609    }
610
611    fn merge_extended_image(&mut self, dofigen: Extend<DofigenPatch>) -> Result<Dofigen> {
612        Ok(dofigen.merge(self)?.into())
613    }
614
615    pub fn clean_unused(&mut self) {
616        self.clean_unused_resources();
617        self.clean_unused_images();
618    }
619
620    //////////  Constructors  //////////
621
622    pub fn new() -> Self {
623        Self {
624            offline: false,
625            update_docker_tags: false,
626            update_file_resources: true,
627            update_url_resources: false,
628            display_updates: true,
629            no_default_labels: false,
630            load_resource_stack: vec![],
631            resources: HashMap::new(),
632            used_resources: HashSet::new(),
633            images: HashMap::new(),
634            used_images: HashSet::new(),
635        }
636    }
637
638    pub fn from(
639        resources: HashMap<Resource, ResourceVersion>,
640        images: HashMap<ImageName, DockerTag>,
641    ) -> Self {
642        Self {
643            offline: false,
644            update_docker_tags: false,
645            update_file_resources: true,
646            update_url_resources: false,
647            display_updates: true,
648            no_default_labels: false,
649            load_resource_stack: vec![],
650            resources,
651            used_resources: HashSet::new(),
652            images,
653            used_images: HashSet::new(),
654        }
655    }
656}
657
658#[derive(Debug, Deserialize, Clone, PartialEq, PartialOrd, Eq)]
659pub struct DockerHubTagResponse {
660    pub digest: Option<String>,
661    images: Vec<DockerTag>,
662}
663
664#[derive(PartialEq, PartialOrd, Eq)]
665pub enum UpdateCommand<K, V> {
666    Update(K, V, V),
667    Add(K, V),
668    Remove(K, V),
669}
670
671impl<K, V> Ord for UpdateCommand<K, V>
672where
673    K: Ord,
674    V: Ord,
675{
676    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
677        match (self, other) {
678            (
679                UpdateCommand::Add(a, _)
680                | UpdateCommand::Update(a, _, _)
681                | UpdateCommand::Remove(a, _),
682                UpdateCommand::Add(b, _)
683                | UpdateCommand::Update(b, _, _)
684                | UpdateCommand::Remove(b, _),
685            ) => a.cmp(b),
686        }
687    }
688}
689
690impl Ord for ImageName {
691    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
692        self.to_string().cmp(&other.to_string())
693    }
694}
695
696impl Ord for Resource {
697    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
698        match (self, other) {
699            (Resource::File(a), Resource::File(b)) => a.cmp(b),
700            (Resource::Url(a), Resource::Url(b)) => a.cmp(b),
701            (Resource::File(_), Resource::Url(_)) => std::cmp::Ordering::Less,
702            (Resource::Url(_), Resource::File(_)) => std::cmp::Ordering::Greater,
703        }
704    }
705}
706
707impl FromStr for Resource {
708    type Err = Error;
709
710    fn from_str(s: &str) -> Result<Self> {
711        if s.starts_with("http://") || s.starts_with("https://") {
712            Ok(Resource::Url(s.parse().map_err(Error::display)?))
713        } else {
714            Ok(Resource::File(s.into()))
715        }
716    }
717}