1use serde::{Deserialize, Serialize};
2use crate::models::k8s::{K8sMetadata, K8sPodTemplate};
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct HPAMetricTarget {
12 #[serde(default, rename = "type")]
13 pub target_type: Option<String>,
14 #[serde(default, rename = "averageUtilization")]
15 pub average_utilization: Option<u32>,
16 #[serde(default, rename = "averageValue")]
17 pub average_value: Option<String>,
18 #[serde(default)]
19 pub value: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct HPAMetricResource {
25 pub name: String,
26 pub target: HPAMetricTarget,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct HPAMetric {
32 #[serde(rename = "type")]
33 pub metric_type: String,
34 #[serde(default)]
35 pub resource: Option<HPAMetricResource>,
36 #[serde(default)]
37 pub pods: Option<serde_json::Value>,
38 #[serde(default)]
39 pub object: Option<serde_json::Value>,
40 #[serde(default)]
41 pub external: Option<serde_json::Value>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct HPAScaleTargetRef {
47 #[serde(rename = "apiVersion")]
48 pub api_version: Option<String>,
49 pub kind: String,
50 pub name: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct HPABehaviorPolicy {
56 #[serde(rename = "type")]
57 pub policy_type: String,
58 pub value: u32,
59 #[serde(rename = "periodSeconds")]
60 pub period_seconds: u32,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct HPABehaviorRules {
66 #[serde(default, rename = "stabilizationWindowSeconds")]
67 pub stabilization_window_seconds: Option<u32>,
68 #[serde(default)]
69 pub policies: Vec<HPABehaviorPolicy>,
70 #[serde(default, rename = "selectPolicy")]
71 pub select_policy: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct HPABehavior {
77 #[serde(default, rename = "scaleUp")]
78 pub scale_up: Option<HPABehaviorRules>,
79 #[serde(default, rename = "scaleDown")]
80 pub scale_down: Option<HPABehaviorRules>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct HPASpec {
86 #[serde(rename = "scaleTargetRef")]
87 pub scale_target_ref: HPAScaleTargetRef,
88 #[serde(rename = "minReplicas")]
89 pub min_replicas: Option<u32>,
90 #[serde(rename = "maxReplicas")]
91 pub max_replicas: u32,
92 #[serde(default)]
93 pub metrics: Vec<HPAMetric>,
94 #[serde(default)]
95 pub behavior: Option<HPABehavior>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct K8sHPA {
101 #[serde(rename = "apiVersion")]
102 pub api_version: String,
103 pub kind: String,
104 pub metadata: K8sMetadata,
105 pub spec: HPASpec,
106}
107
108impl ConfigValidator for K8sHPA {
109 fn yaml_type(&self) -> YamlType { YamlType::K8sHPA }
110
111 fn validate_structure(&self) -> Vec<Diagnostic> {
112 let mut diags = Vec::new();
113 if let Some(min) = self.spec.min_replicas
114 && min > self.spec.max_replicas {
115 diags.push(Diagnostic {
116 severity: Severity::Error,
117 message: format!("minReplicas ({}) > maxReplicas ({})", min, self.spec.max_replicas),
118 path: Some("spec".into()),
119 });
120 }
121 if self.spec.max_replicas == 0 {
122 diags.push(Diagnostic {
123 severity: Severity::Error,
124 message: "maxReplicas is 0 — HPA will not create any pods".into(),
125 path: Some("spec > maxReplicas".into()),
126 });
127 }
128 diags
129 }
130
131 fn validate_semantics(&self) -> Vec<Diagnostic> {
132 let mut diags = Vec::new();
133 if self.spec.metrics.is_empty() {
134 diags.push(Diagnostic {
135 severity: Severity::Warning,
136 message: "No metrics defined — HPA will default to CPU at 80%".into(),
137 path: Some("spec > metrics".into()),
138 });
139 }
140 if self.spec.min_replicas == Some(0) {
141 diags.push(Diagnostic {
142 severity: Severity::Warning,
143 message: "minReplicas=0 — scale-to-zero requires KEDA or custom setup".into(),
144 path: Some("spec > minReplicas".into()),
145 });
146 }
147 if self.spec.max_replicas > 100 {
148 diags.push(Diagnostic {
149 severity: Severity::Info,
150 message: format!("maxReplicas={} is unusually high", self.spec.max_replicas),
151 path: Some("spec > maxReplicas".into()),
152 });
153 }
154 diags
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct CronJobJobTemplate {
165 #[serde(default)]
166 pub metadata: Option<K8sMetadata>,
167 pub spec: JobSpec,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct CronJobSpec {
173 pub schedule: String,
174 #[serde(default, rename = "timeZone")]
175 pub time_zone: Option<String>,
176 #[serde(default, rename = "concurrencyPolicy")]
177 pub concurrency_policy: Option<String>,
178 #[serde(default, rename = "suspend")]
179 pub suspend: Option<bool>,
180 #[serde(default, rename = "successfulJobsHistoryLimit")]
181 pub successful_jobs_history_limit: Option<u32>,
182 #[serde(default, rename = "failedJobsHistoryLimit")]
183 pub failed_jobs_history_limit: Option<u32>,
184 #[serde(default, rename = "startingDeadlineSeconds")]
185 pub starting_deadline_seconds: Option<u64>,
186 #[serde(rename = "jobTemplate")]
187 pub job_template: CronJobJobTemplate,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct K8sCronJob {
193 #[serde(rename = "apiVersion")]
194 pub api_version: String,
195 pub kind: String,
196 pub metadata: K8sMetadata,
197 pub spec: CronJobSpec,
198}
199
200fn is_valid_cron(expr: &str) -> bool {
202 let parts: Vec<&str> = expr.split_whitespace().collect();
203 parts.len() == 5
204}
205
206impl ConfigValidator for K8sCronJob {
207 fn yaml_type(&self) -> YamlType { YamlType::K8sCronJob }
208
209 fn validate_structure(&self) -> Vec<Diagnostic> {
210 let mut diags = Vec::new();
211 if !is_valid_cron(&self.spec.schedule) {
212 diags.push(Diagnostic {
213 severity: Severity::Error,
214 message: format!("Invalid cron schedule '{}' — expected 5 fields (min hour dom month dow)", self.spec.schedule),
215 path: Some("spec > schedule".into()),
216 });
217 }
218 diags
219 }
220
221 fn validate_semantics(&self) -> Vec<Diagnostic> {
222 let mut diags = Vec::new();
223 if let Some(policy) = &self.spec.concurrency_policy
224 && !["Allow", "Forbid", "Replace"].contains(&policy.as_str()) {
225 diags.push(Diagnostic {
226 severity: Severity::Warning,
227 message: format!("Unknown concurrencyPolicy '{}' — expected Allow/Forbid/Replace", policy),
228 path: Some("spec > concurrencyPolicy".into()),
229 });
230 }
231 if self.spec.suspend == Some(true) {
232 diags.push(Diagnostic {
233 severity: Severity::Info,
234 message: "CronJob is suspended — no jobs will be created".into(),
235 path: Some("spec > suspend".into()),
236 });
237 }
238 if self.spec.schedule.starts_with("* ") || self.spec.schedule.starts_with("*/1 ") {
240 diags.push(Diagnostic {
241 severity: Severity::Warning,
242 message: "CronJob runs every minute — ensure this is intentional".into(),
243 path: Some("spec > schedule".into()),
244 });
245 }
246 diags
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct JobSpec {
257 pub template: K8sPodTemplate,
258 #[serde(default, rename = "backoffLimit")]
259 pub backoff_limit: Option<u32>,
260 #[serde(default)]
261 pub completions: Option<u32>,
262 #[serde(default)]
263 pub parallelism: Option<u32>,
264 #[serde(default, rename = "activeDeadlineSeconds")]
265 pub active_deadline_seconds: Option<u64>,
266 #[serde(default, rename = "ttlSecondsAfterFinished")]
267 pub ttl_seconds_after_finished: Option<u64>,
268 #[serde(default)]
269 pub selector: Option<serde_json::Value>,
270 #[serde(default, rename = "completionMode")]
271 pub completion_mode: Option<String>,
272 #[serde(default, rename = "manualSelector")]
273 pub manual_selector: Option<bool>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct K8sJob {
279 #[serde(rename = "apiVersion")]
280 pub api_version: String,
281 pub kind: String,
282 pub metadata: K8sMetadata,
283 pub spec: JobSpec,
284}
285
286impl ConfigValidator for K8sJob {
287 fn yaml_type(&self) -> YamlType { YamlType::K8sJob }
288
289 fn validate_structure(&self) -> Vec<Diagnostic> {
290 vec![]
291 }
292
293 fn validate_semantics(&self) -> Vec<Diagnostic> {
294 let mut diags = Vec::new();
295 if let Some(limit) = self.spec.backoff_limit
296 && limit > 10 {
297 diags.push(Diagnostic {
298 severity: Severity::Warning,
299 message: format!("backoffLimit={} is high — job will retry many times before giving up", limit),
300 path: Some("spec > backoffLimit".into()),
301 });
302 }
303 if self.spec.active_deadline_seconds.is_none() {
304 diags.push(Diagnostic {
305 severity: Severity::Info,
306 message: "No activeDeadlineSeconds — job may run indefinitely".into(),
307 path: Some("spec > activeDeadlineSeconds".into()),
308 });
309 }
310 let restart = self.spec.template.spec.restart_policy.as_deref().unwrap_or("Always");
312 if restart == "Always" {
313 diags.push(Diagnostic {
314 severity: Severity::Warning,
315 message: "Job template has restartPolicy=Always — should be 'Never' or 'OnFailure'".into(),
316 path: Some("spec > template > spec > restartPolicy".into()),
317 });
318 }
319 diags
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct StatefulSetSpec {
330 #[serde(default)]
331 pub replicas: Option<u32>,
332 #[serde(rename = "serviceName")]
333 pub service_name: String,
334 pub selector: serde_json::Value,
335 pub template: K8sPodTemplate,
336 #[serde(default, rename = "volumeClaimTemplates")]
337 pub volume_claim_templates: Vec<serde_json::Value>,
338 #[serde(default, rename = "updateStrategy")]
339 pub update_strategy: Option<serde_json::Value>,
340 #[serde(default, rename = "podManagementPolicy")]
341 pub pod_management_policy: Option<String>,
342 #[serde(default, rename = "revisionHistoryLimit")]
343 pub revision_history_limit: Option<u32>,
344 #[serde(default, rename = "minReadySeconds")]
345 pub min_ready_seconds: Option<u32>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350pub struct K8sStatefulSet {
351 #[serde(rename = "apiVersion")]
352 pub api_version: String,
353 pub kind: String,
354 pub metadata: K8sMetadata,
355 pub spec: StatefulSetSpec,
356}
357
358impl ConfigValidator for K8sStatefulSet {
359 fn yaml_type(&self) -> YamlType { YamlType::K8sStatefulSet }
360
361 fn validate_structure(&self) -> Vec<Diagnostic> {
362 let mut diags = Vec::new();
363 if self.spec.service_name.is_empty() {
364 diags.push(Diagnostic {
365 severity: Severity::Error,
366 message: "serviceName is required for StatefulSet".into(),
367 path: Some("spec > serviceName".into()),
368 });
369 }
370 diags
371 }
372
373 fn validate_semantics(&self) -> Vec<Diagnostic> {
374 let mut diags = Vec::new();
375 if self.spec.volume_claim_templates.is_empty() {
376 diags.push(Diagnostic {
377 severity: Severity::Info,
378 message: "No volumeClaimTemplates — StatefulSet pods will have no persistent storage".into(),
379 path: Some("spec > volumeClaimTemplates".into()),
380 });
381 }
382 if self.spec.replicas == Some(0) {
383 diags.push(Diagnostic {
384 severity: Severity::Warning,
385 message: "replicas=0 — no pods will be created".into(),
386 path: Some("spec > replicas".into()),
387 });
388 }
389 diags
390 }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(deny_unknown_fields)]
399pub struct DaemonSetSpec {
400 pub selector: serde_json::Value,
401 pub template: K8sPodTemplate,
402 #[serde(default, rename = "updateStrategy")]
403 pub update_strategy: Option<serde_json::Value>,
404 #[serde(default, rename = "revisionHistoryLimit")]
405 pub revision_history_limit: Option<u32>,
406 #[serde(default, rename = "minReadySeconds")]
407 pub min_ready_seconds: Option<u32>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(deny_unknown_fields)]
412pub struct K8sDaemonSet {
413 #[serde(rename = "apiVersion")]
414 pub api_version: String,
415 pub kind: String,
416 pub metadata: K8sMetadata,
417 pub spec: DaemonSetSpec,
418}
419
420impl ConfigValidator for K8sDaemonSet {
421 fn yaml_type(&self) -> YamlType { YamlType::K8sDaemonSet }
422
423 fn validate_structure(&self) -> Vec<Diagnostic> {
424 vec![]
425 }
426
427 fn validate_semantics(&self) -> Vec<Diagnostic> {
428 let mut diags = Vec::new();
429 let tolerations = self.spec.template.spec.tolerations.as_ref();
430 if tolerations.is_none() || tolerations.map(|t| t.is_null()).unwrap_or(true) {
431 diags.push(Diagnostic {
432 severity: Severity::Info,
433 message: "No tolerations — DaemonSet won't schedule on tainted nodes (including control-plane)".into(),
434 path: Some("spec > template > spec > tolerations".into()),
435 });
436 }
437 diags
438 }
439}