Skip to main content

tuwunel_admin/query/feds/
version.rs

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}