Skip to main content

arminius_lib_component/
lib.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Allows defining Components, which can represent one microservice in
6//! Arminius. Components can also be combined, letting us host the entire
7//! application as a monolith.
8
9pub mod error;
10mod message_token;
11pub mod probes;
12pub mod request_source;
13pub mod ruma_requests;
14mod tests;
15use std::{future, marker::PhantomData};
16
17pub use arminius_lib_config::ArminiusConfig;
18use arminius_lib_db::DefaultDb;
19use arminius_lib_nats::{
20	NatsConfiguration, NatsConfigurationError, NatsCredentials, SharedNatsClient,
21};
22use axum::{
23	Router,
24	body::Body,
25	extract::{FromRef, State},
26	http::{HeaderName, Method, StatusCode, Uri, header},
27	middleware::Next,
28};
29use axum_tracing_opentelemetry::middleware::OtelAxumLayer;
30use color_eyre::eyre::Context as _;
31use error::{InternalServerError, MatrixApiError};
32pub use message_token::{MessageTokenParseError, MessagesToken};
33use ruma::api::error::ErrorKind;
34use tokio::signal::unix::SignalKind;
35use tokio_util::sync::CancellationToken;
36use tower_http::cors::{Any, CorsLayer};
37
38use crate::probes::probe_router;
39
40/// The state required for running a component
41#[derive(Clone)]
42pub struct ServerState {
43	/// A connection to JetStream
44	pub nats_connection: SharedNatsClient,
45	/// entry point to the DB layer. TODO: remove this when the service layer is
46	/// in place
47	pub database: DefaultDb,
48	/// Configuration for our homeserver
49	pub config: ArminiusConfig,
50}
51
52impl std::fmt::Debug for ServerState {
53	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54		f.debug_struct("ServerState")
55			.field("nats_connection", &self.nats_connection)
56			.field("database", &"(redacted)")
57			.field("config", &self.config)
58			.finish()
59	}
60}
61
62impl ServerState {
63	/// Creates a new server state for use in tests
64	#[must_use]
65	#[cfg(feature = "test_utils")]
66	#[expect(
67		clippy::expect_used,
68		reason = "TODO(#2003): This exception predates us enabling `clippy::allow_attributes_without_reason`, so we don't know why it was enabled"
69	)]
70	pub async fn create_for_test(
71		pg_pool_options: sqlx::pool::PoolOptions<sqlx::Postgres>,
72		pg_connect_options: sqlx::postgres::PgConnectOptions,
73	) -> ServerState {
74		use std::ops::Deref as _;
75
76		use sqlx::ConnectOptions as _;
77
78		let pool = pg_pool_options
79			.connect_with(pg_connect_options)
80			.await
81			.expect("Could not connect to database for tests");
82
83		let database = DefaultDb::new(pool.clone());
84		database.run_migrations().await.expect("Failed to run migrations");
85
86		// We set all queries to be logged with log level INFO
87		// We only do this after migrations have been run, in order to pollute
88		// the results less
89		let connect_options =
90			pool.connect_options().deref().clone().log_statements(tracing::log::LevelFilter::Info);
91		pool.set_connect_options(connect_options);
92
93		let nats_configuration = NatsConfiguration::for_test().await;
94		let nats_connection = nats_configuration
95			.connect(
96				"tests".to_owned(),
97				"0.0.0".to_owned(),
98				"NATS configuration just for tests".to_owned(),
99			)
100			.await
101			.expect("Could not connect to nats");
102		nats_connection.set_up_streams().await.expect("Could not set up streams");
103		nats_connection
104			.set_maintenance_mode(false)
105			.await
106			.expect("Could not disable maintenance mode");
107		let config = ArminiusConfig::for_test_using_ipv6();
108		ServerState { nats_connection, database, config }
109	}
110}
111
112/// Create a new server state while automatically setting the component name on
113/// NATS
114#[macro_export]
115macro_rules! new_server_state {
116	() => {
117		arminius_lib_component::ServerState::new(
118			env!("CARGO_BIN_NAME").to_owned(),
119			env!("CARGO_PKG_VERSION").to_owned(),
120			env!("CARGO_PKG_DESCRIPTION").to_owned(),
121		)
122	};
123}
124
125impl ServerState {
126	/// Creates a new DB using the provided infrastructure
127	#[must_use]
128	pub fn create(
129		nats_connection: SharedNatsClient,
130		database: DefaultDb,
131		config: ArminiusConfig,
132	) -> ServerState {
133		Self { nats_connection, database, config }
134	}
135	/// Builds a new component state by reading configuration from the
136	/// environment and connecting to NATS and Postgres
137	pub async fn new(
138		service_name: String,
139		version: String,
140		description: String,
141	) -> Result<Self, ComponentInitializationError> {
142		let nats_credentials =
143			NatsCredentials::from_env().map_err(NatsConfigurationError::Credential)?;
144		let database = DefaultDb::from_env().await?;
145		Ok(Self {
146			nats_connection: NatsConfiguration::with_credentials(nats_credentials)?
147				.connect(service_name, version, description)
148				.await?,
149			database,
150			config: ArminiusConfig::from_env()?,
151		})
152	}
153}
154
155/// Signals an error during component creation / initialization
156#[derive(thiserror::Error, Debug)]
157pub enum ComponentInitializationError {
158	/// Error connecting to DB
159	#[error(transparent)]
160	DatabaseError(#[from] arminius_lib_db::DatabaseError),
161	/// NATS configuration error
162	#[error(transparent)]
163	NatsConfiguration(#[from] NatsConfigurationError),
164	/// Malformed Arminius Configuration
165	#[error(transparent)]
166	ArminiusConfigurationError(#[from] arminius_lib_config::ArminiusConfigurationError),
167}
168
169impl FromRef<ServerState> for SharedNatsClient {
170	fn from_ref(component_state: &ServerState) -> SharedNatsClient {
171		component_state.nats_connection.clone()
172	}
173}
174
175impl FromRef<ServerState> for DefaultDb {
176	fn from_ref(component_state: &ServerState) -> DefaultDb {
177		component_state.database.clone()
178	}
179}
180
181impl FromRef<ServerState> for ArminiusConfig {
182	fn from_ref(component_state: &ServerState) -> ArminiusConfig {
183		component_state.config.clone()
184	}
185}
186
187/// Error returned when the user used an unknown endpoint.
188#[derive(thiserror::Error, Debug, MatrixApiError)]
189#[error("Endpoint not found")]
190#[matrix_api_error(status_code = StatusCode::NOT_FOUND, error_kind = ErrorKind::Unrecognized, component_crate_root = crate)]
191pub struct EndpointNotFound;
192
193/// Always returns NOT_FOUND. Called as a fallback option
194async fn unknown_endpoint(method: Method, uri: Uri) -> EndpointNotFound {
195	tracing::info!(%uri, %method, "Got unknown endpoint");
196	EndpointNotFound
197}
198
199/// Error returned when the user used a disallowed method.
200#[derive(thiserror::Error, Debug, MatrixApiError)]
201#[error("Method not allowed")]
202#[matrix_api_error(status_code = StatusCode::METHOD_NOT_ALLOWED, error_kind = ErrorKind::Unrecognized, component_crate_root = crate)]
203pub struct MethodNotAllowed;
204
205/// Always returns METHOD_NOT_ALLOWED. Called as a method_not_allowed_fallback
206async fn method_not_allowed_endpoint(method: Method, uri: Uri) -> MethodNotAllowed {
207	tracing::info!(%uri, %method, "User used disallowed method");
208	MethodNotAllowed
209}
210
211/// Error type for the maintenance mode middleware layer.
212#[derive(MatrixApiError, thiserror::Error, Debug)]
213#[matrix_api_error(component_crate_root = crate)]
214pub enum MaintenanceModeLayerError {
215	/// Returned when the service is in maintenance mode.
216	#[error("Service in maintenance mode")]
217	#[matrix_api_error(status_code = StatusCode::SERVICE_UNAVAILABLE, error_kind = ErrorKind::Unknown)]
218	MaintenanceMode,
219
220	/// Returned when an internal error occurs.
221	#[error(transparent)]
222	InternalServerError(#[from] InternalServerError),
223}
224
225/// Middleware layer that returns a maintenance mode error if the service is
226/// currently in maintenance mode.
227async fn maintenance_mode_layer(
228	state: State<ServerState>,
229	req: axum::http::Request<Body>,
230	next: Next,
231) -> Result<axum::http::Response<Body>, MaintenanceModeLayerError> {
232	let in_maintenance =
233		state.nats_connection.is_in_maintenance_mode().await.map_err(|_| InternalServerError)?;
234
235	if in_maintenance {
236		Err(MaintenanceModeLayerError::MaintenanceMode)
237	} else {
238		Ok(next.run(req).await)
239	}
240}
241
242/// Represents the "microservice", which is composed out of one or more
243/// components handing specific functions.
244#[derive(Debug)]
245#[must_use = "Initialized server but never used"]
246pub struct Server<COMPONENT: Component> {
247	/// State passed to all components. This is cloned for each call.
248	pub state: ServerState,
249	/// The router used by this component. Might be used by tests to skip doing
250	/// actual network requests.
251	pub router: Router,
252	/// The listener on which we will listen for stuff
253	pub listener: tokio::net::TcpListener,
254	/// The components of this server. Not actually stored, but this is used to
255	/// invoke their initialize() and run() functions.
256	components: PhantomData<COMPONENT>,
257}
258
259impl<COMPONENT: Component> Server<COMPONENT> {
260	/// Initialize and construct the server.
261	pub async fn initialize(state: ServerState) -> color_eyre::eyre::Result<Self> {
262		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
263		COMPONENT::initialize(&state).await?;
264
265		let traced_router = COMPONENT::router()
266			.with_state(state.clone())
267			.fallback(unknown_endpoint)
268			.method_not_allowed_fallback(method_not_allowed_endpoint)
269			// Add spans to requests.
270			.layer(OtelAxumLayer::default());
271
272		let router = Router::new()
273			.merge(traced_router)
274			.layer(axum::middleware::from_fn_with_state(state.clone(), maintenance_mode_layer))
275			.layer(
276				// https://spec.matrix.org/v1.14/client-server-api/#web-browser-clients
277				CorsLayer::new()
278					.allow_headers([
279						header::AUTHORIZATION,
280						header::CONTENT_TYPE,
281						HeaderName::from_static("x-requested-with"),
282					])
283					.allow_methods([
284						Method::GET,
285						Method::POST,
286						Method::PUT,
287						Method::DELETE,
288						Method::OPTIONS,
289					])
290					.allow_origin(Any),
291			)
292			// We don't want probe endpoints to be traced
293			.merge(probe_router(state.clone()));
294
295		let listener = tokio::net::TcpListener::bind(state.config.addr)
296			.await
297			.context("Failed binding listener")?;
298
299		Ok(Self { state, router, listener, components: PhantomData })
300	}
301
302	/// Helper to run the axum server with the router created during
303	/// initialization.
304	pub async fn run_axum(
305		self,
306		cancellation_token: CancellationToken,
307	) -> color_eyre::eyre::Result<()> {
308		tracing::info!(addr = %self.state.config.addr, "Starting listener");
309		axum::serve(self.listener, self.router)
310			.with_graceful_shutdown(cancellation_token.cancelled_owned())
311			.await
312			.context("Failed serving webserver")?;
313		Ok(())
314	}
315
316	/// Execute the Server/microservice. Call in main. Returns after the service
317	/// exited.
318	pub async fn run(self) -> color_eyre::eyre::Result<()> {
319		let cancellation_token = CancellationToken::new();
320		// pin the future to avoid excessive stack usage
321		Box::pin(self.run_with_cancellation_token(cancellation_token)).await
322	}
323
324	/// Execute the Server/microservice, and close it when the cancellation
325	/// token gets used
326	pub async fn run_with_cancellation_token(
327		self,
328		cancellation_token: CancellationToken,
329	) -> color_eyre::eyre::Result<()> {
330		let tc = cancellation_token.clone();
331		tokio::spawn(async move {
332			await_shutdown_signal().await;
333			tc.cancel();
334		});
335
336		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
337		COMPONENT::initialize(&self.state).await?;
338
339		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
340		tokio::try_join!(
341			COMPONENT::run(self.state.clone(), cancellation_token.clone()),
342			self.run_axum(cancellation_token),
343		)?;
344
345		Ok(())
346	}
347}
348
349/// Represents a component in the microservice
350pub trait Component {
351	/// Returns all the routes exposed by this component.
352	fn router() -> Router<ServerState> {
353		Router::new()
354	}
355	/// Hook to run necessary setup tasks for this component.
356	#[must_use]
357	fn initialize(_: &ServerState) -> impl Future<Output = color_eyre::eyre::Result<()>> + Send {
358		future::ready(Ok(()))
359	}
360
361	/// Hook to run background tasks and other long running async executions for
362	/// this component, e.g. subscribing to a NATS stream and handling messages.
363	#[must_use]
364	fn run(
365		_state: ServerState,
366		_cancellation_token: CancellationToken,
367	) -> impl Future<Output = color_eyre::eyre::Result<()>> + Send {
368		future::ready(Ok(()))
369	}
370}
371
372/// A component composed of two components. Initializing this component is
373/// equivalent to initializing the two components one after the other. Running
374/// it is equivalent to running them as a monolith.
375#[derive(Debug)]
376pub struct ComposedComponent<T: Component, U: Component> {
377	/// Phantom data
378	_1: PhantomData<T>,
379	/// Phantom data
380	_2: PhantomData<U>,
381}
382
383impl<T: Component, U: Component> Component for ComposedComponent<T, U> {
384	fn router() -> Router<ServerState> {
385		T::router().merge(U::router())
386	}
387
388	async fn initialize(database: &ServerState) -> color_eyre::eyre::Result<()> {
389		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
390		T::initialize(database).await?;
391		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
392		U::initialize(database).await?;
393		Ok(())
394	}
395
396	async fn run(
397		state: ServerState,
398		cancellation_token: CancellationToken,
399	) -> color_eyre::eyre::Result<()> {
400		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
401		tokio::try_join!(
402			T::run(state.clone(), cancellation_token.clone()),
403			U::run(state.clone(), cancellation_token.clone())
404		)?;
405		Ok(())
406	}
407}
408
409/// A component with no endpoints - useful for healthchecks for services that
410/// don't expose their own HTTP endpoints otherwise
411#[derive(Debug)]
412pub struct EmptyComponent;
413
414impl Component for EmptyComponent {}
415
416/// For a list of components `ComponentA`, `ComponentB`, `ComponentC` constructs
417/// a type `ComposedComponent<ComponentA, ComposedComponent<ComponentB,
418/// ComponentC>>`.
419///
420/// As an optimization we match subtrees up to a certain depth and build up a
421/// tree like `((c,c),(c,c))` instead of `(c, (c, (c, c)))`. This about halves
422/// the recursion depth for each level of additional matching we do.
423#[macro_export]
424macro_rules! compose_components {
425        ($c1:ident,$c2:ident) => {
426		::arminius_lib_component::ComposedComponent<$c1, $c2>
427	};
428	($c1:ident,$c2:ident,$c3:ident,$c4:ident,$c5:ident,$c6:ident,$c7:ident,$c8:ident,$c9:ident,$($c10:ident),+) => {
429		::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<$c1, $c2>, ::arminius_lib_component::ComposedComponent<$c3, $c4>>, ::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<$c5, $c6>, ::arminius_lib_component::ComposedComponent<$c7, $c8>>>, compose_components!($c9,$($c10),+)>
430	};
431	($c1:ident,$c2:ident,$c3:ident,$c4:ident,$c5:ident,$($c6:ident),+) => {
432		::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<$c1, $c2>, ::arminius_lib_component::ComposedComponent<$c3, $c4>>, compose_components!($c5,$($c6),+)>
433	};
434	($c1:ident,$c2:ident,$c3:ident,$($c4:ident),+) => {
435		::arminius_lib_component::ComposedComponent<::arminius_lib_component::ComposedComponent<$c1, $c2>, compose_components!($c3,$($c4),+)>
436	};
437	($c1:ident,$c2:ident,$($c3:ident),+) => {
438		::arminius_lib_component::ComposedComponent<$c1, compose_components!($c2,$($c3),+)>
439	};
440}
441
442/// Future that completes when a SIGTERM or SIGINT is received, or immediately
443/// if no listener can be registered.
444pub async fn await_shutdown_signal() {
445	let mut sigterm_signal = match tokio::signal::unix::signal(SignalKind::terminate()) {
446		Ok(signal) => signal,
447		Err(error) => {
448			tracing::error!(%error, "Could not create listener for sigterm");
449			return;
450		}
451	};
452	tokio::select! {
453		result = tokio::signal::ctrl_c() => {
454			match result {
455				Ok(_) => (),
456				Err(error) => {
457					tracing::error!(%error, "Error registering ctrl+c listener");
458				}
459			}
460		},
461		result = sigterm_signal.recv() => {
462			match result {
463				Some(()) => (),
464				None => {
465					tracing::error!("Error registering SIGTERM listener");
466				}
467			}
468		}
469	}
470}