Skip to main content

vector/config/
validation.rs

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
13/// Minimum value (exclusive) for EWMA alpha options.
14/// The alpha value must be strictly greater than this value.
15const EWMA_ALPHA_MIN: f64 = 0.0;
16
17/// Maximum value (exclusive) for EWMA alpha options.
18/// The alpha value must be strictly less than this value.
19const EWMA_ALPHA_MAX: f64 = 1.0;
20
21/// Minimum value (exclusive) for EWMA half-life options.
22/// The half-life value must be strictly greater than this value.
23const EWMA_HALF_LIFE_SECONDS_MIN: f64 = 0.0;
24
25/// Validates an optional EWMA alpha value and returns an error message if invalid.
26/// Returns `None` if the value is `None` or valid, otherwise returns an error message.
27fn 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/// Validates an optional EWMA half-life value and returns an error message if invalid.
40/// Returns `None` if the value is `None` or valid, otherwise returns an error message.
41#[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
60/// Check that provide + topology config aren't present in the same builder, which is an error.
61pub 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    // Helper for below
105    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    // Check for non-unique names across sources, sinks, and transforms
113    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    // Warnings and errors
131    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
196/// Validates that `*_ewma_alpha` values are within the valid range (0 < alpha < 1).
197pub 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
218/// To avoid collisions between `output` metric tags, check that a component
219/// does not have a named output with the name [`DEFAULT_OUTPUT`]
220pub 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        // Structural validation: reserved names, duplicate routes, invalid sample rates.
237        // These checks run during config compilation. Transforms that need the schema/enrichment
238        // context must implement validate_with_context(), called later in validate.rs.
239        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    // We need to assert that Vector's data directory is located on a mountpoint that has enough
265    // capacity to allow all sinks with disk buffers configured to be able to use up to their
266    // maximum configured size without overrunning the total capacity.
267    //
268    // More subtly, we need to make sure we properly map a given buffer's data directory to the
269    // appropriate mountpoint, as it is technically possible that individual buffers could be on
270    // separate mountpoints.
271    //
272    // Notably, this does *not* cover other data usage by Vector on the same mountpoint because we
273    // don't always know the upper bound of that usage i.e. file checkpoint state.
274
275    // Grab all configured disk buffers, and if none are present, simply return early.
276    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    // Now query all the mountpoints on the system, and get their total capacity. We also have to
292    // sort the mountpoints from longest to shortest so we can find the longest prefix match for
293    // each buffer data directory by simply iterating from beginning to end.
294    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    // Now build a mapping of buffer IDs/usage configuration to the mountpoint they reside on.
317    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    // Finally, we have a mapping of disk buffers, based on their underlying mountpoint. Go through
345    // and check to make sure the sum total of `max_size` for all buffers associated with each
346    // mountpoint does not exceed that mountpoint's total capacity.
347    //
348    // We specifically do not do any sort of warning on free space because that has to be the
349    // responsibility of the operator to ensure there's enough total space for all buffers present.
350    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}