mod read;
mod refuse;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use shep_client::shep_core::prelude::AppConfig;
use toml::{Table, Value};
use crate::build::BuildSpec;
use crate::error::Error;
use crate::hook::{self, Hooks};
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,
release: PathBuf,
}
pub fn read(release: &Path, shared: &FromCheckout) -> Result<Merged, Error> {
Ok(Merged {
doc: merged_document(release, shared)?,
release: release.to_owned(),
})
}
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())
))
})
}
pub fn build_spec(&self) -> Result<BuildSpec, Error> {
let doc = self.doc.as_table();
if doc.is_some_and(|doc| doc.contains_key("build")) {
return Err(Error::Config(format!(
"{}: `[build]` moved to `[dog.deploy.build]`, in Flockfile.toml or \
Flockfile.override.toml, whichever carries it. shep refuses a Flockfile with \
a top-level `build` key, so the old spelling could not be registered with \
`shep start` at all; `[dog]` is the table shep keeps for a dog's own config. \
Rename the block.",
self.release.display()
)));
}
let Some(build) = doc
.and_then(|doc| doc.get("dog"))
.and_then(Value::as_table)
.and_then(|dogs| dogs.get("deploy"))
.and_then(Value::as_table)
.and_then(|deploy| deploy.get("build"))
else {
return Ok(BuildSpec::default());
};
build.clone().try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"{}: `[dog.deploy.build]` does not match the build schema: {}",
self.release.display(),
printable(source.message())
))
})
}
pub fn hooks(&self) -> Result<Hooks, Error> {
hook::parse(&self.doc, &self.release)
}
}
#[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)
}
#[cfg(test)]
pub fn build_spec(release: &Path, shared: &[PathBuf]) -> Result<BuildSpec, Error> {
read(release, &FromCheckout::of(shared.to_vec()))?.build_spec()
}
fn merged_document(release: &Path, shared: &FromCheckout) -> Result<Value, Error> {
let committed = read_required(&release.join("Flockfile.toml"), Follow::Never)?;
refuse_repo_privilege(&committed)?;
hook::refuse_repo_hooks(&committed, "Flockfile.toml")?;
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)?;
hook::refuse_repo_hooks(&override_doc, OVERRIDE)?;
}
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 a_parser_message_never_carries_a_control_character() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./a\"\n[dog.deploy.build]\n\"a\\u001bb\" = 1\n",
)
.expect("committed");
let err = build_spec(rel.path(), &[]).expect_err("an unknown build key");
let shown = err.to_string();
assert!(!shown.chars().any(char::is_control), "{shown:?}");
assert!(
shown.contains("a?b"),
"the key is still recognisable: {shown}"
);
}
#[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"));
}
#[test]
fn the_old_top_level_build_block_is_refused_by_name() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[build]\ncommand = 'make build'\n",
)]);
let err = build_spec(rel.path(), &[]).expect_err("the old spelling must be refused");
let text = format!("{err}");
assert!(
text.contains("[dog.deploy.build]"),
"must name the new home: {text}"
);
assert!(text.contains("shep start"), "must say why it moved: {text}");
}
#[test]
fn a_build_block_parses_into_a_spec() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[dog.deploy.build]\ncommand = 'make build'\nenv = { CARGO_TARGET_DIR = '/srv/cache' }\nartifacts = ['target/release/koji']\n",
)]);
let spec = build_spec(rel.path(), &[]).expect("parses");
assert_eq!(spec.command.as_deref(), Some("make build"));
assert_eq!(
spec.env.get("CARGO_TARGET_DIR").map(String::as_str),
Some("/srv/cache")
);
assert_eq!(
spec.artifacts,
vec![std::path::PathBuf::from("target/release/koji")]
);
}
#[test]
fn an_absent_build_block_is_the_default_spec() {
let rel =
fixtures::fixture_release(&[("Flockfile.toml", "[[app]]\nname='web'\nscript='x'\n")]);
assert_eq!(
build_spec(rel.path(), &[]).expect("parses"),
BuildSpec::default()
);
}
#[test]
fn the_override_wins_on_the_build_block() {
let rel = fixtures::fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[dog.deploy.build]\ncommand = 'make build'\n",
),
(
"Flockfile.override.toml",
"[dog.deploy.build]\nenv = { CARGO_TARGET_DIR = '/srv/cache' }\n",
),
]);
let spec = build_spec(rel.path(), &[]).expect("parses");
assert_eq!(spec.command.as_deref(), Some("make build"));
assert_eq!(
spec.env.get("CARGO_TARGET_DIR").map(String::as_str),
Some("/srv/cache")
);
}
#[test]
fn an_unknown_build_key_is_refused() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[dog.deploy.build]\ncommands = 'make build'\n",
)]);
let err = build_spec(rel.path(), &[]).expect_err("refuses");
assert!(err.to_string().contains("commands"));
}
}