use reqwest::header::{HeaderValue, AUTHORIZATION};
use reqwest::multipart;
use std::path::{Path, PathBuf};
use crate::runner::RunnerConfig;
#[derive(Debug, Clone)]
pub struct ArtifactSpec {
pub name: String,
pub path: String,
}
const MAX_ARTIFACT_SIZE: u64 = 100 * 1024 * 1024;
pub fn parse_specs(job: &serde_json::Value) -> Vec<ArtifactSpec> {
let Some(specs) = job.get("artifact_specs").and_then(|v| v.as_array()) else {
return Vec::new();
};
specs
.iter()
.filter_map(|spec| {
let name = spec.get("name")?.as_str()?.to_string();
let path = spec.get("path")?.as_str()?.to_string();
Some(ArtifactSpec { name, path })
})
.collect()
}
pub async fn upload_artifacts(
config: &RunnerConfig,
job_id: &str,
workspace: &Path,
specs: &[ArtifactSpec],
) -> u32 {
if specs.is_empty() {
return 0;
}
let client = reqwest::Client::new();
let mut uploaded = 0u32;
for spec in specs {
let files = resolve_paths(workspace, &spec.path);
if files.is_empty() {
eprintln!(
" [artifacts] Warning: no files matched '{}' for artifact '{}'",
spec.path, spec.name
);
continue;
}
for file_path in &files {
let artifact_name = if files.len() > 1 {
let rel = file_path
.strip_prefix(workspace)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
format!("{}/{}", spec.name, rel)
} else {
spec.name.clone()
};
match upload_single(&client, config, job_id, file_path, &artifact_name).await {
Ok(()) => {
eprintln!(" [artifacts] Uploaded '{}'", artifact_name);
uploaded += 1;
}
Err(e) => {
eprintln!(" [artifacts] Failed to upload '{}': {}", artifact_name, e);
}
}
}
}
uploaded
}
fn resolve_paths(workspace: &Path, pattern: &str) -> Vec<PathBuf> {
let full_pattern = workspace.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
if pattern_str.contains('*') || pattern_str.contains('?') || pattern_str.contains('[') {
match glob::glob(&pattern_str) {
Ok(paths) => paths
.filter_map(Result::ok)
.filter(|p| p.is_file())
.collect(),
Err(e) => {
eprintln!(" [artifacts] Invalid glob pattern '{}': {}", pattern, e);
Vec::new()
}
}
} else {
let path = workspace.join(pattern);
if path.is_file() {
vec![path]
} else {
Vec::new()
}
}
}
async fn upload_single(
client: &reqwest::Client,
config: &RunnerConfig,
job_id: &str,
file_path: &Path,
artifact_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let metadata = std::fs::metadata(file_path)?;
if metadata.len() > MAX_ARTIFACT_SIZE {
return Err(format!(
"file too large ({} bytes, max {} bytes)",
metadata.len(),
MAX_ARTIFACT_SIZE
)
.into());
}
let file_bytes = std::fs::read(file_path)?;
let url = config.api_url(&format!("/runners/jobs/{job_id}/artifacts"));
let file_part = multipart::Part::bytes(file_bytes)
.file_name(artifact_name.to_string())
.mime_str("application/octet-stream")?;
let form = multipart::Form::new()
.text("name", artifact_name.to_string())
.part("file", file_part);
let resp = client
.post(&url)
.header(AUTHORIZATION, HeaderValue::from_str(&config.auth_header())?)
.multipart(form)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("upload failed ({status}): {body}").into())
}
}