LCOV - code coverage report
Current view: top level - antares-broker/src - main.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 90.1 % 816 735
Test Date: 2026-09-21 10:31:06 Functions: 59.9 % 267 160

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Antares — NGSI-LD context broker (composition root).
       3              : //!
       4              : //! Config: ANTARES_* env vars only for v0 (antares.toml layering can land
       5              : //! later via figment). Unknown ANTARES_* keys are fatal.
       6              : #![cfg_attr(not(test), warn(clippy::expect_used))]
       7              : 
       8              : mod shutdown;
       9              : mod telemetry;
      10              : mod wiring;
      11              : 
      12              : use antares_api::AppState;
      13              : 
      14              : // ANTARES_DATABASE_URL: accepted — the ETSI compose wires one DB per broker —
      15              : // consumed by the postgres/timescale store modes.
      16              : const KNOWN_KEYS: &[&str] = &[
      17              :     "ANTARES_HTTP_PORT",
      18              :     "ANTARES_HOST_ALIAS",
      19              :     // 5.8.1.4 distributed subscriptions: the public base URL other brokers
      20              :     // reach this one at (the reduced-copy notification endpoint); defaults
      21              :     // to http://{host_alias}.
      22              :     "ANTARES_PUBLIC_URL",
      23              :     "ANTARES_ROLES",
      24              :     // The example policy engine's rules document; accepted only in a build
      25              :     // that carries the engine, so a release binary rejects it like any
      26              :     // other key it has no code for.
      27              :     #[cfg(feature = "plugin-example")]
      28              :     antares_plugin_example::RULES_ENV,
      29              :     "ANTARES_DATABASE_URL",
      30              :     "ANTARES_STORE",
      31              :     // Temporal driver: a store mode, or `none` — history off (temporal
      32              :     // reads answer OperationNotSupported, Table 6.3.2-1). Defaults to the
      33              :     // current-state store, so one instance serves both seams.
      34              :     "ANTARES_TEMPORAL",
      35              :     // History gate: `all` (default) records every changed instance;
      36              :     // `observed` records only instances carrying observedAt; `none`
      37              :     // auto-records nothing (temporal API + reads stay on).
      38              :     "ANTARES_TEMPORAL_RECORD",
      39              :     "ANTARES_DATA_DIR",
      40              :     // Egress: private-range destinations are ALLOWED by default (ADR-0010 —
      41              :     // brokers federate inside private networks); a hardened deployment sets
      42              :     // this to false to arm the SSRF wall.
      43              :     "ANTARES_EGRESS_ALLOW_PRIVATE",
      44              :     // Refuse to start when the DB role bypasses RLS (production gate;
      45              :     // default off so the dev/ETSI superuser stack still boots).
      46              :     "ANTARES_REQUIRE_RLS",
      47              :     // Temporal retention horizon in days; absent = keep forever (a
      48              :     // maintenance job must never default to dropping data).
      49              :     "ANTARES_TEMPORAL_RETENTION_DAYS",
      50              :     // 4.22 GC interval (memory/file arm); default 900 s, the ETSI stack runs
      51              :     // at 2 s so the transient TPs (422_01) exercise the sweep itself.
      52              :     "ANTARES_SWEEP_SECS",
      53              :     // Batch entity-count cap; default 1000 — raised where a
      54              :     // trusted producer legitimately batches larger (the spec sets no ceiling).
      55              :     "ANTARES_MAX_BATCH_ITEMS",
      56              :     "ANTARES_MAX_BODY_BYTES",
      57              :     "ANTARES_CORS_ORIGINS",
      58              :     // The policy engine every operation is asked about, and how long it has
      59              :     // to answer before the seam denies (ADR-0020). The built-in allow-all
      60              :     // engine decides nothing and never waits; the header list is what an
      61              :     // engine is given to identify the caller by.
      62              :     "ANTARES_POLICY",
      63              :     "ANTARES_POLICY_SUBJECT_HEADERS",
      64              :     "ANTARES_POLICY_TIMEOUT_MS",
      65              :     // The HTTP surfaces mounted beside the NGSI-LD API root, comma-separated;
      66              :     // default `admin` (/q). An unknown name is fatal and names the shelf.
      67              :     "ANTARES_API_SURFACES",
      68              :     // Drain: the LB notice window, and the ceiling on waiting for
      69              :     // in-flight requests once the listener has closed.
      70              :     "ANTARES_DRAIN_DELAY_MS",
      71              :     "ANTARES_DRAIN_DEADLINE_SECS",
      72              :     // Optional PEM bundle of extra TLS trust anchors (private CAs,
      73              :     // incomplete-chain servers). Never disables verification.
      74              :     "ANTARES_EXTRA_CA_FILE",
      75              :     // The bus seam: local (default, single process, all roles) or
      76              :     // nats (the JetStream spine — requires a postgres/timescale store and
      77              :     // ANTARES_NATS_URL).
      78              :     "ANTARES_BUS",
      79              :     "ANTARES_NATS_URL",
      80              :     // Stream/KV replication factor on a clustered JetStream (3 for
      81              :     // the reference manifests' R3; default 1 for single-node).
      82              :     "ANTARES_NATS_REPLICAS",
      83              :     // OTLP/HTTP span export endpoint (e.g. http://collector:4318/v1/traces);
      84              :     // unset = no OTLP anywhere.
      85              :     "ANTARES_OTLP_ENDPOINT",
      86              :     "ANTARES_TELEMETRY",
      87              :     // Outbox drain on this pod, on (default) | off. `off` is the
      88              :     // crash-drill lever (rows commit but this pod never publishes them —
      89              :     // another pod's drain must) and the knob for a dedicated-drainer split.
      90              :     "ANTARES_OUTBOX_DRAIN",
      91              :     // Notification delivery policy: total attempts (default 1 = 5.8.6 as
      92              :     // written), first-retry backoff, and the age after which no retry
      93              :     // starts. An exhausted policy leaves a dead letter (/q/dead-letters).
      94              :     "ANTARES_NOTIFY_ATTEMPTS",
      95              :     "ANTARES_NOTIFY_BACKOFF_MS",
      96              :     "ANTARES_NOTIFY_MAX_AGE_SECS",
      97              :     // Postgres pool size (max connections); default 20.
      98              :     "ANTARES_PG_POOL",
      99              :     "ANTARES_PG_STATEMENT_TIMEOUT_MS",
     100              :     // bus=local over a shared postgres/timescale store is refused — every
     101              :     // replica would run its own matcher and fire its own copy of each
     102              :     // notification. This opt-in states the deployment runs exactly ONE
     103              :     // broker process against that database.
     104              :     "ANTARES_ALLOW_SHARED_LOCAL",
     105              :     // HTTP/1 header read timeout in ms (default 10000): a connection that
     106              :     // never finishes its request headers is closed instead of holding a
     107              :     // slot forever.
     108              :     "ANTARES_HEADER_READ_TIMEOUT_MS",
     109              :     // Ceiling on concurrently served connections (default 10000);
     110              :     // connections accepted above it are dropped immediately.
     111              :     "ANTARES_MAX_CONNECTIONS",
     112              :     // 5.8.6 delivery concurrency: notifications in flight at once for the
     113              :     // whole broker, and the share of that width one tenant may hold.
     114              :     "ANTARES_DELIVERY_WIDTH",
     115              :     "ANTARES_DELIVERY_WIDTH_PER_TENANT",
     116              :     // 5.7.2.4 fan-out ceiling: how many matching registrations one
     117              :     // distributed operation may contact.
     118              :     "ANTARES_FED_FANOUT",
     119              :     "ANTARES_FED_INFLIGHT",
     120              :     // Ceiling on the body this broker will read back from a forwarded
     121              :     // request.
     122              :     "ANTARES_MAX_FED_RESPONSE_BYTES",
     123              :     // 5.7.5/5.7.6 discovery scan ceiling (types/attributes listing).
     124              :     "ANTARES_DISCOVERY_SCAN_MAX",
     125              :     // Run the DDL on this process; off keeps replicas from racing the
     126              :     // migration on boot.
     127              :     "ANTARES_MIGRATE",
     128              : ];
     129              : 
     130              : /// Jemalloc with decay-based purging — RSS returns to ~live×1.2 when idle;
     131              : /// tune via MALLOC_CONF (e.g. dirty_decay_ms). Deliberately not glibc
     132              : /// malloc, whose arena fragmentation never gives memory back.
     133              : #[global_allocator]
     134              : static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
     135              : 
     136              : /// Unknown-config-is-fatal, minus what the platform injects: a Service
     137              : /// named `antares*` makes kubelet write ANTARES_PORT,
     138              : /// ANTARES_PORT_9090_TCP*, ANTARES_SERVICE_* (and the antares-file /
     139              : /// antares-api variants) into every pod, and treating those as typos put the
     140              : /// shipped manifests into 100% CrashLoopBackOff. The manifests also set
     141              : /// enableServiceLinks: false — this check is the belt for clusters that
     142              : /// re-enable links or add their own Services.
     143         8516 : fn unknown_config_key(key: &str) -> bool {
     144         8516 :     if !key.starts_with("ANTARES_") || key.starts_with("ANTARES_TEST_") {
     145         8099 :         return false;
     146          417 :     }
     147          417 :     if KNOWN_KEYS.contains(&key) {
     148          385 :         return false;
     149           32 :     }
     150              :     // kubelet service-link shapes for the Services OUR manifests ship
     151              :     // (antares, antares-file, antares-api, antares-worker): {NAME}_PORT,
     152              :     // {NAME}_PORT_<n>_<proto>*, {NAME}_SERVICE_*. Only those exact name
     153              :     // infixes are exempt — an arbitrary ANTARES_*-shaped var stays a fatal
     154              :     // typo, and foreign Services are covered by
     155              :     // enableServiceLinks: false in the manifests.
     156           32 :     let rest = &key["ANTARES_".len()..];
     157           92 :     let injected = ["", "FILE_", "API_", "WORKER_"].iter().any(|infix| {
     158           92 :         rest.strip_prefix(infix)
     159           92 :             .is_some_and(|t| t == "PORT" || t.starts_with("PORT_") || t.starts_with("SERVICE_"))
     160           92 :     });
     161           32 :     !injected
     162         8516 : }
     163              : 
     164              : /// ANTARES_SWEEP_SECS paces the 4.22 expiry sweep in every store mode. Absent
     165              : /// is the 15 min default; anything that is not a positive integer is fatal,
     166              : /// because a garbage cadence silently becoming the default one is exactly the
     167              : /// misconfiguration the unknown-key policy exists to catch.
     168           69 : fn parse_sweep_secs(raw: Option<&str>) -> Result<u64, String> {
     169           69 :     let Some(v) = raw else {
     170           45 :         return Ok(15 * 60);
     171              :     };
     172           24 :     match v.parse::<u64>() {
     173           18 :         Ok(0) | Err(_) => Err(format!(
     174           18 :             "ANTARES_SWEEP_SECS must be a positive integer number of seconds, got {v:?}"
     175           18 :         )),
     176            6 :         Ok(n) => Ok(n),
     177              :     }
     178           69 : }
     179              : 
     180              : /// An explicitly-off switch value, whatever the operator's spelling. Used by
     181              : /// the knobs where the DEFAULT is off, so that only an off value keeps them
     182              : /// off and a typo cannot silently disable a security control.
     183           74 : fn is_off(v: &str) -> bool {
     184           74 :     let v = v.trim();
     185           74 :     v.is_empty()
     186           66 :         || v == "0"
     187           58 :         || v.eq_ignore_ascii_case("false")
     188           48 :         || v.eq_ignore_ascii_case("off")
     189           40 :         || v.eq_ignore_ascii_case("no")
     190           74 : }
     191              : 
     192              : /// One plain HTTP/1.0 GET of /q/health on the configured port; anything but
     193              : /// a 200 status line is an error, so `HEALTHCHECK` sees exit 1.
     194            6 : fn health_probe() -> Result<(), Box<dyn std::error::Error>> {
     195              :     use std::io::{Read, Write};
     196            6 :     let port = std::env::var("ANTARES_HTTP_PORT").unwrap_or_else(|_| "9090".into());
     197            6 :     let timeout = std::time::Duration::from_secs(3);
     198            6 :     let addr = format!("127.0.0.1:{port}").parse::<std::net::SocketAddr>()?;
     199            6 :     let mut s = std::net::TcpStream::connect_timeout(&addr, timeout)?;
     200            2 :     s.set_read_timeout(Some(timeout))?;
     201            2 :     s.write_all(b"GET /q/health HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
     202            2 :     let mut head = [0u8; 16];
     203            2 :     s.read_exact(&mut head)?;
     204            2 :     if head.starts_with(b"HTTP/1.1 200") || head.starts_with(b"HTTP/1.0 200") {
     205            2 :         Ok(())
     206              :     } else {
     207            0 :         Err(format!("health: {}", String::from_utf8_lossy(&head)).into())
     208              :     }
     209            6 : }
     210              : 
     211           65 : fn main() -> Result<(), Box<dyn std::error::Error>> {
     212              :     // --version answers without starting anything (a bare `antares
     213              :     // --version` used to boot a server).
     214              :     // `args()`/`vars()` PANIC on non-UTF-8; the *_os variants do not, and a
     215              :     // stray byte in the environment or argv must not kill the process.
     216           73 :     if std::env::args_os().any(|a| a == "--version" || a == "-V") {
     217            0 :         println!(
     218              :             "antares {} ({})",
     219              :             env!("CARGO_PKG_VERSION"),
     220              :             antares_api::GIT_HASH
     221              :         );
     222            0 :         return Ok(());
     223           65 :     }
     224           73 :     if std::env::args_os().any(|a| a == "--help" || a == "-h") {
     225            2 :         println!(
     226              :             "antares {} — NGSI-LD context broker (ETSI GS CIM 009 V1.9.1)\n\n\
     227              :              Usage: antares [--version | --health | --help]\n\n\
     228              :              Configuration is environment only; every accepted key is listed\n\
     229              :              below and documented with its default in docs/src/configuration.md\n\
     230              :              (an unknown ANTARES_* key is fatal at startup).\n",
     231              :             env!("CARGO_PKG_VERSION")
     232              :         );
     233           88 :         for key in KNOWN_KEYS {
     234           88 :             println!("  {key}");
     235           88 :         }
     236            2 :         return Ok(());
     237           63 :     }
     238              :     // --health is the container health probe: the image has no shell or
     239              :     // curl, so the binary asks its own /q/health and exits 0 only on 200.
     240           69 :     if std::env::args_os().any(|a| a == "--health") {
     241            6 :         return health_probe();
     242           57 :     }
     243              :     // reqwest is built provider-less, so the FIRST client anything in this
     244              :     // process builds decides whether it panics — and the OTLP exporter
     245              :     // builds one inside `opentelemetry-http`, before any broker code runs.
     246              :     // Installed here, ahead of telemetry, rather than only in
     247              :     // `client_builder`, which a dependency does not call.
     248           57 :     antares_jsonld::install_crypto_provider();
     249              : 
     250              :     // Tracing (fmt + env-gated OTLP [+ console feature]) and, with the
     251              :     // `telemetry` feature, the Prometheus recorder rendering /q/metrics.
     252           57 :     let metrics_render = telemetry::init()?;
     253              : 
     254              :     // Unknown-config-is-fatal: catch typos before they become Scorpio's
     255              :     // $[quarkus.uuid} class of silent misconfiguration. ANTARES_TEST_* is the
     256              :     // reserved harness namespace (ANTARES_TEST_DATABASE_URL, ANTARES_TEST_MQTT_URL,
     257              :     // …) — CI exports those for the integration tests, and they land in the env of
     258              :     // any broker a test spawns. Reserving the prefix here beats making every
     259              :     // spawn site remember an env_remove allowlist.
     260         8198 :     for (key, _) in std::env::vars_os() {
     261         8198 :         let key = key.to_string_lossy();
     262         8198 :         if unknown_config_key(&key) {
     263            2 :             return Err(format!("unknown config key {key} (known: {KNOWN_KEYS:?})").into());
     264         8196 :         }
     265              :     }
     266              : 
     267           55 :     let port_raw = std::env::var("ANTARES_HTTP_PORT").unwrap_or_else(|_| "9090".into());
     268           55 :     let port: u16 = port_raw.parse().map_err(|e| {
     269            4 :         format!("ANTARES_HTTP_PORT must be a port number 0-65535, got {port_raw:?} ({e})")
     270            4 :     })?;
     271              :     // Every remaining config value is parsed HERE, before the runtime starts,
     272              :     // so a garbage window, cadence or switch fails the process instead of
     273              :     // silently running at its default.
     274           51 :     let sweep_secs = parse_sweep_secs(std::env::var("ANTARES_SWEEP_SECS").ok().as_deref())?;
     275              :     // Same: validated here, read again where the state is built.
     276           47 :     antares_api::DeliveryPolicy::from_env()?;
     277           47 :     let drain_delay = shutdown::drain_delay()?;
     278           45 :     let drain_deadline = shutdown::drain_deadline()?;
     279              :     // Validated here so a typo fails startup; the value itself is read again
     280              :     // where the drain task is wired, which is the only place it is used.
     281           43 :     wiring::outbox_drain_enabled()?;
     282           41 :     let host_alias = std::env::var("ANTARES_HOST_ALIAS").unwrap_or_else(|_| "antares".into());
     283              :     // 6.3.18 sends this as the Via pseudonym — an RFC 7230 token. `~` is
     284              :     // reserved as the tenant separator (federation::alias_for), so allowing
     285              :     // it in the configured alias would let `a~b` in the default tenant
     286              :     // collide with `a` in tenant `b` and cross-detect as a loop. Fatal at
     287              :     // startup, like every other bad config value.
     288           41 :     if host_alias.is_empty()
     289           39 :         || !host_alias
     290           39 :             .bytes()
     291          263 :             .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|".contains(&b))
     292              :     {
     293            4 :         return Err(format!(
     294            4 :             "ANTARES_HOST_ALIAS {host_alias:?} is not a valid RFC 7230 token \
     295            4 :              (and may not contain '~', the tenant separator)"
     296            4 :         )
     297            4 :         .into());
     298           37 :     }
     299           37 :     let roles = std::env::var("ANTARES_ROLES").unwrap_or_else(|_| "all".into());
     300              :     // Unknown store backend is fatal BEFORE the runtime spins up, and the
     301              :     // message names the shelf this binary was built with — never a silent
     302              :     // fallback to memory.
     303           37 :     let store_name = std::env::var("ANTARES_STORE").unwrap_or_else(|_| "memory".into());
     304           37 :     if !store_shelf().contains(&store_name.as_str()) {
     305            4 :         return Err(format!(
     306            4 :             "unknown ANTARES_STORE {store_name:?}; built with {}",
     307            4 :             store_shelf().join("|")
     308            4 :         )
     309            4 :         .into());
     310           33 :     }
     311              :     // bus=local wires an in-process matcher into every process, so N
     312              :     // replicas over ONE shared database each fire their own copy of every
     313              :     // notification. Refused here — before any store connection is attempted
     314              :     // — unless the deployment states it runs exactly one broker process.
     315              :     // (Mirror of the nats arm's store check in `run`.)
     316           33 :     let bus_mode = std::env::var("ANTARES_BUS").unwrap_or_else(|_| "local".into());
     317           33 :     if bus_mode == "local"
     318           31 :         && shared_state(&store_name)
     319            6 :         && !std::env::var("ANTARES_ALLOW_SHARED_LOCAL")
     320            6 :             .is_ok_and(|v| matches!(v.as_str(), "1" | "true"))
     321              :     {
     322            2 :         return Err(format!(
     323            2 :             "ANTARES_BUS=local with ANTARES_STORE={store_name} double-fires notifications when \
     324            2 :              more than one broker process shares the database (each process runs its own \
     325            2 :              matcher). Use ANTARES_BUS=nats, or set ANTARES_ALLOW_SHARED_LOCAL=1 for a \
     326            2 :              strictly single-process deployment"
     327            2 :         )
     328            2 :         .into());
     329           31 :     }
     330              : 
     331           31 :     runtime()?.block_on(async {
     332           31 :         let drivers = build_drivers(&store_name).await?;
     333           24 :         run(
     334           24 :             port,
     335           24 :             host_alias,
     336           24 :             roles,
     337           24 :             drivers.store,
     338           24 :             drivers.temporal,
     339           24 :             store_name,
     340           24 :             drivers.temporal_name,
     341           24 :             drivers.maintenance,
     342           24 :             metrics_render,
     343           24 :             sweep_secs,
     344           24 :             drain_delay,
     345           24 :             drain_deadline,
     346           24 :         )
     347           24 :         .await
     348           31 :     })
     349           65 : }
     350              : 
     351              : /// ANTARES_PG_STATEMENT_TIMEOUT_MS → the per-session `statement_timeout`
     352              : /// every pooled connection carries (a runaway query is cancelled, 5.5.2
     353              : /// InternalError); absent = 30 000; not a positive integer = fatal.
     354           11 : fn parse_pg_statement_timeout(raw: Option<&str>) -> Result<std::time::Duration, String> {
     355           11 :     match raw {
     356            5 :         None => Ok(std::time::Duration::from_secs(30)),
     357            6 :         Some(v) => match v.parse::<u64>() {
     358            4 :             Ok(n) if n > 0 => Ok(std::time::Duration::from_millis(n)),
     359            4 :             _ => Err(format!(
     360            4 :                 "ANTARES_PG_STATEMENT_TIMEOUT_MS must be a positive integer (got {v:?})"
     361            4 :             )),
     362              :         },
     363              :     }
     364           11 : }
     365              : 
     366              : /// ANTARES_PG_POOL → pool size: absent defaults to 20; anything that is not
     367              : /// a positive integer is fatal (a misread size must never silently run with
     368              : /// a default, matching the unknown-key policy).
     369           13 : fn parse_pg_pool(raw: Option<&str>) -> Result<u32, String> {
     370           13 :     match raw {
     371            5 :         None => Ok(20),
     372            8 :         Some(v) => match v.parse::<u32>() {
     373            4 :             Ok(n) if n > 0 => Ok(n),
     374            6 :             _ => Err(format!(
     375            6 :                 "ANTARES_PG_POOL must be a positive integer (got {v:?})"
     376            6 :             )),
     377              :         },
     378              :     }
     379           13 : }
     380              : 
     381              : /// What ANTARES_TEMPORAL resolved to, before anything is built.
     382              : #[derive(Debug, PartialEq, Eq)]
     383              : enum TemporalChoice {
     384              :     /// The current-state store records and serves history too (default).
     385              :     SameAsStore,
     386              :     /// History off: `NoTemporal`.
     387              :     None,
     388              :     /// A second store instance of this backend, used only through its
     389              :     /// temporal half.
     390              :     Second(String),
     391              : }
     392              : 
     393              : /// The current-state backends this binary can build: the built-ins, plus
     394              : /// any driver compiled in from outside this workspace. A list of NAMES and
     395              : /// not an enum, because a plugin's driver is not one of `StoreMode`'s arms
     396              : /// — adding a backend must never mean editing a core crate.
     397           58 : fn store_shelf() -> Vec<&'static str> {
     398              :     // Backends from outside this workspace, in selection order. Each is
     399              :     // compiled in by its own feature; without one the list is empty and the
     400              :     // shelf is exactly `StoreMode`.
     401              :     const PLUGINS: &[&str] = &[
     402              :         #[cfg(feature = "plugin-example")]
     403              :         antares_plugin_example::NAME,
     404              :     ];
     405              :     antares_sql::StoreMode::ALL
     406           58 :         .into_iter()
     407          232 :         .map(|m| m.as_str())
     408           58 :         .chain(PLUGINS.iter().copied())
     409           58 :         .collect()
     410           58 : }
     411              : 
     412              : /// Does this backend keep its state where several broker processes can
     413              : /// reach it? The precondition for `ANTARES_BUS=nats`, and the reason
     414              : /// `bus=local` over one shared database double-fires notifications. Only
     415              : /// the database backends qualify: a per-process store — memory, file, or a
     416              : /// driver from outside the workspace — never does.
     417           41 : fn shared_state(name: &str) -> bool {
     418           41 :     name.parse::<antares_sql::StoreMode>()
     419           41 :         .is_ok_and(|m| m.is_pg())
     420           41 : }
     421              : 
     422              : /// The shelf this binary was built with, rendered from the backend list
     423              : /// rather than spelled out: a backend reaches every message that names the
     424              : /// shelf without a second edit, whether it came from `StoreMode` or from a
     425              : /// crate outside this workspace.
     426            8 : fn built_with() -> String {
     427            8 :     format!("{} (temporal also: none)", store_shelf().join("|"))
     428            8 : }
     429              : 
     430              : /// ANTARES_TEMPORAL → driver choice. Absent or the store's own mode = one
     431              : /// instance for both seams; `none` = no history; any other backend name =
     432              : /// a second store. An unknown name is fatal and names the shelf.
     433           37 : fn temporal_choice(store_name: &str, raw: Option<&str>) -> Result<TemporalChoice, String> {
     434            7 :     match raw {
     435           26 :         None => Ok(TemporalChoice::SameAsStore),
     436           11 :         Some(m) if m == store_name => Ok(TemporalChoice::SameAsStore),
     437            9 :         Some("none") => Ok(TemporalChoice::None),
     438            7 :         Some(other) if store_shelf().contains(&other) => {
     439            3 :             Ok(TemporalChoice::Second(other.to_owned()))
     440              :         }
     441            4 :         Some(other) => Err(format!(
     442            4 :             "ANTARES_TEMPORAL: unknown backend {other:?}; built with {}",
     443            4 :             built_with()
     444            4 :         )),
     445              :     }
     446           37 : }
     447              : 
     448              : /// One entry of the surface shelf: the name a deployment selects it with,
     449              : /// and how to build it.
     450              : type SurfaceCtor = fn() -> Box<dyn antares_api::ApiSurface>;
     451              : 
     452              : /// The HTTP surfaces this binary was built with, outside the NGSI-LD API
     453              : /// root. Every one is compiled in, so the shelf is static; a feature-gated
     454              : /// surface would drop out here.
     455              : const SURFACE_SHELF: &[(&str, SurfaceCtor)] = &[
     456           12 :     ("admin", || Box::new(antares_api::Admin)),
     457              :     #[cfg(feature = "plugin-example")]
     458              :     ("example", || {
     459              :         Box::new(antares_plugin_example::ExampleSurface)
     460              :     }),
     461              : ];
     462              : 
     463              : /// ANTARES_API_SURFACES → the surfaces mounted beside the NGSI-LD API,
     464              : /// comma-separated; absent = `admin`. An unknown name is fatal and names
     465              : /// the shelf, and so is a selection that ends up empty: health, readiness
     466              : /// and metrics are the admin surface, and a pod that serves none of them
     467              : /// can never report itself up.
     468           28 : fn api_surfaces(raw: Option<&str>) -> Result<Vec<(&'static str, SurfaceCtor)>, String> {
     469           28 :     let mut chosen = Vec::new();
     470           28 :     for name in raw
     471           28 :         .unwrap_or("admin")
     472           28 :         .split(',')
     473           28 :         .map(str::trim)
     474           36 :         .filter(|n| !n.is_empty())
     475              :     {
     476           22 :         let entry = SURFACE_SHELF
     477           22 :             .iter()
     478           22 :             .find(|(n, _)| *n == name)
     479           22 :             .ok_or_else(|| {
     480            2 :                 let shelf: Vec<&str> = SURFACE_SHELF.iter().map(|(n, _)| *n).collect();
     481            2 :                 format!(
     482              :                     "ANTARES_API_SURFACES: unknown surface {name:?}; built with {}",
     483            2 :                     shelf.join("|")
     484              :                 )
     485            2 :             })?;
     486           20 :         chosen.push(*entry);
     487              :     }
     488           26 :     if chosen.is_empty() {
     489            8 :         return Err(
     490            8 :             "ANTARES_API_SURFACES selects no surface; /q (health, readiness, metrics) \
     491            8 :                     is the admin surface"
     492            8 :                 .into(),
     493            8 :         );
     494           18 :     }
     495           18 :     Ok(chosen)
     496           28 : }
     497              : 
     498              : /// One entry of the policy shelf: the name a deployment selects the engine
     499              : /// with, and how to build it.
     500              : type PolicyCtor = fn() -> std::sync::Arc<dyn antares_api::policy::PolicyEngine>;
     501              : 
     502              : /// The policy engines this binary was built with (ADR-0020). `allow-all` is
     503              : /// the one the broker ships and the one conformance is asserted against; an
     504              : /// engine from outside this workspace joins the list behind its own
     505              : /// off-by-default feature, exactly as a store or a surface does, and is
     506              : /// absent from a release build.
     507              : const POLICY_SHELF: &[(&str, PolicyCtor)] = &[
     508            0 :     ("allow-all", || {
     509            0 :         std::sync::Arc::new(antares_api::policy::AllowAll)
     510            0 :     }),
     511              :     #[cfg(feature = "plugin-example")]
     512              :     (antares_plugin_example::POLICY_NAME, || {
     513              :         std::sync::Arc::new(antares_plugin_example::ExamplePolicy::from_env())
     514              :     }),
     515              : ];
     516              : 
     517              : /// ANTARES_POLICY → the engine every operation is asked about; absent =
     518              : /// `allow-all`. An unknown name is fatal and names the shelf: a deployment
     519              : /// that meant to run its engine and got a typo would otherwise serve every
     520              : /// request wide open and never know.
     521           20 : fn policy_engine(raw: Option<&str>) -> Result<(&'static str, PolicyCtor), String> {
     522           20 :     let name = raw
     523           20 :         .map(str::trim)
     524           20 :         .filter(|n| !n.is_empty())
     525           20 :         .unwrap_or("allow-all");
     526           20 :     POLICY_SHELF
     527           20 :         .iter()
     528           20 :         .find(|(n, _)| *n == name)
     529           20 :         .copied()
     530           20 :         .ok_or_else(|| {
     531            2 :             let shelf: Vec<&str> = POLICY_SHELF.iter().map(|(n, _)| *n).collect();
     532            2 :             format!(
     533              :                 "ANTARES_POLICY: unknown policy engine {name:?}; built with {}",
     534            2 :                 shelf.join("|")
     535              :             )
     536            2 :         })
     537           20 : }
     538              : 
     539              : /// The backend registry: the two driver seams from their configured names.
     540              : /// Every backend is one arm of `build_store`; the temporal driver is by
     541              : /// default the same instance (history recorded and served by the
     542              : /// current-state store), `none` turns history off (temporal reads answer
     543              : /// OperationNotSupported 422, Table 6.3.2-1; the recorder produces
     544              : /// nothing), and a different backend name builds a second store used only
     545              : /// through its temporal half.
     546           31 : async fn build_drivers(store_name: &str) -> Result<Drivers, Box<dyn std::error::Error>> {
     547           31 :     let built = build_store(store_name, false).await?;
     548              :     // Every pg half gets the maintenance job: partitions, retention and the
     549              :     // 4.22 reap belong to whichever database holds the history.
     550           25 :     let mut maintenance = Vec::from_iter(built.maintenance);
     551           25 :     let raw = std::env::var("ANTARES_TEMPORAL").ok();
     552           25 :     let (temporal, temporal_name) = match temporal_choice(store_name, raw.as_deref())? {
     553              :         TemporalChoice::SameAsStore => {
     554           24 :             let name = built.temporal.supported().then(|| store_name.to_owned());
     555           24 :             (built.temporal.clone(), name)
     556              :         }
     557            0 :         TemporalChoice::None => (
     558            0 :             std::sync::Arc::new(antares_store::NoTemporal)
     559            0 :                 as std::sync::Arc<dyn antares_store::TemporalDriver>,
     560            0 :             None,
     561            0 :         ),
     562            1 :         TemporalChoice::Second(name) => {
     563            1 :             let second = build_store(&name, true).await?;
     564            0 :             maintenance.extend(second.maintenance);
     565            0 :             (second.temporal, Some(name))
     566              :         }
     567              :     };
     568           24 :     Ok(Drivers {
     569           24 :         store: built.store,
     570           24 :         temporal,
     571           24 :         maintenance,
     572           24 :         temporal_name,
     573           24 :     })
     574           31 : }
     575              : 
     576              : /// The two storage seams a running broker holds, and what the maintenance
     577              : /// job and `/q/health` need to know about them.
     578              : struct Drivers {
     579              :     store: std::sync::Arc<dyn antares_store::CurrentStateDriver>,
     580              :     temporal: std::sync::Arc<dyn antares_store::TemporalDriver>,
     581              :     maintenance: Vec<(
     582              :         antares_sql::sqlx::PgPool,
     583              :         antares_sql::store::pg::maintenance::TemporalBackend,
     584              :     )>,
     585              :     /// What `/q/health` calls the history backend; `None` = history off.
     586              :     temporal_name: Option<String>,
     587              : }
     588              : 
     589              : /// One built backend: the two driver seams of a single store instance, and
     590              : /// the Postgres handles its maintenance job needs.
     591              : struct Built {
     592              :     store: std::sync::Arc<dyn antares_store::CurrentStateDriver>,
     593              :     temporal: std::sync::Arc<dyn antares_store::TemporalDriver>,
     594              :     maintenance: Option<(
     595              :         antares_sql::sqlx::PgPool,
     596              :         antares_sql::store::pg::maintenance::TemporalBackend,
     597              :     )>,
     598              : }
     599              : 
     600              : /// One backend by name. `temporal_only` builds an instance whose
     601              : /// current-state half is never served — the second store of a split
     602              : /// `ANTARES_TEMPORAL`. An unknown name is an error naming the shelf.
     603           32 : async fn build_store(name: &str, temporal_only: bool) -> Result<Built, Box<dyn std::error::Error>> {
     604              :     #[cfg(feature = "plugin-example")]
     605              :     if name == antares_plugin_example::NAME {
     606              :         // A driver from outside `crates/` mounts through exactly the two
     607              :         // trait objects a built-in does; nothing downstream can tell them
     608              :         // apart, and its history half is its own — no `temporal_only` flag
     609              :         // to set, because the current-state half is simply never asked.
     610              :         let store = std::sync::Arc::new(antares_plugin_example::ExampleStore::new());
     611              :         return Ok(Built {
     612              :             store: store.clone(),
     613              :             temporal: store,
     614              :             maintenance: None,
     615              :         });
     616              :     }
     617           32 :     let mode: antares_sql::StoreMode = name.parse().map_err(|_| {
     618            0 :         format!(
     619              :             "unknown store backend {name:?}; built with {}",
     620            0 :             store_shelf().join("|")
     621              :         )
     622            0 :     })?;
     623           32 :     let (store, backend) = build_builtin(mode).await?;
     624           25 :     let store = if temporal_only {
     625            0 :         store.temporal_only()
     626              :     } else {
     627           25 :         store
     628              :     };
     629           25 :     let maintenance = match (&store, backend) {
     630            2 :         (antares_sql::store::any::AnyStore::Pg(p), Some(backend)) => {
     631            2 :             Some((p.docs.pool().clone(), backend))
     632              :         }
     633           23 :         _ => None,
     634              :     };
     635           25 :     let store = std::sync::Arc::new(store);
     636           25 :     Ok(Built {
     637           25 :         store: store.clone(),
     638           25 :         temporal: store,
     639           25 :         maintenance,
     640           25 :     })
     641           32 : }
     642              : 
     643              : /// ANTARES_STORE → store construction: `file` requires ANTARES_DATA_DIR
     644              : /// (never a default inside the image); postgres and timescale require
     645              : /// ANTARES_DATABASE_URL, connect ONE shared pool, run the embedded
     646              : /// migrations at start and serve from the Pg backend.
     647           32 : async fn build_builtin(
     648           32 :     mode: antares_sql::StoreMode,
     649           32 : ) -> Result<
     650           32 :     (
     651           32 :         antares_sql::store::any::AnyStore,
     652           32 :         Option<antares_sql::store::pg::maintenance::TemporalBackend>,
     653           32 :     ),
     654           32 :     Box<dyn std::error::Error>,
     655           32 : > {
     656              :     use antares_sql::store::any::{AnyStore, PgBackend};
     657              :     use antares_sql::store::pg::maintenance::TemporalBackend;
     658              :     use antares_sql::store::Store;
     659              :     use antares_sql::StoreMode;
     660           32 :     match mode {
     661           21 :         StoreMode::Memory => Ok((AnyStore::Mem(Store::default()), None)),
     662              :         StoreMode::File => {
     663            6 :             let dir = std::env::var("ANTARES_DATA_DIR").map_err(|_| {
     664            2 :                 "ANTARES_STORE=file requires ANTARES_DATA_DIR (a mounted volume — data \
     665            2 :                  must never live inside the image)"
     666            2 :             })?;
     667            4 :             let dir = std::path::PathBuf::from(dir);
     668            4 :             warn_if_not_mount_point(&dir);
     669            4 :             Ok((AnyStore::Mem(Store::open_file(&dir)?), None))
     670              :         }
     671              :         StoreMode::Postgres | StoreMode::Timescale => {
     672            5 :             let url = std::env::var("ANTARES_DATABASE_URL")
     673            5 :                 .map_err(|_| format!("ANTARES_STORE={mode} requires ANTARES_DATABASE_URL"))?;
     674            3 :             let pool_size = parse_pg_pool(std::env::var("ANTARES_PG_POOL").ok().as_deref())?;
     675            3 :             let statement_timeout = parse_pg_statement_timeout(
     676            3 :                 std::env::var("ANTARES_PG_STATEMENT_TIMEOUT_MS")
     677            3 :                     .ok()
     678            3 :                     .as_deref(),
     679            0 :             )?;
     680              :             // The DB container may still be booting — bounded retry, then die.
     681            3 :             let mut last = String::new();
     682            3 :             for _ in 0..30 {
     683            3 :                 match antares_sql::store::pg::connect_with(&url, pool_size, statement_timeout).await
     684              :                 {
     685            3 :                     Ok(pool) => {
     686              :                         // The temporal backend is what the migrations actually
     687              :                         // BUILT, detected once from the catalog and pinned —
     688              :                         // the maintenance branch can never disagree with the
     689              :                         // DDL on disk, whatever happened to the extension since.
     690            3 :                         let backend =
     691            3 :                             antares_sql::store::pg::maintenance::detect_temporal_backend(&pool)
     692            3 :                                 .await
     693            3 :                                 .map_err(|e| format!("ANTARES_STORE={mode}: {e}"))?;
     694              :                         // Never silently fall back — timescale mode whose
     695              :                         // database is not hypertable-shaped is a config error,
     696              :                         // not a downgrade (extension missing at first boot, or
     697              :                         // installed only after the migrations ran).
     698            3 :                         if mode == StoreMode::Timescale && backend != TemporalBackend::Hypertable {
     699            1 :                             return Err(format!(
     700            1 :                                 "timescale requested (ANTARES_STORE or ANTARES_TEMPORAL) but \
     701            1 :                                  attr_instances is {backend:?} — the timescaledb extension was \
     702            1 :                                  not CREATEd when the migrations first ran. Install it in a fresh \
     703            1 :                                  database (CREATE EXTENSION timescaledb before first boot) or use \
     704            1 :                                  postgres"
     705            1 :                             )
     706            1 :                             .into());
     707            2 :                         }
     708            2 :                         if mode == StoreMode::Postgres && backend == TemporalBackend::Hypertable {
     709            0 :                             tracing::info!(
     710              :                                 "attr_instances is a hypertable (migrations ran with \
     711              :                                  the timescaledb extension present); the plain-mode partition \
     712              :                                  job stands down, retention runs via drop_chunks"
     713              :                             );
     714            2 :                         }
     715              :                         // RLS is a belt only when the role wears it —
     716              :                         // superuser/BYPASSRLS makes every policy inert. Warn
     717              :                         // always; in production set ANTARES_REQUIRE_RLS=1 to turn
     718              :                         // the warning into a hard refusal so a superuser DSN can
     719              :                         // never silently ship (dev/ETSI stacks leave it unset).
     720            2 :                         if antares_sql::store::pg::role_bypasses_rls(&pool).await {
     721              :                             // A gate that only understands two spellings
     722              :                             // fails OPEN on `TRUE`/`yes`/`on`: the operator
     723              :                             // believes RLS is enforced and the broker serves
     724              :                             // with a BYPASSRLS role. Anything but an explicit
     725              :                             // off value turns it on.
     726            2 :                             let strict =
     727            2 :                                 std::env::var("ANTARES_REQUIRE_RLS").is_ok_and(|v| !is_off(&v));
     728            2 :                             if strict {
     729            0 :                                 return Err(
     730            0 :                                     "ANTARES_REQUIRE_RLS=1 but the database role bypasses \
     731            0 :                                      row-level security (superuser or BYPASSRLS) — connect as a \
     732            0 :                                      non-superuser, non-BYPASSRLS role so the RLS tenant-isolation \
     733            0 :                                      backstop is enforced"
     734            0 :                                         .into(),
     735            0 :                                 );
     736            2 :                             }
     737            2 :                             tracing::warn!(
     738              :                                 "database role bypasses row-level security (superuser or \
     739              :                                  BYPASSRLS) — tenant isolation rests on the explicit \
     740              :                                  predicates only; use a non-superuser role in production \
     741              :                                  (set ANTARES_REQUIRE_RLS=1 to enforce)"
     742              :                             );
     743            0 :                         }
     744            2 :                         tracing::info!(
     745              :                             "ANTARES_STORE={mode}: pool up, migrations applied, serving \
     746              :                              from postgres (temporal backend: {backend:?})"
     747              :                         );
     748              :                         // Read once, here: /q/health is polled, so what it
     749              :                         // says about the server is captured at startup and
     750              :                         // never queried on the request.
     751            2 :                         let version = antares_sql::store::pg::version_info(&pool).await;
     752            2 :                         return Ok((
     753            2 :                             AnyStore::Pg(PgBackend::new(pool).with_version(version)),
     754            2 :                             Some(backend),
     755            2 :                         ));
     756              :                     }
     757            0 :                     Err(e) if antares_sql::store::pg::is_schema_mismatch(&e) => {
     758              :                         // Waiting cannot make this database match: its
     759              :                         // migration history was written by a different
     760              :                         // release. Retried, the boot ends 30 s later blaming
     761              :                         // the network for a schema that will never fit.
     762            0 :                         return Err(format!(
     763            0 :                             "ANTARES_STORE={mode}: {e} — the database was migrated by a \
     764            0 :                              different release of the broker, so this binary cannot serve \
     765            0 :                              it. Point it at a database migrated by this release, or start \
     766            0 :                              from an empty one"
     767            0 :                         )
     768            0 :                         .into());
     769              :                     }
     770            0 :                     Err(e) => {
     771            0 :                         last = e.to_string();
     772            0 :                         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     773              :                     }
     774              :                 }
     775              :             }
     776            0 :             Err(format!("ANTARES_STORE={mode}: database not reachable after 30 s: {last}").into())
     777              :         }
     778              :     }
     779           32 : }
     780              : 
     781              : /// Warn when the data dir shares a device with its parent — i.e. it is
     782              : /// not a mount point, so the redb file dies with the container.
     783              : #[cfg(unix)]
     784            4 : fn warn_if_not_mount_point(dir: &std::path::Path) {
     785              :     use std::os::unix::fs::MetadataExt;
     786            4 :     let _ = std::fs::create_dir_all(dir);
     787            4 :     if let (Ok(md), Some(Ok(parent_md))) =
     788            4 :         (std::fs::metadata(dir), dir.parent().map(std::fs::metadata))
     789              :     {
     790            4 :         if md.dev() == parent_md.dev() {
     791            4 :             eprintln!(
     792            4 :                 "WARN: ANTARES_DATA_DIR {} is not a mount point — data will be lost when \
     793            4 :                  the container is removed",
     794            4 :                 dir.display()
     795            4 :             );
     796            4 :         }
     797            0 :     }
     798            4 : }
     799              : 
     800              : #[cfg(not(unix))]
     801              : fn warn_if_not_mount_point(_dir: &std::path::Path) {}
     802              : 
     803              : // Every parameter is one config value `main` parsed fatally before the
     804              : // runtime started; a struct would only rename the same ten values.
     805              : #[allow(clippy::too_many_arguments)]
     806           24 : async fn run(
     807           24 :     port: u16,
     808           24 :     host_alias: String,
     809           24 :     roles: String,
     810           24 :     store: std::sync::Arc<dyn antares_store::CurrentStateDriver>,
     811           24 :     temporal: std::sync::Arc<dyn antares_store::TemporalDriver>,
     812           24 :     store_name: String,
     813           24 :     temporal_name: Option<String>,
     814           24 :     maintenance: Vec<(
     815           24 :         antares_sql::sqlx::PgPool,
     816           24 :         antares_sql::store::pg::maintenance::TemporalBackend,
     817           24 :     )>,
     818           24 :     metrics_render: Option<telemetry::MetricsRender>,
     819           24 :     sweep_secs: u64,
     820           24 :     drain_delay: std::time::Duration,
     821           24 :     drain_deadline: std::time::Duration,
     822           24 : ) -> Result<(), Box<dyn std::error::Error>> {
     823           24 :     let roles = wiring::Roles::parse(&roles).map_err(|e| format!("ANTARES_ROLES: {e}"))?;
     824              :     // Bus seam: local (default) or nats. An unknown value is fatal.
     825           22 :     let bus_mode = std::env::var("ANTARES_BUS").unwrap_or_else(|_| "local".into());
     826           22 :     match bus_mode.as_str() {
     827           22 :         "local" => {
     828              :             // bus=local means ONE process running every role — a role
     829              :             // split without a shared bus would silently drop whole concerns.
     830           20 :             if !roles.all() {
     831            8 :                 return Err(
     832            8 :                     "ANTARES_BUS=local requires all roles in one process (ANTARES_ROLES=all); \
     833            8 :                      role splits need ANTARES_BUS=nats"
     834            8 :                         .into(),
     835            8 :                 );
     836           12 :             }
     837              :         }
     838            2 :         "nats" => {
     839            0 :             if !shared_state(&store_name) {
     840            0 :                 return Err(format!(
     841            0 :                     "ANTARES_BUS=nats requires a shared store (ANTARES_STORE=postgres|timescale); \
     842            0 :                      {store_name} state is per-process and cannot back multiple instances"
     843            0 :                 )
     844            0 :                 .into());
     845            0 :             }
     846              :         }
     847            2 :         other => return Err(format!("unknown ANTARES_BUS={other} (local|nats)").into()),
     848              :     }
     849           12 :     tracing::info!(port, store = %store_name, %bus_mode, ?roles, "starting antares");
     850              : 
     851              :     // Trailing-slash tolerance: Table 6.2-1 spells collection resources with a
     852              :     // trailing '/'; normalize before routing.
     853           12 :     let mut state = AppState::with_drivers(host_alias, store, temporal, &store_name);
     854           12 :     state.temporal_name = temporal_name;
     855           12 :     state.delivery = antares_api::DeliveryPolicy::from_env().unwrap_or_default();
     856           12 :     state.temporal_record = std::env::var("ANTARES_TEMPORAL_RECORD")
     857           12 :         .as_deref()
     858           12 :         .unwrap_or("all")
     859           12 :         .parse()?;
     860              :     // The surfaces mounted beside the NGSI-LD API root, from configuration
     861              :     // rather than from a hard-wired list: the selection replaces what the
     862              :     // default mounting put there, before the state is shared.
     863           12 :     let selected = api_surfaces(std::env::var("ANTARES_API_SURFACES").ok().as_deref())?;
     864           12 :     state = state.with_surfaces(selected.iter().map(|(_, build)| build()).collect())?;
     865              :     // The policy engine, from configuration and from the shelf this binary
     866              :     // was built with. Without one the broker asks `allow-all` and behaves
     867              :     // exactly as it did before the seam existed.
     868           12 :     let (policy_name, build_policy) =
     869           12 :         policy_engine(std::env::var("ANTARES_POLICY").ok().as_deref())?;
     870           12 :     tracing::info!("policy engine: {policy_name}");
     871              :     // The built-in engine is attached as no engine at all: it decides
     872              :     // nothing, and a gate with nothing to ask skips the whole apparatus
     873              :     // rather than boxing a future that always answers allow.
     874           12 :     if policy_name != antares_api::policy::BUILT_IN_NAME {
     875            0 :         state = state.with_policy(build_policy());
     876           12 :     }
     877              :     // A notification binding compiled in from outside the workspace mounts
     878              :     // exactly like the two shipped ones: one registration, no core-crate
     879              :     // edit. It is not in a release build — the feature is off by default,
     880              :     // so the shipped binary serves network schemes only.
     881              :     #[cfg(feature = "plugin-example")]
     882              :     let mut state = state.with_sink(Box::new(antares_plugin_example::MemorySink::new()));
     883              :     // /q/metrics renders through this closure (None without the
     884              :     // `telemetry` feature — the endpoint answers 404); the sampler feeds
     885              :     // the process-level gauges the whole run.
     886           12 :     state.metrics_render = metrics_render;
     887              :     // Heap stats on /q/health (allocated/resident bytes via jemalloc-ctl)
     888           16 :     state.mem_stats = Some(std::sync::Arc::new(|| {
     889              :         use tikv_jemalloc_ctl::{epoch, stats};
     890           16 :         let _ = epoch::advance();
     891           16 :         serde_json::json!({
     892           16 :             "allocatedBytes": stats::allocated::read().unwrap_or(0),
     893           16 :             "residentBytes": stats::resident::read().unwrap_or(0),
     894              :         })
     895           16 :     }));
     896              :     // 5.8.1.4 consumer half, whichever bus carries the delivery. A
     897              :     // distributed Subscription is served by an internal Context Source
     898              :     // Registration Subscription that notifies to `urn:antares:distsub:…`,
     899              :     // and the delivery path drops that notification unless this handler is
     900              :     // installed — the Subscription is accepted and no copy is ever
     901              :     // forwarded. `antares_api::wire` installs it with the in-process
     902              :     // matcher; the role-split fleet wires its matcher through `wire_nats`
     903              :     // and needs it installed here.
     904           12 :     antares_api::install_csource_notification(&mut state);
     905           12 :     if bus_mode == "nats" {
     906              :         // Outbox producer + drain, KV/registry mirrors, durable
     907              :         // consumers per role, topology asserted before traffic.
     908            0 :         let url = std::env::var("ANTARES_NATS_URL")
     909            0 :             .map_err(|_| "ANTARES_BUS=nats requires ANTARES_NATS_URL")?;
     910            0 :         wiring::wire_nats(&mut state, &url, roles).await?;
     911              :     } else {
     912           12 :         antares_api::wire(&mut state).await; // in-process matcher + notifier + interval firing
     913              :     }
     914           12 :     telemetry::spawn_sampler(state.clone());
     915              : 
     916              :     // Boot preload — Cached rows persisted by the AppState write-through
     917              :     // re-seed the parsed-context cache on start, so expansion doesn't refetch
     918              :     // what a previous life already downloaded. (The writer itself is wired in
     919              :     // AppState::with_store — rows are the 5.13 source of truth.)
     920              :     {
     921              :         // Metadata first, then ONE body at a time. Reading every row whole
     922              :         // put up to MAX_CONTEXT_BYTES per row in memory at once, capped only
     923              :         // for Cached rows and not at all for the rest — a boot that a client
     924              :         // could make impossible by storing large @contexts.
     925              :         // No Tenant: the store then hands back the rows that belong to none
     926              :         // (ADR-0021), which is exactly the `Cached` set this warm wants.
     927           12 :         for row in state
     928           12 :             .store
     929           12 :             .context_list_meta(None)
     930           12 :             .await
     931           12 :             .unwrap_or_default()
     932              :         {
     933            0 :             if row.get("kind").and_then(|v| v.as_str()) != Some("Cached") {
     934            0 :                 continue;
     935            0 :             }
     936            0 :             let (Some(url), Some(id), Some(created)) = (
     937            0 :                 row.get("url").and_then(|v| v.as_str()),
     938            0 :                 row.get("localId").and_then(|v| v.as_str()),
     939            0 :                 row.get("createdAt").and_then(|v| v.as_str()),
     940              :             ) else {
     941            0 :                 continue;
     942              :             };
     943            0 :             let Some(full) = state.store.context_get(None, id).await.ok().flatten() else {
     944            0 :                 continue;
     945              :             };
     946            0 :             if let Some(v) = full.pointer("/body/@context") {
     947            0 :                 state.loader.seed_cached(url, id, created, v.clone()).await;
     948            0 :             }
     949              :         }
     950              :     }
     951              : 
     952              :     // 4.22 GC on the memory/file arm: reads already refuse expired entities;
     953              :     // this reaps them (spec-sanctioned lag). The Pg arm's sweep runs inside
     954              :     // the maintenance job below — one job per backend, mode-switched.
     955              :     // ANTARES_SWEEP_SECS paces 4.22 GC identically across ALL backends —
     956              :     // this loop and the Pg/Timescale maintenance job below both tick on it
     957              :     // (the ETSI stack runs at 2 s so transient TPs observe GC, not just the
     958              :     // read filter); parsed at startup, default 15 min. The loop runs for
     959              :     // every driver and asks it what it reaped: a backend whose GC lives
     960              :     // elsewhere — the Pg arm's is inside the maintenance job — answers 0.
     961           12 :     let sweeper = state.store.clone();
     962           12 :     let doc_sweeper = state.clone();
     963           12 :     tokio::spawn(async move {
     964           12 :         let mut tick = tokio::time::interval(std::time::Duration::from_secs(sweep_secs));
     965              :         loop {
     966          788 :             tick.tick().await;
     967          776 :             let n = sweeper.sweep_expired().await;
     968          776 :             if n > 0 {
     969            4 :                 tracing::debug!("4.22 sweep reaped {n} expired entities");
     970          772 :             }
     971              :             // Registrations, Snapshots and EntityMaps carry their own
     972              :             // expiry, and every read already refuses one that has passed;
     973              :             // this is what frees the row behind it.
     974          776 :             let n = antares_api::sweep_expired_docs(&doc_sweeper).await;
     975          776 :             if n > 0 {
     976            8 :                 tracing::debug!("expiry sweep reaped {n} expired documents");
     977          768 :             }
     978              :         }
     979              :     });
     980              :     // Temporal maintenance — plain-mode partition pre-creation and the
     981              :     // (opt-in) retention horizon, single-winner via SKIP LOCKED.
     982              :     // One job per pg half (current state and/or history), PINNED to the
     983              :     // backend detected at startup; memory and file halves get none.
     984           12 :     if !maintenance.is_empty() {
     985            2 :         let retention: Option<i64> = std::env::var("ANTARES_TEMPORAL_RETENTION_DAYS")
     986            2 :             .ok()
     987            2 :             .map(|v| {
     988            0 :                 v.parse::<i64>()
     989            0 :                     .map_err(|_| "ANTARES_TEMPORAL_RETENTION_DAYS must be an integer")
     990              :                     // A zero/negative horizon inverts `now() - make_interval(days)`
     991              :                     // and would reap all current + future history — a data-loss
     992              :                     // footgun. Retention is opt-in; a bad value must not silently
     993              :                     // delete. Cap at i32 too (bound as $1::int downstream).
     994            0 :                     .and_then(|d| {
     995            0 :                         (d > 0 && d <= i64::from(i32::MAX)).then_some(d).ok_or(
     996            0 :                             "ANTARES_TEMPORAL_RETENTION_DAYS must be between 1 and 2147483647",
     997              :                         )
     998            0 :                     })
     999            0 :             })
    1000            2 :             .transpose()?;
    1001            2 :         for (pool, backend) in maintenance {
    1002            2 :             tokio::spawn(async move {
    1003              :                 // Same ANTARES_SWEEP_SECS cadence as the Mem arm — the job's 4.22
    1004              :                 // reap is the sweep here; the partition/retention steps riding on
    1005              :                 // the same tick are idempotent and SKIP LOCKED single-winner.
    1006            2 :                 let mut tick = tokio::time::interval(std::time::Duration::from_secs(sweep_secs));
    1007              :                 loop {
    1008          396 :                     tick.tick().await; // first tick is immediate: partitions at boot
    1009          394 :                     match antares_sql::store::pg::maintenance::temporal_maintenance(
    1010          394 :                         &pool, backend, retention,
    1011              :                     )
    1012          394 :                     .await
    1013              :                     {
    1014          394 :                         Ok(msg) => tracing::debug!("temporal maintenance: {msg}"),
    1015            0 :                         Err(e) => tracing::warn!("temporal maintenance failed: {e}"),
    1016              :                     }
    1017              :                 }
    1018              :             });
    1019              :         }
    1020           10 :     }
    1021              :     // Handles the drain needs, taken before `state` is consumed by the
    1022              :     // router — the flag the health endpoint reads, and the store whose pools
    1023              :     // close last.
    1024           12 :     let draining = state.draining.clone();
    1025           12 :     let store_for_drain = state.store.clone();
    1026              :     // The temporal seam may be a second store with its own pool; the drain
    1027              :     // closes both.
    1028           12 :     let temporal_for_drain = state.temporal.clone();
    1029           12 :     let pending_for_drain = state.pending_changes.clone();
    1030              :     // Only the api role serves the NGSI-LD surface — a worker pod
    1031              :     // exposes health/ready/metrics and nothing else (a subscription created
    1032              :     // on a worker would bypass the roles.api KV sync and never notify).
    1033           12 :     let routed = if roles.api {
    1034           12 :         antares_api::router(state)
    1035              :     } else {
    1036            0 :         antares_api::ops_router(state)
    1037              :     };
    1038           12 :     let app = tower::Layer::layer(
    1039           12 :         &tower_http::normalize_path::NormalizePathLayer::trim_trailing_slash(),
    1040           12 :         routed,
    1041              :     );
    1042              : 
    1043              :     // A connection that never finishes its request headers must not hold a
    1044              :     // slot forever; hyper closes it after this timeout.
    1045           12 :     let header_read_timeout = std::env::var("ANTARES_HEADER_READ_TIMEOUT_MS")
    1046           12 :         .ok()
    1047           12 :         .map(|v| {
    1048            2 :             v.parse::<u64>().map_err(|_| {
    1049            2 :                 format!("ANTARES_HEADER_READ_TIMEOUT_MS must be an integer (got {v:?})")
    1050            2 :             })
    1051            2 :         })
    1052           12 :         .transpose()?
    1053           10 :         .map(std::time::Duration::from_millis)
    1054           10 :         .unwrap_or(std::time::Duration::from_secs(10));
    1055              :     // Ceiling on concurrently served connections: accepted streams beyond
    1056              :     // it are dropped at once — refusing cheaply beats queueing work the box
    1057              :     // cannot serve (each served connection is a spawned task).
    1058           10 :     let max_connections = max_connections()?;
    1059            8 :     let conn_permits = std::sync::Arc::new(tokio::sync::Semaphore::new(max_connections));
    1060              : 
    1061            8 :     let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
    1062            8 :     tracing::info!("listening on http://0.0.0.0:{port}");
    1063              :     // Count open connections so the drain can wait for them. Incremented
    1064              :     // before the task is spawned — incrementing inside the task would race the
    1065              :     // drain's first check and let a just-accepted connection be missed.
    1066              :     // This counts CONNECTIONS, not requests, and stays that way on purpose:
    1067              :     // hyper's graceful_shutdown below draws the distinction already (an idle
    1068              :     // keep-alive closes at once, an active request finishes first), so a
    1069              :     // request-layer counter would add a middleware without changing what the
    1070              :     // drain waits for.
    1071            8 :     let inflight = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    1072              :     // The drain signal each connection listens for. On drain,
    1073              :     // hyper's graceful_shutdown closes IDLE keep-alive connections immediately
    1074              :     // (the LB holds one per backend — counting them as in-flight made every
    1075              :     // api roll burn the full deadline) while an active request still finishes.
    1076            8 :     let (drain_tx, drain_rx) = tokio::sync::watch::channel(false);
    1077              :     // The signal future is created ONCE and polled by reference. Written
    1078              :     // inline in the select, it would be dropped and re-created on every
    1079              :     // accepted connection — and a SIGTERM landing in that drop-to-recreate
    1080              :     // window is lost for good (tokio signal streams do not replay events from
    1081              :     // before their creation). Under health-check polling that window is hit
    1082              :     // constantly, which is exactly how the drain test caught it.
    1083            8 :     let mut sigterm = std::pin::pin!(shutdown::signal());
    1084              :     // A pod whose drain is switched off publishes nothing, so waiting for the
    1085              :     // outbox to empty there would only burn the deadline.
    1086            8 :     let flush_outbox = wiring::outbox_drain_enabled()?;
    1087              :     // Manual serve loop: the ETSI suite reads response headers case-sensitively
    1088              :     // ("Location"), so HTTP/1 responses are written with title-case headers.
    1089              :     loop {
    1090        12865 :         let stream = tokio::select! {
    1091        12865 :             s = accept(&listener) => s,
    1092        12865 :             _ = &mut sigterm => {
    1093              :                 // 1+2: unhealthy FIRST, then keep serving for the LB's notice
    1094              :                 // window — still inside this select, so connections arriving
    1095              :                 // during it are accepted normally.
    1096            8 :                 shutdown::begin(&draining, drain_delay);
    1097            8 :                 let until = tokio::time::Instant::now() + drain_delay;
    1098              :                 loop {
    1099           12 :                     tokio::select! {
    1100           12 :                         stream = accept(&listener) => {
    1101            4 :                             serve(stream, app.clone(), inflight.clone(), drain_rx.clone(),
    1102            4 :                                   conn_permits.clone(), header_read_timeout);
    1103            4 :                         }
    1104           12 :                         _ = tokio::time::sleep_until(until) => break,
    1105              :                     }
    1106              :                 }
    1107              :                 // 3–6: listener dropped, idle conns told to close (active
    1108              :                 // requests finish), in-flight drained, pools closed.
    1109            8 :                 drop(listener);
    1110            8 :                 let _ = drain_tx.send(true);
    1111            8 :                 shutdown::drain(
    1112            8 :                     &inflight,
    1113            8 :                     &pending_for_drain,
    1114            8 :                     &*store_for_drain,
    1115            8 :                     &*temporal_for_drain,
    1116            8 :                     drain_deadline,
    1117            8 :                     flush_outbox,
    1118            8 :                 )
    1119            8 :                 .await;
    1120            8 :                 tracing::info!("shutting down");
    1121            8 :                 return Ok(());
    1122              :             }
    1123              :         };
    1124        12857 :         serve(
    1125        12857 :             stream,
    1126        12857 :             app.clone(),
    1127        12857 :             inflight.clone(),
    1128        12857 :             drain_rx.clone(),
    1129        12857 :             conn_permits.clone(),
    1130        12857 :             header_read_timeout,
    1131              :         );
    1132              :     }
    1133           24 : }
    1134              : 
    1135              : /// ANTARES_MAX_CONNECTIONS: the ceiling on concurrently served connections
    1136              : /// (default 10 000).
    1137           10 : fn max_connections() -> Result<usize, Box<dyn std::error::Error>> {
    1138           10 :     Ok(std::env::var("ANTARES_MAX_CONNECTIONS")
    1139           10 :         .ok()
    1140           10 :         .map(|v| {
    1141            2 :             v.parse::<usize>().ok().filter(|n| *n > 0).ok_or_else(|| {
    1142            2 :                 format!("ANTARES_MAX_CONNECTIONS must be a positive integer (got {v:?})")
    1143            2 :             })
    1144            2 :         })
    1145           10 :         .transpose()?
    1146            8 :         .unwrap_or(10_000))
    1147           10 : }
    1148              : 
    1149              : /// The request runtime. Store calls are futures the workers poll, so a
    1150              : /// waiting caller holds no thread and the blocking pool carries only what
    1151              : /// is genuinely blocking — the `file` mode's per-commit fsync, which redb
    1152              : /// serializes behind its single writer. Tokio's own bounds hold.
    1153           31 : fn runtime() -> std::io::Result<tokio::runtime::Runtime> {
    1154           31 :     tokio::runtime::Builder::new_multi_thread()
    1155           31 :         .enable_all()
    1156           31 :         .build()
    1157           31 : }
    1158              : 
    1159              : /// Accept the next connection. There is no failure value: `accept()`
    1160              : /// propagates every non-`WouldBlock` errno from the syscall, and an
    1161              : /// `ECONNABORTED` (a client resetting between SYN and accept), `EMFILE`/
    1162              : /// `ENFILE` (the fd ceiling — the connection cap defaults above many
    1163              : /// containers' `nofile`) or `ENOBUFS` must never take the broker down.
    1164        12879 : async fn accept(listener: &tokio::net::TcpListener) -> tokio::net::TcpStream {
    1165              :     loop {
    1166        12879 :         match listener.accept().await {
    1167        12863 :             Ok((stream, _)) => {
    1168              :                 // Nagle held every multi-segment response for the peer's
    1169              :                 // delayed ACK: a fixed ~40 ms on any body over one segment,
    1170              :                 // 25 req/s per connection whatever the core count.
    1171        12863 :                 let _ = stream.set_nodelay(true);
    1172        12863 :                 return stream;
    1173              :             }
    1174            0 :             Err(e) => {
    1175            0 :                 tracing::warn!("accept failed, retrying: {e}");
    1176            0 :                 if accept_backoff(&e) {
    1177            0 :                     tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    1178            0 :                 }
    1179              :             }
    1180              :         }
    1181              :     }
    1182        12863 : }
    1183              : 
    1184              : /// Whether an `accept()` error warrants a pause before the next attempt.
    1185              : /// The per-connection failures retry at once (the next connection is
    1186              : /// unaffected); a resource exhaustion would otherwise spin the loop at full
    1187              : /// speed until the pressure clears. Neither is fatal.
    1188           16 : fn accept_backoff(e: &std::io::Error) -> bool {
    1189           10 :     !matches!(
    1190           16 :         e.kind(),
    1191              :         std::io::ErrorKind::ConnectionAborted
    1192              :             | std::io::ErrorKind::ConnectionReset
    1193              :             | std::io::ErrorKind::Interrupted
    1194              :     )
    1195           16 : }
    1196              : 
    1197              : /// The served app: the router under trailing-slash normalization.
    1198              : type App = tower_http::normalize_path::NormalizePath<axum::Router>;
    1199              : 
    1200              : /// One accepted connection. Split out of the accept loop so the drain's
    1201              : /// notice window serves connections with identical behaviour, and so the
    1202              : /// in-flight counter is incremented in exactly one place.
    1203        12861 : fn serve(
    1204        12861 :     stream: tokio::net::TcpStream,
    1205        12861 :     app: App,
    1206        12861 :     inflight: std::sync::Arc<std::sync::atomic::AtomicUsize>,
    1207        12861 :     mut drain_rx: tokio::sync::watch::Receiver<bool>,
    1208        12861 :     conn_permits: std::sync::Arc<tokio::sync::Semaphore>,
    1209        12861 :     header_read_timeout: std::time::Duration,
    1210        12861 : ) {
    1211              :     // Over the connection cap: drop the accepted stream immediately. The
    1212              :     // permit rides in the connection task, so the slot frees exactly when
    1213              :     // the connection ends — and never enters the inflight drain accounting.
    1214        12861 :     let Ok(permit) = conn_permits.try_acquire_owned() else {
    1215            0 :         return;
    1216              :     };
    1217        12861 :     inflight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    1218        12861 :     tokio::spawn(async move {
    1219        12861 :         let _permit = permit;
    1220        12861 :         let svc = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
    1221        12861 :             let mut app = app.clone();
    1222        12861 :             async move { tower::Service::call(&mut app, req.map(axum::body::Body::new)).await }
    1223        12861 :         });
    1224        12861 :         let mut builder =
    1225        12861 :             hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
    1226        12861 :         builder
    1227        12861 :             .http1()
    1228        12861 :             .timer(hyper_util::rt::TokioTimer::new())
    1229        12861 :             .header_read_timeout(header_read_timeout)
    1230        12861 :             .title_case_headers(true);
    1231        12861 :         let conn = builder.serve_connection(hyper_util::rt::TokioIo::new(stream), svc);
    1232        12861 :         let mut conn = std::pin::pin!(conn);
    1233              :         // On drain, close an IDLE keep-alive connection immediately —
    1234              :         // hyper finishes any active request first, then closes. Without this
    1235              :         // the LB's idle keep-alives count as in-flight and every roll waits
    1236              :         // out the entire drain deadline.
    1237        12861 :         tokio::select! {
    1238        12861 :             r = conn.as_mut() => { let _ = r; }
    1239        12861 :             _ = wait_drain(&mut drain_rx) => {
    1240            2 :                 conn.as_mut().graceful_shutdown();
    1241            2 :                 let _ = conn.as_mut().await;
    1242              :             }
    1243              :         }
    1244        12861 :         inflight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
    1245        12861 :     });
    1246        12861 : }
    1247              : 
    1248              : /// Resolves when the drain signal fires; pends forever once the sender is
    1249              : /// gone (the connection future then completes on its own in the select).
    1250        12861 : async fn wait_drain(rx: &mut tokio::sync::watch::Receiver<bool>) {
    1251              :     loop {
    1252        12858 :         if *rx.borrow() {
    1253            2 :             return;
    1254        12856 :         }
    1255        12856 :         if rx.changed().await.is_err() {
    1256            0 :             std::future::pending::<()>().await;
    1257            2 :         }
    1258              :     }
    1259            2 : }
    1260              : 
    1261              : #[cfg(test)]
    1262              : mod config_key_tests {
    1263              :     use super::unknown_config_key;
    1264              : 
    1265              :     /// Kubelet-injected service links must never be fatal; real typos
    1266              :     /// must stay fatal.
    1267              :     #[test]
    1268            2 :     fn kubelet_service_links_are_not_typos() {
    1269           14 :         for k in [
    1270            2 :             "ANTARES_PORT",
    1271            2 :             "ANTARES_PORT_9090_TCP",
    1272            2 :             "ANTARES_PORT_9090_TCP_ADDR",
    1273            2 :             "ANTARES_SERVICE_HOST",
    1274            2 :             "ANTARES_SERVICE_PORT",
    1275            2 :             "ANTARES_API_SERVICE_HOST",
    1276            2 :             "ANTARES_FILE_PORT_9090_TCP_PROTO",
    1277            2 :         ] {
    1278           14 :             assert!(!unknown_config_key(k), "{k} is platform-injected");
    1279              :         }
    1280           14 :         for k in [
    1281            2 :             "ANTARES_HTTP_PORT",
    1282            2 :             "ANTARES_STORE",
    1283            2 :             "ANTARES_TEST_ANYTHING",
    1284            2 :             "ANTARES_PG_POOL",
    1285            2 :             "ANTARES_ALLOW_SHARED_LOCAL",
    1286            2 :             "ANTARES_HEADER_READ_TIMEOUT_MS",
    1287            2 :             "ANTARES_MAX_CONNECTIONS",
    1288            2 :         ] {
    1289           14 :             assert!(!unknown_config_key(k), "{k} is known/reserved");
    1290              :         }
    1291            2 :         assert!(
    1292            2 :             unknown_config_key("ANTARES_STROE"),
    1293              :             "a real typo stays fatal"
    1294              :         );
    1295            2 :         assert!(
    1296            2 :             unknown_config_key("ANTARES_HTPT_PORT"),
    1297              :             "a typo'd *_PORT var is NOT a service link"
    1298              :         );
    1299            2 :         assert!(unknown_config_key("ANTARES_BOGUS_FLAG"));
    1300            2 :     }
    1301              : 
    1302              :     /// The exemption is narrow on purpose: only the exact kubelet shapes for
    1303              :     /// the Services this repo ships. Near-misses stay fatal, and a non-ANTARES
    1304              :     /// variable is never our business.
    1305              :     #[test]
    1306            2 :     fn the_service_link_exemption_does_not_over_reach() {
    1307           10 :         for k in [
    1308            2 :             "ANTARES_",
    1309            2 :             "ANTARES_PORTAL",
    1310            2 :             "ANTARES_SERVICEHOST",
    1311            2 :             "ANTARES_DB_PORT",
    1312            2 :             "ANTARES_WORKER_PROT",
    1313            2 :         ] {
    1314           10 :             assert!(unknown_config_key(k), "{k} must stay a fatal typo");
    1315              :         }
    1316           10 :         for k in ["PATH", "HOME", "antares_store", "ANTARE_STORE", ""] {
    1317           10 :             assert!(!unknown_config_key(k), "{k} is not broker config");
    1318              :         }
    1319            2 :     }
    1320              : 
    1321              :     /// Unknown keys are FATAL, so every ANTARES_* variable the workspace
    1322              :     /// actually reads has to be accepted — otherwise setting a documented
    1323              :     /// deployment knob is a CrashLoopBackOff and the knob cannot be used at
    1324              :     /// all. Scans the sources rather than restating the list, so the check
    1325              :     /// keeps holding as crates add knobs.
    1326              :     #[test]
    1327            2 :     fn known_keys_cover_every_variable_the_workspace_reads() {
    1328            2 :         let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
    1329            2 :             .parent()
    1330            2 :             .expect("crates dir")
    1331            2 :             .to_path_buf();
    1332            2 :         let needles = [
    1333            2 :             concat!("var(", "\"ANTARES_"),
    1334            2 :             concat!("var_os(", "\"ANTARES_"),
    1335            2 :         ];
    1336            2 :         let mut missing: Vec<String> = Vec::new();
    1337            2 :         let mut stack = vec![crates];
    1338           80 :         while let Some(dir) = stack.pop() {
    1339          530 :             for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() {
    1340          530 :                 let path = entry.path();
    1341          530 :                 if path.is_dir() {
    1342           76 :                     stack.push(path);
    1343           76 :                     continue;
    1344          454 :                 }
    1345          454 :                 if path.extension().is_none_or(|e| e != "rs") {
    1346           46 :                     continue;
    1347          408 :                 }
    1348          408 :                 let src = std::fs::read_to_string(&path).unwrap_or_default();
    1349          816 :                 for needle in needles {
    1350          816 :                     for (i, _) in src.match_indices(needle) {
    1351          264 :                         let rest = &src[i + needle.len() - "\"ANTARES_".len() + 1..];
    1352          264 :                         let Some(end) = rest.find('"') else { continue };
    1353          264 :                         let key = &rest[..end];
    1354          264 :                         if unknown_config_key(key) {
    1355            0 :                             missing.push(format!("{key} (read in {})", path.display()));
    1356          264 :                         }
    1357              :                     }
    1358              :                 }
    1359              :             }
    1360              :         }
    1361            2 :         missing.sort();
    1362            2 :         missing.dedup();
    1363            2 :         assert!(
    1364            2 :             missing.is_empty(),
    1365              :             "KNOWN_KEYS is missing variables the workspace reads: {missing:#?}"
    1366              :         );
    1367            2 :     }
    1368              : }
    1369              : 
    1370              : #[cfg(test)]
    1371              : mod accept_loop_tests {
    1372              :     use super::{accept, accept_backoff};
    1373              : 
    1374              :     /// An accept() error must never end the serve loop: tokio propagates
    1375              :     /// every non-WouldBlock errno straight from the syscall, so ECONNABORTED
    1376              :     /// (a client resetting between SYN and accept), EMFILE/ENFILE (the fd
    1377              :     /// ceiling) and ENOBUFS all used to take the whole broker down. `accept`
    1378              :     /// has no failure value at all — the only decision left is whether to
    1379              :     /// pause before retrying.
    1380              :     #[test]
    1381            2 :     fn no_accept_error_is_fatal_and_resource_errors_back_off() {
    1382              :         use std::io::ErrorKind::*;
    1383            6 :         for kind in [ConnectionAborted, ConnectionReset, Interrupted] {
    1384            6 :             assert!(
    1385            6 :                 !accept_backoff(&std::io::Error::new(kind, "x")),
    1386              :                 "{kind:?} is per-connection — the next accept must run at once"
    1387              :             );
    1388              :         }
    1389           10 :         for kind in [
    1390            2 :             Other,
    1391            2 :             OutOfMemory,
    1392            2 :             PermissionDenied,
    1393            2 :             InvalidInput,
    1394            2 :             NotConnected,
    1395            2 :         ] {
    1396           10 :             assert!(
    1397           10 :                 accept_backoff(&std::io::Error::new(kind, "x")),
    1398              :                 "{kind:?} would spin the loop without a pause"
    1399              :             );
    1400              :         }
    1401            2 :     }
    1402              : 
    1403              :     /// …and the healthy path still hands the loop its connection.
    1404              :     #[tokio::test]
    1405            2 :     async fn accept_yields_the_next_connection() {
    1406            2 :         let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
    1407            2 :             .await
    1408            2 :             .expect("bind");
    1409            2 :         let addr = listener.local_addr().expect("addr");
    1410            2 :         let client = tokio::spawn(async move { tokio::net::TcpStream::connect(addr).await });
    1411            2 :         let stream = tokio::time::timeout(std::time::Duration::from_secs(5), accept(&listener))
    1412            2 :             .await
    1413            2 :             .expect("a connection is accepted");
    1414            2 :         assert!(stream.peer_addr().is_ok());
    1415            2 :         let _ = client.await;
    1416            2 :     }
    1417              : }
    1418              : 
    1419              : #[cfg(test)]
    1420              : mod switch_tests {
    1421              :     use super::is_off;
    1422              : 
    1423              :     /// The knobs whose default is OFF (ANTARES_REQUIRE_RLS, ANTARES_TELEMETRY)
    1424              :     /// must fail SAFE on a spelling they do not know: recognizing only
    1425              :     /// `1|true` turned the RLS gate off on `TRUE`/`yes`/`on` while the
    1426              :     /// operator believed it was enforced.
    1427              :     #[test]
    1428            2 :     fn only_an_explicit_off_value_reads_as_off() {
    1429           20 :         for off in [
    1430            2 :             "0", "false", "FALSE", "False", "off", "OFF", "no", "", " ", " 0\t",
    1431            2 :         ] {
    1432           20 :             assert!(is_off(off), "{off:?} must read as off");
    1433              :         }
    1434           20 :         for on in [
    1435            2 :             "1", "true", "TRUE", "True", "on", "On", "yes", "YES", " 1 ", "enabled",
    1436            2 :         ] {
    1437           20 :             assert!(!is_off(on), "{on:?} must NOT read as off");
    1438              :         }
    1439            2 :     }
    1440              : }
    1441              : 
    1442              : #[cfg(test)]
    1443              : mod sweep_secs_tests {
    1444              :     use super::parse_sweep_secs;
    1445              : 
    1446              :     /// ANTARES_SWEEP_SECS paces the 4.22 GC in every store mode: absent is
    1447              :     /// the 15 min default, and a value that is not a positive integer is
    1448              :     /// fatal — a garbage cadence must never silently become the default one.
    1449              :     #[test]
    1450            2 :     fn sweep_secs_defaults_and_rejects() {
    1451            2 :         assert_eq!(parse_sweep_secs(None).expect("default"), 900);
    1452            2 :         assert_eq!(parse_sweep_secs(Some("2")).expect("explicit"), 2);
    1453           14 :         for bad in ["0", "-1", "", "2s", "abc", "1.5", "99999999999999999999999"] {
    1454           14 :             let err =
    1455           14 :                 parse_sweep_secs(Some(bad)).expect_err(&format!("SWEEP_SECS={bad:?} is fatal"));
    1456           14 :             assert!(err.contains("ANTARES_SWEEP_SECS"), "{err}");
    1457              :         }
    1458            2 :     }
    1459              : }
    1460              : 
    1461              : #[cfg(test)]
    1462              : mod driver_registry_tests {
    1463              :     use super::{built_with, shared_state, store_shelf, temporal_choice, TemporalChoice};
    1464              :     use antares_sql::StoreMode;
    1465              : 
    1466              :     /// The shelf in the message is rendered from the backend list, so a
    1467              :     /// backend added to `StoreMode` — or compiled in from outside this
    1468              :     /// workspace — cannot go missing from what the broker claims to have
    1469              :     /// been built with.
    1470              :     #[test]
    1471            2 :     fn the_shelf_names_every_store_mode() {
    1472            2 :         let shelf = built_with();
    1473            8 :         for m in StoreMode::ALL {
    1474            8 :             assert!(shelf.contains(m.as_str()), "{shelf} omits {m}");
    1475              :         }
    1476            2 :         assert!(
    1477            2 :             shelf.contains("none"),
    1478              :             "temporal `none` is part of the shelf: {shelf}"
    1479              :         );
    1480            8 :         for name in store_shelf() {
    1481            8 :             assert!(shelf.contains(name), "{shelf} omits {name}");
    1482              :         }
    1483            2 :     }
    1484              : 
    1485              :     /// Absent, or the store's own name, means one instance serves both
    1486              :     /// seams — no second store is ever built for the default.
    1487              :     #[test]
    1488            2 :     fn absent_or_same_name_shares_the_store() {
    1489            2 :         assert_eq!(
    1490            2 :             temporal_choice("postgres", None).expect("ok"),
    1491              :             TemporalChoice::SameAsStore
    1492              :         );
    1493            2 :         assert_eq!(
    1494            2 :             temporal_choice("postgres", Some("postgres")).expect("ok"),
    1495              :             TemporalChoice::SameAsStore
    1496              :         );
    1497            2 :     }
    1498              : 
    1499              :     #[test]
    1500            2 :     fn none_turns_history_off_and_other_names_build_a_second_store() {
    1501            2 :         assert_eq!(
    1502            2 :             temporal_choice("memory", Some("none")).expect("ok"),
    1503              :             TemporalChoice::None
    1504              :         );
    1505            2 :         assert_eq!(
    1506            2 :             temporal_choice("memory", Some("timescale")).expect("ok"),
    1507            2 :             TemporalChoice::Second("timescale".to_owned())
    1508              :         );
    1509            2 :     }
    1510              : 
    1511              :     /// An unknown backend is fatal at startup and the message names the
    1512              :     /// shelf this binary was built with — never a silent default.
    1513              :     #[test]
    1514            2 :     fn unknown_backend_is_fatal_and_lists_the_shelf() {
    1515            2 :         let err = temporal_choice("memory", Some("mongo")).expect_err("must fail");
    1516            2 :         assert!(err.contains("mongo"), "{err}");
    1517            2 :         assert!(err.contains(&built_with()), "{err}");
    1518            2 :         assert!(
    1519            2 :             temporal_choice("memory", Some("")).is_err(),
    1520              :             "an empty name is not a default"
    1521              :         );
    1522            2 :     }
    1523              : 
    1524              :     /// Shared state is what `ANTARES_BUS=nats` needs and what `bus=local`
    1525              :     /// refuses: a per-process backend — the built-in memory and file arms,
    1526              :     /// and any driver from outside the workspace — is never shared.
    1527              :     #[test]
    1528            2 :     fn only_the_database_backends_hold_shared_state() {
    1529            2 :         assert!(shared_state("postgres"));
    1530            2 :         assert!(shared_state("timescale"));
    1531            2 :         assert!(!shared_state("memory"));
    1532            2 :         assert!(!shared_state("file"));
    1533            2 :         assert!(
    1534            2 :             !shared_state("mongo"),
    1535              :             "an unknown name is not shared state"
    1536              :         );
    1537            2 :     }
    1538              : 
    1539              :     /// A driver compiled in from outside `crates/` is selected by name
    1540              :     /// exactly like a built-in, and serves both storage seams.
    1541              :     #[cfg(feature = "plugin-example")]
    1542              :     #[tokio::test]
    1543              :     async fn a_plugin_backend_is_on_the_shelf_and_builds_both_seams() {
    1544              :         let name = antares_plugin_example::NAME;
    1545              :         assert!(
    1546              :             store_shelf().contains(&name),
    1547              :             "the shelf omits the compiled-in plugin: {:?}",
    1548              :             store_shelf()
    1549              :         );
    1550              :         assert_eq!(
    1551              :             temporal_choice(name, None).expect("ok"),
    1552              :             TemporalChoice::SameAsStore
    1553              :         );
    1554              :         let drivers = super::build_drivers(name)
    1555              :             .await
    1556              :             .expect("the plugin driver builds");
    1557              :         assert_eq!(drivers.temporal_name.as_deref(), Some(name));
    1558              :         assert!(
    1559              :             drivers.maintenance.is_empty(),
    1560              :             "a plugin backend brings no Postgres maintenance job"
    1561              :         );
    1562              :         let tenant = antares_model::TenantId::default();
    1563              :         drivers
    1564              :             .store
    1565              :             .create(
    1566              :                 &tenant,
    1567              :                 antares_store::Kind::Entity,
    1568              :                 "urn:ngsi-ld:Probe:1",
    1569              :                 serde_json::json!({"id": "urn:ngsi-ld:Probe:1"}),
    1570              :             )
    1571              :             .await
    1572              :             .expect("create through the plugin driver");
    1573              :         assert_eq!(
    1574              :             drivers
    1575              :                 .store
    1576              :                 .list(&tenant, antares_store::Kind::Entity)
    1577              :                 .await
    1578              :                 .expect("list")
    1579              :                 .len(),
    1580              :             1
    1581              :         );
    1582              :     }
    1583              : }
    1584              : 
    1585              : #[cfg(test)]
    1586              : mod api_surface_tests {
    1587              :     use super::{api_surfaces, SURFACE_SHELF};
    1588              : 
    1589            6 :     fn names(raw: Option<&str>) -> Vec<&'static str> {
    1590            6 :         api_surfaces(raw)
    1591            6 :             .expect("selection")
    1592            6 :             .iter()
    1593            6 :             .map(|(n, _)| *n)
    1594            6 :             .collect()
    1595            6 :     }
    1596              : 
    1597              :     /// Absent means the operational surface, and nothing else: a pod that
    1598              :     /// was never configured still answers its probes.
    1599              :     #[test]
    1600            2 :     fn absent_selects_admin() {
    1601            2 :         assert_eq!(names(None), vec!["admin"]);
    1602            2 :         assert_eq!(names(Some("admin")), vec!["admin"]);
    1603            2 :         assert_eq!(
    1604            2 :             names(Some(" admin , ")),
    1605            2 :             vec!["admin"],
    1606              :             "spacing is not a name"
    1607              :         );
    1608            2 :     }
    1609              : 
    1610              :     /// An unknown name is fatal at startup and the message names the shelf
    1611              :     /// this binary was built with — never a silently ignored surface.
    1612              :     #[test]
    1613            2 :     fn an_unknown_surface_is_fatal_and_lists_the_shelf() {
    1614            2 :         let err = api_surfaces(Some("admin,dashboard")).expect_err("must fail");
    1615            2 :         assert!(err.contains("dashboard"), "{err}");
    1616            2 :         for (name, _) in SURFACE_SHELF {
    1617            2 :             assert!(err.contains(name), "the message lists {name}: {err}");
    1618              :         }
    1619            2 :     }
    1620              : 
    1621              :     /// A surface compiled in from outside `crates/` is selected by name
    1622              :     /// beside the operational one, and claims its own prefix under `/x`.
    1623              :     #[cfg(feature = "plugin-example")]
    1624              :     #[test]
    1625              :     fn a_plugin_surface_mounts_beside_admin() {
    1626              :         assert_eq!(names(Some("admin,example")), vec!["admin", "example"]);
    1627              :         let built: Vec<Box<dyn antares_api::ApiSurface>> = api_surfaces(Some("admin,example"))
    1628              :             .expect("selection")
    1629              :             .iter()
    1630              :             .map(|(_, build)| build())
    1631              :             .collect();
    1632              :         let example = built
    1633              :             .iter()
    1634              :             .find(|s| s.name() == "example")
    1635              :             .expect("the plugin surface is built");
    1636              :         assert!(
    1637              :             example.prefix().starts_with("/x/"),
    1638              :             "a plugin surface lives under /x, never under the NGSI-LD root: {}",
    1639              :             example.prefix()
    1640              :         );
    1641              :     }
    1642              : 
    1643              :     /// An empty selection is fatal too: readiness, health and metrics are
    1644              :     /// the admin surface, so a pod without one can never report itself up.
    1645              :     #[test]
    1646            2 :     fn an_empty_selection_is_fatal() {
    1647            8 :         for raw in ["", " ", ",", " , "] {
    1648            8 :             let err = api_surfaces(Some(raw)).expect_err(&format!("{raw:?} selects nothing"));
    1649            8 :             assert!(err.contains("ANTARES_API_SURFACES"), "{err}");
    1650              :         }
    1651            2 :     }
    1652              : }
    1653              : 
    1654              : #[cfg(test)]
    1655              : mod policy_shelf_tests {
    1656              :     use super::*;
    1657              : 
    1658            4 :     fn shelf() -> Vec<&'static str> {
    1659            4 :         POLICY_SHELF.iter().map(|(n, _)| *n).collect()
    1660            4 :     }
    1661              : 
    1662              :     /// ADR-0020: the broker ships one engine, and conformance is asserted
    1663              :     /// against it. A release build carries no other — an engine reaches the
    1664              :     /// shelf only through an off-by-default feature, so this assertion is
    1665              :     /// what "no addon in the shipped build" means for the policy seam.
    1666              :     #[cfg(not(feature = "plugin-example"))]
    1667              :     #[test]
    1668            2 :     fn the_shipped_build_carries_only_the_built_in_engine() {
    1669            2 :         assert_eq!(shelf(), vec!["allow-all"]);
    1670            2 :     }
    1671              : 
    1672              :     /// No selection is the built-in engine, which is the only default that
    1673              :     /// keeps a broker without a policy behaving like a broker.
    1674              :     #[test]
    1675            2 :     fn no_selection_is_the_built_in_engine() {
    1676            6 :         for raw in [None, Some(""), Some("  ")] {
    1677            6 :             assert_eq!(policy_engine(raw).expect("default").0, "allow-all");
    1678              :         }
    1679            2 :     }
    1680              : 
    1681              :     /// A typo must not serve every request wide open, so it is fatal and
    1682              :     /// the message names what this binary was built with.
    1683              :     #[test]
    1684            2 :     fn an_unknown_engine_is_fatal_and_names_the_shelf() {
    1685            2 :         let err = policy_engine(Some("opa")).expect_err("unknown engine");
    1686            2 :         assert!(err.contains("ANTARES_POLICY"), "{err}");
    1687            2 :         for name in shelf() {
    1688            2 :             assert!(err.contains(name), "the message lists {name}: {err}");
    1689              :         }
    1690            2 :     }
    1691              : 
    1692              :     /// An engine compiled in from outside `crates/` is selected by name
    1693              :     /// exactly like the built-in one — and, given no rules to enforce,
    1694              :     /// refuses every operation rather than allowing it (ADR-0020: a broken
    1695              :     /// engine costs service, never access rules).
    1696              :     #[cfg(feature = "plugin-example")]
    1697              :     #[test]
    1698              :     fn a_plugin_engine_is_on_the_shelf_and_fails_closed() {
    1699              :         let name = antares_plugin_example::POLICY_NAME;
    1700              :         assert!(shelf().contains(&name), "the shelf omits it: {:?}", shelf());
    1701              :         let (selected, build) = policy_engine(Some(name)).expect("selection");
    1702              :         assert_eq!(selected, name);
    1703              :         assert_eq!(build().name(), name);
    1704              :         assert!(
    1705              :             std::env::var(antares_plugin_example::RULES_ENV).is_err(),
    1706              :             "this test reads the engine built with no rules document"
    1707              :         );
    1708              :         assert!(
    1709              :             antares_plugin_example::ExamplePolicy::from_env()
    1710              :                 .broken()
    1711              :                 .is_some(),
    1712              :             "an engine selected without rules would otherwise allow everything"
    1713              :         );
    1714              :     }
    1715              : }
    1716              : 
    1717              : #[cfg(test)]
    1718              : mod pg_pool_tests {
    1719              :     use super::{parse_pg_pool, parse_pg_statement_timeout};
    1720              : 
    1721              :     /// ANTARES_PG_POOL: absent defaults to 20; a value that is not a
    1722              :     /// positive integer is fatal — misconfiguration must never silently
    1723              :     /// run with a default.
    1724              :     #[test]
    1725            2 :     fn pg_pool_parse_defaults_and_rejects() {
    1726            2 :         assert_eq!(
    1727            2 :             parse_pg_statement_timeout(None).expect("default"),
    1728            2 :             std::time::Duration::from_secs(30)
    1729              :         );
    1730            2 :         assert_eq!(
    1731            2 :             parse_pg_statement_timeout(Some("1500")).expect("explicit"),
    1732            2 :             std::time::Duration::from_millis(1500)
    1733              :         );
    1734            2 :         assert!(parse_pg_statement_timeout(Some("0")).is_err());
    1735            2 :         assert!(parse_pg_statement_timeout(Some("30s")).is_err());
    1736            2 :         assert_eq!(parse_pg_pool(None).expect("default"), 20);
    1737            2 :         assert_eq!(parse_pg_pool(Some("7")).expect("explicit"), 7);
    1738            2 :         assert!(
    1739            2 :             parse_pg_pool(Some("abc")).is_err(),
    1740              :             "non-numeric ANTARES_PG_POOL must be fatal"
    1741              :         );
    1742            2 :         assert!(
    1743            2 :             parse_pg_pool(Some("0")).is_err(),
    1744              :             "a zero-sized pool must be fatal"
    1745              :         );
    1746            2 :         assert!(parse_pg_pool(Some("-3")).is_err());
    1747            2 :     }
    1748              : }
        

Generated by: LCOV version 2.0-1