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 pub tag: String,
26 pub tag_filter: Option<(Regex, bool)>,
29 pub ignore_unauthorized: bool,
32 pub no_slug: bool,
34 pub offline: bool,
36 pub verbosity: Verbosity,
38 pub progress_verbosity: Verbosity,
40 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(); } 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 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 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 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; }
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 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 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 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 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 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 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 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 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 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 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
546static 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
616pub 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 Err("wrong expression")
652}
653
654pub 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 Err("wrong header expression")
694}