swiftide_docker_executor/
context_builder.rs1use std::{
2 os::unix::fs::MetadataExt as _,
3 path::{Path, PathBuf},
4};
5
6use ignore::gitignore::{Gitignore, GitignoreBuilder};
7use tokio::io::AsyncReadExt as _;
9use tokio_tar::{Builder, EntryType, Header};
10use walkdir::{DirEntry, WalkDir};
11
12use crate::ContextError;
13
14type ContextArchive = Vec<u8>;
15
16#[derive(Debug)]
17pub struct ContextBuilder {
18 context_path: PathBuf,
19 ignore: Gitignore,
20 dockerfile: PathBuf,
21 global: Option<Gitignore>,
22}
23
24impl ContextBuilder {
25 pub fn from_path(
26 context_path: impl Into<PathBuf>,
27 dockerfile: impl AsRef<Path>,
28 ) -> Result<Self, ContextError> {
29 let path = context_path.into();
30 let mut gitignore = GitignoreBuilder::new(&path);
31
32 if let Some(err) = gitignore.add(path.join(".gitignore")) {
33 tracing::warn!(?err, "Error adding .gitignore");
34 }
35 if let Some(err) = gitignore.add(path.join(".dockerignore")) {
36 tracing::warn!(?err, "Error adding .dockerignore");
37 }
38
39 let gitignore = gitignore.build()?;
40
41 let (global_gitignore, maybe_error) = Gitignore::global();
42 let maybe_global = if let Some(err) = maybe_error {
43 tracing::warn!(?err, "Error adding global gitignore");
44 None
45 } else {
46 Some(global_gitignore)
47 };
48
49 Ok(Self {
50 dockerfile: dockerfile.as_ref().to_path_buf(),
51 context_path: path,
52 ignore: gitignore,
53 global: maybe_global,
54 })
55 }
56
57 fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
58 let Ok(relative_path) = path.as_ref().strip_prefix(&self.context_path) else {
59 tracing::debug!(
60 "not ignoring {path} as it seems to be not prefixed by {prefix}",
61 path = path.as_ref().display(),
62 prefix = self.context_path.to_string_lossy()
63 );
64 return false;
65 };
66
67 if relative_path.starts_with(".git") {
68 tracing::debug!(
69 "not ignoring {path} as it seems to be a git file",
70 path = path.as_ref().display()
71 );
72 return false;
73 }
74
75 if let Some(global) = &self.global {
76 if global
77 .matched_path_or_any_parents(relative_path, false)
78 .is_ignore()
79 {
80 tracing::debug!(
81 "ignoring {path} as it is ignored by global gitignore",
82 path = path.as_ref().display()
83 );
84 return true;
85 }
86 }
87
88 self.ignore
89 .matched_path_or_any_parents(relative_path, false)
90 .is_ignore()
91 }
92
93 fn iter(&self) -> impl Iterator<Item = Result<DirEntry, walkdir::Error>> {
94 WalkDir::new(&self.context_path).into_iter()
95 }
96
97 pub async fn build_tar(&self) -> Result<ContextArchive, ContextError> {
98 let buffer = Vec::new();
99
100 let mut tar = Builder::new(buffer);
101
102 let mut file = fs_err::tokio::File::open(&self.dockerfile).await?;
104 let mut buffer_content = Vec::new();
105 file.read_to_end(&mut buffer_content).await?;
106
107 let mut header = Header::new_gnu();
109 header.set_size(buffer_content.len() as u64);
110 header.set_mode(0o644);
111 header.set_cksum();
112
113 tar.append_data(
115 &mut header,
116 self.dockerfile
117 .file_name()
118 .expect("Infallible; No file name"),
119 &*buffer_content,
120 )
121 .await?;
122
123 for entry in self.iter() {
124 let Ok(entry) = entry else {
125 tracing::warn!(?entry, "Failed to read entry");
126 continue;
127 };
128 let path = entry.path();
129
130 let Ok(relative_path) = path.strip_prefix(&self.context_path) else {
131 tracing::warn!(?path, "Failed to strip prefix on path");
132 continue;
133 };
134
135 if path.is_dir() && !path.is_symlink() {
136 tracing::debug!(path = ?path, relative_path = ?relative_path, "Adding directory to tar");
137 if let Err(err) = tar.append_path(relative_path).await {
138 tracing::warn!(?err, "Failed to append path to tar");
139 }
140 continue;
141 }
142
143 if self.is_ignored(path) {
144 tracing::debug!(path = ?path, "Ignored file");
145 continue;
146 }
147
148 if path.is_symlink() {
149 tracing::debug!(path = ?path, "Adding symlink to tar");
150 let Ok(link_target) = tokio::fs::read_link(path).await else {
151 continue;
152 }; let Ok(metadata) = entry.metadata() else {
154 continue;
155 };
156 tracing::debug!(link_target = ?link_target, "Symlink target");
157 let mut header = Header::new_gnu();
158
159 header.set_entry_type(EntryType::Symlink);
161 if let Err(error) = header.set_link_name(&link_target) {
163 tracing::warn!(?error, "Failed to set link name on {link_target:#?}");
164 continue;
165 }
166
167 header.set_uid(metadata.uid() as u64);
169 header.set_gid(metadata.gid() as u64);
170 header.set_mode(metadata.mode());
173 header.set_mtime(metadata.mtime() as u64);
175 header.set_size(0);
177
178 if let Err(error) = tar.append_data(&mut header, path, tokio::io::empty()).await {
179 tracing::warn!(
180 ?error,
181 "Failed to append symlink to tar on {link_target:#?}"
182 );
183 continue;
184 }
185 continue;
186 }
187
188 tracing::debug!(path = ?path, "Adding file to tar");
189 let mut file = fs_err::tokio::File::open(path).await?;
190 let mut buffer_content = Vec::new();
191 file.read_to_end(&mut buffer_content).await?;
192
193 let mut header = Header::new_gnu();
194 header.set_size(buffer_content.len() as u64);
195 header.set_mode(0o644);
196 header.set_cksum();
197
198 let relative_path = path.strip_prefix(&self.context_path)?;
199 tar.append_data(&mut header, relative_path, &*buffer_content)
200 .await?;
201 }
202
203 let result = tar.into_inner().await?;
204
205 Ok(result)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use std::fs;
213 use std::io::Write;
214 use tempfile::{tempdir, NamedTempFile};
215
216 #[test_log::test(tokio::test)]
217 async fn test_is_ignored() {
218 let dir = tempdir().unwrap();
219 let context_path = dir.path().to_path_buf();
220
221 let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
223 writeln!(gitignore_file, "*.log").unwrap();
224
225 let mut dockerignore_file = fs::File::create(context_path.join(".dockerignore")).unwrap();
227 writeln!(dockerignore_file, "*.tmp").unwrap();
228
229 dbg!(&std::fs::read_to_string(context_path.join(".gitignore")).unwrap());
230
231 let dockerfile = NamedTempFile::new().unwrap();
232
233 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
234
235 let log_file = context_path.join("test.log");
237 let tmp_file = context_path.join("test.tmp");
238 let txt_file = context_path.join("test.txt");
239
240 fs::File::create(&log_file).unwrap();
241 fs::File::create(&tmp_file).unwrap();
242 fs::File::create(&txt_file).unwrap();
243
244 assert!(context_builder.is_ignored(&log_file));
245 assert!(context_builder.is_ignored(&tmp_file));
246 assert!(!context_builder.is_ignored(&txt_file));
247 }
248
249 #[test_log::test(tokio::test)]
250 async fn test_adds_git_even_if_in_ignore() {
251 let dir = tempdir().unwrap();
252 let context_path = dir.path().to_path_buf();
253
254 let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
256 writeln!(gitignore_file, ".git").unwrap();
257
258 let dockerfile = NamedTempFile::new().unwrap();
259 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
260
261 assert!(!context_builder.is_ignored(".git"));
262 }
263
264 #[test_log::test(tokio::test)]
265 async fn test_works_without_gitignore() {
266 let dir = tempdir().unwrap();
267 let context_path = dir.path().to_path_buf();
268
269 let dockerfile = NamedTempFile::new().unwrap();
272
273 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
274
275 assert!(!context_builder.is_ignored(".git"));
276 assert!(!context_builder.is_ignored("Dockerfile"));
277
278 fs::File::create(context_path.join("Dockerfile")).unwrap();
279
280 assert!(!context_builder.is_ignored("Dockerfile"));
281 }
282}