toolkit/runtime/oop_serve.rs
1//! Out-of-process (`OoP`) HTTP server: probes, two-plane auth, and graceful
2//! drain (`cpt-cf-component-oop-bootstrap`).
3//!
4//! This module owns the edge of an `OoP` gear's HTTP surface:
5//!
6//! - **Framework probes** — `/healthz` (liveness), `/readyz` (readiness, gated
7//! on dependency resolution + custom checks), `/health` (diagnostics, full
8//! healthcheck report), and `/.well-known/openapi.json` (the canonical
9//! discovery path; `cpt-cf-binding-constraint-openapi-well-known`).
10//! - **Two-plane auth** — platform-plane ([`internal_auth_middleware`]) runs
11//! *before* tenant-plane ([`security_context_middleware`]) per
12//! `cpt-cf-adr-two-plane-auth`,
13//! but only when the corresponding authenticator is injected. Because both
14//! authenticator traits use return-position `impl Trait` (not `dyn`-safe),
15//! callers inject the object-safe [`DynBearerAuthenticator`] /
16//! [`DynInternalAuthenticator`] adapters.
17//! - **Graceful drain** — a drain guard rejects new gear-route requests with
18//! `503 + Retry-After` once draining begins, while in-flight requests finish.
19//! - **Framework middleware** — a canonical-error layer normalizes gear/auth
20//! error responses into `problem+json` (`trace_id` / `instance` filled),
21//! matching the in-process (`api-gateway`) path. The drain guard also catches
22//! panics so a panic can neither drop the client connection nor leak the
23//! in-flight counter (which would otherwise stall graceful drain).
24//!
25//! The server is driven by [`OopHttpServer`], invoked from the `HostRuntime`
26//! `OoP` path: it binds and serves the probes **before** the gear's `start()`
27//! phase (so `/healthz` is up during a slow startup), then swaps in the composed
28//! gear routes. Self-registration and dependency resolution live in
29//! [`super::oop_registration`].
30
31use std::collections::BTreeMap;
32use std::panic::AssertUnwindSafe;
33use std::sync::Arc;
34use std::sync::atomic::{AtomicUsize, Ordering};
35use std::time::Duration;
36
37use axum::{
38 Json, Router,
39 extract::{Request, State},
40 http::{StatusCode, header::RETRY_AFTER},
41 middleware::{Next, from_fn, from_fn_with_state},
42 response::{IntoResponse, Response},
43 routing::get,
44};
45use futures_util::FutureExt as _;
46use tokio_util::sync::CancellationToken;
47use tower::ServiceExt as _;
48use url::Url;
49
50use cf_system_sdks::directory::{DirectoryClient, RegisterInstanceInfo, ServiceEndpoint};
51use toolkit_canonical_errors::CanonicalError;
52use toolkit_http_middleware::{RouteAuthPolicy, layer_route_auth};
53use toolkit_security::{DynBearerAuthenticator, DynInternalAuthenticator};
54
55use super::readiness::ReadinessState;
56use crate::api::canonical_error_middleware;
57use crate::api::operation_builder::OperationSpec;
58
59/// `Retry-After` (seconds) advertised while the gear is draining.
60const DRAIN_RETRY_AFTER_SECONDS: u64 = 5;
61
62/// `Retry-After` (seconds) advertised for gear routes before the gear has
63/// finished starting (probes are already live; routes come up shortly).
64const STARTING_RETRY_AFTER_SECONDS: u64 = 1;
65
66// ---------------------------------------------------------------------------
67// Serve options
68// ---------------------------------------------------------------------------
69
70/// Configuration and collaborators for the `OoP` HTTP runtime, assembled by the
71/// bootstrap layer and consumed by `HostRuntime`'s `OoP` serving path.
72///
73/// `#[non_exhaustive]` so new fields (like `labels`) stay additive. Construct via
74/// [`OopServeOptions::new`] + the `with_*` builders rather than a struct literal,
75/// exactly as [`RunOptions`](super::RunOptions) is built: the five arguments to
76/// `new` are the fields with no meaningful default, and everything else defaults
77/// to the same values the bootstrap config layer uses. (A `Default` impl is still
78/// not provided — the `directory` collaborator is a required dependency.)
79///
80/// The builders are what keep [`run_oop_serving`](super::run_oop_serving)
81/// callable: it is `pub` and takes this type by value, so an out-of-crate caller
82/// — an integration test driving the real serve path, for instance — needs some
83/// way to build one.
84#[non_exhaustive]
85pub struct OopServeOptions {
86 /// Logical gear name (used for registration + `OpenAPI` title).
87 pub gear_name: String,
88 /// Process instance id (used for registration/deregistration).
89 pub instance_id: String,
90 /// Optional gear version (used for registration + `OpenAPI` version).
91 pub version: Option<String>,
92 /// Base URL other services use to reach this instance's REST endpoint
93 /// (e.g. `http://billing.default.svc.cluster.local:8080`). Registered with
94 /// `DirectoryService` as the instance's `rest_endpoint`.
95 pub advertise_uri: String,
96 /// Address the main HTTP server binds to (gear routes + probes).
97 pub listen_addr: std::net::SocketAddr,
98 /// Optional separate address for probe endpoints (sidecar port). When set,
99 /// probes are served here *and* on the main listener.
100 pub probe_bind_addr: Option<std::net::SocketAddr>,
101 /// Maximum time to wait for in-flight requests to drain on shutdown.
102 pub drain_timeout: Duration,
103 /// Interval between `DirectoryService` heartbeats. The single presence task
104 /// ([`presence_loop`](super::oop_registration)) sends a heartbeat every
105 /// interval to keep the instance `Healthy`/routable and avoid
106 /// heartbeat-timeout eviction. The presence loop clamps this to a 1s minimum.
107 pub heartbeat_interval: Duration,
108 /// Per-check timeout for readiness healthchecks (`/readyz`). A gear's
109 /// [`Healthcheck`](crate::healthcheck::Healthcheck) that exceeds this is
110 /// reported `Unhealthy`. Mirrors the `api-gateway` `healthcheck_timeout_ms`.
111 pub healthcheck_timeout: Duration,
112 /// Directory client used for self-registration and dependency resolution.
113 pub directory: Arc<dyn DirectoryClient>,
114 /// Tenant-plane authenticator; when `Some`, `security_context_middleware`
115 /// is installed on gear routes.
116 pub bearer_authenticator: Option<DynBearerAuthenticator>,
117 /// Platform-plane authenticator; when `Some`, `internal_auth_middleware` is
118 /// installed on gear routes (runs before the tenant plane).
119 pub internal_authenticator: Option<DynInternalAuthenticator>,
120 /// Stable addressing labels (k8s `matchLabels` style) advertised with this
121 /// instance's directory registration for label-based selection.
122 pub labels: BTreeMap<String, String>,
123}
124
125impl OopServeOptions {
126 /// Create `OopServeOptions` with the fields that have no meaningful default;
127 /// every optional field defaults to the bootstrap config layer's own default
128 /// (`drain_timeout` 30s, `heartbeat_interval` 5s, `healthcheck_timeout`
129 /// 500ms, no probe sidecar port, no authenticators, no labels). Layer the
130 /// rest on with the `with_*` builders.
131 #[must_use]
132 pub fn new(
133 gear_name: String,
134 instance_id: String,
135 advertise_uri: String,
136 listen_addr: std::net::SocketAddr,
137 directory: Arc<dyn DirectoryClient>,
138 ) -> Self {
139 Self {
140 gear_name,
141 instance_id,
142 version: None,
143 advertise_uri,
144 listen_addr,
145 probe_bind_addr: None,
146 drain_timeout: Duration::from_secs(30),
147 heartbeat_interval: Duration::from_secs(5),
148 healthcheck_timeout: Duration::from_millis(500),
149 directory,
150 bearer_authenticator: None,
151 internal_authenticator: None,
152 labels: BTreeMap::new(),
153 }
154 }
155
156 /// Set the gear version (used for registration + `OpenAPI` version).
157 #[must_use]
158 pub fn with_version(mut self, version: Option<String>) -> Self {
159 self.version = version;
160 self
161 }
162
163 /// Serve probes on a separate sidecar port *in addition to* the main listener.
164 #[must_use]
165 pub fn with_probe_bind_addr(mut self, addr: Option<std::net::SocketAddr>) -> Self {
166 self.probe_bind_addr = addr;
167 self
168 }
169
170 /// Override how long in-flight requests may drain on shutdown.
171 #[must_use]
172 pub fn with_drain_timeout(mut self, timeout: Duration) -> Self {
173 self.drain_timeout = timeout;
174 self
175 }
176
177 /// Override the `DirectoryService` heartbeat interval.
178 #[must_use]
179 pub fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
180 self.heartbeat_interval = interval;
181 self
182 }
183
184 /// Override the per-check readiness healthcheck timeout.
185 #[must_use]
186 pub fn with_healthcheck_timeout(mut self, timeout: Duration) -> Self {
187 self.healthcheck_timeout = timeout;
188 self
189 }
190
191 /// Install the tenant-plane authenticator (`security_context_middleware`).
192 #[must_use]
193 pub fn with_bearer_authenticator(mut self, auth: Option<DynBearerAuthenticator>) -> Self {
194 self.bearer_authenticator = auth;
195 self
196 }
197
198 /// Install the platform-plane authenticator (`internal_auth_middleware`).
199 #[must_use]
200 pub fn with_internal_authenticator(mut self, auth: Option<DynInternalAuthenticator>) -> Self {
201 self.internal_authenticator = auth;
202 self
203 }
204
205 /// Advertise stable addressing labels for label-based selection.
206 #[must_use]
207 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
208 self.labels = labels;
209 self
210 }
211}
212
213impl std::fmt::Debug for OopServeOptions {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("OopServeOptions")
216 .field("gear_name", &self.gear_name)
217 .field("instance_id", &self.instance_id)
218 .field("version", &self.version)
219 .field("advertise_uri", &self.advertise_uri)
220 .field("listen_addr", &self.listen_addr)
221 .field("probe_bind_addr", &self.probe_bind_addr)
222 .field("drain_timeout", &self.drain_timeout)
223 .field("heartbeat_interval", &self.heartbeat_interval)
224 .field("healthcheck_timeout", &self.healthcheck_timeout)
225 .field("bearer_authenticator", &self.bearer_authenticator.is_some())
226 .field(
227 "internal_authenticator",
228 &self.internal_authenticator.is_some(),
229 )
230 .field("labels", &self.labels)
231 .finish_non_exhaustive()
232 }
233}
234
235// ---------------------------------------------------------------------------
236// Probe router
237// ---------------------------------------------------------------------------
238
239/// Composed gear routes + `OpenAPI` document, published *after* the gear's
240/// `start()` phase completes.
241///
242/// Held behind an [`arc_swap::ArcSwapOption`] so the probe listener can bind and
243/// serve `/healthz` immediately (while a slow `start()` runs), then have the gear
244/// routes swapped in atomically — no port rebind, so liveness never blips.
245#[derive(Clone, Default)]
246struct LateRoutes {
247 inner: Arc<arc_swap::ArcSwapOption<LateRoutesInner>>,
248}
249
250struct LateRoutesInner {
251 /// Fully-layered gear router (auth planes + drain guard + canonical errors).
252 gear: Router,
253 /// Serialized `OpenAPI` document served at the well-known path.
254 openapi: Arc<str>,
255}
256
257impl LateRoutes {
258 /// Publish the composed gear routes; subsequent requests are served by them.
259 fn publish(&self, gear: Router, openapi: Arc<str>) {
260 self.inner
261 .store(Some(Arc::new(LateRoutesInner { gear, openapi })));
262 }
263
264 /// The current gear router, if the gear has finished starting.
265 fn gear(&self) -> Option<Router> {
266 self.inner.load_full().map(|i| i.gear.clone())
267 }
268
269 /// The current `OpenAPI` document, if published.
270 fn openapi(&self) -> Option<Arc<str>> {
271 self.inner.load_full().map(|i| Arc::clone(&i.openapi))
272 }
273}
274
275/// `503 + Retry-After` returned for gear routes before the gear has started.
276fn starting_response() -> Response {
277 (
278 StatusCode::SERVICE_UNAVAILABLE,
279 [(RETRY_AFTER, STARTING_RETRY_AFTER_SECONDS.to_string())],
280 "starting",
281 )
282 .into_response()
283}
284
285/// Shared state for the probe endpoints.
286#[derive(Clone)]
287struct ProbeState {
288 readiness: Arc<ReadinessState>,
289 late: LateRoutes,
290}
291
292/// Build the outer router bound at startup: framework probes plus a gear-route
293/// fallback that is `503 starting` until [`LateRoutes::publish`] swaps in the
294/// composed gear router. Probes are live as soon as the listener binds.
295fn build_outer_router(readiness: Arc<ReadinessState>, late: LateRoutes) -> Router {
296 let fallback_late = late.clone();
297 build_probe_router(readiness, late).fallback_service(tower::util::service_fn(
298 move |req: Request| {
299 let late = fallback_late.clone();
300 async move {
301 match late.gear() {
302 // `Router`'s service error is `Infallible`, hence `match e {}`.
303 Some(router) => Ok::<_, std::convert::Infallible>(
304 router.oneshot(req).await.unwrap_or_else(|e| match e {}),
305 ),
306 None => Ok::<_, std::convert::Infallible>(starting_response()),
307 }
308 }
309 },
310 ))
311}
312
313/// Build the framework probe router: `/healthz`, `/readyz`, `/health`, and the
314/// well-known `OpenAPI` discovery path.
315///
316/// These routes carry no tenant JWT and are never subject to the drain guard or
317/// the auth middlewares (they must respond during startup and drain).
318fn build_probe_router(readiness: Arc<ReadinessState>, late: LateRoutes) -> Router {
319 let state = ProbeState { readiness, late };
320
321 Router::new()
322 .route("/healthz", get(healthz))
323 .route("/readyz", get(readyz))
324 .route("/health", get(health))
325 .route("/.well-known/openapi.json", get(openapi))
326 .route("/openapi.json", get(openapi))
327 .with_state(state)
328}
329
330/// Liveness: always `200` with plain body `ok` once the server is listening.
331async fn healthz() -> &'static str {
332 "ok"
333}
334
335/// Readiness: `200` when ready, `503` with the unresolved-deps / failing-checks
336/// body otherwise. `Degraded` checks keep the response `200`.
337async fn readyz(State(state): State<ProbeState>) -> Response {
338 let report = state.readiness.evaluate().await;
339 let status = if report.ready {
340 StatusCode::OK
341 } else {
342 StatusCode::SERVICE_UNAVAILABLE
343 };
344 (status, Json(report)).into_response()
345}
346
347/// Health: returns the full `HealthcheckReport` (`status` + per-component
348/// detail). `200` for `Healthy`/`Degraded`, `503` for `Unhealthy`.
349async fn health(State(state): State<ProbeState>) -> impl IntoResponse {
350 let report = state.readiness.health_report().await;
351 let status = if report.is_ready() {
352 StatusCode::OK
353 } else {
354 StatusCode::SERVICE_UNAVAILABLE
355 };
356 (status, Json(report))
357}
358
359/// Serve the gear's generated `OpenAPI` document once published (after
360/// `start()`); `503 starting` beforehand.
361async fn openapi(State(state): State<ProbeState>) -> Response {
362 match state.late.openapi() {
363 Some(spec) => (
364 StatusCode::OK,
365 [(axum::http::header::CONTENT_TYPE, "application/json")],
366 spec.to_string(),
367 )
368 .into_response(),
369 None => starting_response(),
370 }
371}
372
373// ---------------------------------------------------------------------------
374// Drain guard
375// ---------------------------------------------------------------------------
376
377/// Tracks in-flight gear-route requests and enforces the drain state.
378#[derive(Clone)]
379struct DrainGuard {
380 readiness: Arc<ReadinessState>,
381 in_flight: Arc<AtomicUsize>,
382}
383
384impl DrainGuard {
385 fn new(readiness: Arc<ReadinessState>) -> Self {
386 Self {
387 readiness,
388 in_flight: Arc::new(AtomicUsize::new(0)),
389 }
390 }
391
392 /// Current number of in-flight gear-route requests.
393 fn in_flight(&self) -> usize {
394 self.in_flight.load(Ordering::SeqCst)
395 }
396
397 /// Begin draining: flip readiness to `503` so upstreams stop routing new
398 /// traffic and the guard starts rejecting new requests (drain step 1/2).
399 fn begin_drain(&self) {
400 self.readiness.set_draining(true);
401 }
402}
403
404/// RAII guard that increments the in-flight request counter on creation and
405/// decrements it on drop, so cancellation or panic cannot leak a count.
406struct InFlightGuard(Arc<AtomicUsize>);
407
408impl InFlightGuard {
409 fn new(counter: Arc<AtomicUsize>) -> Self {
410 counter.fetch_add(1, Ordering::SeqCst);
411 Self(counter)
412 }
413}
414
415impl Drop for InFlightGuard {
416 fn drop(&mut self) {
417 self.0.fetch_sub(1, Ordering::SeqCst);
418 }
419}
420
421/// Middleware: reject new requests with `503 + Retry-After` while draining;
422/// otherwise track the request as in-flight until it completes.
423async fn drain_guard_middleware(
424 State(guard): State<DrainGuard>,
425 request: axum::extract::Request,
426 next: Next,
427) -> Response {
428 if guard.readiness.is_draining() {
429 return (
430 StatusCode::SERVICE_UNAVAILABLE,
431 [(RETRY_AFTER, DRAIN_RETRY_AFTER_SECONDS.to_string())],
432 "draining",
433 )
434 .into_response();
435 }
436
437 // The in-flight counter is tracked by an RAII guard so it is decremented
438 // whether the handler completes normally, panics, or the future is cancelled.
439 // Panics are caught and converted to a canonical 500 (enriched by the outer
440 // canonical-error layer) instead of dropping the connection — the OoP
441 // equivalent of `tower_http`'s `CatchPanicLayer`.
442 let _in_flight = InFlightGuard::new(guard.in_flight.clone());
443 let outcome = AssertUnwindSafe(next.run(request)).catch_unwind().await;
444
445 match outcome {
446 Ok(response) => response,
447 Err(panic) => {
448 let detail = panic_message(panic.as_ref());
449 tracing::error!(panic = %detail, "OoP gear handler panicked");
450 // `detail` is a server-side diagnostic only; `CanonicalError::internal`
451 // keeps it off the wire (`#[serde(skip)]`) per the error contract.
452 CanonicalError::internal(format!("gear handler panicked: {detail}"))
453 .create()
454 .into_response()
455 }
456 }
457}
458
459/// Extract a human-readable message from a caught panic payload.
460fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
461 panic
462 .downcast_ref::<&str>()
463 .map(|s| (*s).to_owned())
464 .or_else(|| panic.downcast_ref::<String>().cloned())
465 .unwrap_or_else(|| "unknown panic".to_owned())
466}
467
468// ---------------------------------------------------------------------------
469// Router assembly
470// ---------------------------------------------------------------------------
471
472/// Derive the listener policy from [`OperationSpec::auth`].
473pub(super) fn route_auth_policy<S>(specs: impl IntoIterator<Item = S>) -> RouteAuthPolicy
474where
475 S: std::ops::Deref<Target = OperationSpec>,
476{
477 specs
478 .into_iter()
479 .map(|spec| (spec.method.clone(), spec.path.clone(), spec.auth))
480 .collect()
481}
482
483/// Layer policy, auth, drain guard and canonical errors. Probes stay on
484/// [`build_outer_router`] so they remain available during startup and drain.
485fn layer_gear_router(
486 gear_router: Router,
487 route_auth: RouteAuthPolicy,
488 drain_guard: DrainGuard,
489 options: &OopServeOptions,
490) -> Router {
491 // Policy, both auth planes (each only when injected) and the platform gate, in the
492 // order `cpt-cf-adr-two-plane-auth` requires. A missing internal authenticator fails
493 // closed for platform routes only; without a tenant authenticator, tenant routes are
494 // served without bearer validation (the bearer authenticator is optional, see
495 // `resolve_bearer_authenticator`).
496 let mut gear = layer_route_auth(
497 gear_router,
498 route_auth,
499 options.bearer_authenticator.clone(),
500 options.internal_authenticator.clone(),
501 );
502
503 // Drain guard sits just inside the canonical-error layer: it rejects new
504 // requests while draining and tracks the in-flight count (catching handler
505 // panics so the counter can never leak and stall the drain).
506 gear = gear.layer(from_fn_with_state(drain_guard, drain_guard_middleware));
507
508 // Canonical-error middleware post-processes every gear/auth response into
509 // `problem+json` with `trace_id` / `instance` filled — matching the
510 // in-process (`api-gateway`) path.
511 gear = gear.layer(from_fn(canonical_error_middleware));
512
513 // Trace-context layer is the *outermost* gear layer so its `http_request`
514 // span is active for every inner layer, including the canonical-error layer
515 // that resolves the wire `trace_id` and emits the log line.
516 gear.layer(from_fn(trace_context_middleware))
517}
518
519/// Run every gear request inside an `http_request` span that continues the
520/// caller's W3C trace when the inbound `traceparent` carries one.
521///
522/// Without this an `OoP` gear's request span is a fresh root, so the canonical-
523/// error layer would put the caller's inbound trace id on the wire while the
524/// gear's own log lines carried a different, freshly-minted one. Seeding the
525/// parent makes the span's trace id equal the inbound one, so the wire
526/// `trace_id` and the log-correlation splice agree. (`api-gateway` does the same
527/// in-process via its own `TraceLayer`.)
528async fn trace_context_middleware(req: Request, next: Next) -> Response {
529 use tracing::Instrument as _;
530 use tracing::field::Empty;
531
532 // Record the gateway-supplied `x-request-id` (forwarded to this gear, absent
533 // when the gear is reached without the gateway) and the wire `trace_id` /
534 // `parent.trace_id` on the span, so an OoP gear's log lines join to the
535 // gateway access log and the wire trace even with the text console format,
536 // without OTel, or with the log-correlation splice off. The id is the
537 // gateway's; this gear does not mint one.
538 let request_id = req
539 .headers()
540 .get("x-request-id")
541 .and_then(|v| v.to_str().ok())
542 .unwrap_or("n/a");
543
544 let span = tracing::info_span!(
545 "http_request",
546 method = %req.method(),
547 uri = %req.uri().path(),
548 request_id = %request_id,
549 trace_id = Empty,
550 parent.trace_id = Empty,
551 );
552
553 // Seed the span's parent from the inbound W3C trace context and record
554 // `trace_id` / `parent.trace_id`. Shared with the api-gateway via
555 // `toolkit_trace_context`; without `otel` it records the ids from the header.
556 toolkit_trace_context::set_parent_from_headers(&span, req.headers());
557
558 next.run(req).instrument(span).await
559}
560
561// ---------------------------------------------------------------------------
562// Serve
563// ---------------------------------------------------------------------------
564
565/// Serve the pre-bound `OoP` listener(s) until `cancel` fires, then drain.
566///
567/// Binding happens earlier in [`OopHttpServer::start`] so `/healthz` is up
568/// before the gear's (possibly slow) `start()` phase; this loop only drives the
569/// accept + graceful-shutdown machinery:
570/// 1. Serve with graceful shutdown wired to `cancel`.
571/// 2. On `cancel`: flip readiness to draining (via the caller-owned
572/// `ReadinessState`), wait up to `drain_timeout` for in-flight to reach zero,
573/// then let `axum` close the listener.
574///
575/// The `DirectoryService` deregistration is orchestrated by [`OopHttpServer::join`]
576/// so the full drain sequence (`cpt-cf-component-oop-bootstrap`) is honored.
577///
578/// # Errors
579/// Returns an error if the server task fails.
580async fn serve_loop(
581 listener: tokio::net::TcpListener,
582 router: Router,
583 drain_guard: DrainGuard,
584 sidecar: Option<(tokio::net::TcpListener, Router)>,
585 drain_timeout: Duration,
586 cancel: CancellationToken,
587) -> anyhow::Result<()> {
588 // Optional sidecar probe listener (bound by the caller).
589 let sidecar = match sidecar {
590 Some((probe_listener, probe_router)) => {
591 let shutdown = {
592 let cancel = cancel.clone();
593 async move { cancel.cancelled().await }
594 };
595 Some(tokio::spawn(async move {
596 if let Err(e) = axum::serve(probe_listener, probe_router)
597 .with_graceful_shutdown(shutdown)
598 .await
599 {
600 tracing::warn!(error = %e, "OoP probe sidecar server error");
601 }
602 }))
603 }
604 None => None,
605 };
606
607 // Main server: graceful shutdown waits for cancellation, then drains.
608 let shutdown = {
609 let cancel = cancel.clone();
610 let guard = drain_guard.clone();
611 async move {
612 cancel.cancelled().await;
613 // Drain step 1/2: flip readiness to 503 and start rejecting new work.
614 guard.begin_drain();
615 tracing::info!("OoP HTTP server draining (graceful shutdown)");
616 // Drain step 3: wait for in-flight requests to complete.
617 drain_in_flight(&guard, drain_timeout).await;
618 }
619 };
620
621 let result = axum::serve(
622 listener,
623 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
624 )
625 .with_graceful_shutdown(shutdown)
626 .await
627 .map_err(anyhow::Error::from);
628
629 if let Some(handle) = sidecar {
630 handle.abort();
631 if let Err(e) = handle.await
632 && !e.is_cancelled()
633 {
634 tracing::warn!(error = %e, "OoP probe sidecar task join error");
635 }
636 }
637
638 result
639}
640
641/// Owns an `OoP` gear's HTTP surface across the startup boundary.
642///
643/// [`start`](Self::start) binds the listener and serves the framework probes
644/// **immediately** — `/healthz` → `200`, `/readyz` → `starting`/`503`, and gear
645/// routes → `503 starting` — so the kubelet's liveness probe passes while the
646/// gear's (possibly slow) `start()` phase runs. Once the gear router is composed,
647/// [`attach`](Self::attach) swaps it in atomically (no port rebind) and starts
648/// background directory presence + dependency resolution. [`join`](Self::join)
649/// waits for the graceful drain on shutdown, then deregisters from
650/// `DirectoryService` (drain step 4, `cpt-cf-component-oop-bootstrap`).
651///
652/// Steps 5–7 of the drain order (reverse-dependency wait, stopping runtime
653/// services, process exit) are the caller's / operator's responsibility;
654/// the presence task stops when `cancel` fires.
655pub(super) struct OopHttpServer {
656 readiness: Arc<ReadinessState>,
657 late: LateRoutes,
658 drain_guard: DrainGuard,
659 options: OopServeOptions,
660 cancel: CancellationToken,
661 registration_task: Option<tokio::task::JoinHandle<()>>,
662 serve: tokio::task::JoinHandle<anyhow::Result<()>>,
663}
664
665impl OopHttpServer {
666 /// Bind the listener(s) and start serving probes immediately; gear routes
667 /// reply `503 starting` until [`attach`](Self::attach) publishes them.
668 ///
669 /// # Errors
670 /// Returns an error if the main (or sidecar) listener cannot be bound.
671 pub(super) async fn start(
672 readiness: Arc<ReadinessState>,
673 options: OopServeOptions,
674 cancel: CancellationToken,
675 ) -> anyhow::Result<Self> {
676 let late = LateRoutes::default();
677 let drain_guard = DrainGuard::new(Arc::clone(&readiness));
678 let mut options = options;
679
680 let listener = tokio::net::TcpListener::bind(options.listen_addr).await?;
681 let bound = listener.local_addr()?;
682 let configured_port = options.listen_addr.port();
683 if bound.port() != configured_port {
684 if let Some(rewritten) =
685 rewrite_advertise_uri_port(&options.advertise_uri, bound.port(), configured_port)
686 {
687 tracing::info!(
688 old = %options.advertise_uri,
689 new = %rewritten,
690 "advertise_uri rewritten to use actually bound port"
691 );
692 options.advertise_uri = rewritten;
693 } else {
694 tracing::info!(
695 advertise_uri = %options.advertise_uri,
696 bound_port = bound.port(),
697 configured_port,
698 "bound port differs from configured; leaving advertise_uri unchanged"
699 );
700 }
701 }
702 options.listen_addr = bound;
703 tracing::info!(
704 addr = %options.listen_addr,
705 "OoP HTTP server bound (probes live; gear routes attach after start)"
706 );
707
708 let outer = build_outer_router(Arc::clone(&readiness), late.clone());
709
710 let sidecar = if let Some(addr) = options.probe_bind_addr {
711 let probe_listener = tokio::net::TcpListener::bind(addr).await?;
712 let bound_probe = probe_listener.local_addr()?;
713 options.probe_bind_addr = Some(bound_probe);
714 tracing::info!(addr = %bound_probe, "OoP probe sidecar bound");
715 Some((
716 probe_listener,
717 build_probe_router(Arc::clone(&readiness), late.clone()),
718 ))
719 } else {
720 None
721 };
722
723 let serve = tokio::spawn(serve_loop(
724 listener,
725 outer,
726 drain_guard.clone(),
727 sidecar,
728 options.drain_timeout,
729 cancel.clone(),
730 ));
731
732 Ok(Self {
733 readiness,
734 late,
735 drain_guard,
736 options,
737 cancel,
738 registration_task: None,
739 serve,
740 })
741 }
742
743 /// The serve options (used by the caller to compose the `OpenAPI` document).
744 pub(super) fn options(&self) -> &OopServeOptions {
745 &self.options
746 }
747
748 /// Resolve the tenant-plane authenticator from the populated `ClientHub`.
749 ///
750 /// Called by the `OoP` serving path after the gear lifecycle's `start`
751 /// phase and before [`attach`](Self::attach) layers the middleware. When an
752 /// in-process authn stack (e.g. the `authn-resolver` gear) is linked into
753 /// the binary it registers a [`DynBearerAuthenticator`] bridge during
754 /// `init`; picking it up here installs `security_context_middleware` on the
755 /// gear routes. A no-op if an authenticator was already supplied directly or
756 /// none is registered in the hub.
757 pub(super) fn resolve_bearer_authenticator(&mut self, hub: &crate::ClientHub) {
758 if self.options.bearer_authenticator.is_some() {
759 return;
760 }
761 if let Ok(auth) = hub.get::<DynBearerAuthenticator>() {
762 self.options.bearer_authenticator = Some((*auth).clone());
763 tracing::info!(
764 gear = %self.options.gear_name,
765 "tenant-plane authenticator installed (security_context_middleware enabled)"
766 );
767 } else {
768 tracing::warn!(
769 gear = %self.options.gear_name,
770 "no tenant-plane authenticator registered in ClientHub; tenant plane not installed"
771 );
772 }
773 }
774
775 /// Atomically publish the composed gear routes (with auth policy) and their
776 /// `OpenAPI` document, then start background directory presence.
777 ///
778 /// Presence (registration + heartbeat + self-heal) starts only once the gear
779 /// can serve requests, so discovery never advertises a not-yet-serving instance.
780 /// Dependency resolution runs in the proxy-wiring phase before serving.
781 pub(super) fn attach(
782 &mut self,
783 gear_router: Router,
784 openapi_json: String,
785 route_auth: RouteAuthPolicy,
786 ) {
787 let openapi_arc: Arc<str> = Arc::from(openapi_json);
788 let layered = layer_gear_router(
789 gear_router,
790 route_auth,
791 self.drain_guard.clone(),
792 &self.options,
793 );
794 self.late.publish(layered, Arc::clone(&openapi_arc));
795 self.readiness.mark_startup_complete();
796 tracing::info!(gear = %self.options.gear_name, "OoP gear routes attached (now serving)");
797
798 // Single directory-presence task: registration + heartbeat + self-heal.
799 // Dependency resolution is handled by the proxy-wiring phase (typed
800 // `#[toolkit::consumes]` clients), which runs before serving.
801 let mut registration_info = RegisterInstanceInfo::new(
802 self.options.gear_name.clone(),
803 self.options.instance_id.clone(),
804 )
805 .with_rest_endpoint(ServiceEndpoint::new(self.options.advertise_uri.clone()))
806 .with_openapi_spec(openapi_arc.to_string())
807 .with_labels(self.options.labels.clone());
808 if let Some(version) = self.options.version.clone() {
809 registration_info = registration_info.with_version(version);
810 }
811 self.registration_task = Some(tokio::spawn(super::oop_registration::presence_loop(
812 Arc::clone(&self.options.directory),
813 registration_info,
814 self.options.heartbeat_interval,
815 self.cancel.clone(),
816 )));
817 }
818
819 /// Wait for the server to drain on shutdown, then deregister from
820 /// `DirectoryService` (drain step 4 — after in-flight requests finish so
821 /// consumers don't see stale "ready" state).
822 ///
823 /// # Errors
824 /// Propagates a serve error; deregistration failures are logged only.
825 pub(super) async fn join(mut self) -> anyhow::Result<()> {
826 let serve_result = match self.serve.await {
827 Ok(r) => r,
828 Err(e) => Err(anyhow::anyhow!("OoP serve task join error: {e}")),
829 };
830
831 if let Some(task) = self.registration_task.take() {
832 task.abort();
833 }
834 if let Err(e) = self
835 .options
836 .directory
837 .deregister_instance(&self.options.gear_name, &self.options.instance_id)
838 .await
839 {
840 tracing::warn!(
841 gear = %self.options.gear_name,
842 error = %e,
843 "deregistration from DirectoryService failed on shutdown"
844 );
845 } else {
846 tracing::info!(gear = %self.options.gear_name, "deregistered from DirectoryService");
847 }
848
849 serve_result
850 }
851}
852
853/// Rewrite `advertise_uri` to use the actually bound port, but only if the
854/// URI currently uses the `configured_port` (i.e. the port the caller asked
855/// the server to bind to). This preserves user-provided load-balancer URLs
856/// that intentionally advertise a different port from the local socket.
857fn rewrite_advertise_uri_port(
858 advertise_uri: &str,
859 bound_port: u16,
860 configured_port: u16,
861) -> Option<String> {
862 let mut url = Url::parse(advertise_uri).ok()?;
863 if url.port_or_known_default() != Some(configured_port) {
864 return None;
865 }
866 url.set_port(Some(bound_port)).ok()?;
867 Some(url.to_string())
868}
869
870/// Wait up to `timeout` for the in-flight counter to reach zero.
871async fn drain_in_flight(guard: &DrainGuard, timeout: Duration) {
872 let deadline = tokio::time::Instant::now() + timeout;
873 loop {
874 let in_flight = guard.in_flight();
875 if in_flight == 0 {
876 tracing::info!("OoP drain complete: no in-flight requests");
877 return;
878 }
879 if tokio::time::Instant::now() >= deadline {
880 tracing::warn!(
881 in_flight,
882 timeout_secs = timeout.as_secs(),
883 "OoP drain timed out with in-flight requests remaining"
884 );
885 return;
886 }
887 tokio::time::sleep(Duration::from_millis(50)).await;
888 }
889}
890
891#[cfg(test)]
892#[cfg_attr(coverage_nightly, coverage(off))]
893#[path = "oop_serve_tests.rs"]
894mod tests;