380 lines
10 KiB
Rust
380 lines
10 KiB
Rust
mod model;
|
|
mod templating;
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::io::{Read, Seek, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
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, trace, 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}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("{0}")]
|
|
Yaml(#[from] serde_yaml::Error),
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum SetPermissionsError {
|
|
#[error("{0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("User not found: {0}")]
|
|
UserNotFound(String),
|
|
#[error("Group not found: {0}")]
|
|
GroupNotFound(String),
|
|
#[error("Bad mode string")]
|
|
BadMode,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum ProcessInstructionsError {
|
|
#[error("Error loading templates: {0}")]
|
|
Tera(#[from] tera::Error),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Args {
|
|
instructions: String,
|
|
values: String,
|
|
templates: Option<String>,
|
|
destdir: Option<PathBuf>,
|
|
skip_hooks: bool,
|
|
}
|
|
|
|
fn parse_args(args: &mut Args) {
|
|
let mut parser = ArgumentParser::new();
|
|
parser
|
|
.refer(&mut args.instructions)
|
|
.required()
|
|
.add_argument("instructions", Store, "Instructions");
|
|
parser
|
|
.refer(&mut args.values)
|
|
.required()
|
|
.add_argument("values", Store, "Values");
|
|
parser.refer(&mut args.templates).add_option(
|
|
&["--templates", "-t"],
|
|
StoreOption,
|
|
"Templates directory",
|
|
);
|
|
parser.refer(&mut args.destdir).add_option(
|
|
&["--destdir", "-d"],
|
|
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();
|
|
}
|
|
|
|
fn main() {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
|
|
let mut args: Args = Default::default();
|
|
parse_args(&mut args);
|
|
let instructions = match load_yaml(&args.instructions) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
error!(
|
|
"Could not load instructions from {}: {}",
|
|
args.instructions, e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
let values = match load_yaml(&args.values) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
error!("Could not load values from {}: {}", args.values, e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
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,
|
|
args.skip_hooks,
|
|
) {
|
|
error!("Failed to process instructions: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn load_yaml<T: DeserializeOwned>(path: &str) -> Result<T, LoadError> {
|
|
let mut yaml = vec![];
|
|
if path == "-" {
|
|
std::io::stdin().read_to_end(&mut yaml)?;
|
|
} else {
|
|
let mut f = std::fs::File::open(path)?;
|
|
f.read_to_end(&mut yaml)?;
|
|
}
|
|
Ok(serde_yaml::from_slice(&yaml)?)
|
|
}
|
|
|
|
fn process_instructions(
|
|
templates: &str,
|
|
destdir: impl AsRef<Path>,
|
|
instructions: Instructions,
|
|
values: Value,
|
|
skip_hooks: bool,
|
|
) -> Result<(), ProcessInstructionsError> {
|
|
let mut tera = Tera::new(&format!("{}/**", templates))?;
|
|
tera.register_filter(
|
|
templating::decrypt::NAME,
|
|
templating::decrypt::DecryptFilter,
|
|
);
|
|
|
|
let ctx = Context::from_serialize(&values)?;
|
|
let mut post_hooks = BTreeSet::new();
|
|
for i in instructions.render {
|
|
let out = match tera.render(&i.template, &ctx) {
|
|
Ok(o) => o,
|
|
Err(e) => {
|
|
let msg = format_error(&e);
|
|
error!("Failed to render template {}: {}", i.template, msg);
|
|
continue;
|
|
}
|
|
};
|
|
let mut dest = PathBuf::from(destdir.as_ref());
|
|
dest.push(i.dest.strip_prefix("/").unwrap_or(i.dest.as_path()));
|
|
let changed = match write_file(&dest, out.as_bytes()) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
error!(
|
|
"Failed to write output file {}: {}",
|
|
dest.display(),
|
|
e
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
if changed {
|
|
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())
|
|
{
|
|
error!("Failed to set ownership of {}: {}", dest.display(), e);
|
|
}
|
|
}
|
|
if let Some(mode) = i.mode {
|
|
if let Err(e) = chmod(&dest, &mode) {
|
|
error!("Failed to set mode of {}: {}", dest.display(), e);
|
|
}
|
|
}
|
|
}
|
|
|
|
for args in post_hooks {
|
|
if !skip_hooks {
|
|
run_hook(args);
|
|
} else {
|
|
info!("Skipping hook: {}", joinargs!(args));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_file(
|
|
dest: impl AsRef<Path>,
|
|
data: &[u8],
|
|
) -> Result<bool, std::io::Error> {
|
|
if let Some(p) = dest.as_ref().parent() {
|
|
if !p.exists() {
|
|
info!("Creating directory {}", p.display());
|
|
std::fs::create_dir_all(p)?;
|
|
}
|
|
}
|
|
if let Ok(orig_cksm) = checksum(&dest) {
|
|
trace!(
|
|
"Original checksum: {}: {}",
|
|
dest.as_ref().display(),
|
|
hex::encode(&orig_cksm)
|
|
);
|
|
let mut blake = Blake2b512::new();
|
|
blake.update(data);
|
|
let new_cksm = blake.finalize().to_vec();
|
|
trace!(
|
|
"New checksum: {}: {}",
|
|
dest.as_ref().display(),
|
|
hex::encode(&new_cksm)
|
|
);
|
|
if orig_cksm == new_cksm {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
debug!("Writing output: {}", dest.as_ref().display());
|
|
let mut f = std::fs::File::create(&dest)?;
|
|
f.write_all(data)?;
|
|
let size = f.stream_position()?;
|
|
debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
|
|
Ok(true)
|
|
}
|
|
|
|
fn chown(
|
|
path: impl AsRef<Path>,
|
|
owner: Option<&str>,
|
|
group: Option<&str>,
|
|
) -> Result<(), SetPermissionsError> {
|
|
let uid = if let Some(owner) = owner {
|
|
debug!("Looking up UID for user {}", owner);
|
|
if let Some(pw) = pwd_grp::getpwnam(owner)? {
|
|
debug!("Found UID {} for user {}", pw.uid, owner);
|
|
Some(pw.uid)
|
|
} else {
|
|
return Err(SetPermissionsError::UserNotFound(owner.into()));
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let gid = if let Some(group) = group {
|
|
debug!("Looking up GID for group {}", group);
|
|
if let Some(gr) = pwd_grp::getgrnam(group)? {
|
|
debug!("Found GID {} for group {}", gr.gid, group);
|
|
Some(gr.gid)
|
|
} else {
|
|
return Err(SetPermissionsError::GroupNotFound(group.into()));
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
debug!(
|
|
"Setting ownership of {} to {:?} / {:?}",
|
|
path.as_ref().display(),
|
|
uid,
|
|
gid
|
|
);
|
|
Ok(std::os::unix::fs::chown(path, uid, gid)?)
|
|
}
|
|
|
|
fn chmod(
|
|
path: impl AsRef<Path>,
|
|
mode: &str,
|
|
) -> Result<(), SetPermissionsError> {
|
|
let mut filemode = file_mode::Mode::empty();
|
|
filemode
|
|
.set_str(mode)
|
|
.map_err(|_| SetPermissionsError::BadMode)?;
|
|
debug!(
|
|
"Changing mode of {} to {:o}",
|
|
path.as_ref().display(),
|
|
filemode.mode()
|
|
);
|
|
let newmode = filemode.set_mode_path(&path)?;
|
|
info!("Set mode of {} to {:o}", path.as_ref().display(), newmode);
|
|
Ok(())
|
|
}
|
|
|
|
fn format_error(e: &dyn std::error::Error) -> String {
|
|
// TODO replace this with std::error::Error::sources when it is stablized.
|
|
// https://github.com/rust-lang/rust/issues/58520
|
|
let mut msg = e.to_string();
|
|
if let Some(e) = e.source() {
|
|
msg.push_str(&format!(": {}", format_error(e)));
|
|
}
|
|
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];
|
|
let sz = f.read(&mut buf)?;
|
|
if sz == 0 {
|
|
break;
|
|
}
|
|
blake.update(&buf[..sz]);
|
|
}
|
|
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(())
|
|
}
|