Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ crc32fast = "1.5.0"
tempfile = "3.0"
proptest = "1.4"
criterion = "0.5"
figment = { version = "0.10", features = ["test"] }

[[bin]]
name = "inputlayer"
Expand Down
4 changes: 2 additions & 2 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,5 @@ enabled = true
static_dir = "./gui/dist"

[http.auth]
# Authentication is disabled by default
enabled = false
# Authentication is always on; bootstrap the admin user via
# INPUTLAYER_ADMIN_PASSWORD / INPUTLAYER_BOOTSTRAP_API_KEY env vars.
29 changes: 17 additions & 12 deletions src/bin/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ static TRACE_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = Once
#[derive(Parser, Debug)]
#[command(name = "inputlayer-server", version, about)]
struct Cli {
/// Host address to bind to
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Host address to bind to (default: config value, 127.0.0.1)
#[arg(long)]
host: Option<String>,

/// Port to listen on
#[arg(long, default_value_t = 8080)]
port: u16,
/// Port to listen on (default: config value, 8080)
#[arg(long)]
port: Option<u16>,

/// Path to configuration file (TOML)
#[arg(long, short)]
Expand Down Expand Up @@ -82,9 +82,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::process::exit(1);
})
} else {
Config::load().unwrap_or_else(|_| {
println!("Using default configuration");
Config::default()
Config::load().unwrap_or_else(|e| {
eprintln!("ERROR: invalid configuration: {e}");
std::process::exit(1);
})
};

Expand All @@ -110,9 +110,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
eprintln!("PANIC at {location}: {payload}");
}));

// Override from CLI flags
config.http.host = cli.host;
config.http.port = cli.port;
// CLI flags override config/env only when explicitly passed - previously
// the clap defaults clobbered file and env values unconditionally (#92).
if let Some(host) = cli.host {
config.http.host = host;
}
if let Some(port) = cli.port {
config.http.port = port;
}
config.http.enabled = true;

