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