kdep 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,32 @@ module Kdep
7
7
  end
8
8
 
9
9
  def app_name
10
+ # Try .app-name file in deploy dir
10
11
  name_file = File.join(@path, ".app-name")
11
- raise "Missing .app-name file in #{@path}" unless File.exist?(name_file)
12
- File.read(name_file).strip
12
+ if File.exist?(name_file)
13
+ return File.read(name_file).strip
14
+ end
15
+ # Try .app-name in parent dir (old pattern: repo/.app-name + repo/deploy/)
16
+ parent_name_file = File.join(@path, "..", ".app-name")
17
+ if File.exist?(parent_name_file)
18
+ return File.read(parent_name_file).strip
19
+ end
20
+ # Try name from app.yml config
21
+ cfg = config
22
+ return cfg["name"] if cfg["name"]
23
+ # Fall back to image field
24
+ return cfg["image"] if cfg["image"]
25
+ # Fall back to directory name (deploy_web -> web)
26
+ dirname = File.basename(@path)
27
+ name = dirname.sub(/\Adeploy_?/, "")
28
+ return name unless name.empty?
29
+ # "deploy" dir -> use parent dir name (repo name)
30
+ File.basename(File.expand_path(File.join(@path, "..")))
13
31
  end
14
32
 
15
33
  def config
16
34
  config_file = File.join(@path, "app.yml")
17
- raise "Missing app.yml in #{@path}" unless File.exist?(config_file)
35
+ return {} unless File.exist?(config_file)
18
36
  content = File.read(config_file)
19
37
  if RUBY_VERSION >= "3.1"
20
38
  YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
@@ -25,32 +43,695 @@ module Kdep
25
43
 
26
44
  def materialized_files
27
45
  mat_dir = materialized_dir
28
- return [] unless mat_dir && File.directory?(mat_dir)
29
- Dir.glob(File.join(mat_dir, "*.yml")).sort
46
+ if mat_dir
47
+ return Dir.glob(File.join(mat_dir, "*.yml")).sort
48
+ end
49
+ # Collect yml/yaml files from subdirectories (app/, config/, secrets/, net/)
50
+ files = []
51
+ Dir.entries(@path).sort.each do |entry|
52
+ next if entry.start_with?(".")
53
+ next if entry.start_with?("_")
54
+ subdir = File.join(@path, entry)
55
+ next unless File.directory?(subdir)
56
+ next if entry == "script"
57
+ next if entry == "disabled"
58
+ Dir.glob(File.join(subdir, "*.{yml,yaml}")).sort.each { |f| files << f }
59
+ end
60
+ files
30
61
  end
31
62
 
32
63
  def materialized_dir
33
64
  dir = File.join(@path, "materialized")
34
65
  return dir if File.directory?(dir)
35
- # Fallback to .rendered/ directory
36
66
  dir = File.join(@path, ".rendered")
37
67
  return dir if File.directory?(dir)
38
68
  nil
39
69
  end
40
70
 
71
+ # Build a complete kdep app.yml by extracting config from materialized K8s files
41
72
  def to_kdep_config
42
73
  old = config
43
74
  result = {}
75
+
76
+ # Start with what's in the old app.yml
44
77
  %w[preset namespace name image registry context port replicas
45
- command target env env_from resources probe domains
46
- image_pull_secrets schedule].each do |key|
78
+ command args env env_from resources probe domains
79
+ image_pull_secrets image_pull_policy schedule service_port
80
+ ingress_name ingress_annotations ingress_class_field
81
+ tls_secret_name tls_hosts ssl_redirect
82
+ volume_mounts volumes lifecycle security_context
83
+ termination_grace_period container_name deployment_name
84
+ configmap_name secret_name service_name cronjob_name
85
+ restart_policy cronjob_label
86
+ pod_labels service_type cluster_ip service_ports
87
+ service_port_protocol container_port_protocol
88
+ suspend ingress_backend_service statefulset_name
89
+ secret_type].each do |key|
47
90
  result[key] = old[key] if old.key?(key)
48
91
  end
49
- # Ensure name from .app-name if not in config
92
+
93
+ # Old format uses "target" as the preset name
94
+ result["preset"] ||= old["target"]
95
+
96
+ # Convert Hash-style domains (host => config) to Array-style
97
+ if result["domains"].is_a?(Hash)
98
+ result["domains"] = result["domains"].map do |host, cfg|
99
+ if cfg.is_a?(Hash) && !cfg.empty?
100
+ domain = { "host" => host }
101
+ domain["path"] = cfg["path"] if cfg["path"]
102
+ domain["path_type"] = cfg["path_type"] if cfg["path_type"]
103
+ # Per-host TLS secret
104
+ if cfg["tls_secret"]
105
+ result["tls_hosts"] ||= {}
106
+ result["tls_hosts"][host] = cfg["tls_secret"]
107
+ end
108
+ domain
109
+ else
110
+ host
111
+ end
112
+ end
113
+ end
114
+
115
+ # Extract missing config from materialized K8s files
116
+ # Save app.yml namespace, let manifest namespace take priority
117
+ appyml_namespace = result.delete("namespace")
118
+ extract_from_manifests(result)
119
+ # Fall back to app.yml namespace if manifests didn't have one
120
+ result["namespace"] ||= appyml_namespace
121
+
122
+ # Merge env from ConfigMap into config (for rendering)
123
+ manifest_env = extract_env
124
+ unless manifest_env.empty?
125
+ result["env"] = (result["env"] || {}).merge(manifest_env)
126
+ end
127
+
128
+ # Ensure name: prefer deploy metadata name > image > app_name
129
+ result["name"] ||= result.delete("deploy_meta_name")
50
130
  result["name"] ||= app_name
131
+ if result["name"].nil? || result["name"].empty?
132
+ result["name"] = result["image"]
133
+ end
51
134
  # Ensure image defaults to name
52
135
  result["image"] ||= result["name"]
136
+
137
+ # Resolve ingress_backend_service: if backend differs from app name
138
+ candidate = result.delete("_ingress_backend_service_candidate")
139
+ if candidate && result["name"] && candidate != result["name"]
140
+ result["ingress_backend_service"] = candidate
141
+ end
142
+
143
+ # Handle ingress class annotation flag
144
+ no_ingress_class = result.delete("_no_ingress_class_annotation")
145
+ if no_ingress_class && !result["ingress_class_field"]
146
+ # No annotation AND no ingressClassName field: tell template not to add annotation
147
+ result["no_ingress_class_annotation"] = true
148
+ elsif !no_ingress_class && result["ingress_class_field"]
149
+ # Has BOTH annotation and ingressClassName field: keep both
150
+ result["ingress_class_annotation"] = true
151
+ end
152
+
153
+ # Handle Secret-specific namespace (cross-namespace secrets)
154
+ sec_ns = result.delete("_secret_namespace")
155
+ if sec_ns && result["namespace"] && sec_ns != result["namespace"]
156
+ result["secret_namespace"] = sec_ns
157
+ end
158
+
159
+ # Process multi-ingress domains
160
+ if result["domains"].is_a?(Array)
161
+ hash_domains = result["domains"].select { |d| d.is_a?(Hash) }
162
+
163
+ # Extract common ssl_redirect to global if ALL domains share the same value
164
+ # Domains without ssl_redirect default to "false"
165
+ ssl_values = hash_domains.map { |d| d["ssl_redirect"] || "false" }.uniq
166
+ if ssl_values.size == 1 && ssl_values[0] != "false"
167
+ result["ssl_redirect"] = ssl_values[0]
168
+ hash_domains.each { |d| d.delete("ssl_redirect") }
169
+ elsif ssl_values.size == 1
170
+ # All default "false" - no need for global
171
+ hash_domains.each { |d| d.delete("ssl_redirect") }
172
+ end
173
+ # If ssl_redirect differs across groups, keep per-domain
174
+
175
+ # Extract common ingress_annotations to global if all annotated domains share them
176
+ all_annot = hash_domains.map { |d| d["ingress_annotations"] }.compact
177
+ if all_annot.size > 0 && all_annot.uniq.size == 1
178
+ result["ingress_annotations"] = all_annot[0]
179
+ hash_domains.each { |d| d.delete("ingress_annotations") }
180
+ end
181
+ # If annotations differ across groups, keep them per-domain
182
+
183
+ # Simplify: if all domains have same ingress name, remove ingress tag
184
+ ingress_names = hash_domains.map { |d| d["ingress"] }.compact.uniq
185
+ if ingress_names.size <= 1
186
+ # All from same ingress (or no ingress tag), simplify domains
187
+ result["domains"] = result["domains"].map do |d|
188
+ if d.is_a?(Hash)
189
+ d = d.dup
190
+ d.delete("ingress")
191
+ d.delete("ssl_redirect") if d["ssl_redirect"].nil?
192
+ d.delete("ingress_annotations") if d["ingress_annotations"].nil?
193
+ # Simplify to string if only host remains
194
+ if d.keys == ["host"]
195
+ d["host"]
196
+ else
197
+ d
198
+ end
199
+ else
200
+ d
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ # Simplify tls_hosts: if all hosts use same secret, use tls_secret_name
207
+ if result["tls_hosts"].is_a?(Hash) && result["tls_hosts"].size > 0
208
+ unique_secrets = result["tls_hosts"].values.uniq
209
+ if unique_secrets.size == 1
210
+ result["tls_secret_name"] = unique_secrets[0]
211
+ result.delete("tls_hosts")
212
+ end
213
+ end
214
+
215
+ # Clean up nil values
216
+ result.delete_if { |_, v| v.nil? }
53
217
  result
