Skip to main content

yah_qed/
artifact_local.rs

1//! Single-host artifact store for the qed-gha runtime (R594).
2//!
3//! Implements [`yah_qed_gha::ArtifactStore`] for `actions/upload-artifact` /
4//! `actions/download-artifact` by copying into / out of a content-addressed
5//! directory under the workspace (`.qed-artifacts/<name>/`). Restores the
6//! R487-F6 behavior W224 retired, as an injected handler owned by the runner.
7//!
8//! This is the single-host case: uploads and downloads share one workspace, so
9//! a job that uploads binaries and a later job that downloads them just move
10//! files through the on-disk store. The fleet phase swaps in a transport-backed
11//! store so a build-worker on another node can fetch an artifact produced here.
12
13use std::ffi::OsStr;
14use std::path::Path;
15
16use indexmap::IndexMap;
17use yah_qed_gha::{ArtifactCall, ArtifactStore, StepConclusion, ToolkitOutcome, Value};
18
19/// Directory (workspace-relative) the artifacts live under.
20const STORE_DIR: &str = ".qed-artifacts";
21
22/// [`ArtifactStore`] backed by a workspace-local directory. Cheap to clone; no
23/// state beyond the store-dir convention.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct LocalArtifactStore;
26
27impl LocalArtifactStore {
28    pub fn new() -> Self {
29        Self
30    }
31}
32
33impl ArtifactStore for LocalArtifactStore {
34    /// `actions/upload-artifact` — copy each `with.path` line into
35    /// `.qed-artifacts/<name>/`. A missing source path is a step failure (not a
36    /// runtime error) so the producing job fails alone and consumers skip via
37    /// the needs-gate.
38    fn upload(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
39        let with = call.with;
40        let name = with_string(with, "name")
41            .ok_or_else(|| "actions/upload-artifact: missing `name`".to_string())?;
42        let path_input = with_string(with, "path")
43            .ok_or_else(|| "actions/upload-artifact: missing `path`".to_string())?;
44
45        let dest_root = call.workspace.join(STORE_DIR).join(&name);
46        if dest_root.exists() {
47            std::fs::remove_dir_all(&dest_root)
48                .map_err(|e| format!("clean {}: {e}", dest_root.display()))?;
49        }
50        std::fs::create_dir_all(&dest_root)
51            .map_err(|e| format!("mkdir {}: {e}", dest_root.display()))?;
52
53        let mut count = 0usize;
54        for raw in path_input.lines() {
55            let p = raw.trim();
56            if p.is_empty() {
57                continue;
58            }
59            let src = call.workspace.join(p);
60            if !src.exists() {
61                return Ok(failure(format!(
62                    "actions/upload-artifact: path `{p}` does not exist (artifact `{name}`); \
63                     the step that was supposed to produce it did not run or produced nothing.",
64                )));
65            }
66            let dst = dest_root.join(src.file_name().unwrap_or_else(|| OsStr::new("artifact")));
67            copy_tree(&src, &dst).map_err(|e| format!("copy {p}: {e}"))?;
68            count += 1;
69        }
70
71        let mut outputs = IndexMap::new();
72        outputs.insert("artifact-id".into(), Value::String(name.clone()));
73        outputs.insert(
74            "artifact-url".into(),
75            Value::String(format!("qed-artifact://{name}")),
76        );
77        Ok(ToolkitOutcome {
78            outputs,
79            log: format!("actions/upload-artifact: stored {count} path(s) under `{name}`"),
80            conclusion: StepConclusion::Success,
81        })
82    }
83
84    /// `actions/download-artifact` — copy from `.qed-artifacts/<name>/` to
85    /// `with.path` (default the workspace). With no `name`, restores every
86    /// stored artifact into a subdir by name. A missing artifact is a step
87    /// failure carrying a diagnostic that lists what *was* produced.
88    fn download(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
89        let with = call.with;
90        let name = with_string(with, "name");
91        let dest = with_string(with, "path").unwrap_or_else(|| ".".into());
92        let dest_dir = call.workspace.join(&dest);
93        std::fs::create_dir_all(&dest_dir)
94            .map_err(|e| format!("mkdir {}: {e}", dest_dir.display()))?;
95
96        let root = call.workspace.join(STORE_DIR);
97        if !root.exists() {
98            let want = name.as_deref().unwrap_or("<all>");
99            return Ok(failure(format!(
100                "actions/download-artifact: cannot fetch artifact `{want}` — the artifact store \
101                 (`{STORE_DIR}/`) is empty; no `actions/upload-artifact` step has run \
102                 successfully yet in this workflow run.",
103            )));
104        }
105
106        let mut count = 0usize;
107        if let Some(name) = name {
108            let src = root.join(&name);
109            if !src.exists() {
110                return Ok(failure(format!(
111                    "actions/download-artifact: artifact `{name}` not found. Artifacts present: \
112                     [{}]. The job whose `actions/upload-artifact` step produces `{name}` did \
113                     not complete successfully.",
114                    list_stored_artifacts(&root).join(", "),
115                )));
116            }
117            copy_tree_contents(&src, &dest_dir).map_err(|e| format!("restore {name}: {e}"))?;
118            count = 1;
119        } else {
120            for entry in std::fs::read_dir(&root).map_err(|e| format!("scan artifacts: {e}"))? {
121                let entry = entry.map_err(|e| format!("scan entry: {e}"))?;
122                let entry_name = entry.file_name();
123                let dst = dest_dir.join(&entry_name);
124                std::fs::create_dir_all(&dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
125                copy_tree_contents(&entry.path(), &dst)
126                    .map_err(|e| format!("restore {}: {e}", entry_name.to_string_lossy()))?;
127                count += 1;
128            }
129        }
130
131        Ok(ToolkitOutcome {
132            outputs: IndexMap::new(),
133            log: format!(
134                "actions/download-artifact: restored {count} artifact(s) into {}",
135                dest_dir.display()
136            ),
137            conclusion: StepConclusion::Success,
138        })
139    }
140}
141
142fn failure(log: String) -> ToolkitOutcome {
143    ToolkitOutcome {
144        outputs: IndexMap::new(),
145        log,
146        conclusion: StepConclusion::Failure,
147    }
148}
149
150fn list_stored_artifacts(root: &Path) -> Vec<String> {
151    let Ok(rd) = std::fs::read_dir(root) else {
152        return Vec::new();
153    };
154    let mut names: Vec<String> = rd
155        .flatten()
156        .filter(|e| e.path().is_dir())
157        .map(|e| e.file_name().to_string_lossy().into_owned())
158        .collect();
159    names.sort();
160    names
161}
162
163fn with_string(with: &IndexMap<String, Value>, key: &str) -> Option<String> {
164    with.get(key).map(|v| v.as_str_lossy())
165}
166
167fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
168    let meta = std::fs::symlink_metadata(src)?;
169    if meta.file_type().is_dir() {
170        std::fs::create_dir_all(dst)?;
171        for entry in std::fs::read_dir(src)? {
172            let entry = entry?;
173            copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
174        }
175        Ok(())
176    } else if meta.file_type().is_symlink() {
177        let target = std::fs::read_link(src)?;
178        #[cfg(unix)]
179        std::os::unix::fs::symlink(&target, dst)?;
180        #[cfg(not(unix))]
181        std::fs::copy(src, dst).map(|_| ())?;
182        Ok(())
183    } else {
184        if let Some(parent) = dst.parent() {
185            std::fs::create_dir_all(parent)?;
186        }
187        std::fs::copy(src, dst).map(|_| ())
188    }
189}
190
191fn copy_tree_contents(src: &Path, dst: &Path) -> std::io::Result<()> {
192    std::fs::create_dir_all(dst)?;
193    for entry in std::fs::read_dir(src)? {
194        let entry = entry?;
195        copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
196    }
197    Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tempfile::TempDir;
204
205    fn with(pairs: &[(&str, &str)]) -> IndexMap<String, Value> {
206        let mut m = IndexMap::new();
207        for (k, v) in pairs {
208            m.insert((*k).into(), Value::String((*v).into()));
209        }
210        m
211    }
212
213    #[test]
214    fn upload_then_download_roundtrips_a_file() {
215        let ws = TempDir::new().unwrap();
216        std::fs::write(ws.path().join("bin"), b"hello").unwrap();
217        let store = LocalArtifactStore::new();
218
219        let up = with(&[("name", "mybin"), ("path", "bin")]);
220        let out = store
221            .upload(&ArtifactCall { with: &up, workspace: ws.path() })
222            .unwrap();
223        assert_eq!(out.conclusion, StepConclusion::Success);
224
225        let dl = with(&[("name", "mybin"), ("path", "out")]);
226        let out = store
227            .download(&ArtifactCall { with: &dl, workspace: ws.path() })
228            .unwrap();
229        assert_eq!(out.conclusion, StepConclusion::Success);
230        let restored = std::fs::read(ws.path().join("out").join("bin")).unwrap();
231        assert_eq!(restored, b"hello");
232    }
233
234    #[test]
235    fn upload_missing_path_is_step_failure_not_error() {
236        let ws = TempDir::new().unwrap();
237        let store = LocalArtifactStore::new();
238        let up = with(&[("name", "x"), ("path", "does-not-exist")]);
239        let out = store
240            .upload(&ArtifactCall { with: &up, workspace: ws.path() })
241            .expect("step failure, not Err");
242        assert_eq!(out.conclusion, StepConclusion::Failure);
243    }
244
245    #[test]
246    fn download_missing_artifact_is_step_failure() {
247        let ws = TempDir::new().unwrap();
248        std::fs::create_dir_all(ws.path().join(STORE_DIR).join("other")).unwrap();
249        let store = LocalArtifactStore::new();
250        let dl = with(&[("name", "absent"), ("path", "out")]);
251        let out = store
252            .download(&ArtifactCall { with: &dl, workspace: ws.path() })
253            .unwrap();
254        assert_eq!(out.conclusion, StepConclusion::Failure);
255        assert!(out.log.contains("other"), "diagnostic lists present artifacts");
256    }
257}