mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
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:
parent
80dfbd5309
commit
f4488c4d35
24 changed files with 418 additions and 26 deletions
|
|
@ -214,6 +214,7 @@ mod tests {
|
|||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<()>),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -841,6 +841,7 @@ mod tests {
|
|||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
provenance: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -748,6 +748,7 @@ mod tests {
|
|||
repo_origin_url: None,
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue