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 && 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 self.ignore
88 .matched_path_or_any_parents(relative_path, false)
89 .is_ignore()
90 }
91
92 fn iter(&self) -> impl Iterator<Item = Result<DirEntry, walkdir::Error>> {
93 WalkDir::new(&self.context_path).into_iter()
94 }
95
96 pub async fn build_tar(&self) -> Result<ContextArchive, ContextError> {
97 let buffer = Vec::new();
98
99 let mut tar = Builder::new(buffer);
100
101 let mut file = fs_err::tokio::File::open(&self.dockerfile).await?;
103 let mut buffer_content = Vec::new();
104 file.read_to_end(&mut buffer_content).await?;
105
106 let mut header = Header::new_gnu();
108 header.set_size(buffer_content.len() as u64);
109 header.set_mode(0o644);
110 header.set_cksum();
111
112 tar.append_data(
114 &mut header,
115 self.dockerfile
116 .file_name()
117 .expect("Infallible; No file name"),
118 &*buffer_content,
119 )
120 .await?;
121
122 for entry in self.iter() {
123 let Ok(entry) = entry else {
124 tracing::warn!(?entry, "Failed to read entry");
125 continue;
126 };
127 let path = entry.path();
128
129 let Ok(relative_path) = path.strip_prefix(&self.context_path) else {
130 tracing::warn!(?path, "Failed to strip prefix on path");
131 continue;
132 };
133
134 if path.is_dir() && !path.is_symlink() {
135 tracing::debug!(path = ?path, relative_path = ?relative_path, "Adding directory to tar");
136 if let Err(err) = tar.append_path(relative_path).await {
137 tracing::warn!(?err, "Failed to append path to tar");
138 }
139 continue;
140 }
141
142 if self.is_ignored(path) {
143 tracing::debug!(path = ?path, "Ignored file");
144 continue;
145 }
146
147 if path.is_symlink() {
148 tracing::debug!(path = ?path, "Adding symlink to tar");
149 let Ok(link_target) = tokio::fs::read_link(path).await else {
150 continue;
151 }; let Ok(metadata) = entry.metadata() else {
153 continue;
154 };
155 tracing::debug!(link_target = ?link_target, "Symlink target");
156 let mut header = Header::new_gnu();
157
158 header.set_entry_type(EntryType::Symlink);
160 if let Err(error) = header.set_link_name(&link_target) {
162 tracing::warn!(?error, "Failed to set link name on {link_target:#?}");
163 continue;
164 }
165
166 header.set_uid(metadata.uid() as u64);
168 header.set_gid(metadata.gid() as u64);
169 header.set_mode(metadata.mode());
172 header.set_mtime(metadata.mtime() as u64);
174 header.set_size(0);
176
177 if let Err(error) = tar.append_data(&mut header, path, tokio::io::empty()).await {
178 tracing::warn!(
179 ?error,
180 "Failed to append symlink to tar on {link_target:#?}"
181 );
182 continue;
183 }
184 continue;
185 }
186
187 tracing::debug!(path = ?path, "Adding file to tar");
188 let mut file = fs_err::tokio::File::open(path).await?;
189 let mut buffer_content = Vec::new();
190 file.read_to_end(&mut buffer_content).await?;
191
192 let mut header = Header::new_gnu();
193 header.set_size(buffer_content.len() as u64);
194 header.set_mode(0o644);
195 header.set_cksum();
196
197 let relative_path = path.strip_prefix(&self.context_path)?;
198 tar.append_data(&mut header, relative_path, &*buffer_content)
199 .await?;
200 }
201
202 let result = tar.into_inner().await?;
203
204 Ok(result)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::fs;
212 use std::io::Write;
213 use tempfile::{NamedTempFile, tempdir};
214
215 #[test_log::test(tokio::test)]
216 async fn test_is_ignored() {
217 let dir = tempdir().unwrap();
218 let context_path = dir.path().to_path_buf();
219
220 let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
222 writeln!(gitignore_file, "*.log").unwrap();
223
224 let mut dockerignore_file = fs::File::create(context_path.join(".dockerignore")).unwrap();
226 writeln!(dockerignore_file, "*.tmp").unwrap();
227
228 dbg!(&std::fs::read_to_string(context_path.join(".gitignore")).unwrap());
229
230 let dockerfile = NamedTempFile::new().unwrap();
231
232 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
233
234 let log_file = context_path.join("test.log");
236 let tmp_file = context_path.join("test.tmp");
237 let txt_file = context_path.join("test.txt");
238
239 fs::File::create(&log_file).unwrap();
240 fs::File::create(&tmp_file).unwrap();
241 fs::File::create(&txt_file).unwrap();
242
243 assert!(context_builder.is_ignored(&log_file));
244 assert!(context_builder.is_ignored(&tmp_file));
245 assert!(!context_builder.is_ignored(&txt_file));
246 }
247
248 #[test_log::test(tokio::test)]
249 async fn test_adds_git_even_if_in_ignore() {
250 let dir = tempdir().unwrap();
251 let context_path = dir.path().to_path_buf();
252
253 let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
255 writeln!(gitignore_file, ".git").unwrap();
256
257 let dockerfile = NamedTempFile::new().unwrap();
258 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
259
260 assert!(!context_builder.is_ignored(".git"));
261 }
262
263 #[test_log::test(tokio::test)]
264 async fn test_works_without_gitignore() {
265 let dir = tempdir().unwrap();
266 let context_path = dir.path().to_path_buf();
267
268 let dockerfile = NamedTempFile::new().unwrap();
271
272 let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
273
274 assert!(!context_builder.is_ignored(".git"));
275 assert!(!context_builder.is_ignored("Dockerfile"));
276
277 fs::File::create(context_path.join("Dockerfile")).unwrap();
278
279 assert!(!context_builder.is_ignored("Dockerfile"));
280 }
281}