use std::collections::BTreeMap;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use flate2::Compression;
use flate2::write::GzEncoder;
use isoloom_manifest::ignore::{FILE as IGNORE_FILE, Rules};
use isoloom_manifest::{SnakeName, Step, Template};
use crate::Result;
use crate::error::Error;
use crate::executor::Cx;
pub(crate) const ARCHIVE: &str = "project.tgz";
const SKIP: [&str; 3] = [".git", ".vagrant", ".terraform"];
const EARLIER_ARCHIVE: &str = ".isoloom-project";
pub(crate) async fn project(cx: &Cx<'_>, step: &Step, with: &BTreeMap<SnakeName, Template>) -> Result<()> {
let at = format!("{}.with.root", step.id);
let root = match with.iter().find(|(k, _)| k.as_str() == "root") {
Some((_, t)) => cx.vars.render(t, &at)?,
None => {
return Err(Error::Unsupported {
what: format!("step `{}`: a `project.archive` without its `root`", step.id),
});
}
};
let root = cx.run.dir().join(root);
let out = cx.run.runner.store.run_dir(cx.run.name())?.join(ARCHIVE);
let target = out.clone();
let written = tokio::task::spawn_blocking(move || write(&root, &target)).await;
match written {
Ok(done) => done.map_err(Error::io(&out, "written")),
Err(e) => Err(Error::io(&out, "written")(io::Error::other(e.to_string()))),
}
}
fn write(root: &Path, out: &Path) -> io::Result<()> {
let rules = match std::fs::read_to_string(root.join(IGNORE_FILE)) {
Ok(text) => Rules::parse(&text),
Err(e) if e.kind() == io::ErrorKind::NotFound => Rules::default(),
Err(e) => return Err(e),
};
let entries = entries(root, &rules)?;
let part = out.with_file_name(format!("{ARCHIVE}.part"));
let written = (|| {
let file: File = crate::store::create_private(&part)?;
let mut tar = tar::Builder::new(GzEncoder::new(file, Compression::default()));
tar.follow_symlinks(false);
for rel in &entries {
tar.append_path_with_name(root.join(rel), rel)?;
}
tar.into_inner()?.finish()?.sync_all()
})();
match written.and_then(|()| std::fs::rename(&part, out)) {
Ok(()) => Ok(()),
Err(e) => {
if let Err(left) = std::fs::remove_file(&part) {
tracing::debug!(error = %left, path = %part.display(), "a partial archive was left behind");
}
Err(e)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Visit {
add: bool,
descend: bool,
}
fn visit(rel: &str, folder: bool, rules: &Rules) -> Visit {
let name = rel.rsplit('/').next().unwrap_or(rel);
let top = !rel.contains('/');
if SKIP.contains(&name) || (top && name.starts_with(EARLIER_ARCHIVE)) {
return Visit { add: false, descend: false };
}
let ignored = if folder { rules.ignored(&format!("{rel}/")) } else { rules.ignored(rel) };
Visit {
add: !ignored,
descend: folder && (!ignored || rules.brings_back()),
}
}
fn entries(root: &Path, rules: &Rules) -> io::Result<Vec<String>> {
let mut out = Vec::new();
let mut pending: Vec<String> = vec![String::new()];
while let Some(dir) = pending.pop() {
let mut names: Vec<String> = std::fs::read_dir(root.join(&dir))?
.map(|e| e.map(|e| e.file_name().to_string_lossy().into_owned()))
.collect::<io::Result<_>>()?;
names.sort();
let mut folders = Vec::new();
for name in names {
let rel = if dir.is_empty() { name } else { format!("{dir}/{name}") };
let folder = std::fs::symlink_metadata(root.join(&rel))?.is_dir();
let v = visit(&rel, folder, rules);
if v.add {
out.push(rel.clone());
}
if v.descend {
folders.push(rel);
}
}
pending.extend(folders.into_iter().rev());
}
Ok(out)
}
pub(crate) fn forget(cx: &Cx<'_>) -> Result<()> {
let path: PathBuf = cx.run.runner.store.run_dir(cx.run.name())?.join(ARCHIVE);
match std::fs::remove_file(&path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => Err(Error::io(&path, "removed")(e)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests;