From 6d0bfeedaf08c2747944bb7c065e37c14ca5d78a Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Thu, 11 Jan 2024 20:02:37 -0600 Subject: [PATCH] Add post-change hooks feature After rendering a template, `tmpl` will now run any commands specified in the `hooks` property of a template instruction. Hooks can either be "immediate," meaning they will run as soon as the new file is written, or "deferred," and will run after all templates have been rendered. Deferred hooks are deduplicated: if multiple templates specify the exact same command, it will only be run once. Hook commands can include the `%s` placeholder, which will be replaced by the path of the rendered file. --- Cargo.lock | 24 ++++++++++ Cargo.toml | 2 + src/main.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/model.rs | 13 ++++++ 4 files changed, 162 insertions(+), 6 deletions(-) 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)]