1use std::collections::{HashMap, HashSet};
2
3use crate::dofigen_struct::*;
4
5const WARN_MESSAGE_FROM_CONTEXT: &str =
6 "Prefer to use fromImage and fromBuilder instead of fromContext";
7const WARN_MESSAGE_FROM_CONTEXT_UNLESS: &str = "(unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)";
8
9#[derive(Debug, Clone, PartialEq)]
10struct StageDependency {
11 stage: String,
12 path: String,
13 origin: Vec<String>,
14}
15
16macro_rules! linter_path {
17 ($session:expr_2021, $part:expr_2021, $block:block) => {
18 $session.push_path_part($part);
19 $block
20 $session.pop_path_part();
21 };
22}
23
24trait Linter {
25 fn analyze(&self, session: &mut LintSession);
26}
27
28impl Linter for Dofigen {
29 fn analyze(&self, session: &mut LintSession) {
30 linter_path!(session, "builders".into(), {
31 for (name, builder) in self.builders.iter() {
32 linter_path!(session, name.clone(), {
33 if name == "runtime" {
34 session.add_message(
35 MessageLevel::Error,
36 "The builder name 'runtime' is reserved".into(),
37 );
38 }
39 builder.analyze(session);
40 });
41 }
42 });
43
44 self.stage.analyze(session);
45
46 if let Some(user) = &self.stage.user {
48 if user.user == "root" || user.uid() == Some(0) {
49 session.messages.push(LintMessage {
50 level: MessageLevel::Warn,
51 message: "The runtime user should not be root".into(),
52 path: vec!["user".into()],
53 });
54 }
55 }
56
57 session.check_dependencies();
58 }
59}
60
61impl Linter for Stage {
62 fn analyze(&self, session: &mut LintSession) {
63 let name = session.current_path.last().cloned();
64
65 if let Some(name) = name.clone() {
67 if self.copy.is_empty() && self.run.run.is_empty() && self.root.is_none() {
68 session.add_message(
69 MessageLevel::Warn,
70 format!("The builder '{}' is empty and should be removed", name),
71 );
72 }
73 }
74
75 let name = name.unwrap_or("runtime".to_string());
76
77 let dependencies = self.get_dependencies(&session.current_path);
78 session.messages.append(
79 &mut dependencies
80 .iter()
81 .filter(|dep| dep.stage == "runtime")
82 .map(|dep| LintMessage {
83 level: MessageLevel::Error,
84 message: format!("The stage '{}' can't depend on the 'runtime'", &name,),
85 path: dep.origin.clone(),
86 })
87 .collect(),
88 );
89 let cache_paths = session.get_stage_cache_paths(self);
90 session.stage_infos.insert(
91 name,
92 StageLintInfo {
93 dependencies,
94 cache_paths,
95 },
96 );
97
98 if let FromContext::FromContext(Some(_)) = self.from {
100 linter_path!(session, "fromContext".into(), {
101 session.add_message(MessageLevel::Warn, WARN_MESSAGE_FROM_CONTEXT.to_string());
102 });
103 }
104
105 linter_path!(session, "copy".into(), {
106 for (position, copy) in self.copy.iter().enumerate() {
107 linter_path!(session, position.to_string(), {
108 copy.analyze(session);
109 });
110 }
111 });
112
113 if let Some(root) = &self.root {
114 linter_path!(session, "root".into(), {
115 root.analyze(session);
116 });
117 }
118
119 self.run.analyze(session);
120
121 if let Some(user) = &self.user {
123 if user.uid().is_none() {
124 linter_path!(session, "user".into(), {
125 session.add_message(
126 MessageLevel::Warn,
127 "UID should be used instead of username".to_string(),
128 );
129 });
130 }
131 }
132 }
133}
134
135impl Linter for CopyResource {
136 fn analyze(&self, session: &mut LintSession) {
137 match self {
138 CopyResource::Copy(copy) => copy.analyze(session),
139 _ => {}
140 }
141 }
142}
143
144impl Linter for Copy {
145 fn analyze(&self, session: &mut LintSession) {
146 match &self.from {
147 FromContext::FromContext(Some(_)) => {
148 linter_path!(session, "fromContext".into(), {
149 session.add_message(
150 MessageLevel::Warn,
151 format!(
152 "{} {}",
153 WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS
154 ),
155 );
156 });
157 }
158 _ => {}
159 }
160 }
161}
162
163impl Linter for Run {
164 fn analyze(&self, session: &mut LintSession) {
165 if self.run.is_empty() {
166 if !self.bind.is_empty() {
167 linter_path!(session, "bind".into(), {
168 session.add_message(
169 MessageLevel::Warn,
170 "The run list is empty but there are bind definitions".to_string(),
171 );
172 });
173 }
174
175 if !self.cache.is_empty() {
176 linter_path!(session, "cache".into(), {
177 session.add_message(
178 MessageLevel::Warn,
179 "The run list is empty but there are cache definitions".to_string(),
180 );
181 });
182 }
183 }
184
185 linter_path!(session, "run".into(), {
186 for (position, command) in self.run.iter().enumerate() {
187 linter_path!(session, position.to_string(), {
188 if command.starts_with("cd ") {
189 session.add_message(
190 MessageLevel::Warn,
191 "Avoid using 'cd' in the run command".to_string(),
192 );
193 }
194 });
195 }
196 });
197
198 linter_path!(session, "bind".into(), {
199 for (position, bind) in self.bind.iter().enumerate() {
200 linter_path!(session, position.to_string(), {
201 if let FromContext::FromContext(Some(_)) = bind.from {
202 linter_path!(session, "fromContext".into(), {
203 session.add_message(
204 MessageLevel::Warn,
205 format!(
206 "{} {}",
207 WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS
208 ),
209 );
210 });
211 }
212 });
213 }
214 });
215
216 linter_path!(session, "cache".into(), {
217 for (position, cache) in self.cache.iter().enumerate() {
218 linter_path!(session, position.to_string(), {
219 if let FromContext::FromContext(Some(_)) = cache.from {
220 linter_path!(session, "fromContext".into(), {
221 session.add_message(
222 MessageLevel::Warn,
223 format!(
224 "{} {}",
225 WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS
226 ),
227 );
228 });
229 }
230 });
231 }
232 });
233 }
234}
235
236trait StageDependencyGetter {
237 fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency>;
238}
239
240impl StageDependencyGetter for Stage {
241 fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
242 let mut dependencies = vec![];
243 if let FromContext::FromBuilder(builder) = &self.from {
244 dependencies.push(StageDependency {
245 stage: builder.clone(),
246 path: "/".into(),
247 origin: [origin.clone(), vec!["from".into()]].concat(),
248 });
249 }
250 for (position, copy) in self.copy.iter().enumerate() {
251 dependencies.append(&mut copy.get_dependencies(
252 &[origin.clone(), vec!["copy".into(), position.to_string()]].concat(),
253 ));
254 }
255 dependencies.append(&mut self.run.get_dependencies(origin));
256 if let Some(root) = &self.root {
257 dependencies.append(
258 &mut root.get_dependencies(&[origin.clone(), vec!["root".into()]].concat()),
259 );
260 }
261 dependencies
262 }
263}
264
265impl StageDependencyGetter for Run {
266 fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
267 let mut dependencies = vec![];
268 for (position, cache) in self.cache.iter().enumerate() {
269 if let FromContext::FromBuilder(builder) = &cache.from {
270 dependencies.push(StageDependency {
271 stage: builder.clone(),
272 path: cache.source.clone().unwrap_or("/".into()),
273 origin: [origin.clone(), vec!["cache".into(), position.to_string()]].concat(),
274 });
275 }
276 }
277 for (position, bind) in self.bind.iter().enumerate() {
278 if let FromContext::FromBuilder(builder) = &bind.from {
279 dependencies.push(StageDependency {
280 stage: builder.clone(),
281 path: bind.source.clone().unwrap_or("/".into()),
282 origin: [origin.clone(), vec!["bind".into(), position.to_string()]].concat(),
283 });
284 }
285 }
286 dependencies
287 }
288}
289
290impl StageDependencyGetter for CopyResource {
291 fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
292 match self {
293 CopyResource::Copy(copy) => match ©.from {
294 FromContext::FromBuilder(builder) => copy
295 .paths
296 .iter()
297 .map(|path| StageDependency {
298 stage: builder.clone(),
299 path: path.clone(),
300 origin: origin.clone(),
301 })
302 .collect(),
303 _ => vec![],
304 },
305 _ => vec![],
306 }
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Default)]
311pub struct LintSession {
312 current_path: Vec<String>,
313 messages: Vec<LintMessage>,
314 stage_infos: HashMap<String, StageLintInfo>,
315 recursive_stage_dependencies: HashMap<String, Vec<String>>,
316}
317
318impl LintSession {
319 fn push_path_part(&mut self, part: String) {
320 self.current_path.push(part);
321 }
322
323 fn pop_path_part(&mut self) {
324 self.current_path.pop();
325 }
326
327 fn add_message(&mut self, level: MessageLevel, message: String) {
328 self.messages.push(LintMessage {
329 level,
330 message,
331 path: self.current_path.clone(),
332 });
333 }
334
335 pub fn messages(&self) -> Vec<LintMessage> {
336 self.messages.clone()
337 }
338
339 pub fn get_sorted_builders(&mut self) -> Vec<String> {
340 let mut stages: HashMap<String, Vec<String>> = self
341 .stage_infos
342 .clone()
343 .keys()
344 .map(|name| {
345 (
346 name.clone(),
347 self.get_stage_recursive_dependencies(name.clone()),
348 )
349 })
350 .collect();
351
352 let mut sorted: Vec<String> = vec![];
353
354 loop {
355 let mut part: Vec<String> = stages
356 .extract_if(|_name, deps| deps.is_empty())
357 .map(|(name, _deps)| name)
358 .collect();
359
360 if part.is_empty() {
361 break;
363 }
364
365 part.sort();
366
367 for name in part.iter() {
368 for deps in stages.values_mut() {
369 deps.retain(|dep| dep != name);
370 }
371 }
372
373 sorted.append(&mut part);
374
375 if stages.is_empty() {
376 break;
377 }
378 }
379
380 sorted
381 .into_iter()
382 .filter(|name| name != "runtime")
383 .collect()
384 }
385
386 pub fn get_stage_recursive_dependencies(&mut self, stage: String) -> Vec<String> {
387 self.resolve_stage_recursive_dependencies(&mut vec![stage])
388 }
389
390 fn resolve_stage_recursive_dependencies(&mut self, path: &mut Vec<String>) -> Vec<String> {
391 let stage = &path.last().expect("The path is empty").clone();
392 if let Some(dependencies) = self.recursive_stage_dependencies.get(stage) {
393 return dependencies.clone();
394 }
395 let mut deps = HashSet::new();
396 let dependencies = self
397 .stage_infos
398 .get(stage)
399 .expect(format!("The stage info not found for stage '{}'", stage).as_str())
400 .dependencies
401 .clone();
402 for dependency in dependencies {
403 let dep_stage = &dependency.stage;
404 if path.contains(dep_stage) {
405 self.messages.push(LintMessage {
406 level: MessageLevel::Error,
407 message: format!(
408 "Circular dependency detected: {} -> {}",
409 path.join(" -> "),
410 dependency.stage
411 ),
412 path: dependency.origin.clone(),
413 });
414 continue;
415 }
416 deps.insert(dep_stage.clone());
417 if self.stage_infos.contains_key(dep_stage) {
418 path.push(dep_stage.clone());
419 deps.extend(self.resolve_stage_recursive_dependencies(path));
420 path.pop();
421 } }
423 let deps: Vec<String> = deps.into_iter().collect();
424 self.recursive_stage_dependencies
425 .insert(stage.clone(), deps.clone());
426 deps
427 }
428
429 fn check_dependencies(&mut self) {
431 let dependencies = self
432 .stage_infos
433 .values()
434 .flat_map(|info| info.dependencies.clone())
435 .collect::<Vec<_>>();
436
437 let caches = self
438 .stage_infos
439 .iter()
440 .map(|(name, info)| (name.clone(), info.cache_paths.clone()))
441 .collect::<HashMap<_, _>>();
442
443 let used_builders = dependencies
445 .iter()
446 .map(|dep| dep.stage.clone())
447 .collect::<HashSet<_>>();
448
449 let unused_builders = self
450 .stage_infos
451 .keys()
452 .filter(|name| name != &"runtime")
453 .map(|name| name.clone())
454 .filter(|name| !used_builders.contains(name))
455 .collect::<HashSet<_>>();
456
457 linter_path!(self, "builders".into(), {
458 for builder in unused_builders {
459 linter_path!(self, builder.clone(), {
460 self.add_message(
461 MessageLevel::Warn,
462 format!(
463 "The builder '{}' is not used and should be removed",
464 builder
465 ),
466 );
467 });
468 }
469 });
470
471 for dependency in dependencies {
472 if let Some(paths) = caches.get(&dependency.stage) {
473 paths
474 .iter()
475 .filter(|path| dependency.path.starts_with(*path))
476 .for_each(|path| {
477 self.messages.push(LintMessage {
478 level: MessageLevel::Error,
479 message: format!(
480 "Use of the '{}' builder cache path '{}'",
481 dependency.stage, path
482 ),
483 path: dependency.origin.clone(),
484 });
485 });
486 } else {
487 self.messages.push(LintMessage {
488 level: MessageLevel::Error,
489 message: format!("The builder '{}' not found", dependency.stage),
490 path: dependency.origin.clone(),
491 });
492 }
493 }
494 }
495
496 fn get_stage_cache_paths(&mut self, stage: &Stage) -> Vec<String> {
497 let mut paths = vec![];
498 paths.append(&mut self.get_run_cache_paths(
499 &stage.run,
500 &self.current_path.clone(),
501 &stage.workdir,
502 ));
503 if let Some(root) = &stage.root {
504 paths.append(&mut self.get_run_cache_paths(
505 root,
506 &[self.current_path.clone(), vec!["root".into()]].concat(),
507 &stage.workdir,
508 ));
509 }
510 paths
511 }
512
513 fn get_run_cache_paths(
514 &mut self,
515 run: &Run,
516 path: &Vec<String>,
517 workdir: &Option<String>,
518 ) -> Vec<String> {
519 let mut cache_paths = vec![];
520 for (position, cache) in run.cache.iter().enumerate() {
521 let target = cache.target.clone();
522 cache_paths.push(if target.starts_with("/") {
523 target.clone()
524 } else {
525 if let Some(workdir) = workdir {
526 format!("{}/{}", workdir, target)
527 }
528 else {
529 self.messages.push(LintMessage {
530 level: MessageLevel::Warn,
531 message: "The cache target should be absolute or a workdir should be defined in the stage".to_string(),
532 path: [path.clone(), vec!["cache".into(), position.to_string()]].concat(),
533 });
534 target.clone()
535 }
536 });
537 }
538 cache_paths
539 }
540
541 pub fn analyze(dofigen: &Dofigen) -> Self {
545 let mut session = Self::default();
546 dofigen.analyze(&mut session);
547
548 session
549 }
550}
551
552#[derive(Debug, Clone, PartialEq)]
553pub struct StageLintInfo {
554 dependencies: Vec<StageDependency>,
555 cache_paths: Vec<String>,
556}
557
558#[derive(Debug, Clone, PartialEq)]
559pub struct LintMessage {
560 pub level: MessageLevel,
561 pub path: Vec<String>,
562 pub message: String,
563}
564
565#[derive(Debug, Clone, PartialEq)]
566pub enum MessageLevel {
567 Warn,
568 Error,
569}
570
571#[cfg(test)]
572mod test {
573 use crate::Dofigen;
574
575 use super::*;
576 use pretty_assertions_sorted::assert_eq_sorted;
577
578 mod stage_dependencies {
579 use super::*;
580
581 #[test]
582 fn builders_dependencies() {
583 let dofigen = Dofigen {
584 builders: HashMap::from([
585 (
586 "builder1".into(),
587 Stage {
588 copy: vec![CopyResource::Copy(Copy {
589 from: FromContext::FromBuilder("builder2".into()),
590 paths: vec!["/path/to/copy".into()],
591 options: Default::default(),
592 ..Default::default()
593 })],
594 ..Default::default()
595 },
596 ),
597 (
598 "builder2".into(),
599 Stage {
600 copy: vec![CopyResource::Copy(Copy {
601 from: FromContext::FromBuilder("builder3".into()),
602 paths: vec!["/path/to/copy".into()],
603 options: Default::default(),
604 ..Default::default()
605 })],
606 ..Default::default()
607 },
608 ),
609 (
610 "builder3".into(),
611 Stage {
612 run: Run {
613 run: vec!["echo Hello".into()].into(),
614 ..Default::default()
615 },
616 ..Default::default()
617 },
618 ),
619 ]),
620 stage: Stage {
621 copy: vec![CopyResource::Copy(Copy {
622 from: FromContext::FromBuilder("builder1".into()),
623 paths: vec!["/path/to/copy".into()],
624 ..Default::default()
625 })],
626 ..Default::default()
627 },
628 ..Default::default()
629 };
630
631 let mut lint_session = LintSession::analyze(&dofigen);
632
633 let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
634 dependencies.sort();
635 assert_eq_sorted!(dependencies, vec!["builder1", "builder2", "builder3"]);
636
637 dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
638 dependencies.sort();
639 assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
640
641 dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
642 assert_eq_sorted!(dependencies, vec!["builder3"]);
643
644 dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
645 assert_eq_sorted!(dependencies, Vec::<String>::new());
646
647 let builders = lint_session.get_sorted_builders();
648
649 assert_eq_sorted!(builders, vec!["builder3", "builder2", "builder1",]);
650
651 assert_eq_sorted!(lint_session.messages, vec![]);
652 }
653
654 #[test]
655 fn builders_circular_dependencies() {
656 let dofigen = Dofigen {
657 builders: HashMap::from([
658 (
659 "builder1".into(),
660 Stage {
661 copy: vec![CopyResource::Copy(Copy {
662 from: FromContext::FromBuilder("builder2".into()),
663 paths: vec!["/path/to/copy".into()],
664 options: Default::default(),
665 ..Default::default()
666 })],
667 ..Default::default()
668 },
669 ),
670 (
671 "builder2".into(),
672 Stage {
673 copy: vec![CopyResource::Copy(Copy {
674 from: FromContext::FromBuilder("builder3".into()),
675 paths: vec!["/path/to/copy".into()],
676 options: Default::default(),
677 ..Default::default()
678 })],
679 ..Default::default()
680 },
681 ),
682 (
683 "builder3".into(),
684 Stage {
685 copy: vec![CopyResource::Copy(Copy {
686 from: FromContext::FromBuilder("builder1".into()),
687 paths: vec!["/path/to/copy".into()],
688 options: Default::default(),
689 ..Default::default()
690 })],
691 ..Default::default()
692 },
693 ),
694 ]),
695 ..Default::default()
696 };
697
698 let mut lint_session = LintSession::analyze(&dofigen);
699
700 let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
701 dependencies.sort();
702 assert_eq_sorted!(dependencies, Vec::<String>::new());
703
704 dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
705 dependencies.sort();
706 assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
707
708 dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
709 assert_eq_sorted!(dependencies, vec!["builder3"]);
710
711 dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
712 assert_eq_sorted!(dependencies, Vec::<String>::new());
713
714 let mut builders = lint_session.get_sorted_builders();
715 builders.sort();
716
717 assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]);
718
719 assert_eq_sorted!(
720 lint_session.messages,
721 vec![LintMessage {
722 level: MessageLevel::Error,
723 path: vec![
724 "builders".into(),
725 "builder3".into(),
726 "copy".into(),
727 "0".into(),
728 ],
729 message:
730 "Circular dependency detected: builder1 -> builder2 -> builder3 -> builder1"
731 .into(),
732 },]
733 );
734 }
735
736 #[test]
737 fn builder_named_runtime() {
738 let dofigen = Dofigen {
739 builders: HashMap::from([(
740 "runtime".into(),
741 Stage {
742 run: Run {
743 run: vec!["echo Hello".into()].into(),
744 ..Default::default()
745 },
746 ..Default::default()
747 },
748 )]),
749 ..Default::default()
750 };
751
752 let mut lint_session = LintSession::analyze(&dofigen);
753
754 let mut builders = lint_session.get_sorted_builders();
755 builders.sort();
756
757 assert_eq_sorted!(builders, Vec::<String>::new());
758
759 assert_eq_sorted!(
760 lint_session.messages,
761 vec![LintMessage {
762 level: MessageLevel::Error,
763 path: vec!["builders".into(), "runtime".into(),],
764 message: "The builder name 'runtime' is reserved".into(),
765 },]
766 );
767 }
768
769 #[test]
770 fn builder_not_found() {
771 let dofigen = Dofigen {
772 stage: Stage {
773 from: FromContext::FromBuilder("builder1".into()),
774 ..Default::default()
775 },
776 ..Default::default()
777 };
778
779 let mut lint_session = LintSession::analyze(&dofigen);
780
781 let builders = lint_session.get_sorted_builders();
782
783 assert_eq_sorted!(builders, Vec::<String>::new());
784
785 assert_eq_sorted!(
786 lint_session.messages,
787 vec![LintMessage {
788 level: MessageLevel::Error,
789 path: vec!["from".into(),],
790 message: "The builder 'builder1' not found".into(),
791 },]
792 );
793 }
794
795 #[test]
796 fn dependency_to_runtime() {
797 let dofigen = Dofigen {
798 builders: HashMap::from([(
799 "builder".into(),
800 Stage {
801 copy: vec![CopyResource::Copy(Copy {
802 from: FromContext::FromBuilder("runtime".into()),
803 paths: vec!["/path/to/copy".into()],
804 ..Default::default()
805 })],
806 ..Default::default()
807 },
808 )]),
809 stage: Stage {
810 run: Run {
811 run: vec!["echo Hello".into()].into(),
812 ..Default::default()
813 },
814 ..Default::default()
815 },
816 ..Default::default()
817 };
818
819 let mut lint_session = LintSession::analyze(&dofigen);
820
821 let mut builders = lint_session.get_sorted_builders();
822 builders.sort();
823
824 assert_eq_sorted!(builders, vec!["builder"]);
825
826 assert_eq_sorted!(
827 lint_session.messages,
828 vec![
829 LintMessage {
830 level: MessageLevel::Error,
831 path: vec![
832 "builders".into(),
833 "builder".into(),
834 "copy".into(),
835 "0".into()
836 ],
837 message: "The stage 'builder' can't depend on the 'runtime'".into(),
838 },
839 LintMessage {
840 level: MessageLevel::Warn,
841 path: vec!["builders".into(), "builder".into(),],
842 message: "The builder 'builder' is not used and should be removed".into(),
843 }
844 ]
845 );
846 }
847
848 #[test]
849 fn dependency_to_cache_path() {
850 let dofigen = Dofigen {
851 builders: HashMap::from([
852 (
853 "builder1".into(),
854 Stage {
855 run: Run {
856 run: vec!["echo Hello".into()].into(),
857 cache: vec![Cache {
858 target: "/path/to/cache".into(),
859 ..Default::default()
860 }],
861 ..Default::default()
862 },
863 ..Default::default()
864 },
865 ),
866 (
867 "builder2".into(),
868 Stage {
869 copy: vec![CopyResource::Copy(Copy {
870 from: FromContext::FromBuilder("builder1".into()),
871 paths: vec!["/path/to/cache/test".into()],
872 ..Default::default()
873 })],
874 ..Default::default()
875 },
876 ),
877 ]),
878 stage: Stage {
879 from: FromContext::FromBuilder("builder2".into()),
880 ..Default::default()
881 },
882 ..Default::default()
883 };
884
885 let mut lint_session = LintSession::analyze(&dofigen);
886
887 let mut builders = lint_session.get_sorted_builders();
888 builders.sort();
889
890 assert_eq_sorted!(builders, vec!["builder1", "builder2"]);
891
892 assert_eq_sorted!(
893 lint_session.messages,
894 vec![LintMessage {
895 level: MessageLevel::Error,
896 path: vec![
897 "builders".into(),
898 "builder2".into(),
899 "copy".into(),
900 "0".into()
901 ],
902 message: "Use of the 'builder1' builder cache path '/path/to/cache'".into(),
903 },]
904 );
905 }
906
907 #[test]
908 fn runtime_dependencies() {
909 let dofigen = Dofigen {
910 builders: HashMap::from([
911 (
912 "install-deps".to_string(),
913 Stage {
914 from: FromContext::FromImage(ImageName {
915 path: "php".to_string(),
916 version: Some(ImageVersion::Tag("8.3-fpm-alpine".to_string())),
917 ..Default::default()
918 }),
919 run: Run {
920 run: vec!["echo coucou".to_string()],
921 ..Default::default()
922 },
923 ..Default::default()
924 },
925 ),
926 (
927 "install-php-ext".to_string(),
928 Stage {
929 from: FromContext::FromBuilder("install-deps".to_string()),
930 run: Run {
931 run: vec!["echo coucou".to_string()],
932 ..Default::default()
933 },
934 ..Default::default()
935 },
936 ),
937 (
938 "get-composer".to_string(),
939 Stage {
940 from: FromContext::FromImage(ImageName {
941 path: "composer".to_string(),
942 version: Some(ImageVersion::Tag("latest".to_string())),
943 ..Default::default()
944 }),
945 run: Run {
946 run: vec!["echo coucou".to_string()],
947 ..Default::default()
948 },
949 ..Default::default()
950 },
951 ),
952 ]),
953 stage: Stage {
954 from: FromContext::FromBuilder("install-php-ext".to_string()),
955 copy: vec![CopyResource::Copy(Copy {
956 from: FromContext::FromBuilder("get-composer".to_string()),
957 paths: vec!["/usr/bin/composer".to_string()],
958 options: CopyOptions {
959 target: Some("/bin/".to_string()),
960 ..Default::default()
961 },
962 ..Default::default()
963 })],
964 ..Default::default()
965 },
966 ..Default::default()
967 };
968
969 let mut lint_session = LintSession::analyze(&dofigen);
970
971 let mut dependencies =
972 lint_session.get_stage_recursive_dependencies("install-deps".into());
973 dependencies.sort();
974 assert_eq_sorted!(dependencies, Vec::<String>::new());
975
976 dependencies = lint_session.get_stage_recursive_dependencies("install-php-ext".into());
977 assert_eq_sorted!(dependencies, vec!["install-deps"]);
978
979 dependencies = lint_session.get_stage_recursive_dependencies("get-composer".into());
980 assert_eq_sorted!(dependencies, Vec::<String>::new());
981
982 dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
983 dependencies.sort();
984 assert_eq_sorted!(
985 dependencies,
986 vec!["get-composer", "install-deps", "install-php-ext"]
987 );
988
989 let mut builders = lint_session.get_sorted_builders();
990 builders.sort();
991
992 assert_eq_sorted!(
993 builders,
994 vec!["get-composer", "install-deps", "install-php-ext"]
995 );
996
997 assert_eq_sorted!(lint_session.messages, vec![]);
998 }
999 }
1000
1001 mod builder {
1002 use super::*;
1003
1004 #[test]
1005 fn empty() {
1006 let dofigen = Dofigen {
1007 builders: HashMap::from([(
1008 "builder".into(),
1009 Stage {
1010 from: FromContext::FromImage(ImageName {
1011 path: "php".into(),
1012 ..Default::default()
1013 }),
1014 ..Default::default()
1015 },
1016 )]),
1017 stage: Stage {
1018 from: FromContext::FromBuilder("builder".into()),
1019 ..Default::default()
1020 },
1021 ..Default::default()
1022 };
1023
1024 let lint_session = LintSession::analyze(&dofigen);
1025
1026 assert_eq_sorted!(
1027 lint_session.messages,
1028 vec![LintMessage {
1029 level: MessageLevel::Warn,
1030 path: vec!["builders".into(), "builder".into()],
1031 message: "The builder 'builder' is empty and should be removed".into(),
1032 },]
1033 );
1034 }
1035
1036 #[test]
1037 fn unused() {
1038 let dofigen = Dofigen {
1039 builders: HashMap::from([(
1040 "builder".into(),
1041 Stage {
1042 from: FromContext::FromImage(ImageName {
1043 ..Default::default()
1044 }),
1045 run: Run {
1046 run: vec!["echo Hello".into()],
1047 ..Default::default()
1048 },
1049 ..Default::default()
1050 },
1051 )]),
1052 ..Default::default()
1053 };
1054
1055 let lint_session = LintSession::analyze(&dofigen);
1056
1057 assert_eq_sorted!(
1058 lint_session.messages,
1059 vec![LintMessage {
1060 level: MessageLevel::Warn,
1061 path: vec!["builders".into(), "builder".into()],
1062 message: "The builder 'builder' is not used and should be removed".into(),
1063 },]
1064 );
1065 }
1066 }
1067
1068 mod user {
1069 use super::*;
1070
1071 #[test]
1072 fn uid() {
1073 let dofigen = Dofigen {
1074 stage: Stage {
1075 user: Some(User::new("1000")),
1076 ..Default::default()
1077 },
1078 ..Default::default()
1079 };
1080
1081 let lint_session = LintSession::analyze(&dofigen);
1082
1083 assert_eq_sorted!(lint_session.messages, vec![]);
1084 }
1085
1086 #[test]
1087 fn username() {
1088 let dofigen = Dofigen {
1089 stage: Stage {
1090 user: Some(User::new("test")),
1091 ..Default::default()
1092 },
1093 ..Default::default()
1094 };
1095
1096 let lint_session = LintSession::analyze(&dofigen);
1097
1098 assert_eq_sorted!(
1099 lint_session.messages,
1100 vec![LintMessage {
1101 level: MessageLevel::Warn,
1102 path: vec!["user".into()],
1103 message: "UID should be used instead of username".into(),
1104 },]
1105 );
1106 }
1107 }
1108
1109 mod from_context {
1110 use super::*;
1111
1112 #[test]
1113 fn stage_and_copy() {
1114 let dofigen = Dofigen {
1115 stage: Stage {
1116 from: FromContext::FromContext(Some("php:8.3-fpm-alpine".into())),
1117 copy: vec![CopyResource::Copy(Copy {
1118 from: FromContext::FromContext(Some("composer:latest".into())),
1119 paths: vec!["/usr/bin/composer".into()],
1120 ..Default::default()
1121 })],
1122 ..Default::default()
1123 },
1124 ..Default::default()
1125 };
1126
1127 let lint_session = LintSession::analyze(&dofigen);
1128
1129 assert_eq_sorted!(lint_session.messages, vec![
1130 LintMessage {
1131 level: MessageLevel::Warn,
1132 path: vec!["fromContext".into()],
1133 message: "Prefer to use fromImage and fromBuilder instead of fromContext".into(),
1134 },
1135 LintMessage {
1136 level: MessageLevel::Warn,
1137 path: vec!["copy".into(), "0".into(), "fromContext".into()],
1138 message: "Prefer to use fromImage and fromBuilder instead of fromContext (unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)".into(),
1139 }
1140 ]);
1141 }
1142
1143 #[test]
1144 fn root_bind() {
1145 let dofigen = Dofigen {
1146 builders: HashMap::from([(
1147 "builder".into(),
1148 Stage {
1149 root: Some(Run {
1150 bind: vec![Bind {
1151 from: FromContext::FromContext(Some("builder".into())),
1152 source: Some("/path/to/bind".into()),
1153 target: "/path/to/target".into(),
1154 ..Default::default()
1155 }],
1156 run: vec!["echo Hello".into()],
1157 ..Default::default()
1158 }),
1159 ..Default::default()
1160 },
1161 )]),
1162 stage: Stage {
1163 from: FromContext::FromBuilder("builder".into()),
1164 ..Default::default()
1165 },
1166 ..Default::default()
1167 };
1168
1169 let lint_session = LintSession::analyze(&dofigen);
1170
1171 assert_eq_sorted!(lint_session.messages, vec![
1172 LintMessage {
1173 level: MessageLevel::Warn,
1174 path: vec![
1175 "builders".into(),
1176 "builder".into(),
1177 "root".into(),
1178 "bind".into(),
1179 "0".into(),
1180 "fromContext".into(),
1181 ],
1182 message: "Prefer to use fromImage and fromBuilder instead of fromContext (unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)".into(),
1183 }
1184 ]);
1185 }
1186 }
1187
1188 mod run {
1189 use super::*;
1190
1191 #[test]
1192 fn empty_run() {
1193 let dofigen = Dofigen {
1194 stage: Stage {
1195 run: Run {
1196 bind: vec![Bind {
1197 source: Some("/path/to/bind".into()),
1198 target: "/path/to/target".into(),
1199 ..Default::default()
1200 }],
1201 cache: vec![Cache {
1202 source: Some("/path/to/cache".into()),
1203 target: "/path/to/target".into(),
1204 ..Default::default()
1205 }],
1206 ..Default::default()
1207 },
1208 ..Default::default()
1209 },
1210 ..Default::default()
1211 };
1212
1213 let lint_session = LintSession::analyze(&dofigen);
1214
1215 assert_eq_sorted!(
1216 lint_session.messages,
1217 vec![
1218 LintMessage {
1219 level: MessageLevel::Warn,
1220 message: "The run list is empty but there are bind definitions".into(),
1221 path: vec!["bind".into()],
1222 },
1223 LintMessage {
1224 level: MessageLevel::Warn,
1225 message: "The run list is empty but there are cache definitions".into(),
1226 path: vec!["cache".into()],
1227 },
1228 ]
1229 );
1230 }
1231 }
1232}