Skip to main content

winterbaume_cloudcontrol/
state.rs

1use std::collections::HashMap;
2
3use chrono::Utc;
4use thiserror::Error;
5use uuid::Uuid;
6
7use crate::types::{ManagedResource, OperationStatus, OperationType, ResourceRequest};
8
9/// Domain-specific error enum for Cloud Control API.
10/// Contains no HTTP status codes or AWS error type strings.
11#[derive(Debug, Error)]
12pub enum CloudControlError {
13    #[error("Resource of type {type_name} with identifier {identifier} already exists.")]
14    AlreadyExists {
15        type_name: String,
16        identifier: String,
17    },
18    #[error("Resource of type {type_name} with identifier {identifier} not found.")]
19    ResourceNotFound {
20        type_name: String,
21        identifier: String,
22    },
23    #[error("A resource operation with the specified request token {token} was not found.")]
24    RequestTokenNotFound { token: String },
25    #[error("The specified extension {type_name} does not exist in the CloudFormation registry.")]
26    TypeNotFound { type_name: String },
27    #[error("{message}")]
28    InvalidRequest { message: String },
29    #[error(
30        "The resource operation request with token {token} cannot be cancelled because its status is {status}."
31    )]
32    NotCancellable { token: String, status: String },
33}
34
35#[derive(Debug, Default)]
36pub struct CloudControlState {
37    /// Resources keyed by (type_name, identifier).
38    pub resources: HashMap<(String, String), ManagedResource>,
39    /// Operation requests keyed by request_token.
40    pub requests: HashMap<String, ResourceRequest>,
41}
42
43impl CloudControlState {
44    /// Create a resource. Operations complete synchronously in the mock.
45    pub fn create_resource(
46        &mut self,
47        type_name: &str,
48        desired_state: &str,
49    ) -> Result<ResourceRequest, CloudControlError> {
50        // Parse the desired state to extract an identifier, or generate one.
51        let identifier = extract_identifier_from_model(desired_state)
52            .unwrap_or_else(|| Uuid::new_v4().to_string());
53
54        let key = (type_name.to_string(), identifier.clone());
55        if self.resources.contains_key(&key) {
56            return Err(CloudControlError::AlreadyExists {
57                type_name: type_name.to_string(),
58                identifier,
59            });
60        }
61
62        let resource = ManagedResource {
63            type_name: type_name.to_string(),
64            identifier: identifier.clone(),
65            resource_model: desired_state.to_string(),
66        };
67        self.resources.insert(key, resource);
68
69        let request_token = Uuid::new_v4().to_string();
70        let request = ResourceRequest {
71            request_token: request_token.clone(),
72            type_name: type_name.to_string(),
73            identifier: identifier.clone(),
74            operation: OperationType::Create,
75            operation_status: OperationStatus::Success,
76            event_time: Utc::now(),
77            resource_model: Some(desired_state.to_string()),
78            status_message: None,
79            error_code: None,
80        };
81        self.requests.insert(request_token, request.clone());
82
83        Ok(request)
84    }
85
86    /// Delete a resource by type name and identifier.
87    pub fn delete_resource(
88        &mut self,
89        type_name: &str,
90        identifier: &str,
91    ) -> Result<ResourceRequest, CloudControlError> {
92        let key = (type_name.to_string(), identifier.to_string());
93        if self.resources.remove(&key).is_none() {
94            return Err(CloudControlError::ResourceNotFound {
95                type_name: type_name.to_string(),
96                identifier: identifier.to_string(),
97            });
98        }
99
100        let request_token = Uuid::new_v4().to_string();
101        let request = ResourceRequest {
102            request_token: request_token.clone(),
103            type_name: type_name.to_string(),
104            identifier: identifier.to_string(),
105            operation: OperationType::Delete,
106            operation_status: OperationStatus::Success,
107            event_time: Utc::now(),
108            resource_model: None,
109            status_message: None,
110            error_code: None,
111        };
112        self.requests.insert(request_token, request.clone());
113
114        Ok(request)
115    }
116
117    /// Update a resource by applying a JSON patch document.
118    pub fn update_resource(
119        &mut self,
120        type_name: &str,
121        identifier: &str,
122        patch_document: &str,
123    ) -> Result<ResourceRequest, CloudControlError> {
124        let key = (type_name.to_string(), identifier.to_string());
125        let resource =
126            self.resources
127                .get_mut(&key)
128                .ok_or_else(|| CloudControlError::ResourceNotFound {
129                    type_name: type_name.to_string(),
130                    identifier: identifier.to_string(),
131                })?;
132
133        // Apply the patch: parse existing model and patch operations.
134        let mut model: serde_json::Value = serde_json::from_str(&resource.resource_model)
135            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
136        let patches: Vec<serde_json::Value> =
137            serde_json::from_str(patch_document).unwrap_or_default();
138        for patch in &patches {
139            apply_json_patch(&mut model, patch);
140        }
141        let updated_model = serde_json::to_string(&model).unwrap_or_default();
142        resource.resource_model = updated_model.clone();
143
144        let request_token = Uuid::new_v4().to_string();
145        let request = ResourceRequest {
146            request_token: request_token.clone(),
147            type_name: type_name.to_string(),
148            identifier: identifier.to_string(),
149            operation: OperationType::Update,
150            operation_status: OperationStatus::Success,
151            event_time: Utc::now(),
152            resource_model: Some(updated_model),
153            status_message: None,
154            error_code: None,
155        };
156        self.requests.insert(request_token, request.clone());
157
158        Ok(request)
159    }
160
161    /// Get a resource by type name and identifier.
162    pub fn get_resource(
163        &self,
164        type_name: &str,
165        identifier: &str,
166    ) -> Result<&ManagedResource, CloudControlError> {
167        let key = (type_name.to_string(), identifier.to_string());
168        self.resources
169            .get(&key)
170            .ok_or_else(|| CloudControlError::ResourceNotFound {
171                type_name: type_name.to_string(),
172                identifier: identifier.to_string(),
173            })
174    }
175
176    /// List resources of a given type.
177    pub fn list_resources(&self, type_name: &str) -> Vec<&ManagedResource> {
178        self.resources
179            .values()
180            .filter(|r| r.type_name == type_name)
181            .collect()
182    }
183
184    /// Get the status of a resource operation request.
185    pub fn get_resource_request_status(
186        &self,
187        request_token: &str,
188    ) -> Result<&ResourceRequest, CloudControlError> {
189        self.requests
190            .get(request_token)
191            .ok_or_else(|| CloudControlError::RequestTokenNotFound {
192                token: request_token.to_string(),
193            })
194    }
195
196    /// List all resource operation requests, optionally filtered.
197    pub fn list_resource_requests(
198        &self,
199        operation_filter: Option<&[&str]>,
200        status_filter: Option<&[&str]>,
201    ) -> Vec<&ResourceRequest> {
202        self.requests
203            .values()
204            .filter(|r| {
205                if let Some(ops) = operation_filter {
206                    if !ops.contains(&r.operation.as_str()) {
207                        return false;
208                    }
209                }
210                if let Some(statuses) = status_filter {
211                    if !statuses.contains(&r.operation_status.as_str()) {
212                        return false;
213                    }
214                }
215                true
216            })
217            .collect()
218    }
219
220    /// Cancel a resource operation request.
221    pub fn cancel_resource_request(
222        &mut self,
223        request_token: &str,
224    ) -> Result<ResourceRequest, CloudControlError> {
225        let request = self.requests.get_mut(request_token).ok_or_else(|| {
226            CloudControlError::RequestTokenNotFound {
227                token: request_token.to_string(),
228            }
229        })?;
230
231        // In our mock, operations complete synchronously, so they are always SUCCESS.
232        // Only PENDING or IN_PROGRESS can be cancelled.
233        match request.operation_status {
234            OperationStatus::Pending | OperationStatus::InProgress => {
235                request.operation_status = OperationStatus::CancelComplete;
236                Ok(request.clone())
237            }
238            _ => Err(CloudControlError::NotCancellable {
239                token: request_token.to_string(),
240                status: request.operation_status.as_str().to_string(),
241            }),
242        }
243    }
244}
245
246/// Extract an identifier from a JSON resource model string.
247/// Looks for common identifier fields like "Id", "Name", "Arn".
248fn extract_identifier_from_model(model_json: &str) -> Option<String> {
249    let parsed: serde_json::Value = serde_json::from_str(model_json).ok()?;
250    let obj = parsed.as_object()?;
251
252    // Check common identifier field names
253    for field in &[
254        "Id",
255        "Identifier",
256        "Name",
257        "Arn",
258        "BucketName",
259        "FunctionName",
260        "TableName",
261    ] {
262        if let Some(val) = obj.get(*field) {
263            if let Some(s) = val.as_str() {
264                return Some(s.to_string());
265            }
266        }
267    }
268    None
269}
270
271/// Apply a single RFC 6902 JSON Patch operation to a JSON value.
272fn apply_json_patch(target: &mut serde_json::Value, patch: &serde_json::Value) {
273    let op = patch.get("op").and_then(|v| v.as_str()).unwrap_or("");
274    let path = patch.get("path").and_then(|v| v.as_str()).unwrap_or("");
275
276    // Simple implementation: only handle top-level paths like "/PropertyName"
277    let key = path.trim_start_matches('/');
278    if key.is_empty() {
279        return;
280    }
281
282    match op {
283        "add" | "replace" => {
284            if let Some(value) = patch.get("value") {
285                if let Some(obj) = target.as_object_mut() {
286                    obj.insert(key.to_string(), value.clone());
287                }
288            }
289        }
290        "remove" => {
291            if let Some(obj) = target.as_object_mut() {
292                obj.remove(key);
293            }
294        }
295        _ => {} // ignore unsupported operations
296    }
297}