kdep 0.1.4 → 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.
- checksums.yaml +4 -4
- data/lib/kdep/cli.rb +1 -0
- data/lib/kdep/commands/apply.rb +1 -1
- data/lib/kdep/commands/bump.rb +17 -10
- data/lib/kdep/commands/diff.rb +1 -1
- data/lib/kdep/commands/helm_install.rb +286 -0
- data/lib/kdep/commands/migrate.rb +176 -71
- data/lib/kdep/commands/render.rb +2 -2
- data/lib/kdep/defaults.rb +6 -2
- data/lib/kdep/docker.rb +57 -1
- data/lib/kdep/helm.rb +34 -0
- data/lib/kdep/old_format.rb +690 -9
- data/lib/kdep/preset.rb +1 -1
- data/lib/kdep/registry.rb +119 -3
- data/lib/kdep/version.rb +1 -1
- data/lib/kdep.rb +2 -0
- data/templates/presets/helm +4 -0
- data/templates/presets/statefulset +4 -0
- data/templates/presets/statefulset_svc +5 -0
- data/templates/resources/configmap.yml.erb +7 -3
- data/templates/resources/cronjob.yml.erb +49 -12
- data/templates/resources/deployment.yml.erb +64 -11
- data/templates/resources/ingress.yml.erb +59 -13
- data/templates/resources/secret.yml.erb +7 -7
- data/templates/resources/service.yml.erb +26 -2
- data/templates/resources/statefulset.yml.erb +125 -0
- metadata +12 -6
data/lib/kdep/old_format.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
46
|
-
image_pull_secrets schedule
|
|
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
|
-
|
|
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
|