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 let mut processed = std::collections::HashSet::new();
107 let mut to_process = std::collections::VecDeque::new();
108
109 let seed_types = vec![
112 "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta",
114 "io.k8s.apimachinery.pkg.apis.meta.v1.TypeMeta",
115 "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta",
116 "io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector",
117 "io.k8s.apimachinery.pkg.apis.meta.v1.Time",
118 "io.k8s.apimachinery.pkg.apis.meta.v1.MicroTime",
119 "io.k8s.apimachinery.pkg.apis.meta.v1.Status",
120 "io.k8s.apimachinery.pkg.apis.meta.v1.Condition",
121 "io.k8s.apimachinery.pkg.runtime.RawExtension",
123 "io.k8s.apimachinery.pkg.util.intstr.IntOrString",
124 "io.k8s.api.core.v1.Pod",
126 "io.k8s.api.core.v1.Service",
127 "io.k8s.api.core.v1.ConfigMap",
128 "io.k8s.api.core.v1.Secret",
129 "io.k8s.api.core.v1.Node",
130 "io.k8s.api.core.v1.Namespace",
131 "io.k8s.api.core.v1.PersistentVolume",
132 "io.k8s.api.core.v1.PersistentVolumeClaim",
133 "io.k8s.api.core.v1.ServiceAccount",
134 "io.k8s.api.core.v1.Endpoints",
135 "io.k8s.api.core.v1.Event",
136 "io.k8s.api.apps.v1.Deployment",
138 "io.k8s.api.apps.v1.StatefulSet",
139 "io.k8s.api.apps.v1.DaemonSet",
140 "io.k8s.api.apps.v1.ReplicaSet",
141 "io.k8s.api.batch.v1.Job",
143 "io.k8s.api.batch.v1.CronJob",
144 "io.k8s.api.networking.v1.Ingress",
146 "io.k8s.api.networking.v1.NetworkPolicy",
147 "io.k8s.api.networking.v1.IngressClass",
148 "io.k8s.api.rbac.v1.Role",
150 "io.k8s.api.rbac.v1.RoleBinding",
151 "io.k8s.api.rbac.v1.ClusterRole",
152 "io.k8s.api.rbac.v1.ClusterRoleBinding",
153 "io.k8s.api.storage.v1.StorageClass",
155 "io.k8s.api.storage.v1.VolumeAttachment",
156 "io.k8s.api.storage.v1.CSIDriver",
157 "io.k8s.api.storage.v1.CSINode",
158 "io.k8s.api.storage.v1.CSIStorageCapacity",
159 "io.k8s.api.storage.v1alpha1.VolumeAttributesClass",
161 "io.k8s.api.storage.v1beta1.VolumeAttributesClass",
162 "io.k8s.api.policy.v1.PodDisruptionBudget",
164 "io.k8s.api.policy.v1.Eviction",
165 "io.k8s.api.autoscaling.v1.HorizontalPodAutoscaler",
167 "io.k8s.api.autoscaling.v2.HorizontalPodAutoscaler",
168 "io.k8s.api.networking.v1beta1.IPAddress",
170 "io.k8s.api.networking.v1beta1.ServiceCIDR",
171 "io.k8s.apimachinery.pkg.api.resource.Quantity",
173 ];
174
175 for seed in seed_types {
177 to_process.push_back(seed.to_string());
178 }
179
180 if let Some(definitions) = openapi.get("definitions").and_then(|d| d.as_object()) {
181 while let Some(full_name) = to_process.pop_front() {
182 if processed.contains(&full_name) {
183 continue;
184 }
185 processed.insert(full_name.clone());
186
187 if let Some(schema) = definitions.get(&full_name) {
188 let short_name = full_name
190 .split('.')
191 .next_back()
192 .unwrap_or(full_name.as_str())
193 .to_string();
194
195 match self.parse_type_reference(&full_name) {
197 Ok(type_ref) => {
198 match self.schema_to_type_definition(&short_name, schema) {
199 Ok(type_def) => {
200 let mut refs = std::collections::HashSet::new();
202 Self::collect_schema_references(schema, &mut refs);
203
204 for ref_name in refs {
206 if !processed.contains(&ref_name)
207 && definitions.contains_key(&ref_name)
208 {
209 to_process.push_back(ref_name);
210 }
211 }
212
213 types.insert(type_ref, type_def);
214 }
215 Err(e) => {
216 tracing::debug!("Failed to parse type {}: {}", full_name, e);
218 }
219 }
220 }
221 Err(e) => {
222 tracing::debug!("Failed to parse reference {}: {}", full_name, e);
223 }
224 }
225 }
226 }
227 }
228
229 tracing::info!(
230 "Extracted {} k8s types from OpenAPI schema using recursive discovery",
231 types.len()
232 );
233 Ok(types)
234 }
235
236 fn collect_schema_references(schema: &Value, refs: &mut std::collections::HashSet<String>) {
238 match schema {
239 Value::Object(obj) => {
240 if let Some(ref_val) = obj.get("$ref").and_then(|r| r.as_str()) {
242 if ref_val.starts_with("#/definitions/") {
243 let type_name = ref_val.strip_prefix("#/definitions/").unwrap();
244 refs.insert(type_name.to_string());
245 }
246 }
247
248 for value in obj.values() {
250 Self::collect_schema_references(value, refs);
251 }
252 }
253 Value::Array(arr) => {
254 for value in arr {
256 Self::collect_schema_references(value, refs);
257 }
258 }
259 _ => {}
260 }
261 }
262
263 fn parse_type_reference(&self, full_name: &str) -> Result<TypeReference, ParserError> {
264 let parts: Vec<&str> = full_name.split('.').collect();
268
269 if parts.len() < 4 || parts[0] != "io" || parts[1] != "k8s" {
270 return Err(ParserError::Parse(format!(
271 "Invalid k8s type name: {}",
272 full_name
273 )));
274 }
275
276 let group = if parts[3] == "core" || parts[2] == "apimachinery" {
277 "k8s.io".to_string() } else {
279 format!("{}.k8s.io", parts[3])
280 };
281
282 let is_unversioned = parts.contains(&"runtime") || parts.contains(&"util");
284
285 let (version, kind) = if is_unversioned {
286 ("v0".to_string(), parts.last().unwrap().to_string())
288 } else {
289 if parts.len() < 5 {
291 return Err(ParserError::Parse(format!(
292 "Invalid versioned k8s type name: {}",
293 full_name
294 )));
295 }
296 (
297 parts[parts.len() - 2].to_string(),
298 parts.last().unwrap().to_string(),
299 )
300 };
301
302 Ok(TypeReference::new(group, version, kind))
303 }
304
305 fn schema_to_type_definition(
306 &self,
307 name: &str,
308 schema: &Value,
309 ) -> Result<TypeDefinition, ParserError> {
310 let ty = self.json_schema_to_type(schema)?;
311
312 Ok(TypeDefinition {
313 name: name.to_string(),
314 ty,
315 documentation: schema
316 .get("description")
317 .and_then(|d| d.as_str())
318 .map(String::from),
319 annotations: BTreeMap::new(),
320 })
321 }
322
323 #[allow(clippy::only_used_in_recursion)]
324 fn json_schema_to_type(&self, schema: &Value) -> Result<Type, ParserError> {
325 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
327 let type_name = ref_path.trim_start_matches("#/definitions/");
328
329 return Ok(match type_name {
331 name if name.ends_with(".Time") || name.ends_with(".MicroTime") => Type::String,
332 name if name.ends_with(".Duration") => Type::String,
333 name if name.ends_with(".IntOrString") => {
334 Type::Union(vec![Type::Integer, Type::String])
335 }
336 name if name.ends_with(".Quantity") => Type::String,
337 name if name.ends_with(".FieldsV1") => Type::Any,
338 name if name.starts_with("io.k8s.") => {
339 let short_name = name.split('.').next_back().unwrap_or(name);
341 Type::Reference(short_name.to_string())
342 }
343 _ => Type::Reference(type_name.to_string()),
344 });
345 }
346
347 let schema_type = schema.get("type").and_then(|v| v.as_str());
348
349 match schema_type {
350 Some("string") => Ok(Type::String),
351 Some("number") => Ok(Type::Number),
352 Some("integer") => Ok(Type::Integer),
353 Some("boolean") => Ok(Type::Bool),
354 Some("array") => {
355 let items = schema
356 .get("items")
357 .map(|i| self.json_schema_to_type(i))
358 .transpose()?
359 .unwrap_or(Type::Any);
360 Ok(Type::Array(Box::new(items)))
361 }
362 Some("object") => {
363 let mut fields = BTreeMap::new();
364
365 if let Some(Value::Object(props)) = schema.get("properties") {
366 let required = schema
367 .get("required")
368 .and_then(|r| r.as_array())
369 .map(|arr| {
370 arr.iter()
371 .filter_map(|v| v.as_str())
372 .map(String::from)
373 .collect::<Vec<_>>()
374 })
375 .unwrap_or_default();
376
377 for (field_name, field_schema) in props {
378 if let Some(ref_path) = field_schema.get("$ref").and_then(|r| r.as_str()) {
380 let type_name = ref_path.trim_start_matches("#/definitions/");
382
383 let resolved_type = match type_name {
385 name if name.ends_with(".Time") || name.ends_with(".MicroTime") => {
387 Type::String
388 }
389 name if name.ends_with(".Duration") => Type::String,
391 name if name.ends_with(".IntOrString") => {
393 Type::Union(vec![Type::Integer, Type::String])
394 }
395 name if name.ends_with(".Quantity")
397 || name == "io.k8s.apimachinery.pkg.api.resource.Quantity" =>
398 {
399 Type::String
400 }
401 name if name.ends_with(".FieldsV1") => Type::Any,
403 name if name.starts_with("io.k8s.") => {
406 let short_name = name.split('.').next_back().unwrap_or(name);
408 Type::Reference(short_name.to_string())
409 }
410 _ => Type::Reference(type_name.to_string()),
412 };
413
414 fields.insert(
415 field_name.clone(),
416 Field {
417 ty: resolved_type,
418 required: required.contains(field_name),
419 description: field_schema
420 .get("description")
421 .and_then(|d| d.as_str())
422 .map(String::from),
423 default: None,
424 },
425 );
426 } else {
427 if field_schema.get("type").is_none()
429 && field_schema.get("$ref").is_none()
430 {
431 if let Value::String(type_str) = field_schema {
433 let resolved_type = match type_str.as_str() {
435 s if s.ends_with(".Time") || s.ends_with(".MicroTime") => {
437 Type::String
438 }
439 s if s.ends_with(".Duration") => Type::String,
440 s if s.ends_with(".IntOrString") => {
441 Type::Union(vec![Type::Integer, Type::String])
442 }
443 s if s.ends_with(".Quantity") => Type::String,
444 s if s.ends_with(".FieldsV1") => Type::Any,
445 s if s.starts_with("io.k8s.") => {
446 let short_name = s.split('.').next_back().unwrap_or(s);
448 Type::Reference(short_name.to_string())
449 }
450 _ => Type::Reference(type_str.clone()),
451 };
452
453 fields.insert(
454 field_name.clone(),
455 Field {
456 ty: resolved_type,
457 required: required.contains(field_name),
458 description: None,
459 default: None,
460 },
461 );
462 continue;
463 }
464 }
465
466 let field_type = self.json_schema_to_type(field_schema)?;
467 fields.insert(
468 field_name.clone(),
469 Field {
470 ty: field_type,
471 required: required.contains(field_name),
472 description: field_schema
473 .get("description")
474 .and_then(|d| d.as_str())
475 .map(String::from),
476 default: field_schema.get("default").cloned(),
477 },
478 );
479 }
480 }
481 }
482
483 let open = schema
484 .get("additionalProperties")
485 .map(|v| !matches!(v, Value::Bool(false)))
486 .unwrap_or(false);
487
488 Ok(Type::Record { fields, open })
489 }
490 _ => {
491 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
493 let type_name = ref_path.trim_start_matches("#/definitions/");
494 Ok(Type::Reference(type_name.to_string()))
495 } else {
496 Ok(Type::Any)
497 }
498 }
499 }
500 }
501}
502
503pub fn generate_k8s_package() -> Module {
505 let mut module = Module {
506 name: "k8s.io".to_string(),
507 imports: Vec::new(),
508 types: Vec::new(),
509 constants: Vec::new(),
510 metadata: Default::default(),
511 };
512
513 let object_meta = TypeDefinition {
515 name: "ObjectMeta".to_string(),
516 ty: Type::Record {
517 fields: {
518 let mut fields = BTreeMap::new();
519 fields.insert(
520 "name".to_string(),
521 Field {
522 ty: Type::Optional(Box::new(Type::String)),
523 required: false,
524 description: Some("Name must be unique within a namespace".to_string()),
525 default: None,
526 },
527 );
528 fields.insert(
529 "namespace".to_string(),
530 Field {
531 ty: Type::Optional(Box::new(Type::String)),
532 required: false,
533 description: Some(
534 "Namespace defines the space within which each name must be unique"
535 .to_string(),
536 ),
537 default: None,
538 },
539 );
540 fields.insert(
541 "labels".to_string(),
542 Field {
543 ty: Type::Optional(Box::new(Type::Map {
544 key: Box::new(Type::String),
545 value: Box::new(Type::String),
546 })),
547 required: false,
548 description: Some(
549 "Map of string keys and values for organizing and categorizing objects"
550 .to_string(),
551 ),
552 default: None,
553 },
554 );
555 fields.insert(
556 "annotations".to_string(),
557 Field {
558 ty: Type::Optional(Box::new(Type::Map {
559 key: Box::new(Type::String),
560 value: Box::new(Type::String),
561 })),
562 required: false,
563 description: Some(
564 "Annotations is an unstructured key value map".to_string(),
565 ),
566 default: None,
567 },
568 );
569 fields.insert(
570 "uid".to_string(),
571 Field {
572 ty: Type::Optional(Box::new(Type::String)),
573 required: false,
574 description: Some(
575 "UID is the unique in time and space value for this object".to_string(),
576 ),
577 default: None,
578 },
579 );
580 fields.insert(
581 "resourceVersion".to_string(),
582 Field {
583 ty: Type::Optional(Box::new(Type::String)),
584 required: false,
585 description: Some(
586 "An opaque value that represents the internal version of this object"
587 .to_string(),
588 ),
589 default: None,
590 },
591 );
592 fields
593 },
594 open: true, },
596 documentation: Some(
597 "ObjectMeta is metadata that all persisted resources must have".to_string(),
598 ),
599 annotations: BTreeMap::new(),
600 };
601
602 module.types.push(object_meta);
603
604 module
608}