Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ hostname = "0.3.*"
log = "0.4.*"
maplit = "1.*"
evalexpr = "13"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
serde = { version = "1.*", features = ["derive"] }
shellexpand = "2.*"
simplelog = "0.12.*"
Expand Down
291 changes: 289 additions & 2 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use anyhow::{Context, Result};
use crossterm::style::Stylize;
use handlebars::Handlebars;

use crate::config::{SymbolicTarget, TemplateTarget, Variables};
use crate::config::{CopyEntry, CopyTarget, SymbolicTarget, TemplateTarget, Variables};
use crate::difference::{self, diff_nonempty, generate_template_diff, print_diff};
use crate::filesystem::{Filesystem, SymlinkComparison, TemplateComparison};
use crate::filesystem::{FileHash, Filesystem, SymlinkComparison, TemplateComparison};

#[cfg_attr(test, mockall::automock)]
pub trait ActionRunner {
Expand All @@ -27,6 +27,14 @@ pub trait ActionRunner {
cache: &Path,
target: &TemplateTarget,
) -> Result<bool>;
fn delete_copy(&mut self, source: &Path, cached: &CopyEntry) -> Result<bool>;
fn create_copy(&mut self, source: &Path, target: &CopyTarget) -> Result<Option<CopyEntry>>;
fn update_copy(
&mut self,
source: &Path,
target: &CopyTarget,
cached: &CopyEntry,
) -> Result<Option<CopyEntry>>;
}

pub struct RealActionRunner<'a> {
Expand Down Expand Up @@ -101,6 +109,20 @@ impl ActionRunner for RealActionRunner<'_> {
self.diff_context_lines,
)
}
fn delete_copy(&mut self, source: &Path, cached: &CopyEntry) -> Result<bool> {
delete_copy(source, cached, self.fs, self.force)
}
fn create_copy(&mut self, source: &Path, target: &CopyTarget) -> Result<Option<CopyEntry>> {
create_copy(source, target, self.fs, self.force)
}
fn update_copy(
&mut self,
source: &Path,
target: &CopyTarget,
cached: &CopyEntry,
) -> Result<Option<CopyEntry>> {
update_copy(source, target, cached, self.fs, self.force)
}
}

// == DELETE ==
Expand Down Expand Up @@ -218,6 +240,57 @@ pub fn delete_template(
}
}

/// Returns true if the copy was deleted and should be removed from cache.
pub fn delete_copy(
source: &Path,
cached: &CopyEntry,
fs: &mut dyn Filesystem,
force: bool,
) -> Result<bool> {
info!("{} copy {:?} -> {:?}", "[-]".red(), source, cached.target);

let target_hash = fs
.checksum_file(&cached.target)
.context("checksum copy target")?;
debug!("Target hash: {}", target_hash);

match target_hash {
FileHash::NotPresent => {
warn!(
"Deleting copy {:?} -> {:?} but target doesn't exist. Removing from cache anyways.",
source, cached.target
);
Ok(true)
}
FileHash::Hash(h) if h == cached.target_checksum => {
debug!("Target unchanged, deleting");
fs.remove_file(&cached.target)
.context("remove copy target")?;
fs.delete_parents(&cached.target, false)
.context("delete parents of copy target")?;
Ok(true)
}
FileHash::Hash(_) | FileHash::NotRegularFile if force => {
warn!(
"Deleting copy {:?} -> {:?} but target was externally modified or is not a regular file. Forcing.",
source, cached.target
);
fs.remove_file(&cached.target)
.context("remove copy target")?;
fs.delete_parents(&cached.target, false)
.context("delete parents of copy target")?;
Ok(true)
}
FileHash::Hash(_) | FileHash::NotRegularFile => {
error!(
"Deleting copy {:?} -> {:?} but target was externally modified or is not a regular file. Skipping.",
source, cached.target
);
Ok(false)
}
}
}

fn perform_cache_deletion(fs: &mut dyn Filesystem, cache: &Path) -> Result<()> {
fs.remove_file(cache).context("delete template cache")?;
fs.delete_parents(cache, true)
Expand Down Expand Up @@ -392,6 +465,100 @@ pub fn create_template(
}
}

