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#[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 #[arg(long)]
73 pub no_environment: bool,
74
75 #[arg(long)]
77 pub skip_healthchecks: bool,
78
79 #[arg(short, long)]
82 pub deny_warnings: bool,
83
84 #[arg(
86 id = "config-toml",
87 long,
88 env = "VECTOR_CONFIG_TOML",
89 value_delimiter(',')
90 )]
91 pub paths_toml: Vec<PathBuf>,
92
93 #[arg(
95 id = "config-json",
96 long,
97 env = "VECTOR_CONFIG_JSON",
98 value_delimiter(',')
99 )]
100 pub paths_json: Vec<PathBuf>,
101
102 #[arg(
104 id = "config-yaml",
105 long,
106 env = "VECTOR_CONFIG_YAML",
107 value_delimiter(',')
108 )]
109 pub paths_yaml: Vec<PathBuf>,
110
111 #[arg(env = "VECTOR_CONFIG", value_delimiter(','))]
116 pub paths: Vec<PathBuf>,
117
118 #[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 #[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
159pub 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 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 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 let (config, warnings) = builder
214 .build_with_warnings()
215 .map_err(&mut report_error)
216 .ok()?;
217
218 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 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 }
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 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
385fn 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 max_line_width: usize,
413 print_space: bool,
415 color: bool,
416 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 fn validated(&self) {
448 #[allow(clippy::print_stdout)]
449 {
450 println!("{:-^width$}", "", width = self.max_line_width);
451 }
452 if self.color {
453 #[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 fn success(&mut self, msg: impl AsRef<str>) {
475 self.print(format!("{} {}\n", self.success_intro, msg.as_ref()))
476 }
477
478 fn warning(&mut self, warning: impl AsRef<str>) {
480 self.print(format!("{} {}\n", self.warning_intro, warning.as_ref()))
481 }
482
483 fn error(&mut self, error: impl AsRef<str>) {
485 self.print(format!("{} {}\n", self.error_intro, error.as_ref()))
486 }
487
488 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 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 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 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}