use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::time::Duration;
use serde::Deserialize;
use tokio::process::Command;
use toml::Value;
use crate::build::env;
use crate::build::privilege::{Step, root_warning};
use crate::build::runner;
use crate::build::{drop_to, settle_pointer};
use crate::error::Error;
use crate::shared::printable;
#[derive(Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HookSpec {
pub command: Option<String>,
pub env: BTreeMap<String, String>,
}
impl fmt::Debug for HookSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HookSpec")
.field("command", &self.command)
.field("env", &format_args!("<{} vars>", self.env.len()))
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Hooks {
pub release: HookSpec,
pub after: HookSpec,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ending {
Exited(Option<i32>),
TimedOut(Duration),
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Context<'a> {
pub sheep: &'a str,
pub sha: &'a str,
pub release: &'a Path,
pub as_user: Option<&'a str>,
pub passthrough: &'a [String],
pub budget: Duration,
pub cache: &'a Path,
pub built: bool,
}
const RELEASE: &str = "release";
const AFTER: &str = "after";
pub(crate) async fn run(spec: &HookSpec, ctx: &Context<'_>) -> Result<(), Error> {
run_named(RELEASE, Step::ReleaseHook, spec, ctx).await
}
pub(crate) async fn run_after(spec: &HookSpec, ctx: &Context<'_>) -> Result<(), Error> {
run_named(AFTER, Step::AfterHook, spec, ctx).await
}
async fn run_named(
name: &'static str,
step: Step,
spec: &HookSpec,
ctx: &Context<'_>,
) -> Result<(), Error> {
let Some(command) = spec.command.as_deref() else {
return Ok(());
};
if let Some(warning) = root_warning(ctx.sheep, ctx.as_user, step).await {
eprintln!("{warning}");
}
eprintln!("shep-deploy: {}: running the {name} hook", ctx.sheep);
let release = std::path::absolute(ctx.release).map_err(Error::at(ctx.release))?;
let mut child = Command::new("sh");
child.arg("-c").arg(command).current_dir(&release);
env::apply(&mut child, ctx.sheep, ctx.passthrough, &spec.env);
child
.env("SHEP_DEPLOY_SHEEP", ctx.sheep)
.env("SHEP_DEPLOY_SHA", ctx.sha)
.env("SHEP_DEPLOY_RELEASE", &release);
let mut pointer = None;
if let Some(user) = ctx.as_user {
let dropped = drop_to(ctx.release, ctx.cache, user, !ctx.built).await?;
pointer = dropped.pointer;
let (uid, gid) = (dropped.uid, dropped.gid);
child.gid(gid);
child.uid(uid);
}
let ran = runner::run_in_group(child, ctx.release, ctx.budget).await;
settle_pointer(
ctx.sheep,
ctx.release,
pointer,
&ran,
&format!("the {name} hook"),
)
.await?;
ran.map_err(|error| match error {
Error::Build { status } => Error::Hook {
name,
ending: Ending::Exited(status),
},
Error::BuildTimedOut { after } => Error::Hook {
name,
ending: Ending::TimedOut(after),
},
other => other,
})
}
const OPERATOR_TABLES: [&str; 2] = ["release", "after"];
pub(crate) fn deploy_table<'a>(doc: &'a Value, name: &str) -> Option<&'a Value> {
doc.as_table()?
.get("dog")?
.as_table()?
.get("deploy")?
.as_table()?
.get(name)
}
pub(crate) fn refuse_repo_hooks(doc: &Value, file: &str) -> Result<(), Error> {
for name in OPERATOR_TABLES {
if deploy_table(doc, name).is_some() {
return Err(Error::Config(format!(
"{file} sets `[dog.deploy.{name}]`: a hook's env holds the operator's secrets, \
and a commit must not choose the command that receives them. Set it in the \
Flockfile.override.toml in the operator's checkout instead - it is gitignored \
and never comes from the repo."
)));
}
}
Ok(())
}
pub(crate) fn parse(doc: &Value, release: &Path) -> Result<Hooks, Error> {
Ok(Hooks {
release: parse_table(doc, RELEASE, release)?,
after: parse_table(doc, AFTER, release)?,
})
}
fn parse_table(doc: &Value, name: &str, release: &Path) -> Result<HookSpec, Error> {
let Some(table) = deploy_table(doc, name) else {
return Ok(HookSpec::default());
};
table.clone().try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"{}: `[dog.deploy.{name}]` does not match the hook schema: {}",
release.display(),
printable(source.message())
))
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::flockfile::{self, OVERRIDE};
use crate::shared::FromCheckout;
const APP: &str = "[[app]]\nname = \"web\"\nscript = \"./run\"\n";
const RELEASE_HOOK: &str = "[dog.deploy.release]\ncommand = \"true\"\n";
const AFTER_HOOK: &str = "[dog.deploy.after]\ncommand = \"true\"\n";
fn release_with(committed: &str, committed_override: Option<&str>) -> tempfile::TempDir {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(rel.path().join("Flockfile.toml"), committed).expect("committed");
if let Some(text) = committed_override {
std::fs::write(rel.path().join(OVERRIDE), text).expect("override");
}
rel
}
#[test]
fn a_committed_release_hook_is_refused() {
let rel = release_with(&format!("{APP}{RELEASE_HOOK}"), None);
let err = flockfile::read(rel.path(), &FromCheckout::of(vec![])).expect_err("refused");
let shown = err.to_string();
assert!(matches!(err, Error::Config(_)), "{shown}");
assert!(shown.contains("Flockfile.toml"), "{shown}");
assert!(shown.contains("[dog.deploy.release]"), "{shown}");
}
#[test]
fn a_committed_after_hook_is_refused() {
let rel = release_with(&format!("{APP}{AFTER_HOOK}"), None);
let err = flockfile::read(rel.path(), &FromCheckout::of(vec![])).expect_err("refused");
let shown = err.to_string();
assert!(shown.contains("Flockfile.toml"), "{shown}");
assert!(shown.contains("[dog.deploy.after]"), "{shown}");
}
#[test]
fn an_override_the_dog_did_not_link_is_refused_a_hook() {
let rel = release_with(APP, Some(RELEASE_HOOK));
let err = flockfile::read(rel.path(), &FromCheckout::of(vec![])).expect_err("refused");
let shown = err.to_string();
assert!(shown.contains(OVERRIDE), "{shown}");
assert!(shown.contains("[dog.deploy.release]"), "{shown}");
}
#[test]
fn the_operators_override_may_set_a_release_hook() {
let rel = release_with(APP, None);
let checkout = tempfile::tempdir().expect("checkout");
std::fs::write(
checkout.path().join(OVERRIDE),
"[dog.deploy.release]\ncommand = \"supabase db push\"\n\
env = { SUPABASE_DB_PASSWORD = \"hunter2\" }\n",
)
.expect("the operator's override");
std::os::unix::fs::symlink(checkout.path().join(OVERRIDE), rel.path().join(OVERRIDE))
.expect("linked in");
let merged = flockfile::read(rel.path(), &FromCheckout::of(vec![PathBuf::from(OVERRIDE)]))
.expect("the operator may set a hook");
let hooks = merged.hooks().expect("parses");
assert_eq!(hooks.release.command.as_deref(), Some("supabase db push"));
assert_eq!(
hooks
.release
.env
.get("SUPABASE_DB_PASSWORD")
.map(String::as_str),
Some("hunter2")
);
}
#[test]
fn an_after_hook_parses_from_the_operators_override() {
let doc: Value = "[dog.deploy.after]\ncommand = \"purge-cache\"\n\
env = { CF_TOKEN = \"hunter2\" }\n"
.parse()
.expect("toml");
let hooks = parse(&doc, Path::new("/srv/web/releases/abc")).expect("parses");
assert_eq!(hooks.after.command.as_deref(), Some("purge-cache"));
assert_eq!(
hooks.after.env.get("CF_TOKEN").map(String::as_str),
Some("hunter2")
);
assert_eq!(hooks.release, HookSpec::default());
}
#[test]
fn an_unknown_after_hook_key_is_refused() {
let doc: Value = "[dog.deploy.after]\ncommnd = \"true\"\n"
.parse()
.expect("toml");
let err = parse(&doc, Path::new("/srv/web/releases/abc")).expect_err("unknown key");
let shown = err.to_string();
assert!(shown.contains("commnd"), "{shown}");
assert!(shown.contains("[dog.deploy.after]"), "{shown}");
}
const TEST_BUDGET: Duration = Duration::from_secs(60);
async fn run_hook(
rel: &Path,
spec: &HookSpec,
passthrough: &[String],
budget: Duration,
) -> Result<(), Error> {
let ctx = Context {
sheep: "web",
sha: crate::fixtures::SHA,
release: rel,
as_user: None,
passthrough,
budget,
cache: &rel.join("cache"),
built: true,
};
run(spec, &ctx).await
}
fn hook(command: &str, env: &[(&str, &str)]) -> HookSpec {
HookSpec {
command: Some(command.to_owned()),
env: env
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
}
}
fn written(rel: &Path, name: &str) -> String {
std::fs::read_to_string(rel.join(name))
.unwrap_or_else(|e| panic!("the hook never wrote {name}: {e}"))
}
#[tokio::test]
async fn a_hook_runs_in_the_release_with_a_cleared_environment() {
assert!(
std::env::var("CARGO_PKG_NAME").is_ok(),
"the probe variable must exist in this process or the test proves nothing"
);
let rel = crate::fixtures::fixture_release(&[]);
let spec = hook(
"pwd -P > cwd.txt; printenv CARGO_PKG_NAME > leaked.txt; \
printenv DB_PASSWORD > own.txt; printenv PATH > path.txt; true",
&[("DB_PASSWORD", "hunter2")],
);
run_hook(rel.path(), &spec, &[], TEST_BUDGET)
.await
.expect("hook runs");
let cwd = written(rel.path(), "cwd.txt");
assert_eq!(
Path::new(cwd.trim()),
rel.path().canonicalize().expect("canonical")
);
assert!(written(rel.path(), "leaked.txt").trim().is_empty());
assert_eq!(written(rel.path(), "own.txt").trim(), "hunter2");
assert!(!written(rel.path(), "path.txt").trim().is_empty());
}
#[tokio::test]
async fn the_dogs_variables_win_over_the_hooks_env() {
let rel = crate::fixtures::fixture_release(&[]);
let spec = hook(
"printenv SHEP_DEPLOY_SHEEP > sheep.txt; printenv SHEP_DEPLOY_SHA > sha.txt; \
printenv SHEP_DEPLOY_RELEASE > release.txt",
&[
("SHEP_DEPLOY_SHEEP", "other"),
("SHEP_DEPLOY_SHA", "other"),
("SHEP_DEPLOY_RELEASE", "other"),
],
);
run_hook(rel.path(), &spec, &[], TEST_BUDGET)
.await
.expect("hook runs");
assert_eq!(written(rel.path(), "sheep.txt").trim(), "web");
assert_eq!(written(rel.path(), "sha.txt").trim(), crate::fixtures::SHA);
let release = written(rel.path(), "release.txt");
assert!(Path::new(release.trim()).is_absolute(), "{release}");
assert_eq!(
Path::new(release.trim()).canonicalize().expect("exists"),
rel.path().canonicalize().expect("canonical")
);
}
#[tokio::test]
async fn a_named_passthrough_variable_reaches_the_hook() {
let rel = crate::fixtures::fixture_release(&[]);
let spec = hook("printenv CARGO_PKG_NAME > passed.txt", &[]);
run_hook(
rel.path(),
&spec,
&["CARGO_PKG_NAME".to_owned()],
TEST_BUDGET,
)
.await
.expect("hook runs");
assert_eq!(written(rel.path(), "passed.txt").trim(), "shep-deploy");
}
#[tokio::test]
async fn an_absent_command_runs_nothing() {
let rel = crate::fixtures::fixture_release(&[]);
run_hook(rel.path(), &HookSpec::default(), &[], TEST_BUDGET)
.await
.expect("a no-op");
}
#[tokio::test]
async fn a_hook_that_exits_non_zero_fails() {
let rel = crate::fixtures::fixture_release(&[]);
let err = run_hook(rel.path(), &hook("exit 3", &[]), &[], TEST_BUDGET)
.await
.expect_err("a failing hook");
assert!(
matches!(
err,
Error::Hook {
name: "release",
ending: Ending::Exited(Some(3))
}
),
"{err:?}"
);
}
#[tokio::test]
async fn a_hook_that_never_finishes_is_abandoned() {
let rel = crate::fixtures::fixture_release(&[]);
let spec = hook("sleep 600 & echo $! > descendant.pid; wait", &[]);
let err = run_hook(rel.path(), &spec, &[], Duration::from_secs(2))
.await
.expect_err("must be abandoned");
assert!(
matches!(
err,
Error::Hook {
ending: Ending::TimedOut(_),
..
}
),
"{err:?}"
);
assert!(err.to_string().contains("build_timeout"), "{err}");
let pid = written(rel.path(), "descendant.pid");
assert!(
!still_running(pid.trim()),
"the descendant (pid {}) outlived the hook",
pid.trim()
);
}
fn still_running(pid: &str) -> bool {
for _ in 0..50 {
let alive = std::process::Command::new("kill")
.args(["-0", pid])
.status()
.expect("kill -0")
.success();
if !alive {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
#[test]
fn an_unknown_hook_key_is_refused() {
let doc: Value = "[dog.deploy.release]\ncommnd = \"true\"\n"
.parse()
.expect("toml");
let err = parse(&doc, Path::new("/srv/web/releases/abc")).expect_err("unknown key");
let shown = err.to_string();
assert!(shown.contains("commnd"), "{shown}");
assert!(shown.contains("[dog.deploy.release]"), "{shown}");
}
#[test]
fn an_absent_hook_block_is_the_default() {
let doc: Value = APP.parse().expect("toml");
let hooks = parse(&doc, Path::new("/srv/web/releases/abc")).expect("parses");
assert_eq!(hooks, Hooks::default());
assert_eq!(hooks.release.command, None);
}
#[test]
fn debug_redacts_env_values() {
let spec = HookSpec {
command: Some("supabase db push".into()),
env: [("SUPABASE_DB_PASSWORD".to_owned(), "hunter2".to_owned())].into(),
};
assert_eq!(
format!("{spec:?}"),
"HookSpec { command: Some(\"supabase db push\"), env: <1 vars> }"
);
}
#[tokio::test]
async fn a_hook_runs_as_the_dogs_own_user_after_a_build_or_without_one() {
for built in [false, true] {
let rel = crate::fixtures::fixture_release(&[]);
let me = tokio::process::Command::new("id")
.arg("-un")
.output()
.await
.expect("id runs");
let me = String::from_utf8(me.stdout)
.expect("utf8")
.trim()
.to_owned();
let cache = rel.path().join("cache");
let ctx = Context {
sheep: "web",
sha: crate::fixtures::SHA,
release: rel.path(),
as_user: Some(&me),
passthrough: &[],
budget: TEST_BUDGET,
cache: &cache,
built,
};
run(&hook("touch ran", &[]), &ctx).await.expect("runs");
assert!(rel.path().join("ran").exists());
}
}
}