use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use shep_client::shep_core::prelude::AppConfig;
use toml::{Table, Value};
use crate::build::BuildSpec;
use crate::error::Error;
pub fn app_config(release: &Path, sheep: &str, shared: &[PathBuf]) -> Result<AppConfig, Error> {
let merged = merged_document(release, shared)?;
let app = select_app(&merged, sheep)?;
app.try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"app {sheep:?} does not match shep's app schema: {source}"
))
})
}
pub fn build_spec(release: &Path, shared: &[PathBuf]) -> Result<BuildSpec, Error> {
let merged = merged_document(release, shared)?;
let doc = merged.as_table();
if doc.is_some_and(|doc| doc.contains_key("build")) {
return Err(Error::Config(format!(
"{}: `[build]` moved to `[dog.deploy.build]`. 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.",
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: {source}",
release.display()
))
})
}
fn merged_document(release: &Path, shared: &[PathBuf]) -> Result<Value, Error> {
let committed = read_required(&release.join("Flockfile.toml"))?;
refuse_repo_privilege(&committed)?;
let override_path = release.join(OVERRIDE);
let override_doc = read_optional(&override_path)?;
if !is_operators(shared) {
refuse_repo_privilege(&override_doc)?;
}
Ok(deep_merge(committed, override_doc))
}
fn is_operators(shared: &[PathBuf]) -> bool {
shared.iter().any(|p| p == Path::new(OVERRIDE))
}
pub(crate) const OVERRIDE: &str = "Flockfile.override.toml";
fn read_required(path: &Path) -> Result<Value, Error> {
let text = fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_owned(),
source,
})?;
parse(path, &text)
}
fn read_optional(path: &Path) -> Result<Value, Error> {
match fs::read_to_string(path) {
Ok(text) => parse(path, &text),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(Value::Table(Table::new())),
Err(source) => Err(Error::Io {
path: path.to_owned(),
source,
}),
}
}
fn parse(path: &Path, text: &str) -> Result<Value, Error> {
toml::from_str(text).map_err(|source| Error::Config(format!("{}: {source}", path.display())))
}
fn refuse_repo_privilege(committed: &Value) -> Result<(), Error> {
for app in apps(committed) {
let Some(table) = app.as_table() else {
continue;
};
for field in ["user", "group"] {
if table.contains_key(field) {
let name = table
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
return Err(Error::Config(format!(
"Flockfile.toml sets `{field}` on app {name:?} - a committed Flockfile can \
never set user or group, on any app, because privilege is the one thing a \
compromised build cannot escalate to on its own. Pin `{field}` in \
Flockfile.override.toml instead - it is gitignored and never comes from the \
repo."
)));
}
}
}
Ok(())
}
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(),
};
for over_app in over_apps {
let name = app_name(&over_app).map(str::to_owned);
let existing = name
.as_deref()
.and_then(|name| base_apps.iter().position(|app| app_name(app) == Some(name)));
match existing {
Some(index) => {
let base_app = base_apps.remove(index);
base_apps.insert(index, deep_merge(base_app, over_app));
}
None => 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 {
#[test]
fn a_committed_override_cannot_grant_a_user() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::fs::write(
rel.path().join("Flockfile.override.toml"),
"[[app]]\nname = \"web\"\nuser = \"root\"\n",
)
.expect("committed override");
let err = app_config(rel.path(), "web", &[])
.expect_err("a committed override must not grant a user");
assert!(
format!("{err}").contains("user"),
"the refusal must name the field: {err}"
);
}
#[test]
fn a_committed_symlink_out_of_the_release_buys_no_exemption() {
let tree = tempfile::tempdir().expect("tempdir");
let a = tree.path().join("releases/shaA");
let b = tree.path().join("releases/shaB");
std::fs::create_dir_all(&a).expect("a");
std::fs::create_dir_all(&b).expect("b");
std::fs::write(
a.join(".deploy-payload.toml"),
"[[app]]\nname = \"web\"\nuser = \"root\"\n",
)
.expect("payload");
std::os::unix::fs::symlink(&a, tree.path().join("current")).expect("current");
std::fs::write(
b.join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::os::unix::fs::symlink(
"../../current/.deploy-payload.toml",
b.join("Flockfile.override.toml"),
)
.expect("committed symlink");
let err =
app_config(&b, "web", &[]).expect_err("a committed symlink must not buy the exemption");
assert!(
format!("{err}").contains("user"),
"the refusal must name the field: {err}"
);
}
#[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"));
}
use crate::fixtures;
use super::*;
#[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 a_committed_flockfile_cannot_set_user() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\nuser='root'\n",
)]);
let err = app_config(rel.path(), "web", &[]).expect_err("refuses");
assert!(err.to_string().contains("user"));
}
#[test]
fn a_committed_flockfile_cannot_set_group() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\ngroup='wheel'\n",
)]);
let err = app_config(rel.path(), "web", &[]).expect_err("refuses");
assert!(err.to_string().contains("group"));
}
#[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"));
}
}