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;
9use std::time::Duration;
10
11use axum::{
12    Router, middleware,
13    routing::{delete, get, post},
14};
15use chrono::{DateTime, Utc};
16use tower::ServiceBuilder;
17use tower_http::{
18    cors::{Any, CorsLayer},
19    request_id::MakeRequestUuid,
20    trace::TraceLayer,
21};
22use tracing::info;
23
24use crate::auth::{
25    ApiKey, ApiKeyStore, AuthState, JwtConfig, Role, auth_middleware, local_mode_auth_middleware,
26};
27use crate::cleanup::spawn_cleanup_task;
28use crate::error::ConfigError;
29use crate::handlers::{
30    DefaultRetentionDays, admin_cleanup, create_key, dashboard_index, delete_baseline,
31    dependency_impact, get_baseline, get_latest_baseline, get_trend, health_check, latest_decision,
32    list_audit_events, list_baselines, list_decisions, list_fleet_alerts, list_keys, list_verdicts,
33    promote_baseline, prune_decisions, record_dependency_event, revoke_key, static_asset,
34    submit_verdict, upload_baseline, upload_decision,
35};
36use crate::metrics::{metrics_handler, metrics_middleware, setup_metrics_recorder};
37use crate::oidc::{OidcConfig, OidcProvider, OidcRegistry};
38use crate::storage::fleet::{FleetStore, InMemoryFleetStore};
39use crate::storage::{
40    ArtifactStore, AuditStore, BaselineStore, InMemoryKeyStore, InMemoryStore, KeyStore,
41    ObjectArtifactStore, PostgresStore, SqliteKeyStore, SqliteStore,
42    open_configured_sqlite_connection,
43};
44use metrics_exporter_prometheus::PrometheusHandle;
45
46/// Shared application state holding all stores.
47#[derive(Clone)]
48pub struct AppState {
49    /// Baseline/verdict store
50    pub store: Arc<dyn BaselineStore>,
51    /// Audit event store
52    pub audit: Arc<dyn AuditStore>,
53}
54
55/// Storage backend type.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum StorageBackend {
58    /// In-memory storage (for testing/development)
59    #[default]
60    Memory,
61    /// SQLite persistent storage
62    Sqlite,
63    /// PostgreSQL storage (not yet implemented)
64    Postgres,
65}
66
67impl std::str::FromStr for StorageBackend {
68    type Err = String;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        match s.to_lowercase().as_str() {
72            "memory" => Ok(Self::Memory),
73            "sqlite" => Ok(Self::Sqlite),
74            "postgres" | "postgresql" => Ok(Self::Postgres),
75            _ => Err(format!("Unknown storage backend: {}", s)),
76        }
77    }
78}
79
80/// Configuration for PostgreSQL connection pool tuning.
81///
82/// These parameters control how sqlx manages the underlying connection pool.
83/// All fields have sensible defaults suitable for moderate production load.
84///
85/// # Recommended PostgreSQL server settings
86///
87/// For production workloads, tune these `postgresql.conf` knobs alongside the
88/// pool parameters:
89///
90/// | PostgreSQL setting      | Recommended value | Notes                                    |
91/// |-------------------------|-------------------|------------------------------------------|
92/// | `max_connections`       | 100 - 200         | Must exceed pool `max_connections`        |
93/// | `shared_buffers`        | 25% of RAM        | Main query cache                         |
94/// | `work_mem`              | 4 - 16 MB         | Per-sort/hash memory                     |
95/// | `idle_in_transaction_session_timeout` | 30s  | Kill idle-in-transaction sessions        |
96/// | `statement_timeout`     | 30s               | Server-side query timeout                |
97/// | `tcp_keepalives_idle`   | 60                | Seconds before TCP keepalive probes      |
98/// | `tcp_keepalives_interval` | 10              | Seconds between probes                   |
99/// | `tcp_keepalives_count`  | 6                 | Failed probes before disconnect           |
100#[derive(Debug, Clone)]
101pub struct PostgresPoolConfig {
102    /// Maximum number of connections in the pool (default: 10).
103    pub max_connections: u32,
104    /// Minimum number of idle connections to maintain (default: 2).
105    pub min_connections: u32,
106    /// Time a connection may sit idle before being closed (default: 300s).
107    pub idle_timeout: Duration,
108    /// Maximum lifetime of a connection before it is recycled (default: 1800s).
109    pub max_lifetime: Duration,
110    /// How long to wait when acquiring a connection from the pool (default: 5s).
111    pub acquire_timeout: Duration,
112    /// Statement timeout set on each new connection via `SET statement_timeout`
113    /// (default: 30s). Prevents runaway queries.
114    pub statement_timeout: Duration,
115}
116
117impl Default for PostgresPoolConfig {
118    fn default() -> Self {
119        Self {
120            max_connections: 10,
121            min_connections: 2,
122            idle_timeout: Duration::from_secs(300),
123            max_lifetime: Duration::from_secs(1800),
124            acquire_timeout: Duration::from_secs(5),
125            statement_timeout: Duration::from_secs(30),
126        }
127    }
128}
129
130/// API key configuration.
131#[derive(Debug, Clone)]
132pub struct ApiKeyConfig {
133    /// Optional stable identifier for the key.
134    pub id: Option<String>,
135    /// Optional human-readable name/description.
136    pub name: Option<String>,
137    /// The actual API key string
138    pub key: String,
139    /// Assigned role
140    pub role: Role,
141    /// Project identifier the key is restricted to
142    pub project: String,
143    /// Optional regex to restrict access to specific benchmarks
144    pub benchmark_regex: Option<String>,
145    /// Optional expiration timestamp.
146    pub expires_at: Option<DateTime<Utc>>,
147}
148
149/// Optional stable metadata preserved for externally loaded API keys.
150#[derive(Debug, Clone, Default)]
151pub struct ApiKeyMetadata {
152    pub id: Option<String>,
153    pub name: Option<String>,
154    pub expires_at: Option<DateTime<Utc>>,
155}
156
157/// Server configuration.
158#[derive(Debug, Clone)]
159pub struct ServerConfig {
160    /// Bind address (e.g., "0.0.0.0:8080")
161    pub bind: SocketAddr,
162
163    /// Storage backend type
164    pub storage_backend: StorageBackend,
165
166    /// SQLite database path (when storage_backend is Sqlite)
167    pub sqlite_path: Option<PathBuf>,
168
169    /// PostgreSQL connection URL (when storage_backend is Postgres)
170    pub postgres_url: Option<String>,
171
172    /// PostgreSQL connection pool configuration
173    pub postgres_pool: PostgresPoolConfig,
174
175    /// Artifact storage URL (e.g., s3://bucket/prefix)
176    pub artifacts_url: Option<String>,
177
178    /// API keys for authentication
179    pub api_keys: Vec<ApiKeyConfig>,
180
181    /// Optional JWT validation settings.
182    pub jwt: Option<JwtConfig>,
183
184    /// OIDC provider configurations (GitHub, GitLab, custom).
185    pub oidc_configs: Vec<OidcConfig>,
186
187    /// Enable CORS for all origins
188    pub cors: bool,
189
190    /// Request timeout in seconds
191    pub timeout_seconds: u64,
192
193    /// Local mode: disable authentication for single-user local use.
194    pub local_mode: bool,
195
196    /// Artifact retention period in days (0 = no cleanup).
197    pub retention_days: u64,
198
199    /// Interval between background cleanup passes (in hours).
200    pub cleanup_interval_hours: u64,
201}
202
203impl Default for ServerConfig {
204    fn default() -> Self {
205        Self {
206            bind: "0.0.0.0:8080".parse().unwrap(),
207            storage_backend: StorageBackend::Memory,
208            sqlite_path: None,
209            postgres_url: None,
210            postgres_pool: PostgresPoolConfig::default(),
211            artifacts_url: None,
212            api_keys: vec![],
213            jwt: None,
214            oidc_configs: vec![],
215            cors: true,
216            timeout_seconds: 30,
217            local_mode: false,
218            retention_days: 0,
219            cleanup_interval_hours: 1,
220        }
221    }
222}
223
224impl ServerConfig {
225    /// Creates a new configuration with default values.
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Sets the bind address.
231    pub fn bind(mut self, addr: impl Into<String>) -> Result<Self, ConfigError> {
232        self.bind = addr
233            .into()
234            .parse()
235            .map_err(|e| ConfigError::InvalidValue(format!("Invalid bind address: {}", e)))?;
236        Ok(self)
237    }
238
239    /// Sets the storage backend.
240    pub fn storage_backend(mut self, backend: StorageBackend) -> Self {
241        self.storage_backend = backend;
242        self
243    }
244
245    /// Sets the SQLite database path.
246    pub fn sqlite_path(mut self, path: impl Into<PathBuf>) -> Self {
247        self.sqlite_path = Some(path.into());
248        self
249    }
250
251    /// Sets the PostgreSQL connection URL.
252    pub fn postgres_url(mut self, url: impl Into<String>) -> Self {
253        self.postgres_url = Some(url.into());
254        self
255    }
256
257    /// Sets the PostgreSQL connection pool configuration.
258    pub fn postgres_pool(mut self, pool_config: PostgresPoolConfig) -> Self {
259        self.postgres_pool = pool_config;
260        self
261    }
262
263    /// Sets the artifacts storage URL.
264    pub fn artifacts_url(mut self, url: impl Into<String>) -> Self {
265        self.artifacts_url = Some(url.into());
266        self
267    }
268
269    /// Adds an API key with a specific role.
270    pub fn api_key(self, key: impl Into<String>, role: Role) -> Self {
271        self.scoped_api_key(key, role, "default", None)
272    }
273
274    /// Adds a scoped API key restricted to a project and optional benchmark regex.
275    pub fn scoped_api_key(
276        self,
277        key: impl Into<String>,
278        role: Role,
279        project: impl Into<String>,
280        benchmark_regex: Option<String>,
281    ) -> Self {
282        self.scoped_api_key_with_metadata(
283            key,
284            role,
285            project,
286            benchmark_regex,
287            ApiKeyMetadata::default(),
288        )
289    }
290
291    /// Adds a scoped API key with optional stable metadata preserved from an external source.
292    pub fn scoped_api_key_with_metadata(
293        mut self,
294        key: impl Into<String>,
295        role: Role,
296        project: impl Into<String>,
297        benchmark_regex: Option<String>,
298        metadata: ApiKeyMetadata,
299    ) -> Self {
300        self.api_keys.push(ApiKeyConfig {
301            id: metadata.id,
302            name: metadata.name,
303            key: key.into(),
304            role,
305            project: project.into(),
306            benchmark_regex,
307            expires_at: metadata.expires_at,
308        });
309        self
310    }
311
312    /// Enables JWT token authentication.
313    pub fn jwt(mut self, jwt: JwtConfig) -> Self {
314        self.jwt = Some(jwt);
315        self
316    }
317
318    /// Adds an OIDC provider configuration.
319    ///
320    /// Multiple providers can be registered (e.g. GitHub + GitLab).
321    pub fn oidc(mut self, config: OidcConfig) -> Self {
322        self.oidc_configs.push(config);
323        self
324    }
325
326    /// Enables or disables CORS.
327    pub fn cors(mut self, enabled: bool) -> Self {
328        self.cors = enabled;
329        self
330    }
331
332    /// Enables or disables local mode (no authentication).
333    pub fn local_mode(mut self, enabled: bool) -> Self {
334        self.local_mode = enabled;
335        self
336    }
337
338    /// Sets the artifact retention period in days. 0 means no cleanup.
339    pub fn retention_days(mut self, days: u64) -> Self {
340        self.retention_days = days;
341        self
342    }
343
344    /// Sets the interval (in hours) between background cleanup passes.
345    pub fn cleanup_interval_hours(mut self, hours: u64) -> Self {
346        self.cleanup_interval_hours = hours;
347        self
348    }
349}
350
351/// Creates the artifact storage based on configuration.
352pub(crate) async fn create_artifacts(
353    config: &ServerConfig,
354) -> Result<Option<Arc<dyn ArtifactStore>>, ConfigError> {
355    if let Some(url) = &config.artifacts_url {
356        info!(url = %url, "Using object storage for artifacts");
357        let (store, _path) = object_store::parse_url(
358            &url.parse()
359                .map_err(|e| ConfigError::InvalidValue(format!("Invalid artifacts URL: {}", e)))?,
360        )
361        .map_err(|e| ConfigError::InvalidValue(format!("Failed to parse artifacts URL: {}", e)))?;
362
363        Ok(Some(Arc::new(ObjectArtifactStore::new(Arc::from(store)))))
364    } else {
365        Ok(None)
366    }
367}
368
369/// Creates the storage backend based on configuration.
370///
371/// Returns both a `BaselineStore` and an `AuditStore` backed by the same backend.
372#[cfg(any(test, feature = "test-utils"))]
373pub(crate) async fn create_storage(
374    config: &ServerConfig,
375) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
376    let artifacts = create_artifacts(config).await?;
377    create_storage_with_artifacts(config, artifacts).await
378}
379
380/// Creates the storage backend with a pre-built artifact store.
381pub(crate) async fn create_storage_with_artifacts(
382    config: &ServerConfig,
383    artifacts: Option<Arc<dyn ArtifactStore>>,
384) -> Result<(Arc<dyn BaselineStore>, Arc<dyn AuditStore>), ConfigError> {
385    match config.storage_backend {
386        StorageBackend::Memory => {
387            info!("Using in-memory storage");
388            let store = Arc::new(InMemoryStore::new());
389            Ok((store.clone(), store))
390        }
391        StorageBackend::Sqlite => {
392            let path = config
393                .sqlite_path
394                .clone()
395                .unwrap_or_else(|| PathBuf::from("perfgate.db"));
396            info!(path = %path.display(), "Using SQLite storage");
397            let store = SqliteStore::new(&path, artifacts)
398                .map_err(|e| ConfigError::InvalidValue(format!("Failed to open SQLite: {}", e)))?;
399            let store = Arc::new(store);
400            Ok((store.clone(), store))
401        }
402        StorageBackend::Postgres => {
403            let url = config
404                .postgres_url
405                .clone()
406                .unwrap_or_else(|| "postgres://localhost:5432/perfgate".to_string());
407            info!(url = %url, "Using PostgreSQL storage");
408            let store = PostgresStore::new(&url, artifacts, &config.postgres_pool)
409                .await
410                .map_err(|e| {
411                    ConfigError::InvalidValue(format!("Failed to connect to Postgres: {}", e))
412                })?;
413            let store = Arc::new(store);
414            Ok((store.clone(), store))
415        }
416    }
417}
418
419/// Creates the in-memory API key store from CLI configuration.
420pub(crate) async fn create_key_store(
421    config: &ServerConfig,
422) -> Result<Arc<ApiKeyStore>, ConfigError> {
423    let store = ApiKeyStore::new();
424
425    // Add configured API keys
426    for cfg in &config.api_keys {
427        let key_id = cfg
428            .id
429            .clone()
430            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
431        let mut api_key = ApiKey::new(
432            key_id.clone(),
433            cfg.name
434                .clone()
435                .or_else(|| cfg.id.clone())
436                .unwrap_or_else(|| format!("{:?} key for {}", cfg.role, cfg.project)),
437            cfg.project.clone(),
438            cfg.role,
439        );
440        api_key.benchmark_regex = cfg.benchmark_regex.clone();
441        api_key.expires_at = cfg.expires_at;
442
443        store.add_key(api_key, &cfg.key).await;
444        info!(key_id = %key_id, role = ?cfg.role, project = %cfg.project, "Added API key");
445    }
446
447    Ok(Arc::new(store))
448}
449
450/// Creates the persistent key store based on the storage backend.
451pub(crate) fn create_persistent_key_store(
452    config: &ServerConfig,
453    sqlite_conn: Option<Arc<std::sync::Mutex<rusqlite::Connection>>>,
454) -> Result<Arc<dyn KeyStore>, ConfigError> {
455    match config.storage_backend {
456        StorageBackend::Sqlite => {
457            if let Some(conn) = sqlite_conn {
458                let store = SqliteKeyStore::new(conn).map_err(|e| {
459                    ConfigError::InvalidValue(format!("Failed to create SQLite key store: {}", e))
460                })?;
461                info!("Using SQLite persistent key store");
462                Ok(Arc::new(store))
463            } else {
464                info!("Using in-memory key store (no SQLite connection available)");
465                Ok(Arc::new(InMemoryKeyStore::new()))
466            }
467        }
468        _ => {
469            info!("Using in-memory key store");
470            Ok(Arc::new(InMemoryKeyStore::new()))
471        }
472    }
473}
474
475/// Creates the fleet store (currently always in-memory).
476pub(crate) fn create_fleet_store() -> Arc<dyn FleetStore> {
477    Arc::new(InMemoryFleetStore::new())
478}
479
480/// Creates the router with all routes configured.
481pub(crate) fn create_router(
482    state: AppState,
483    persistent_key_store: Arc<dyn KeyStore>,
484    fleet_store: Arc<dyn FleetStore>,
485    artifact_store: Option<Arc<dyn ArtifactStore>>,
486    auth_state: AuthState,
487    config: &ServerConfig,
488    prometheus_handle: Option<PrometheusHandle>,
489) -> Router {
490    let local_mode = config.local_mode;
491
492    // Health check (no auth required)
493    let health_routes = Router::new().route("/health", get(health_check));
494
495    // Info endpoint: exposes local_mode flag for the dashboard.
496    let info_routes = Router::new().route(
497        "/info",
498        get(move || async move { axum::Json(serde_json::json!({ "local_mode": local_mode })) }),
499    );
500
501    // Dashboard routes (no auth required for read-only view)
502    let dashboard_routes = Router::new()
503        .route("/", get(dashboard_index))
504        .route("/index.html", get(dashboard_index))
505        .route("/assets/{*path}", get(static_asset));
506
507    // Key management routes (admin-only, using the persistent key store as state)
508    let key_routes = Router::new()
509        .route("/keys", post(create_key))
510        .route("/keys", get(list_keys))
511        .route("/keys/{id}", delete(revoke_key))
512        .layer(axum::Extension(persistent_key_store))
513        .layer(middleware::from_fn_with_state(
514            auth_state.clone(),
515            auth_middleware,
516        ))
517        .with_state(state.clone());
518
519    // Admin routes (require admin auth, never skipped even in local mode)
520    let admin_routes = Router::new()
521        .route("/admin/cleanup", delete(admin_cleanup))
522        .layer(axum::Extension(artifact_store.clone()))
523        .layer(axum::Extension(DefaultRetentionDays(config.retention_days)))
524        .layer(middleware::from_fn_with_state(
525            auth_state.clone(),
526            auth_middleware,
527        ));
528
529    // Fleet routes (cross-project, no per-project auth scope)
530    let fleet_routes = Router::new()
531        .route("/fleet/dependency-event", post(record_dependency_event))
532        .route("/fleet/alerts", get(list_fleet_alerts))
533        .route(
534            "/fleet/dependency/{dep_name}/impact",
535            get(dependency_impact),
536        )
537        .with_state(fleet_store);
538
539    // API routes — auth middleware is skipped in local mode.
540    let api_routes_inner = Router::new()
541        // Baseline CRUD
542        .route("/projects/{project}/baselines", post(upload_baseline))
543        .route(
544            "/projects/{project}/baselines/{benchmark}/latest",
545            get(get_latest_baseline),
546        )
547        .route(
548            "/projects/{project}/baselines/{benchmark}/versions/{version}",
549            get(get_baseline),
550        )
551        .route(
552            "/projects/{project}/baselines/{benchmark}/versions/{version}",
553            delete(delete_baseline),
554        )
555        .route("/projects/{project}/baselines", get(list_baselines))
556        .route("/projects/{project}/verdicts", post(submit_verdict))
557        .route("/projects/{project}/verdicts", get(list_verdicts))
558        .route("/projects/{project}/decisions", post(upload_decision))
559        .route("/projects/{project}/decisions", get(list_decisions))
560        .route("/projects/{project}/decisions/latest", get(latest_decision))
561        .route("/projects/{project}/decisions/prune", post(prune_decisions))
562        .route(
563            "/projects/{project}/baselines/{benchmark}/promote",
564            post(promote_baseline),
565        )
566        .route(
567            "/projects/{project}/baselines/{benchmark}/trend",
568            get(get_trend),
569        )
570        // Audit log
571        .route("/audit", get(list_audit_events));
572
573    let api_routes = if config.local_mode {
574        api_routes_inner.layer(middleware::from_fn(local_mode_auth_middleware))
575    } else {
576        api_routes_inner.layer(middleware::from_fn_with_state(auth_state, auth_middleware))
577    };
578
579    // Combine routes under /api/v1, plus root /health and dashboard
580    let mut app = Router::new()
581        .merge(dashboard_routes)
582        .merge(health_routes.clone())
583        .nest(
584            "/api/v1",
585            health_routes
586                .merge(info_routes)
587                .merge(api_routes)
588                .merge(key_routes)
589                .merge(admin_routes)
590                .merge(fleet_routes),
591        );
592
593    // Add /metrics endpoint if Prometheus handle is available
594    if let Some(handle) = prometheus_handle {
595        let metrics_routes = Router::new()
596            .route("/metrics", get(metrics_handler))
597            .with_state(handle);
598        app = app.merge(metrics_routes);
599        // Apply metrics middleware to all routes
600        app = app.layer(middleware::from_fn(metrics_middleware));
601    }
602
603    // Add CORS if enabled
604    if config.cors {
605        app = app.layer(
606            CorsLayer::new()
607                .allow_origin(Any)
608                .allow_methods(Any)
609                .allow_headers(Any),
610        );
611    }
612
613    app.with_state(state)
614}
615
616/// Runs the HTTP server.
617///
618/// This function starts the server and blocks until shutdown.
619pub async fn run_server(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
620    info!(
621        bind = %config.bind,
622        backend = ?config.storage_backend,
623        retention_days = config.retention_days,
624        "Starting perfgate server"
625    );
626
627    // Create artifact store (shared between baseline store and cleanup)
628    let artifact_store = create_artifacts(&config).await?;
629
630    // Create storage
631    let (store, audit) = create_storage_with_artifacts(&config, artifact_store.clone()).await?;
632
633    // Create the persistent key store (shares SQLite connection when applicable)
634    let sqlite_conn = if config.storage_backend == StorageBackend::Sqlite {
635        let path = config
636            .sqlite_path
637            .clone()
638            .unwrap_or_else(|| PathBuf::from("perfgate.db"));
639        let conn = open_configured_sqlite_connection(&path).map_err(|e| {
640            ConfigError::InvalidValue(format!("Failed to open SQLite for key store: {}", e))
641        })?;
642        Some(Arc::new(std::sync::Mutex::new(conn)))
643    } else {
644        None
645    };
646    let persistent_key_store = create_persistent_key_store(&config, sqlite_conn)?;
647
648    // Create in-memory key store (for CLI-provided keys)
649    let key_store = create_key_store(&config).await?;
650
651    let mut oidc_registry = OidcRegistry::new();
652    for oidc_cfg in &config.oidc_configs {
653        let provider = OidcProvider::new(oidc_cfg.clone())
654            .await
655            .map_err(|e| e.to_string())?;
656        oidc_registry.add(provider);
657    }
658
659    let auth_state = AuthState::new(key_store, config.jwt.clone(), oidc_registry)
660        .with_persistent_key_store(persistent_key_store.clone());
661
662    // Spawn background cleanup task if retention is configured
663    let cleanup_handle = if config.retention_days > 0 {
664        if let Some(ref art_store) = artifact_store {
665            info!(
666                retention_days = config.retention_days,
667                interval_hours = config.cleanup_interval_hours,
668                "Spawning background artifact cleanup task"
669            );
670            Some(spawn_cleanup_task(
671                art_store.clone(),
672                config.retention_days,
673                config.cleanup_interval_hours,
674            ))
675        } else {
676            info!(
677                "Retention policy configured but no artifact store available; skipping background cleanup"
678            );
679            None
680        }
681    } else {
682        None
683    };
684
685    // Install Prometheus metrics recorder
686    let prometheus_handle = setup_metrics_recorder();
687    info!("Prometheus metrics enabled at /metrics");
688
689    let app_state = AppState { store, audit };
690
691    // Create fleet store
692    let fleet_store = create_fleet_store();
693
694    // Create router
695    let app = create_router(
696        app_state,
697        persistent_key_store.clone(),
698        fleet_store,
699        artifact_store,
700        auth_state,
701        &config,
702        Some(prometheus_handle),
703    );
704
705    // Add tracing and request ID layers
706    let app = app.layer(
707        ServiceBuilder::new()
708            .layer(TraceLayer::new_for_http())
709            .layer(tower_http::request_id::SetRequestIdLayer::x_request_id(
710                MakeRequestUuid,
711            )),
712    );
713
714    // Create listener
715    let listener = tokio::net::TcpListener::bind(config.bind).await?;
716    info!(addr = %config.bind, "Server listening");
717
718    // Run server with graceful shutdown
719    axum::serve(listener, app)
720        .with_graceful_shutdown(shutdown_signal())
721        .await?;
722
723    // Abort cleanup task on shutdown
724    if let Some(handle) = cleanup_handle {
725        handle.abort();
726    }
727
728    info!("Server shutdown complete");
729    Ok(())
730}
731
732/// Creates a shutdown signal handler.
733async fn shutdown_signal() {
734    use tokio::signal;
735
736    let ctrl_c = async {
737        signal::ctrl_c()
738            .await
739            .expect("Failed to install Ctrl+C handler");
740    };
741
742    #[cfg(unix)]
743    let terminate = async {
744        signal::unix::signal(signal::unix::SignalKind::terminate())
745            .expect("Failed to install signal handler")
746            .recv()
747            .await;
748    };
749
750    #[cfg(not(unix))]
751    let terminate = std::future::pending::<()>();
752
753    tokio::select! {
754        _ = ctrl_c => {},
755        _ = terminate => {},
756    }
757
758    info!("Shutdown signal received");
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn test_server_config_default() {
767        let config = ServerConfig::new();
768        assert_eq!(config.bind.to_string(), "0.0.0.0:8080");
769        assert_eq!(config.storage_backend, StorageBackend::Memory);
770    }
771
772    #[test]
773    fn test_server_config_builder() {
774        let config = ServerConfig::new()
775            .bind("127.0.0.1:3000")
776            .unwrap()
777            .storage_backend(StorageBackend::Sqlite)
778            .sqlite_path("/tmp/test.db")
779            .api_key("test-key", Role::Admin)
780            .scoped_api_key(
781                "scoped-key",
782                Role::Contributor,
783                "my-proj",
784                Some("^bench-.*$".to_string()),
785            )
786            .jwt(JwtConfig::hs256(b"test-secret".to_vec()).issuer("perfgate"))
787            .cors(false);
788
789        assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
790        assert_eq!(config.storage_backend, StorageBackend::Sqlite);
791        assert_eq!(config.sqlite_path, Some(PathBuf::from("/tmp/test.db")));
792        assert_eq!(config.api_keys.len(), 2);
793        assert_eq!(config.api_keys[1].project, "my-proj");
794        assert_eq!(
795            config.api_keys[1].benchmark_regex,
796            Some("^bench-.*$".to_string())
797        );
798        assert!(config.jwt.is_some());
799        assert!(!config.cors);
800    }
801
802    #[test]
803    fn test_storage_backend_from_str() {
804        assert_eq!(
805            "memory".parse::<StorageBackend>().unwrap(),
806            StorageBackend::Memory
807        );
808        assert_eq!(
809            "sqlite".parse::<StorageBackend>().unwrap(),
810            StorageBackend::Sqlite
811        );
812        assert_eq!(
813            "postgres".parse::<StorageBackend>().unwrap(),
814            StorageBackend::Postgres
815        );
816        assert!("invalid".parse::<StorageBackend>().is_err());
817    }
818
819    #[tokio::test]
820    async fn test_create_storage_memory() {
821        let config = ServerConfig::new().storage_backend(StorageBackend::Memory);
822        let (storage, _audit) = create_storage(&config).await.unwrap();
823        assert_eq!(storage.backend_type(), "memory");
824    }
825
826    #[tokio::test(flavor = "multi_thread")]
827    async fn test_create_storage_sqlite() {
828        let config = ServerConfig::new()
829            .storage_backend(StorageBackend::Sqlite)
830            .sqlite_path(":memory:");
831        let (storage, _audit) = create_storage(&config).await.unwrap();
832        assert_eq!(storage.backend_type(), "sqlite");
833    }
834
835    #[tokio::test]
836    async fn test_create_storage_postgres() {
837        let config = ServerConfig::new()
838            .storage_backend(StorageBackend::Postgres)
839            .postgres_url("postgresql://localhost/test");
840        let result = create_storage(&config).await;
841        // Should fail because no Postgres is running
842        assert!(result.is_err());
843    }
844
845    #[tokio::test]
846    async fn test_create_key_store() {
847        let config = ServerConfig::new()
848            .api_key("pg_live_test123456789012345678901234567890", Role::Admin)
849            .scoped_api_key(
850                "pg_live_viewer123456789012345678901234567",
851                Role::Viewer,
852                "project-1",
853                None,
854            );
855
856        let key_store = create_key_store(&config).await.unwrap();
857        let keys = key_store.list_keys().await;
858
859        assert_eq!(keys.len(), 2);
860        let viewer_key = keys.iter().find(|k| k.role == Role::Viewer).unwrap();
861        assert_eq!(viewer_key.project_id, "project-1");
862    }
863
864    #[tokio::test]
865    async fn test_create_key_store_preserves_external_metadata() {
866        let expires_at = Utc::now() + chrono::Duration::hours(1);
867        let config = ServerConfig::new().scoped_api_key_with_metadata(
868            "pg_live_external123456789012345678901234",
869            Role::Contributor,
870            "project-2",
871            Some("^bench-.*$".to_string()),
872            ApiKeyMetadata {
873                id: Some("external-key-1".to_string()),
874                name: Some("external-key-1".to_string()),
875                expires_at: Some(expires_at),
876            },
877        );
878
879        let key_store = create_key_store(&config).await.unwrap();
880        let keys = key_store.list_keys().await;
881
882        assert_eq!(keys.len(), 1);
883        assert_eq!(keys[0].id, "external-key-1");
884        assert_eq!(keys[0].name, "external-key-1");
885        assert_eq!(keys[0].expires_at, Some(expires_at));
886        assert_eq!(keys[0].benchmark_regex.as_deref(), Some("^bench-.*$"));
887    }
888
889    #[tokio::test]
890    async fn test_router_creation() {
891        let store = Arc::new(InMemoryStore::new());
892        let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
893        let fleet_store = create_fleet_store();
894        let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
895        let config = ServerConfig::new();
896        let app_state = AppState {
897            store: store.clone(),
898            audit: store,
899        };
900
901        let _router = create_router(
902            app_state,
903            persistent_key_store,
904            fleet_store,
905            None,
906            auth_state,
907            &config,
908            None,
909        );
910        // Router created successfully
911    }
912
913    #[tokio::test]
914    async fn test_router_local_mode_injects_auth_context_for_api_routes() {
915        let store = Arc::new(InMemoryStore::new());
916        let persistent_key_store: Arc<dyn KeyStore> = Arc::new(InMemoryKeyStore::new());
917        let fleet_store = create_fleet_store();
918        let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
919        let config = ServerConfig::new().local_mode(true);
920        let app_state = AppState {
921            store: store.clone(),
922            audit: store,
923        };
924
925        let router = create_router(
926            app_state,
927            persistent_key_store,
928            fleet_store,
929            None,
930            auth_state,
931            &config,
932            None,
933        );
934
935        let response = tower::ServiceExt::oneshot(
936            router,
937            axum::http::Request::builder()
938                .uri("/api/v1/projects/test/baselines")
939                .body(axum::body::Body::empty())
940                .unwrap(),
941        )
942        .await
943        .unwrap();
944
945        assert_eq!(response.status(), axum::http::StatusCode::OK);
946    }
947
948    #[test]
949    fn test_postgres_pool_config_defaults() {
950        let cfg = PostgresPoolConfig::default();
951        assert_eq!(cfg.max_connections, 10);
952        assert_eq!(cfg.min_connections, 2);
953        assert_eq!(cfg.idle_timeout, Duration::from_secs(300));
954        assert_eq!(cfg.max_lifetime, Duration::from_secs(1800));
955        assert_eq!(cfg.acquire_timeout, Duration::from_secs(5));
956        assert_eq!(cfg.statement_timeout, Duration::from_secs(30));
957    }
958
959    #[test]
960    fn test_server_config_with_postgres_pool() {
961        let pool_config = PostgresPoolConfig {
962            max_connections: 20,
963            min_connections: 5,
964            idle_timeout: Duration::from_secs(120),
965            max_lifetime: Duration::from_secs(3600),
966            acquire_timeout: Duration::from_secs(10),
967            statement_timeout: Duration::from_secs(60),
968        };
969
970        let config = ServerConfig::new()
971            .storage_backend(StorageBackend::Postgres)
972            .postgres_url("postgres://localhost:5432/perfgate")
973            .postgres_pool(pool_config);
974
975        assert_eq!(config.postgres_pool.max_connections, 20);
976        assert_eq!(config.postgres_pool.min_connections, 5);
977        assert_eq!(config.postgres_pool.idle_timeout, Duration::from_secs(120));
978        assert_eq!(config.postgres_pool.max_lifetime, Duration::from_secs(3600));
979        assert_eq!(
980            config.postgres_pool.acquire_timeout,
981            Duration::from_secs(10)
982        );
983        assert_eq!(
984            config.postgres_pool.statement_timeout,
985            Duration::from_secs(60)
986        );
987    }
988
989    #[tokio::test]
990    async fn test_health_endpoint_no_pool_for_memory() {
991        let store: Arc<dyn crate::storage::BaselineStore> = Arc::new(InMemoryStore::new());
992        assert!(store.pool_metrics().is_none());
993    }
994}