From d07596ca807e87135252433bafe39ec22ef45426 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 26 Apr 2026 14:52:15 -0400 Subject: [PATCH] feat(server): support stdout log destination Add configurable server log destinations with an environment override so containers can stream foreground server logs to stdout while local installs keep file logging by default. Validate configured log filters at load time and reject stdout logging for daemon mode. --- Cargo.lock | 2 + Dockerfile | 3 +- docs/api-reference/fabro-api.yaml | 8 +- lib/crates/fabro-api/build.rs | 5 + lib/crates/fabro-api/src/lib.rs | 12 +- .../tests/server_settings_round_trip.rs | 12 +- lib/crates/fabro-cli/Cargo.toml | 1 + .../fabro-cli/src/commands/server/start.rs | 156 +++++++++++++++++- lib/crates/fabro-cli/src/local_server.rs | 19 ++- lib/crates/fabro-cli/src/logging.rs | 59 ++++++- lib/crates/fabro-cli/src/main.rs | 155 ++++++++++++++++- lib/crates/fabro-cli/src/user_config.rs | 76 +++++++-- lib/crates/fabro-config/Cargo.toml | 1 + lib/crates/fabro-config/src/layers/combine.rs | 14 +- .../fabro-config/src/layers/log_filter.rs | 45 +++++ lib/crates/fabro-config/src/layers/mod.rs | 2 + lib/crates/fabro-config/src/layers/server.rs | 10 +- lib/crates/fabro-config/src/lib.rs | 2 +- lib/crates/fabro-config/src/resolve/server.rs | 10 +- lib/crates/fabro-config/src/tests/combine.rs | 25 +++ .../fabro-config/src/tests/log_filter.rs | 28 ++++ lib/crates/fabro-config/src/tests/mod.rs | 1 + .../fabro-config/src/tests/resolve_server.rs | 36 +++- lib/crates/fabro-static/src/env_vars.rs | 6 + lib/crates/fabro-types/src/settings/mod.rs | 2 +- lib/crates/fabro-types/src/settings/server.rs | 13 +- .../src/.openapi-generator/FILES | 1 + .../fabro-api-client/src/models/index.ts | 1 + .../src/models/log-destination.ts | 23 +++ .../src/models/server-logging-settings.ts | 7 +- 30 files changed, 667 insertions(+), 68 deletions(-) create mode 100644 lib/crates/fabro-config/src/layers/log_filter.rs create mode 100644 lib/crates/fabro-config/src/tests/log_filter.rs create mode 100644 lib/packages/fabro-api-client/src/models/log-destination.ts diff --git a/Cargo.lock b/Cargo.lock index 30cce12df..20b015517 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,6 +1690,7 @@ dependencies = [ "serde_yaml", "sha2", "shlex", + "temp-env", "tempfile", "thiserror 2.0.18", "tokio", @@ -1755,6 +1756,7 @@ dependencies = [ "thiserror 2.0.18", "toml 0.8.23", "tracing", + "tracing-subscriber", "ulid", ] diff --git a/Dockerfile b/Dockerfile index 0ced73368..5fa65d8aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,8 @@ COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint ENV FABRO_HOME=/storage/.home \ - FABRO_STORAGE_DIR=/storage + FABRO_STORAGE_DIR=/storage \ + FABRO_LOG_DESTINATION=stdout VOLUME ["/storage"] EXPOSE 32276 diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index ffd692e62..6821fd16b 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -5978,10 +5978,16 @@ components: ServerLoggingSettings: type: object - required: [level] + required: [level, destination] properties: level: type: ["string", "null"] + destination: + $ref: "#/components/schemas/LogDestination" + + LogDestination: + type: string + enum: [file, stdout] ServerIntegrationsSettings: type: object diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 1a3cfca87..5a2c4c42a 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -263,6 +263,11 @@ fn main() { "fabro_types::settings::server::ServerLoggingSettings", &[], ), + ( + "LogDestination", + "fabro_types::settings::server::LogDestination", + &[], + ), ( "ServerIntegrationsSettings", "fabro_types::settings::server::ServerIntegrationsSettings", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 7655ace4d..6f03faf82 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -16,12 +16,12 @@ mod generated { pub mod types { pub use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, - IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, - ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, - ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, - ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, - ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, - TeamsIntegrationSettings, WebhookStrategy, + IntegrationWebhooksSettings, IpAllowEntry, LogDestination, ObjectStoreSettings, + ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, + ServerAuthSettings, ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, + ServerIpAllowlistSettings, ServerListenSettings, ServerLoggingSettings, + ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, + SlackIntegrationSettings, TeamsIntegrationSettings, WebhookStrategy, }; pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; pub use fabro_types::status::{ diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/crates/fabro-api/tests/server_settings_round_trip.rs index 2181c8884..489046d46 100644 --- a/lib/crates/fabro-api/tests/server_settings_round_trip.rs +++ b/lib/crates/fabro-api/tests/server_settings_round_trip.rs @@ -1,12 +1,13 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ - FeaturesNamespace as ApiFeaturesNamespace, ObjectStoreSettings as ApiObjectStoreSettings, - ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings, + FeaturesNamespace as ApiFeaturesNamespace, LogDestination as ApiLogDestination, + ObjectStoreSettings as ApiObjectStoreSettings, ServerNamespace as ApiServerNamespace, + ServerSettings as ApiServerSettings, }; use fabro_config::ServerSettingsBuilder; use fabro_types::ServerSettings; -use fabro_types::settings::server::ObjectStoreSettings; +use fabro_types::settings::server::{LogDestination, ObjectStoreSettings}; use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; #[test] @@ -15,6 +16,7 @@ fn server_settings_family_reuses_domain_types() { assert_same_type::(); assert_same_type::(); assert_same_type::(); + assert_same_type::(); } #[test] @@ -43,6 +45,9 @@ allowed_usernames = ["alice"] [server.storage] root = "/srv/fabro" +[server.logging] +destination = "stdout" + [server.integrations.github] enabled = true strategy = "app" @@ -60,6 +65,7 @@ session_sandboxes = true assert_eq!(json["server"]["listen"]["type"], "tcp"); assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276"); assert_eq!(json["server"]["storage"]["root"], "/srv/fabro"); + assert_eq!(json["server"]["logging"]["destination"], "stdout"); assert_eq!(json["features"]["session_sandboxes"], true); let round_trip: ApiServerSettings = diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 8faebf72e..d73982b86 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -114,6 +114,7 @@ paste = "1" predicates = "3" serde_json.workspace = true tempfile = "3" +temp-env = "0.3" httpmock = "0.8" fabro-test = { workspace = true } fabro-macros = { path = "../fabro-macros" } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 85cf31c2e..eb1ecfa3b 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -15,7 +15,7 @@ use fabro_server::jwt_auth::auth_method_name; use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start}; use fabro_server::{process_env_snapshot, validate_startup}; use fabro_static::EnvVars; -use fabro_types::settings::ServerAuthMethod; +use fabro_types::settings::{LogDestination, ServerAuthMethod}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tokio::net::{TcpStream, UnixStream}; @@ -24,6 +24,7 @@ use tokio::task::spawn_blocking; use tokio::time; use crate::local_server; +use crate::logging::{self, ServerLogDestination}; pub(crate) struct ForegroundServerLogBootstrap { #[expect(dead_code, reason = "held for its Drop to release the server lock")] @@ -67,6 +68,7 @@ pub(crate) async fn execute( pub(crate) async fn prepare_foreground_server_log( runtime_directory: &RuntimeDirectory, + destination: &ServerLogDestination, ) -> Result { let lock_file = acquire_lock(runtime_directory).await?; if let Some(existing) = ServerDaemon::load_running(runtime_directory)? { @@ -77,13 +79,14 @@ pub(crate) async fn prepare_foreground_server_log( ); } - let log_path = runtime_directory.log_path(); - if let Some(parent) = log_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("creating log directory {}", parent.display()))?; + if let ServerLogDestination::File(log_path) = destination { + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating log directory {}", parent.display()))?; + } + std::fs::File::create(log_path) + .with_context(|| format!("creating server log file {}", log_path.display()))?; } - std::fs::File::create(&log_path) - .with_context(|| format!("creating server log file {}", log_path.display()))?; Ok(ForegroundServerLogBootstrap { lock_file }) } @@ -265,6 +268,12 @@ async fn execute_daemon( } let resolved_settings = resolve_runtime_server_settings_for_start(serve_args, storage_dir)?; + let destination = logging::resolve_log_destination(resolved_settings.logging.destination)?; + if matches!(destination, LogDestination::Stdout) { + bail!( + "[server.logging].destination = \"stdout\" is incompatible with daemon mode; use `fabro server start --foreground`" + ); + } validate_startup( runtime_directory.env_path().as_path(), process_env_snapshot(), @@ -317,7 +326,7 @@ async fn execute_daemon( cmd.arg("--storage-dir").arg(storage_dir); - cmd.env_remove("FABRO_JSON"); + cmd.env_remove(EnvVars::FABRO_JSON); cmd.stdout(stdout_log) .stderr(log_file) .stdin(std::process::Stdio::null()); @@ -486,9 +495,55 @@ fn read_log_tail(log_path: &Path, lines: usize) -> String { #[cfg(test)] mod tests { + use fabro_config::bind::BindRequest; + use fabro_server::serve::ServeArgs; + use fabro_static::EnvVars; use fabro_util::Home; + use fabro_util::printer::Printer; + use temp_env::with_var; + use tokio::runtime::Runtime; - use super::ensure_storage_server_autostart_allowed; + use super::{ + ensure_storage_server_autostart_allowed, execute_daemon, prepare_foreground_server_log, + }; + use crate::logging::ServerLogDestination; + + fn runtime() -> Runtime { + Runtime::new().expect("runtime should build") + } + + fn write_server_settings(path: &std::path::Path, destination: &str) { + std::fs::write( + path, + format!( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.logging] +destination = "{destination}" +"# + ), + ) + .expect("settings fixture should write"); + } + + fn serve_args_with_config(config_path: &std::path::Path) -> ServeArgs { + ServeArgs { + bind: Some("127.0.0.1:0".to_string()), + web: false, + no_web: false, + model: None, + provider: None, + sandbox: None, + max_concurrent_runs: None, + config: Some(config_path.to_path_buf()), + #[cfg(debug_assertions)] + watch_web: false, + } + } #[test] fn ensure_server_running_for_storage_errors_when_install_mode_is_required() { @@ -516,4 +571,87 @@ mod tests { "unexpected error: {message}" ); } + + #[test] + fn prepare_foreground_server_log_with_stdout_does_not_create_server_log() { + let storage_dir = tempfile::tempdir().unwrap(); + let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path()); + + let _bootstrap = runtime() + .block_on(prepare_foreground_server_log( + &runtime_directory, + &ServerLogDestination::Stdout, + )) + .expect("stdout foreground bootstrap should succeed"); + + assert!( + !runtime_directory.log_path().exists(), + "stdout destination should not create server.log" + ); + } + + #[test] + fn execute_daemon_rejects_configured_stdout_before_creating_server_log() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_server_settings(&config_path, "stdout"); + + let bind = BindRequest::Tcp("127.0.0.1:0".parse().unwrap()); + let serve_args = serve_args_with_config(&config_path); + let err = runtime() + .block_on(execute_daemon( + &bind, + &serve_args, + storage_dir.path(), + false, + None, + Printer::Silent, + )) + .expect_err("daemon mode should reject stdout logging"); + + assert!( + err.to_string().contains("incompatible with daemon mode"), + "unexpected error: {err}" + ); + let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path()); + assert!( + !runtime_directory.log_path().exists(), + "daemon rejection should happen before server.log is created" + ); + } + + #[test] + fn execute_daemon_rejects_invalid_env_destination_before_creating_server_log() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_server_settings(&config_path, "file"); + + let bind = BindRequest::Tcp("127.0.0.1:0".parse().unwrap()); + let serve_args = serve_args_with_config(&config_path); + + with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdot"), || { + let err = runtime() + .block_on(execute_daemon( + &bind, + &serve_args, + storage_dir.path(), + false, + None, + Printer::Silent, + )) + .expect_err("daemon mode should reject invalid env destination"); + + let message = err.to_string(); + assert!(message.contains(EnvVars::FABRO_LOG_DESTINATION)); + assert!(message.contains("stdot")); + }); + + let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path()); + assert!( + !runtime_directory.log_path().exists(), + "invalid env rejection should happen before server.log is created" + ); + } } diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 5b15c8690..963f46ac5 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -7,15 +7,17 @@ use fabro_config::bind::BindRequest; use fabro_config::user::default_storage_dir; use fabro_server::serve::resolve_bind_request_from_server_settings; use fabro_types::ServerSettings; +use fabro_types::settings::server::LogDestination; use fabro_types::settings::{InterpString, ServerAuthMethod}; use crate::user_config; pub(crate) struct LocalServerConfig { - storage_dir: PathBuf, - auth_methods: Vec, - config_log_level: Option, - server_settings: std::result::Result, + storage_dir: PathBuf, + auth_methods: Vec, + config_log_level: Option, + config_log_destination: Option, + server_settings: std::result::Result, } impl LocalServerConfig { @@ -39,6 +41,7 @@ impl LocalServerConfig { storage_dir: settings.storage_dir, auth_methods, config_log_level: settings.config_log_level, + config_log_destination: settings.config_log_destination, server_settings, } } @@ -52,7 +55,13 @@ impl LocalServerConfig { } pub(crate) fn config_log_level(&self) -> Option<&str> { - self.config_log_level.as_deref() + self.config_log_level + .as_ref() + .map(fabro_config::LogFilter::as_str) + } + + pub(crate) fn config_log_destination(&self) -> Option { + self.config_log_destination } pub(crate) fn bind_request(&self, cli_override: Option<&str>) -> Result { diff --git a/lib/crates/fabro-cli/src/logging.rs b/lib/crates/fabro-cli/src/logging.rs index d0ec8b9b1..6119702e7 100644 --- a/lib/crates/fabro-cli/src/logging.rs +++ b/lib/crates/fabro-cli/src/logging.rs @@ -3,9 +3,11 @@ reason = "CLI logging setup: sync directory scan during startup" )] use std::fs::{File, OpenOptions}; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use fabro_static::EnvVars; +use fabro_types::settings::server::LogDestination; use fabro_util::run_log; use tracing_appender::rolling; use tracing_subscriber::fmt::writer::MakeWriter; @@ -18,7 +20,13 @@ const LOG_RETENTION_DAYS: u32 = 7; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum InternalLogSink { Cli, - Server { path: std::path::PathBuf }, + Server { destination: ServerLogDestination }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ServerLogDestination { + File(PathBuf), + Stdout, } pub(crate) fn init_tracing( @@ -31,8 +39,8 @@ pub(crate) fn init_tracing( } else { config_log_level.unwrap_or("info") }; - let filter = - EnvFilter::try_from_env("FABRO_LOG").unwrap_or_else(|_| EnvFilter::new(default_level)); + let filter = EnvFilter::try_from_env(EnvVars::FABRO_LOG) + .unwrap_or_else(|_| EnvFilter::new(default_level)); match sink { InternalLogSink::Cli => { @@ -52,14 +60,51 @@ pub(crate) fn init_tracing( cleanup_old_logs(&log_dir, "cli", LOG_RETENTION_DAYS); init_subscriber(filter, file_appender); } - InternalLogSink::Server { path } => { - init_subscriber(filter, FixedFileAppender::open(path)?); - } + InternalLogSink::Server { destination } => match destination { + ServerLogDestination::File(path) => { + init_subscriber(filter, FixedFileAppender::open(path)?); + } + ServerLogDestination::Stdout => { + init_subscriber(filter, std::io::stdout); + } + }, } Ok(()) } +pub(crate) fn resolve_log_destination( + config_destination: LogDestination, +) -> Result { + let env_value = std::env::var(EnvVars::FABRO_LOG_DESTINATION).ok(); + resolve_log_destination_with_env(config_destination, env_value.as_deref()) +} + +pub(crate) fn resolve_log_destination_with_env( + config_destination: LogDestination, + env_value: Option<&str>, +) -> Result { + match env_value { + Some(value) => value.parse::().with_context(|| { + format!( + "invalid {} value `{value}`; expected `file` or `stdout`", + EnvVars::FABRO_LOG_DESTINATION + ) + }), + None => Ok(config_destination), + } +} + +pub(crate) fn server_log_destination( + destination: LogDestination, + log_path: PathBuf, +) -> ServerLogDestination { + match destination { + LogDestination::File => ServerLogDestination::File(log_path), + LogDestination::Stdout => ServerLogDestination::Stdout, + } +} + fn cleanup_old_logs(log_dir: &Path, prefix: &str, max_age_days: u32) { let cutoff = chrono::Utc::now().date_naive() - chrono::Duration::days(i64::from(max_age_days)); let Ok(entries) = std::fs::read_dir(log_dir) else { diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 949ab7b90..9cb2b7e55 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -461,15 +461,26 @@ async fn prepare_server_bootstrap( let local_config = local_server::LocalServerConfig::load(config_path, storage_dir)?; let storage_dir = local_config.storage_dir().to_path_buf(); let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.clone()); + let log_destination = logging::resolve_log_destination( + local_config.config_log_destination().unwrap_or_default(), + )?; + let server_log_destination = + logging::server_log_destination(log_destination, runtime_directory.log_path()); let foreground_server_log_bootstrap = if foreground { - Some(commands::server::start::prepare_foreground_server_log(&runtime_directory).await?) + Some( + commands::server::start::prepare_foreground_server_log( + &runtime_directory, + &server_log_destination, + ) + .await?, + ) } else { None }; Ok(PreTracingBootstrap { sink: logging::InternalLogSink::Server { - path: runtime_directory.log_path(), + destination: server_log_destination, }, config_log_level: local_config.config_log_level().map(str::to_owned), foreground_server_log_bootstrap, @@ -486,6 +497,7 @@ mod tests { AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand, ProviderCommand, ProviderNamespace, }; + use temp_env::with_var; use tokio::runtime::Runtime; use super::*; @@ -495,18 +507,32 @@ mod tests { } fn write_test_settings(path: &std::path::Path) { + write_test_settings_with_logging(path, "warn", "stdout"); + } + + fn write_test_settings_with_logging(path: &std::path::Path, level: &str, destination: &str) { std::fs::write( path, - r#" + format!( + r#" _version = 1 [server.logging] -level = "warn" -"#, +level = "{level}" +destination = "{destination}" +"# + ), ) .unwrap(); } + fn expect_bootstrap_err(result: Result) -> anyhow::Error { + match result { + Ok(_) => panic!("bootstrap should have failed"), + Err(err) => err, + } + } + #[test] fn pre_tracing_bootstrap_uses_cli_sink_for_normal_cli_command() { let cli = Cli::try_parse_from(["fabro", "uninstall"]).expect("should parse"); @@ -665,7 +691,7 @@ level = "warn" .expect("bootstrap should resolve"); assert_eq!(bootstrap.sink, logging::InternalLogSink::Server { - path: storage_dir.path().join("logs").join("server.log"), + destination: logging::ServerLogDestination::Stdout, }); assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn")); assert!(bootstrap.foreground_server_log_bootstrap.is_some()); @@ -696,7 +722,7 @@ level = "warn" .expect("bootstrap should resolve"); assert_eq!(bootstrap.sink, logging::InternalLogSink::Server { - path: storage_dir.path().join("logs").join("server.log"), + destination: logging::ServerLogDestination::Stdout, }); assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn")); assert!(bootstrap.foreground_server_log_bootstrap.is_some()); @@ -725,12 +751,125 @@ level = "warn" .expect("bootstrap should resolve"); assert_eq!(bootstrap.sink, logging::InternalLogSink::Server { - path: storage_dir.path().join("logs").join("server.log"), + destination: logging::ServerLogDestination::Stdout, }); assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn")); assert!(bootstrap.foreground_server_log_bootstrap.is_none()); } + #[test] + fn pre_tracing_bootstrap_env_destination_overrides_config_file() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_test_settings_with_logging(&config_path, "warn", "file"); + + let cli = Cli::try_parse_from([ + "fabro", + "server", + "start", + "--foreground", + "--storage-dir", + storage_dir.path().to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + ]) + .expect("should parse"); + let command = cli.command.as_deref().unwrap(); + + with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdout"), || { + let bootstrap = runtime() + .block_on(pre_tracing_bootstrap(command)) + .expect("bootstrap should resolve"); + + assert_eq!(bootstrap.sink, logging::InternalLogSink::Server { + destination: logging::ServerLogDestination::Stdout, + }); + }); + } + + #[test] + fn pre_tracing_bootstrap_rejects_invalid_env_destination() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_test_settings_with_logging(&config_path, "warn", "file"); + + let cli = Cli::try_parse_from([ + "fabro", + "server", + "start", + "--foreground", + "--storage-dir", + storage_dir.path().to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + ]) + .expect("should parse"); + let command = cli.command.as_deref().unwrap(); + + with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdot"), || { + let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command))); + let message = err.to_string(); + assert!(message.contains(EnvVars::FABRO_LOG_DESTINATION)); + assert!(message.contains("stdot")); + }); + } + + #[test] + fn pre_tracing_bootstrap_rejects_invalid_config_log_level() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_test_settings_with_logging(&config_path, "definitely not a filter", "file"); + + let cli = Cli::try_parse_from([ + "fabro", + "server", + "start", + "--foreground", + "--storage-dir", + storage_dir.path().to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + ]) + .expect("should parse"); + let command = cli.command.as_deref().unwrap(); + + let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command))); + assert!( + err.to_string().contains("server.logging.level"), + "unexpected error: {err}" + ); + } + + #[test] + fn pre_tracing_bootstrap_rejects_invalid_config_destination() { + let storage_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let config_path = config_dir.path().join("settings.toml"); + write_test_settings_with_logging(&config_path, "warn", "stdot"); + + let cli = Cli::try_parse_from([ + "fabro", + "server", + "start", + "--foreground", + "--storage-dir", + storage_dir.path().to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + ]) + .expect("should parse"); + let command = cli.command.as_deref().unwrap(); + + let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command))); + assert!( + err.to_string().contains("server.logging.destination"), + "unexpected error: {err}" + ); + } + #[test] fn pre_tracing_bootstrap_uses_cli_sink_for_server_start_daemon_wrapper() { let storage_dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index d6bf4bec9..55bb1cb7e 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,15 +1,16 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; -use anyhow::Result; +use anyhow::{Context, Result, anyhow}; pub(crate) use fabro_client::ServerTarget; pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; use fabro_config::user::{default_settings_path, default_socket_path}; use fabro_config::{ - CliLayer, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, + CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, }; use fabro_static::EnvVars; use fabro_types::settings::cli::CliTargetSettings; +use fabro_types::settings::server::LogDestination; use fabro_types::settings::{CliNamespace, InterpString, RunNamespace}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::version::FABRO_VERSION; @@ -18,11 +19,12 @@ use tracing::debug; use crate::args::ServerTargetArgs; pub(crate) struct LoadedSettings { - pub(crate) storage_dir: PathBuf, - pub(crate) config_log_level: Option, - pub(crate) run_settings: std::result::Result, - pub(crate) server_settings: std::result::Result, - pub(crate) user_settings: UserSettings, + pub(crate) storage_dir: PathBuf, + pub(crate) config_log_level: Option, + pub(crate) config_log_destination: Option, + pub(crate) run_settings: std::result::Result, + pub(crate) server_settings: std::result::Result, + pub(crate) user_settings: UserSettings, } pub(crate) fn load_resolved_settings( @@ -33,7 +35,7 @@ pub(crate) fn load_resolved_settings( let document = load_settings_document(config_path)?; let storage_override = storage_dir.map(Path::to_path_buf); let storage_dir = storage_dir_from_document(&document, storage_dir)?; - let config_log_level = config_log_level_from_document(&document); + let pre_tracing_config = pre_tracing_config_from_document(&document)?; let run_settings = load_run_settings(config_path).map_err(|err| err.to_string()); let server_settings = load_server_settings(config_path) .map(|settings| match storage_override.as_deref() { @@ -45,7 +47,8 @@ pub(crate) fn load_resolved_settings( Ok(LoadedSettings { storage_dir, - config_log_level, + config_log_level: pre_tracing_config.log_level, + config_log_destination: pre_tracing_config.log_destination, run_settings, server_settings, user_settings, @@ -118,8 +121,48 @@ fn load_user_settings( }) } -fn config_log_level_from_document(document: &toml::Value) -> Option { - string_at_path(document, &["server", "logging", "level"]) +struct PreTracingConfig { + log_level: Option, + log_destination: Option, +} + +fn pre_tracing_config_from_document(document: &toml::Value) -> Result { + Ok(PreTracingConfig { + log_level: log_filter_at_path(document, &["server", "logging", "level"])?, + log_destination: log_destination_at_path(document, &["server", "logging", "destination"])?, + }) +} + +fn log_filter_at_path(document: &toml::Value, path: &[&str]) -> Result> { + let Some(value) = value_at_path(document, path) else { + return Ok(None); + }; + let raw = value + .as_str() + .ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?; + LogFilter::parse(raw) + .with_context(|| format!("invalid {} `{raw}`", path.join("."))) + .map(Some) +} + +fn log_destination_at_path( + document: &toml::Value, + path: &[&str], +) -> Result> { + let Some(value) = value_at_path(document, path) else { + return Ok(None); + }; + let raw = value + .as_str() + .ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?; + raw.parse::() + .with_context(|| { + format!( + "invalid {} `{raw}`; expected `file` or `stdout`", + path.join(".") + ) + }) + .map(Some) } fn storage_dir_from_document( @@ -163,11 +206,15 @@ fn storage_dir_from_document_with_lookup( } fn string_at_path(document: &toml::Value, path: &[&str]) -> Option { + value_at_path(document, path).and_then(|value| value.as_str().map(str::to_owned)) +} + +fn value_at_path<'a>(document: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Value> { let mut current = document; for segment in path { current = current.get(*segment)?; } - current.as_str().map(str::to_owned) + Some(current) } /// Pull the resolved CLI target configuration out of `[cli.target]`. @@ -233,7 +280,7 @@ pub(crate) fn load_resolved_settings_from_toml( .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; let storage_override = storage_dir.map(Path::to_path_buf); let storage_dir = storage_dir_from_document(&document, storage_dir)?; - let config_log_level = config_log_level_from_document(&document); + let pre_tracing_config = pre_tracing_config_from_document(&document)?; let run_settings = RunSettingsBuilder::from_toml(source).map_err(|err| err.to_string()); let server_settings = ServerSettingsBuilder::from_toml(source) .map(|settings| match storage_override.as_deref() { @@ -248,7 +295,8 @@ pub(crate) fn load_resolved_settings_from_toml( Ok(LoadedSettings { storage_dir, - config_log_level, + config_log_level: pre_tracing_config.log_level, + config_log_destination: pre_tracing_config.log_destination, run_settings, server_settings, user_settings, diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index f950b63de..1eb3b7247 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -34,6 +34,7 @@ strsim = "0.11" tempfile = "3" toml.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true thiserror.workspace = true ulid.workspace = true diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index 90f2dec14..1cbebf123 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -5,10 +5,12 @@ use fabro_types::settings::run::{ AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, WorktreeMode, }; use fabro_types::settings::server::{ - GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, + GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod, + WebhookStrategy, }; use fabro_types::settings::{Duration, InterpString, Size}; +use super::LogFilter; use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer}; use super::features::FeaturesLayer; use super::run::{ @@ -18,7 +20,7 @@ use super::run::{ }; use super::server::{ ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer, - ServerListenLayer, ServerLoggingLayer, + ServerListenLayer, }; /// Internal merge trait used by sparse config layers inside `fabro-config`. @@ -74,9 +76,11 @@ impl_combine_or_option!( RunMode, WorktreeMode, GithubIntegrationStrategy, + LogDestination, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, + LogFilter, ); impl Combine for Option> { @@ -128,7 +132,6 @@ impl_combine_self!( ServerApiLayer, ServerAuthGithubLayer, ServerListenLayer, - ServerLoggingLayer, ); impl Combine for RunCheckpointLayer { @@ -284,8 +287,13 @@ mod tests { GithubIntegrationStrategy::Token, ); assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local); + assert_option_leaf(LogDestination::Stdout, LogDestination::File); assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken); assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel); + assert_option_leaf( + LogFilter::parse("debug").unwrap(), + LogFilter::parse("info").unwrap(), + ); assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]); assert_option_leaf(vec![ServerAuthMethod::Github], vec![ ServerAuthMethod::DevToken, diff --git a/lib/crates/fabro-config/src/layers/log_filter.rs b/lib/crates/fabro-config/src/layers/log_filter.rs new file mode 100644 index 000000000..4ec82a1e8 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/log_filter.rs @@ -0,0 +1,45 @@ +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogFilter(String); + +impl LogFilter { + pub fn parse(value: &str) -> anyhow::Result { + if value.chars().any(char::is_whitespace) { + anyhow::bail!("filter must not contain whitespace"); + } + + EnvFilter::builder() + .parse(value) + .map(|_| Self(value.to_owned())) + .map_err(Into::into) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Serialize for LogFilter { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for LogFilter { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(|err| { + D::Error::custom(format!("invalid server.logging.level `{value}`: {err}")) + }) + } +} diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index 331c6a67c..233aa952b 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -1,6 +1,7 @@ mod cli; mod combine; mod features; +mod log_filter; mod maps; mod project; mod run; @@ -15,6 +16,7 @@ pub use cli::{ }; pub(crate) use combine::Combine; pub use features::FeaturesLayer; +pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use project::ProjectLayer; pub use run::{ diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/crates/fabro-config/src/layers/server.rs index 8b4d30e02..61560e313 100644 --- a/lib/crates/fabro-config/src/layers/server.rs +++ b/lib/crates/fabro-config/src/layers/server.rs @@ -1,11 +1,13 @@ //! Sparse `[server]` settings layer definitions. use fabro_types::settings::server::{ - GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, + GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod, + WebhookStrategy, }; use fabro_types::settings::{Duration, InterpString}; use serde::{Deserialize, Serialize}; +use super::LogFilter; use super::maps::StickyMap; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] @@ -179,11 +181,13 @@ pub struct ServerSchedulerLayer { } /// `[server.logging]` — process-owned logging configuration for the server. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerLoggingLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub level: Option, + pub level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination: Option, } /// `[server.integrations.]` — cohesive integration surface for chat diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index c02ffa4f7..a5edd51a2 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -39,7 +39,7 @@ pub use layers::{ CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, DiscordIntegrationLayer, FeaturesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, - InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, McpEntryLayer, MergeMap, + InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index eeb8371ca..6805fd6bd 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -49,10 +49,16 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se .expect("defaults.toml should provide server.scheduler.max_concurrent_runs"), }, logging: ServerLoggingSettings { - level: layer + level: layer .logging .as_ref() - .and_then(|logging| logging.level.clone()), + .and_then(|logging| logging.level.as_ref()) + .map(|level| level.as_str().to_owned()), + destination: layer + .logging + .as_ref() + .and_then(|logging| logging.destination) + .unwrap_or_default(), }, integrations, } diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs index be3677265..a3576dd50 100644 --- a/lib/crates/fabro-config/src/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -1,5 +1,6 @@ use fabro_types::settings::InterpString; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::server::LogDestination; use crate::{Combine, SettingsLayer, StringOrSplice}; @@ -259,6 +260,30 @@ level = "debug" assert_eq!(updates.check, Some(true)); } +#[test] +fn server_logging_merges_by_field() { + let lower = parse( + r#" +[server.logging] +level = "warn" +"#, + ); + let higher = parse( + r#" +[server.logging] +destination = "stdout" +"#, + ); + + let merged = higher.combine(lower); + let logging = merged.server.unwrap().logging.unwrap(); + assert_eq!( + logging.level.as_ref().map(fabro_config::LogFilter::as_str), + Some("warn") + ); + assert_eq!(logging.destination, Some(LogDestination::Stdout)); +} + #[test] fn whole_replace_option_subtable_does_not_inherit_fallback_fields() { let lower = parse( diff --git a/lib/crates/fabro-config/src/tests/log_filter.rs b/lib/crates/fabro-config/src/tests/log_filter.rs new file mode 100644 index 000000000..e1da6381f --- /dev/null +++ b/lib/crates/fabro-config/src/tests/log_filter.rs @@ -0,0 +1,28 @@ +use fabro_config::LogFilter; + +#[test] +fn log_filter_accepts_env_filter_directives() { + let filter = LogFilter::parse("info,fabro_server=debug") + .expect("valid env filter directive should parse"); + + assert_eq!(filter.as_str(), "info,fabro_server=debug"); +} + +#[test] +fn log_filter_rejects_invalid_directives() { + LogFilter::parse("definitely not a filter") + .expect_err("whitespace phrase should not be accepted as a filter"); + LogFilter::parse("fabro_server=definitelynotalevel") + .expect_err("unknown level should not be accepted as a filter"); +} + +#[test] +fn log_filter_round_trips_through_serde() { + let filter = LogFilter::parse("warn,fabro_cli=debug").unwrap(); + + let json = serde_json::to_value(&filter).expect("filter should serialize"); + assert_eq!(json, "warn,fabro_cli=debug"); + + let round_trip: LogFilter = serde_json::from_value(json).expect("filter should deserialize"); + assert_eq!(round_trip, filter); +} diff --git a/lib/crates/fabro-config/src/tests/mod.rs b/lib/crates/fabro-config/src/tests/mod.rs index 9e1d8122b..593af6efc 100644 --- a/lib/crates/fabro-config/src/tests/mod.rs +++ b/lib/crates/fabro-config/src/tests/mod.rs @@ -1,5 +1,6 @@ mod combine; mod defaults; +mod log_filter; mod resolve_cli; mod resolve_features; mod resolve_project; diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs index febec8178..a306a9800 100644 --- a/lib/crates/fabro-config/src/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -5,7 +5,7 @@ use fabro_types::settings::InterpString; use fabro_types::settings::server::{ - GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod, + GithubIntegrationStrategy, IpAllowEntry, LogDestination, ObjectStoreSettings, ServerAuthMethod, ServerListenSettings, ServerNamespace, }; use fabro_util::Home; @@ -67,6 +67,7 @@ fn resolves_server_defaults_from_empty_settings() { assert!(settings.web.enabled); assert_eq!(settings.web.url.as_source(), "http://localhost:3000"); assert_eq!(settings.scheduler.max_concurrent_runs, 5); + assert_eq!(settings.logging.destination, LogDestination::File); match settings.listen { ServerListenSettings::Unix { path } => { @@ -108,6 +109,39 @@ fn resolves_server_defaults_from_empty_settings() { assert!(!settings.slatedb.disk_cache); } +#[test] +fn resolves_server_logging_destination_from_settings() { + let file = parse( + r#" +_version = 1 + +[server.logging] +destination = "stdout" +"#, + ); + + let settings = resolve_server(&file); + + assert_eq!(settings.logging.destination, LogDestination::Stdout); +} + +#[test] +fn parsing_rejects_invalid_server_log_filter() { + let err = r#" +_version = 1 + +[server.logging] +level = "definitely not a filter" +"# + .parse::() + .expect_err("invalid log filters should be rejected at parse time"); + + assert!( + err.to_string().contains("server.logging.level"), + "unexpected error: {err}" + ); +} + #[test] fn server_settings_from_layer_matches_namespace_resolvers() { let settings = parse( diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index 30e8594a0..6688b6f17 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -19,6 +19,8 @@ impl EnvVars { pub const FABRO_HOME: &'static str = "FABRO_HOME"; pub const FABRO_HTTP_PROXY_POLICY: &'static str = "FABRO_HTTP_PROXY_POLICY"; pub const FABRO_JSON: &'static str = "FABRO_JSON"; + pub const FABRO_LOG: &'static str = "FABRO_LOG"; + pub const FABRO_LOG_DESTINATION: &'static str = "FABRO_LOG_DESTINATION"; pub const FABRO_NO_UPGRADE_CHECK: &'static str = "FABRO_NO_UPGRADE_CHECK"; pub const FABRO_QUIET: &'static str = "FABRO_QUIET"; pub const FABRO_SERVER: &'static str = "FABRO_SERVER"; @@ -134,6 +136,8 @@ mod tests { #[test] fn env_var_constants_match_their_names() { assert_eq!(EnvVars::FABRO_CONFIG, "FABRO_CONFIG"); + assert_eq!(EnvVars::FABRO_LOG, "FABRO_LOG"); + assert_eq!(EnvVars::FABRO_LOG_DESTINATION, "FABRO_LOG_DESTINATION"); } #[test] @@ -151,6 +155,8 @@ mod tests { EnvVars::FABRO_HOME, EnvVars::FABRO_HTTP_PROXY_POLICY, EnvVars::FABRO_JSON, + EnvVars::FABRO_LOG, + EnvVars::FABRO_LOG_DESTINATION, EnvVars::FABRO_NO_UPGRADE_CHECK, EnvVars::FABRO_QUIET, EnvVars::FABRO_SERVER, diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 76a45cea7..9b3b6aa31 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -45,7 +45,7 @@ pub use run::{ }; pub use server::{ DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksSettings, - IpAllowEntry, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, + IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings, diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index a80937d78..fa7ed1626 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -230,9 +230,20 @@ pub struct ServerSchedulerSettings { pub max_concurrent_runs: usize, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum::EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum LogDestination { + #[default] + File, + Stdout, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerLoggingSettings { - pub level: Option, + pub level: Option, + #[serde(default)] + pub destination: LogDestination, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index e76e82034..64256d2a4 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -103,6 +103,7 @@ models/integration-webhooks-settings.ts models/internal-stage-status.ts models/ip-allow-entry.ts models/literal-ip-allow-entry.ts +models/log-destination.ts models/manifest-args.ts models/manifest-config.ts models/manifest-file-entry.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 83d3be6ef..2bbcaf17c 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -82,6 +82,7 @@ export * from './integration-webhooks-settings'; export * from './internal-stage-status'; export * from './ip-allow-entry'; export * from './literal-ip-allow-entry'; +export * from './log-destination'; export * from './manifest-args'; export * from './manifest-config'; export * from './manifest-file-entry'; diff --git a/lib/packages/fabro-api-client/src/models/log-destination.ts b/lib/packages/fabro-api-client/src/models/log-destination.ts new file mode 100644 index 000000000..6e7caf66f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/log-destination.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const LogDestination = { + FILE: 'file', + STDOUT: 'stdout' +} as const; + +export type LogDestination = typeof LogDestination[keyof typeof LogDestination]; diff --git a/lib/packages/fabro-api-client/src/models/server-logging-settings.ts b/lib/packages/fabro-api-client/src/models/server-logging-settings.ts index d9ab078f4..2fef328fd 100644 --- a/lib/packages/fabro-api-client/src/models/server-logging-settings.ts +++ b/lib/packages/fabro-api-client/src/models/server-logging-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -13,8 +13,11 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { LogDestination } from './log-destination'; export interface ServerLoggingSettings { 'level': string | null; + 'destination': LogDestination; } -