Skip to main content

sova_core/app/
mod.rs

1mod bind;
2mod hooks;
3
4pub(crate) use hooks::{AppInner, ListenParts, ShutdownHook, StartupHook};
5pub use bind::{Bind, BoundApp, Http};
6pub use hooks::Server;
7
8use crate::error::{Error, Result};
9use crate::events::EventBus;
10use crate::handler::BoxFuture;
11use crate::plugin::{
12    check_plugin_sdk, InstalledPlugin, Plugin, SdkCompat, PLUGIN_SDK_VERSION,
13};
14use crate::request::Request;
15use crate::response::Response;
16use crate::router::Router;
17use crate::service::{BackgroundService, BoxedService};
18use crate::state::StateMap;
19use bytes::Bytes;
20use http::Method;
21use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::net::{IpAddr, SocketAddr};
24use std::ops::{Deref, DerefMut};
25use std::sync::Arc;
26use std::time::Duration;
27
28pub(crate) type CliCommandFn =
29    Arc<dyn Fn(Arc<StateMap>, Vec<String>) -> BoxFuture<Result<()>> + Send + Sync>;
30
31pub(crate) type CheckFn =
32    Arc<dyn Fn(Arc<StateMap>) -> BoxFuture<Result<()>> + Send + Sync>;
33
34/// Kind of app check — readiness probes vs deploy-time audits.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CheckKind {
37    /// Runtime dependencies (db, redis, storage, …) — used by `GET /ready`.
38    Ready,
39    /// Deploy-time / config audits (openapi, vld, templates, …) — CLI `check` only.
40    Audit,
41}
42
43/// Outcome of one named check from [`App::run_checks`].
44#[derive(Debug, Clone)]
45pub struct CheckResult {
46    pub name: &'static str,
47    pub ok: bool,
48    pub error: Option<String>,
49}
50
51type CheckEntry = (&'static str, CheckKind, CheckFn);
52type CheckList = Arc<std::sync::Mutex<Vec<CheckEntry>>>;
53
54const DEFAULT_MAX_BODY: usize = 2 * 1024 * 1024;
55const DEFAULT_MAX_CONNECTIONS: usize = 1024;
56const DEFAULT_MAX_UPGRADED: usize = 1024;
57const DEFAULT_MAX_CONCURRENT_STREAMS: usize = 200;
58const DEFAULT_MAX_HEADERS: usize = 100;
59/// Default drain budget — leave headroom under k8s' 30s `terminationGracePeriodSeconds`.
60const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(20);
61const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
62/// Keep-alive idle wait between requests (also Slowloris / first-header wait via hyper).
63const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
64
65/// Thin wrapper over [`Router`] plus server settings and lifecycle hooks.
66pub struct App {
67    pub(crate) router: Router,
68    pub(crate) max_body_size: usize,
69    pub(crate) max_connections: usize,
70    pub(crate) max_upgraded_connections: usize,
71    pub(crate) max_concurrent_streams: usize,
72    pub(crate) max_headers: usize,
73    pub(crate) max_buf_size: Option<usize>,
74    pub(crate) request_timeout: Option<Duration>,
75    pub(crate) header_read_timeout: Duration,
76    pub(crate) idle_timeout: Duration,
77    pub(crate) drain_timeout: Duration,
78    pub(crate) keep_alive: bool,
79    pub(crate) trust_proxy: bool,
80    pub(crate) reuseport: bool,
81    /// Set by CLI listen helpers — BackgroundServices skipped unless `service_in_cli`.
82    pub(crate) cli_mode: bool,
83    pub(crate) service_in_cli: bool,
84    pub(crate) hsts: bool,
85    pub(crate) alt_svc: Option<String>,
86    /// TLS attached by plugins (e.g. Acme) — used by [`BoundApp`] when `.tls()` was not called.
87    #[cfg(feature = "tls")]
88    pub(crate) tls: Option<crate::Tls>,
89    pub(crate) installed_plugins: HashSet<&'static str>,
90    pub(crate) installed_plugin_meta: Vec<InstalledPlugin>,
91    pub(crate) missing_plugin_requires: Vec<(&'static str, &'static str)>,
92    pub(crate) duplicate_plugin_ids: Vec<&'static str>,
93    pub(crate) plugin_sdk_errors: Vec<String>,
94    pub(crate) on_startup: Vec<StartupHook>,
95    pub(crate) on_shutdown: Vec<ShutdownHook>,
96    pub(crate) services: Vec<BoxedService>,
97    pub(crate) cli_commands: HashMap<&'static str, CliCommandFn>,
98    pub(crate) checks: CheckList,
99    pub(crate) probes: bool,
100}
101
102impl App {
103    pub fn new() -> Self {
104        Self {
105            router: Router::new(),
106            max_body_size: DEFAULT_MAX_BODY,
107            max_connections: DEFAULT_MAX_CONNECTIONS,
108            max_upgraded_connections: DEFAULT_MAX_UPGRADED,
109            max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS,
110            max_headers: DEFAULT_MAX_HEADERS,
111            max_buf_size: None,
112            request_timeout: Some(Duration::from_secs(30)),
113            header_read_timeout: DEFAULT_HEADER_READ_TIMEOUT,
114            idle_timeout: DEFAULT_IDLE_TIMEOUT,
115            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
116            keep_alive: true,
117            trust_proxy: false,
118            reuseport: false,
119            cli_mode: false,
120            service_in_cli: false,
121            hsts: false,
122            alt_svc: None,
123            #[cfg(feature = "tls")]
124            tls: None,
125            installed_plugins: HashSet::new(),
126            installed_plugin_meta: Vec::new(),
127            missing_plugin_requires: Vec::new(),
128            duplicate_plugin_ids: Vec::new(),
129            plugin_sdk_errors: Vec::new(),
130            on_startup: Vec::new(),
131            on_shutdown: Vec::new(),
132            services: Vec::new(),
133            cli_commands: HashMap::new(),
134            checks: Arc::new(std::sync::Mutex::new(Vec::new())),
135            probes: false,
136        }
137    }
138
139    /// Register a plugin CLI command handled by [`Self::run`] (e.g. `"migrate"`).
140    pub fn register_cli<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
141    where
142        F: Fn(Arc<StateMap>, Vec<String>) -> Fut + Send + Sync + 'static,
143        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
144    {
145        self.cli_commands
146            .insert(name, Arc::new(move |state, args| Box::pin(f(state, args))));
147        self
148    }
149
150    /// Register a readiness check (`CheckKind::Ready`) for `GET /ready` and CLI `check`.
151    pub fn register_check<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
152    where
153        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
154        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
155    {
156        self.push_check(name, CheckKind::Ready, f)
157    }
158
159    /// Register a deploy-time audit (`CheckKind::Audit`) — CLI `check` only, not `/ready`.
160    pub fn register_audit<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
161    where
162        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
163        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
164    {
165        self.push_check(name, CheckKind::Audit, f)
166    }
167
168    fn push_check<F, Fut>(&mut self, name: &'static str, kind: CheckKind, f: F) -> &mut Self
169    where
170        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
171        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
172    {
173        self.checks
174            .lock()
175            .expect("checks lock")
176            .push((name, kind, Arc::new(move |state| Box::pin(f(state)))));
177        self
178    }
179
180    /// Run registered checks filtered by [`CheckKind`].
181    pub async fn run_checks(
182        &self,
183        state: Arc<StateMap>,
184        kinds: &[CheckKind],
185    ) -> Vec<CheckResult> {
186        run_check_list(&self.checks, state, kinds).await
187    }
188
189    /// Install k8s-style probes: `GET /healthz` (liveness) and `GET /ready` (Ready checks).
190    ///
191    /// Idempotent. Presets (`App::web` / `App::api`) call this automatically.
192    pub fn with_probes(&mut self) -> &mut Self {
193        if self.probes {
194            return self;
195        }
196        self.probes = true;
197        let checks = Arc::clone(&self.checks);
198
199        self.get("/healthz", || async {
200            Response::json(&serde_json::json!({ "status": "ok" }))
201        });
202
203        self.get("/ready", move |req: Request| {
204            let checks = Arc::clone(&checks);
205            async move {
206                let results =
207                    run_check_list(&checks, req.states(), &[CheckKind::Ready]).await;
208                ready_response(&results)
209            }
210        });
211
212        self
213    }
214
215    pub fn max_body_size(&mut self, bytes: usize) -> &mut Self {
216        self.max_body_size = bytes;
217        self.router.defaults.insert(crate::limits::MaxBody::bytes(bytes));
218        self
219    }
220
221    /// Cap concurrent TCP/UDS connections (default 1024).
222    pub fn max_connections(&mut self, n: usize) -> &mut Self {
223        self.max_connections = n.max(1);
224        self
225    }
226
227    /// Cap concurrent HTTP upgrades (WebSocket, …). Excess → **503** + `Retry-After`.
228    pub fn max_upgraded_connections(&mut self, n: usize) -> &mut Self {
229        self.max_upgraded_connections = n.max(1);
230        self
231    }
232
233    /// Cap concurrent HTTP/2 streams per connection (default 200).
234    /// Excess streams → `GOAWAY`/stream-level rejection handled by hyper.
235    pub fn max_concurrent_streams(&mut self, n: usize) -> &mut Self {
236        self.max_concurrent_streams = n.max(1);
237        self
238    }
239
240    /// Max HTTP/1 header count (default 100). Excess → 431 from hyper.
241    pub fn max_headers(&mut self, n: usize) -> &mut Self {
242        self.max_headers = n.max(1);
243        self
244    }
245
246    /// Cap hyper's connection buffer (headers + body framing). Minimum 8192.
247    /// Default ~400 KiB. Use this to bound oversized header blocks.
248    pub fn max_buf_size(&mut self, bytes: usize) -> &mut Self {
249        self.max_buf_size = Some(bytes.max(8192));
250        self
251    }
252
253    /// Per-request timeout around the handler (default 30s). `None` disables.
254    ///
255    /// Measured: timeout ends when the handler returns a [`Response`]. Streaming
256    /// response bodies (SSE) continue afterward and are **not** cut by this timer.
257    /// Idle between stream chunks is governed by TCP/keep-alive, not this setting.
258    pub fn request_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
259        self.request_timeout = timeout;
260        if let Some(d) = timeout {
261            self.router
262                .defaults
263                .insert(crate::limits::RequestTimeout(d));
264        }
265        self
266    }
267
268    /// Timeout for reading request headers (Slowloris). Also applied while waiting
269    /// for the next keep-alive request (see [`Self::idle_timeout`]). Default 10s.
270    pub fn header_read_timeout(&mut self, timeout: Duration) -> &mut Self {
271        self.header_read_timeout = timeout;
272        self
273    }
274
275    /// Keep-alive idle: how long a quiet connection may wait for the next request.
276    /// Hyper uses one timer for header reads; the effective wait is
277    /// `min(header_read_timeout, idle_timeout)`. Default 60s.
278    pub fn idle_timeout(&mut self, timeout: Duration) -> &mut Self {
279        self.idle_timeout = timeout;
280        self
281    }
282
283    /// How long to wait for in-flight connections after accept stops (default 20s).
284    pub fn drain_timeout(&mut self, timeout: Duration) -> &mut Self {
285        self.drain_timeout = timeout;
286        self
287    }
288
289    /// HTTP/1 keep-alive (default `true`).
290    pub fn keep_alive(&mut self, enabled: bool) -> &mut Self {
291        self.keep_alive = enabled;
292        self
293    }
294
295    /// When true, `ClientAddr` may use `X-Forwarded-For` / `Forwarded` (only behind a trusted proxy).
296    pub fn trust_proxy(&mut self, trust: bool) -> &mut Self {
297        self.trust_proxy = trust;
298        self
299    }
300
301    /// Mark this app as running under the CLI helper (skips BackgroundServices by default).
302    pub fn cli_mode(&mut self, enabled: bool) -> &mut Self {
303        self.cli_mode = enabled;
304        self
305    }
306
307    /// Start BackgroundServices even when [`Self::cli_mode`] is set (default `false`).
308    pub fn service_in_cli(&mut self, enabled: bool) -> &mut Self {
309        self.service_in_cli = enabled;
310        self
311    }
312
313    pub fn install<P: Plugin>(&mut self, plugin: P) -> &mut Self {
314        let plugin_id = plugin.id();
315        if self.installed_plugins.contains(plugin_id) {
316            self.duplicate_plugin_ids.push(plugin_id);
317            return self;
318        }
319        let meta = plugin.meta();
320        for dep in plugin.requires() {
321            if !self.installed_plugins.contains(dep) {
322                self.missing_plugin_requires.push((plugin_id, dep));
323            }
324        }
325        match check_plugin_sdk(meta.sdk, PLUGIN_SDK_VERSION) {
326            SdkCompat::Ok => {}
327            SdkCompat::Warn { core, plugin: declared } => {
328                tracing::warn!(
329                    plugin = plugin_id,
330                    plugin_sdk = %declared,
331                    core_sdk = %core,
332                    "plugin SDK is older than core; consider rebuilding against the current Plugin SDK"
333                );
334            }
335            SdkCompat::Error(msg) => {
336                self.plugin_sdk_errors
337                    .push(format!("plugin `{plugin_id}`: {msg}"));
338            }
339        }
340        self.installed_plugin_meta.push(InstalledPlugin {
341            id: plugin_id,
342            meta,
343        });
344        plugin.install(self);
345        self.installed_plugins.insert(plugin_id);
346        self
347    }
348
349    /// Whether a plugin with this [`Plugin::id`] was already installed.
350    pub fn has_plugin(&self, id: &str) -> bool {
351        self.installed_plugins.contains(id)
352    }
353
354    /// Metadata for every plugin passed to [`Self::install`] (order preserved).
355    pub fn installed_plugin_meta(&self) -> &[InstalledPlugin] {
356        &self.installed_plugin_meta
357    }
358
359    /// Primary app entrypoint: run as a server process.
360    ///
361    /// CLI mode:
362    /// - `check`
363    /// - `routes`
364    /// - `plugins`
365    /// - `openapi --out <path>`
366    /// - `tasks`
367    /// - `i18n missing`
368    ///
369    /// Non-CLI mode binds via [`Bind::Env`] (`HOST`/`PORT`, default port `3000`).
370    /// Prefer [`App::bind`] + [`BoundApp::run`] when the address is fixed in code.
371    pub async fn run(self) -> Result<()> {
372        self.bind(Bind::Env { default_port: 3000 }).run().await
373    }
374
375    pub(crate) async fn run_cli_command(&self, args: &[String]) -> Result<bool> {
376        let Some(cmd) = args.first().map(String::as_str) else {
377            return Ok(false);
378        };
379
380        let server = self.build()?;
381        let state = server.state();
382        for hook in &server.startups {
383            hook(Arc::clone(&state)).await?;
384        }
385
386        let rest: Vec<String> = args.iter().skip(1).cloned().collect();
387        let handled = if let Some(handler) = self.cli_commands.get(cmd) {
388            handler(Arc::clone(&state), rest).await?;
389            true
390        } else {
391            match cmd {
392                "check" => {
393                    // `build()` already ran — plugin `requires()` are satisfied.
394                    println!("ok plugins");
395                    let results = self
396                        .run_checks(
397                            Arc::clone(&state),
398                            &[CheckKind::Ready, CheckKind::Audit],
399                        )
400                        .await;
401                    let mut failed = false;
402                    for r in &results {
403                        if r.ok {
404                            println!("ok {}", r.name);
405                        } else {
406                            println!(
407                                "fail {} — {}",
408                                r.name,
409                                r.error.as_deref().unwrap_or("")
410                            );
411                            failed = true;
412                        }
413                    }
414                    if failed {
415                        return Err(Error::Internal(
416                            "one or more checks failed".into(),
417                        ));
418                    }
419                    println!("ok");
420                    true
421                }
422                "routes" => {
423                    println!("{}", self.explain());
424                    true
425                }
426                "plugins" => {
427                    for p in &self.installed_plugin_meta {
428                        let desc = if p.meta.description.is_empty() {
429                            "-"
430                        } else {
431                            p.meta.description
432                        };
433                        println!(
434                            "{:<24} {:<20} sdk={}  {}",
435                            p.id, p.meta.name, p.meta.sdk, desc
436                        );
437                    }
438                    if self.installed_plugin_meta.is_empty() {
439                        println!("(no plugins installed)");
440                    }
441                    true
442                }
443                "openapi" => {
444                    let out_idx = args.iter().position(|a| a == "--out");
445                    let out_path = out_idx
446                        .and_then(|idx| args.get(idx + 1))
447                        .ok_or_else(|| Error::Internal("openapi requires --out <path>".into()))?;
448                    let res = server
449                        .handle_request(Method::GET, "/docs/openapi.json", "")
450                        .await;
451                    if !res.status_code().is_success() {
452                        return Err(Error::Internal(format!(
453                            "openapi endpoint failed with status {}",
454                            res.status_code()
455                        )));
456                    }
457                    let bytes = res
458                        .body_bytes()
459                        .ok_or_else(|| Error::Internal("openapi body is streaming".into()))?;
460                    fs::write(out_path, bytes).map_err(|e| {
461                        Error::Internal(format!("failed writing openapi to {out_path}: {e}"))
462                    })?;
463                    println!("wrote {}", out_path);
464                    true
465                }
466                "tasks" => {
467                    println!(
468                        "tasks CLI requires the Tasks plugin (`app.install(Tasks::…)`).\n\
469                         Then: tasks list | tasks schedule | tasks run NAME"
470                    );
471                    true
472                }
473                "i18n" if args.get(1).map(String::as_str) == Some("missing") => {
474                    let res = server
475                        .handle_request(Method::GET, "/_i18n/_missing.json", "")
476                        .await;
477                    if let Some(body) = res.body_bytes() {
478                        println!("{}", String::from_utf8_lossy(body));
479                    } else {
480                        println!("i18n missing endpoint returned streaming body");
481                    }
482                    true
483                }
484                "i18n" => false,
485                _ => false,
486            }
487        };
488
489        if handled {
490            for hook in &server.shutdowns {
491                hook().await;
492            }
493        }
494        Ok(handled)
495    }
496
497    /// Register a process-local [`BackgroundService`].
498    ///
499    /// Lifecycle: `compile → on_startup → services → accept`;
500    /// stop: `stop accept → drain → stop services → on_shutdown`.
501    pub fn service<S: BackgroundService + 'static>(&mut self, service: S) -> &mut Self {
502        self.services.push(Box::new(service));
503        self
504    }
505
506    /// Run before accepting connections. `Err` prevents the server from starting.
507    pub fn on_startup<F, Fut>(&mut self, f: F) -> &mut Self
508    where
509        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
510        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
511    {
512        self.on_startup
513            .push(Arc::new(move |state| Box::pin(f(state))));
514        self
515    }
516
517    /// Run after the accept loop stops, connections drain, and services stop.
518    pub fn on_shutdown<F, Fut>(&mut self, f: F) -> &mut Self
519    where
520        F: Fn() -> Fut + Send + Sync + 'static,
521        Fut: std::future::Future<Output = ()> + Send + 'static,
522    {
523        self.on_shutdown
524            .push(Arc::new(move || Box::pin(f())));
525        self
526    }
527
528    /// Shared [`EventBus`] — inserts a default bus into app state on first use.
529    pub fn events(&mut self) -> EventBus {
530        if let Some(bus) = self.try_state::<EventBus>() {
531            return (*bus).clone();
532        }
533        let bus = EventBus::new();
534        self.state(bus.clone());
535        bus
536    }
537
538    /// Route map for debugging / startup banner.
539    pub fn explain(&self) -> String {
540        self.router.explain()
541    }
542
543    /// Handle one request (compiles the router each call). Prefer [`Self::build`].
544    pub async fn handle(&self, req: Request) -> Response {
545        match self.build() {
546            Ok(server) => server.handle(req).await,
547            Err(err) => err.into_response(),
548        }
549    }
550
551    /// Sugar over [`Request::builder`] + [`Self::handle`] (no custom headers).
552    /// For headers use `Request::builder().header(...).build()` + [`Self::handle`].
553    /// Prefer [`Server::handle_request`] after [`Self::build`].
554    pub async fn handle_request(&self, method: Method, path: &str, body: &str) -> Response {
555        let req = Request::builder()
556            .method(method)
557            .path(path)
558            .body(Bytes::from(body.to_string()))
559            .build();
560        self.handle(req).await
561    }
562
563    /// Run startup hooks via [`Server::run_startup`] (non-destructive).
564    #[cfg(any(test, feature = "testing"))]
565    pub async fn run_startup(&self) -> Result<Arc<StateMap>> {
566        self.build()?.run_startup().await
567    }
568
569    /// Run shutdown hooks via [`Server::run_shutdown`] (non-destructive).
570    #[cfg(any(test, feature = "testing"))]
571    pub async fn run_shutdown(&self) {
572        if let Ok(server) = self.build() {
573            server.run_shutdown().await;
574        }
575    }
576}
577
578pub(crate) fn addr_from_env(default_port: u16) -> Result<SocketAddr> {
579    let port = std::env::var("PORT")
580        .ok()
581        .and_then(|p| p.parse::<u16>().ok())
582        .unwrap_or(default_port);
583
584    match std::env::var("HOST") {
585        Ok(host) if !host.is_empty() => {
586            if let Ok(ip) = host.parse::<IpAddr>() {
587                return Ok(SocketAddr::new(ip, port));
588            }
589            // Allow HOST="127.0.0.1:3000" to override port entirely.
590            if let Ok(addr) = host.parse::<SocketAddr>() {
591                return Ok(addr);
592            }
593            format!("{host}:{port}")
594                .parse()
595                .map_err(|e| Error::Internal(format!("HOST={host:?} invalid: {e}")))
596        }
597        _ => Ok(SocketAddr::from(([0, 0, 0, 0], port))),
598    }
599}
600
601impl Default for App {
602    fn default() -> Self {
603        Self::new()
604    }
605}
606
607impl Deref for App {
608    type Target = Router;
609
610    fn deref(&self) -> &Router {
611        &self.router
612    }
613}
614
615impl DerefMut for App {
616    fn deref_mut(&mut self) -> &mut Router {
617        &mut self.router
618    }
619}
620
621async fn run_check_list(
622    checks: &CheckList,
623    state: Arc<StateMap>,
624    kinds: &[CheckKind],
625) -> Vec<CheckResult> {
626    let entries: Vec<CheckEntry> = checks.lock().expect("checks lock").clone();
627    let mut out = Vec::with_capacity(entries.len());
628    for (name, kind, check) in entries {
629        if !kinds.contains(&kind) {
630            continue;
631        }
632        match check(Arc::clone(&state)).await {
633            Ok(()) => out.push(CheckResult {
634                name,
635                ok: true,
636                error: None,
637            }),
638            Err(e) => out.push(CheckResult {
639                name,
640                ok: false,
641                error: Some(e.to_string()),
642            }),
643        }
644    }
645    out
646}
647
648fn ready_response(results: &[CheckResult]) -> Response {
649    let mut checks = serde_json::Map::new();
650    let mut failed = Vec::new();
651    for r in results {
652        if r.ok {
653            checks.insert(r.name.to_string(), serde_json::json!("ok"));
654        } else {
655            let msg = r.error.clone().unwrap_or_else(|| "failed".into());
656            checks.insert(r.name.to_string(), serde_json::json!(msg));
657            failed.push(r.name);
658        }
659    }
660    let mut res = if failed.is_empty() {
661        Response::json(&serde_json::json!({
662            "status": "ok",
663            "checks": checks,
664        }))
665    } else {
666        Response::json(&serde_json::json!({
667            "status": "not_ready",
668            "failed": failed,
669            "checks": checks,
670        }))
671        .status(503)
672    };
673    // Set by `cargo sovax dev --graceful` so the orchestrator can detect the new process
674    // while the old one still answers on the same REUSEPORT socket.
675    if let Ok(id) = std::env::var("SOVA_INSTANCE_ID") {
676        if !id.is_empty() {
677            res = res.header("x-sova-instance", id);
678        }
679    }
680    res
681}
682
683#[cfg(test)]
684mod env_addr_tests {
685    use super::addr_from_env;
686    use std::sync::Mutex;
687
688    static ENV_LOCK: Mutex<()> = Mutex::new(());
689
690    #[test]
691    fn port_from_env() {
692        let _g = ENV_LOCK.lock().unwrap();
693        std::env::set_var("PORT", "9876");
694        std::env::remove_var("HOST");
695        let addr = addr_from_env(3000).unwrap();
696        assert_eq!(addr.port(), 9876);
697        std::env::remove_var("PORT");
698    }
699
700    #[test]
701    fn host_ip_from_env() {
702        let _g = ENV_LOCK.lock().unwrap();
703        std::env::remove_var("PORT");
704        std::env::set_var("HOST", "127.0.0.1");
705        let addr = addr_from_env(3000).unwrap();
706        assert_eq!(addr, "127.0.0.1:3000".parse().unwrap());
707        std::env::remove_var("HOST");
708    }
709}