LCOV - code coverage report
Current view: top level - antares-api/src - ops.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 93.1 % 494 460
Test Date: 2026-11-02 13:21:27 Functions: 46.5 % 228 106

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The broker's operational surface (`Admin`): health, readiness, metrics, the tenant list, size and purge, and the dead-letter admin.
       3              : 
       4              : use super::*;
       5              : 
       6              : /// The broker's own operational surface: health, readiness, metrics, the
       7              : /// tenant list and purge, and the dead-letter admin. Health and readiness
       8              : /// are always open; the rest sits behind the admin credential when the
       9              : /// state carries one (`admin_auth.rs`), `/q/metrics` unless the
      10              : /// deployment left it open for a scraper.
      11              : pub struct Admin;
      12              : 
      13              : impl Admin {
      14              :     /// Every path this surface mounts, and the only source of the route
      15              :     /// count `/q/health` reports. The destructuring in `router` binds one
      16              :     /// name per entry, so a route added to one and not the other does not
      17              :     /// compile.
      18              :     pub(crate) const PATHS: [&'static str; 8] = [
      19              :         "/health",
      20              :         "/ready",
      21              :         "/metrics",
      22              :         "/tenants",
      23              :         "/tenants/{tenant}",
      24              :         "/dead-letters",
      25              :         "/dead-letters/{id}",
      26              :         "/dead-letters/{id}/replay",
      27              :     ];
      28              : }
      29              : 
      30              : impl ApiSurface for Admin {
      31           88 :     fn name(&self) -> &str {
      32           88 :         "admin"
      33           88 :     }
      34              : 
      35        19026 :     fn prefix(&self) -> &str {
      36        19026 :         "/q"
      37        19026 :     }
      38              : 
      39        18848 :     fn router(&self, st: AppState) -> Router<AppState> {
      40        18848 :         let [health_p, ready_p, metrics_p, tenants_p, tenant_p, dl_p, dl_id_p, replay_p] =
      41        18848 :             Self::PATHS;
      42        18848 :         let guard = axum::middleware::from_fn_with_state(st.clone(), admin_auth::require);
      43        18848 :         let metrics = Self::metrics_routes(&st, metrics_p);
      44        18848 :         let routes = Router::new()
      45        18848 :             .route(tenants_p, get(tenants_list))
      46        18848 :             .route(tenant_p, get(tenant_get).delete(tenant_purge))
      47        18848 :             .route(dl_p, get(dead_letters_list))
      48        18848 :             .route(dl_id_p, delete(dead_letter_delete))
      49        18848 :             .route(replay_p, post(dead_letter_replay));
      50        18848 :         routes
      51        18848 :             .route_layer(guard)
      52        18848 :             .route(health_p, get(health))
      53        18848 :             .route(ready_p, get(ready))
      54        18848 :             .merge(metrics)
      55        18848 :     }
      56              : 
      57           86 :     fn version_info(&self) -> serde_json::Value {
      58           86 :         serde_json::json!({"routes": Self::PATHS.len()})
      59           86 :     }
      60              : }
      61              : 
      62              : impl Admin {
      63              :     /// `/q/metrics` alone, under the credential rule of the rest of the
      64              :     /// surface: the admin router mounts it, and so does the metrics listener
      65              :     /// (`metrics_router`). Prometheus text format; 404 until the
      66              :     /// broker installs the renderer, the api crate never depends on an
      67              :     /// exporter.
      68        18848 :     fn metrics_routes(st: &AppState, path: &str) -> Router<AppState> {
      69        18848 :         let metrics = Router::new().route(path, get(metrics_endpoint));
      70        18848 :         if st.admin_auth.as_ref().is_some_and(|a| !a.metrics_open) {
      71          102 :             metrics.route_layer(axum::middleware::from_fn_with_state(
      72          102 :                 st.clone(),
      73              :                 admin_auth::require,
      74              :             ))
      75              :         } else {
      76        18746 :             metrics
      77              :         }
      78        18848 :     }
      79              : 
      80              :     /// The metrics listener's whole surface (ANTARES_METRICS_PORT):
      81              :     /// `/q/metrics` and nothing else, so a NetworkPolicy that opens that
      82              :     /// port to a scraper opens no API or admin path.
      83            0 :     pub fn metrics_router(state: AppState) -> Router {
      84            0 :         let [_, _, metrics_p, ..] = Self::PATHS;
      85            0 :         Router::new()
      86            0 :             .nest(Admin.prefix(), Self::metrics_routes(&state, metrics_p))
      87            0 :             .with_state(state)
      88            0 :     }
      89              : }
      90              : 
      91              : /// /q/metrics — Prometheus exposition, rendered by the closure the
      92              : /// broker installed; 404 when no recorder exists (tests, embedded builds).
      93           18 : pub(super) async fn metrics_endpoint(
      94           18 :     axum::extract::State(state): axum::extract::State<AppState>,
      95           18 : ) -> Response {
      96           18 :     match &state.metrics_render {
      97            0 :         Some(render) => Response::builder()
      98            0 :             .status(axum::http::StatusCode::OK)
      99            0 :             .header(
     100            0 :                 axum::http::header::CONTENT_TYPE,
     101              :                 "text/plain; version=0.0.4",
     102              :             )
     103            0 :             .body(axum::body::Body::from(render()))
     104            0 :             .unwrap_or_else(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()),
     105           18 :         None => axum::http::StatusCode::NOT_FOUND.into_response(),
     106              :     }
     107           18 : }
     108              : 
     109              : /// The build-time git hash (build.rs) — re-exported for --version.
     110              : pub const GIT_HASH: &str = env!("ANTARES_GIT_HASH");
     111              : 
     112              : /// Liveness plus the deployment's own description: which drivers are
     113              : /// running, what they are, every configured cap with its rejection counters,
     114              : /// the mounted surfaces and the notification schemes they can deliver to.
     115              : /// 503 while draining, so a load balancer stops routing here before the
     116              : /// listener stops accepting.
     117           86 : pub(super) async fn health(
     118           86 :     axum::extract::State(state): axum::extract::State<AppState>,
     119           86 :     headers: axum::http::HeaderMap,
     120           86 : ) -> (StatusCode, axum::Json<serde_json::Value>) {
     121              :     // Drain: the load balancer decides on the STATUS CODE, so draining has
     122              :     // to be a 503 — a 200 body saying "DRAINING" would keep traffic arriving.
     123              :     // This flips BEFORE the listener stops accepting, which is the whole point
     124              :     // of the ordering: the LB must stop routing while the socket still works.
     125           86 :     let draining = state.draining.load(std::sync::atomic::Ordering::Relaxed);
     126           86 :     let code = if draining {
     127            2 :         StatusCode::SERVICE_UNAVAILABLE
     128              :     } else {
     129           84 :         StatusCode::OK
     130              :     };
     131           86 :     let mut body = serde_json::Map::new();
     132           86 :     body.insert(
     133           86 :         "status".into(),
     134           86 :         (if draining { "DRAINING" } else { "UP" }).into(),
     135              :     );
     136           86 :     body.insert("store".into(), state.store_name.clone().into());
     137           86 :     body.insert(
     138           86 :         "temporal".into(),
     139           86 :         state.temporal_name.as_deref().unwrap_or("none").into(),
     140              :     );
     141              :     // Version surface: workspace version + build-time git hash
     142              :     // (build.rs), both asserted against the checkout by health_is_up.
     143           86 :     body.insert("version".into(), env!("CARGO_PKG_VERSION").into());
     144           86 :     body.insert("commit".into(), env!("ANTARES_GIT_HASH").into());
     145              :     // What each driver runs on — engine, server version, extensions — so an
     146              :     // operator can tell two deployments of the same backend name apart. Both
     147              :     // seams answer from state captured at startup; a driver with nothing to
     148              :     // add answers an empty object and the key stays off the body.
     149          172 :     for (key, info) in [
     150           86 :         ("storeInfo", state.store.version_info()),
     151           86 :         ("temporalInfo", state.temporal.version_info()),
     152           86 :     ] {
     153          172 :         if info.as_object().is_some_and(|m| !m.is_empty()) {
     154          162 :             body.insert(key.into(), info);
     155          162 :         }
     156              :     }
     157              :     // Where commits serialize behind one writer, the queue depth (current,
     158              :     // peak) is the signal that decides the group-commit lever. A driver
     159              :     // without such a committer reports nothing, so the branch is the
     160              :     // driver's, not a backend name read here.
     161           86 :     if let Some((depth, peak)) = state.store.commit_queue() {
     162            2 :         body.insert("commitQueueDepth".into(), depth.into());
     163            2 :         body.insert("commitQueuePeak".into(), peak.into());
     164           84 :     }
     165              :     // The policy engine this binary was started with, and how long it has
     166              :     // to answer before the seam denies for it (ADR-0020). An operator
     167              :     // reading `allow-all` here knows no engine is attached.
     168           86 :     let mut policy = serde_json::Map::new();
     169           86 :     policy.insert("engine".into(), state.policy_name().into());
     170           86 :     policy.insert(
     171           86 :         "timeoutMs".into(),
     172           86 :         u64::try_from(policy::TIMEOUT.as_millis())
     173           86 :             .unwrap_or(u64::MAX)
     174           86 :             .into(),
     175              :     );
     176              :     // Configured caps + rejection counters, for observability.
     177           86 :     body.insert("limits".into(), state.limits.snapshot());
     178              :     // History events a driver failed to record after the write stood.
     179           86 :     body.insert("temporalDrainErrors".into(), history::drain_errors().into());
     180              :     // What this binary serves beside the NGSI-LD API: one entry per mounted
     181              :     // surface, its prefix and whatever the surface reports about itself.
     182           86 :     body.insert(
     183           86 :         "surfaces".into(),
     184              :         serde_json::Value::Object(
     185           86 :             state
     186           86 :                 .surfaces
     187           86 :                 .iter()
     188           94 :                 .map(|s| {
     189           94 :                     let mut info = match s.version_info() {
     190           94 :                         serde_json::Value::Object(m) => m,
     191            0 :                         other => {
     192            0 :                             let mut m = serde_json::Map::new();
     193            0 :                             m.insert("info".into(), other);
     194            0 :                             m
     195              :                         }
     196              :                     };
     197           94 :                     info.insert("prefix".into(), s.prefix().into());
     198           94 :                     (s.name().to_owned(), serde_json::Value::Object(info))
     199           94 :                 })
     200           86 :                 .collect(),
     201              :         ),
     202              :     );
     203              :     // Notification endpoint schemes this deployment can deliver notifications to: the
     204              :     // registered bindings (6.3.8, clause 7, and any a deployment added).
     205              :     // A subscription naming a scheme absent here is refused at creation.
     206           86 :     body.insert("notificationSchemes".into(), state.sinks.schemes().into());
     207              :     // Notifications the delivery policy gave up on (this process, since start).
     208           86 :     body.insert("deadLetters".into(), notify::dead_letters_written().into());
     209              :     // Each stored view, loaded or invalid with the reason.
     210              :     // only the view reports below read the credential
     211           86 :     drop(headers);
     212              :     // Changes the bounded matcher queue dropped (this process, since start).
     213           86 :     body.insert("changesDropped".into(), notify::changes_dropped().into());
     214              :     // Panics absorbed at the notification-task boundary, each one a change
     215              :     // whose notification was lost. Reported here and not only through the
     216              :     // metrics facade: the recorder is installed by ANTARES_TELEMETRY, which
     217              :     // is off by default, so this endpoint is where a default deployment can
     218              :     // see it at all.
     219           86 :     body.insert("taskPanics".into(), notify::task_panics().into());
     220              :     // Jemalloc heap stats (RSS ≈ live×1.2 is the target).
     221           86 :     if let Some(mem) = &state.mem_stats {
     222           16 :         body.insert("memory".into(), mem());
     223           70 :     }
     224              :     // Bus visibility: present only when the nats wiring installed the
     225              :     // provider — bus=local carries NO bus member.
     226           86 :     if let Some(bus) = &state.bus_stats {
     227            4 :         body.insert("bus".into(), bus());
     228           82 :     }
     229           86 :     body.insert("policy".into(), serde_json::Value::Object(policy));
     230           86 :     (code, axum::Json(serde_json::Value::Object(body)))
     231           86 : }
     232              : 
     233              : /// The tenant an admin dead-letter call addresses: `?tenant=` (default
     234              : /// tenant when absent), grammar-checked, never a reserved internal one.
     235          104 : pub(super) fn admin_tenant(
     236          104 :     q: &std::collections::HashMap<String, String>,
     237          104 : ) -> Result<TenantId, NgsiError> {
     238          104 :     let raw = q.get("tenant").map_or(TenantId::DEFAULT, String::as_str);
     239          104 :     let bad = || NgsiError::BadRequestData(format!("invalid tenant: {raw:?}"));
     240          104 :     if reserved_tenant(raw) {
     241            8 :         return Err(bad());
     242           96 :     }
     243           96 :     TenantId::new(raw).map_err(|_| bad())
     244          104 : }
     245              : 
     246              : /// One dead letter as the listing may show it: everything on it that can be
     247              : /// a credential is blanked, the keys are not.
     248              : ///
     249              : /// `receiverInfo` and `notifierInfo` are KeyValuePair arrays (5.2.22), and
     250              : /// the example content the datatype names is the HTTP Authentication header;
     251              : /// a letter written before the bindings moved behind the registry carries the
     252              : /// same values already rendered into `headers`. `notifierInfo` is opaque to
     253              : /// every sink but the endpoint's own, so the broker cannot tell which of its
     254              : /// keys names a secret and blanks them all. The stored letter keeps the
     255              : /// values a replay has to send — only what leaves through this route loses
     256              : /// them.
     257           28 : pub(super) fn redact_letter(l: &mut serde_json::Value) {
     258              :     // Assigning through `Value`'s index panics on anything but an object,
     259              :     // and the row comes from the store rather than from this process.
     260           28 :     let Some(letter) = l.as_object_mut() else {
     261            8 :         return;
     262              :     };
     263           20 :     if let Some(u) = letter.get("uri").and_then(serde_json::Value::as_str) {
     264           20 :         let redacted = serde_json::Value::String(notify::redact_userinfo(u));
     265           20 :         letter.insert("uri".into(), redacted);
     266           20 :     }
     267           60 :     for member in ["receiverInfo", "notifierInfo", "headers"] {
     268           60 :         let Some(pairs) = letter
     269           60 :             .get_mut(member)
     270           60 :             .and_then(serde_json::Value::as_array_mut)
     271              :         else {
     272           36 :             continue;
     273              :         };
     274           42 :         for pair in pairs {
     275           42 :             if let Some(v) = pair.get_mut(1) {
     276           42 :                 *v = serde_json::Value::String("[redacted]".into());
     277           42 :             }
     278              :         }
     279              :     }
     280           28 : }
     281              : 
     282              : /// Dead letters of one tenant, newest first; `subscription=` narrows to one
     283              : /// subscription, `limit=` bounds the page (`ANTARES_DEFAULT_LIMIT` unless
     284              : /// named, 403 TooManyResults past `ANTARES_MAX_LIMIT`). The endpoint URI is
     285              : /// shown with its userinfo redacted and every credential the letter carries
     286              : /// is blanked.
     287           52 : pub(super) async fn dead_letters_list(
     288           52 :     axum::extract::State(st): axum::extract::State<AppState>,
     289           52 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
     290           52 : ) -> Response {
     291           52 :     let tenant = match admin_tenant(&q) {
     292           44 :         Ok(t) => t,
     293            8 :         Err(e) => return ApiError::from(e).into_response(),
     294              :     };
     295           44 :     let limit = match q.get("limit") {
     296           32 :         None => st.default_limit,
     297           12 :         Some(v) => match v.parse::<usize>() {
     298            8 :             Ok(n) if n > st.max_limit => {
     299            2 :                 return ApiError::from(NgsiError::TooManyResults(format!(
     300            2 :                     "limit {n} exceeds the server maximum {}",
     301            2 :                     st.max_limit
     302            2 :                 )))
     303            2 :                 .into_response()
     304              :             }
     305            6 :             Ok(n) if n > 0 => n,
     306              :             _ => {
     307            6 :                 return ApiError::from(NgsiError::BadRequestData(format!(
     308            6 :                     "limit must be a positive integer, got {v:?}"
     309            6 :                 )))
     310            6 :                 .into_response()
     311              :             }
     312              :         },
     313              :     };
     314           36 :     let mut letters = match st
     315           36 :         .store
     316           36 :         .list(&tenant, antares_store::Kind::DeadLetter)
     317           36 :         .await
     318              :     {
     319           36 :         Ok(l) => l,
     320            0 :         Err(e) => return ApiError::from(e).into_response(),
     321              :     };
     322           36 :     if let Some(sid) = q.get("subscription") {
     323            6 :         letters.retain(|l| l["subscriptionId"].as_str() == Some(sid.as_str()));
     324           34 :     }
     325           36 :     letters.sort_by(|a, b| b["lastAt"].as_str().cmp(&a["lastAt"].as_str()));
     326           36 :     letters.truncate(limit);
     327           36 :     for l in &mut letters {
     328           28 :         redact_letter(l);
     329           28 :     }
     330           36 :     (
     331           36 :         StatusCode::OK,
     332           36 :         axum::Json(serde_json::Value::Array(letters)),
     333           36 :     )
     334           36 :         .into_response()
     335           52 : }
     336              : 
     337              : /// Re-deliver one dead letter once through its own binding: 204 and the
     338              : /// letter is gone on success; 502 with the failure text and the letter
     339              : /// kept (attempt history extended) otherwise.
     340           34 : pub(super) async fn dead_letter_replay(
     341           34 :     axum::extract::State(st): axum::extract::State<AppState>,
     342           34 :     axum::extract::Path(id): axum::extract::Path<String>,
     343           34 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
     344           34 : ) -> Response {
     345              :     use antares_store::{CurrentStateDriverExt as _, Kind};
     346           34 :     let tenant = match admin_tenant(&q) {
     347           32 :         Ok(t) => t,
     348            2 :         Err(e) => return ApiError::from(e).into_response(),
     349              :     };
     350           32 :     let letter = match st.store.get(&tenant, Kind::DeadLetter, &id).await {
     351           16 :         Ok(Some(l)) => l,
     352              :         Ok(None) => {
     353           16 :             return ApiError::from(NgsiError::ResourceNotFound(format!("dead letter {id}")))
     354           16 :                 .into_response()
     355              :         }
     356            0 :         Err(e) => return ApiError::from(e).into_response(),
     357              :     };
     358           16 :     match notify::replay_dead_letter(&st, &tenant, &letter).await {
     359            4 :         Ok(()) => match st.store.delete(&tenant, Kind::DeadLetter, &id).await {
     360            4 :             Ok(_) => StatusCode::NO_CONTENT.into_response(),
     361            0 :             Err(e) => ApiError::from(e).into_response(),
     362              :         },
     363           12 :         Err(why) => {
     364           12 :             let ts = state::now_iso();
     365           12 :             let w = why.clone();
     366           12 :             let _ = st
     367           12 :                 .store
     368           12 :                 .mutate(&tenant, Kind::DeadLetter, &id, move |d| {
     369           12 :                     let Some(letter) = d.as_object_mut() else {
     370            2 :                         return Ok::<(), NgsiError>(());
     371              :                     };
     372           10 :                     let n = letter
     373           10 :                         .get("attempts")
     374           10 :                         .and_then(serde_json::Value::as_u64)
     375           10 :                         .unwrap_or(0)
     376           10 :                         .saturating_add(1);
     377           10 :                     letter.insert("attempts".into(), n.into());
     378           10 :                     letter.insert("lastError".into(), serde_json::Value::String(w));
     379           10 :                     letter.insert("lastAt".into(), serde_json::Value::String(ts));
     380           10 :                     Ok::<(), NgsiError>(())
     381           12 :                 })
     382           12 :                 .await;
     383           12 :             (
     384           12 :                 StatusCode::BAD_GATEWAY,
     385           12 :                 axum::Json(serde_json::json!({"detail": why, "id": id})),
     386           12 :             )
     387           12 :                 .into_response()
     388              :         }
     389              :     }
     390           34 : }
     391              : 
     392              : /// Drop one dead letter without replaying it: 204 when it was there, 404
     393              : /// when it was not. The letter is the only copy of a notification the
     394              : /// delivery policy gave up on, so this is the one route that discards it.
     395           18 : pub(super) async fn dead_letter_delete(
     396           18 :     axum::extract::State(st): axum::extract::State<AppState>,
     397           18 :     axum::extract::Path(id): axum::extract::Path<String>,
     398           18 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
     399           18 : ) -> Response {
     400           18 :     let tenant = match admin_tenant(&q) {
     401           16 :         Ok(t) => t,
     402            2 :         Err(e) => return ApiError::from(e).into_response(),
     403              :     };
     404           16 :     match st
     405           16 :         .store
     406           16 :         .delete(&tenant, antares_store::Kind::DeadLetter, &id)
     407           16 :         .await
     408              :     {
     409            2 :         Ok(true) => StatusCode::NO_CONTENT.into_response(),
     410              :         Ok(false) => {
     411           14 :             ApiError::from(NgsiError::ResourceNotFound(format!("dead letter {id}"))).into_response()
     412              :         }
     413            0 :         Err(e) => ApiError::from(e).into_response(),
     414              :     }
     415           18 : }
     416              : 
     417              : /// Tenant inventory: the names the backends know, sorted. Names only —
     418              : /// at the 10 000-tenant target (ADR-0001) a list carrying per-kind counts
     419              : /// would cost a count per kind per tenant, so a client picks a name here and
     420              : /// reads its detail from `GET /q/tenants/{tenant}`. Admin surface, never
     421              : /// under the API root.
     422           28 : pub(super) async fn tenants_list(
     423           28 :     axum::extract::State(st): axum::extract::State<AppState>,
     424           28 : ) -> Response {
     425           28 :     match st.store.tenant_ids().await {
     426           28 :         Ok(ids) => (StatusCode::OK, axum::Json(ids)).into_response(),
     427            0 :         Err(e) => crate::negotiate::ApiError::from(e).into_response(),
     428              :     }
     429           28 : }
     430              : 
     431              : /// What one tenant holds: 400 for a name no request could carry, 404 when it
     432              : /// does not exist (5.5.10 keeps the default Tenant existing even when
     433              : /// empty), 200 with its counts otherwise. The path names the tenant; the
     434              : /// NGSILD-Tenant header never redirects the read.
     435           50 : pub(super) async fn tenant_get(
     436           50 :     axum::extract::State(st): axum::extract::State<AppState>,
     437           50 :     axum::extract::Path(raw): axum::extract::Path<String>,
     438           50 : ) -> Response {
     439              :     use crate::negotiate::ApiError;
     440           50 :     let bad = || {
     441           10 :         ApiError::from(NgsiError::BadRequestData(format!(
     442           10 :             "invalid tenant: {raw:?}"
     443           10 :         )))
     444           10 :         .into_response()
     445           10 :     };
     446              :     // the broker's internal snapshot tenants are not addressable
     447           50 :     if reserved_tenant(&raw) {
     448            8 :         return bad();
     449           42 :     }
     450           42 :     let Ok(tenant) = TenantId::new(&raw) else {
     451            2 :         return bad();
     452              :     };
     453           40 :     let stats = match st.store.tenant_stats_one(&tenant).await {
     454           32 :         Ok(Some(s)) => s,
     455              :         Ok(None) => {
     456            8 :             return ApiError::from(NgsiError::ResourceNotFound(format!("tenant {raw}")))
     457            8 :                 .into_response()
     458              :         }
     459            0 :         Err(e) => return ApiError::from(e).into_response(),
     460              :     };
     461              :     // a tenant with a long history counts for seconds: bounded like the
     462              :     // size scans, and a count that did not finish is left out, never 0
     463           32 :     let instances = match st
     464           32 :         .temporal
     465           32 :         .attr_instance_count_within(&tenant, TENANT_COUNT_BOUND)
     466           32 :         .await
     467              :     {
     468           30 :         Ok(n) => Some(n),
     469            2 :         Err(e) => {
     470            2 :             tracing::warn!(tenant = tenant.as_str(), error = %e, "tenant history not counted");
     471            2 :             None
     472              :         }
     473              :     };
     474           32 :     let bytes = tenant_bytes(&st, &tenant).await;
     475           32 :     let mut row = serde_json::json!({
     476           32 :         "tenant": stats.tenant,
     477           32 :         "counts": {
     478           32 :             "entities": stats.entities,
     479           32 :             "subscriptions": stats.subscriptions,
     480           32 :             "csourceSubscriptions": stats.csource_subscriptions,
     481           32 :             "registrations": stats.registrations,
     482           32 :             "snapshots": stats.snapshots,
     483           32 :             "entityMaps": stats.entity_maps,
     484           32 :             "distSubs": stats.dist_subs,
     485              :         },
     486              :     });
     487           30 :     if let (Some(n), Some(counts)) = (
     488           32 :         instances,
     489           32 :         row.get_mut("counts")
     490           32 :             .and_then(serde_json::Value::as_object_mut),
     491           30 :     ) {
     492           30 :         counts.insert("attrInstances".into(), n.into());
     493           30 :     }
     494           32 :     if let Some(row) = row.as_object_mut() {
     495           32 :         if let Some(c) = stats.created_at {
     496            0 :             row.insert("createdAt".into(), serde_json::Value::String(c));
     497           32 :         }
     498           32 :         if let Some(b) = bytes {
     499           32 :             row.insert("bytes".into(), b);
     500           32 :         }
     501            0 :     }
     502           32 :     (StatusCode::OK, axum::Json(row)).into_response()
     503           50 : }
     504              : 
     505              : /// How long `GET /q/tenants/{tenant}` waits for the history count,
     506              : /// the same bound as one size scan.
     507              : pub const TENANT_COUNT_BOUND: std::time::Duration = std::time::Duration::from_secs(5);
     508              : 
     509              : /// How long `GET /q/tenants/{tenant}` reuses a measured size.
     510              : pub const TENANT_BYTES_TTL: std::time::Duration = std::time::Duration::from_secs(300);
     511              : /// How long a size that could not be measured, or is being measured, stays
     512              : /// unanswered before the next read tries again.
     513              : pub const TENANT_BYTES_RETRY: std::time::Duration = std::time::Duration::from_secs(60);
     514              : 
     515              : /// One tenant's entry in [`crate::state::Shared::tenant_bytes`]: when it was
     516              : /// written and the size, `None` while it is measured or after it failed.
     517              : pub type BytesEntry = (std::time::Instant, Option<serde_json::Value>);
     518              : 
     519              : /// What a size read takes from the cache: `Some(answer)` while an entry is
     520              : /// fresh (a measured size for [`TENANT_BYTES_TTL`], a failure or a
     521              : /// measurement in flight for [`TENANT_BYTES_RETRY`]), or `None` after
     522              : /// claiming the measurement — the claim is an in-flight entry, so concurrent
     523              : /// misses scan once and the others answer without a size.
     524           60 : pub(super) fn bytes_cached_or_claim(
     525           60 :     map: &mut std::collections::HashMap<String, BytesEntry>,
     526           60 :     tenant: &str,
     527           60 :     now: std::time::Instant,
     528           60 : ) -> Option<Option<serde_json::Value>> {
     529           60 :     if let Some((at, v)) = map.get(tenant) {
     530           22 :         let ttl = if v.is_some() {
     531           10 :             TENANT_BYTES_TTL
     532              :         } else {
     533           12 :             TENANT_BYTES_RETRY
     534              :         };
     535           22 :         if now.saturating_duration_since(*at) < ttl {
     536           14 :             return Some(v.clone());
     537            8 :         }
     538           38 :     }
     539           46 :     map.insert(tenant.to_owned(), (now, None));
     540           46 :     None
     541           60 : }
     542              : 
     543              : /// `{entities, attrInstances, byType}` in bytes, measured in the store under
     544              : /// the store's own statement bound and kept as [`bytes_cached_or_claim`]
     545              : /// says. `None` when the backend cannot measure, the scan failed or another
     546              : /// read is measuring: the counts still answer, without a size.
     547           32 : pub(super) async fn tenant_bytes(st: &AppState, tenant: &TenantId) -> Option<serde_json::Value> {
     548           62 :     let cache = || {
     549           62 :         st.tenant_bytes
     550           62 :             .lock()
     551           62 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
     552           62 :     };
     553            2 :     if let Some(answer) =
     554           32 :         bytes_cached_or_claim(&mut cache(), tenant.as_str(), std::time::Instant::now())
     555              :     {
     556            2 :         return answer;
     557           30 :     }
     558           30 :     let measured = async {
     559           30 :         let entities = st.store.tenant_bytes(tenant).await?;
     560           30 :         let instances = st.temporal.attr_instance_bytes(tenant).await?;
     561           30 :         Ok::<_, NgsiError>(serde_json::json!({
     562           30 :             "entities": entities.entities,
     563           30 :             "attrInstances": instances,
     564           30 :             "byType": entities.by_type,
     565           30 :         }))
     566           30 :     }
     567           30 :     .await;
     568           30 :     let v = match measured {
     569           30 :         Ok(v) => Some(v),
     570            0 :         Err(NgsiError::OperationNotSupported(_)) => None,
     571            0 :         Err(e) => {
     572            0 :             tracing::warn!(tenant = tenant.as_str(), error = %e, "tenant size not measured");
     573            0 :             None
     574              :         }
     575              :     };
     576           30 :     cache().insert(
     577           30 :         tenant.as_str().to_owned(),
     578           30 :         (std::time::Instant::now(), v.clone()),
     579           30 :     );
     580           30 :     v
     581           32 : }
     582              : 
     583              : /// Purge one tenant from both backends: 400 for a name no request could
     584              : /// carry, 404 when it does not exist (5.5.10), 409 while it still holds
     585              : /// distributed subscriptions (unsubscribe them first), 204 once every
     586              : /// document of it is gone. Deleted subscriptions and registrations are
     587              : /// announced to the bus mirrors the way an API delete is.
     588           58 : pub(super) async fn tenant_purge(
     589           58 :     axum::extract::State(st): axum::extract::State<AppState>,
     590           58 :     axum::extract::Path(raw): axum::extract::Path<String>,
     591           58 : ) -> Response {
     592              :     use crate::negotiate::ApiError;
     593              :     use antares_store::Kind;
     594           58 :     let bad = || {
     595           18 :         ApiError::from(NgsiError::BadRequestData(format!(
     596           18 :             "invalid tenant: {raw:?}"
     597           18 :         )))
     598           18 :         .into_response()
     599           18 :     };
     600           58 :     if reserved_tenant(&raw) {
     601            8 :         return bad();
     602           50 :     }
     603           50 :     let Ok(tenant) = TenantId::new(&raw) else {
     604           10 :         return bad();
     605              :     };
     606           40 :     match st.store.tenant_exists(&tenant).await {
     607           30 :         Ok(true) => {}
     608              :         Ok(false) => {
     609           10 :             return ApiError::from(NgsiError::ResourceNotFound(format!("tenant {raw}")))
     610           10 :                 .into_response()
     611              :         }
     612            0 :         Err(e) => return ApiError::from(e).into_response(),
     613              :     }
     614              :     // The paged walk, not `list`: the all-at-once read carries a ceiling
     615              :     // (the Postgres arm refuses past MAX_UNDECIDED_ROWS), and a purge behind
     616              :     // that ceiling means the Tenants most worth reclaiming are the ones that
     617              :     // can never be reclaimed — while their rows stay readable to anyone
     618              :     // sending the Tenant header.
     619           84 :     async fn ids(st: &AppState, tenant: &TenantId, kind: Kind) -> Result<Vec<String>, NgsiError> {
     620           84 :         let mut out = Vec::new();
     621           84 :         crate::csource::walk_docs(st, tenant, kind, |doc| {
     622           20 :             if let Some(id) = doc.get("id").and_then(|v| v.as_str()) {
     623           20 :                 out.push(id.to_owned());
     624           20 :             }
     625           20 :             Ok(())
     626           20 :         })
     627           84 :         .await?;
     628           84 :         Ok(out)
     629           84 :     }
     630           30 :     let run = async {
     631              :         // 5.8.1.4 stores one mapping document per distributed Subscription;
     632              :         // its `remotes` names the subscription copies that live AT context
     633              :         // sources. Those are deleted at their source by deleting the
     634              :         // Subscription (5.8.5.4), which a purge does not do, so a tenant
     635              :         // still holding them is refused rather than orphaning them. The
     636              :         // mapping shell of a Subscription no source has matched yet, and
     637              :         // the internal Registration Subscription beside it, hold nothing
     638              :         // remote and block nothing — the id-shaped read this used to do
     639              :         // saw neither, since a mapping document carries no `id` member.
     640           30 :         let mut remote_copies = false;
     641           38 :         crate::csource::walk_docs(&st, &tenant, Kind::DistSub, |doc| {
     642           34 :             if doc
     643           34 :                 .get("remotes")
     644           34 :                 .and_then(serde_json::Value::as_object)
     645           34 :                 .is_some_and(|m| !m.is_empty())
     646            2 :             {
     647            2 :                 remote_copies = true;
     648           32 :             }
     649           34 :             Ok(())
     650           34 :         })
     651           30 :         .await?;
     652           30 :         if remote_copies {
     653            2 :             return Err(NgsiError::Conflict(
     654            2 :                 "tenant holds active distributed subscriptions".into(),
     655            2 :             ));
     656           28 :         }
     657           28 :         let subs = ids(&st, &tenant, Kind::Subscription).await?;
     658           28 :         let csubs = ids(&st, &tenant, Kind::CSourceSubscription).await?;
     659           28 :         let regs = ids(&st, &tenant, Kind::Registration).await?;
     660              :         // 5.2.41: a Snapshot's isolated copy does not live under the Tenant.
     661              :         // It lives under the synthetic tenant its internal `__tenant` member
     662              :         // names, and the Snapshot document is the only pointer to it — so the
     663              :         // two purges below would delete that pointer and leave the copy
     664              :         // behind, reachable by nothing and freeable by nothing. Each snapshot
     665              :         // goes through the same removal a DELETE of it does, first.
     666           28 :         let mut snaps = Vec::new();
     667           28 :         crate::csource::walk_docs(&st, &tenant, Kind::Snapshot, |meta| {
     668            2 :             snaps.push(meta);
     669            2 :             Ok(())
     670            2 :         })
     671           28 :         .await?;
     672           28 :         for meta in &snaps {
     673            2 :             if let Some(id) = meta.get("id").and_then(|v| v.as_str()) {
     674            2 :                 crate::snapshots::snap_remove(&st, &tenant, id, meta).await;
     675            0 :             }
     676              :         }
     677              :         // 5.13.1: a Hosted @context belongs to the Tenant that stored it.
     678              :         // Its rows go first, while the store still lists them: this pass
     679              :         // also drops the loader's warm copy of each, which the store purge
     680              :         // below cannot reach (run after it, the pass listed
     681              :         // nothing and a Tenant recreated under the name resolved the old
     682              :         // term mappings).
     683           28 :         crate::contexts::purge_tenant(&st, &tenant).await?;
     684           28 :         st.temporal.purge_tenant(&tenant).await?;
     685           28 :         st.store.purge_tenant(&tenant).await?;
     686           28 :         st.known_tenants.forget(&tenant);
     687           28 :         if let Some(sync) = &st.sub_sync {
     688              :             // Kept apart: the mirror entry is keyed per kind, so one id
     689              :             // naming both a Subscription and a Context Source Registration
     690              :             // Subscription needs a tombstone for each.
     691           18 :             for id in &subs {
     692           14 :                 sync(&tenant, Kind::Subscription, id, None);
     693           14 :             }
     694           18 :             for id in &csubs {
     695            0 :                 sync(&tenant, Kind::CSourceSubscription, id, None);
     696            0 :             }
     697           10 :         }
     698           28 :         if let Some(sync) = &st.reg_sync {
     699           18 :             for id in &regs {
     700            0 :                 sync(&tenant, id, None);
     701            0 :             }
     702           10 :         }
     703           28 :         Ok::<(), NgsiError>(())
     704           30 :     };
     705           30 :     if let Err(e) = run.await {
     706            2 :         return ApiError::from(e).into_response();
     707           28 :     }
     708           28 :     st.tenant_bytes
     709           28 :         .lock()
     710           28 :         .unwrap_or_else(std::sync::PoisonError::into_inner)
     711           28 :         .remove(tenant.as_str());
     712           28 :     StatusCode::NO_CONTENT.into_response()
     713           58 : }
     714              : 
     715              : /// Readiness, distinct from `/q/health` liveness: ready = not draining AND
     716              : /// the store answers a trivial request AND (when bus=nats) the bus is
     717              : /// connected. On a Postgres failover or a NATS partition the pod is still
     718              : /// alive (liveness stays 200 — a restart fixes nothing) but must stop
     719              : /// receiving traffic, so the readinessProbe points HERE.
     720           16 : pub(super) async fn ready(
     721           16 :     axum::extract::State(state): axum::extract::State<AppState>,
     722           16 : ) -> (StatusCode, axum::Json<serde_json::Value>) {
     723           16 :     let draining = state.draining.load(std::sync::atomic::Ordering::Relaxed);
     724           16 :     let store_ok = state.store.ping().await.is_ok();
     725           16 :     let bus = state.bus_stats.as_ref().map(|b| b());
     726           16 :     let bus_ok = bus
     727           16 :         .as_ref()
     728           16 :         .is_none_or(|b| b.get("connected").and_then(serde_json::Value::as_bool) == Some(true));
     729           16 :     let ready = !draining && store_ok && bus_ok;
     730           16 :     let mut body = serde_json::json!({
     731           16 :         "status": if ready { "READY" } else { "NOT_READY" },
     732           16 :         "store": store_ok,
     733              :     });
     734           16 :     if let (Some(b), Some(body)) = (bus, body.as_object_mut()) {
     735            4 :         body.insert("bus".into(), b);
     736           12 :     }
     737           16 :     let code = if ready {
     738           12 :         StatusCode::OK
     739              :     } else {
     740            4 :         StatusCode::SERVICE_UNAVAILABLE
     741              :     };
     742           16 :     (code, axum::Json(body))
     743           16 : }
     744              : 
     745              : /// CIM 009 5.15.1 / 6.33 — Context Source identity.
     746              : /// 5.15.1 Retrieve Context Source Identity Information: the 5.2.40
     747              : /// ContextSourceIdentity object for this source (per tenant in the
     748              : /// multi-tenancy case). The NotImplemented arm of 5.15.1.4 is vacuous here —
     749              : /// this broker can always supply its identity.
     750           56 : pub(super) async fn source_identity(
     751           56 :     axum::extract::State(state): axum::extract::State<AppState>,
     752           56 :     headers: HeaderMap,
     753           56 : ) -> Response {
     754           56 :     let go = async {
     755           56 :         let tenant = tenant_from(&headers)?;
     756           56 :         gate!(state, &tenant, &headers, "5.15.1").await?;
     757           56 :         let accept = negotiate::parse_accept(&headers)?;
     758           56 :         let ctx = state.loader.core();
     759           56 :         let uptime = state.started.elapsed().as_secs();
     760              :         // Table 5.2.40-1: contextSourceAlias, contextSourceUptime and
     761              :         // contextSourceTimeAt are all cardinality 1. `hostAlias`/`uptime` were
     762              :         // not spec members at all — neither expands to an NGSI-LD IRI, so the
     763              :         // payload was not valid JSON-LD against the core context either.
     764              :         // Table 5.2.40-1: in the multi-tenancy case the alias "shall be
     765              :         // identifying a specific Tenant within a registered Context Source",
     766              :         // so what this resource serves depends on NGSILD-Tenant — a peer
     767              :         // retrieves it per tenant and registers it as `contextSourceAlias`.
     768           56 :         let alias = crate::federation::alias_for(&state.host_alias, &tenant);
     769           56 :         let body = serde_json::json!({
     770           56 :             "id": format!("urn:ngsi-ld:ContextSourceIdentity:{alias}"),
     771           56 :             "type": "ContextSourceIdentity",
     772           56 :             "contextSourceAlias": alias,
     773           56 :             "contextSourceUptime": format!("PT{uptime}S"),
     774           56 :             "contextSourceTimeAt": crate::state::now_iso(),
     775              :         });
     776           56 :         Ok::<_, ApiError>(respond(
     777           56 :             axum::http::StatusCode::OK,
     778           56 :             body,
     779           56 :             &ctx,
     780           56 :             accept,
     781           56 :             &tenant,
     782           56 :         ))
     783           56 :     };
     784           56 :     go.await.unwrap_or_else(|e| e.into_response())
     785           56 : }
        

Generated by: LCOV version 2.0-1