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}
162
163impl RuntimeContext {
164    /// Detect the full runtime context.
165    ///
166    /// Reads environment variables and filesystem signals. Safe to call
167    /// on bare metal — fields will be `None` when not in a container.
168    #[must_use]
169    pub fn detect() -> Self {
170        let environment = Environment::detect();
171
172        let pod_name = std::env::var("POD_NAME").ok().or_else(|| {
173            if environment.is_container() {
174                std::env::var("HOSTNAME").ok()
175            } else {
176                None
177            }
178        });
179
180        let namespace = std::env::var("POD_NAMESPACE").ok().or_else(|| {
181            // Fall back to reading the K8s service account namespace file
182            std::fs::read_to_string("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
183                .ok()
184                .map(|s| s.trim().to_string())
185        });
186
187        let node_name = std::env::var("NODE_NAME").ok();
188
189        let container_id = if environment.is_container() {
190            std::env::var("HOSTNAME").ok()
191        } else {
192            None
193        };
194
195        Self {
196            environment,
197            pod_name,
198            namespace,
199            node_name,
200            container_id,
201        }
202    }
203
204    /// Convenience: is this running in Kubernetes?
205    #[must_use]
206    pub fn is_kubernetes(&self) -> bool {
207        self.environment.is_kubernetes()
208    }
209
210    /// Convenience: is this running in any container?
211    #[must_use]
212    pub fn is_container(&self) -> bool {
213        self.environment.is_container()
214    }
215
216    /// Convenience: is this bare metal / local dev?
217    #[must_use]
218    pub fn is_bare_metal(&self) -> bool {
219        self.environment.is_bare_metal()
220    }
221}
222
223impl Default for RuntimeContext {
224    fn default() -> Self {
225        Self::detect()
226    }
227}
228
229static RUNTIME_CONTEXT: std::sync::OnceLock<RuntimeContext> = std::sync::OnceLock::new();
230
231/// Get the global runtime context (detected lazily on first call).
232///
233/// All modules should use this instead of reading env vars directly.
234/// The context is immutable after first detection.
235#[must_use]
236pub fn runtime_context() -> &'static RuntimeContext {
237    RUNTIME_CONTEXT.get_or_init(RuntimeContext::detect)
238}
239
240// =============================================================================
241// Helm detection and app env helpers
242// =============================================================================
243
244/// Check if the application was deployed via Helm.
245///
246/// Looks for Helm-specific labels in Kubernetes downward API.
247#[must_use]
248pub fn is_helm() -> bool {
249    // Check for Helm release name env var (commonly set)
250    if std::env::var("HELM_RELEASE_NAME").is_ok() {
251        return true;
252    }
253
254    // Check for Helm labels via downward API
255    let labels_path = Path::new("/etc/podinfo/labels");
256    if labels_path.exists()
257        && let Ok(content) = std::fs::read_to_string(labels_path)
258    {
259        return content.contains("helm.sh/chart")
260            || content.contains("app.kubernetes.io/managed-by=\"Helm\"");
261    }
262
263    false
264}
265
266/// Get the current application environment name (dev, staging, prod).
267///
268/// Checks in order: `APP_ENV`, `ENVIRONMENT`, `ENV`, defaults to "development".
269#[must_use]
270pub fn get_app_env() -> String {
271    std::env::var("APP_ENV")
272        .or_else(|_| std::env::var("ENVIRONMENT"))
273        .or_else(|_| std::env::var("ENV"))
274        .unwrap_or_else(|_| "development".to_string())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_environment_display() {
283        assert_eq!(Environment::Kubernetes.to_string(), "kubernetes");
284        assert_eq!(Environment::Docker.to_string(), "docker");
285        assert_eq!(Environment::Container.to_string(), "container");
286        assert_eq!(Environment::BareMetal.to_string(), "bare_metal");
287    }
288
289    #[test]
290    fn test_environment_is_container() {
291        assert!(Environment::Kubernetes.is_container());
292        assert!(Environment::Docker.is_container());
293        assert!(Environment::Container.is_container());
294        assert!(!Environment::BareMetal.is_container());
295    }
296
297    #[test]
298    fn test_environment_is_kubernetes() {
299        assert!(Environment::Kubernetes.is_kubernetes());
300        assert!(!Environment::Docker.is_kubernetes());
301        assert!(!Environment::Container.is_kubernetes());
302        assert!(!Environment::BareMetal.is_kubernetes());
303    }
304
305    #[test]
306    fn test_environment_is_bare_metal() {
307        assert!(!Environment::Kubernetes.is_bare_metal());
308        assert!(!Environment::Docker.is_bare_metal());
309        assert!(!Environment::Container.is_bare_metal());
310        assert!(Environment::BareMetal.is_bare_metal());
311    }
312
313    #[test]
314    fn test_get_app_env_default() {
315        temp_env::with_vars(
316            [
317                ("APP_ENV", None::<&str>),
318                ("ENVIRONMENT", None),
319                ("ENV", None),
320            ],
321            || assert_eq!(get_app_env(), "development"),
322        );
323    }
324
325    #[test]
326    fn test_get_app_env_from_app_env() {
327        temp_env::with_var("APP_ENV", Some("production"), || {
328            assert_eq!(get_app_env(), "production");
329        });
330    }
331
332    #[test]
333    fn test_environment_detect_returns_valid() {
334        // Just ensure detect() doesn't panic and returns a valid variant
335        let env = Environment::detect();
336        assert!(matches!(
337            env,
338            Environment::Kubernetes
339                | Environment::Docker
340                | Environment::Container
341                | Environment::BareMetal
342        ));
343    }
344}