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.
master
Dustin 2024-01-11 20:02:37 -06:00
parent bd7b80dede
commit 6d0bfeedaf
4 changed files with 162 additions and 6 deletions

24
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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<String>,
destdir: Option<PathBuf>,
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<Path>,
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<Path>) -> std::io::Result<Vec<u8>> {
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<Vec<String>> {
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<String>) {
info!("Running hook: {}", joinargs!(args));
if let Err(e) = _run_hook(args) {
error!("Error running hook: {}", e);
}
}
fn _run_hook(args: Vec<String>) -> std::io::Result<()> {
Command::new(&args[0]).args(&args[1..]).spawn()?.wait()?;
Ok(())
}

View File

@ -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<Vec<Hook>>,
}
#[derive(Debug, Deserialize)]
pub struct RenderInstruction {
pub template: String,
@ -9,6 +21,7 @@ pub struct RenderInstruction {
pub owner: Option<String>,
pub group: Option<String>,
pub mode: Option<String>,
pub hooks: Option<Hooks>,
}
#[derive(Debug, Deserialize)]