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
parent
bd7b80dede
commit
6d0bfeedaf
|
@ -44,6 +44,15 @@ version = "1.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
@ -209,6 +218,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -776,6 +786,12 @@ dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
|
@ -820,6 +836,12 @@ dependencies = [
|
||||||
"syn 2.0.48",
|
"syn 2.0.48",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
|
@ -899,10 +921,12 @@ name = "tmpl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argparse",
|
"argparse",
|
||||||
|
"blake2",
|
||||||
"file-mode",
|
"file-mode",
|
||||||
"pwd-grp",
|
"pwd-grp",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"shlex",
|
||||||
"tera",
|
"tera",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
@ -7,10 +7,12 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argparse = "0.2.2"
|
argparse = "0.2.2"
|
||||||
|
blake2 = "0.10.6"
|
||||||
file-mode = { version = "0.1.2", features = ["serde"] }
|
file-mode = { version = "0.1.2", features = ["serde"] }
|
||||||
pwd-grp = "0.1.1"
|
pwd-grp = "0.1.1"
|
||||||
serde = { version = "1.0.195", features = ["derive"] }
|
serde = { version = "1.0.195", features = ["derive"] }
|
||||||
serde_yaml = "0.9.30"
|
serde_yaml = "0.9.30"
|
||||||
|
shlex = "1.2.0"
|
||||||
tera = "1.19.1"
|
tera = "1.19.1"
|
||||||
thiserror = "1.0.56"
|
thiserror = "1.0.56"
|
||||||
tracing = { version = "0.1.40", features = ["log"] }
|
tracing = { version = "0.1.40", features = ["log"] }
|
||||||
|
|
129
src/main.rs
129
src/main.rs
|
@ -1,17 +1,27 @@
|
||||||
mod model;
|
mod model;
|
||||||
mod templating;
|
mod templating;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::io::{Read, Seek, Write};
|
use std::io::{Read, Seek, Write};
|
||||||
use std::path::{Path, PathBuf};
|
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::de::DeserializeOwned;
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
|
use shlex::Shlex;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use model::Instructions;
|
use model::Instructions;
|
||||||
|
|
||||||
|
macro_rules! joinargs {
|
||||||
|
($args:ident) => {
|
||||||
|
shlex::join($args.iter().map(|s| s.as_str()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum LoadError {
|
enum LoadError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
@ -44,6 +54,7 @@ struct Args {
|
||||||
values: String,
|
values: String,
|
||||||
templates: Option<String>,
|
templates: Option<String>,
|
||||||
destdir: Option<PathBuf>,
|
destdir: Option<PathBuf>,
|
||||||
|
skip_hooks: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(args: &mut Args) {
|
fn parse_args(args: &mut Args) {
|
||||||
|
@ -66,6 +77,11 @@ fn parse_args(args: &mut Args) {
|
||||||
StoreOption,
|
StoreOption,
|
||||||
"Destination directory",
|
"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();
|
parser.parse_args_or_exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,9 +112,13 @@ fn main() {
|
||||||
};
|
};
|
||||||
let templates = args.templates.as_deref().unwrap_or("templates");
|
let templates = args.templates.as_deref().unwrap_or("templates");
|
||||||
let destdir = args.destdir.as_deref().unwrap_or(Path::new("/"));
|
let destdir = args.destdir.as_deref().unwrap_or(Path::new("/"));
|
||||||
if let Err(e) =
|
if let Err(e) = process_instructions(
|
||||||
process_instructions(templates, destdir, instructions, values)
|
templates,
|
||||||
{
|
destdir,
|
||||||
|
instructions,
|
||||||
|
values,
|
||||||
|
args.skip_hooks,
|
||||||
|
) {
|
||||||
error!("Failed to process instructions: {}", e);
|
error!("Failed to process instructions: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
@ -120,6 +140,7 @@ fn process_instructions(
|
||||||
destdir: impl AsRef<Path>,
|
destdir: impl AsRef<Path>,
|
||||||
instructions: Instructions,
|
instructions: Instructions,
|
||||||
values: Value,
|
values: Value,
|
||||||
|
skip_hooks: bool,
|
||||||
) -> Result<(), ProcessInstructionsError> {
|
) -> Result<(), ProcessInstructionsError> {
|
||||||
let mut tera = Tera::new(&format!("{}/**", templates))?;
|
let mut tera = Tera::new(&format!("{}/**", templates))?;
|
||||||
tera.register_filter(
|
tera.register_filter(
|
||||||
|
@ -128,6 +149,7 @@ fn process_instructions(
|
||||||
);
|
);
|
||||||
|
|
||||||
let ctx = Context::from_serialize(&values)?;
|
let ctx = Context::from_serialize(&values)?;
|
||||||
|
let mut post_hooks = HashSet::new();
|
||||||
for i in instructions.render {
|
for i in instructions.render {
|
||||||
let out = match tera.render(&i.template, &ctx) {
|
let out = match tera.render(&i.template, &ctx) {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
|
@ -139,10 +161,37 @@ fn process_instructions(
|
||||||
};
|
};
|
||||||
let mut dest = PathBuf::from(destdir.as_ref());
|
let mut dest = PathBuf::from(destdir.as_ref());
|
||||||
dest.push(i.dest.strip_prefix("/").unwrap_or(i.dest.as_path()));
|
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()) {
|
if let Err(e) = write_file(&dest, out.as_bytes()) {
|
||||||
error!("Failed to write output file {}: {}", dest.display(), e);
|
error!("Failed to write output file {}: {}", dest.display(), e);
|
||||||
continue;
|
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 i.owner.is_some() || i.group.is_some() {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
chown(&dest, i.owner.as_deref(), i.group.as_deref())
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,7 +230,7 @@ fn write_file(
|
||||||
let mut f = std::fs::File::create(&dest)?;
|
let mut f = std::fs::File::create(&dest)?;
|
||||||
f.write_all(data)?;
|
f.write_all(data)?;
|
||||||
let size = f.stream_position()?;
|
let size = f.stream_position()?;
|
||||||
info!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
|
debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,3 +297,63 @@ fn format_error(e: &dyn std::error::Error) -> String {
|
||||||
}
|
}
|
||||||
msg
|
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(())
|
||||||
|
}
|
||||||
|
|
13
src/model.rs
13
src/model.rs
|
@ -2,6 +2,18 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RenderInstruction {
|
pub struct RenderInstruction {
|
||||||
pub template: String,
|
pub template: String,
|
||||||
|
@ -9,6 +21,7 @@ pub struct RenderInstruction {
|
||||||
pub owner: Option<String>,
|
pub owner: Option<String>,
|
||||||
pub group: Option<String>,
|
pub group: Option<String>,
|
||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
|
pub hooks: Option<Hooks>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
Loading…
Reference in New Issue