Skip to main content

hyperi_rustlib/
env.rs

1// Project:   hyperi-rustlib
2// File:      src/env.rs
3// Purpose:   Runtime environment detection (K8s, Docker, container, bare metal)
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Runtime environment detection.
10//!
11//! Detects whether the application is running in Kubernetes, Docker,
12//! a generic container, or on bare metal. This information is used
13//! to configure paths, logging format, and other runtime behaviour.
14
15use std::path::Path;
16
17/// Runtime environment types.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum Environment {
20    /// Running in Kubernetes
21    Kubernetes,
22    /// Running in Docker (but not K8s)
23    Docker,
24    /// Running in a generic container (detected via cgroups)
25    Container,
26    /// Running on bare metal / local development
27    BareMetal,
28}
29
30impl Environment {
31    /// Detect the current runtime environment.
32    ///
33    /// Detection priority (highest confidence first):
34    /// 1. Kubernetes service account token exists
35    /// 2. Kubernetes environment variables present
36    /// 3. Docker: `/.dockerenv` file exists
37    /// 4. Container: cgroups contain container markers
38    /// 5. Default: `BareMetal`
39    #[must_use]
40    pub fn detect() -> Self {
41        // Check for Kubernetes first (highest priority)
42        if Self::is_kubernetes_by_token() || Self::is_kubernetes_by_env() {
43            return Self::Kubernetes;
44        }
45
46        // Check for Docker
47        if Self::is_docker_by_file() {
48            return Self::Docker;
49        }
50
51        // Check for generic container via cgroups
52        if Self::is_container_by_cgroups() {
53            return Self::Container;
54        }
55
56        Self::BareMetal
57    }
58
59    /// Check if running in any container environment.
60    #[must_use]
61    pub const fn is_container(&self) -> bool {
62        matches!(self, Self::Kubernetes | Self::Docker | Self::Container)
63    }
64
65    /// Check if running in Kubernetes.
66    #[must_use]
67    pub const fn is_kubernetes(&self) -> bool {
68        matches!(self, Self::Kubernetes)
69    }
70
71    /// Check if running in Docker (but not K8s).
72    #[must_use]
73    pub const fn is_docker(&self) -> bool {
74        matches!(self, Self::Docker)
75    }
76
77    /// Check if running on bare metal.
78    #[must_use]
79    pub const fn is_bare_metal(&self) -> bool {
80        matches!(self, Self::BareMetal)
81    }
82
83    // Detection helpers
84
85    fn is_kubernetes_by_token() -> bool {
86        Path::new("/var/run/secrets/kubernetes.io/serviceaccount/token").exists()
87    }
88
89    fn is_kubernetes_by_env() -> bool {
90        std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
91    }
92
93    fn is_docker_by_file() -> bool {
94        Path::new("/.dockerenv").exists()
95    }
96
97    fn is_container_by_cgroups() -> bool {
98        // Check cgroup v1
99        if let Ok(content) = std::fs::read_to_string("/proc/1/cgroup")
100            && (content.contains("/docker/")
101                || content.contains("/kubepods/")
102                || content.contains("/lxc/")
103                || content.contains("/containerd/"))
104        {
105            return true;
106        }
107
108        // Check cgroup v2 (unified hierarchy)
109        if let Ok(content) = std::fs::read_to_string("/proc/1/mountinfo")
110            && (content.contains("/docker/")
111                || content.contains("/kubepods/")
112                || content.contains("/containerd/"))
113        {
114            return true;
115        }
116
117        false
118    }
119}
120
121impl std::fmt::Display for Environment {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::Kubernetes => write!(f, "kubernetes"),
125            Self::Docker => write!(f, "docker"),
126            Self::Container => write!(f, "container"),
127            Self::BareMetal => write!(f, "bare_metal"),
128        }
129    }
130}
131
132impl Default for Environment {
133    fn default() -> Self {
134        Self::detect()
135    }
136}
137
138// =============================================================================
139// RuntimeContext -- rich runtime metadata detected once at startup
140// =============================================================================
141
142/// Rich runtime context detected once at startup, immutable after.
143///
144/// Provides all K8s/container metadata in one place. Modules read from this
145/// instead of doing their own env var lookups. Detected lazily on first access
146/// via [`runtime_context()`].
147///
148/// On bare metal, most fields are `None` -- features that read them become no-ops.
149#[derive(Debug, Clone)]
150pub struct RuntimeContext {
151    /// Detected runtime environment.
152    pub environment: Environment,
153    /// K8s pod name (from `POD_NAME` or `HOSTNAME` env var).
154    pub pod_name: Option<String>,
155    /// K8s namespace (from `POD_NAMESPACE` env var or service account).
156    pub namespace: Option<String>,
157    /// K8s node name (from `NODE_NAME` env var).
158    pub node_name: Option<String>,
159    /// Container ID (from `HOSTNAME` in container environments).
160    pub container_id: Option<String>,
161    /// cgroup memory limit in bytes (`None` if unlimited or bare metal).
162    pub memory_limit_bytes: Option<u64>,
163    /// cgroup CPU quota in cores (`None` if unlimited or bare metal).
164    pub cpu_quota_cores: Option<f64>,
165}
166
167impl RuntimeContext {
168    /// Detect the full runtime context.
169    ///
170    /// Reads environment variables and filesystem signals. Safe to call
171    /// on bare metal -- fields will be `None` when not in a container.
172    #[must_use]
173    pub fn detect() -> Self {
174        let environment = Environment::detect();
175
176        let pod_name = std::env::var("POD_NAME").ok().or_else(|| {
177            if environment.is_container() {
178                std::env::var("HOSTNAME").ok()
179            } else {
180                None
181            }
182        });
183
184        let namespace = std::env::var("POD_NAMESPACE").ok().or_else(|| {
185            // Fall back to reading the K8s service account namespace file
186            std::fs::read_to_string("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
187                .ok()
188                .map(|s| s.trim().to_string())
189        });
190
191        let node_name = std::env::var("NODE_NAME").ok();
192
193        let container_id = if environment.is_container() {
194            std::env::var("HOSTNAME").ok()
195        } else {
196            None
197        };
198
199        // cgroup resource limits (container environments only)
200        let memory_limit_bytes = if environment.is_container() {
201            read_cgroup_memory_limit()
202        } else {
203            None
204        };
205
206        let cpu_quota_cores = if environment.is_container() {
207            read_cgroup_cpu_quota()
208        } else {
209            None
210        };
211
212        Self {
213            environment,
214            pod_name,
215            namespace,
216            node_name,
217            container_id,
218            memory_limit_bytes,
219            cpu_quota_cores,
220        }
221    }
222
223    /// Convenience: is this running in Kubernetes?
224    #[must_use]
225    pub fn is_kubernetes(&self) -> bool {
226        self.environment.is_kubernetes()
227    }
228
229    /// Convenience: is this running in any container?
230    #[must_use]
231    pub fn is_container(&self) -> bool {
232        self.environment.is_container()
233    }
234
235    /// Convenience: is this bare metal / local dev?
236    #[must_use]
237    pub fn is_bare_metal(&self) -> bool {
238        self.environment.is_bare_metal()
239    }
240}
241
242impl Default for RuntimeContext {
243    fn default() -> Self {
244        Self::detect()
245    }
246}
247
248static RUNTIME_CONTEXT: std::sync::OnceLock<RuntimeContext> = std::sync::OnceLock::new();
249
250/// Get the global runtime context (detected lazily on first call).
251///
252/// All modules should use this instead of reading env vars directly.
253/// The context is immutable after first detection.
254#[must_use]
255pub fn runtime_context() -> &'static RuntimeContext {
256    RUNTIME_CONTEXT.get_or_init(RuntimeContext::detect)
257}
258
259// =============================================================================
260// cgroup resource limit helpers
261// =============================================================================
262
263/// Read cgroup v2 memory limit (returns None if unlimited or not in a cgroup).
264fn read_cgroup_memory_limit() -> Option<u64> {
265    let content = std::fs::read_to_string("/sys/fs/cgroup/memory.max").ok()?;
266    let trimmed = content.trim();
267    if trimmed == "max" {
268        return None; // No limit set
269    }
270    trimmed.parse::<u64>().ok()
271}
272
273/// Read cgroup v2 CPU quota as fractional cores (returns None if unlimited).
274///
275/// Reads `/sys/fs/cgroup/cpu.max` which contains `quota period` (e.g. "200000 100000" = 2 cores).
276fn read_cgroup_cpu_quota() -> Option<f64> {
277    let content = std::fs::read_to_string("/sys/fs/cgroup/cpu.max").ok()?;
278    let parts: Vec<&str> = content.split_whitespace().collect();
279    if parts.len() < 2 || parts[0] == "max" {
280        return None; // No limit
281    }
282    let quota: f64 = parts[0].parse().ok()?;
283    let period: f64 = parts[1].parse().ok()?;
284    if period > 0.0 {
285        Some(quota / period)
286    } else {
287        None
288    }
289}
290
291// =============================================================================
292// Helm detection and app env helpers
293// =============================================================================
294
295/// Check if the application was deployed via Helm.
296///
297/// Looks for Helm-specific labels in Kubernetes downward API.
298#[must_use]
299pub fn is_helm() -> bool {
300    // Check for Helm release name env var (commonly set)
301    if std::env::var("HELM_RELEASE_NAME").is_ok() {
302        return true;
303    }
304
305    // Check for Helm labels via downward API
306    let labels_path = Path::new("/etc/podinfo/labels");
307    if labels_path.exists()
308        && let Ok(content) = std::fs::read_to_string(labels_path)
309    {
310        return content.contains("helm.sh/chart")
311            || content.contains("app.kubernetes.io/managed-by=\"Helm\"");
312    }
313
314    false
315}
316
317/// Get the current application environment name (dev, staging, prod).
318///
319/// Checks in order: `APP_ENV`, `ENVIRONMENT`, `ENV`, defaults to "development".
320#[must_use]
321pub fn get_app_env() -> String {
322    std::env::var("APP_ENV")
323        .or_else(|_| std::env::var("ENVIRONMENT"))
324        .or_else(|_| std::env::var("ENV"))
325        .unwrap_or_else(|_| "development".to_string())
326}
327
328/// Whether the current app environment is production-like.
329///
330/// True when [`get_app_env`] resolves (case-insensitively) to `production`
331/// or `prod`. Used by config `validate(is_production)` methods to reject
332/// insecure-by-design settings (e.g. TLS `skip_verify`, plaintext disk
333/// caches) outside of dev/test.
334#[must_use]
335pub fn is_production() -> bool {
336    matches!(
337        get_app_env().to_ascii_lowercase().as_str(),
338        "production" | "prod"
339    )
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_environment_display() {
348        assert_eq!(Environment::Kubernetes.to_string(), "kubernetes");
349        assert_eq!(Environment::Docker.to_string(), "docker");
350        assert_eq!(Environment::Container.to_string(), "container");
351        assert_eq!(Environment::BareMetal.to_string(), "bare_metal");
352    }
353
354    #[test]
355    fn test_environment_is_container() {
356        assert!(Environment::Kubernetes.is_container());
357        assert!(Environment::Docker.is_container());
358        assert!(Environment::Container.is_container());
359        assert!(!Environment::BareMetal.is_container());
360    }
361
362    #[test]
363    fn test_environment_is_kubernetes() {
364        assert!(Environment::Kubernetes.is_kubernetes());
365        assert!(!Environment::Docker.is_kubernetes());
366        assert!(!Environment::Container.is_kubernetes());
367        assert!(!Environment::BareMetal.is_kubernetes());
368    }
369
370    #[test]
371    fn test_environment_is_bare_metal() {
372        assert!(!Environment::Kubernetes.is_bare_metal());
373        assert!(!Environment::Docker.is_bare_metal());
374        assert!(!Environment::Container.is_bare_metal());
375        assert!(Environment::BareMetal.is_bare_metal());
376    }
377
378    #[test]
379    fn test_get_app_env_default() {
380        temp_env::with_vars(
381            [
382                ("APP_ENV", None::<&str>),
383                ("ENVIRONMENT", None),
384                ("ENV", None),
385            ],
386            || assert_eq!(get_app_env(), "development"),
387        );
388    }
389
390    #[test]
391    fn test_get_app_env_from_app_env() {
392        temp_env::with_var("APP_ENV", Some("production"), || {
393            assert_eq!(get_app_env(), "production");
394        });
395    }
396
397    #[test]
398    fn test_environment_detect_returns_valid() {
399        // Just ensure detect() doesn't panic and returns a valid variant
400        let env = Environment::detect();
401        assert!(matches!(
402            env,
403            Environment::Kubernetes
404                | Environment::Docker
405                | Environment::Container
406                | Environment::BareMetal
407        ));
408    }
409
410    // --- RuntimeContext tests ---
411
412    #[test]
413    fn test_runtime_context_detect_does_not_panic() {
414        let ctx = RuntimeContext::detect();
415        // Environment is always set
416        assert!(matches!(
417            ctx.environment,
418            Environment::Kubernetes
419                | Environment::Docker
420                | Environment::Container
421                | Environment::BareMetal
422        ));
423    }
424
425    #[test]
426    fn test_runtime_context_bare_metal_has_no_k8s_fields() {
427        // On a dev machine (bare metal), K8s fields should be None
428        // unless POD_NAME etc. env vars happen to be set
429        let ctx = RuntimeContext::detect();
430        if ctx.environment.is_bare_metal() {
431            assert!(
432                ctx.node_name.is_none(),
433                "node_name should be None on bare metal"
434            );
435            // pod_name might come from HOSTNAME, so we don't assert it's None
436        }
437    }
438
439    #[test]
440    fn test_runtime_context_reads_pod_name_env() {
441        temp_env::with_vars(
442            [
443                ("POD_NAME", Some("test-pod-123")),
444                ("KUBERNETES_SERVICE_HOST", Some("10.0.0.1")),
445            ],
446            || {
447                let ctx = RuntimeContext::detect();
448                assert_eq!(ctx.pod_name.as_deref(), Some("test-pod-123"));
449            },
450        );
451    }
452
453    #[test]
454    fn test_runtime_context_reads_namespace_env() {
455        temp_env::with_var("POD_NAMESPACE", Some("production"), || {
456            let ctx = RuntimeContext::detect();
457            assert_eq!(ctx.namespace.as_deref(), Some("production"));
458        });
459    }
460
461    #[test]
462    fn test_runtime_context_reads_node_name_env() {
463        temp_env::with_var("NODE_NAME", Some("node-1"), || {
464            let ctx = RuntimeContext::detect();
465            assert_eq!(ctx.node_name.as_deref(), Some("node-1"));
466        });
467    }
468
469    #[test]
470    fn test_runtime_context_global_singleton() {
471        // runtime_context() should return the same instance every time
472        let ctx1 = runtime_context();
473        let ctx2 = runtime_context();
474        assert_eq!(ctx1.environment, ctx2.environment);
475        assert_eq!(ctx1.pod_name, ctx2.pod_name);
476    }
477
478    #[test]
479    fn test_runtime_context_is_kubernetes_convenience() {
480        let mut ctx = RuntimeContext::detect();
481        ctx.environment = Environment::Kubernetes;
482        assert!(ctx.is_kubernetes());
483        assert!(ctx.is_container());
484        assert!(!ctx.is_bare_metal());
485    }
486
487    #[test]
488    fn test_runtime_context_is_bare_metal_convenience() {
489        let mut ctx = RuntimeContext::detect();
490        ctx.environment = Environment::BareMetal;
491        assert!(!ctx.is_kubernetes());
492        assert!(!ctx.is_container());
493        assert!(ctx.is_bare_metal());
494    }
495
496    // --- cgroup helper tests ---
497
498    #[test]
499    fn test_read_cgroup_memory_limit_returns_option() {
500        // On bare metal, returns None (no cgroup). On container, returns Some.
501        let limit = read_cgroup_memory_limit();
502        // Just verify it doesn't panic -- result depends on environment
503        let _ = limit;
504    }
505
506    #[test]
507    fn test_read_cgroup_cpu_quota_returns_option() {
508        let quota = read_cgroup_cpu_quota();
509        let _ = quota;
510    }
511
512    #[test]
513    fn test_prestop_delay_default_bare_metal() {
514        // On bare metal, default pre-stop delay should be 0
515        temp_env::with_var("PRESTOP_DELAY_SECS", None::<&str>, || {
516            let ctx = RuntimeContext::detect();
517            if ctx.environment.is_bare_metal() {
518                // The prestop_delay_secs function is in shutdown.rs,
519                // but we can verify the RuntimeContext is bare metal
520                assert!(!ctx.is_kubernetes());
521            }
522        });
523    }
524}