vdev/commands/build/component_docs/
runner.rs1use super::schema::SchemaContext;
2use anyhow::{Context, Result, bail};
3use indexmap::IndexMap;
4use serde_json::{Value, json};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9pub fn run(schema_path: &Path) -> Result<()> {
10 let schema_content = fs::read_to_string(schema_path)
11 .with_context(|| format!("Failed to read schema file from {}", schema_path.display()))?;
12
13 let root_schema: Value =
14 serde_json::from_str(&schema_content).with_context(|| "Failed to parse schema JSON")?;
15
16 let mut context = SchemaContext::new(root_schema.clone())?;
17
18 let component_types = ["source", "transform", "sink"];
19
20 let mut component_bases: IndexMap<String, String> = IndexMap::new();
22 if let Some(definitions) = root_schema.get("definitions").and_then(|d| d.as_object()) {
23 for (key, definition) in definitions {
24 if let Some(base_type) =
25 super::schema::get_schema_metadata(definition, "docs::component_base_type")
26 .and_then(|v| v.as_str())
27 && component_types.contains(&base_type)
28 {
29 component_bases.insert(base_type.to_string(), key.clone());
30 }
31 }
32 }
33 component_bases.sort_keys();
34
35 for (comp_type, schema_name) in &component_bases {
36 render_and_import_generated_component_schema(&mut context, schema_name, comp_type)?;
37 }
38
39 let mut all_components: IndexMap<String, IndexMap<String, String>> = IndexMap::new();
41 if let Some(definitions) = root_schema.get("definitions").and_then(|d| d.as_object()) {
42 for (key, definition) in definitions {
43 let comp_type = super::schema::get_schema_metadata(definition, "docs::component_type")
44 .and_then(|v| v.as_str());
45 let comp_name = super::schema::get_schema_metadata(definition, "docs::component_name")
46 .and_then(|v| v.as_str());
47
48 if let (Some(t), Some(n)) = (comp_type, comp_name)
49 && component_types.contains(&t)
50 {
51 all_components
52 .entry(t.to_string())
53 .or_default()
54 .insert(n.to_string(), key.clone());
55 }
56 }
57 }
58 all_components.sort_keys();
59 for (_, components) in &mut all_components {
60 components.sort_keys();
61 }
62
63 for (comp_type, components) in &all_components {
64 for (comp_name, schema_name) in components {
65 render_and_import_component_schema(&mut context, schema_name, comp_type, comp_name)?;
66 }
67 }
68
69 render_and_import_generated_top_level_config_schema(&mut context, &root_schema)?;
73
74 Ok(())
75}
76
77fn write_to_temp_file(prefix: &str, suffix: &str, content: &str) -> Result<PathBuf> {
78 use std::io::Write;
79 let mut tmp = tempfile::Builder::new()
80 .prefix(prefix)
81 .suffix(suffix)
82 .tempfile()?;
83 tmp.write_all(content.as_bytes())?;
84 let path = tmp.into_temp_path().keep()?;
85 Ok(path)
86}
87
88fn render_and_import_schema(
89 context: &mut SchemaContext,
90 unwrapped_resolved_schema: Value,
91 friendly_name: &str,
92 config_map_path: &[&str],
93 cue_relative_path: &str,
94) -> Result<()> {
95 let mut data = serde_json::Map::new();
96 let mut current_obj = &mut data;
100 for segment in config_map_path {
101 current_obj.insert(
102 (*segment).to_string(),
103 Value::Object(serde_json::Map::new()),
104 );
105 current_obj = current_obj
106 .get_mut(*segment)
107 .unwrap()
108 .as_object_mut()
109 .unwrap();
110 }
111 current_obj.insert("configuration".to_string(), unwrapped_resolved_schema);
112
113 let mut prefix = String::from("config-schema-base-");
114 prefix.push_str(&config_map_path.join("-"));
115 prefix.push('-');
116
117 let final_json = serde_json::to_string_pretty(&data)?;
118 let json_output_file = write_to_temp_file(&prefix, ".json", &final_json)?;
119
120 info!(
121 "[✓] Wrote {} schema to '{}'. ({} bytes)",
122 friendly_name,
123 json_output_file.display(),
124 final_json.len()
125 );
126
127 info!("[*] Importing {} schema as Cue file...", friendly_name);
128 let cue_output_file = PathBuf::from("website/cue/reference").join(cue_relative_path);
129
130 if let Some(parent) = cue_output_file.parent() {
131 fs::create_dir_all(parent)?;
132 }
133
134 let status = Command::new(&context.cue_binary_path)
135 .args([
136 "import",
137 "-f",
138 "-o",
139 cue_output_file.to_str().unwrap(),
140 "-p",
141 "metadata",
142 json_output_file.to_str().unwrap(),
143 ])
144 .status()?;
145
146 if !status.success() {
147 bail!(
148 "Failed to import {friendly_name} schema as valid Cue (cue exit status {status}). JSON written to {json_path}.",
149 json_path = json_output_file.display()
150 );
151 }
152
153 info!(
154 "[✓] Imported {} schema to '{}'.",
155 friendly_name,
156 cue_output_file.display()
157 );
158 Ok(())
159}
160
161fn render_and_import_generated_component_schema(
162 context: &mut SchemaContext,
163 schema_name: &str,
164 component_type: &str,
165) -> Result<()> {
166 let friendly_name = format!("generated {component_type} configuration");
167 let unwrapped = context.unwrap_resolved_schema(schema_name, &friendly_name)?;
168 let cue_path = format!("components/generated/{component_type}s.cue");
169
170 render_and_import_schema(
171 context,
172 Value::Object(unwrapped),
173 &friendly_name,
174 &["generated", "components", &format!("{component_type}s")],
175 &cue_path,
176 )
177}
178
179fn render_and_import_component_schema(
180 context: &mut SchemaContext,
181 schema_name: &str,
182 component_type: &str,
183 component_name: &str,
184) -> Result<()> {
185 let friendly_name = format!("'{component_name}' {component_type} configuration");
186 let unwrapped = context.unwrap_resolved_schema(schema_name, &friendly_name)?;
187 let cue_path = format!("components/{component_type}s/generated/{component_name}.cue");
188
189 render_and_import_schema(
190 context,
191 Value::Object(unwrapped),
192 &friendly_name,
193 &[
194 "generated",
195 "components",
196 &format!("{component_type}s"),
197 component_name,
198 ],
199 &cue_path,
200 )
201}
202
203const TOP_LEVEL_FIELD_GROUPS: &[(&str, &str)] = &[
205 ("sources", "pipeline_components"),
206 ("transforms", "pipeline_components"),
207 ("sinks", "pipeline_components"),
208 ("enrichment_tables", "pipeline_components"),
209 ("api", "api"),
210 ("schema", "schema"),
211 ("log_schema", "schema"),
212 ("secret", "secrets"),
213];
214
215fn top_level_group_metadata() -> Value {
216 json!({
217 "global_options": {
218 "title": "Global Options",
219 "description": "Global configuration options that apply to Vector as a whole.",
220 "order": 1,
221 },
222 "pipeline_components": {
223 "title": "Pipeline Components",
224 "description": "Configure sources, transforms, sinks, and enrichment tables for your observability pipeline.",
225 "order": 2,
226 },
227 "api": {
228 "title": "API",
229 "description": "Configure Vector's observability API.",
230 "order": 3,
231 },
232 "schema": {
233 "title": "Schema",
234 "description": "Configure Vector's internal schema system for type tracking and validation.",
235 "order": 4,
236 },
237 "secrets": {
238 "title": "Secrets",
239 "description": "Configure secrets management for secure configuration.",
240 "order": 5,
241 },
242 })
243}
244
245fn resolve_top_level_config_fields(
246 context: &mut SchemaContext,
247 root_schema: &Value,
248) -> Result<serde_json::Map<String, Value>> {
249 let all_of = root_schema
252 .get("allOf")
253 .and_then(Value::as_array)
254 .ok_or_else(|| anyhow::anyhow!("Could not find ConfigBuilder allOf schemas"))?;
255 if all_of.is_empty() {
256 anyhow::bail!("ConfigBuilder allOf schemas are empty");
257 }
258
259 let mut all_properties: IndexMap<String, Value> = IndexMap::new();
260 for subschema in all_of {
261 if let Some(props) = subschema.get("properties").and_then(Value::as_object) {
262 for (k, v) in props {
263 all_properties.insert(k.clone(), v.clone());
264 }
265 }
266 }
267
268 let mut resolved_fields = serde_json::Map::new();
269 for (field_name, field_schema) in all_properties {
270 if super::schema::get_schema_metadata(&field_schema, "docs::hidden").is_some() {
271 debug!("Skipping '{field_name}' (marked as docs::hidden)");
272 continue;
273 }
274
275 let mut resolved = context.resolve_schema(&field_schema)?;
276 if !resolved.is_object() {
277 continue;
278 }
279
280 let group = TOP_LEVEL_FIELD_GROUPS
281 .iter()
282 .find(|(name, _)| *name == field_name)
283 .map_or("global_options", |(_, g)| *g);
284
285 resolved
286 .as_object_mut()
287 .unwrap()
288 .insert("group".to_string(), Value::String(group.to_string()));
289
290 resolved_fields.insert(field_name, resolved);
291 }
292 Ok(resolved_fields)
293}
294
295fn render_and_import_generated_top_level_config_schema(
296 context: &mut SchemaContext,
297 root_schema: &Value,
298) -> Result<()> {
299 let resolved_fields = resolve_top_level_config_fields(context, root_schema)?;
300
301 let data = json!({
302 "generated": {
303 "configuration": {
304 "configuration": Value::Object(resolved_fields),
305 "groups": top_level_group_metadata(),
306 }
307 }
308 });
309
310 let friendly_name = "configuration";
311 let prefix = "config-schema-base-generated-configuration-";
312
313 let final_json = serde_json::to_string_pretty(&data)?;
314 let json_output_file = write_to_temp_file(prefix, ".json", &final_json)?;
315
316 info!(
317 "[✓] Wrote {} schema to '{}'. ({} bytes)",
318 friendly_name,
319 json_output_file.display(),
320 final_json.len()
321 );
322
323 info!("[*] Importing {} schema as Cue file...", friendly_name);
324 let cue_output_file =
325 PathBuf::from("website/cue/reference").join("generated/configuration.cue");
326
327 if let Some(parent) = cue_output_file.parent() {
328 fs::create_dir_all(parent)?;
329 }
330
331 let status = Command::new(&context.cue_binary_path)
332 .args([
333 "import",
334 "-f",
335 "-o",
336 cue_output_file.to_str().unwrap(),
337 "-p",
338 "metadata",
339 json_output_file.to_str().unwrap(),
340 ])
341 .status()?;
342
343 if !status.success() {
344 bail!(
345 "Failed to import {friendly_name} schema as valid Cue (cue exit status {status}). JSON written to {json_path}.",
346 json_path = json_output_file.display()
347 );
348 }
349
350 info!(
351 "[✓] Imported {} schema to '{}'.",
352 friendly_name,
353 cue_output_file.display()
354 );
355 Ok(())
356}