1use crate::{imports::TypeReference, ParserError};
4use amalgam_core::{
5 ir::{Module, TypeDefinition},
6 types::{Field, Type},
7};
8use indicatif::{ProgressBar, ProgressStyle};
9use reqwest;
10use serde_json::Value;
11use std::collections::{BTreeMap, HashMap};
12use std::time::Duration;
13
14pub struct K8sTypesFetcher {
16 client: reqwest::Client,
17}
18
19impl Default for K8sTypesFetcher {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25impl K8sTypesFetcher {
26 pub fn new() -> Self {
27 Self {
28 client: reqwest::Client::builder()
29 .timeout(Duration::from_secs(60))
30 .user_agent("amalgam")
31 .build()
32 .unwrap(),
33 }
34 }
35
36 pub async fn fetch_k8s_openapi(&self, version: &str) -> Result<Value, ParserError> {
38 let is_tty = atty::is(atty::Stream::Stdout);
39
40 let pb = if is_tty {
41 let pb = ProgressBar::new_spinner();
42 pb.set_style(
43 ProgressStyle::default_spinner()
44 .template("{spinner:.cyan} {msg}")
45 .unwrap()
46 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
47 );
48 pb.enable_steady_tick(Duration::from_millis(100));
49 pb.set_message(format!("Fetching Kubernetes {} OpenAPI schema...", version));
50 Some(pb)
51 } else {
52 println!("Fetching Kubernetes {} OpenAPI schema...", version);
53 None
54 };
55
56 let url = format!(
58 "https://raw.githubusercontent.com/kubernetes/kubernetes/{}/api/openapi-spec/swagger.json",
59 version
60 );
61
62 let response = self
63 .client
64 .get(&url)
65 .send()
66 .await
67 .map_err(|e| ParserError::Network(e.to_string()))?;
68
69 if !response.status().is_success() {
70 if let Some(pb) = pb {
71 pb.finish_with_message(format!(
72 "✗ Failed to fetch k8s OpenAPI: {}",
73 response.status()
74 ));
75 }
76 return Err(ParserError::Network(format!(
77 "Failed to fetch k8s OpenAPI: {}",
78 response.status()
79 )));
80 }
81
82 if let Some(ref pb) = pb {
83 pb.set_message("Parsing OpenAPI schema...");
84 }
85
86 let schema: Value = response
87 .json()
88 .await
89 .map_err(|e| ParserError::Parse(e.to_string()))?;
90
91 if let Some(pb) = pb {
92 pb.finish_with_message(format!("✓ Fetched Kubernetes {} OpenAPI schema", version));
93 } else {
94 println!("Successfully fetched Kubernetes {} OpenAPI schema", version);
95 }
96
97 Ok(schema)
98 }
99
100 pub fn extract_core_types(
102 &self,
103 openapi: &Value,
104 ) -> Result<HashMap<TypeReference, TypeDefinition>, ParserError> {
105 let mut types = HashMap::new();
106
107 let target_namespaces = [
109 "io.k8s.apimachinery.pkg.apis.meta.v1", "io.k8s.api.core.v1", "io.k8s.api.apps.v1", "io.k8s.api.batch.v1", "io.k8s.api.networking.v1", "io.k8s.api.rbac.v1", "io.k8s.api.storage.v1", "io.k8s.api.autoscaling.v1", "io.k8s.api.policy.v1", "io.k8s.apimachinery.pkg.api.resource", ];
120
121 if let Some(definitions) = openapi.get("definitions").and_then(|d| d.as_object()) {
122 for (full_name, schema) in definitions {
124 let should_include = target_namespaces
126 .iter()
127 .any(|&namespace| full_name.starts_with(namespace));
128
129 if should_include {
130 let short_name = full_name
132 .split('.')
133 .next_back()
134 .unwrap_or(full_name.as_str())
135 .to_string();
136
137 match self.parse_type_reference(full_name) {
139 Ok(type_ref) => {
140 match self.schema_to_type_definition(&short_name, schema) {
141 Ok(type_def) => {
142 types.insert(type_ref, type_def);
143 }
144 Err(e) => {
145 tracing::debug!("Failed to parse type {}: {}", full_name, e);
147 }
148 }
149 }
150 Err(e) => {
151 tracing::debug!("Failed to parse reference {}: {}", full_name, e);
152 }
153 }
154 }
155 }
156 }
157
158 tracing::info!("Extracted {} k8s types from OpenAPI schema", types.len());
159 Ok(types)
160 }
161
162 fn parse_type_reference(&self, full_name: &str) -> Result<TypeReference, ParserError> {
163 let parts: Vec<&str> = full_name.split('.').collect();
165
166 if parts.len() < 5 || parts[0] != "io" || parts[1] != "k8s" {
167 return Err(ParserError::Parse(format!(
168 "Invalid k8s type name: {}",
169 full_name
170 )));
171 }
172
173 let group = if parts[3] == "core" || parts[2] == "apimachinery" {
174 "k8s.io".to_string() } else {
176 format!("{}.k8s.io", parts[3])
177 };
178
179 let version = parts[parts.len() - 2].to_string();
180 let kind = parts.last().unwrap().to_string();
181
182 Ok(TypeReference::new(group, version, kind))
183 }
184
185 fn schema_to_type_definition(
186 &self,
187 name: &str,
188 schema: &Value,
189 ) -> Result<TypeDefinition, ParserError> {
190 let ty = self.json_schema_to_type(schema)?;
191
192 Ok(TypeDefinition {
193 name: name.to_string(),
194 ty,
195 documentation: schema
196 .get("description")
197 .and_then(|d| d.as_str())
198 .map(String::from),
199 annotations: BTreeMap::new(),
200 })
201 }
202
203 #[allow(clippy::only_used_in_recursion)]
204 fn json_schema_to_type(&self, schema: &Value) -> Result<Type, ParserError> {
205 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
207 let type_name = ref_path.trim_start_matches("#/definitions/");
208
209 return Ok(match type_name {
211 name if name.ends_with(".Time") || name.ends_with(".MicroTime") => Type::String,
212 name if name.ends_with(".Duration") => Type::String,
213 name if name.ends_with(".IntOrString") => {
214 Type::Union(vec![Type::Integer, Type::String])
215 }
216 name if name.ends_with(".Quantity") => Type::String,
217 name if name.ends_with(".FieldsV1") => Type::Any,
218 name if name.starts_with("io.k8s.") => {
219 let short_name = name.split('.').next_back().unwrap_or(name);
221 Type::Reference(short_name.to_string())
222 }
223 _ => Type::Reference(type_name.to_string()),
224 });
225 }
226
227 let schema_type = schema.get("type").and_then(|v| v.as_str());
228
229 match schema_type {
230 Some("string") => Ok(Type::String),
231 Some("number") => Ok(Type::Number),
232 Some("integer") => Ok(Type::Integer),
233 Some("boolean") => Ok(Type::Bool),
234 Some("array") => {
235 let items = schema
236 .get("items")
237 .map(|i| self.json_schema_to_type(i))
238 .transpose()?
239 .unwrap_or(Type::Any);
240 Ok(Type::Array(Box::new(items)))
241 }
242 Some("object") => {
243 let mut fields = BTreeMap::new();
244
245 if let Some(Value::Object(props)) = schema.get("properties") {
246 let required = schema
247 .get("required")
248 .and_then(|r| r.as_array())
249 .map(|arr| {
250 arr.iter()
251 .filter_map(|v| v.as_str())
252 .map(String::from)
253 .collect::<Vec<_>>()
254 })
255 .unwrap_or_default();
256
257 for (field_name, field_schema) in props {
258 if let Some(ref_path) = field_schema.get("$ref").and_then(|r| r.as_str()) {
260 let type_name = ref_path.trim_start_matches("#/definitions/");
262
263 let resolved_type = match type_name {
265 name if name.ends_with(".Time") || name.ends_with(".MicroTime") => {
267 Type::String
268 }
269 name if name.ends_with(".Duration") => Type::String,
271 name if name.ends_with(".IntOrString") => {
273 Type::Union(vec![Type::Integer, Type::String])
274 }
275 name if name.ends_with(".Quantity")
277 || name == "io.k8s.apimachinery.pkg.api.resource.Quantity" =>
278 {
279 Type::String
280 }
281 name if name.ends_with(".FieldsV1") => Type::Any,
283 name if name.starts_with("io.k8s.") => {
286 let short_name = name.split('.').next_back().unwrap_or(name);
288 Type::Reference(short_name.to_string())
289 }
290 _ => Type::Reference(type_name.to_string()),
292 };
293
294 fields.insert(
295 field_name.clone(),
296 Field {
297 ty: resolved_type,
298 required: required.contains(field_name),
299 description: field_schema
300 .get("description")
301 .and_then(|d| d.as_str())
302 .map(String::from),
303 default: None,
304 },
305 );
306 } else {
307 if field_schema.get("type").is_none()
309 && field_schema.get("$ref").is_none()
310 {
311 if let Value::String(type_str) = field_schema {
313 let resolved_type = match type_str.as_str() {
315 s if s.ends_with(".Time") || s.ends_with(".MicroTime") => {
317 Type::String
318 }
319 s if s.ends_with(".Duration") => Type::String,
320 s if s.ends_with(".IntOrString") => {
321 Type::Union(vec![Type::Integer, Type::String])
322 }
323 s if s.ends_with(".Quantity") => Type::String,
324 s if s.ends_with(".FieldsV1") => Type::Any,
325 s if s.starts_with("io.k8s.") => {
326 let short_name = s.split('.').next_back().unwrap_or(s);
328 Type::Reference(short_name.to_string())
329 }
330 _ => Type::Reference(type_str.clone()),
331 };
332
333 fields.insert(
334 field_name.clone(),
335 Field {
336 ty: resolved_type,
337 required: required.contains(field_name),
338 description: None,
339 default: None,
340 },
341 );
342 continue;
343 }
344 }
345
346 let field_type = self.json_schema_to_type(field_schema)?;
347 fields.insert(
348 field_name.clone(),
349 Field {
350 ty: field_type,
351 required: required.contains(field_name),
352 description: field_schema
353 .get("description")
354 .and_then(|d| d.as_str())
355 .map(String::from),
356 default: field_schema.get("default").cloned(),
357 },
358 );
359 }
360 }
361 }
362
363 let open = schema
364 .get("additionalProperties")
365 .map(|v| !matches!(v, Value::Bool(false)))
366 .unwrap_or(false);
367
368 Ok(Type::Record { fields, open })
369 }
370 _ => {
371 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
373 let type_name = ref_path.trim_start_matches("#/definitions/");
374 Ok(Type::Reference(type_name.to_string()))
375 } else {
376 Ok(Type::Any)
377 }
378 }
379 }
380 }
381}
382
383pub fn generate_k8s_package() -> Module {
385 let mut module = Module {
386 name: "k8s.io".to_string(),
387 imports: Vec::new(),
388 types: Vec::new(),
389 constants: Vec::new(),
390 metadata: Default::default(),
391 };
392
393 let object_meta = TypeDefinition {
395 name: "ObjectMeta".to_string(),
396 ty: Type::Record {
397 fields: {
398 let mut fields = BTreeMap::new();
399 fields.insert(
400 "name".to_string(),
401 Field {
402 ty: Type::Optional(Box::new(Type::String)),
403 required: false,
404 description: Some("Name must be unique within a namespace".to_string()),
405 default: None,
406 },
407 );
408 fields.insert(
409 "namespace".to_string(),
410 Field {
411 ty: Type::Optional(Box::new(Type::String)),
412 required: false,
413 description: Some(
414 "Namespace defines the space within which each name must be unique"
415 .to_string(),
416 ),
417 default: None,
418 },
419 );
420 fields.insert(
421 "labels".to_string(),
422 Field {
423 ty: Type::Optional(Box::new(Type::Map {
424 key: Box::new(Type::String),
425 value: Box::new(Type::String),
426 })),
427 required: false,
428 description: Some(
429 "Map of string keys and values for organizing and categorizing objects"
430 .to_string(),
431 ),
432 default: None,
433 },
434 );
435 fields.insert(
436 "annotations".to_string(),
437 Field {
438 ty: Type::Optional(Box::new(Type::Map {
439 key: Box::new(Type::String),
440 value: Box::new(Type::String),
441 })),
442 required: false,
443 description: Some(
444 "Annotations is an unstructured key value map".to_string(),
445 ),
446 default: None,
447 },
448 );
449 fields.insert(
450 "uid".to_string(),
451 Field {
452 ty: Type::Optional(Box::new(Type::String)),
453 required: false,
454 description: Some(
455 "UID is the unique in time and space value for this object".to_string(),
456 ),
457 default: None,
458 },
459 );
460 fields.insert(
461 "resourceVersion".to_string(),
462 Field {
463 ty: Type::Optional(Box::new(Type::String)),
464 required: false,
465 description: Some(
466 "An opaque value that represents the internal version of this object"
467 .to_string(),
468 ),
469 default: None,
470 },
471 );
472 fields
473 },
474 open: true, },
476 documentation: Some(
477 "ObjectMeta is metadata that all persisted resources must have".to_string(),
478 ),
479 annotations: BTreeMap::new(),
480 };
481
482 module.types.push(object_meta);
483
484 module
488}