54
218
  end
219
+
220
+ # Return secrets extracted from materialized Secret manifest (already base64)
221
+ def extract_secrets
222
+ secrets = {}
223
+ each_manifest do |doc|
224
+ next unless doc["kind"] == "Secret" && doc["data"]
225
+ doc["data"].each do |k, v|
226
+ secrets[k] = v # already base64-encoded
227
+ end
228
+ end
229
+ secrets
230
+ end
231
+
232
+ # Return custom resources that need to be generated as standalone templates
233
+ # Returns array of { kind:, doc:, file: } hashes
234
+ def extract_custom_resources
235
+ customs = []
236
+ each_manifest do |doc|
237
+ case doc["kind"]
238
+ when "PersistentVolumeClaim"
239
+ customs << { "kind" => doc["kind"], "doc" => doc }
240
+ when "Secret"
241
+ # stringData secrets are custom (not handled by standard secret template)
242
+ if doc["stringData"]
243
+ customs << { "kind" => "Secret", "doc" => doc, "subtype" => "stringData" }
244
+ end
245
+ end
246
+ end
247
+ customs
248
+ end
249
+
250
+ # Return env vars from materialized ConfigMap
251
+ def extract_env
252
+ env = {}
253
+ each_manifest do |doc|
254
+ next unless doc["kind"] == "ConfigMap" && doc["data"]
255
+ doc["data"].each do |k, v|
256
+ env[k] = v
257
+ end
258
+ end
259
+ env
260
+ end
261
+
262
+ private
263
+
264
+ def extract_from_manifests(result)
265
+ has_service = false
266
+ has_ingress = false
267
+ has_statefulset = false
268
+ has_deployment = false
269
+ has_cronjob = false
270
+ each_manifest do |doc|
271
+ case doc["kind"]
272
+ when "StatefulSet"
273
+ has_statefulset = true
274
+ extract_from_deployment(doc, result)
275
+ when "Deployment"
276
+ has_deployment = true
277
+ extract_from_deployment(doc, result)
278
+ when "Service"
279
+ has_service = true
280
+ extract_from_service(doc, result)
281
+ when "Ingress"
282
+ has_ingress = true
283
+ extract_from_ingress(doc, result)
284
+ when "CronJob"
285
+ has_cronjob = true
286
+ extract_from_cronjob(doc, result)
287
+ when "ConfigMap"
288
+ cm_name = doc.dig("metadata", "name")
289
+ result["configmap_name"] ||= cm_name if cm_name
290
+ result["namespace"] ||= doc.dig("metadata", "namespace")
291
+ when "Secret"
292
+ sec_name = doc.dig("metadata", "name")
293
+ result["secret_name"] ||= sec_name if sec_name
294
+ # Track Secret-specific namespace for cross-namespace secrets
295
+ sec_ns = doc.dig("metadata", "namespace")
296
+ result["_secret_namespace"] ||= sec_ns if sec_ns
297
+ result["namespace"] ||= sec_ns
298
+ # Extract secret type if present
299
+ if doc["type"] && !result["secret_type"]
300
+ result["secret_type"] = doc["type"]
301
+ end
302
+ end
303
+ end
304
+
305
+ # Manifest kind determines preset
306
+ # StatefulSet/CronJob always override since they're distinct workload types
307
+ if has_statefulset
308
+ result["preset"] = has_service ? "statefulset_svc" : "statefulset"
309
+ elsif has_cronjob
310
+ result["preset"] = "cronjob"
311
+ elsif has_deployment
312
+ # Deployment is compatible with web/worker but NOT job/cronjob
313
+ if result["preset"] == "job" || result["preset"] == "cronjob"
314
+ result["preset"] = (has_service || has_ingress) ? "web" : "worker"
315
+ elsif !result["preset"]
316
+ result["preset"] = (has_service || has_ingress) ? "web" : "worker"
317
+ end
318
+ elsif !result["preset"]
319
+ result["preset"] ||= "worker"
320
+ end
321
+ # Normalize non-standard preset names
322
+ unless Kdep::Preset::BUILT_IN.include?(result["preset"])
323
+ result["preset"] = (has_service || has_ingress) ? "web" : "worker"
324
+ end
325
+ end
326
+
327
+ def extract_from_deployment(doc, result)
328
+ # Name from deployment metadata (most authoritative source)
329
+ deploy_name = doc.dig("metadata", "name")
330
+ if deploy_name
331
+ # Strip common suffixes like "-deployment"
332
+ clean_name = deploy_name.sub(/-deployment\z/, "")
333
+ result["deploy_meta_name"] ||= clean_name
334
+ # Preserve original deployment name if it has a suffix
335
+ if deploy_name != clean_name
336
+ result["deployment_name"] ||= deploy_name
337
+ end
338
+ end
339
+
340
+ # Namespace from metadata
341
+ result["namespace"] ||= doc.dig("metadata", "namespace")
342
+
343
+ spec = doc.dig("spec") || {}
344
+ result["replicas"] ||= spec["replicas"]
345
+
346
+ # Extract pod labels (beyond standard app: name)
347
+ pod_labels = doc.dig("spec", "template", "metadata", "labels") || {}
348
+ app_label_name = result["deploy_meta_name"] || result["name"]
349
+ extra_labels = pod_labels.reject { |k, _| k == "app" }
350
+ if !extra_labels.empty? && !result["pod_labels"]
351
+ result["pod_labels"] = extra_labels
352
+ end
353
+
354
+ container = doc.dig("spec", "template", "spec", "containers", 0)
355
+ return unless container
356
+
357
+ # Container name (if differs from app name)
358
+ if container["name"] && !result["container_name"]
359
+ cname = container["name"]
360
+ # Only preserve if it differs from what we'd default to
361
+ meta_name = result["deploy_meta_name"] || result["name"]
362
+ if meta_name && cname != meta_name
363
+ result["container_name"] = cname
364
+ end
365
+ end
366
+
367
+ # Image -> extract registry
368
+ if container["image"]
369
+ img = container["image"]
370
+ # Split "registry/image:tag" -> registry, image
371
+ parts = img.split(":")
372
+ image_path = parts[0] # e.g. "leadfycr.azurecr.io/msger-web"
373
+ if image_path.include?("/")
374
+ segments = image_path.split("/")
375
+ image_name = segments.pop
376
+ registry = segments.join("/")
377
+ result["registry"] ||= registry
378
+ result["image"] ||= image_name
379
+ end
380
+ end
381
+
382
+ # Port
383
+ if container["ports"] && container["ports"][0]
384
+ result["port"] ||= container["ports"][0]["containerPort"]
385
+ # Extract protocol if not TCP (default)
386
+ protocol = container["ports"][0]["protocol"]
387
+ if protocol && protocol != "TCP" && !result["container_port_protocol"]
388
+ result["container_port_protocol"] = protocol
389
+ end
390
+ end
391
+
392
+ # imagePullPolicy
393
+ if container["imagePullPolicy"] && !result["image_pull_policy"]
394
+ result["image_pull_policy"] = container["imagePullPolicy"]
395
+ end
396
+
397
+ # args
398
+ if container["args"] && !result["args"]
399
+ result["args"] = container["args"]
400
+ end
401
+
402
+ # Resources
403
+ if container["resources"] && !result["resources"]
404
+ result["resources"] = container["resources"]
405
+ end
406
+
407
+ # Probe
408
+ if container["livenessProbe"] && !result["probe"]
409
+ lp = container["livenessProbe"]
410
+ if lp["httpGet"]
411
+ result["probe"] = {
412
+ "path" => lp["httpGet"]["path"],
413
+ "port" => lp["httpGet"]["port"],
414
+ "initial_delay" => lp["initialDelaySeconds"],
415
+ "period" => lp["periodSeconds"],
416
+ "failure_threshold" => lp["failureThreshold"],
417
+ "liveness_success_threshold" => lp["successThreshold"],
418
+ "timeout" => lp["timeoutSeconds"]
419
+ }
420
+ end
421
+ end
422
+ # Readiness probe (may differ from liveness)
423
+ if container["readinessProbe"] && result["probe"]
424
+ rp = container["readinessProbe"]
425
+ result["probe"]["readiness_initial_delay"] = rp["initialDelaySeconds"]
426
+ result["probe"]["readiness_success_threshold"] = rp["successThreshold"]
427
+ if rp["httpGet"]
428
+ rp_path = rp["httpGet"]["path"]
429
+ rp_port = rp["httpGet"]["port"]
430
+ lp = container["livenessProbe"]
431
+ lp_path = lp&.dig("httpGet", "path")
432
+ lp_port = lp&.dig("httpGet", "port")
433
+ result["probe"]["readiness_path"] = rp_path if rp_path && rp_path != lp_path
434
+ result["probe"]["readiness_port"] = rp_port if rp_port && rp_port != lp_port
435
+ end
436
+ result["probe"]["readiness_period"] = rp["periodSeconds"] if rp["periodSeconds"] != result["probe"]["period"]
437
+ result["probe"]["readiness_failure_threshold"] = rp["failureThreshold"] if rp["failureThreshold"] != result["probe"]["failure_threshold"]
438
+ result["probe"]["readiness_timeout"] = rp["timeoutSeconds"] if rp["timeoutSeconds"] != result["probe"]["timeout"]
439
+ end
440
+
441
+ # Volume mounts
442
+ if container["volumeMounts"] && !result["volume_mounts"]
443
+ result["volume_mounts"] = container["volumeMounts"]
444
+ end
445
+
446
+ # Volumes (pod-level)
447
+ pod_volumes = doc.dig("spec", "template", "spec", "volumes")
448
+ if pod_volumes && !result["volumes"]
449
+ result["volumes"] = pod_volumes
450
+ end
451
+
452
+ # Lifecycle hooks
453
+ if container["lifecycle"] && !result["lifecycle"]
454
+ result["lifecycle"] = container["lifecycle"]
455
+ end
456
+
457
+ # Security context
458
+ if container["securityContext"] && !result["security_context"]
459
+ result["security_context"] = container["securityContext"]
460
+ end
461
+
462
+ # Termination grace period
463
+ termination = doc.dig("spec", "template", "spec", "terminationGracePeriodSeconds")
464
+ if termination && !result["termination_grace_period"]
465
+ result["termination_grace_period"] = termination
466
+ end
467
+
468
+ # Image pull secrets
469
+ pull_secrets = doc.dig("spec", "template", "spec", "imagePullSecrets")
470
+ if pull_secrets && pull_secrets[0] && !result["image_pull_secrets"]
471
+ result["image_pull_secrets"] = pull_secrets[0]["name"]
472
+ end
473
+
474
+ # envFrom -> preserve full list in original order
475
+ if container["envFrom"] && !result["env_from"]
476
+ refs = []
477
+ container["envFrom"].each do |ref|
478
+ if ref["configMapRef"]
479
+ refs << "configmap/#{ref["configMapRef"]["name"]}"
480
+ elsif ref["secretRef"]
481
+ refs << "secret/#{ref["secretRef"]["name"]}"
482
+ end
483
+ end
484
+ result["env_from"] = refs unless refs.empty?
485
+ end
486
+ end
487
+
488
+ def extract_from_cronjob(doc, result)
489
+ result["schedule"] ||= doc.dig("spec", "schedule")
490
+
491
+ # Extract suspend field
492
+ suspend = doc.dig("spec", "suspend")
493
+ if !suspend.nil? && !result.key?("suspend")
494
+ result["suspend"] = suspend
495
+ end
496
+
497
+ # Preserve cronjob name from metadata
498
+ cron_name = doc.dig("metadata", "name")
499
+ if cron_name && !result["cronjob_name"]
500
+ result["cronjob_name"] = cron_name
501
+ # Derive app name by stripping cronjob suffixes
502
+ clean = cron_name.sub(/-cron-?job\z/, "")
503
+ if clean != cron_name
504
+ result["deploy_meta_name"] ||= clean
505
+ end
506
+ end
507
+
508
+ # Namespace
509
+ result["namespace"] ||= doc.dig("metadata", "namespace")
510
+
511
+ # Extract cronjob template label
512
+ cron_label = doc.dig("spec", "jobTemplate", "spec", "template", "metadata", "labels", "cronjob")
513
+ if cron_label && !result["cronjob_label"]
514
+ result["cronjob_label"] = cron_label
515
+ end
516
+
517
+ # restartPolicy
518
+ restart = doc.dig("spec", "jobTemplate", "spec", "template", "spec", "restartPolicy")
519
+ if restart && !result["restart_policy"]
520
+ result["restart_policy"] = restart
521
+ end
522
+
523
+ container = doc.dig("spec", "jobTemplate", "spec", "template", "spec", "containers", 0)
524
+ return unless container
525
+
526
+ result["command"] ||= container["command"]
527
+ result["args"] ||= container["args"]
528
+
529
+ # Container name - compare against app name (stripped), not cronjob_name
530
+ if container["name"] && !result["container_name"]
531
+ app_name_candidate = result["deploy_meta_name"] || result["name"]
532
+ if app_name_candidate && container["name"] != app_name_candidate
533
+ result["container_name"] = container["name"]
534
+ end
535
+ end
536
+
537
+ if container["image"]
538
+ img = container["image"]
539
+ parts = img.split(":")
540
+ image_path = parts[0]
541
+ if image_path.include?("/")
542
+ segments = image_path.split("/")
543
+ image_name = segments.pop
544
+ registry = segments.join("/")
545
+ result["registry"] ||= registry
546
+ result["image"] ||= image_name
547
+ end
548
+ end
549
+
550
+ # imagePullPolicy
551
+ if container["imagePullPolicy"] && !result["image_pull_policy"]
552
+ result["image_pull_policy"] = container["imagePullPolicy"]
553
+ end
554
+
555
+ if container["resources"] && !result["resources"]
556
+ result["resources"] = container["resources"]
557
+ end
558
+
559
+ # Volume mounts
560
+ if container["volumeMounts"] && !result["volume_mounts"]
561
+ result["volume_mounts"] = container["volumeMounts"]
562
+ end
563
+
564
+ # Volumes (pod-level)
565
+ pod_volumes = doc.dig("spec", "jobTemplate", "spec", "template", "spec", "volumes")
566
+ if pod_volumes && !result["volumes"]
567
+ result["volumes"] = pod_volumes
568
+ end
569
+
570
+ # imagePullSecrets
571
+ pull_secrets = doc.dig("spec", "jobTemplate", "spec", "template", "spec", "imagePullSecrets")
572
+ if pull_secrets && pull_secrets[0] && !result["image_pull_secrets"]
573
+ result["image_pull_secrets"] = pull_secrets[0]["name"]
574
+ end
575
+
576
+ # envFrom
577
+ if container["envFrom"] && !result["env_from"]
578
+ refs = []
579
+ container["envFrom"].each do |ref|
580
+ if ref["configMapRef"]
581
+ refs << "configmap/#{ref["configMapRef"]["name"]}"
582
+ elsif ref["secretRef"]
583
+ refs << "secret/#{ref["secretRef"]["name"]}"
584
+ end
585
+ end
586
+ result["env_from"] = refs unless refs.empty?
587
+ end
588
+ end
589
+
590
+ def extract_from_service(doc, result)
591
+ # Preserve custom service name
592
+ svc_name = doc.dig("metadata", "name")
593
+ result["service_name"] ||= svc_name if svc_name
594
+ result["namespace"] ||= doc.dig("metadata", "namespace")
595
+
596
+ # Extract service type (NodePort, LoadBalancer, etc.)
597
+ svc_type = doc.dig("spec", "type")
598
+ if svc_type && !result["service_type"]
599
+ result["service_type"] = svc_type
600
+ end
601
+
602
+ # Extract clusterIP (for headless services)
603
+ cluster_ip = doc.dig("spec", "clusterIP")
604
+ if cluster_ip && !result["cluster_ip"]
605
+ result["cluster_ip"] = cluster_ip
606
+ end
607
+
608
+ ports = doc.dig("spec", "ports")
609
+ return unless ports && ports[0]
610
+
611
+ # Multi-port support
612
+ if ports.size > 1
613
+ # Multiple ports: save full port array
614
+ if !result["service_ports"]
615
+ result["service_ports"] = ports.map do |p|
616
+ sp = { "port" => p["port"], "targetPort" => p["targetPort"] || p["port"] }
617
+ sp["protocol"] = p["protocol"] if p["protocol"]
618
+ sp["name"] = p["name"] if p["name"]
619
+ sp
620
+ end
621
+ end
622
+ else
623
+ # Single port
624
+ svc_port = ports[0]["port"]
625
+ # If service port differs from container port, save it
626
+ if svc_port && result["port"] && svc_port != result["port"]
627
+ result["service_port"] = svc_port
628
+ end
629
+ # Extract protocol if not TCP
630
+ protocol = ports[0]["protocol"]
631
+ if protocol && protocol != "TCP" && !result["service_port_protocol"]
632
+ result["service_port_protocol"] = protocol
633
+ end
634
+ end
635
+ end
636
+
637
+ def extract_from_ingress(doc, result)
638
+ ingress_name = doc.dig("metadata", "name")
639
+
640
+ # Multi-ingress: accumulate domains from all ingresses
641
+ rules = doc.dig("spec", "rules") || []
642
+
643
+ # Extract backend service name from first rule (for ingress_backend_service)
644
+ first_backend = rules.dig(0, "http", "paths", 0, "backend", "service", "name")
645
+
646
+ # Detect per-ingress-specific annotations (beyond standard ones)
647
+ annotations = doc.dig("metadata", "annotations") || {}
648
+ standard = %w[kubernetes.io/ingress.class acme.cert-manager.io/http01-edit-in-place
649
+ nginx.ingress.kubernetes.io/ssl-redirect]
650
+ per_ingress_extra = annotations.reject { |k, _| standard.include?(k) }
651
+
652
+ new_domains = rules.map do |r|
653
+ host = r["host"]
654
+ next nil unless host
655
+ path_info = r.dig("http", "paths", 0)
656
+ domain = { "host" => host }
657
+ if path_info
658
+ domain["path"] = path_info["path"] if path_info["path"] && path_info["path"] != "/"
659
+ domain["path_type"] = path_info["pathType"] if path_info["pathType"] && path_info["pathType"] != "Prefix"
660
+ end
661
+ # Tag domain with its ingress name for multi-ingress grouping
662
+ domain["ingress"] = ingress_name if ingress_name
663
+ # Store per-ingress annotations on domain
664
+ domain["ingress_annotations"] = per_ingress_extra unless per_ingress_extra.empty?
665
+ # Store per-ingress ssl-redirect if non-default
666
+ ssl_val = annotations["nginx.ingress.kubernetes.io/ssl-redirect"]
667
+ domain["ssl_redirect"] = ssl_val if ssl_val && ssl_val != "false"
668
+ domain
669
+ end.compact
670
+
671
+ if result["domains"]
672
+ # Append to existing domains (multi-ingress)
673
+ result["domains"].concat(new_domains)
674
+ else
675
+ result["domains"] = new_domains
676
+ end
677
+
678
+ # Detect backend service name mismatch
679
+ if first_backend && !result["ingress_backend_service"]
680
+ # Will be set later; check after name is resolved
681
+ result["_ingress_backend_service_candidate"] = first_backend
682
+ end
683
+
684
+ # Preserve first ingress name
685
+ result["ingress_name"] ||= ingress_name
686
+
687
+ # Namespace
688
+ result["namespace"] ||= doc.dig("metadata", "namespace")
689
+
690
+ # Detect ingressClassName vs annotation
691
+ ingress_class = doc.dig("spec", "ingressClassName")
692
+ if ingress_class
693
+ result["ingress_class_field"] = ingress_class
694
+ end
695
+
696
+ # Check if ingress.class annotation is absent (don't add it if not in original)
697
+ if !annotations.key?("kubernetes.io/ingress.class") && !result.key?("_no_ingress_class_annotation")
698
+ result["_no_ingress_class_annotation"] = true
699
+ end
700
+ if annotations.key?("kubernetes.io/ingress.class")
701
+ # Has the annotation explicitly - clear the flag
702
+ result.delete("_no_ingress_class_annotation")
703
+ end
704
+
705
+ # NOTE: ssl_redirect and ingress_annotations are stored per-domain (above)
706
+ # They will be extracted as globals in post-processing if all domains share them
707
+
708
+ # Preserve per-host TLS secrets
709
+ tls = doc.dig("spec", "tls") || []
710
+ tls_map = result["tls_hosts"] || {}
711
+ tls.each do |t|
712
+ secret = t["secretName"]
713
+ (t["hosts"] || []).each { |h| tls_map[h] = secret } if secret
714
+ end
715
+ # Store tls_hosts map - will be simplified later
716
+ if tls_map.size > 0
717
+ result["tls_hosts"] = tls_map
718
+ end
719
+ end
720
+
721
+ def each_manifest
722
+ materialized_files.each do |file|
723
+ content = File.read(file)
724
+ # Handle multi-document YAML
725
+ content.split(/^---\s*$/).each do |part|
726
+ next if part.strip.empty?
727
+ doc = if RUBY_VERSION >= "3.1"
728
+ YAML.safe_load(part, permitted_classes: [], permitted_symbols: [], aliases: false)
729
+ else
730
+ YAML.safe_load(part, [], [], false)
731
+ end
732
+ yield doc if doc.is_a?(Hash)
733
+ end
734
+ end
735
+ end
55
736
  end
56
737
  end