/// Returns `Some(entry)` if the copy was deployed and should be added to cache,
/// or `None` if it was skipped.
pub fn create_copy(
source: &Path,
target: &CopyTarget,
fs: &mut dyn Filesystem,
force: bool,
) -> Result<Option<CopyEntry>> {
info!("{} copy {:?} -> {:?}", "[+]".green(), source, target.target);

let source_hash = fs.checksum_file(source).context("checksum source")?;
let source_hash = match source_hash {
FileHash::Hash(h) => h,
_ => {
error!(
"Creating copy {:?} -> {:?} but source is missing or not a regular file. Skipping.",
source, target.target
);
return Ok(None);
}
};

let target_hash = fs
.checksum_file(&target.target)
.context("checksum target")?;

match target_hash {
FileHash::NotPresent => {
debug!("Performing creation");
fs.create_dir_all(
target
.target
.parent()
.context("get parent of target file")?,
&target.owner,
)
.context("create parent for target file")?;
fs.copy_file(source, &target.target, &target.owner)
.context("copy source to target")?;
fs.copy_permissions(source, &target.target, &target.owner)
.context("copy permissions from source to target")?;
// After copy, source and target are byte-identical
Ok(Some(CopyEntry {
target: target.target.clone(),
source_checksum: source_hash,
target_checksum: source_hash,
}))
}
FileHash::Hash(h) if h == source_hash => {
warn!(
"Creating copy {:?} -> {:?} but target already matches source. Adding to cache.",
source, target.target
);
Ok(Some(CopyEntry {
target: target.target.clone(),
source_checksum: source_hash,
target_checksum: h,
}))
}
FileHash::Hash(_) | FileHash::NotRegularFile if force => {
warn!(
"Creating copy {:?} -> {:?} but target exists with different content. Forcing.",
source, target.target
);
fs.remove_file(&target.target)
.context("remove existing target")?;
fs.create_dir_all(
target
.target
.parent()
.context("get parent of target file")?,
&target.owner,
)
.context("create parent for target file")?;
fs.copy_file(source, &target.target, &target.owner)
.context("copy source to target")?;
fs.copy_permissions(source, &target.target, &target.owner)
.context("copy permissions from source to target")?;
Ok(Some(CopyEntry {
target: target.target.clone(),
source_checksum: source_hash,
target_checksum: source_hash,
}))
}
FileHash::Hash(_) | FileHash::NotRegularFile => {
error!(
"Creating copy {:?} -> {:?} but target exists with different content. Skipping.",
source, target.target
);
Ok(None)
}
}
}

// == UPDATE ==

/// Returns true if the symlink wasn't skipped
Expand Down Expand Up @@ -567,6 +734,126 @@ pub fn update_template(
}
}

/// Copies `source` to `cached.target`, creating parent directories as needed.
/// Returns a fresh `CopyEntry` with both checksums set to `source_hash`.
fn perform_copy_update(
source: &Path,
cached: &CopyEntry,
target: &CopyTarget,
fs: &mut dyn Filesystem,
source_hash: u64,
) -> Result<Option<CopyEntry>> {
debug_assert!(
cached.target == target.target,
"cached.target {:?} must match target.target {:?}",
cached.target,
target.target
);
fs.create_dir_all(
cached
.target
.parent()
.context("get parent of target file")?,
&target.owner,
)
.context("create parent for target file")?;
fs.copy_file(source, &cached.target, &target.owner)
.context("copy source to target")?;
fs.copy_permissions(source, &cached.target, &target.owner)
.context("copy permissions from source to target")?;
Ok(Some(CopyEntry {
target: cached.target.clone(),
source_checksum: source_hash,
target_checksum: source_hash,
}))
}

/// Returns `Some(entry)` with updated checksums if the copy was processed successfully,
/// or `None` if it was skipped due to an external modification.
pub fn update_copy(
source: &Path,
target: &CopyTarget,
cached: &CopyEntry,
fs: &mut dyn Filesystem,
force: bool,
) -> Result<Option<CopyEntry>> {
debug!("Updating copy {:?} -> {:?}...", source, cached.target);

let source_hash = fs.checksum_file(source).context("checksum source")?;
let source_hash = match source_hash {
FileHash::Hash(h) => h,
_ => {
error!(
"Updating copy {:?} -> {:?} but source is missing. Skipping.",
source, cached.target
);
return Ok(None);
}
};

let target_hash = fs
.checksum_file(&cached.target)
.context("checksum target")?;
debug!("Source hash: {source_hash:#018x}, target hash: {target_hash}");

let source_changed = source_hash != cached.source_checksum;
let target_changed = !matches!(&target_hash, FileHash::Hash(h) if *h == cached.target_checksum);

match (source_changed, target_changed, &target_hash) {
(false, false, _) => {
// Nothing changed: ensure owner/perms are correct
debug!("Already up to date");
fs.set_owner(&cached.target, &target.owner)
.context("set target file owner")?;
fs.copy_permissions(source, &cached.target, &target.owner)
.context("copy permissions from source to target")?;
Ok(Some(CopyEntry {
target: cached.target.clone(),
source_checksum: source_hash,
target_checksum: cached.target_checksum,
}))
}
(true, false, _) => {
// Source updated, target untouched: re-copy
info!(
"{} copy {:?} -> {:?} (source changed)",
"[~]".yellow(),
source,
cached.target
);
fs.remove_file(&cached.target)
.context("remove stale copy target")?;
perform_copy_update(source, cached, target, fs, source_hash)
}
(_, true, FileHash::NotPresent) => {
// Target missing: recreate (no remove_file needed)
warn!(
"Updating copy {:?} -> {:?} but target is missing. Recreating.",
source, cached.target
);
perform_copy_update(source, cached, target, fs, source_hash)
}
(_, true, _) if force => {
// Target externally modified but --force
warn!(
"Updating copy {:?} -> {:?} but target was externally modified or is not a regular file. Forcing.",
source, cached.target
);
fs.remove_file(&cached.target)
.context("remove externally modified target")?;
perform_copy_update(source, cached, target, fs, source_hash)
}
(_, true, _) => {
// Target externally modified: back off
error!(
"Updating copy {:?} -> {:?} but target was externally modified or is not a regular file. Skipping.",
source, cached.target
);
Ok(None)
}
}
}

pub(crate) fn perform_template_deploy(
source: &Path,
cache: &Path,
Expand Down
Loading
Loading