mod read;
mod refuse;
use std::collections::BTreeMap;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use shep_client::shep_core::prelude::AppConfig;
use toml::{Table, Value};
use crate::error::Error;
use crate::shared::{FromCheckout, printable};
pub(crate) use read::OVERRIDE;
use read::{Follow, read_optional, read_required};
use refuse::{refuse_duplicate_apps, refuse_repo_privilege};
#[derive(Debug)]
pub struct Merged {
doc: Value,
}
pub fn read(release: &Path, shared: &FromCheckout) -> Result<Merged, Error> {
Ok(Merged {
doc: merged_document(release, shared)?,
})
}
impl Merged {
pub fn app_config(&self, sheep: &str) -> Result<AppConfig, Error> {
let app = select_app(&self.doc, sheep)?;
app.try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"app {sheep:?} does not match shep's app schema: {}",
printable(source.message())
))
})
}
}
#[cfg(test)]
pub fn app_config(release: &Path, sheep: &str, shared: &[PathBuf]) -> Result<AppConfig, Error> {
read(release, &FromCheckout::of(shared.to_vec()))?.app_config(sheep)
}
fn merged_document(release: &Path, shared: &FromCheckout) -> Result<Value, Error> {
let committed = read_required(&release.join("Flockfile.toml"), Follow::Never)?;
refuse_repo_privilege(&committed)?;
refuse_duplicate_apps(&committed)?;
let operators = shared.includes_override();
let override_path = release.join(OVERRIDE);
let override_doc = read_optional(
&override_path,
if operators {
Follow::Operators
} else {
Follow::Never
},
)?;
if !operators {
refuse_repo_privilege(&override_doc)?;
}
Ok(deep_merge(committed, override_doc))
}
fn apps(doc: &Value) -> &[Value] {
doc.as_table()
.and_then(|table| table.get("app"))
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice)
}
fn deep_merge(base: Value, over: Value) -> Value {
match (base, over) {
(Value::Table(mut base), Value::Table(over)) => {
for (key, over_value) in over {
let merged = if key == "app" {
merge_apps(
base.remove(&key).unwrap_or(Value::Array(Vec::new())),
over_value,
)
} else {
match base.remove(&key) {
Some(base_value) => deep_merge(base_value, over_value),
None => over_value,
}
};
base.insert(key, merged);
}
Value::Table(base)
}
(_, over) => over,
}
}
fn merge_apps(base: Value, over: Value) -> Value {
let mut base_apps = match base {
Value::Array(apps) => apps,
_ => Vec::new(),
};
let over_apps = match over {
Value::Array(apps) => apps,
_ => Vec::new(),
};
let mut by_name: BTreeMap<String, usize> = BTreeMap::new();
for (index, app) in base_apps.iter().enumerate() {
if let Some(name) = app_name(app) {
by_name.entry(name.to_owned()).or_insert(index);
}
}
for over_app in over_apps {
let existing = app_name(&over_app).and_then(|name| by_name.get(name).copied());
match existing {
Some(index) => {
let base_app = std::mem::replace(&mut base_apps[index], Value::Table(Table::new()));
base_apps[index] = deep_merge(base_app, over_app);
}
None => {
if let Some(name) = app_name(&over_app) {
by_name.entry(name.to_owned()).or_insert(base_apps.len());
}
base_apps.push(over_app);
}
}
}
Value::Array(base_apps)
}
fn app_name(app: &Value) -> Option<&str> {
app.as_table()?.get("name")?.as_str()
}
fn select_app(merged: &Value, sheep: &str) -> Result<Value, Error> {
apps(merged)
.iter()
.find(|app| app_name(app) == Some(sheep))
.cloned()
.ok_or_else(|| Error::Config(format!("no app named {sheep:?} in Flockfile.toml")))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::fixtures;
#[test]
fn the_operators_override_may_set_every_refused_field() {
let rel = tempfile::tempdir().expect("tempdir");
let checkout = tempfile::tempdir().expect("checkout");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::fs::write(
checkout.path().join(OVERRIDE),
"[[app]]\nname = \"web\"\nuser = \"svc\"\nout_file = \"/var/log/web.log\"\n\
[app.readiness_probe]\nkind = \"exec\"\ntarget = \"./healthy\"\n",
)
.expect("the operator's override");
std::os::unix::fs::symlink(checkout.path().join(OVERRIDE), rel.path().join(OVERRIDE))
.expect("linked in");
let app = app_config(rel.path(), "web", &[PathBuf::from(OVERRIDE)])
.expect("the operator may set all of them");
assert_eq!(app.user.as_deref(), Some("svc"));
assert_eq!(app.out_file.as_deref(), Some("/var/log/web.log"));
assert!(app.readiness_probe.is_some());
}
#[test]
fn the_operators_own_override_still_pins_a_user() {
let rel = tempfile::tempdir().expect("tempdir");
let checkout = tempfile::tempdir().expect("checkout");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
let theirs = checkout.path().join("Flockfile.override.toml");
std::fs::write(&theirs, "[[app]]\nname = \"web\"\nuser = \"svc\"\n").expect("theirs");
std::os::unix::fs::symlink(&theirs, rel.path().join("Flockfile.override.toml"))
.expect("shared in");
let shared = [PathBuf::from("Flockfile.override.toml")];
let app =
app_config(rel.path(), "web", &shared).expect("the operator's override is honoured");
assert_eq!(app.user.as_deref(), Some("svc"));
}
#[test]
fn the_override_wins_on_merge() {
let rel = fixtures::fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='upstream.js'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='web'\nscript='mine.js'\n",
),
]);
let app = app_config(rel.path(), "web", &[]).expect("merges");
assert_eq!(app.script, "mine.js");
}
#[test]
fn an_override_present_does_not_launder_a_committed_user_field() {
let rel = fixtures::fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\nuser='root'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='web'\nscript='mine.js'\n",
),
]);
let err = app_config(rel.path(), "web", &[]).expect_err("still refuses");
assert!(err.to_string().contains("user"));
}
#[test]
fn apps_in_different_files_merge_by_name_not_position() {
let rel = fixtures::fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='web.js'\n\n[[app]]\nname='worker'\nscript='worker.js'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='worker'\nscript='worker-mine.js'\n\n[[app]]\nname='web'\nscript='web-mine.js'\n",
),
]);
let web = app_config(rel.path(), "web", &[]).expect("merges web");
let worker = app_config(rel.path(), "worker", &[]).expect("merges worker");
assert_eq!(web.script, "web-mine.js");
assert_eq!(worker.script, "worker-mine.js");
}
#[test]
fn an_override_can_add_a_new_app_not_in_the_committed_file() {
let rel = fixtures::fixture_release(&[
("Flockfile.toml", "[[app]]\nname='web'\nscript='web.js'\n"),
(
"Flockfile.override.toml",
"[[app]]\nname='sidecar'\nscript='sidecar.js'\n",
),
]);
let sidecar =
app_config(rel.path(), "sidecar", &[]).expect("the override's own app merges");
assert_eq!(sidecar.script, "sidecar.js");
}
#[test]
fn app_config_refuses_an_unknown_sheep_name() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\n",
)]);
let err = app_config(rel.path(), "ghost", &[]).expect_err("no such app");
assert!(err.to_string().contains("ghost"));
}
}