Skip to content

Commit 9cb0ca3

Browse files
prontbruceg
andauthored
feat: add convert config command (vectordotdev#18378)
* feat: add convert config command * more and better tests * add defensive checks and improve messages * improve sub-command description * Update src/cli.rs Co-authored-by: Bruce Guenter <[email protected]> * Update src/config/format.rs Co-authored-by: Bruce Guenter <[email protected]> * Update src/convert_config.rs Co-authored-by: Bruce Guenter <[email protected]> * fix format * format should remain an option --------- Co-authored-by: Bruce Guenter <[email protected]>
1 parent 567de50 commit 9cb0ca3

File tree

8 files changed

+447
-2
lines changed

8 files changed

+447
-2
lines changed

src/cli.rs

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::service;
99
use crate::tap;
1010
#[cfg(feature = "api-client")]
1111
use crate::top;
12-
use crate::{config, generate, get_version, graph, list, unit_test, validate};
12+
use crate::{config, convert_config, generate, get_version, graph, list, unit_test, validate};
1313
use crate::{generate_schema, signal};
1414

1515
#[derive(Parser, Debug)]
@@ -34,6 +34,7 @@ impl Opts {
3434
Some(SubCommand::Validate(_))
3535
| Some(SubCommand::Graph(_))
3636
| Some(SubCommand::Generate(_))
37+
| Some(SubCommand::ConvertConfig(_))
3738
| Some(SubCommand::List(_))
3839
| Some(SubCommand::Test(_)) => {
3940
if self.root.verbose == 0 {
@@ -241,6 +242,14 @@ pub enum SubCommand {
241242
/// Validate the target config, then exit.
242243
Validate(validate::Opts),
243244

245+
/// Convert a config file from one format to another.
246+
/// This command can also walk directories recursively and convert all config files that are discovered.
247+
/// Note that this is a best effort conversion due to the following reasons:
248+
/// * The comments from the original config file are not preserved.
249+
/// * Explicitly set default values in the original implementation might be omitted.
250+
/// * Depending on how each source/sink config struct configures serde, there might be entries with null values.
251+
ConvertConfig(convert_config::Opts),
252+
244253
/// Generate a Vector configuration containing a list of components.
245254
Generate(generate::Opts),
246255

@@ -290,6 +299,7 @@ impl SubCommand {
290299
) -> exitcode::ExitCode {
291300
match self {
292301
Self::Config(c) => config::cmd(c),
302+
Self::ConvertConfig(opts) => convert_config::cmd(opts),
293303
Self::Generate(g) => generate::cmd(g),
294304
Self::GenerateSchema => generate_schema::cmd(),
295305
Self::Graph(g) => graph::cmd(g),

src/config/format.rs

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
#![deny(missing_docs, missing_debug_implementations)]
44

5+
use std::fmt;
56
use std::path::Path;
67
use std::str::FromStr;
78

@@ -35,6 +36,17 @@ impl FromStr for Format {
3536
}
3637
}
3738

39+
impl fmt::Display for Format {
40+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
41+
let format = match self {
42+
Format::Toml => "toml",
43+
Format::Json => "json",
44+
Format::Yaml => "yaml",
45+
};
46+
write!(f, "{}", format)
47+
}
48+
}
49+
3850
impl Format {
3951
/// Obtain the format from the file path using extension as a hint.
4052
pub fn from_path<T: AsRef<Path>>(path: T) -> Result<Self, T> {

src/config/mod.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::{
99
};
1010

1111
use indexmap::IndexMap;
12+
use serde::Serialize;
1213
pub use vector_config::component::{GenerateConfig, SinkDescription, TransformDescription};
1314
use vector_config::configurable_component;
1415
pub use vector_core::config::{
@@ -100,7 +101,7 @@ impl ConfigPath {
100101
}
101102
}
102103

103-
#[derive(Debug, Default)]
104+
#[derive(Debug, Default, Serialize)]
104105
pub struct Config {
105106
#[cfg(feature = "api")]
106107
pub api: api::Options,

src/convert_config.rs

+291
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
use crate::config::{format, ConfigBuilder, Format};
2+
use clap::Parser;
3+
use colored::*;
4+
use std::fs;
5+
use std::path::{Path, PathBuf};
6+
use std::str::FromStr;
7+
8+
#[derive(Parser, Debug)]
9+
#[command(rename_all = "kebab-case")]
10+
pub struct Opts {
11+
/// The input path. It can be a single file or a directory. If this points to a directory,
12+
/// all files with a "toml", "yaml" or "json" extension will be converted.
13+
pub(crate) input_path: PathBuf,
14+
15+
/// The output file or directory to be created. This command will fail if the output directory exists.
16+
pub(crate) output_path: PathBuf,
17+
18+
/// The target format to which existing config files will be converted to.
19+
#[arg(long, default_value = "yaml")]
20+
pub(crate) output_format: Format,
21+
}
22+
23+
fn check_paths(opts: &Opts) -> Result<(), String> {
24+
let in_metadata = fs::metadata(&opts.input_path)
25+
.unwrap_or_else(|_| panic!("Failed to get metadata for: {:?}", &opts.input_path));
26+
27+
if opts.output_path.exists() {
28+
return Err(format!(
29+
"Output path {:?} already exists. Please provide a non-existing output path.",
30+
opts.output_path
31+
));
32+
}
33+
34+
if opts.output_path.extension().is_none() {
35+
if in_metadata.is_file() {
36+
return Err(format!(
37+
"{:?} points to a file but {:?} points to a directory.",
38+
opts.input_path, opts.output_path
39+
));
40+
}
41+
} else if in_metadata.is_dir() {
42+
return Err(format!(
43+
"{:?} points to a directory but {:?} points to a file.",
44+
opts.input_path, opts.output_path
45+
));
46+
}
47+
48+
Ok(())
49+
}
50+
51+
pub(crate) fn cmd(opts: &Opts) -> exitcode::ExitCode {
52+
if let Err(e) = check_paths(opts) {
53+
#[allow(clippy::print_stderr)]
54+
{
55+
eprintln!("{}", e.red());
56+
}
57+
return exitcode::SOFTWARE;
58+
}
59+
60+
return if opts.input_path.is_file() && opts.output_path.extension().is_some() {
61+
if let Some(base_dir) = opts.output_path.parent() {
62+
if !base_dir.exists() {
63+
fs::create_dir_all(base_dir).unwrap_or_else(|_| {
64+
panic!("Failed to create output dir(s): {:?}", &opts.output_path)
65+
});
66+
}
67+
}
68+
69+
match convert_config(&opts.input_path, &opts.output_path, opts.output_format) {
70+
Ok(_) => exitcode::OK,
71+
Err(errors) => {
72+
#[allow(clippy::print_stderr)]
73+
{
74+
errors.iter().for_each(|e| eprintln!("{}", e.red()));
75+
}
76+
exitcode::SOFTWARE
77+
}
78+
}
79+
} else {
80+
match walk_dir_and_convert(&opts.input_path, &opts.output_path, opts.output_format) {
81+
Ok(()) => {
82+
#[allow(clippy::print_stdout)]
83+
{
84+
println!(
85+
"Finished conversion(s). Results are in {:?}",
86+
opts.output_path
87+
);
88+
}
89+
exitcode::OK
90+
}
91+
Err(errors) => {
92+
#[allow(clippy::print_stderr)]
93+
{
94+
errors.iter().for_each(|e| eprintln!("{}", e.red()));
95+
}
96+
exitcode::SOFTWARE
97+
}
98+
}
99+
};
100+
}
101+
102+
fn convert_config(
103+
input_path: &Path,
104+
output_path: &Path,
105+
output_format: Format,
106+
) -> Result<(), Vec<String>> {
107+
if output_path.exists() {
108+
return Err(vec![format!("Output path {output_path:?} exists")]);
109+
}
110+
let input_format = match Format::from_str(
111+
input_path
112+
.extension()
113+
.unwrap_or_else(|| panic!("Failed to get extension for: {input_path:?}"))
114+
.to_str()
115+
.unwrap_or_else(|| panic!("Failed to convert OsStr to &str for: {input_path:?}")),
116+
) {
117+
Ok(format) => format,
118+
Err(_) => return Ok(()), // skip irrelevant files
119+
};
120+
121+
if input_format == output_format {
122+
return Ok(());
123+
}
124+
125+
#[allow(clippy::print_stdout)]
126+
{
127+
println!("Converting {input_path:?} config to {output_format:?}.");
128+
}
129+
let file_contents = fs::read_to_string(input_path).map_err(|e| vec![e.to_string()])?;
130+
let builder: ConfigBuilder = format::deserialize(&file_contents, input_format)?;
131+
let config = builder.build()?;
132+
let output_string =
133+
format::serialize(&config, output_format).map_err(|e| vec![e.to_string()])?;
134+
fs::write(output_path, output_string).map_err(|e| vec![e.to_string()])?;
135+
136+
#[allow(clippy::print_stdout)]
137+
{
138+
println!("Wrote result to {output_path:?}.");
139+
}
140+
Ok(())
141+
}
142+
143+
fn walk_dir_and_convert(
144+
input_path: &Path,
145+
output_dir: &Path,
146+
output_format: Format,
147+
) -> Result<(), Vec<String>> {
148+
let mut errors = Vec::new();
149+
150+
if input_path.is_dir() {
151+
for entry in fs::read_dir(input_path)
152+
.unwrap_or_else(|_| panic!("Failed to read dir: {input_path:?}"))
153+
{
154+
let entry_path = entry
155+
.unwrap_or_else(|_| panic!("Failed to get entry for dir: {input_path:?}"))
156+
.path();
157+
let new_output_dir = if entry_path.is_dir() {
158+
let last_component = entry_path
159+
.file_name()
160+
.unwrap_or_else(|| panic!("Failed to get file_name for {entry_path:?}"))
161+
.clone();
162+
let new_dir = output_dir.join(last_component);
163+
164+
if !new_dir.exists() {
165+
fs::create_dir_all(&new_dir)
166+
.unwrap_or_else(|_| panic!("Failed to create output dir: {new_dir:?}"));
167+
}
168+
new_dir
169+
} else {
170+
output_dir.to_path_buf()
171+
};
172+
173+
if let Err(new_errors) = walk_dir_and_convert(
174+
&input_path.join(&entry_path),
175+
&new_output_dir,
176+
output_format,
177+
) {
178+
errors.extend(new_errors);
179+
}
180+
}
181+
} else {
182+
let output_path = output_dir.join(
183+
input_path
184+
.with_extension(output_format.to_string().as_str())
185+
.file_name()
186+
.ok_or_else(|| {
187+
vec![format!(
188+
"Cannot create output path for input: {input_path:?}"
189+
)]
190+
})?,
191+
);
192+
if let Err(new_errors) = convert_config(input_path, &output_path, output_format) {
193+
errors.extend(new_errors);
194+
}
195+
}
196+
197+
if errors.is_empty() {
198+
Ok(())
199+
} else {
200+
Err(errors)
201+
}
202+
}
203+
204+
#[cfg(test)]
205+
mod tests {
206+
use crate::config::{format, ConfigBuilder, Format};
207+
use crate::convert_config::{check_paths, walk_dir_and_convert, Opts};
208+
use std::path::{Path, PathBuf};
209+
use std::str::FromStr;
210+
use std::{env, fs};
211+
use tempfile::tempdir;
212+
213+
fn test_data_dir() -> PathBuf {
214+
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("tests/data/cmd/config")
215+
}
216+
217+
// Read the contents of the specified `path` and deserialize them into a `ConfigBuilder`.
218+
// Finally serialize them a string again. Configs do not implement equality,
219+
// so for these tests we will rely on strings for comparisons.
220+
fn convert_file_to_config_string(path: &Path) -> String {
221+
let files_contents = fs::read_to_string(path).unwrap();
222+
let extension = path.extension().unwrap().to_str().unwrap();
223+
let file_format = Format::from_str(extension).unwrap();
224+
let builder: ConfigBuilder = format::deserialize(&files_contents, file_format).unwrap();
225+
let config = builder.build().unwrap();
226+
227+
format::serialize(&config, file_format).unwrap()
228+
}
229+
230+
#[test]
231+
fn invalid_path_opts() {
232+
let check_error = |opts, pattern| {
233+
let error = check_paths(&opts).unwrap_err();
234+
assert!(error.contains(pattern));
235+
};
236+
237+
check_error(
238+
Opts {
239+
input_path: ["./"].iter().collect(),
240+
output_path: ["./"].iter().collect(),
241+
output_format: Format::Yaml,
242+
},
243+
"already exists",
244+
);
245+
246+
check_error(
247+
Opts {
248+
input_path: ["./"].iter().collect(),
249+
output_path: ["./out.yaml"].iter().collect(),
250+
output_format: Format::Yaml,
251+
},
252+
"points to a file.",
253+
);
254+
255+
check_error(
256+
Opts {
257+
input_path: [test_data_dir(), "config_2.toml".into()].iter().collect(),
258+
output_path: ["./another_dir"].iter().collect(),
259+
output_format: Format::Yaml,
260+
},
261+
"points to a directory.",
262+
);
263+
}
264+
265+
#[test]
266+
fn convert_all_from_dir() {
267+
let input_path = test_data_dir();
268+
let output_dir = tempdir()
269+
.expect("Unable to create tempdir for config")
270+
.into_path();
271+
walk_dir_and_convert(&input_path, &output_dir, Format::Yaml).unwrap();
272+
273+
let mut count: usize = 0;
274+
let original_config = convert_file_to_config_string(&test_data_dir().join("config_1.yaml"));
275+
for entry in fs::read_dir(&output_dir).unwrap() {
276+
let entry = entry.unwrap();
277+
let path = entry.path();
278+
if path.is_file() {
279+
let extension = path.extension().unwrap().to_str().unwrap();
280+
if extension == Format::Yaml.to_string() {
281+
// Note that here we read the converted string directly.
282+
let converted_config = fs::read_to_string(&output_dir.join(&path)).unwrap();
283+
assert_eq!(converted_config, original_config);
284+
count += 1;
285+
}
286+
}
287+
}
288+
// There two non-yaml configs in the input directory.
289+
assert_eq!(count, 2);
290+
}
291+
}

src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ pub mod aws;
6767
#[allow(unreachable_pub)]
6868
pub mod codecs;
6969
pub(crate) mod common;
70+
mod convert_config;
7071
pub mod encoding_transcode;
7172
pub mod enrichment_tables;
7273
#[cfg(feature = "gcp")]

0 commit comments

Comments
 (0)