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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
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());
}
}
_ => {}
}
}
if meta.enable != Some(false) {
apply_partner_labels(&mut meta, labels);
}
meta
}
pub fn is_disabled(&self) -> bool {
self.enable == Some(false)
}
}
fn apply_partner_labels(meta: &mut KoiMetadata, labels: &HashMap<String, String>) {
let derived = extract_traefik(labels).or_else(|| extract_caddy(labels));
let Some(derived) = derived else {
return;
};
let mut used = false;
if let Some(host) = derived.host {
if meta.dns_name.is_none() {
meta.dns_name = Some(host.clone());
used = true;
}
if meta.name.is_none() {
meta.name = Some(host);
used = true;
}
}
if let Some(port) = derived.port {
if meta.proxy_port.is_none() {
meta.proxy_port = Some(port);
used = true;
}
}
if used {
if meta.enable.is_none() {
meta.enable = Some(true);
}
if meta.source.is_none() {
meta.source = Some(derived.source.to_string());
}
}
}
struct DerivedRouting {
host: Option<String>,
port: Option<u16>,
source: &'static str,
}
fn extract_traefik(labels: &HashMap<String, String>) -> Option<DerivedRouting> {
let has_traefik = labels.keys().any(|k| k.starts_with("traefik."));
if !has_traefik {
return None;
}
if let Some(enable) = labels.get("traefik.enable") {
if enable.trim().eq_ignore_ascii_case("false") {
return None;
}
}
let host = labels
.iter()
.filter(|(k, _)| is_traefik_rule_key(k))
.find_map(|(_, rule)| first_traefik_host(rule));
let port = labels
.iter()
.find(|(k, _)| is_traefik_port_key(k))
.and_then(|(_, v)| v.trim().parse::<u16>().ok());
if host.is_none() && port.is_none() {
return None;
}
Some(DerivedRouting {
host,
port,
source: "traefik-labels",
})
}
fn is_traefik_rule_key(key: &str) -> bool {
key.starts_with("traefik.http.routers.") && key.ends_with(".rule")
}
fn is_traefik_port_key(key: &str) -> bool {
key.starts_with("traefik.http.services.") && key.ends_with(".loadbalancer.server.port")
}
fn first_traefik_host(rule: &str) -> Option<String> {
let bytes = rule.as_bytes();
let mut search_from = 0usize;
while let Some(rel) = rule[search_from..].find("Host(") {
let open = search_from + rel + "Host(".len();
let mut i = open;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i >= bytes.len() {
return None;
}
let delim = bytes[i] as char;
if delim == '`' || delim == '"' {
let value_start = i + 1;
if let Some(end_rel) = rule[value_start..].find(delim) {
let value = rule[value_start..value_start + end_rel].trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
return None;
}
search_from = open;
}
None
}
fn extract_caddy(labels: &HashMap<String, String>) -> Option<DerivedRouting> {
let caddy = labels.get("caddy")?;
let host = caddy
.split(',')
.map(str::trim)
.find(|s| !s.is_empty())
.map(|s| s.to_string());
let port = labels
.get("caddy.reverse_proxy")
.and_then(|v| caddy_upstream_port(v));
if host.is_none() && port.is_none() {
return None;
}
Some(DerivedRouting {
host,
port,
source: "caddy-labels",
})
}
fn caddy_upstream_port(value: &str) -> Option<u16> {
let inner = value.trim().trim_start_matches("{{").trim_end_matches("}}");
inner
.split_whitespace()
.filter(|tok| !tok.eq_ignore_ascii_case("upstreams"))
.find_map(|tok| tok.parse::<u16>().ok())
}
#[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");
}
fn labels_of(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn traefik_host_simple() {
assert_eq!(
first_traefik_host("Host(`grafana.lab.internal`)").as_deref(),
Some("grafana.lab.internal")
);
}
#[test]
fn traefik_host_with_and_pathprefix() {
let rule = "Host(`api.lab.internal`) && PathPrefix(`/v1`)";
assert_eq!(
first_traefik_host(rule).as_deref(),
Some("api.lab.internal")
);
}
#[test]
fn traefik_host_with_or_takes_first() {
let rule = "Host(`a.lab.internal`) || Host(`b.lab.internal`)";
assert_eq!(first_traefik_host(rule).as_deref(), Some("a.lab.internal"));
}
#[test]
fn traefik_host_double_quote_form() {
assert_eq!(
first_traefik_host("Host(\"grafana.lab.internal\")").as_deref(),
Some("grafana.lab.internal")
);
}
#[test]
fn traefik_host_no_host_clause_is_none() {
assert_eq!(first_traefik_host("PathPrefix(`/api`)"), None);
}
#[test]
fn traefik_host_malformed_does_not_panic() {
assert_eq!(first_traefik_host("Host(`unterminated"), None);
assert_eq!(first_traefik_host("Host(``)"), None);
assert_eq!(first_traefik_host("Host("), None);
assert_eq!(first_traefik_host("HostSNI(`x`)"), None);
assert_eq!(first_traefik_host(""), None);
}
#[test]
fn traefik_full_labels_derive_host_and_port() {
let labels = labels_of(&[
("traefik.enable", "true"),
(
"traefik.http.routers.grafana.rule",
"Host(`grafana.lab.internal`)",
),
(
"traefik.http.services.grafana.loadbalancer.server.port",
"3000",
),
]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("grafana.lab.internal"));
assert_eq!(meta.proxy_port, Some(3000));
assert_eq!(meta.source.as_deref(), Some("traefik-labels"));
assert_eq!(meta.enable, Some(true));
}
#[test]
fn traefik_enable_false_is_not_managed() {
let labels = labels_of(&[
("traefik.enable", "false"),
("traefik.http.routers.x.rule", "Host(`x.lab.internal`)"),
]);
let meta = KoiMetadata::from_labels(&labels);
assert!(meta.dns_name.is_none());
assert!(meta.source.is_none());
}
#[test]
fn no_traefik_labels_no_derivation() {
let labels = labels_of(&[("some.other.label", "Host(`x`)")]);
let meta = KoiMetadata::from_labels(&labels);
assert!(meta.dns_name.is_none());
assert!(meta.source.is_none());
}
#[test]
fn caddy_bare_host() {
let labels = labels_of(&[("caddy", "grafana.lab.internal")]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("grafana.lab.internal"));
assert_eq!(meta.source.as_deref(), Some("caddy-labels"));
}
#[test]
fn caddy_comma_list_takes_first() {
let labels = labels_of(&[("caddy", " a.lab.internal , b.lab.internal ")]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("a.lab.internal"));
}
#[test]
fn caddy_upstreams_port_variants() {
assert_eq!(caddy_upstream_port("{{upstreams 8080}}"), Some(8080));
assert_eq!(caddy_upstream_port("{{upstreams http 8080}}"), Some(8080));
assert_eq!(caddy_upstream_port("{{upstreams https 8443}}"), Some(8443));
assert_eq!(caddy_upstream_port("{{upstreams https}}"), None);
assert_eq!(caddy_upstream_port("{{upstreams}}"), None);
}
#[test]
fn caddy_full_labels_derive_host_and_port() {
let labels = labels_of(&[
("caddy", "grafana.lab.internal"),
("caddy.reverse_proxy", "{{upstreams 3000}}"),
]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("grafana.lab.internal"));
assert_eq!(meta.proxy_port, Some(3000));
assert_eq!(meta.source.as_deref(), Some("caddy-labels"));
}
#[test]
fn explicit_koi_labels_beat_traefik() {
let labels = labels_of(&[
("koi.dns.name", "explicit"),
("koi.name", "Explicit Name"),
("koi.type", "_http._tcp"),
("koi.proxy.port", "9999"),
(
"traefik.http.routers.x.rule",
"Host(`from-traefik.lab.internal`)",
),
("traefik.http.services.x.loadbalancer.server.port", "3000"),
]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("explicit"));
assert_eq!(meta.name.as_deref(), Some("Explicit Name"));
assert_eq!(meta.service_type.as_deref(), Some("_http._tcp"));
assert_eq!(meta.proxy_port, Some(9999));
assert!(meta.source.is_none());
}
#[test]
fn explicit_dns_name_wins_but_traefik_fills_free_fields() {
let labels = labels_of(&[
("koi.dns.name", "pinned"),
(
"traefik.http.routers.x.rule",
"Host(`from-traefik.lab.internal`)",
),
("traefik.http.services.x.loadbalancer.server.port", "3000"),
]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("pinned")); assert_eq!(meta.name.as_deref(), Some("from-traefik.lab.internal")); assert_eq!(meta.proxy_port, Some(3000)); assert_eq!(meta.source.as_deref(), Some("traefik-labels"));
}
#[test]
fn traefik_beats_heuristics_marker() {
let labels = labels_of(&[("traefik.http.routers.x.rule", "Host(`svc.lab.internal`)")]);
let meta = KoiMetadata::from_labels(&labels);
assert_eq!(meta.dns_name.as_deref(), Some("svc.lab.internal"));
assert_eq!(meta.source.as_deref(), Some("traefik-labels"));
}
#[test]
fn koi_enable_false_beats_everything() {
let labels = labels_of(&[
("koi.enable", "false"),
("traefik.http.routers.x.rule", "Host(`x.lab.internal`)"),
("caddy", "y.lab.internal"),
]);
let meta = KoiMetadata::from_labels(&labels);
assert!(meta.is_disabled());
assert!(meta.dns_name.is_none());
assert!(meta.source.is_none());
}
#[test]
fn partner_parsing_never_panics_on_arbitrary_labels() {
let labels = labels_of(&[
("traefik.enable", "maybe"),
("traefik.http.routers.r.rule", "Host(`)(`weird"),
(
"traefik.http.services.s.loadbalancer.server.port",
"not-a-number",
),
("caddy", ",,, , "),
("caddy.reverse_proxy", "{{garbage"),
]);
let _ = KoiMetadata::from_labels(&labels);
}
}