Read global config once at launch instead of on every call
GlobalConfig::load() replaces the fallible Quire::global_config()
method. Missing config files now produce a default config rather than
ConfigNotFound; parse errors fail fast at startup. Quire::new() takes
the loaded GlobalConfig so it's always available via an infallible
&GlobalConfig getter. Error::ConfigNotFound is removed.
change
commit 98f58cee7b8ebcf0d7d455cef75d4167f248d9e0
author Claude <noreply@anthropic.com>
date
parent otoqsnul
diff --git a/docs/config.md b/docs/config.md
index 720b30e..55311bd 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -7,7 +7,8 @@ loaded via the embedding described in [`fennel.md`](fennel.md).
 ## Global config
 
 Lives at `/var/quire/config.fnl` on the bind-mounted volume.
-Operator-created. Re-read on every call (no caching today).
+Operator-created. Read once at launch; a server restart is required to
+pick up changes.
 
 | Key                  | Type           | Required | Purpose                                                  |
 |----------------------|----------------|----------|----------------------------------------------------------|
@@ -28,8 +29,9 @@ With Sentry, secrets, and the token sourced from a Docker secret:
  :secrets {:github_token {:file "/run/secrets/github_token"}}}
 ```
 
-A missing file is a typed error (`Error::ConfigNotFound`). A malformed
-file surfaces as a Fennel parse or eval error with source labels.
+A missing file causes all settings to use their defaults. A malformed
+file surfaces as a Fennel parse or eval error at startup and prevents
+the server from starting.
 
 ## Per-repo config
 
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index c390603..abb65c5 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -1,7 +1,6 @@
 use std::path::PathBuf;
 
 use miette::{IntoDiagnostic, Result};
-use quire::Quire;
 use quire::ci::{Ci, CommitRef, RunMeta, Runs};
 
 /// Validate a repo's ci.fnl without executing any jobs.
@@ -40,18 +39,11 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
 /// default), creates a transient Run rooted at a tempdir, dispatches
 /// to `quire-ci` via `execute`, and prints the combined
 /// log to stdout. The tempdir is removed when the command exits.
-pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
+pub async fn run(maybe_sha: Option<&str>) -> Result<()> {
     let repo_path = discover_repo()?;
     let commit = resolve_commit(maybe_sha)?;
     let ci = Ci::new(repo_path.clone());
 
-    // Ensure the global config is valid; absence is fine for local testing.
-    match quire.global_config() {
-        Ok(_) => {}
-        Err(quire::Error::ConfigNotFound(_)) => {}
-        Err(e) => return Err(e)?,
-    };
-
     let Some(_pipeline) = ci.pipeline(&commit)? else {
         println!("No ci.fnl found at {}.", commit.display);
         return Ok(());
diff --git a/quire-server/src/bin/quire/commands/dev.rs b/quire-server/src/bin/quire/commands/dev.rs
index 16159aa..8442719 100644
--- a/quire-server/src/bin/quire/commands/dev.rs
+++ b/quire-server/src/bin/quire/commands/dev.rs
@@ -67,7 +67,7 @@ impl Seeder {
         let base_dir = dir.keep();
         tracing::info!(path = %base_dir.display(), "seeded tempdir");
 
-        let quire = Quire::new(base_dir);
+        let quire = Quire::new(base_dir, quire::GlobalConfig::default());
 
         // Create the repos dir + a bare repo so the web view resolves the repo.
         let bare_repo = quire.repos_dir().join("example.git");
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 6fcbb6f..7195400 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -5,7 +5,7 @@ use clap::{CommandFactory, Parser, Subcommand};
 use clap_complete::Shell;
 use miette::IntoDiagnostic;
 use miette::Result;
-use quire::Quire;
+use quire::{GlobalConfig, Quire};
 use quire_core::telemetry::{self, FmtMode, MietteLayer};
 
 const VERSION: &str = env!("QUIRE_VERSION");
@@ -109,9 +109,10 @@ enum CiCommands {
 async fn main() -> Result<()> {
     let cli = Cli::parse();
 
-    let quire = Quire::new(cli.base_dir.into());
+    let base_dir: std::path::PathBuf = cli.base_dir.into();
+    let config = GlobalConfig::load(&base_dir.join("config.fnl"))?;
+    let quire = Quire::new(base_dir, config);
 
-    let sentry_config = quire.global_config().ok().and_then(|c| c.sentry);
     let miette_layer = MietteLayer::new()
         .with_type::<quire::Error>()
         .with_type::<quire::ci::Error>()
@@ -119,7 +120,7 @@ async fn main() -> Result<()> {
     let _guard = telemetry::init_telemetry(
         miette_layer,
         FmtMode::AutoJson,
-        sentry_config.as_ref(),
+        quire.global_config().sentry.as_ref(),
         VERSION,
     )?;
 
@@ -166,7 +167,7 @@ async fn main() -> Result<()> {
         },
         Commands::Ci { command } => match command {
             CiCommands::Validate { sha } => commands::ci::validate(sha.as_deref()).await?,
-            CiCommands::Run { sha } => commands::ci::run(&quire, sha.as_deref()).await?,
+            CiCommands::Run { sha } => commands::ci::run(sha.as_deref()).await?,
         },
     }
 
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index d803f1b..b9aec0d 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -28,8 +28,7 @@ async fn index() -> String {
 }
 
 pub async fn run(quire: &Quire, web_routes: axum::Router, api_routes: axum::Router) -> Result<()> {
-    let config = quire.global_config()?;
-    let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
+    let addr = SocketAddr::from(([0, 0, 0, 0], quire.global_config().port));
 
     // Set up event socket.
     let socket_path = quire.socket_path();
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index d3cb6ce..2735732 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -127,13 +127,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
         }
     };
 
-    let config = match quire.global_config() {
-        Ok(config) => config,
-        Err(e) => {
-            tracing::error!(repo = %event.repo, error = &e as &(dyn std::error::Error + 'static), "failed to load global config");
-            return;
-        }
-    };
+    let config = quire.global_config();
 
     let sentry_dsn = config.sentry.as_ref().and_then(|s| match s.dsn.reveal() {
         Ok(dsn) => Some(dsn.to_string()),
@@ -260,6 +254,7 @@ mod tests {
     use super::*;
     use crate::Quire;
     use crate::event::PushRef;
+    use crate::quire::GlobalConfig;
     use std::path::Path;
 
     fn git_in(cwd: &Path, args: &[&str]) {
@@ -304,7 +299,7 @@ mod tests {
             ],
         );
 
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         // Initialize the database.
         let mut db = crate::db::open(&quire.db_path()).expect("init db");
         crate::db::migrate(&mut db).expect("migrate db");
@@ -330,7 +325,7 @@ mod tests {
             ],
         );
 
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let mut db = crate::db::open(&quire.db_path()).expect("init db");
         crate::db::migrate(&mut db).expect("migrate db");
         drop(db);
@@ -622,7 +617,7 @@ exit 0
     #[test]
     fn trigger_skips_nonexistent_repo() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let event = push_event("no-such.git", "abc123");
         // Should not panic — just logs and returns.
         trigger(&quire, &event);
@@ -631,7 +626,7 @@ exit 0
     #[test]
     fn trigger_skips_repo_not_on_disk() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         // repo name is valid but directory doesn't exist.
         let event = push_event("missing.git", "abc123");
         trigger(&quire, &event);
@@ -640,7 +635,7 @@ exit 0
     #[test]
     fn trigger_skips_invalid_repo_name() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         // Repo name with path traversal — quire.repo() returns Err.
         let event = push_event("../evil.git", "abc123");
         trigger(&quire, &event);
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 929fa5a..6ffc7f8 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -567,7 +567,10 @@ mod tests {
 
     fn tmp_quire() -> (tempfile::TempDir, Quire) {
         let dir = tempfile::tempdir().expect("tempdir");
-        let quire = Quire::new(dir.path().to_path_buf());
+        let quire = Quire::new(
+            dir.path().to_path_buf(),
+            crate::quire::GlobalConfig::default(),
+        );
         // Initialize the database.
         let mut db = crate::db::open(&quire.db_path()).expect("init db");
         crate::db::migrate(&mut db).expect("migrate db");
diff --git a/quire-server/src/error.rs b/quire-server/src/error.rs
index cf07e44..944f6c4 100644
--- a/quire-server/src/error.rs
+++ b/quire-server/src/error.rs
@@ -9,9 +9,6 @@ pub enum Error {
     #[error("io error: {0}")]
     Io(#[from] std::io::Error),
 
-    #[error("config not found: {0}")]
-    ConfigNotFound(String),
-
     #[error(transparent)]
     Repo(#[from] RepoNameError),
 
diff --git a/quire-server/src/lib.rs b/quire-server/src/lib.rs
index b3d9e86..f048738 100644
--- a/quire-server/src/lib.rs
+++ b/quire-server/src/lib.rs
@@ -10,4 +10,4 @@ pub use quire_core::telemetry::SentryConfig;
 pub use error::Error;
 pub use error::RepoNameError;
 pub use error::Result;
-pub use quire::Quire;
+pub use quire::{GlobalConfig, Quire};
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index 32bec81..631957f 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -42,10 +42,11 @@ pub fn trigger(quire: &Quire, event: &PushEvent) -> miette::Result<()> {
         return Err(MirrorError::RepoNotFound(event.repo.clone()).into());
     }
 
-    let config = quire.global_config()?;
+    let config = quire.global_config();
     let Some(mirror_token) = config
         .github
         .mirror_token
+        .as_ref()
         .map(|s| s.reveal().map(str::to_owned))
         .transpose()?
     else {
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 9d15310..25dbf76 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
 pub mod web;
 
 use crate::ci::{Ci, Executor, Runs};
-use crate::{Error, RepoNameError, Result};
+use crate::{RepoNameError, Result};
 pub use quire_core::telemetry::SentryConfig;
 
 use quire_core::fennel::Fennel;
@@ -36,6 +36,29 @@ pub struct GlobalConfig {
     pub github: GlobalGithubConfig,
 }
 
+impl Default for GlobalConfig {
+    fn default() -> Self {
+        Self {
+            sentry: None,
+            secrets: HashMap::new(),
+            port: default_port(),
+            ci: CiConfig::default(),
+            github: GlobalGithubConfig::default(),
+        }
+    }
+}
+
+impl GlobalConfig {
+    /// Load config from `path`. Returns `Self::default()` if the file is absent;
+    /// propagates parse errors.
+    pub fn load(path: &Path) -> Result<Self> {
+        if !path.exists() {
+            return Ok(Self::default());
+        }
+        Ok(Fennel::load_config(path)?)
+    }
+}
+
 /// Global GitHub integration configuration.
 #[derive(serde::Deserialize, Debug, Default)]
 #[serde(rename_all = "kebab-case")]
@@ -209,20 +232,22 @@ pub struct RepoGithubConfig {
 #[derive(Clone)]
 pub struct Quire {
     base_dir: PathBuf,
+    config: Arc<GlobalConfig>,
     db_pool: Arc<OnceLock<Mutex<rusqlite::Connection>>>,
 }
 
 impl Default for Quire {
     fn default() -> Self {
-        Self::new(PathBuf::from("/var/quire"))
+        Self::new(PathBuf::from("/var/quire"), GlobalConfig::default())
     }
 }
 
 impl Quire {
-    /// Create a `Quire` rooted at the given base directory.
-    pub fn new(base_dir: PathBuf) -> Self {
+    /// Create a `Quire` rooted at the given base directory with the given config.
+    pub fn new(base_dir: PathBuf, config: GlobalConfig) -> Self {
         Self {
             base_dir,
+            config: Arc::new(config),
             db_pool: Arc::new(OnceLock::new()),
         }
     }
@@ -258,16 +283,9 @@ impl Quire {
         })
     }
 
-    /// Load and parse the global Fennel config file.
-    ///
-    /// Re-reads on every call. Cheap at current call volume; revisit if
-    /// `quire serve` ends up loading per-request.
-    pub fn global_config(&self) -> Result<GlobalConfig> {
-        let config_path = self.config_path();
-        if !config_path.exists() {
-            return Err(Error::ConfigNotFound(config_path.display().to_string()));
-        }
-        Ok(Fennel::load_config(&config_path)?)
+    /// Return the global configuration loaded at launch.
+    pub fn global_config(&self) -> &GlobalConfig {
+        &self.config
     }
 
     /// Validate a repository name and return its resolved path.
@@ -347,7 +365,7 @@ mod tests {
     #[test]
     fn repos_lists_bare_repos() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let q = Quire::new(dir.path().to_path_buf());
+        let q = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let repos_dir = q.repos_dir();
 
         // Create two bare repos.
@@ -366,7 +384,7 @@ mod tests {
     #[test]
     fn repos_empty_when_no_dirs() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let q = Quire::new(dir.path().to_path_buf());
+        let q = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let repos: Vec<_> = q.repos().expect("repos").collect();
         assert!(repos.is_empty());
     }
@@ -421,7 +439,7 @@ mod tests {
     #[test]
     fn repo_from_path_valid() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let q = Quire::new(dir.path().to_path_buf());
+        let q = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let path = dir.path().join("repos").join("foo.git");
         let repo = q.repo_from_path(&path).expect("should resolve");
         assert_eq!(repo.path(), path);
@@ -430,7 +448,7 @@ mod tests {
     #[test]
     fn repo_from_path_outside_repos() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let q = Quire::new(dir.path().to_path_buf());
+        let q = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let path = PathBuf::from("/tmp/evil.git");
         assert!(q.repo_from_path(&path).is_err());
     }
@@ -438,7 +456,7 @@ mod tests {
     #[test]
     fn repo_from_path_rejects_bad_name() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let q = Quire::new(dir.path().to_path_buf());
+        let q = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
         let path = dir.path().join("repos").join("foo"); // missing .git
         assert!(q.repo_from_path(&path).is_err());
     }
@@ -449,8 +467,7 @@ mod tests {
         let config_path = dir.path().join("config.fnl");
         fs_err::write(&config_path, "{}").expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert_eq!(config.ci.executor, Executor::Process);
         assert_eq!(config.port, 3000);
     }
@@ -461,8 +478,7 @@ mod tests {
         let config_path = dir.path().join("config.fnl");
         fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert_eq!(config.port, 4000);
     }
 
@@ -472,21 +488,19 @@ mod tests {
         let config_path = dir.path().join("config.fnl");
         fs_err::write(&config_path, "{}").expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert!(config.secrets.is_empty());
     }
 
     #[test]
-    fn global_config_missing_file_errors() {
+    fn global_config_missing_file_uses_defaults() {
         let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let err = q.global_config().unwrap_err();
-        assert!(
-            matches!(err, Error::ConfigNotFound(_)),
-            "expected ConfigNotFound, got {err:?}"
-        );
+        let config = GlobalConfig::load(&config_path).expect("missing file should use defaults");
+        assert_eq!(config.port, 3000);
+        assert!(config.sentry.is_none());
+        assert!(config.secrets.is_empty());
     }
 
     #[test]
@@ -499,8 +513,7 @@ mod tests {
         )
         .expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         let sentry = config.sentry.expect("sentry should be present");
         assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
     }
@@ -511,8 +524,7 @@ mod tests {
         let config_path = dir.path().join("config.fnl");
         fs_err::write(&config_path, "{}").expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert!(config.sentry.is_none());
     }
 
@@ -522,8 +534,7 @@ mod tests {
         let config_path = dir.path().join("config.fnl");
         fs_err::write(&config_path, "{}").expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert!(config.secrets.is_empty());
     }
 
@@ -543,8 +554,7 @@ mod tests {
         )
         .expect("write");
 
-        let q = Quire::new(dir.path().to_path_buf());
-        let config = q.global_config().expect("global_config should load");
+        let config = GlobalConfig::load(&config_path).expect("should load");
         assert_eq!(config.secrets.len(), 2);
         assert_eq!(
             config.secrets["github_token"].reveal().unwrap(),
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index 1e4db80..39f3ce3 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -216,13 +216,14 @@ async fn get_secret(
     AxumPath(SecretPath { name }): AxumPath<SecretPath>,
 ) -> Result<axum::Json<serde_json::Value>, ApiError> {
     let value = tokio::task::spawn_blocking(move || -> std::result::Result<String, ApiError> {
-        let config = quire.global_config()?;
-        Ok(config
+        quire
+            .global_config()
             .secrets
             .get(&name)
             .ok_or(ApiError::NotFound)?
-            .reveal()?
-            .to_string())
+            .reveal()
+            .map(|s| s.to_string())
+            .map_err(ApiError::Secret)
     })
     .await
     .expect("blocking task panicked")?;
@@ -246,12 +247,15 @@ mod tests {
 
     impl TestEnv {
         fn new() -> Self {
+            Self::with_config(crate::quire::GlobalConfig::default())
+        }
+
+        fn with_config(config: crate::quire::GlobalConfig) -> Self {
             let dir = tempfile::tempdir().expect("tempdir");
-            let quire = Quire::new(dir.path().to_path_buf());
+            let quire = Quire::new(dir.path().to_path_buf(), config);
             let mut db = crate::db::open(&quire.db_path()).expect("db open");
             crate::db::migrate(&mut db).expect("migrate");
             drop(db);
-            fs_err::write(quire.config_path(), "{}").expect("write config");
             Self { _dir: dir, quire }
         }
 
@@ -366,12 +370,14 @@ mod tests {
 
     #[tokio::test]
     async fn secret_returns_plaintext_value() {
-        let env = TestEnv::new();
-        fs_err::write(
-            env.quire.config_path(),
-            r#"{:secrets {:my_token "hunter2"}}"#,
-        )
-        .expect("write config");
+        let config = {
+            let dir = tempfile::tempdir().expect("tempdir");
+            let config_path = dir.path().join("config.fnl");
+            fs_err::write(&config_path, r#"{:secrets {:my_token "hunter2"}}"#)
+                .expect("write config");
+            crate::quire::GlobalConfig::load(&config_path).expect("load config")
+        };
+        let env = TestEnv::with_config(config);
         let session = ApiSession::new(3000);
         env.runs()
             .create(&TestEnv::meta(), Some(&session))
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
index adb401c..de4cf6e 100644
--- a/quire-server/src/quire/web/handlers.rs
+++ b/quire-server/src/quire/web/handlers.rs
@@ -286,7 +286,7 @@ mod tests {
     use axum::http::{Request, StatusCode};
     use tower::ServiceExt;
 
-    use crate::Quire;
+    use crate::{GlobalConfig, Quire};
 
     /// Build a test axum Router with the CI routes, backed by a tempdir.
     struct TestEnv {
@@ -297,7 +297,7 @@ mod tests {
     impl TestEnv {
         fn new() -> Self {
             let dir = tempfile::tempdir().expect("tempdir");
-            let quire = Quire::new(dir.path().to_path_buf());
+            let quire = Quire::new(dir.path().to_path_buf(), GlobalConfig::default());
 
             // Create repos dir + a bare repo so `quire.repo("example.git")` resolves.
             let repos_dir = quire.repos_dir();