use std::{
os::unix::fs::MetadataExt as _,
path::{Path, PathBuf},
};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use tokio::io::AsyncReadExt as _;
use tokio_tar::{Builder, EntryType, Header};
use walkdir::{DirEntry, WalkDir};
use crate::ContextError;
type ContextArchive = Vec<u8>;
#[derive(Debug)]
pub struct ContextBuilder {
context_path: PathBuf,
ignore: Gitignore,
dockerfile: PathBuf,
global: Option<Gitignore>,
}
impl ContextBuilder {
pub fn from_path(
context_path: impl Into<PathBuf>,
dockerfile: impl AsRef<Path>,
) -> Result<Self, ContextError> {
let path = context_path.into();
let mut gitignore = GitignoreBuilder::new(&path);
if let Some(err) = gitignore.add(path.join(".gitignore")) {
tracing::warn!(?err, "Error adding .gitignore");
}
if let Some(err) = gitignore.add(path.join(".dockerignore")) {
tracing::warn!(?err, "Error adding .dockerignore");
}
let gitignore = gitignore.build()?;
let (global_gitignore, maybe_error) = Gitignore::global();
let maybe_global = if let Some(err) = maybe_error {
tracing::warn!(?err, "Error adding global gitignore");
None
} else {
Some(global_gitignore)
};
Ok(Self {
dockerfile: dockerfile.as_ref().to_path_buf(),
context_path: path,
ignore: gitignore,
global: maybe_global,
})
}
fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
let Ok(relative_path) = path.as_ref().strip_prefix(&self.context_path) else {
tracing::debug!(
"not ignoring {path} as it seems to be not prefixed by {prefix}",
path = path.as_ref().display(),
prefix = self.context_path.to_string_lossy()
);
return false;
};
if relative_path.starts_with(".git") {
tracing::debug!(
"not ignoring {path} as it seems to be a git file",
path = path.as_ref().display()
);
return false;
}
if let Some(global) = &self.global
&& global
.matched_path_or_any_parents(relative_path, false)
.is_ignore()
{
tracing::debug!(
"ignoring {path} as it is ignored by global gitignore",
path = path.as_ref().display()
);
return true;
}
self.ignore
.matched_path_or_any_parents(relative_path, false)
.is_ignore()
}
fn iter(&self) -> impl Iterator<Item = Result<DirEntry, walkdir::Error>> {
WalkDir::new(&self.context_path).into_iter()
}
pub async fn build_tar(&self) -> Result<ContextArchive, ContextError> {
let buffer = Vec::new();
let mut tar = Builder::new(buffer);
let mut file = fs_err::tokio::File::open(&self.dockerfile).await?;
let mut buffer_content = Vec::new();
file.read_to_end(&mut buffer_content).await?;
let mut header = Header::new_gnu();
header.set_size(buffer_content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(
&mut header,
self.dockerfile
.file_name()
.expect("Infallible; No file name"),
&*buffer_content,
)
.await?;
for entry in self.iter() {
let Ok(entry) = entry else {
tracing::warn!(?entry, "Failed to read entry");
continue;
};
let path = entry.path();
let Ok(relative_path) = path.strip_prefix(&self.context_path) else {
tracing::warn!(?path, "Failed to strip prefix on path");
continue;
};
if path.is_dir() && !path.is_symlink() {
tracing::debug!(path = ?path, relative_path = ?relative_path, "Adding directory to tar");
if let Err(err) = tar.append_path(relative_path).await {
tracing::warn!(?err, "Failed to append path to tar");
}
continue;
}
if self.is_ignored(path) {
tracing::debug!(path = ?path, "Ignored file");
continue;
}
if path.is_symlink() {
tracing::debug!(path = ?path, "Adding symlink to tar");
let Ok(link_target) = tokio::fs::read_link(path).await else {
continue;
}; let Ok(metadata) = entry.metadata() else {
continue;
};
tracing::debug!(link_target = ?link_target, "Symlink target");
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Symlink);
if let Err(error) = header.set_link_name(&link_target) {
tracing::warn!(?error, "Failed to set link name on {link_target:#?}");
continue;
}
header.set_uid(metadata.uid() as u64);
header.set_gid(metadata.gid() as u64);
header.set_mode(metadata.mode());
header.set_mtime(metadata.mtime() as u64);
header.set_size(0);
if let Err(error) = tar.append_data(&mut header, path, tokio::io::empty()).await {
tracing::warn!(
?error,
"Failed to append symlink to tar on {link_target:#?}"
);
continue;
}
continue;
}
tracing::debug!(path = ?path, "Adding file to tar");
let mut file = fs_err::tokio::File::open(path).await?;
let mut buffer_content = Vec::new();
file.read_to_end(&mut buffer_content).await?;
let mut header = Header::new_gnu();
header.set_size(buffer_content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
let relative_path = path.strip_prefix(&self.context_path)?;
tar.append_data(&mut header, relative_path, &*buffer_content)
.await?;
}
let result = tar.into_inner().await?;
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::{NamedTempFile, tempdir};
#[test_log::test(tokio::test)]
async fn test_is_ignored() {
let dir = tempdir().unwrap();
let context_path = dir.path().to_path_buf();
let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
writeln!(gitignore_file, "*.log").unwrap();
let mut dockerignore_file = fs::File::create(context_path.join(".dockerignore")).unwrap();
writeln!(dockerignore_file, "*.tmp").unwrap();
dbg!(&std::fs::read_to_string(context_path.join(".gitignore")).unwrap());
let dockerfile = NamedTempFile::new().unwrap();
let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
let log_file = context_path.join("test.log");
let tmp_file = context_path.join("test.tmp");
let txt_file = context_path.join("test.txt");
fs::File::create(&log_file).unwrap();
fs::File::create(&tmp_file).unwrap();
fs::File::create(&txt_file).unwrap();
assert!(context_builder.is_ignored(&log_file));
assert!(context_builder.is_ignored(&tmp_file));
assert!(!context_builder.is_ignored(&txt_file));
}
#[test_log::test(tokio::test)]
async fn test_adds_git_even_if_in_ignore() {
let dir = tempdir().unwrap();
let context_path = dir.path().to_path_buf();
let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
writeln!(gitignore_file, ".git").unwrap();
let dockerfile = NamedTempFile::new().unwrap();
let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
assert!(!context_builder.is_ignored(".git"));
}
#[test_log::test(tokio::test)]
async fn test_works_without_gitignore() {
let dir = tempdir().unwrap();
let context_path = dir.path().to_path_buf();
let dockerfile = NamedTempFile::new().unwrap();
let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
assert!(!context_builder.is_ignored(".git"));
assert!(!context_builder.is_ignored("Dockerfile"));
fs::File::create(context_path.join("Dockerfile")).unwrap();
assert!(!context_builder.is_ignored("Dockerfile"));
}
}