Skip to main content

planspec_server/
lib.rs

1//! PlanSpec Server Library
2//!
3//! This crate provides the PlanSpec API server as a library, allowing it to be
4//! embedded in other applications (like the CLI's `serve` command).
5
6use anyhow::Result;
7use axum::{routing::get, Router};
8use std::net::SocketAddr;
9use std::time::Duration;
10use tower_http::trace::TraceLayer;
11
12mod controllers;
13mod routes;
14mod storage;
15mod watch;
16
17#[cfg(test)]
18mod tests;
19
20pub use controllers::PlanResolver;
21pub use storage::Store;
22pub use watch::WatchBroadcaster;
23
24/// Application state shared across handlers
25#[derive(Clone)]
26pub struct AppState {
27    pub store: Store,
28    pub broadcaster: WatchBroadcaster,
29}
30
31/// Server configuration
32#[derive(Debug, Clone)]
33pub struct ServerConfig {
34    pub host: String,
35    pub port: u16,
36    pub db_path: String,
37    pub reconcile_interval_secs: u64,
38}
39
40impl Default for ServerConfig {
41    fn default() -> Self {
42        Self {
43            host: "127.0.0.1".to_string(),
44            port: 8080,
45            db_path: "planspec.db".to_string(),
46            reconcile_interval_secs: 5,
47        }
48    }
49}
50
51impl ServerConfig {
52    /// Create configuration from environment variables
53    pub fn from_env() -> Self {
54        Self {
55            host: std::env::var("PLANSPEC_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()),
56            port: std::env::var("PORT")
57                .ok()
58                .and_then(|p| p.parse().ok())
59                .unwrap_or(8080),
60            db_path: std::env::var("PLANSPEC_DB").unwrap_or_else(|_| "planspec.db".to_string()),
61            reconcile_interval_secs: std::env::var("PLANSPEC_RECONCILE_INTERVAL")
62                .ok()
63                .and_then(|i| i.parse().ok())
64                .unwrap_or(5),
65        }
66    }
67}
68
69/// Build the application router with the given state
70pub fn build_router(state: AppState) -> Router {
71    Router::new()
72        .route("/healthz", get(|| async { "ok" }))
73        .nest("/apis/planspec.io/v1alpha1", routes::api_routes())
74        .layer(TraceLayer::new_for_http())
75        .with_state(state)
76}
77
78/// Run the PlanSpec server with the given configuration
79pub async fn run_server(config: ServerConfig) -> Result<()> {
80    // Initialize storage
81    let store = Store::new(&config.db_path).await?;
82
83    // Initialize watch broadcaster
84    let broadcaster = WatchBroadcaster::new();
85
86    let state = AppState {
87        store: store.clone(),
88        broadcaster: broadcaster.clone(),
89    };
90
91    // Start the plan resolution controller as a background task
92    let resolver = PlanResolver::new(store, broadcaster);
93    let interval = Duration::from_secs(config.reconcile_interval_secs);
94    tokio::spawn(async move {
95        loop {
96            if let Err(e) = resolver.reconcile_all().await {
97                tracing::warn!(error = %e, "Plan resolution reconcile failed");
98            }
99            tokio::time::sleep(interval).await;
100        }
101    });
102
103    // Build router
104    let app = build_router(state);
105
106    // Start server
107    let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
108    tracing::info!("PlanSpec server listening on {}", addr);
109
110    let listener = tokio::net::TcpListener::bind(addr).await?;
111    axum::serve(listener, app).await?;
112
113    Ok(())
114}