mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
27cc8c75eb
commit
494a7fe1cc
53 changed files with 1953 additions and 259 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
lib/crates/fabro-workflow/src/artifact_upload.rs
Normal file
17
lib/crates/fabro-workflow/src/artifact_upload.rs
Normal 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<()>;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ impl Handler for SubWorkflowHandler {
|
|||
sandbox,
|
||||
registry,
|
||||
on_node: None,
|
||||
artifact_uploader: None,
|
||||
run_control: None,
|
||||
hook_runner,
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\'s workflow graph, serialized as `node_id@visit`.
|
||||
* @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid.
|
||||
* @param {File} body
|
||||
* @param {string} [filename] Relative artifact path for `application/octet-stream` 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\'s workflow graph, serialized as `node_id@visit`.
|
||||
* @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid.
|
||||
* @param {File} body
|
||||
* @param {string} [filename] Relative artifact path for `application/octet-stream` 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\'s workflow graph, serialized as `node_id@visit`.
|
||||
* @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid.
|
||||
* @param {File} body
|
||||
* @param {string} [filename] Relative artifact path for `application/octet-stream` 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\'s workflow graph, serialized as `node_id@visit`.
|
||||
* @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid.
|
||||
* @param {File} body
|
||||
* @param {string} [filename] Relative artifact path for `application/octet-stream` 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue