feat(run): record run creation provenance

Persist server, client, and subject provenance on run creation so
run state and inspect output can show which Fabro version created a
run, which first-party client submitted it, and how the request was
authenticated.
This commit is contained in:
Bryan Helmkamp 2026-04-07 12:59:25 -04:00
parent 80dfbd5309
commit f4488c4d35
No known key found for this signature in database
24 changed files with 418 additions and 26 deletions

View file

@ -214,6 +214,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
}
}

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())]),
provenance: None,
}
}
@ -325,6 +326,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await

View file

@ -51,7 +51,13 @@ fn inspect_created_run_shows_run_record_without_start_or_conclusion() {
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
"dry_run": true
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "disabled"
}
},
"start_record": null,
"conclusion": null,
@ -78,7 +84,13 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
"dry_run": true
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "disabled"
}
},
"start_record": {
"has_start_time": true
@ -147,7 +159,13 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
"dry_run": true
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "disabled"
}
},
"start_record": {
"has_start_time": true
@ -192,7 +210,13 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
"workflow_name": "Flow",
"workflow_slug": "flow",
"llm_provider": "openai",
"sandbox_provider": "local"
"sandbox_provider": "local",
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "disabled"
}
},
"start_record": {
"has_start_time": true,

View file

@ -796,6 +796,14 @@ pub(crate) fn compact_inspect(output: &Output) -> Value {
"workflow_slug": run_record.pointer("/workflow_slug"),
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
"dry_run": run_record.pointer("/settings/dry_run"),
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
serde_json::json!({
"server_version": "[VERSION]",
"client_name": run_record.pointer("/provenance/client/name"),
"client_version": "[VERSION]",
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
})
}),
},
"start_record": item["start_record"].as_object().map(|record| {
serde_json::json!({
@ -847,6 +855,14 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
"workflow_slug": run_record.pointer("/workflow_slug"),
"llm_provider": run_record.pointer("/settings/llm/provider"),
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
serde_json::json!({
"server_version": "[VERSION]",
"client_name": run_record.pointer("/provenance/client/name"),
"client_version": "[VERSION]",
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
})
}),
},
"start_record": start_record.as_object().map(|_| {
serde_json::json!({

View file

@ -11,6 +11,7 @@ use tracing::warn;
use crate::error::ApiError;
use crate::web_auth::SessionCookie;
use fabro_config::server::ApiSettings;
use fabro_types::RunAuthMethod;
/// JWT claims for service-to-service authentication.
#[derive(Debug, Deserialize)]
@ -292,16 +293,13 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
}
}
/// Axum extractor that authenticates and extracts the user's login.
///
/// - Demo mode → `login: "demo"`
/// - JWT → login from the `sub` claim (last path segment of URL)
/// - mTLS → CN from the peer certificate
pub struct AuthenticatedUser {
pub login: String,
/// Axum extractor that authenticates and extracts the request subject.
pub struct AuthenticatedSubject {
pub login: Option<String>,
pub auth_method: RunAuthMethod,
}
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedSubject {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
@ -313,7 +311,8 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
let strategies = match auth_mode {
AuthMode::Disabled => {
return Ok(Self {
login: "demo".to_string(),
login: None,
auth_method: RunAuthMethod::Disabled,
});
}
AuthMode::Strategies(strategies) => strategies,
@ -330,7 +329,8 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
AuthStrategy::Cookie => {
if let Some(session) = parts.extensions.get::<SessionCookie>() {
return Ok(Self {
login: session.login.clone(),
login: Some(session.login.clone()),
auth_method: RunAuthMethod::Cookie,
});
}
last_err = ApiError::unauthorized();
@ -342,7 +342,10 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
} => {
if try_jwt(parts, key, validation, allowed_usernames).is_ok() {
if let Some(login) = extract_jwt_login(parts, key, validation) {
return Ok(Self { login });
return Ok(Self {
login: Some(login),
auth_method: RunAuthMethod::Jwt,
});
}
}
last_err = ApiError::unauthorized();
@ -350,7 +353,10 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
AuthStrategy::Mtls => {
if try_mtls(parts).is_ok() {
if let Some(login) = extract_mtls_cn(parts) {
return Ok(Self { login });
return Ok(Self {
login: Some(login),
auth_method: RunAuthMethod::Mtls,
});
}
}
last_err = ApiError::unauthorized();
@ -365,23 +371,44 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
#[cfg(test)]
mod tests {
use super::*;
use axum::Json;
use axum::Router;
use axum::body::Body;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use tower::ServiceExt;
use crate::web_auth::SessionCookie;
async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse {
"ok"
}
async fn subject_handler(subject: AuthenticatedSubject) -> impl IntoResponse {
Json(serde_json::json!({
"login": subject.login,
"auth_method": subject.auth_method,
}))
}
fn test_router(mode: AuthMode) -> Router {
Router::new()
.route("/test", get(protected_handler))
.layer(axum::Extension(mode))
}
fn subject_router(mode: AuthMode) -> Router {
Router::new()
.route("/subject", get(subject_handler))
.layer(axum::Extension(mode))
}
async fn response_json(response: axum::response::Response) -> serde_json::Value {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn generate_test_keypair() -> (jsonwebtoken::EncodingKey, DecodingKey) {
let output = std::process::Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
@ -714,6 +741,72 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn disabled_mode_extracts_disabled_subject() {
let app = subject_router(AuthMode::Disabled);
let req = Request::builder()
.uri("/subject")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["login"], serde_json::Value::Null);
assert_eq!(body["auth_method"], "disabled");
}
#[tokio::test]
async fn jwt_subject_extracts_login_and_auth_method() {
let (encoding, decoding) = generate_test_keypair();
let app = subject_router(jwt_mode(decoding, vec!["brynary"]));
let token = sign_token(
&encoding,
"fabro-web",
60,
Some("https://github.com/brynary"),
);
let req = Request::builder()
.uri("/subject")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["login"], "brynary");
assert_eq!(body["auth_method"], "jwt");
}
#[tokio::test]
async fn cookie_subject_extracts_login_and_auth_method() {
let app = subject_router(AuthMode::Strategies(vec![AuthStrategy::Cookie]));
let mut req = Request::builder()
.uri("/subject")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(SessionCookie {
login: "brynary".to_string(),
name: "Brynary".to_string(),
email: "b@example.com".to_string(),
avatar_url: "https://example.com/avatar.png".to_string(),
user_url: "https://github.com/brynary".to_string(),
github_id: 1,
exp: 9999999999,
});
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["login"], "brynary");
assert_eq!(body["auth_method"], "cookie");
}
#[tokio::test]
async fn empty_strategies_rejects() {
let app = test_router(AuthMode::Strategies(vec![]));
@ -768,6 +861,20 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn mtls_subject_extracts_login_and_auth_method() {
let app = subject_router(AuthMode::Strategies(vec![AuthStrategy::Mtls]));
let cert = generate_test_client_cert("brynary");
let req = request_with_peer_certs("/subject", Some(vec![cert]));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["login"], "brynary");
assert_eq!(body["auth_method"], "mtls");
}
// --- Multi-strategy tests ---
#[tokio::test]

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()),
provenance: None,
}
}

View file

@ -549,7 +549,10 @@ mod tests {
let disk_store = build_object_store_with_preference(&store_path, false)
.expect("disk-backed store should build");
assert!(store_path.exists(), "disk-backed store should create store dir");
assert!(
store_path.exists(),
"disk-backed store should create store dir"
);
drop(disk_store);
let mem_path = temp.path().join("memory-store");

View file

@ -10,7 +10,7 @@ use crate::bind::Bind;
#[cfg(test)]
use axum::body::to_bytes;
use axum::extract::{self as axum_extract, Path, Query, State};
use axum::http::{HeaderValue, Method, StatusCode};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
@ -29,7 +29,10 @@ use fabro_llm::types::{
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
};
use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId};
use fabro_types::{RunBlobId, RunControlAction, RunEvent, RunId, Settings};
use fabro_types::{
RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance,
RunServerProvenance, RunSubjectProvenance, Settings,
};
use fabro_util::redact::redact_jsonl_line;
use fabro_util::version::FABRO_VERSION;
use fabro_workflow::artifacts as workflow_artifacts;
@ -58,7 +61,7 @@ use tracing::{error, info};
use crate::demo;
use crate::diagnostics;
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, AuthenticatedService};
use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedSubject};
use crate::run_manifest;
use crate::secret_store::{SecretStore, SecretStoreError};
use crate::static_files;
@ -2223,8 +2226,9 @@ async fn write_file_answer(run_dir: &std::path::Path, answer: &Answer) -> anyhow
}
async fn create_run(
_auth: AuthenticatedService,
subject: AuthenticatedSubject,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RunManifest>,
) -> Response {
let prepared = match run_manifest::prepare_manifest_with_mode(
@ -2240,6 +2244,7 @@ async fn create_run(
let mut create_input = run_manifest::create_run_input(prepared.clone());
create_input.run_id = Some(run_id);
create_input.provenance = Some(run_provenance(&headers, &subject));
let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await {
Ok(created) => created,
@ -2285,6 +2290,47 @@ async fn create_run(
.into_response()
}
fn run_provenance(headers: &HeaderMap, subject: &AuthenticatedSubject) -> RunProvenance {
RunProvenance {
server: Some(RunServerProvenance {
version: FABRO_VERSION.to_string(),
}),
client: run_client_provenance(headers),
subject: Some(RunSubjectProvenance {
login: subject.login.clone(),
auth_method: subject.auth_method,
}),
}
}
fn run_client_provenance(headers: &HeaderMap) -> Option<RunClientProvenance> {
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::to_string)?;
let (name, version) = parse_known_fabro_user_agent(&user_agent)
.map_or((None, None), |(name, version)| {
(Some(name.to_string()), Some(version.to_string()))
});
Some(RunClientProvenance {
user_agent: Some(user_agent),
name,
version,
})
}
fn parse_known_fabro_user_agent(user_agent: &str) -> Option<(&str, &str)> {
let token = user_agent.split_whitespace().next()?;
let (name, version) = token.split_once('/')?;
if version.is_empty() {
return None;
}
match name {
"fabro-cli" | "fabro-web" => Some((name, version)),
_ => None,
}
}
async fn run_preflight(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -4998,6 +5044,49 @@ mod tests {
assert!(body["nodes"].is_object());
}
#[tokio::test]
async fn get_run_state_includes_provenance_from_user_agent() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.header("user-agent", "fabro-cli/1.2.3")
.body(manifest_body(MINIMAL_DOT))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap();
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/state")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(
body["run"]["provenance"]["server"]["version"],
FABRO_VERSION
);
assert_eq!(
body["run"]["provenance"]["client"]["user_agent"],
"fabro-cli/1.2.3"
);
assert_eq!(body["run"]["provenance"]["client"]["name"], "fabro-cli");
assert_eq!(body["run"]["provenance"]["client"]["version"], "1.2.3");
assert_eq!(
body["run"]["provenance"]["subject"]["auth_method"],
"disabled"
);
assert!(body["run"]["provenance"]["subject"]["login"].is_null());
}
#[tokio::test]
async fn list_run_events_returns_paginated_json() {
let state = create_app_state();

View file

@ -99,6 +99,7 @@ impl RunProjection {
repo_origin_url: props.repo_origin_url.clone(),
base_branch: props.base_branch.clone(),
labels,
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())]),
provenance: None,
}
}

View file

@ -31,7 +31,10 @@ pub use retro::{
AggregateStats, FrictionKind, FrictionPoint, Learning, LearningCategory, OpenItem,
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::RunRecord;
pub use run::{
RunAuthMethod, RunClientProvenance, RunProvenance, RunRecord, RunServerProvenance,
RunSubjectProvenance,
};
pub use run_blob_id::RunBlobId;
pub use run_event::{EventBody, RunEvent, RunNoticeLevel, TokenUsage};
pub use run_id::RunId;

View file

@ -7,6 +7,47 @@ use crate::graph::Graph;
use crate::run_id::RunId;
use crate::settings::Settings;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunAuthMethod {
Disabled,
Cookie,
Jwt,
Mtls,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunServerProvenance {
pub version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunClientProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunSubjectProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login: Option<String>,
pub auth_method: RunAuthMethod,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<RunServerProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<RunClientProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<RunSubjectProvenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: RunId,
@ -23,4 +64,6 @@ pub struct RunRecord {
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
}

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Graph, RunControlAction, Settings, StatusReason};
use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason};
use super::{RunNoticeLevel, TokenUsage};
@ -28,6 +28,8 @@ pub struct RunCreatedProps {
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -56,6 +58,7 @@ pub struct RunControlRequestedProps {
pub action: RunControlAction,
}
#[allow(clippy::empty_structs_with_brackets)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunControlEffectProps {}

View file

@ -49,6 +49,8 @@ pub enum Event {
workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<::fabro_types::RunProvenance>,
},
WorkflowRunStarted {
name: String,
@ -1355,6 +1357,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
base_branch,
workflow_slug,
db_prefix,
provenance,
..
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
settings: serde_json::from_value(settings.clone()).expect("run.created settings"),
@ -1369,6 +1372,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
base_branch: base_branch.clone(),
workflow_slug: workflow_slug.clone(),
db_prefix: db_prefix.clone(),
provenance: provenance.clone(),
}),
Event::WorkflowRunStarted {
name,
@ -2370,7 +2374,7 @@ pub enum RunEventSink {
Store(RunDatabase),
JsonLines(Arc<AsyncMutex<Pin<Box<dyn AsyncWrite + Send>>>>),
Callback(Arc<RunEventSinkCallback>),
Composite(Vec<RunEventSink>),
Composite(Vec<Self>),
}
type RunEventSinkFuture = Pin<Box<dyn Future<Output = Result<()>> + Send + 'static>>;
@ -2440,6 +2444,7 @@ impl RunEventSink {
}
}
#[allow(clippy::large_enum_variant)]
enum RunEventCommand {
Event(RunEvent),
Flush(oneshot::Sender<()>),

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, Settings};
use fabro_types::{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 provenance: Option<RunProvenance>,
}
#[derive(Debug)]
@ -55,6 +56,7 @@ struct PersistCreateOptions {
working_directory: PathBuf,
host_repo_path: Option<String>,
repo_origin_url: Option<String>,
provenance: Option<RunProvenance>,
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.
@ -81,6 +83,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
host_repo_path,
repo_origin_url,
base_branch,
provenance,
} = request;
let settings = resolved.settings.clone();
@ -118,6 +121,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
working_directory,
host_repo_path,
repo_origin_url,
provenance,
},
current_dir,
file_resolver,
@ -183,6 +187,7 @@ async fn persist_created_run(
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
provenance: record.provenance.clone(),
},
record.run_id.created_at(),
);
@ -314,6 +319,7 @@ fn persist_validated(
working_directory,
host_repo_path,
repo_origin_url,
provenance,
} = options;
let settings = resolve_run_settings(settings, validated.graph());
@ -331,6 +337,7 @@ fn persist_validated(
repo_origin_url,
base_branch,
labels,
provenance,
};
pipeline::persist(
@ -689,6 +696,7 @@ mod tests {
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
provenance: None,
},
)
.await
@ -736,6 +744,7 @@ mod tests {
host_repo_path: Some(dir.path().display().to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
provenance: None,
},
)
.await
@ -815,6 +824,7 @@ mod tests {
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
provenance: None,
},
)
.await
@ -857,6 +867,7 @@ mod tests {
host_repo_path: None,
repo_origin_url: Some("https://github.com/acme/widgets".to_string()),
base_branch: None,
provenance: None,
},
)
.await
@ -896,6 +907,7 @@ mod tests {
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
provenance: None,
},
)
.await
@ -908,4 +920,67 @@ mod tests {
"run.created"
);
}
#[tokio::test]
async fn create_hydrates_provenance_into_store_state() {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
std::fs::create_dir_all(storage_dir.join("store")).unwrap();
let object_store =
Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).unwrap());
let store = Arc::new(Database::new(object_store, "", Duration::from_millis(1)));
let created = create(
store.as_ref(),
CreateRunInput {
workflow: WorkflowInput::DotSource {
source: MINIMAL_DOT.to_string(),
base_dir: None,
},
settings: Settings {
storage_dir: Some(storage_dir.clone()),
dry_run: Some(true),
..Default::default()
},
cwd: dir.path().to_path_buf(),
workflow_slug: Some("slug".to_string()),
workflow_path: None,
workflow_bundle: None,
run_id: Some(fixtures::RUN_64),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
provenance: Some(fabro_types::RunProvenance {
server: Some(fabro_types::RunServerProvenance {
version: "0.9.0".to_string(),
}),
client: Some(fabro_types::RunClientProvenance {
user_agent: Some("fabro-cli/0.9.0".to_string()),
name: Some("fabro-cli".to_string()),
version: Some("0.9.0".to_string()),
}),
subject: Some(fabro_types::RunSubjectProvenance {
login: None,
auth_method: fabro_types::RunAuthMethod::Disabled,
}),
}),
},
)
.await
.unwrap();
let run_store = store.open_run_reader(&created.run_id).await.unwrap();
let state = run_store.state().await.unwrap();
let run = state.run.expect("run should be projected");
let provenance = run.provenance.expect("provenance should be projected");
assert_eq!(provenance.server.unwrap().version, "0.9.0");
assert_eq!(
provenance.client.unwrap().name.as_deref(),
Some("fabro-cli")
);
assert_eq!(
provenance.subject.unwrap().auth_method,
fabro_types::RunAuthMethod::Disabled
);
}
}

View file

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

View file

@ -841,6 +841,7 @@ mod tests {
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
provenance: None,
},
)
.await

View file

@ -144,6 +144,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
},
)
}

View file

@ -748,6 +748,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
},
)
}

View file

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

View file

@ -1093,6 +1093,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
};
append_event(
&run_store,
@ -1111,6 +1112,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await
@ -1161,6 +1163,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
};
append_event(
&run_store,
@ -1179,6 +1182,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await
@ -1382,6 +1386,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
};
append_event(
&run_store,
@ -1400,6 +1405,7 @@ mod tests {
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await

View file

@ -227,6 +227,7 @@ mod tests {
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
};
append_event(
&run_store,
@ -245,6 +246,7 @@ mod tests {
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await

View file

@ -418,6 +418,7 @@ mod tests {
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
}
}
@ -448,6 +449,7 @@ mod tests {
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
provenance: run_record.provenance.clone(),
},
)
.await

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,
provenance: None,
},
)
.await