Skip to main content

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(&not_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}