fabro/lib/crates/fabro-cli/src/user_config.rs
Scott Werner 2bd04c7935
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Demote control-plane config to plain String; native FABRO_WEB_URL read (#510)
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.

## Demoted to `String` (was `InterpString`)

Both the layer and resolved types:

- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`

**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.

## Native `FABRO_WEB_URL` read

Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.

## Behavior change (release notes)

- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).

## Cleanup

`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).

## Verification

- `cargo build --workspace` 
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` 
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` 
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:03:14 -04:00

620 lines
20 KiB
Rust

use std::path::{Path, PathBuf};
use std::str::FromStr;
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, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
};
use fabro_static::EnvVars;
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::CliTargetSettings;
use fabro_types::settings::server::LogDestination;
use fabro_types::{ServerSettings, UserSettings};
use fabro_util::error::SharedError;
use fabro_util::version::FABRO_VERSION;
use toml_edit::{DocumentMut, Item, Table, value};
use tracing::debug;
use crate::args::ServerTargetArgs;
pub(crate) struct LoadedSettings {
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<LogFilter>,
pub(crate) config_log_destination: Option<LogDestination>,
pub(crate) run_settings: std::result::Result<RunNamespace, SharedError>,
pub(crate) server_settings: std::result::Result<ServerSettings, SharedError>,
pub(crate) user_settings: UserSettings,
}
pub(crate) fn load_resolved_settings(
config_path: Option<&Path>,
storage_dir: Option<&Path>,
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
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 pre_tracing_config = pre_tracing_config_from_document(&document)?;
let run_settings = load_run_settings(config_path).map_err(SharedError::new);
let server_settings = load_server_settings(config_path)
.map(|settings| match storage_override.as_deref() {
Some(dir) => settings.with_storage_override(dir),
None => settings,
})
.map_err(SharedError::new);
let user_settings = load_user_settings(config_path, cli_layer)?;
Ok(LoadedSettings {
storage_dir,
config_log_level: pre_tracing_config.log_level,
config_log_destination: pre_tracing_config.log_destination,
run_settings,
server_settings,
user_settings,
})
}
fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result<toml::Value> {
load_settings_document_with_lookup(config_path, process_env_var_os)
}
#[expect(
clippy::disallowed_methods,
reason = "sync settings load during CLI startup; not on a Tokio path"
)]
fn load_settings_document_with_lookup(
config_path: Option<&Path>,
lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> anyhow::Result<toml::Value> {
let config_path = config_path
.map(Path::to_path_buf)
.or_else(|| lookup(EnvVars::FABRO_CONFIG).map(PathBuf::from));
let path = if let Some(path) = config_path {
path
} else {
let default_path = default_settings_path();
if !default_path.is_file() {
return Ok(toml::Value::Table(toml::Table::new()));
}
default_path
};
let contents = std::fs::read_to_string(&path)
.map_err(|source| fabro_config::Error::read_file(&path, source))?;
let table: toml::Table = toml::from_str(&contents).map_err(|source| {
fabro_config::Error::parse_file(
"Failed to parse settings file",
&path,
ParseError::Toml(source.to_string()),
)
})?;
Ok(toml::Value::Table(table))
}
fn load_run_settings(config_path: Option<&Path>) -> anyhow::Result<RunNamespace> {
let catalog = fabro_environment::seeded_catalog_layer();
Ok(match config_path {
Some(path) => RunSettingsBuilder::load_from_with_catalog(path, catalog)?,
None => RunSettingsBuilder::load_default_with_catalog(catalog)?,
})
}
fn load_server_settings(config_path: Option<&Path>) -> anyhow::Result<ServerSettings> {
Ok(match config_path {
Some(path) => ServerSettingsBuilder::load_from(path)?,
None => ServerSettingsBuilder::load_default()?,
})
}
fn load_user_settings(
config_path: Option<&Path>,
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<UserSettings> {
Ok(match (config_path, cli_layer) {
(Some(path), Some(cli_layer)) => {
UserSettingsBuilder::load_from_with_cli_overrides(path, cli_layer)?
}
(Some(path), None) => UserSettingsBuilder::load_from(path)?,
(None, Some(cli_layer)) => UserSettingsBuilder::load_default_with_cli_overrides(cli_layer)?,
(None, None) => UserSettingsBuilder::load_default()?,
})
}
struct PreTracingConfig {
log_level: Option<LogFilter>,
log_destination: Option<LogDestination>,
}
fn pre_tracing_config_from_document(document: &toml::Value) -> Result<PreTracingConfig> {
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<Option<LogFilter>> {
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<Option<LogDestination>> {
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::<LogDestination>()
.with_context(|| {
format!(
"invalid {} `{raw}`; expected `file` or `stdout`",
path.join(".")
)
})
.map(Some)
}
pub(crate) fn storage_dir_from_document(
document: &toml::Value,
storage_dir: Option<&Path>,
) -> PathBuf {
if let Some(dir) = storage_dir {
return dir.to_path_buf();
}
// `server.storage.root` is plain control-plane config and does not
// interpolate; the FABRO_STORAGE_DIR-backed `storage_dir` argument above is
// the deployment-time override.
let storage_root = string_at_path(document, &["server", "storage", "root"])
.unwrap_or_else(|| default_storage_dir().to_string_lossy().into_owned());
PathBuf::from(storage_root)
}
#[expect(
clippy::disallowed_methods,
reason = "CLI settings loading owns the process-env facade for config path lookup."
)]
fn process_env_var_os(name: &str) -> Option<std::ffi::OsString> {
std::env::var_os(name)
}
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
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)?;
}
Some(current)
}
fn configured_server_target(settings: &UserSettings) -> Result<Option<ServerTarget>> {
match settings.cli.target.as_ref() {
Some(CliTargetSettings::Http { url }) => ServerTarget::http_url(url).map(Some),
Some(CliTargetSettings::Unix { path }) => ServerTarget::unix_socket_path(path).map(Some),
None => Ok(None),
}
}
pub(crate) fn default_server_target() -> ServerTarget {
ServerTarget::unix_socket_path(default_socket_path()).expect("default socket path is absolute")
}
fn parse_server_target(value: &str) -> Result<ServerTarget> {
ServerTarget::from_str(value)
}
fn explicit_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget>> {
args.as_deref().map(parse_server_target).transpose()
}
pub(crate) fn resolve_nondefault_server_target(
args: &ServerTargetArgs,
settings: &UserSettings,
) -> Result<Option<ServerTarget>> {
Ok(explicit_server_target(args)?.or(configured_server_target(settings)?))
}
pub(crate) fn resolve_server_target(
args: &ServerTargetArgs,
settings: &UserSettings,
) -> Result<ServerTarget> {
Ok(resolve_nondefault_server_target(args, settings)?.unwrap_or_else(default_server_target))
}
#[expect(
clippy::disallowed_methods,
reason = "CLI auth/login updates the user settings file synchronously after successful login."
)]
pub(crate) fn configure_cli_target_if_missing(
config_path: &Path,
settings: &UserSettings,
target: &ServerTarget,
) -> Result<bool> {
if settings.cli.target.is_some() {
return Ok(false);
}
let mut document = read_settings_document_for_write(config_path)?;
if document
.get("cli")
.and_then(Item::as_table)
.is_some_and(|cli| cli.get("target").is_some_and(|target| !target.is_none()))
{
return Ok(false);
}
if document.get("_version").is_none() {
document["_version"] = value(1);
}
let cli = ensure_document_table(&mut document, "cli")?;
cli.insert("target", Item::Table(cli_target_table(target)));
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(config_path, document.to_string())
.with_context(|| format!("failed to write {}", config_path.display()))?;
Ok(true)
}
#[expect(
clippy::disallowed_methods,
reason = "CLI auth/login reads the user settings file synchronously before writing it."
)]
fn read_settings_document_for_write(config_path: &Path) -> Result<DocumentMut> {
if !config_path.exists() {
return Ok(DocumentMut::new());
}
let contents = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
contents
.parse::<DocumentMut>()
.with_context(|| format!("failed to parse {}", config_path.display()))
}
fn ensure_document_table<'a>(document: &'a mut DocumentMut, key: &str) -> Result<&'a mut Table> {
if document.get(key).is_none() {
let mut table = Table::new();
table.set_implicit(true);
document[key] = Item::Table(table);
}
document[key]
.as_table_mut()
.ok_or_else(|| anyhow!("settings.toml [{key}] is not a table"))
}
fn cli_target_table(target: &ServerTarget) -> Table {
let mut table = Table::new();
if let Some(url) = target.as_http_url() {
table.insert("type", value("http"));
table.insert("url", value(url));
} else if let Some(path) = target.as_unix_socket_path() {
table.insert("type", value("unix"));
table.insert("path", value(path.display().to_string()));
}
table
}
pub(crate) fn exec_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget>> {
let target = explicit_server_target(args)?;
debug!(?target, "Resolved exec server target");
Ok(target)
}
pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder {
fabro_http::HttpClientBuilder::new().user_agent(format!("fabro-cli/{FABRO_VERSION}"))
}
#[cfg(test)]
pub(crate) fn load_resolved_settings_from_toml(
source: &str,
storage_dir: Option<&Path>,
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir);
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
let run_settings = RunSettingsBuilder::from_toml_with_catalog(
source,
fabro_environment::seeded_catalog_layer(),
)
.map_err(|err| SharedError::new(anyhow::Error::new(err)));
let server_settings = ServerSettingsBuilder::from_toml(source)
.map(|settings| match storage_override.as_deref() {
Some(dir) => settings.with_storage_override(dir),
None => settings,
})
.map_err(|err| SharedError::new(anyhow::Error::new(err)));
let user_settings = match cli_layer {
Some(cli_layer) => UserSettingsBuilder::from_toml_with_cli_overrides(source, cli_layer)?,
None => UserSettingsBuilder::from_toml(source)?,
};
Ok(LoadedSettings {
storage_dir,
config_log_level: pre_tracing_config.log_level,
config_log_destination: pre_tracing_config.log_destination,
run_settings,
server_settings,
user_settings,
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fabro_config::UserSettingsBuilder;
use fabro_config::user::default_storage_dir;
use fabro_types::UserSettings;
use super::*;
use crate::args::ServerTargetArgs;
fn server_target_args(value: Option<&str>) -> ServerTargetArgs {
ServerTargetArgs {
server: value.map(str::to_string),
}
}
fn parse_user_settings(source: &str) -> UserSettings {
UserSettingsBuilder::from_toml(source).expect("fixture should resolve")
}
#[test]
fn exec_has_no_server_target_by_default() {
assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None);
}
#[test]
fn exec_uses_cli_server_target() {
assert_eq!(
exec_server_target(&server_target_args(Some("https://cli.example.com"))).unwrap(),
Some(ServerTarget::http_url("https://cli.example.com").unwrap())
);
}
#[test]
fn exec_supports_explicit_unix_socket_target() {
assert_eq!(
exec_server_target(&server_target_args(Some("/tmp/fabro.sock"))).unwrap(),
Some(ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap())
);
}
#[test]
fn exec_ignores_configured_server_target_without_cli_override() {
assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None);
}
#[test]
fn resolve_server_target_uses_configured_server_target() {
let settings = parse_user_settings(
r#"
_version = 1
[cli.target]
type = "http"
url = "https://config.example.com"
"#,
);
assert_eq!(
resolve_server_target(&server_target_args(None), &settings).unwrap(),
ServerTarget::http_url("https://config.example.com").unwrap()
);
}
#[test]
fn resolve_server_target_explicit_target_overrides_config_target() {
let settings = parse_user_settings(
r#"
_version = 1
[cli.target]
type = "http"
url = "https://config.example.com"
"#,
);
assert_eq!(
resolve_server_target(
&server_target_args(Some("https://cli.example.com")),
&settings
)
.unwrap(),
ServerTarget::http_url("https://cli.example.com").unwrap()
);
}
#[test]
fn resolve_server_target_defaults_to_default_unix_socket_target() {
let settings = UserSettings::default();
assert_eq!(
resolve_server_target(&server_target_args(None), &settings).unwrap(),
ServerTarget::unix_socket_path(dirs::home_dir().unwrap().join(".fabro/fabro.sock"))
.unwrap()
);
}
#[test]
fn explicit_server_target_overrides_config_target() {
let settings = parse_user_settings(
r#"
_version = 1
[cli.target]
type = "http"
url = "https://config.example.com"
"#,
);
assert_eq!(
resolve_server_target(
&server_target_args(Some("https://cli.example.com")),
&settings
)
.unwrap(),
ServerTarget::http_url("https://cli.example.com").unwrap()
);
}
#[test]
fn invalid_server_target_is_rejected() {
let error = exec_server_target(&server_target_args(Some("fabro.internal"))).unwrap_err();
assert_eq!(
error.to_string(),
"server target must be an http(s) URL or absolute Unix socket path"
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs"
)]
fn configure_cli_target_creates_settings_file_when_missing() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".fabro").join("settings.toml");
let settings = UserSettings::default();
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
assert!(configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
let contents = std::fs::read_to_string(&config_path).unwrap();
insta::assert_snapshot!(contents, @r#"
_version = 1
[cli.target]
type = "http"
url = "http://127.0.0.1:32276"
"#);
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
assert_eq!(
resolve_server_target(&server_target_args(None), &settings).unwrap(),
target
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs"
)]
fn configure_cli_target_does_not_overwrite_existing_target() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("settings.toml");
let existing = r#"
_version = 1
[cli.target]
type = "http"
url = "https://configured.example.com"
"#;
std::fs::write(&config_path, existing).unwrap();
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
let target = ServerTarget::http_url("https://new.example.com").unwrap();
assert!(!configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
assert_eq!(std::fs::read_to_string(&config_path).unwrap(), existing);
}
#[test]
fn storage_dir_defaults_without_server_auth_methods() {
let document = toml::Value::Table(toml::Table::new());
assert_eq!(
storage_dir_from_document(&document, None),
default_storage_dir()
);
}
#[test]
fn storage_dir_uses_explicit_server_storage_root() {
let document: toml::Value = toml::from_str(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
)
.expect("fixture should parse");
assert_eq!(
storage_dir_from_document(&document, None),
PathBuf::from("/srv/fabro")
);
}
#[test]
fn storage_dir_keeps_template_token_literal() {
// server.storage.root is plain control-plane config now: a `{{ env.* }}`
// token is used verbatim, never resolved. Deployment-time overrides go
// through the FABRO_STORAGE_DIR-backed `storage_dir` argument instead.
let document: toml::Value = toml::from_str(
r#"
_version = 1
[server.storage]
root = "{{ env.FABRO_STORAGE_ROOT }}"
"#,
)
.expect("fixture should parse");
assert_eq!(
storage_dir_from_document(&document, None),
PathBuf::from("{{ env.FABRO_STORAGE_ROOT }}")
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs::write"
)]
fn load_settings_document_uses_fabro_config_env_for_storage_root() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("settings.toml");
std::fs::write(
&config_path,
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
)
.unwrap();
let document = load_settings_document_with_lookup(None, |_| {
Some(config_path.clone().into_os_string())
})
.expect("settings document should load");
assert_eq!(
storage_dir_from_document(&document, None),
PathBuf::from("/srv/fabro")
);
}
}