diff --git a/Cargo.lock b/Cargo.lock index 1beca79..9188545 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -209,6 +218,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -776,6 +786,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" + [[package]] name = "siphasher" version = "0.3.11" @@ -820,6 +836,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -899,10 +921,12 @@ name = "tmpl" version = "0.1.0" dependencies = [ "argparse", + "blake2", "file-mode", "pwd-grp", "serde", "serde_yaml", + "shlex", "tera", "thiserror", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 53841ce..b5a2c1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,12 @@ edition = "2021" [dependencies] argparse = "0.2.2" +blake2 = "0.10.6" file-mode = { version = "0.1.2", features = ["serde"] } pwd-grp = "0.1.1" serde = { version = "1.0.195", features = ["derive"] } serde_yaml = "0.9.30" +shlex = "1.2.0" tera = "1.19.1" thiserror = "1.0.56" tracing = { version = "0.1.40", features = ["log"] } diff --git a/src/main.rs b/src/main.rs index 9ae6187..209bff4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,27 @@ mod model; mod templating; +use std::collections::HashSet; use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; +use std::process::Command; -use argparse::{ArgumentParser, Store, StoreOption}; +use argparse::{ArgumentParser, Store, StoreOption, StoreTrue}; +use blake2::{Blake2b512, Digest}; use serde::de::DeserializeOwned; use serde_yaml::Value; +use shlex::Shlex; use tera::{Context, Tera}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use model::Instructions; +macro_rules! joinargs { + ($args:ident) => { + shlex::join($args.iter().map(|s| s.as_str())) + }; +} + #[derive(Debug, thiserror::Error)] enum LoadError { #[error("{0}")] @@ -44,6 +54,7 @@ struct Args { values: String, templates: Option, destdir: Option, + skip_hooks: bool, } fn parse_args(args: &mut Args) { @@ -66,6 +77,11 @@ fn parse_args(args: &mut Args) { StoreOption, "Destination directory", ); + parser.refer(&mut args.skip_hooks).add_option( + &["--skip-hooks", "-S"], + StoreTrue, + "Skip running hooks after rendering templates", + ); parser.parse_args_or_exit(); } @@ -96,9 +112,13 @@ fn main() { }; let templates = args.templates.as_deref().unwrap_or("templates"); let destdir = args.destdir.as_deref().unwrap_or(Path::new("/")); - if let Err(e) = - process_instructions(templates, destdir, instructions, values) - { + if let Err(e) = process_instructions( + templates, + destdir, + instructions, + values, + args.skip_hooks, + ) { error!("Failed to process instructions: {}", e); std::process::exit(1); } @@ -120,6 +140,7 @@ fn process_instructions( destdir: impl AsRef, instructions: Instructions, values: Value, + skip_hooks: bool, ) -> Result<(), ProcessInstructionsError> { let mut tera = Tera::new(&format!("{}/**", templates))?; tera.register_filter( @@ -128,6 +149,7 @@ fn process_instructions( ); let ctx = Context::from_serialize(&values)?; + let mut post_hooks = HashSet::new(); for i in instructions.render { let out = match tera.render(&i.template, &ctx) { Ok(o) => o, @@ -139,10 +161,37 @@ fn process_instructions( }; let mut dest = PathBuf::from(destdir.as_ref()); dest.push(i.dest.strip_prefix("/").unwrap_or(i.dest.as_path())); + let orig_cksm = checksum(&dest).ok(); if let Err(e) = write_file(&dest, out.as_bytes()) { error!("Failed to write output file {}: {}", dest.display(), e); continue; } + let new_cksm = checksum(&dest).ok(); + if orig_cksm != new_cksm { + info!("File {} was changed", dest.display()); + if let Some(hooks) = i.hooks { + if let Some(changed) = hooks.changed { + for hook in changed { + if let Some(args) = parse_hook(&hook.run, &dest) { + if hook.immediate { + if !skip_hooks { + run_hook(args); + } else { + info!( + "Skipping hook: {}", + joinargs!(args) + ); + } + } else { + post_hooks.insert(args); + } + } + } + } + } + } else { + debug!("File {} was NOT changed", dest.display()); + } if i.owner.is_some() || i.group.is_some() { if let Err(e) = chown(&dest, i.owner.as_deref(), i.group.as_deref()) @@ -156,6 +205,14 @@ fn process_instructions( } } } + + for args in post_hooks { + if !skip_hooks { + run_hook(args); + } else { + info!("Skipping hook: {}", joinargs!(args)); + } + } Ok(()) } @@ -173,7 +230,7 @@ fn write_file( let mut f = std::fs::File::create(&dest)?; f.write_all(data)?; let size = f.stream_position()?; - info!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size); + debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size); Ok(()) } @@ -240,3 +297,63 @@ fn format_error(e: &dyn std::error::Error) -> String { } msg } + +fn checksum(path: impl AsRef) -> std::io::Result> { + let mut f = std::fs::File::open(path)?; + let mut blake = Blake2b512::new(); + loop { + let mut buf = vec![0u8; 16384]; + match f.read_exact(&mut buf) { + Ok(_) => blake.update(buf), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + blake.update(buf); + break; + } + Err(e) => return Err(e), + } + } + Ok(blake.finalize().to_vec()) +} + +fn parse_hook(command: &str, path: &Path) -> Option> { + let mut bad_path = false; + let args: Vec<_> = Shlex::new(command) + .map(|a| { + if a == "%s" { + if let Some(p) = path.as_os_str().to_str() { + p.into() + } else { + bad_path = true; + a + } + } else { + a + } + }) + .collect(); + if bad_path { + warn!("Cannot run hook: path is not valid UTF-8"); + return None; + } + if args.is_empty() { + warn!( + "Invalid hook for {} ({}): empty argument list", + path.display(), + command + ); + return None; + } + Some(args) +} + +fn run_hook(args: Vec) { + info!("Running hook: {}", joinargs!(args)); + if let Err(e) = _run_hook(args) { + error!("Error running hook: {}", e); + } +} + +fn _run_hook(args: Vec) -> std::io::Result<()> { + Command::new(&args[0]).args(&args[1..]).spawn()?.wait()?; + Ok(()) +} diff --git a/src/model.rs b/src/model.rs index f21371e..57869bf 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,6 +2,18 @@ use std::path::PathBuf; use serde::Deserialize; +#[derive(Debug, Deserialize)] +pub struct Hook { + pub run: String, + #[serde(default)] + pub immediate: bool, +} + +#[derive(Debug, Deserialize)] +pub struct Hooks { + pub changed: Option>, +} + #[derive(Debug, Deserialize)] pub struct RenderInstruction { pub template: String, @@ -9,6 +21,7 @@ pub struct RenderInstruction { pub owner: Option, pub group: Option, pub mode: Option, + pub hooks: Option, } #[derive(Debug, Deserialize)]