1use std::{
2 borrow::Cow,
3 cmp::Ordering,
4 collections::BTreeMap,
5 fmt::{Result as FmtResult, Write as _},
6 num::NonZeroUsize,
7 time::{Duration, Instant},
8};
9
10use clap::ValueEnum;
11use futures::{StreamExt, stream::iter};
12use ruma::{
13 OwnedRoomOrAliasId,
14 api::federation::discovery::get_server_version::v1::{Request, Response, Server},
15};
16use tuwunel_core::{Result, itertools::Itertools, utils::time::now_secs};
17use tuwunel_service::federation::feds::Outcome;
18
19use super::{
20 ListMode, Sort, SweepArgs, count_results, fault_message, markdown_cell, partition_backoffs,
21 prepare, render_totals, sorted, write_cell, write_elapsed_cell,
22};
23use crate::admin_command;
24
25pub(super) const WIDTH_DEFAULT: NonZeroUsize = NonZeroUsize::new(192).expect("192 is nonzero");
26const DEFAULT_FIELDS: &[Field] = &[Field::Name, Field::Version];
27
28#[derive(Default, Eq, Ord, PartialEq, PartialOrd)]
29struct Version {
30 name: Option<String>,
31 version: Option<String>,
32 compiler: Option<String>,
33}
34
35#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, ValueEnum)]
36pub(crate) enum Field {
37 Name,
38 Version,
39 Compiler,
40}
41
42impl Field {
43 fn label(self) -> &'static str {
44 match self {
45 | Self::Name => "name",
46 | Self::Version => "version",
47 | Self::Compiler => "compiler",
48 }
49 }
50
51 fn value(self, version: &Version) -> Option<&str> {
52 match self {
53 | Self::Name => version.name.as_deref(),
54 | Self::Version => version.version.as_deref(),
55 | Self::Compiler => version.compiler.as_deref(),
56 }
57 }
58}
59
60#[derive(Clone, Copy, Eq)]
61struct VersionClass<'a> {
62 fields: &'a [Field],
63 version: &'a Version,
64}
65
66impl Ord for VersionClass<'_> {
67 fn cmp(&self, other: &Self) -> Ordering {
68 self.fields.cmp(other.fields).then_with(|| {
69 self.fields
70 .iter()
71 .map(|field| field.value(self.version))
72 .cmp(
73 other
74 .fields
75 .iter()
76 .map(|field| field.value(other.version)),
77 )
78 })
79 }
80}
81
82impl PartialOrd for VersionClass<'_> {
83 fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
84}
85
86impl PartialEq for VersionClass<'_> {
87 fn eq(&self, other: &Self) -> bool { self.cmp(other).is_eq() }
88}
89
90type ClassCounts<'a> = BTreeMap<VersionClass<'a>, usize>;
91type VersionOutcome = Outcome<Option<Version>>;
92
93#[admin_command]
94pub(super) async fn feds_version(
95 &self,
96 room: OwnedRoomOrAliasId,
97 fields: Vec<Field>,
98 list: bool,
99 list_all: bool,
100 list_errors: bool,
101 sort: Sort,
102 sweep: SweepArgs,
103) -> Result {
104 let prepared = prepare(self, &room, sweep, WIDTH_DEFAULT).await?;
105 let backoffs = self.services.federation.peer_backoffs().await;
106 let (eligible, outcomes) = partition_backoffs(self, &prepared, &backoffs, now_secs()).await;
107
108 let started = Instant::now();
109 let responses = self
110 .services
111 .federation
112 .fanout_to(iter(eligible), |_| Request::new(), prepared.opts)
113 .map(|outcome| Outcome {
114 origin: outcome.origin,
115 elapsed: outcome.elapsed,
116 result: outcome.result.map(into_version),
117 });
118
119 let outcomes = iter(outcomes)
120 .chain(responses)
121 .collect::<Vec<_>>()
122 .await;
123
124 let total = started.elapsed();
125
126 let list_mode = ListMode::new(list, list_all, list_errors);
127 let fields = selected_fields(fields);
128 let output = render(outcomes, total, list_mode, sort, &fields);
129
130 self.write_str(&output).await
131}
132
133fn into_version(response: Response) -> Option<Version> {
134 response.server.map(|server| {
135 let Server { name, version, compiler, .. } = server;
136
137 Version { name, version, compiler }
138 })
139}
140
141fn selected_fields(mut fields: Vec<Field>) -> Cow<'static, [Field]> {
142 if fields.is_empty() {
143 return Cow::Borrowed(DEFAULT_FIELDS);
144 }
145
146 fields.sort_unstable();
147 fields.dedup();
148 Cow::Owned(fields)
149}
150
151fn render(
152 outcomes: Vec<VersionOutcome>,
153 total: Duration,
154 list_mode: ListMode,
155 sort: Sort,
156 fields: &[Field],
157) -> String {
158 let outcomes = sorted(outcomes, sort, fault_cell);
159 let mut output = String::new();
160
161 render_into(&mut output, &outcomes, total, list_mode, fields)
162 .expect("writing to a String cannot fail");
163
164 output
165}
166
167fn fault_cell(outcome: &VersionOutcome) -> Cow<'static, str> {
168 match &outcome.result {
169 | Ok(Some(_)) => Cow::Borrowed(""),
170 | Ok(None) => Cow::Borrowed("missing server metadata"),
171 | Err(fault) => fault_message(fault),
172 }
173}
174
175fn render_into(
176 output: &mut String,
177 outcomes: &[VersionOutcome],
178 total: Duration,
179 list_mode: ListMode,
180 fields: &[Field],
181) -> FmtResult {
182 let results = count_results(outcomes);
183 let counts: ClassCounts<'_> = outcomes
184 .iter()
185 .filter_map(|outcome| {
186 outcome
187 .result
188 .as_ref()
189 .ok()
190 .and_then(Option::as_ref)
191 })
192 .fold(BTreeMap::new(), |mut classes, version| {
193 classes
194 .entry(VersionClass { fields, version })
195 .and_modify(|count| *count = count.saturating_add(1))
196 .or_insert(1);
197
198 classes
199 });
200
201 write!(output, "| rank | servers |")?;
202 for field in fields {
203 write!(output, " {} |", field.label())?;
204 }
205
206 writeln!(output)?;
207
208 write!(output, "| ---: | ------: |")?;
209 for _ in fields {
210 write!(output, " :--- |")?;
211 }
212
213 writeln!(output)?;
214
215 let classes = counts.into_iter().sorted_unstable_by(
216 |(left_class, left_count), (right_class, right_count)| {
217 right_count
218 .cmp(left_count)
219 .then_with(|| left_class.cmp(right_class))
220 },
221 );
222
223 for (rank, (class, count)) in classes.enumerate() {
224 write!(output, "| {} | {count} |", rank.saturating_add(1))?;
225 for field in fields {
226 write!(output, " {} |", option_cell(field.value(class.version)))?;
227 }
228
229 writeln!(output)?;
230 }
231
232 if matches!(list_mode, ListMode::None) {
233 return render_totals(output, results, total);
234 }
235
236 write!(output, "\n| origin | name | version |")?;
237 for field in extra_fields(fields) {
238 write!(output, " {} |", field.label())?;
239 }
240
241 writeln!(output, " elapsed | fault |")?;
242
243 write!(output, "| :--- | :--- | :--- |")?;
244 for _ in extra_fields(fields) {
245 write!(output, " :--- |")?;
246 }
247
248 writeln!(output, " ---: | :--- |")?;
249 for outcome in outcomes
250 .iter()
251 .filter(|outcome| list_mode.includes(outcome))
252 {
253 render_row(output, outcome, fields)?;
254 }
255
256 render_totals(output, results, total)
257}
258
259fn extra_fields(fields: &[Field]) -> impl Iterator<Item = Field> + '_ {
260 fields
261 .iter()
262 .copied()
263 .filter(|field| !matches!(field, Field::Name | Field::Version))
264}
265
266fn render_row(output: &mut String, outcome: &VersionOutcome, fields: &[Field]) -> FmtResult {
267 let fault = fault_cell(outcome);
268 let version = outcome
269 .result
270 .as_ref()
271 .ok()
272 .and_then(Option::as_ref);
273
274 write!(output, "| {} |", outcome.origin)?;
275 write_version_cell(output, version, Field::Name)?;
276 write_version_cell(output, version, Field::Version)?;
277
278 for field in extra_fields(fields) {
279 write_version_cell(output, version, field)?;
280 }
281
282 write_elapsed_cell(output, outcome)?;
283
284 let fault = markdown_cell(&fault);
285 write_cell(output, &fault)?;
286 writeln!(output)
287}
288
289fn write_version_cell(output: &mut String, version: Option<&Version>, field: Field) -> FmtResult {
290 match version {
291 | Some(version) => write!(output, " {} |", option_cell(field.value(version))),
292 | None => write!(output, " |"),
293 }
294}
295
296fn option_cell(value: Option<&str>) -> Cow<'_, str> {
297 value
298 .map(markdown_cell)
299 .unwrap_or(Cow::Borrowed(""))
300}
301
302#[cfg(test)]
303mod tests {
304 use ruma::{ServerName, server_name};
305 use tuwunel_service::federation::{Classification, feds::Fault};
306
307 use super::*;
308
309 #[test]
310 fn render_ranks_versions_by_population_and_leaves_missing_metadata_blank() {
311 let outcomes = vec![
312 success(server_name!("rare.example"), "alpha"),
313 success(server_name!("popular-a.example"), "zeta"),
314 success(server_name!("popular-b.example"), "zeta"),
315 Outcome {
316 origin: server_name!("bare.example").to_owned(),
317 elapsed: Duration::ZERO,
318 result: Ok(None),
319 },
320 failed(server_name!("skipped.example"), Duration::ZERO, Fault::NotAttempted),
321 ];
322
323 let output =
324 render(outcomes, Duration::ZERO, ListMode::All, Sort::Origin, DEFAULT_FIELDS);
325
326 let popular = output
327 .find("| 1 | 2 | zeta | |")
328 .expect("popular version should be rendered first");
329
330 let rare = output
331 .find("| 2 | 1 | alpha | |")
332 .expect("rare version should be rendered second");
333
334 assert!(popular < rare, "more common versions should precede rarer versions");
335 assert_eq!(option_cell(None), "", "missing metadata should render blank");
336 assert!(
337 output.contains("| skipped.example | | | | sweep budget exhausted before dispatch |")
338 );
339
340 assert!(output.contains("| bare.example | | | 0ns | missing server metadata |"));
341 assert!(output.ends_with("\n4 results in 0ns.\n"));
342 }
343
344 #[test]
345 fn selected_fields_control_summary_grouping_and_detail_columns() {
346 let outcomes = || {
347 let outcome = |origin: &ServerName, compiler: &str| Outcome {
348 origin: origin.to_owned(),
349 elapsed: Duration::ZERO,
350 result: Ok(Some(Version {
351 name: Some("tuwunel".to_owned()),
352 version: Some("1.2".to_owned()),
353 compiler: Some(compiler.to_owned()),
354 })),
355 };
356
357 vec![
358 outcome(server_name!("first.example"), "rustc-a"),
359 outcome(server_name!("second.example"), "rustc-b"),
360 ]
361 };
362
363 let default =
364 render(outcomes(), Duration::ZERO, ListMode::None, Sort::Origin, DEFAULT_FIELDS);
365
366 assert!(default.contains("| rank | servers | name | version |\n"));
367 assert!(default.contains("| 1 | 2 | tuwunel | 1.2 |\n"));
368 assert!(!default.contains("compiler"));
369
370 let fields = [Field::Name, Field::Version, Field::Compiler];
371
372 let selected = render(outcomes(), Duration::ZERO, ListMode::All, Sort::Origin, &fields);
373
374 assert!(selected.contains("| rank | servers | name | version | compiler |\n"));
375
376 assert!(selected.contains("| 1 | 1 | tuwunel | 1.2 | rustc-a |\n"));
377 assert!(selected.contains("| 2 | 1 | tuwunel | 1.2 | rustc-b |\n"));
378 assert!(selected.contains("| origin | name | version | compiler | elapsed | fault |\n"));
379 assert!(selected.contains("| first.example | tuwunel | 1.2 | rustc-a | 0ns | |\n"));
380 }
381
382 #[test]
383 fn detail_listing_is_opt_in_and_filters_by_request_result() {
384 let outcomes = || {
385 vec![
386 success(server_name!("good.example"), "alpha"),
387 failed(server_name!("bad.example"), Duration::from_secs(1), Fault::Elapsed),
388 failed(server_name!("backoff.example"), Duration::ZERO, Fault::Backoff {
389 class: Classification::Transient,
390 age: Duration::from_secs(30),
391 retry: Duration::from_secs(10),
392 }),
393 ]
394 };
395
396 let summary =
397 render(outcomes(), Duration::ZERO, ListMode::None, Sort::Origin, DEFAULT_FIELDS);
398
399 assert!(!summary.contains("| origin |"));
400 assert!(summary.ends_with("\n1 result in 0ns.\n"));
401
402 let successes =
403 render(outcomes(), Duration::ZERO, ListMode::Successes, Sort::Origin, DEFAULT_FIELDS);
404
405 assert!(successes.contains("| good.example | alpha | | 0ns | |"));
406 assert!(!successes.contains("bad.example"));
407 assert!(!successes.contains("backoff.example"));
408
409 let all = render(outcomes(), Duration::ZERO, ListMode::All, Sort::Origin, DEFAULT_FIELDS);
410
411 assert!(all.contains("backoff.example"));
412
413 let errors =
414 render(outcomes(), Duration::ZERO, ListMode::Errors, Sort::Origin, DEFAULT_FIELDS);
415
416 assert!(!errors.contains("good.example"));
417 assert!(errors.contains("bad.example"));
418 assert!(errors.contains("backoff.example"));
419 assert!(errors.ends_with("\n1 result in 0ns.\n"));
420 }
421
422 #[test]
423 fn detail_listing_associates_servers_with_escaped_versions() {
424 for list_mode in [ListMode::Successes, ListMode::All] {
425 let outcomes = [
426 (server_name!("first.example"), "alpha", "1.0"),
427 (server_name!("second.example"), "be|ta", "2.0\nrc"),
428 ]
429 .into_iter()
430 .map(|(origin, name, version)| {
431 let version = Version {
432 name: Some(name.to_owned()),
433 version: Some(version.to_owned()),
434 ..Default::default()
435 };
436
437 Outcome {
438 origin: origin.to_owned(),
439 elapsed: Duration::ZERO,
440 result: Ok(Some(version)),
441 }
442 })
443 .collect();
444
445 let output =
446 render(outcomes, Duration::ZERO, list_mode, Sort::Origin, DEFAULT_FIELDS);
447
448 assert!(output.contains("| first.example | alpha | 1.0 | 0ns | |"));
449 assert!(output.contains("| second.example | be\\|ta | 2.0 rc | 0ns | |"));
450 }
451 }
452
453 #[test]
454 fn detail_listing_sorts_by_column_with_origin_as_tie_breaker() {
455 let outcomes = || {
456 vec![
457 Outcome {
458 origin: server_name!("slow.example").to_owned(),
459 elapsed: Duration::from_secs(2),
460 result: Ok(Some(Version::default())),
461 },
462 failed(server_name!("b-timeout.example"), Duration::from_secs(1), Fault::Elapsed),
463 failed(server_name!("a-timeout.example"), Duration::from_secs(1), Fault::Elapsed),
464 failed(server_name!("skipped.example"), Duration::ZERO, Fault::NotAttempted),
465 ]
466 };
467
468 let by_origin =
469 render(outcomes(), Duration::ZERO, ListMode::All, Sort::Origin, DEFAULT_FIELDS);
470
471 assert_eq!(listed_origins(&by_origin), [
472 "a-timeout.example",
473 "b-timeout.example",
474 "skipped.example",
475 "slow.example"
476 ]);
477
478 let by_elapsed =
479 render(outcomes(), Duration::ZERO, ListMode::All, Sort::Elapsed, DEFAULT_FIELDS);
480
481 assert_eq!(listed_origins(&by_elapsed), [
482 "skipped.example",
483 "a-timeout.example",
484 "b-timeout.example",
485 "slow.example"
486 ]);
487
488 let by_fault =
489 render(outcomes(), Duration::ZERO, ListMode::All, Sort::Fault, DEFAULT_FIELDS);
490
491 assert_eq!(listed_origins(&by_fault), [
492 "slow.example",
493 "a-timeout.example",
494 "b-timeout.example",
495 "skipped.example"
496 ]);
497 }
498
499 fn listed_origins(output: &str) -> Vec<&str> {
500 let (_, listing) = output
501 .split_once("| origin | name | version | elapsed | fault |\n")
502 .expect("detail listing should be rendered");
503
504 listing
505 .lines()
506 .skip(1)
507 .take_while(|line| line.starts_with('|'))
508 .filter_map(|line| line.split('|').nth(1))
509 .map(str::trim)
510 .collect()
511 }
512
513 fn success(origin: &ServerName, name: &str) -> VersionOutcome {
514 let version = Version {
515 name: Some(name.to_owned()),
516 ..Default::default()
517 };
518
519 Outcome {
520 origin: origin.to_owned(),
521 elapsed: Duration::ZERO,
522 result: Ok(Some(version)),
523 }
524 }
525
526 fn failed(origin: &ServerName, elapsed: Duration, fault: Fault) -> VersionOutcome {
527 Outcome {
528 origin: origin.to_owned(),
529 elapsed,
530 result: Err(fault),
531 }
532 }
533}