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//!
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;