diff --git a/README.md b/README.md index 0d2a472..b5fd80f 100755 --- a/README.md +++ b/README.md @@ -190,7 +190,23 @@ Don't forget to give the project a star! Thanks again! To run the tests, use the following command: ```bash -cargo test --all-features +cargo test +``` + +#### Test coverage + +To generate the test coverage, use the following commands: + +```bash +# Generate the coverage report +RUSTFLAGS="-C instrument-coverage" \ + RUSTDOCFLAGS="-C instrument-coverage" \ + LLVM_PROFILE_FILE="target/coverage/profiles/cargo-test-%p-%m.profraw" \ + cargo test +# Convert to lcov format +grcov target/coverage/profiles/ --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore ../* --ignore /* -o target/coverage/lcov.info +# Generate the HTML report +grcov target/coverage/profiles/ --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore ../* --ignore /* -o target/coverage/html ``` ### Generate the JSON Schema diff --git a/src/bin/commands/generate.rs b/src/bin/commands/generate.rs index fec78af..24118fe 100644 --- a/src/bin/commands/generate.rs +++ b/src/bin/commands/generate.rs @@ -7,7 +7,6 @@ use crate::{CliCommand, GlobalOptions}; use clap::Args; use colored::{Color, Colorize}; use dofigen_lib::{ - generate_dockerignore, lock::{Lock, LockFile}, DofigenContext, Error, GenerationContext, MessageLevel, Result, }; @@ -89,9 +88,9 @@ impl CliCommand for Generate { locked_image }; - let mut generation_context = GenerationContext::from(&dofigen); + let mut generation_context = GenerationContext::from(dofigen); - let dockerfile_content = generation_context.generate_dockerfile(&dofigen)?; + let dockerfile_content = generation_context.generate_dockerfile()?; let messages = generation_context.get_lint_messages().clone(); @@ -125,7 +124,7 @@ impl CliCommand for Generate { } else { self.write_dockerfile( dockerfile_content.as_str(), - generate_dockerignore(&dofigen).as_str(), + generation_context.generate_dockerignore()?.as_str(), )?; }; Ok(()) diff --git a/src/dofigen_struct.rs b/src/dofigen_struct.rs index b0cec43..411419a 100644 --- a/src/dofigen_struct.rs +++ b/src/dofigen_struct.rs @@ -252,7 +252,7 @@ pub struct Cache { #[serde(skip_serializing_if = "Option::is_none")] pub sharing: Option, - /// The base of the cache mount + /// Build stage, context, or image name to use as a base of the cache mount. Defaults to empty directory. #[serde(flatten, skip_serializing_if = "FromContext::is_empty")] #[patch(name = "FromContextPatch", attribute(serde(flatten)))] pub from: FromContext, diff --git a/src/generator.rs b/src/generator.rs index 1f30e1c..6c729ba 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,7 +1,6 @@ -use std::collections::{HashMap, HashSet}; - use crate::{ - dockerfile_struct::*, dofigen_struct::*, Result, DOCKERFILE_VERSION, FILE_HEADER_COMMENTS, + dockerfile_struct::*, dofigen_struct::*, LintMessage, LintSession, Result, DOCKERFILE_VERSION, + FILE_HEADER_COMMENTS, }; pub const LINE_SEPARATOR: &str = " \\\n "; @@ -9,6 +8,7 @@ pub const DEFAULT_FROM: &str = "scratch"; #[derive(Debug, Clone, PartialEq)] pub struct GenerationContext { + dofigen: Dofigen, pub(crate) user: Option, pub(crate) stage_name: String, pub(crate) default_from: FromContext, @@ -18,7 +18,7 @@ pub struct GenerationContext { impl GenerationContext { pub fn get_lint_messages(&self) -> Vec { - self.lint_session.messages.clone() + self.lint_session.messages() } fn push_state(&mut self, state: GenerationContextState) { @@ -51,18 +51,20 @@ impl GenerationContext { } } - pub fn from(dofigen: &Dofigen) -> Self { + pub fn from(dofigen: Dofigen) -> Self { + let lint_session = LintSession::analyze(&dofigen); Self { + dofigen, user: None, stage_name: String::default(), default_from: FromContext::default(), - lint_session: LintSession::analyze(dofigen), + lint_session, state_stack: vec![], } } - pub fn generate_dockerfile(&mut self, dofigen: &Dofigen) -> Result { - let mut lines = dofigen.generate_dockerfile_lines(self)?; + pub fn generate_dockerfile(&mut self) -> Result { + let mut lines = self.dofigen.clone().generate_dockerfile_lines(self)?; let mut line_number = 1; for line in FILE_HEADER_COMMENTS { @@ -79,6 +81,33 @@ impl GenerationContext { .join("\n") )) } + + pub fn generate_dockerignore(&self) -> Result { + let mut content = String::new(); + + for line in FILE_HEADER_COMMENTS { + content.push_str("# "); + content.push_str(line); + content.push_str("\n"); + } + content.push_str("\n"); + + if !self.dofigen.context.is_empty() { + content.push_str("**\n"); + self.dofigen.context.iter().for_each(|path| { + content.push_str("!"); + content.push_str(path); + content.push_str("\n"); + }); + } + if !self.dofigen.ignore.is_empty() { + self.dofigen.ignore.iter().for_each(|path| { + content.push_str(path); + content.push_str("\n"); + }); + } + Ok(content) + } } #[derive(Debug, Clone, PartialEq, Default)] @@ -704,314 +733,6 @@ fn string_vec_into(string_vec: Vec) -> String { ) } -#[derive(Debug, Clone, PartialEq)] -struct StageDependency { - stage: String, - path: String, - origin: Vec, -} - -trait StageDependencyGetter { - fn get_dependencies(&self, origin: &Vec) -> Vec; -} - -impl StageDependencyGetter for Stage { - fn get_dependencies(&self, origin: &Vec) -> Vec { - let mut dependencies = vec![]; - if let FromContext::FromBuilder(builder) = &self.from { - dependencies.push(StageDependency { - stage: builder.clone(), - path: "/".into(), - origin: [origin.clone(), vec!["from".into()]].concat(), - }); - } - for (position, copy) in self.copy.iter().enumerate() { - dependencies.append(&mut copy.get_dependencies( - &[origin.clone(), vec!["copy".into(), position.to_string()]].concat(), - )); - } - dependencies.append(&mut self.run.get_dependencies(origin)); - if let Some(root) = &self.root { - dependencies.append( - &mut root.get_dependencies(&[origin.clone(), vec!["root".into()]].concat()), - ); - } - dependencies - } -} - -impl StageDependencyGetter for Run { - fn get_dependencies(&self, origin: &Vec) -> Vec { - let mut dependencies = vec![]; - for (position, cache) in self.cache.iter().enumerate() { - if let FromContext::FromBuilder(builder) = &cache.from { - dependencies.push(StageDependency { - stage: builder.clone(), - path: cache.source.clone().unwrap_or("/".into()), - origin: [origin.clone(), vec!["cache".into(), position.to_string()]].concat(), - }); - } - } - for (position, bind) in self.bind.iter().enumerate() { - if let FromContext::FromBuilder(builder) = &bind.from { - dependencies.push(StageDependency { - stage: builder.clone(), - path: bind.source.clone().unwrap_or("/".into()), - origin: [origin.clone(), vec!["bind".into(), position.to_string()]].concat(), - }); - } - } - dependencies - } -} - -impl StageDependencyGetter for CopyResource { - fn get_dependencies(&self, origin: &Vec) -> Vec { - match self { - CopyResource::Copy(copy) => match ©.from { - FromContext::FromBuilder(builder) => copy - .paths - .iter() - .map(|path| StageDependency { - stage: builder.clone(), - path: path.clone(), - origin: origin.clone(), - }) - .collect(), - _ => vec![], - }, - _ => vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Default)] -pub struct LintSession { - messages: Vec, - stage_infos: HashMap, - recursive_stage_dependencies: HashMap>, -} - -impl LintSession { - pub fn get_sorted_builders(&mut self) -> Vec { - let mut stages: Vec<(String, Vec)> = self - .stage_infos - .clone() - .keys() - .map(|name| { - ( - name.clone(), - self.get_stage_recursive_dependencies(name.clone()), - ) - }) - .collect(); - - stages.sort_by(|(a_stage, a_deps), (b_stage, b_deps)| { - if a_deps.contains(b_stage) { - return std::cmp::Ordering::Greater; - } - if b_deps.contains(a_stage) { - return std::cmp::Ordering::Less; - } - a_stage.cmp(b_stage) - }); - - stages - .into_iter() - .map(|(stage, _)| stage) - .filter(|name| *name != "runtime") - .collect() - } - - pub fn get_stage_recursive_dependencies(&mut self, stage: String) -> Vec { - self.resolve_stage_recursive_dependencies(&mut vec![stage]) - } - - fn resolve_stage_recursive_dependencies(&mut self, path: &mut Vec) -> Vec { - let stage = &path.last().expect("The path is empty").clone(); - if let Some(dependencies) = self.recursive_stage_dependencies.get(stage) { - return dependencies.clone(); - } - let mut deps = HashSet::new(); - let dependencies = self - .stage_infos - .get(stage) - .expect(format!("The stage info not found for stage '{}'", stage).as_str()) - .dependencies - .clone(); - for dependency in dependencies { - let dep_stage = &dependency.stage; - if path.contains(dep_stage) { - self.messages.push(LintMessage { - level: MessageLevel::Error, - message: format!( - "Circular dependency detected: {} -> {}", - path.join(" -> "), - dependency.stage - ), - path: dependency.origin.clone(), - }); - continue; - } - deps.insert(dep_stage.clone()); - if self.stage_infos.contains_key(dep_stage) { - path.push(dep_stage.clone()); - deps.extend(self.resolve_stage_recursive_dependencies(path)); - path.pop(); - } // the else is already managed in check_dependencies - } - let deps: Vec = deps.into_iter().collect(); - self.recursive_stage_dependencies - .insert(stage.clone(), deps.clone()); - deps - } - - /// Checks if dependencies are using path that are in cache - fn check_dependencies(&mut self) { - let dependencies = self - .stage_infos - .iter() - .flat_map(|(_name, info)| info.dependencies.clone()) - .collect::>(); - - let caches = self - .stage_infos - .iter() - .map(|(name, info)| (name, info.cache_paths.clone())) - .collect::>(); - - for dependency in dependencies { - if let Some(paths) = caches.get(&dependency.stage) { - paths - .iter() - .filter(|path| dependency.path.starts_with(*path)) - .for_each(|path| { - self.messages.push(LintMessage { - level: MessageLevel::Error, - message: format!( - "Use of the '{}' builder cache path '{}'", - dependency.stage, path - ), - path: dependency.origin.clone(), - }); - }); - } else { - self.messages.push(LintMessage { - level: MessageLevel::Error, - message: format!("The builder '{}' not found", dependency.stage), - path: dependency.origin.clone(), - }); - } - } - } - - fn analyze_stage(&mut self, path: &Vec, name: &String, stage: &Stage) { - let dependencies = stage.get_dependencies(path); - self.messages.append( - &mut dependencies - .iter() - .filter(|dep| dep.stage == "runtime") - .map(|dep| LintMessage { - level: MessageLevel::Error, - message: format!("The builder '{}' can't depend on the 'runtime'", name,), - path: dep.origin.clone(), - }) - .collect(), - ); - let cache_paths = self.get_stage_cache_paths(stage, path); - self.stage_infos.insert( - name.clone(), - StageLintInfo { - dependencies, - cache_paths, - }, - ); - } - - fn get_stage_cache_paths(&mut self, stage: &Stage, path: &Vec) -> Vec { - let mut paths = vec![]; - paths.append(&mut self.get_run_cache_paths(&stage.run, path, &stage.workdir)); - if let Some(root) = &stage.root { - paths.append(&mut self.get_run_cache_paths( - root, - &[path.clone(), vec!["root".into()]].concat(), - &stage.workdir, - )); - } - paths - } - - fn get_run_cache_paths( - &mut self, - run: &Run, - path: &Vec, - workdir: &Option, - ) -> Vec { - let mut cache_paths = vec![]; - for (position, cache) in run.cache.iter().enumerate() { - let target = cache.target.clone(); - cache_paths.push(if target.starts_with("/") { - target.clone() - } else { - if let Some(workdir) = workdir { - format!("{}/{}", workdir, target) - } - else { - self.messages.push(LintMessage { - level: MessageLevel::Warn, - message: "The cache target should be absolute or a workdir should be defined in the stage".to_string(), - path: [path.clone(), vec!["cache".into(), position.to_string()]].concat(), - }); - target.clone() - } - }); - } - cache_paths - } - - ////////// Statics ////////// - - /// Analyze the given Dofigen configuration and return a lint session - pub fn analyze(dofigen: &Dofigen) -> Self { - let mut session = Self::default(); - for (name, builder) in dofigen.builders.iter() { - let base_origin = vec!["builders".into(), name.clone()]; - if name == "runtime" { - session.messages.push(LintMessage { - level: MessageLevel::Error, - message: "The builder name 'runtime' is reserved".into(), - path: base_origin.clone(), - }); - } - session.analyze_stage(&base_origin, name, builder); - } - - session.analyze_stage(&vec![], &"runtime".into(), &dofigen.stage); - session.check_dependencies(); - - session - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct StageLintInfo { - dependencies: Vec, - cache_paths: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct LintMessage { - pub level: MessageLevel, - pub path: Vec, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum MessageLevel { - Warn, - Error, -} - #[cfg(test)] mod test { use super::*; @@ -1020,6 +741,7 @@ mod test { impl Default for GenerationContext { fn default() -> Self { Self { + dofigen: Dofigen::default(), user: None, stage_name: String::default(), default_from: FromContext::default(), @@ -1030,6 +752,8 @@ mod test { } mod stage { + use std::collections::HashMap; + use super::*; #[test] @@ -1233,6 +957,7 @@ mod test { run: vec!["echo Hello".into()].into(), cache: vec![Cache { target: "/path/to/cache".into(), + readonly: Some(true), ..Default::default() }] .into(), @@ -1253,6 +978,7 @@ mod test { InstructionOptionOption::new("type", "cache".into()), InstructionOptionOption::new("target", "/path/to/cache".into()), InstructionOptionOption::new("sharing", "locked".into()), + InstructionOptionOption::new_flag("readonly"), ], )], })] @@ -1324,398 +1050,4 @@ mod test { ); } } - - mod lint_session { - use super::*; - - #[test] - fn builders_dependencies() { - let dofigen = Dofigen { - builders: HashMap::from([ - ( - "builder1".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder2".into()), - paths: vec!["/path/to/copy".into()], - options: Default::default(), - ..Default::default() - })], - ..Default::default() - }, - ), - ( - "builder2".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder3".into()), - paths: vec!["/path/to/copy".into()], - options: Default::default(), - ..Default::default() - })], - ..Default::default() - }, - ), - ( - "builder3".into(), - Stage { - run: Run { - run: vec!["echo Hello".into()].into(), - ..Default::default() - }, - ..Default::default() - }, - ), - ]), - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); - dependencies.sort(); - assert_eq_sorted!(dependencies, Vec::::new()); - - dependencies = lint_session.get_stage_recursive_dependencies("builder1".into()); - dependencies.sort(); - assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]); - - dependencies = lint_session.get_stage_recursive_dependencies("builder2".into()); - assert_eq_sorted!(dependencies, vec!["builder3"]); - - dependencies = lint_session.get_stage_recursive_dependencies("builder3".into()); - assert_eq_sorted!(dependencies, Vec::::new()); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]); - - assert_eq_sorted!(lint_session.messages, vec![]); - } - - #[test] - fn builders_circular_dependencies() { - let dofigen = Dofigen { - builders: HashMap::from([ - ( - "builder1".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder2".into()), - paths: vec!["/path/to/copy".into()], - options: Default::default(), - ..Default::default() - })], - ..Default::default() - }, - ), - ( - "builder2".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder3".into()), - paths: vec!["/path/to/copy".into()], - options: Default::default(), - ..Default::default() - })], - ..Default::default() - }, - ), - ( - "builder3".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder1".into()), - paths: vec!["/path/to/copy".into()], - options: Default::default(), - ..Default::default() - })], - ..Default::default() - }, - ), - ]), - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); - dependencies.sort(); - assert_eq_sorted!(dependencies, Vec::::new()); - - dependencies = lint_session.get_stage_recursive_dependencies("builder1".into()); - dependencies.sort(); - assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]); - - dependencies = lint_session.get_stage_recursive_dependencies("builder2".into()); - assert_eq_sorted!(dependencies, vec!["builder3"]); - - dependencies = lint_session.get_stage_recursive_dependencies("builder3".into()); - assert_eq_sorted!(dependencies, Vec::::new()); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]); - - assert_eq_sorted!( - lint_session.messages, - vec![LintMessage { - level: MessageLevel::Error, - path: vec![ - "builders".into(), - "builder3".into(), - "copy".into(), - "0".into(), - ], - message: - "Circular dependency detected: builder1 -> builder2 -> builder3 -> builder1" - .into(), - },] - ); - } - - #[test] - fn builder_named_runtime() { - let dofigen = Dofigen { - builders: HashMap::from([( - "runtime".into(), - Stage { - run: Run { - run: vec!["echo Hello".into()].into(), - ..Default::default() - }, - ..Default::default() - }, - )]), - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, Vec::::new()); - - assert_eq_sorted!( - lint_session.messages, - vec![LintMessage { - level: MessageLevel::Error, - path: vec!["builders".into(), "runtime".into(),], - message: "The builder name 'runtime' is reserved".into(), - },] - ); - } - - #[test] - fn builder_not_found() { - let dofigen = Dofigen { - stage: Stage { - from: FromContext::FromBuilder("builder1".into()), - ..Default::default() - }, - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, Vec::::new()); - - assert_eq_sorted!( - lint_session.messages, - vec![LintMessage { - level: MessageLevel::Error, - path: vec!["from".into(),], - message: "The builder 'builder1' not found".into(), - },] - ); - } - - #[test] - fn dependency_to_runtime() { - let dofigen = Dofigen { - builders: HashMap::from([( - "builder".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("runtime".into()), - paths: vec!["/path/to/copy".into()], - ..Default::default() - })], - ..Default::default() - }, - )]), - stage: Stage { - run: Run { - run: vec!["echo Hello".into()].into(), - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, vec!["builder"]); - - assert_eq_sorted!( - lint_session.messages, - vec![LintMessage { - level: MessageLevel::Error, - path: vec![ - "builders".into(), - "builder".into(), - "copy".into(), - "0".into() - ], - message: "The builder 'builder' can't depend on the 'runtime'".into(), - },] - ); - } - - #[test] - fn dependency_to_cache_path() { - let dofigen = Dofigen { - builders: HashMap::from([ - ( - "builder1".into(), - Stage { - run: Run { - run: vec!["echo Hello".into()].into(), - cache: vec![Cache { - target: "/path/to/cache".into(), - ..Default::default() - }], - ..Default::default() - }, - ..Default::default() - }, - ), - ( - "builder2".into(), - Stage { - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("builder1".into()), - paths: vec!["/path/to/cache/test".into()], - ..Default::default() - })], - ..Default::default() - }, - ), - ]), - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!(builders, vec!["builder1", "builder2"]); - - assert_eq_sorted!( - lint_session.messages, - vec![LintMessage { - level: MessageLevel::Error, - path: vec![ - "builders".into(), - "builder2".into(), - "copy".into(), - "0".into() - ], - message: "Use of the 'builder1' builder cache path '/path/to/cache'".into(), - },] - ); - } - - #[test] - fn runtime_dependencies() { - let dofigen = Dofigen { - builders: HashMap::from([ - ( - "install-deps".to_string(), - Stage { - from: FromContext::FromImage(ImageName { - path: "php".to_string(), - version: Some(ImageVersion::Tag("8.3-fpm-alpine".to_string())), - ..Default::default() - }), - ..Default::default() - }, - ), - ( - "install-php-ext".to_string(), - Stage { - from: FromContext::FromBuilder("install-deps".to_string()), - ..Default::default() - }, - ), - ( - "get-composer".to_string(), - Stage { - from: FromContext::FromImage(ImageName { - path: "composer".to_string(), - version: Some(ImageVersion::Tag("latest".to_string())), - ..Default::default() - }), - ..Default::default() - }, - ), - ]), - stage: Stage { - from: FromContext::FromBuilder("install-php-ext".to_string()), - copy: vec![CopyResource::Copy(Copy { - from: FromContext::FromBuilder("get-composer".to_string()), - paths: vec!["/usr/bin/composer".to_string()], - options: CopyOptions { - target: Some("/bin/".to_string()), - ..Default::default() - }, - ..Default::default() - })], - ..Default::default() - }, - ..Default::default() - }; - - let mut lint_session = LintSession::analyze(&dofigen); - - let mut dependencies = - lint_session.get_stage_recursive_dependencies("install-deps".into()); - dependencies.sort(); - assert_eq_sorted!(dependencies, Vec::::new()); - - dependencies = lint_session.get_stage_recursive_dependencies("install-php-ext".into()); - assert_eq_sorted!(dependencies, vec!["install-deps"]); - - dependencies = lint_session.get_stage_recursive_dependencies("get-composer".into()); - assert_eq_sorted!(dependencies, Vec::::new()); - - dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); - dependencies.sort(); - assert_eq_sorted!( - dependencies, - vec!["get-composer", "install-deps", "install-php-ext"] - ); - - let mut builders = lint_session.get_sorted_builders(); - builders.sort(); - - assert_eq_sorted!( - builders, - vec!["get-composer", "install-deps", "install-php-ext"] - ); - - assert_eq_sorted!(lint_session.messages, vec![]); - } - } } diff --git a/src/lib.rs b/src/lib.rs index 40cfea1..dcd5383 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,16 +28,13 @@ mod from_str; mod generator; #[cfg(feature = "json_schema")] mod json_schema; +mod linter; pub mod lock; #[cfg(feature = "json_schema")] use schemars::gen::*; pub use { - context::*, - deserialize::*, - dofigen_struct::*, - errors::*, - extend::*, - generator::{GenerationContext, MessageLevel}, + context::*, deserialize::*, dofigen_struct::*, errors::*, extend::*, + generator::GenerationContext, linter::*, }; #[cfg(all(feature = "strict", feature = "permissive"))] @@ -77,8 +74,12 @@ const FILE_HEADER_COMMENTS: [&str; 2] = [ /// "# syntax=docker/dockerfile:1.7\n# This file is generated by Dofigen v0.0.0\n# See https://github.com/lenra-io/dofigen\n\n# runtime\nFROM ubuntu AS runtime\nUSER 1000:1000\n" /// ); /// ``` +#[deprecated( + since = "2.2.0", + note = "Please use `GenerationContext::generate_dockerfile` from `dofigen_lib` instead" +)] pub fn generate_dockerfile(dofigen: &Dofigen) -> Result { - GenerationContext::from(dofigen).generate_dockerfile(dofigen) + GenerationContext::from(dofigen.clone()).generate_dockerfile() } /// Generates the .dockerignore file content from an Dofigen struct. @@ -136,31 +137,14 @@ pub fn generate_dockerfile(dofigen: &Dofigen) -> Result { /// "# This file is generated by Dofigen v0.0.0\n# See https://github.com/lenra-io/dofigen\n\n**\n!/src\n/src/*.test.rs\n" /// ); /// ``` +#[deprecated( + since = "2.2.0", + note = "Please use `GenerationContext::generate_dockerignore` from `dofigen_lib` instead" +)] pub fn generate_dockerignore(dofigen: &Dofigen) -> String { - let mut content = String::new(); - - for line in FILE_HEADER_COMMENTS { - content.push_str("# "); - content.push_str(line); - content.push_str("\n"); - } - content.push_str("\n"); - - if !dofigen.context.is_empty() { - content.push_str("**\n"); - dofigen.context.iter().for_each(|path| { - content.push_str("!"); - content.push_str(path); - content.push_str("\n"); - }); - } - if !dofigen.ignore.is_empty() { - dofigen.ignore.iter().for_each(|path| { - content.push_str(path); - content.push_str("\n"); - }); - } - content + GenerationContext::from(dofigen.clone()) + .generate_dockerignore() + .unwrap() } /// Generates the effective Dofigen content from a Dofigen struct. diff --git a/src/linter.rs b/src/linter.rs new file mode 100644 index 0000000..f18a13a --- /dev/null +++ b/src/linter.rs @@ -0,0 +1,1218 @@ +use std::collections::{HashMap, HashSet}; + +use crate::dofigen_struct::*; + +const WARN_MESSAGE_FROM_CONTEXT: &str = + "Prefer to use fromImage and fromBuilder instead of fromContext"; +const 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)"; + +#[derive(Debug, Clone, PartialEq)] +struct StageDependency { + stage: String, + path: String, + origin: Vec, +} + +macro_rules! linter_path { + ($session:expr, $part:expr, $block:block) => { + $session.push_path_part($part); + $block + $session.pop_path_part(); + }; +} + +trait Linter { + fn analyze(&self, session: &mut LintSession); +} + +impl Linter for Dofigen { + fn analyze(&self, session: &mut LintSession) { + linter_path!(session, "builders".into(), { + for (name, builder) in self.builders.iter() { + linter_path!(session, name.clone(), { + if name == "runtime" { + session.add_message( + MessageLevel::Error, + "The builder name 'runtime' is reserved".into(), + ); + } + builder.analyze(session); + }); + } + }); + + self.stage.analyze(session); + + // Check root user in runtime stage + if let Some(user) = &self.stage.user { + if user.user == "root" || user.uid() == Some(0) { + session.messages.push(LintMessage { + level: MessageLevel::Warn, + message: "The runtime user should not be root".into(), + path: vec!["user".into()], + }); + } + } + + session.check_dependencies(); + } +} + +impl Linter for Stage { + fn analyze(&self, session: &mut LintSession) { + let name = session.current_path.last().cloned(); + + // Check empty stage + if let Some(name) = name.clone() { + if self.copy.is_empty() && self.run.run.is_empty() && self.root.is_none() { + session.add_message( + MessageLevel::Warn, + format!("The builder '{}' is empty and should be removed", name), + ); + } + } + + let name = name.unwrap_or("runtime".to_string()); + + let dependencies = self.get_dependencies(&session.current_path); + session.messages.append( + &mut dependencies + .iter() + .filter(|dep| dep.stage == "runtime") + .map(|dep| LintMessage { + level: MessageLevel::Error, + message: format!("The stage '{}' can't depend on the 'runtime'", &name,), + path: dep.origin.clone(), + }) + .collect(), + ); + let cache_paths = session.get_stage_cache_paths(self); + session.stage_infos.insert( + name, + StageLintInfo { + dependencies, + cache_paths, + }, + ); + + // Check the use of fromContext + if let FromContext::FromContext(Some(_)) = self.from { + linter_path!(session, "fromContext".into(), { + session.add_message(MessageLevel::Warn, WARN_MESSAGE_FROM_CONTEXT.to_string()); + }); + } + + linter_path!(session, "copy".into(), { + for (position, copy) in self.copy.iter().enumerate() { + linter_path!(session, position.to_string(), { + copy.analyze(session); + }); + } + }); + + if let Some(root) = &self.root { + linter_path!(session, "root".into(), { + root.analyze(session); + }); + } + + self.run.analyze(session); + + // Check if the user is using the username instead of the UID + if let Some(user) = &self.user { + if user.uid().is_none() { + linter_path!(session, "user".into(), { + session.add_message( + MessageLevel::Warn, + "UID should be used instead of username".to_string(), + ); + }); + } + } + } +} + +impl Linter for CopyResource { + fn analyze(&self, session: &mut LintSession) { + match self { + CopyResource::Copy(copy) => copy.analyze(session), + _ => {} + } + } +} + +impl Linter for Copy { + fn analyze(&self, session: &mut LintSession) { + match &self.from { + FromContext::FromContext(Some(_)) => { + linter_path!(session, "fromContext".into(), { + session.add_message( + MessageLevel::Warn, + format!( + "{} {}", + WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS + ), + ); + }); + } + _ => {} + } + } +} + +impl Linter for Run { + fn analyze(&self, session: &mut LintSession) { + if self.run.is_empty() { + if !self.bind.is_empty() { + linter_path!(session, "bind".into(), { + session.add_message( + MessageLevel::Warn, + "The run list is empty but there are bind definitions".to_string(), + ); + }); + } + + if !self.cache.is_empty() { + linter_path!(session, "cache".into(), { + session.add_message( + MessageLevel::Warn, + "The run list is empty but there are cache definitions".to_string(), + ); + }); + } + } + + linter_path!(session, "run".into(), { + for (position, command) in self.run.iter().enumerate() { + linter_path!(session, position.to_string(), { + if command.starts_with("cd ") { + session.add_message( + MessageLevel::Warn, + "Avoid using 'cd' in the run command".to_string(), + ); + } + }); + } + }); + + linter_path!(session, "bind".into(), { + for (position, bind) in self.bind.iter().enumerate() { + linter_path!(session, position.to_string(), { + if let FromContext::FromContext(Some(_)) = bind.from { + linter_path!(session, "fromContext".into(), { + session.add_message( + MessageLevel::Warn, + format!( + "{} {}", + WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS + ), + ); + }); + } + }); + } + }); + + linter_path!(session, "cache".into(), { + for (position, cache) in self.cache.iter().enumerate() { + linter_path!(session, position.to_string(), { + if let FromContext::FromContext(Some(_)) = cache.from { + linter_path!(session, "fromContext".into(), { + session.add_message( + MessageLevel::Warn, + format!( + "{} {}", + WARN_MESSAGE_FROM_CONTEXT, WARN_MESSAGE_FROM_CONTEXT_UNLESS + ), + ); + }); + } + }); + } + }); + } +} + +trait StageDependencyGetter { + fn get_dependencies(&self, origin: &Vec) -> Vec; +} + +impl StageDependencyGetter for Stage { + fn get_dependencies(&self, origin: &Vec) -> Vec { + let mut dependencies = vec![]; + if let FromContext::FromBuilder(builder) = &self.from { + dependencies.push(StageDependency { + stage: builder.clone(), + path: "/".into(), + origin: [origin.clone(), vec!["from".into()]].concat(), + }); + } + for (position, copy) in self.copy.iter().enumerate() { + dependencies.append(&mut copy.get_dependencies( + &[origin.clone(), vec!["copy".into(), position.to_string()]].concat(), + )); + } + dependencies.append(&mut self.run.get_dependencies(origin)); + if let Some(root) = &self.root { + dependencies.append( + &mut root.get_dependencies(&[origin.clone(), vec!["root".into()]].concat()), + ); + } + dependencies + } +} + +impl StageDependencyGetter for Run { + fn get_dependencies(&self, origin: &Vec) -> Vec { + let mut dependencies = vec![]; + for (position, cache) in self.cache.iter().enumerate() { + if let FromContext::FromBuilder(builder) = &cache.from { + dependencies.push(StageDependency { + stage: builder.clone(), + path: cache.source.clone().unwrap_or("/".into()), + origin: [origin.clone(), vec!["cache".into(), position.to_string()]].concat(), + }); + } + } + for (position, bind) in self.bind.iter().enumerate() { + if let FromContext::FromBuilder(builder) = &bind.from { + dependencies.push(StageDependency { + stage: builder.clone(), + path: bind.source.clone().unwrap_or("/".into()), + origin: [origin.clone(), vec!["bind".into(), position.to_string()]].concat(), + }); + } + } + dependencies + } +} + +impl StageDependencyGetter for CopyResource { + fn get_dependencies(&self, origin: &Vec) -> Vec { + match self { + CopyResource::Copy(copy) => match ©.from { + FromContext::FromBuilder(builder) => copy + .paths + .iter() + .map(|path| StageDependency { + stage: builder.clone(), + path: path.clone(), + origin: origin.clone(), + }) + .collect(), + _ => vec![], + }, + _ => vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LintSession { + current_path: Vec, + messages: Vec, + stage_infos: HashMap, + recursive_stage_dependencies: HashMap>, +} + +impl LintSession { + fn push_path_part(&mut self, part: String) { + self.current_path.push(part); + } + + fn pop_path_part(&mut self) { + self.current_path.pop(); + } + + fn add_message(&mut self, level: MessageLevel, message: String) { + self.messages.push(LintMessage { + level, + message, + path: self.current_path.clone(), + }); + } + + pub fn messages(&self) -> Vec { + self.messages.clone() + } + + pub fn get_sorted_builders(&mut self) -> Vec { + let mut stages: Vec<(String, Vec)> = self + .stage_infos + .clone() + .keys() + .map(|name| { + ( + name.clone(), + self.get_stage_recursive_dependencies(name.clone()), + ) + }) + .collect(); + + stages.sort_by(|(a_stage, a_deps), (b_stage, b_deps)| { + if a_deps.contains(b_stage) { + return std::cmp::Ordering::Greater; + } + if b_deps.contains(a_stage) { + return std::cmp::Ordering::Less; + } + a_stage.cmp(b_stage) + }); + + stages + .into_iter() + .map(|(stage, _)| stage) + .filter(|name| *name != "runtime") + .collect() + } + + pub fn get_stage_recursive_dependencies(&mut self, stage: String) -> Vec { + self.resolve_stage_recursive_dependencies(&mut vec![stage]) + } + + fn resolve_stage_recursive_dependencies(&mut self, path: &mut Vec) -> Vec { + let stage = &path.last().expect("The path is empty").clone(); + if let Some(dependencies) = self.recursive_stage_dependencies.get(stage) { + return dependencies.clone(); + } + let mut deps = HashSet::new(); + let dependencies = self + .stage_infos + .get(stage) + .expect(format!("The stage info not found for stage '{}'", stage).as_str()) + .dependencies + .clone(); + for dependency in dependencies { + let dep_stage = &dependency.stage; + if path.contains(dep_stage) { + self.messages.push(LintMessage { + level: MessageLevel::Error, + message: format!( + "Circular dependency detected: {} -> {}", + path.join(" -> "), + dependency.stage + ), + path: dependency.origin.clone(), + }); + continue; + } + deps.insert(dep_stage.clone()); + if self.stage_infos.contains_key(dep_stage) { + path.push(dep_stage.clone()); + deps.extend(self.resolve_stage_recursive_dependencies(path)); + path.pop(); + } // the else is already managed in check_dependencies + } + let deps: Vec = deps.into_iter().collect(); + self.recursive_stage_dependencies + .insert(stage.clone(), deps.clone()); + deps + } + + /// Checks if dependencies are using path that are in cache + fn check_dependencies(&mut self) { + let dependencies = self + .stage_infos + .values() + .flat_map(|info| info.dependencies.clone()) + .collect::>(); + + let caches = self + .stage_infos + .iter() + .map(|(name, info)| (name.clone(), info.cache_paths.clone())) + .collect::>(); + + // Check if there is unused builders + let used_builders = dependencies + .iter() + .map(|dep| dep.stage.clone()) + .collect::>(); + + let unused_builders = self + .stage_infos + .keys() + .filter(|name| name != &"runtime") + .map(|name| name.clone()) + .filter(|name| !used_builders.contains(name)) + .collect::>(); + + linter_path!(self, "builders".into(), { + for builder in unused_builders { + linter_path!(self, builder.clone(), { + self.add_message( + MessageLevel::Warn, + format!( + "The builder '{}' is not used and should be removed", + builder + ), + ); + }); + } + }); + + for dependency in dependencies { + if let Some(paths) = caches.get(&dependency.stage) { + paths + .iter() + .filter(|path| dependency.path.starts_with(*path)) + .for_each(|path| { + self.messages.push(LintMessage { + level: MessageLevel::Error, + message: format!( + "Use of the '{}' builder cache path '{}'", + dependency.stage, path + ), + path: dependency.origin.clone(), + }); + }); + } else { + self.messages.push(LintMessage { + level: MessageLevel::Error, + message: format!("The builder '{}' not found", dependency.stage), + path: dependency.origin.clone(), + }); + } + } + } + + fn get_stage_cache_paths(&mut self, stage: &Stage) -> Vec { + let mut paths = vec![]; + paths.append(&mut self.get_run_cache_paths( + &stage.run, + &self.current_path.clone(), + &stage.workdir, + )); + if let Some(root) = &stage.root { + paths.append(&mut self.get_run_cache_paths( + root, + &[self.current_path.clone(), vec!["root".into()]].concat(), + &stage.workdir, + )); + } + paths + } + + fn get_run_cache_paths( + &mut self, + run: &Run, + path: &Vec, + workdir: &Option, + ) -> Vec { + let mut cache_paths = vec![]; + for (position, cache) in run.cache.iter().enumerate() { + let target = cache.target.clone(); + cache_paths.push(if target.starts_with("/") { + target.clone() + } else { + if let Some(workdir) = workdir { + format!("{}/{}", workdir, target) + } + else { + self.messages.push(LintMessage { + level: MessageLevel::Warn, + message: "The cache target should be absolute or a workdir should be defined in the stage".to_string(), + path: [path.clone(), vec!["cache".into(), position.to_string()]].concat(), + }); + target.clone() + } + }); + } + cache_paths + } + + ////////// Statics ////////// + + /// Analyze the given Dofigen configuration and return a lint session + pub fn analyze(dofigen: &Dofigen) -> Self { + let mut session = Self::default(); + dofigen.analyze(&mut session); + + session + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StageLintInfo { + dependencies: Vec, + cache_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LintMessage { + pub level: MessageLevel, + pub path: Vec, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MessageLevel { + Warn, + Error, +} + +#[cfg(test)] +mod test { + use crate::Dofigen; + + use super::*; + use pretty_assertions_sorted::assert_eq_sorted; + + mod stage_dependencies { + use super::*; + + #[test] + fn builders_dependencies() { + let dofigen = Dofigen { + builders: HashMap::from([ + ( + "builder1".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder2".into()), + paths: vec!["/path/to/copy".into()], + options: Default::default(), + ..Default::default() + })], + ..Default::default() + }, + ), + ( + "builder2".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder3".into()), + paths: vec!["/path/to/copy".into()], + options: Default::default(), + ..Default::default() + })], + ..Default::default() + }, + ), + ( + "builder3".into(), + Stage { + run: Run { + run: vec!["echo Hello".into()].into(), + ..Default::default() + }, + ..Default::default() + }, + ), + ]), + stage: Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder1".into()), + paths: vec!["/path/to/copy".into()], + ..Default::default() + })], + ..Default::default() + }, + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); + dependencies.sort(); + assert_eq_sorted!(dependencies, vec!["builder1", "builder2", "builder3"]); + + dependencies = lint_session.get_stage_recursive_dependencies("builder1".into()); + dependencies.sort(); + assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]); + + dependencies = lint_session.get_stage_recursive_dependencies("builder2".into()); + assert_eq_sorted!(dependencies, vec!["builder3"]); + + dependencies = lint_session.get_stage_recursive_dependencies("builder3".into()); + assert_eq_sorted!(dependencies, Vec::::new()); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]); + + assert_eq_sorted!(lint_session.messages, vec![]); + } + + #[test] + fn builders_circular_dependencies() { + let dofigen = Dofigen { + builders: HashMap::from([ + ( + "builder1".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder2".into()), + paths: vec!["/path/to/copy".into()], + options: Default::default(), + ..Default::default() + })], + ..Default::default() + }, + ), + ( + "builder2".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder3".into()), + paths: vec!["/path/to/copy".into()], + options: Default::default(), + ..Default::default() + })], + ..Default::default() + }, + ), + ( + "builder3".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder1".into()), + paths: vec!["/path/to/copy".into()], + options: Default::default(), + ..Default::default() + })], + ..Default::default() + }, + ), + ]), + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); + dependencies.sort(); + assert_eq_sorted!(dependencies, Vec::::new()); + + dependencies = lint_session.get_stage_recursive_dependencies("builder1".into()); + dependencies.sort(); + assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]); + + dependencies = lint_session.get_stage_recursive_dependencies("builder2".into()); + assert_eq_sorted!(dependencies, vec!["builder3"]); + + dependencies = lint_session.get_stage_recursive_dependencies("builder3".into()); + assert_eq_sorted!(dependencies, Vec::::new()); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Error, + path: vec![ + "builders".into(), + "builder3".into(), + "copy".into(), + "0".into(), + ], + message: + "Circular dependency detected: builder1 -> builder2 -> builder3 -> builder1" + .into(), + },] + ); + } + + #[test] + fn builder_named_runtime() { + let dofigen = Dofigen { + builders: HashMap::from([( + "runtime".into(), + Stage { + run: Run { + run: vec!["echo Hello".into()].into(), + ..Default::default() + }, + ..Default::default() + }, + )]), + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, Vec::::new()); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Error, + path: vec!["builders".into(), "runtime".into(),], + message: "The builder name 'runtime' is reserved".into(), + },] + ); + } + + #[test] + fn builder_not_found() { + let dofigen = Dofigen { + stage: Stage { + from: FromContext::FromBuilder("builder1".into()), + ..Default::default() + }, + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, Vec::::new()); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Error, + path: vec!["from".into(),], + message: "The builder 'builder1' not found".into(), + },] + ); + } + + #[test] + fn dependency_to_runtime() { + let dofigen = Dofigen { + builders: HashMap::from([( + "builder".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("runtime".into()), + paths: vec!["/path/to/copy".into()], + ..Default::default() + })], + ..Default::default() + }, + )]), + stage: Stage { + run: Run { + run: vec!["echo Hello".into()].into(), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, vec!["builder"]); + + assert_eq_sorted!( + lint_session.messages, + vec![ + LintMessage { + level: MessageLevel::Error, + path: vec![ + "builders".into(), + "builder".into(), + "copy".into(), + "0".into() + ], + message: "The stage 'builder' can't depend on the 'runtime'".into(), + }, + LintMessage { + level: MessageLevel::Warn, + path: vec!["builders".into(), "builder".into(),], + message: "The builder 'builder' is not used and should be removed".into(), + } + ] + ); + } + + #[test] + fn dependency_to_cache_path() { + let dofigen = Dofigen { + builders: HashMap::from([ + ( + "builder1".into(), + Stage { + run: Run { + run: vec!["echo Hello".into()].into(), + cache: vec![Cache { + target: "/path/to/cache".into(), + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }, + ), + ( + "builder2".into(), + Stage { + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("builder1".into()), + paths: vec!["/path/to/cache/test".into()], + ..Default::default() + })], + ..Default::default() + }, + ), + ]), + stage: Stage { + from: FromContext::FromBuilder("builder2".into()), + ..Default::default() + }, + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!(builders, vec!["builder1", "builder2"]); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Error, + path: vec![ + "builders".into(), + "builder2".into(), + "copy".into(), + "0".into() + ], + message: "Use of the 'builder1' builder cache path '/path/to/cache'".into(), + },] + ); + } + + #[test] + fn runtime_dependencies() { + let dofigen = Dofigen { + builders: HashMap::from([ + ( + "install-deps".to_string(), + Stage { + from: FromContext::FromImage(ImageName { + path: "php".to_string(), + version: Some(ImageVersion::Tag("8.3-fpm-alpine".to_string())), + ..Default::default() + }), + run: Run { + run: vec!["echo coucou".to_string()], + ..Default::default() + }, + ..Default::default() + }, + ), + ( + "install-php-ext".to_string(), + Stage { + from: FromContext::FromBuilder("install-deps".to_string()), + run: Run { + run: vec!["echo coucou".to_string()], + ..Default::default() + }, + ..Default::default() + }, + ), + ( + "get-composer".to_string(), + Stage { + from: FromContext::FromImage(ImageName { + path: "composer".to_string(), + version: Some(ImageVersion::Tag("latest".to_string())), + ..Default::default() + }), + run: Run { + run: vec!["echo coucou".to_string()], + ..Default::default() + }, + ..Default::default() + }, + ), + ]), + stage: Stage { + from: FromContext::FromBuilder("install-php-ext".to_string()), + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromBuilder("get-composer".to_string()), + paths: vec!["/usr/bin/composer".to_string()], + options: CopyOptions { + target: Some("/bin/".to_string()), + ..Default::default() + }, + ..Default::default() + })], + ..Default::default() + }, + ..Default::default() + }; + + let mut lint_session = LintSession::analyze(&dofigen); + + let mut dependencies = + lint_session.get_stage_recursive_dependencies("install-deps".into()); + dependencies.sort(); + assert_eq_sorted!(dependencies, Vec::::new()); + + dependencies = lint_session.get_stage_recursive_dependencies("install-php-ext".into()); + assert_eq_sorted!(dependencies, vec!["install-deps"]); + + dependencies = lint_session.get_stage_recursive_dependencies("get-composer".into()); + assert_eq_sorted!(dependencies, Vec::::new()); + + dependencies = lint_session.get_stage_recursive_dependencies("runtime".into()); + dependencies.sort(); + assert_eq_sorted!( + dependencies, + vec!["get-composer", "install-deps", "install-php-ext"] + ); + + let mut builders = lint_session.get_sorted_builders(); + builders.sort(); + + assert_eq_sorted!( + builders, + vec!["get-composer", "install-deps", "install-php-ext"] + ); + + assert_eq_sorted!(lint_session.messages, vec![]); + } + } + + mod builder { + use super::*; + + #[test] + fn empty() { + let dofigen = Dofigen { + builders: HashMap::from([( + "builder".into(), + Stage { + from: FromContext::FromImage(ImageName { + path: "php".into(), + ..Default::default() + }), + ..Default::default() + }, + )]), + stage: Stage { + from: FromContext::FromBuilder("builder".into()), + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Warn, + path: vec!["builders".into(), "builder".into()], + message: "The builder 'builder' is empty and should be removed".into(), + },] + ); + } + + #[test] + fn unused() { + let dofigen = Dofigen { + builders: HashMap::from([( + "builder".into(), + Stage { + from: FromContext::FromImage(ImageName { + ..Default::default() + }), + run: Run { + run: vec!["echo Hello".into()], + ..Default::default() + }, + ..Default::default() + }, + )]), + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Warn, + path: vec!["builders".into(), "builder".into()], + message: "The builder 'builder' is not used and should be removed".into(), + },] + ); + } + } + + mod user { + use super::*; + + #[test] + fn uid() { + let dofigen = Dofigen { + stage: Stage { + user: Some(User::new("1000")), + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!(lint_session.messages, vec![]); + } + + #[test] + fn username() { + let dofigen = Dofigen { + stage: Stage { + user: Some(User::new("test")), + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!( + lint_session.messages, + vec![LintMessage { + level: MessageLevel::Warn, + path: vec!["user".into()], + message: "UID should be used instead of username".into(), + },] + ); + } + } + + mod from_context { + use super::*; + + #[test] + fn stage_and_copy() { + let dofigen = Dofigen { + stage: Stage { + from: FromContext::FromContext(Some("php:8.3-fpm-alpine".into())), + copy: vec![CopyResource::Copy(Copy { + from: FromContext::FromContext(Some("composer:latest".into())), + paths: vec!["/usr/bin/composer".into()], + ..Default::default() + })], + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!(lint_session.messages, vec![ + LintMessage { + level: MessageLevel::Warn, + path: vec!["fromContext".into()], + message: "Prefer to use fromImage and fromBuilder instead of fromContext".into(), + }, + LintMessage { + level: MessageLevel::Warn, + path: vec!["copy".into(), "0".into(), "fromContext".into()], + 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(), + } + ]); + } + + #[test] + fn root_bind() { + let dofigen = Dofigen { + builders: HashMap::from([( + "builder".into(), + Stage { + root: Some(Run { + bind: vec![Bind { + from: FromContext::FromContext(Some("builder".into())), + source: Some("/path/to/bind".into()), + target: "/path/to/target".into(), + ..Default::default() + }], + run: vec!["echo Hello".into()], + ..Default::default() + }), + ..Default::default() + }, + )]), + stage: Stage { + from: FromContext::FromBuilder("builder".into()), + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!(lint_session.messages, vec![ + LintMessage { + level: MessageLevel::Warn, + path: vec![ + "builders".into(), + "builder".into(), + "root".into(), + "bind".into(), + "0".into(), + "fromContext".into(), + ], + 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(), + } + ]); + } + } + + mod run { + use super::*; + + #[test] + fn empty_run() { + let dofigen = Dofigen { + stage: Stage { + run: Run { + bind: vec![Bind { + source: Some("/path/to/bind".into()), + target: "/path/to/target".into(), + ..Default::default() + }], + cache: vec![Cache { + source: Some("/path/to/cache".into()), + target: "/path/to/target".into(), + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + let lint_session = LintSession::analyze(&dofigen); + + assert_eq_sorted!( + lint_session.messages, + vec![ + LintMessage { + level: MessageLevel::Warn, + message: "The run list is empty but there are bind definitions".into(), + path: vec!["bind".into()], + }, + LintMessage { + level: MessageLevel::Warn, + message: "The run list is empty but there are cache definitions".into(), + path: vec!["cache".into()], + }, + ] + ); + } + } +} diff --git a/tests/cases.rs b/tests/cases.rs index 9bb1e71..0284089 100644 --- a/tests/cases.rs +++ b/tests/cases.rs @@ -47,7 +47,9 @@ fn test_cases() { if let Some(content) = dockerfile_results.get(basename.as_str()) { println!("Compare with Dockerfile result"); - let dockerfile = generate_dockerfile(&dofigen).unwrap(); + let dockerfile = GenerationContext::from(dofigen) + .generate_dockerfile() + .unwrap(); assert_eq_sorted!(&dockerfile, content); } } @@ -108,7 +110,9 @@ fn test_load_url() { .unwrap() ); - let dockerfile = generate_dockerfile(&dofigen).unwrap(); + let dockerfile = GenerationContext::from(dofigen) + .generate_dockerfile() + .unwrap(); assert_eq_sorted!( dockerfile, std::fs::read_to_string(test_case_dir.join("springboot-maven.override.result.Dockerfile")) diff --git a/tests/lib_test.rs b/tests/lib_test.rs index afdf782..be92bcb 100644 --- a/tests/lib_test.rs +++ b/tests/lib_test.rs @@ -9,7 +9,9 @@ fn yaml_to_dockerfile_empty() { .parse_from_string(yaml) .map_err(Error::from) .unwrap(); - let dockerfile: String = generate_dockerfile(&dofigen).unwrap(); + + let mut generation_context = GenerationContext::from(dofigen); + let dockerfile: String = generation_context.generate_dockerfile().unwrap(); assert_eq_sorted!( dockerfile, @@ -23,7 +25,7 @@ USER 1000:1000 "# ); - let dockerignore: String = generate_dockerignore(&dofigen); + let dockerignore: String = generation_context.generate_dockerignore().unwrap(); assert_eq_sorted!( dockerignore, @@ -33,12 +35,12 @@ USER 1000:1000 #[test] #[cfg(feature = "permissive")] -fn yaml_to_dockerfile_complexe() { +fn yaml_to_dockerfile_complex() { let yaml = r#" builders: builder: fromImage: ekidd/rust-musl-builder - user: rust + user: 1000 add: "." run: - ls -al @@ -72,7 +74,8 @@ ignores: .parse_from_string(yaml) .map_err(Error::from) .unwrap(); - let dockerfile: String = generate_dockerfile(&dofigen).unwrap(); + let mut generation_context = GenerationContext::from(dofigen); + let dockerfile: String = generation_context.generate_dockerfile().unwrap(); assert_eq_sorted!( dockerfile, @@ -83,12 +86,12 @@ ignores: # builder FROM ekidd/rust-musl-builder AS builder COPY \ - --chown=rust \ + --chown=1000 \ --link \ "." "./" -USER rust +USER 1000 RUN \ - --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/registry,uid=1000,sharing=locked \ <