1use serde::{Deserialize, Serialize};
7
8use crate::{
9 DeploymentConfig, DeploymentState, ObservedInventoryBatch, ReleaseInfo, ResourceHeartbeat,
10};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "kebab-case")]
16pub enum OperatorCapabilityState {
17 Granted,
19 Denied,
21 Unavailable,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct OperatorCapabilityReport {
30 pub key: String,
32 pub state: OperatorCapabilityState,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub detail: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct SyncRequest {
43 pub deployment_id: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub current_state: Option<DeploymentState>,
48 #[serde(
50 default,
51 rename = "resourceHeartbeats",
52 skip_serializing_if = "Vec::is_empty"
53 )]
54 pub heartbeats: Vec<ResourceHeartbeat>,
55 #[serde(
57 default,
58 rename = "observedInventoryBatches",
59 skip_serializing_if = "Vec::is_empty"
60 )]
61 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub capabilities: Vec<OperatorCapabilityReport>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub operator_version: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct SyncResponse {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub current_state: Option<DeploymentState>,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub target: Option<TargetDeployment>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub commands_url: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct TargetDeployment {
97 pub release_info: ReleaseInfo,
99 pub config: DeploymentConfig,
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn test_sync_request_serialization() {
109 let req = SyncRequest {
110 deployment_id: "dep_abc123".to_string(),
111 current_state: None,
112 heartbeats: Vec::new(),
113 observed_inventory_batches: Vec::new(),
114 capabilities: Vec::new(),
115 operator_version: None,
116 };
117
118 let json = serde_json::to_value(&req).unwrap();
119 assert_eq!(json["deploymentId"], "dep_abc123");
120 assert!(json.get("currentState").is_none());
122 assert!(json.get("resourceHeartbeats").is_none());
123 assert!(json.get("capabilities").is_none());
124 assert!(json.get("operatorVersion").is_none());
125 }
126
127 #[test]
128 fn test_sync_request_deserialization() {
129 let json = r#"{"deploymentId": "dep_xyz"}"#;
130 let req: SyncRequest = serde_json::from_str(json).unwrap();
131 assert_eq!(req.deployment_id, "dep_xyz");
132 assert!(req.current_state.is_none());
133 assert!(req.heartbeats.is_empty());
134 assert!(req.observed_inventory_batches.is_empty());
135 assert!(req.capabilities.is_empty());
136 assert!(req.operator_version.is_none());
137 }
138
139 #[test]
140 fn test_sync_response_empty() {
141 let resp = SyncResponse {
142 current_state: None,
143 target: None,
144 commands_url: None,
145 };
146 let json = serde_json::to_value(&resp).unwrap();
147 assert!(json.get("target").is_none());
149 assert!(json.get("currentState").is_none());
150 }
151
152 #[test]
153 fn test_sync_response_roundtrip_no_target() {
154 let resp = SyncResponse {
155 current_state: None,
156 target: None,
157 commands_url: None,
158 };
159 let serialized = serde_json::to_string(&resp).unwrap();
160 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
161 assert!(deserialized.target.is_none());
162 assert!(deserialized.current_state.is_none());
163 }
164
165 #[test]
166 fn test_sync_request_with_camel_case() {
167 let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
169 let req: SyncRequest = serde_json::from_str(json).unwrap();
170 assert_eq!(req.deployment_id, "dep_1");
171 assert!(req.current_state.is_none());
172 assert!(req.heartbeats.is_empty());
173 assert!(req.capabilities.is_empty());
174
175 let json = r#"{"deployment_id": "dep_1"}"#;
177 assert!(serde_json::from_str::<SyncRequest>(json).is_err());
178 }
179
180 #[test]
181 fn test_sync_request_heartbeats_roundtrip() {
182 let json = serde_json::json!({
183 "deploymentId": "dep_1",
184 "resourceHeartbeats": [{
185 "deploymentId": "dep_1",
186 "resourceId": "api",
187 "resourceType": "container",
188 "controllerPlatform": "kubernetes",
189 "backend": "kubernetes",
190 "observedAt": "2026-01-01T00:00:00Z",
191 "data": {
192 "resourceType": "container",
193 "data": {
194 "backend": "kubernetes",
195 "status": {
196 "health": "healthy",
197 "lifecycle": "running",
198 "message": null,
199 "stale": false,
200 "partial": false,
201 "collectionIssues": []
202 },
203 "namespace": "default",
204 "name": "api",
205 "workloadKind": "deployment",
206 "replicas": { "desired": 1, "current": 1, "ready": 1, "available": 1, "updated": null, "misscheduled": null },
207 "restarts": 0,
208 "cpu": null,
209 "memory": null,
210 "workload": null,
211 "pods": [],
212 "instances": [],
213 "events": []
214 }
215 },
216 "raw": []
217 }]
218 });
219
220 let req: SyncRequest = serde_json::from_value(json).unwrap();
221 assert_eq!(req.heartbeats.len(), 1);
222 assert_eq!(req.heartbeats[0].resource_id, "api");
223 assert!(req.capabilities.is_empty());
224
225 let serialized = serde_json::to_value(&req).unwrap();
226 assert_eq!(serialized["resourceHeartbeats"][0]["resourceId"], "api");
227 }
228
229 #[test]
230 fn test_sync_response_observe_only_state_roundtrip() {
231 let state = DeploymentState {
232 status: crate::DeploymentStatus::Running,
233 platform: crate::Platform::Kubernetes,
234 current_release: None,
235 target_release: None,
236 stack_state: None,
237 error: None,
238 environment_info: None,
239 runtime_metadata: None,
240 retry_requested: false,
241 protocol_version: crate::DEPLOYMENT_PROTOCOL_VERSION,
242 };
243 assert!(!state.has_desired());
244
245 let resp = SyncResponse {
246 current_state: Some(state),
247 target: None,
248 commands_url: None,
249 };
250
251 let serialized = serde_json::to_string(&resp).unwrap();
252 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
253 let current_state = deserialized.current_state.unwrap();
254
255 assert_eq!(current_state.status, crate::DeploymentStatus::Running);
256 assert!(!current_state.has_desired());
257 assert!(deserialized.target.is_none());
258 }
259}