use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tokio::process::Command;
use crate::error::Error;
#[derive(Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BuildSpec {
pub command: Option<String>,
pub env: BTreeMap<String, String>,
pub artifacts: Vec<PathBuf>,
}
impl fmt::Debug for BuildSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuildSpec")
.field("command", &self.command)
.field("env", &format_args!("<{} vars>", self.env.len()))
.field("artifacts", &self.artifacts)
.finish()
}
}
async fn resolve_id(release: &Path, user: &str, flag: &str, kind: &str) -> Result<u32, Error> {
let output = Command::new("id")
.arg(flag)
.arg(user)
.output()
.await
.map_err(|source| Error::Io {
path: release.to_owned(),
source,
})?;
if !output.status.success() {
return Err(Error::Config(format!(
"as_user {user:?} does not resolve to a {kind} on this host (`id {flag} {user}` \
failed)"
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.map_err(|_| Error::Config(format!("`id {flag} {user}` did not print a {kind}")))
}
async fn effective_uid() -> Option<u32> {
let output = Command::new("id").arg("-u").output().await.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
async fn root_warning(sheep: &str, as_user: Option<&str>) -> Option<String> {
root_build_warning(sheep, effective_uid().await?, as_user)
}
fn root_build_warning(sheep: &str, euid: u32, as_user: Option<&str>) -> Option<String> {
const ROOT: u32 = 0;
(euid == ROOT && as_user.is_none()).then(|| {
format!(
"shep-deploy: warning: {sheep}'s build is about to run as root, because its app sets \
no `user`. The build command comes from the deployed repository, so whatever that \
repository can be made to run, it runs as root. Set `user` on the app to build as \
that user instead."
)
})
}
async fn uid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-u", "uid").await
}
async fn gid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-g", "gid").await
}
fn artifact_source(release: &Path, env: &BTreeMap<String, String>, artifact: &Path) -> PathBuf {
if let Some(target_dir) = env.get("CARGO_TARGET_DIR")
&& let Ok(rest) = artifact.strip_prefix("target")
{
return Path::new(target_dir).join(rest);
}
release.join(artifact)
}
fn copy_artifact(
release: &Path,
env: &BTreeMap<String, String>,
artifact: &Path,
) -> Result<(), Error> {
let from = artifact_source(release, env, artifact);
let to = release.join(artifact);
if from == to {
return Ok(());
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_owned(),
source,
})?;
}
fs::copy(&from, &to).map_err(|source| Error::Io { path: from, source })?;
Ok(())
}
pub async fn run(
sheep: &str,
release: &Path,
spec: &BuildSpec,
as_user: Option<&str>,
) -> Result<(), Error> {
let Some(command) = spec.command.as_deref() else {
return Ok(());
};
if let Some(warning) = root_warning(sheep, as_user).await {
eprintln!("{warning}");
}
let mut child = Command::new("sh");
child.arg("-c").arg(command);
child.current_dir(release);
child.envs(&spec.env);
if let Some(user) = as_user {
child.gid(gid_for(release, user).await?);
child.uid(uid_for(release, user).await?);
}
let status = child.status().await.map_err(|source| Error::Io {
path: release.to_owned(),
source,
})?;
if !status.success() {
return Err(Error::Build {
status: status.code(),
});
}
for artifact in &spec.artifacts {
copy_artifact(release, &spec.env, artifact)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture_release(files: &[(&str, &str)]) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (name, contents) in files {
fs::write(dir.path().join(name), contents).expect("write fixture file");
}
dir
}
fn tempdir() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
fn current_username() -> String {
let output = std::process::Command::new("id")
.arg("-un")
.output()
.expect("id -un runs");
assert!(output.status.success(), "id -un must succeed");
String::from_utf8(output.stdout)
.expect("id -un prints utf-8")
.trim()
.to_owned()
}
fn current_uid() -> u32 {
let output = std::process::Command::new("id")
.arg("-u")
.output()
.expect("id -u runs");
assert!(output.status.success(), "id -u must succeed");
String::from_utf8(output.stdout)
.expect("id -u prints utf-8")
.trim()
.parse()
.expect("id -u prints a number")
}
fn current_gid() -> u32 {
let output = std::process::Command::new("id")
.arg("-g")
.output()
.expect("id -g runs");
assert!(output.status.success(), "id -g must succeed");
String::from_utf8(output.stdout)
.expect("id -g prints utf-8")
.trim()
.parse()
.expect("id -g prints a number")
}
#[test]
fn only_a_rootless_build_as_root_is_warned_about() {
assert!(root_build_warning("web", 0, None).is_some());
assert!(root_build_warning("web", 0, Some("reactmap")).is_none());
assert!(root_build_warning("web", 501, None).is_none());
assert!(root_build_warning("web", 501, Some("reactmap")).is_none());
}
#[test]
fn the_root_warning_names_the_sheep_and_the_way_out() {
let warning = root_build_warning("bpm", 0, None).expect("warns");
assert!(warning.contains("bpm"), "{warning}");
assert!(warning.contains("root"), "{warning}");
assert!(warning.contains("repository"), "{warning}");
assert!(warning.contains("`user`"), "{warning}");
}
#[tokio::test]
async fn the_effective_uid_can_be_read() {
assert!(effective_uid().await.is_some());
}
#[tokio::test]
async fn the_warning_reads_this_process_and_agrees_with_the_decision() {
let euid = effective_uid().await.expect("a uid");
assert!(root_warning("web", Some("reactmap")).await.is_none());
assert_eq!(
root_warning("web", None).await.is_some(),
euid == 0,
"warned about as uid {euid}"
);
}
#[tokio::test]
async fn a_failing_build_is_an_error() {
let rel = fixture_release(&[]);
let spec = BuildSpec {
command: Some("exit 3".into()),
..Default::default()
};
assert!(run("web", rel.path(), &spec, None).await.is_err());
}
#[tokio::test]
async fn an_absent_build_command_is_not_an_error() {
let rel = fixture_release(&[]);
let spec = BuildSpec::default();
assert!(run("web", rel.path(), &spec, None).await.is_ok());
}
#[tokio::test]
async fn declared_artifacts_are_copied_into_the_release() {
let rel = fixture_release(&[]);
let cache = tempdir();
std::fs::create_dir_all(cache.path().join("release")).unwrap();
std::fs::write(cache.path().join("release/koji"), b"binary").unwrap();
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/release/koji")],
};
run("web", rel.path(), &spec, None).await.expect("builds");
assert!(rel.path().join("target/release/koji").exists());
}
#[test]
fn debug_redacts_env_values() {
let spec = BuildSpec {
command: Some("make build".into()),
env: [
("REGISTRY_TOKEN".to_string(), "secret".to_string()),
("RUST_LOG".to_string(), "info".to_string()),
]
.into(),
artifacts: vec![],
};
assert_eq!(
format!("{spec:?}"),
"BuildSpec { command: Some(\"make build\"), env: <2 vars>, artifacts: [] }"
);
}
#[tokio::test]
async fn an_absent_command_skips_artifacts_too() {
let rel = fixture_release(&[]);
let cache = tempdir();
let spec = BuildSpec {
command: None,
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/release/nothing-built-this")],
};
assert!(run("web", rel.path(), &spec, None).await.is_ok());
}
#[tokio::test]
async fn running_as_the_current_user_succeeds() {
let rel = fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let user = current_username();
run("web", rel.path(), &spec, Some(&user))
.await
.expect("dropping to one's own user and group is always permitted");
}
#[tokio::test]
async fn an_unknown_as_user_is_a_config_error() {
let rel = fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let err = run(
"web",
rel.path(),
&spec,
Some("shep-deploy-test-no-such-user"),
)
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
#[tokio::test]
async fn uid_for_resolves_the_current_users_own_uid() {
let user = current_username();
let uid = uid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(uid, current_uid());
}
#[tokio::test]
async fn gid_for_resolves_the_current_users_primary_gid() {
let user = current_username();
let gid = gid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(gid, current_gid());
}
#[tokio::test]
async fn uid_for_reports_a_config_error_for_an_unknown_user() {
let err = uid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a uid"));
}
#[tokio::test]
async fn gid_for_reports_a_config_error_for_an_unknown_user() {
let err = gid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
#[tokio::test]
async fn an_artifact_already_in_the_release_is_left_intact() {
let rel = fixture_release(&[]);
let spec = BuildSpec {
command: Some("mkdir -p dist && printf hello > dist/app.js".into()),
artifacts: vec![PathBuf::from("dist/app.js")],
..Default::default()
};
run("web", rel.path(), &spec, None).await.expect("builds");
let contents = fs::read_to_string(rel.path().join("dist/app.js")).expect("reads");
assert_eq!(contents, "hello");
}
}