LCOV - code coverage report
Current view: top level - antares-broker/src - main.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 89.1 % 1043 929
Test Date: 2026-11-02 13:21:27 Functions: 61.1 % 329 201

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

Generated by: LCOV version 2.0-1