1use anyhow::Context as _;
2use axum::Router;
3use axum::body::Body;
4use axum::http::{HeaderName, HeaderValue, Method, header};
5use axum::middleware;
6use axum::response::Html;
7use hyper::body::Incoming;
8use hyper::service::service_fn;
9use hyper_util::{
10 rt::{TokioExecutor, TokioIo},
11 server::conn::auto::Builder as HyperConnectionBuilder,
12};
13use platform_core::{
14 AppConfig, AppContext, LoggingEventPublisher, PostgresRuntimeConfigProvider,
15 RuntimeConfigRegistry, Shutdown, connect_pool, connect_redis, telemetry,
16};
17use platform_http::request_context_middleware;
18use spiffe_rustls::{LocalOnly, authorizer, mtls_server};
19use spiffe_rustls_tokio::TlsAcceptor;
20use std::convert::Infallible;
21use std::future::Future;
22use std::net::SocketAddr;
23use std::sync::Arc;
24use tokio::sync::watch;
25use tower::ServiceExt as _;
26use tower_http::cors::CorsLayer;
27use tracing::info;
28
29mod console_bridge;
30pub mod openapi;
31
32pub use openapi::openapi_document;
33
34pub async fn run_from_env() -> anyhow::Result<()> {
35 run_from_env_with_composition(lenso_bootstrap::HostComposition::default()).await
36}
37
38pub async fn run_from_env_with_composition(
39 composition: lenso_bootstrap::HostComposition,
40) -> anyhow::Result<()> {
41 let config = AppConfig::try_from_env().context("invalid application configuration")?;
42 telemetry::init(&config.telemetry)?;
43
44 let db = connect_pool(&config.database).await?;
45 let redis = connect_redis(&config.redis).await?;
46 let mut ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher)).with_redis(redis);
47
48 let descriptors =
49 lenso_bootstrap::runtime_config_descriptors_with_composition(&ctx, &composition)
50 .context("failed to collect runtime-config descriptors")?;
51 let groups =
52 lenso_bootstrap::runtime_config_group_descriptors_with_composition(&ctx, &composition)
53 .context("failed to collect runtime-config groups")?;
54 let registry = RuntimeConfigRegistry::try_new_with_groups(descriptors, groups)
55 .context("duplicate runtime-config descriptor registered")?;
56 let runtime_config =
57 PostgresRuntimeConfigProvider::connect(ctx.db.clone(), Arc::new(registry), "api")
58 .await
59 .context("failed to load runtime-config snapshot")?;
60 runtime_config.spawn_listener();
61 ctx = ctx.with_runtime_config_provider(runtime_config);
62
63 let provider_plan = lenso_bootstrap::provider_runtime_plan_from_workspace(".")
64 .context("failed to compile Provider Runtime Plan")?;
65 if let Some(provider_runtime) = lenso_bootstrap::load_provider_runtime_with_composition(
66 &ctx,
67 &composition,
68 provider_plan.as_ref(),
69 )
70 .await
71 .context("failed to load locked Provider Runtime")?
72 {
73 platform_provider::install_provider_http_proxy_registry(provider_runtime.proxy_registry());
74 }
75
76 let app = try_build_router_with_composition(ctx.clone(), &composition)
77 .context("failed to build API router")?;
78 let address: SocketAddr = format!("{}:{}", ctx.config.http.host, ctx.config.http.port)
79 .parse()
80 .context("invalid HTTP bind address")?;
81
82 info!(%address, "starting API server");
83 let listener = tokio::net::TcpListener::bind(address).await?;
84 let shutdown = ctx.shutdown.clone();
85 axum::serve(
86 listener,
87 app.into_make_service_with_connect_info::<SocketAddr>(),
88 )
89 .with_graceful_shutdown(async move {
90 let mut shutdown_rx = shutdown.subscribe();
91 tokio::select! {
92 () = Shutdown::wait_for_signal() => {},
93 changed = shutdown_rx.changed() => {
94 let _ = changed;
95 },
96 }
97 })
98 .await?;
99
100 Ok(())
101}
102
103pub fn build_router(ctx: AppContext) -> Router {
104 try_build_router(ctx).expect("Runtime API router should build with a valid composition profile")
105}
106
107pub fn try_build_router(ctx: AppContext) -> platform_core::AppResult<Router> {
108 try_build_router_with_composition(ctx, &lenso_bootstrap::HostComposition::default())
109}
110
111pub fn try_build_router_with_composition(
112 mut ctx: AppContext,
113 composition: &lenso_bootstrap::HostComposition,
114) -> platform_core::AppResult<Router> {
115 if let Some(actor_resolver) =
116 lenso_bootstrap::auth_actor_resolver_for_context_with_composition(&ctx, composition)?
117 {
118 ctx = ctx.with_actor_resolver(actor_resolver);
119 }
120 let host_wiring = lenso_bootstrap::host_wiring_for_context_with_composition(&ctx, composition)?;
121 let console_bridge = if let Some(authority) = composition.console_bridge_authority() {
122 Some(console_bridge::ConsoleBridgeRegistry::from_modules(
123 lenso_bootstrap::modules_for_config_with_composition(&ctx, composition)?,
124 authority.clone(),
125 ))
126 } else {
127 None
128 };
129 let (router, mut document) =
130 openapi::api_router_for_context_with_composition(&ctx, composition)?.split_for_parts();
131 openapi::normalize_error_response_content_types(&mut document);
132 let document = Arc::new(document);
133
134 let router = if let Some(console_bridge) = console_bridge {
135 router.layer(axum::Extension(console_bridge))
136 } else {
137 router
138 };
139
140 Ok(router
141 .route("/docs", axum::routing::get(scalar_docs))
142 .route("/openapi.json", axum::routing::get(serve_openapi))
143 .layer(axum::Extension(document))
144 .layer(axum::Extension(host_wiring.auth_session_policy()))
145 .layer(middleware::from_fn_with_state(
146 ctx.clone(),
147 request_context_middleware,
148 ))
149 .layer(cors_layer(&ctx))
150 .with_state(ctx))
151}
152
153pub fn try_build_router_with_composition_and_system_plane(
156 _ctx: AppContext,
157 _composition: &lenso_bootstrap::HostComposition,
158 runtime: &lenso_bootstrap::HostSystemPlaneRuntime,
159) -> platform_core::AppResult<Router> {
160 let core = Some(Arc::clone(&runtime.core));
161 let installations = Some(Arc::clone(&runtime.service_installations));
162 let observability = runtime.runtime_observability.clone();
163 let operations = runtime.runtime_operations.clone();
164 let (router, _document) = platform_system_plane::router(core.clone())
165 .merge(platform_module_management::system_plane_router(
166 installations,
167 ))
168 .merge(platform_runtime_observability::router(observability))
169 .merge(platform_runtime_operations::router(operations))
170 .layer(axum::Extension(core))
171 .split_for_parts();
172 Ok(router)
173}
174
175pub async fn run_production_system_plane<F>(
179 listener: tokio::net::TcpListener,
180 router: Router,
181 identity: Arc<lenso_service::SpiffeWorkloadIdentityProvider>,
182 allowed_peer_spiffe_ids: impl IntoIterator<Item = String>,
183 shutdown: F,
184) -> anyhow::Result<()>
185where
186 F: Future<Output = ()> + Send,
187{
188 let allowed_peer_spiffe_ids = allowed_peer_spiffe_ids.into_iter().collect::<Vec<_>>();
189 if allowed_peer_spiffe_ids.is_empty() {
190 anyhow::bail!("production System Plane requires at least one allowed peer SPIFFE ID");
191 }
192 let tls = mtls_server(identity.x509_source())
193 .authorize(
194 authorizer::exact(allowed_peer_spiffe_ids)
195 .context("invalid System Plane peer SPIFFE allow list")?,
196 )
197 .trust_domain_policy(LocalOnly(identity.config().trust_domain().clone()))
198 .with_alpn_protocols([b"http/1.1"])
199 .build()
200 .context("failed to build System Plane mTLS configuration")?;
201 let acceptor = TlsAcceptor::new(Arc::new(tls));
202 let (shutdown_tx, shutdown_rx) = watch::channel(false);
203 let mut connections = tokio::task::JoinSet::new();
204 tokio::pin!(shutdown);
205
206 loop {
207 tokio::select! {
208 () = &mut shutdown => break,
209 accepted = listener.accept() => {
210 let (stream, peer_address) = accepted.context("System Plane listener failed")?;
211 let acceptor = acceptor.clone();
212 let router = router.clone();
213 let mut connection_shutdown = shutdown_rx.clone();
214 connections.spawn(async move {
215 let (tls, peer) = match acceptor.accept(stream).await {
216 Ok(result) => result,
217 Err(error) => {
218 tracing::warn!(%peer_address, %error, "rejected System Plane mTLS connection");
219 return;
220 }
221 };
222 let Some(peer_spiffe_id) = peer.spiffe_id() else {
223 tracing::warn!(%peer_address, "rejected System Plane peer without a SPIFFE ID");
224 return;
225 };
226 let binding =
227 lenso_service::SpiffeWorkloadIdentityProvider::authenticated_transport_binding(
228 peer_spiffe_id,
229 );
230 let service = service_fn(move |request: hyper::Request<Incoming>| {
231 let router = router.clone();
232 let binding = binding.clone();
233 async move {
234 let (mut parts, incoming) = request.into_parts();
235 parts.extensions.insert(binding);
236 let request = hyper::Request::from_parts(parts, Body::new(incoming));
237 let response = router.oneshot(request).await?;
238 Ok::<_, Infallible>(response)
239 }
240 });
241 let connection_builder = HyperConnectionBuilder::new(TokioExecutor::new());
242 let connection =
243 connection_builder.serve_connection(TokioIo::new(tls), service);
244 tokio::pin!(connection);
245 tokio::select! {
246 result = &mut connection => {
247 if let Err(error) = result {
248 tracing::warn!(%peer_address, %error, "System Plane connection failed");
249 }
250 }
251 changed = connection_shutdown.changed() => {
252 if changed.is_ok() {
253 connection.as_mut().graceful_shutdown();
254 if let Err(error) = connection.await {
255 tracing::warn!(%peer_address, %error, "System Plane graceful shutdown failed");
256 }
257 }
258 }
259 }
260 });
261 }
262 }
263 }
264
265 let _ = shutdown_tx.send(true);
266 while let Some(result) = connections.join_next().await {
267 if let Err(error) = result {
268 tracing::warn!(%error, "System Plane connection task failed");
269 }
270 }
271 identity.shutdown().await;
272 Ok(())
273}
274
275async fn scalar_docs() -> ([(HeaderName, HeaderValue); 3], Html<&'static str>) {
276 (
277 [
278 (
279 HeaderName::from_static("content-security-policy"),
280 HeaderValue::from_static(SCALAR_DOCS_CSP),
281 ),
282 (
283 HeaderName::from_static("referrer-policy"),
284 HeaderValue::from_static("no-referrer"),
285 ),
286 (
287 HeaderName::from_static("x-content-type-options"),
288 HeaderValue::from_static("nosniff"),
289 ),
290 ],
291 Html(SCALAR_DOCS_HTML),
292 )
293}
294
295async fn serve_openapi(
296 axum::Extension(document): axum::Extension<Arc<utoipa::openapi::OpenApi>>,
297) -> axum::Json<utoipa::openapi::OpenApi> {
298 axum::Json((*document).clone())
299}
300
301fn cors_layer(ctx: &AppContext) -> CorsLayer {
302 let origins: Vec<HeaderValue> = ctx
303 .config
304 .http
305 .cors_allowed_origins
306 .iter()
307 .filter_map(|origin| origin.parse().ok())
308 .collect();
309
310 CorsLayer::new()
311 .allow_origin(origins)
312 .allow_methods([
313 Method::GET,
314 Method::POST,
315 Method::PUT,
316 Method::PATCH,
317 Method::DELETE,
318 Method::OPTIONS,
319 ])
320 .allow_headers([header::ACCEPT, header::AUTHORIZATION, header::CONTENT_TYPE])
321}
322
323const SCALAR_DOCS_CSP: &str = "default-src 'none'; script-src https://cdn.jsdelivr.net 'sha256-wT12sSim/cr/4i3SfCUXmSC76WSRp+uWevWj0uNZ/vU='; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
324
325const SCALAR_DOCS_HTML: &str = r##"<!doctype html>
326<html lang="en">
327 <head>
328 <meta charset="utf-8" />
329 <meta name="viewport" content="width=device-width, initial-scale=1" />
330 <title>Lenso API Docs</title>
331 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.62.5" integrity="sha384-jVBCKhcCfx34USN27x4iQK1SBNdL/HxKq3KuBAxTS4WPaP5w80K4fjpwB+DezJL5" crossorigin="anonymous"></script>
332 <style>
333 body {
334 margin: 0;
335 }
336 </style>
337 </head>
338 <body>
339 <div id="app"></div>
340 <script>Scalar.createApiReference("#app",{url:"/openapi.json",theme:"default"});</script>
341 </body>
342</html>
343"##;