ant_core/node/daemon/forward/
mod.rs1pub mod config;
20pub mod document;
21pub mod es;
22pub mod offsets;
23pub mod parse;
24pub mod runner;
25pub mod sink;
26pub mod tail;
27
28use serde::{Deserialize, Serialize};
29
30pub use config::{LogForwardConfig, LogLevel, DEFAULT_ENDPOINT, DEFAULT_INDEX_PREFIX};
31pub use document::{ForwardDocument, NodeTags};
32pub use es::ElasticsearchSink;
33pub use offsets::OffsetStore;
34pub use parse::{parse_line, LogEvent};
35pub use runner::{classify_nodes, spawn_log_forwarder, ForwarderHandle, DEFAULT_POLL_INTERVAL};
36pub use sink::{BatchOutcome, DocumentOutcome, DocumentQueue, LogSink, RetryPolicy};
37pub use tail::{LogTailer, TailedEvent};
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
41pub struct ForwardingNode {
42 pub node_id: u32,
43 pub service: String,
44 pub log_dir: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
54pub struct SkippedNode {
55 pub node_id: u32,
56 pub service: String,
57 pub reason: String,
59}
60
61impl SkippedNode {
62 #[must_use]
64 pub fn no_logging(node_id: u32, service: impl Into<String>) -> Self {
65 Self {
66 node_id,
67 service: service.into(),
68 reason: "logging is not enabled for this node — re-add it with --log-dir-path to \
69 forward its logs"
70 .to_string(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
80pub struct ForwardStats {
81 pub events_forwarded: u64,
83 pub events_dropped_by_level: u64,
85 pub events_dropped_by_overflow: u64,
88 pub batches_sent: u64,
90 pub batches_failed: u64,
92 pub last_success_unix: Option<u64>,
94 pub last_error: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
103pub struct LogForwardStatus {
104 pub enabled: bool,
105 pub endpoint: String,
106 pub index_prefix: String,
107 pub min_level: LogLevel,
108 pub token_fingerprint: Option<String>,
110 pub active: bool,
113 pub nodes_forwarding: Vec<ForwardingNode>,
114 pub nodes_skipped: Vec<SkippedNode>,
115 pub stats: ForwardStats,
116}
117
118impl LogForwardStatus {
119 #[must_use]
122 pub fn inactive(config: &LogForwardConfig) -> Self {
123 Self {
124 enabled: config.enabled,
125 endpoint: config.endpoint.clone(),
126 index_prefix: config.index_prefix.clone(),
127 min_level: config.min_level,
128 token_fingerprint: config.token_fingerprint(),
129 active: false,
130 nodes_forwarding: Vec::new(),
131 nodes_skipped: Vec::new(),
132 stats: ForwardStats::default(),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
143pub struct LogForwardEnableRequest {
144 #[serde(default)]
146 pub token: Option<String>,
147 #[serde(default)]
149 pub endpoint: Option<String>,
150 #[serde(default)]
152 pub min_level: Option<LogLevel>,
153}
154
155pub fn apply_enable(
161 stored: &LogForwardConfig,
162 request: &LogForwardEnableRequest,
163) -> crate::error::Result<LogForwardConfig> {
164 let mut config = stored.clone();
165 config.enabled = true;
166
167 if let Some(token) = &request.token {
168 config.token = token.trim().to_string();
169 }
170 if let Some(endpoint) = &request.endpoint {
171 config.endpoint = endpoint.trim().to_string();
172 }
173 if let Some(level) = request.min_level {
174 config.min_level = level;
175 }
176
177 config.ensure_installation_id();
180
181 config.validate()?;
182 Ok(config)
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
187pub struct LogForwardResult {
188 pub enabled: bool,
190 pub already_in_state: bool,
192 pub endpoint: String,
193 pub min_level: LogLevel,
194 pub nodes_forwarding: Vec<ForwardingNode>,
196 pub nodes_skipped: Vec<SkippedNode>,
198 pub pending_daemon_start: bool,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn stored_with_token() -> LogForwardConfig {
208 LogForwardConfig {
209 enabled: false,
210 token: "stored-key".to_string(),
211 installation_id: "0123456789abcdef".to_string(),
212 ..LogForwardConfig::disabled()
213 }
214 }
215
216 #[test]
217 fn enabling_for_the_first_time_requires_a_token() {
218 let error = apply_enable(
219 &LogForwardConfig::disabled(),
220 &LogForwardEnableRequest::default(),
221 )
222 .unwrap_err()
223 .to_string();
224 assert!(error.contains("token"), "{error}");
225 }
226
227 #[test]
229 fn re_enabling_reuses_the_stored_token_and_settings() {
230 let stored = LogForwardConfig {
231 endpoint: "http://127.0.0.1:9999".to_string(),
232 min_level: LogLevel::Warn,
233 ..stored_with_token()
234 };
235
236 let config = apply_enable(&stored, &LogForwardEnableRequest::default()).unwrap();
237
238 assert!(config.enabled);
239 assert_eq!(config.token, "stored-key");
240 assert_eq!(config.endpoint, "http://127.0.0.1:9999");
241 assert_eq!(config.min_level, LogLevel::Warn);
242 }
243
244 #[test]
247 fn enabling_mints_an_installation_id_and_later_enables_keep_it() {
248 let config = apply_enable(
249 &LogForwardConfig::disabled(),
250 &LogForwardEnableRequest {
251 token: Some("first-key".to_string()),
252 ..LogForwardEnableRequest::default()
253 },
254 )
255 .unwrap();
256 assert_eq!(config.installation_id.len(), 16);
257
258 let re_enabled = apply_enable(&config, &LogForwardEnableRequest::default()).unwrap();
259 assert_eq!(re_enabled.installation_id, config.installation_id);
260
261 let rotated = apply_enable(
263 &config,
264 &LogForwardEnableRequest {
265 token: Some("rotated-key".to_string()),
266 ..LogForwardEnableRequest::default()
267 },
268 )
269 .unwrap();
270 assert_eq!(rotated.installation_id, config.installation_id);
271 }
272
273 #[test]
274 fn a_supplied_token_endpoint_and_level_override_what_was_stored() {
275 let config = apply_enable(
276 &stored_with_token(),
277 &LogForwardEnableRequest {
278 token: Some(" rotated-key ".to_string()),
279 endpoint: Some("http://localhost:8080".to_string()),
280 min_level: Some(LogLevel::Error),
281 },
282 )
283 .unwrap();
284
285 assert_eq!(config.token, "rotated-key", "surrounding space is trimmed");
286 assert_eq!(config.endpoint, "http://localhost:8080");
287 assert_eq!(config.min_level, LogLevel::Error);
288 }
289
290 #[test]
291 fn an_invalid_endpoint_is_rejected_before_anything_is_persisted() {
292 let error = apply_enable(
293 &stored_with_token(),
294 &LogForwardEnableRequest {
295 endpoint: Some("logs.autonomi.com".to_string()),
296 ..LogForwardEnableRequest::default()
297 },
298 )
299 .unwrap_err()
300 .to_string();
301 assert!(error.contains("http(s) URL"), "{error}");
302 }
303
304 #[test]
305 fn inactive_status_mirrors_the_config_without_exposing_the_token() {
306 let config = LogForwardConfig {
307 enabled: true,
308 token: "secret-api-key".to_string(),
309 ..LogForwardConfig::disabled()
310 };
311
312 let status = LogForwardStatus::inactive(&config);
313
314 assert!(status.enabled);
315 assert!(!status.active);
316 assert_eq!(status.endpoint, DEFAULT_ENDPOINT);
317 assert_eq!(status.min_level, LogLevel::Info);
318 assert_eq!(status.token_fingerprint, config.token_fingerprint());
319
320 let json = serde_json::to_string(&status).unwrap();
321 assert!(
322 !json.contains("secret-api-key"),
323 "status must never carry the token: {json}"
324 );
325 }
326
327 #[test]
328 fn inactive_status_of_a_disabled_config_has_no_fingerprint() {
329 let status = LogForwardStatus::inactive(&LogForwardConfig::disabled());
330 assert!(!status.enabled);
331 assert_eq!(status.token_fingerprint, None);
332 }
333
334 #[test]
335 fn skip_reason_points_at_the_flag_that_fixes_it() {
336 let skipped = SkippedNode::no_logging(3, "node3");
337 assert_eq!(skipped.node_id, 3);
338 assert_eq!(skipped.service, "node3");
339 assert!(skipped.reason.contains("--log-dir-path"));
340 }
341
342 #[test]
343 fn stats_start_at_zero() {
344 let stats = ForwardStats::default();
345 assert_eq!(stats.events_forwarded, 0);
346 assert_eq!(stats.batches_failed, 0);
347 assert_eq!(stats.last_success_unix, None);
348 assert_eq!(stats.last_error, None);
349 }
350}