1pub mod admin;
7pub mod limits;
8pub mod measure;
9pub mod observe;
10pub mod openapi;
11#[cfg(feature = "testing")]
12pub mod testing;
13
14pub use limits::Limits;
15
16use std::{error::Error, fmt::Display, time::Duration};
17
18use axum::{
19 Json, Router,
20 http::{HeaderValue, StatusCode, header},
21 response::{IntoResponse, Response},
22};
23use edda_auth::PublicKey;
24use edda_client::auth::AuthClient;
25use serde_json::json;
26use sqlx::{
27 Connection, PgConnection, PgPool, Postgres,
28 migrate::{MigrateDatabase, Migrator},
29 postgres::PgPoolOptions,
30};
31use tower_http::cors::{AllowOrigin, Any, CorsLayer};
32use tracing_subscriber::EnvFilter;
33
34#[utoipa::path(
36 get,
37 path = "/health",
38 tag = "health",
39 security(()),
40 responses((status = 200, description = "The service is running.", body = String, example = "ok"))
41)]
42#[expect(clippy::unused_async, reason = "axum handlers are async")]
43pub async fn health() -> &'static str {
44 "ok"
45}
46
47const READY_TIMEOUT: Duration = Duration::from_secs(2);
49
50pub fn readiness(pool: PgPool) -> Router {
54 Router::new()
55 .route("/ready", axum::routing::get(ready))
56 .with_state(pool)
57}
58
59#[utoipa::path(
61 get,
62 path = "/ready",
63 tag = "health",
64 security(()),
65 responses(
66 (status = 200, description = "The service can reach its database.", body = String, example = "ready"),
67 (status = 503, description = "It can't, for now.", body = openapi::ErrorBody),
68 )
69)]
70pub async fn ready(axum::extract::State(pool): axum::extract::State<PgPool>) -> Response {
71 let checked = tokio::time::timeout(READY_TIMEOUT, sqlx::query("SELECT 1").execute(&pool)).await;
72 match checked {
73 Ok(Ok(_)) => "ready".into_response(),
74 Ok(Err(e)) => {
75 tracing::warn!(error = %e, "not ready: the database refuses");
76 error_response(
77 StatusCode::SERVICE_UNAVAILABLE,
78 &"the database can't be reached",
79 )
80 }
81 Err(_) => {
82 tracing::warn!("not ready: the database doesn't answer");
83 error_response(
84 StatusCode::SERVICE_UNAVAILABLE,
85 &"the database can't be reached",
86 )
87 }
88 }
89}
90
91pub fn init_logging(crate_name: &str) {
97 let filter = EnvFilter::try_from_default_env()
98 .unwrap_or_else(|_| format!("{crate_name}=info,edda_server=info,tower_http=info").into());
99 let json = std::env::var("LOG_FORMAT").is_ok_and(|f| f == "json");
100 let traces = observe::traces(crate_name).unwrap_or_else(|e| {
101 eprintln!("not sending traces: {e}");
102 None
103 });
104 tracing::subscriber::set_global_default(observe::subscriber(
105 json,
106 filter,
107 std::io::stderr,
108 traces,
109 ))
110 .expect("logging is set up once");
111}
112
113pub async fn open_database(url: &str, migrations: &Migrator) -> Result<PgPool, sqlx::Error> {
119 let direct = direct_database_url(url);
120 if !Postgres::database_exists(&direct).await? {
121 if let Err(e) = Postgres::create_database(&direct).await
123 && !Postgres::database_exists(&direct).await?
124 {
125 return Err(e);
126 }
127 }
128 let mut session = PgConnection::connect(&direct).await?;
129 migrations.run(&mut session).await?;
130 session.close().await?;
131 PgPoolOptions::new().connect(url).await
132}
133
134pub fn direct_database_url(url: &str) -> String {
139 direct_or(url, std::env::var("DATABASE_DIRECT_URL").ok())
140}
141
142fn direct_or(url: &str, direct: Option<String>) -> String {
143 direct
144 .filter(|d| !d.is_empty())
145 .unwrap_or_else(|| url.to_owned())
146}
147
148pub async fn auth_key(auth_service: &str) -> Result<PublicKey, Box<dyn Error>> {
153 if let Ok(key) = std::env::var("AUTH_PUBLIC_KEY") {
154 return Ok(key.parse()?);
155 }
156 let key = AuthClient::new(auth_service)
157 .wait_for_server_key(Duration::from_secs(30))
158 .await
159 .map_err(|e| {
160 format!(
161 "could not fetch the auth service's key from {auth_service} ({e}); \
162 start it or set AUTH_PUBLIC_KEY"
163 )
164 })?;
165 tracing::info!(%key, "using the auth service's token key");
166 Ok(key)
167}
168
169pub fn auth_service_url() -> String {
171 std::env::var("AUTH_SERVICE_URL").unwrap_or_else(|_| "http://127.0.0.1:3002".to_string())
172}
173
174pub async fn serve(
178 app: Router,
179 admin: Router,
180 bind: &str,
181 name: &str,
182 limits: &Limits,
183) -> Result<(), Box<dyn Error>> {
184 if let Some(config) = admin::Admin::from_env()? {
185 let listener = tokio::net::TcpListener::bind(&config.address).await?;
186 tracing::info!(address = %config.address, "{name}'s admin API listening");
187 let admin = observe::requests(config.guard(admin), false);
188 tokio::spawn(async move {
189 if let Err(e) = axum::serve(listener, admin).await {
190 tracing::error!(error = %e, "serving the admin API failed");
191 }
192 });
193 }
194 let app = match std::env::var("METRICS_ADDR") {
195 Ok(address) => {
196 let measured = measure::endpoint(measure::install(name)?);
197 let listener = tokio::net::TcpListener::bind(&address).await?;
198 tracing::info!(%address, "{name}'s metrics at /metrics");
199 tokio::spawn(async move {
200 if let Err(e) = axum::serve(listener, measured).await {
201 tracing::error!(error = %e, "serving metrics failed");
202 }
203 });
204 measure::requests(app)
205 }
206 Err(_) => app,
207 };
208 let app = observe::requests(limits.apply(app.layer(cors()?)), limits.trust_proxy);
209 let listener = tokio::net::TcpListener::bind(bind).await?;
210 tracing::info!(%bind, limits = limits.enabled(), "{name} listening");
211 let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
213 axum::serve(listener, app)
214 .with_graceful_shutdown(stopping())
215 .await?;
216 tracing::info!("{name} stopped");
217 observe::flush();
218 Ok(())
219}
220
221async fn stopping() {
225 let interrupted = async {
226 let _ = tokio::signal::ctrl_c().await;
227 };
228 #[cfg(unix)]
229 let terminated = async {
230 use tokio::signal::unix::{SignalKind, signal};
231 match signal(SignalKind::terminate()) {
232 Ok(mut terminate) => {
233 terminate.recv().await;
234 }
235 Err(e) => {
236 tracing::warn!(error = %e, "can't listen for SIGTERM");
237 std::future::pending::<()>().await;
238 }
239 }
240 };
241 #[cfg(not(unix))]
242 let terminated = std::future::pending::<()>();
243 tokio::select! {
244 () = interrupted => {}
245 () = terminated => {}
246 }
247 tracing::info!("stopping");
248}
249
250pub fn error_message(status: StatusCode, error: &dyn Display) -> String {
253 if status == StatusCode::INTERNAL_SERVER_ERROR {
254 tracing::error!(%error, "internal error");
255 "internal server error".to_string()
256 } else {
257 error.to_string()
258 }
259}
260
261pub fn error_response(status: StatusCode, error: &dyn Display) -> Response {
264 (
265 status,
266 Json(json!({ "error": error_message(status, error) })),
267 )
268 .into_response()
269}
270
271fn cors() -> Result<CorsLayer, String> {
277 let setting = std::env::var("CORS_ORIGINS").unwrap_or_default();
278 cors_for(&setting)
279}
280
281fn cors_for(setting: &str) -> Result<CorsLayer, String> {
282 let origins: Vec<&str> = setting
283 .split(',')
284 .map(str::trim)
285 .filter(|o| !o.is_empty())
286 .collect();
287 if origins.is_empty() {
288 return Ok(CorsLayer::new());
289 }
290 let allowed = if origins == ["*"] {
291 AllowOrigin::any()
292 } else {
293 let parsed: Result<Vec<HeaderValue>, _> = origins
294 .iter()
295 .map(|o| {
296 HeaderValue::from_str(o).map_err(|_| format!("CORS_ORIGINS: {o:?} isn't an origin"))
297 })
298 .collect();
299 AllowOrigin::list(parsed?)
300 };
301 Ok(CorsLayer::new()
304 .allow_origin(allowed)
305 .allow_methods(Any)
306 .allow_headers(Any)
307 .expose_headers([header::ETAG, header::LOCATION]))
308}
309
310#[cfg(test)]
311mod tests {
312 use axum::{body::Body, http::Request};
313 use sqlx::postgres::PgPoolOptions;
314 use tower::ServiceExt;
315
316 use super::*;
317
318 #[tokio::test]
319 async fn it_is_not_ready_while_its_database_cant_be_reached() {
320 let pool = PgPoolOptions::new()
322 .acquire_timeout(Duration::from_millis(200))
323 .connect_lazy("postgres://postgres@127.0.0.1:1/nothing")
324 .unwrap();
325 let res = readiness(pool)
326 .oneshot(Request::get("/ready").body(Body::empty()).unwrap())
327 .await
328 .unwrap();
329 assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
330 }
331
332 #[test]
333 fn a_direct_url_is_the_pooled_one_unless_given() {
334 let pooled = "postgres://pgbouncer:6432/combats";
335 assert_eq!(direct_or(pooled, None), pooled);
336 assert_eq!(direct_or(pooled, Some(String::new())), pooled);
337 let direct = "postgres://postgres:5432/combats";
338 assert_eq!(direct_or(pooled, Some(direct.into())), direct);
339 }
340
341 #[test]
342 fn origins_are_a_list_or_any() {
343 assert!(cors_for("").is_ok());
344 assert!(cors_for("https://dnd.example.com, http://192.168.1.20:8080").is_ok());
345 assert!(cors_for("*").is_ok());
346 assert!(cors_for("https://bad\norigin").is_err());
347 }
348}