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:   FSL-1.1-ALv2
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#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_environment_display() {
334        assert_eq!(Environment::Kubernetes.to_string(), "kubernetes");
335        assert_eq!(Environment::Docker.to_string(), "docker");
336        assert_eq!(Environment::Container.to_string(), "container");
337        assert_eq!(Environment::BareMetal.to_string(), "bare_metal");
338    }
339
340    #[test]
341    fn test_environment_is_container() {
342        assert!(Environment::Kubernetes.is_container());
343        assert!(Environment::Docker.is_container());
344        assert!(Environment::Container.is_container());
345        assert!(!Environment::BareMetal.is_container());
346    }
347
348    #[test]
349    fn test_environment_is_kubernetes() {
350        assert!(Environment::Kubernetes.is_kubernetes());
351        assert!(!Environment::Docker.is_kubernetes());
352        assert!(!Environment::Container.is_kubernetes());
353        assert!(!Environment::BareMetal.is_kubernetes());
354    }
355
356    #[test]
357    fn test_environment_is_bare_metal() {
358        assert!(!Environment::Kubernetes.is_bare_metal());
359        assert!(!Environment::Docker.is_bare_metal());
360        assert!(!Environment::Container.is_bare_metal());
361        assert!(Environment::BareMetal.is_bare_metal());
362    }
363
364    #[test]
365    fn test_get_app_env_default() {
366        temp_env::with_vars(
367            [
368                ("APP_ENV", None::<&str>),
369                ("ENVIRONMENT", None),
370                ("ENV", None),
371            ],
372            || assert_eq!(get_app_env(), "development"),
373        );
374    }
375
376    #[test]
377    fn test_get_app_env_from_app_env() {
378        temp_env::with_var("APP_ENV", Some("production"), || {
379            assert_eq!(get_app_env(), "production");
380        });
381    }
382
383    #[test]
384    fn test_environment_detect_returns_valid() {
385        // Just ensure detect() doesn't panic and returns a valid variant
386        let env = Environment::detect();
387        assert!(matches!(
388            env,
389            Environment::Kubernetes
390                | Environment::Docker
391                | Environment::Container
392                | Environment::BareMetal
393        ));
394    }
395
396    // --- RuntimeContext tests ---
397
398    #[test]
399    fn test_runtime_context_detect_does_not_panic() {
400        let ctx = RuntimeContext::detect();
401        // Environment is always set
402        assert!(matches!(
403            ctx.environment,
404            Environment::Kubernetes
405                | Environment::Docker
406                | Environment::Container
407                | Environment::BareMetal
408        ));
409    }
410
411    #[test]
412    fn test_runtime_context_bare_metal_has_no_k8s_fields() {
413        // On a dev machine (bare metal), K8s fields should be None
414        // unless POD_NAME etc. env vars happen to be set
415        let ctx = RuntimeContext::detect();
416        if ctx.environment.is_bare_metal() {
417            assert!(
418                ctx.node_name.is_none(),
419                "node_name should be None on bare metal"
420            );
421            // pod_name might come from HOSTNAME, so we don't assert it's None
422        }
423    }
424
425    #[test]
426    fn test_runtime_context_reads_pod_name_env() {
427        temp_env::with_vars(
428            [
429                ("POD_NAME", Some("test-pod-123")),
430                ("KUBERNETES_SERVICE_HOST", Some("10.0.0.1")),
431            ],
432            || {
433                let ctx = RuntimeContext::detect();
434                assert_eq!(ctx.pod_name.as_deref(), Some("test-pod-123"));
435            },
436        );
437    }
438
439    #[test]
440    fn test_runtime_context_reads_namespace_env() {
441        temp_env::with_var("POD_NAMESPACE", Some("production"), || {
442            let ctx = RuntimeContext::detect();
443            assert_eq!(ctx.namespace.as_deref(), Some("production"));
444        });
445    }
446
447    #[test]
448    fn test_runtime_context_reads_node_name_env() {
449        temp_env::with_var("NODE_NAME", Some("node-1"), || {
450            let ctx = RuntimeContext::detect();
451            assert_eq!(ctx.node_name.as_deref(), Some("node-1"));
452        });
453    }
454
455    #[test]
456    fn test_runtime_context_global_singleton() {
457        // runtime_context() should return the same instance every time
458        let ctx1 = runtime_context();
459        let ctx2 = runtime_context();
460        assert_eq!(ctx1.environment, ctx2.environment);
461        assert_eq!(ctx1.pod_name, ctx2.pod_name);
462    }
463
464    #[test]
465    fn test_runtime_context_is_kubernetes_convenience() {
466        let mut ctx = RuntimeContext::detect();
467        ctx.environment = Environment::Kubernetes;
468        assert!(ctx.is_kubernetes());
469        assert!(ctx.is_container());
470        assert!(!ctx.is_bare_metal());
471    }
472
473    #[test]
474    fn test_runtime_context_is_bare_metal_convenience() {
475        let mut ctx = RuntimeContext::detect();
476        ctx.environment = Environment::BareMetal;
477        assert!(!ctx.is_kubernetes());
478        assert!(!ctx.is_container());
479        assert!(ctx.is_bare_metal());
480    }
481
482    // --- cgroup helper tests ---
483
484    #[test]
485    fn test_read_cgroup_memory_limit_returns_option() {
486        // On bare metal, returns None (no cgroup). On container, returns Some.
487        let limit = read_cgroup_memory_limit();
488        // Just verify it doesn't panic — result depends on environment
489        let _ = limit;
490    }
491
492    #[test]
493    fn test_read_cgroup_cpu_quota_returns_option() {
494        let quota = read_cgroup_cpu_quota();
495        let _ = quota;
496    }
497
498    #[test]
499    fn test_prestop_delay_default_bare_metal() {
500        // On bare metal, default pre-stop delay should be 0
501        temp_env::with_var("PRESTOP_DELAY_SECS", None::<&str>, || {
502            let ctx = RuntimeContext::detect();
503            if ctx.environment.is_bare_metal() {
504                // The prestop_delay_secs function is in shutdown.rs,
505                // but we can verify the RuntimeContext is bare metal
506                assert!(!ctx.is_kubernetes());
507            }
508        });
509    }
510}