serve.rs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. //! Server bootstrap and graceful shutdown (`serve`).
  2. //!
  3. //! [`run`] is the whole `serve` subcommand: load [`Config`], install the
  4. //! JSON tracing subscriber, connect [`AppState`] (which fails closed when
  5. //! Postgres or Redis is unreachable or Redis violates the no-eviction/AOF
  6. //! durability contract), build the public router with
  7. //! [`build_router`](crate::routes::build_router), load the rustls server
  8. //! config with
  9. //! [`build_server_config`](crate::routes::build_server_config), drive the
  10. //! **TLS public listener** with [`serve_tls`](crate::tls::serve_tls) (the
  11. //! custom hyper-util/tokio-rustls accept loop that injects the verified
  12. //! peer chain — never `axum::serve`, which cannot carry a per-connection
  13. //! acceptor), and drive the **plaintext health listener** (`health_addr`,
  14. //! the pod IP, never loopback-only) with `axum::serve` on the dedicated
  15. //! [`health_router`](crate::state::health_router) (liveness + readiness
  16. //! only, so every OAuth route 404s there by construction).
  17. //!
  18. //! Migrations are NEVER run here; the `migrate` subcommand owns them and
  19. //! readiness fails closed when the schema version does not match.
  20. //!
  21. //! On SIGTERM (or SIGINT): `/health/ready` flips to 503 immediately, a
  22. //! short propagation grace lets the kubelet observe it, in-flight requests
  23. //! drain under a bounded deadline (below `terminationGracePeriodSeconds`),
  24. //! then the pools close and the process exits 0. No business logic lives
  25. //! here — only listener wiring and shutdown plumbing.
  26. use std::{
  27. net::SocketAddr,
  28. sync::{
  29. Arc,
  30. atomic::{AtomicBool, Ordering},
  31. },
  32. time::Duration,
  33. };
  34. use axum::{
  35. Json,
  36. extract::{Request, State},
  37. http::StatusCode,
  38. middleware::{Next, from_fn_with_state},
  39. response::{IntoResponse, Response},
  40. };
  41. use tokio::net::TcpListener;
  42. use crate::{cli::CliError, config::Config, state::AppState};
  43. /// Grace between the readiness flip and listener shutdown, so the kubelet
  44. /// observes 503 before endpoints are removed.
  45. pub const PROPAGATION_GRACE: Duration = Duration::from_secs(1);
  46. /// Upper bound for in-flight drain after the grace; under the
  47. /// `terminationGracePeriodSeconds: 45` budget from the deployment manifests.
  48. pub const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
  49. /// Run the `serve` subcommand to completion (returns on clean shutdown).
  50. ///
  51. /// # Errors
  52. /// Returns [`CliError`] for invalid configuration, a loopback health
  53. /// address, unreadable TLS material, unconnectable state, bind failures, or
  54. /// a listener that dies mid-serve. Every error path closes the pools.
  55. pub async fn run() -> Result<(), CliError> {
  56. let _ = crate::middleware::observability::init();
  57. let config = Arc::new(Config::load()?);
  58. ensure_health_not_loopback(config.health_addr)?;
  59. let state = AppState::connect(config.clone()).await?;
  60. tracing::info!(
  61. redis_topology = state.redis.topology_name(),
  62. bind_addr = %config.bind_addr,
  63. health_addr = %config.health_addr,
  64. "server starting"
  65. );
  66. let acceptor = load_tls_acceptor(&config).await?;
  67. let draining = Arc::new(AtomicBool::new(false));
  68. let public = crate::routes::build_router(state.clone())
  69. .layer(from_fn_with_state(draining.clone(), drain_guard));
  70. let health = crate::state::health_router(state.clone())
  71. .layer(from_fn_with_state(draining.clone(), drain_guard));
  72. let tls_listener = TcpListener::bind(config.bind_addr).await?;
  73. let health_listener = TcpListener::bind(config.health_addr).await?;
  74. let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
  75. let mut tls_task = tokio::spawn(crate::tls::serve_tls(tls_listener, acceptor, public));
  76. let mut health_task = tokio::spawn(async move {
  77. axum::serve(health_listener, health.into_make_service())
  78. .with_graceful_shutdown(async {
  79. let _ = shutdown_rx.await;
  80. })
  81. .await
  82. });
  83. tokio::select! {
  84. outcome = &mut tls_task => {
  85. abort(&mut health_task);
  86. state.pool.close().await;
  87. return match outcome {
  88. Ok(Ok(())) => Ok(()),
  89. Ok(Err(error)) => Err(CliError::Io(error)),
  90. Err(error) => Err(CliError::Input(format!("tls listener task failed: {error}"))),
  91. };
  92. }
  93. () = wait_termination() => {}
  94. }
  95. draining.store(true, Ordering::SeqCst);
  96. tracing::warn!("shutdown signal received: readiness now 503");
  97. tokio::time::sleep(PROPAGATION_GRACE).await;
  98. let _ = shutdown_tx.send(());
  99. if tokio::time::timeout(DRAIN_DEADLINE, &mut health_task)
  100. .await
  101. .is_err()
  102. {
  103. tracing::warn!("health listener drain timed out; aborting");
  104. abort(&mut health_task);
  105. }
  106. tls_task.abort();
  107. state.pool.close().await;
  108. tracing::warn!("shutdown complete");
  109. Ok(())
  110. }
  111. /// Refuse a loopback-only health listener: kubelet probes originate from
  112. /// the node netns and cannot reach pod-loopback.
  113. ///
  114. /// # Errors
  115. /// Returns [`CliError::Input`] when `addr` is loopback.
  116. fn ensure_health_not_loopback(addr: SocketAddr) -> Result<(), CliError> {
  117. if addr.ip().is_loopback() {
  118. return Err(CliError::Input(format!(
  119. "health_addr must bind the pod IP, not loopback: {addr}"
  120. )));
  121. }
  122. Ok(())
  123. }
  124. /// Load the PEM material and build the rustls acceptor for the public
  125. /// listener. Any unreadable or invalid material fails closed here, so the
  126. /// server never starts half-secured.
  127. ///
  128. /// # Errors
  129. /// Returns [`CliError`] when a file cannot be read or the material is
  130. /// invalid.
  131. async fn load_tls_acceptor(config: &Config) -> Result<tokio_rustls::TlsAcceptor, CliError> {
  132. let chain = tokio::fs::read(&config.tls.cert_path).await?;
  133. let key = tokio::fs::read(&config.tls.key_path).await?;
  134. let client_ca = match &config.tls.client_ca_path {
  135. Some(path) => Some(tokio::fs::read(path).await?),
  136. None => None,
  137. };
  138. let server_config = crate::routes::build_server_config(&chain, &key, client_ca.as_deref())
  139. .map_err(|error| CliError::Crypto(format!("invalid TLS material: {error}")))?;
  140. Ok(tokio_rustls::TlsAcceptor::from(Arc::new(server_config)))
  141. }
  142. /// Wait for SIGTERM (or SIGINT for local development).
  143. async fn wait_termination() {
  144. #[cfg(unix)]
  145. {
  146. let term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate());
  147. let interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt());
  148. let (term, interrupt) = match (term, interrupt) {
  149. (Ok(term), Ok(interrupt)) => (term, interrupt),
  150. _ => return,
  151. };
  152. tokio::select! {
  153. _ = wait_for(term) => {}
  154. _ = wait_for(interrupt) => {}
  155. }
  156. }
  157. #[cfg(not(unix))]
  158. {
  159. let _ = tokio::signal::ctrl_c().await;
  160. }
  161. }
  162. /// Resolve one unix signal stream.
  163. #[cfg(unix)]
  164. async fn wait_for(mut signal: tokio::signal::unix::Signal) {
  165. signal.recv().await;
  166. }
  167. /// Abort a listener task that is no longer needed.
  168. fn abort<T>(task: &mut tokio::task::JoinHandle<T>) {
  169. task.abort();
  170. }
  171. /// See [`run`]: while draining, `/health/ready` answers 503 so the
  172. /// endpoint is pulled before connections close; every other route passes
  173. /// through untouched.
  174. async fn drain_guard(
  175. State(draining): State<Arc<AtomicBool>>,
  176. request: Request,
  177. next: Next,
  178. ) -> Response {
  179. if draining.load(Ordering::SeqCst) && request.uri().path() == "/health/ready" {
  180. return (
  181. StatusCode::SERVICE_UNAVAILABLE,
  182. Json(serde_json::json!({ "status": "not_ready" })),
  183. )
  184. .into_response();
  185. }
  186. next.run(request).await
  187. }
  188. #[cfg(test)]
  189. mod bootstrap_tests {
  190. use super::{DRAIN_DEADLINE, PROPAGATION_GRACE, drain_guard, ensure_health_not_loopback};
  191. use std::{sync::Arc, sync::atomic::AtomicBool, time::Duration};
  192. /// Test body result (no `unwrap`/`expect` under the deny lints).
  193. type TestResult = Result<(), Box<dyn std::error::Error>>;
  194. /// A loopback health listener is refused: kubelet probes cannot reach it.
  195. #[test]
  196. fn bootstrap_health_loopback_is_refused() -> TestResult {
  197. for addr in ["127.0.0.1:8081", "[::1]:8081"] {
  198. let parsed: std::net::SocketAddr = addr.parse()?;
  199. if ensure_health_not_loopback(parsed).is_ok() {
  200. return Err(format!("loopback health_addr must be refused: {addr}").into());
  201. }
  202. }
  203. for addr in ["0.0.0.0:8081", "[::]:8081"] {
  204. let parsed: std::net::SocketAddr = addr.parse()?;
  205. ensure_health_not_loopback(parsed)?;
  206. }
  207. Ok(())
  208. }
  209. /// The drain budget fits inside `terminationGracePeriodSeconds: 45`.
  210. #[test]
  211. fn bootstrap_drain_deadline_fits_termination_grace() -> TestResult {
  212. if DRAIN_DEADLINE + PROPAGATION_GRACE >= Duration::from_secs(45) {
  213. return Err("drain budget must stay below terminationGracePeriodSeconds".into());
  214. }
  215. Ok(())
  216. }
  217. /// While draining, `/health/ready` flips to 503 and `/health/live`
  218. /// stays 200.
  219. #[tokio::test]
  220. async fn bootstrap_drain_flips_ready_not_live() -> TestResult {
  221. use tower::ServiceExt as _;
  222. let draining = Arc::new(AtomicBool::new(false));
  223. let router = axum::Router::new()
  224. .route("/health/live", axum::routing::get(|| async { "live" }))
  225. .route("/health/ready", axum::routing::get(|| async { "ready" }))
  226. .layer(axum::middleware::from_fn_with_state(
  227. draining.clone(),
  228. drain_guard,
  229. ));
  230. let get = |router: axum::Router, path: &'static str| async move {
  231. let request = axum::http::Request::builder()
  232. .uri(path)
  233. .body(axum::body::Body::empty())?;
  234. let response = router.oneshot(request).await.map_err(|_| "router error")?;
  235. Ok::<_, Box<dyn std::error::Error>>(response.status())
  236. };
  237. if get(router.clone(), "/health/live").await? != axum::http::StatusCode::OK {
  238. return Err("live must be 200 before drain".into());
  239. }
  240. draining.store(true, std::sync::atomic::Ordering::SeqCst);
  241. if get(router.clone(), "/health/ready").await? != axum::http::StatusCode::SERVICE_UNAVAILABLE {
  242. return Err("ready must flip to 503 while draining".into());
  243. }
  244. if get(router, "/health/live").await? != axum::http::StatusCode::OK {
  245. return Err("live must stay 200 while draining".into());
  246. }
  247. Ok(())
  248. }
  249. }