Skip to main content

perfgate_server/
server.rs

1//! Server configuration and bootstrap.
2//!
3//! This module provides the [`ServerConfig`] and [`run_server`] function
4//! for starting the HTTP server.
5
6use 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/// Storage backend type.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StorageBackend {
36    /// In-memory storage (for testing/development)
37    #[default]
38    Memory,
39    /// SQLite persistent storage
40    Sqlite,
41    /// PostgreSQL storage (not yet implemented)
42    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/// API key configuration.
59#[derive(Debug, Clone)]
60pub struct ApiKeyConfig {
61    /// The actual API key string
62    pub key: String,
63    /// Assigned role
64    pub role: Role,
65    /// Project identifier the key is restricted to
66    pub project: String,
67    /// Optional regex to restrict access to specific benchmarks
68    pub benchmark_regex: Option<String>,
69}
70
71/// Server configuration.
72#[derive(Debug, Clone)]
73pub struct ServerConfig {
74    /// Bind address (e.g., "0.0.0.0:8080")
75    pub bind: SocketAddr,
76
77    /// Storage backend type
78    pub storage_backend: StorageBackend,
79
80    /// SQLite database path (when storage_backend is Sqlite)
81    pub sqlite_path: Option<PathBuf>,
82
83    /// PostgreSQL connection URL (when storage_backend is Postgres)
84    pub postgres_url: Option<String>,
85
86    /// Artifact storage URL (e.g., s3://bucket/prefix)
87    pub artifacts_url: Option<String>,
88
89    /// API keys for authentication
90    pub api_keys: Vec<ApiKeyConfig>,
91
92    /// Optional JWT validation settings.
93    pub jwt: Option<JwtConfig>,
94
95    /// Optional OIDC configuration.
96    pub oidc: Option<OidcConfig>,
97
98    /// Enable CORS for all origins
99    pub cors: bool,
100
101    /// Request timeout in seconds
102    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    /// Creates a new configuration with default values.
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Sets the bind address.
129    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    /// Sets the storage backend.
138    pub fn storage_backend(mut self, backend: StorageBackend) -> Self {
139        self.storage_backend = backend;
140        self
141    }
142
143    /// Sets the SQLite database path.
144    pub fn sqlite_path(mut self, path: impl Into<PathBuf>) -> Self {
145        self.sqlite_path = Some(path.into());
146        self
147    }
148
149    /// Sets the PostgreSQL connection URL.
150    pub fn postgres_url(mut self, url: impl Into<String>) -> Self {
151        self.postgres_url = Some(url.into());
152        self
153    }
154
155    /// Sets the artifacts storage URL.
156    pub fn artifacts_url(mut self, url: impl Into<String>) -> Self {
157        self.artifacts_url = Some(url.into());
158        self
159    }
160
161    /// Adds an API key with a specific role.
162    pub fn api_key(self, key: impl Into<String>, role: Role) -> Self {
163        self.scoped_api_key(key, role, "default", None)
164    }
165
166    /// Adds a scoped API key restricted to a project and optional benchmark regex.
167    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    /// Enables JWT token authentication.
184    pub fn jwt(mut self, jwt: JwtConfig) -> Self {
185        self.jwt = Some(jwt);
186        self
187    }
188
189    /// Configures OIDC authentication.
190    pub fn oidc(mut self, config: OidcConfig) -> Self {
191        self.oidc = Some(config);
192        self
193    }
194
195    /// Enables or disables CORS.
196    pub fn cors(mut self, enabled: bool) -> Self {
197        self.cors = enabled;
198        self
199    }
200}
201
202/// Creates the artifact storage based on configuration.
203pub(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
220/// Creates the storage backend based on configuration.
221pub(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
255/// Creates the API key store from configuration.
256pub(crate) async fn create_key_store(
257    config: &ServerConfig,
258) -> Result<Arc<ApiKeyStore>, ConfigError> {
259    let store = ApiKeyStore::new();
260
261    // Add configured API keys
262    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
278/// Creates the router with all routes configured.
279pub(crate) fn create_router(
280    store: Arc<dyn BaselineStore>,
281    auth_state: AuthState,
282    config: &ServerConfig,
283) -> Router {
284    // Health check (no auth required)
285    let health_routes = Router::new().route("/health", get(health_check));
286
287    // Dashboard routes (no auth required for read-only view)
288    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    // API routes that require authentication
294    let api_routes = Router::new()
295        // Baseline CRUD
296        .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    // Combine routes under /api/v1, plus root /health and dashboard
319    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    // Add CORS if enabled
325    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
337/// Runs the HTTP server.
338///
339/// This function starts the server and blocks until shutdown.
340pub 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    // Create storage
348    let store = create_storage(&config).await?;
349
350    // Create key store
351    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    // Create router
364    let app = create_router(store.clone(), auth_state, &config);
365
366    // Add tracing and request ID layers
367    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    // Create listener
376    let listener = tokio::net::TcpListener::bind(config.bind).await?;
377    info!(addr = %config.bind, "Server listening");
378
379    // Run server with graceful shutdown
380    axum::serve(listener, app)
381        .with_graceful_shutdown(shutdown_signal())
382        .await?;
383
384    info!("Server shutdown complete");
385    Ok(())
386}
387
388/// Creates a shutdown signal handler.
389async 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        // Should fail because no Postgres is running
498        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        // Router created successfully
528    }
529}