mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(server): add variables API (#430)
## Summary
Adds a workflow-visible variables store and HTTP API for managing
non-sensitive run variables, then wires those variables into run config
interpolation before run creation, validation, and preflight.
## What Changed
- Adds `/api/v1/variables` CRUD endpoints backed by a JSON variable
store and generated Rust/TypeScript API types.
- Supports `{{ vars.NAME }}` interpolation alongside existing `{{
env.NAME }}` handling for run-owned config fields, including
environment, MCP, hook, artifact, checkpoint, SCM, and notification
settings.
- Reuses canonical `fabro-types` variable DTOs in `fabro-api` and adds
OpenAPI name patterns so clients see the same env-style variable
contract enforced by the server.
- Keeps variable updates store-owned with `update_existing`, avoiding
duplicated not-found/update semantics in the HTTP handler.
- Shares env-style name validation between variables, interpolation
parsing, and vault token names to avoid grammar drift.
Variables are intentionally non-sensitive: list/get responses include
values, unlike vault secrets.
## Validation
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo test -p fabro-types`
- `cargo test -p fabro-variable`
- `cargo test -p fabro-api --test variable_round_trip`
- `cargo test -p fabro-server --features test-support --test it
api::variables`
- `cargo +nightly-2026-04-14 clippy -p fabro-types -p fabro-variable -p
fabro-vault --all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy -p fabro-server --features
test-support --all-targets -- -D warnings`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex/)
This commit is contained in:
parent
8bca376f35
commit
b1bd2f522c
32 changed files with 2417 additions and 37 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
79
lib/crates/fabro-api/tests/variable_round_trip.rs
Normal file
79
lib/crates/fabro-api/tests/variable_round_trip.rs
Normal file
|
|
@ -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::<ApiVariable, Variable>();
|
||||
assert_same_type::<ApiVariableListResponse, VariableListResponse>();
|
||||
assert_same_type::<ApiCreateVariableRequest, CreateVariableRequest>();
|
||||
assert_same_type::<ApiUpdateVariableRequest, UpdateVariableRequest>();
|
||||
}
|
||||
|
||||
#[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<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<AsyncRwLock<Vault>>,
|
||||
pub(crate) variables: Arc<AsyncRwLock<VariableStore>>,
|
||||
pub(super) server_secrets: ServerSecrets,
|
||||
pub(crate) llm_source: Arc<dyn CredentialSource>,
|
||||
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
||||
|
|
@ -1125,6 +1128,7 @@ pub(crate) struct AppStateConfig {
|
|||
pub(crate) store: Arc<Database>,
|
||||
pub(crate) artifact_store: ArtifactStore,
|
||||
pub(crate) vault_path: PathBuf,
|
||||
pub(crate) variables_path: PathBuf,
|
||||
pub(crate) preloaded_vault: Option<Vault>,
|
||||
pub(crate) server_secrets: ServerSecrets,
|
||||
pub(crate) env_lookup: EnvLookup,
|
||||
|
|
@ -2167,6 +2171,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
store,
|
||||
artifact_store,
|
||||
vault_path,
|
||||
variables_path,
|
||||
preloaded_vault,
|
||||
server_secrets,
|
||||
env_lookup,
|
||||
|
|
@ -2177,6 +2182,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
shutdown,
|
||||
} = config;
|
||||
|
||||
let variables = VariableStore::load(variables_path).context("load variables")?;
|
||||
let variables = Arc::new(AsyncRwLock::new(variables));
|
||||
let vault = match preloaded_vault {
|
||||
Some(vault) => vault,
|
||||
None => load_startup_vault(&vault_path)?,
|
||||
|
|
@ -2266,6 +2273,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
pull_request_create_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
parent_link_lock: AsyncMutex::new(()),
|
||||
vault,
|
||||
variables,
|
||||
server_secrets,
|
||||
llm_source,
|
||||
manifest_run_defaults: RwLock::new(current_manifest_run_defaults),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ mod secrets;
|
|||
mod sessions;
|
||||
mod steer;
|
||||
pub(in crate::server) mod system;
|
||||
mod variables;
|
||||
|
||||
pub(super) use system::{health, openapi_spec};
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
|
|||
.merge(graph::run_routes())
|
||||
.merge(models::routes())
|
||||
.merge(secrets::routes())
|
||||
.merge(variables::routes())
|
||||
.merge(sessions::routes())
|
||||
.merge(system::routes())
|
||||
.merge(completions::routes())
|
||||
|
|
|
|||
|
|
@ -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<Arc<AppState>>,
|
||||
|
|
|
|||
128
lib/crates/fabro-server/src/server/handler/variables.rs
Normal file
128
lib/crates/fabro-server/src/server/handler/variables.rs
Normal file
|
|
@ -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<Arc<AppState>> {
|
||||
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<Arc<AppState>>) -> Response {
|
||||
let data = state.variables.read().await.list();
|
||||
(StatusCode::OK, Json(VariableListResponse { data })).into_response()
|
||||
}
|
||||
|
||||
async fn create_variable(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<CreateVariableRequest>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<UpdateVariableRequest>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> 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<Result<Variable, VariableError>, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Arc
|
|||
max_concurrent_runs: 5,
|
||||
store,
|
||||
artifact_store,
|
||||
variables_path: vault_path.with_file_name("variables.json"),
|
||||
vault_path: vault_path.to_path_buf(),
|
||||
preloaded_vault: None,
|
||||
server_secrets: load_test_server_secrets(
|
||||
|
|
@ -5272,6 +5276,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
|
|||
max_concurrent_runs: 5,
|
||||
store,
|
||||
artifact_store,
|
||||
variables_path: vault_path.with_file_name("variables.json"),
|
||||
vault_path,
|
||||
preloaded_vault: None,
|
||||
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ impl TestAppStateBuilder {
|
|||
max_concurrent_runs: self.max_concurrent_runs,
|
||||
store,
|
||||
artifact_store,
|
||||
variables_path: vault_path.with_file_name("variables.json"),
|
||||
vault_path,
|
||||
preloaded_vault: None,
|
||||
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ mod sessions;
|
|||
mod settings;
|
||||
mod system;
|
||||
mod tcp;
|
||||
mod variables;
|
||||
|
|
|
|||
254
lib/crates/fabro-server/tests/it/api/variables.rs
Normal file
254
lib/crates/fabro-server/tests/it/api/variables.rs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{
|
||||
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, response_status,
|
||||
test_app_state,
|
||||
};
|
||||
|
||||
fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request<Body> {
|
||||
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<Body> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Segment>,
|
||||
|
|
@ -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<String> {
|
||||
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<String>`] 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<F, G>(
|
||||
&self,
|
||||
mut env_lookup: F,
|
||||
mut variable_lookup: G,
|
||||
) -> Result<Resolved, ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
G: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<F>(&self, mut lookup: F) -> Result<Self, ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<String> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<F>(&mut self, mut lookup: F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<F>(goal: &mut Option<RunGoal>, lookup: &mut F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
match goal {
|
||||
Some(RunGoal::Inline(value) | RunGoal::File(value)) => substitute(value, lookup),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_option<F>(
|
||||
value: &mut Option<InterpString>,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
match value {
|
||||
Some(value) => substitute(value, lookup),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_map<F>(
|
||||
values: &mut HashMap<String, InterpString>,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
for value in values.values_mut() {
|
||||
substitute(value, lookup)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn substitute<F>(value: &mut InterpString, lookup: &mut F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !value.references_vars() {
|
||||
return Ok(());
|
||||
}
|
||||
*value = value.substitute_variables(lookup)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn substitute_string<F>(value: &mut String, lookup: &mut F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<F>(
|
||||
value: &mut Option<String>,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
match value {
|
||||
Some(value) => substitute_string(value, lookup),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_string_vec<F>(values: &mut [String], lookup: &mut F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
for value in values {
|
||||
substitute_string(value, lookup)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn substitute_string_map<F>(
|
||||
values: &mut HashMap<String, String>,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
for value in values.values_mut() {
|
||||
substitute_string(value, lookup)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn substitute_mcp_transport<F>(
|
||||
transport: &mut McpTransport,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<F>(
|
||||
environment: &mut RunEnvironmentSettings,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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<F>(
|
||||
source: &mut Option<DockerfileSource>,
|
||||
lookup: &mut F,
|
||||
) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
match source {
|
||||
Some(DockerfileSource::Inline(value) | DockerfileSource::Path { path: value }) => {
|
||||
substitute_string(value, lookup)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_hook_type<F>(hook_type: &mut HookType, lookup: &mut F) -> Result<(), ResolveEnvError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
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 {
|
||||
|
|
|
|||
59
lib/crates/fabro-types/src/variable.rs
Normal file
59
lib/crates/fabro-types/src/variable.rs
Normal file
|
|
@ -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<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VariableListResponse {
|
||||
pub data: Vec<Variable>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct UpdateVariableRequest {
|
||||
pub value: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/crates/fabro-variable/Cargo.toml
Normal file
23
lib/crates/fabro-variable/Cargo.toml
Normal file
|
|
@ -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"
|
||||
218
lib/crates/fabro-variable/src/lib.rs
Normal file
218
lib/crates/fabro-variable/src/lib.rs
Normal file
|
|
@ -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<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Serde(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct VariableStore {
|
||||
path: PathBuf,
|
||||
entries: HashMap<String, VariableEntry>,
|
||||
}
|
||||
|
||||
impl VariableStore {
|
||||
pub fn load(path: PathBuf) -> Result<Self, Error> {
|
||||
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<Variable, Error> {
|
||||
self.set_with_policy(name, value, description, false)
|
||||
}
|
||||
|
||||
pub fn update_existing(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<Variable, Error> {
|
||||
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<Variable, Error> {
|
||||
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<Variable> {
|
||||
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<Variable> {
|
||||
let mut data = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|(name, entry)| variable_from_entry(name, entry))
|
||||
.collect::<Vec<_>>();
|
||||
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()))
|
||||
}
|
||||
111
lib/crates/fabro-variable/tests/store.rs
Normal file
111
lib/crates/fabro-variable/tests/store.rs
Normal file
|
|
@ -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<_>>(),
|
||||
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();
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
lib/packages/fabro-api-client/src/api.ts
generated
1
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -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';
|
||||
|
|
|
|||
435
lib/packages/fabro-api-client/src/api/variables-api.ts
generated
Normal file
435
lib/packages/fabro-api-client/src/api/variables-api.ts
generated
Normal file
|
|
@ -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<RequestArgs> => {
|
||||
// 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<RequestArgs> => {
|
||||
// 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<RequestArgs> => {
|
||||
// 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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
// 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<Variable>> {
|
||||
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<void>> {
|
||||
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<Variable>> {
|
||||
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<VariableListResponse>> {
|
||||
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<Variable>> {
|
||||
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<Variable> {
|
||||
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<void> {
|
||||
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<Variable> {
|
||||
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<VariableListResponse> {
|
||||
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<Variable> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
33
lib/packages/fabro-api-client/src/models/create-variable-request.ts
generated
Normal file
33
lib/packages/fabro-api-client/src/models/create-variable-request.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
29
lib/packages/fabro-api-client/src/models/update-variable-request.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/update-variable-request.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
25
lib/packages/fabro-api-client/src/models/variable-list-response.ts
generated
Normal file
25
lib/packages/fabro-api-client/src/models/variable-list-response.ts
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { Variable } from './variable';
|
||||
|
||||
/**
|
||||
* List of stored variables.
|
||||
*/
|
||||
export interface VariableListResponse {
|
||||
'data': Array<Variable>;
|
||||
}
|
||||
41
lib/packages/fabro-api-client/src/models/variable.ts
generated
Normal file
41
lib/packages/fabro-api-client/src/models/variable.ts
generated
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue