mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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>
This commit is contained in:
parent
1626240220
commit
2bd04c7935
26 changed files with 364 additions and 543 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
_version = 1
|
_version = 1
|
||||||
|
|
||||||
|
# server.web.url is provided at runtime by the FABRO_WEB_URL environment
|
||||||
|
# variable (set in docker-compose.split-web.yaml), read natively by the server.
|
||||||
|
# It overrides the default below, so no value is needed here.
|
||||||
[server.web]
|
[server.web]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "{{ env.FABRO_WEB_URL }}"
|
|
||||||
|
|
||||||
[server.auth]
|
[server.auth]
|
||||||
methods = ["dev-token"]
|
methods = ["dev-token"]
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ use fabro_model::Catalog;
|
||||||
use fabro_server::run_tool_manifest;
|
use fabro_server::run_tool_manifest;
|
||||||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||||
use fabro_tool::fabro_client::ClientBackend;
|
use fabro_tool::fabro_client::ClientBackend;
|
||||||
use fabro_types::settings::InterpString;
|
|
||||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId,
|
ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId,
|
||||||
|
|
@ -1108,11 +1107,6 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
|
|
||||||
interpolation unification (Phase 2)"
|
|
||||||
)]
|
|
||||||
fn maybe_build_github_credentials(
|
fn maybe_build_github_credentials(
|
||||||
settings: &WorkflowSettings,
|
settings: &WorkflowSettings,
|
||||||
vault: Option<&fabro_vault::Vault>,
|
vault: Option<&fabro_vault::Vault>,
|
||||||
|
|
@ -1123,12 +1117,8 @@ fn maybe_build_github_credentials(
|
||||||
let strategy = server_ns
|
let strategy = server_ns
|
||||||
.map(|server| server.integrations.github.strategy)
|
.map(|server| server.integrations.github.strategy)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let app_id = server_ns
|
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
|
||||||
.and_then(|server| server.integrations.github.app_id.as_ref())
|
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
|
||||||
.map(InterpString::as_source);
|
|
||||||
let app_slug = server_ns
|
|
||||||
.and_then(|server| server.integrations.github.slug.as_ref())
|
|
||||||
.map(InterpString::as_source);
|
|
||||||
|
|
||||||
if requires_github_credentials(resolved_run) {
|
if requires_github_credentials(resolved_run) {
|
||||||
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
|
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use fabro_config::bind::BindRequest;
|
use fabro_config::bind::BindRequest;
|
||||||
use fabro_config::user::default_storage_dir;
|
|
||||||
use fabro_server::serve::resolve_bind_request_from_server_settings;
|
use fabro_server::serve::resolve_bind_request_from_server_settings;
|
||||||
use fabro_types::ServerSettings;
|
use fabro_types::ServerSettings;
|
||||||
|
use fabro_types::settings::ServerAuthMethod;
|
||||||
use fabro_types::settings::server::LogDestination;
|
use fabro_types::settings::server::LogDestination;
|
||||||
use fabro_types::settings::{InterpString, ServerAuthMethod};
|
|
||||||
use fabro_util::error::SharedError;
|
use fabro_util::error::SharedError;
|
||||||
|
|
||||||
use crate::user_config;
|
use crate::user_config;
|
||||||
|
|
@ -75,42 +74,8 @@ impl LocalServerConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn storage_dir_from_toml(source: &str) -> Result<PathBuf> {
|
pub(crate) fn storage_dir_from_toml(source: &str) -> Result<PathBuf> {
|
||||||
storage_dir_from_toml_with_lookup(source, &process_env_var)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "Local server config interpolation owns a process-env lookup facade for {{ env.* }} values."
|
|
||||||
)]
|
|
||||||
fn process_env_var(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "raw source shown in the error message when resolution fails"
|
|
||||||
)]
|
|
||||||
fn storage_dir_from_toml_with_lookup(
|
|
||||||
source: &str,
|
|
||||||
lookup: &dyn Fn(&str) -> Option<String>,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
|
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
|
||||||
let storage_root = string_at_path(&document, &["server", "storage", "root"]).map_or_else(
|
Ok(user_config::storage_dir_from_document(&document, None))
|
||||||
|| InterpString::parse(&default_storage_dir().to_string_lossy()),
|
|
||||||
|root| InterpString::parse(&root),
|
|
||||||
);
|
|
||||||
let resolved_root = storage_root
|
|
||||||
.resolve(lookup)
|
|
||||||
.with_context(|| format!("failed to resolve {}", storage_root.as_source()))?;
|
|
||||||
Ok(PathBuf::from(resolved_root.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
|
|
||||||
let mut current = document;
|
|
||||||
for segment in path {
|
|
||||||
current = current.get(*segment)?;
|
|
||||||
}
|
|
||||||
current.as_str().map(str::to_owned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -119,7 +84,7 @@ mod tests {
|
||||||
|
|
||||||
use fabro_config::user::default_storage_dir;
|
use fabro_config::user::default_storage_dir;
|
||||||
|
|
||||||
use super::{storage_dir_from_toml, storage_dir_from_toml_with_lookup};
|
use super::storage_dir_from_toml;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn storage_dir_from_toml_reads_explicit_root_without_full_server_resolution() {
|
fn storage_dir_from_toml_reads_explicit_root_without_full_server_resolution() {
|
||||||
|
|
@ -144,18 +109,17 @@ root = "/srv/fabro"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn storage_dir_from_toml_resolves_env_interpolation() {
|
fn storage_dir_from_toml_keeps_template_token_literal() {
|
||||||
let path = storage_dir_from_toml_with_lookup(
|
let path = storage_dir_from_toml(
|
||||||
r#"
|
r#"
|
||||||
_version = 1
|
_version = 1
|
||||||
|
|
||||||
[server.storage]
|
[server.storage]
|
||||||
root = "{{ env.FABRO_STORAGE_ROOT }}"
|
root = "{{ env.FABRO_STORAGE_ROOT }}"
|
||||||
"#,
|
"#,
|
||||||
&|name| (name == "FABRO_STORAGE_ROOT").then_some("/srv/fabro".to_string()),
|
|
||||||
)
|
)
|
||||||
.expect("storage root should resolve");
|
.expect("storage root should parse");
|
||||||
|
|
||||||
assert_eq!(path, PathBuf::from("/srv/fabro"));
|
assert_eq!(path, PathBuf::from("{{ env.FABRO_STORAGE_ROOT }}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ use fabro_config::{
|
||||||
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
|
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
|
||||||
};
|
};
|
||||||
use fabro_static::EnvVars;
|
use fabro_static::EnvVars;
|
||||||
|
use fabro_types::settings::RunNamespace;
|
||||||
use fabro_types::settings::cli::CliTargetSettings;
|
use fabro_types::settings::cli::CliTargetSettings;
|
||||||
use fabro_types::settings::server::LogDestination;
|
use fabro_types::settings::server::LogDestination;
|
||||||
use fabro_types::settings::{InterpString, RunNamespace};
|
|
||||||
use fabro_types::{ServerSettings, UserSettings};
|
use fabro_types::{ServerSettings, UserSettings};
|
||||||
use fabro_util::error::SharedError;
|
use fabro_util::error::SharedError;
|
||||||
use fabro_util::version::FABRO_VERSION;
|
use fabro_util::version::FABRO_VERSION;
|
||||||
|
|
@ -36,7 +36,7 @@ pub(crate) fn load_resolved_settings(
|
||||||
) -> anyhow::Result<LoadedSettings> {
|
) -> anyhow::Result<LoadedSettings> {
|
||||||
let document = load_settings_document(config_path)?;
|
let document = load_settings_document(config_path)?;
|
||||||
let storage_override = storage_dir.map(Path::to_path_buf);
|
let storage_override = storage_dir.map(Path::to_path_buf);
|
||||||
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
|
let storage_dir = storage_dir_from_document(&document, storage_dir);
|
||||||
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
||||||
let run_settings = load_run_settings(config_path).map_err(SharedError::new);
|
let run_settings = load_run_settings(config_path).map_err(SharedError::new);
|
||||||
let server_settings = load_server_settings(config_path)
|
let server_settings = load_server_settings(config_path)
|
||||||
|
|
@ -168,19 +168,20 @@ fn log_destination_at_path(
|
||||||
.map(Some)
|
.map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn storage_dir_from_document(
|
pub(crate) fn storage_dir_from_document(
|
||||||
document: &toml::Value,
|
document: &toml::Value,
|
||||||
storage_dir: Option<&Path>,
|
storage_dir: Option<&Path>,
|
||||||
) -> anyhow::Result<PathBuf> {
|
) -> PathBuf {
|
||||||
storage_dir_from_document_with_lookup(document, storage_dir, &process_env_var)
|
if let Some(dir) = storage_dir {
|
||||||
}
|
return dir.to_path_buf();
|
||||||
|
}
|
||||||
|
|
||||||
#[expect(
|
// `server.storage.root` is plain control-plane config and does not
|
||||||
clippy::disallowed_methods,
|
// interpolate; the FABRO_STORAGE_DIR-backed `storage_dir` argument above is
|
||||||
reason = "CLI settings loading owns the process-env facade for interpolation."
|
// the deployment-time override.
|
||||||
)]
|
let storage_root = string_at_path(document, &["server", "storage", "root"])
|
||||||
fn process_env_var(name: &str) -> Option<String> {
|
.unwrap_or_else(|| default_storage_dir().to_string_lossy().into_owned());
|
||||||
std::env::var(name).ok()
|
PathBuf::from(storage_root)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|
@ -191,23 +192,6 @@ fn process_env_var_os(name: &str) -> Option<std::ffi::OsString> {
|
||||||
std::env::var_os(name)
|
std::env::var_os(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn storage_dir_from_document_with_lookup(
|
|
||||||
document: &toml::Value,
|
|
||||||
storage_dir: Option<&Path>,
|
|
||||||
lookup: &dyn Fn(&str) -> Option<String>,
|
|
||||||
) -> anyhow::Result<PathBuf> {
|
|
||||||
if let Some(dir) = storage_dir {
|
|
||||||
return Ok(dir.to_path_buf());
|
|
||||||
}
|
|
||||||
|
|
||||||
let storage_root = string_at_path(document, &["server", "storage", "root"]).map_or_else(
|
|
||||||
|| InterpString::parse(&default_storage_dir().to_string_lossy()),
|
|
||||||
|root| InterpString::parse(&root),
|
|
||||||
);
|
|
||||||
let resolved_root = storage_root.resolve(lookup)?;
|
|
||||||
Ok(PathBuf::from(resolved_root.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
|
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))
|
value_at_path(document, path).and_then(|value| value.as_str().map(str::to_owned))
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +334,7 @@ pub(crate) fn load_resolved_settings_from_toml(
|
||||||
) -> anyhow::Result<LoadedSettings> {
|
) -> anyhow::Result<LoadedSettings> {
|
||||||
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
|
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_override = storage_dir.map(Path::to_path_buf);
|
||||||
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
|
let storage_dir = storage_dir_from_document(&document, storage_dir);
|
||||||
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
||||||
let run_settings = RunSettingsBuilder::from_toml_with_catalog(
|
let run_settings = RunSettingsBuilder::from_toml_with_catalog(
|
||||||
source,
|
source,
|
||||||
|
|
@ -560,7 +544,7 @@ url = "https://configured.example.com"
|
||||||
let document = toml::Value::Table(toml::Table::new());
|
let document = toml::Value::Table(toml::Table::new());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage_dir_from_document(&document, None).unwrap(),
|
storage_dir_from_document(&document, None),
|
||||||
default_storage_dir()
|
default_storage_dir()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -578,13 +562,16 @@ root = "/srv/fabro"
|
||||||
.expect("fixture should parse");
|
.expect("fixture should parse");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage_dir_from_document(&document, None).unwrap(),
|
storage_dir_from_document(&document, None),
|
||||||
PathBuf::from("/srv/fabro")
|
PathBuf::from("/srv/fabro")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn storage_dir_resolves_env_interpolated_root() {
|
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(
|
let document: toml::Value = toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
_version = 1
|
_version = 1
|
||||||
|
|
@ -594,14 +581,10 @@ root = "{{ env.FABRO_STORAGE_ROOT }}"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("fixture should parse");
|
.expect("fixture should parse");
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage_dir_from_document_with_lookup(&document, None, &|name| {
|
storage_dir_from_document(&document, None),
|
||||||
(name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string())
|
PathBuf::from("{{ env.FABRO_STORAGE_ROOT }}")
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
temp.path()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,7 +613,7 @@ root = "/srv/fabro"
|
||||||
.expect("settings document should load");
|
.expect("settings document should load");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage_dir_from_document(&document, None).unwrap(),
|
storage_dir_from_document(&document, None),
|
||||||
PathBuf::from("/srv/fabro")
|
PathBuf::from("/srv/fabro")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ pub enum ServerListenLayer {
|
||||||
},
|
},
|
||||||
Unix {
|
Unix {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: Option<InterpString>,
|
path: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ pub enum ServerListenLayer {
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ServerApiLayer {
|
pub struct ServerApiLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<InterpString>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[server.web]` — web surface settings.
|
/// `[server.web]` — web surface settings.
|
||||||
|
|
@ -67,7 +67,7 @@ pub struct ServerWebLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<InterpString>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[server.auth]` — cohesive server auth surface.
|
/// `[server.auth]` — cohesive server auth surface.
|
||||||
|
|
@ -122,7 +122,7 @@ pub struct ServerSandboxProviderLayer {
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ServerStorageLayer {
|
pub struct ServerStorageLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub root: Option<InterpString>,
|
pub root: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[server.artifacts]` — object-store-backed artifact storage.
|
/// `[server.artifacts]` — object-store-backed artifact storage.
|
||||||
|
|
@ -132,7 +132,7 @@ pub struct ServerArtifactsLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub provider: Option<ObjectStoreProvider>,
|
pub provider: Option<ObjectStoreProvider>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub prefix: Option<InterpString>,
|
pub prefix: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub local: Option<ObjectStoreLocalLayer>,
|
pub local: Option<ObjectStoreLocalLayer>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -146,7 +146,7 @@ pub struct ServerSlateDbLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub provider: Option<ObjectStoreProvider>,
|
pub provider: Option<ObjectStoreProvider>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub prefix: Option<InterpString>,
|
pub prefix: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub flush_interval: Option<Duration>,
|
pub flush_interval: Option<Duration>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -163,18 +163,18 @@ pub struct ObjectStoreLocalLayer {
|
||||||
/// Overrides the default root, which otherwise falls back to
|
/// Overrides the default root, which otherwise falls back to
|
||||||
/// `{server.storage.root}/objects/{domain}`.
|
/// `{server.storage.root}/objects/{domain}`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub root: Option<InterpString>,
|
pub root: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ObjectStoreS3Layer {
|
pub struct ObjectStoreS3Layer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub bucket: Option<InterpString>,
|
pub bucket: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub region: Option<InterpString>,
|
pub region: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub endpoint: Option<InterpString>,
|
pub endpoint: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub path_style: Option<bool>,
|
pub path_style: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
@ -218,11 +218,11 @@ pub struct GithubIntegrationLayer {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub strategy: Option<GithubIntegrationStrategy>,
|
pub strategy: Option<GithubIntegrationStrategy>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub app_id: Option<InterpString>,
|
pub app_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub client_id: Option<InterpString>,
|
pub client_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub slug: Option<InterpString>,
|
pub slug: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub webhooks: Option<IntegrationWebhooksLayer>,
|
pub webhooks: Option<IntegrationWebhooksLayer>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@ pub(crate) fn parse_socket_addr(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn default_interp(path: impl AsRef<std::path::Path>) -> InterpString {
|
pub(crate) fn default_string(path: impl AsRef<std::path::Path>) -> String {
|
||||||
InterpString::parse(&path.as_ref().to_string_lossy())
|
path.as_ref().to_string_lossy().into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warn when a field demoted out of the interpolation set (D2) still contains
|
/// Warn when a field demoted out of the interpolation set (D2) still contains
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use fabro_types::settings::InterpString;
|
use std::path::Path;
|
||||||
|
|
||||||
use fabro_types::settings::server::{
|
use fabro_types::settings::server::{
|
||||||
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
|
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
|
||||||
ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
|
ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
|
||||||
|
|
@ -10,7 +11,10 @@ use fabro_types::settings::server::{
|
||||||
};
|
};
|
||||||
use fabro_util::Home;
|
use fabro_util::Home;
|
||||||
|
|
||||||
use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
|
use super::{
|
||||||
|
ResolveError, default_string, parse_socket_addr, require_interp, require_string,
|
||||||
|
warn_if_demoted_template,
|
||||||
|
};
|
||||||
use crate::user::default_storage_dir;
|
use crate::user::default_storage_dir;
|
||||||
use crate::{
|
use crate::{
|
||||||
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
|
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
|
||||||
|
|
@ -27,11 +31,12 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
|
||||||
let integrations = resolve_integrations(layer.integrations.as_ref());
|
let integrations = resolve_integrations(layer.integrations.as_ref());
|
||||||
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
|
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
|
||||||
|
|
||||||
|
let api_url = layer.api.as_ref().and_then(|api| api.url.clone());
|
||||||
|
warn_if_demoted_template("server.api.url", api_url.as_deref());
|
||||||
|
|
||||||
ServerNamespace {
|
ServerNamespace {
|
||||||
listen,
|
listen,
|
||||||
api: ServerApiSettings {
|
api: ServerApiSettings { url: api_url },
|
||||||
url: layer.api.as_ref().and_then(|api| api.url.clone()),
|
|
||||||
},
|
|
||||||
web,
|
web,
|
||||||
auth,
|
auth,
|
||||||
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
|
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
|
||||||
|
|
@ -87,10 +92,10 @@ fn resolve_sandbox_provider(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
|
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
|
||||||
|
let root = layer.and_then(|storage| storage.root.as_deref());
|
||||||
|
warn_if_demoted_template("server.storage.root", root);
|
||||||
ServerStorageSettings {
|
ServerStorageSettings {
|
||||||
root: layer
|
root: root.map_or_else(|| default_string(default_storage_dir()), str::to_owned),
|
||||||
.and_then(|storage| storage.root.clone())
|
|
||||||
.unwrap_or_else(|| default_interp(default_storage_dir())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,13 +105,16 @@ fn resolve_listen(
|
||||||
) -> ServerListenSettings {
|
) -> ServerListenSettings {
|
||||||
match layer {
|
match layer {
|
||||||
None => ServerListenSettings::Unix {
|
None => ServerListenSettings::Unix {
|
||||||
path: default_interp(Home::from_env().socket_path()),
|
path: default_string(Home::from_env().socket_path()),
|
||||||
},
|
|
||||||
Some(ServerListenLayer::Unix { path }) => ServerListenSettings::Unix {
|
|
||||||
path: path
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| default_interp(Home::from_env().socket_path())),
|
|
||||||
},
|
},
|
||||||
|
Some(ServerListenLayer::Unix { path }) => {
|
||||||
|
warn_if_demoted_template("server.listen.path", path.as_deref());
|
||||||
|
ServerListenSettings::Unix {
|
||||||
|
path: path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| default_string(Home::from_env().socket_path())),
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(ServerListenLayer::Tcp { address }) => {
|
Some(ServerListenLayer::Tcp { address }) => {
|
||||||
let address = parse_socket_addr(
|
let address = parse_socket_addr(
|
||||||
&require_interp(address.as_ref(), "server.listen.address", errors),
|
&require_interp(address.as_ref(), "server.listen.address", errors),
|
||||||
|
|
@ -121,14 +129,17 @@ fn resolve_listen(
|
||||||
fn resolve_web(layer: Option<&ServerWebLayer>) -> ServerWebSettings {
|
fn resolve_web(layer: Option<&ServerWebLayer>) -> ServerWebSettings {
|
||||||
let layer = layer.expect("defaults.toml should provide server.web defaults");
|
let layer = layer.expect("defaults.toml should provide server.web defaults");
|
||||||
|
|
||||||
|
let url = layer
|
||||||
|
.url
|
||||||
|
.clone()
|
||||||
|
.expect("defaults.toml should provide server.web.url");
|
||||||
|
warn_if_demoted_template("server.web.url", Some(url.as_str()));
|
||||||
|
|
||||||
ServerWebSettings {
|
ServerWebSettings {
|
||||||
enabled: layer
|
enabled: layer
|
||||||
.enabled
|
.enabled
|
||||||
.expect("defaults.toml should provide server.web.enabled"),
|
.expect("defaults.toml should provide server.web.enabled"),
|
||||||
url: layer
|
url,
|
||||||
.url
|
|
||||||
.clone()
|
|
||||||
.expect("defaults.toml should provide server.web.url"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,18 +218,21 @@ fn validate_github_webhook_strategy(
|
||||||
|
|
||||||
fn resolve_artifacts(
|
fn resolve_artifacts(
|
||||||
layer: Option<&ServerArtifactsLayer>,
|
layer: Option<&ServerArtifactsLayer>,
|
||||||
storage_root: &InterpString,
|
storage_root: &str,
|
||||||
errors: &mut Vec<ResolveError>,
|
errors: &mut Vec<ResolveError>,
|
||||||
) -> ServerArtifactsSettings {
|
) -> ServerArtifactsSettings {
|
||||||
let provider = layer
|
let provider = layer
|
||||||
.and_then(|artifacts| artifacts.provider)
|
.and_then(|artifacts| artifacts.provider)
|
||||||
.expect("defaults.toml should provide server.artifacts.provider");
|
.expect("defaults.toml should provide server.artifacts.provider");
|
||||||
|
|
||||||
|
let prefix = layer
|
||||||
|
.and_then(|artifacts| artifacts.prefix.clone())
|
||||||
|
.expect("defaults.toml should provide server.artifacts.prefix");
|
||||||
|
warn_if_demoted_template("server.artifacts.prefix", Some(prefix.as_str()));
|
||||||
|
|
||||||
ServerArtifactsSettings {
|
ServerArtifactsSettings {
|
||||||
prefix: layer
|
prefix,
|
||||||
.and_then(|artifacts| artifacts.prefix.clone())
|
store: resolve_object_store(
|
||||||
.expect("defaults.toml should provide server.artifacts.prefix"),
|
|
||||||
store: resolve_object_store(
|
|
||||||
provider,
|
provider,
|
||||||
layer.and_then(|artifacts| artifacts.local.as_ref()),
|
layer.and_then(|artifacts| artifacts.local.as_ref()),
|
||||||
layer.and_then(|artifacts| artifacts.s3.as_ref()),
|
layer.and_then(|artifacts| artifacts.s3.as_ref()),
|
||||||
|
|
@ -231,7 +245,7 @@ fn resolve_artifacts(
|
||||||
|
|
||||||
fn resolve_slatedb(
|
fn resolve_slatedb(
|
||||||
layer: Option<&ServerSlateDbLayer>,
|
layer: Option<&ServerSlateDbLayer>,
|
||||||
storage_root: &InterpString,
|
storage_root: &str,
|
||||||
errors: &mut Vec<ResolveError>,
|
errors: &mut Vec<ResolveError>,
|
||||||
) -> ServerSlateDbSettings {
|
) -> ServerSlateDbSettings {
|
||||||
let provider = layer
|
let provider = layer
|
||||||
|
|
@ -250,10 +264,13 @@ fn resolve_slatedb(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prefix = layer
|
||||||
|
.and_then(|slatedb| slatedb.prefix.clone())
|
||||||
|
.expect("defaults.toml should provide server.slatedb.prefix");
|
||||||
|
warn_if_demoted_template("server.slatedb.prefix", Some(prefix.as_str()));
|
||||||
|
|
||||||
ServerSlateDbSettings {
|
ServerSlateDbSettings {
|
||||||
prefix: layer
|
prefix,
|
||||||
.and_then(|slatedb| slatedb.prefix.clone())
|
|
||||||
.expect("defaults.toml should provide server.slatedb.prefix"),
|
|
||||||
store: resolve_object_store(
|
store: resolve_object_store(
|
||||||
provider,
|
provider,
|
||||||
layer.and_then(|slatedb| slatedb.local.as_ref()),
|
layer.and_then(|slatedb| slatedb.local.as_ref()),
|
||||||
|
|
@ -274,59 +291,70 @@ fn resolve_object_store(
|
||||||
provider: ObjectStoreProvider,
|
provider: ObjectStoreProvider,
|
||||||
local: Option<&ObjectStoreLocalLayer>,
|
local: Option<&ObjectStoreLocalLayer>,
|
||||||
s3: Option<&ObjectStoreS3Layer>,
|
s3: Option<&ObjectStoreS3Layer>,
|
||||||
storage_root: &InterpString,
|
storage_root: &str,
|
||||||
path_prefix: &str,
|
path_prefix: &str,
|
||||||
errors: &mut Vec<ResolveError>,
|
errors: &mut Vec<ResolveError>,
|
||||||
) -> ObjectStoreSettings {
|
) -> ObjectStoreSettings {
|
||||||
match provider {
|
match provider {
|
||||||
ObjectStoreProvider::Local => ObjectStoreSettings::Local {
|
ObjectStoreProvider::Local => {
|
||||||
root: local
|
let root = local.and_then(|local| local.root.as_deref());
|
||||||
.and_then(|local| local.root.clone())
|
warn_if_demoted_template(&format!("{path_prefix}.local.root"), root);
|
||||||
.unwrap_or_else(|| storage_root.clone()),
|
ObjectStoreSettings::Local {
|
||||||
},
|
root: root.map_or_else(|| storage_root.to_owned(), str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
ObjectStoreProvider::S3 => {
|
ObjectStoreProvider::S3 => {
|
||||||
let bucket = require_interp(
|
let bucket_field = format!("{path_prefix}.s3.bucket");
|
||||||
s3.and_then(|s3| s3.bucket.as_ref()),
|
let region_field = format!("{path_prefix}.s3.region");
|
||||||
&format!("{path_prefix}.s3.bucket"),
|
let endpoint_field = format!("{path_prefix}.s3.endpoint");
|
||||||
errors,
|
let bucket =
|
||||||
);
|
require_string(s3.and_then(|s3| s3.bucket.as_ref()), &bucket_field, errors);
|
||||||
let region = require_interp(
|
let region =
|
||||||
s3.and_then(|s3| s3.region.as_ref()),
|
require_string(s3.and_then(|s3| s3.region.as_ref()), ®ion_field, errors);
|
||||||
&format!("{path_prefix}.s3.region"),
|
let endpoint = s3.and_then(|s3| s3.endpoint.clone());
|
||||||
errors,
|
warn_if_demoted_template(&bucket_field, Some(bucket.as_str()));
|
||||||
);
|
warn_if_demoted_template(®ion_field, Some(region.as_str()));
|
||||||
|
warn_if_demoted_template(&endpoint_field, endpoint.as_deref());
|
||||||
ObjectStoreSettings::S3 {
|
ObjectStoreSettings::S3 {
|
||||||
bucket,
|
bucket,
|
||||||
region,
|
region,
|
||||||
endpoint: s3.and_then(|s3| s3.endpoint.clone()),
|
endpoint,
|
||||||
path_style: s3.and_then(|s3| s3.path_style).unwrap_or(false),
|
path_style: s3.and_then(|s3| s3.path_style).unwrap_or(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
fn object_store_default_root(storage_root: &str, domain: &str) -> String {
|
||||||
clippy::disallowed_methods,
|
Path::new(storage_root)
|
||||||
reason = "derives sibling default paths in source form; the result is re-parsed as an \
|
.join("objects")
|
||||||
InterpString and resolves at consumption"
|
.join(domain)
|
||||||
)]
|
.to_string_lossy()
|
||||||
fn object_store_default_root(storage_root: &InterpString, domain: &str) -> InterpString {
|
.into_owned()
|
||||||
let root = storage_root.as_source();
|
|
||||||
let root = root.trim_end_matches('/');
|
|
||||||
InterpString::parse(&format!("{root}/objects/{domain}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
|
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
|
||||||
ServerIntegrationsSettings {
|
ServerIntegrationsSettings {
|
||||||
github: layer
|
github: layer
|
||||||
.and_then(|integrations| integrations.github.as_ref())
|
.and_then(|integrations| integrations.github.as_ref())
|
||||||
.map(|github| GithubIntegrationSettings {
|
.map(|github| {
|
||||||
enabled: github.enabled.unwrap_or(true),
|
warn_if_demoted_template(
|
||||||
strategy: github.strategy.unwrap_or_default(),
|
"server.integrations.github.app_id",
|
||||||
app_id: github.app_id.clone(),
|
github.app_id.as_deref(),
|
||||||
client_id: github.client_id.clone(),
|
);
|
||||||
slug: github.slug.clone(),
|
warn_if_demoted_template(
|
||||||
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
|
"server.integrations.github.client_id",
|
||||||
|
github.client_id.as_deref(),
|
||||||
|
);
|
||||||
|
warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref());
|
||||||
|
GithubIntegrationSettings {
|
||||||
|
enabled: github.enabled.unwrap_or(true),
|
||||||
|
strategy: github.strategy.unwrap_or_default(),
|
||||||
|
app_id: github.app_id.clone(),
|
||||||
|
client_id: github.client_id.clone(),
|
||||||
|
slug: github.slug.clone(),
|
||||||
|
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
slack: layer
|
slack: layer
|
||||||
|
|
|
||||||
|
|
@ -315,9 +315,6 @@ bucket = "higher-bucket"
|
||||||
|
|
||||||
let merged = higher.combine(lower);
|
let merged = higher.combine(lower);
|
||||||
let s3 = merged.server.unwrap().artifacts.unwrap().s3.unwrap();
|
let s3 = merged.server.unwrap().artifacts.unwrap().s3.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(s3.bucket, Some("higher-bucket".to_string()));
|
||||||
s3.bucket.map(|bucket| bucket.as_source()),
|
|
||||||
Some("higher-bucket".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(s3.region, None);
|
assert_eq!(s3.region, None);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,6 @@ provider = "not-a-provider"
|
||||||
assert!(rendered.contains("run.environment.provider"));
|
assert!(rendered.contains("run.environment.provider"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "test asserts the raw template source"
|
|
||||||
)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn namespace_resolvers_cover_root_level_settings_shape() {
|
fn namespace_resolvers_cover_root_level_settings_shape() {
|
||||||
let source = r#"
|
let source = r#"
|
||||||
|
|
@ -115,7 +111,7 @@ name = "gpt-5"
|
||||||
"resolved project settings should not expose deprecated directory"
|
"resolved project settings should not expose deprecated directory"
|
||||||
);
|
);
|
||||||
assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot");
|
assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot");
|
||||||
assert_eq!(server.server.storage.root.as_source(), "/srv/fabro");
|
assert_eq!(server.server.storage.root, "/srv/fabro");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workflow_settings.run.model.provider.as_deref(),
|
workflow_settings.run.model.provider.as_deref(),
|
||||||
Some("openai")
|
Some("openai")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
|
reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use fabro_types::settings::InterpString;
|
|
||||||
use fabro_types::settings::server::{
|
use fabro_types::settings::server::{
|
||||||
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
|
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
|
||||||
ServerListenSettings, ServerNamespace,
|
ServerListenSettings, ServerNamespace,
|
||||||
|
|
@ -61,20 +60,17 @@ fn resolves_server_defaults_from_empty_settings() {
|
||||||
let settings = resolve_server(&empty_settings_with_auth_methods());
|
let settings = resolve_server(&empty_settings_with_auth_methods());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.storage.root.as_source(),
|
settings.storage.root,
|
||||||
default_storage_dir().to_string_lossy()
|
default_storage_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
assert!(settings.web.enabled);
|
assert!(settings.web.enabled);
|
||||||
assert_eq!(settings.web.url.as_source(), "http://localhost:3000");
|
assert_eq!(settings.web.url, "http://localhost:3000");
|
||||||
assert_eq!(settings.scheduler.max_concurrent_runs, 5);
|
assert_eq!(settings.scheduler.max_concurrent_runs, 5);
|
||||||
assert_eq!(settings.logging.destination, LogDestination::File);
|
assert_eq!(settings.logging.destination, LogDestination::File);
|
||||||
|
|
||||||
match settings.listen {
|
match settings.listen {
|
||||||
ServerListenSettings::Unix { path } => {
|
ServerListenSettings::Unix { path } => {
|
||||||
assert_eq!(
|
assert_eq!(path, Home::from_env().socket_path().to_string_lossy());
|
||||||
path.as_source(),
|
|
||||||
Home::from_env().socket_path().to_string_lossy()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ServerListenSettings::Tcp { .. } => panic!("expected default listen transport to be unix"),
|
ServerListenSettings::Tcp { .. } => panic!("expected default listen transport to be unix"),
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +78,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
||||||
match settings.artifacts.store {
|
match settings.artifacts.store {
|
||||||
ObjectStoreSettings::Local { root } => {
|
ObjectStoreSettings::Local { root } => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
root.as_source(),
|
root,
|
||||||
default_storage_dir()
|
default_storage_dir()
|
||||||
.join("objects")
|
.join("objects")
|
||||||
.join("artifacts")
|
.join("artifacts")
|
||||||
|
|
@ -91,12 +87,12 @@ fn resolves_server_defaults_from_empty_settings() {
|
||||||
}
|
}
|
||||||
ObjectStoreSettings::S3 { .. } => panic!("expected local artifact store by default"),
|
ObjectStoreSettings::S3 { .. } => panic!("expected local artifact store by default"),
|
||||||
}
|
}
|
||||||
assert_eq!(settings.artifacts.prefix.as_source(), "");
|
assert_eq!(settings.artifacts.prefix, "");
|
||||||
|
|
||||||
match settings.slatedb.store {
|
match settings.slatedb.store {
|
||||||
ObjectStoreSettings::Local { root } => {
|
ObjectStoreSettings::Local { root } => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
root.as_source(),
|
root,
|
||||||
default_storage_dir()
|
default_storage_dir()
|
||||||
.join("objects")
|
.join("objects")
|
||||||
.join("slatedb")
|
.join("slatedb")
|
||||||
|
|
@ -278,7 +274,7 @@ root = "/srv/fabro"
|
||||||
let context = fabro_config::ServerSettingsBuilder::from_layer(&settings)
|
let context = fabro_config::ServerSettingsBuilder::from_layer(&settings)
|
||||||
.expect("settings should resolve");
|
.expect("settings should resolve");
|
||||||
|
|
||||||
assert_eq!(context.server.storage.root.as_source(), "/srv/fabro");
|
assert_eq!(context.server.storage.root, "/srv/fabro");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -301,7 +297,7 @@ root = "/srv/from-home"
|
||||||
with_var("FABRO_HOME", Some(home.path()), || {
|
with_var("FABRO_HOME", Some(home.path()), || {
|
||||||
let settings =
|
let settings =
|
||||||
fabro_config::ServerSettingsBuilder::load_default().expect("settings should resolve");
|
fabro_config::ServerSettingsBuilder::load_default().expect("settings should resolve");
|
||||||
assert_eq!(settings.server.storage.root.as_source(), "/srv/from-home");
|
assert_eq!(settings.server.storage.root, "/srv/from-home");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,22 +363,22 @@ slug = "fabro-app"
|
||||||
|
|
||||||
match settings.listen {
|
match settings.listen {
|
||||||
ServerListenSettings::Unix { path } => {
|
ServerListenSettings::Unix { path } => {
|
||||||
assert_eq!(path, InterpString::parse("{{ env.FABRO_SOCKET }}"));
|
assert_eq!(path, "{{ env.FABRO_SOCKET }}");
|
||||||
}
|
}
|
||||||
ServerListenSettings::Tcp { .. } => panic!("expected unix listen transport"),
|
ServerListenSettings::Tcp { .. } => panic!("expected unix listen transport"),
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.integrations.github.app_id,
|
settings.integrations.github.app_id.as_deref(),
|
||||||
Some(InterpString::parse("{{ env.GITHUB_APP_ID }}"))
|
Some("{{ env.GITHUB_APP_ID }}")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.integrations.github.client_id,
|
settings.integrations.github.client_id.as_deref(),
|
||||||
Some(InterpString::parse("{{ env.GITHUB_CLIENT_ID }}"))
|
Some("{{ env.GITHUB_CLIENT_ID }}")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.integrations.github.slug,
|
settings.integrations.github.slug.as_deref(),
|
||||||
Some(InterpString::parse("fabro-app"))
|
Some("fabro-app")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,7 +518,7 @@ fn resolve_storage_root_defaults_with_minimal_server_auth_methods() {
|
||||||
let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods())
|
let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods())
|
||||||
.expect("default server settings should resolve");
|
.expect("default server settings should resolve");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.server.storage.root.as_source(),
|
settings.server.storage.root,
|
||||||
default_storage_dir().to_string_lossy()
|
default_storage_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -540,11 +536,14 @@ root = "/srv/fabro"
|
||||||
let settings =
|
let settings =
|
||||||
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
|
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
|
||||||
|
|
||||||
assert_eq!(settings.server.storage.root.as_source(), "/srv/fabro");
|
assert_eq!(settings.server.storage.root, "/srv/fabro");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_storage_root_preserves_env_interpolation() {
|
fn resolve_storage_root_keeps_template_token_literal() {
|
||||||
|
// `server.storage.root` is plain control-plane config now: a `{{ env.* }}`
|
||||||
|
// token is stored verbatim and never interpolated (deployment-time
|
||||||
|
// overrides go through FABRO_STORAGE_DIR, not a settings token).
|
||||||
let file = parse(
|
let file = parse(
|
||||||
r#"
|
r#"
|
||||||
_version = 1
|
_version = 1
|
||||||
|
|
@ -556,10 +555,7 @@ root = "{{ env.FABRO_STORAGE_ROOT }}"
|
||||||
let settings =
|
let settings =
|
||||||
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
|
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(settings.server.storage.root, "{{ env.FABRO_STORAGE_ROOT }}");
|
||||||
settings.server.storage.root,
|
|
||||||
InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -770,12 +770,10 @@ fn github_auth_not_configured() -> Response {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolved_web_url(state: &AppState) -> Option<String> {
|
|
||||||
state.canonical_origin().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_cookie_secure(state: &AppState) -> bool {
|
fn session_cookie_secure(state: &AppState) -> bool {
|
||||||
resolved_web_url(state).is_some_and(|web_url| web_url.starts_with("https://"))
|
state
|
||||||
|
.canonical_origin()
|
||||||
|
.is_ok_and(|web_url| web_url.starts_with("https://"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eligible_session(session: Option<&SessionCookie>) -> Option<&SessionCookie> {
|
fn eligible_session(session: Option<&SessionCookie>) -> Option<&SessionCookie> {
|
||||||
|
|
@ -810,7 +808,7 @@ fn confirm_resume_origin_is_valid(headers: &HeaderMap, state: &AppState) -> bool
|
||||||
let Ok(origin) = origin.to_str() else {
|
let Ok(origin) = origin.to_str() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let Some(web_url) = resolved_web_url(state) else {
|
let Ok(web_url) = state.canonical_origin() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let Ok(origin_url) = Url::parse(origin) else {
|
let Ok(origin_url) = Url::parse(origin) else {
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ url = "{web_url}"
|
||||||
|
|
||||||
fn invalid_canonical_origin_state() -> Arc<AppState> {
|
fn invalid_canonical_origin_state() -> Arc<AppState> {
|
||||||
crate::test_support::test_app_state_with_env_lookup(
|
crate::test_support::test_app_state_with_env_lookup(
|
||||||
settings_with_web_url("{{ env.FABRO_WEB_URL }}"),
|
settings_with_web_url("http://valid.example.com"),
|
||||||
RunLayer::default(),
|
RunLayer::default(),
|
||||||
5,
|
5,
|
||||||
|_| Some("/relative".to_string()),
|
|_| Some("/relative".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,40 @@
|
||||||
reason = "Canonical origin validation handles the public server origin; it is not credential-bearing log output."
|
reason = "Canonical origin validation handles the public server origin; it is not credential-bearing log output."
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
use fabro_static::EnvVars;
|
||||||
use fabro_types::settings::{ServerNamespace, validate_public_url};
|
use fabro_types::settings::{ServerNamespace, validate_public_url};
|
||||||
|
|
||||||
use crate::server::EnvLookup;
|
use crate::server::EnvLookup;
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "raw source shown in the error message when resolution fails"
|
|
||||||
)]
|
|
||||||
pub(crate) fn resolve_canonical_origin(
|
pub(crate) fn resolve_canonical_origin(
|
||||||
resolved: &ServerNamespace,
|
resolved: &ServerNamespace,
|
||||||
env_lookup: &EnvLookup,
|
env_lookup: &EnvLookup,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let value = resolved
|
let value = effective_web_url(resolved, |name| env_lookup(name));
|
||||||
.web
|
canonical_origin_from_effective_web_url(&value)
|
||||||
.url
|
}
|
||||||
.resolve(|name| env_lookup(name))
|
|
||||||
.map_err(|_| canonical_origin_error(&resolved.web.url.as_source()))?
|
|
||||||
.value;
|
|
||||||
|
|
||||||
validate_public_url(&value).map_err(|_| canonical_origin_error(&value))
|
pub(crate) fn canonical_origin_from_effective_web_url(value: &str) -> Result<String, String> {
|
||||||
|
validate_public_url(value).map_err(|_| canonical_origin_error(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective `server.web.url`.
|
||||||
|
///
|
||||||
|
/// `web.url` is plain control-plane config that never interpolates `{{ env.*
|
||||||
|
/// }}` tokens. Deployment-time late binding instead goes through the native
|
||||||
|
/// `FABRO_WEB_URL` process-env read: the env override wins when set (and
|
||||||
|
/// non-empty), otherwise the literal settings value is used.
|
||||||
|
///
|
||||||
|
/// Generic over the lookup so both the `EnvLookup`-backed callers and
|
||||||
|
/// `resolve_jwt_issuer` (which threads a bare `Fn`) share this one definition
|
||||||
|
/// of the override precedence.
|
||||||
|
pub(crate) fn effective_web_url(
|
||||||
|
resolved: &ServerNamespace,
|
||||||
|
lookup: impl Fn(&str) -> Option<String>,
|
||||||
|
) -> String {
|
||||||
|
lookup(EnvVars::FABRO_WEB_URL)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| resolved.web.url.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_origin_error(value: &str) -> String {
|
fn canonical_origin_error(value: &str) -> String {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use fabro_model::{Catalog, ProviderId};
|
||||||
use fabro_redact::redact_string;
|
use fabro_redact::redact_string;
|
||||||
use fabro_sandbox::daytona;
|
use fabro_sandbox::daytona;
|
||||||
use fabro_static::EnvVars;
|
use fabro_static::EnvVars;
|
||||||
|
use fabro_types::settings::ServerAuthMethod;
|
||||||
use fabro_types::settings::server::GithubIntegrationStrategy;
|
use fabro_types::settings::server::GithubIntegrationStrategy;
|
||||||
use fabro_types::settings::{InterpString, ServerAuthMethod};
|
|
||||||
use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus};
|
use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus};
|
||||||
use fabro_util::dev_token::validate_dev_token_format;
|
use fabro_util::dev_token::validate_dev_token_format;
|
||||||
use fabro_util::session_secret;
|
use fabro_util::session_secret;
|
||||||
|
|
@ -327,11 +327,6 @@ fn short_error_line(rendered: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
|
|
||||||
interpolation unification (Phase 2)"
|
|
||||||
)]
|
|
||||||
async fn check_github_app(state: &AppState) -> CheckResult {
|
async fn check_github_app(state: &AppState) -> CheckResult {
|
||||||
let settings = state.server_settings();
|
let settings = state.server_settings();
|
||||||
if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token {
|
if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token {
|
||||||
|
|
@ -446,20 +441,8 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_id = settings
|
let app_id = settings.server.integrations.github.app_id.clone();
|
||||||
.server
|
let slug = settings.server.integrations.github.slug.clone();
|
||||||
.integrations
|
|
||||||
.github
|
|
||||||
.app_id
|
|
||||||
.as_ref()
|
|
||||||
.map(InterpString::as_source);
|
|
||||||
let slug = settings
|
|
||||||
.server
|
|
||||||
.integrations
|
|
||||||
.github
|
|
||||||
.slug
|
|
||||||
.as_ref()
|
|
||||||
.map(InterpString::as_source);
|
|
||||||
let private_key_raw = state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY);
|
let private_key_raw = state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY);
|
||||||
let client_id = settings.server.integrations.github.client_id.is_some();
|
let client_id = settings.server.integrations.github.client_id.is_some();
|
||||||
let client_secret = state
|
let client_secret = state
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ use fabro_sandbox::daytona;
|
||||||
use fabro_static::EnvVars;
|
use fabro_static::EnvVars;
|
||||||
use fabro_store::ArtifactStore;
|
use fabro_store::ArtifactStore;
|
||||||
use fabro_types::ServerSettings;
|
use fabro_types::ServerSettings;
|
||||||
use fabro_types::settings::interp::InterpString;
|
|
||||||
use fabro_types::settings::run::EnvironmentProvider;
|
use fabro_types::settings::run::EnvironmentProvider;
|
||||||
use fabro_types::settings::server::ObjectStoreSettings;
|
use fabro_types::settings::server::ObjectStoreSettings;
|
||||||
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
|
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
|
||||||
|
|
@ -1188,8 +1187,8 @@ fn object_store_validation_settings(
|
||||||
match selection {
|
match selection {
|
||||||
InstallObjectStoreState::Local { .. } => None,
|
InstallObjectStoreState::Local { .. } => None,
|
||||||
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
|
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
|
||||||
bucket: InterpString::parse(bucket),
|
bucket: bucket.clone(),
|
||||||
region: InterpString::parse(region),
|
region: region.clone(),
|
||||||
endpoint: None,
|
endpoint: None,
|
||||||
path_style: false,
|
path_style: false,
|
||||||
}),
|
}),
|
||||||
|
|
@ -2277,10 +2276,8 @@ async fn write_artifact_store_metadata(
|
||||||
settings: &ServerSettings,
|
settings: &ServerSettings,
|
||||||
storage_dir: &Path,
|
storage_dir: &Path,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use fabro_types::settings::interp::InterpString;
|
|
||||||
|
|
||||||
let mut settings = settings.clone();
|
let mut settings = settings.clone();
|
||||||
settings.server.storage.root = InterpString::parse(&storage_dir.display().to_string());
|
settings.server.storage.root = storage_dir.display().to_string();
|
||||||
let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?;
|
let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?;
|
||||||
let artifact_store = ArtifactStore::new(object_store, prefix);
|
let artifact_store = ArtifactStore::new(object_store, prefix);
|
||||||
artifact_store.write_metadata(FABRO_VERSION).await?;
|
artifact_store.write_metadata(FABRO_VERSION).await?;
|
||||||
|
|
@ -2581,8 +2578,7 @@ methods = ["dev-token"]
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut overridden = settings.clone();
|
let mut overridden = settings.clone();
|
||||||
overridden.server.storage.root =
|
overridden.server.storage.root = dir.path().display().to_string();
|
||||||
fabro_types::settings::interp::InterpString::parse(&dir.path().display().to_string());
|
|
||||||
let (object_store, prefix) =
|
let (object_store, prefix) =
|
||||||
crate::serve::build_artifact_object_store(&overridden.server).unwrap();
|
crate::serve::build_artifact_object_store(&overridden.server).unwrap();
|
||||||
let marker = if prefix.is_empty() {
|
let marker = if prefix.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,17 @@
|
||||||
//! Shared process-env interpolation helpers for server-scope settings.
|
//! The server-owned process-environment lookup facade.
|
||||||
//!
|
//!
|
||||||
//! Server-scope `InterpString` fields resolve `{{ env.* }}` tokens against
|
//! Server control-plane settings (storage root, listen path, web/api URL,
|
||||||
//! the server's own process environment. This module owns the single
|
//! object-store coordinates, GitHub App identifiers) are plain `String` and do
|
||||||
//! process-env lookup facade and the canonical resolve helpers; do not add
|
//! not interpolate; deployment-time late binding goes through native env reads
|
||||||
//! per-module copies.
|
//! (e.g. `FABRO_WEB_URL`, read in `canonical_origin`). This module owns the
|
||||||
|
//! single process-env lookup facade those reads — and server secret reads — go
|
||||||
|
//! through; do not add per-module copies.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
/// The server-owned process-env lookup facade for native env reads and server
|
||||||
|
/// configuration/secret reads.
|
||||||
use anyhow::Context;
|
|
||||||
use fabro_types::settings::InterpString;
|
|
||||||
|
|
||||||
/// Resolve a server-scope `InterpString` with a caller-provided env lookup.
|
|
||||||
///
|
|
||||||
/// This is the single resolve core; [`resolve_interp`] (process env) and
|
|
||||||
/// `AppState::resolve_interp` (injectable `env_lookup` seam) both delegate
|
|
||||||
/// here.
|
|
||||||
pub(crate) fn resolve_interp_with(
|
|
||||||
value: &InterpString,
|
|
||||||
lookup: impl FnMut(&str) -> Option<String>,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
value
|
|
||||||
.resolve(lookup)
|
|
||||||
.map(|resolved| resolved.value)
|
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a server-scope `InterpString` against the process environment.
|
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::disallowed_methods,
|
clippy::disallowed_methods,
|
||||||
reason = "raw source shown in the error message when resolution fails"
|
reason = "server configuration and secret reads own this process-env lookup facade"
|
||||||
)]
|
|
||||||
pub(crate) fn resolve_interp(value: &InterpString) -> anyhow::Result<String> {
|
|
||||||
resolve_interp_with(value, process_env_var)
|
|
||||||
.with_context(|| format!("failed to resolve {}", value.as_source()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`resolve_interp`], parsed into a filesystem path.
|
|
||||||
pub(crate) fn resolve_interp_path(value: &InterpString) -> anyhow::Result<PathBuf> {
|
|
||||||
Ok(PathBuf::from(resolve_interp(value)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The server-owned process-env lookup facade for `{{ env.* }}`
|
|
||||||
/// interpolation and server configuration/secret reads.
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "server-scope interpolation and configuration own this process-env lookup facade"
|
|
||||||
)]
|
)]
|
||||||
pub(crate) fn process_env_var(name: &str) -> Option<String> {
|
pub(crate) fn process_env_var(name: &str) -> Option<String> {
|
||||||
std::env::var(name).ok()
|
std::env::var(name).ok()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use tracing::info;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::auth::REFRESH_TOKEN_PREFIX;
|
use crate::auth::REFRESH_TOKEN_PREFIX;
|
||||||
use crate::auth::{self, AuthErrorCode, JwtError, JwtSigningKey, KeyDeriveError};
|
use crate::auth::{self, AuthErrorCode, JwtError, JwtSigningKey, KeyDeriveError};
|
||||||
|
use crate::canonical_origin::effective_web_url;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::interp::process_env_var;
|
use crate::interp::process_env_var;
|
||||||
|
|
||||||
|
|
@ -138,22 +139,16 @@ fn resolve_jwt_issuer<F>(settings: &ServerNamespace, lookup: &F) -> String
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> Option<String>,
|
F: Fn(&str) -> Option<String>,
|
||||||
{
|
{
|
||||||
|
let web_url = effective_web_url(settings, lookup);
|
||||||
|
if !web_url.is_empty() {
|
||||||
|
return web_url;
|
||||||
|
}
|
||||||
|
|
||||||
settings
|
settings
|
||||||
.web
|
.api
|
||||||
.url
|
.url
|
||||||
.resolve(|name| lookup(name))
|
.clone()
|
||||||
.ok()
|
|
||||||
.map(|resolved| resolved.value)
|
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.or_else(|| {
|
|
||||||
settings
|
|
||||||
.api
|
|
||||||
.url
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|url| url.resolve(|name| lookup(name)).ok())
|
|
||||||
.map(|resolved| resolved.value)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "fabro-server".to_string())
|
.unwrap_or_else(|| "fabro-server".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::canonical_origin::resolve_canonical_origin;
|
use crate::canonical_origin::resolve_canonical_origin;
|
||||||
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
|
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
|
||||||
use crate::interp::{process_env_var, resolve_interp, resolve_interp_path};
|
use crate::interp::process_env_var;
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
|
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
|
||||||
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
|
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
|
||||||
|
|
@ -260,28 +260,22 @@ fn resolve_webhook_preconditions(
|
||||||
github: &GithubIntegrationSettings,
|
github: &GithubIntegrationSettings,
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
webhook_secret_present: bool,
|
webhook_secret_present: bool,
|
||||||
) -> anyhow::Result<WebhookPreconditions> {
|
) -> WebhookPreconditions {
|
||||||
if github.strategy != GithubIntegrationStrategy::App {
|
if github.strategy != GithubIntegrationStrategy::App {
|
||||||
return Ok(WebhookPreconditions::Skip(
|
return WebhookPreconditions::Skip("GitHub integration auth is not set to app".to_string());
|
||||||
"GitHub integration auth is not set to app".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if !webhook_secret_present {
|
if !webhook_secret_present {
|
||||||
return Ok(WebhookPreconditions::Skip(format!(
|
return WebhookPreconditions::Skip(format!("{WEBHOOK_SECRET_ENV} is not set"));
|
||||||
"{WEBHOOK_SECRET_ENV} is not set"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
let Some(app_id) = github.app_id.as_ref().map(resolve_interp).transpose()? else {
|
let Some(app_id) = github.app_id.clone() else {
|
||||||
return Ok(WebhookPreconditions::Skip(
|
return WebhookPreconditions::Skip(
|
||||||
"server.integrations.github.app_id is not set".to_string(),
|
"server.integrations.github.app_id is not set".to_string(),
|
||||||
));
|
);
|
||||||
};
|
};
|
||||||
let github_app = match state.github_credentials(github) {
|
let github_app = match state.github_credentials(github) {
|
||||||
Ok(creds) => creds,
|
Ok(creds) => creds,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Ok(WebhookPreconditions::Skip(format!(
|
return WebhookPreconditions::Skip(format!("GitHub credentials are invalid: {err}"));
|
||||||
"GitHub credentials are invalid: {err}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let github_app = match github_app {
|
let github_app = match github_app {
|
||||||
|
|
@ -290,20 +284,20 @@ fn resolve_webhook_preconditions(
|
||||||
fabro_github::GitHubCredentials::Pat(_)
|
fabro_github::GitHubCredentials::Pat(_)
|
||||||
| fabro_github::GitHubCredentials::Installation(_),
|
| fabro_github::GitHubCredentials::Installation(_),
|
||||||
) => {
|
) => {
|
||||||
return Ok(WebhookPreconditions::Skip(
|
return WebhookPreconditions::Skip(
|
||||||
"GitHub webhooks require GitHub App credentials".to_string(),
|
"GitHub webhooks require GitHub App credentials".to_string(),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
return Ok(WebhookPreconditions::Skip(
|
return WebhookPreconditions::Skip(
|
||||||
"GITHUB_APP_PRIVATE_KEY is not available".to_string(),
|
"GITHUB_APP_PRIVATE_KEY is not available".to_string(),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(WebhookPreconditions::Ready {
|
WebhookPreconditions::Ready {
|
||||||
app_id,
|
app_id,
|
||||||
private_key_pem: github_app.private_key_pem,
|
private_key_pem: github_app.private_key_pem,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_webhook_strategy(
|
async fn start_webhook_strategy(
|
||||||
|
|
@ -318,7 +312,7 @@ async fn start_webhook_strategy(
|
||||||
};
|
};
|
||||||
|
|
||||||
let (app_id, private_key_pem) =
|
let (app_id, private_key_pem) =
|
||||||
match resolve_webhook_preconditions(github, state, webhook_secret_present)? {
|
match resolve_webhook_preconditions(github, state, webhook_secret_present) {
|
||||||
WebhookPreconditions::Ready {
|
WebhookPreconditions::Ready {
|
||||||
app_id,
|
app_id,
|
||||||
private_key_pem,
|
private_key_pem,
|
||||||
|
|
@ -355,9 +349,7 @@ async fn start_webhook_strategy(
|
||||||
let server_api_url = resolved_server_settings
|
let server_api_url = resolved_server_settings
|
||||||
.api
|
.api
|
||||||
.url
|
.url
|
||||||
.as_ref()
|
.clone()
|
||||||
.map(resolve_interp)
|
|
||||||
.transpose()?
|
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"server.api.url must be set when webhook strategy = \"server_url\" (resolver invariant)"
|
"server.api.url must be set when webhook strategy = \"server_url\" (resolver invariant)"
|
||||||
|
|
@ -495,7 +487,7 @@ where
|
||||||
let build_options = build_options.cloned().unwrap_or_default();
|
let build_options = build_options.cloned().unwrap_or_default();
|
||||||
match settings {
|
match settings {
|
||||||
ObjectStoreSettings::Local { root } => {
|
ObjectStoreSettings::Local { root } => {
|
||||||
build_local_object_store_with_preference(&resolve_interp_path(root)?, false)
|
build_local_object_store_with_preference(&PathBuf::from(root), false)
|
||||||
}
|
}
|
||||||
ObjectStoreSettings::S3 {
|
ObjectStoreSettings::S3 {
|
||||||
bucket,
|
bucket,
|
||||||
|
|
@ -505,11 +497,11 @@ where
|
||||||
} => {
|
} => {
|
||||||
let mut builder = AmazonS3Builder::new()
|
let mut builder = AmazonS3Builder::new()
|
||||||
.with_http_connector(NoProxyReqwestConnector)
|
.with_http_connector(NoProxyReqwestConnector)
|
||||||
.with_bucket_name(resolve_interp(bucket)?)
|
.with_bucket_name(bucket.clone())
|
||||||
.with_region(resolve_interp(region)?)
|
.with_region(region.clone())
|
||||||
.with_virtual_hosted_style_request(!*path_style);
|
.with_virtual_hosted_style_request(!*path_style);
|
||||||
if let Some(endpoint) = endpoint.as_ref() {
|
if let Some(endpoint) = endpoint.as_ref() {
|
||||||
builder = builder.with_endpoint(resolve_interp(endpoint)?);
|
builder = builder.with_endpoint(endpoint.clone());
|
||||||
}
|
}
|
||||||
builder = configure_s3_builder_from_env_lookup(builder, env_lookup, &build_options)?;
|
builder = configure_s3_builder_from_env_lookup(builder, env_lookup, &build_options)?;
|
||||||
Ok(Arc::new(builder.build()?))
|
Ok(Arc::new(builder.build()?))
|
||||||
|
|
@ -543,16 +535,14 @@ pub fn resolve_bind_request_from_server_settings(
|
||||||
) -> anyhow::Result<BindRequest> {
|
) -> anyhow::Result<BindRequest> {
|
||||||
match explicit_bind.map(bind::parse_bind).transpose()? {
|
match explicit_bind.map(bind::parse_bind).transpose()? {
|
||||||
Some(bind) => Ok(bind),
|
Some(bind) => Ok(bind),
|
||||||
None => resolved_bind_request(&settings.server),
|
None => Ok(resolved_bind_request(&settings.server)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolved_bind_request(
|
fn resolved_bind_request(resolved_server_settings: &ServerNamespace) -> BindRequest {
|
||||||
resolved_server_settings: &ServerNamespace,
|
|
||||||
) -> anyhow::Result<BindRequest> {
|
|
||||||
match &resolved_server_settings.listen {
|
match &resolved_server_settings.listen {
|
||||||
ServerListenSettings::Unix { path } => Ok(BindRequest::Unix(resolve_interp_path(path)?)),
|
ServerListenSettings::Unix { path } => BindRequest::Unix(PathBuf::from(path)),
|
||||||
ServerListenSettings::Tcp { address, .. } => Ok(BindRequest::Tcp(*address)),
|
ServerListenSettings::Tcp { address, .. } => BindRequest::Tcp(*address),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -567,7 +557,7 @@ fn absolute_path(path: PathBuf) -> anyhow::Result<PathBuf> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_server_secrets_for_settings(settings: &ServerNamespace) -> anyhow::Result<ServerSecrets> {
|
fn load_server_secrets_for_settings(settings: &ServerNamespace) -> anyhow::Result<ServerSecrets> {
|
||||||
let storage_root = resolve_interp_path(&settings.storage.root)?;
|
let storage_root = PathBuf::from(&settings.storage.root);
|
||||||
let server_env_path = Storage::new(&storage_root).runtime_directory().env_path();
|
let server_env_path = Storage::new(&storage_root).runtime_directory().env_path();
|
||||||
ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::from)
|
ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::from)
|
||||||
}
|
}
|
||||||
|
|
@ -576,7 +566,7 @@ pub(crate) fn build_artifact_object_store_with_server_secrets(
|
||||||
settings: &ServerNamespace,
|
settings: &ServerNamespace,
|
||||||
server_secrets: &ServerSecrets,
|
server_secrets: &ServerSecrets,
|
||||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
|
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
|
||||||
let prefix = resolve_interp(&settings.artifacts.prefix)?;
|
let prefix = settings.artifacts.prefix.clone();
|
||||||
let object_store = build_object_store_from_settings_with_lookup(
|
let object_store = build_object_store_from_settings_with_lookup(
|
||||||
&settings.artifacts.store,
|
&settings.artifacts.store,
|
||||||
&|name| server_secrets.get(name),
|
&|name| server_secrets.get(name),
|
||||||
|
|
@ -596,7 +586,7 @@ fn build_slatedb_store_with_server_secrets(
|
||||||
settings: &ServerNamespace,
|
settings: &ServerNamespace,
|
||||||
server_secrets: &ServerSecrets,
|
server_secrets: &ServerSecrets,
|
||||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
|
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
|
||||||
let prefix = resolve_interp(&settings.slatedb.prefix)?;
|
let prefix = settings.slatedb.prefix.clone();
|
||||||
let object_store = build_object_store_from_settings_with_lookup(
|
let object_store = build_object_store_from_settings_with_lookup(
|
||||||
&settings.slatedb.store,
|
&settings.slatedb.store,
|
||||||
&|name| server_secrets.get(name),
|
&|name| server_secrets.get(name),
|
||||||
|
|
@ -654,7 +644,7 @@ where
|
||||||
let disk_server_settings = runtime_settings.server_settings.server.clone();
|
let disk_server_settings = runtime_settings.server_settings.server.clone();
|
||||||
let data_dir = match storage_dir_override {
|
let data_dir = match storage_dir_override {
|
||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
None => resolve_interp_path(&disk_server_settings.storage.root)?,
|
None => PathBuf::from(&disk_server_settings.storage.root),
|
||||||
};
|
};
|
||||||
let storage = Storage::new(&data_dir);
|
let storage = Storage::new(&data_dir);
|
||||||
let vault_path = storage.secrets_path();
|
let vault_path = storage.secrets_path();
|
||||||
|
|
@ -1148,10 +1138,6 @@ fn server_bind_title(bind: &Bind) -> String {
|
||||||
reason = "tests reserve/probe ports via sync std::net::TcpListener; the async server under \
|
reason = "tests reserve/probe ports via sync std::net::TcpListener; the async server under \
|
||||||
test uses tokio::net::TcpListener separately"
|
test uses tokio::net::TcpListener separately"
|
||||||
)]
|
)]
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "tests assert the raw template source"
|
|
||||||
)]
|
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -1163,7 +1149,6 @@ mod tests {
|
||||||
use fabro_config::ServerSettingsBuilder;
|
use fabro_config::ServerSettingsBuilder;
|
||||||
use fabro_config::bind::{Bind, BindRequest};
|
use fabro_config::bind::{Bind, BindRequest};
|
||||||
use fabro_types::ServerSettings;
|
use fabro_types::ServerSettings;
|
||||||
use fabro_types::settings::interp::InterpString;
|
|
||||||
use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
|
use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
|
||||||
use fabro_util::Home;
|
use fabro_util::Home;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
@ -1173,9 +1158,8 @@ mod tests {
|
||||||
SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase, apply_effective_log_destination,
|
SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase, apply_effective_log_destination,
|
||||||
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
|
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
|
||||||
build_object_store_from_settings_with_lookup, build_slatedb_store,
|
build_object_store_from_settings_with_lookup, build_slatedb_store,
|
||||||
force_exit_after_shutdown, resolve_bind_request_from_server_settings, resolve_interp,
|
force_exit_after_shutdown, resolve_bind_request_from_server_settings, serve_overrides,
|
||||||
serve_overrides, serve_until_shutdown, server_bind_title, server_title,
|
serve_until_shutdown, server_bind_title, server_title, spawn_shutdown_orchestrator_inner,
|
||||||
spawn_shutdown_orchestrator_inner,
|
|
||||||
};
|
};
|
||||||
use crate::server::ResolvedAppStateSettings;
|
use crate::server::ResolvedAppStateSettings;
|
||||||
|
|
||||||
|
|
@ -1210,16 +1194,6 @@ mod tests {
|
||||||
.expect("settings should resolve")
|
.expect("settings should resolve")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_settings_interpolation_rejects_variables() {
|
|
||||||
let err = resolve_interp(&InterpString::parse("{{ vars.STORAGE_ROOT }}")).unwrap_err();
|
|
||||||
|
|
||||||
let rendered = format!("{err:#}");
|
|
||||||
assert!(rendered.contains("failed to resolve {{ vars.STORAGE_ROOT }}"));
|
|
||||||
assert!(rendered.contains("variable \"STORAGE_ROOT\""));
|
|
||||||
assert!(rendered.contains("not supported in this interpolation context"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolved_runtime_settings(source: &str) -> ResolvedAppStateSettings {
|
fn resolved_runtime_settings(source: &str) -> ResolvedAppStateSettings {
|
||||||
ResolvedAppStateSettings {
|
ResolvedAppStateSettings {
|
||||||
manifest_run_defaults: manifest_run_defaults(source),
|
manifest_run_defaults: manifest_run_defaults(source),
|
||||||
|
|
@ -1351,7 +1325,7 @@ mod tests {
|
||||||
.with_storage_override(&PathBuf::from("/srv/fabro-storage"));
|
.with_storage_override(&PathBuf::from("/srv/fabro-storage"));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.server_settings.server.storage.root.as_source(),
|
resolved.server_settings.server.storage.root,
|
||||||
"/srv/fabro-storage"
|
"/srv/fabro-storage"
|
||||||
);
|
);
|
||||||
let fabro_types::settings::ObjectStoreSettings::Local { root } =
|
let fabro_types::settings::ObjectStoreSettings::Local { root } =
|
||||||
|
|
@ -1359,13 +1333,13 @@ mod tests {
|
||||||
else {
|
else {
|
||||||
panic!("artifacts store should stay local");
|
panic!("artifacts store should stay local");
|
||||||
};
|
};
|
||||||
assert_eq!(root.as_source(), "/srv/fabro-storage/objects/artifacts");
|
assert_eq!(root, "/srv/fabro-storage/objects/artifacts");
|
||||||
let fabro_types::settings::ObjectStoreSettings::Local { root } =
|
let fabro_types::settings::ObjectStoreSettings::Local { root } =
|
||||||
&resolved.server_settings.server.slatedb.store
|
&resolved.server_settings.server.slatedb.store
|
||||||
else {
|
else {
|
||||||
panic!("slatedb store should stay local");
|
panic!("slatedb store should stay local");
|
||||||
};
|
};
|
||||||
assert_eq!(root.as_source(), "/srv/fabro-storage/objects/slatedb");
|
assert_eq!(root, "/srv/fabro-storage/objects/slatedb");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1383,7 +1357,7 @@ root = "/srv/from-disk"
|
||||||
.with_storage_override(&PathBuf::from("/srv/from-runtime"));
|
.with_storage_override(&PathBuf::from("/srv/from-runtime"));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.server_settings.server.storage.root.as_source(),
|
resolved.server_settings.server.storage.root,
|
||||||
"/srv/from-runtime"
|
"/srv/from-runtime"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1623,8 +1597,8 @@ disk_cache = true
|
||||||
#[test]
|
#[test]
|
||||||
fn build_object_store_from_settings_uses_injected_static_credentials() {
|
fn build_object_store_from_settings_uses_injected_static_credentials() {
|
||||||
let settings = ObjectStoreSettings::S3 {
|
let settings = ObjectStoreSettings::S3 {
|
||||||
bucket: InterpString::parse("fabro-data"),
|
bucket: "fabro-data".to_string(),
|
||||||
region: InterpString::parse("us-east-1"),
|
region: "us-east-1".to_string(),
|
||||||
endpoint: None,
|
endpoint: None,
|
||||||
path_style: false,
|
path_style: false,
|
||||||
};
|
};
|
||||||
|
|
@ -1645,8 +1619,8 @@ disk_cache = true
|
||||||
#[test]
|
#[test]
|
||||||
fn build_object_store_from_settings_rejects_partial_static_credentials() {
|
fn build_object_store_from_settings_rejects_partial_static_credentials() {
|
||||||
let settings = ObjectStoreSettings::S3 {
|
let settings = ObjectStoreSettings::S3 {
|
||||||
bucket: InterpString::parse("fabro-data"),
|
bucket: "fabro-data".to_string(),
|
||||||
region: InterpString::parse("us-east-1"),
|
region: "us-east-1".to_string(),
|
||||||
endpoint: None,
|
endpoint: None,
|
||||||
path_style: false,
|
path_style: false,
|
||||||
};
|
};
|
||||||
|
|
@ -1670,8 +1644,8 @@ disk_cache = true
|
||||||
#[test]
|
#[test]
|
||||||
fn build_object_store_from_settings_ignores_endpoint_override_env_vars() {
|
fn build_object_store_from_settings_ignores_endpoint_override_env_vars() {
|
||||||
let settings = ObjectStoreSettings::S3 {
|
let settings = ObjectStoreSettings::S3 {
|
||||||
bucket: InterpString::parse("fabro-data"),
|
bucket: "fabro-data".to_string(),
|
||||||
region: InterpString::parse("us-east-1"),
|
region: "us-east-1".to_string(),
|
||||||
endpoint: None,
|
endpoint: None,
|
||||||
path_style: false,
|
path_style: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -87,11 +87,11 @@ use fabro_store::{
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use fabro_types::BlockedReason;
|
use fabro_types::BlockedReason;
|
||||||
|
use fabro_types::settings::RunNamespace;
|
||||||
use fabro_types::settings::run::{NotificationRouteSettings, RunMode};
|
use fabro_types::settings::run::{NotificationRouteSettings, RunMode};
|
||||||
use fabro_types::settings::server::{
|
use fabro_types::settings::server::{
|
||||||
GithubIntegrationSettings, GithubIntegrationStrategy, LogDestination,
|
GithubIntegrationSettings, GithubIntegrationStrategy, LogDestination,
|
||||||
};
|
};
|
||||||
use fabro_types::settings::{InterpString, RunNamespace};
|
|
||||||
use fabro_types::{
|
use fabro_types::{
|
||||||
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
|
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
|
||||||
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
|
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
|
||||||
|
|
@ -141,12 +141,12 @@ use crate::automation_materializer::{
|
||||||
AutomationRunMaterializeError, AutomationRunMaterializeInput, AutomationRunMaterialized,
|
AutomationRunMaterializeError, AutomationRunMaterializeInput, AutomationRunMaterialized,
|
||||||
AutomationRunMaterializer, GitRepoCache, ProductionAutomationRunMaterializer,
|
AutomationRunMaterializer, GitRepoCache, ProductionAutomationRunMaterializer,
|
||||||
};
|
};
|
||||||
use crate::canonical_origin::resolve_canonical_origin;
|
use crate::canonical_origin::{canonical_origin_from_effective_web_url, effective_web_url};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::github_webhooks::{
|
use crate::github_webhooks::{
|
||||||
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
|
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
|
||||||
};
|
};
|
||||||
use crate::interp::{process_env_var, resolve_interp, resolve_interp_with};
|
use crate::interp::process_env_var;
|
||||||
use crate::jwt_auth::{self, AuthMode};
|
use crate::jwt_auth::{self, AuthMode};
|
||||||
use crate::principal_middleware::{
|
use crate::principal_middleware::{
|
||||||
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget,
|
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget,
|
||||||
|
|
@ -1090,6 +1090,7 @@ pub struct AppState {
|
||||||
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
||||||
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
|
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
|
||||||
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
|
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
|
||||||
|
effective_web_url: RwLock<String>,
|
||||||
catalog: RwLock<Arc<Catalog>>,
|
catalog: RwLock<Arc<Catalog>>,
|
||||||
pub(crate) env_lookup: EnvLookup,
|
pub(crate) env_lookup: EnvLookup,
|
||||||
pub(crate) github_api_base_url: String,
|
pub(crate) github_api_base_url: String,
|
||||||
|
|
@ -1349,10 +1350,7 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn server_storage_dir(&self) -> PathBuf {
|
pub(crate) fn server_storage_dir(&self) -> PathBuf {
|
||||||
PathBuf::from(
|
PathBuf::from(&self.server_settings().server.storage.root)
|
||||||
resolve_interp(&self.server_settings().server.storage.root)
|
|
||||||
.expect("server storage root should be resolved at startup"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scratch directory used by the automation materializer when staging
|
/// Scratch directory used by the automation materializer when staging
|
||||||
|
|
@ -1487,12 +1485,15 @@ impl AppState {
|
||||||
daemon.bind.to_target().parse()
|
daemon.bind.to_target().parse()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result<String> {
|
pub(crate) fn effective_web_url(&self) -> String {
|
||||||
resolve_interp_with(value, |name| (self.env_lookup)(name))
|
self.effective_web_url
|
||||||
|
.read()
|
||||||
|
.expect("effective web url lock poisoned")
|
||||||
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn canonical_origin(&self) -> Result<String, String> {
|
pub(crate) fn canonical_origin(&self) -> Result<String, String> {
|
||||||
resolve_canonical_origin(&self.server_settings().server, &self.env_lookup)
|
canonical_origin_from_effective_web_url(&self.effective_web_url())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn session_key(&self) -> Option<Key> {
|
pub(crate) fn session_key(&self) -> Option<Key> {
|
||||||
|
|
@ -1500,18 +1501,13 @@ impl AppState {
|
||||||
.and_then(|value| auth::derive_cookie_key(value.as_bytes()).ok())
|
.and_then(|value| auth::derive_cookie_key(value.as_bytes()).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
|
|
||||||
interpolation unification (Phase 2)"
|
|
||||||
)]
|
|
||||||
pub(crate) fn github_credentials(
|
pub(crate) fn github_credentials(
|
||||||
&self,
|
&self,
|
||||||
settings: &GithubIntegrationSettings,
|
settings: &GithubIntegrationSettings,
|
||||||
) -> Result<Option<fabro_github::GitHubCredentials>, String> {
|
) -> Result<Option<fabro_github::GitHubCredentials>, String> {
|
||||||
match settings.strategy {
|
match settings.strategy {
|
||||||
GithubIntegrationStrategy::App => {
|
GithubIntegrationStrategy::App => {
|
||||||
let Some(app_id) = settings.app_id.as_ref().map(InterpString::as_source) else {
|
let Some(app_id) = settings.app_id.clone() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let raw = self.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY);
|
let raw = self.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY);
|
||||||
|
|
@ -1523,7 +1519,7 @@ impl AppState {
|
||||||
fabro_github::GitHubAppCredentials {
|
fabro_github::GitHubAppCredentials {
|
||||||
app_id,
|
app_id,
|
||||||
private_key_pem,
|
private_key_pem,
|
||||||
slug: settings.slug.as_ref().map(InterpString::as_source),
|
slug: settings.slug.clone(),
|
||||||
},
|
},
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
@ -1574,6 +1570,8 @@ impl AppState {
|
||||||
} = resolved_settings;
|
} = resolved_settings;
|
||||||
let server_settings = Arc::new(server_settings);
|
let server_settings = Arc::new(server_settings);
|
||||||
let manifest_run_defaults = Arc::new(manifest_run_defaults);
|
let manifest_run_defaults = Arc::new(manifest_run_defaults);
|
||||||
|
let effective_web_url =
|
||||||
|
effective_web_url(&server_settings.server, |name| (self.env_lookup)(name));
|
||||||
let manifest_run_settings = resolve_manifest_run_settings_with_catalog(
|
let manifest_run_settings = resolve_manifest_run_settings_with_catalog(
|
||||||
manifest_run_defaults.as_ref(),
|
manifest_run_defaults.as_ref(),
|
||||||
&self.environment_store,
|
&self.environment_store,
|
||||||
|
|
@ -1582,8 +1580,7 @@ impl AppState {
|
||||||
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
|
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
|
||||||
.context("building LLM model catalog")?,
|
.context("building LLM model catalog")?,
|
||||||
);
|
);
|
||||||
resolve_canonical_origin(&server_settings.server, &self.env_lookup)
|
canonical_origin_from_effective_web_url(&effective_web_url).map_err(anyhow::Error::msg)?;
|
||||||
.map_err(anyhow::Error::msg)?;
|
|
||||||
|
|
||||||
*self
|
*self
|
||||||
.manifest_run_defaults
|
.manifest_run_defaults
|
||||||
|
|
@ -1597,6 +1594,10 @@ impl AppState {
|
||||||
.server_settings
|
.server_settings
|
||||||
.write()
|
.write()
|
||||||
.expect("server settings lock poisoned") = server_settings;
|
.expect("server settings lock poisoned") = server_settings;
|
||||||
|
*self
|
||||||
|
.effective_web_url
|
||||||
|
.write()
|
||||||
|
.expect("effective web url lock poisoned") = effective_web_url;
|
||||||
*self.catalog.write().expect("catalog lock poisoned") = catalog;
|
*self.catalog.write().expect("catalog lock poisoned") = catalog;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -2334,6 +2335,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
||||||
Arc::new(VaultCredentialSource::vault_only(Arc::clone(&vault)));
|
Arc::new(VaultCredentialSource::vault_only(Arc::clone(&vault)));
|
||||||
let (global_event_tx, _) = broadcast::channel(4096);
|
let (global_event_tx, _) = broadcast::channel(4096);
|
||||||
let current_server_settings = Arc::new(resolved_settings.server_settings);
|
let current_server_settings = Arc::new(resolved_settings.server_settings);
|
||||||
|
let current_effective_web_url =
|
||||||
|
effective_web_url(¤t_server_settings.server, |name| env_lookup(name));
|
||||||
let current_manifest_run_defaults = Arc::new(resolved_settings.manifest_run_defaults);
|
let current_manifest_run_defaults = Arc::new(resolved_settings.manifest_run_defaults);
|
||||||
let current_manifest_run_settings = resolve_manifest_run_settings_with_catalog(
|
let current_manifest_run_settings = resolve_manifest_run_settings_with_catalog(
|
||||||
current_manifest_run_defaults.as_ref(),
|
current_manifest_run_defaults.as_ref(),
|
||||||
|
|
@ -2396,10 +2399,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
||||||
};
|
};
|
||||||
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
|
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
|
||||||
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
|
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
|
||||||
let storage_root = PathBuf::from(
|
let storage_root = PathBuf::from(¤t_server_settings.server.storage.root);
|
||||||
resolve_interp(¤t_server_settings.server.storage.root)
|
|
||||||
.context("resolve server storage root")?,
|
|
||||||
);
|
|
||||||
let automation_repo_cache = Arc::new(GitRepoCache::new(
|
let automation_repo_cache = Arc::new(GitRepoCache::new(
|
||||||
Storage::new(&storage_root)
|
Storage::new(&storage_root)
|
||||||
.cache_dir()
|
.cache_dir()
|
||||||
|
|
@ -2455,6 +2455,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
||||||
manifest_run_defaults: RwLock::new(current_manifest_run_defaults),
|
manifest_run_defaults: RwLock::new(current_manifest_run_defaults),
|
||||||
manifest_run_settings: RwLock::new(current_manifest_run_settings),
|
manifest_run_settings: RwLock::new(current_manifest_run_settings),
|
||||||
server_settings: RwLock::new(current_server_settings),
|
server_settings: RwLock::new(current_server_settings),
|
||||||
|
effective_web_url: RwLock::new(current_effective_web_url),
|
||||||
catalog: RwLock::new(current_catalog),
|
catalog: RwLock::new(current_catalog),
|
||||||
env_lookup: Arc::clone(&env_lookup),
|
env_lookup: Arc::clone(&env_lookup),
|
||||||
github_api_base_url,
|
github_api_base_url,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
|
|
@ -40,7 +39,6 @@ use super::super::{
|
||||||
parse_stage_id_path, reject_if_archived, submit_pending_interview_answer, workflow_event,
|
parse_stage_id_path, reject_if_archived, submit_pending_interview_answer, workflow_event,
|
||||||
};
|
};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::interp::resolve_interp;
|
|
||||||
use crate::principal_middleware::{
|
use crate::principal_middleware::{
|
||||||
RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped,
|
RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped,
|
||||||
RequiredRunManagementActor, RequiredUser,
|
RequiredRunManagementActor, RequiredUser,
|
||||||
|
|
@ -698,16 +696,7 @@ pub(crate) async fn create_run_from_manifest(
|
||||||
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
|
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
|
||||||
create_input.automation = automation;
|
create_input.automation = automation;
|
||||||
|
|
||||||
let storage_root = match resolve_interp(&state.server_settings().server.storage.root) {
|
let storage_root = state.server_storage_dir();
|
||||||
Ok(path) => PathBuf::from(path),
|
|
||||||
Err(err) => {
|
|
||||||
return ApiError::new(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to resolve server storage root: {err}"),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let created = match Box::pin(operations::create(
|
let created = match Box::pin(operations::create(
|
||||||
state.store.as_ref(),
|
state.store.as_ref(),
|
||||||
create_input,
|
create_input,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ use super::super::{
|
||||||
counts_toward_scheduler_capacity, delete_run_internal, diagnostics, get, post,
|
counts_toward_scheduler_capacity, delete_run_internal, diagnostics, get, post,
|
||||||
resource_sampler, spawn_blocking, system_sandbox_provider, to_i64,
|
resource_sampler, spawn_blocking, system_sandbox_provider, to_i64,
|
||||||
};
|
};
|
||||||
use crate::interp::resolve_interp;
|
|
||||||
|
|
||||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|
@ -52,14 +51,8 @@ async fn get_server_settings(_auth: RequiredUser, State(state): State<Arc<AppSta
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "known leak: server.web.url passes unresolved; strict resolution scheduled in the \
|
|
||||||
interpolation unification (Phase 2)"
|
|
||||||
)]
|
|
||||||
async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
||||||
let manifest_run_settings = state.manifest_run_settings();
|
let manifest_run_settings = state.manifest_run_settings();
|
||||||
let server_settings = state.server_settings();
|
|
||||||
let (total_runs, active_runs, scheduler_slots_used) = {
|
let (total_runs, active_runs, scheduler_slots_used) = {
|
||||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||||
let active = runs
|
let active = runs
|
||||||
|
|
@ -85,7 +78,7 @@ async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>
|
||||||
|
|
||||||
let response = SystemInfoResponse {
|
let response = SystemInfoResponse {
|
||||||
version: Some(FABRO_VERSION.to_string()),
|
version: Some(FABRO_VERSION.to_string()),
|
||||||
server_url: Some(server_settings.server.web.url.as_source()),
|
server_url: Some(state.effective_web_url()),
|
||||||
git_sha: option_env!("FABRO_GIT_SHA").map(str::to_string),
|
git_sha: option_env!("FABRO_GIT_SHA").map(str::to_string),
|
||||||
build_date: option_env!("FABRO_BUILD_DATE").map(str::to_string),
|
build_date: option_env!("FABRO_BUILD_DATE").map(str::to_string),
|
||||||
profile: option_env!("FABRO_BUILD_PROFILE").map(str::to_string),
|
profile: option_env!("FABRO_BUILD_PROFILE").map(str::to_string),
|
||||||
|
|
@ -132,10 +125,10 @@ fn github_integration_status(
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
if let Some(slug) = settings.slug.as_ref() {
|
if let Some(slug) = settings.slug.as_ref() {
|
||||||
metadata.insert("slug".to_string(), display_interp(state, slug));
|
metadata.insert("slug".to_string(), slug.clone());
|
||||||
}
|
}
|
||||||
if let Some(app_id) = settings.app_id.as_ref() {
|
if let Some(app_id) = settings.app_id.as_ref() {
|
||||||
metadata.insert("app_id".to_string(), display_interp(state, app_id));
|
metadata.insert("app_id".to_string(), app_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !settings.enabled {
|
if !settings.enabled {
|
||||||
|
|
@ -188,6 +181,10 @@ fn github_integration_status(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn display_interp(state: &AppState, value: &InterpString) -> String {
|
||||||
|
value.resolve_or_source(|name| (state.env_lookup)(name))
|
||||||
|
}
|
||||||
|
|
||||||
fn slack_integration_status(state: &AppState) -> SystemIntegrationStatus {
|
fn slack_integration_status(state: &AppState) -> SystemIntegrationStatus {
|
||||||
let settings = &state.server_settings().server.integrations.slack;
|
let settings = &state.server_settings().server.integrations.slack;
|
||||||
let mut metadata = BTreeMap::new();
|
let mut metadata = BTreeMap::new();
|
||||||
|
|
@ -281,10 +278,6 @@ fn missing_vault_secret(state: &AppState, name: &str) -> bool {
|
||||||
.is_none_or(str::is_empty)
|
.is_none_or(str::is_empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_interp(state: &AppState, value: &InterpString) -> String {
|
|
||||||
value.resolve_or_source(|name| (state.env_lookup)(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_system_resources(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
async fn get_system_resources(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
||||||
match resource_sampler::sample_system_resources(&state).await {
|
match resource_sampler::sample_system_resources(&state).await {
|
||||||
Ok(response) => (StatusCode::OK, Json(response)).into_response(),
|
Ok(response) => (StatusCode::OK, Json(response)).into_response(),
|
||||||
|
|
@ -475,16 +468,12 @@ async fn get_github_repo(
|
||||||
let base_url = fabro_github::github_api_base_url();
|
let base_url = fabro_github::github_api_base_url();
|
||||||
let (token, client) = match github_settings.strategy {
|
let (token, client) = match github_settings.strategy {
|
||||||
GithubIntegrationStrategy::App => {
|
GithubIntegrationStrategy::App => {
|
||||||
let Some(app_id) = github_settings.app_id.as_ref() else {
|
if github_settings.app_id.is_none() {
|
||||||
return ApiError::new(
|
return ApiError::new(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"server.integrations.github.app_id is not configured",
|
"server.integrations.github.app_id is not configured",
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
};
|
|
||||||
if let Err(err) = resolve_interp(app_id) {
|
|
||||||
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
let creds = match state.github_credentials(github_settings) {
|
let creds = match state.github_credentials(github_settings) {
|
||||||
Ok(Some(fabro_github::GitHubCredentials::App(creds))) => creds,
|
Ok(Some(fabro_github::GitHubCredentials::App(creds))) => creds,
|
||||||
|
|
@ -509,16 +498,9 @@ async fn get_github_repo(
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let install_url = match github_settings.slug.as_ref() {
|
let install_url = creds.installation_url(&owner).unwrap_or_else(|| {
|
||||||
Some(slug) => match resolve_interp(slug) {
|
format!("https://github.com/organizations/{owner}/settings/installations")
|
||||||
Ok(slug) => format!("https://github.com/apps/{slug}/installations/new"),
|
});
|
||||||
Err(err) => {
|
|
||||||
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => format!("https://github.com/organizations/{owner}/settings/installations"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = match state.http_client() {
|
let client = match state.http_client() {
|
||||||
Ok(http) => http,
|
Ok(http) => http,
|
||||||
|
|
|
||||||
|
|
@ -1056,18 +1056,18 @@ fn replace_settings_rejects_invalid_canonical_origin_and_keeps_previous_settings
|
||||||
"ftp://fabro.example.com",
|
"ftp://fabro.example.com",
|
||||||
"http://0.0.0.0:32276",
|
"http://0.0.0.0:32276",
|
||||||
] {
|
] {
|
||||||
|
// No FABRO_WEB_URL override: web.url is plain config now, so the
|
||||||
|
// invalid value is rejected from the settings literal and the kept
|
||||||
|
// previous settings stay valid.
|
||||||
let state = test_app_state_with_env_lookup(
|
let state = test_app_state_with_env_lookup(
|
||||||
canonical_origin_settings("http://valid.example.com"),
|
canonical_origin_settings("http://valid.example.com"),
|
||||||
RunLayer::default(),
|
RunLayer::default(),
|
||||||
5,
|
5,
|
||||||
{
|
|_| None,
|
||||||
let invalid = invalid.to_string();
|
|
||||||
move |name| (name == "FABRO_WEB_URL").then(|| invalid.clone())
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let err = state
|
let err = state
|
||||||
.replace_runtime_settings(resolved_runtime_settings_from_toml(
|
.replace_runtime_settings(resolved_runtime_settings_from_toml(&format!(
|
||||||
r#"
|
r#"
|
||||||
_version = 1
|
_version = 1
|
||||||
|
|
||||||
|
|
@ -1075,9 +1075,9 @@ _version = 1
|
||||||
methods = ["dev-token"]
|
methods = ["dev-token"]
|
||||||
|
|
||||||
[server.web]
|
[server.web]
|
||||||
url = "{{ env.FABRO_WEB_URL }}"
|
url = "{invalid}"
|
||||||
"#,
|
"#,
|
||||||
))
|
)))
|
||||||
.expect_err("invalid canonical origin should be rejected");
|
.expect_err("invalid canonical origin should be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string()
|
err.to_string()
|
||||||
|
|
@ -1091,10 +1091,36 @@ url = "{{ env.FABRO_WEB_URL }}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[test]
|
||||||
clippy::disallowed_methods,
|
fn canonical_origin_prefers_fabro_web_url_env_override() {
|
||||||
reason = "test asserts the raw template source"
|
// FABRO_WEB_URL is the native control-plane override; it wins over the
|
||||||
)]
|
// plain `server.web.url` settings literal.
|
||||||
|
let state = test_app_state_with_env_lookup(
|
||||||
|
canonical_origin_settings("http://settings.example.com"),
|
||||||
|
RunLayer::default(),
|
||||||
|
5,
|
||||||
|
|name| (name == "FABRO_WEB_URL").then(|| "http://env.example.com".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state.canonical_origin().unwrap(), "http://env.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_origin_uses_settings_literal_without_env_override() {
|
||||||
|
// Without FABRO_WEB_URL set, the plain `server.web.url` literal is used.
|
||||||
|
let state = test_app_state_with_env_lookup(
|
||||||
|
canonical_origin_settings("http://settings.example.com"),
|
||||||
|
RunLayer::default(),
|
||||||
|
5,
|
||||||
|
|_| None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.canonical_origin().unwrap(),
|
||||||
|
"http://settings.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replace_settings_updates_layer_and_typed_server_settings() {
|
fn replace_settings_updates_layer_and_typed_server_settings() {
|
||||||
let state = test_app_state_with_options(
|
let state = test_app_state_with_options(
|
||||||
|
|
@ -1150,10 +1176,7 @@ root = "/srv/new"
|
||||||
.expect("valid settings should replace current state");
|
.expect("valid settings should replace current state");
|
||||||
|
|
||||||
assert_eq!(state.canonical_origin().unwrap(), "http://new.example.com");
|
assert_eq!(state.canonical_origin().unwrap(), "http://new.example.com");
|
||||||
assert_eq!(
|
assert_eq!(state.server_settings().server.storage.root, "/srv/new");
|
||||||
state.server_settings().server.storage.root.as_source(),
|
|
||||||
"/srv/new"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state
|
state
|
||||||
.manifest_run_settings()
|
.manifest_run_settings()
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::auth::{GithubEndpoints, browser_shell};
|
use crate::auth::{GithubEndpoints, browser_shell};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::interp::process_env_var;
|
|
||||||
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
|
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
|
||||||
use crate::principal_middleware::{
|
use crate::principal_middleware::{
|
||||||
RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user,
|
RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user,
|
||||||
|
|
@ -324,12 +323,8 @@ fn session_provider(auth_method: AuthMethod) -> &'static str {
|
||||||
|
|
||||||
fn session_cookie_secure(state: &AppState) -> bool {
|
fn session_cookie_secure(state: &AppState) -> bool {
|
||||||
state
|
state
|
||||||
.server_settings()
|
.canonical_origin()
|
||||||
.server
|
.is_ok_and(|web_url| web_url.starts_with("https://"))
|
||||||
.web
|
|
||||||
.url
|
|
||||||
.resolve(process_env_var)
|
|
||||||
.is_ok_and(|resolved| resolved.value.starts_with("https://"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redacted_url_for_log(url: &str) -> String {
|
fn redacted_url_for_log(url: &str) -> String {
|
||||||
|
|
@ -443,16 +438,7 @@ async fn login_github(
|
||||||
json!({"error": "GitHub App client_id is not configured"}),
|
json!({"error": "GitHub App client_id is not configured"}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let client_id = match state.resolve_interp(client_id) {
|
let client_id = client_id.clone();
|
||||||
Ok(client_id) => client_id,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "OAuth login failed: client_id could not be resolved");
|
|
||||||
return json_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
json!({"error": format!("GitHub App client_id could not be resolved: {err}")}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let web_url = match state.canonical_origin() {
|
let web_url = match state.canonical_origin() {
|
||||||
Ok(web_url) => web_url,
|
Ok(web_url) => web_url,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -590,16 +576,7 @@ async fn callback_github(
|
||||||
json!({"error": "GitHub App client_id is not configured"}),
|
json!({"error": "GitHub App client_id is not configured"}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let client_id = match state.resolve_interp(client_id) {
|
let client_id = client_id.clone();
|
||||||
Ok(client_id) => client_id,
|
|
||||||
Err(err) => {
|
|
||||||
error!(error = %err, "OAuth callback failed: client_id could not be resolved");
|
|
||||||
return json_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
json!({"error": format!("GitHub App client_id could not be resolved: {err}")}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(client_secret) = state.vault_secret(EnvVars::GITHUB_APP_CLIENT_SECRET) else {
|
let Some(client_secret) = state.vault_secret(EnvVars::GITHUB_APP_CLIENT_SECRET) else {
|
||||||
error!("OAuth callback failed: GITHUB_APP_CLIENT_SECRET not configured");
|
error!("OAuth callback failed: GITHUB_APP_CLIENT_SECRET not configured");
|
||||||
return json_response(
|
return json_response(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use fabro_config::Storage;
|
use fabro_config::Storage;
|
||||||
use fabro_types::RunId;
|
use fabro_types::RunId;
|
||||||
use fabro_types::settings::interp::InterpString;
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
|
@ -40,8 +39,7 @@ fn temp_storage_settings() -> (tempfile::TempDir, TestAppSettings, PathBuf) {
|
||||||
let temp = tempdir().expect("tempdir should create");
|
let temp = tempdir().expect("tempdir should create");
|
||||||
let mut settings = test_settings();
|
let mut settings = test_settings();
|
||||||
let storage_dir = temp.path().join("storage");
|
let storage_dir = temp.path().join("storage");
|
||||||
settings.server_settings.server.storage.root =
|
settings.server_settings.server.storage.root = storage_dir.to_string_lossy().into_owned();
|
||||||
InterpString::parse(&storage_dir.to_string_lossy());
|
|
||||||
(temp, settings, storage_dir)
|
(temp, settings, storage_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,14 +112,10 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::disallowed_methods,
|
|
||||||
reason = "test asserts the raw template source"
|
|
||||||
)]
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_system_info_returns_runtime_fields() {
|
async fn get_system_info_returns_runtime_fields() {
|
||||||
let (_temp, settings, expected_storage_dir) = temp_storage_settings();
|
let (_temp, settings, expected_storage_dir) = temp_storage_settings();
|
||||||
let configured_server_url = settings.server_settings.server.web.url.as_source();
|
let configured_server_url = settings.server_settings.server.web.url.clone();
|
||||||
let app =
|
let app =
|
||||||
fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5));
|
fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ use std::path::Path;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
CliNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, RunNamespace,
|
CliNamespace, ObjectStoreSettings, ProjectNamespace, RunNamespace, ServerNamespace,
|
||||||
ServerNamespace, WorkflowNamespace,
|
WorkflowNamespace,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -16,7 +16,7 @@ pub struct ServerSettings {
|
||||||
impl ServerSettings {
|
impl ServerSettings {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_storage_override(mut self, path: &Path) -> Self {
|
pub fn with_storage_override(mut self, path: &Path) -> Self {
|
||||||
self.server.storage.root = InterpString::parse(&path.display().to_string());
|
self.server.storage.root = path.display().to_string();
|
||||||
override_local_object_store_root(&mut self.server.artifacts.store, path, "artifacts");
|
override_local_object_store_root(&mut self.server.artifacts.store, path, "artifacts");
|
||||||
override_local_object_store_root(&mut self.server.slatedb.store, path, "slatedb");
|
override_local_object_store_root(&mut self.server.slatedb.store, path, "slatedb");
|
||||||
self
|
self
|
||||||
|
|
@ -31,13 +31,11 @@ fn override_local_object_store_root(
|
||||||
let ObjectStoreSettings::Local { root } = store else {
|
let ObjectStoreSettings::Local { root } = store else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
*root = InterpString::parse(
|
*root = storage_root
|
||||||
&storage_root
|
.join("objects")
|
||||||
.join("objects")
|
.join(domain)
|
||||||
.join(domain)
|
.display()
|
||||||
.display()
|
.to_string();
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -70,36 +70,27 @@ pub enum ServerListenSettings {
|
||||||
address: SocketAddr,
|
address: SocketAddr,
|
||||||
},
|
},
|
||||||
Unix {
|
Unix {
|
||||||
path: InterpString,
|
path: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerListenSettings {
|
impl Default for ServerListenSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Unix {
|
Self::Unix {
|
||||||
path: InterpString::parse(""),
|
path: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ServerApiSettings {
|
pub struct ServerApiSettings {
|
||||||
pub url: Option<InterpString>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ServerWebSettings {
|
pub struct ServerWebSettings {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub url: InterpString,
|
pub url: String,
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ServerWebSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
url: InterpString::parse(""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -160,37 +151,20 @@ impl Default for ServerSandboxProviderSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ServerStorageSettings {
|
pub struct ServerStorageSettings {
|
||||||
pub root: InterpString,
|
pub root: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerStorageSettings {
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
root: InterpString::parse(""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ServerArtifactsSettings {
|
pub struct ServerArtifactsSettings {
|
||||||
pub prefix: InterpString,
|
pub prefix: String,
|
||||||
pub store: ObjectStoreSettings,
|
pub store: ObjectStoreSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerArtifactsSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
prefix: InterpString::parse(""),
|
|
||||||
store: ObjectStoreSettings::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ServerSlateDbSettings {
|
pub struct ServerSlateDbSettings {
|
||||||
pub prefix: InterpString,
|
pub prefix: String,
|
||||||
pub store: ObjectStoreSettings,
|
pub store: ObjectStoreSettings,
|
||||||
#[serde(
|
#[serde(
|
||||||
serialize_with = "serialize_std_duration",
|
serialize_with = "serialize_std_duration",
|
||||||
|
|
@ -203,7 +177,7 @@ pub struct ServerSlateDbSettings {
|
||||||
impl Default for ServerSlateDbSettings {
|
impl Default for ServerSlateDbSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
prefix: InterpString::parse(""),
|
prefix: String::new(),
|
||||||
store: ObjectStoreSettings::default(),
|
store: ObjectStoreSettings::default(),
|
||||||
flush_interval: StdDuration::ZERO,
|
flush_interval: StdDuration::ZERO,
|
||||||
disk_cache: false,
|
disk_cache: false,
|
||||||
|
|
@ -215,12 +189,12 @@ impl Default for ServerSlateDbSettings {
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ObjectStoreSettings {
|
pub enum ObjectStoreSettings {
|
||||||
Local {
|
Local {
|
||||||
root: InterpString,
|
root: String,
|
||||||
},
|
},
|
||||||
S3 {
|
S3 {
|
||||||
bucket: InterpString,
|
bucket: String,
|
||||||
region: InterpString,
|
region: String,
|
||||||
endpoint: Option<InterpString>,
|
endpoint: Option<String>,
|
||||||
path_style: bool,
|
path_style: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +202,7 @@ pub enum ObjectStoreSettings {
|
||||||
impl Default for ObjectStoreSettings {
|
impl Default for ObjectStoreSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Local {
|
Self::Local {
|
||||||
root: InterpString::parse(""),
|
root: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,9 +249,9 @@ pub struct ServerIntegrationsSettings {
|
||||||
pub struct GithubIntegrationSettings {
|
pub struct GithubIntegrationSettings {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub strategy: GithubIntegrationStrategy,
|
pub strategy: GithubIntegrationStrategy,
|
||||||
pub app_id: Option<InterpString>,
|
pub app_id: Option<String>,
|
||||||
pub client_id: Option<InterpString>,
|
pub client_id: Option<String>,
|
||||||
pub slug: Option<InterpString>,
|
pub slug: Option<String>,
|
||||||
pub webhooks: Option<IntegrationWebhooksSettings>,
|
pub webhooks: Option<IntegrationWebhooksSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue