feat(artifacts): finish object-backed artifact uploads

Add scoped worker upload tokens and HTTP artifact upload clients.

Support manifest-first multipart stage artifact uploads with validation and checksums.

Gate artifact reads by run capability while preserving legacy scratch fallback.
This commit is contained in:
Bryan Helmkamp 2026-04-07 16:56:37 -04:00
parent 27cc8c75eb
commit 494a7fe1cc
53 changed files with 1953 additions and 259 deletions

20
Cargo.lock generated
View file

@ -1587,6 +1587,7 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
"tokio-util",
"toml 0.8.23",
"tracing",
"tracing-appender",
@ -1885,6 +1886,7 @@ dependencies = [
"hyper-util",
"jsonwebtoken",
"mime_guess",
"multer",
"object_store",
"openapiv3",
"rand 0.8.5",
@ -3664,6 +3666,23 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "naive-timer"
version = "0.2.0"
@ -4966,6 +4985,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",

View file

@ -17,7 +17,7 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] }
ulid = "1"
uuid = { version = "1", features = ["v4", "v7", "v8"] }
rand = "0.8"
@ -69,7 +69,7 @@ sentry = { version = "0.35", default-features = false, features = ["backtrace",
fork = "0.2"
exec = "0.3"
slatedb = "0.11.2"
object_store = "0.12.5"
object_store = { version = "0.12.5", features = ["aws"] }
rust-embed = "8"
percent-encoding = "2"

View file

@ -562,6 +562,24 @@ paths:
schema:
type: string
format: binary
multipart/form-data:
schema:
type: object
required:
- manifest
properties:
manifest:
$ref: "#/components/schemas/ArtifactBatchUploadManifest"
additionalProperties:
type: string
format: binary
description: |
Strict multipart upload format. The `manifest` part must arrive first with JSON
matching `ArtifactBatchUploadManifest`. Each subsequent file part name must match
a manifest entry `part` value.
encoding:
manifest:
contentType: application/json
responses:
"200":
description: Blob written
@ -758,11 +776,24 @@ paths:
operationId: putStageArtifact
tags: [Run Internals]
summary: Put Stage Artifact
description: Uploads an artifact for a stage. Intended for trusted internal callers.
description: |
Uploads one or more artifacts for a stage. Intended for trusted internal callers.
The server accepts both:
- `application/octet-stream` for single-file uploads with the `filename` query parameter
- strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest`
The generated Rust client currently exposes the octet-stream variant because the OpenAPI
code generator in this repo does not support multiple request media types on one operation.
parameters:
- $ref: "#/components/parameters/RunId"
- $ref: "#/components/parameters/StageId"
- $ref: "#/components/parameters/ArtifactFilename"
- name: filename
in: query
required: false
description: Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads.
schema:
type: string
requestBody:
required: true
content:
@ -774,7 +805,7 @@ paths:
"204":
description: Artifact written
"400":
description: Missing filename
description: Invalid filename, multipart manifest, checksum, or upload body
content:
application/json:
schema:
@ -2684,6 +2715,51 @@ components:
items:
$ref: "#/components/schemas/ArtifactEntry"
ArtifactBatchUploadEntry:
description: One file entry in a strict multipart artifact upload manifest.
type: object
required:
- part
- path
properties:
part:
type: string
description: Multipart field name for the file part.
example: file1
path:
type: string
description: Relative artifact path to store.
example: src/lib.rs
sha256:
type: string
nullable: true
description: Optional lowercase hex SHA-256 checksum for the file contents.
example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c
expected_bytes:
type: integer
format: int64
nullable: true
minimum: 0
description: Optional exact byte length expected for the file part.
example: 1234
content_type:
type: string
nullable: true
description: Optional client-supplied content type for the file part.
example: text/plain
ArtifactBatchUploadManifest:
description: Manifest for strict multipart artifact uploads.
type: object
required:
- entries
properties:
entries:
type: array
minItems: 1
items:
$ref: "#/components/schemas/ArtifactBatchUploadEntry"
RunArtifactEntry:
description: A captured artifact file for a run.
type: object

View file

@ -64,6 +64,59 @@ fn patch_nullable(value: &mut serde_json::Value) {
}
}
/// Progenitor currently panics when an operation advertises more than one request-body media type.
///
/// Keep the source OpenAPI spec accurate for docs, but collapse the generated-client view down to
/// a single preferred media type so code generation can proceed.
fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) {
let Some(paths) = value
.get_mut("paths")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
for path_item in paths.values_mut() {
let Some(item) = path_item.as_object_mut() else {
continue;
};
for method in ["get", "put", "post", "delete", "patch"] {
let Some(operation) = item
.get_mut(method)
.and_then(serde_json::Value::as_object_mut)
else {
continue;
};
let Some(content) = operation
.get_mut("requestBody")
.and_then(|request_body| request_body.get_mut("content"))
.and_then(serde_json::Value::as_object_mut)
else {
continue;
};
if content.len() <= 1 {
continue;
}
let preferred = content
.get("application/octet-stream")
.cloned()
.map(|value| ("application/octet-stream".to_string(), value))
.or_else(|| {
content
.iter()
.next()
.map(|(key, value)| (key.clone(), value.clone()))
});
if let Some((key, value)) = preferred {
content.clear();
content.insert(key, value);
}
}
}
}
fn main() {
let spec_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
@ -86,6 +139,7 @@ fn main() {
// rely on any 3.1-only features that affect codegen.
spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string());
patch_nullable(&mut spec_value);
patch_codegen_request_body_media_types(&mut spec_value);
let spec: openapiv3::OpenAPI =
serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec");

View file

@ -83,6 +83,7 @@ shlex = "1"
walkdir.workspace = true
object_store.workspace = true
bytes.workspace = true
tokio-util.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "0.9", optional = true }

View file

@ -730,6 +730,10 @@ pub(crate) struct RunWorkerArgs {
#[arg(long)]
pub(crate) server: String,
/// Short-lived bearer token for artifact uploads
#[arg(long, hide = true)]
pub(crate) artifact_upload_token: Option<String>,
/// Run scratch directory
#[arg(long)]
pub(crate) run_dir: PathBuf,

View file

@ -78,10 +78,11 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
}
RunCommands::RunWorker(RunWorkerArgs {
server,
artifact_upload_token,
run_dir,
run_id,
mode,
}) => runner::execute(run_id, server, run_dir, mode).await,
}) => runner::execute(run_id, server, artifact_upload_token, run_dir, mode).await,
RunCommands::Diff(args) => diff::run(args, globals).await,
RunCommands::Logs(args) => {
let styles = Styles::detect_stdout();

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
@ -9,6 +9,8 @@ use fabro_config::RunScratch;
use fabro_interview::FileInterviewer;
use fabro_store::{EventEnvelope, EventPayload, RunProjection};
use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, Settings, StatusReason};
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
use fabro_workflow::artifact_upload::StageArtifactUploader;
use fabro_workflow::event::{Emitter, RunEventSink};
use fabro_workflow::run_control::RunControlState;
use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle};
@ -43,6 +45,7 @@ enum WorkerTitlePhase {
pub(crate) async fn execute(
run_id: RunId,
server: String,
artifact_upload_token: Option<String>,
run_dir: PathBuf,
mode: RunWorkerMode,
) -> Result<()> {
@ -59,6 +62,12 @@ pub(crate) async fn execute(
.run
.as_ref()
.ok_or_else(|| anyhow!("Run {run_id} has no run record in store"))?;
let artifact_uploader = build_artifact_uploader(
run_id,
run_record,
client.clone_for_reuse(),
artifact_upload_token,
)?;
let scratch = RunScratch::new(&run_dir);
let interviewer = Arc::new(FileInterviewer::new(
scratch.interview_request_path(),
@ -82,6 +91,7 @@ pub(crate) async fn execute(
async move { Ok(()) }
}),
]),
artifact_uploader,
run_control: Some(run_control),
github_app,
on_node: None,
@ -100,6 +110,70 @@ pub(crate) async fn execute(
Ok(())
}
fn build_artifact_uploader(
run_id: RunId,
run_record: &fabro_types::RunRecord,
client: server_client::ServerStoreClient,
artifact_upload_token: Option<String>,
) -> Result<Option<Arc<dyn StageArtifactUploader>>> {
if !run_record.uses_object_backed_artifacts() {
return Ok(None);
}
let token = artifact_upload_token
.ok_or_else(|| anyhow!("run {run_id} is configured for object-backed artifacts but the worker did not receive an artifact upload token"))?;
Ok(Some(Arc::new(HttpArtifactUploader {
run_id,
client,
bearer_token: token,
})))
}
struct HttpArtifactUploader {
run_id: RunId,
client: server_client::ServerStoreClient,
bearer_token: String,
}
#[async_trait]
impl StageArtifactUploader for HttpArtifactUploader {
async fn upload_stage_artifacts(
&self,
stage_id: &fabro_types::StageId,
artifact_capture_dir: &Path,
artifacts: &[CapturedArtifactInfo],
) -> Result<()> {
if artifacts.is_empty() {
return Ok(());
}
if artifacts.len() == 1 {
let artifact = &artifacts[0];
return self
.client
.upload_stage_artifact_file(
&self.run_id,
stage_id,
&artifact.path,
&artifact_capture_dir.join(&artifact.path),
&self.bearer_token,
)
.await;
}
self.client
.upload_stage_artifact_batch(
&self.run_id,
stage_id,
artifact_capture_dir,
artifacts,
&self.bearer_token,
)
.await
}
}
#[derive(Clone)]
struct HttpRunStore {
run_id: RunId,
@ -495,6 +569,7 @@ mod tests {
let error = execute(
run_id,
format!("{}/api/v1", server.base_url()),
None,
run_dir.path().to_path_buf(),
RunWorkerMode::Start,
)

View file

@ -193,6 +193,7 @@ mod tests {
repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "infra".to_string())]),
artifact_storage: None,
provenance: None,
}
}
@ -328,6 +329,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)

View file

@ -498,6 +498,8 @@ mod tests {
"__run-worker",
"--server",
"/tmp/fabro.sock",
"--artifact-upload-token",
"token-123",
"--run-dir",
"/tmp/run",
"--run-id",
@ -509,6 +511,7 @@ mod tests {
match *cli.command {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "/tmp/fabro.sock");
assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123"));
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Start));
@ -535,6 +538,7 @@ mod tests {
match *cli.command {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "http://127.0.0.1:3000");
assert!(args.artifact_upload_token.is_none());
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Resume));

View file

@ -9,9 +9,12 @@ use fabro_api::types;
use fabro_server::bind::Bind;
use fabro_store::{EventEnvelope, RunSummary, StageId};
use fabro_types::{RunBlobId, RunEvent, RunId, Settings};
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
use futures::StreamExt;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::time::sleep;
use tokio_util::io::ReaderStream;
use crate::args::ServerTargetArgs;
use crate::commands::server::start;
@ -21,6 +24,8 @@ use crate::user_config;
#[derive(Clone)]
pub(crate) struct ServerStoreClient {
client: fabro_api::Client,
http_client: reqwest::Client,
base_url: String,
}
#[derive(Debug, Clone)]
@ -76,22 +81,19 @@ impl RunAttachEventStream {
pub(crate) use fabro_store::RunProjection;
pub(crate) async fn connect_server(storage_dir: &Path) -> Result<ServerStoreClient> {
Ok(ServerStoreClient {
client: connect_api_client(storage_dir).await?,
})
connect_api_client_bundle(storage_dir).await
}
pub(crate) async fn connect_server_target_direct(target: &str) -> Result<ServerStoreClient> {
let client = if target.starts_with("http://") || target.starts_with("https://") {
connect_remote_api_client(target, None)?
if target.starts_with("http://") || target.starts_with("https://") {
connect_remote_api_client_bundle(target, None)
} else {
let path = Path::new(target);
if !path.is_absolute() {
bail!("server target must be an http(s) URL or absolute Unix socket path");
}
connect_unix_socket_api_client(path).await?
};
Ok(ServerStoreClient { client })
connect_unix_socket_api_client_bundle(path).await
}
}
pub(crate) async fn connect_server_only(args: &ServerTargetArgs) -> Result<ServerStoreClient> {
@ -101,33 +103,46 @@ pub(crate) async fn connect_server_only(args: &ServerTargetArgs) -> Result<Serve
active_config_path: user_config::active_settings_path(None),
storage_dir: settings.storage_dir(),
};
Ok(ServerStoreClient {
client: connect_target_api_client(&target, &runtime).await?,
})
connect_target_api_client_bundle(&target, &runtime).await
}
pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result<fabro_api::Client> {
async fn connect_api_client_bundle(storage_dir: &Path) -> Result<ServerStoreClient> {
let config_path = user_config::active_settings_path(None);
let bind = start::ensure_server_running_for_storage(storage_dir, &config_path)
.with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?;
match bind {
Bind::Unix(path) => connect_unix_socket_api_client(&path).await,
Bind::Unix(path) => connect_unix_socket_api_client_bundle(&path).await,
Bind::Tcp(addr) => Err(anyhow!(
"Unsupported server bind for store client auto-connect: {addr}"
)),
}
}
pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result<fabro_api::Client> {
connect_api_client_bundle(storage_dir)
.await
.map(|client| client.client)
}
async fn connect_target_api_client(
target: &user_config::ServerTarget,
runtime: &LocalServerRuntime,
) -> Result<fabro_api::Client> {
connect_target_api_client_bundle(target, runtime)
.await
.map(|client| client.client)
}
async fn connect_target_api_client_bundle(
target: &user_config::ServerTarget,
runtime: &LocalServerRuntime,
) -> Result<ServerStoreClient> {
match target {
user_config::ServerTarget::HttpUrl { api_url, tls } => {
Ok(connect_remote_api_client(api_url, tls.as_ref())?)
connect_remote_api_client_bundle(api_url, tls.as_ref())
}
user_config::ServerTarget::UnixSocket(path) => {
if let Ok(client) = connect_unix_socket_api_client(path).await {
if let Ok(client) = connect_unix_socket_api_client_bundle(path).await {
Ok(client)
} else {
start::ensure_server_running_on_socket(
@ -136,7 +151,7 @@ async fn connect_target_api_client(
&runtime.storage_dir,
)
.with_context(|| format!("Failed to start fabro server for {}", path.display()))?;
connect_unix_socket_api_client(path).await
connect_unix_socket_api_client_bundle(path).await
}
}
}
@ -161,13 +176,18 @@ pub(crate) async fn connect_server_backed_api_client_with_storage_dir(
connect_target_api_client(&target, &runtime).await
}
pub(crate) fn connect_remote_api_client(
fn connect_remote_api_client_bundle(
api_url: &str,
tls: Option<&user_config::ClientTlsSettings>,
) -> Result<fabro_api::Client> {
) -> Result<ServerStoreClient> {
let http_client = user_config::build_server_client(tls)?;
let normalized = normalize_remote_server_target(api_url);
Ok(fabro_api::Client::new_with_client(&normalized, http_client))
let client = fabro_api::Client::new_with_client(&normalized, http_client.clone());
Ok(ServerStoreClient {
client,
http_client,
base_url: normalized,
})
}
fn normalize_remote_server_target(api_url: &str) -> String {
@ -178,7 +198,7 @@ fn normalize_remote_server_target(api_url: &str) -> String {
.to_string()
}
pub(crate) async fn connect_unix_socket_api_client(path: &Path) -> Result<fabro_api::Client> {
async fn connect_unix_socket_api_client_bundle(path: &Path) -> Result<ServerStoreClient> {
let http_client = crate::user_config::cli_http_client_builder()
.unix_socket(path)
.no_proxy()
@ -186,10 +206,13 @@ pub(crate) async fn connect_unix_socket_api_client(path: &Path) -> Result<fabro_
.context("Failed to build Unix-socket HTTP client for fabro server")?;
wait_for_server_ready(&http_client).await?;
Ok(fabro_api::Client::new_with_client(
"http://fabro",
let base_url = "http://fabro".to_string();
let client = fabro_api::Client::new_with_client(&base_url, http_client.clone());
Ok(ServerStoreClient {
client,
http_client,
))
base_url,
})
}
async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> {
@ -213,6 +236,23 @@ async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> {
Err(last_error.unwrap_or_else(|| anyhow!("server did not become ready in time")))
}
#[derive(Debug, Serialize)]
struct ArtifactBatchUploadManifest {
entries: Vec<ArtifactBatchUploadEntry>,
}
#[derive(Debug, Serialize)]
struct ArtifactBatchUploadEntry {
part: String,
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
expected_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
}
impl ServerStoreClient {
pub(crate) fn clone_for_reuse(&self) -> Self {
self.clone()
@ -508,6 +548,120 @@ impl ServerStoreClient {
Ok(bytes)
}
fn stage_artifacts_url(&self, run_id: &RunId, stage_id: &StageId) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(&self.base_url)
.with_context(|| format!("invalid server base URL {}", self.base_url))?;
url.path_segments_mut()
.map_err(|_| anyhow!("server base URL cannot accept path segments"))?
.extend([
"api",
"v1",
"runs",
&run_id.to_string(),
"stages",
&stage_id.to_string(),
"artifacts",
]);
Ok(url)
}
pub(crate) async fn upload_stage_artifact_file(
&self,
run_id: &RunId,
stage_id: &StageId,
filename: &str,
path: &Path,
bearer_token: &str,
) -> Result<()> {
let mut url = self.stage_artifacts_url(run_id, stage_id)?;
url.query_pairs_mut().append_pair("filename", filename);
let file = tokio::fs::File::open(path)
.await
.with_context(|| format!("failed to open artifact {}", path.display()))?;
let content_length = file
.metadata()
.await
.with_context(|| format!("failed to stat artifact {}", path.display()))?
.len();
let body = reqwest::Body::wrap_stream(ReaderStream::new(file));
let response = self
.http_client
.post(url)
.bearer_auth(bearer_token)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.header(reqwest::header::CONTENT_LENGTH, content_length.to_string())
.body(body)
.send()
.await
.with_context(|| format!("failed to upload artifact {}", path.display()))?;
ensure_raw_response_success(response).await
}
pub(crate) async fn upload_stage_artifact_batch(
&self,
run_id: &RunId,
stage_id: &StageId,
artifact_capture_dir: &Path,
artifacts: &[CapturedArtifactInfo],
bearer_token: &str,
) -> Result<()> {
let url = self.stage_artifacts_url(run_id, stage_id)?;
let mut manifest_entries = Vec::with_capacity(artifacts.len());
let mut file_parts = Vec::with_capacity(artifacts.len());
for (index, artifact) in artifacts.iter().enumerate() {
let part_name = format!("file{}", index + 1);
let path = artifact_capture_dir.join(&artifact.path);
let file = tokio::fs::File::open(&path)
.await
.with_context(|| format!("failed to open artifact {}", path.display()))?;
let content_length = file
.metadata()
.await
.with_context(|| format!("failed to stat artifact {}", path.display()))?
.len();
manifest_entries.push(ArtifactBatchUploadEntry {
part: part_name.clone(),
path: artifact.path.clone(),
sha256: Some(artifact.content_sha256.clone()),
expected_bytes: Some(artifact.bytes),
content_type: Some(artifact.mime.clone()),
});
file_parts.push((
part_name,
reqwest::multipart::Part::stream_with_length(
reqwest::Body::wrap_stream(ReaderStream::new(file)),
content_length,
)
.file_name(artifact.path.clone()),
));
}
let manifest = ArtifactBatchUploadManifest {
entries: manifest_entries,
};
let manifest_part = reqwest::multipart::Part::text(serde_json::to_string(&manifest)?)
.mime_str("application/json")?;
let mut form = reqwest::multipart::Form::new().part("manifest", manifest_part);
for (part_name, part) in file_parts {
form = form.part(part_name, part);
}
let response = self
.http_client
.post(url)
.bearer_auth(bearer_token)
.multipart(form)
.send()
.await
.context("failed to upload artifact batch")?;
ensure_raw_response_success(response).await
}
pub(crate) async fn generate_preview_url(
&self,
run_id: &RunId,
@ -629,6 +783,32 @@ where
}
}
async fn ensure_raw_response_success(response: reqwest::Response) -> Result<()> {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(detail) = value
.get("errors")
.and_then(serde_json::Value::as_array)
.and_then(|errors| errors.first())
.and_then(|entry| entry.get("detail"))
.and_then(serde_json::Value::as_str)
{
bail!("{detail}");
}
}
if body.is_empty() {
bail!("request failed with status {status}");
}
bail!("request failed with status {status}: {body}");
}
fn is_not_found_error<E>(err: &progenitor_client::Error<E>) -> bool
where
E: serde::Serialize + std::fmt::Debug,

View file

@ -108,6 +108,9 @@ pub struct ConfigLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<fabro_types::ArtifactStorageSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebConfig>,
@ -168,6 +171,7 @@ impl Combine for ConfigLayer {
no_retro: self.no_retro.combine(other.no_retro),
storage_dir: self.storage_dir.combine(other.storage_dir),
max_concurrent_runs: self.max_concurrent_runs.combine(other.max_concurrent_runs),
artifact_storage: self.artifact_storage.combine(other.artifact_storage),
web: self.web.combine(other.web),
api: self.api.combine(other.api),
features: self.features.combine(other.features),

View file

@ -97,6 +97,7 @@ fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLaye
Ok(ConfigLayer {
storage_dir: layer.storage_dir,
max_concurrent_runs: layer.max_concurrent_runs,
artifact_storage: layer.artifact_storage,
web: layer.web,
api: layer.api,
features: layer.features,
@ -109,6 +110,7 @@ fn strip_server_owned_fields(layer: &mut ConfigLayer) {
layer.exec = None;
layer.storage_dir = None;
layer.max_concurrent_runs = None;
layer.artifact_storage = None;
layer.web = None;
layer.api = None;
layer.features = None;

View file

@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize};
use fabro_types::Settings;
pub use fabro_types::settings::server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
GitProvider, GitSettings, LogSettings, TlsSettings, WebSettings, WebhookSettings,
WebhookStrategy,
ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider,
AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings,
TlsSettings, WebSettings, WebhookSettings, WebhookStrategy,
};
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]

View file

@ -33,6 +33,7 @@ impl TryFrom<ConfigLayer> for Settings {
no_retro: value.no_retro,
storage_dir: value.storage_dir,
max_concurrent_runs: value.max_concurrent_runs,
artifact_storage: value.artifact_storage,
web: value.web.map(Into::into),
api: value.api.map(TryInto::try_into).transpose()?,
features: value.features.map(Into::into),

View file

@ -58,6 +58,11 @@ impl Storage {
pub fn store_dir(&self) -> PathBuf {
self.root.join("store")
}
#[must_use]
pub fn artifact_store_dir(&self) -> PathBuf {
self.root.join("artifacts")
}
}
impl ServerState {
@ -193,6 +198,10 @@ mod tests {
storage.store_dir(),
std::path::Path::new("/tmp/fabro-data/store")
);
assert_eq!(
storage.artifact_store_dir(),
std::path::Path::new("/tmp/fabro-data/artifacts")
);
assert_eq!(
storage.server_state().record_path(),
std::path::Path::new("/tmp/fabro-data/server.json")

View file

@ -71,6 +71,7 @@ rust-embed.workspace = true
regex.workspace = true
semver.workspace = true
walkdir.workspace = true
multer = "3"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -253,43 +253,48 @@ fn try_cookie(parts: &Parts) -> Result<(), ApiError> {
/// When auth is disabled, the extractor accepts all requests.
pub struct AuthenticatedService;
pub fn authenticate_service_parts(parts: &Parts) -> Result<(), ApiError> {
let auth_mode = parts
.extensions
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let strategies = match auth_mode {
AuthMode::Disabled => return Ok(()),
AuthMode::Strategies(strategies) => strategies,
};
if strategies.is_empty() {
return Err(ApiError::unauthorized());
}
let mut last_err = ApiError::unauthorized();
for strategy in strategies {
let result = match strategy {
AuthStrategy::Mtls => try_mtls(parts),
AuthStrategy::Cookie => try_cookie(parts),
AuthStrategy::Jwt {
key,
validation,
allowed_usernames,
} => try_jwt(parts, key, validation, allowed_usernames),
};
match result {
Ok(()) => return Ok(()),
Err(err) => last_err = err,
}
}
Err(last_err)
}
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_mode = parts
.extensions
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let strategies = match auth_mode {
AuthMode::Disabled => return Ok(Self),
AuthMode::Strategies(strategies) => strategies,
};
if strategies.is_empty() {
return Err(ApiError::unauthorized());
}
let mut last_err = ApiError::unauthorized();
for strategy in strategies {
let result = match strategy {
AuthStrategy::Mtls => try_mtls(parts),
AuthStrategy::Cookie => try_cookie(parts),
AuthStrategy::Jwt {
key,
validation,
allowed_usernames,
} => try_jwt(parts, key, validation, allowed_usernames),
};
match result {
Ok(()) => return Ok(Self),
Err(e) => last_err = e,
}
}
Err(last_err)
authenticate_service_parts(parts)?;
Ok(Self)
}
}

View file

@ -131,6 +131,7 @@ pub(crate) fn create_run_input(prepared: PreparedManifest) -> CreateRunInput {
.as_ref()
.map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)),
base_branch: prepared.git.as_ref().map(|git| git.branch.clone()),
artifact_storage: None,
provenance: None,
}
}

View file

@ -3,10 +3,11 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use fabro_config::Storage;
use fabro_config::server::resolve_storage_dir;
use fabro_config::server::{ArtifactStorageBackend, resolve_storage_dir};
use fabro_config::user::{active_settings_path, load_settings_config};
use fabro_util::terminal::Styles;
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
use object_store::local::LocalFileSystem;
use object_store::memory::InMemory;
use tokio::net::{TcpListener, UnixListener};
@ -129,6 +130,43 @@ fn build_object_store(store_path: &Path) -> anyhow::Result<Arc<dyn ObjectStore>>
build_object_store_with_preference(store_path, use_in_memory_store())
}
fn build_artifact_object_store(
settings: &Settings,
storage: &Storage,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
let artifact_settings = settings.artifact_storage.clone().unwrap_or_default();
if use_in_memory_store() {
return Ok((Arc::new(InMemory::new()), artifact_settings.prefix));
}
match artifact_settings.backend {
ArtifactStorageBackend::Local => {
std::fs::create_dir_all(storage.artifact_store_dir())?;
let object_store = Arc::new(LocalFileSystem::new_with_prefix(storage.root())?);
Ok((object_store, artifact_settings.prefix))
}
ArtifactStorageBackend::S3 => {
let bucket = artifact_settings
.bucket
.ok_or_else(|| anyhow::anyhow!("artifact_storage.bucket is required for s3"))?;
let region = artifact_settings
.region
.ok_or_else(|| anyhow::anyhow!("artifact_storage.region is required for s3"))?;
let mut builder = AmazonS3Builder::from_env()
.with_bucket_name(bucket)
.with_region(region)
.with_virtual_hosted_style_request(!artifact_settings.path_style.unwrap_or(false));
if let Some(endpoint) = artifact_settings.endpoint {
builder = builder.with_endpoint(endpoint);
}
let object_store = Arc::new(builder.build()?);
Ok((object_store, artifact_settings.prefix))
}
}
}
/// Start the HTTP API server.
///
/// # Errors
@ -215,7 +253,11 @@ pub async fn serve_command(
"",
Duration::from_millis(1),
));
let artifact_store = fabro_store::ArtifactStore::new(object_store, "artifacts");
let (artifact_object_store, artifact_prefix) = build_artifact_object_store(
&shared_settings.read().expect("config lock poisoned"),
&storage,
)?;
let artifact_store = fabro_store::ArtifactStore::new(artifact_object_store, artifact_prefix);
let state = build_app_state_with_path(
Arc::clone(&shared_settings),
None,

File diff suppressed because it is too large Load diff

View file

@ -4,17 +4,20 @@ use bytes::Bytes;
use futures::StreamExt;
use object_store::{ObjectStore, path::Path as ObjectPath};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use tokio::io::AsyncWriteExt;
use crate::{Result, StageId, StoreError};
use fabro_types::RunId;
const ARTIFACT_SEGMENT_ENCODE_SET: &AsciiSet =
&NON_ALPHANUMERIC.remove(b'.').remove(b'_').remove(b'-');
const STREAM_BUFFER_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NodeArtifact {
pub node: StageId,
pub filename: String,
pub size: u64,
}
#[derive(Clone)]
@ -54,6 +57,45 @@ impl ArtifactStore {
Ok(())
}
pub fn writer(
&self,
run_id: &RunId,
node: &StageId,
filename: &str,
) -> Result<object_store::buffered::BufWriter> {
let path = self.artifact_path(run_id, node, filename)?;
Ok(object_store::buffered::BufWriter::with_capacity(
Arc::clone(&self.object_store),
path,
STREAM_BUFFER_BYTES,
))
}
pub async fn put_stream<S>(
&self,
run_id: &RunId,
node: &StageId,
filename: &str,
mut stream: S,
) -> Result<()>
where
S: futures::Stream<Item = Result<Bytes>> + Unpin,
{
let mut writer = self.writer(run_id, node, filename)?;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
writer
.write_all(&chunk)
.await
.map_err(|err| StoreError::Other(format!("artifact write failed: {err}")))?;
}
writer
.shutdown()
.await
.map_err(|err| StoreError::Other(format!("artifact finalize failed: {err}")))?;
Ok(())
}
pub async fn get(
&self,
run_id: &RunId,
@ -73,7 +115,11 @@ impl ArtifactStore {
let mut stream = self.object_store.list(Some(&prefix));
let mut artifacts = Vec::new();
while let Some(meta) = stream.next().await.transpose()? {
artifacts.push(decode_artifact_location(&prefix, &meta.location)?);
artifacts.push(decode_artifact_location(
&prefix,
&meta.location,
meta.size,
)?);
}
artifacts.sort();
Ok(artifacts)
@ -166,7 +212,11 @@ fn decode_path_segment(kind: &str, value: &str) -> Result<String> {
.map_err(|err| StoreError::Other(format!("invalid {kind}: {err}")))
}
fn decode_artifact_location(prefix: &ObjectPath, location: &ObjectPath) -> Result<NodeArtifact> {
fn decode_artifact_location(
prefix: &ObjectPath,
location: &ObjectPath,
size: u64,
) -> Result<NodeArtifact> {
let mut parts = location.prefix_match(prefix).ok_or_else(|| {
StoreError::Other(format!(
"artifact location {location} does not match expected prefix {prefix}"
@ -199,6 +249,7 @@ fn decode_artifact_location(prefix: &ObjectPath, location: &ObjectPath) -> Resul
Ok(NodeArtifact {
node: StageId::new(node_id, visit),
filename: filename_segments.join("/"),
size,
})
}
@ -260,10 +311,37 @@ mod tests {
vec![NodeArtifact {
node,
filename: filename.to_string(),
size: 5,
}]
);
}
#[tokio::test]
async fn put_stream_round_trips_chunked_writes() {
let store = test_store();
let run_id = fixtures::RUN_1;
let node = StageId::new("build", 2);
let filename = "logs/output.txt";
store
.put_stream(
&run_id,
&node,
filename,
futures::stream::iter(vec![
Ok(Bytes::from_static(b"hello ")),
Ok(Bytes::from_static(b"world")),
]),
)
.await
.unwrap();
assert_eq!(
store.get(&run_id, &node, filename).await.unwrap(),
Some(Bytes::from_static(b"hello world"))
);
}
#[tokio::test]
async fn rejects_invalid_relative_filenames() {
let store = test_store();

View file

@ -85,6 +85,7 @@ impl RunProjection {
repo_origin_url: props.repo_origin_url.clone(),
base_branch: props.base_branch.clone(),
labels,
artifact_storage: props.artifact_storage,
provenance: props.provenance.clone(),
});
self.graph_source.clone_from(&props.workflow_source);

View file

@ -288,6 +288,7 @@ mod tests {
repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()),
base_branch: Some("main".to_string()),
labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]),
artifact_storage: None,
provenance: None,
}
}

View file

@ -39,15 +39,15 @@ pub use retro::{
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::{
RunAuthMethod, RunClientProvenance, RunProvenance, RunRecord, RunServerProvenance,
RunSubjectProvenance,
RunArtifactStorage, RunAuthMethod, RunClientProvenance, RunProvenance, RunRecord,
RunServerProvenance, RunSubjectProvenance,
};
pub use run_blob_id::RunBlobId;
pub use run_event::{EventBody, RunEvent, RunNoticeLevel};
pub use run_id::RunId;
pub use run_id::fixtures;
pub use sandbox_record::SandboxRecord;
pub use settings::Settings;
pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings, Settings};
pub use stage_id::StageId;
pub use start::StartRecord;
pub use status::{

View file

@ -48,6 +48,12 @@ pub struct RunProvenance {
pub subject: Option<RunSubjectProvenance>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunArtifactStorage {
ObjectStoreV1,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: RunId,
@ -65,5 +71,17 @@ pub struct RunRecord {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<RunArtifactStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
}
impl RunRecord {
#[must_use]
pub fn uses_object_backed_artifacts(&self) -> bool {
matches!(
self.artifact_storage,
Some(RunArtifactStorage::ObjectStoreV1)
)
}
}

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason};
use crate::{Graph, RunArtifactStorage, RunControlAction, RunProvenance, Settings, StatusReason};
use super::{BilledTokenCounts, RunNoticeLevel};
@ -29,6 +29,8 @@ pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<RunArtifactStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
}

View file

@ -26,9 +26,9 @@ pub use sandbox::{
LocalSandboxSettings, SandboxSettings, WorktreeMode,
};
pub use server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
GitProvider, GitSettings, LogSettings, TlsSettings, WebSettings, WebhookSettings,
WebhookStrategy,
ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider,
AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings,
TlsSettings, WebSettings, WebhookSettings, WebhookStrategy,
};
pub use user::{ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings};
@ -91,6 +91,8 @@ pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<ArtifactStorageSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiSettings>,

View file

@ -2,6 +2,10 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
fn default_artifact_storage_prefix() -> String {
"artifacts".to_string()
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
@ -124,3 +128,40 @@ pub struct FeaturesSettings {
pub struct LogSettings {
pub level: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactStorageBackend {
#[default]
Local,
S3,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ArtifactStorageSettings {
#[serde(default)]
pub backend: ArtifactStorageBackend,
#[serde(default = "default_artifact_storage_prefix")]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_style: Option<bool>,
}
impl Default for ArtifactStorageSettings {
fn default() -> Self {
Self {
backend: ArtifactStorageBackend::Local,
prefix: default_artifact_storage_prefix(),
bucket: None,
region: None,
endpoint: None,
path_style: None,
}
}
}

View file

@ -0,0 +1,17 @@
use std::path::Path;
use anyhow::Result;
use async_trait::async_trait;
use fabro_types::StageId;
use crate::artifact_snapshot::CapturedArtifactInfo;
#[async_trait]
pub trait StageArtifactUploader: Send + Sync {
async fn upload_stage_artifacts(
&self,
stage_id: &StageId,
artifact_capture_dir: &Path,
artifacts: &[CapturedArtifactInfo],
) -> Result<()>;
}

View file

@ -54,6 +54,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
artifact_storage: Option<::fabro_types::RunArtifactStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<::fabro_types::RunProvenance>,
},
WorkflowRunStarted {
@ -1360,6 +1362,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
base_branch,
workflow_slug,
db_prefix,
artifact_storage,
provenance,
..
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
@ -1375,6 +1378,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
base_branch: base_branch.clone(),
workflow_slug: workflow_slug.clone(),
db_prefix: db_prefix.clone(),
artifact_storage: *artifact_storage,
provenance: provenance.clone(),
}),
Event::WorkflowRunStarted {

View file

@ -258,6 +258,7 @@ impl Handler for SubWorkflowHandler {
sandbox,
registry,
on_node: None,
artifact_uploader: None,
run_control: None,
hook_runner,
env,

View file

@ -114,6 +114,7 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap<
#[doc(hidden)]
pub mod artifact;
pub mod artifact_snapshot;
pub mod artifact_upload;
pub mod artifacts;
pub(crate) mod condition;
pub mod context;

View file

@ -1,8 +1,10 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use fabro_types::StageId;
use fabro_core::graph::NodeSpec;
use fabro_core::lifecycle::{AttemptContext, AttemptResultContext, RunLifecycle};
@ -11,6 +13,7 @@ use fabro_core::state::ExecutionState;
use crate::artifact::{offload_large_values, sync_artifacts_to_env};
use crate::artifact_snapshot::collect_artifacts;
use crate::artifact_upload::StageArtifactUploader;
use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
@ -23,6 +26,12 @@ type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
const ARTIFACT_UPLOAD_RETRY_DELAYS: [Duration; 3] = [
Duration::from_millis(100),
Duration::from_millis(250),
Duration::from_millis(500),
];
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
pub(crate) struct ArtifactLifecycle {
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
@ -31,6 +40,7 @@ pub(crate) struct ArtifactLifecycle {
pub emitter: Arc<Emitter>,
pub artifacts_dir: PathBuf,
pub artifact_globs: Vec<String>,
pub artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
pub captured_artifact_count: Arc<AtomicUsize>,
/// Per-attempt state: epoch seconds when the attempt started.
attempt_start_epoch: std::sync::Mutex<Option<f64>>,
@ -45,6 +55,7 @@ impl ArtifactLifecycle {
emitter: Arc<Emitter>,
artifacts_dir: PathBuf,
artifact_globs: Vec<String>,
artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
captured_artifact_count: Arc<AtomicUsize>,
) -> Self {
Self {
@ -54,6 +65,7 @@ impl ArtifactLifecycle {
emitter,
artifacts_dir,
artifact_globs,
artifact_uploader,
captured_artifact_count,
attempt_start_epoch: std::sync::Mutex::new(None),
}
@ -113,6 +125,18 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
.await
{
Ok(summary) if summary.files_copied > 0 => {
let stage_id = StageId::new(node_id.to_string(), ctx.attempt);
if let Err(err) = self
.upload_artifacts(&stage_id, &artifact_capture_dir, &summary.captured_assets)
.await
{
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "artifact_upload_failed".to_string(),
message: format!("[node: {node_id}] artifact upload failed: {err}"),
});
return Ok(());
}
for asset in &summary.captured_assets {
self.captured_artifact_count.fetch_add(1, Ordering::Relaxed);
self.emitter.emit(&Event::ArtifactCaptured {
@ -177,3 +201,33 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
Ok(())
}
}
impl ArtifactLifecycle {
async fn upload_artifacts(
&self,
stage_id: &StageId,
artifact_capture_dir: &std::path::Path,
artifacts: &[crate::artifact_snapshot::CapturedArtifactInfo],
) -> Result<(), String> {
let Some(uploader) = self.artifact_uploader.as_ref() else {
return Ok(());
};
let mut last_error = None;
for attempt in 0..=ARTIFACT_UPLOAD_RETRY_DELAYS.len() {
match uploader
.upload_stage_artifacts(stage_id, artifact_capture_dir, artifacts)
.await
{
Ok(()) => return Ok(()),
Err(err) => last_error = Some(err.to_string()),
}
if let Some(delay) = ARTIFACT_UPLOAD_RETRY_DELAYS.get(attempt) {
tokio::time::sleep(*delay).await;
}
}
Err(last_error.unwrap_or_else(|| "artifact upload failed".to_string()))
}
}

View file

@ -24,6 +24,7 @@ use fabro_core::lifecycle::{
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use crate::artifact_upload::StageArtifactUploader;
use crate::context;
use crate::error::{FailureSignature, FailureSignatureExt};
use crate::event::Emitter;
@ -85,6 +86,7 @@ impl WorkflowLifecycle {
graph: Arc<GvGraph>,
run_dir: &PathBuf,
run_store: &RunStoreHandle,
artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
run_options: &Arc<RunOptions>,
is_resume: bool,
on_node: crate::OnNodeCallback,
@ -163,6 +165,7 @@ impl WorkflowLifecycle {
Arc::clone(emitter),
run_scratch.artifact_files_dir(),
run_options.artifact_globs().to_vec(),
artifact_uploader,
captured_artifact_count,
);

View file

@ -3,7 +3,7 @@ use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
use fabro_types::{RunId, RunProvenance, Settings};
use fabro_types::{RunArtifactStorage, RunId, RunProvenance, Settings};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@ -35,6 +35,7 @@ pub struct CreateRunInput {
pub host_repo_path: Option<String>,
pub repo_origin_url: Option<String>,
pub base_branch: Option<String>,
pub artifact_storage: Option<RunArtifactStorage>,
pub provenance: Option<RunProvenance>,
}
@ -56,6 +57,7 @@ struct PersistCreateOptions {
working_directory: PathBuf,
host_repo_path: Option<String>,
repo_origin_url: Option<String>,
artifact_storage: Option<RunArtifactStorage>,
provenance: Option<RunProvenance>,
}
@ -83,6 +85,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
host_repo_path,
repo_origin_url,
base_branch,
artifact_storage,
provenance,
} = request;
@ -121,6 +124,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
working_directory,
host_repo_path,
repo_origin_url,
artifact_storage,
provenance,
},
current_dir,
@ -187,6 +191,7 @@ async fn persist_created_run(
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: record.artifact_storage,
provenance: record.provenance.clone(),
},
record.run_id.created_at(),
@ -319,6 +324,7 @@ fn persist_validated(
working_directory,
host_repo_path,
repo_origin_url,
artifact_storage,
provenance,
} = options;
@ -337,6 +343,7 @@ fn persist_validated(
repo_origin_url,
base_branch,
labels,
artifact_storage,
provenance,
};

View file

@ -378,6 +378,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
artifact_storage: None,
provenance: None,
}
}
@ -452,6 +453,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)

View file

@ -11,6 +11,7 @@ use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::{SandboxProvider, SandboxSpec};
use fabro_types::{RunId, Settings};
use crate::artifact_upload::StageArtifactUploader;
use crate::context::Context;
use crate::error::FabroError;
use crate::event::{
@ -50,6 +51,7 @@ struct RunSession {
seed_context: Option<Context>,
run_store: RunStoreHandle,
event_sink: RunEventSink,
artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
git: Option<GitCheckpointOptions>,
github_app: Option<fabro_github::GitHubAppCredentials>,
worktree_mode: Option<WorktreeMode>,
@ -72,6 +74,7 @@ pub struct StartServices {
pub interviewer: Arc<dyn Interviewer>,
pub run_store: RunStoreHandle,
pub event_sink: RunEventSink,
pub artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
pub run_control: Option<Arc<RunControlState>>,
pub github_app: Option<fabro_github::GitHubAppCredentials>,
pub on_node: crate::OnNodeCallback,
@ -393,6 +396,7 @@ impl RunSession {
devcontainer,
seed_context: None,
run_store: services.run_store,
artifact_uploader: services.artifact_uploader,
git,
github_app: services.github_app.clone(),
worktree_mode: Some(resolve_worktree_mode(&settings)),
@ -523,6 +527,7 @@ impl RunSession {
git: self.git,
worktree_mode: self.worktree_mode,
registry_override: self.registry_override,
artifact_uploader: self.artifact_uploader,
run_control: self.run_control,
checkpoint,
seed_context: self.seed_context,
@ -869,6 +874,7 @@ mod tests {
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer),
run_store: store.open_run(&fixtures::RUN_1).await.unwrap().into(),
event_sink: RunEventSink::store(store.open_run(&fixtures::RUN_1).await.unwrap()),
artifact_uploader: None,
run_control: None,
github_app: None,
on_node: None,

View file

@ -47,6 +47,7 @@ pub async fn execute(init: Initialized) -> Executed {
sandbox,
registry,
on_node,
artifact_uploader,
run_control,
hook_runner,
env,
@ -100,6 +101,7 @@ pub async fn execute(init: Initialized) -> Executed {
graph_arc,
&run_options.run_dir,
&run_store,
artifact_uploader,
&settings_arc,
checkpoint.is_some(),
on_node,

View file

@ -219,6 +219,7 @@ async fn execute_test_run_with_options(
worktree_mode: None,
run_control: None,
registry_override,
artifact_uploader: None,
checkpoint: None,
seed_context: None,
},
@ -275,6 +276,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_uploader: None,
checkpoint: None,
seed_context: None,
},
@ -341,6 +343,7 @@ async fn run_with_lifecycle(
worktree_mode: None,
run_control: None,
registry_override: Some(Arc::new(registry)),
artifact_uploader: None,
checkpoint: None,
seed_context: None,
},

View file

@ -651,6 +651,7 @@ pub async fn initialize(
sandbox,
registry,
on_node: None,
artifact_uploader: options.artifact_uploader,
run_control: options.run_control,
hook_runner,
env,
@ -804,6 +805,7 @@ mod tests {
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_uploader: None,
checkpoint: None,
seed_context: None,
},
@ -880,6 +882,7 @@ mod tests {
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_uploader: None,
checkpoint: None,
seed_context: None,
},

View file

@ -133,6 +133,7 @@ mod tests {
("env".to_string(), "test".to_string()),
("team".to_string(), "workflow".to_string()),
]),
artifact_storage: None,
provenance: None,
}
}
@ -157,6 +158,7 @@ mod tests {
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: record.artifact_storage,
provenance: record.provenance.clone(),
},
)

View file

@ -1090,6 +1090,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
artifact_storage: None,
provenance: None,
};
append_event(
@ -1109,6 +1110,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)
@ -1160,6 +1162,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
artifact_storage: None,
provenance: None,
};
append_event(
@ -1179,6 +1182,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)
@ -1383,6 +1387,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
artifact_storage: None,
provenance: None,
};
append_event(
@ -1402,6 +1407,7 @@ mod tests {
base_branch: None,
workflow_slug: None,
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)

View file

@ -241,6 +241,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
artifact_storage: None,
provenance: None,
};
append_event(
@ -260,6 +261,7 @@ mod tests {
base_branch: None,
workflow_slug: None,
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)

View file

@ -14,6 +14,7 @@ use fabro_sandbox::SandboxSpec;
use fabro_types::RunId;
use fabro_validate::Diagnostic;
use crate::artifact_upload::StageArtifactUploader;
use crate::context::Context;
use crate::error::FabroError;
use crate::event::Emitter;
@ -246,6 +247,7 @@ pub struct InitOptions {
pub git: Option<GitCheckpointOptions>,
pub worktree_mode: Option<WorktreeMode>,
pub registry_override: Option<Arc<HandlerRegistry>>,
pub artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
pub run_control: Option<Arc<RunControlState>>,
pub checkpoint: Option<Checkpoint>,
pub seed_context: Option<Context>,
@ -266,6 +268,7 @@ pub struct Initialized {
pub sandbox: Arc<dyn Sandbox>,
pub registry: Arc<HandlerRegistry>,
pub on_node: crate::OnNodeCallback,
pub artifact_uploader: Option<Arc<dyn StageArtifactUploader>>,
pub run_control: Option<Arc<RunControlState>>,
pub hook_runner: Option<Arc<HookRunner>>,
pub env: HashMap<String, String>,

View file

@ -427,6 +427,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
artifact_storage: None,
provenance: None,
}
}
@ -458,6 +459,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
artifact_storage: run_record.artifact_storage,
provenance: run_record.provenance.clone(),
},
)

View file

@ -140,6 +140,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
artifact_storage: None,
provenance: None,
}
}
@ -165,6 +166,7 @@ mod tests {
base_branch: None,
workflow_slug: Some("test".to_string()),
db_prefix: None,
artifact_storage: None,
provenance: None,
},
)

View file

@ -91,6 +91,7 @@ async fn initialized(
base_branch: run_options.base_branch.clone(),
workflow_slug: run_options.workflow_slug.clone(),
db_prefix: None,
artifact_storage: None,
provenance: None,
},
)
@ -113,6 +114,7 @@ async fn initialized(
sandbox,
registry: Arc::new(registry),
on_node: None,
artifact_uploader: None,
run_control: None,
hook_runner: options.hook_runner,
env: options.env,

View file

@ -22,6 +22,8 @@ models/api-question-option.ts
models/api-question.ts
models/api-settings.ts
models/append-event-response.ts
models/artifact-batch-upload-entry.ts
models/artifact-batch-upload-manifest.ts
models/artifact-entry.ts
models/artifact-list-response.ts
models/artifacts-settings.ts

View file

@ -45,6 +45,8 @@ import type { RunProjection } from '../models';
import type { RunSettings } from '../models';
// @ts-ignore
import type { WriteBlobResponse } from '../models';
// @ts-ignore
import type { WriteRunBlobRequest } from '../models';
/**
* RunInternalsApi - axios parameter creator
*/
@ -479,22 +481,20 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
};
},
/**
* Uploads an artifact for a stage. Intended for trusted internal callers.
* Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation.
* @summary Put Stage Artifact
* @param {string} id Unique run identifier (ULID).
* @param {string} stageId Identifier of a stage within a run\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @param {string} filename Relative artifact path. &#x60;/&#x60; is allowed as a path separator. Backslash, empty segments, and traversal segments (&#x60;.&#x60; and &#x60;..&#x60;) are invalid.
* @param {File} body
* @param {string} [filename] Relative artifact path for &#x60;application/octet-stream&#x60; uploads. Ignored for multipart uploads.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putStageArtifact: async (id: string, stageId: string, filename: string, body: File, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
putStageArtifact: async (id: string, stageId: string, body: File, filename?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('putStageArtifact', 'id', id)
// verify required parameter 'stageId' is not null or undefined
assertParamExists('putStageArtifact', 'stageId', stageId)
// verify required parameter 'filename' is not null or undefined
assertParamExists('putStageArtifact', 'filename', filename)
// verify required parameter 'body' is not null or undefined
assertParamExists('putStageArtifact', 'body', body)
const localVarPath = `/api/v1/runs/{id}/stages/{stageId}/artifacts`
@ -847,17 +847,17 @@ export const RunInternalsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Uploads an artifact for a stage. Intended for trusted internal callers.
* Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation.
* @summary Put Stage Artifact
* @param {string} id Unique run identifier (ULID).
* @param {string} stageId Identifier of a stage within a run\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @param {string} filename Relative artifact path. &#x60;/&#x60; is allowed as a path separator. Backslash, empty segments, and traversal segments (&#x60;.&#x60; and &#x60;..&#x60;) are invalid.
* @param {File} body
* @param {string} [filename] Relative artifact path for &#x60;application/octet-stream&#x60; uploads. Ignored for multipart uploads.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async putStageArtifact(id: string, stageId: string, filename: string, body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.putStageArtifact(id, stageId, filename, body, options);
async putStageArtifact(id: string, stageId: string, body: File, filename?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.putStageArtifact(id, stageId, body, filename, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.putStageArtifact']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -1028,17 +1028,17 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b
return localVarFp.listStageTurns(id, stageId, pageLimit, pageOffset, options).then((request) => request(axios, basePath));
},
/**
* Uploads an artifact for a stage. Intended for trusted internal callers.
* Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation.
* @summary Put Stage Artifact
* @param {string} id Unique run identifier (ULID).
* @param {string} stageId Identifier of a stage within a run\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @param {string} filename Relative artifact path. &#x60;/&#x60; is allowed as a path separator. Backslash, empty segments, and traversal segments (&#x60;.&#x60; and &#x60;..&#x60;) are invalid.
* @param {File} body
* @param {string} [filename] Relative artifact path for &#x60;application/octet-stream&#x60; uploads. Ignored for multipart uploads.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putStageArtifact(id: string, stageId: string, filename: string, body: File, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putStageArtifact(id, stageId, filename, body, options).then((request) => request(axios, basePath));
putStageArtifact(id: string, stageId: string, body: File, filename?: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putStageArtifact(id, stageId, body, filename, options).then((request) => request(axios, basePath));
},
/**
* Reads a previously stored blob by identifier.
@ -1201,17 +1201,17 @@ export class RunInternalsApi extends BaseAPI {
}
/**
* Uploads an artifact for a stage. Intended for trusted internal callers.
* Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation.
* @summary Put Stage Artifact
* @param {string} id Unique run identifier (ULID).
* @param {string} stageId Identifier of a stage within a run\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @param {string} filename Relative artifact path. &#x60;/&#x60; is allowed as a path separator. Backslash, empty segments, and traversal segments (&#x60;.&#x60; and &#x60;..&#x60;) are invalid.
* @param {File} body
* @param {string} [filename] Relative artifact path for &#x60;application/octet-stream&#x60; uploads. Ignored for multipart uploads.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public putStageArtifact(id: string, stageId: string, filename: string, body: File, options?: RawAxiosRequestConfig) {
return RunInternalsApiFp(this.configuration).putStageArtifact(id, stageId, filename, body, options).then((request) => request(this.axios, this.basePath));
public putStageArtifact(id: string, stageId: string, body: File, filename?: string, options?: RawAxiosRequestConfig) {
return RunInternalsApiFp(this.configuration).putStageArtifact(id, stageId, body, filename, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -1260,4 +1260,3 @@ export class RunInternalsApi extends BaseAPI {
return RunInternalsApiFp(this.configuration).writeRunBlob(id, body, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* One file entry in a strict multipart artifact upload manifest.
*/
export interface ArtifactBatchUploadEntry {
/**
* Multipart field name for the file part.
*/
'part': string;
/**
* Relative artifact path to store.
*/
'path': string;
/**
* Optional lowercase hex SHA-256 checksum for the file contents.
*/
'sha256'?: string;
/**
* Optional exact byte length expected for the file part.
*/
'expected_bytes'?: number;
/**
* Optional client-supplied content type for the file part.
*/
'content_type'?: string;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ArtifactBatchUploadEntry } from './artifact-batch-upload-entry';
/**
* Manifest for strict multipart artifact uploads.
*/
export interface ArtifactBatchUploadManifest {
'entries': Array<ArtifactBatchUploadEntry>;
}

View file

@ -4,6 +4,8 @@ export * from './api-question';
export * from './api-question-option';
export * from './api-settings';
export * from './append-event-response';
export * from './artifact-batch-upload-entry';
export * from './artifact-batch-upload-manifest';
export * from './artifact-entry';
export * from './artifact-list-response';
export * from './artifacts-settings';