Skip to main content

devops_models/models/
k8s_workloads.rs

1use serde::{Deserialize, Serialize};
2use crate::models::k8s::{K8sMetadata, K8sPodTemplate};
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5// ═══════════════════════════════════════════════════════════════════════════
6// HorizontalPodAutoscaler (v2)
7// ═══════════════════════════════════════════════════════════════════════════
8
9#[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// ═══════════════════════════════════════════════════════════════════════════
159// CronJob
160// ═══════════════════════════════════════════════════════════════════════════
161
162#[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
200/// Basic cron expression validation (5 fields: min hour dom month dow)
201fn 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        // Warn if schedule runs very frequently
239        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// ═══════════════════════════════════════════════════════════════════════════
251// Job
252// ═══════════════════════════════════════════════════════════════════════════
253
254#[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        // Check restartPolicy
311        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// ═══════════════════════════════════════════════════════════════════════════
324// StatefulSet
325// ═══════════════════════════════════════════════════════════════════════════
326
327#[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// ═══════════════════════════════════════════════════════════════════════════
394// DaemonSet
395// ═══════════════════════════════════════════════════════════════════════════
396
397#[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}