1use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
14pub struct Instance {
15 pub id: String,
17 pub name: String,
19 pub ports: Vec<PortMapping>,
21 pub ips: Vec<String>,
23 pub metadata: KoiMetadata,
25 pub backend: String,
27 pub state: InstanceState,
29 pub discovered_at: DateTime<Utc>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub image: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct PortMapping {
39 pub host_port: u16,
41 pub container_port: u16,
43 pub protocol: PortProtocol,
45 pub host_ip: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
51#[serde(rename_all = "lowercase")]
52pub enum PortProtocol {
53 Tcp,
54 Udp,
55}
56
57#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
73pub struct KoiMetadata {
74 #[serde(skip_serializing_if = "Option::is_none")]
77 pub enable: Option<bool>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub service_type: Option<String>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub name: Option<String>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub dns_name: Option<String>,
90
91 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
93 pub txt: HashMap<String, String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub health_path: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub health_kind: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub health_interval: Option<u64>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub health_timeout: Option<u64>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub proxy_port: Option<u16>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub proxy_remote: Option<bool>,
118
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub certmesh: Option<bool>,
122}
123
124impl KoiMetadata {
125 pub fn from_labels(labels: &HashMap<String, String>) -> Self {
130 Self::from_labels_and_env(labels, &[])
131 }
132
133 pub fn from_labels_and_env(labels: &HashMap<String, String>, env: &[String]) -> Self {
145 let mut meta = Self::default();
146
147 let env_announce = env
149 .iter()
150 .find_map(|e| e.strip_prefix("KOI_MDNS_ANNOUNCE=").map(|v| v.to_string()));
151
152 let label_announce = labels.get("koi.announce").cloned();
154
155 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 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" => {} 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 pub fn is_disabled(&self) -> bool {
191 self.enable == Some(false)
192 }
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct ComposeInfo {
198 pub project: Option<String>,
199 pub service: Option<String>,
200}
201
202impl ComposeInfo {
203 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 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 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)); assert_eq!(meta.name.as_deref(), Some("Pi-Hole DNS")); assert_eq!(meta.dns_name.as_deref(), Some("pihole")); assert_eq!(meta.service_type.as_deref(), Some("_dns._tcp")); }
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}