Skip to main content

max / makenotwork

8.3 KB · 237 lines History Blame Raw
1 //! SyncKit field-version check, polls `<host>/api/internal/synckit/client-versions`
2 //! on a target makenotwork instance and records which SDK versions are syncing.
3 //!
4 //! SyncKit is client-side only, linked into mnw-cli, balanced_breakfast and
5 //! audiofiles, so there is no deployed version to poll the way every other target
6 //! is polled. The server records the SDK version off each sync request's
7 //! User-Agent (`sync_devices.client_version`) and aggregates it; this reads that
8 //! aggregate.
9 //!
10 //! **This check is informational, and cannot fail on what it finds.** An old
11 //! version still syncing is a fact, not an incident: nobody can be paged into
12 //! upgrading someone else's laptop, and there is no threshold that would make a
13 //! version distribution "wrong". What it *can* report is that the readout itself
14 //! is broken (endpoint gone, token rotated out from under PoM, unexpected shape),
15 //! because then PoM has quietly stopped being able to answer the question at all.
16 //! That is a monitoring gap, so it reads as degraded rather than down; whether the
17 //! platform itself is up is the `health` check's business, not this one's.
18
19 use std::time::Duration;
20
21 use tracing::instrument;
22
23 use crate::types::{SyncKitFleetCheckResult, SyncKitVersionSnapshot};
24
25 /// Longest version string we will store per row. The server already bounds what
26 /// it accepts to 32 chars; this is the same bound applied on the way in, so a
27 /// server that loosens its own rule cannot widen our column.
28 const VERSION_MAX_LENGTH: usize = 32;
29
30 /// Most distinct versions kept from one readout. A healthy fleet has a handful;
31 /// hundreds would mean the server is aggregating something other than versions,
32 /// and the row should not grow without bound on the strength of that.
33 const MAX_VERSIONS: usize = 32;
34
35 #[derive(Debug, serde::Deserialize)]
36 struct UpstreamVersion {
37 /// `None` is a real answer from the server: "syncing, version unknown", a
38 /// client from before the SDK sent a User-Agent.
39 client_version: Option<String>,
40 devices: i64,
41 last_seen_at: Option<String>,
42 }
43
44 #[derive(Debug, serde::Deserialize)]
45 struct UpstreamFleet {
46 window_days: i32,
47 devices: i64,
48 versions: Vec<UpstreamVersion>,
49 }
50
51 #[instrument(skip(token))]
52 pub async fn check_synckit_fleet(
53 target_name: &str,
54 base_url: &str,
55 token: &str,
56 window_days: u32,
57 timeout_secs: u64,
58 ) -> SyncKitFleetCheckResult {
59 let checked_at = chrono::Utc::now().to_rfc3339();
60 let url = format!(
61 "{}/api/internal/synckit/client-versions?days={window_days}",
62 base_url.trim_end_matches('/')
63 );
64
65 let client = match crate::tls::https_client_builder()
66 .timeout(Duration::from_secs(timeout_secs))
67 .build()
68 {
69 Ok(c) => c,
70 Err(e) => return unreadable(target_name, &checked_at, format!("client build: {e}")),
71 };
72
73 let response = match client.get(&url).bearer_auth(token).send().await {
74 Ok(r) => r,
75 Err(e) => return unreadable(target_name, &checked_at, format!("request: {e}")),
76 };
77
78 let status = response.status();
79 if !status.is_success() {
80 // 401/403 is called out by name because it is the failure this check will
81 // actually hit: the endpoint is authed with the alerts ingest token, so a
82 // credential rotation that misses PoM's config lands here, and "HTTP 401"
83 // alone reads like a server bug rather than a stale token.
84 let msg = match status.as_u16() {
85 401 | 403 => format!("HTTP {} (alerts ingest token rejected)", status.as_u16()),
86 code => format!("HTTP {code}"),
87 };
88 return unreadable(target_name, &checked_at, msg);
89 }
90
91 let body: UpstreamFleet = match response.json().await {
92 Ok(b) => b,
93 Err(e) => return unreadable(target_name, &checked_at, format!("parse: {e}")),
94 };
95
96 summarize(target_name, &checked_at, body)
97 }
98
99 /// The readout could not be produced. Distinct from an empty fleet, which is a
100 /// successful reading of zero devices.
101 fn unreadable(target: &str, checked_at: &str, msg: String) -> SyncKitFleetCheckResult {
102 SyncKitFleetCheckResult {
103 target: target.to_string(),
104 window_days: 0,
105 devices: 0,
106 versions: Vec::new(),
107 checked_at: checked_at.to_string(),
108 error: Some(msg),
109 }
110 }
111
112 fn summarize(target: &str, checked_at: &str, body: UpstreamFleet) -> SyncKitFleetCheckResult {
113 let versions: Vec<SyncKitVersionSnapshot> = body
114 .versions
115 .into_iter()
116 .take(MAX_VERSIONS)
117 .map(|v| SyncKitVersionSnapshot {
118 client_version: v
119 .client_version
120 .filter(|s| !s.is_empty() && s.len() <= VERSION_MAX_LENGTH),
121 devices: v.devices,
122 last_seen_at: v.last_seen_at,
123 })
124 .collect();
125
126 SyncKitFleetCheckResult {
127 target: target.to_string(),
128 window_days: body.window_days,
129 devices: body.devices,
130 versions,
131 checked_at: checked_at.to_string(),
132 error: None,
133 }
134 }
135
136 #[cfg(test)]
137 mod tests {
138 use super::*;
139
140 fn fleet(versions: Vec<(Option<&str>, i64)>) -> UpstreamFleet {
141 UpstreamFleet {
142 window_days: 30,
143 devices: versions.iter().map(|(_, d)| d).sum(),
144 versions: versions
145 .into_iter()
146 .map(|(v, devices)| UpstreamVersion {
147 client_version: v.map(str::to_string),
148 devices,
149 last_seen_at: Some("2026-07-29T12:00:00Z".into()),
150 })
151 .collect(),
152 }
153 }
154
155 #[test]
156 fn a_readout_never_reports_an_error() {
157 let r = summarize("mnw", "now", fleet(vec![(Some("0.6.0"), 12)]));
158 assert!(r.error.is_none());
159 assert_eq!(r.devices, 12);
160 assert_eq!(r.window_days, 30);
161 }
162
163 #[test]
164 fn an_empty_fleet_is_a_reading_not_a_failure() {
165 // Nobody syncing is a real answer, and it must not look like a broken
166 // endpoint: pre-launch, this is the expected state.
167 let r = summarize("mnw", "now", fleet(vec![]));
168 assert!(r.error.is_none());
169 assert_eq!(r.devices, 0);
170 assert!(r.versions.is_empty());
171 }
172
173 #[test]
174 fn an_old_version_in_the_field_is_still_not_an_error() {
175 let r = summarize(
176 "mnw",
177 "now",
178 fleet(vec![(Some("0.1.0"), 40), (Some("0.6.0"), 1)]),
179 );
180 assert!(r.error.is_none(), "version age is a fact, not an incident");
181 assert_eq!(r.versions.len(), 2);
182 }
183
184 #[test]
185 fn unknown_version_is_preserved_as_unknown() {
186 // Clients older than the User-Agent change report nothing, and guessing
187 // would corrupt the readout this check exists to produce.
188 let r = summarize("mnw", "now", fleet(vec![(None, 3)]));
189 assert_eq!(r.versions.len(), 1);
190 assert!(r.versions[0].client_version.is_none());
191 assert_eq!(r.versions[0].devices, 3);
192 }
193
194 #[test]
195 fn an_empty_version_string_reads_as_unknown() {
196 let r = summarize("mnw", "now", fleet(vec![(Some(""), 2)]));
197 assert!(r.versions[0].client_version.is_none());
198 }
199
200 #[test]
201 fn an_overlong_version_reads_as_unknown_rather_than_truncated() {
202 let long = "9".repeat(VERSION_MAX_LENGTH + 1);
203 let r = summarize("mnw", "now", fleet(vec![(Some(&long), 1)]));
204 assert!(
205 r.versions[0].client_version.is_none(),
206 "a truncated version reads like a real one it is not"
207 );
208 }
209
210 #[test]
211 fn a_version_at_the_length_bound_is_kept() {
212 let at_max = "9".repeat(VERSION_MAX_LENGTH);
213 let r = summarize("mnw", "now", fleet(vec![(Some(&at_max), 1)]));
214 assert_eq!(r.versions[0].client_version.as_deref(), Some(&*at_max));
215 }
216
217 #[test]
218 fn the_version_list_is_bounded() {
219 let many: Vec<(Option<&str>, i64)> =
220 (0..MAX_VERSIONS + 10).map(|_| (Some("0.6.0"), 1)).collect();
221 let r = summarize("mnw", "now", fleet(many));
222 assert_eq!(r.versions.len(), MAX_VERSIONS);
223 }
224
225 #[test]
226 fn an_unreadable_endpoint_carries_the_reason() {
227 let r = unreadable(
228 "mnw",
229 "now",
230 "HTTP 401 (alerts ingest token rejected)".into(),
231 );
232 assert!(r.error.as_deref().unwrap().contains("401"));
233 assert_eq!(r.devices, 0);
234 assert!(r.versions.is_empty());
235 }
236 }
237