Skip to main content

koi_runtime/
instance.rs

1//! Normalized instance and metadata types.
2//!
3//! Every runtime backend converts its native types into these
4//! runtime-agnostic representations.
5
6use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12/// A runtime-managed instance (container, VM, or service unit).
13#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
14pub struct Instance {
15    /// Unique identifier from the runtime (container ID, pod UID, unit name).
16    pub id: String,
17    /// Human-readable name (container name, pod name, unit description).
18    pub name: String,
19    /// Resolved host-side port mappings.
20    pub ports: Vec<PortMapping>,
21    /// IP addresses reachable from the host network (as strings for serde/OpenAPI).
22    pub ips: Vec<String>,
23    /// Koi-specific metadata extracted from labels/annotations/config.
24    pub metadata: KoiMetadata,
25    /// Runtime backend that discovered this instance.
26    pub backend: String,
27    /// Current lifecycle state.
28    pub state: InstanceState,
29    /// When the instance was first observed.
30    pub discovered_at: DateTime<Utc>,
31    /// Image or unit source (e.g., "grafana/grafana:latest").
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub image: Option<String>,
34}
35
36/// A host-side port mapping.
37#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct PortMapping {
39    /// Host port (the one reachable from the network).
40    pub host_port: u16,
41    /// Container/internal port.
42    pub container_port: u16,
43    /// Protocol (tcp or udp).
44    pub protocol: PortProtocol,
45    /// Host IP the port is bound to (0.0.0.0, 127.0.0.1, etc.).
46    pub host_ip: String,
47}
48
49/// Port protocol.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
51#[serde(rename_all = "lowercase")]
52pub enum PortProtocol {
53    Tcp,
54    Udp,
55}
56
57/// Lifecycle state of a runtime instance.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
59#[serde(rename_all = "lowercase")]
60pub enum InstanceState {
61    Running,
62    Stopped,
63    Paused,
64    Restarting,
65    Unknown,
66}
67
68/// Koi-specific metadata extracted from runtime labels/annotations.
69///
70/// All fields are optional — when absent, the adapter uses heuristics
71/// or skips the corresponding Koi capability.
72#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
73pub struct KoiMetadata {
74    /// Opt-in flag. When `Some(false)`, the instance is ignored.
75    /// When `None`, the adapter uses its default policy (opt-in or opt-out).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub enable: Option<bool>,
78
79    /// mDNS service type override (e.g., `_http._tcp`).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub service_type: Option<String>,
82
83    /// Service name override for mDNS/DNS.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub name: Option<String>,
86
87    /// DNS name override (without zone suffix).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub dns_name: Option<String>,
90
91    /// TXT record key-value pairs for mDNS.
92    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
93    pub txt: HashMap<String, String>,
94
95    /// Health check HTTP path (e.g., `/healthz`).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub health_path: Option<String>,
98
99    /// Health check kind override (`http` or `tcp`).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub health_kind: Option<String>,
102
103    /// Health check interval in seconds.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub health_interval: Option<u64>,
106
107    /// Health check timeout in seconds.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub health_timeout: Option<u64>,
110
111    /// TLS proxy listen port.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub proxy_port: Option<u16>,
114
115    /// Allow remote proxy connections.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub proxy_remote: Option<bool>,
118
119    /// Enable certmesh cert injection.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub certmesh: Option<bool>,
122}
123
124impl KoiMetadata {
125    /// Parse from a flat key-value map (Docker labels, Incus user.* config).
126    ///
127    /// Keys use the `koi.` prefix: `koi.type`, `koi.name`, `koi.dns.name`,
128    /// `koi.txt.key`, `koi.health.path`, etc.
129    pub fn from_labels(labels: &HashMap<String, String>) -> Self {
130        Self::from_labels_and_env(labels, &[])
131    }
132
133    /// Parse from labels with optional environment variable overrides.
134    ///
135    /// Environment variables provide a lower-precedence shorthand:
136    /// - `KOI_MDNS_ANNOUNCE=<name>` — equivalent to `koi.announce=<name>` label
137    ///
138    /// The `koi.announce=<name>` shorthand (label or env var) sets:
139    /// - `enable = true`
140    /// - `name = <name>`
141    /// - `dns_name = <name>`
142    ///
143    /// Explicit `koi.*` labels always override the shorthand.
144    pub fn from_labels_and_env(labels: &HashMap<String, String>, env: &[String]) -> Self {
145        let mut meta = Self::default();
146
147        // 1. Check env var shorthand (lowest precedence)
148        let env_announce = env
149            .iter()
150            .find_map(|e| e.strip_prefix("KOI_MDNS_ANNOUNCE=").map(|v| v.to_string()));
151
152        // 2. Check label shorthand (overrides env var)
153        let label_announce = labels.get("koi.announce").cloned();
154
155        // Apply announce shorthand: label > env var
156        if let Some(announce_name) = label_announce.or(env_announce) {
157            meta.enable = Some(true);
158            meta.name = Some(announce_name.clone());
159            meta.dns_name = Some(announce_name);
160        }
161
162        // 3. Apply explicit labels (highest precedence — override shorthand)
163        for (key, value) in labels {
164            match key.as_str() {
165                "koi.enable" => meta.enable = value.parse().ok(),
166                "koi.type" => meta.service_type = Some(value.clone()),
167                "koi.name" => meta.name = Some(value.clone()),
168                "koi.dns.name" => meta.dns_name = Some(value.clone()),
169                "koi.health.path" => meta.health_path = Some(value.clone()),
170                "koi.health.kind" => meta.health_kind = Some(value.clone()),
171                "koi.health.interval" => meta.health_interval = value.parse().ok(),
172                "koi.health.timeout" => meta.health_timeout = value.parse().ok(),
173                "koi.proxy.port" => meta.proxy_port = value.parse().ok(),
174                "koi.proxy.remote" => meta.proxy_remote = value.parse().ok(),
175                "koi.certmesh" => meta.certmesh = value.parse().ok(),
176                "koi.announce" => {} // already handled above
177                k if k.starts_with("koi.txt.") => {
178                    if let Some(txt_key) = k.strip_prefix("koi.txt.") {
179                        meta.txt.insert(txt_key.to_string(), value.clone());
180                    }
181                }
182                _ => {}
183            }
184        }
185
186        meta
187    }
188
189    /// Whether this instance is explicitly opted out.
190    pub fn is_disabled(&self) -> bool {
191        self.enable == Some(false)
192    }
193}
194
195/// Compose metadata extracted from Docker Compose labels.
196#[derive(Debug, Clone, Default)]
197pub struct ComposeInfo {
198    pub project: Option<String>,
199    pub service: Option<String>,
200}
201
202impl ComposeInfo {
203    /// Extract from Docker labels.
204    pub fn from_labels(labels: &HashMap<String, String>) -> Self {
205        Self {
206            project: labels.get("com.docker.compose.project").cloned(),
207            service: labels.get("com.docker.compose.service").cloned(),
208        }
209    }
210
211    /// Best available service name: Compose service > container name.
212    pub fn effective_name<'a>(&'a self, container_name: &'a str) -> &'a str {
213        self.service.as_deref().unwrap_or(container_name)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn parse_labels_extracts_all_fields() {
223        let mut labels = HashMap::new();
224        labels.insert("koi.enable".into(), "true".into());
225        labels.insert("koi.type".into(), "_http._tcp".into());
226        labels.insert("koi.name".into(), "My App".into());
227        labels.insert("koi.dns.name".into(), "myapp".into());
228        labels.insert("koi.txt.version".into(), "1.0".into());
229        labels.insert("koi.txt.env".into(), "production".into());
230        labels.insert("koi.health.path".into(), "/healthz".into());
231        labels.insert("koi.health.kind".into(), "http".into());
232        labels.insert("koi.health.interval".into(), "30".into());
233        labels.insert("koi.health.timeout".into(), "5".into());
234        labels.insert("koi.proxy.port".into(), "443".into());
235        labels.insert("koi.proxy.remote".into(), "true".into());
236        labels.insert("koi.certmesh".into(), "true".into());
237
238        let meta = KoiMetadata::from_labels(&labels);
239
240        assert_eq!(meta.enable, Some(true));
241        assert_eq!(meta.service_type.as_deref(), Some("_http._tcp"));
242        assert_eq!(meta.name.as_deref(), Some("My App"));
243        assert_eq!(meta.dns_name.as_deref(), Some("myapp"));
244        assert_eq!(meta.txt.get("version").map(|s| s.as_str()), Some("1.0"));
245        assert_eq!(meta.txt.get("env").map(|s| s.as_str()), Some("production"));
246        assert_eq!(meta.health_path.as_deref(), Some("/healthz"));
247        assert_eq!(meta.health_kind.as_deref(), Some("http"));
248        assert_eq!(meta.health_interval, Some(30));
249        assert_eq!(meta.health_timeout, Some(5));
250        assert_eq!(meta.proxy_port, Some(443));
251        assert_eq!(meta.proxy_remote, Some(true));
252        assert_eq!(meta.certmesh, Some(true));
253    }
254
255    #[test]
256    fn empty_labels_produce_defaults() {
257        let meta = KoiMetadata::from_labels(&HashMap::new());
258        assert!(meta.enable.is_none());
259        assert!(meta.service_type.is_none());
260        assert!(meta.txt.is_empty());
261    }
262
263    #[test]
264    fn is_disabled_when_enable_false() {
265        let mut labels = HashMap::new();
266        labels.insert("koi.enable".into(), "false".into());
267        let meta = KoiMetadata::from_labels(&labels);
268        assert!(meta.is_disabled());
269    }
270
271    #[test]
272    fn announce_label_sets_enable_name_dns() {
273        let mut labels = HashMap::new();
274        labels.insert("koi.announce".into(), "pi-hole".into());
275        let meta = KoiMetadata::from_labels(&labels);
276
277        assert_eq!(meta.enable, Some(true));
278        assert_eq!(meta.name.as_deref(), Some("pi-hole"));
279        assert_eq!(meta.dns_name.as_deref(), Some("pi-hole"));
280        // service_type left to heuristics
281        assert!(meta.service_type.is_none());
282    }
283
284    #[test]
285    fn env_var_announce_sets_enable_name_dns() {
286        let labels = HashMap::new();
287        let env = vec![
288            "PATH=/usr/bin".to_string(),
289            "KOI_MDNS_ANNOUNCE=grafana".to_string(),
290        ];
291        let meta = KoiMetadata::from_labels_and_env(&labels, &env);
292
293        assert_eq!(meta.enable, Some(true));
294        assert_eq!(meta.name.as_deref(), Some("grafana"));
295        assert_eq!(meta.dns_name.as_deref(), Some("grafana"));
296    }
297
298    #[test]
299    fn label_announce_overrides_env_var() {
300        let mut labels = HashMap::new();
301        labels.insert("koi.announce".into(), "from-label".into());
302        let env = vec!["KOI_MDNS_ANNOUNCE=from-env".to_string()];
303        let meta = KoiMetadata::from_labels_and_env(&labels, &env);
304
305        assert_eq!(meta.name.as_deref(), Some("from-label"));
306    }
307
308    #[test]
309    fn explicit_labels_override_announce_shorthand() {
310        let mut labels = HashMap::new();
311        labels.insert("koi.announce".into(), "pi-hole".into());
312        labels.insert("koi.name".into(), "Pi-Hole DNS".into());
313        labels.insert("koi.dns.name".into(), "pihole".into());
314        labels.insert("koi.type".into(), "_dns._tcp".into());
315        let meta = KoiMetadata::from_labels(&labels);
316
317        assert_eq!(meta.enable, Some(true)); // from announce
318        assert_eq!(meta.name.as_deref(), Some("Pi-Hole DNS")); // overridden
319        assert_eq!(meta.dns_name.as_deref(), Some("pihole")); // overridden
320        assert_eq!(meta.service_type.as_deref(), Some("_dns._tcp")); // explicit
321    }
322
323    #[test]
324    fn no_announce_no_env_leaves_defaults() {
325        let labels = HashMap::new();
326        let env = vec!["PATH=/usr/bin".to_string()];
327        let meta = KoiMetadata::from_labels_and_env(&labels, &env);
328
329        assert!(meta.enable.is_none());
330        assert!(meta.name.is_none());
331        assert!(meta.dns_name.is_none());
332    }
333
334    #[test]
335    fn compose_info_prefers_service_over_container_name() {
336        let mut labels = HashMap::new();
337        labels.insert("com.docker.compose.service".into(), "grafana".into());
338        labels.insert("com.docker.compose.project".into(), "monitoring".into());
339        let info = ComposeInfo::from_labels(&labels);
340        assert_eq!(info.effective_name("random-container-name"), "grafana");
341    }
342
343    #[test]
344    fn compose_info_falls_back_to_container_name() {
345        let info = ComposeInfo::from_labels(&HashMap::new());
346        assert_eq!(info.effective_name("my-container"), "my-container");
347    }
348}