Skip to main content

zenkey_fleet/report/
interface.rs

1//! The interface plane: payload types and the carriers that move them —
2//! one type's producers, subjects and media streams in one document.
3
4use super::asked::Asked;
5use super::schema::{SchemaDrift, SchemaRow};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Serialize)]
9pub struct InterfaceTypeRow {
10    pub name: String,
11    pub carriers: usize,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct InterfaceList {
16    pub types: Vec<InterfaceTypeRow>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct CarrierRow {
21    pub producer: String,
22    pub class: String,
23    pub path: String,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct InterfaceShow {
28    pub type_name: String,
29    pub carriers: Vec<CarrierRow>,
30    /// What each producer serving this type name says its schema is
31    /// (issue #51). `NotAsked` = `--schema` was not passed, so the bus was
32    /// never asked; `Asked(vec![])` = asked and no carrier served one — the
33    /// empty `Vec` used to conflate the two (RFC 09 §5.1 O4, review finding
34    /// R4). Two rows with different hashes *is* the RFC 08 §7 drift finding,
35    /// visible right here rather than only in `doctor`.
36    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
37    pub schemas: Asked<Vec<SchemaRow>>,
38    /// The engine's verdict on whether the carriers agree about this type —
39    /// [`schema_drift`](crate::model::decode::schema_drift) over the
40    /// describe sweep, filtered to `type_name` (#410).
41    ///
42    /// Not derivable from `schemas`: a [`SchemaRow`] names a producer and
43    /// no origin, and flattens an unserved hash to `""`, so two rows can
44    /// neither show two hosts of one producer disagreeing nor tell "both
45    /// said nothing" from "both said the same" (the O4 bug #370 fixed in the
46    /// doctor). The renderer used to recompute drift over the rows and got
47    /// exactly those two cases wrong; this field is the one implementation's
48    /// answer, and the note reads it.
49    ///
50    /// Empty when nothing disagrees — and empty when `--schema` was not
51    /// passed, which `schemas` already says (`NotAsked`); omitted from the
52    /// document in both cases, so an unasked run does not sprout a field
53    /// that reads as "asked, clean".
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub drift: Vec<SchemaDrift>,
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::report::{DriftVerdict, SchemaServer};
62
63    fn show(drift: Vec<SchemaDrift>) -> InterfaceShow {
64        InterfaceShow {
65            type_name: "Health".into(),
66            carriers: vec![CarrierRow {
67                producer: "sysinfo".into(),
68                class: "state".into(),
69                path: "health".into(),
70            }],
71            schemas: Asked::Asked(vec![SchemaRow {
72                producer: "sysinfo".into(),
73                type_name: "Health".into(),
74                kind: "json-schema".into(),
75                hash: "sha256:abc".into(),
76                document: None,
77            }]),
78            drift,
79        }
80    }
81
82    /// `drift` is additive to the pinned document (#410): absent when
83    /// nothing disagrees, so a script keyed on the pre-#410 shape reads a
84    /// clean run unchanged, and present as the engine's own `SchemaDrift`
85    /// when something does.
86    #[test]
87    fn interface_show_json_shape_is_pinned_and_drift_is_absent_when_empty() {
88        assert_eq!(
89            serde_json::to_value(show(vec![])).expect("serialize"),
90            serde_json::json!({
91                "type_name": "Health",
92                "carriers": [{"producer": "sysinfo", "class": "state", "path": "health"}],
93                "schemas": [{
94                    "producer": "sysinfo",
95                    "type_name": "Health",
96                    "kind": "json-schema",
97                    "hash": "sha256:abc",
98                }],
99            })
100        );
101        let drifted = show(vec![SchemaDrift {
102            type_name: "Health".into(),
103            servers: vec![
104                SchemaServer {
105                    producer: "sysinfo".into(),
106                    origin: "h-3fa9c2d41b7e".into(),
107                    hash: Asked::Asked("sha256:abc".into()),
108                },
109                SchemaServer {
110                    producer: "sysinfo".into(),
111                    origin: "h-8b1e07af22c9".into(),
112                    hash: Asked::Asked("sha256:def".into()),
113                },
114            ],
115            verdict: DriftVerdict::Disagree,
116        }]);
117        let value = serde_json::to_value(drifted).expect("serialize");
118        assert_eq!(
119            value["drift"],
120            serde_json::json!([{
121                "type_name": "Health",
122                "servers": [
123                    {"producer": "sysinfo", "origin": "h-3fa9c2d41b7e", "hash": "sha256:abc"},
124                    {"producer": "sysinfo", "origin": "h-8b1e07af22c9", "hash": "sha256:def"},
125                ],
126                "verdict": "disagree",
127            }])
128        );
129    }
130}