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 pub tag: String,
28 pub tag_filter: Option<(Regex, bool)>,
31 pub ignore_unauthorized: bool,
34 pub no_slug: bool,
36 pub offline: bool,
38 pub verbosity: Verbosity,
40 pub progress_verbosity: Verbosity,
42 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(); drop(v); 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 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 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 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
442static 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
516pub 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 Err("wrong expression")
552}
553
554pub 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 Err("wrong header expression")
594}