mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Merge branch 'dense-default-fabro-settings'
This commit is contained in:
commit
7d01c4e42b
21 changed files with 1252 additions and 366 deletions
|
|
@ -1510,10 +1510,23 @@ paths:
|
|||
operationId: retrieveServerSettings
|
||||
tags: [Settings]
|
||||
summary: Retrieve Server Settings
|
||||
description: Returns the structured server settings.
|
||||
description: >
|
||||
Returns the server settings view selected by the optional `view` query
|
||||
parameter. `view=layer` (the default) returns the current sparse
|
||||
redacted `SettingsLayer` payload. `view=resolved` returns the server's
|
||||
dense resolved settings payload after applying the same redaction
|
||||
policy.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SettingsView"
|
||||
responses:
|
||||
"200":
|
||||
description: Server settings
|
||||
headers:
|
||||
X-Fabro-Settings-View:
|
||||
description: Present with value `resolved` when the response body is the dense resolved settings view.
|
||||
schema:
|
||||
type: string
|
||||
enum: [resolved]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -1549,6 +1562,16 @@ components:
|
|||
type: string
|
||||
example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z
|
||||
|
||||
SettingsView:
|
||||
name: view
|
||||
in: query
|
||||
required: false
|
||||
description: Selects the server settings representation to return.
|
||||
schema:
|
||||
type: string
|
||||
enum: [layer, resolved]
|
||||
default: layer
|
||||
|
||||
StageId:
|
||||
name: stageId
|
||||
in: path
|
||||
|
|
@ -4046,22 +4069,24 @@ components:
|
|||
|
||||
ServerSettings:
|
||||
description: |
|
||||
Non-secret view of the server's effective v2 settings.
|
||||
Redacted server settings payload.
|
||||
|
||||
Wire shape mirrors `fabro_types::settings::SettingsFile` with the
|
||||
secret-bearing subtrees dropped before serialization:
|
||||
The `/api/v1/settings` endpoint supports two response shapes:
|
||||
|
||||
- `server.listen.*` (bind address, TLS key material)
|
||||
- `server.auth.api.{jwt,mtls}` internals
|
||||
- `server.artifacts.s3` / `server.slatedb.s3` credentials
|
||||
- `server.integrations.github.webhooks`, Slack/Discord/Teams tokens
|
||||
- Every `{{ env.NAME }}` InterpString is serialized in its unresolved
|
||||
template form, never the resolved secret value.
|
||||
- `view=layer` (default): the sparse redacted `SettingsLayer` shape
|
||||
- `view=resolved`: the dense resolved `Settings` shape
|
||||
|
||||
The top-level object keys follow the v2 schema: `_version`, `project`,
|
||||
`workflow`, `run`, `cli`, `server`, `features`.
|
||||
Both views drop the same exact secret-bearing paths:
|
||||
|
||||
See `lib/crates/fabro-types/src/settings/tree.rs` for the full type.
|
||||
- `server.listen`
|
||||
- `server.auth.api.jwt.issuer`
|
||||
- `server.auth.api.jwt.audience`
|
||||
- `server.auth.api.mtls.ca`
|
||||
- `server.auth.web.providers.github.client_secret`
|
||||
|
||||
For non-redacted `InterpString` fields, the wire payload preserves the
|
||||
unresolved source/template string rather than any environment-resolved
|
||||
secret value.
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::io::Write;
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
|
||||
use fabro_config::{effective_settings, load_settings_project, project};
|
||||
use fabro_config::{load_and_resolve, load_settings_project, project};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
|
|
@ -57,30 +57,53 @@ fn workflow_and_project_layers(
|
|||
Ok((workflow_layer, project_layer))
|
||||
}
|
||||
|
||||
async fn merged_config(args: &SettingsArgs, printer: Printer) -> anyhow::Result<SettingsLayer> {
|
||||
fn strip_nulls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for child in map.values_mut() {
|
||||
strip_nulls(child);
|
||||
}
|
||||
map.retain(|_, child| !child.is_null());
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for child in values {
|
||||
strip_nulls(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn local_settings_value(
|
||||
args: &SettingsArgs,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let base_ctx = CommandContext::base(printer)?;
|
||||
let layers = config_layers(&base_ctx, args.workflow.as_deref())?;
|
||||
if args.local {
|
||||
return Ok(effective_settings::resolve_settings(
|
||||
layers,
|
||||
None,
|
||||
EffectiveSettingsMode::LocalOnly,
|
||||
)?);
|
||||
}
|
||||
|
||||
let ctx = CommandContext::for_target(&args.target, printer)?;
|
||||
let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?;
|
||||
let server_settings = ctx.server().await?.retrieve_server_settings().await?;
|
||||
let mode = match target {
|
||||
user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer,
|
||||
user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon,
|
||||
};
|
||||
|
||||
Ok(effective_settings::resolve_settings(
|
||||
let mut value = serde_json::to_value(load_and_resolve(
|
||||
layers,
|
||||
Some(&server_settings),
|
||||
mode,
|
||||
)?)
|
||||
None,
|
||||
EffectiveSettingsMode::LocalOnly,
|
||||
)?)?;
|
||||
strip_nulls(&mut value);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn rendered_config(
|
||||
args: &SettingsArgs,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if args.local {
|
||||
return local_settings_value(args, printer);
|
||||
}
|
||||
if args.workflow.is_some() {
|
||||
anyhow::bail!("WORKFLOW requires --local; use `fabro settings --local WORKFLOW`");
|
||||
}
|
||||
let ctx = CommandContext::for_target(&args.target, printer)?;
|
||||
ctx.server()
|
||||
.await?
|
||||
.retrieve_resolved_server_settings()
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
|
|
@ -88,7 +111,7 @@ pub(crate) async fn execute(
|
|||
globals: &GlobalArgs,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = Box::pin(merged_config(args, printer)).await?;
|
||||
let config = Box::pin(rendered_config(args, printer)).await?;
|
||||
if globals.json {
|
||||
print_json_pretty(&config)?;
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -279,16 +279,43 @@ impl ServerStoreClient {
|
|||
&self.base_url
|
||||
}
|
||||
|
||||
pub(crate) async fn retrieve_server_settings(&self) -> Result<SettingsLayer> {
|
||||
let response = self
|
||||
.client
|
||||
.retrieve_server_settings()
|
||||
.send()
|
||||
pub(crate) async fn retrieve_resolved_server_settings(&self) -> Result<serde_json::Value> {
|
||||
let url = format!("{}/api/v1/settings?view=resolved", self.base_url);
|
||||
let response = self.http_client.get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(detail) = value
|
||||
.get("errors")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(|entry| entry.get("detail"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
bail!("{detail}");
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
bail!("request failed with status {status}");
|
||||
}
|
||||
bail!("request failed with status {status}: {body}");
|
||||
}
|
||||
|
||||
let marker = response
|
||||
.headers()
|
||||
.get("x-fabro-settings-view")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
if marker != Some("resolved") {
|
||||
bail!(
|
||||
"server does not support resolved settings view; upgrade the server or use --local"
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
let raw = serde_json::Value::Object(response.into_inner().into());
|
||||
serde_json::from_value::<SettingsLayer>(raw)
|
||||
.context("server returned a settings payload that does not match the v2 schema")
|
||||
.context("server returned invalid JSON for the resolved settings view")
|
||||
}
|
||||
|
||||
pub(crate) async fn create_run_from_manifest(
|
||||
|
|
|
|||
|
|
@ -31,104 +31,104 @@ fn old_config_show_command_is_rejected() {
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_settings(stdout: &[u8]) -> SettingsLayer {
|
||||
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsLayer")
|
||||
fn parse_settings(stdout: &[u8]) -> serde_json::Value {
|
||||
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML settings")
|
||||
}
|
||||
|
||||
fn resolve_cli(settings: &SettingsLayer) -> fabro_types::settings::CliSettings {
|
||||
fabro_config::resolve_cli_from_file(settings).expect("cli settings should resolve")
|
||||
fn parse_settings_json(stdout: &[u8]) -> serde_json::Value {
|
||||
serde_json::from_slice(stdout).expect("stdout should be valid JSON settings")
|
||||
}
|
||||
|
||||
fn resolve_project(settings: &SettingsLayer) -> fabro_types::settings::ProjectSettings {
|
||||
fabro_config::resolve_project_from_file(settings).expect("project settings should resolve")
|
||||
fn run_goal_inline(settings: &serde_json::Value) -> Option<&str> {
|
||||
let goal = settings.get("run")?.get("goal")?;
|
||||
(goal.get("type")?.as_str() == Some("inline"))
|
||||
.then(|| goal.get("value")?.as_str())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn resolve_run(settings: &SettingsLayer) -> fabro_types::settings::RunSettings {
|
||||
fabro_config::resolve_run_from_file(settings).expect("run settings should resolve")
|
||||
fn run_model_name(settings: &serde_json::Value) -> Option<&str> {
|
||||
settings.get("run")?.get("model")?.get("name")?.as_str()
|
||||
}
|
||||
|
||||
fn resolve_server(settings: &SettingsLayer) -> fabro_types::settings::ServerSettings {
|
||||
fabro_config::resolve_server_from_file(settings).expect("server settings should resolve")
|
||||
fn run_model_provider(settings: &serde_json::Value) -> Option<&str> {
|
||||
settings.get("run")?.get("model")?.get("provider")?.as_str()
|
||||
}
|
||||
|
||||
fn run_goal_inline(settings: &SettingsLayer) -> Option<String> {
|
||||
match resolve_run(settings).goal {
|
||||
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_model_name(settings: &SettingsLayer) -> Option<String> {
|
||||
resolve_run(settings)
|
||||
.model
|
||||
.name
|
||||
.as_ref()
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
}
|
||||
|
||||
fn run_model_provider(settings: &SettingsLayer) -> Option<String> {
|
||||
resolve_run(settings)
|
||||
.model
|
||||
.provider
|
||||
.as_ref()
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
}
|
||||
|
||||
fn run_inputs(settings: &SettingsLayer) -> &std::collections::HashMap<String, toml::Value> {
|
||||
fn run_inputs(settings: &serde_json::Value) -> &serde_json::Map<String, serde_json::Value> {
|
||||
settings
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.inputs.as_ref())
|
||||
.get("run")
|
||||
.and_then(|run| run.get("inputs"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("run.inputs")
|
||||
}
|
||||
|
||||
fn run_sandbox(settings: &SettingsLayer) -> &fabro_types::settings::run::RunSandboxLayer {
|
||||
fn run_sandbox(settings: &serde_json::Value) -> &serde_json::Value {
|
||||
settings
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.sandbox.as_ref())
|
||||
.get("run")
|
||||
.and_then(|run| run.get("sandbox"))
|
||||
.expect("run.sandbox")
|
||||
}
|
||||
|
||||
fn run_checkpoint(settings: &SettingsLayer) -> &fabro_types::settings::run::RunCheckpointLayer {
|
||||
fn run_checkpoint(settings: &serde_json::Value) -> &serde_json::Value {
|
||||
settings
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.checkpoint.as_ref())
|
||||
.get("run")
|
||||
.and_then(|run| run.get("checkpoint"))
|
||||
.expect("run.checkpoint")
|
||||
}
|
||||
|
||||
fn run_hooks(settings: &SettingsLayer) -> &[fabro_types::settings::run::HookEntry] {
|
||||
fn run_hooks(settings: &serde_json::Value) -> &[serde_json::Value] {
|
||||
settings
|
||||
.run
|
||||
.as_ref()
|
||||
.map_or(&[], |run| run.hooks.as_slice())
|
||||
.get("run")
|
||||
.and_then(|run| run.get("hooks"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("run.hooks")
|
||||
}
|
||||
|
||||
fn run_agent_mcps(
|
||||
settings: &SettingsLayer,
|
||||
) -> &std::collections::HashMap<String, fabro_types::settings::run::McpEntryLayer> {
|
||||
fn run_agent_mcps(settings: &serde_json::Value) -> &serde_json::Map<String, serde_json::Value> {
|
||||
settings
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.agent.as_ref())
|
||||
.map(|agent| &agent.mcps)
|
||||
.get("run")
|
||||
.and_then(|run| run.get("agent"))
|
||||
.and_then(|agent| agent.get("mcps"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("run.agent.mcps")
|
||||
}
|
||||
|
||||
fn auto_approve_enabled(settings: &SettingsLayer) -> bool {
|
||||
resolve_run(settings).execution.approval == fabro_types::settings::run::ApprovalMode::Auto
|
||||
fn auto_approve_enabled(settings: &serde_json::Value) -> bool {
|
||||
settings
|
||||
.get("run")
|
||||
.and_then(|run| run.get("execution"))
|
||||
.and_then(|execution| execution.get("approval"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("auto")
|
||||
}
|
||||
|
||||
fn run_prepare_commands(settings: &SettingsLayer) -> Vec<String> {
|
||||
resolve_run(settings).prepare.commands
|
||||
fn run_prepare_commands(settings: &serde_json::Value) -> Vec<String> {
|
||||
settings
|
||||
.get("run")
|
||||
.and_then(|run| run.get("prepare"))
|
||||
.and_then(|prepare| prepare.get("commands"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("run.prepare.commands")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.expect("command should be a string")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn server_storage_root(settings: &SettingsLayer) -> String {
|
||||
resolve_server(settings).storage.root.as_source()
|
||||
fn server_storage_root(settings: &serde_json::Value) -> &str {
|
||||
settings
|
||||
.get("server")
|
||||
.and_then(|server| server.get("storage"))
|
||||
.and_then(|storage| storage.get("root"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("server.storage.root")
|
||||
}
|
||||
|
||||
fn server_settings_fixture() -> SettingsLayer {
|
||||
fn server_settings_layer_fixture() -> SettingsLayer {
|
||||
parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
|
@ -148,7 +148,13 @@ shared = "server"
|
|||
.expect("server settings fixture should parse")
|
||||
}
|
||||
|
||||
fn server_settings_body(settings: &SettingsLayer) -> String {
|
||||
fn resolved_server_settings_fixture() -> serde_json::Value {
|
||||
let settings = fabro_config::resolve(&server_settings_layer_fixture())
|
||||
.expect("server settings fixture should resolve");
|
||||
serde_json::to_value(settings).expect("resolved settings payload should serialize")
|
||||
}
|
||||
|
||||
fn server_settings_body(settings: &serde_json::Value) -> String {
|
||||
serde_json::to_string(settings).expect("settings payload should serialize")
|
||||
}
|
||||
|
||||
|
|
@ -383,42 +389,26 @@ fn settings_local_merges_cli_and_project_defaults() {
|
|||
.clone();
|
||||
|
||||
let cfg = parse_settings(&output);
|
||||
assert_eq!(
|
||||
cfg.project
|
||||
.as_ref()
|
||||
.and_then(|project| project.directory.as_deref()),
|
||||
Some(".")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.workflow
|
||||
.as_ref()
|
||||
.and_then(|workflow| workflow.graph.as_deref()),
|
||||
Some("workflow.fabro")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.execution.as_ref())
|
||||
.and_then(|execution| execution.approval),
|
||||
Some(fabro_types::settings::run::ApprovalMode::Prompt)
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.sandbox.as_ref())
|
||||
.and_then(|sandbox| sandbox.provider.as_deref()),
|
||||
Some("daytona")
|
||||
);
|
||||
assert!(cfg.get("_version").is_none());
|
||||
assert_eq!(cfg["project"]["directory"].as_str(), Some("."));
|
||||
assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro"));
|
||||
assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt"));
|
||||
assert_eq!(cfg["run"]["sandbox"]["provider"].as_str(), Some("daytona"));
|
||||
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
|
||||
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
|
||||
assert_eq!(run_goal_inline(&cfg).as_deref(), None);
|
||||
assert_eq!(resolve_project(&cfg).directory, ".");
|
||||
|
||||
// v2 R22: run.inputs replaces the inherited map wholesale rather than
|
||||
// merging by key, so the project layer wipes out the CLI layer's inputs.
|
||||
let vars = run_inputs(&cfg);
|
||||
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
|
||||
assert_eq!(
|
||||
vars.get("project_only").and_then(serde_json::Value::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
vars.get("shared").and_then(serde_json::Value::as_str),
|
||||
Some("project")
|
||||
);
|
||||
assert!(
|
||||
!vars.contains_key("cli_only"),
|
||||
"run.inputs should replace across layers, not merge by key"
|
||||
|
|
@ -427,14 +417,13 @@ fn settings_local_merges_cli_and_project_defaults() {
|
|||
// v2 R71: provider-native maps such as run.sandbox.daytona.labels remain
|
||||
// sticky merge-by-key, so CLI labels persist under the project layer.
|
||||
let sandbox = run_sandbox(&cfg);
|
||||
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
|
||||
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(labels.get("shared").map(String::as_str), Some("cli"));
|
||||
let labels = &sandbox["daytona"]["labels"];
|
||||
assert_eq!(labels["cli_only"].as_str(), Some("1"));
|
||||
assert_eq!(labels["shared"].as_str(), Some("cli"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
||||
use fabro_types::settings::run::McpEntryLayer;
|
||||
let context = test_context!();
|
||||
let project = setup_settings_fixture(&context);
|
||||
|
||||
|
|
@ -461,10 +450,10 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
|||
|
||||
// checkpoint.exclude_globs is a security/policy list: replace by default.
|
||||
let checkpoint = run_checkpoint(&cfg);
|
||||
assert_eq!(checkpoint.exclude_globs, vec![
|
||||
"run-only".to_string(),
|
||||
"shared".to_string()
|
||||
]);
|
||||
assert_eq!(
|
||||
checkpoint["exclude_globs"],
|
||||
serde_json::json!(["run-only", "shared"])
|
||||
);
|
||||
|
||||
// Hooks: id-based replacement. The "shared" hook appears in both cli and
|
||||
// workflow layers and resolves to the workflow entry; project and run-only
|
||||
|
|
@ -473,62 +462,35 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
|||
assert!(hooks.len() >= 2);
|
||||
let shared_hook = hooks
|
||||
.iter()
|
||||
.find(|hook| hook.name.as_deref() == Some("shared"))
|
||||
.find(|hook| hook["name"].as_str() == Some("shared"))
|
||||
.expect("shared hook");
|
||||
assert_eq!(
|
||||
shared_hook
|
||||
.script
|
||||
.as_ref()
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("echo run")
|
||||
);
|
||||
assert_eq!(shared_hook["command"].as_str(), Some("echo run"));
|
||||
assert!(
|
||||
hooks
|
||||
.iter()
|
||||
.any(|hook| hook.name.as_deref() == Some("run-only"))
|
||||
.any(|hook| hook["name"].as_str() == Some("run-only"))
|
||||
);
|
||||
|
||||
let mcps = run_agent_mcps(&cfg);
|
||||
match mcps.get("shared").expect("shared mcp") {
|
||||
McpEntryLayer::Stdio { command, .. } => {
|
||||
let command = command.as_ref().expect("command");
|
||||
let parts: Vec<String> = command
|
||||
.iter()
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
.collect();
|
||||
assert_eq!(parts, vec!["echo".to_string(), "run".to_string()]);
|
||||
}
|
||||
other => panic!("unexpected MCP transport: {other:?}"),
|
||||
}
|
||||
let shared = mcps.get("shared").expect("shared mcp");
|
||||
assert_eq!(shared["transport"]["type"].as_str(), Some("stdio"));
|
||||
assert_eq!(
|
||||
shared["transport"]["command"],
|
||||
serde_json::json!(["echo", "run"])
|
||||
);
|
||||
assert!(mcps.contains_key("run_only"));
|
||||
|
||||
// run.sandbox.daytona.labels stays sticky merge-by-key per R71.
|
||||
let sandbox = run_sandbox(&cfg);
|
||||
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
|
||||
assert_eq!(labels.get("run_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(labels.get("shared").map(String::as_str), Some("run"));
|
||||
let labels = &sandbox["daytona"]["labels"];
|
||||
assert_eq!(labels["run_only"].as_str(), Some("1"));
|
||||
assert_eq!(labels["shared"].as_str(), Some("run"));
|
||||
|
||||
// run.sandbox.env stays sticky merge-by-key per R71.
|
||||
let env = &sandbox.env;
|
||||
assert_eq!(
|
||||
env.get("CLI_ONLY")
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("RUN_ONLY")
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("SHARED")
|
||||
.map(fabro_types::settings::InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("run")
|
||||
);
|
||||
let env = &sandbox["env"];
|
||||
assert_eq!(env["CLI_ONLY"].as_str(), Some("1"));
|
||||
assert_eq!(env["RUN_ONLY"].as_str(), Some("1"));
|
||||
assert_eq!(env["SHARED"].as_str(), Some("run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -557,7 +519,7 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
|
|||
assert_eq!(run_prepare_commands(&cfg), vec![
|
||||
"workflow-setup".to_string()
|
||||
]);
|
||||
assert_eq!(run_sandbox(&cfg).preserve, Some(true));
|
||||
assert_eq!(run_sandbox(&cfg)["preserve"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -706,15 +668,10 @@ name = "legacy-model"
|
|||
.stderr(predicate::str::contains("Rename it to"));
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(
|
||||
resolve_cli(&cfg).output.verbosity,
|
||||
fabro_types::settings::cli::OutputVerbosity::Normal
|
||||
);
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
||||
assert!(
|
||||
cfg.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.model.as_ref())
|
||||
.is_none()
|
||||
cfg["run"]["model"].get("name").is_none(),
|
||||
"resolved dense settings should omit an unset run.model.name"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -746,7 +703,10 @@ shared = "legacy"
|
|||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
|
||||
let vars = run_inputs(&cfg);
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
|
||||
assert_eq!(
|
||||
vars.get("shared").and_then(serde_json::Value::as_str),
|
||||
Some("project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -783,7 +743,8 @@ name = "from-fabro-home"
|
|||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
let cfg: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let cfg = parse_settings_json(&output.stdout);
|
||||
assert!(cfg.get("_version").is_none());
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose"));
|
||||
assert_eq!(
|
||||
cfg["run"]["model"]["name"].as_str(),
|
||||
|
|
@ -831,15 +792,34 @@ fn settings_rejects_local_and_server_combination() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn settings_fetches_server_settings_and_merges_with_local_config() {
|
||||
fn settings_rejects_workflow_without_local() {
|
||||
let context = test_context!();
|
||||
let project = setup_settings_fixture(&context);
|
||||
|
||||
context
|
||||
.settings()
|
||||
.current_dir(project.path())
|
||||
.arg("demo")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(
|
||||
"WORKFLOW requires --local; use `fabro settings --local WORKFLOW`",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_fetches_server_resolved_settings() {
|
||||
let context = test_context!();
|
||||
let project = setup_settings_fixture(&context);
|
||||
let server = MockServer::start();
|
||||
let server_settings = server_settings_fixture();
|
||||
let server_settings = resolved_server_settings_fixture();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method("GET").path("/api/v1/settings");
|
||||
when.method("GET")
|
||||
.path("/api/v1/settings")
|
||||
.query_param("view", "resolved");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Fabro-Settings-View", "resolved")
|
||||
.body(server_settings_body(&server_settings));
|
||||
});
|
||||
context.write_home(
|
||||
|
|
@ -878,42 +858,29 @@ shared = "cli"
|
|||
|
||||
mock.assert();
|
||||
let cfg = parse_settings(&output);
|
||||
assert_eq!(
|
||||
cfg.project
|
||||
.as_ref()
|
||||
.and_then(|project| project.directory.as_deref()),
|
||||
Some(".")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.workflow
|
||||
.as_ref()
|
||||
.and_then(|workflow| workflow.graph.as_deref()),
|
||||
Some("workflow.fabro")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.execution.as_ref())
|
||||
.and_then(|execution| execution.approval),
|
||||
Some(fabro_types::settings::run::ApprovalMode::Prompt)
|
||||
);
|
||||
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
|
||||
assert!(cfg.get("_version").is_none());
|
||||
assert_eq!(cfg["project"]["directory"].as_str(), Some("."));
|
||||
assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro"));
|
||||
assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt"));
|
||||
assert_eq!(run_model_name(&cfg).as_deref(), Some("server-model"));
|
||||
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
|
||||
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
|
||||
assert_eq!(
|
||||
resolve_cli(&cfg).output.verbosity,
|
||||
fabro_types::settings::cli::OutputVerbosity::Verbose
|
||||
);
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
||||
|
||||
// R22: run.inputs replaces wholesale across layers. Project is the
|
||||
// highest-precedence layer that sets inputs, so project's vars win
|
||||
// and server-side vars are discarded rather than merged.
|
||||
// Server-backed mode now returns the selected server's own dense resolved
|
||||
// settings; local project/user overlays are not merged into the output.
|
||||
let vars = run_inputs(&cfg);
|
||||
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
|
||||
assert_eq!(
|
||||
vars.get("server_only").and_then(serde_json::Value::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
vars.get("shared").and_then(serde_json::Value::as_str),
|
||||
Some("server")
|
||||
);
|
||||
assert!(
|
||||
!vars.contains_key("server_only"),
|
||||
"v2 merge matrix replaces run.inputs wholesale; server_only should be dropped"
|
||||
!vars.contains_key("project_only"),
|
||||
"server-backed settings output must not include local workflow/project overlays"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -923,16 +890,21 @@ fn settings_cli_server_target_overrides_configured_server_target() {
|
|||
let project = setup_settings_fixture(&context);
|
||||
let configured_server = MockServer::start();
|
||||
let configured_mock = configured_server.mock(|when, then| {
|
||||
when.method("GET").path("/api/v1/settings");
|
||||
when.method("GET")
|
||||
.path("/api/v1/settings")
|
||||
.query_param("view", "resolved");
|
||||
then.status(500)
|
||||
.body("configured-server-should-not-be-used");
|
||||
});
|
||||
let cli_server = MockServer::start();
|
||||
let cli_server_settings = server_settings_fixture();
|
||||
let cli_server_settings = resolved_server_settings_fixture();
|
||||
let cli_mock = cli_server.mock(|when, then| {
|
||||
when.method("GET").path("/api/v1/settings");
|
||||
when.method("GET")
|
||||
.path("/api/v1/settings")
|
||||
.query_param("view", "resolved");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Fabro-Settings-View", "resolved")
|
||||
.body(server_settings_body(&cli_server_settings));
|
||||
});
|
||||
context.write_home(
|
||||
|
|
@ -968,6 +940,46 @@ verbosity = "verbose"
|
|||
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_errors_when_server_lacks_resolved_view_marker() {
|
||||
let context = test_context!();
|
||||
let project = setup_settings_fixture(&context);
|
||||
let server = MockServer::start();
|
||||
let server_settings = resolved_server_settings_fixture();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/settings")
|
||||
.query_param("view", "resolved");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(server_settings_body(&server_settings));
|
||||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "{}/api/v1"
|
||||
"#,
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
context
|
||||
.settings()
|
||||
.current_dir(project.path())
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(
|
||||
"server does not support resolved settings view; upgrade the server or use --local",
|
||||
));
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_unreachable_http_target_fails_clearly() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -48,8 +48,9 @@ impl EffectiveSettingsLayers {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve layered configuration down to a single effective [`SettingsLayer`].
|
||||
pub fn resolve_settings(
|
||||
/// Materialize layered configuration down to a single effective
|
||||
/// [`SettingsLayer`].
|
||||
pub fn materialize_settings_layer(
|
||||
layers: EffectiveSettingsLayers,
|
||||
server_settings: Option<&SettingsLayer>,
|
||||
mode: EffectiveSettingsMode,
|
||||
|
|
@ -187,7 +188,7 @@ mod tests {
|
|||
use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer};
|
||||
use fabro_types::settings::{InterpString, SettingsLayer};
|
||||
|
||||
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings};
|
||||
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, materialize_settings_layer};
|
||||
use crate::parse::parse_settings_layer;
|
||||
|
||||
fn layer(source: &str) -> SettingsLayer {
|
||||
|
|
@ -196,7 +197,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn local_only_merges_project_and_user_layers() {
|
||||
let settings = resolve_settings(
|
||||
let settings = materialize_settings_layer(
|
||||
EffectiveSettingsLayers::new(
|
||||
SettingsLayer::default(),
|
||||
SettingsLayer::default(),
|
||||
|
|
@ -286,7 +287,7 @@ shared = "user"
|
|||
|
||||
#[test]
|
||||
fn local_only_merges_workflow_project_user() {
|
||||
let settings = resolve_settings(
|
||||
let settings = materialize_settings_layer(
|
||||
EffectiveSettingsLayers::new(
|
||||
SettingsLayer::default(),
|
||||
layer(
|
||||
|
|
@ -381,7 +382,7 @@ root = "/tmp/should-be-inert"
|
|||
"#,
|
||||
);
|
||||
|
||||
let settings = resolve_settings(
|
||||
let settings = materialize_settings_layer(
|
||||
EffectiveSettingsLayers::new(
|
||||
SettingsLayer::default(),
|
||||
SettingsLayer::default(),
|
||||
|
|
@ -445,7 +446,7 @@ root = "/tmp/should-be-inert"
|
|||
..SettingsLayer::default()
|
||||
};
|
||||
|
||||
let settings = resolve_settings(
|
||||
let settings = materialize_settings_layer(
|
||||
EffectiveSettingsLayers::default(),
|
||||
Some(&server_settings),
|
||||
EffectiveSettingsMode::LocalDaemon,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ pub fn load_and_resolve(
|
|||
server_settings: Option<&SettingsLayer>,
|
||||
mode: effective_settings::EffectiveSettingsMode,
|
||||
) -> Result<Settings> {
|
||||
let layer = effective_settings::resolve_settings(layers, server_settings, mode)?;
|
||||
let layer = effective_settings::materialize_settings_layer(layers, server_settings, mode)?;
|
||||
resolve(&layer).map_err(|errors| Error::resolve("failed to resolve settings", errors))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,3 +110,107 @@ pub(crate) fn parse_socket_addr(
|
|||
pub(crate) fn default_interp(path: impl AsRef<std::path::Path>) -> InterpString {
|
||||
InterpString::parse(&path.as_ref().to_string_lossy())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::settings::run::{HookType, McpTransport};
|
||||
|
||||
use super::resolve;
|
||||
use crate::parse_settings_layer;
|
||||
|
||||
#[test]
|
||||
fn resolve_preserves_source_templates_for_mcp_and_hook_strings() {
|
||||
let settings = parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.agent.mcps.stdio]
|
||||
type = "stdio"
|
||||
command = ["fabro-mcp", "--stdio"]
|
||||
|
||||
[run.agent.mcps.stdio.env]
|
||||
TOKEN = "Bearer {{ env.MCP_STDIO_TOKEN }}"
|
||||
|
||||
[run.agent.mcps.http]
|
||||
type = "http"
|
||||
url = "https://mcp.example.com"
|
||||
|
||||
[run.agent.mcps.http.headers]
|
||||
Authorization = "Bearer {{ env.MCP_HTTP_TOKEN }}"
|
||||
|
||||
[run.agent.mcps.sandbox]
|
||||
type = "sandbox"
|
||||
command = ["fabro-mcp", "--sandbox"]
|
||||
port = 3333
|
||||
|
||||
[run.agent.mcps.sandbox.env]
|
||||
TOKEN = "{{ env.MCP_SANDBOX_TOKEN }}"
|
||||
|
||||
[[run.hooks]]
|
||||
name = "notify"
|
||||
event = "run_complete"
|
||||
url = "https://hooks.example.com"
|
||||
|
||||
[run.hooks.headers]
|
||||
Authorization = "Bearer {{ env.HOOK_TOKEN }}"
|
||||
"#,
|
||||
)
|
||||
.expect("settings fixture should parse");
|
||||
|
||||
let resolved = resolve(&settings).expect("settings should resolve");
|
||||
let mcps = &resolved.run.agent.mcps;
|
||||
|
||||
assert_eq!(
|
||||
mcps.get("stdio").map(|mcp| &mcp.transport),
|
||||
Some(&McpTransport::Stdio {
|
||||
command: vec!["fabro-mcp".to_string(), "--stdio".to_string()],
|
||||
env: HashMap::from([(
|
||||
"TOKEN".to_string(),
|
||||
"Bearer {{ env.MCP_STDIO_TOKEN }}".to_string(),
|
||||
)]),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
mcps.get("http").map(|mcp| &mcp.transport),
|
||||
Some(&McpTransport::Http {
|
||||
url: "https://mcp.example.com".to_string(),
|
||||
headers: HashMap::from([(
|
||||
"Authorization".to_string(),
|
||||
"Bearer {{ env.MCP_HTTP_TOKEN }}".to_string(),
|
||||
)]),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
mcps.get("sandbox").map(|mcp| &mcp.transport),
|
||||
Some(&McpTransport::Sandbox {
|
||||
command: vec!["fabro-mcp".to_string(), "--sandbox".to_string()],
|
||||
port: 3333,
|
||||
env: HashMap::from([(
|
||||
"TOKEN".to_string(),
|
||||
"{{ env.MCP_SANDBOX_TOKEN }}".to_string(),
|
||||
)]),
|
||||
})
|
||||
);
|
||||
|
||||
let hook = resolved
|
||||
.run
|
||||
.hooks
|
||||
.iter()
|
||||
.find(|hook| hook.name.as_deref() == Some("notify"))
|
||||
.expect("notify hook");
|
||||
assert_eq!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(&HookType::Http {
|
||||
url: "https://hooks.example.com".to_string(),
|
||||
headers: Some(HashMap::from([(
|
||||
"Authorization".to_string(),
|
||||
"Bearer {{ env.HOOK_TOKEN }}".to_string(),
|
||||
)])),
|
||||
allowed_env_vars: Vec::new(),
|
||||
tls: fabro_types::settings::run::TlsMode::Verify,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use serde_json::json;
|
|||
use crate::error::ApiError;
|
||||
use crate::jwt_auth::AuthenticatedService;
|
||||
use crate::server::{AppState, PaginationParams};
|
||||
use crate::settings_view;
|
||||
|
||||
fn paginated_response<T: serde::Serialize>(
|
||||
items: Vec<T>,
|
||||
|
|
@ -494,8 +495,22 @@ pub(crate) async fn list_query_history(
|
|||
pub(crate) async fn get_server_settings(
|
||||
_auth: AuthenticatedService,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Query(query): Query<settings_view::SettingsQuery>,
|
||||
) -> Response {
|
||||
(StatusCode::OK, Json(settings::server_settings())).into_response()
|
||||
match query.view {
|
||||
settings_view::SettingsApiView::Layer => {
|
||||
(StatusCode::OK, Json(settings::server_settings())).into_response()
|
||||
}
|
||||
settings_view::SettingsApiView::Resolved => {
|
||||
let mut response =
|
||||
(StatusCode::OK, Json(settings::resolved_server_settings())).into_response();
|
||||
response.headers_mut().insert(
|
||||
settings_view::RESOLVED_VIEW_HEADER_NAME,
|
||||
axum::http::HeaderValue::from_static(settings_view::RESOLVED_VIEW_HEADER_VALUE),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── System ────────────────────────────────────────────────────────────
|
||||
|
|
@ -1539,4 +1554,149 @@ mod settings {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolved_server_settings() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"project": {
|
||||
"directory": "."
|
||||
},
|
||||
"workflow": {
|
||||
"graph": "workflow.fabro"
|
||||
},
|
||||
"run": {
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"name": "claude-sonnet",
|
||||
"fallbacks": []
|
||||
},
|
||||
"execution": {
|
||||
"mode": "normal",
|
||||
"approval": "prompt",
|
||||
"retros": true
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": "daytona",
|
||||
"preserve": false,
|
||||
"devcontainer": false,
|
||||
"env": {},
|
||||
"local": {
|
||||
"worktree_mode": "clean"
|
||||
},
|
||||
"daytona": {
|
||||
"auto_stop_interval": 60,
|
||||
"labels": {},
|
||||
"network": "block",
|
||||
"skip_clone": false
|
||||
}
|
||||
},
|
||||
"notifications": {},
|
||||
"interviews": {},
|
||||
"agent": {
|
||||
"mcps": {}
|
||||
},
|
||||
"hooks": [],
|
||||
"scm": {},
|
||||
"artifacts": {
|
||||
"include": []
|
||||
},
|
||||
"inputs": {},
|
||||
"metadata": {},
|
||||
"git": {},
|
||||
"prepare": {
|
||||
"commands": [],
|
||||
"timeout_ms": 300000
|
||||
},
|
||||
"checkpoint": {
|
||||
"exclude_globs": []
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"auth": {},
|
||||
"exec": {
|
||||
"prevent_idle_sleep": false,
|
||||
"model": {},
|
||||
"agent": {
|
||||
"mcps": {}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"format": "text",
|
||||
"verbosity": "normal"
|
||||
},
|
||||
"updates": {
|
||||
"check": true
|
||||
},
|
||||
"logging": {}
|
||||
},
|
||||
"server": {
|
||||
"api": {
|
||||
"url": "https://api.fabro.example.com"
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"url": "https://fabro.example.com"
|
||||
},
|
||||
"auth": {
|
||||
"api": {
|
||||
"jwt": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"allowed_usernames": ["brynary", "alice"],
|
||||
"providers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"client_id": "Iv1.abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"root": "/home/fabro/.fabro"
|
||||
},
|
||||
"artifacts": {
|
||||
"prefix": "",
|
||||
"store": {
|
||||
"type": "local",
|
||||
"root": ""
|
||||
}
|
||||
},
|
||||
"slatedb": {
|
||||
"prefix": "",
|
||||
"store": {
|
||||
"type": "local",
|
||||
"root": ""
|
||||
},
|
||||
"flush_interval": "0s"
|
||||
},
|
||||
"scheduler": {
|
||||
"max_concurrent_runs": 10
|
||||
},
|
||||
"logging": {},
|
||||
"integrations": {
|
||||
"github": {
|
||||
"enabled": false,
|
||||
"strategy": "gh_cli",
|
||||
"app_id": "12345",
|
||||
"client_id": "Iv1.abc123",
|
||||
"slug": "fabro-dev",
|
||||
"permissions": {}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false
|
||||
},
|
||||
"teams": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"session_sandboxes": false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub(crate) fn prepare_manifest_with_mode(
|
|||
.try_fold(SettingsLayer::default(), |layer, config| {
|
||||
Ok::<_, anyhow::Error>(combine_files(layer, parse_manifest_config(config)?))
|
||||
})?;
|
||||
let mut settings = effective_settings::resolve_settings(
|
||||
let mut settings = effective_settings::materialize_settings_layer(
|
||||
EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer),
|
||||
Some(server_settings),
|
||||
if local_daemon_mode {
|
||||
|
|
|
|||
|
|
@ -1123,35 +1123,53 @@ async fn health() -> Response {
|
|||
async fn get_server_settings(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<settings_view::SettingsQuery>,
|
||||
) -> Response {
|
||||
let settings = state.settings.read().unwrap().clone();
|
||||
let redacted = settings_view::redact_for_api(&settings);
|
||||
let mut value = match serde_json::to_value(&redacted) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
match query.view {
|
||||
settings_view::SettingsApiView::Layer => {
|
||||
let redacted = settings_view::redact_for_api(&settings);
|
||||
let mut value = match serde_json::to_value(&redacted) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
strip_nulls(&mut value);
|
||||
(StatusCode::OK, Json(value)).into_response()
|
||||
}
|
||||
};
|
||||
strip_nulls(&mut value);
|
||||
(StatusCode::OK, Json(value)).into_response()
|
||||
settings_view::SettingsApiView::Resolved => {
|
||||
let resolved = match fabro_config::resolve(&settings) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to resolve settings: {err:?}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mut value = match settings_view::redact_resolved_value(&resolved) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
strip_nulls(&mut value);
|
||||
let mut response = (StatusCode::OK, Json(value)).into_response();
|
||||
response.headers_mut().insert(
|
||||
settings_view::RESOLVED_VIEW_HEADER_NAME,
|
||||
HeaderValue::from_static(settings_view::RESOLVED_VIEW_HEADER_VALUE),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_nulls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for child in map.values_mut() {
|
||||
strip_nulls(child);
|
||||
}
|
||||
map.retain(|_, child| !child.is_null());
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for child in values {
|
||||
strip_nulls(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
settings_view::strip_nulls(value);
|
||||
}
|
||||
|
||||
async fn get_system_info(
|
||||
|
|
@ -6208,6 +6226,34 @@ mod tests {
|
|||
format!("/api/v1{path}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() {
|
||||
let state = create_app_state();
|
||||
*state.settings.write().unwrap() = fabro_config::parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
"#,
|
||||
)
|
||||
.expect("settings fixture should parse");
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/settings?view=resolved"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
|
||||
let state = create_app_state();
|
||||
|
|
|
|||
|
|
@ -34,7 +34,40 @@
|
|||
//! Any future field that carries a raw secret in-band (without env
|
||||
//! interpolation) must be added to the drop list below.
|
||||
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::{Settings, SettingsLayer};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const RESOLVED_VIEW_HEADER_NAME: &str = "X-Fabro-Settings-View";
|
||||
pub(crate) const RESOLVED_VIEW_HEADER_VALUE: &str = "resolved";
|
||||
|
||||
const REDACTED_PATHS: &[&[&str]] = &[
|
||||
&["server", "listen"],
|
||||
&["server", "auth", "api", "jwt", "issuer"],
|
||||
&["server", "auth", "api", "jwt", "audience"],
|
||||
&["server", "auth", "api", "mtls", "ca"],
|
||||
&[
|
||||
"server",
|
||||
"auth",
|
||||
"web",
|
||||
"providers",
|
||||
"github",
|
||||
"client_secret",
|
||||
],
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum SettingsApiView {
|
||||
#[default]
|
||||
Layer,
|
||||
Resolved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
pub(crate) struct SettingsQuery {
|
||||
#[serde(default)]
|
||||
pub(crate) view: SettingsApiView,
|
||||
}
|
||||
|
||||
/// Build a redacted clone of `settings` safe to serialize outward.
|
||||
///
|
||||
|
|
@ -70,6 +103,54 @@ pub(crate) fn redact_for_api(settings: &SettingsLayer) -> SettingsLayer {
|
|||
out
|
||||
}
|
||||
|
||||
pub(crate) fn redact_resolved_value(settings: &Settings) -> serde_json::Result<serde_json::Value> {
|
||||
let mut value = serde_json::to_value(settings)?;
|
||||
redact_value_paths(&mut value);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn strip_nulls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for child in map.values_mut() {
|
||||
strip_nulls(child);
|
||||
}
|
||||
map.retain(|_, child| !child.is_null());
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for child in values {
|
||||
strip_nulls(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_value_paths(value: &mut serde_json::Value) {
|
||||
for path in REDACTED_PATHS {
|
||||
remove_path(value, path);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_path(value: &mut serde_json::Value, path: &[&str]) {
|
||||
let Some((head, tail)) = path.split_first() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tail.is_empty() {
|
||||
object.remove(*head);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(child) = object.get_mut(*head) {
|
||||
remove_path(child, tail);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_config::parse_settings_layer;
|
||||
|
|
@ -268,4 +349,78 @@ default_channel = "{{ env.SLACK_CHANNEL }}"
|
|||
Some("{{ env.SLACK_CHANNEL }}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_dense_resolved_settings_with_the_same_secret_paths() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:32276"
|
||||
|
||||
[server.listen.tls]
|
||||
cert = "/etc/fabro/tls/cert.pem"
|
||||
key = "/etc/fabro/tls/key.pem"
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
issuer = "https://auth.example.com"
|
||||
audience = "fabro"
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
|
||||
[server.auth.web.providers.github]
|
||||
enabled = true
|
||||
client_id = "Iv1.abcdef"
|
||||
client_secret = "{{ env.GITHUB_OAUTH_SECRET }}"
|
||||
|
||||
[server.storage]
|
||||
root = "{{ env.FABRO_STORAGE_ROOT }}"
|
||||
"#,
|
||||
);
|
||||
|
||||
let resolved = fabro_config::resolve(&settings).expect("settings should resolve");
|
||||
let mut redacted =
|
||||
redact_resolved_value(&resolved).expect("resolved settings should serialize");
|
||||
|
||||
assert!(redacted["server"].get("listen").is_none());
|
||||
assert_eq!(redacted["server"]["auth"]["api"]["jwt"]["enabled"], true);
|
||||
assert!(
|
||||
redacted["server"]["auth"]["api"]["jwt"]
|
||||
.get("issuer")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
redacted["server"]["auth"]["api"]["jwt"]
|
||||
.get("audience")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(redacted["server"]["auth"]["api"]["mtls"]["enabled"], true);
|
||||
assert!(
|
||||
redacted["server"]["auth"]["api"]["mtls"]
|
||||
.get("ca")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
redacted["server"]["auth"]["web"]["providers"]["github"]["client_id"],
|
||||
"Iv1.abcdef"
|
||||
);
|
||||
assert!(
|
||||
redacted["server"]["auth"]["web"]["providers"]["github"]
|
||||
.get("client_secret")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
redacted["server"]["storage"]["root"],
|
||||
"{{ env.FABRO_STORAGE_ROOT }}"
|
||||
);
|
||||
|
||||
strip_nulls(&mut redacted);
|
||||
assert!(redacted["server"].get("listen").is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@ use tower::ServiceExt;
|
|||
use crate::helpers::body_json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrieve_server_settings_returns_runtime_settings() {
|
||||
async fn retrieve_server_settings_default_view_returns_redacted_layer_settings() {
|
||||
let settings: SettingsLayer = parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:32276"
|
||||
|
||||
[server.listen.tls]
|
||||
cert = "/etc/fabro/tls/cert.pem"
|
||||
key = "/etc/fabro/tls/key.pem"
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
|
||||
|
|
@ -23,6 +32,20 @@ max_concurrent_runs = 9
|
|||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
issuer = "https://auth.example.com"
|
||||
audience = "fabro"
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
ca = "/etc/fabro/ca.pem"
|
||||
|
||||
[server.auth.web.providers.github]
|
||||
enabled = true
|
||||
client_id = "Iv1.abcdef"
|
||||
client_secret = "{{ env.GITHUB_OAUTH_SECRET }}"
|
||||
|
||||
[run.inputs]
|
||||
server_only = "1"
|
||||
"#,
|
||||
|
|
@ -42,10 +65,96 @@ server_only = "1"
|
|||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
// `/api/v1/settings` emits the v2 SettingsLayer shape directly now.
|
||||
// Stage 6.6 will replace this with an explicit allow-list DTO.
|
||||
assert_eq!(body["_version"], 1);
|
||||
assert_eq!(body["server"]["storage"]["root"], "/srv/fabro");
|
||||
assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9);
|
||||
assert_eq!(body["cli"]["output"]["verbosity"], "verbose");
|
||||
assert_eq!(body["run"]["inputs"]["server_only"], "1");
|
||||
assert!(body["server"].get("listen").is_none());
|
||||
assert_eq!(body["server"]["auth"]["api"]["jwt"]["enabled"], true);
|
||||
assert!(body["server"]["auth"]["api"]["jwt"].get("issuer").is_none());
|
||||
assert!(
|
||||
body["server"]["auth"]["api"]["jwt"]
|
||||
.get("audience")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(body["server"]["auth"]["api"]["mtls"]["enabled"], true);
|
||||
assert!(body["server"]["auth"]["api"]["mtls"].get("ca").is_none());
|
||||
assert_eq!(
|
||||
body["server"]["auth"]["web"]["providers"]["github"]["client_id"],
|
||||
"Iv1.abcdef"
|
||||
);
|
||||
assert!(
|
||||
body["server"]["auth"]["web"]["providers"]["github"]
|
||||
.get("client_secret")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrieve_server_settings_resolved_view_returns_dense_settings_and_marker() {
|
||||
let settings: SettingsLayer = parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:32276"
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
|
||||
[server.auth.web.providers.github]
|
||||
enabled = true
|
||||
client_id = "Iv1.abcdef"
|
||||
client_secret = "{{ env.GITHUB_OAUTH_SECRET }}"
|
||||
|
||||
[run.model]
|
||||
provider = "openai"
|
||||
name = "server-model"
|
||||
|
||||
[run.inputs]
|
||||
server_only = "1"
|
||||
"#,
|
||||
)
|
||||
.expect("settings fixture should parse");
|
||||
let app = build_router(
|
||||
create_app_state_with_options(settings, 5),
|
||||
AuthMode::Disabled,
|
||||
);
|
||||
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/settings?view=resolved")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-fabro-settings-view")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("resolved")
|
||||
);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert!(body.get("_version").is_none());
|
||||
assert_eq!(body["project"]["directory"], ".");
|
||||
assert_eq!(body["workflow"]["graph"], "workflow.fabro");
|
||||
assert_eq!(body["run"]["execution"]["approval"], "prompt");
|
||||
assert_eq!(body["run"]["model"]["provider"], "openai");
|
||||
assert_eq!(body["run"]["model"]["name"], "server-model");
|
||||
assert_eq!(body["run"]["inputs"]["server_only"], "1");
|
||||
assert_eq!(body["server"]["storage"]["root"], "/srv/fabro");
|
||||
assert!(body["server"].get("listen").is_none());
|
||||
assert_eq!(
|
||||
body["server"]["auth"]["web"]["providers"]["github"]["client_id"],
|
||||
"Iv1.abcdef"
|
||||
);
|
||||
assert!(
|
||||
body["server"]["auth"]["web"]["providers"]["github"]
|
||||
.get("client_secret")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use super::interp::InterpString;
|
|||
use super::run::{AgentPermissions, McpEntryLayer, McpServerSettings};
|
||||
|
||||
/// A structurally resolved `[cli]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliSettings {
|
||||
pub target: Option<CliTargetSettings>,
|
||||
pub auth: CliAuthSettings,
|
||||
|
|
@ -23,7 +23,8 @@ pub struct CliSettings {
|
|||
pub logging: CliLoggingSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum CliTargetSettings {
|
||||
Http {
|
||||
url: InterpString,
|
||||
|
|
@ -34,49 +35,49 @@ pub enum CliTargetSettings {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct CliTargetTlsSettings {
|
||||
pub cert: InterpString,
|
||||
pub key: InterpString,
|
||||
pub ca: InterpString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliAuthSettings {
|
||||
pub strategy: Option<CliAuthStrategy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliExecSettings {
|
||||
pub prevent_idle_sleep: bool,
|
||||
pub model: CliExecModelSettings,
|
||||
pub agent: CliExecAgentSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliExecModelSettings {
|
||||
pub provider: Option<InterpString>,
|
||||
pub name: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliExecAgentSettings {
|
||||
pub permissions: Option<AgentPermissions>,
|
||||
pub mcps: HashMap<String, McpServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliOutputSettings {
|
||||
pub format: OutputFormat,
|
||||
pub verbosity: OutputVerbosity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliUpdatesSettings {
|
||||
pub check: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct CliLoggingSettings {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A structurally resolved `[features]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct FeaturesSettings {
|
||||
pub session_sandboxes: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use std::collections::HashMap;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A structurally resolved `[project]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct ProjectSettings {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use super::{
|
||||
CliSettings, FeaturesSettings, ProjectSettings, RunSettings, ServerSettings, WorkflowSettings,
|
||||
};
|
||||
|
||||
/// A fully resolved settings view across all namespaces.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct Settings {
|
||||
pub project: ProjectSettings,
|
||||
pub workflow: WorkflowSettings,
|
||||
|
|
@ -12,3 +14,160 @@ pub struct Settings {
|
|||
pub server: ServerSettings,
|
||||
pub features: FeaturesSettings,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::Settings;
|
||||
use crate::settings::cli::{CliTargetSettings, CliTargetTlsSettings};
|
||||
use crate::settings::interp::InterpString;
|
||||
use crate::settings::run::{
|
||||
DockerfileSource, McpServerSettings, McpTransport, RunAgentSettings, RunGoal, RunSettings,
|
||||
};
|
||||
use crate::settings::server::{
|
||||
ObjectStoreSettings, ServerListenSettings, ServerSettings, ServerSlateDbSettings, TlsConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn settings_serializes_successfully() {
|
||||
serde_json::to_value(Settings::default()).expect("resolved settings should serialize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_enums_use_human_readable_tagged_shapes() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(CliTargetSettings::Http {
|
||||
url: InterpString::parse("https://api.example.com"),
|
||||
tls: Some(CliTargetTlsSettings {
|
||||
cert: InterpString::parse("/tmp/client.crt"),
|
||||
key: InterpString::parse("/tmp/client.key"),
|
||||
ca: InterpString::parse("/tmp/ca.pem"),
|
||||
}),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "http",
|
||||
"url": "https://api.example.com",
|
||||
"tls": {
|
||||
"cert": "/tmp/client.crt",
|
||||
"key": "/tmp/client.key",
|
||||
"ca": "/tmp/ca.pem"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(RunGoal::Inline(InterpString::parse("ship it"))).unwrap(),
|
||||
json!({
|
||||
"type": "inline",
|
||||
"value": "ship it"
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(McpTransport::Sandbox {
|
||||
command: vec!["fabro-mcp".to_string(), "--serve".to_string()],
|
||||
port: 3333,
|
||||
env: HashMap::from([("TOKEN".to_string(), "{{ env.MCP_TOKEN }}".to_string())]),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "sandbox",
|
||||
"command": ["fabro-mcp", "--serve"],
|
||||
"port": 3333,
|
||||
"env": {
|
||||
"TOKEN": "{{ env.MCP_TOKEN }}"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(DockerfileSource::Path {
|
||||
path: "Dockerfile".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "path",
|
||||
"path": "Dockerfile"
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(ObjectStoreSettings::S3 {
|
||||
bucket: InterpString::parse("fabro-artifacts"),
|
||||
region: InterpString::parse("us-east-1"),
|
||||
endpoint: Some(InterpString::parse("https://s3.example.com")),
|
||||
path_style: true,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "s3",
|
||||
"bucket": "fabro-artifacts",
|
||||
"region": "us-east-1",
|
||||
"endpoint": "https://s3.example.com",
|
||||
"path_style": true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_addrs_and_std_durations_use_settings_strings() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ServerListenSettings::Tcp {
|
||||
address: "127.0.0.1:8080".parse().unwrap(),
|
||||
tls: Some(TlsConfig {
|
||||
cert: InterpString::parse("/tmp/server.crt"),
|
||||
key: InterpString::parse("/tmp/server.key"),
|
||||
ca: InterpString::parse("/tmp/server-ca.pem"),
|
||||
}),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "tcp",
|
||||
"address": "127.0.0.1:8080",
|
||||
"tls": {
|
||||
"cert": "/tmp/server.crt",
|
||||
"key": "/tmp/server.key",
|
||||
"ca": "/tmp/server-ca.pem"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.server = ServerSettings {
|
||||
slatedb: ServerSlateDbSettings {
|
||||
prefix: InterpString::parse("slatedb/"),
|
||||
store: ObjectStoreSettings::Local {
|
||||
root: InterpString::parse("/srv/slatedb"),
|
||||
},
|
||||
flush_interval: StdDuration::from_secs(30),
|
||||
},
|
||||
..ServerSettings::default()
|
||||
};
|
||||
settings.run = RunSettings {
|
||||
agent: RunAgentSettings {
|
||||
mcps: HashMap::from([("sandboxed".to_string(), McpServerSettings {
|
||||
name: "sandboxed".to_string(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://mcp.example.com".to_string(),
|
||||
headers: HashMap::from([(
|
||||
"Authorization".to_string(),
|
||||
"Bearer {{ env.MCP_TOKEN }}".to_string(),
|
||||
)]),
|
||||
},
|
||||
startup_timeout_secs: 15,
|
||||
tool_timeout_secs: 90,
|
||||
})]),
|
||||
..RunAgentSettings::default()
|
||||
},
|
||||
..RunSettings::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(settings).unwrap();
|
||||
assert_eq!(value["server"]["slatedb"]["flush_interval"], "30s");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::duration::Duration;
|
||||
|
|
@ -16,7 +17,7 @@ use super::interp::InterpString;
|
|||
use super::model_ref::ModelRef;
|
||||
|
||||
/// A structurally resolved `[run]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunSettings {
|
||||
pub goal: Option<RunGoal>,
|
||||
pub working_dir: Option<InterpString>,
|
||||
|
|
@ -38,31 +39,32 @@ pub struct RunSettings {
|
|||
}
|
||||
|
||||
/// The resolved source of a run goal.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
|
||||
pub enum RunGoal {
|
||||
Inline(InterpString),
|
||||
File(InterpString),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunModelSettings {
|
||||
pub provider: Option<InterpString>,
|
||||
pub name: Option<InterpString>,
|
||||
pub fallbacks: Vec<ModelRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunGitSettings {
|
||||
pub author: Option<GitAuthorSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct GitAuthorSettings {
|
||||
pub name: Option<InterpString>,
|
||||
pub email: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RunPrepareSettings {
|
||||
pub commands: Vec<String>,
|
||||
pub timeout_ms: u64,
|
||||
|
|
@ -77,7 +79,7 @@ impl Default for RunPrepareSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RunExecutionSettings {
|
||||
pub mode: RunMode,
|
||||
pub approval: ApprovalMode,
|
||||
|
|
@ -94,12 +96,12 @@ impl Default for RunExecutionSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunCheckpointSettings {
|
||||
pub exclude_globs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RunSandboxSettings {
|
||||
pub provider: String,
|
||||
pub preserve: bool,
|
||||
|
|
@ -122,12 +124,12 @@ impl Default for RunSandboxSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct LocalSandboxSettings {
|
||||
pub worktree_mode: WorktreeMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct DaytonaSettings {
|
||||
pub auto_stop_interval: Option<i32>,
|
||||
pub labels: HashMap<String, String>,
|
||||
|
|
@ -142,7 +144,27 @@ pub enum DockerfileSource {
|
|||
Path { path: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
impl Serialize for DockerfileSource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("DockerfileSource", 2)?;
|
||||
match self {
|
||||
Self::Inline(value) => {
|
||||
state.serialize_field("type", "inline")?;
|
||||
state.serialize_field("value", value)?;
|
||||
}
|
||||
Self::Path { path } => {
|
||||
state.serialize_field("type", "path")?;
|
||||
state.serialize_field("path", path)?;
|
||||
}
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DaytonaSnapshotSettings {
|
||||
pub name: String,
|
||||
pub cpu: Option<i32>,
|
||||
|
|
@ -151,7 +173,7 @@ pub struct DaytonaSnapshotSettings {
|
|||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct NotificationRouteSettings {
|
||||
pub enabled: bool,
|
||||
pub provider: Option<String>,
|
||||
|
|
@ -161,12 +183,12 @@ pub struct NotificationRouteSettings {
|
|||
pub teams: Option<NotificationProviderSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct NotificationProviderSettings {
|
||||
pub channel: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunInterviewsSettings {
|
||||
pub provider: Option<String>,
|
||||
pub slack: Option<InterviewProviderSettings>,
|
||||
|
|
@ -174,18 +196,18 @@ pub struct RunInterviewsSettings {
|
|||
pub teams: Option<InterviewProviderSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct InterviewProviderSettings {
|
||||
pub channel: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunAgentSettings {
|
||||
pub permissions: Option<AgentPermissions>,
|
||||
pub mcps: HashMap<String, McpServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct McpServerSettings {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
|
|
@ -219,7 +241,8 @@ impl McpServerSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: Vec<String>,
|
||||
|
|
@ -348,7 +371,7 @@ impl HookDefinition {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RunScmSettings {
|
||||
pub provider: Option<String>,
|
||||
pub owner: Option<InterpString>,
|
||||
|
|
@ -359,7 +382,17 @@ pub struct RunScmSettings {
|
|||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ScmGitHubSettings;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
impl Serialize for ScmGitHubSettings {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let state = serializer.serialize_struct("ScmGitHubSettings", 0)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PullRequestSettings {
|
||||
pub enabled: bool,
|
||||
pub draft: bool,
|
||||
|
|
@ -378,7 +411,7 @@ impl Default for PullRequestSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct ArtifactsSettings {
|
||||
pub include: Vec<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ use std::collections::HashMap;
|
|||
use std::net::SocketAddr;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
use super::duration::Duration as DurationLayer;
|
||||
use super::interp::InterpString;
|
||||
|
||||
/// A structurally resolved `[server]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerSettings {
|
||||
pub listen: ServerListenSettings,
|
||||
pub api: ServerApiSettings,
|
||||
|
|
@ -29,9 +29,11 @@ pub struct ServerSettings {
|
|||
pub integrations: ServerIntegrationsSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ServerListenSettings {
|
||||
Tcp {
|
||||
#[serde(serialize_with = "serialize_socket_addr")]
|
||||
address: SocketAddr,
|
||||
tls: Option<TlsConfig>,
|
||||
},
|
||||
|
|
@ -48,7 +50,7 @@ impl Default for ServerListenSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct TlsConfig {
|
||||
pub cert: InterpString,
|
||||
pub key: InterpString,
|
||||
|
|
@ -65,12 +67,12 @@ impl Default for TlsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerApiSettings {
|
||||
pub url: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerWebSettings {
|
||||
pub enabled: bool,
|
||||
pub url: InterpString,
|
||||
|
|
@ -85,50 +87,50 @@ impl Default for ServerWebSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthSettings {
|
||||
pub api: ServerAuthApiSettings,
|
||||
pub web: ServerAuthWebSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthApiSettings {
|
||||
pub jwt: Option<ServerAuthApiJwtSettings>,
|
||||
pub mtls: Option<ServerAuthApiMtlsSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthApiJwtSettings {
|
||||
pub enabled: bool,
|
||||
pub issuer: Option<InterpString>,
|
||||
pub audience: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthApiMtlsSettings {
|
||||
pub enabled: bool,
|
||||
pub ca: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthWebSettings {
|
||||
pub allowed_usernames: Vec<String>,
|
||||
pub providers: ServerAuthWebProvidersSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerAuthWebProvidersSettings {
|
||||
pub github: Option<GithubOauthSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct GithubOauthSettings {
|
||||
pub enabled: bool,
|
||||
pub client_id: Option<InterpString>,
|
||||
pub client_secret: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerStorageSettings {
|
||||
pub root: InterpString,
|
||||
}
|
||||
|
|
@ -141,7 +143,7 @@ impl Default for ServerStorageSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerArtifactsSettings {
|
||||
pub prefix: InterpString,
|
||||
pub store: ObjectStoreSettings,
|
||||
|
|
@ -156,10 +158,11 @@ impl Default for ServerArtifactsSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerSlateDbSettings {
|
||||
pub prefix: InterpString,
|
||||
pub store: ObjectStoreSettings,
|
||||
#[serde(serialize_with = "serialize_std_duration")]
|
||||
pub flush_interval: StdDuration,
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +176,8 @@ impl Default for ServerSlateDbSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ObjectStoreSettings {
|
||||
Local {
|
||||
root: InterpString,
|
||||
|
|
@ -194,17 +198,17 @@ impl Default for ObjectStoreSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerSchedulerSettings {
|
||||
pub max_concurrent_runs: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerLoggingSettings {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ServerIntegrationsSettings {
|
||||
pub github: GithubIntegrationSettings,
|
||||
pub slack: SlackIntegrationSettings,
|
||||
|
|
@ -212,7 +216,7 @@ pub struct ServerIntegrationsSettings {
|
|||
pub teams: TeamsIntegrationSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct GithubIntegrationSettings {
|
||||
pub enabled: bool,
|
||||
pub strategy: GithubIntegrationStrategy,
|
||||
|
|
@ -223,27 +227,41 @@ pub struct GithubIntegrationSettings {
|
|||
pub webhooks: Option<IntegrationWebhooksSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SlackIntegrationSettings {
|
||||
pub enabled: bool,
|
||||
pub default_channel: Option<InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct DiscordIntegrationSettings {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct TeamsIntegrationSettings {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct IntegrationWebhooksSettings {
|
||||
pub strategy: Option<WebhookStrategy>,
|
||||
}
|
||||
|
||||
fn serialize_socket_addr<S>(value: &SocketAddr, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&value.to_string())
|
||||
}
|
||||
|
||||
fn serialize_std_duration<S>(value: &StdDuration, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&DurationLayer::from_std(*value).to_string())
|
||||
}
|
||||
|
||||
/// A sparse `[server]` layer as it appears in a single settings file.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use std::collections::HashMap;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A structurally resolved `[workflow]` view for consumers.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct WorkflowSettings {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ import type { SecretMetadata } from '../models';
|
|||
export const SecretsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @summary Store or update a secret
|
||||
* Stores a secret in the workflow-visible vault. Anything stored here may be used by workflows.
|
||||
* @summary Store or update a vault secret
|
||||
* @param {CreateSecretRequest} createSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -80,7 +80,7 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati
|
|||
},
|
||||
/**
|
||||
*
|
||||
* @summary Delete a stored secret
|
||||
* @summary Delete a vault secret
|
||||
* @param {DeleteSecretRequest} deleteSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -121,8 +121,8 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Returns stored secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List stored secrets
|
||||
* Returns workflow-visible vault secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List vault secrets
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
|
@ -167,8 +167,8 @@ export const SecretsApiFp = function(configuration?: Configuration) {
|
|||
const localVarAxiosParamCreator = SecretsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @summary Store or update a secret
|
||||
* Stores a secret in the workflow-visible vault. Anything stored here may be used by workflows.
|
||||
* @summary Store or update a vault secret
|
||||
* @param {CreateSecretRequest} createSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -181,7 +181,7 @@ export const SecretsApiFp = function(configuration?: Configuration) {
|
|||
},
|
||||
/**
|
||||
*
|
||||
* @summary Delete a stored secret
|
||||
* @summary Delete a vault secret
|
||||
* @param {DeleteSecretRequest} deleteSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -193,8 +193,8 @@ export const SecretsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns stored secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List stored secrets
|
||||
* Returns workflow-visible vault secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List vault secrets
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
|
@ -214,8 +214,8 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
|
|||
const localVarFp = SecretsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @summary Store or update a secret
|
||||
* Stores a secret in the workflow-visible vault. Anything stored here may be used by workflows.
|
||||
* @summary Store or update a vault secret
|
||||
* @param {CreateSecretRequest} createSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -225,7 +225,7 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
|
|||
},
|
||||
/**
|
||||
*
|
||||
* @summary Delete a stored secret
|
||||
* @summary Delete a vault secret
|
||||
* @param {DeleteSecretRequest} deleteSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -234,8 +234,8 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
|
|||
return localVarFp.deleteSecretByName(deleteSecretRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns stored secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List stored secrets
|
||||
* Returns workflow-visible vault secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List vault secrets
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
|
@ -250,8 +250,8 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
|
|||
*/
|
||||
export class SecretsApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @summary Store or update a secret
|
||||
* Stores a secret in the workflow-visible vault. Anything stored here may be used by workflows.
|
||||
* @summary Store or update a vault secret
|
||||
* @param {CreateSecretRequest} createSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -262,7 +262,7 @@ export class SecretsApi extends BaseAPI {
|
|||
|
||||
/**
|
||||
*
|
||||
* @summary Delete a stored secret
|
||||
* @summary Delete a vault secret
|
||||
* @param {DeleteSecretRequest} deleteSecretRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
|
@ -272,8 +272,8 @@ export class SecretsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns stored secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List stored secrets
|
||||
* Returns workflow-visible vault secret names and timestamps. Secret values are never exposed.
|
||||
* @summary List vault secrets
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
|
|||
export const SettingsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
* Returns the structured server settings.
|
||||
* Returns the server settings view selected by the optional `view` query parameter. `view=layer` (the default) returns the current sparse redacted `SettingsLayer` payload. `view=resolved` returns the server\'s dense resolved settings payload after applying the same redaction policy.
|
||||
* @summary Retrieve Server Settings
|
||||
* @param {RetrieveServerSettingsViewEnum} [view] Selects the server settings representation to return.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveServerSettings: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
retrieveServerSettings: async (view?: RetrieveServerSettingsViewEnum, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/settings`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
|
|
@ -52,6 +53,10 @@ export const SettingsApiAxiosParamCreator = function (configuration?: Configurat
|
|||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (view !== undefined) {
|
||||
localVarQueryParameter['view'] = view;
|
||||
}
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
|
|
@ -73,13 +78,14 @@ export const SettingsApiFp = function(configuration?: Configuration) {
|
|||
const localVarAxiosParamCreator = SettingsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
* Returns the structured server settings.
|
||||
* Returns the server settings view selected by the optional `view` query parameter. `view=layer` (the default) returns the current sparse redacted `SettingsLayer` payload. `view=resolved` returns the server\'s dense resolved settings payload after applying the same redaction policy.
|
||||
* @summary Retrieve Server Settings
|
||||
* @param {RetrieveServerSettingsViewEnum} [view] Selects the server settings representation to return.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async retrieveServerSettings(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveServerSettings(options);
|
||||
async retrieveServerSettings(view?: RetrieveServerSettingsViewEnum, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveServerSettings(view, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['SettingsApi.retrieveServerSettings']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
|
@ -94,13 +100,14 @@ export const SettingsApiFactory = function (configuration?: Configuration, baseP
|
|||
const localVarFp = SettingsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
* Returns the structured server settings.
|
||||
* Returns the server settings view selected by the optional `view` query parameter. `view=layer` (the default) returns the current sparse redacted `SettingsLayer` payload. `view=resolved` returns the server\'s dense resolved settings payload after applying the same redaction policy.
|
||||
* @summary Retrieve Server Settings
|
||||
* @param {RetrieveServerSettingsViewEnum} [view] Selects the server settings representation to return.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveServerSettings(options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> {
|
||||
return localVarFp.retrieveServerSettings(options).then((request) => request(axios, basePath));
|
||||
retrieveServerSettings(view?: RetrieveServerSettingsViewEnum, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> {
|
||||
return localVarFp.retrieveServerSettings(view, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -110,13 +117,19 @@ export const SettingsApiFactory = function (configuration?: Configuration, baseP
|
|||
*/
|
||||
export class SettingsApi extends BaseAPI {
|
||||
/**
|
||||
* Returns the structured server settings.
|
||||
* Returns the server settings view selected by the optional `view` query parameter. `view=layer` (the default) returns the current sparse redacted `SettingsLayer` payload. `view=resolved` returns the server\'s dense resolved settings payload after applying the same redaction policy.
|
||||
* @summary Retrieve Server Settings
|
||||
* @param {RetrieveServerSettingsViewEnum} [view] Selects the server settings representation to return.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public retrieveServerSettings(options?: RawAxiosRequestConfig) {
|
||||
return SettingsApiFp(this.configuration).retrieveServerSettings(options).then((request) => request(this.axios, this.basePath));
|
||||
public retrieveServerSettings(view?: RetrieveServerSettingsViewEnum, options?: RawAxiosRequestConfig) {
|
||||
return SettingsApiFp(this.configuration).retrieveServerSettings(view, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
export const RetrieveServerSettingsViewEnum = {
|
||||
LAYER: 'layer',
|
||||
RESOLVED: 'resolved'
|
||||
} as const;
|
||||
export type RetrieveServerSettingsViewEnum = typeof RetrieveServerSettingsViewEnum[keyof typeof RetrieveServerSettingsViewEnum];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue