use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Read as _};
use std::os::unix::fs::OpenOptionsExt as _;
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::shared::{FromCheckout, is_eloop, o_nofollow, printable};
const MAX_FLOCKFILE_BYTES: u64 = 1024 * 1024;
#[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())
))
})
}
}
#[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)?;
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))
}
pub(crate) const OVERRIDE: &str = "Flockfile.override.toml";
#[derive(Clone, Copy, PartialEq, Eq)]
enum Follow {
Operators,
Never,
}
fn read_required(path: &Path, follow: Follow) -> Result<Value, Error> {
let text = read_flockfile(path, follow)?;
parse(path, &text)
}
fn read_optional(path: &Path, follow: Follow) -> Result<Value, Error> {
match read_flockfile(path, follow) {
Ok(text) => parse(path, &text),
Err(Error::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
Ok(Value::Table(Table::new()))
}
Err(err) => Err(err),
}
}
fn read_flockfile(path: &Path, follow: Follow) -> Result<String, Error> {
let at = |source| Error::Io {
path: path.to_owned(),
source,
};
let mut options = fs::OpenOptions::new();
options.read(true);
if follow == Follow::Never {
options.custom_flags(o_nofollow());
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(source) if is_eloop(&source) => {
return Err(Error::Config(format!(
"{} is a symlink, and a Flockfile the repository commits has to be a regular \
file: this dog reads it at its own uid before any build drops privilege, so a \
link could point it at any file on the host. Commit the file itself.",
path.display()
)));
}
Err(source) => return Err(at(source)),
};
let len = file.metadata().map_err(at)?.len();
if len > MAX_FLOCKFILE_BYTES {
return Err(Error::Config(format!(
"{} is {len} bytes, past the {MAX_FLOCKFILE_BYTES}-byte limit for a Flockfile. A \
real one is a few hundred bytes; this dog reads and merges both files in memory \
on every deploy.",
path.display()
)));
}
let mut text = String::with_capacity(usize::try_from(len).unwrap_or(0));
file.read_to_string(&mut text).map_err(at)?;
Ok(text)
}
fn parse(path: &Path, text: &str) -> Result<Value, Error> {
toml::from_str(text).map_err(|source| {
let line = source.span().map(|span| {
let upto = span.start.min(text.len());
text.as_bytes()[..upto]
.iter()
.filter(|b| **b == b'\n')
.count()
+ 1
});
let message = printable(source.message());
Error::Config(match line {
Some(line) => format!("{}: {message}, at line {line}", path.display()),
None => format!("{}: {message}", path.display()),
})
})
}
fn refuse_repo_privilege(committed: &Value) -> Result<(), Error> {
for app in apps(committed) {
let Some(table) = app.as_table() else {
continue;
};
let name = table
.get("name")
.and_then(Value::as_str)
.map(printable)
.unwrap_or_else(|| "<unnamed>".to_owned());
let refuse = |field: &str, why: &str| {
Error::Config(format!(
"Flockfile.toml sets `{field}` on app {name:?}: {why}. A committed Flockfile \
can never set it, on any app, because the shepherd acts on it at its own uid \
and a build cannot reach that on its own. Pin `{field}` in \
Flockfile.override.toml instead - it is gitignored and never comes from the \
repo."
))
};
for field in ["user", "group"] {
if table.contains_key(field) {
return Err(refuse(
field,
"privilege is the one thing a compromised build cannot escalate to",
));
}
}
for field in ["out_file", "err_file"] {
if table.contains_key(field) {
return Err(refuse(
field,
"the shepherd opens, appends to and on `shep flush` truncates that path \
itself, at its own uid, with no check on where it points",
));
}
}
if table.contains_key("watch") {
return Err(refuse(
"watch",
"the shepherd watches the app's whole working directory itself, at its own \
uid, following symlinks, so a committed link out of the release has it walk \
and watch whatever the link points at",
));
}
for field in ["readiness_probe", "liveness_probe"] {
let Some(value) = table.get(field) else {
continue;
};
let Some(probe) = value.as_table() else {
return Err(refuse(
field,
"a probe the committed file spells as anything but a table is one this \
check cannot read, and shep would accept it",
));
};
let (Some(kind), Some(target)) = (
probe.get("kind").and_then(Value::as_str),
probe.get("target").and_then(Value::as_str),
) else {
return Err(refuse(
field,
"a probe whose `kind` or `target` is not a string is one this check \
cannot read",
));
};
if kind == "exec" {
return Err(refuse(
field,
"an exec probe is run by the shepherd through `sh -c` on the probe's \
interval, forever, at the shepherd's own uid and ignoring `user`",
));
}
if !probes_loopback(kind, target) {
return Err(refuse(
field,
"the shepherd connects to the probe's target from its own network \
position, so a committed target has to be on loopback: `127.0.0.1`, \
`::1` or `localhost`",
));
}
}
}
Ok(())
}
fn probes_loopback(kind: &str, target: &str) -> bool {
let host_and_rest = match kind {
"http" => target
.split_once("://")
.map_or(target, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or(""),
"tcp" => target,
_ => return false,
};
let host = host_and_rest.rsplit('@').next().unwrap_or("");
let host = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or("")
} else {
host.rsplit_once(':').map_or(host, |(h, port)| {
if port.chars().all(|c| c.is_ascii_digit()) {
h
} else {
host
}
})
};
host.eq_ignore_ascii_case("localhost")
|| host == "::1"
|| host.starts_with("127.")
&& host.split('.').count() == 4
&& host.split('.').all(|octet| octet.parse::<u8>().is_ok())
}
fn refuse_duplicate_apps(committed: &Value) -> Result<(), Error> {
let mut seen = std::collections::BTreeSet::new();
for app in apps(committed) {
if let Some(name) = app_name(app)
&& !seen.insert(name)
{
return Err(Error::Config(format!(
"Flockfile.toml declares an app named {:?} more than once; one entry per name",
printable(name)
)));
}
}
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(),
};
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 {
#[test]
fn a_committed_flockfile_that_is_a_symlink_is_refused_unread() {
let rel = tempfile::tempdir().expect("tempdir");
let secret = rel.path().join("outside.txt");
std::fs::write(&secret, "-----BEGIN SECRET-----\nnot toml\n").expect("secret");
std::os::unix::fs::symlink(&secret, rel.path().join("Flockfile.toml")).expect("link");
let err = app_config(rel.path(), "web", &[]).expect_err("a link must be refused");
let shown = err.to_string();
assert!(matches!(err, Error::Config(_)), "{shown}");
assert!(shown.contains("symlink"), "must say why: {shown}");
assert!(
!shown.contains("SECRET"),
"must not quote the file: {shown}"
);
}
#[test]
fn a_committed_exec_probe_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n[app.readiness_probe]\nkind = \
\"exec\"\ntarget = \"curl -fs http://127.0.0.1/health\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("an exec probe");
let shown = err.to_string();
assert!(
shown.contains("readiness_probe") && shown.contains("sh -c"),
"{shown}"
);
}
#[test]
fn a_committed_probe_spelled_as_a_sequence_is_refused() {
for probe in [
"readiness_probe = [\"exec\", \"id > /tmp/pwn\"]",
"readiness_probe = [\"http\", \"http://metadata.internal/\"]",
"liveness_probe = \"exec\"",
] {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
format!("[[app]]\nname = \"web\"\nscript = \"./run\"\n{probe}\n"),
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err(probe);
assert!(err.to_string().contains("_probe"), "{probe}: {err}");
}
}
#[test]
fn a_committed_watch_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\nwatch = true\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("watch");
assert!(err.to_string().contains("`watch`"), "{err}");
}
#[test]
fn a_committed_log_path_is_refused() {
for field in ["out_file", "err_file"] {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
format!(
"[[app]]\nname = \"web\"\nscript = \"./run\"\n{field} = \"/etc/cron.d/x\"\n"
),
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err(field);
let shown = err.to_string();
assert!(
shown.contains(field) && shown.contains("truncates"),
"{shown}"
);
}
}
#[test]
fn a_committed_network_probe_must_target_loopback() {
let refused = [
("http", "http://metadata.internal/latest/meta-data"),
("http", "http://internal.example:8080/"),
("tcp", "192.0.2.5:5432"),
("tcp", "db:5432"),
("http", "http://127.0.0.1.evil.example/"),
("http", "http://localhost@evil.example/"),
];
for (kind, target) in refused {
assert!(
!probes_loopback(kind, target),
"{kind} {target} must be refused"
);
}
let allowed = [
("http", "http://127.0.0.1:3000/health"),
("http", "http://localhost/health?deep=1"),
("http", "https://[::1]:8443/"),
("http", "http://user:pw@127.0.0.1/"),
("tcp", "127.0.0.1:5432"),
("tcp", "localhost:6379"),
("tcp", "[::1]:80"),
];
for (kind, target) in allowed {
assert!(
probes_loopback(kind, target),
"{kind} {target} must be allowed"
);
}
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n[app.readiness_probe]\nkind = \
\"http\"\ntarget = \"http://metadata.internal/\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("off-host target");
assert!(err.to_string().contains("loopback"), "{err}");
}
#[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_committed_flockfile_naming_an_app_twice_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./a\"\n[[app]]\nname = \"web\"\nscript = \"./b\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("two apps named web");
let shown = err.to_string();
assert!(
shown.contains("web") && shown.contains("more than once"),
"{shown}"
);
}
#[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_override_is_still_followed() {
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\"\n",
)
.expect("the operator's override");
std::os::unix::fs::symlink(checkout.path().join(OVERRIDE), rel.path().join(OVERRIDE))
.expect("linked in, as link_into does");
let app = app_config(rel.path(), "web", &[PathBuf::from(OVERRIDE)])
.expect("the operator's link resolves");
assert_eq!(app.user.as_deref(), Some("svc"));
}
#[test]
fn a_parse_error_names_the_line_and_not_its_text() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\n-----BEGIN OPENSSH PRIVATE KEY-----\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("not toml");
let shown = err.to_string();
assert!(shown.contains("line 3"), "must name the line: {shown}");
assert!(!shown.contains("OPENSSH"), "must not quote it: {shown}");
}
#[test]
fn a_flockfile_past_the_size_limit_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
let mut big = String::from("[[app]]\nname = \"web\"\nscript = \"./run\"\n");
big.push_str(&"# padding\n".repeat(120_000));
assert!(
big.len() as u64 > MAX_FLOCKFILE_BYTES,
"the fixture must exceed the limit"
);
std::fs::write(rel.path().join("Flockfile.toml"), big).expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("too big");
assert!(err.to_string().contains("limit"), "{err}");
}
#[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");
let shown = format!("{err}");
assert!(
shown.contains("symlink"),
"the link is refused before it is followed: {shown}"
);
assert!(
!shown.contains("root"),
"and nothing of the payload is read: {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"));
}
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"));
}
}