zenkey_fleet/report/registry.rs
1//! The registry plane (RFC 08 §5/§6): a served slice against a local one,
2//! per producer and in aggregate.
3
4use super::asked::Asked;
5use serde::Serialize;
6
7/// One producer, as the bus serves it versus as the checkout declares it
8/// (issue #50). A `None` version means "not present on that side", which is a
9/// fact with two very different explanations — the findings say which.
10#[derive(Debug, Clone, Serialize)]
11pub struct ProducerDiff {
12 pub producer: String,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub served_version: Option<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub local_version: Option<String>,
17 /// RFC 08 §6 findings, rendered. Empty = the two agree.
18 pub findings: Vec<String>,
19}
20
21/// `zenctl registry diff` (issue #50).
22#[derive(Debug, Clone, Serialize)]
23pub struct RegistryDiff {
24 pub producers: Vec<ProducerDiff>,
25 /// Producers several origins answered for, and whether they agreed
26 /// (#399).
27 ///
28 /// The diff is computed from **one slice per producer**, so where the
29 /// fleet is mid-rollout it is computed from one arbitrary host's answer.
30 /// Without this the result read as fleet-wide truth: two hosts on last
31 /// month's registry and three on this month's produced a diff with no
32 /// indication that four other answers existed, let alone disagreed.
33 ///
34 /// `NotAsked` = the served side did not come from the bus, so there was
35 /// no origin to collapse and the question was never put; `Asked([])` =
36 /// asked, and every producer had exactly one origin answer. Empty must
37 /// not read as agreement (RFC 13 §3 O4).
38 #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
39 pub collapsed: Asked<Vec<CollapsedProducer>>,
40}
41
42impl RegistryDiff {
43 /// Producers whose two sides disagree.
44 pub fn disagreeing(&self) -> usize {
45 self.producers
46 .iter()
47 .filter(|p| !p.findings.is_empty())
48 .count()
49 }
50
51 /// Producers the fleet does not agree with *itself* about (#399).
52 ///
53 /// A different question from [`disagreeing`](Self::disagreeing), which is
54 /// the fleet against the checkout. `NotAsked` yields zero, and a caller
55 /// that renders the number must say which zero it is.
56 pub fn self_disagreeing(&self) -> usize {
57 self.collapsed
58 .as_deref()
59 .map(|c| c.iter().filter(|c| !c.agreed).count())
60 .unwrap_or(0)
61 }
62}
63
64/// One producer where the served slice and the on-disk slice disagree.
65///
66/// A disagreement is **data**, not an error: served wins in the union (the
67/// bus is the runtime truth, RFC 08 §6.1), and the difference is retained for
68/// `doctor` to report instead of being silently overwritten.
69#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
70pub struct SliceDisagreement {
71 pub producer: String,
72 pub bus_version: String,
73 pub dirs_version: String,
74 /// Whether anything beyond the version string differs (subjects,
75 /// procedures, blob tiers).
76 pub shape_differs: bool,
77}
78
79/// What one producer's fleet-wide answers looked like *before* a
80/// [`SliceSet`](crate::SliceSet) kept one of them (#385).
81///
82/// A set is indexed by producer name, so N hosts running one producer
83/// collapse to one entry. For a decoder that is right — refining a key needs
84/// *a* slice per producer and which host served it is irrelevant. What was
85/// wrong is that the collapse was silent: the resulting set looked complete
86/// and was one arbitrary host's answer, and a `diff` computed from it read
87/// as fleet-wide truth. This is the receipt.
88///
89/// It lives here rather than beside the fold that builds it because it is
90/// rendered: `zenctl registry diff --format json` carries it, so it is a wire
91/// shape, and every wire shape in this crate lives under `report/` (#399).
92///
93/// It loses the `#[non_exhaustive]` it carried in `model/` on the same move,
94/// and for the same reason: nothing under `report/` has one. A wire shape is
95/// pinned by a test rather than by a marker, and a shape a fixture crate
96/// cannot spell is one no corpus can pin.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub struct CollapsedProducer {
99 /// The producer (or service) base name the collapse happened on.
100 pub producer: String,
101 /// Every origin that served a slice for it, in reply order.
102 pub origins: Vec<String>,
103 /// The `[registry] version` each of those served, index-parallel with
104 /// [`origins`](Self::origins).
105 pub versions: Vec<String>,
106 /// Whether every origin served byte-identical TOML.
107 ///
108 /// `false` is the finding: the fleet does not agree about what this
109 /// producer declares — mid-rollout, or a host running last month's build
110 /// — and this set kept one of the answers. Which one is arrival order,
111 /// which is not a fact about the fleet.
112 pub agreed: bool,
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 /// The receipt is a wire shape the moment `registry diff --format json`
120 /// carries it (#399), and the half that matters is the absence: a served
121 /// side that never came off the bus must serialize *no* `collapsed` key,
122 /// not an empty list, because an empty list is the answer "asked, and
123 /// nothing collapsed" (RFC 13 §3 O4).
124 #[test]
125 fn the_collapse_receipt_keeps_not_asked_off_the_wire() {
126 let asked = RegistryDiff {
127 producers: vec![],
128 collapsed: Asked::Asked(vec![CollapsedProducer {
129 producer: "sysinfo".into(),
130 origins: vec!["h-3fa9c2d41b7e".into(), "h-8b1e07af22c9".into()],
131 versions: vec!["1.1".into(), "1.0".into()],
132 agreed: false,
133 }]),
134 };
135 assert_eq!(
136 serde_json::to_value(&asked).expect("serialize"),
137 serde_json::json!({
138 "producers": [],
139 "collapsed": [{
140 "producer": "sysinfo",
141 "origins": ["h-3fa9c2d41b7e", "h-8b1e07af22c9"],
142 "versions": ["1.1", "1.0"],
143 "agreed": false,
144 }],
145 })
146 );
147 assert_eq!(asked.self_disagreeing(), 1);
148
149 let asked_clean = RegistryDiff {
150 producers: vec![],
151 collapsed: Asked::Asked(vec![]),
152 };
153 assert_eq!(
154 serde_json::to_value(&asked_clean).expect("serialize"),
155 serde_json::json!({"producers": [], "collapsed": []}),
156 "asked and nothing collapsed is a present, empty list"
157 );
158
159 let not_asked = RegistryDiff {
160 producers: vec![],
161 collapsed: Asked::NotAsked,
162 };
163 assert_eq!(
164 serde_json::to_value(¬_asked).expect("serialize"),
165 serde_json::json!({"producers": []}),
166 "not asked is absence — never `[]`, never `null`"
167 );
168 assert_eq!(
169 not_asked.self_disagreeing(),
170 0,
171 "and its zero is a different zero, which the renderer says out loud"
172 );
173 }
174}