use compose_yml::v2 as dc;
use compose_yml::v2::MergeOverride;
use std::collections::btree_map;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::args;
use crate::cmd::CommandRun;
use crate::command_runner::CommandRunner;
use crate::errors::*;
use crate::project::Project;
use crate::serde_helpers::load_yaml;
use crate::target::Target;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
pub enum PodType {
#[serde(rename = "placeholder")]
Placeholder,
#[serde(rename = "service")]
Service,
#[serde(rename = "task")]
Task,
}
impl fmt::Display for PodType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
PodType::Placeholder => write!(f, "placeholder"),
PodType::Service => write!(f, "service"),
PodType::Task => write!(f, "task"),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
enable_in_targets: Option<Vec<String>>,
pod_type: Option<PodType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
run_on_init: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
services: BTreeMap<String, ServiceConfig>,
}
impl Config {
pub fn run_script<CR>(
&self,
runner: &CR,
project: &Project,
service_name: &str,
script_name: &str,
opts: &args::opts::Run,
) -> Result<()>
where
CR: CommandRunner,
{
if let Some(service_config) = self.services.get(service_name) {
service_config.run_script(
runner,
&project,
&service_name,
&script_name,
&opts,
)?;
}
Ok(())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ServiceConfig {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
scripts: BTreeMap<String, Script>,
}
impl ServiceConfig {
pub fn run_script<CR>(
&self,
runner: &CR,
project: &Project,
service_name: &str,
script_name: &str,
opts: &args::opts::Run,
) -> Result<()>
where
CR: CommandRunner,
{
if let Some(script) = self.scripts.get(script_name) {
script.run(runner, &project, service_name, &opts)?;
}
Ok(())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Script(Vec<Vec<String>>);
impl Script {
pub fn run<CR>(
&self,
runner: &CR,
project: &Project,
service_name: &str,
opts: &args::opts::Run,
) -> Result<()>
where
CR: CommandRunner,
{
for cmd in &self.0 {
if cmd.is_empty() {
return Err("all items in script must have at least one value".into());
}
let cmd = if cmd.len() >= 2 {
Some(args::Command::new(&cmd[0]).with_args(&cmd[1..]))
} else {
None
};
project.run(runner, service_name, cmd.as_ref(), &opts)?;
}
Ok(())
}
}
#[derive(Debug)]
struct FileInfo {
rel_path: PathBuf,
file: dc::File,
}
impl FileInfo {
fn unnormalized(base_dir: &Path, rel_path: &Path) -> Result<FileInfo> {
let path = base_dir.join(rel_path);
Ok(FileInfo {
rel_path: rel_path.to_owned(),
file: if path.exists() {
debug!("Parsing {}", path.display());
dc::File::read_from_path(&path).chain_err(|| {
ErrorKind::CouldNotReadFile(path.clone())
})?
} else {
Default::default()
},
})
}
fn ensure_same_services(
&mut self,
base_file: &Path,
service_names: &BTreeSet<String>,
) -> Result<()> {
let ours: BTreeSet<String> = self.file.services.keys().cloned().collect();
let introduced: Vec<String> =
ours.difference(service_names).cloned().collect();
if !introduced.is_empty() {
return Err(ErrorKind::ServicesAddedInTarget(
base_file.to_owned(),
self.rel_path.clone(),
introduced,
)
.into());
}
for name in service_names {
self.file
.services
.entry(name.to_owned())
.or_insert_with(Default::default);
}
Ok(())
}
fn finish_normalization(&mut self) {
let env_path = self.rel_path.parent().unwrap().join("common.env");
for service in self.file.services.values_mut() {
service.env_files.insert(0, dc::value(env_path.clone()));
}
}
}
#[derive(Debug)]
pub struct Pod {
base_dir: PathBuf,
name: String,
file_info: FileInfo,
target_file_infos: BTreeMap<Target, FileInfo>,
config: Config,
service_names: BTreeSet<String>,
}
impl Pod {
#[doc(hidden)]
pub fn new<P, S>(base_dir: P, name: S, targets: &[Target]) -> Result<Pod>
where
P: Into<PathBuf>,
S: Into<String>,
{
let base_dir = base_dir.into();
let name = name.into();
let config_path = base_dir.join(&format!("{}.metadata.yml", &name));
let config: Config = if config_path.exists() {
load_yaml(&config_path)?
} else {
Config::default()
};
let rel_path = Path::new(&format!("{}.yml", &name)).to_owned();
let mut file_info = FileInfo::unnormalized(&base_dir, &rel_path)?;
file_info.finish_normalization();
let service_names = file_info.file.services.keys().cloned().collect();
let mut target_infos = BTreeMap::new();
for target in targets {
let target_rel_path =
Path::new(&format!("targets/{}/{}.yml", target.name(), &name))
.to_owned();
let mut target_info = FileInfo::unnormalized(&base_dir, &target_rel_path)?;
target_info.ensure_same_services(&rel_path, &service_names)?;
target_info.finish_normalization();
target_infos.insert(target.to_owned(), target_info);
}
Ok(Pod {
base_dir,
name,
file_info,
target_file_infos: target_infos,
config,
service_names,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn pod_type(&self) -> PodType {
self.config.pod_type.unwrap_or(PodType::Service)
}
pub fn service_names(&self) -> &BTreeSet<String> {
&self.service_names
}
pub fn enabled_in(&self, target: &Target) -> bool {
target.is_enabled_by(&self.config.enable_in_targets)
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
pub fn rel_path(&self) -> &Path {
&self.file_info.rel_path
}
pub fn file(&self) -> &dc::File {
&self.file_info.file
}
fn target_file_info(&self, target: &Target) -> Result<&FileInfo> {
self.target_file_infos
.get(target)
.ok_or_else(|| err!("The target {} is not defined", target.name()))
}
pub fn target_rel_path(&self, target: &Target) -> Result<&Path> {
Ok(&(self.target_file_info(target)?.rel_path))
}
pub fn target_file(&self, target: &Target) -> Result<&dc::File> {
Ok(&(self.target_file_info(target)?.file))
}
pub fn merged_file(&self, target: &Target) -> Result<dc::File> {
debug!("Merging pod {} with target {}", self.name(), target.name());
Ok(self.file().merge_override(self.target_file(target)?))
}
pub fn target_files(&self) -> TargetFiles<'_> {
TargetFiles {
iter: self.target_file_infos.iter(),
}
}
pub fn all_files(&self) -> AllFiles<'_> {
AllFiles {
pod: self,
state: AllFilesState::TopLevelFile,
}
}
pub fn service(&self, target: &Target, name: &str) -> Result<Option<dc::Service>> {
let file = self.merged_file(target)?;
Ok(file.services.get(name).cloned())
}
pub fn service_or_err(&self, target: &Target, name: &str) -> Result<dc::Service> {
self.service(target, name)?
.ok_or_else(|| ErrorKind::UnknownService(name.to_owned()).into())
}
pub fn compose_args(&self, proj: &Project) -> Result<Vec<OsString>> {
Ok(vec![
"-p".into(),
proj.compose_name().into(),
"-f".into(),
proj.output_pods_dir().join(self.rel_path()).into(),
])
}
pub fn run_on_init(&self) -> &[Vec<String>] {
&self.config.run_on_init
}
pub fn run_script<CR>(
&self,
runner: &CR,
project: &Project,
service_name: &str,
script_name: &str,
opts: &args::opts::Run,
) -> Result<()>
where
CR: CommandRunner,
{
self.config
.run_script(runner, &project, &service_name, &script_name, &opts)
}
}
#[allow(missing_debug_implementations)]
pub struct TargetFiles<'a> {
iter: btree_map::Iter<'a, Target, FileInfo>,
}
impl<'a> Iterator for TargetFiles<'a> {
type Item = (&'a Target, &'a dc::File);
fn next(&mut self) -> Option<Self::Item> {
self.iter
.next()
.map(|(target, file_info)| (target, &file_info.file))
}
}
#[allow(missing_debug_implementations)]
enum AllFilesState<'a> {
TopLevelFile,
TargetFiles(TargetFiles<'a>),
}
#[allow(missing_debug_implementations)]
pub struct AllFiles<'a> {
pod: &'a Pod,
state: AllFilesState<'a>,
}
impl<'a> Iterator for AllFiles<'a> {
type Item = &'a dc::File;
fn next(&mut self) -> Option<Self::Item> {
match self.state {
AllFilesState::TopLevelFile => {
self.state = AllFilesState::TargetFiles(self.pod.target_files());
Some(self.pod.file())
}
AllFilesState::TargetFiles(ref mut iter) => {
iter.next().map(|(_, file)| file)
}
}
}
}
#[test]
fn pods_are_normalized_on_load() {
use crate::project::Project;
let _ = env_logger::try_init();
let proj = Project::from_example("hello").unwrap();
let frontend = proj.pod("frontend").unwrap();
let web = frontend.file().services.get("web").unwrap();
assert_eq!(web.env_files.len(), 1);
assert_eq!(web.env_files[0].value().unwrap(), Path::new("common.env"));
let production = proj.target("production").unwrap();
let web_target = frontend
.target_file(production)
.unwrap()
.services
.get("web")
.unwrap();
assert_eq!(web_target.env_files.len(), 1);
assert_eq!(
web_target.env_files[0].value().unwrap(),
Path::new("targets/production/common.env")
);
}
#[test]
fn can_merge_base_file_and_target() {
let _ = env_logger::try_init();
let proj: Project = Project::from_example("hello").unwrap();
let target = proj.target("development").unwrap();
let frontend = proj.pod("frontend").unwrap();
let merged = frontend.merged_file(target).unwrap();
let proxy = merged.services.get("proxy").unwrap();
assert_eq!(proxy.env_files.len(), 2);
}
#[test]
fn pod_type_returns_type_of_pod() {
let _ = env_logger::try_init();
let proj: Project = Project::from_example("rails_hello").unwrap();
let frontend = proj.pod("frontend").unwrap();
assert_eq!(frontend.pod_type(), PodType::Service);
let rake = proj.pod("rake").unwrap();
assert_eq!(rake.pod_type(), PodType::Task);
}