#[cfg(test)]
use compose_yml::v2 as dc;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
use std::env;
use std::fs;
use std::io;
use std::io::Read;
use std::marker::PhantomData;
use std::ops::Deref;
use std::path::Path;
use std::path::PathBuf;
use std::result;
use std::slice;
use std::str;
use crate::dir;
use crate::errors::*;
use crate::hook::HookManager;
use crate::plugins::{self, Operation};
use crate::pod::{Pod, PodType};
use crate::runtime_state::RuntimeState;
use crate::serde_helpers::deserialize_parsable_opt;
use crate::service_locations::ServiceLocations;
use crate::sources::Sources;
use crate::target::Target;
use crate::util::{ConductorPathExt, ToStrOrErr};
use crate::version;
use crate::{default_tags::DefaultTags, sources::SourcesDirs};
use rayon::prelude::*;
lazy_static! {
pub static ref PROJECT_CONFIG_PATH: PathBuf =
Path::new("config/project.yml").to_owned();
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(default, deserialize_with = "deserialize_parsable_opt")]
pub cage_version: Option<semver::VersionReq>,
#[serde(default, skip_deserializing)]
_phantom: PhantomData<()>,
}
impl ProjectConfig {
pub fn new(path: &Path) -> Result<Self> {
if path.exists() {
let mkerr = || ErrorKind::CouldNotReadFile(path.to_owned());
let f = fs::File::open(path).chain_err(&mkerr)?;
let mut reader = io::BufReader::new(f);
let mut yaml = String::new();
reader.read_to_string(&mut yaml).chain_err(&mkerr)?;
Self::check_config_version(&path, &yaml)?;
serde_yaml::from_str(&yaml).chain_err(&mkerr)
} else {
warn!("No {} file, using default values", path.display());
Ok(Default::default())
}
}
fn check_config_version(path: &Path, config_yml: &str) -> Result<()> {
#[derive(Debug, Deserialize)]
struct VersionOnly {
#[serde(default, deserialize_with = "deserialize_parsable_opt")]
cage_version: Option<semver::VersionReq>,
}
let config: VersionOnly = serde_yaml::from_str(config_yml)
.chain_err(|| ErrorKind::CouldNotReadFile(path.to_owned()))?;
if let Some(ref req) = config.cage_version {
if !req.matches(&version()) {
return Err(ErrorKind::MismatchedVersion(req.to_owned()).into());
}
} else {
warn!(
"No cage_version specified in {}, trying anyway",
path.display()
);
}
Ok(())
}
}
#[test]
fn semver_behaves_as_expected() {
let req = semver::VersionReq::parse("0.2.3").unwrap();
let examples = &[
("0.2.2", false),
("0.2.3", true),
("0.2.4", true),
("0.3.0", false),
];
for &(version, expected_to_match) in examples {
assert_eq!(
req.matches(&semver::Version::parse(version).unwrap()),
expected_to_match
);
}
}
#[test]
fn check_config_version() {
let p = Path::new("dummy.yml");
let yaml = format!("cage_version: \"{}\"", version());
assert!(ProjectConfig::check_config_version(&p, &yaml).is_ok());
let yaml = "---\n{}";
ProjectConfig::check_config_version(&p, yaml).unwrap();
assert!(ProjectConfig::check_config_version(&p, yaml).is_ok());
let yaml = "---\ncage_version: \"0.0.1\"\nunknown_field: true";
let res = ProjectConfig::check_config_version(&p, yaml);
assert!(res.is_err());
match *res.unwrap_err().kind() {
ErrorKind::MismatchedVersion(_) => {}
ref e => panic!("Unexpected error type {}", e),
}
}
#[derive(Debug)]
pub enum PodOrService<'a> {
Pod(&'a Pod),
Service(&'a Pod, &'a str),
}
impl<'a> PodOrService<'a> {
pub fn pod_type(&self) -> PodType {
match *self {
PodOrService::Pod(pod) | PodOrService::Service(pod, _) => pod.pod_type(),
}
}
}
#[derive(Debug)]
pub struct Project {
name: String,
root_dir: PathBuf,
src_dir: PathBuf,
output_dir: PathBuf,
pods: Vec<Pod>,
service_locations: ServiceLocations,
targets: Vec<Target>,
current_target: Target,
sources: Sources,
hooks: HookManager,
config: ProjectConfig,
default_tags: Option<DefaultTags>,
plugins: Option<plugins::Manager>,
}
impl Project {
fn from_dirs(
root_dir: &Path,
src_dir: &Path,
output_dir: &Path,
) -> Result<Project> {
let targets = Project::find_targets(root_dir)?;
let current_target = targets
.iter()
.find(|target| target.name() == "development")
.ok_or_else(|| ErrorKind::UnknownTarget("development".into()))?
.to_owned();
let pods = Project::find_pods(root_dir, &targets)?;
let service_locations = ServiceLocations::new(&pods);
let sources = Sources::new(root_dir, output_dir, &pods)?;
let config_path = root_dir.join(PROJECT_CONFIG_PATH.deref());
let config = ProjectConfig::new(&config_path)?;
let absolute_root = root_dir.to_absolute()?;
let name = absolute_root
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| {
err!("Can't find directory name for {}", root_dir.display())
})?;
let mut proj = Project {
name: name.to_owned(),
root_dir: root_dir.to_owned(),
src_dir: src_dir.to_owned(),
output_dir: output_dir.to_owned(),
pods,
service_locations,
targets,
current_target,
sources,
hooks: HookManager::new(root_dir)?,
config,
default_tags: None,
plugins: None,
};
let plugins = plugins::Manager::new(&proj)?;
proj.plugins = Some(plugins);
Ok(proj)
}
pub fn from_current_dir() -> Result<Project> {
let current = env::current_dir()?;
let root_dir = dir::find_project(¤t)?;
Project::from_dirs(&root_dir, &root_dir.join("src"), &root_dir.join(".cage"))
}
#[cfg(test)]
pub fn from_example(name: &str) -> Result<Project> {
use rand::random;
Project::from_example_and_random_id(name, random())
}
#[cfg(test)]
pub fn from_example_and_random_id(name: &str, id: u16) -> Result<Project> {
let root_dir = Path::new("examples").join(name);
let rand_name = format!("{}-{}", name, id);
let test_output = Path::new("target/test_output").join(&rand_name);
Project::from_dirs(&root_dir, &test_output.join("src"), &test_output)
}
#[cfg(test)]
pub fn from_fixture(name: &str) -> Result<Project> {
use rand::random;
let root_dir = Path::new("tests/fixtures").join(name);
let rand_name = format!("{}-{}", name, random::<u16>());
let test_output = Path::new("target/test_output").join(&rand_name);
Project::from_dirs(&root_dir, &test_output.join("src"), &test_output)
}
#[cfg(test)]
pub fn remove_test_output(&self) -> Result<()> {
if self.output_dir.exists() {
fs::remove_dir_all(&self.output_dir)?;
}
Ok(())
}
fn find_targets(root_dir: &Path) -> Result<Vec<Target>> {
let targets_dir = root_dir.join("pods").join("targets");
let mut targets = vec![];
for glob_result in targets_dir.glob("*")? {
let path = glob_result?;
if path.is_dir() {
let name = path.file_name().unwrap().to_str_or_err()?.to_owned();
targets.push(Target::new(name));
}
}
Ok(targets)
}
fn find_pods(root_dir: &Path, targets: &[Target]) -> Result<Vec<Pod>> {
let pods_dir = root_dir.join("pods");
let mut pods = vec![];
for glob_result in pods_dir.glob("*.yml")? {
let path = glob_result?;
let name = path.file_stem().unwrap().to_str_or_err()?.to_owned();
if !name.ends_with(".metadata") {
pods.push(Pod::new(pods_dir.clone(), name, targets)?);
}
}
pods.sort_by_key(|p| (p.pod_type(), p.name().to_owned()));
Ok(pods)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn set_name(&mut self, name: &str) -> &mut Project {
self.name = name.to_owned();
self
}
pub fn compose_name(&self) -> String {
self.current_target.compose_project_name(self)
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn src_dir(&self) -> &Path {
&self.src_dir
}
pub fn output_dir(&self) -> &Path {
&self.output_dir
}
pub fn pods_dir(&self) -> PathBuf {
self.root_dir.join("pods")
}
pub fn output_pods_dir(&self) -> PathBuf {
self.output_dir.join("pods")
}
pub(crate) fn sources_dirs(&self) -> SourcesDirs {
SourcesDirs {
src_dir: self.src_dir().to_owned(),
pods_dir: self.pods_dir(),
}
}
pub fn pods(&self) -> Pods<'_> {
Pods {
iter: self.pods.iter(),
}
}
pub fn pod(&self, name: &str) -> Option<&Pod> {
self.pods().find(|pod| pod.name() == name)
}
pub fn service<'a>(&self, name: &'a str) -> Option<(&Pod, &str)> {
if let Some((pod_name, service_name)) = self.service_locations.find(name) {
let pod = self.pod(pod_name).expect("pod should exist");
Some((pod, service_name))
} else {
None
}
}
pub fn service_or_err<'a>(&self, name: &'a str) -> Result<(&Pod, &str)> {
self.service(name)
.ok_or_else(|| ErrorKind::UnknownService(name.to_owned()).into())
}
pub fn pod_or_service<'a, 'b>(
&'a self,
name: &'b str,
) -> Option<PodOrService<'a>> {
if let Some(pod) = self.pod(name) {
Some(PodOrService::Pod(pod))
} else if let Some((pod, service_name)) = self.service(name) {
Some(PodOrService::Service(pod, service_name))
} else {
None
}
}
pub fn pod_or_service_or_err<'a, 'b>(
&'a self,
name: &'b str,
) -> Result<PodOrService<'a>> {
self.pod_or_service(name)
.ok_or_else(|| ErrorKind::UnknownPodOrService(name.to_owned()).into())
}
pub fn targets(&self) -> Targets<'_> {
Targets {
iter: self.targets.iter(),
}
}
pub fn target(&self, name: &str) -> Option<&Target> {
self.targets().find(|target| target.name() == name)
}
pub fn target_or_err(&self, name: &str) -> Result<&Target> {
self.target(name)
.ok_or_else(|| ErrorKind::UnknownTarget(name.into()).into())
}
pub fn current_target(&self) -> &Target {
&self.current_target
}
pub fn set_current_target_name(&mut self, name: &str) -> Result<()> {
self.current_target = self.target_or_err(name)?.to_owned();
Ok(())
}
pub fn sources(&self) -> &Sources {
&self.sources
}
pub fn sources_mut(&mut self) -> &mut Sources {
&mut self.sources
}
pub fn hooks(&self) -> &HookManager {
&self.hooks
}
pub fn default_tags(&self) -> Option<&DefaultTags> {
self.default_tags.as_ref()
}
pub fn set_default_tags(&mut self, tags: DefaultTags) -> &mut Project {
self.default_tags = Some(tags);
self
}
pub fn plugins(&self) -> &plugins::Manager {
self.plugins
.as_ref()
.expect("plugins should always be set at Project init")
}
pub fn save_settings(&mut self) -> Result<()> {
self.sources.save_settings(&self.output_dir)
}
pub fn enabled_pods_that_are_not_running(&self) -> Result<Vec<&Pod>> {
let mut result = vec![];
let state = RuntimeState::for_project(self)?;
for pod in self.pods() {
if pod.enabled_in(&self.current_target)
&& pod.pod_type() != PodType::Task
&& !state.all_services_in_pod_are_running(pod)
{
result.push(pod);
}
}
Ok(result)
}
fn output_helper(
&self,
op: Operation,
subcommand: &str,
export_dir: &Path,
) -> Result<()> {
self.pods
.par_iter()
.filter(|pod| {
pod.enabled_in(&self.current_target) || op == Operation::Output
})
.map(|pod| -> Result<()> {
let file_name = format!("{}.yml", pod.name());
let rel_path = match (op, pod.pod_type()) {
(Operation::Export, PodType::Task) => {
Path::new("tasks").join(file_name)
}
_ => Path::new(&file_name).to_owned(),
};
let out_path = export_dir.join(&rel_path).with_guaranteed_parent()?;
debug!("Outputting {}", out_path.display());
let mut file = pod.merged_file(&self.current_target)?;
file.make_standalone(&self.pods_dir())?;
let ctx = plugins::Context::new(self, pod, subcommand);
self.plugins().transform(op, &ctx, &mut file)?;
file.write_to_path(out_path)?;
Ok(())
})
.reduce_with(|result1, result2| result1.and(result2).and(Ok(())))
.unwrap_or(Ok(()))
}
pub fn output(&self, subcommand: &str) -> Result<()> {
let out_pods = self.output_pods_dir();
if out_pods.exists() {
fs::remove_dir_all(&out_pods)
.map_err(|e| err!("Cannot delete {}: {}", out_pods.display(), e))?;
}
self.output_helper(Operation::Output, subcommand, &out_pods)
}
pub fn export(&self, export_dir: &Path) -> Result<()> {
if export_dir.exists() {
return Err(err!(
"The directory {} already exists",
export_dir.display()
));
}
if self.default_tags().is_none() {
warn!("Exporting project without --default-tags");
}
self.output_helper(Operation::Export, "export", export_dir)
}
}
impl Serialize for Project {
fn serialize<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("name", self.name())?;
map.end()
}
}
#[derive(Debug, Clone)]
pub struct Pods<'a> {
iter: slice::Iter<'a, Pod>,
}
impl<'a> Iterator for Pods<'a> {
type Item = &'a Pod;
fn next(&mut self) -> Option<&'a Pod> {
self.iter.next()
}
}
#[derive(Debug, Clone)]
pub struct Targets<'a> {
iter: slice::Iter<'a, Target>,
}
impl<'a> Iterator for Targets<'a> {
type Item = &'a Target;
fn next(&mut self) -> Option<&'a Target> {
self.iter.next()
}
}
#[test]
fn new_from_example_uses_example_and_target() {
let _ = env_logger::try_init();
let proj = Project::from_example("hello").unwrap();
assert_eq!(proj.root_dir, Path::new("examples/hello"));
let output_dir = proj.output_dir.to_str_or_err().unwrap();
assert!(
output_dir.starts_with("target/test_output/hello-")
|| output_dir.starts_with("target/test_output\\hello-")
);
let src_dir = proj.src_dir.to_str_or_err().unwrap();
assert!(
src_dir.starts_with("target/test_output/hello-")
|| src_dir.starts_with("target/test_output\\hello-")
);
}
#[test]
fn name_defaults_to_project_dir_but_can_be_overridden() {
let _ = env_logger::try_init();
let mut proj = Project::from_example("hello").unwrap();
assert_eq!(proj.name(), "hello");
proj.set_name("hi");
assert_eq!(proj.name(), "hi");
}
#[test]
fn pod_or_service_finds_either() {
let _ = env_logger::try_init();
let proj = Project::from_example("hello").unwrap();
match proj.pod_or_service("frontend").unwrap() {
PodOrService::Pod(pod) => assert_eq!(pod.name(), "frontend"),
_ => panic!("Did not find pod 'frontend'"),
}
match proj.pod_or_service("frontend/web").unwrap() {
PodOrService::Service(pod, "web") => assert_eq!(pod.name(), "frontend"),
_ => panic!("Did not find service 'frontend/web'"),
}
match proj.pod_or_service("web").unwrap() {
PodOrService::Service(pod, "web") => assert_eq!(pod.name(), "frontend"),
_ => panic!("Did not find service 'web'"),
}
}
#[test]
fn pods_are_loaded() {
let _ = env_logger::try_init();
let proj = Project::from_example("rails_hello").unwrap();
let names: Vec<_> = proj.pods.iter().map(|pod| pod.name()).collect();
assert_eq!(names, ["db", "frontend", "rake"]);
}
#[test]
fn targets_are_loaded() {
let _ = env_logger::try_init();
let proj = Project::from_example("hello").unwrap();
let names: Vec<_> = proj.targets.iter().map(|o| o.name()).collect();
assert_eq!(names, ["development", "production", "test"]);
}
#[test]
fn output_creates_a_directory_of_flat_yml_files() {
let _ = env_logger::try_init();
let proj = Project::from_example("rails_hello").unwrap();
proj.output("up").unwrap();
assert!(proj.output_dir.join("pods").join("frontend.yml").exists());
assert!(proj.output_dir.join("pods").join("db.yml").exists());
assert!(proj.output_dir.join("pods").join("rake.yml").exists());
proj.remove_test_output().unwrap();
}
#[test]
fn output_applies_expected_transforms() {
let _ = env_logger::try_init();
let cursor = io::Cursor::new("dockercloud/hello-world:staging\n");
let default_tags = DefaultTags::read(cursor).unwrap();
let mut proj = Project::from_example("hello").unwrap();
proj.set_default_tags(default_tags);
let sources_dirs = proj.sources_dirs();
{
let source = proj
.sources_mut()
.find_by_alias_mut("dockercloud-hello-world")
.unwrap();
source.fake_clone_source(&sources_dirs).unwrap();
}
proj.output("build").unwrap();
let frontend_file = proj.output_dir().join("pods").join("frontend.yml");
let file = dc::File::read_from_path(frontend_file).unwrap();
let web = file.services.get("web").unwrap();
let source = proj
.sources()
.find_by_alias("dockercloud-hello-world")
.unwrap();
let src_path = source.path(&sources_dirs).to_absolute().unwrap();
assert_eq!(
web.build.as_ref().unwrap().context.value().unwrap(),
&dc::Context::new(src_path.to_str().unwrap())
);
let mount = web
.volumes
.last()
.expect("expected web service to have volumes")
.value()
.unwrap();
assert_eq!(mount.host, Some(dc::HostVolume::Path(src_path)));
assert_eq!(mount.container, "/app");
assert_eq!(
web.image.as_ref().unwrap().value().unwrap(),
&dc::Image::new("dockercloud/hello-world:staging").unwrap()
);
proj.remove_test_output().unwrap();
}
#[test]
fn output_mounts_cloned_libraries() {
let _ = env_logger::try_init();
let mut proj = Project::from_example("rails_hello").unwrap();
let sources_dirs = proj.sources_dirs();
{
let source = proj
.sources_mut()
.find_by_lib_key_mut("coffee_rails")
.expect("should define lib coffee_rails");
source.fake_clone_source(&sources_dirs).unwrap();
}
proj.output("up").unwrap();
let frontend_file = proj.output_dir().join("pods").join("frontend.yml");
let file = dc::File::read_from_path(frontend_file).unwrap();
let web = file.services.get("web").unwrap();
let source = proj
.sources()
.find_by_lib_key("coffee_rails")
.expect("should define lib coffee_rails");
let src_path = source.path(&proj.sources_dirs()).to_absolute().unwrap();
let mount = web
.volumes
.last()
.expect("expected web service to have volumes")
.value()
.unwrap();
assert_eq!(mount.host, Some(dc::HostVolume::Path(src_path)));
assert_eq!(mount.container, "/usr/src/app/vendor/coffee-rails");
}
#[test]
fn output_supports_in_tree_source_code() {
let proj = Project::from_example("node_hello").unwrap();
proj.output("build").unwrap();
let frontend_file = proj.output_dir().join("pods").join("frontend.yml");
let file = dc::File::read_from_path(frontend_file).unwrap();
let web = file.services.get("web").unwrap();
let abs_src = proj
.root_dir()
.join("pods")
.join("..")
.join("src")
.join("node_hello")
.to_absolute()
.unwrap();
assert_eq!(
web.build.as_ref().unwrap().context.value().unwrap(),
&dc::Context::Dir(abs_src)
);
}
#[test]
fn export_creates_a_directory_of_flat_yml_files() {
let _ = env_logger::try_init();
let mut proj = Project::from_example("rails_hello").unwrap();
let export_dir = proj.output_dir.join("hello_export");
proj.set_current_target_name("production").unwrap();
proj.export(&export_dir).unwrap();
assert!(export_dir.join("frontend.yml").exists());
assert!(!export_dir.join("db.yml").exists());
assert!(export_dir.join("tasks").join("rake.yml").exists());
proj.remove_test_output().unwrap();
}
#[test]
fn export_applies_expected_transforms() {
let _ = env_logger::try_init();
let mut proj = Project::from_example("hello").unwrap();
let sources_dirs = proj.sources_dirs();
{
let source = proj
.sources_mut()
.find_by_alias_mut("dockercloud-hello-world")
.unwrap();
source.fake_clone_source(&sources_dirs).unwrap();
}
let export_dir = proj.output_dir.join("hello_export");
proj.export(&export_dir).unwrap();
let frontend_file = export_dir.join("frontend.yml");
let file = dc::File::read_from_path(frontend_file).unwrap();
let web = file.services.get("web").unwrap();
assert!(web.build.is_none());
assert_eq!(
web.labels
.get("io.fdy.cage.target")
.unwrap()
.value()
.unwrap(),
"development"
);
assert_eq!(
web.labels.get("io.fdy.cage.pod").unwrap().value().unwrap(),
"frontend"
);
}