Add config loading to quire-ci serve
Port the same config setup from quire-server: a QuireCi struct with
base_dir, config.fnl loading via Fennel, and a GlobalConfig with
optional sentry and port (default 3000). The serve command reads
port from config and initializes Sentry + tracing with the same
telemetry pipeline. Added --base-dir flag with QQUIRE_CI_BASE_DIR
env alias.
Assisted-by: Owl Alpha via pi
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 7d5ed03..1839535 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,3 +1,4 @@
+mod quire;
mod server;
mod sink;
@@ -46,6 +47,10 @@ const VERSION: &str = env!("QUIRE_VERSION");
/// Run and validate quire CI pipelines.
#[derive(Facet)]
struct Cli {
+ /// Root directory for quire-ci data (default: /var/quire-ci).
+ #[facet(args::named, args::env_alias = "QUIRE_CI_BASE_DIR")]
+ base_dir: Option<PathBuf>,
+
/// Workspace root containing .quire/ci.fnl. Defaults to cwd.
#[facet(args::named, args::short = 'w', default = ".")]
workspace: PathBuf,
@@ -123,11 +128,7 @@ enum Commands {
},
/// Start the HTTP server.
- Serve {
- /// Port to listen on.
- #[facet(args::named, default = 3000)]
- port: u16,
- },
+ Serve,
}
/// RAII wrapper around a tempdir holding captured sh logs. On drop,
@@ -296,12 +297,16 @@ fn main() -> Result<()> {
match cli.command {
Commands::Validate => validate(workspace),
- Commands::Serve { port } => {
+ Commands::Serve => {
+ let quire = match cli.base_dir {
+ Some(ref dir) => quire::QuireCi::new(dir.clone()),
+ None => quire::QuireCi::default(),
+ };
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.into_diagnostic()?;
- rt.block_on(server::run(port))
+ rt.block_on(server::run(quire))
}
Commands::Run {
events,
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
new file mode 100644
index 0000000..c98d9d9
--- /dev/null
+++ b/quire-ci/src/quire.rs
@@ -0,0 +1,131 @@
+use std::path::{Path, PathBuf};
+
+use miette::{Result, ensure};
+
+use quire_core::fennel::Fennel;
+
+/// Parsed global configuration (`<base-dir>/config.fnl`).
+#[derive(serde::Deserialize, Debug)]
+#[serde(rename_all = "kebab-case")]
+pub struct GlobalConfig {
+ #[serde(default)]
+ pub sentry: Option<SentryConfig>,
+ /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
+ #[serde(default = "default_port")]
+ pub port: u16,
+}
+
+fn default_port() -> u16 {
+ 3000
+}
+
+#[derive(serde::Deserialize, Debug)]
+pub struct SentryConfig {
+ pub dsn: quire_core::secret::SecretString,
+}
+
+/// Application runtime context.
+///
+/// Carries configuration and provides resolved paths.
+#[derive(Clone)]
+pub struct QuireCi {
+ base_dir: PathBuf,
+}
+
+impl Default for QuireCi {
+ fn default() -> Self {
+ Self::new(PathBuf::from("/var/quire-ci"))
+ }
+}
+
+impl QuireCi {
+ pub fn new(base_dir: PathBuf) -> Self {
+ Self { base_dir }
+ }
+
+ #[allow(dead_code)]
+ pub fn base_dir(&self) -> &Path {
+ &self.base_dir
+ }
+
+ pub fn config_path(&self) -> PathBuf {
+ self.base_dir.join("config.fnl")
+ }
+
+ /// Load and parse the global Fennel config file.
+ pub fn global_config(&self) -> Result<GlobalConfig> {
+ let config_path = self.config_path();
+ ensure!(
+ config_path.exists(),
+ "config not found: {}",
+ config_path.display()
+ );
+ let fennel = Fennel::new()?;
+ Ok(fennel.load_file(&config_path)?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn quire() -> QuireCi {
+ QuireCi::default()
+ }
+
+ #[test]
+ fn default_paths() {
+ let q = quire();
+ assert_eq!(q.base_dir(), Path::new("/var/quire-ci"));
+ assert_eq!(q.config_path(), PathBuf::from("/var/quire-ci/config.fnl"));
+ }
+
+ #[test]
+ fn global_config_defaults() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, "{}").expect("write");
+
+ let q = QuireCi::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ assert_eq!(config.port, 3000);
+ }
+
+ #[test]
+ fn global_config_parses_custom_port() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
+
+ let q = QuireCi::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ assert_eq!(config.port, 4000);
+ }
+
+ #[test]
+ fn global_config_missing_file_errors() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let q = QuireCi::new(dir.path().to_path_buf());
+ let err = q.global_config().unwrap_err();
+ assert!(
+ err.to_string().contains("config not found"),
+ "expected config not found error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn global_config_loads_sentry() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(
+ &config_path,
+ r#"{:sentry {:dsn "https://key@sentry.io/123"}}"#,
+ )
+ .expect("write");
+
+ let q = QuireCi::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ let sentry = config.sentry.expect("sentry should be present");
+ assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
+ }
+}
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index 9ac7a0d..88bcfa8 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -3,9 +3,10 @@ use std::net::SocketAddr;
use axum::Router;
use axum::routing::get;
use miette::{IntoDiagnostic, Result};
-use tracing_subscriber::EnvFilter;
-use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt;
-use tracing_subscriber::util::SubscriberInitExt;
+use quire_core::telemetry::{self, FmtMode};
+use sentry::ClientInitGuard;
+
+use crate::quire::QuireCi;
const VERSION: &str = env!("QUIRE_VERSION");
@@ -17,18 +18,44 @@ async fn index() -> String {
format!("quire-ci {VERSION}\n")
}
-pub async fn run(port: u16) -> Result<()> {
- let filter = EnvFilter::builder()
- .with_env_var("QUIRE_LOG")
- .from_env()
- .into_diagnostic()?;
+/// Initialize Sentry if the global config provides a DSN.
+fn init_sentry(quire: &QuireCi) -> Option<ClientInitGuard> {
+ let config = match quire.global_config() {
+ Ok(config) => config,
+ Err(e) => {
+ tracing::warn!(
+ error = %e,
+ "failed to load global config, skipping Sentry init"
+ );
+ return None;
+ }
+ };
+
+ let sentry_config = config.sentry.as_ref()?;
+ let dsn = match sentry_config.dsn.reveal() {
+ Ok(dsn) => dsn,
+ Err(e) => {
+ tracing::warn!(
+ error = &e as &(dyn std::error::Error + 'static),
+ "failed to resolve Sentry DSN, skipping Sentry init"
+ );
+ return None;
+ }
+ };
- let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
+ Some(sentry::init((
+ dsn,
+ telemetry::sentry_client_options(VERSION),
+ )))
+}
+
+pub async fn run(quire: QuireCi) -> Result<()> {
+ let config = quire.global_config().ok();
+ let port = config.as_ref().map(|c| c.port).unwrap_or(3000);
- tracing_subscriber::registry()
- .with(fmt_layer)
- .with(filter)
- .init();
+ let _sentry = init_sentry(&quire);
+ let miette_layer = telemetry::MietteLayer::new();
+ let _tracing_guard = telemetry::init_tracing(miette_layer, FmtMode::AutoJson)?;
let app = Router::new()
.route("/health", get(health))
@@ -43,7 +70,5 @@ pub async fn run(port: u16) -> Result<()> {
axum::serve(listener, app).await.into_diagnostic()?;
- tracing::info!(version = %VERSION, "server shutdown complete");
-
Ok(())
}