1use std::future::Future;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use futures_util::StreamExt;
22use glob::glob;
23use tokio::fs::{File, create_dir_all};
24use tokio::io::AsyncWriteExt;
25use tracing::{info, warn};
26use uuid::Uuid;
27
28use ironflow_artifacts::blob_store::{BlobStore, ByteStream};
29use ironflow_artifacts::error::ArtifactError;
30use ironflow_artifacts::name::{guess_content_type, storage_key, validate_artifact_name};
31use ironflow_artifacts::stream_from_path;
32use ironflow_store::entities::{Artifact, ArtifactLookup, NewArtifact};
33use ironflow_store::store::Store;
34
35use crate::config::ShellConfig;
36use crate::error::EngineError;
37
38pub type ArtifactFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, EngineError>> + Send + 'a>>;
40
41#[derive(Debug, Clone)]
58pub struct ArtifactUpload {
59 pub run_id: Uuid,
61 pub step_id: Uuid,
63 pub name: String,
65 pub content_type: String,
67}
68
69pub trait ArtifactSink: Send + Sync {
99 fn put<'a>(
107 &'a self,
108 upload: ArtifactUpload,
109 content: ByteStream,
110 ) -> ArtifactFuture<'a, Artifact>;
111
112 fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream>;
118}
119
120pub struct DirectArtifactSink {
141 blob: Arc<dyn BlobStore>,
142 store: Arc<dyn Store>,
143}
144
145impl DirectArtifactSink {
146 pub fn new(blob: Arc<dyn BlobStore>, store: Arc<dyn Store>) -> Self {
148 Self { blob, store }
149 }
150}
151
152impl std::fmt::Debug for DirectArtifactSink {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("DirectArtifactSink").finish_non_exhaustive()
155 }
156}
157
158impl ArtifactSink for DirectArtifactSink {
159 fn put<'a>(
160 &'a self,
161 upload: ArtifactUpload,
162 content: ByteStream,
163 ) -> ArtifactFuture<'a, Artifact> {
164 Box::pin(async move {
165 validate_artifact_name(&upload.name)?;
166
167 let id = Uuid::now_v7();
168 let key = storage_key(upload.run_id, upload.step_id, id);
169 let digest = self.blob.put(&key, content).await?;
170
171 let (final_key, dedup_hit) =
174 match self.store.find_artifact_by_sha256(&digest.sha256).await {
175 Ok(Some(existing)) => {
176 if let Err(cleanup) = self.blob.delete(&key).await {
177 warn!(
178 storage_key = %key,
179 error = %cleanup,
180 "failed to remove deduplicated blob"
181 );
182 }
183 info!(
184 sha256 = %digest.sha256,
185 reused_key = %existing.storage_key,
186 "artifact deduplicated"
187 );
188 (existing.storage_key, true)
189 }
190 _ => (key.clone(), false),
191 };
192
193 let recorded = self
194 .store
195 .create_artifact(NewArtifact {
196 id,
197 run_id: upload.run_id,
198 step_id: upload.step_id,
199 name: upload.name,
200 storage_key: final_key.clone(),
201 content_type: upload.content_type,
202 size_bytes: digest.size_bytes,
203 sha256: digest.sha256,
204 })
205 .await;
206
207 match recorded {
208 Ok(artifact) => Ok(artifact),
209 Err(err) => {
210 if !dedup_hit && let Err(cleanup) = self.blob.delete(&key).await {
211 warn!(
212 storage_key = %key,
213 error = %cleanup,
214 "failed to remove the blob of an unrecorded artifact"
215 );
216 }
217 Err(err.into())
218 }
219 }
220 })
221 }
222
223 fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream> {
224 Box::pin(async move { Ok(self.blob.get(&artifact.storage_key).await?) })
225 }
226}
227
228#[derive(Debug, Clone, Copy)]
230pub(crate) struct StepLocation {
231 pub(crate) run_id: Uuid,
233 pub(crate) attempt: u32,
235 pub(crate) position: u32,
237}
238
239pub(crate) async fn materialize_inputs(
243 sink: &Arc<dyn ArtifactSink>,
244 store: &Arc<dyn Store>,
245 config: &ShellConfig,
246 location: StepLocation,
247) -> Result<(), EngineError> {
248 let work_dir = working_dir(config);
249
250 for input in &config.inputs {
251 let artifact = store
252 .find_artifact_for_input(ArtifactLookup {
253 run_id: location.run_id,
254 attempt: location.attempt,
255 before_position: location.position,
256 step_name: input.step.clone(),
257 name: input.name.clone(),
258 })
259 .await?
260 .ok_or_else(|| EngineError::ArtifactNotFound {
261 step: input.step.clone(),
262 name: input.name.clone(),
263 })?;
264
265 let destination = work_dir.join(input.destination());
266 if let Some(parent) = destination.parent() {
267 create_dir_all(parent).await.map_err(ArtifactError::from)?;
268 }
269
270 let mut content = sink.get(&artifact).await?;
271 let mut file = File::create(&destination)
272 .await
273 .map_err(ArtifactError::from)?;
274 while let Some(chunk) = content.next().await {
275 file.write_all(&chunk?).await.map_err(ArtifactError::from)?;
276 }
277 file.flush().await.map_err(ArtifactError::from)?;
278
279 info!(
280 run_id = %location.run_id,
281 artifact = %input.name,
282 produced_by = %input.step,
283 destination = %destination.display(),
284 "artifact input materialized"
285 );
286 }
287
288 Ok(())
289}
290
291pub(crate) async fn collect_outputs(
298 sink: &Arc<dyn ArtifactSink>,
299 config: &ShellConfig,
300 run_id: Uuid,
301 step_id: Uuid,
302 step_name: &str,
303 step_succeeded: bool,
304) -> Result<(), EngineError> {
305 let work_dir = working_dir(config);
306
307 for output in &config.outputs {
308 let pattern = work_dir.join(&output.pattern);
309 let pattern = pattern.to_str().ok_or_else(|| {
310 EngineError::StepConfig(format!(
311 "output pattern {:?} is not valid UTF-8",
312 output.pattern
313 ))
314 })?;
315
316 let matches = glob(pattern)
317 .map_err(|err| {
318 EngineError::StepConfig(format!(
319 "invalid output pattern {:?}: {err}",
320 output.pattern
321 ))
322 })?
323 .filter_map(Result::ok)
324 .filter(|path| path.is_file())
325 .collect::<Vec<_>>();
326
327 if matches.is_empty() {
328 if step_succeeded {
329 return Err(EngineError::MissingArtifact {
330 step: step_name.to_string(),
331 pattern: output.pattern.clone(),
332 });
333 }
334 warn!(
335 run_id = %run_id,
336 step = %step_name,
337 pattern = %output.pattern,
338 "declared output matched no file on a failed step"
339 );
340 continue;
341 }
342
343 for path in matches {
344 let name = path
345 .file_name()
346 .and_then(|name| name.to_str())
347 .ok_or_else(|| {
348 EngineError::StepConfig(format!(
349 "output file {:?} has no valid UTF-8 name",
350 path.display()
351 ))
352 })?
353 .to_string();
354
355 let content_type = output
356 .content_type
357 .clone()
358 .unwrap_or_else(|| guess_content_type(&name));
359
360 let content = stream_from_path(&path).await?;
361 let artifact = sink
362 .put(
363 ArtifactUpload {
364 run_id,
365 step_id,
366 name: name.clone(),
367 content_type,
368 },
369 content,
370 )
371 .await?;
372
373 info!(
374 run_id = %run_id,
375 step = %step_name,
376 artifact = %artifact.name,
377 size_bytes = artifact.size_bytes,
378 "artifact output stored"
379 );
380 }
381 }
382
383 Ok(())
384}
385
386fn working_dir(config: &ShellConfig) -> PathBuf {
388 PathBuf::from(config.dir.as_deref().unwrap_or("."))
389}
390
391#[cfg(test)]
392mod tests {
393 use std::collections::HashMap;
394
395 use futures_util::TryStreamExt;
396 use ironflow_artifacts::local::LocalBlobStore;
397 use ironflow_artifacts::stream_from_bytes;
398 use ironflow_store::entities::{NewRun, NewStep, StepKind, TriggerKind, step_trace_id};
399 use ironflow_store::memory::InMemoryStore;
400 use serde_json::json;
401 use tempfile::TempDir;
402
403 use super::*;
404
405 fn count_files_recursive(dir: &std::path::Path) -> usize {
406 let mut count = 0;
407 if let Ok(entries) = std::fs::read_dir(dir) {
408 for entry in entries.flatten() {
409 let path = entry.path();
410 if path.is_dir() {
411 count += count_files_recursive(&path);
412 } else if path.is_file() {
413 count += 1;
414 }
415 }
416 }
417 count
418 }
419
420 async fn sink_with_step() -> (TempDir, DirectArtifactSink, Uuid, Uuid) {
421 let dir = TempDir::new().expect("temp dir");
422 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
423 let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
424
425 let run = store
426 .create_run(NewRun {
427 workflow_name: "artifacts".to_string(),
428 trigger: TriggerKind::Manual,
429 payload: json!({}),
430 max_retries: 0,
431 handler_version: None,
432 labels: HashMap::new(),
433 scheduled_at: None,
434 created_by: None,
435 idempotency_key: None,
436 max_cost_usd: None,
437 })
438 .await
439 .expect("create run")
440 .into_run();
441
442 let step = store
443 .create_step(NewStep {
444 run_id: run.id,
445 trace_id: step_trace_id(run.id, "build", 0),
446 name: "build".to_string(),
447 kind: StepKind::Shell,
448 position: 0,
449 input: None,
450 is_error_handler: false,
451 })
452 .await
453 .expect("create step");
454
455 let sink = DirectArtifactSink::new(blob, store);
456 (dir, sink, run.id, step.id)
457 }
458
459 fn upload(run_id: Uuid, step_id: Uuid, name: &str) -> ArtifactUpload {
460 ArtifactUpload {
461 run_id,
462 step_id,
463 name: name.to_string(),
464 content_type: "text/plain".to_string(),
465 }
466 }
467
468 #[tokio::test]
469 async fn put_records_size_and_hash() {
470 let (_dir, sink, run_id, step_id) = sink_with_step().await;
471
472 let artifact = sink
473 .put(
474 upload(run_id, step_id, "report.txt"),
475 stream_from_bytes(b"abc".to_vec()),
476 )
477 .await
478 .expect("put");
479
480 assert_eq!(artifact.size_bytes, 3);
481 assert_eq!(
482 artifact.sha256,
483 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
484 );
485 }
486
487 #[tokio::test]
488 async fn put_then_get_roundtrips_the_bytes() {
489 let (_dir, sink, run_id, step_id) = sink_with_step().await;
490
491 let artifact = sink
492 .put(
493 upload(run_id, step_id, "report.txt"),
494 stream_from_bytes(b"hello".to_vec()),
495 )
496 .await
497 .expect("put");
498
499 let chunks: Vec<bytes::Bytes> = sink
500 .get(&artifact)
501 .await
502 .expect("get")
503 .try_collect()
504 .await
505 .expect("collect");
506
507 assert_eq!(chunks.concat(), b"hello");
508 }
509
510 #[tokio::test]
511 async fn the_storage_key_never_embeds_the_name() {
512 let (_dir, sink, run_id, step_id) = sink_with_step().await;
513
514 let artifact = sink
515 .put(
516 upload(run_id, step_id, "report.txt"),
517 stream_from_bytes(b"x".to_vec()),
518 )
519 .await
520 .expect("put");
521
522 assert!(!artifact.storage_key.contains("report"));
523 assert!(artifact.storage_key.ends_with(&artifact.id.to_string()));
524 }
525
526 #[tokio::test]
527 async fn an_invalid_name_is_rejected_before_anything_is_written() {
528 let (dir, sink, run_id, step_id) = sink_with_step().await;
529
530 let err = sink
531 .put(
532 upload(run_id, step_id, "../escape"),
533 stream_from_bytes(b"x".to_vec()),
534 )
535 .await
536 .expect_err("invalid name");
537
538 assert!(matches!(err, EngineError::Artifact(_)));
539 assert!(!dir.path().join("artifacts").exists());
540 }
541
542 #[tokio::test]
543 async fn dedup_same_sha256_reuses_storage_key() {
544 let (dir, sink, run_id, step_id) = sink_with_step().await;
545
546 let second_step_id = sink
547 .store
548 .create_step(NewStep {
549 run_id,
550 trace_id: step_trace_id(run_id, "test", 1),
551 name: "test".to_string(),
552 kind: StepKind::Shell,
553 position: 1,
554 input: None,
555 is_error_handler: false,
556 })
557 .await
558 .expect("create step")
559 .id;
560
561 let first = sink
562 .put(
563 upload(run_id, step_id, "report.txt"),
564 stream_from_bytes(b"identical content".to_vec()),
565 )
566 .await
567 .expect("first put");
568
569 let second = sink
570 .put(
571 upload(run_id, second_step_id, "report-copy.txt"),
572 stream_from_bytes(b"identical content".to_vec()),
573 )
574 .await
575 .expect("second put");
576
577 assert_eq!(first.sha256, second.sha256);
578 assert_eq!(
579 first.storage_key, second.storage_key,
580 "dedup should reuse the same storage_key"
581 );
582
583 let file_count = count_files_recursive(dir.path());
585 assert_eq!(file_count, 1, "dedup should not store a second copy");
586 }
587
588 #[tokio::test]
589 async fn dedup_different_sha256_gets_separate_storage_key() {
590 let (_dir, sink, run_id, step_id) = sink_with_step().await;
591
592 let second_step_id = sink
593 .store
594 .create_step(NewStep {
595 run_id,
596 trace_id: step_trace_id(run_id, "test", 1),
597 name: "test".to_string(),
598 kind: StepKind::Shell,
599 position: 1,
600 input: None,
601 is_error_handler: false,
602 })
603 .await
604 .expect("create step")
605 .id;
606
607 let first = sink
608 .put(
609 upload(run_id, step_id, "a.txt"),
610 stream_from_bytes(b"content A".to_vec()),
611 )
612 .await
613 .expect("first put");
614
615 let second = sink
616 .put(
617 upload(run_id, second_step_id, "b.txt"),
618 stream_from_bytes(b"content B".to_vec()),
619 )
620 .await
621 .expect("second put");
622
623 assert_ne!(first.sha256, second.sha256);
624 assert_ne!(
625 first.storage_key, second.storage_key,
626 "different content should get different storage keys"
627 );
628 }
629
630 #[tokio::test]
631 async fn a_duplicate_name_fails_and_leaves_no_orphan_blob() {
632 let (dir, sink, run_id, step_id) = sink_with_step().await;
633
634 sink.put(
635 upload(run_id, step_id, "report.txt"),
636 stream_from_bytes(b"first".to_vec()),
637 )
638 .await
639 .expect("first");
640
641 let err = sink
642 .put(
643 upload(run_id, step_id, "report.txt"),
644 stream_from_bytes(b"second".to_vec()),
645 )
646 .await
647 .expect_err("duplicate");
648
649 assert!(matches!(err, EngineError::Store(_)));
650
651 let stored: Vec<_> = std::fs::read_dir(
652 dir.path()
653 .join("artifacts")
654 .join(run_id.to_string())
655 .join(step_id.to_string()),
656 )
657 .expect("read dir")
658 .filter_map(Result::ok)
659 .collect();
660 assert_eq!(stored.len(), 1, "the rejected blob was not cleaned up");
661 }
662}