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
50pub 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
67type EngineTask = (Box<dyn AsyncEngine>, &'static str);
69
70pub 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
98async 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 sqlx::migrate!().run(&pool).await?;
111
112 Ok(pool)
113}
114
115fn 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
131fn 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
155async 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
175pub 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
195async 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
217pub 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
261async 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
284async 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
308pub 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}