Skip to main content

commit_bridge/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs, clippy::missing_docs_in_private_items)]
3#![warn(
4    clippy::panic,
5    clippy::expect_used,
6    clippy::todo,
7    clippy::unimplemented,
8    clippy::indexing_slicing
9)]
10
11use std::fs;
12use std::str::FromStr;
13
14use axum::{
15    Router,
16    body::Body,
17    extract::State,
18    http::{HeaderValue, Request, Response, StatusCode, header},
19    middleware::{self, Next},
20    response::IntoResponse,
21};
22use jsonwebtoken::EncodingKey;
23use reqwest::Client;
24use rovo::Router as RovoRouter;
25use rovo::aide::openapi::OpenApi;
26use rovo::rovo;
27use sqlx::sqlite::SqliteConnectOptions;
28use subtle::ConstantTimeEq;
29use tokio::signal;
30use tokio_util::sync::CancellationToken;
31use tokio_util::task::TaskTracker;
32use tower_http::timeout::TimeoutLayer;
33use tracing::info;
34
35use crate::{
36    config::Config,
37    context::SharedContext,
38    domain::NonEmptyString,
39    engine::AsyncEngine,
40    error::{ClientCreationError, FatalError},
41    handler::{
42        create_subscription, delete_subscription, get_subscription, list_subscriptions,
43        update_subscription,
44    },
45    polling::PollingEngine,
46    state::AppState,
47    trigger::{GitHubAuthenticator, TriggerEngine},
48};
49
50/// Server configuration module.
51pub mod config;
52pub mod context;
53pub mod domain;
54pub mod engine;
55pub mod error;
56pub mod handler;
57pub mod model;
58pub mod polling;
59pub mod repository;
60pub mod state;
61#[cfg(test)]
62mod test_utils;
63#[cfg(test)]
64mod tests;
65pub mod trigger;
66
67/// A task for an engine to be started.
68type EngineTask = (Box<dyn AsyncEngine>, &'static str);
69
70/// Runs the server, delegating errors to the caller.
71pub async fn run_app(tracker: &TaskTracker, token: &CancellationToken) -> Result<(), FatalError> {
72    let config = Config::load()?;
73    let pool = init_database(&config).await?;
74    let repository = std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone()));
75    let http_client = build_http_client(&config)?;
76
77    let ctx = init_context(
78        repository.clone(),
79        pool.clone(),
80        config.clone(),
81        token.clone(),
82    );
83
84    crate::trigger::recover_stuck_tasks(&repository, &config)
85        .await
86        .map_err(FatalError::Repository)?;
87
88    let engines = init_engines(&ctx, http_client)?;
89    for (engine, message) in engines {
90        crate::engine::start_engine(engine, message, tracker);
91    }
92
93    let app = build_router(repository, pool, &config);
94
95    run_server(app, &ctx.config, token.clone()).await
96}
97
98/// Initializes the database pool.
99async fn init_database(config: &Config) -> Result<sqlx::SqlitePool, FatalError> {
100    let options = SqliteConnectOptions::from_str(config.database.url.as_str())?
101        .foreign_keys(true)
102        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
103
104    let pool = sqlx::sqlite::SqlitePoolOptions::new()
105        .acquire_timeout(config.database.timeout)
106        .connect_with(options)
107        .await?;
108
109    // Ensures database schema is up to date in all environments.
110    sqlx::migrate!().run(&pool).await?;
111
112    Ok(pool)
113}
114
115/// Initializes the shared application context.
116fn init_context(
117    repository: std::sync::Arc<crate::repository::SqliteRepository>,
118    pool: sqlx::SqlitePool,
119    config: Config,
120    token: CancellationToken,
121) -> SharedContext {
122    SharedContext {
123        config,
124        repository,
125        db_pool: pool,
126        token,
127        git_fetcher: std::sync::Arc::new(crate::polling::git::MainGitFetcher),
128    }
129}
130
131/// Initializes the background engines.
132fn init_engines(ctx: &SharedContext, http_client: Client) -> Result<Vec<EngineTask>, FatalError> {
133    let polling_engine = PollingEngine { ctx: ctx.clone() };
134
135    let pem = fs::read(&ctx.config.auth.pem_path).map_err(FatalError::AuthKeyIo)?;
136    let encoding_key = EncodingKey::from_rsa_pem(&pem).map_err(FatalError::AuthKeyLoading)?;
137
138    let authenticator = Box::new(GitHubAuthenticator {
139        http_client: http_client.clone(),
140        config: ctx.config.clone(),
141        encoding_key,
142    });
143    let trigger_engine = TriggerEngine {
144        ctx: ctx.clone(),
145        http_client,
146        authenticator,
147    };
148
149    Ok(vec![
150        (Box::new(polling_engine), "Starting polling engine"),
151        (Box::new(trigger_engine), "Starting trigger engine"),
152    ])
153}
154
155/// Middleware to authorize requests with an API key.
156async fn auth_middleware(
157    State(state): State<AppState>,
158    req: Request<Body>,
159    next: Next,
160) -> Response<Body> {
161    let needs_authentication =
162        req.uri().path().starts_with("/subscriptions") && !state.config.auth.allow_unauthenticated;
163
164    if needs_authentication {
165        let auth_header = req.headers().get("X-API-KEY").and_then(|v| v.to_str().ok());
166
167        if !verify_api_key(state.config.auth.api_key.as_ref(), auth_header) {
168            return StatusCode::UNAUTHORIZED.into_response();
169        }
170    }
171
172    next.run(req).await
173}
174
175/// Uses constant-time verification to check API key correspondence.
176///
177/// If the API keys correspond, returns `true`,
178/// otherwise `false`.
179///
180/// ### Cryptographic security
181///
182/// The function short-circuits if lengths of `expected` and `provided` are unequal.
183/// While this allows an attacker to extract the key length,
184/// it is order of magnitudes safer than using a simple string equality test,
185/// which would allow the attacker to gradually know the exact key
186/// over many requests.
187pub fn verify_api_key(expected: Option<&NonEmptyString>, provided: Option<&str>) -> bool {
188    expected.zip(provided).is_some_and(|(key, header)| {
189        let key_bytes = key.as_bytes();
190        let header_bytes = header.as_bytes();
191        key_bytes.ct_eq(header_bytes).into()
192    })
193}
194
195/// Middleware to set Cache-Control header.
196async fn set_no_cache_header(req: Request<Body>, next: Next) -> Response<Body> {
197    let path = req.uri().path().to_string();
198    let mut response = next.run(req).await;
199    if path.starts_with("/subscriptions") {
200        response.headers_mut().insert(
201            header::CACHE_CONTROL,
202            HeaderValue::from_static("private, no-cache, no-store, must-revalidate, max-age=0"),
203        );
204    }
205    response
206}
207
208#[allow(missing_docs, clippy::missing_docs_in_private_items)]
209mod health_handler {
210    use super::*;
211    #[rovo]
212    pub async fn health_check(State(_state): State<AppState>) -> &'static str {
213        "CommitBridge is alive"
214    }
215}
216
217/// Builds the application router.
218pub fn build_router(
219    repository: std::sync::Arc<crate::repository::SqliteRepository>,
220    pool: sqlx::SqlitePool,
221    config: &Config,
222) -> Router {
223    let state = AppState {
224        config: std::sync::Arc::new(config.clone()),
225        repository,
226        db_pool: pool,
227    };
228
229    let mut api = OpenApi::default();
230    api.info.title = "CommitBridge API".to_string();
231    api.info.description =
232        Some("API for managing repository subscriptions and triggering workflows".to_string());
233
234    let subscriptions = RovoRouter::<AppState>::new()
235        .route(
236            "/",
237            rovo::routing::post(create_subscription).get(list_subscriptions),
238        )
239        .route(
240            "/{id}",
241            rovo::routing::get(get_subscription)
242                .patch(update_subscription)
243                .delete(delete_subscription),
244        );
245
246    RovoRouter::<AppState>::new()
247        .route("/health", rovo::routing::get(health_handler::health_check))
248        .nest("/subscriptions", subscriptions)
249        .with_oas(api)
250        .with_scalar("/scalar")
251        .with_state(state.clone())
252        .finish()
253        .layer(middleware::from_fn_with_state(state, auth_middleware))
254        .layer(middleware::from_fn(set_no_cache_header))
255        .layer(TimeoutLayer::with_status_code(
256            StatusCode::REQUEST_TIMEOUT,
257            config.server.in_request_timeout,
258        ))
259}
260
261/// Runs the server.
262async fn run_server(
263    app: Router,
264    config: &Config,
265    token: CancellationToken,
266) -> Result<(), FatalError> {
267    let listener = tokio::net::TcpListener::bind(config.server.address)
268        .await
269        .map_err(FatalError::TcpBinding)?;
270    println!("Server listening on http://{}", config.server.address);
271    println!(
272        "Scalar UI available at http://{}/scalar",
273        config.server.address
274    );
275
276    axum::serve(listener, app)
277        .with_graceful_shutdown(shutdown_signal(token))
278        .await
279        .map_err(FatalError::Serve)?;
280
281    Ok(())
282}
283
284/// Creates a future that resolves when a termination signal is received.
285async fn shutdown_signal(token: CancellationToken) {
286    let ctrl_c = signal::ctrl_c();
287
288    #[cfg(unix)]
289    let terminate = async {
290        if let Ok(mut signal) = signal::unix::signal(signal::unix::SignalKind::terminate()) {
291            signal.recv().await;
292        } else {
293            std::future::pending::<()>().await;
294        }
295    };
296
297    #[cfg(not(unix))]
298    let terminate = std::future::pending::<()>();
299
300    tokio::select! {
301        _ = ctrl_c => {},
302        _ = terminate => {},
303        _ = token.cancelled() => {},
304    }
305    info!("Shutdown signal received, initiating graceful shutdown...");
306}
307
308/// Creates a new HTTP client.
309pub fn build_http_client(config: &Config) -> Result<Client, ClientCreationError> {
310    let client = Client::builder()
311        .user_agent(config.server.user_agent.to_string())
312        .timeout(config.server.out_request_timeout)
313        .build()?;
314
315    Ok(client)
316}