docker_pose/
parse.rs

1use crate::verbose::Verbosity;
2use crate::{DockerCommand, get_slug};
3use clap_num::number_range;
4use colored::*;
5use regex::Regex;
6use serde_yaml::{Error, Mapping, Value, to_string};
7use std::cmp::{max, min};
8use std::collections::BTreeMap;
9use std::path::Path;
10use std::sync::mpsc::{Receiver, Sender};
11use std::sync::{Arc, LazyLock, Mutex, mpsc};
12use std::{process, thread};
13
14static EMPTY_MAP: LazyLock<Mapping> = LazyLock::new(Mapping::default);
15static ENV_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\w+$").unwrap());
16static QUOTED_NUM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^'[0-9]+'$").unwrap());
17
18pub struct ComposeYaml {
19    map: BTreeMap<String, Value>,
20}
21
22#[derive(Clone)]
23pub struct ReplaceTag {
24    /// replace tag with local or remote tag if exists
25    pub tag: String,
26    /// don't replace with tag unless this regex matches the image name / tag,
27    /// in case the bool is false, the replacing is done if the regex doesn't match
28    pub tag_filter: Option<(Regex, bool)>,
29    /// docker may require to be logged-in to fetch some images info, with
30    /// `true` unauthorized errors are ignored
31    pub ignore_unauthorized: bool,
32    /// Don't slugify the value from tag.
33    pub no_slug: bool,
34    /// only check tag with the local docker registry
35    pub offline: bool,
36    /// verbosity used when fetching remote images info
37    pub verbosity: Verbosity,
38    /// show tags found while they are fetched
39    pub progress_verbosity: Verbosity,
40    /// max number of threads used to fetch remote images info
41    pub threads: u8,
42}
43
44impl ReplaceTag {
45    pub fn get_remote_tag(&self) -> String {
46        match self.no_slug {
47            true => self.tag.clone(),
48            false => get_slug(&self.tag),
49        }
50    }
51}
52
53impl ComposeYaml {
54    pub fn new(yaml: &str) -> Result<ComposeYaml, Error> {
55        let map = serde_yaml::from_str(yaml)?;
56        Ok(ComposeYaml { map })
57    }
58
59    pub fn to_string(&self) -> Result<String, Error> {
60        let yaml_string = to_string(&self.map)?;
61        Ok(yaml_string)
62    }
63
64    pub fn get_root_element(&self, element_name: &str) -> Option<&Mapping> {
65        let value = self.map.get(element_name);
66        value.map(|v| v.as_mapping()).unwrap_or_default()
67    }
68
69    pub fn get_root_element_names(&self, element_name: &str) -> Vec<&str> {
70        let elements = self.get_root_element(element_name).unwrap_or(&EMPTY_MAP);
71        elements
72            .keys()
73            .map(|k| k.as_str().unwrap())
74            .collect::<Vec<_>>()
75    }
76
77    pub fn get_services(&self) -> Option<&Mapping> {
78        self.get_root_element("services")
79    }
80
81    pub fn get_profiles_names(&self) -> Option<Vec<&str>> {
82        let services = self.get_services()?;
83        let mut profiles = services
84            .values()
85            .flat_map(|v| v.as_mapping())
86            .flat_map(|s| s.get("profiles"))
87            .flat_map(|p| p.as_sequence())
88            .flat_map(|seq| seq.iter())
89            .flat_map(|e| e.as_str())
90            .collect::<Vec<_>>();
91        profiles.sort();
92        profiles.dedup();
93        Some(profiles)
94    }
95
96    pub fn get_images(
97        &self,
98        filter_by_tag: Option<&str>,
99        tag: Option<&ReplaceTag>,
100    ) -> Option<Vec<String>> {
101        let services = self.get_services()?;
102        let mut images = services
103            .values()
104            .flat_map(|v| v.as_mapping())
105            .flat_map(|s| s.get("image"))
106            .flat_map(|p| p.as_str())
107            .filter(|image| match filter_by_tag {
108                None => true,
109                Some(tag) => {
110                    let image_parts = image.split(':').collect::<Vec<_>>();
111                    let image_tag = if image_parts.len() > 1 {
112                        *image_parts.get(1).unwrap()
113                    } else {
114                        "latest"
115                    };
116                    tag == image_tag
117                }
118            })
119            .collect::<Vec<_>>();
120        images.sort();
121        images.dedup();
122        if let Some(replace_tag) = tag {
123            let show_progress = matches!(replace_tag.verbosity, Verbosity::Verbose)
124                || matches!(replace_tag.progress_verbosity, Verbosity::Verbose);
125            let input = Arc::new(Mutex::new(
126                images
127                    .iter()
128                    .rev()
129                    .map(|e| e.to_string())
130                    .collect::<Vec<String>>(),
131            ));
132            let replace_arc = Arc::new(replace_tag.clone());
133            let mut updated_images: Vec<String> = Vec::with_capacity(images.len());
134            let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
135            let mut thread_children = Vec::new();
136            let nthreads = max(1, min(images.len(), replace_tag.threads as usize));
137            if matches!(replace_tag.verbosity, Verbosity::Verbose) {
138                eprintln!(
139                    "{}: spawning {} threads to fetch remote info from {} images",
140                    "DEBUG".green(),
141                    nthreads,
142                    images.len()
143                )
144            }
145            for _ in 0..nthreads {
146                let input = Arc::clone(&input);
147                let replace = Arc::clone(&replace_arc);
148                let thread_tx = tx.clone();
149                let child = thread::spawn(move || {
150                    loop {
151                        let last: Option<String>;
152                        {
153                            let mut v = input.lock().unwrap();
154                            last = v.pop(); // take one element out from the vec and free
155                        } // the vector lock so other threads can get it (drop of v happens here)
156                        if let Some(image) = last {
157                            let image_parts = image.split(':').collect::<Vec<_>>();
158                            let image_name = *image_parts.first().unwrap();
159                            let remote_image =
160                                format!("{}:{}", image_name, replace.get_remote_tag());
161                            if replace
162                                .tag_filter
163                                .as_ref()
164                                .map(|r| (r.1, r.0.is_match(&image)))
165                                .map(|(affirmative_expr, is_match)| {
166                                    (affirmative_expr && is_match)
167                                        || (!affirmative_expr && !is_match)
168                                })
169                                .unwrap_or(true)
170                            {
171                                // check whether the image:<tag> exists or not locally
172                                match Self::has_image(&replace, &remote_image, show_progress) {
173                                    true => thread_tx.send(remote_image).unwrap(),
174                                    false => match replace.offline {
175                                        true => thread_tx.send(image).unwrap(),
176                                        // if not exists locally, check remote registry
177                                        false => match Self::has_manifest(
178                                            &replace,
179                                            &remote_image,
180                                            show_progress,
181                                        ) {
182                                            true => thread_tx.send(remote_image).unwrap(),
183                                            false => thread_tx.send(image).unwrap(),
184                                        },
185                                    },
186                                }
187                            } else {
188                                // skip the remote check and add it as it is into the list
189                                if show_progress {
190                                    eprintln!(
191                                        "{}: manifest for image {} ... {} ",
192                                        "DEBUG".green(),
193                                        image_name.yellow(),
194                                        "skipped".bright_black()
195                                    );
196                                }
197                                thread_tx.send(image.to_string()).unwrap();
198                            }
199                        } else {
200                            break; // The vector got empty, all elements were processed
201                        }
202                    }
203                });
204                thread_children.push(child);
205            }
206            for _ in 0..images.len() {
207                let out = rx.recv().unwrap();
208                updated_images.push(out);
209            }
210            for child in thread_children {
211                child.join().unwrap_or_else(|e| {
212                    eprintln!(
213                        "{}: child thread panicked while fetching remote images info: {:?}",
214                        "ERROR".red(),
215                        e
216                    );
217                });
218            }
219            updated_images.sort();
220            return Some(updated_images);
221        }
222        Some(images.iter().map(|i| i.to_string()).collect::<Vec<_>>())
223    }
224
225    /// Returns whether the image exists locally, handling possible errors.
226    /// When the image exists, means the image exists for the
227    /// particular tag passed in the local registry.
228    fn has_image(replace_tag: &ReplaceTag, remote_image: &str, show_progress: bool) -> bool {
229        let command = DockerCommand::new(replace_tag.verbosity.clone());
230        let inspect_output = command.get_image_inspect(remote_image).unwrap_or_else(|e| {
231            eprintln!(
232                "{}: fetching image manifest locally for {}: {}",
233                "ERROR".red(),
234                remote_image,
235                e
236            );
237            process::exit(151);
238        });
239        if inspect_output.status.success() {
240            if show_progress {
241                eprintln!(
242                    "{}: manifest for image {} ... {} ",
243                    "DEBUG".green(),
244                    remote_image.yellow(),
245                    "found".green()
246                );
247            }
248            true
249        } else {
250            let exit_code = command.exit_code(&inspect_output);
251            let stderr = String::from_utf8(inspect_output.stderr).unwrap();
252            if stderr.to_lowercase().contains("no such image") {
253                if show_progress && replace_tag.offline {
254                    eprintln!(
255                        "{}: manifest for image {} ... {}",
256                        "DEBUG".green(),
257                        remote_image.yellow(),
258                        "not found".purple()
259                    );
260                }
261                false
262            } else {
263                eprintln!(
264                    "{}: fetching local image manifest for {}: {}",
265                    "ERROR".red(),
266                    remote_image,
267                    stderr
268                );
269                process::exit(exit_code);
270            }
271        }
272    }
273
274    /// Returns whether the manifest exists, handling possible errors.
275    /// When the manifest exists, means the image exists for the
276    /// particular tag passed in the remote registry.
277    fn has_manifest(replace_tag: &ReplaceTag, remote_image: &str, show_progress: bool) -> bool {
278        let command = DockerCommand::new(replace_tag.verbosity.clone());
279        let inspect_output = command
280            .get_manifest_inspect(remote_image)
281            .unwrap_or_else(|e| {
282                eprintln!(
283                    "{}: fetching image manifest for {}: {}",
284                    "ERROR".red(),
285                    remote_image,
286                    e
287                );
288                process::exit(151);
289            });
290        if inspect_output.status.success() {
291            if show_progress {
292                eprintln!(
293                    "{}: manifest for image {} ... {} ",
294                    "DEBUG".green(),
295                    remote_image.yellow(),
296                    "found".green()
297                );
298            }
299            true
300        } else {
301            let exit_code = command.exit_code(&inspect_output);
302            let stderr = String::from_utf8(inspect_output.stderr).unwrap();
303            if stderr.to_lowercase().contains("no such manifest")
304                || (replace_tag.ignore_unauthorized && stderr.contains("unauthorized:"))
305            {
306                if show_progress {
307                    eprintln!(
308                        "{}: manifest for image {} ... {}",
309                        "DEBUG".green(),
310                        remote_image.yellow(),
311                        "not found".purple()
312                    );
313                }
314                false
315            } else {
316                eprintln!(
317                    "{}: fetching image manifest for {}: {}",
318                    "ERROR".red(),
319                    remote_image,
320                    stderr
321                );
322                process::exit(exit_code);
323            }
324        }
325    }
326
327    /// Update all services' image attributes with the tag passed if the
328    /// tag exists locally or in the remote registry, otherwise
329    /// the image value is untouched.
330    pub fn update_images_tag(&mut self, replace_tag: &ReplaceTag) {
331        if let Some(images_with_remote) = self.get_images(None, Some(replace_tag)) {
332            let services_names = self
333                .get_root_element_names("services")
334                .iter()
335                .map(|s| s.to_string())
336                .collect::<Vec<_>>();
337            let services_op = self
338                .map
339                .get_mut("services")
340                .and_then(|v| v.as_mapping_mut());
341            if let Some(services) = services_op {
342                for service_name in services_names {
343                    let service = services.entry(Value::String(service_name.to_string()));
344                    service.and_modify(|serv| {
345                        if let Some(image_value) = serv.get_mut("image") {
346                            let image = image_value
347                                .as_str()
348                                .map(|i| i.to_string())
349                                .unwrap_or_default();
350                            let image_name = image.split(':').next().unwrap_or_default();
351                            let remote_image_op = images_with_remote.iter().find(|i| {
352                                let remote_image_name = i.split(':').next().unwrap_or_default();
353                                image_name == remote_image_name
354                            });
355                            if let Some(remote_image) = remote_image_op
356                                && remote_image != &image
357                                && let Value::String(string) = image_value
358                            {
359                                string.replace_range(.., remote_image);
360                            }
361                        }
362                    });
363                }
364            }
365        }
366    }
367
368    pub fn get_service(&self, service_name: &str) -> Option<&Mapping> {
369        let services = self.get_services()?;
370        let service = services.get(service_name);
371        service.map(|v| v.as_mapping()).unwrap_or_default()
372    }
373
374    /// Return the list of services found in a vector of tuples (name, service).
375    /// If the list is smaller than `service_names.len()`, means one or more
376    /// services don't exist
377    pub fn filter_services_by_names(&self, service_names: &[String]) -> Vec<(String, &Mapping)> {
378        let services = self.get_services();
379        let services = services.unwrap_or_else(|| &*EMPTY_MAP);
380        let mut list: Vec<(String, &Mapping)> = Vec::new();
381        for name in service_names {
382            let service = services.get(name);
383            if let Some(s) = service.and_then(|s| s.as_mapping()) {
384                list.push((name.to_string(), s));
385            }
386        }
387        list
388    }
389
390    /// Return the list of services found in a vector of tuples (name, service),
391    /// filtering by image tag name.
392    pub fn filter_services_by_image_tag(&self, filter_by_tag: &str) -> Vec<(String, &Mapping)> {
393        let services = self.get_services().unwrap_or_else(|| &*EMPTY_MAP);
394        let mut list: Vec<(String, &Mapping)> = Vec::new();
395        for service_name in services.keys().flat_map(|k| k.as_str()) {
396            let service = services
397                .get(service_name)
398                .and_then(|s| s.as_mapping())
399                .unwrap_or(&*EMPTY_MAP);
400            if let Some(img) = service.get("image").and_then(|v| v.as_str()) {
401                let image_parts = img.split(':').collect::<Vec<_>>();
402                let image_tag = if image_parts.len() > 1 {
403                    *image_parts.get(1).unwrap()
404                } else {
405                    "latest"
406                };
407                if image_tag == filter_by_tag {
408                    list.push((service_name.to_string(), service));
409                }
410            }
411        }
412        list
413    }
414
415    /// Like `filter_services_by_names`, but return the list of services if all
416    /// elements exist, otherwise it fails with a list of services not found.
417    pub fn get_services_by_names(
418        &self,
419        service_names: &[String],
420    ) -> Result<Vec<(String, &Mapping)>, Vec<String>> {
421        let services = self.filter_services_by_names(service_names);
422        if services.len() < service_names.len() {
423            let not_found = service_names
424                .iter()
425                .filter(|s| !services.iter().any(|(name, _)| *s == name))
426                .map(|s| s.to_string())
427                .collect::<Vec<_>>();
428            return Err(not_found);
429        }
430        Ok(services)
431    }
432
433    /// List of services that are dependencies of the list of services passed.
434    /// If some of the service names passed don't exist, return a list
435    /// of services not found as an error.
436    pub fn get_services_depends_on(
437        &self,
438        service_names: &[String],
439    ) -> Result<Vec<String>, Vec<String>> {
440        let services_list = self.get_services_by_names(service_names)?;
441        let mut all_deps_op: Vec<String> = vec![];
442        for (_, serv) in services_list {
443            let deps_op = self.get_service_depends_on(serv);
444            if let Some(deps) = deps_op {
445                deps.iter().for_each(|dep| {
446                    if !all_deps_op.contains(dep) && !service_names.contains(dep) {
447                        all_deps_op.push(dep.to_string())
448                    }
449                });
450            }
451        }
452        all_deps_op.sort();
453        Ok(all_deps_op)
454    }
455
456    /// Return the list of services that are dependents of the list of services passed.
457    pub fn get_services_dependants(&self, service_names: &[String]) -> Option<Vec<String>> {
458        let services = self.get_services()?;
459        let mut all_dependants: Vec<String> = vec![];
460        for (service_name, serv) in services {
461            if let Some(serv) = serv.as_mapping()
462                && let Some(service_name) = service_name.as_str().map(|s| s.to_string())
463                && !service_names.contains(&service_name)
464            {
465                let deps_op = self.get_service_depends_on(serv);
466                if let Some(deps) = deps_op {
467                    for dep in deps.iter() {
468                        if !all_dependants.contains(dep) && service_names.contains(dep) {
469                            all_dependants.push(service_name.clone())
470                        }
471                    }
472                }
473            }
474        }
475        all_dependants.sort();
476        Some(all_dependants)
477    }
478
479    pub fn get_service_envs(&self, service: &Mapping) -> Option<Vec<String>> {
480        let envs = service.get("environment")?;
481        match envs.as_sequence() {
482            Some(seq) => Some(
483                seq.iter()
484                    .map(|v| {
485                        let val = v.as_str().unwrap_or("");
486                        if ENV_NAME_REGEX.captures(val).is_some() {
487                            // Env variable without a value or "=" at the end
488                            format!("{val}=")
489                        } else {
490                            String::from(val)
491                        }
492                    })
493                    .collect::<Vec<_>>(),
494            ),
495            None => Some(
496                envs.as_mapping()
497                    .unwrap_or(&EMPTY_MAP)
498                    .into_iter()
499                    .map(|(k, v)| {
500                        let env = k.as_str().unwrap_or("".as_ref());
501                        let val = to_string(v).unwrap_or("".to_string());
502                        let val = val.trim_end();
503                        if val.contains(' ') {
504                            if val.contains('"') {
505                                format!("{env}='{val}'")
506                            } else {
507                                format!("{env}=\"{val}\"")
508                            }
509                        } else if QUOTED_NUM_REGEX.captures(val).is_some() {
510                            // remove unnecessary quotes
511                            let val = &val[1..val.len() - 1];
512                            format!("{env}={val}")
513                        } else {
514                            format!("{env}={val}")
515                        }
516                    })
517                    .collect::<Vec<_>>(),
518            ),
519        }
520    }
521
522    pub fn get_service_depends_on(&self, service: &Mapping) -> Option<Vec<String>> {
523        let depends = service.get("depends_on")?;
524        match depends.as_sequence() {
525            Some(seq) => Some(
526                seq.iter()
527                    .map(|el| el.as_str().unwrap_or(""))
528                    .filter(|o| !o.is_empty())
529                    .map(String::from)
530                    .collect::<Vec<_>>(),
531            ),
532            None => Some(
533                depends
534                    .as_mapping()
535                    .unwrap_or(&EMPTY_MAP)
536                    .keys()
537                    .map(|k| k.as_str().unwrap_or(""))
538                    .filter(|o| !o.is_empty())
539                    .map(String::from)
540                    .collect::<Vec<_>>(),
541            ),
542        }
543    }
544}
545
546// where to look for the compose file when the user
547// don't provide a path
548static COMPOSE_PATHS: [&str; 4] = [
549    "compose.yaml",
550    "compose.yml",
551    "docker-compose.yaml",
552    "docker-compose.yml",
553];
554
555pub fn get_compose_filename(
556    filename: Option<&str>,
557    verbosity: Verbosity,
558) -> Result<String, String> {
559    match filename {
560        Some(name) => {
561            if Path::new(&name).exists() {
562                Ok(String::from(name))
563            } else {
564                Err(format!("{}: no such file or directory", name))
565            }
566        }
567        None => {
568            let files = COMPOSE_PATHS.into_iter().filter(|f| Path::new(f).exists());
569            let files_count = files.clone().count();
570            match files_count {
571                0 => Err(format!(
572                    "Can't find a suitable configuration file in this directory.\n\
573                    Are you in the right directory?\n\n\
574                    Supported filenames: {}",
575                    COMPOSE_PATHS.into_iter().collect::<Vec<&str>>().join(", ")
576                )),
577                1 => {
578                    let filename_0 = files.map(String::from).next().unwrap();
579                    if matches!(verbosity, Verbosity::Verbose) {
580                        eprintln!("{}: Filename not provided", "DEBUG".green());
581                        eprintln!("{}: Using {}", "DEBUG".green(), filename_0);
582                    }
583                    Ok(filename_0)
584                }
585                _ => {
586                    let filenames = files.into_iter().collect::<Vec<&str>>();
587                    let filename = filenames.first().map(|s| s.to_string()).unwrap();
588                    if !matches!(verbosity, Verbosity::Quiet) {
589                        eprintln!(
590                            "{}: Found multiple config files with supported names: {}\n\
591                            {}: Using {}",
592                            "WARN".yellow(),
593                            filenames.join(", "),
594                            "WARN".yellow(),
595                            filename
596                        );
597                    }
598                    Ok(filename)
599                }
600            }
601        }
602    }
603}
604
605pub fn positive_less_than_32(s: &str) -> Result<u8, String> {
606    number_range(s, 1, 32)
607}
608
609pub fn string_no_empty(s: &str) -> Result<String, &'static str> {
610    if s.is_empty() {
611        return Err("must be at least 1 character long");
612    }
613    Ok(s.to_string())
614}
615
616/// Parser of strings in the form of "text1:text2".
617/// Return a tuple of 2 strings: ("text1, "text2").
618///
619/// ```
620/// use docker_pose::string_script;
621///
622/// assert_eq!(string_script("abc:def"), Ok(("abc".to_string(), "def".to_string())));
623/// assert_eq!(string_script("abc:"), Ok(("abc".to_string(), "".to_string())));
624/// assert_eq!(
625///     string_script("abc:def:more after->:"),
626///     Ok(("abc".to_string(), "def:more after->:".to_string()))
627/// );
628/// assert_eq!(string_script(""), Err("must be at least 2 characters long"));
629/// assert_eq!(string_script("a"), Err("must be at least 2 characters long"));
630/// assert_eq!(string_script("abc"), Err("separator symbol : not found in the expression"));
631/// assert_eq!(string_script(":def"), Err("empty left expression"));
632pub fn string_script(s: &str) -> Result<(String, String), &'static str> {
633    if s.len() < 2 {
634        return Err("must be at least 2 characters long");
635    }
636    let mut split = s.splitn(2, ':');
637    let left = split.next();
638    let right = split.next();
639    if let Some(left_text) = left {
640        if left_text == s {
641            return Err("separator symbol : not found in the expression");
642        }
643        if left_text.is_empty() {
644            return Err("empty left expression");
645        }
646        if let Some(right_text) = right {
647            return Ok((left_text.to_string(), right_text.to_string()));
648        }
649    }
650    // should never end here
651    Err("wrong expression")
652}
653
654/// Parser of headers in the form of "Name: value".
655/// Return a tuple of 2 strings: ("text1, "text2").
656///
657/// ```
658/// use docker_pose::header;
659///
660/// assert_eq!(header("abc: def"), Ok(("abc".to_string(), "def".to_string())));
661/// assert_eq!(header("a:b"), header("a: b"));
662/// assert_eq!(header("abc:"), Ok(("abc".to_string(), "".to_string())));
663/// assert_eq!(
664///     header("abc: def:more after->:"),
665///     Ok(("abc".to_string(), "def:more after->:".to_string()))
666/// );
667/// assert_eq!(header(""), Err("must be at least 3 characters long"));
668/// assert_eq!(header("a"), Err("must be at least 3 characters long"));
669/// assert_eq!(header("abc"), Err("separator symbol : not found in the header expression"));
670/// assert_eq!(header(":def"), Err("empty header name"));
671pub fn header(s: &str) -> Result<(String, String), &'static str> {
672    if s.len() < 3 {
673        return Err("must be at least 3 characters long");
674    }
675    let mut split = s.splitn(2, ':');
676    let left = split.next();
677    let right = split.next();
678    if let Some(left_text) = left {
679        if left_text == s {
680            return Err("separator symbol : not found in the header expression");
681        }
682        if left_text.is_empty() {
683            return Err("empty header name");
684        }
685        if let Some(right_text) = right {
686            return Ok((
687                left_text.trim_start().to_string(),
688                right_text.trim_start().to_string(),
689            ));
690        }
691    }
692    // should never end here
693    Err("wrong header expression")
694}