1use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use axum::{
11 Router, middleware,
12 routing::{delete, get, post},
13};
14use tower::ServiceBuilder;
15use tower_http::{
16 cors::{Any, CorsLayer},
17 request_id::MakeRequestUuid,
18 trace::TraceLayer,
19};
20use tracing::info;
21
22use crate::auth::{ApiKey, ApiKeyStore, AuthState, JwtConfig, Role, auth_middleware};
23use crate::error::ConfigError;
24use crate::handlers::{
25 dashboard_index, delete_baseline, get_baseline, get_latest_baseline, health_check,
26 list_baselines, list_verdicts, promote_baseline, static_asset, submit_verdict, upload_baseline,
27};
28use crate::oidc::{OidcConfig, OidcProvider};
29use crate::storage::{
30 ArtifactStore, BaselineStore, InMemoryStore, ObjectArtifactStore, PostgresStore, SqliteStore,
31};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StorageBackend {
36 #[default]
38 Memory,
39 Sqlite,
41 Postgres,
43}
44
45impl std::str::FromStr for StorageBackend {
46 type Err = String;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s.to_lowercase().as_str() {
50 "memory" => Ok(Self::Memory),
51 "sqlite" => Ok(Self::Sqlite),
52 "postgres" | "postgresql" => Ok(Self::Postgres),
53 _ => Err(format!("Unknown storage backend: {}", s)),
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct ApiKeyConfig {
61 pub key: String,
63 pub role: Role,
65 pub project: String,
67 pub benchmark_regex: Option<String>,
69}
70
71#[derive(Debug, Clone)]
73pub struct ServerConfig {
74 pub bind: SocketAddr,
76
77 pub storage_backend: StorageBackend,
79
80 pub sqlite_path: Option<PathBuf>,
82
83 pub postgres_url: Option<String>,
85
86 pub artifacts_url: Option<String>,
88
89 pub api_keys: Vec<ApiKeyConfig>,
91
92 pub jwt: Option<JwtConfig>,
94
95 pub oidc: Option<OidcConfig>,
97
98 pub cors: bool,
100
101 pub timeout_seconds: u64,
103}
104
105impl Default for ServerConfig {
106 fn default() -> Self {
107 Self {
108 bind: "0.0.0.0:8080".parse().unwrap(),
109 storage_backend: StorageBackend::Memory,
110 sqlite_path: None,
111 postgres_url: None,
112 artifacts_url: None,
113 api_keys: vec![],
114 jwt: None,
115 oidc: None,
116 cors: true,
117 timeout_seconds: 30,
118 }
119 }
120}
121
122impl ServerConfig {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn bind(mut self, addr: impl Into<String>) -> Result<Self, ConfigError> {
130 self.bind = addr
131 .into()
132 .parse()
133 .map_err(|e| ConfigError::InvalidValue(format!("Invalid bind address: {}", e)))?;
134 Ok(self)
135 }
136
137 pub fn storage_backend(mut self, backend: StorageBackend) -> Self {
139 self.storage_backend = backend;
140 self
141 }
142
143 pub fn sqlite_path(mut self, path: impl Into<PathBuf>) -> Self {
145 self.sqlite_path = Some(path.into());
146 self
147 }
148
149 pub fn postgres_url(mut self, url: impl Into<String>) -> Self {
151 self.postgres_url = Some(url.into());
152 self
153 }
154
155 pub fn artifacts_url(mut self, url: impl Into<String>) -> Self {
157 self.artifacts_url = Some(url.into());
158 self
159 }
160
161 pub fn api_key(self, key: impl Into<String>, role: Role) -> Self {
163 self.scoped_api_key(key, role, "default", None)
164 }
165
166 pub fn scoped_api_key(
168 mut self,
169 key: impl Into<String>,
170 role: Role,
171 project: impl Into<String>,
172 benchmark_regex: Option<String>,
173 ) -> Self {
174 self.api_keys.push(ApiKeyConfig {
175 key: key.into(),
176 role,
177 project: project.into(),
178 benchmark_regex,
179 });
180 self
181 }
182
183 pub fn jwt(mut self, jwt: JwtConfig) -> Self {
185 self.jwt = Some(jwt);
186 self
187 }
188
189 pub fn oidc(mut self, config: OidcConfig) -> Self {
191 self.oidc = Some(config);
192 self
193 }
194
195 pub fn cors(mut self, enabled: bool) -> Self {
197 self.cors = enabled;
198 self
199 }
200}
201
202pub(crate) async fn create_artifacts(
204 config: &ServerConfig,
205) -> Result<Option<Arc<dyn ArtifactStore>>, ConfigError> {
206 if let Some(url) = &config.artifacts_url {
207 info!(url = %url, "Using object storage for artifacts");
208 let (store, _path) = object_store::parse_url(
209 &url.parse()
210 .map_err(|e| ConfigError::InvalidValue(format!("Invalid artifacts URL: {}", e)))?,
211 )
212 .map_err(|e| ConfigError::InvalidValue(format!("Failed to parse artifacts URL: {}", e)))?;
213
214 Ok(Some(Arc::new(ObjectArtifactStore::new(Arc::from(store)))))
215 } else {
216 Ok(None)
217 }
218}
219
220pub(crate) async fn create_storage(
222 config: &ServerConfig,
223) -> Result<Arc<dyn BaselineStore>, ConfigError> {
224 let artifacts = create_artifacts(config).await?;
225
226 match config.storage_backend {
227 StorageBackend::Memory => {
228 info!("Using in-memory storage");
229 Ok(Arc::new(InMemoryStore::new()))
230 }
231 StorageBackend::Sqlite => {
232 let path = config
233 .sqlite_path
234 .clone()
235 .unwrap_or_else(|| PathBuf::from("perfgate.db"));
236 info!(path = %path.display(), "Using SQLite storage");
237 let store = SqliteStore::new(&path, artifacts)
238 .map_err(|e| ConfigError::InvalidValue(format!("Failed to open SQLite: {}", e)))?;
239 Ok(Arc::new(store))
240 }
241 StorageBackend::Postgres => {
242 let url = config
243 .postgres_url
244 .clone()
245 .unwrap_or_else(|| "postgres://localhost:5432/perfgate".to_string());
246 info!(url = %url, "Using PostgreSQL storage");
247 let store = PostgresStore::new(&url, artifacts).await.map_err(|e| {
248 ConfigError::InvalidValue(format!("Failed to connect to Postgres: {}", e))
249 })?;
250 Ok(Arc::new(store))
251 }
252 }
253}
254
255pub(crate) async fn create_key_store(
257 config: &ServerConfig,
258) -> Result<Arc<ApiKeyStore>, ConfigError> {
259 let store = ApiKeyStore::new();
260
261 for cfg in &config.api_keys {
263 let mut api_key = ApiKey::new(
264 uuid::Uuid::new_v4().to_string(),
265 format!("{:?} key for {}", cfg.role, cfg.project),
266 cfg.project.clone(),
267 cfg.role,
268 );
269 api_key.benchmark_regex = cfg.benchmark_regex.clone();
270
271 store.add_key(api_key, &cfg.key).await;
272 info!(role = ?cfg.role, project = %cfg.project, "Added API key");
273 }
274
275 Ok(Arc::new(store))
276}
277
278pub(crate) fn create_router(
280 store: Arc<dyn BaselineStore>,
281 auth_state: AuthState,
282 config: &ServerConfig,
283) -> Router {
284 let health_routes = Router::new().route("/health", get(health_check));
286
287 let dashboard_routes = Router::new()
289 .route("/", get(dashboard_index))
290 .route("/index.html", get(dashboard_index))
291 .route("/assets/{*path}", get(static_asset));
292
293 let api_routes = Router::new()
295 .route("/projects/{project}/baselines", post(upload_baseline))
297 .route(
298 "/projects/{project}/baselines/{benchmark}/latest",
299 get(get_latest_baseline),
300 )
301 .route(
302 "/projects/{project}/baselines/{benchmark}/versions/{version}",
303 get(get_baseline),
304 )
305 .route(
306 "/projects/{project}/baselines/{benchmark}/versions/{version}",
307 delete(delete_baseline),
308 )
309 .route("/projects/{project}/baselines", get(list_baselines))
310 .route("/projects/{project}/verdicts", post(submit_verdict))
311 .route("/projects/{project}/verdicts", get(list_verdicts))
312 .route(
313 "/projects/{project}/baselines/{benchmark}/promote",
314 post(promote_baseline),
315 )
316 .layer(middleware::from_fn_with_state(auth_state, auth_middleware));
317
318 let mut app = Router::new()
320 .merge(dashboard_routes)
321 .merge(health_routes.clone())
322 .nest("/api/v1", health_routes.merge(api_routes));
323
324 if config.cors {
326 app = app.layer(
327 CorsLayer::new()
328 .allow_origin(Any)
329 .allow_methods(Any)
330 .allow_headers(Any),
331 );
332 }
333
334 app.with_state(store)
335}
336
337pub async fn run_server(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
341 info!(
342 bind = %config.bind,
343 backend = ?config.storage_backend,
344 "Starting perfgate server"
345 );
346
347 let store = create_storage(&config).await?;
349
350 let key_store = create_key_store(&config).await?;
352
353 let mut oidc_provider = None;
354 if let Some(oidc_cfg) = config.oidc.clone() {
355 let provider = OidcProvider::new(oidc_cfg)
356 .await
357 .map_err(|e| e.to_string())?;
358 oidc_provider = Some(provider);
359 }
360
361 let auth_state = AuthState::new(key_store, config.jwt.clone(), oidc_provider);
362
363 let app = create_router(store.clone(), auth_state, &config);
365
366 let app = app.layer(
368 ServiceBuilder::new()
369 .layer(TraceLayer::new_for_http())
370 .layer(tower_http::request_id::SetRequestIdLayer::x_request_id(
371 MakeRequestUuid,
372 )),
373 );
374
375 let listener = tokio::net::TcpListener::bind(config.bind).await?;
377 info!(addr = %config.bind, "Server listening");
378
379 axum::serve(listener, app)
381 .with_graceful_shutdown(shutdown_signal())
382 .await?;
383
384 info!("Server shutdown complete");
385 Ok(())
386}
387
388async fn shutdown_signal() {
390 use tokio::signal;
391
392 let ctrl_c = async {
393 signal::ctrl_c()
394 .await
395 .expect("Failed to install Ctrl+C handler");
396 };
397
398 #[cfg(unix)]
399 let terminate = async {
400 signal::unix::signal(signal::unix::SignalKind::terminate())
401 .expect("Failed to install signal handler")
402 .recv()
403 .await;
404 };
405
406 #[cfg(not(unix))]
407 let terminate = std::future::pending::<()>();
408
409 tokio::select! {
410 _ = ctrl_c => {},
411 _ = terminate => {},
412 }
413
414 info!("Shutdown signal received");
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_server_config_default() {
423 let config = ServerConfig::new();
424 assert_eq!(config.bind.to_string(), "0.0.0.0:8080");
425 assert_eq!(config.storage_backend, StorageBackend::Memory);
426 }
427
428 #[test]
429 fn test_server_config_builder() {
430 let config = ServerConfig::new()
431 .bind("127.0.0.1:3000")
432 .unwrap()
433 .storage_backend(StorageBackend::Sqlite)
434 .sqlite_path("/tmp/test.db")
435 .api_key("test-key", Role::Admin)
436 .scoped_api_key(
437 "scoped-key",
438 Role::Contributor,
439 "my-proj",
440 Some("^bench-.*$".to_string()),
441 )
442 .jwt(JwtConfig::hs256(b"test-secret".to_vec()).issuer("perfgate"))
443 .cors(false);
444
445 assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
446 assert_eq!(config.storage_backend, StorageBackend::Sqlite);
447 assert_eq!(config.sqlite_path, Some(PathBuf::from("/tmp/test.db")));
448 assert_eq!(config.api_keys.len(), 2);
449 assert_eq!(config.api_keys[1].project, "my-proj");
450 assert_eq!(
451 config.api_keys[1].benchmark_regex,
452 Some("^bench-.*$".to_string())
453 );
454 assert!(config.jwt.is_some());
455 assert!(!config.cors);
456 }
457
458 #[test]
459 fn test_storage_backend_from_str() {
460 assert_eq!(
461 "memory".parse::<StorageBackend>().unwrap(),
462 StorageBackend::Memory
463 );
464 assert_eq!(
465 "sqlite".parse::<StorageBackend>().unwrap(),
466 StorageBackend::Sqlite
467 );
468 assert_eq!(
469 "postgres".parse::<StorageBackend>().unwrap(),
470 StorageBackend::Postgres
471 );
472 assert!("invalid".parse::<StorageBackend>().is_err());
473 }
474
475 #[tokio::test]
476 async fn test_create_storage_memory() {
477 let config = ServerConfig::new().storage_backend(StorageBackend::Memory);
478 let storage = create_storage(&config).await.unwrap();
479 assert_eq!(storage.backend_type(), "memory");
480 }
481
482 #[tokio::test(flavor = "multi_thread")]
483 async fn test_create_storage_sqlite() {
484 let config = ServerConfig::new()
485 .storage_backend(StorageBackend::Sqlite)
486 .sqlite_path(":memory:");
487 let storage = create_storage(&config).await.unwrap();
488 assert_eq!(storage.backend_type(), "sqlite");
489 }
490
491 #[tokio::test]
492 async fn test_create_storage_postgres() {
493 let config = ServerConfig::new()
494 .storage_backend(StorageBackend::Postgres)
495 .postgres_url("postgresql://localhost/test");
496 let result = create_storage(&config).await;
497 assert!(result.is_err());
499 }
500
501 #[tokio::test]
502 async fn test_create_key_store() {
503 let config = ServerConfig::new()
504 .api_key("pg_live_test123456789012345678901234567890", Role::Admin)
505 .scoped_api_key(
506 "pg_live_viewer123456789012345678901234567",
507 Role::Viewer,
508 "project-1",
509 None,
510 );
511
512 let key_store = create_key_store(&config).await.unwrap();
513 let keys = key_store.list_keys().await;
514
515 assert_eq!(keys.len(), 2);
516 let viewer_key = keys.iter().find(|k| k.role == Role::Viewer).unwrap();
517 assert_eq!(viewer_key.project_id, "project-1");
518 }
519
520 #[tokio::test]
521 async fn test_router_creation() {
522 let store = Arc::new(InMemoryStore::new());
523 let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, None);
524 let config = ServerConfig::new();
525
526 let _router = create_router(store, auth_state, &config);
527 }
529}