diff --git a/crates/common/src/bootstrap.rs b/crates/common/src/bootstrap.rs index dfbe259..9607c62 100644 --- a/crates/common/src/bootstrap.rs +++ b/crates/common/src/bootstrap.rs @@ -1,4 +1,5 @@ -//! Declarative bootstrap: upsert projects, jobsets, and API keys from config. +//! Declarative bootstrap: upsert projects, jobsets, API keys and users from +//! config. //! //! Called once on server startup to reconcile declarative configuration //! with database state. Uses upsert semantics so repeated runs are idempotent. @@ -17,20 +18,25 @@ use crate::{ /// /// This function is idempotent: running it multiple times with the same config /// produces the same database state. It upserts (insert or update) all -/// configured projects, jobsets, and API keys. +/// configured projects, jobsets, API keys, and users. pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> { - if config.projects.is_empty() && config.api_keys.is_empty() { + if config.projects.is_empty() + && config.api_keys.is_empty() + && config.users.is_empty() + { return Ok(()); } let n_projects = config.projects.len(); let n_jobsets: usize = config.projects.iter().map(|p| p.jobsets.len()).sum(); let n_keys = config.api_keys.len(); + let n_users = config.users.len(); tracing::info!( projects = n_projects, jobsets = n_jobsets, api_keys = n_keys, + users = n_users, "Bootstrapping declarative configuration" ); @@ -59,6 +65,7 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> { check_interval: Some(decl_jobset.check_interval), branch: None, scheduling_shares: None, + state: None, }) .await?; @@ -87,6 +94,92 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> { ); } + // Upsert users + for decl_user in &config.users { + // Resolve password from inline or file + let password = if let Some(ref p) = decl_user.password { + Some(p.clone()) + } else if let Some(ref file) = decl_user.password_file { + match std::fs::read_to_string(file) { + Ok(p) => Some(p.trim().to_string()), + Err(e) => { + tracing::warn!( + username = %decl_user.username, + file = %file, + "Failed to read password file: {e}" + ); + None + }, + } + } else { + None + }; + + // Check if user exists + let existing = + repo::users::get_by_username(pool, &decl_user.username).await?; + + if let Some(user) = existing { + // Update existing user + let update = crate::models::UpdateUser { + email: Some(decl_user.email.clone()), + full_name: decl_user.full_name.clone(), + password, + role: Some(decl_user.role.clone()), + enabled: Some(decl_user.enabled), + public_dashboard: None, + }; + if let Err(e) = repo::users::update(pool, user.id, &update).await { + tracing::warn!( + username = %decl_user.username, + "Failed to update declarative user: {e}" + ); + } else { + tracing::info!( + username = %decl_user.username, + "Updated declarative user" + ); + } + } else if let Some(pwd) = password { + // Create new user + let create = crate::models::CreateUser { + username: decl_user.username.clone(), + email: decl_user.email.clone(), + full_name: decl_user.full_name.clone(), + password: pwd, + role: Some(decl_user.role.clone()), + }; + match repo::users::create(pool, &create).await { + Ok(user) => { + tracing::info!( + username = %user.username, + "Created declarative user" + ); + // Set enabled status if false (users are enabled by default) + if !decl_user.enabled + && let Err(e) = repo::users::set_enabled(pool, user.id, false).await + { + tracing::warn!( + username = %user.username, + "Failed to disable declarative user: {e}" + ); + } + }, + Err(e) => { + tracing::warn!( + username = %decl_user.username, + "Failed to create declarative user: {e}" + ); + }, + } + } else { + tracing::warn!( + username = %decl_user.username, + "Declarative user has no password set, skipping creation" + ); + } + } + tracing::info!("Declarative bootstrap complete"); Ok(()) } diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 47c6e35..46e302b 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -154,13 +154,14 @@ pub struct CacheUploadConfig { pub store_uri: Option, } -/// Declarative project/jobset/api-key definitions. +/// 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, pub api_keys: Vec, + pub users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -192,11 +193,31 @@ pub struct DeclarativeApiKey { pub role: String, } -fn default_true() -> bool { +/// 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, + /// Password provided inline (for dev/testing only). + pub password: Option, + /// Path to a file containing the password (for production use with secrets). + pub password_file: Option, + #[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 } -fn default_check_interval() -> i32 { +const fn default_check_interval() -> i32 { 60 } @@ -531,6 +552,7 @@ mod tests { key: "fc_test".to_string(), role: "admin".to_string(), }], + users: vec![], }; let json = serde_json::to_string(&config).unwrap();