Skip to main content

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