use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use futures_util::StreamExt;
use glob::glob;
use tokio::fs::{File, create_dir_all};
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use uuid::Uuid;
use ironflow_artifacts::blob_store::{BlobStore, ByteStream};
use ironflow_artifacts::error::ArtifactError;
use ironflow_artifacts::name::{guess_content_type, storage_key, validate_artifact_name};
use ironflow_artifacts::stream_from_path;
use ironflow_store::entities::{Artifact, ArtifactLookup, NewArtifact};
use ironflow_store::store::Store;
use crate::config::ShellConfig;
use crate::error::EngineError;
pub type ArtifactFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, EngineError>> + Send + 'a>>;
#[derive(Debug, Clone)]
pub struct ArtifactUpload {
pub run_id: Uuid,
pub step_id: Uuid,
pub name: String,
pub content_type: String,
}
pub trait ArtifactSink: Send + Sync {
fn put<'a>(
&'a self,
upload: ArtifactUpload,
content: ByteStream,
) -> ArtifactFuture<'a, Artifact>;
fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream>;
}
pub struct DirectArtifactSink {
blob: Arc<dyn BlobStore>,
store: Arc<dyn Store>,
}
impl DirectArtifactSink {
pub fn new(blob: Arc<dyn BlobStore>, store: Arc<dyn Store>) -> Self {
Self { blob, store }
}
}
impl std::fmt::Debug for DirectArtifactSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DirectArtifactSink").finish_non_exhaustive()
}
}
impl ArtifactSink for DirectArtifactSink {
fn put<'a>(
&'a self,
upload: ArtifactUpload,
content: ByteStream,
) -> ArtifactFuture<'a, Artifact> {
Box::pin(async move {
validate_artifact_name(&upload.name)?;
let id = Uuid::now_v7();
let key = storage_key(upload.run_id, upload.step_id, id);
let digest = self.blob.put(&key, content).await?;
let recorded = self
.store
.create_artifact(NewArtifact {
id,
run_id: upload.run_id,
step_id: upload.step_id,
name: upload.name,
storage_key: key.clone(),
content_type: upload.content_type,
size_bytes: digest.size_bytes,
sha256: digest.sha256,
})
.await;
match recorded {
Ok(artifact) => Ok(artifact),
Err(err) => {
if let Err(cleanup) = self.blob.delete(&key).await {
warn!(
storage_key = %key,
error = %cleanup,
"failed to remove the blob of an unrecorded artifact"
);
}
Err(err.into())
}
}
})
}
fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream> {
Box::pin(async move { Ok(self.blob.get(&artifact.storage_key).await?) })
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct StepLocation {
pub(crate) run_id: Uuid,
pub(crate) attempt: u32,
pub(crate) position: u32,
}
pub(crate) async fn materialize_inputs(
sink: &Arc<dyn ArtifactSink>,
store: &Arc<dyn Store>,
config: &ShellConfig,
location: StepLocation,
) -> Result<(), EngineError> {
let work_dir = working_dir(config);
for input in &config.inputs {
let artifact = store
.find_artifact_for_input(ArtifactLookup {
run_id: location.run_id,
attempt: location.attempt,
before_position: location.position,
step_name: input.step.clone(),
name: input.name.clone(),
})
.await?
.ok_or_else(|| EngineError::ArtifactNotFound {
step: input.step.clone(),
name: input.name.clone(),
})?;
let destination = work_dir.join(input.destination());
if let Some(parent) = destination.parent() {
create_dir_all(parent).await.map_err(ArtifactError::from)?;
}
let mut content = sink.get(&artifact).await?;
let mut file = File::create(&destination)
.await
.map_err(ArtifactError::from)?;
while let Some(chunk) = content.next().await {
file.write_all(&chunk?).await.map_err(ArtifactError::from)?;
}
file.flush().await.map_err(ArtifactError::from)?;
info!(
run_id = %location.run_id,
artifact = %input.name,
produced_by = %input.step,
destination = %destination.display(),
"artifact input materialized"
);
}
Ok(())
}
pub(crate) async fn collect_outputs(
sink: &Arc<dyn ArtifactSink>,
config: &ShellConfig,
run_id: Uuid,
step_id: Uuid,
step_name: &str,
step_succeeded: bool,
) -> Result<(), EngineError> {
let work_dir = working_dir(config);
for output in &config.outputs {
let pattern = work_dir.join(&output.pattern);
let pattern = pattern.to_str().ok_or_else(|| {
EngineError::StepConfig(format!(
"output pattern {:?} is not valid UTF-8",
output.pattern
))
})?;
let matches = glob(pattern)
.map_err(|err| {
EngineError::StepConfig(format!(
"invalid output pattern {:?}: {err}",
output.pattern
))
})?
.filter_map(Result::ok)
.filter(|path| path.is_file())
.collect::<Vec<_>>();
if matches.is_empty() {
if step_succeeded {
return Err(EngineError::MissingArtifact {
step: step_name.to_string(),
pattern: output.pattern.clone(),
});
}
warn!(
run_id = %run_id,
step = %step_name,
pattern = %output.pattern,
"declared output matched no file on a failed step"
);
continue;
}
for path in matches {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
EngineError::StepConfig(format!(
"output file {:?} has no valid UTF-8 name",
path.display()
))
})?
.to_string();
let content_type = output
.content_type
.clone()
.unwrap_or_else(|| guess_content_type(&name));
let content = stream_from_path(&path).await?;
let artifact = sink
.put(
ArtifactUpload {
run_id,
step_id,
name: name.clone(),
content_type,
},
content,
)
.await?;
info!(
run_id = %run_id,
step = %step_name,
artifact = %artifact.name,
size_bytes = artifact.size_bytes,
"artifact output stored"
);
}
}
Ok(())
}
fn working_dir(config: &ShellConfig) -> PathBuf {
PathBuf::from(config.dir.as_deref().unwrap_or("."))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use futures_util::TryStreamExt;
use ironflow_artifacts::local::LocalBlobStore;
use ironflow_artifacts::stream_from_bytes;
use ironflow_store::entities::{NewRun, NewStep, StepKind, TriggerKind};
use ironflow_store::memory::InMemoryStore;
use serde_json::json;
use tempfile::TempDir;
use super::*;
async fn sink_with_step() -> (TempDir, DirectArtifactSink, Uuid, Uuid) {
let dir = TempDir::new().expect("temp dir");
let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
let run = store
.create_run(NewRun {
workflow_name: "artifacts".to_string(),
trigger: TriggerKind::Manual,
payload: json!({}),
max_retries: 0,
handler_version: None,
labels: HashMap::new(),
scheduled_at: None,
created_by: None,
idempotency_key: None,
max_cost_usd: None,
})
.await
.expect("create run")
.into_run();
let step = store
.create_step(NewStep {
run_id: run.id,
name: "build".to_string(),
kind: StepKind::Shell,
position: 0,
input: None,
is_error_handler: false,
})
.await
.expect("create step");
let sink = DirectArtifactSink::new(blob, store);
(dir, sink, run.id, step.id)
}
fn upload(run_id: Uuid, step_id: Uuid, name: &str) -> ArtifactUpload {
ArtifactUpload {
run_id,
step_id,
name: name.to_string(),
content_type: "text/plain".to_string(),
}
}
#[tokio::test]
async fn put_records_size_and_hash() {
let (_dir, sink, run_id, step_id) = sink_with_step().await;
let artifact = sink
.put(
upload(run_id, step_id, "report.txt"),
stream_from_bytes(b"abc".to_vec()),
)
.await
.expect("put");
assert_eq!(artifact.size_bytes, 3);
assert_eq!(
artifact.sha256,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[tokio::test]
async fn put_then_get_roundtrips_the_bytes() {
let (_dir, sink, run_id, step_id) = sink_with_step().await;
let artifact = sink
.put(
upload(run_id, step_id, "report.txt"),
stream_from_bytes(b"hello".to_vec()),
)
.await
.expect("put");
let chunks: Vec<bytes::Bytes> = sink
.get(&artifact)
.await
.expect("get")
.try_collect()
.await
.expect("collect");
assert_eq!(chunks.concat(), b"hello");
}
#[tokio::test]
async fn the_storage_key_never_embeds_the_name() {
let (_dir, sink, run_id, step_id) = sink_with_step().await;
let artifact = sink
.put(
upload(run_id, step_id, "report.txt"),
stream_from_bytes(b"x".to_vec()),
)
.await
.expect("put");
assert!(!artifact.storage_key.contains("report"));
assert!(artifact.storage_key.ends_with(&artifact.id.to_string()));
}
#[tokio::test]
async fn an_invalid_name_is_rejected_before_anything_is_written() {
let (dir, sink, run_id, step_id) = sink_with_step().await;
let err = sink
.put(
upload(run_id, step_id, "../escape"),
stream_from_bytes(b"x".to_vec()),
)
.await
.expect_err("invalid name");
assert!(matches!(err, EngineError::Artifact(_)));
assert!(!dir.path().join("artifacts").exists());
}
#[tokio::test]
async fn a_duplicate_name_fails_and_leaves_no_orphan_blob() {
let (dir, sink, run_id, step_id) = sink_with_step().await;
sink.put(
upload(run_id, step_id, "report.txt"),
stream_from_bytes(b"first".to_vec()),
)
.await
.expect("first");
let err = sink
.put(
upload(run_id, step_id, "report.txt"),
stream_from_bytes(b"second".to_vec()),
)
.await
.expect_err("duplicate");
assert!(matches!(err, EngineError::Store(_)));
let stored: Vec<_> = std::fs::read_dir(
dir.path()
.join("artifacts")
.join(run_id.to_string())
.join(step_id.to_string()),
)
.expect("read dir")
.filter_map(Result::ok)
.collect();
assert_eq!(stored.len(), 1, "the rejected blob was not cleaned up");
}
}