docker_pose/
parse.rs

1use crate::verbose::Verbosity;
2use crate::{get_slug, DockerCommand};
3use clap_num::number_range;
4use colored::*;
5use regex::Regex;
6use serde_yaml::{to_string, Error, Mapping, Value};
7use std::cmp::{max, min};
8use std::collections::BTreeMap;
9use std::path::Path;
10use std::sync::mpsc::{Receiver, Sender};
11use std::sync::{mpsc, Arc, Mutex};
12use std::{process, thread};
13
14lazy_static! {
15    static ref EMPTY_MAP: Mapping = Mapping::default();
16    static ref ENV_NAME_REGEX: Regex = Regex::new(r"^\w+$").unwrap();
17    static ref QUOTED_NUM_REGEX: Regex = Regex::new(r"^'[0-9]+'$").unwrap();
18}
19
20pub struct ComposeYaml {
21    map: BTreeMap<String, Value>,
22}
23
24#[derive(Clone)]
25pub struct ReplaceTag {
26    /// replace tag with local or remote tag if exists
27    pub tag: String,
28    /// don't replace with tag unless this regex matches the image name / tag,
29    /// in case the bool is false, the replacing is done if the regex doesn't match
30    pub tag_filter: Option<(Regex, bool)>,
31    /// docker may require to be logged-in to fetch some images info, with
32    /// `true` unauthorized errors are ignored
33    pub ignore_unauthorized: bool,
34    /// Don't slugify the value from tag.
35    pub no_slug: bool,
36    /// only check tag with the local docker registry
37    pub offline: bool,
38    /// verbosity used when fetching remote images info
39    pub verbosity: Verbosity,
40    /// show tags found while they are fetched
41    pub progress_verbosity: Verbosity,
42    /// max number of threads used to fetch remote images info
43    pub threads: u8,
44}
45
46impl ReplaceTag {
47    pub fn get_remote_tag(&self) -> String {
48        match self.no_slug {
49            true => self.tag.clone(),
50            false => get_slug(&self.tag),
51        }
52    }
53}
54
55impl ComposeYaml {
56    pub fn new(yaml: &str) -> Result<ComposeYaml, Error> {
57        let map = serde_yaml::from_str(yaml)?;
58        Ok(ComposeYaml { map })
59    }
60
61    pub fn to_string(&self) -> Result<String, Error> {
62        let yaml_string = to_string(&self.map)?;
63        Ok(yaml_string)
64    }
65
66    pub fn get_root_element(&self, element_name: &str) -> Option<&Mapping> {
67        let value = self.map.get(element_name);
68        value.map(|v| v.as_mapping()).unwrap_or_default()
69    }
70
71    pub fn get_root_element_names(&self, element_name: &str) -> Vec<&str> {
72        let elements = self.get_root_element(element_name).unwrap_or(&EMPTY_MAP);
73        elements
74            .keys()
75            .map(|k| k.as_str().unwrap())
76            .collect::<Vec<_>>()
77    }
78
79    pub fn get_services(&self) -> Option<&Mapping> {
80        self.get_root_element("services")
81    }
82
83    pub fn get_profiles_names(&self) -> Option<Vec<&str>> {
84        let services = self.get_services()?;
85        let mut profiles = services
86            .values()
87            .flat_map(|v| v.as_mapping())
88            .flat_map(|s| s.get("profiles"))
89            .flat_map(|p| p.as_sequence())
90            .flat_map(|seq| seq.iter())
91            .flat_map(|e| e.as_str())
92            .collect::<Vec<_>>();
93        profiles.sort();
94        profiles.dedup();
95        Some(profiles)
96    }
97
98    pub fn get_images(
99        &self,
100        filter_by_tag: Option<&str>,
101        tag: Option<&ReplaceTag>,
102    ) -> Option<Vec<String>> {
103        let services = self.get_services()?;
104        let mut images = services
105            .values()
106            .flat_map(|v| v.as_mapping())
107            .flat_map(|s| s.get("image"))
108            .flat_map(|p| p.as_str())
109            .filter(|image| match filter_by_tag {
110                None => true,
111                Some(tag) => {
112                    let image_parts = image.split(':').collect::<Vec<_>>();
113                    let image_tag = if image_parts.len() > 1 {
114                        *image_parts.get(1).unwrap()
115                    } else {
116                        "latest"
117                    };
118                    tag == image_tag
119                }
120            })
121            .collect::<Vec<_>>();
122        images.sort();
123        images.dedup();
124        if let Some(replace_tag) = tag {
125            let show_progress = matches!(replace_tag.verbosity, Verbosity::Verbose)
126                || matches!(replace_tag.progress_verbosity, Verbosity::Verbose);
127            let input = Arc::new(Mutex::new(
128                images
129                    .iter()
130                    .rev()
131                    .map(|e| e.to_string())
132                    .collect::<Vec<String>>(),
133            ));
134            let replace_arc = Arc::new(replace_tag.clone());
135            let mut updated_images: Vec<String> = Vec::with_capacity(images.len());
136            let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
137            let mut thread_children = Vec::new();
138            let nthreads = max(1, min(images.len(), replace_tag.threads as usize));
139            if matches!(replace_tag.verbosity, Verbosity::Verbose) {
140                eprintln!(
141                    "{}: spawning {} threads to fetch remote info from {} images",
142                    "DEBUG".green(),
143                    nthreads,
144                    images.len()
145                )
146            }
147            for _ in 0..nthreads {
148                let input = Arc::clone(&input);
149                let replace = Arc::clone(&replace_arc);
150                let thread_tx = tx.clone();
151                let child = thread::spawn(move || {
152                    loop {
153                        let mut v = input.lock().unwrap();
154                        let last = v.pop(); // take one element out from the vec and free
155                        drop(v); // the vector lock so other threads can get it
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                                if remote_image != &image {
357                                    if let Value::String(string) = image_value {
358                                        string.replace_range(.., remote_image);
359                                    }
360                                }
361                            }
362                        }
363                    });
364                }
365            }
366        }
367    }
368
369    pub fn get_service(&self, service_name: &str) -> Option<&Mapping> {
370        let services = self.get_services()?;
371        let service = services.get(service_name);
372        service.map(|v| v.as_mapping()).unwrap_or_default()
373    }
374
375    pub fn get_service_envs(&self, service: &Mapping) -> Option<Vec<String>> {
376        let envs = service.get("environment")?;
377        match envs.as_sequence() {
378            Some(seq) => Some(
379                seq.iter()
380                    .map(|v| {
381                        let val = v.as_str().unwrap_or("");
382                        if ENV_NAME_REGEX.captures(val).is_some() {
383                            // Env variable without a value or "=" at the end
384                            format!("{val}=")
385                        } else {
386                            String::from(val)
387                        }
388                    })
389                    .collect::<Vec<_>>(),
390            ),
391            None => Some(
392                envs.as_mapping()
393                    .unwrap_or(&EMPTY_MAP)
394                    .into_iter()
395                    .map(|(k, v)| {
396                        let env = k.as_str().unwrap_or("".as_ref());
397                        let val = to_string(v).unwrap_or("".to_string());
398                        let val = val.trim_end();
399                        if val.contains(' ') {
400                            if val.contains('"') {
401                                format!("{env}='{val}'")
402                            } else {
403                                format!("{env}=\"{val}\"")
404                            }
405                        } else if QUOTED_NUM_REGEX.captures(val).is_some() {
406                            // remove unnecessary quotes
407                            let val = &val[1..val.len() - 1];
408                            format!("{env}={val}")
409                        } else {
410                            format!("{env}={val}")
411                        }
412                    })
413                    .collect::<Vec<_>>(),
414            ),
415        }
416    }
417
418    pub fn get_service_depends_on(&self, service: &Mapping) -> Option<Vec<String>> {
419        let depends = service.get("depends_on")?;
420        match depends.as_sequence() {
421            Some(seq) => Some(
422                seq.iter()
423                    .map(|el| el.as_str().unwrap_or(""))
424                    .filter(|o| !o.is_empty())
425                    .map(String::from)
426                    .collect::<Vec<_>>(),
427            ),
428            None => Some(
429                depends
430                    .as_mapping()
431                    .unwrap_or(&EMPTY_MAP)
432                    .keys()
433                    .map(|k| k.as_str().unwrap_or(""))
434                    .filter(|o| !o.is_empty())
435                    .map(String::from)
436                    .collect::<Vec<_>>(),
437            ),
438        }
439    }
440}
441
442// where to look for the compose file when the user
443// don't provide a path
444static COMPOSE_PATHS: [&str; 4] = [
445    "compose.yaml",
446    "compose.yml",
447    "docker-compose.yaml",
448    "docker-compose.yml",
449];
450
451pub fn get_compose_filename(
452    filename: Option<&str>,
453    verbosity: Verbosity,
454) -> Result<String, String> {
455    match filename {
456        Some(name) => {
457            if Path::new(&name).exists() {
458                Ok(String::from(name))
459            } else {
460                Err(format!(
461                    "{}: {}: no such file or directory",
462                    "ERROR".red(),
463                    name
464                ))
465            }
466        }
467        None => {
468            let files = COMPOSE_PATHS.into_iter().filter(|f| Path::new(f).exists());
469            let files_count = files.clone().count();
470            match files_count {
471                0 => Err(format!(
472                    "Can't find a suitable configuration file in this directory.\n\
473                    Are you in the right directory?\n\n\
474                    Supported filenames: {}",
475                    COMPOSE_PATHS.into_iter().collect::<Vec<&str>>().join(", ")
476                )),
477                1 => {
478                    let filename_0 = files.map(String::from).next().unwrap();
479                    if matches!(verbosity, Verbosity::Verbose) {
480                        eprintln!("{}: Filename not provided", "DEBUG".green());
481                        eprintln!("{}: Using {}", "DEBUG".green(), filename_0);
482                    }
483                    Ok(filename_0)
484                }
485                _ => {
486                    let filenames = files.into_iter().collect::<Vec<&str>>();
487                    let filename = filenames.first().map(|s| s.to_string()).unwrap();
488                    if !matches!(verbosity, Verbosity::Quiet) {
489                        eprintln!(
490                            "{}: Found multiple config files with supported names: {}\n\
491                            {}: Using {}",
492                            "WARN".yellow(),
493                            filenames.join(", "),
494                            "WARN".yellow(),
495                            filename
496                        );
497                    }
498                    Ok(filename)
499                }
500            }
501        }
502    }
503}
504
505pub fn positive_less_than_32(s: &str) -> Result<u8, String> {
506    number_range(s, 1, 32)
507}
508
509pub fn string_no_empty(s: &str) -> Result<String, &'static str> {
510    if s.is_empty() {
511        return Err("must be at least 1 character long");
512    }
513    Ok(s.to_string())
514}
515
516/// Parser of strings in the form of "text1:text2".
517/// Return a tuple of 2 strings: ("text1, "text2").
518///
519/// ```
520/// use docker_pose::string_script;
521///
522/// assert_eq!(string_script("abc:def"), Ok(("abc".to_string(), "def".to_string())));
523/// assert_eq!(string_script("abc:"), Ok(("abc".to_string(), "".to_string())));
524/// assert_eq!(
525///     string_script("abc:def:more after->:"),
526///     Ok(("abc".to_string(), "def:more after->:".to_string()))
527/// );
528/// assert_eq!(string_script(""), Err("must be at least 2 characters long"));
529/// assert_eq!(string_script("a"), Err("must be at least 2 characters long"));
530/// assert_eq!(string_script("abc"), Err("separator symbol : not found in the expression"));
531/// assert_eq!(string_script(":def"), Err("empty left expression"));
532pub fn string_script(s: &str) -> Result<(String, String), &'static str> {
533    if s.len() < 2 {
534        return Err("must be at least 2 characters long");
535    }
536    let mut split = s.splitn(2, ':');
537    let left = split.next();
538    let right = split.next();
539    if let Some(left_text) = left {
540        if left_text == s {
541            return Err("separator symbol : not found in the expression");
542        }
543        if left_text.is_empty() {
544            return Err("empty left expression");
545        }
546        if let Some(right_text) = right {
547            return Ok((left_text.to_string(), right_text.to_string()));
548        }
549    }
550    // should never end here
551    Err("wrong expression")
552}
553
554/// Parser of headers in the form of "Name: value".
555/// Return a tuple of 2 strings: ("text1, "text2").
556///
557/// ```
558/// use docker_pose::header;
559///
560/// assert_eq!(header("abc: def"), Ok(("abc".to_string(), "def".to_string())));
561/// assert_eq!(header("a:b"), header("a: b"));
562/// assert_eq!(header("abc:"), Ok(("abc".to_string(), "".to_string())));
563/// assert_eq!(
564///     header("abc: def:more after->:"),
565///     Ok(("abc".to_string(), "def:more after->:".to_string()))
566/// );
567/// assert_eq!(header(""), Err("must be at least 3 characters long"));
568/// assert_eq!(header("a"), Err("must be at least 3 characters long"));
569/// assert_eq!(header("abc"), Err("separator symbol : not found in the header expression"));
570/// assert_eq!(header(":def"), Err("empty header name"));
571pub fn header(s: &str) -> Result<(String, String), &'static str> {
572    if s.len() < 3 {
573        return Err("must be at least 3 characters long");
574    }
575    let mut split = s.splitn(2, ':');
576    let left = split.next();
577    let right = split.next();
578    if let Some(left_text) = left {
579        if left_text == s {
580            return Err("separator symbol : not found in the header expression");
581        }
582        if left_text.is_empty() {
583            return Err("empty header name");
584        }
585        if let Some(right_text) = right {
586            return Ok((
587                left_text.trim_start().to_string(),
588                right_text.trim_start().to_string(),
589            ));
590        }
591    }
592    // should never end here
593    Err("wrong header expression")
594}