circus/crates/common/src/config.rs
NotAShelf 865b2f5f66
fc-common: better support declarative users with password file
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eac6decd68a4e59a52fecaecdd476b26a6a6964
2026-02-08 22:23:17 +03:00

598 lines
16 KiB
Rust

//! Configuration management for FC CI
use std::path::PathBuf;
use config as config_crate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub database: DatabaseConfig,
pub server: ServerConfig,
pub evaluator: EvaluatorConfig,
pub queue_runner: QueueRunnerConfig,
pub gc: GcConfig,
pub logs: LogConfig,
pub notifications: NotificationsConfig,
pub cache: CacheConfig,
pub signing: SigningConfig,
#[serde(default)]
pub cache_upload: CacheUploadConfig,
pub tracing: TracingConfig,
#[serde(default)]
pub declarative: DeclarativeConfig,
#[serde(default)]
pub oauth: OAuthConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
pub connect_timeout: u64,
pub idle_timeout: u64,
pub max_lifetime: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub request_timeout: u64,
pub max_body_size: usize,
pub api_key: Option<String>,
pub allowed_origins: Vec<String>,
pub cors_permissive: bool,
pub rate_limit_rps: Option<u64>,
pub rate_limit_burst: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EvaluatorConfig {
pub poll_interval: u64,
pub git_timeout: u64,
pub nix_timeout: u64,
pub max_concurrent_evals: usize,
pub work_dir: PathBuf,
pub restrict_eval: bool,
pub allow_ifd: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueRunnerConfig {
pub workers: usize,
pub poll_interval: u64,
pub build_timeout: u64,
pub work_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GcConfig {
pub gc_roots_dir: PathBuf,
pub enabled: bool,
pub max_age_days: u64,
pub cleanup_interval: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogConfig {
pub log_dir: PathBuf,
pub compress: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct OAuthConfig {
pub github: Option<GitHubOAuthConfig>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GitHubOAuthConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
impl std::fmt::Debug for GitHubOAuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHubOAuthConfig")
.field("client_id", &self.client_id)
.field("client_secret", &"[REDACTED]")
.field("redirect_uri", &self.redirect_uri)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct NotificationsConfig {
pub run_command: Option<String>,
pub github_token: Option<String>,
pub gitea_url: Option<String>,
pub gitea_token: Option<String>,
pub gitlab_url: Option<String>,
pub gitlab_token: Option<String>,
pub email: Option<EmailConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmailConfig {
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_user: Option<String>,
pub smtp_password: Option<String>,
pub from_address: String,
pub to_addresses: Vec<String>,
pub tls: bool,
pub on_failure_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub enabled: bool,
pub secret_key_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct SigningConfig {
pub enabled: bool,
pub key_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct CacheUploadConfig {
pub enabled: bool,
pub store_uri: Option<String>,
}
/// Declarative project/jobset/api-key/user definitions.
/// These are upserted on server startup, enabling fully declarative operation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DeclarativeConfig {
pub projects: Vec<DeclarativeProject>,
pub api_keys: Vec<DeclarativeApiKey>,
pub users: Vec<DeclarativeUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclarativeProject {
pub name: String,
pub repository_url: String,
pub description: Option<String>,
#[serde(default)]
pub jobsets: Vec<DeclarativeJobset>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclarativeJobset {
pub name: String,
pub nix_expression: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub flake_mode: bool,
#[serde(default = "default_check_interval")]
pub check_interval: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclarativeApiKey {
pub name: String,
pub key: String,
#[serde(default = "default_role")]
pub role: String,
}
/// Declarative user definition for configuration-driven user management.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclarativeUser {
pub username: String,
pub email: String,
pub full_name: Option<String>,
/// Password provided inline (for dev/testing only).
pub password: Option<String>,
/// Path to a file containing the password (for production use with secrets).
pub password_file: Option<String>,
#[serde(default = "default_user_role")]
pub role: String,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_user_role() -> String {
"read-only".to_string()
}
const fn default_true() -> bool {
true
}
const fn default_check_interval() -> i32 {
60
}
fn default_role() -> String {
"admin".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TracingConfig {
pub level: String,
pub format: String,
pub show_targets: bool,
pub show_timestamps: bool,
}
impl Default for TracingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
format: "compact".to_string(),
show_targets: true,
show_timestamps: true,
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "postgresql://fc_ci:password@localhost/fc_ci"
.to_string(),
max_connections: 20,
min_connections: 5,
connect_timeout: 30,
idle_timeout: 600,
max_lifetime: 1800,
}
}
}
impl DatabaseConfig {
pub fn validate(&self) -> anyhow::Result<()> {
if self.url.is_empty() {
return Err(anyhow::anyhow!("Database URL cannot be empty"));
}
if !self.url.starts_with("postgresql://")
&& !self.url.starts_with("postgres://")
{
return Err(anyhow::anyhow!(
"Database URL must start with postgresql:// or postgres://"
));
}
if self.max_connections == 0 {
return Err(anyhow::anyhow!(
"Max database connections must be greater than 0"
));
}
if self.min_connections > self.max_connections {
return Err(anyhow::anyhow!(
"Min database connections cannot exceed max connections"
));
}
Ok(())
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 3000,
request_timeout: 30,
max_body_size: 10 * 1024 * 1024, // 10MB
api_key: None,
allowed_origins: Vec::new(),
cors_permissive: false,
rate_limit_rps: None,
rate_limit_burst: None,
}
}
}
impl Default for EvaluatorConfig {
fn default() -> Self {
Self {
poll_interval: 60,
git_timeout: 600,
nix_timeout: 1800,
max_concurrent_evals: 4,
work_dir: PathBuf::from("/tmp/fc-evaluator"),
restrict_eval: true,
allow_ifd: false,
}
}
}
impl Default for QueueRunnerConfig {
fn default() -> Self {
Self {
workers: 4,
poll_interval: 5,
build_timeout: 3600,
work_dir: PathBuf::from("/tmp/fc-queue-runner"),
}
}
}
impl Default for GcConfig {
fn default() -> Self {
Self {
gc_roots_dir: PathBuf::from(
"/nix/var/nix/gcroots/per-user/fc/fc-roots",
),
enabled: true,
max_age_days: 30,
cleanup_interval: 3600,
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
log_dir: PathBuf::from("/var/lib/fc/logs"),
compress: false,
}
}
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
secret_key_file: None,
}
}
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
let mut settings = config_crate::Config::builder();
// Load default configuration
settings =
settings.add_source(config_crate::Config::try_from(&Self::default())?);
// Load from config file if it exists
if let Ok(config_path) = std::env::var("FC_CONFIG_FILE") {
if std::path::Path::new(&config_path).exists() {
settings =
settings.add_source(config_crate::File::with_name(&config_path));
}
} else if std::path::Path::new("fc.toml").exists() {
settings = settings
.add_source(config_crate::File::with_name("fc").required(false));
}
// Load from environment variables with FC_ prefix (highest priority)
settings = settings.add_source(
config_crate::Environment::with_prefix("FC")
.separator("__")
.try_parsing(true),
);
let config = settings.build()?.try_deserialize::<Self>()?;
// Validate configuration
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> anyhow::Result<()> {
// Validate database URL
if self.database.url.is_empty() {
return Err(anyhow::anyhow!("Database URL cannot be empty"));
}
if !self.database.url.starts_with("postgresql://")
&& !self.database.url.starts_with("postgres://")
{
return Err(anyhow::anyhow!(
"Database URL must start with postgresql:// or postgres://"
));
}
// Validate connection pool settings
if self.database.max_connections == 0 {
return Err(anyhow::anyhow!(
"Max database connections must be greater than 0"
));
}
if self.database.min_connections > self.database.max_connections {
return Err(anyhow::anyhow!(
"Min database connections cannot exceed max connections"
));
}
// Validate server settings
if self.server.port == 0 {
return Err(anyhow::anyhow!("Server port must be greater than 0"));
}
// Validate evaluator settings
if self.evaluator.poll_interval == 0 {
return Err(anyhow::anyhow!(
"Evaluator poll interval must be greater than 0"
));
}
// Validate queue runner settings
if self.queue_runner.workers == 0 {
return Err(anyhow::anyhow!(
"Queue runner workers must be greater than 0"
));
}
// Validate GC config
if self.gc.enabled && self.gc.gc_roots_dir.as_os_str().is_empty() {
return Err(anyhow::anyhow!(
"GC roots directory cannot be empty when GC is enabled"
));
}
// Validate log config
if self.logs.log_dir.as_os_str().is_empty() {
return Err(anyhow::anyhow!("Log directory cannot be empty"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::env;
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_invalid_database_url() {
let mut config = Config::default();
config.database.url = "invalid://url".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_invalid_port() {
let mut config = Config::default();
config.server.port = 0;
assert!(config.validate().is_err());
config.server.port = 65535;
assert!(config.validate().is_ok()); // valid port
}
#[test]
fn test_invalid_connections() {
let mut config = Config::default();
config.database.max_connections = 0;
assert!(config.validate().is_err());
config.database.max_connections = 10;
config.database.min_connections = 15;
assert!(config.validate().is_err());
}
#[test]
fn test_declarative_config_default_is_empty() {
let config = DeclarativeConfig::default();
assert!(config.projects.is_empty());
assert!(config.api_keys.is_empty());
}
#[test]
fn test_declarative_config_deserialization() {
let toml_str = r#"
[[projects]]
name = "my-project"
repository_url = "https://github.com/test/repo"
description = "Test project"
[[projects.jobsets]]
name = "packages"
nix_expression = "packages"
[[api_keys]]
name = "admin-key"
key = "fc_secret_key_123"
role = "admin"
"#;
let config: DeclarativeConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.projects.len(), 1);
assert_eq!(config.projects[0].name, "my-project");
assert_eq!(config.projects[0].jobsets.len(), 1);
assert_eq!(config.projects[0].jobsets[0].name, "packages");
assert!(config.projects[0].jobsets[0].enabled); // default true
assert!(config.projects[0].jobsets[0].flake_mode); // default true
assert_eq!(config.api_keys.len(), 1);
assert_eq!(config.api_keys[0].role, "admin");
}
#[test]
fn test_declarative_config_serialization_roundtrip() {
let config = DeclarativeConfig {
projects: vec![DeclarativeProject {
name: "test".to_string(),
repository_url: "https://example.com/repo".to_string(),
description: Some("desc".to_string()),
jobsets: vec![DeclarativeJobset {
name: "checks".to_string(),
nix_expression: "checks".to_string(),
enabled: true,
flake_mode: true,
check_interval: 300,
}],
}],
api_keys: vec![DeclarativeApiKey {
name: "test-key".to_string(),
key: "fc_test".to_string(),
role: "admin".to_string(),
}],
users: vec![],
};
let json = serde_json::to_string(&config).unwrap();
let parsed: DeclarativeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.projects.len(), 1);
assert_eq!(parsed.projects[0].jobsets[0].check_interval, 300);
assert_eq!(parsed.api_keys[0].name, "test-key");
}
#[test]
fn test_declarative_config_with_main_config() {
// Ensure declarative section is optional (default empty)
// Use the config crate loader which provides defaults for missing fields
let config = Config::default();
assert!(config.declarative.projects.is_empty());
assert!(config.declarative.api_keys.is_empty());
// And that the Config can be serialized back with declarative section
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert!(parsed.declarative.projects.is_empty());
}
#[test]
fn test_environment_override() {
// Test environment variable parsing directly
unsafe {
env::set_var("FC_DATABASE__URL", "postgresql://test:test@localhost/test");
env::set_var("FC_SERVER__PORT", "8080");
}
// Test that environment variables are being read correctly
let db_url = std::env::var("FC_DATABASE__URL").unwrap();
let server_port = std::env::var("FC_SERVER__PORT").unwrap();
assert_eq!(db_url, "postgresql://test:test@localhost/test");
assert_eq!(server_port, "8080");
unsafe {
env::remove_var("FC_DATABASE__URL");
env::remove_var("FC_SERVER__PORT");
}
}
}