polykit_core/remote_cache/
artifact.rs1use std::collections::BTreeMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::error::{Error, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ArtifactMetadata {
16 pub package_name: String,
18 pub task_name: String,
20 pub command: String,
22 pub cache_key_hash: String,
24 pub created_at: u64,
26 pub version: u32,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ArtifactManifest {
33 pub files: BTreeMap<PathBuf, String>,
35 pub total_size: u64,
37}
38
39#[derive(Debug)]
46pub struct Artifact {
47 metadata: ArtifactMetadata,
48 manifest: ArtifactManifest,
49 compressed_data: Vec<u8>,
50}
51
52impl Artifact {
53 pub fn new(
67 package_name: String,
68 task_name: String,
69 command: String,
70 cache_key_hash: String,
71 output_files: BTreeMap<PathBuf, Vec<u8>>,
72 ) -> Result<Self> {
73 let created_at = SystemTime::now()
74 .duration_since(UNIX_EPOCH)
75 .map_err(|e| Error::Adapter {
76 package: "artifact".to_string(),
77 message: format!("Failed to get timestamp: {}", e),
78 })?
79 .as_secs();
80
81 let mut files = BTreeMap::new();
82 let mut total_size = 0u64;
83
84 for (path, content) in &output_files {
86 let mut hasher = Sha256::new();
87 hasher.update(content);
88 let hash = format!("{:x}", hasher.finalize());
89 files.insert(path.clone(), hash);
90 total_size += content.len() as u64;
91 }
92
93 let manifest = ArtifactManifest {
94 files,
95 total_size,
96 };
97
98 let metadata = ArtifactMetadata {
99 package_name,
100 task_name,
101 command,
102 cache_key_hash,
103 created_at,
104 version: 1,
105 };
106
107 let mut tar_data = Vec::new();
109 {
110 let mut tar = tar::Builder::new(&mut tar_data);
111
112 let metadata_json = serde_json::to_string(&metadata).map_err(|e| Error::Adapter {
114 package: "artifact".to_string(),
115 message: format!("Failed to serialize metadata: {}", e),
116 })?;
117 let mut metadata_header = tar::Header::new_gnu();
118 metadata_header.set_path("metadata.json").map_err(|e| Error::Adapter {
119 package: "artifact".to_string(),
120 message: format!("Failed to set metadata path: {}", e),
121 })?;
122 metadata_header.set_size(metadata_json.len() as u64);
123 metadata_header.set_cksum();
124 tar.append(&metadata_header, metadata_json.as_bytes())
125 .map_err(|e| Error::Adapter {
126 package: "artifact".to_string(),
127 message: format!("Failed to append metadata: {}", e),
128 })?;
129
130 let manifest_json = serde_json::to_string(&manifest).map_err(|e| Error::Adapter {
132 package: "artifact".to_string(),
133 message: format!("Failed to serialize manifest: {}", e),
134 })?;
135 let mut manifest_header = tar::Header::new_gnu();
136 manifest_header.set_path("manifest.json").map_err(|e| Error::Adapter {
137 package: "artifact".to_string(),
138 message: format!("Failed to set manifest path: {}", e),
139 })?;
140 manifest_header.set_size(manifest_json.len() as u64);
141 manifest_header.set_cksum();
142 tar.append(&manifest_header, manifest_json.as_bytes())
143 .map_err(|e| Error::Adapter {
144 package: "artifact".to_string(),
145 message: format!("Failed to append manifest: {}", e),
146 })?;
147
148 for (path, content) in &output_files {
150 let mut header = tar::Header::new_gnu();
151 let output_path = Path::new("outputs").join(path);
152 header.set_path(&output_path).map_err(|e| Error::Adapter {
153 package: "artifact".to_string(),
154 message: format!("Failed to set output path: {}", e),
155 })?;
156 header.set_size(content.len() as u64);
157 header.set_cksum();
158 tar.append(&header, content.as_slice()).map_err(|e| Error::Adapter {
159 package: "artifact".to_string(),
160 message: format!("Failed to append output file: {}", e),
161 })?;
162 }
163
164 tar.finish().map_err(|e| Error::Adapter {
165 package: "artifact".to_string(),
166 message: format!("Failed to finish tar archive: {}", e),
167 })?;
168 }
169
170 let compressed_data = zstd::encode_all(&tar_data[..], 3).map_err(|e| Error::Adapter {
172 package: "artifact".to_string(),
173 message: format!("Failed to compress artifact: {}", e),
174 })?;
175
176 Ok(Self {
177 metadata,
178 manifest,
179 compressed_data,
180 })
181 }
182
183 pub fn from_compressed(data: Vec<u8>) -> Result<Self> {
189 let tar_data = zstd::decode_all(&data[..]).map_err(|e| Error::Adapter {
191 package: "artifact".to_string(),
192 message: format!("Failed to decompress artifact: {}", e),
193 })?;
194
195 let mut archive = tar::Archive::new(&tar_data[..]);
197 let mut metadata: Option<ArtifactMetadata> = None;
198 let mut manifest: Option<ArtifactManifest> = None;
199
200 for entry_result in archive.entries().map_err(|e| Error::Adapter {
201 package: "artifact".to_string(),
202 message: format!("Failed to read tar archive: {}", e),
203 })? {
204 let mut entry = entry_result.map_err(|e| Error::Adapter {
205 package: "artifact".to_string(),
206 message: format!("Failed to read tar entry: {}", e),
207 })?;
208
209 let path = entry.path().map_err(|e| Error::Adapter {
210 package: "artifact".to_string(),
211 message: format!("Failed to get entry path: {}", e),
212 })?;
213
214 if path == Path::new("metadata.json") {
215 let mut content = String::new();
216 entry.read_to_string(&mut content).map_err(|e| Error::Adapter {
217 package: "artifact".to_string(),
218 message: format!("Failed to read metadata: {}", e),
219 })?;
220 metadata = Some(serde_json::from_str(&content).map_err(|e| Error::Adapter {
221 package: "artifact".to_string(),
222 message: format!("Failed to parse metadata: {}", e),
223 })?);
224 } else if path == Path::new("manifest.json") {
225 let mut content = String::new();
226 entry.read_to_string(&mut content).map_err(|e| Error::Adapter {
227 package: "artifact".to_string(),
228 message: format!("Failed to read manifest: {}", e),
229 })?;
230 manifest = Some(serde_json::from_str(&content).map_err(|e| Error::Adapter {
231 package: "artifact".to_string(),
232 message: format!("Failed to parse manifest: {}", e),
233 })?);
234 }
235 }
236
237 let metadata = metadata.ok_or_else(|| Error::Adapter {
238 package: "artifact".to_string(),
239 message: "Missing metadata.json in artifact".to_string(),
240 })?;
241
242 let manifest = manifest.ok_or_else(|| Error::Adapter {
243 package: "artifact".to_string(),
244 message: "Missing manifest.json in artifact".to_string(),
245 })?;
246
247 Ok(Self {
248 metadata,
249 manifest,
250 compressed_data: data,
251 })
252 }
253
254 pub fn metadata(&self) -> &ArtifactMetadata {
256 &self.metadata
257 }
258
259 pub fn manifest(&self) -> &ArtifactManifest {
261 &self.manifest
262 }
263
264 pub fn compressed_data(&self) -> &[u8] {
266 &self.compressed_data
267 }
268
269 pub fn extract_outputs(&self, output_dir: &Path) -> Result<()> {
275 use std::fs;
276
277 let tar_data = zstd::decode_all(&self.compressed_data[..]).map_err(|e| Error::Adapter {
279 package: "artifact".to_string(),
280 message: format!("Failed to decompress artifact: {}", e),
281 })?;
282
283 let mut archive = tar::Archive::new(&tar_data[..]);
285 let outputs_dir = Path::new("outputs");
286
287 for entry_result in archive.entries().map_err(|e| Error::Adapter {
288 package: "artifact".to_string(),
289 message: format!("Failed to read tar archive: {}", e),
290 })? {
291 let mut entry = entry_result.map_err(|e| Error::Adapter {
292 package: "artifact".to_string(),
293 message: format!("Failed to read tar entry: {}", e),
294 })?;
295
296 let path = entry.path().map_err(|e| Error::Adapter {
297 package: "artifact".to_string(),
298 message: format!("Failed to get entry path: {}", e),
299 })?;
300
301 if path == Path::new("metadata.json") || path == Path::new("manifest.json") {
303 continue;
304 }
305
306 if let Ok(relative_path) = path.strip_prefix(outputs_dir) {
308 let dest_path = output_dir.join(relative_path);
309 if let Some(parent) = dest_path.parent() {
310 fs::create_dir_all(parent).map_err(Error::Io)?;
311 }
312 entry.unpack(&dest_path).map_err(|e| Error::Adapter {
313 package: "artifact".to_string(),
314 message: format!("Failed to extract file: {}", e),
315 })?;
316 }
317 }
318
319 Ok(())
320 }
321
322 pub fn hash(&self) -> String {
324 let mut hasher = Sha256::new();
325 hasher.update(&self.compressed_data);
326 format!("{:x}", hasher.finalize())
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_artifact_creation_and_extraction() {
336 let mut output_files = BTreeMap::new();
337 output_files.insert(PathBuf::from("file1.txt"), b"content1".to_vec());
338 output_files.insert(PathBuf::from("subdir/file2.txt"), b"content2".to_vec());
339
340 let artifact = Artifact::new(
341 "test-package".to_string(),
342 "build".to_string(),
343 "echo test".to_string(),
344 "abc123".to_string(),
345 output_files,
346 )
347 .unwrap();
348
349 assert_eq!(artifact.metadata().package_name, "test-package");
350 assert_eq!(artifact.metadata().task_name, "build");
351 assert_eq!(artifact.manifest().files.len(), 2);
352
353 let compressed = artifact.compressed_data().to_vec();
355 let artifact2 = Artifact::from_compressed(compressed).unwrap();
356
357 assert_eq!(artifact.metadata().package_name, artifact2.metadata().package_name);
358 assert_eq!(artifact.metadata().task_name, artifact2.metadata().task_name);
359 assert_eq!(artifact.manifest().files.len(), artifact2.manifest().files.len());
360 }
361}