Skip to main content

koi_embedded/
events.rs

1use koi_common::posture::Posture;
2use koi_common::types::ServiceRecord;
3use koi_health::HealthStatus;
4use koi_proxy::ProxyEntry;
5
6#[derive(Debug, Clone)]
7pub enum KoiEvent {
8    MdnsFound(ServiceRecord),
9    MdnsResolved(ServiceRecord),
10    MdnsRemoved {
11        name: String,
12        service_type: String,
13    },
14    DnsEntryUpdated {
15        name: String,
16        ip: String,
17    },
18    DnsEntryRemoved {
19        name: String,
20    },
21    HealthChanged {
22        name: String,
23        status: HealthStatus,
24    },
25    CertmeshMemberJoined {
26        hostname: String,
27        fingerprint: String,
28    },
29    CertmeshMemberRevoked {
30        hostname: String,
31    },
32    CertmeshDestroyed,
33    /// This node's leaf certificate was renewed successfully.
34    CertRenewed {
35        expires_at: chrono::DateTime<chrono::Utc>,
36    },
37    /// The leaf is past its renewal threshold but renewal is failing.
38    CertExpiringSoon {
39        days_left: i64,
40    },
41    /// A renewal attempt failed.
42    CertRenewalFailed {
43        reason: String,
44        consecutive_failures: u32,
45    },
46    /// The trust bundle was updated (policy refresh or revocation).
47    BundleUpdated {
48        self_revoked: bool,
49    },
50    /// This node's trust posture changed (ADR-020 §5/§13). Emitted on every
51    /// Open↔Authenticated transition. The **degrade** direction (identity lost →
52    /// fell back to Open) is surfaced as loudly as the upgrade — exactly where
53    /// silent expiry/fallback loses operators.
54    PostureChanged {
55        from: Posture,
56        to: Posture,
57    },
58    ProxyEntryUpdated {
59        entry: ProxyEntry,
60    },
61    ProxyEntryRemoved {
62        name: String,
63    },
64    RuntimeInstanceStarted {
65        name: String,
66        backend: String,
67    },
68    RuntimeInstanceStopped {
69        name: String,
70    },
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::collections::HashMap;
77
78    fn sample_record() -> ServiceRecord {
79        ServiceRecord {
80            name: "My App".to_string(),
81            service_type: "_http._tcp".to_string(),
82            host: Some("server.local".to_string()),
83            ip: Some("192.168.1.42".to_string()),
84            port: Some(8080),
85            txt: HashMap::new(),
86        }
87    }
88
89    #[test]
90    fn mdns_found_variant_construction() {
91        let event = KoiEvent::MdnsFound(sample_record());
92        assert!(matches!(event, KoiEvent::MdnsFound(ref r) if r.name == "My App"));
93    }
94
95    #[test]
96    fn mdns_resolved_variant_construction() {
97        let event = KoiEvent::MdnsResolved(sample_record());
98        assert!(matches!(event, KoiEvent::MdnsResolved(ref r) if r.port == Some(8080)));
99    }
100
101    #[test]
102    fn mdns_removed_variant_construction() {
103        let event = KoiEvent::MdnsRemoved {
104            name: "Old Service".to_string(),
105            service_type: "_http._tcp".to_string(),
106        };
107        assert!(matches!(event, KoiEvent::MdnsRemoved { ref name, .. } if name == "Old Service"));
108    }
109
110    #[test]
111    fn dns_entry_updated_variant() {
112        let event = KoiEvent::DnsEntryUpdated {
113            name: "grafana".to_string(),
114            ip: "10.0.0.5".to_string(),
115        };
116        assert!(
117            matches!(event, KoiEvent::DnsEntryUpdated { ref name, ref ip } if name == "grafana" && ip == "10.0.0.5")
118        );
119    }
120
121    #[test]
122    fn dns_entry_removed_variant() {
123        let event = KoiEvent::DnsEntryRemoved {
124            name: "grafana".to_string(),
125        };
126        assert!(matches!(event, KoiEvent::DnsEntryRemoved { ref name } if name == "grafana"));
127    }
128
129    #[test]
130    fn health_changed_variant() {
131        let event = KoiEvent::HealthChanged {
132            name: "web-api".to_string(),
133            status: HealthStatus::Up,
134        };
135        assert!(
136            matches!(event, KoiEvent::HealthChanged { ref name, status: HealthStatus::Up } if name == "web-api")
137        );
138    }
139
140    #[test]
141    fn certmesh_member_joined_variant() {
142        let event = KoiEvent::CertmeshMemberJoined {
143            hostname: "node1".to_string(),
144            fingerprint: "abc123".to_string(),
145        };
146        assert!(
147            matches!(event, KoiEvent::CertmeshMemberJoined { ref hostname, .. } if hostname == "node1")
148        );
149    }
150
151    #[test]
152    fn certmesh_member_revoked_variant() {
153        let event = KoiEvent::CertmeshMemberRevoked {
154            hostname: "node2".to_string(),
155        };
156        assert!(
157            matches!(event, KoiEvent::CertmeshMemberRevoked { ref hostname } if hostname == "node2")
158        );
159    }
160
161    #[test]
162    fn certmesh_destroyed_variant() {
163        let event = KoiEvent::CertmeshDestroyed;
164        assert!(matches!(event, KoiEvent::CertmeshDestroyed));
165    }
166
167    #[test]
168    fn posture_changed_variant() {
169        let event = KoiEvent::PostureChanged {
170            from: Posture::OPEN,
171            to: Posture::new(true, false),
172        };
173        assert!(
174            matches!(event, KoiEvent::PostureChanged { from, to } if !from.signed && to.signed)
175        );
176    }
177
178    #[test]
179    fn proxy_entry_updated_variant() {
180        let entry = ProxyEntry {
181            name: "grafana".to_string(),
182            listen_port: 443,
183            backend: "http://localhost:3000".to_string(),
184            allow_remote: false,
185        };
186        let event = KoiEvent::ProxyEntryUpdated {
187            entry: entry.clone(),
188        };
189        assert!(
190            matches!(event, KoiEvent::ProxyEntryUpdated { ref entry } if entry.name == "grafana")
191        );
192    }
193
194    #[test]
195    fn proxy_entry_removed_variant() {
196        let event = KoiEvent::ProxyEntryRemoved {
197            name: "grafana".to_string(),
198        };
199        assert!(matches!(event, KoiEvent::ProxyEntryRemoved { ref name } if name == "grafana"));
200    }
201
202    #[test]
203    fn runtime_instance_started_variant() {
204        let event = KoiEvent::RuntimeInstanceStarted {
205            name: "nginx".to_string(),
206            backend: "docker".to_string(),
207        };
208        assert!(
209            matches!(event, KoiEvent::RuntimeInstanceStarted { ref name, ref backend } if name == "nginx" && backend == "docker")
210        );
211    }
212
213    #[test]
214    fn runtime_instance_stopped_variant() {
215        let event = KoiEvent::RuntimeInstanceStopped {
216            name: "nginx".to_string(),
217        };
218        assert!(matches!(event, KoiEvent::RuntimeInstanceStopped { ref name } if name == "nginx"));
219    }
220
221    #[test]
222    fn clone_preserves_data() {
223        let event = KoiEvent::MdnsFound(sample_record());
224        let cloned = event.clone();
225        match (&event, &cloned) {
226            (KoiEvent::MdnsFound(a), KoiEvent::MdnsFound(b)) => {
227                assert_eq!(a.name, b.name);
228                assert_eq!(a.port, b.port);
229                assert_eq!(a.service_type, b.service_type);
230            }
231            _ => panic!("clone should preserve variant"),
232        }
233    }
234
235    #[test]
236    fn debug_does_not_panic() {
237        let events = vec![
238            KoiEvent::MdnsFound(sample_record()),
239            KoiEvent::MdnsRemoved {
240                name: "x".to_string(),
241                service_type: "y".to_string(),
242            },
243            KoiEvent::DnsEntryUpdated {
244                name: "a".to_string(),
245                ip: "1.2.3.4".to_string(),
246            },
247            KoiEvent::DnsEntryRemoved {
248                name: "a".to_string(),
249            },
250            KoiEvent::HealthChanged {
251                name: "svc".to_string(),
252                status: HealthStatus::Down,
253            },
254            KoiEvent::CertmeshMemberJoined {
255                hostname: "h".to_string(),
256                fingerprint: "f".to_string(),
257            },
258            KoiEvent::CertmeshMemberRevoked {
259                hostname: "h".to_string(),
260            },
261            KoiEvent::CertmeshDestroyed,
262            KoiEvent::CertRenewed {
263                expires_at: chrono::Utc::now(),
264            },
265            KoiEvent::CertExpiringSoon { days_left: 3 },
266            KoiEvent::CertRenewalFailed {
267                reason: "timeout".to_string(),
268                consecutive_failures: 2,
269            },
270            KoiEvent::BundleUpdated {
271                self_revoked: false,
272            },
273            KoiEvent::PostureChanged {
274                from: Posture::OPEN,
275                to: Posture::new(true, true),
276            },
277            KoiEvent::ProxyEntryUpdated {
278                entry: ProxyEntry {
279                    name: "p".to_string(),
280                    listen_port: 443,
281                    backend: "http://localhost".to_string(),
282                    allow_remote: false,
283                },
284            },
285            KoiEvent::ProxyEntryRemoved {
286                name: "p".to_string(),
287            },
288            KoiEvent::RuntimeInstanceStarted {
289                name: "web".to_string(),
290                backend: "docker".to_string(),
291            },
292            KoiEvent::RuntimeInstanceStopped {
293                name: "web".to_string(),
294            },
295        ];
296        for event in &events {
297            let _ = format!("{event:?}");
298        }
299    }
300}