diff --git a/Cargo.lock b/Cargo.lock index 4db37eb1e..42edbe8a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2299,6 +2299,7 @@ dependencies = [ "fabro-types", "fabro-util", "fabro-validate", + "fabro-variable", "fabro-vault", "fabro-workflow", "futures-util", @@ -2554,6 +2555,18 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "fabro-variable" +version = "0.245.0-nightly.1" +dependencies = [ + "chrono", + "fabro-types", + "serde", + "serde_json", + "tempfile", + "ulid", +] + [[package]] name = "fabro-vault" version = "0.246.0-nightly.0" diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index b39bd5f1a..74a50a00e 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -4433,6 +4433,176 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + # ── Variables ──────────────────────────────────────────────────────── + + /api/v1/variables: + get: + operationId: listVariables + tags: [Variables] + summary: List variables + description: Returns non-sensitive variables, including values. + responses: + "200": + description: Variable list + content: + application/json: + schema: + $ref: "#/components/schemas/VariableListResponse" + post: + operationId: createVariable + tags: [Variables] + summary: Store or update a variable + description: Stores a non-sensitive variable for run config interpolation. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateVariableRequest" + responses: + "200": + description: Variable stored + content: + application/json: + schema: + $ref: "#/components/schemas/Variable" + "400": + description: Invalid variable name or request body + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Variable store write failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/variables/{name}: + parameters: + - name: name + in: path + required: true + schema: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Variable name. + get: + operationId: getVariable + tags: [Variables] + summary: Get a variable + responses: + "200": + description: Variable + content: + application/json: + schema: + $ref: "#/components/schemas/Variable" + "400": + description: Invalid variable name + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Variable not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + operationId: updateVariable + tags: [Variables] + summary: Replace a variable value + description: Replaces a variable value and preserves the existing description when omitted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateVariableRequest" + responses: + "200": + description: Variable updated + content: + application/json: + schema: + $ref: "#/components/schemas/Variable" + "400": + description: Invalid variable name or request body + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Variable not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Variable store write failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteVariable + tags: [Variables] + summary: Delete a variable + responses: + "204": + description: Variable deleted + "400": + description: Invalid variable name + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Variable not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Variable store write failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # ── Repos ──────────────────────────────────────────────────────────── /api/v1/repos/github/{owner}/{name}: @@ -13099,6 +13269,78 @@ components: items: $ref: "#/components/schemas/SecretMetadata" + Variable: + description: Non-sensitive variable available for run config interpolation. + type: object + required: + - name + - value + - created_at + - updated_at + properties: + name: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Env-style variable name. + example: DEPLOY_ENV + value: + type: string + description: Variable value. + example: production + description: + type: string + description: Optional operator-facing description of the variable. + created_at: + type: string + format: date-time + description: When the variable was first stored. + updated_at: + type: string + format: date-time + description: When the variable was last updated. + + VariableListResponse: + description: List of stored variables. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/Variable" + + CreateVariableRequest: + description: Request to store or update a variable. + type: object + required: + - name + - value + properties: + name: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Env-style variable name. + value: + type: string + description: Variable value. Empty values are allowed. + description: + type: string + description: Optional operator-facing description of the variable. + + UpdateVariableRequest: + description: Request to update a variable. + type: object + required: + - value + properties: + value: + type: string + description: Replacement value. Empty values are allowed. + description: + type: string + description: Optional operator-facing description. Omitted descriptions preserve the existing value. + RepoCheckResponse: description: Repository access check result. type: object diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 2f66a02d5..8483a12b0 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -437,6 +437,22 @@ fn main() { &[], ), ("SecretMetadata", "fabro_types::SecretMetadata", &[]), + ("Variable", "fabro_types::Variable", &[]), + ( + "VariableListResponse", + "fabro_types::VariableListResponse", + &[], + ), + ( + "CreateVariableRequest", + "fabro_types::CreateVariableRequest", + &[], + ), + ( + "UpdateVariableRequest", + "fabro_types::UpdateVariableRequest", + &[], + ), ("InterviewOption", "fabro_types::InterviewOption", &[]), ( "InterviewQuestionRecord", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index b6a44b687..26bbd1b2e 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -36,9 +36,9 @@ pub mod types { pub use fabro_types::{ ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro, - AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats, DiffSummary, - DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, - FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind, + AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, CreateVariableRequest, + DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, + FailureDetail, FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord, McpServerProjection, McpServerStatus, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, @@ -59,7 +59,8 @@ pub mod types { StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection, StageState, SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus, - SystemIntegrationsResponse, TodoListProjection, TurnId, UserPrincipal, WorkflowSettings, + SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest, + UserPrincipal, Variable, VariableListResponse, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/variable_round_trip.rs b/lib/crates/fabro-api/tests/variable_round_trip.rs new file mode 100644 index 000000000..777cca96c --- /dev/null +++ b/lib/crates/fabro-api/tests/variable_round_trip.rs @@ -0,0 +1,79 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::{ + CreateVariableRequest as ApiCreateVariableRequest, + UpdateVariableRequest as ApiUpdateVariableRequest, Variable as ApiVariable, + VariableListResponse as ApiVariableListResponse, +}; +use fabro_types::{CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse}; +use serde_json::json; + +#[test] +fn variable_api_types_reuse_canonical_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn variable_round_trips_representative_json() { + let value = json!({ + "name": "DEPLOY_ENV", + "value": "production", + "description": "Deployment target", + "created_at": "2026-05-27T12:34:56Z", + "updated_at": "2026-05-27T12:40:00Z" + }); + + let variable: Variable = serde_json::from_value(value.clone()).unwrap(); + + assert_eq!(variable.name, "DEPLOY_ENV"); + assert_eq!(variable.value, "production"); + assert_eq!(variable.description.as_deref(), Some("Deployment target")); + assert_eq!(serde_json::to_value(variable).unwrap(), value); +} + +#[test] +fn variable_requests_round_trip_json() { + let create = json!({ + "name": "EMPTY_ALLOWED", + "value": "", + "description": "Intentionally blank" + }); + let parsed_create: CreateVariableRequest = serde_json::from_value(create.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed_create).unwrap(), create); + + let update = json!({ + "value": "updated" + }); + let parsed_update: UpdateVariableRequest = serde_json::from_value(update.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed_update).unwrap(), update); +} + +#[test] +fn variable_list_response_wraps_data() { + let value = json!({ + "data": [{ + "name": "DEPLOY_ENV", + "value": "production", + "created_at": "2026-05-27T12:34:56Z", + "updated_at": "2026-05-27T12:40:00Z" + }] + }); + + let response: VariableListResponse = serde_json::from_value(value.clone()).unwrap(); + + assert_eq!(response.data.len(), 1); + assert_eq!(serde_json::to_value(response).unwrap(), value); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/crates/fabro-config/src/storage.rs index 9bf0bedab..52442add2 100644 --- a/lib/crates/fabro-config/src/storage.rs +++ b/lib/crates/fabro-config/src/storage.rs @@ -47,6 +47,11 @@ impl Storage { .join("secrets.json") } + #[must_use] + pub fn variables_path(&self) -> PathBuf { + self.root.join("variables.json") + } + #[must_use] pub fn runtime_directory(&self) -> RuntimeDirectory { RuntimeDirectory::new(self.root.clone()) @@ -183,6 +188,10 @@ mod tests { storage.secrets_path(), std::path::Path::new("/tmp/fabro-data/vaults/default/secrets.json") ); + assert_eq!( + storage.variables_path(), + std::path::Path::new("/tmp/fabro-data/variables.json") + ); assert_eq!( storage.objects_dir(), std::path::Path::new("/tmp/fabro-data/objects") diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index f83c73981..6aebf6cb0 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -45,6 +45,7 @@ fabro-api = { path = "../fabro-api" } fabro-client = { path = "../fabro-client" } fabro-store = { path = "../fabro-store" } fabro-vault = { path = "../fabro-vault" } +fabro-variable = { path = "../fabro-variable" } fabro-http.workspace = true fabro-redact.workspace = true fabro-static.workspace = true diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 2a5488426..b2ff5c7ce 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -714,6 +714,7 @@ where }; let storage = Storage::new(&data_dir); let vault_path = storage.secrets_path(); + let variables_path = storage.variables_path(); let server_env_path = storage.runtime_directory().env_path(); runtime_settings.server_settings = runtime_settings .server_settings @@ -804,6 +805,7 @@ where store, artifact_store, vault_path, + variables_path, preloaded_vault: Some(startup_vault), server_secrets, env_lookup, @@ -1249,9 +1251,9 @@ mod tests { apply_effective_log_destination, bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_object_store_from_settings_with_lookup, build_slatedb_store, force_exit_after_shutdown, resolve_bind_request_from_server_settings, - resolve_github_webhook_ip_allowlist, resolve_startup_github_webhook_ip_allowlist, - serve_overrides, serve_until_shutdown, server_bind_title, server_title, - spawn_shutdown_orchestrator_inner, + resolve_github_webhook_ip_allowlist, resolve_interp, + resolve_startup_github_webhook_ip_allowlist, serve_overrides, serve_until_shutdown, + server_bind_title, server_title, spawn_shutdown_orchestrator_inner, }; use crate::server::ResolvedAppStateSettings; @@ -1286,6 +1288,16 @@ mod tests { .expect("settings should resolve") } + #[test] + fn server_settings_interpolation_rejects_variables() { + let err = resolve_interp(&InterpString::parse("{{ vars.STORAGE_ROOT }}")).unwrap_err(); + + let rendered = format!("{err:#}"); + assert!(rendered.contains("failed to resolve {{ vars.STORAGE_ROOT }}")); + assert!(rendered.contains("variable \"STORAGE_ROOT\"")); + assert!(rendered.contains("not supported in this interpolation context")); + } + fn resolved_runtime_settings(source: &str) -> ResolvedAppStateSettings { let manifest_run_defaults = manifest_run_defaults(source); ResolvedAppStateSettings { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index b0bb8432c..1c83dd57b 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -30,9 +30,9 @@ pub use fabro_api::types::{ BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox, - DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, - ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind, + CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse, + DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, + DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, @@ -46,7 +46,8 @@ pub use fabro_api::types::{ SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemIntegrationStatus, SystemIntegrationsResponse, SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, - TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse, + TimelineEntryResponse, UpdateVariableRequest, VariableListResponse, VncPreviewResponse, + WriteBlobResponse, }; use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message}; #[cfg(test)] @@ -103,6 +104,7 @@ use fabro_util::error::{ SharedError, collect_causes, render_compact_with_causes, render_with_causes, }; use fabro_util::version::FABRO_VERSION; +use fabro_variable::{Error as VariableError, VariableStore}; use fabro_vault::{Error as VaultError, SecretType, Vault}; use fabro_workflow::artifact_upload::ArtifactSink; #[cfg(test)] @@ -1019,6 +1021,7 @@ pub struct AppState { parent_link_lock: AsyncMutex<()>, pub(crate) vault: Arc>, + pub(crate) variables: Arc>, pub(super) server_secrets: ServerSecrets, pub(crate) llm_source: Arc, manifest_run_defaults: RwLock>, @@ -1125,6 +1128,7 @@ pub(crate) struct AppStateConfig { pub(crate) store: Arc, pub(crate) artifact_store: ArtifactStore, pub(crate) vault_path: PathBuf, + pub(crate) variables_path: PathBuf, pub(crate) preloaded_vault: Option, pub(crate) server_secrets: ServerSecrets, pub(crate) env_lookup: EnvLookup, @@ -2167,6 +2171,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result vault, None => load_startup_vault(&vault_path)?, @@ -2266,6 +2273,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Router> { .merge(graph::run_routes()) .merge(models::routes()) .merge(secrets::routes()) + .merge(variables::routes()) .merge(sessions::routes()) .merge(system::routes()) .merge(completions::routes()) diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 090d1ba5d..c4928d261 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -19,10 +19,11 @@ use fabro_api::types::{ use fabro_config::Storage; use fabro_interview::AnswerSubmission; use fabro_llm::client::Client as LlmClient; +use fabro_types::settings::ResolveEnvError; use fabro_types::{ Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, StageContextWindow, StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler, - StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref, + StageModelUsage, StageProjection, SystemActorKind, WorkflowSettings, parse_blob_ref, }; use fabro_util::version::FABRO_VERSION; use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; @@ -599,7 +600,7 @@ async fn create_run( let explicit_title_supplied = req.title.is_some(); let manifest_run_defaults = state.manifest_run_defaults(); let manifest_environment_defaults = state.manifest_environment_defaults(); - let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults( manifest_run_defaults.as_ref(), manifest_environment_defaults.as_ref(), &req, @@ -607,6 +608,10 @@ async fn create_run( Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; + if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await { + return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) + .into_response(); + } let run_id = prepared.run_id.unwrap_or_else(RunId::new); let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run); if let Some(error) = @@ -856,7 +861,7 @@ async fn run_preflight( ) -> Response { let manifest_run_defaults = state.manifest_run_defaults(); let manifest_environment_defaults = state.manifest_environment_defaults(); - let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults( manifest_run_defaults.as_ref(), manifest_environment_defaults.as_ref(), &req, @@ -864,6 +869,10 @@ async fn run_preflight( Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; + if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await { + return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) + .into_response(); + } let mut validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) { Ok(validated) => validated, Err(WorkflowError::Parse(_)) => { @@ -889,7 +898,7 @@ async fn validate_run_manifest( ) -> Response { let manifest_run_defaults = state.manifest_run_defaults(); let manifest_environment_defaults = state.manifest_environment_defaults(); - let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults( manifest_run_defaults.as_ref(), manifest_environment_defaults.as_ref(), &req, @@ -897,6 +906,10 @@ async fn validate_run_manifest( Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; + if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await { + return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) + .into_response(); + } let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) { Ok(validated) => validated, Err(WorkflowError::Parse(_)) => { @@ -911,6 +924,16 @@ async fn validate_run_manifest( .into_response() } +async fn substitute_run_variables( + state: &AppState, + settings: &mut WorkflowSettings, +) -> Result<(), ResolveEnvError> { + let variables = state.variables.read().await; + settings + .run + .substitute_variables(|name| variables.get_value(name).map(str::to_string)) +} + async fn get_run_status( RequireRunManagementTarget(id, _actor): RequireRunManagementTarget, State(state): State>, diff --git a/lib/crates/fabro-server/src/server/handler/variables.rs b/lib/crates/fabro-server/src/server/handler/variables.rs new file mode 100644 index 000000000..368d69ad5 --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/variables.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use fabro_types::Variable; +use tokio::task::JoinError; + +use super::super::{ + ApiError, AppState, CreateVariableRequest, IntoResponse, Json, Path, RequiredUser, Response, + Router, State, StatusCode, UpdateVariableRequest, VariableError, VariableListResponse, + VariableStore, get, spawn_blocking, +}; + +pub(super) fn routes() -> Router> { + Router::new() + .route("/variables", get(list_variables).post(create_variable)) + .route( + "/variables/{name}", + get(get_variable) + .put(update_variable) + .delete(delete_variable), + ) +} + +async fn list_variables(_auth: RequiredUser, State(state): State>) -> Response { + let data = state.variables.read().await.list(); + (StatusCode::OK, Json(VariableListResponse { data })).into_response() +} + +async fn create_variable( + _auth: RequiredUser, + State(state): State>, + Json(body): Json, +) -> Response { + let name = body.name; + let value = body.value; + let description = body.description; + let state_for_write = Arc::clone(&state); + let result = spawn_blocking(move || { + let mut variables = state_for_write.variables.blocking_write(); + variables.set(&name, &value, description.as_deref()) + }) + .await; + + variable_write_response(result) +} + +async fn get_variable( + _auth: RequiredUser, + State(state): State>, + Path(name): Path, +) -> Response { + if let Err(VariableError::InvalidName(_)) = VariableStore::validate_name(&name) { + return ApiError::bad_request("invalid variable name").into_response(); + } + match state.variables.read().await.get(&name) { + Some(variable) => (StatusCode::OK, Json(variable)).into_response(), + None => ApiError::not_found(format!("variable not found: {name}")).into_response(), + } +} + +async fn update_variable( + _auth: RequiredUser, + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> Response { + let value = body.value; + let description = body.description; + let state_for_write = Arc::clone(&state); + let result = spawn_blocking(move || { + let mut variables = state_for_write.variables.blocking_write(); + variables.update_existing(&name, &value, description.as_deref()) + }) + .await; + + variable_write_response(result) +} + +async fn delete_variable( + _auth: RequiredUser, + State(state): State>, + Path(name): Path, +) -> Response { + let state_for_write = Arc::clone(&state); + let result = spawn_blocking(move || { + let mut variables = state_for_write.variables.blocking_write(); + variables.remove(&name) + }) + .await; + + match result { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + Ok(Err(err)) => variable_error_response(err), + Err(err) => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("variable delete task failed: {err}"), + ) + .into_response(), + } +} + +fn variable_write_response(result: Result, JoinError>) -> Response { + match result { + Ok(Ok(variable)) => (StatusCode::OK, Json(variable)).into_response(), + Ok(Err(err)) => variable_error_response(err), + Err(err) => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("variable write task failed: {err}"), + ) + .into_response(), + } +} + +fn variable_error_response(err: VariableError) -> Response { + match err { + VariableError::InvalidName(_) => { + ApiError::bad_request("invalid variable name").into_response() + } + VariableError::NotFound(name) => { + ApiError::not_found(format!("variable not found: {name}")).into_response() + } + VariableError::Io(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + VariableError::Serde(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + } +} diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index ba4520d87..6b08f4ed8 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -1652,6 +1652,7 @@ fn slack_app_state_with_secret_sources( max_concurrent_runs: 5, store, artifact_store, + variables_path: vault_path.with_file_name("variables.json"), vault_path, preloaded_vault: Some(vault), server_secrets: load_test_server_secrets(server_env_path, server_secret_env), @@ -1747,6 +1748,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() { max_concurrent_runs: 5, store, artifact_store, + variables_path: vault_path.with_file_name("variables.json"), vault_path, preloaded_vault: Some(vault), server_secrets: load_test_server_secrets( @@ -2053,6 +2055,7 @@ methods = ["dev-token"] max_concurrent_runs: 5, store, artifact_store, + variables_path: vault_path.with_file_name("variables.json"), vault_path, preloaded_vault: None, server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(), @@ -2176,6 +2179,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result Request { + Request::builder() + .method(method) + .uri(api(path)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(body).expect("request body should serialize"), + )) + .expect("request should build") +} + +fn empty_request(method: Method, path: &str) -> Request { + Request::builder() + .method(method) + .uri(api(path)) + .body(Body::empty()) + .expect("request should build") +} + +#[tokio::test] +async fn variables_crud_exposes_values() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + + let list_empty = app + .clone() + .oneshot(empty_request(Method::GET, "/variables")) + .await + .expect("GET /variables should route"); + let body = response_json(list_empty, StatusCode::OK, "GET /api/v1/variables").await; + assert_eq!(body, serde_json::json!({ "data": [] })); + + let create = app + .clone() + .oneshot(json_request( + Method::POST, + "/variables", + &serde_json::json!({ + "name": "DEPLOY_ENV", + "value": "staging", + "description": "Deployment target" + }), + )) + .await + .expect("POST /variables should route"); + let body = response_json(create, StatusCode::OK, "POST /api/v1/variables").await; + assert_eq!(body["name"], "DEPLOY_ENV"); + assert_eq!(body["value"], "staging"); + assert_eq!(body["description"], "Deployment target"); + assert!(body.get("created_at").is_some()); + assert!(body.get("updated_at").is_some()); + + let post_upsert = app + .clone() + .oneshot(json_request( + Method::POST, + "/variables", + &serde_json::json!({ + "name": "DEPLOY_ENV", + "value": "qa" + }), + )) + .await + .expect("POST /variables upsert should route"); + let body = response_json(post_upsert, StatusCode::OK, "POST /api/v1/variables").await; + assert_eq!(body["value"], "qa"); + assert_eq!(body["description"], "Deployment target"); + + let get = app + .clone() + .oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV")) + .await + .expect("GET /variables/DEPLOY_ENV should route"); + let body = response_json(get, StatusCode::OK, "GET /api/v1/variables/DEPLOY_ENV").await; + assert_eq!(body["value"], "qa"); + + let update = app + .clone() + .oneshot(json_request( + Method::PUT, + "/variables/DEPLOY_ENV", + &serde_json::json!({ "value": "production" }), + )) + .await + .expect("PUT /variables/DEPLOY_ENV should route"); + let body = response_json(update, StatusCode::OK, "PUT /api/v1/variables/DEPLOY_ENV").await; + assert_eq!(body["value"], "production"); + assert_eq!(body["description"], "Deployment target"); + + let list = app + .clone() + .oneshot(empty_request(Method::GET, "/variables")) + .await + .expect("GET /variables should route"); + let body = response_json(list, StatusCode::OK, "GET /api/v1/variables").await; + assert_eq!(body["data"][0]["name"], "DEPLOY_ENV"); + assert_eq!(body["data"][0]["value"], "production"); + + let delete = app + .clone() + .oneshot(empty_request(Method::DELETE, "/variables/DEPLOY_ENV")) + .await + .expect("DELETE /variables/DEPLOY_ENV should route"); + response_status( + delete, + StatusCode::NO_CONTENT, + "DELETE /api/v1/variables/DEPLOY_ENV", + ) + .await; + + let missing = app + .oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV")) + .await + .expect("GET /variables/DEPLOY_ENV should route"); + response_status( + missing, + StatusCode::NOT_FOUND, + "GET /api/v1/variables/DEPLOY_ENV", + ) + .await; +} + +#[tokio::test] +async fn variables_validate_names_and_allow_empty_values() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + + let invalid = app + .clone() + .oneshot(json_request( + Method::POST, + "/variables", + &serde_json::json!({ + "name": "1BAD", + "value": "nope" + }), + )) + .await + .expect("POST /variables should route"); + response_status(invalid, StatusCode::BAD_REQUEST, "POST /api/v1/variables").await; + + let invalid_get = app + .clone() + .oneshot(empty_request(Method::GET, "/variables/1BAD")) + .await + .expect("GET /variables/1BAD should route"); + response_status( + invalid_get, + StatusCode::BAD_REQUEST, + "GET /api/v1/variables/1BAD", + ) + .await; + + let empty = app + .clone() + .oneshot(json_request( + Method::POST, + "/variables", + &serde_json::json!({ + "name": "EMPTY_ALLOWED", + "value": "" + }), + )) + .await + .expect("POST /variables should route"); + let body = response_json(empty, StatusCode::OK, "POST /api/v1/variables").await; + assert_eq!(body["value"], ""); + + let missing_delete = app + .oneshot(empty_request(Method::DELETE, "/variables/MISSING")) + .await + .expect("DELETE /variables/MISSING should route"); + response_status( + missing_delete, + StatusCode::NOT_FOUND, + "DELETE /api/v1/variables/MISSING", + ) + .await; +} + +#[tokio::test] +async fn run_config_substitutes_variables_before_persisting_settings() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + + let create_variable = app + .clone() + .oneshot(json_request( + Method::POST, + "/variables", + &serde_json::json!({ + "name": "RUNTIME_TOKEN", + "value": "token-from-variable" + }), + )) + .await + .expect("POST /variables should route"); + response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await; + + let mut manifest = minimal_manifest_json(MINIMAL_DOT); + manifest["configs"] = serde_json::json!([{ + "type": "project", + "path": ".fabro/project.toml", + "source": r#" +_version = 1 + +[run.environment] +id = "local" + +[environments.local] +provider = "local" + +[environments.local.env] +RUNTIME_TOKEN = "{{ vars.RUNTIME_TOKEN }}" +"# + }]); + + let create_run = app + .clone() + .oneshot(json_request(Method::POST, "/runs", &manifest)) + .await + .expect("POST /runs should route"); + let create_status = create_run.status(); + let create_body = body_json(create_run.into_body()).await; + assert_eq!(create_status, StatusCode::CREATED, "{create_body}"); + let run_id = create_body["id"] + .as_str() + .expect("create run response should include id"); + + let settings = app + .oneshot(empty_request( + Method::GET, + &format!("/runs/{run_id}/settings"), + )) + .await + .expect("GET run settings should route"); + let body = response_json( + settings, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/settings"), + ) + .await; + + assert_eq!( + body["run"]["environment"]["env"]["RUNTIME_TOKEN"], + "token-from-variable" + ); +} diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 318f982ad..31103c13a 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -47,6 +47,7 @@ pub mod system_integrations; pub mod timing; pub mod todo; pub mod transcript; +pub mod variable; pub use artifact::ArtifactUpload; pub use auth::{IdpIdentity, IdpIdentityError}; @@ -157,3 +158,6 @@ pub use transcript::{ AudioData, ContentPart, DocumentData, ImageData, MessageId, MessageKind, MessageSource, PairMessageRef, ThinkingData, ToolCall, ToolResult, TranscriptMessage, }; +pub use variable::{ + CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name, +}; diff --git a/lib/crates/fabro-types/src/settings/interp.rs b/lib/crates/fabro-types/src/settings/interp.rs index f3aef95fc..2eec30a5a 100644 --- a/lib/crates/fabro-types/src/settings/interp.rs +++ b/lib/crates/fabro-types/src/settings/interp.rs @@ -1,8 +1,9 @@ -//! Env var interpolation for config strings. +//! Env var and run variable interpolation for config strings. //! //! Any string field may use `{{ env.NAME }}` tokens, either as a whole value or -//! as one or more substrings inside a larger string. Resolution happens only -//! when the field is consumed, and provenance tracking lets outward-facing +//! as one or more substrings inside a larger string. Run-scoped settings may +//! additionally use non-sensitive `{{ vars.NAME }}` tokens. Resolution happens +//! only when the field is consumed, and provenance tracking lets outward-facing //! renderers redact env-sourced values uniformly. use std::fmt; @@ -10,7 +11,10 @@ use std::fmt; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -/// A config string that may contain `{{ env.NAME }}` tokens. +use crate::variable::is_env_style_name; + +/// A config string that may contain `{{ env.NAME }}` or `{{ vars.NAME }}` +/// tokens. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InterpString { segments: Vec, @@ -20,6 +24,7 @@ pub struct InterpString { enum Segment { Literal(String), EnvVar(String), + Variable(String), } impl InterpString { @@ -30,7 +35,9 @@ impl InterpString { match segments.last_mut() { Some(Segment::Literal(existing)) => existing.push_str(text), - Some(Segment::EnvVar(_)) | None => segments.push(Segment::Literal(text.to_owned())), + Some(Segment::EnvVar(_) | Segment::Variable(_)) | None => { + segments.push(Segment::Literal(text.to_owned())); + } } } @@ -47,6 +54,16 @@ impl InterpString { Some(name.to_owned()) } + fn parse_vars_token(token: &str) -> Option { + let trimmed = token.trim(); + let name = trimmed.strip_prefix("vars.")?; + if is_env_style_name(name) { + Some(name.to_owned()) + } else { + None + } + } + /// Parse a raw string into its literal/env-var segments. /// /// The [`From`] and [`From<&str>`] impls delegate here. @@ -66,6 +83,8 @@ impl InterpString { let token = &after_open[..close]; if let Some(name) = Self::parse_env_token(token) { segments.push(Segment::EnvVar(name)); + } else if let Some(name) = Self::parse_vars_token(token) { + segments.push(Segment::Variable(name)); } else { Self::push_literal(&mut segments, &rest[start..start + 2 + close + 2]); } @@ -89,7 +108,7 @@ impl InterpString { Self { segments } } - /// True when this string contains no env var tokens. + /// True when this string contains no interpolation tokens. #[must_use] pub fn is_literal(&self) -> bool { self.segments @@ -105,6 +124,14 @@ impl InterpString { .any(|seg| matches!(seg, Segment::EnvVar(_))) } + /// True when this string contains at least one run variable token. + #[must_use] + pub fn references_vars(&self) -> bool { + self.segments + .iter() + .any(|seg| matches!(seg, Segment::Variable(_))) + } + /// The env var names referenced by this string, in source order. #[must_use] pub fn env_var_names(&self) -> Vec<&str> { @@ -112,7 +139,19 @@ impl InterpString { .iter() .filter_map(|seg| match seg { Segment::EnvVar(name) => Some(name.as_str()), - Segment::Literal(_) => None, + Segment::Literal(_) | Segment::Variable(_) => None, + }) + .collect() + } + + /// The run variable names referenced by this string, in source order. + #[must_use] + pub fn var_names(&self) -> Vec<&str> { + self.segments + .iter() + .filter_map(|seg| match seg { + Segment::Variable(name) => Some(name.as_str()), + Segment::Literal(_) | Segment::EnvVar(_) => None, }) .collect() } @@ -129,6 +168,11 @@ impl InterpString { out.push_str(name); out.push_str(" }}"); } + Segment::Variable(name) => { + out.push_str("{{ vars."); + out.push_str(name); + out.push_str(" }}"); + } } } out @@ -151,11 +195,14 @@ impl InterpString { Segment::Literal(text) => value.push_str(text), Segment::EnvVar(name) => { let Some(resolved) = lookup(name) else { - return Err(ResolveEnvError { name: name.clone() }); + return Err(ResolveEnvError::missing_env(name)); }; value.push_str(&resolved); used.push(name.clone()); } + Segment::Variable(name) => { + return Err(ResolveEnvError::unsupported_variable(name)); + } } } @@ -166,6 +213,73 @@ impl InterpString { }; Ok(Resolved { value, provenance }) } + + /// Resolve env and run variable tokens with separate lookup functions. + /// + /// Variables are non-sensitive, so variable-only interpolation does not + /// mark the value as env-sourced for redaction. + pub fn resolve_with_variables( + &self, + mut env_lookup: F, + mut variable_lookup: G, + ) -> Result + where + F: FnMut(&str) -> Option, + G: FnMut(&str) -> Option, + { + let mut value = String::new(); + let mut used_env = Vec::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => value.push_str(text), + Segment::EnvVar(name) => { + let Some(resolved) = env_lookup(name) else { + return Err(ResolveEnvError::missing_env(name)); + }; + value.push_str(&resolved); + used_env.push(name.clone()); + } + Segment::Variable(name) => { + let Some(resolved) = variable_lookup(name) else { + return Err(ResolveEnvError::missing_variable(name)); + }; + value.push_str(&resolved); + } + } + } + + let provenance = if used_env.is_empty() { + Provenance::Literal + } else { + Provenance::EnvSourced { names: used_env } + }; + Ok(Resolved { value, provenance }) + } + + /// Substitute only `{{ vars.* }}` tokens while preserving `{{ env.* }}` + /// tokens for their existing consumption-time env lookup. + pub fn substitute_variables(&self, mut lookup: F) -> Result + where + F: FnMut(&str) -> Option, + { + let mut segments = Vec::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => Self::push_literal(&mut segments, text), + Segment::EnvVar(name) => segments.push(Segment::EnvVar(name.clone())), + Segment::Variable(name) => { + let Some(resolved) = lookup(name) else { + return Err(ResolveEnvError::missing_variable(name)); + }; + Self::push_literal(&mut segments, &resolved); + } + } + } + if segments.is_empty() { + segments.push(Segment::Literal(String::new())); + } + Ok(Self { segments }) + } } impl From for InterpString { @@ -201,15 +315,59 @@ pub enum Provenance { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolveEnvError { pub name: String, + pub kind: ResolveEnvErrorKind, +} + +impl ResolveEnvError { + fn missing_env(name: &str) -> Self { + Self { + name: name.to_string(), + kind: ResolveEnvErrorKind::MissingEnv, + } + } + + fn missing_variable(name: &str) -> Self { + Self { + name: name.to_string(), + kind: ResolveEnvErrorKind::MissingVariable, + } + } + + fn unsupported_variable(name: &str) -> Self { + Self { + name: name.to_string(), + kind: ResolveEnvErrorKind::UnsupportedVariable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveEnvErrorKind { + MissingEnv, + MissingVariable, + UnsupportedVariable, } impl fmt::Display for ResolveEnvError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "environment variable {:?} referenced by {{{{ env.{} }}}} is not set", - self.name, self.name - ) + match self.kind { + ResolveEnvErrorKind::MissingEnv => write!( + f, + "environment variable {:?} referenced by {{{{ env.{} }}}} is not set", + self.name, self.name + ), + ResolveEnvErrorKind::MissingVariable => write!( + f, + "variable {:?} referenced by {{{{ vars.{} }}}} is not set", + self.name, self.name + ), + ResolveEnvErrorKind::UnsupportedVariable => write!( + f, + "variable {:?} referenced by {{{{ vars.{} }}}} is not supported in this \ + interpolation context", + self.name, self.name + ), + } } } @@ -354,4 +512,51 @@ mod tests { let rendered = serde_json::to_string(&parsed).unwrap(); assert_eq!(rendered, input); } + + #[test] + fn vars_reference_round_trips_source() { + let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}"); + + assert_eq!(s.var_names(), vec!["RUNTIME_TOKEN"]); + assert_eq!(s.as_source(), "{{ vars.RUNTIME_TOKEN }}"); + } + + #[test] + fn resolve_with_variables_substitutes_env_and_var_tokens() { + let s = InterpString::parse("https://{{ env.REGION }}.{{ vars.DOMAIN }}"); + + let resolved = s + .resolve_with_variables( + lookup_from(&[("REGION", "us-east-1")]), + lookup_from(&[("DOMAIN", "example.com")]), + ) + .unwrap(); + + assert_eq!(resolved.value, "https://us-east-1.example.com"); + assert_eq!(resolved.provenance, Provenance::EnvSourced { + names: vec!["REGION".into()], + }); + } + + #[test] + fn resolve_with_variables_reports_missing_variable() { + let s = InterpString::parse("{{ vars.MISSING }}"); + + let err = s + .resolve_with_variables(lookup_from(&[]), lookup_from(&[])) + .unwrap_err(); + + assert_eq!(err.name, "MISSING"); + assert_eq!(err.kind, ResolveEnvErrorKind::MissingVariable); + } + + #[test] + fn env_only_resolution_rejects_vars_reference() { + let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}"); + + let err = s.resolve(lookup_from(&[])).unwrap_err(); + + assert_eq!(err.name, "RUNTIME_TOKEN"); + assert_eq!(err.kind, ResolveEnvErrorKind::UnsupportedVariable); + } } diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index a4ff1132c..78a201aa5 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -14,7 +14,7 @@ use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; use super::duration::Duration; -use super::interp::InterpString; +use super::interp::{InterpString, ResolveEnvError}; use super::model_ref::ModelRef; use super::size::Size; @@ -76,6 +76,404 @@ impl Default for RunNamespace { } } +impl RunNamespace { + pub fn substitute_variables(&mut self, mut lookup: F) -> Result<(), ResolveEnvError> + where + F: FnMut(&str) -> Option, + { + substitute_goal(&mut self.goal, &mut lookup)?; + substitute_option(&mut self.working_dir, &mut lookup)?; + substitute_string_map(&mut self.metadata, &mut lookup)?; + substitute_option(&mut self.model.provider, &mut lookup)?; + substitute_option(&mut self.model.name, &mut lookup)?; + substitute_option_string(&mut self.model.controls.reasoning_effort, &mut lookup)?; + substitute_option_string(&mut self.model.controls.speed, &mut lookup)?; + if let Some(author) = &mut self.git.author { + substitute_option(&mut author.name, &mut lookup)?; + substitute_option(&mut author.email, &mut lookup)?; + } + substitute_string_vec(&mut self.checkpoint.exclude_globs, &mut lookup)?; + substitute_environment(&mut self.environment, &mut lookup)?; + substitute_map(&mut self.environment.env, &mut lookup)?; + for route in self.notifications.values_mut() { + substitute_option_string(&mut route.provider, &mut lookup)?; + substitute_string_vec(&mut route.events, &mut lookup)?; + if let Some(slack) = &mut route.slack { + substitute_option(&mut slack.channel, &mut lookup)?; + } + } + substitute_option_string(&mut self.interviews.provider, &mut lookup)?; + if let Some(slack) = &mut self.interviews.slack { + substitute_option(&mut slack.channel, &mut lookup)?; + } + substitute_map(&mut self.integrations.github.permissions, &mut lookup)?; + substitute_option(&mut self.scm.owner, &mut lookup)?; + substitute_option(&mut self.scm.repository, &mut lookup)?; + substitute_string_vec(&mut self.prepare.commands, &mut lookup)?; + for mcp in self.agent.mcps.values_mut() { + substitute_string(&mut mcp.name, &mut lookup)?; + substitute_mcp_transport(&mut mcp.transport, &mut lookup)?; + } + for hook in &mut self.hooks { + substitute_option_string(&mut hook.name, &mut lookup)?; + substitute_option_string(&mut hook.command, &mut lookup)?; + substitute_option_string(&mut hook.matcher, &mut lookup)?; + if let Some(hook_type) = &mut hook.hook_type { + substitute_hook_type(hook_type, &mut lookup)?; + } + } + substitute_option_string(&mut self.scm.provider, &mut lookup)?; + substitute_string_vec(&mut self.artifacts.include, &mut lookup)?; + Ok(()) + } +} + +fn substitute_goal(goal: &mut Option, lookup: &mut F) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match goal { + Some(RunGoal::Inline(value) | RunGoal::File(value)) => substitute(value, lookup), + None => Ok(()), + } +} + +fn substitute_option( + value: &mut Option, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match value { + Some(value) => substitute(value, lookup), + None => Ok(()), + } +} + +fn substitute_map( + values: &mut HashMap, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + for value in values.values_mut() { + substitute(value, lookup)?; + } + Ok(()) +} + +fn substitute(value: &mut InterpString, lookup: &mut F) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + if !value.references_vars() { + return Ok(()); + } + *value = value.substitute_variables(lookup)?; + Ok(()) +} + +fn substitute_string(value: &mut String, lookup: &mut F) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + if !may_reference_variable(value) { + return Ok(()); + } + let parsed = InterpString::parse(value); + if parsed.references_vars() { + *value = parsed.substitute_variables(lookup)?.as_source(); + } + Ok(()) +} + +fn may_reference_variable(value: &str) -> bool { + value.contains("{{") && value.contains("vars.") +} + +fn substitute_option_string( + value: &mut Option, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match value { + Some(value) => substitute_string(value, lookup), + None => Ok(()), + } +} + +fn substitute_string_vec(values: &mut [String], lookup: &mut F) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + for value in values { + substitute_string(value, lookup)?; + } + Ok(()) +} + +fn substitute_string_map( + values: &mut HashMap, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + for value in values.values_mut() { + substitute_string(value, lookup)?; + } + Ok(()) +} + +fn substitute_mcp_transport( + transport: &mut McpTransport, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match transport { + McpTransport::Stdio { command, env } | McpTransport::Sandbox { command, env, .. } => { + substitute_string_vec(command, lookup)?; + substitute_string_map(env, lookup) + } + McpTransport::Http { url, headers, .. } => { + substitute_string(url, lookup)?; + substitute_string_map(headers, lookup) + } + } +} + +fn substitute_environment( + environment: &mut RunEnvironmentSettings, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + substitute_string(&mut environment.id, lookup)?; + substitute_option_string(&mut environment.image.reference, lookup)?; + substitute_dockerfile_source(&mut environment.image.dockerfile, lookup)?; + substitute_string_vec(&mut environment.network.allow, lookup)?; + substitute_string_map(&mut environment.labels, lookup)?; + for volume in &mut environment.volumes { + substitute_string(&mut volume.id, lookup)?; + substitute_string(&mut volume.mount_path, lookup)?; + substitute_option_string(&mut volume.subpath, lookup)?; + } + Ok(()) +} + +fn substitute_dockerfile_source( + source: &mut Option, + lookup: &mut F, +) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match source { + Some(DockerfileSource::Inline(value) | DockerfileSource::Path { path: value }) => { + substitute_string(value, lookup) + } + None => Ok(()), + } +} + +fn substitute_hook_type(hook_type: &mut HookType, lookup: &mut F) -> Result<(), ResolveEnvError> +where + F: FnMut(&str) -> Option, +{ + match hook_type { + HookType::Command { command } => substitute_string(command, lookup), + HookType::Http { url, headers, .. } => { + substitute_string(url, lookup)?; + if let Some(headers) = headers { + substitute_string_map(headers, lookup)?; + } + Ok(()) + } + HookType::Prompt { prompt, model } | HookType::Agent { prompt, model, .. } => { + substitute_string(prompt, lookup)?; + substitute_option_string(model, lookup) + } + } +} + +#[cfg(test)] +mod run_namespace_variable_substitution_tests { + use std::collections::HashMap; + + use super::{ + ArtifactsSettings, DockerfileSource, EnvironmentImageSettings, EnvironmentNetworkMode, + EnvironmentNetworkSettings, EnvironmentVolumeSettings, HookDefinition, HookEvent, HookType, + InterpString, McpHttpProtocol, McpServerSettings, McpTransport, RunCheckpointSettings, + RunEnvironmentSettings, RunGoal, RunNamespace, RunPrepareSettings, + }; + + #[test] + fn substitutes_variables_in_interp_and_late_bound_run_strings() { + let mut run = RunNamespace { + goal: Some(RunGoal::Inline(InterpString::parse( + "deploy {{ vars.ENV }} in {{ env.REGION }}", + ))), + working_dir: Some(InterpString::parse("/workspace/{{ vars.ENV }}")), + prepare: RunPrepareSettings { + commands: vec!["echo {{ vars.ENV }} {{ env.REGION }}".to_string()], + timeout_ms: 1_000, + }, + agent: super::RunAgentSettings { + mcps: HashMap::from([("http".to_string(), McpServerSettings { + name: "http".to_string(), + transport: McpTransport::Http { + protocol: McpHttpProtocol::default(), + url: "https://{{ vars.HOST }}/mcp".to_string(), + headers: HashMap::from([( + "X-Env".to_string(), + "{{ vars.ENV }}".to_string(), + )]), + }, + current_dir: None, + clear_env: false, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + })]), + ..super::RunAgentSettings::default() + }, + hooks: vec![HookDefinition { + name: Some("notify".to_string()), + event: HookEvent::RunComplete, + command: None, + hook_type: Some(HookType::Http { + url: "https://hooks.example/{{ vars.ENV }}".to_string(), + headers: Some(HashMap::from([( + "X-Env".to_string(), + "{{ vars.ENV }}".to_string(), + )])), + allowed_env_vars: Vec::new(), + tls: super::TlsMode::Verify, + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }], + ..RunNamespace::default() + }; + + run.substitute_variables(|name| match name { + "ENV" => Some("prod".to_string()), + "HOST" => Some("mcp.example".to_string()), + _ => None, + }) + .unwrap(); + + let goal_source = match run.goal.as_ref() { + Some(RunGoal::Inline(value) | RunGoal::File(value)) => Some(value.as_source()), + None => None, + }; + assert_eq!( + goal_source, + Some("deploy prod in {{ env.REGION }}".to_string()) + ); + assert_eq!( + run.working_dir.as_ref().map(InterpString::as_source), + Some("/workspace/prod".to_string()) + ); + assert_eq!(run.prepare.commands, vec![ + "echo prod {{ env.REGION }}".to_string() + ]); + let mcp = &run.agent.mcps["http"]; + match &mcp.transport { + McpTransport::Http { url, headers, .. } => { + assert_eq!(url, "https://mcp.example/mcp"); + assert_eq!(headers.get("X-Env").map(String::as_str), Some("prod")); + } + other => panic!("expected http mcp transport, got {other:?}"), + } + match run.hooks[0].hook_type.as_ref().unwrap() { + HookType::Http { url, headers, .. } => { + assert_eq!(url, "https://hooks.example/prod"); + assert_eq!( + headers + .as_ref() + .and_then(|headers| headers.get("X-Env")) + .map(String::as_str), + Some("prod") + ); + } + other => panic!("expected http hook type, got {other:?}"), + } + } + + #[test] + fn substitutes_variables_in_string_backed_settings_families() { + let mut run = RunNamespace { + checkpoint: RunCheckpointSettings { + exclude_globs: vec!["tmp/{{ vars.ENV }}/**".to_string()], + skip_git_hooks: false, + }, + environment: RunEnvironmentSettings { + image: EnvironmentImageSettings { + reference: Some("registry.example/{{ vars.ENV }}:latest".to_string()), + dockerfile: Some(DockerfileSource::Inline( + "FROM registry.example/base:{{ vars.ENV }}".to_string(), + )), + }, + network: EnvironmentNetworkSettings { + mode: EnvironmentNetworkMode::CidrAllowList, + allow: vec!["{{ vars.CIDR }}".to_string()], + }, + labels: HashMap::from([("deploy-env".to_string(), "{{ vars.ENV }}".to_string())]), + volumes: vec![EnvironmentVolumeSettings { + id: "vol_{{ vars.ENV }}".to_string(), + mount_path: "/mnt/{{ vars.ENV }}".to_string(), + subpath: Some("cache/{{ vars.ENV }}".to_string()), + }], + ..RunEnvironmentSettings::default() + }, + artifacts: ArtifactsSettings { + include: vec!["reports/{{ vars.ENV }}/**".to_string()], + }, + ..RunNamespace::default() + }; + + run.substitute_variables(|name| match name { + "CIDR" => Some("10.0.0.0/8".to_string()), + "ENV" => Some("prod".to_string()), + _ => None, + }) + .unwrap(); + + assert_eq!(run.checkpoint.exclude_globs, vec!["tmp/prod/**"]); + assert_eq!( + run.environment.image.reference.as_deref(), + Some("registry.example/prod:latest") + ); + assert_eq!( + run.environment.image.dockerfile, + Some(DockerfileSource::Inline( + "FROM registry.example/base:prod".to_string() + )) + ); + assert_eq!(run.environment.network.allow, vec!["10.0.0.0/8"]); + assert_eq!( + run.environment.labels.get("deploy-env").map(String::as_str), + Some("prod") + ); + assert_eq!(run.environment.volumes[0].id, "vol_prod"); + assert_eq!(run.environment.volumes[0].mount_path, "/mnt/prod"); + assert_eq!( + run.environment.volumes[0].subpath.as_deref(), + Some("cache/prod") + ); + assert_eq!(run.artifacts.include, vec!["reports/prod/**"]); + } +} + /// `[run.integrations]` — run-level integration knobs. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunIntegrationsSettings { diff --git a/lib/crates/fabro-types/src/variable.rs b/lib/crates/fabro-types/src/variable.rs new file mode 100644 index 000000000..67a7f836c --- /dev/null +++ b/lib/crates/fabro-types/src/variable.rs @@ -0,0 +1,59 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[must_use] +pub fn is_env_style_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphabetic() || first == '_' => {} + _ => return false, + } + + chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Variable { + pub name: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VariableListResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CreateVariableRequest { + pub name: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UpdateVariableRequest { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[cfg(test)] +mod tests { + use super::is_env_style_name; + + #[test] + fn env_style_names_match_variable_store_contract() { + for valid in ["A", "_A", "A_123"] { + assert!(is_env_style_name(valid), "{valid} should be accepted"); + } + + for invalid in ["", "1BAD", "bad-name", "BAD.NAME"] { + assert!(!is_env_style_name(invalid), "{invalid} should be rejected"); + } + } +} diff --git a/lib/crates/fabro-variable/Cargo.toml b/lib/crates/fabro-variable/Cargo.toml new file mode 100644 index 000000000..d5648b07a --- /dev/null +++ b/lib/crates/fabro-variable/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "fabro-variable" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Workflow-visible non-sensitive variables for Fabro" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +fabro-types = { path = "../fabro-types" } +serde.workspace = true +serde_json.workspace = true +ulid.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/lib/crates/fabro-variable/src/lib.rs b/lib/crates/fabro-variable/src/lib.rs new file mode 100644 index 000000000..1b15f3249 --- /dev/null +++ b/lib/crates/fabro-variable/src/lib.rs @@ -0,0 +1,218 @@ +#![expect( + clippy::disallowed_methods, + reason = "fabro-variable: sync JSON-file storage; not used on a Tokio hot path" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::{fmt, io}; + +use chrono::{DateTime, Utc}; +use fabro_types::{Variable, is_env_style_name}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct VariableEntry { + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Debug)] +pub enum Error { + InvalidName(String), + NotFound(String), + Io(std::io::Error), + Serde(serde_json::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName(name) => write!(f, "invalid variable name: {name}"), + Self::NotFound(name) => write!(f, "variable not found: {name}"), + Self::Io(err) => write!(f, "{err}"), + Self::Serde(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Serde(err) => Some(err), + Self::InvalidName(_) | Self::NotFound(_) => None, + } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Self::Serde(value) + } +} + +#[derive(Debug)] +pub struct VariableStore { + path: PathBuf, + entries: HashMap, +} + +impl VariableStore { + pub fn load(path: PathBuf) -> Result { + let entries = match std::fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(), + Err(err) => return Err(io_context("read variables", &path, &err).into()), + }; + + Ok(Self { path, entries }) + } + + pub fn set( + &mut self, + name: &str, + value: &str, + description: Option<&str>, + ) -> Result { + self.set_with_policy(name, value, description, false) + } + + pub fn update_existing( + &mut self, + name: &str, + value: &str, + description: Option<&str>, + ) -> Result { + self.set_with_policy(name, value, description, true) + } + + fn set_with_policy( + &mut self, + name: &str, + value: &str, + description: Option<&str>, + require_existing: bool, + ) -> Result { + Self::validate_name(name)?; + + let now = Utc::now(); + let existing = self.entries.get(name); + if require_existing && existing.is_none() { + return Err(Error::NotFound(name.to_string())); + } + let (created_at, description) = existing.map_or_else( + || (now, description.map(str::to_string)), + |entry| { + ( + entry.created_at, + description + .map(str::to_string) + .or_else(|| entry.description.clone()), + ) + }, + ); + let entry = VariableEntry { + value: value.to_string(), + description: description.clone(), + created_at, + updated_at: now, + }; + self.entries.insert(name.to_string(), entry); + self.write_atomic()?; + + Ok(Variable { + name: name.to_string(), + value: value.to_string(), + description, + created_at, + updated_at: now, + }) + } + + pub fn get(&self, name: &str) -> Option { + self.entries + .get(name) + .map(|entry| variable_from_entry(name, entry)) + } + + pub fn get_value(&self, name: &str) -> Option<&str> { + self.entries.get(name).map(|entry| entry.value.as_str()) + } + + pub fn list(&self) -> Vec { + let mut data = self + .entries + .iter() + .map(|(name, entry)| variable_from_entry(name, entry)) + .collect::>(); + data.sort_by(|a, b| a.name.cmp(&b.name)); + data + } + + pub fn remove(&mut self, name: &str) -> Result<(), Error> { + Self::validate_name(name)?; + if self.entries.remove(name).is_none() { + return Err(Error::NotFound(name.to_string())); + } + self.write_atomic()?; + Ok(()) + } + + pub fn validate_name(name: &str) -> Result<(), Error> { + if is_env_style_name(name) { + Ok(()) + } else { + Err(Error::InvalidName(name.to_string())) + } + } + + fn write_atomic(&self) -> Result<(), Error> { + let parent = self + .path + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + std::fs::create_dir_all(&parent) + .map_err(|err| io_context("create variables directory", &parent, &err))?; + + let file_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("variables.json"); + let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new())); + let json = serde_json::to_vec_pretty(&self.entries)?; + std::fs::write(&tmp_path, json) + .map_err(|err| io_context("write variables temp file", &tmp_path, &err))?; + std::fs::rename(&tmp_path, &self.path).map_err(|err| { + io_context( + &format!("rename variables temp file to {}", self.path.display()), + &tmp_path, + &err, + ) + })?; + Ok(()) + } +} + +fn variable_from_entry(name: &str, entry: &VariableEntry) -> Variable { + Variable { + name: name.to_string(), + value: entry.value.clone(), + description: entry.description.clone(), + created_at: entry.created_at, + updated_at: entry.updated_at, + } +} + +fn io_context(op: &str, path: &Path, source: &io::Error) -> io::Error { + io::Error::new(source.kind(), format!("{op} {}: {source}", path.display())) +} diff --git a/lib/crates/fabro-variable/tests/store.rs b/lib/crates/fabro-variable/tests/store.rs new file mode 100644 index 000000000..f88aa1a20 --- /dev/null +++ b/lib/crates/fabro-variable/tests/store.rs @@ -0,0 +1,111 @@ +use fabro_variable::{Error, VariableStore}; + +#[test] +fn load_missing_file_returns_empty_store() { + let dir = tempfile::tempdir().unwrap(); + let store = VariableStore::load(dir.path().join("variables.json")).unwrap(); + + assert!(store.list().is_empty()); +} + +#[test] +fn set_get_list_and_reload_variables() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("variables.json"); + let mut store = VariableStore::load(path.clone()).unwrap(); + + let first = store.set("ZETA", "last", Some("Last variable")).unwrap(); + let second = store.set("ALPHA", "", None).unwrap(); + + assert_eq!(first.name, "ZETA"); + assert_eq!(first.value, "last"); + assert_eq!(first.description.as_deref(), Some("Last variable")); + assert_eq!(second.value, ""); + assert_eq!(store.get("ZETA").unwrap().value, "last"); + assert_eq!( + store + .list() + .into_iter() + .map(|variable| variable.name) + .collect::>(), + vec!["ALPHA", "ZETA"] + ); + + let reloaded = VariableStore::load(path).unwrap(); + assert_eq!(reloaded.get("ALPHA").unwrap().value, ""); + assert_eq!( + reloaded.get("ZETA").unwrap().description.as_deref(), + Some("Last variable") + ); +} + +#[test] +fn upsert_preserves_description_when_omitted_and_updates_when_present() { + let dir = tempfile::tempdir().unwrap(); + let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap(); + + let created = store + .set("DEPLOY_ENV", "staging", Some("Deployment target")) + .unwrap(); + let preserved = store.set("DEPLOY_ENV", "production", None).unwrap(); + let updated = store + .set("DEPLOY_ENV", "preview", Some("Preview target")) + .unwrap(); + + assert_eq!(preserved.created_at, created.created_at); + assert_eq!(preserved.description.as_deref(), Some("Deployment target")); + assert_eq!(updated.description.as_deref(), Some("Preview target")); + assert!(updated.updated_at >= preserved.updated_at); +} + +#[test] +fn update_existing_preserves_description_and_reports_missing() { + let dir = tempfile::tempdir().unwrap(); + let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap(); + + let created = store + .set("DEPLOY_ENV", "staging", Some("Deployment target")) + .unwrap(); + let updated = store + .update_existing("DEPLOY_ENV", "production", None) + .unwrap(); + + assert_eq!(updated.created_at, created.created_at); + assert_eq!(updated.value, "production"); + assert_eq!(updated.description.as_deref(), Some("Deployment target")); + assert!(matches!( + store.update_existing("MISSING", "value", None), + Err(Error::NotFound(name)) if name == "MISSING" + )); +} + +#[test] +fn remove_deletes_variable_and_reports_missing() { + let dir = tempfile::tempdir().unwrap(); + let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap(); + store.set("DEPLOY_ENV", "staging", None).unwrap(); + + store.remove("DEPLOY_ENV").unwrap(); + + assert!(store.get("DEPLOY_ENV").is_none()); + assert!(matches!( + store.remove("DEPLOY_ENV"), + Err(Error::NotFound(name)) if name == "DEPLOY_ENV" + )); +} + +#[test] +fn env_style_names_are_required() { + let dir = tempfile::tempdir().unwrap(); + let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap(); + + for invalid in ["", "1BAD", "bad-name", "BAD.NAME"] { + assert!(matches!( + store.set(invalid, "value", None), + Err(Error::InvalidName(name)) if name == invalid + )); + } + + store.set("_OK", "value", None).unwrap(); + store.set("OK_123", "value", None).unwrap(); +} diff --git a/lib/crates/fabro-vault/src/lib.rs b/lib/crates/fabro-vault/src/lib.rs index caf697577..c0aaccfb2 100644 --- a/lib/crates/fabro-vault/src/lib.rs +++ b/lib/crates/fabro-vault/src/lib.rs @@ -9,8 +9,8 @@ use std::{fmt, io}; use chrono::{DateTime, Utc}; use fabro_static::EnvVars; -use fabro_types::SecretMetadata; pub use fabro_types::SecretType; +use fabro_types::{SecretMetadata, is_env_style_name}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SecretEntry { @@ -164,13 +164,7 @@ impl Vault { } fn validate_env_name(name: &str) -> Result<(), Error> { - let mut chars = name.chars(); - match chars.next() { - Some(first) if first.is_ascii_alphabetic() || first == '_' => {} - _ => return Err(Error::InvalidName(name.to_string())), - } - - if chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { + if is_env_style_name(name) { Ok(()) } else { Err(Error::InvalidName(name.to_string())) diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 8e4fa7d1d..a3e517248 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -17,6 +17,7 @@ api/secrets-api.ts api/sessions-api.ts api/settings-api.ts api/system-api.ts +api/variables-api.ts api/workflows-api.ts base.ts common.ts @@ -87,6 +88,7 @@ models/create-completion-request.ts models/create-run-pull-request-request.ts models/create-run-session-request.ts models/create-secret-request.ts +models/create-variable-request.ts models/delete-run-response.ts models/delete-run-sandbox.ts models/delete-secret-request.ts @@ -482,8 +484,11 @@ models/todo-projection.ts models/todo-status.ts models/update-run-parent-request.ts models/update-run-request.ts +models/update-variable-request.ts models/user-response.ts models/validate-response.ts +models/variable-list-response.ts +models/variable.ts models/vnc-preview-response.ts models/webhook-strategy.ts models/workflow-detail-response.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index 22fabf4d5..3ca0a417f 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -32,4 +32,5 @@ export * from './api/secrets-api'; export * from './api/sessions-api'; export * from './api/settings-api'; export * from './api/system-api'; +export * from './api/variables-api'; export * from './api/workflows-api'; diff --git a/lib/packages/fabro-api-client/src/api/variables-api.ts b/lib/packages/fabro-api-client/src/api/variables-api.ts new file mode 100644 index 000000000..0542e1110 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/variables-api.ts @@ -0,0 +1,435 @@ +/* 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. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { CreateVariableRequest } from '../models'; +// @ts-ignore +import type { ErrorResponse } from '../models'; +// @ts-ignore +import type { UpdateVariableRequest } from '../models'; +// @ts-ignore +import type { Variable } from '../models'; +// @ts-ignore +import type { VariableListResponse } from '../models'; +/** + * VariablesApi - axios parameter creator + */ +export const VariablesApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Stores a non-sensitive variable for run config interpolation. + * @summary Store or update a variable + * @param {CreateVariableRequest} createVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createVariable: async (createVariableRequest: CreateVariableRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'createVariableRequest' is not null or undefined + assertParamExists('createVariable', 'createVariableRequest', createVariableRequest) + const localVarPath = `/api/v1/variables`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createVariableRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Delete a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteVariable: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('deleteVariable', 'name', name) + const localVarPath = `/api/v1/variables/{name}` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getVariable: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('getVariable', 'name', name) + const localVarPath = `/api/v1/variables/{name}` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Returns non-sensitive variables, including values. + * @summary List variables + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listVariables: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/variables`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Replaces a variable value and preserves the existing description when omitted. + * @summary Replace a variable value + * @param {string} name Variable name. + * @param {UpdateVariableRequest} updateVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateVariable: async (name: string, updateVariableRequest: UpdateVariableRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('updateVariable', 'name', name) + // verify required parameter 'updateVariableRequest' is not null or undefined + assertParamExists('updateVariable', 'updateVariableRequest', updateVariableRequest) + const localVarPath = `/api/v1/variables/{name}` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateVariableRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * VariablesApi - functional programming interface + */ +export const VariablesApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = VariablesApiAxiosParamCreator(configuration) + return { + /** + * Stores a non-sensitive variable for run config interpolation. + * @summary Store or update a variable + * @param {CreateVariableRequest} createVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createVariable(createVariableRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['VariablesApi.createVariable']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Delete a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteVariable(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteVariable(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['VariablesApi.deleteVariable']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getVariable(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getVariable(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['VariablesApi.getVariable']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Returns non-sensitive variables, including values. + * @summary List variables + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listVariables(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listVariables(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['VariablesApi.listVariables']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Replaces a variable value and preserves the existing description when omitted. + * @summary Replace a variable value + * @param {string} name Variable name. + * @param {UpdateVariableRequest} updateVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateVariable(name, updateVariableRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['VariablesApi.updateVariable']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * VariablesApi - factory interface + */ +export const VariablesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = VariablesApiFp(configuration) + return { + /** + * Stores a non-sensitive variable for run config interpolation. + * @summary Store or update a variable + * @param {CreateVariableRequest} createVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createVariable(createVariableRequest, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Delete a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteVariable(name: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.deleteVariable(name, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getVariable(name: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getVariable(name, options).then((request) => request(axios, basePath)); + }, + /** + * Returns non-sensitive variables, including values. + * @summary List variables + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listVariables(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listVariables(options).then((request) => request(axios, basePath)); + }, + /** + * Replaces a variable value and preserves the existing description when omitted. + * @summary Replace a variable value + * @param {string} name Variable name. + * @param {UpdateVariableRequest} updateVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.updateVariable(name, updateVariableRequest, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * VariablesApi - object-oriented interface + */ +export class VariablesApi extends BaseAPI { + /** + * Stores a non-sensitive variable for run config interpolation. + * @summary Store or update a variable + * @param {CreateVariableRequest} createVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig) { + return VariablesApiFp(this.configuration).createVariable(createVariableRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Delete a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public deleteVariable(name: string, options?: RawAxiosRequestConfig) { + return VariablesApiFp(this.configuration).deleteVariable(name, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get a variable + * @param {string} name Variable name. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getVariable(name: string, options?: RawAxiosRequestConfig) { + return VariablesApiFp(this.configuration).getVariable(name, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Returns non-sensitive variables, including values. + * @summary List variables + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listVariables(options?: RawAxiosRequestConfig) { + return VariablesApiFp(this.configuration).listVariables(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Replaces a variable value and preserves the existing description when omitted. + * @summary Replace a variable value + * @param {string} name Variable name. + * @param {UpdateVariableRequest} updateVariableRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig) { + return VariablesApiFp(this.configuration).updateVariable(name, updateVariableRequest, options).then((request) => request(this.axios, this.basePath)); + } +} diff --git a/lib/packages/fabro-api-client/src/models/create-variable-request.ts b/lib/packages/fabro-api-client/src/models/create-variable-request.ts new file mode 100644 index 000000000..f33de18d1 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-variable-request.ts @@ -0,0 +1,33 @@ +/* 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. + */ + + + +/** + * Request to store or update a variable. + */ +export interface CreateVariableRequest { + /** + * Env-style variable name. + */ + 'name': string; + /** + * Variable value. Empty values are allowed. + */ + 'value': string; + /** + * Optional operator-facing description of the variable. + */ + 'description'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 4a208f5e3..2ee3e1593 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -63,6 +63,7 @@ export * from './create-completion-request'; export * from './create-run-pull-request-request'; export * from './create-run-session-request'; export * from './create-secret-request'; +export * from './create-variable-request'; export * from './delete-run-response'; export * from './delete-run-sandbox'; export * from './delete-secret-request'; @@ -457,8 +458,11 @@ export * from './todo-projection'; export * from './todo-status'; export * from './update-run-parent-request'; export * from './update-run-request'; +export * from './update-variable-request'; export * from './user-response'; export * from './validate-response'; +export * from './variable'; +export * from './variable-list-response'; export * from './vnc-preview-response'; export * from './webhook-strategy'; export * from './workflow-detail-response'; diff --git a/lib/packages/fabro-api-client/src/models/update-variable-request.ts b/lib/packages/fabro-api-client/src/models/update-variable-request.ts new file mode 100644 index 000000000..71d925149 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/update-variable-request.ts @@ -0,0 +1,29 @@ +/* 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. + */ + + + +/** + * Request to update a variable. + */ +export interface UpdateVariableRequest { + /** + * Replacement value. Empty values are allowed. + */ + 'value': string; + /** + * Optional operator-facing description. Omitted descriptions preserve the existing value. + */ + 'description'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/variable-list-response.ts b/lib/packages/fabro-api-client/src/models/variable-list-response.ts new file mode 100644 index 000000000..b4ed0ab0a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/variable-list-response.ts @@ -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 { Variable } from './variable'; + +/** + * List of stored variables. + */ +export interface VariableListResponse { + 'data': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/variable.ts b/lib/packages/fabro-api-client/src/models/variable.ts new file mode 100644 index 000000000..a5d39e711 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/variable.ts @@ -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. + */ + + + +/** + * Non-sensitive variable available for run config interpolation. + */ +export interface Variable { + /** + * Env-style variable name. + */ + 'name': string; + /** + * Variable value. + */ + 'value': string; + /** + * Optional operator-facing description of the variable. + */ + 'description'?: string; + /** + * When the variable was first stored. + */ + 'created_at': string; + /** + * When the variable was last updated. + */ + 'updated_at': string; +}