if let Some(data_dir) = cli.data_dir {
Expand Down
214 changes: 199 additions & 15 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
//! ```

use figment::{
providers::{Env, Format, Toml},
Figment,
providers::{Env, Format, Serialized, Toml},
Figment, Metadata, Profile, Provider,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
Expand Down Expand Up @@ -499,26 +499,121 @@ impl Default for RateLimitConfig {
}
}

/// Renames the legacy storage aliases (`default_database`,
/// `auto_create_databases`) to their canonical field names before figment
/// merges. The defaults seed always emits canonical names, and serde's
/// derive rejects a merged document containing both spellings of the same
/// field as a duplicate - so a config using a documented alias would fail
/// to parse at startup.
struct NormalizeAliases<P>(P);

impl<P: Provider> Provider for NormalizeAliases<P> {
fn metadata(&self) -> Metadata {
self.0.metadata()
}

fn data(&self) -> Result<figment::value::Map<Profile, figment::value::Dict>, figment::Error> {
let mut data = self.0.data()?;
for dict in data.values_mut() {
if let Some(figment::value::Value::Dict(_, storage)) = dict.get_mut("storage") {
for (alias, canonical) in [
("default_database", "default_knowledge_graph"),
("auto_create_databases", "auto_create_knowledge_graphs"),
] {
if let Some(value) = storage.remove(alias) {
storage.insert(canonical.to_string(), value);
}
}
}
}
Ok(data)
}
}

impl Config {
/// Load configuration from default locations
///
/// Merges in order:
/// 1. config.toml (base configuration)
/// 2. config.local.toml (local overrides, git-ignored)
/// 3. Environment variables (INPUTLAYER_* prefix)
/// Seed of default values, built THROUGH serde so the field-level serde
/// defaults are authoritative for omitted keys - exactly the values a
/// config that omits those keys received before seeding existed. The
/// hand-written Default impls diverge on several flags
/// (enable_join_planning, max_result_rows, gui.enabled, ...) and must
/// not leak into merged configs.
fn seed() -> Self {
// Every nested config struct appears as an explicit empty section:
// `#[serde(default)]` on a struct-typed FIELD falls back to the
// hand-written Default impl, which diverges from the field-level
// defaults inside that struct (e.g. max_result_rows 100000 vs 0).
// An empty section forces field-level defaults throughout.
let minimal = serde_json::json!({
"storage": {
"data_dir": "./data",
"default_knowledge_graph": "default",
"persistence": { "format": "parquet", "compression": "snappy" },
"persist": {},
"performance": {}
},
"optimization": {},
"logging": {},
"http": { "gui": {}, "auth": {}, "rate_limit": {} }
});
serde_json::from_value(minimal).expect("minimal config seed must deserialize")
}

/// Environment source for `INPUTLAYER_*` config overrides.
///
/// Only keys under the known top-level config sections pass through.
/// The server documents env vars under the same prefix that are NOT
/// config fields (`INPUTLAYER_BOOTSTRAP_API_KEY`,
/// `INPUTLAYER_ADMIN_PASSWORD`, `INPUTLAYER_API_KEY` for clients), and
/// strict parsing turned any of them into a startup failure (#92). The
/// TOML sources stay strict; only the env source is filtered.
fn env_source() -> Env {
const SECTIONS: [&str; 4] = ["storage", "optimization", "logging", "http"];
Env::prefixed("INPUTLAYER_")
.filter(|key| {
let key = key.as_str().to_ascii_lowercase();
let section = key.split("__").next().unwrap_or_default();
SECTIONS.contains(&section)
})
.split("__")
}

/// Merges in order (later wins):
/// 1. Built-in defaults
/// 2. config.toml (base configuration)
/// 3. config.local.toml (local overrides, git-ignored)
/// 4. Environment variables (INPUTLAYER_* prefix)
///
/// Defaults are seeded first so that a missing config file still yields
/// a valid config with env overrides applied - previously the no-file
/// path fell back to `Config::default()` and env vars did nothing (#92).
pub fn load() -> Result<Self, figment::Error> {
Figment::new()
.merge(Toml::file("config.toml"))
.merge(Toml::file("config.local.toml"))
.merge(Env::prefixed("INPUTLAYER_").split("__"))
.extract()
let config: Self = Figment::from(Serialized::defaults(Self::seed()))
.merge(NormalizeAliases(Toml::file("config.toml")))
.merge(NormalizeAliases(Toml::file("config.local.toml")))
.merge(NormalizeAliases(Self::env_source()))
.extract()?;
config.warn_unsafe_defaults();
Ok(config)
}

/// Load configuration from specific file path
/// Load configuration from specific file path.
///
/// Defaults are seeded first, so a partial config file only needs the
/// fields it wants to change. Unknown keys in the file are still
/// rejected (strict TOML parsing), and an explicitly given path that
/// does not exist is an error - with seeded defaults a missing file
/// would otherwise silently produce a default config.
pub fn from_file(path: &str) -> Result<Self, figment::Error> {
let config: Self = Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed("INPUTLAYER_").split("__"))
if !std::path::Path::new(path).is_file() {
return Err(figment::Error::from(format!(
"config file not found: {path}"
)));
}
let config: Self = Figment::from(Serialized::defaults(Self::seed()))
.merge(NormalizeAliases(Toml::file(path)))
.merge(NormalizeAliases(Self::env_source()))
.extract()?;
config.warn_unsafe_defaults();
Ok(config)
Expand Down Expand Up @@ -729,6 +824,95 @@ impl Default for AuthConfig {
mod tests {
use super::*;

#[test]
fn test_non_config_env_vars_are_ignored() {
// #92: the server documents INPUTLAYER_* env vars that are not
// config fields; they must not break config parsing.
figment::Jail::expect_with(|jail| {
jail.create_file("server.toml", "[storage]\ndata_dir = \"/tmp/x\"\n")?;
jail.set_env("INPUTLAYER_BOOTSTRAP_API_KEY", "secret");
jail.set_env("INPUTLAYER_ADMIN_PASSWORD", "secret");
jail.set_env("INPUTLAYER_API_KEY", "client-key");
let config = Config::from_file("server.toml").expect("parse must succeed");
assert_eq!(config.storage.data_dir, PathBuf::from("/tmp/x"));
Ok(())
});
}

#[test]
fn test_env_overrides_apply_without_config_file() {
// #92: the no-config-file path used Config::default() directly and
// env overrides did nothing.
figment::Jail::expect_with(|jail| {
jail.set_env("INPUTLAYER_HTTP__PORT", "8082");
jail.set_env("INPUTLAYER_STORAGE__DATA_DIR", "/tmp/env-data");
let config = Config::load().expect("load must succeed with no file");
assert_eq!(config.http.port, 8082);
assert_eq!(config.storage.data_dir, PathBuf::from("/tmp/env-data"));
Ok(())
});
}

#[test]
fn test_env_overrides_beat_config_file() {
figment::Jail::expect_with(|jail| {
jail.create_file("server.toml", "[http]\nenabled = true\nport = 9000\n")?;
jail.set_env("INPUTLAYER_HTTP__PORT", "9001");
let config = Config::from_file("server.toml").expect("parse must succeed");
assert_eq!(config.http.port, 9001);
Ok(())
});
}

#[test]
fn test_storage_aliases_survive_the_seed() {
// #103 review blocker: the seed emits canonical names, and a config
// using the documented aliases must not die as a duplicate field.
figment::Jail::expect_with(|jail| {
jail.create_file(
"server.toml",
"[storage]\ndefault_database = \"legacy\"\nauto_create_databases = true\n",
)?;
let config = Config::from_file("server.toml").expect("aliases must parse");
assert_eq!(config.storage.default_knowledge_graph, "legacy");
assert!(config.storage.auto_create_knowledge_graphs);
Ok(())
});
}

#[test]
fn test_storage_alias_env_var_survives_the_seed() {
figment::Jail::expect_with(|jail| {
jail.set_env("INPUTLAYER_STORAGE__DEFAULT_DATABASE", "legacy_env");
let config = Config::load().expect("aliased env var must parse");
assert_eq!(config.storage.default_knowledge_graph, "legacy_env");
Ok(())
});
}

#[test]
fn test_seed_matches_serde_field_defaults() {
// Omitted keys must get the serde field defaults (pre-seeding
// semantics), not the hand-written Default impl values.
figment::Jail::expect_with(|_jail| {
let config = Config::load().expect("no-file load");
assert!(!config.optimization.enable_join_planning);
assert!(!config.optimization.enable_sip_rewriting);
assert_eq!(config.storage.performance.max_result_rows, 0);
assert!(!config.http.enabled);
Ok(())
});
}

#[test]
fn test_shipped_template_parses() {
figment::Jail::expect_with(|jail| {
jail.create_file("shipped.toml", include_str!("../config.toml"))?;
Config::from_file("shipped.toml").expect("shipped config.toml must parse");
Ok(())
});
}

#[test]
fn test_default_config() {
let config = Config::default();
Expand Down
Loading