use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Instance {
pub id: String,
pub name: String,
pub ports: Vec<PortMapping>,
pub ips: Vec<String>,
pub metadata: KoiMetadata,
pub backend: String,
pub state: InstanceState,
pub discovered_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PortMapping {
pub host_port: u16,
pub container_port: u16,
pub protocol: PortProtocol,
pub host_ip: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum PortProtocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum InstanceState {
Running,
Stopped,
Paused,
Restarting,
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct KoiMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub enable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_name: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub txt: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_remote: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certmesh: Option<bool>,
}
impl KoiMetadata {
pub fn from_labels(labels: &HashMap<String, String>) -> Self {
Self::from_labels_and_env(labels, &[])
}
pub fn from_labels_and_env(labels: &HashMap<String, String>, env: &[String]) -> Self {
let mut meta = Self::default();
let env_announce = env
.iter()
.find_map(|e| e.strip_prefix("KOI_MDNS_ANNOUNCE=").map(|v| v.to_string()));
let label_announce = labels.get("koi.announce").cloned();
if let Some(announce_name) = label_announce.or(env_announce) {
meta.enable = Some(true);
meta.name = Some(announce_name.clone());
meta.dns_name = Some(announce_name);
}
for (key, value) in labels {
match key.as_str() {
"koi.enable" => meta.enable = value.parse().ok(),
"koi.type" => meta.service_type = Some(value.clone()),
"koi.name" => meta.name = Some(value.clone()),
"koi.dns.name" => meta.dns_name = Some(value.clone()),
"koi.health.path" => meta.health_path = Some(value.clone()),
"koi.health.kind" => meta.health_kind = Some(value.clone()),
"koi.health.interval" => meta.health_interval = value.parse().ok(),
"koi.health.timeout" => meta.health_timeout = value.parse().ok(),
"koi.proxy.port" => meta.proxy_port = value.parse().ok(),
"koi.proxy.remote" => meta.proxy_remote = value.parse().ok(),
"koi.certmesh" => meta.certmesh = value.parse().ok(),
"koi.announce" => {} k if k.starts_with("koi.txt.") => {
if let Some(txt_key) = k.strip_prefix("koi.txt.") {
meta.txt.insert(txt_key.to_string(), value.clone());
}
}
_ => {}
}
}
meta
}
pub fn is_disabled(&self) -> bool {
self.enable == Some(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct ComposeInfo {
pub project: Option<String>,
pub service: Option<String>,
}
impl ComposeInfo {
pub fn from_labels(labels: &HashMap<String, String>) -> Self {
Self {
project: labels.get("com.docker.compose.project").cloned(),
service: labels.get("com.docker.compose.service").cloned(),
}
}
pub fn effective_name<'a>(&'a self, container_name: &'a str) -> &'a str {
self.service.as_deref().unwrap_or(container_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_labels_extracts_all_fields() {
let mut labels = HashMap::new();
labels.insert("koi.enable".into(), "true".into());
labels.insert("koi.type".into(), "_http._tcp".into());
labels.insert("koi.name".into(), "My App".into());
labels.insert("koi.dns.name".into(), "myapp".into());
labels.insert("koi.txt.version".into(), "1.0".into());
labels.insert("koi.txt.env".into(), "production".into());
labels.insert("koi.health.path".into(), "/healthz".into());
labels.insert("koi.health.kind".into(), "http".into());
labels.insert("koi.health.interval".into(), "30".into());
labels.insert("koi.health.timeout".into(), "5".into());
labels.insert("koi.proxy.port".into(), "443".into());
labels.insert("koi.proxy.remote".into(), "true".into());
labels.insert("koi.certmesh".into(), "true".into());
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.enable, Some(true));
assert_eq!(meta.service_type.as_deref(), Some("_http._tcp"));
assert_eq!(meta.name.as_deref(), Some("My App"));
assert_eq!(meta.dns_name.as_deref(), Some("myapp"));
assert_eq!(meta.txt.get("version").map(|s| s.as_str()), Some("1.0"));
assert_eq!(meta.txt.get("env").map(|s| s.as_str()), Some("production"));
assert_eq!(meta.health_path.as_deref(), Some("/healthz"));
assert_eq!(meta.health_kind.as_deref(), Some("http"));
assert_eq!(meta.health_interval, Some(30));
assert_eq!(meta.health_timeout, Some(5));
assert_eq!(meta.proxy_port, Some(443));
assert_eq!(meta.proxy_remote, Some(true));
assert_eq!(meta.certmesh, Some(true));
}
#[test]
fn empty_labels_produce_defaults() {
let meta = KoiMetadata::from_labels(&HashMap::new());
assert!(meta.enable.is_none());
assert!(meta.service_type.is_none());
assert!(meta.txt.is_empty());
}
#[test]
fn is_disabled_when_enable_false() {
let mut labels = HashMap::new();
labels.insert("koi.enable".into(), "false".into());
let meta = KoiMetadata::from_labels(&labels);
assert!(meta.is_disabled());
}
#[test]
fn announce_label_sets_enable_name_dns() {
let mut labels = HashMap::new();
labels.insert("koi.announce".into(), "pi-hole".into());
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.enable, Some(true));
assert_eq!(meta.name.as_deref(), Some("pi-hole"));
assert_eq!(meta.dns_name.as_deref(), Some("pi-hole"));
assert!(meta.service_type.is_none());
}
#[test]
fn env_var_announce_sets_enable_name_dns() {
let labels = HashMap::new();
let env = vec![
"PATH=/usr/bin".to_string(),
"KOI_MDNS_ANNOUNCE=grafana".to_string(),
];
let meta = KoiMetadata::from_labels_and_env(&labels, &env);
assert_eq!(meta.enable, Some(true));
assert_eq!(meta.name.as_deref(), Some("grafana"));
assert_eq!(meta.dns_name.as_deref(), Some("grafana"));
}
#[test]
fn label_announce_overrides_env_var() {
let mut labels = HashMap::new();
labels.insert("koi.announce".into(), "from-label".into());
let env = vec!["KOI_MDNS_ANNOUNCE=from-env".to_string()];
let meta = KoiMetadata::from_labels_and_env(&labels, &env);
assert_eq!(meta.name.as_deref(), Some("from-label"));
}
#[test]
fn explicit_labels_override_announce_shorthand() {
let mut labels = HashMap::new();
labels.insert("koi.announce".into(), "pi-hole".into());
labels.insert("koi.name".into(), "Pi-Hole DNS".into());
labels.insert("koi.dns.name".into(), "pihole".into());
labels.insert("koi.type".into(), "_dns._tcp".into());
let meta = KoiMetadata::from_labels(&labels);
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")); }
#[test]
fn no_announce_no_env_leaves_defaults() {
let labels = HashMap::new();
let env = vec!["PATH=/usr/bin".to_string()];
let meta = KoiMetadata::from_labels_and_env(&labels, &env);
assert!(meta.enable.is_none());
assert!(meta.name.is_none());
assert!(meta.dns_name.is_none());
}
#[test]
fn compose_info_prefers_service_over_container_name() {
let mut labels = HashMap::new();
labels.insert("com.docker.compose.service".into(), "grafana".into());
labels.insert("com.docker.compose.project".into(), "monitoring".into());
let info = ComposeInfo::from_labels(&labels);
assert_eq!(info.effective_name("random-container-name"), "grafana");
}
#[test]
fn compose_info_falls_back_to_container_name() {
let info = ComposeInfo::from_labels(&HashMap::new());
assert_eq!(info.effective_name("my-container"), "my-container");
}
}