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.
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();