diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 2cb28f1ea..1751ccc8c 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -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 diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 54f36b757..12a935589 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -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 { +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 { 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 { + 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(()); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index b8cca5020..60512872a 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -279,16 +279,43 @@ impl ServerStoreClient { &self.base_url } - pub(crate) async fn retrieve_server_settings(&self) -> Result { - let response = self - .client - .retrieve_server_settings() - .send() + pub(crate) async fn retrieve_resolved_server_settings(&self) -> Result { + 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::(&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::() .await - .map_err(map_api_error)?; - let raw = serde_json::Value::Object(response.into_inner().into()); - serde_json::from_value::(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( diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 62506fae5..a64a382e0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -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 { - 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 { - resolve_run(settings) - .model - .name - .as_ref() - .map(fabro_types::settings::InterpString::as_source) -} - -fn run_model_provider(settings: &SettingsLayer) -> Option { - resolve_run(settings) - .model - .provider - .as_ref() - .map(fabro_types::settings::InterpString::as_source) -} - -fn run_inputs(settings: &SettingsLayer) -> &std::collections::HashMap { +fn run_inputs(settings: &serde_json::Value) -> &serde_json::Map { 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 { +fn run_agent_mcps(settings: &serde_json::Value) -> &serde_json::Map { 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 { - resolve_run(settings).prepare.commands +fn run_prepare_commands(settings: &serde_json::Value) -> Vec { + 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 = 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!(); diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 608adc6aa..915344700 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -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, diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index eba76ad1a..0929f62d8 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -41,7 +41,7 @@ pub fn load_and_resolve( server_settings: Option<&SettingsLayer>, mode: effective_settings::EffectiveSettingsMode, ) -> Result { - 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)) } diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 76ae41b29..ddb3970b4 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -110,3 +110,107 @@ pub(crate) fn parse_socket_addr( pub(crate) fn default_interp(path: impl AsRef) -> 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, + }) + ); + } +} diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 48f5d77e7..060a48f6f 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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( items: Vec, @@ -494,8 +495,22 @@ pub(crate) async fn list_query_history( pub(crate) async fn get_server_settings( _auth: AuthenticatedService, State(_state): State>, + Query(query): Query, ) -> 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 + } + }) + } } diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index a5be1f741..1b6f81679 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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 { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index e448c9639..babe221e7 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1123,35 +1123,53 @@ async fn health() -> Response { async fn get_server_settings( _auth: AuthenticatedService, State(state): State>, + Query(query): Query, ) -> 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(); diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs index 0e6ca50ef..9166f597d 100644 --- a/lib/crates/fabro-server/src/settings_view.rs +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -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 { + 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()); + } } diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index 834da2167..8b4866f7c 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -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() + ); } diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index 6031c8f93..177484feb 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -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, 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, } -#[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, pub name: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct CliExecAgentSettings { pub permissions: Option, pub mcps: HashMap, } -#[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, } diff --git a/lib/crates/fabro-types/src/settings/features.rs b/lib/crates/fabro-types/src/settings/features.rs index f7ffe53c9..2ed227d00 100644 --- a/lib/crates/fabro-types/src/settings/features.rs +++ b/lib/crates/fabro-types/src/settings/features.rs @@ -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, } diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index f7164ae44..dcdd529bb 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -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, pub description: Option, diff --git a/lib/crates/fabro-types/src/settings/resolved.rs b/lib/crates/fabro-types/src/settings/resolved.rs index b68ef51b9..63bd5650a 100644 --- a/lib/crates/fabro-types/src/settings/resolved.rs +++ b/lib/crates/fabro-types/src/settings/resolved.rs @@ -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"); + } +} diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index fafe34958..c5c45c13c 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -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, pub working_dir: Option, @@ -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, pub name: Option, pub fallbacks: Vec, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct RunGitSettings { pub author: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct GitAuthorSettings { pub name: Option, pub email: Option, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct RunPrepareSettings { pub commands: Vec, 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, } -#[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, pub labels: HashMap, @@ -142,7 +144,27 @@ pub enum DockerfileSource { Path { path: String }, } -#[derive(Debug, Clone, PartialEq)] +impl Serialize for DockerfileSource { + fn serialize(&self, serializer: S) -> Result + 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, @@ -151,7 +173,7 @@ pub struct DaytonaSnapshotSettings { pub dockerfile: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct NotificationRouteSettings { pub enabled: bool, pub provider: Option, @@ -161,12 +183,12 @@ pub struct NotificationRouteSettings { pub teams: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct NotificationProviderSettings { pub channel: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct RunInterviewsSettings { pub provider: Option, pub slack: Option, @@ -174,18 +196,18 @@ pub struct RunInterviewsSettings { pub teams: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct InterviewProviderSettings { pub channel: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct RunAgentSettings { pub permissions: Option, pub mcps: HashMap, } -#[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, @@ -348,7 +371,7 @@ impl HookDefinition { } } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct RunScmSettings { pub provider: Option, pub owner: Option, @@ -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(&self, serializer: S) -> Result + 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, } diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 44958565a..d6a271f6c 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -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, }, @@ -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, } -#[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, pub mtls: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ServerAuthApiJwtSettings { pub enabled: bool, pub issuer: Option, pub audience: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ServerAuthApiMtlsSettings { pub enabled: bool, pub ca: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ServerAuthWebSettings { pub allowed_usernames: Vec, pub providers: ServerAuthWebProvidersSettings, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ServerAuthWebProvidersSettings { pub github: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct GithubOauthSettings { pub enabled: bool, pub client_id: Option, pub client_secret: Option, } -#[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, } -#[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, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct SlackIntegrationSettings { pub enabled: bool, pub default_channel: Option, } -#[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, } +fn serialize_socket_addr(value: &SocketAddr, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +fn serialize_std_duration(value: &StdDuration, serializer: S) -> Result +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)] diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index 7dc0d0b27..d97c7e74e 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -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, pub description: Option, diff --git a/lib/packages/fabro-api-client/src/api/secrets-api.ts b/lib/packages/fabro-api-client/src/api/secrets-api.ts index c5c7a3b21..50fd78d96 100644 --- a/lib/packages/fabro-api-client/src/api/secrets-api.ts +++ b/lib/packages/fabro-api-client/src/api/secrets-api.ts @@ -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} */ diff --git a/lib/packages/fabro-api-client/src/api/settings-api.ts b/lib/packages/fabro-api-client/src/api/settings-api.ts index 6b648b707..f594a2d85 100644 --- a/lib/packages/fabro-api-client/src/api/settings-api.ts +++ b/lib/packages/fabro-api-client/src/api/settings-api.ts @@ -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 => { + retrieveServerSettings: async (view?: RetrieveServerSettingsViewEnum, options: RawAxiosRequestConfig = {}): Promise => { 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];