Skip to main content

alien_core/
sync.rs

1//! Sync protocol types for agent ↔ manager communication.
2//!
3//! The agent periodically calls `POST /v1/sync` with a `SyncRequest` and
4//! receives a `SyncResponse` containing the target deployment state.
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    DeploymentConfig, DeploymentState, ObservedInventoryBatch, ReleaseInfo, ResourceHeartbeat,
10};
11
12/// State of an Operator capability as observed inside the environment.
13#[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    /// The Operator has the permission or local facility needed for the capability.
18    Granted,
19    /// The environment explicitly denied the capability.
20    Denied,
21    /// The capability does not apply in this environment.
22    Unavailable,
23}
24
25/// Report-only Operator capability status.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct OperatorCapabilityReport {
30    /// Stable capability key, such as `k8s-workloads` or `logs`.
31    pub key: String,
32    /// Whether the capability is currently usable.
33    pub state: OperatorCapabilityState,
34    /// Optional human-readable detail from the Operator.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub detail: Option<String>,
37}
38
39/// Request sent by the agent to the manager during periodic sync.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct SyncRequest {
43    /// The deployment ID this agent is managing.
44    pub deployment_id: String,
45    /// Current deployment state as seen by the agent.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub current_state: Option<DeploymentState>,
48    /// Managed Alien resource status samples emitted by the Operator's deployment step.
49    #[serde(
50        default,
51        rename = "resourceHeartbeats",
52        skip_serializing_if = "Vec::is_empty"
53    )]
54    pub heartbeats: Vec<ResourceHeartbeat>,
55    /// Observed raw-resource inventory batches successfully read by the Operator.
56    #[serde(
57        default,
58        rename = "observedInventoryBatches",
59        skip_serializing_if = "Vec::is_empty"
60    )]
61    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
62    /// Report-only capabilities observed by the Operator.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub capabilities: Vec<OperatorCapabilityReport>,
65    /// Version of the Operator binary reporting this sync.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub operator_version: Option<String>,
68}
69
70/// Response from the manager to the agent sync request.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct SyncResponse {
74    /// Authoritative deployment state from the manager.
75    ///
76    /// Pull agents use this to hydrate local state when attaching to an
77    /// already-imported deployment. Absent means the agent's local state is
78    /// already authoritative or no state has been established yet.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub current_state: Option<DeploymentState>,
81    /// Target deployment the agent should converge toward.
82    /// None means no changes needed or this is an observe-only deployment.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub target: Option<TargetDeployment>,
85    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
86    /// Operators and app-owned receivers use this to lease pending commands;
87    /// Workers themselves receive pushes.
88    /// When absent, the agent falls back to its sync URL.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub commands_url: Option<String>,
91}
92
93/// Target deployment state for the agent to converge toward.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct TargetDeployment {
97    /// Release information (ID, version, stack definition).
98    pub release_info: ReleaseInfo,
99    /// Full deployment configuration (settings, env vars, etc.).
100    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        // current_state is None → should be omitted
121        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        // target is None → should be omitted
148        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        // Verify camelCase renaming works correctly
168        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        // snake_case should NOT work
176        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}