Skip to main content

bestool_alertd/
http_server.rs

1//! HTTP server for alertd daemon control and metrics.
2
3use std::{collections::HashMap, sync::Arc, time::Duration};
4
5use axum::{
6	Router,
7	routing::{get, post},
8};
9use jiff::Timestamp;
10use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
11use tracing::{Level, error, info, warn};
12
13use crate::{
14	context::InternalContext,
15	daemon::DaemonControl,
16	tasks::{BackgroundTask, TaskEndpointHandler},
17};
18
19mod endpoints;
20mod metrics_render;
21mod state;
22#[cfg(test)]
23mod test_utils;
24mod types;
25
26pub use endpoints::*;
27pub use state::ServerState;
28pub use types::*;
29
30#[expect(
31	clippy::too_many_arguments,
32	reason = "daemon wiring; each shared resource is threaded in explicitly"
33)]
34pub async fn start_server(
35	internal_context: Arc<InternalContext>,
36	addrs: Vec<std::net::SocketAddr>,
37	watchdog_timeout: Option<Duration>,
38	background_tasks: &[Arc<dyn BackgroundTask>],
39	control: DaemonControl,
40	backups: Option<Arc<crate::BackupRegistry>>,
41	metrics: Option<crate::doctor::DoctorMetricsHandle>,
42	binary_version: String,
43) {
44	let started_at = Timestamp::now();
45	let pid = std::process::id();
46
47	let task_endpoints = collect_task_endpoints(background_tasks);
48
49	let state = ServerState {
50		started_at,
51		pid,
52		binary_version,
53		internal_context,
54		watchdog_timeout,
55		task_endpoints: Arc::new(task_endpoints),
56		control,
57		backups,
58		metrics,
59	};
60
61	let app = Router::new()
62		.route("/", get(handle_index))
63		.route("/metrics", get(handle_metrics))
64		.route("/status", get(handle_status))
65		.route("/health", get(handle_health))
66		.route("/seedling", get(handle_seedling))
67		.route("/reload", post(handle_reload))
68		.route("/restart", post(handle_restart))
69		.route("/tasks/{task}/{endpoint}", get(handle_task_endpoint))
70		.layer(
71			TraceLayer::new_for_http()
72				.make_span_with(
73					DefaultMakeSpan::new()
74						.level(Level::INFO)
75						.include_headers(false),
76				)
77				.on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
78					info!(
79						method = %request.method(),
80						uri = %request.uri(),
81						"HTTP request"
82					);
83				})
84				.on_response(
85					DefaultOnResponse::new()
86						.level(Level::INFO)
87						.include_headers(false),
88				),
89		)
90		.with_state(Arc::new(state));
91
92	// Use default if no addresses provided
93	let addrs_to_try = if addrs.is_empty() {
94		vec![
95			"[::1]:8271".parse().unwrap(),
96			"127.0.0.1:8271".parse().unwrap(),
97		]
98	} else {
99		addrs
100	};
101
102	let mut listener = None;
103	let mut last_error = None;
104
105	// Try each address in order until one succeeds
106	for addr in &addrs_to_try {
107		match tokio::net::TcpListener::bind(addr).await {
108			Ok(l) => {
109				info!("HTTP server listening on http://{}", addr);
110				listener = Some(l);
111				break;
112			}
113			Err(e) => {
114				warn!("failed to bind HTTP server to {}: {}", addr, e);
115				last_error = Some(e);
116			}
117		}
118	}
119
120	let listener = match listener {
121		Some(l) => l,
122		None => {
123			if let Some(e) = last_error {
124				warn!("failed to bind HTTP server to any address: {}", e);
125			} else {
126				warn!("no addresses provided for HTTP server");
127			}
128			warn!("waiting 10 seconds before continuing without");
129			warn!("use --no-server to disable the HTTP server and this warning");
130
131			tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
132
133			info!("continuing without HTTP server");
134			return;
135		}
136	};
137
138	if let Err(e) = axum::serve(listener, app).await {
139		error!("HTTP server error: {}", e);
140	}
141}
142
143fn collect_task_endpoints(
144	tasks: &[Arc<dyn BackgroundTask>],
145) -> HashMap<(String, String), TaskEndpointHandler> {
146	let mut map = HashMap::new();
147	for task in tasks {
148		let task_name = task.name();
149		for endpoint in task.http_endpoints() {
150			let key = (task_name.to_string(), endpoint.name.to_string());
151			if map.contains_key(&key) {
152				warn!(
153					task = task_name,
154					endpoint = endpoint.name,
155					"duplicate task endpoint name; later registration wins"
156				);
157			}
158			info!(
159				task = task_name,
160				endpoint = endpoint.name,
161				path = %format!("/tasks/{task_name}/{}", endpoint.name),
162				"mounting task endpoint"
163			);
164			map.insert(key, endpoint.handler);
165		}
166	}
167	map
168}