1use std::{collections::HashMap, path::PathBuf};
2
3use futures_util::{FutureExt, StreamExt, TryFutureExt, TryStreamExt, stream};
4use heim::{disk::Partition, units::information::byte};
5use indexmap::IndexMap;
6use vector_lib::{buffers::config::DiskUsage, internal_event::DEFAULT_OUTPUT};
7
8use super::{
9 ComponentKey, Config, OutputId, Resource, builder::ConfigBuilder,
10 transform::get_transform_output_ids,
11};
12
13const EWMA_ALPHA_MIN: f64 = 0.0;
16
17const EWMA_ALPHA_MAX: f64 = 1.0;
20
21const EWMA_HALF_LIFE_SECONDS_MIN: f64 = 0.0;
24
25fn validate_ewma_alpha(alpha: Option<f64>, field_name: &str) -> Option<String> {
28 if let Some(alpha) = alpha
29 && !(alpha > EWMA_ALPHA_MIN && alpha < EWMA_ALPHA_MAX)
30 {
31 Some(format!(
32 "Global `{field_name}` must be between 0 and 1 exclusive (0 < alpha < 1), got {alpha}"
33 ))
34 } else {
35 None
36 }
37}
38
39#[expect(
42 clippy::neg_cmp_op_on_partial_ord,
43 reason = "!(x > 0) rejects NaN and non-positive values; (x <= 0) would incorrectly accept NaN"
44)]
45fn validate_ewma_half_life_seconds(
46 half_life_seconds: Option<f64>,
47 field_name: &str,
48) -> Option<String> {
49 if let Some(half_life_seconds) = half_life_seconds
50 && !(half_life_seconds > EWMA_HALF_LIFE_SECONDS_MIN)
51 {
52 Some(format!(
53 "Global `{field_name}` must be greater than 0, got {half_life_seconds}"
54 ))
55 } else {
56 None
57 }
58}
59
60pub fn check_provider(config: &ConfigBuilder) -> Result<(), Vec<String>> {
62 if config.provider.is_some()
63 && (!config.sources.is_empty() || !config.transforms.is_empty() || !config.sinks.is_empty())
64 {
65 Err(vec![
66 "No sources/transforms/sinks are allowed if provider config is present.".to_owned(),
67 ])
68 } else {
69 Ok(())
70 }
71}
72
73pub fn check_names<'a, I: Iterator<Item = &'a ComponentKey>>(names: I) -> Result<(), Vec<String>> {
74 let errors: Vec<_> = names
75 .filter(|component_key| component_key.id().contains('.'))
76 .map(|component_key| {
77 format!(
78 "Component name \"{}\" should not contain a \".\"",
79 component_key.id()
80 )
81 })
82 .collect();
83
84 if errors.is_empty() {
85 Ok(())
86 } else {
87 Err(errors)
88 }
89}
90
91pub fn check_shape(config: &ConfigBuilder) -> Result<(), Vec<String>> {
92 let mut errors = vec![];
93
94 if !config.allow_empty {
95 if config.sources.is_empty() {
96 errors.push("No sources defined in the config.".to_owned());
97 }
98
99 if config.sinks.is_empty() {
100 errors.push("No sinks defined in the config.".to_owned());
101 }
102 }
103
104 fn tagged<'a>(
106 tag: &'static str,
107 iter: impl Iterator<Item = &'a ComponentKey>,
108 ) -> impl Iterator<Item = (&'static str, &'a ComponentKey)> {
109 iter.map(move |x| (tag, x))
110 }
111
112 let mut used_keys = HashMap::<&ComponentKey, Vec<&'static str>>::new();
114 for (ctype, id) in tagged("source", config.sources.keys())
115 .chain(tagged("transform", config.transforms.keys()))
116 .chain(tagged("sink", config.sinks.keys()))
117 {
118 let uses = used_keys.entry(id).or_default();
119 uses.push(ctype);
120 }
121
122 for (id, uses) in used_keys.into_iter().filter(|(_id, uses)| uses.len() > 1) {
123 errors.push(format!(
124 "More than one component with name \"{}\" ({}).",
125 id,
126 uses.join(", ")
127 ));
128 }
129
130 let sink_inputs = config
132 .sinks
133 .iter()
134 .map(|(key, sink)| ("sink", key.clone(), sink.inputs.clone()));
135 let transform_inputs = config
136 .transforms
137 .iter()
138 .map(|(key, transform)| ("transform", key.clone(), transform.inputs.clone()));
139 for (output_type, key, inputs) in sink_inputs.chain(transform_inputs) {
140 if inputs.is_empty() {
141 errors.push(format!(
142 "{} \"{}\" has no inputs",
143 capitalize(output_type),
144 key
145 ));
146 }
147
148 let mut frequencies = HashMap::new();
149 for input in inputs {
150 let entry = frequencies.entry(input).or_insert(0usize);
151 *entry += 1;
152 }
153
154 for (dup, count) in frequencies.into_iter().filter(|(_name, count)| *count > 1) {
155 errors.push(format!(
156 "{} \"{}\" has input \"{}\" duplicated {} times",
157 capitalize(output_type),
158 key,
159 dup,
160 count,
161 ));
162 }
163 }
164
165 if errors.is_empty() {
166 Ok(())
167 } else {
168 Err(errors)
169 }
170}
171
172pub fn check_resources(config: &ConfigBuilder) -> Result<(), Vec<String>> {
173 let source_resources = config
174 .sources
175 .iter()
176 .map(|(id, config)| (id, config.inner.resources()));
177 let sink_resources = config
178 .sinks
179 .iter()
180 .map(|(id, config)| (id, config.resources(id)));
181
182 let conflicting_components = Resource::conflicts(source_resources.chain(sink_resources));
183
184 if conflicting_components.is_empty() {
185 Ok(())
186 } else {
187 Err(conflicting_components
188 .into_iter()
189 .map(|(resource, components)| {
190 format!("Resource `{resource}` is claimed by multiple components: {components:?}")
191 })
192 .collect())
193 }
194}
195
196pub fn check_values(config: &ConfigBuilder) -> Result<(), Vec<String>> {
198 let mut errors = Vec::new();
199
200 if let Some(error) = validate_ewma_half_life_seconds(
201 config.global.buffer_utilization_ewma_half_life_seconds,
202 "buffer_utilization_ewma_half_life_seconds",
203 ) {
204 errors.push(error);
205 }
206 if let Some(error) = validate_ewma_alpha(config.global.latency_ewma_alpha, "latency_ewma_alpha")
207 {
208 errors.push(error);
209 }
210
211 if errors.is_empty() {
212 Ok(())
213 } else {
214 Err(errors)
215 }
216}
217
218pub fn check_outputs(config: &ConfigBuilder) -> Result<(), Vec<String>> {
221 let mut errors = Vec::new();
222 for (key, source) in config.sources.iter() {
223 let outputs = source.inner.outputs(config.schema.log_namespace());
224 if outputs
225 .iter()
226 .map(|output| output.port.as_deref().unwrap_or(""))
227 .any(|name| name == DEFAULT_OUTPUT)
228 {
229 errors.push(format!(
230 "Source {key} cannot have a named output with reserved name: `{DEFAULT_OUTPUT}`"
231 ));
232 }
233 }
234
235 for (key, transform) in config.transforms.iter() {
236 if let Err(errs) = transform.inner.validate_structure() {
240 errors.extend(errs.into_iter().map(|msg| format!("Transform {key} {msg}")));
241 }
242
243 if get_transform_output_ids(
244 transform.inner.as_ref(),
245 key.clone(),
246 config.schema.log_namespace(),
247 )
248 .any(|output| matches!(output.port, Some(output) if output == DEFAULT_OUTPUT))
249 {
250 errors.push(format!(
251 "Transform {key} cannot have a named output with reserved name: `{DEFAULT_OUTPUT}`"
252 ));
253 }
254 }
255
256 if errors.is_empty() {
257 Ok(())
258 } else {
259 Err(errors)
260 }
261}
262
263pub async fn check_buffer_preconditions(config: &Config) -> Result<(), Vec<String>> {
264 let global_data_dir = config.global.data_dir.clone();
277 let configured_disk_buffers = config
278 .sinks()
279 .flat_map(|(id, sink)| {
280 sink.buffer
281 .stages()
282 .iter()
283 .filter_map(|stage| stage.disk_usage(global_data_dir.clone(), id))
284 })
285 .collect::<Vec<_>>();
286
287 if configured_disk_buffers.is_empty() {
288 return Ok(());
289 }
290
291 let mountpoints = heim::disk::partitions()
295 .and_then(|stream| stream.try_collect::<Vec<_>>().and_then(process_partitions))
296 .or_else(|_| {
297 heim::disk::partitions_physical()
298 .and_then(|stream| stream.try_collect::<Vec<_>>().and_then(process_partitions))
299 })
300 .await;
301
302 let mountpoints = match mountpoints {
303 Ok(mut mountpoints) => {
304 mountpoints.sort_by(|m1, _, m2, _| m2.cmp(m1));
305 mountpoints
306 }
307 Err(e) => {
308 warn!(
309 cause = %e,
310 message = "Failed to query disk partitions. Cannot ensure that buffer size limits are within physical storage capacity limits.",
311 );
312 return Ok(());
313 }
314 };
315
316 let mountpoint_buffer_mapping = configured_disk_buffers.into_iter().fold(
318 HashMap::new(),
319 |mut mappings: HashMap<PathBuf, Vec<DiskUsage>>, usage| {
320 let canonicalized_data_dir = usage
321 .data_dir()
322 .canonicalize()
323 .unwrap_or_else(|_| usage.data_dir().to_path_buf());
324 let mountpoint = mountpoints
325 .keys()
326 .find(|mountpoint| canonicalized_data_dir.starts_with(mountpoint));
327
328 match mountpoint {
329 None => warn!(
330 buffer_id = usage.id().id(),
331 data_dir = usage.data_dir().to_string_lossy().as_ref(),
332 canonicalized_data_dir = canonicalized_data_dir.to_string_lossy().as_ref(),
333 message = "Found no matching mountpoint for buffer data directory.",
334 ),
335 Some(mountpoint) => {
336 mappings.entry(mountpoint.clone()).or_default().push(usage);
337 }
338 }
339
340 mappings
341 },
342 );
343
344 let mut errors = Vec::new();
351
352 for (mountpoint, buffers) in mountpoint_buffer_mapping {
353 let buffer_max_size_total: u64 = buffers.iter().map(|usage| usage.max_size()).sum();
354 let mountpoint_total_capacity = mountpoints
355 .get(&mountpoint)
356 .copied()
357 .expect("mountpoint must exist");
358
359 if buffer_max_size_total > mountpoint_total_capacity {
360 let component_ids = buffers
361 .iter()
362 .map(|usage| usage.id().id())
363 .collect::<Vec<_>>();
364 errors.push(format!(
365 "Mountpoint '{}' has total capacity of {} bytes, but configured buffers using mountpoint have total maximum size of {} bytes. \
366Reduce the `max_size` of the buffers to fit within the total capacity of the mountpoint. (components associated with mountpoint: {})",
367 mountpoint.to_string_lossy(), mountpoint_total_capacity, buffer_max_size_total, component_ids.join(", "),
368 ));
369 }
370 }
371
372 if errors.is_empty() {
373 Ok(())
374 } else {
375 Err(errors)
376 }
377}
378
379async fn process_partitions(partitions: Vec<Partition>) -> heim::Result<IndexMap<PathBuf, u64>> {
380 stream::iter(partitions)
381 .map(Ok)
382 .and_then(|partition| {
383 let mountpoint_path = partition.mount_point().to_path_buf();
384 heim::disk::usage(mountpoint_path.clone())
385 .map(|usage| usage.map(|usage| (mountpoint_path, usage.total().get::<byte>())))
386 })
387 .try_collect::<IndexMap<_, _>>()
388 .await
389}
390
391pub fn warnings(config: &Config) -> Vec<String> {
392 let mut warnings = vec![];
393
394 let table_sources = config
395 .enrichment_tables
396 .iter()
397 .filter_map(|(key, table)| table.as_source(key))
398 .collect::<Vec<_>>();
399 let source_ids = config
400 .sources
401 .iter()
402 .chain(table_sources.iter().map(|(k, s)| (k, s)))
403 .flat_map(|(key, source)| {
404 source
405 .inner
406 .outputs(config.schema.log_namespace())
407 .iter()
408 .map(|output| {
409 if let Some(port) = &output.port {
410 ("source", OutputId::from((key, port.clone())))
411 } else {
412 ("source", OutputId::from(key))
413 }
414 })
415 .collect::<Vec<_>>()
416 });
417 let transform_ids = config.transforms.iter().flat_map(|(key, transform)| {
418 get_transform_output_ids(
419 transform.inner.as_ref(),
420 key.clone(),
421 config.schema.log_namespace(),
422 )
423 .map(|output| ("transform", output))
424 .collect::<Vec<_>>()
425 });
426
427 let table_sinks = config
428 .enrichment_tables
429 .iter()
430 .filter_map(|(key, table)| table.as_sink(key))
431 .collect::<Vec<_>>();
432 for (input_type, id) in transform_ids.chain(source_ids) {
433 if !config
434 .transforms
435 .iter()
436 .any(|(_, transform)| transform.inputs.contains(&id))
437 && !config
438 .sinks
439 .iter()
440 .any(|(_, sink)| sink.inputs.contains(&id))
441 && !table_sinks
442 .iter()
443 .any(|(_, sink)| sink.inputs.contains(&id))
444 {
445 warnings.push(format!(
446 "{} \"{}\" has no consumers",
447 capitalize(input_type),
448 id
449 ));
450 }
451 }
452
453 warnings
454}
455
456fn capitalize(s: &str) -> String {
457 let mut s = s.to_owned();
458 if let Some(r) = s.get_mut(0..1) {
459 r.make_ascii_uppercase();
460 }
461 s
462}