orcs 0.0.8

Microservices monorepo orchestration tool
Documentation
use crate::{git::init_repo, Error, Recipe, Service, Template};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs::{create_dir, File};
use std::io::prelude::*;
use std::path::Path;
use std::sync::{Arc, RwLock};
use tracing::{debug, instrument};

const RESERVED_VALUES: &[&str] = &["all", "env", "stage", "service", "recipe"];
const PROJECT_CFG_TEMPLATE: &str = "[global.params]
project = \"{{{ project_name }}}\"

##################
#  Environments  #
##################

[envs.dev]
params.is-prod = \"false\"

[envs.prod]
params.is-prod = \"true\"

##################
#     Stages     #
##################

[stages.build]

[stages.deploy]
depends_on = [\"build\"]

params.target = \"linux\"

envs = [\"dev\", \"prod\"]
";

/// Orcs Project File Structure
#[derive(Serialize, Deserialize, Default)]
pub struct Project {
    #[serde(skip)]
    pub path: String,

    /// Store for all services
    ///
    /// For projects with lots of services, this prevent going to disk every
    /// time we need to fetch metadata about a given service. This also allow
    /// re-use for dependencies.
    #[serde(skip)]
    pub services: Arc<RwLock<HashMap<String, Arc<Service>>>>,

    /// Store for all recipes
    #[serde(skip)]
    pub recipes: Arc<RwLock<HashMap<String, Arc<Recipe>>>>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub global: Option<Section>,

    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub envs: HashMap<String, Section>,

    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub stages: HashMap<String, Stage>,

    #[serde(default)]
    pub store: Store,
}

impl Project {
    /// Create a new project at the specified path
    pub fn create(project_path: &str, template: Option<&str>) -> Result<(), Error> {
        debug!("Create project {}", project_path);
        if project_path.contains("..") {
            return Err(Error::CreateProjectError(
                "illegal sequence in project name",
                project_path.to_string(),
            ));
        }

        // Create the project folder
        create_dir(project_path)?;

        // Initialize the git repo
        init_repo(project_path)?;

        // Create template
        let template = match template {
            Some(template) => Template::from_string(template),
            None => {
                let mut template_map = HashMap::new();
                template_map.insert("orcs.toml".to_string(), PROJECT_CFG_TEMPLATE.to_string());
                Template::from_map(template_map)
            }
        };

        // Render template
        let mut template_data = HashMap::new();
        let project_path = Path::new(project_path).canonicalize()?;
        let project_name = project_path.file_name().unwrap().to_str().unwrap();
        template_data.insert("project_name", &project_name);

        template.render(project_path.as_path(), &template_data)?;

        // Done here
        Ok(())
    }

    /// Load the project from a project folder
    #[instrument]
    pub fn from_path(project_path: &str) -> Result<Arc<Self>, Error> {
        debug!("Load project");
        let filename = Path::new(project_path).canonicalize()?.join("orcs.toml");
        if !filename.is_file() {
            // File not found, return an error
            return Err(Error::LoadProjectError("could not find the project file"));
        }

        // Load the file
        debug!("Load project configuration file");
        let mut file = File::open(&filename)?;
        let mut data = String::new();
        file.read_to_string(&mut data)?;
        let extension = match filename.extension() {
            Some(ext) => ext,
            _ => return Err(Error::LoadProjectError("missing project file extension")),
        }
        .to_str();
        debug!("Deserialize configuration file");
        match extension {
            Some("toml") => Self::from_toml(project_path, &data),
            _ => Err(Error::LoadProjectError("unsupported extension")),
        }
    }

    /// Create a project from TOML data
    fn from_toml(project_path: &str, data: &str) -> Result<Arc<Self>, Error> {
        let mut project: Self = toml::from_str(&data)?;
        project.path = project_path.to_string();
        project.validate()?;
        Ok(Arc::new(project))
    }

    /// Return the canonical path to the project
    pub fn canonical_path(&self) -> std::path::PathBuf {
        Path::new(&self.path)
            .canonicalize()
            .expect("Could not canonicalize path")
    }

    /// Check if the project file is valid
    #[instrument(skip(self))]
    fn validate(&self) -> Result<(), Error> {
        debug!("Validate project configuration");
        // Stage cannot have a reserved value as name
        for reserved_value in RESERVED_VALUES {
            if self.stages.contains_key(&(*reserved_value).to_string()) {
                return Err(Error::CheckProjectError(
                    "invalid stage name",
                    self.path.clone(),
                ));
            }
            if self.envs.contains_key(&(*reserved_value).to_string()) {
                return Err(Error::CheckProjectError(
                    "invalid environment name",
                    self.path.clone(),
                ));
            }
        }

        // TODO: check that depends_on values exist

        // Environments in stages must exist
        for stage in self.stages.values() {
            for stage_env in &stage.envs {
                // Stage environments must exist
                if !self.envs.contains_key(stage_env) {
                    return Err(Error::CheckProjectError(
                        "env in stage does not exist",
                        stage_env.clone(),
                    ));
                }
            }
            for stage_dep in &stage.depends_on {
                // Stage dependencies must exist
                if !self.stages.contains_key(stage_dep) {
                    return Err(Error::CheckProjectError(
                        "stage in depends_on does not exist",
                        stage_dep.clone(),
                    ));
                }

                // Stage dependencies must not be skipped on complete run
                if self.stages.get(stage_dep).unwrap().skip {
                    return Err(Error::CheckProjectError(
                        "stage in depends_on cannot be skipped",
                        stage_dep.clone(),
                    ));
                }
            }
        }

        Ok(())
    }
}

impl PartialEq for Project {
    fn eq(&self, other: &Self) -> bool {
        self.path == other.path
    }
}

impl fmt::Debug for Project {
    /// Custom formatter to prevent recursion with the project
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Project")
            .field("path", &self.path)
            .field("global", &self.global)
            .field("envs", &self.envs)
            .field("stages", &self.stages)
            .field("store", &self.store)
            .finish()
    }
}

/// Section in an Orcs Project File
#[derive(Serialize, Deserialize, Debug)]
pub struct Section {
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub params: HashMap<String, String>,
}

/// Stage Sections in an Orcs Project File
#[derive(Serialize, Deserialize, Debug)]
pub struct Stage {
    #[serde(default)]
    pub depends_on: Vec<String>,

    #[serde(default)]
    pub skip: bool,

    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub params: HashMap<String, String>,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub envs: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Store {
    #[serde(rename = "type")]
    pub store_type: String,
}

impl Default for Store {
    fn default() -> Self {
        Self {
            store_type: "local".to_string(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::prelude::*;

    // TODO: Refactor this test
    // #[test]
    // fn load_template() {
    //     match Project::from_toml(".", format!(PROJECT_CFG_TEMPLATE!(), ".").as_str()) {
    //         Ok(_) => assert!(true),
    //         _ => assert!(false, "template should be loadable"),
    //     }
    // }

    #[test]
    fn validate_reserved_stage_name() {
        let mut rng = rand::thread_rng();
        let reserved_value = RESERVED_VALUES.choose(&mut rng).unwrap();

        let data = format!("[stages.{}]", reserved_value);

        match Project::from_toml(".", &data) {
            Err(Error::CheckProjectError(_, _)) => assert!(true),
            _ => assert!(
                false,
                "validate should fail for reserved value in stage name"
            ),
        }
    }

    #[test]
    fn validate_reserved_env_name() {
        let mut rng = rand::thread_rng();
        let reserved_value = RESERVED_VALUES.choose(&mut rng).unwrap();

        let data = format!("[envs.{}]", reserved_value);

        match Project::from_toml(".", &data) {
            Err(Error::CheckProjectError(_, _)) => assert!(true),
            _ => assert!(false, "validate should fail for reserved value in env name"),
        }
    }

    #[test]
    fn validate_missing_env_in_stage() {
        let data = "[stages.missing_env]
        envs = [\"missing_env\"]"
            .to_string();

        match Project::from_toml(".", &data) {
            Err(Error::CheckProjectError(_, _)) => assert!(true),
            _ => assert!(false, "validate should fail for missing env in stage"),
        }
    }

    #[test]
    fn validate_missing_dependency_in_stage() {
        let data = "[stages.test_dep]
        depends_on = [\"missing_dep\"]"
            .to_string();

        match Project::from_toml(".", &data) {
            Err(Error::CheckProjectError(_, _)) => assert!(true),
            _ => assert!(
                false,
                "validate should fail for missing dependency in stage"
            ),
        }
    }

    #[test]
    fn validate_skipped_dependency_in_stage() {
        let data = "[stages.test_dep]
        depends_on = [\"skipped_dep\"]
        
        [stages.skipped_dep]
        skip = true"
            .to_string();

        match Project::from_toml(".", &data) {
            Err(Error::CheckProjectError(_, _)) => assert!(true),
            _ => assert!(
                false,
                "validate should fail for skipped dependency in stage"
            ),
        }
    }

    // TODO: make this pass
    // #[test]
    // fn validate_circular_dependency_stage() {
    //     let data = "[stages.circular]
    //     depends_on = [\"circular\"]".to_string();

    //     match Project::from_toml(".", &data) {
    //         Err(Error::CheckProjectError(_, _)) => assert!(true),
    //         _ => assert!(false, "validate should fail for circular dependency on itself"),
    //     }
    // }

    // TODO: make this pass
    // #[test]
    // fn validate_circular_dependency_stage2() {
    //     let data = "[stages.circular1]
    //     depends_on = [\"circular2\"]
    //     [stages.circular2]
    //     depends_on = [\"circular1\"]".to_string();

    //     match Project::from_toml(".", &data) {
    //         Err(Error::CheckProjectError(_, _)) => assert!(true),
    //         _ => assert!(false, "validate should fail for circular dependency"),
    //     }
    // }
}