Skip to main content

vector/
validate.rs

1#![allow(missing_docs)]
2
3use std::{collections::HashMap, fmt, fs::remove_dir_all, path::PathBuf};
4
5use clap::Parser;
6use colored::*;
7use exitcode::ExitCode;
8use vector_lib::enrichment::{Case, IndexHandle, TableRegistry};
9use vector_vrl_metrics::MetricsStorage;
10use vrl::value::ObjectMap;
11
12use crate::{
13    config::{self, Config, ConfigDiff, TransformContext, loading::ConfigBuilderLoader},
14    schema::Definition,
15    topology::{
16        self,
17        builder::{TopologyPieces, TopologyPiecesBuilder},
18    },
19};
20
21/// Stub enrichment table used during transform validation so VRL can resolve
22/// table name references without loading actual table data.
23#[derive(Clone)]
24struct StubEnrichmentTable;
25
26impl vector_lib::enrichment::Table for StubEnrichmentTable {
27    fn find_table_row<'a>(
28        &self,
29        _: Case,
30        _: &'a [vector_lib::enrichment::Condition<'a>],
31        _: Option<&[String]>,
32        _: Option<&vrl::value::Value>,
33        _: Option<IndexHandle>,
34    ) -> Result<ObjectMap, vector_lib::enrichment::Error> {
35        unreachable!("stub table is compile-time only")
36    }
37
38    fn find_table_rows<'a>(
39        &self,
40        _: Case,
41        _: &'a [vector_lib::enrichment::Condition<'a>],
42        _: Option<&[String]>,
43        _: Option<&vrl::value::Value>,
44        _: Option<IndexHandle>,
45    ) -> Result<Vec<ObjectMap>, vector_lib::enrichment::Error> {
46        unreachable!("stub table is compile-time only")
47    }
48
49    fn add_index(
50        &mut self,
51        _: Case,
52        _: &[&str],
53    ) -> Result<IndexHandle, vector_lib::enrichment::Error> {
54        Ok(IndexHandle(0))
55    }
56
57    fn index_fields(&self) -> Vec<(Case, Vec<String>)> {
58        vec![]
59    }
60
61    fn needs_reload(&self) -> bool {
62        false
63    }
64}
65
66const TEMPORARY_DIRECTORY: &str = "validate_tmp";
67
68#[derive(Parser, Debug)]
69#[command(rename_all = "kebab-case")]
70pub struct Opts {
71    /// Disables environment checks. That includes component checks and health checks.
72    #[arg(long)]
73    pub no_environment: bool,
74
75    /// Disables health checks during validation.
76    #[arg(long)]
77    pub skip_healthchecks: bool,
78
79    /// Fail validation on warnings that are probably a mistake in the configuration
80    /// or are recommended to be fixed.
81    #[arg(short, long)]
82    pub deny_warnings: bool,
83
84    /// Vector config files in TOML format to validate.
85    #[arg(
86        id = "config-toml",
87        long,
88        env = "VECTOR_CONFIG_TOML",
89        value_delimiter(',')
90    )]
91    pub paths_toml: Vec<PathBuf>,
92
93    /// Vector config files in JSON format to validate.
94    #[arg(
95        id = "config-json",
96        long,
97        env = "VECTOR_CONFIG_JSON",
98        value_delimiter(',')
99    )]
100    pub paths_json: Vec<PathBuf>,
101
102    /// Vector config files in YAML format to validate.
103    #[arg(
104        id = "config-yaml",
105        long,
106        env = "VECTOR_CONFIG_YAML",
107        value_delimiter(',')
108    )]
109    pub paths_yaml: Vec<PathBuf>,
110
111    /// Any number of Vector config files to validate.
112    /// Format is detected from the file name.
113    /// If none are specified, the default config path `/etc/vector/vector.yaml`
114    /// is targeted.
115    #[arg(env = "VECTOR_CONFIG", value_delimiter(','))]
116    pub paths: Vec<PathBuf>,
117
118    /// Read configuration from files in one or more directories.
119    /// File format is detected from the file name.
120    ///
121    /// Files not ending in .toml, .json, .yaml, or .yml will be ignored.
122    #[arg(
123        id = "config-dir",
124        short = 'C',
125        long,
126        env = "VECTOR_CONFIG_DIR",
127        value_delimiter(',')
128    )]
129    pub config_dirs: Vec<PathBuf>,
130
131    /// Allow interpolation of environment variables in configuration files. Enabling this may
132    /// expose environment secrets into your Vector configuration.
133    #[arg(
134        long,
135        env = "VECTOR_DANGEROUSLY_ALLOW_ENV_VAR_INTERPOLATION",
136        default_value = "false"
137    )]
138    pub dangerously_allow_env_var_interpolation: bool,
139}
140
141impl Opts {
142    fn paths_with_formats(&self) -> Vec<config::ConfigPath> {
143        config::merge_path_lists(vec![
144            (&self.paths, None),
145            (&self.paths_toml, Some(config::Format::Toml)),
146            (&self.paths_json, Some(config::Format::Json)),
147            (&self.paths_yaml, Some(config::Format::Yaml)),
148        ])
149        .map(|(path, hint)| config::ConfigPath::File(path, hint))
150        .chain(
151            self.config_dirs
152                .iter()
153                .map(|dir| config::ConfigPath::Dir(dir.to_path_buf())),
154        )
155        .collect()
156    }
157}
158
159/// Performs topology, component, and health checks.
160pub async fn validate(opts: &Opts, color: bool) -> ExitCode {
161    let mut fmt = Formatter::new(color);
162
163    let mut validated = true;
164
165    let mut config = match validate_config(opts, &mut fmt) {
166        Some(config) => config,
167        None => return exitcode::CONFIG,
168    };
169
170    validated &= validate_transforms(&config, &mut fmt).await;
171
172    if !opts.no_environment {
173        if let Some(tmp_directory) = create_tmp_directory(&mut config, &mut fmt) {
174            validated &= validate_environment(opts, &config, &mut fmt).await;
175            remove_tmp_directory(tmp_directory);
176        } else {
177            validated = false;
178        }
179    }
180
181    if validated {
182        fmt.validated();
183        exitcode::OK
184    } else {
185        exitcode::CONFIG
186    }
187}
188
189pub fn validate_config(opts: &Opts, fmt: &mut Formatter) -> Option<Config> {
190    // Prepare paths
191    let paths = opts.paths_with_formats();
192    let paths = if let Some(paths) = config::process_paths(&paths) {
193        paths
194    } else {
195        fmt.error("No config file paths");
196        return None;
197    };
198
199    // Load
200    let paths_list: Vec<_> = paths.iter().map(<&PathBuf>::from).collect();
201
202    let mut report_error = |errors| {
203        fmt.title(format!("Failed to load {:?}", &paths_list));
204        fmt.sub_error(errors);
205    };
206    let builder = ConfigBuilderLoader::default()
207        .load_from_paths(&paths)
208        .map_err(&mut report_error)
209        .ok()?;
210    config::init_log_schema(builder.global.log_schema.clone(), true);
211
212    // Build
213    let (config, warnings) = builder
214        .build_with_warnings()
215        .map_err(&mut report_error)
216        .ok()?;
217
218    // Warnings
219    if !warnings.is_empty() {
220        if opts.deny_warnings {
221            report_error(warnings);
222            return None;
223        }
224
225        fmt.title(format!("Loaded with warnings {:?}", &paths_list));
226        fmt.sub_warning(warnings);
227    } else {
228        fmt.success(format!("Loaded {:?}", &paths_list));
229    }
230
231    Some(config)
232}
233
234async fn validate_transforms(config: &Config, fmt: &mut Formatter) -> bool {
235    let enrichment_tables = TableRegistry::default();
236    // Register stub tables so VRL can resolve configured enrichment table names
237    // without loading actual data. This lets us catch real VRL errors (syntax,
238    // type, wrong table name) while deferring data-loading to the environment phase.
239    let stubs: HashMap<String, Box<dyn vector_lib::enrichment::Table + Send + Sync>> = config
240        .enrichment_tables
241        .keys()
242        .map(|key| {
243            (
244                key.to_string(),
245                Box::new(StubEnrichmentTable)
246                    as Box<dyn vector_lib::enrichment::Table + Send + Sync>,
247            )
248        })
249        .collect();
250    if !stubs.is_empty() {
251        enrichment_tables.load(stubs);
252        // Do not call finish_load(): table_ids() and add_index() (used during
253        // VRL compilation) both operate on the loading stage. finish_load()
254        // would move tables to the ArcSwap and make table_ids() return nothing.
255    }
256    let mut definition_cache = HashMap::new();
257    let mut errors = Vec::new();
258
259    for (key, transform) in config.transforms() {
260        let input_definitions = topology::schema::input_definitions(
261            &transform.inputs,
262            config,
263            enrichment_tables.clone(),
264            &mut definition_cache,
265        )
266        .unwrap_or_default();
267
268        let merged_schema_definition = input_definitions
269            .iter()
270            .map(|(_, definition)| definition.clone())
271            .reduce(Definition::merge)
272            .unwrap_or_else(Definition::any);
273
274        let context = TransformContext {
275            key: Some(key.clone()),
276            globals: config.global.clone(),
277            enrichment_tables: enrichment_tables.clone(),
278            metrics_storage: MetricsStorage::default(),
279            merged_schema_definition,
280            schema: config.schema,
281            ..Default::default()
282        };
283
284        for err in transform
285            .inner
286            .validate(&context)
287            .err()
288            .into_iter()
289            .chain(transform.inner.validate_env(&context).err())
290            .flatten()
291        {
292            errors.push(format!("Transform \"{key}\": {err}"));
293        }
294    }
295
296    if errors.is_empty() {
297        fmt.success("Transforms configuration");
298        true
299    } else {
300        fmt.title("Transform errors");
301        fmt.sub_error(errors);
302        false
303    }
304}
305
306async fn validate_environment(opts: &Opts, config: &Config, fmt: &mut Formatter) -> bool {
307    let diff = ConfigDiff::initial(config);
308
309    let mut pieces = match validate_components(config, &diff, fmt).await {
310        Some(pieces) => pieces,
311        _ => {
312            return false;
313        }
314    };
315    opts.skip_healthchecks || validate_healthchecks(opts, config, &diff, &mut pieces, fmt).await
316}
317
318async fn validate_components(
319    config: &Config,
320    diff: &ConfigDiff,
321    fmt: &mut Formatter,
322) -> Option<TopologyPieces> {
323    match TopologyPiecesBuilder::new(config, diff).build().await {
324        Ok(pieces) => {
325            fmt.success("Component configuration");
326            Some(pieces)
327        }
328        Err(errors) => {
329            fmt.title("Component errors");
330            fmt.sub_error(errors);
331            None
332        }
333    }
334}
335
336async fn validate_healthchecks(
337    opts: &Opts,
338    config: &Config,
339    diff: &ConfigDiff,
340    pieces: &mut TopologyPieces,
341    fmt: &mut Formatter,
342) -> bool {
343    if !config.healthchecks.enabled {
344        fmt.warning("Health checks are disabled");
345        return !opts.deny_warnings;
346    }
347
348    let healthchecks = topology::take_healthchecks(diff, pieces);
349    // We are running health checks in serial so it's easier for the users
350    // to parse which errors/warnings/etc. belong to which healthcheck.
351    let mut validated = true;
352    for (id, healthcheck) in healthchecks {
353        let mut failed = |error| {
354            validated = false;
355            fmt.error(error);
356        };
357
358        trace!("Healthcheck for {id} starting.");
359        match tokio::spawn(healthcheck).await {
360            Ok(Ok(_)) => {
361                if config
362                    .sink(&id)
363                    .expect("Sink not present")
364                    .healthcheck()
365                    .enabled
366                {
367                    fmt.success(format!("Health check \"{id}\""));
368                } else {
369                    fmt.warning(format!("Health check disabled for \"{id}\""));
370                    validated &= !opts.deny_warnings;
371                }
372            }
373            Ok(Err(e)) => failed(format!("Health check for \"{id}\" failed: {e}")),
374            Err(error) if error.is_cancelled() => {
375                failed(format!("Health check for \"{id}\" was cancelled"))
376            }
377            Err(_) => failed(format!("Health check for \"{id}\" panicked")),
378        }
379        trace!("Healthcheck for {id} done.");
380    }
381
382    validated
383}
384
385/// For data directory that we write to:
386/// 1. Create a tmp directory in it.
387/// 2. Change config to point to that tmp directory.
388fn create_tmp_directory(config: &mut Config, fmt: &mut Formatter) -> Option<PathBuf> {
389    match config
390        .global
391        .resolve_and_make_data_subdir(None, TEMPORARY_DIRECTORY)
392    {
393        Ok(path) => {
394            config.global.data_dir = Some(path.clone());
395            Some(path)
396        }
397        Err(error) => {
398            fmt.error(error.to_string());
399            None
400        }
401    }
402}
403
404fn remove_tmp_directory(path: PathBuf) {
405    if let Err(error) = remove_dir_all(&path) {
406        error!(message = "Failed to remove temporary directory.", path = ?path, %error);
407    }
408}
409
410pub struct Formatter {
411    /// Width of largest printed line
412    max_line_width: usize,
413    /// Can empty line be printed
414    print_space: bool,
415    color: bool,
416    // Intros
417    error_intro: String,
418    warning_intro: String,
419    success_intro: String,
420}
421
422impl Formatter {
423    pub fn new(color: bool) -> Self {
424        Self {
425            max_line_width: 0,
426            print_space: false,
427            error_intro: if color {
428                "x".red().to_string()
429            } else {
430                "x".to_owned()
431            },
432            warning_intro: if color {
433                "~".yellow().to_string()
434            } else {
435                "~".to_owned()
436            },
437            success_intro: if color {
438                "√".green().to_string()
439            } else {
440                "√".to_owned()
441            },
442            color,
443        }
444    }
445
446    /// Final confirmation that validation process was successful.
447    fn validated(&self) {
448        #[allow(clippy::print_stdout)]
449        {
450            println!("{:-^width$}", "", width = self.max_line_width);
451        }
452        if self.color {
453            // Coloring needs to be used directly so that print
454            // infrastructure correctly determines length of the
455            // "Validated". Otherwise, ansi escape coloring is
456            // calculated into the length.
457            #[allow(clippy::print_stdout)]
458            {
459                println!(
460                    "{:>width$}",
461                    "Validated".green(),
462                    width = self.max_line_width
463                );
464            }
465        } else {
466            #[allow(clippy::print_stdout)]
467            {
468                println!("{:>width$}", "Validated", width = self.max_line_width)
469            }
470        }
471    }
472
473    /// Standalone line
474    fn success(&mut self, msg: impl AsRef<str>) {
475        self.print(format!("{} {}\n", self.success_intro, msg.as_ref()))
476    }
477
478    /// Standalone line
479    fn warning(&mut self, warning: impl AsRef<str>) {
480        self.print(format!("{} {}\n", self.warning_intro, warning.as_ref()))
481    }
482
483    /// Standalone line
484    fn error(&mut self, error: impl AsRef<str>) {
485        self.print(format!("{} {}\n", self.error_intro, error.as_ref()))
486    }
487
488    /// Marks sub
489    fn title(&mut self, title: impl AsRef<str>) {
490        self.space();
491        self.print(format!(
492            "{}\n{:-<width$}\n",
493            title.as_ref(),
494            "",
495            width = title.as_ref().len()
496        ))
497    }
498
499    /// A list of warnings that go with a title.
500    fn sub_warning<I: IntoIterator>(&mut self, warnings: I)
501    where
502        I::Item: fmt::Display,
503    {
504        self.sub(self.warning_intro.clone(), warnings)
505    }
506
507    /// A list of errors that go with a title.
508    fn sub_error<I: IntoIterator>(&mut self, errors: I)
509    where
510        I::Item: fmt::Display,
511    {
512        self.sub(self.error_intro.clone(), errors)
513    }
514
515    fn sub<I: IntoIterator>(&mut self, intro: impl AsRef<str>, msgs: I)
516    where
517        I::Item: fmt::Display,
518    {
519        for msg in msgs {
520            self.print(format!("{} {}\n", intro.as_ref(), msg));
521        }
522        self.space();
523    }
524
525    /// Prints empty space if necessary.
526    fn space(&mut self) {
527        if self.print_space {
528            self.print_space = false;
529            #[allow(clippy::print_stdout)]
530            {
531                println!();
532            }
533        }
534    }
535
536    fn print(&mut self, print: impl AsRef<str>) {
537        let width = print
538            .as_ref()
539            .lines()
540            .map(|line| {
541                String::from_utf8_lossy(&strip_ansi_escapes::strip(line))
542                    .chars()
543                    .count()
544            })
545            .max()
546            .unwrap_or(0);
547        self.max_line_width = width.max(self.max_line_width);
548        self.print_space = true;
549        #[allow(clippy::print_stdout)]
550        {
551            print!("{}", print.as_ref())
552        }
553    }
554}