Skip to main content

alien_core/
stack_state.rs

1//!
2//! Defines structures for managing the runtime state of deployed resources.
3//! This includes platform-specific internal states, overall stack status,
4//! resource outputs, error tracking, and pending user actions.
5
6use crate::{
7    Platform, Resource, ResourceLifecycle, ResourceOutputs, ResourceOutputsDefinition, ResourceRef,
8    ResourceStatus, ResourceType,
9};
10
11use alien_error::AlienError;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt::Debug;
15use uuid::Uuid;
16
17use crate::{ErrorData, Result};
18
19/// Represents the overall status of a stack based on its resource states.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22#[serde(rename_all = "snake_case")]
23pub enum StackStatus {
24    /// Stack is initializing with no resources yet created
25    Pending,
26    /// Stack has resources that are currently being provisioned, updated, or deleted
27    InProgress,
28    /// All resources are successfully running and the stack is operational
29    Running,
30    /// All resources have been successfully deleted and the stack is removed
31    Deleted,
32    /// One or more resources have failed during provisioning, updating, or deleting
33    Failure,
34}
35
36/// Represents the collective state of all resources in a stack, including platform and pending actions.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39#[serde(rename_all = "camelCase")]
40pub struct StackState {
41    /// The target platform for this stack state.
42    pub platform: Platform,
43    /// The state of individual resources, keyed by resource ID.
44    pub resources: HashMap<String, StackResourceState>,
45    /// A prefix used for resource naming to ensure uniqueness across deployments.
46    pub resource_prefix: String,
47}
48
49impl StackState {
50    /// Creates a new, empty StackState for a given platform with a generated resource prefix.
51    pub fn new(platform: Platform) -> Self {
52        // Generate a resource prefix that matches [a-zA-Z][a-zA-Z\d\-]*[a-zA-Z\d] pattern
53        // (e.g., "k44e9b72", "m8a3f1d5")
54        let letters = "abcdefghijklmnopqrstuvwxyz";
55        let first_char = letters
56            .chars()
57            .nth(Uuid::new_v4().as_bytes()[0] as usize % 26)
58            .unwrap();
59        let uuid_part = Uuid::new_v4().simple().to_string()[..7].to_string();
60        let prefix = format!("{}{}", first_char, uuid_part);
61
62        StackState {
63            platform,
64            resources: HashMap::new(),
65            resource_prefix: prefix,
66        }
67    }
68
69    /// Returns a reference to the state of a specific resource if it exists.
70    pub fn resource(&self, id: &str) -> Option<&StackResourceState> {
71        self.resources.get(id)
72    }
73
74    /// Computes the stack status from the current resource statuses.
75    /// This is the main function that implements the logic from the TypeScript version.
76    pub fn compute_stack_status(&self) -> Result<StackStatus> {
77        let resource_statuses: Vec<ResourceStatus> = self
78            .resources
79            .values()
80            .map(|resource| resource.status)
81            .collect();
82
83        Self::compute_stack_status_from_resources(&resource_statuses)
84    }
85
86    /// Static method to compute stack status from a list of resource statuses.
87    /// This method contains the core logic and can be tested independently.
88    pub fn compute_stack_status_from_resources(
89        resource_statuses: &[ResourceStatus],
90    ) -> Result<StackStatus> {
91        // If there are no resources, it's pending (initializing a completely new stack state)
92        if resource_statuses.is_empty() {
93            return Ok(StackStatus::Pending);
94        }
95
96        // Check for any failure states
97        if resource_statuses.iter().any(|status| {
98            matches!(
99                status,
100                ResourceStatus::ProvisionFailed
101                    | ResourceStatus::UpdateFailed
102                    | ResourceStatus::DeleteFailed
103                    | ResourceStatus::RefreshFailed
104            )
105        }) {
106            return Ok(StackStatus::Failure);
107        }
108
109        // Check for any in-progress states
110        if resource_statuses.iter().any(|status| {
111            matches!(
112                status,
113                ResourceStatus::Pending
114                    | ResourceStatus::Provisioning
115                    | ResourceStatus::Updating
116                    | ResourceStatus::Deleting
117            )
118        }) {
119            return Ok(StackStatus::InProgress);
120        }
121
122        // Check for terminal states
123        if resource_statuses
124            .iter()
125            .all(|status| matches!(status, ResourceStatus::Running))
126        {
127            return Ok(StackStatus::Running);
128        }
129
130        if resource_statuses
131            .iter()
132            .all(|status| matches!(status, ResourceStatus::Deleted))
133        {
134            return Ok(StackStatus::Deleted);
135        }
136
137        // Check for mixed Running + Deleted (deletion in progress)
138        // This happens during dependency-ordered deletion when some resources are deleted
139        // but others are still running while waiting for dependencies to clear
140        let has_running = resource_statuses
141            .iter()
142            .any(|status| matches!(status, ResourceStatus::Running));
143        let has_deleted = resource_statuses
144            .iter()
145            .any(|status| matches!(status, ResourceStatus::Deleted));
146        let only_running_or_deleted = resource_statuses
147            .iter()
148            .all(|status| matches!(status, ResourceStatus::Running | ResourceStatus::Deleted));
149
150        if has_running && has_deleted && only_running_or_deleted {
151            return Ok(StackStatus::InProgress);
152        }
153
154        // Mixed terminal states or unexpected combinations
155        let status_strings: Vec<String> = resource_statuses
156            .iter()
157            .map(|status| format!("{:?}", status).to_lowercase().replace('_', "-"))
158            .collect();
159
160        Err(AlienError::new(
161            ErrorData::UnexpectedResourceStatusCombination {
162                resource_statuses: status_strings,
163                operation: "stack status computation".to_string(),
164            },
165        ))
166    }
167
168    /// Retrieves and downcasts the outputs of a resource from the stack state.
169    ///
170    /// # Arguments
171    /// * `resource_id` - The ID of the resource to get outputs for
172    ///
173    /// # Returns
174    /// * `Ok(T)` - The downcasted outputs if successful
175    /// * `Err(Error)` - If the resource doesn't exist, has no outputs, or the outputs are not of the expected type
176    ///
177    /// # Example
178    /// ```rust,ignore
179    /// use alien_core::{StackState, Platform, FunctionOutputs};
180    ///
181    /// let stack_state = StackState::new(Platform::Aws);
182    ///
183    /// // Get function outputs with error handling
184    /// let function_outputs = stack_state.get_resource_outputs::<FunctionOutputs>("my-function")?;
185    /// if let Some(url) = &function_outputs.url {
186    ///     println!("Function URL: {}", url);
187    /// }
188    /// ```
189    pub fn get_resource_outputs<T: ResourceOutputsDefinition + 'static>(
190        &self,
191        resource_id: &str,
192    ) -> Result<&T> {
193        let resource_state = self.resources.get(resource_id).ok_or_else(|| {
194            AlienError::new(ErrorData::ResourceNotFound {
195                resource_id: resource_id.to_string(),
196                available_resources: self.resources.keys().cloned().collect(),
197            })
198        })?;
199
200        let outputs = resource_state.outputs.as_ref().ok_or_else(|| {
201            AlienError::new(ErrorData::ResourceHasNoOutputs {
202                resource_id: resource_id.to_string(),
203            })
204        })?;
205
206        outputs.downcast_ref::<T>().ok_or_else(|| {
207            AlienError::new(ErrorData::UnexpectedResourceType {
208                resource_id: resource_id.to_string(),
209                expected: ResourceType::from_static(std::any::type_name::<T>()),
210                actual: resource_state.resource_type.clone().into(),
211            })
212        })
213    }
214}
215
216/// Represents the state of a single resource within the stack for a specific platform.
217#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "camelCase")]
220pub struct StackResourceState {
221    /// The high-level type of the resource (e.g., Function::RESOURCE_TYPE, Storage::RESOURCE_TYPE).
222    #[serde(rename = "type")]
223    pub resource_type: String,
224
225    /// The platform-specific resource controller that manages this resource's lifecycle.
226    /// This is None when the resource status is Pending.
227    /// Stored as JSON to make the struct serializable and movable to alien-core.
228    #[serde(rename = "_internal", skip_serializing_if = "Option::is_none")]
229    pub internal_state: Option<serde_json::Value>,
230
231    /// High-level status derived from the internal state.
232    pub status: ResourceStatus,
233
234    /// Outputs generated by the resource (e.g., ARN, URL, Bucket Name).
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub outputs: Option<ResourceOutputs>,
237
238    /// The current resource configuration.
239    pub config: Resource,
240
241    /// The previous resource configuration during updates.
242    /// This is set when an update is initiated and cleared when the update completes or fails.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub previous_config: Option<Resource>,
245
246    /// Tracks consecutive retry attempts for the current state transition.
247    #[serde(default, skip_serializing_if = "is_zero")]
248    #[builder(default)]
249    pub retry_attempt: u32,
250
251    /// Stores the last error encountered during a failed step transition.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub error: Option<AlienError>,
254
255    /// True if the resource was provisioned by an external system (e.g., CloudFormation).
256    /// Defaults to false, indicating dynamic provisioning by the executor.
257    #[serde(default, skip_serializing_if = "is_false")]
258    #[builder(default)]
259    pub is_externally_provisioned: bool,
260
261    /// The lifecycle of the resource (Frozen or Live).
262    /// Defaults to Live if not specified.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub lifecycle: Option<ResourceLifecycle>,
265
266    /// Complete list of dependencies for this resource, including infrastructure dependencies.
267    /// This preserves the full dependency information from the stack definition.
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    #[builder(default = vec![])]
270    pub dependencies: Vec<ResourceRef>,
271
272    /// Stores the controller state that failed, used for manual retry operations.
273    /// This allows resuming from the exact point where the failure occurred.
274    /// Stored as JSON to make the struct serializable and movable to alien-core.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub last_failed_state: Option<serde_json::Value>,
277
278    /// Binding parameters for remote access.
279    /// Only populated when the resource has `remote_access: true` in its ResourceEntry.
280    /// This is the JSON serialization of the binding configuration (e.g., StorageBinding, VaultBinding).
281    /// Populated by controllers during provisioning using get_binding_params().
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub remote_binding_params: Option<serde_json::Value>,
284}
285
286impl StackResourceState {
287    /// Creates a new pending StackResourceState for a resource that's about to be created
288    pub fn new_pending(
289        resource_type: String,
290        config: Resource,
291        lifecycle: Option<ResourceLifecycle>,
292        dependencies: Vec<ResourceRef>,
293    ) -> Self {
294        Self {
295            resource_type,
296            internal_state: None,
297            status: ResourceStatus::Pending,
298            outputs: None,
299            config,
300            previous_config: None,
301            retry_attempt: 0,
302            error: None,
303            is_externally_provisioned: false,
304            lifecycle,
305            dependencies,
306            last_failed_state: None,
307            remote_binding_params: None,
308        }
309    }
310
311    /// Creates a new StackResourceState based on this one, with only the specified fields modified
312    pub fn with_updates<F>(&self, update_fn: F) -> Self
313    where
314        F: FnOnce(&mut Self),
315    {
316        let mut new_state = self.clone();
317        update_fn(&mut new_state);
318        new_state
319    }
320
321    /// Creates a new StackResourceState with the status changed to a failure state and error set
322    pub fn with_failure(&self, status: ResourceStatus, error: AlienError) -> Self {
323        self.with_updates(|state| {
324            state.status = status;
325            state.error = Some(error);
326            state.retry_attempt = 0;
327        })
328    }
329}
330
331// Helper function for skip_serializing_if on retry_attempt
332fn is_zero(num: &u32) -> bool {
333    *num == 0
334}
335
336// Helper function for skip_serializing_if on is_externally_provisioned
337fn is_false(b: &bool) -> bool {
338    !*b
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::{Function, FunctionCode, FunctionOutputs, ResourceType, Storage, StorageOutputs};
345
346    #[test]
347    fn test_get_resource_outputs_success() {
348        let mut stack_state = StackState::new(Platform::Aws);
349
350        // Create a function with outputs
351        let function_outputs = FunctionOutputs {
352            function_name: "test-function".to_string(),
353            url: Some("https://example.lambda-url.us-east-1.on.aws/".to_string()),
354            identifier: Some(
355                "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
356            ),
357            load_balancer_endpoint: None,
358            commands_push_target: None,
359        };
360
361        let test_function = Function::new("test-function".to_string())
362            .code(FunctionCode::Image {
363                image: "test:latest".to_string(),
364            })
365            .permissions("test-profile".to_string())
366            .build();
367
368        let resource_state = StackResourceState::new_pending(
369            "function".to_string(),
370            Resource::new(test_function),
371            None,
372            Vec::new(),
373        )
374        .with_updates(|state| {
375            state.status = ResourceStatus::Running;
376            state.outputs = Some(ResourceOutputs::new(function_outputs.clone()));
377        });
378
379        stack_state
380            .resources
381            .insert("test-function".to_string(), resource_state);
382
383        // Test successful retrieval
384        let retrieved_outputs = stack_state
385            .get_resource_outputs::<FunctionOutputs>("test-function")
386            .unwrap();
387        assert_eq!(retrieved_outputs.function_name, "test-function");
388        assert_eq!(
389            retrieved_outputs.url,
390            Some("https://example.lambda-url.us-east-1.on.aws/".to_string())
391        );
392        assert_eq!(
393            retrieved_outputs.identifier,
394            Some("arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string())
395        );
396    }
397
398    #[test]
399    fn test_get_resource_outputs_resource_not_found() {
400        let stack_state = StackState::new(Platform::Aws);
401
402        // Test resource not found
403        let result = stack_state.get_resource_outputs::<FunctionOutputs>("nonexistent-function");
404        assert!(result.is_err());
405        let error = result.unwrap_err();
406
407        // Assert on the specific error variant
408        let error_data = &error.error;
409        if let Some(ErrorData::ResourceNotFound {
410            resource_id,
411            available_resources,
412        }) = error_data
413        {
414            assert_eq!(resource_id, "nonexistent-function");
415            assert_eq!(available_resources, &Vec::<String>::new());
416        } else {
417            panic!("Expected ResourceNotFound error, got: {:?}", error_data);
418        }
419
420        // Also check the string representation
421        let error_message = error.to_string();
422        assert!(error_message.contains("Resource 'nonexistent-function' not found in stack state"));
423        assert!(error_message.contains("Available resources: []"));
424    }
425
426    #[test]
427    fn test_get_resource_outputs_no_outputs() {
428        let mut stack_state = StackState::new(Platform::Aws);
429
430        // Create a resource without outputs
431        let test_function_2 = Function::new("test-function".to_string())
432            .code(FunctionCode::Image {
433                image: "test:latest".to_string(),
434            })
435            .permissions("test-profile".to_string())
436            .build();
437
438        let resource_state = StackResourceState::new_pending(
439            "function".to_string(),
440            Resource::new(test_function_2),
441            None,
442            Vec::new(),
443        )
444        .with_updates(|state| {
445            state.status = ResourceStatus::Provisioning;
446        });
447
448        stack_state
449            .resources
450            .insert("test-function".to_string(), resource_state);
451
452        // Test no outputs
453        let result = stack_state.get_resource_outputs::<FunctionOutputs>("test-function");
454        assert!(result.is_err());
455        let error = result.unwrap_err();
456
457        // Assert on the specific error variant
458        let error_data = &error.error;
459        if let Some(ErrorData::ResourceHasNoOutputs { resource_id, .. }) = error_data {
460            assert_eq!(resource_id, "test-function");
461        } else {
462            panic!("Expected ResourceHasNoOutputs error, got: {:?}", error_data);
463        }
464
465        // Also check the string representation
466        let error_message = error.to_string();
467        assert!(error_message.contains("Resource 'test-function' has no outputs"));
468    }
469
470    #[test]
471    fn test_get_resource_outputs_wrong_type() {
472        let mut stack_state = StackState::new(Platform::Aws);
473
474        // Create a storage resource with storage outputs
475        let storage_outputs = StorageOutputs {
476            bucket_name: "test-bucket".to_string(),
477        };
478
479        let test_storage = Storage::new("test-storage".to_string()).build();
480
481        let resource_state = StackResourceState::new_pending(
482            "storage".to_string(),
483            Resource::new(test_storage),
484            None,
485            Vec::new(),
486        )
487        .with_updates(|state| {
488            state.status = ResourceStatus::Running;
489            state.outputs = Some(ResourceOutputs::new(storage_outputs));
490        });
491
492        stack_state
493            .resources
494            .insert("test-storage".to_string(), resource_state);
495
496        // Try to get function outputs from a storage resource
497        let result = stack_state.get_resource_outputs::<FunctionOutputs>("test-storage");
498        assert!(result.is_err());
499        let error = result.unwrap_err();
500
501        // Assert on the specific error variant
502        let error_data = &error.error;
503        if let Some(ErrorData::UnexpectedResourceType {
504            resource_id,
505            expected,
506            actual,
507        }) = error_data
508        {
509            assert_eq!(resource_id, "test-storage");
510            assert!(
511                expected.0.contains("FunctionOutputs"),
512                "expected should reference FunctionOutputs, got: {}",
513                expected.0
514            );
515            assert_eq!(*actual, ResourceType::from_static("storage"));
516        } else {
517            panic!(
518                "Expected UnexpectedResourceType error, got: {:?}",
519                error_data
520            );
521        }
522    }
523
524    #[test]
525    fn test_get_resource_outputs_usage_example() {
526        let mut stack_state = StackState::new(Platform::Aws);
527
528        // Create a function with outputs (similar to your original sketch)
529        let function_outputs = FunctionOutputs {
530            function_name: "test-alien-function".to_string(),
531            url: Some("https://test.lambda-url.us-east-1.on.aws/".to_string()),
532            identifier: Some(
533                "arn:aws:lambda:us-east-1:123456789012:function:test-alien-function".to_string(),
534            ),
535            load_balancer_endpoint: None,
536            commands_push_target: None,
537        };
538
539        let test_alien_function = Function::new("test-alien-function".to_string())
540            .code(FunctionCode::Image {
541                image: "test:latest".to_string(),
542            })
543            .permissions("test-profile".to_string())
544            .build();
545
546        let resource_state = StackResourceState {
547            resource_type: "function".to_string(),
548            internal_state: None,
549            status: ResourceStatus::Running,
550            outputs: Some(ResourceOutputs::new(function_outputs)),
551            config: Resource::new(test_alien_function),
552            previous_config: None,
553            retry_attempt: 0,
554            error: None,
555            is_externally_provisioned: false,
556            lifecycle: None,
557            dependencies: Vec::new(),
558            last_failed_state: None,
559            remote_binding_params: None,
560        };
561
562        stack_state
563            .resources
564            .insert("test-alien-function".to_string(), resource_state);
565
566        // Test the usage pattern from your original sketch
567        let function_outputs = stack_state
568            .get_resource_outputs::<FunctionOutputs>("test-alien-function")
569            .unwrap();
570
571        let function_url = function_outputs
572            .url
573            .as_ref()
574            .ok_or_else(|| "Function URL not found in stack state")
575            .unwrap();
576
577        assert_eq!(function_url, "https://test.lambda-url.us-east-1.on.aws/");
578    }
579
580    // Tests for StackStatus computation - ported from TypeScript
581    #[cfg(test)]
582    mod stack_status_tests {
583        use super::*;
584
585        #[test]
586        fn test_compute_stack_status_empty_resources() {
587            let result = StackState::compute_stack_status_from_resources(&[]).unwrap();
588            assert_eq!(result, StackStatus::Pending);
589        }
590
591        #[test]
592        fn test_compute_stack_status_single_pending() {
593            let result =
594                StackState::compute_stack_status_from_resources(&[ResourceStatus::Pending])
595                    .unwrap();
596            assert_eq!(result, StackStatus::InProgress);
597        }
598
599        #[test]
600        fn test_compute_stack_status_single_provisioning() {
601            let result =
602                StackState::compute_stack_status_from_resources(&[ResourceStatus::Provisioning])
603                    .unwrap();
604            assert_eq!(result, StackStatus::InProgress);
605        }
606
607        #[test]
608        fn test_compute_stack_status_single_updating() {
609            let result =
610                StackState::compute_stack_status_from_resources(&[ResourceStatus::Updating])
611                    .unwrap();
612            assert_eq!(result, StackStatus::InProgress);
613        }
614
615        #[test]
616        fn test_compute_stack_status_single_deleting() {
617            let result =
618                StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleting])
619                    .unwrap();
620            assert_eq!(result, StackStatus::InProgress);
621        }
622
623        #[test]
624        fn test_compute_stack_status_single_provision_failed() {
625            let result =
626                StackState::compute_stack_status_from_resources(&[ResourceStatus::ProvisionFailed])
627                    .unwrap();
628            assert_eq!(result, StackStatus::Failure);
629        }
630
631        #[test]
632        fn test_compute_stack_status_single_update_failed() {
633            let result =
634                StackState::compute_stack_status_from_resources(&[ResourceStatus::UpdateFailed])
635                    .unwrap();
636            assert_eq!(result, StackStatus::Failure);
637        }
638
639        #[test]
640        fn test_compute_stack_status_single_delete_failed() {
641            let result =
642                StackState::compute_stack_status_from_resources(&[ResourceStatus::DeleteFailed])
643                    .unwrap();
644            assert_eq!(result, StackStatus::Failure);
645        }
646
647        #[test]
648        fn test_compute_stack_status_single_refresh_failed() {
649            let result =
650                StackState::compute_stack_status_from_resources(&[ResourceStatus::RefreshFailed])
651                    .unwrap();
652            assert_eq!(result, StackStatus::Failure);
653        }
654
655        #[test]
656        fn test_compute_stack_status_single_running() {
657            let result =
658                StackState::compute_stack_status_from_resources(&[ResourceStatus::Running])
659                    .unwrap();
660            assert_eq!(result, StackStatus::Running);
661        }
662
663        #[test]
664        fn test_compute_stack_status_single_deleted() {
665            let result =
666                StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleted])
667                    .unwrap();
668            assert_eq!(result, StackStatus::Deleted);
669        }
670
671        #[test]
672        fn test_compute_stack_status_all_running() {
673            let statuses = vec![
674                ResourceStatus::Running,
675                ResourceStatus::Running,
676                ResourceStatus::Running,
677            ];
678            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
679            assert_eq!(result, StackStatus::Running);
680        }
681
682        #[test]
683        fn test_compute_stack_status_all_deleted() {
684            let statuses = vec![
685                ResourceStatus::Deleted,
686                ResourceStatus::Deleted,
687                ResourceStatus::Deleted,
688            ];
689            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
690            assert_eq!(result, StackStatus::Deleted);
691        }
692
693        #[test]
694        fn test_compute_stack_status_all_pending() {
695            let statuses = vec![
696                ResourceStatus::Pending,
697                ResourceStatus::Pending,
698                ResourceStatus::Pending,
699            ];
700            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
701            assert_eq!(result, StackStatus::InProgress);
702        }
703
704        #[test]
705        fn test_compute_stack_status_all_provisioning() {
706            let statuses = vec![
707                ResourceStatus::Provisioning,
708                ResourceStatus::Provisioning,
709                ResourceStatus::Provisioning,
710            ];
711            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
712            assert_eq!(result, StackStatus::InProgress);
713        }
714
715        #[test]
716        fn test_compute_stack_status_all_provision_failed() {
717            let statuses = vec![
718                ResourceStatus::ProvisionFailed,
719                ResourceStatus::ProvisionFailed,
720                ResourceStatus::ProvisionFailed,
721            ];
722            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
723            assert_eq!(result, StackStatus::Failure);
724        }
725
726        #[test]
727        fn test_compute_stack_status_mixed_with_failure() {
728            let statuses = vec![
729                ResourceStatus::Running,
730                ResourceStatus::ProvisionFailed,
731                ResourceStatus::Updating,
732            ];
733            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
734            assert_eq!(result, StackStatus::Failure);
735        }
736
737        #[test]
738        fn test_compute_stack_status_failure_with_success() {
739            let statuses = vec![
740                ResourceStatus::Running,
741                ResourceStatus::UpdateFailed,
742                ResourceStatus::Running,
743            ];
744            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
745            assert_eq!(result, StackStatus::Failure);
746        }
747
748        #[test]
749        fn test_compute_stack_status_failure_with_in_progress() {
750            let statuses = vec![
751                ResourceStatus::Provisioning,
752                ResourceStatus::DeleteFailed,
753                ResourceStatus::Deleting,
754            ];
755            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
756            assert_eq!(result, StackStatus::Failure);
757        }
758
759        #[test]
760        fn test_compute_stack_status_any_in_progress() {
761            let statuses = vec![
762                ResourceStatus::Running,
763                ResourceStatus::Updating,
764                ResourceStatus::Running,
765            ];
766            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
767            assert_eq!(result, StackStatus::InProgress);
768        }
769
770        #[test]
771        fn test_compute_stack_status_mixed_in_progress_states() {
772            let statuses = vec![
773                ResourceStatus::Pending,
774                ResourceStatus::Provisioning,
775                ResourceStatus::Updating,
776                ResourceStatus::Deleting,
777            ];
778            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
779            assert_eq!(result, StackStatus::InProgress);
780        }
781
782        #[test]
783        fn test_compute_stack_status_deletion_in_progress() {
784            // During deletion, some resources are deleted while others are still running
785            // (waiting for dependencies to clear). This should be InProgress, not an error.
786            let statuses = vec![ResourceStatus::Running, ResourceStatus::Deleted];
787            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
788            assert_eq!(result, StackStatus::InProgress);
789        }
790
791        #[test]
792        fn test_compute_stack_status_deletion_in_progress_many_resources() {
793            // Test with a more realistic scenario: 9 resources, 2 deleted, 7 still running
794            let statuses = vec![
795                ResourceStatus::Running,
796                ResourceStatus::Running,
797                ResourceStatus::Deleted,
798                ResourceStatus::Deleted,
799                ResourceStatus::Running,
800                ResourceStatus::Running,
801                ResourceStatus::Running,
802                ResourceStatus::Running,
803                ResourceStatus::Running,
804            ];
805            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
806            assert_eq!(result, StackStatus::InProgress);
807        }
808
809        #[test]
810        fn test_compute_stack_status_mixed_terminal_with_in_progress() {
811            let statuses = vec![
812                ResourceStatus::Running,
813                ResourceStatus::Deleted,
814                ResourceStatus::Pending,
815            ];
816            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
817            assert_eq!(result, StackStatus::InProgress);
818        }
819
820        #[test]
821        fn test_compute_stack_status_large_number_of_resources() {
822            let statuses: Vec<ResourceStatus> = (0..100).map(|_| ResourceStatus::Running).collect();
823            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
824            assert_eq!(result, StackStatus::Running);
825        }
826
827        #[test]
828        fn test_compute_stack_status_single_failure_among_many() {
829            let mut statuses: Vec<ResourceStatus> =
830                (0..50).map(|_| ResourceStatus::Running).collect();
831            statuses.push(ResourceStatus::ProvisionFailed);
832            statuses.extend((0..49).map(|_| ResourceStatus::Provisioning));
833
834            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
835            assert_eq!(result, StackStatus::Failure);
836        }
837
838        #[test]
839        fn test_compute_stack_status_failure_priority_over_in_progress() {
840            let statuses = vec![
841                ResourceStatus::ProvisionFailed,
842                ResourceStatus::UpdateFailed,
843                ResourceStatus::DeleteFailed,
844                ResourceStatus::Provisioning,
845                ResourceStatus::Updating,
846                ResourceStatus::Deleting,
847            ];
848            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
849            assert_eq!(result, StackStatus::Failure);
850        }
851
852        #[test]
853        fn test_compute_stack_status_mixed_success_and_in_progress() {
854            let statuses = vec![
855                ResourceStatus::Running,
856                ResourceStatus::Provisioning,
857                ResourceStatus::Running,
858            ];
859            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
860            assert_eq!(result, StackStatus::InProgress);
861        }
862
863        #[test]
864        fn test_stack_state_status_computation() {
865            let mut stack_state = StackState::new(Platform::Aws);
866
867            // Initially should be pending (no resources)
868            assert_eq!(
869                stack_state.compute_stack_status().unwrap(),
870                StackStatus::Pending
871            );
872
873            // Add a running resource
874            let test_function = Function::new("test-function".to_string())
875                .code(FunctionCode::Image {
876                    image: "test:latest".to_string(),
877                })
878                .permissions("test-profile".to_string())
879                .build();
880
881            let resource_state = StackResourceState::new_pending(
882                "function".to_string(),
883                Resource::new(test_function),
884                None,
885                Vec::new(),
886            )
887            .with_updates(|state| {
888                state.status = ResourceStatus::Running;
889            });
890
891            stack_state
892                .resources
893                .insert("test-function".to_string(), resource_state);
894
895            // Compute status
896            assert_eq!(
897                stack_state.compute_stack_status().unwrap(),
898                StackStatus::Running
899            );
900        }
901
902        /// Regression test: externally provisioned AzureContainerAppsEnvironment
903        /// must survive a JSON serialization roundtrip (simulates push model's
904        /// state transfer through the manager API and SQLite).
905        #[test]
906        fn test_external_container_env_survives_json_roundtrip() {
907            use crate::resources::AzureContainerAppsEnvironmentOutputs;
908            use crate::AzureContainerAppsEnvironment;
909
910            let mut stack_state = StackState::new(Platform::Azure);
911
912            // 1. Create the externally provisioned container env resource
913            //    (mirrors what executor.step() does for external bindings)
914            let env_config =
915                AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
916            let env_outputs = AzureContainerAppsEnvironmentOutputs {
917                environment_name: "test-env".to_string(),
918                resource_id: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.App/managedEnvironments/test-env".to_string(),
919                resource_group_name: "shared-rg".to_string(),
920                default_domain: "test-env.azurecontainerapps.io".to_string(),
921                static_ip: Some("10.0.0.1".to_string()),
922            };
923
924            let env_state = StackResourceState::new_pending(
925                AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
926                Resource::new(env_config),
927                Some(ResourceLifecycle::Frozen),
928                Vec::new(),
929            )
930            .with_updates(|state| {
931                state.status = ResourceStatus::Running;
932                state.is_externally_provisioned = true;
933                state.outputs = Some(ResourceOutputs::new(env_outputs.clone()));
934            });
935
936            stack_state
937                .resources
938                .insert("default-container-env".to_string(), env_state);
939
940            // 2. Also add a function that depends on it (like the real stack)
941            let test_function = Function::new("alien-rs-fn".to_string())
942                .code(FunctionCode::Image {
943                    image: "test:latest".to_string(),
944                })
945                .permissions("execution".to_string())
946                .build();
947
948            let fn_state = StackResourceState::new_pending(
949                "function".to_string(),
950                Resource::new(test_function),
951                Some(ResourceLifecycle::Live),
952                vec![crate::ResourceRef::new(
953                    AzureContainerAppsEnvironment::RESOURCE_TYPE,
954                    "default-container-env",
955                )],
956            )
957            .with_updates(|state| {
958                state.status = ResourceStatus::Running;
959            });
960
961            stack_state
962                .resources
963                .insert("alien-rs-fn".to_string(), fn_state);
964
965            // 3. Verify before roundtrip
966            assert!(
967                stack_state.resources.contains_key("default-container-env"),
968                "default-container-env should be in state before roundtrip"
969            );
970            assert_eq!(stack_state.resources.len(), 2);
971
972            // 4. Simulate the push model roundtrip:
973            //    push client: serde_json::to_value(state) → send to manager API
974            //    manager API: serde_json::from_value(json) → DeploymentState
975            //    manager store: serde_json::to_string(stack_state) → SQLite TEXT
976            //    manager read: serde_json::from_str(text) → StackState
977
978            // Step A: to_value (what ManagerApiTransport.reconcile_step does)
979            let json_value = serde_json::to_value(&stack_state)
980                .expect("StackState serialization to Value should not fail");
981
982            // Step B: from_value (what the manager reconcile handler does)
983            let deserialized_from_value: StackState = serde_json::from_value(json_value)
984                .expect("StackState deserialization from Value should not fail");
985
986            assert!(
987                deserialized_from_value
988                    .resources
989                    .contains_key("default-container-env"),
990                "default-container-env lost during to_value/from_value roundtrip! \
991                 Available: {:?}",
992                deserialized_from_value.resources.keys().collect::<Vec<_>>()
993            );
994
995            // Step C: to_string (what SQLite store does)
996            let json_string = serde_json::to_string(&deserialized_from_value)
997                .expect("StackState serialization to String should not fail");
998
999            // Step D: from_str (what SQLite store does on read)
1000            let deserialized_from_str: StackState = serde_json::from_str(&json_string)
1001                .expect("StackState deserialization from String should not fail");
1002
1003            assert!(
1004                deserialized_from_str
1005                    .resources
1006                    .contains_key("default-container-env"),
1007                "default-container-env lost during to_string/from_str roundtrip! \
1008                 Available: {:?}",
1009                deserialized_from_str.resources.keys().collect::<Vec<_>>()
1010            );
1011
1012            // 5. Verify the outputs survived too
1013            let outputs = deserialized_from_str
1014                .get_resource_outputs::<AzureContainerAppsEnvironmentOutputs>(
1015                    "default-container-env",
1016                )
1017                .expect("Should be able to get container env outputs after roundtrip");
1018            assert_eq!(outputs.environment_name, "test-env");
1019            assert_eq!(outputs.resource_group_name, "shared-rg");
1020            assert_eq!(outputs.static_ip, Some("10.0.0.1".to_string()));
1021
1022            // 6. Verify externally_provisioned flag survived
1023            let env_resource = deserialized_from_str
1024                .resources
1025                .get("default-container-env")
1026                .unwrap();
1027            assert!(
1028                env_resource.is_externally_provisioned,
1029                "is_externally_provisioned flag should survive roundtrip"
1030            );
1031            assert_eq!(env_resource.status, ResourceStatus::Running);
1032            assert_eq!(env_resource.lifecycle, Some(ResourceLifecycle::Frozen));
1033        }
1034
1035        /// Test the full DeploymentState roundtrip (not just StackState),
1036        /// since the push model serializes the entire DeploymentState.
1037        #[test]
1038        fn test_deployment_state_roundtrip_preserves_external_binding() {
1039            use crate::resources::AzureContainerAppsEnvironmentOutputs;
1040            use crate::{AzureContainerAppsEnvironment, DeploymentState, DeploymentStatus};
1041
1042            let mut stack_state = StackState::new(Platform::Azure);
1043
1044            let env_config =
1045                AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
1046            let env_outputs = AzureContainerAppsEnvironmentOutputs {
1047                environment_name: "test-env".to_string(),
1048                resource_id: "/subscriptions/sub/rg/env".to_string(),
1049                resource_group_name: "shared-rg".to_string(),
1050                default_domain: "test.io".to_string(),
1051                static_ip: None,
1052            };
1053
1054            let env_state = StackResourceState::new_pending(
1055                AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
1056                Resource::new(env_config),
1057                Some(ResourceLifecycle::Frozen),
1058                Vec::new(),
1059            )
1060            .with_updates(|state| {
1061                state.status = ResourceStatus::Running;
1062                state.is_externally_provisioned = true;
1063                state.outputs = Some(ResourceOutputs::new(env_outputs));
1064            });
1065
1066            stack_state
1067                .resources
1068                .insert("default-container-env".to_string(), env_state);
1069
1070            // Build DeploymentState (what final_reconcile serializes)
1071            let deployment_state = DeploymentState {
1072                status: DeploymentStatus::Provisioning,
1073                platform: Platform::Azure,
1074                current_release: None,
1075                target_release: None,
1076                stack_state: Some(stack_state),
1077                environment_info: None,
1078                runtime_metadata: None,
1079                retry_requested: false,
1080                protocol_version: 1,
1081            };
1082
1083            // Roundtrip through serde_json::Value (what the push client does)
1084            let json_value =
1085                serde_json::to_value(&deployment_state).expect("DeploymentState to_value failed");
1086
1087            let deserialized: DeploymentState =
1088                serde_json::from_value(json_value).expect("DeploymentState from_value failed");
1089
1090            let ss = deserialized
1091                .stack_state
1092                .as_ref()
1093                .expect("stack_state should be present");
1094
1095            assert!(
1096                ss.resources.contains_key("default-container-env"),
1097                "default-container-env lost in DeploymentState roundtrip! \
1098                 Available: {:?}",
1099                ss.resources.keys().collect::<Vec<_>>()
1100            );
1101        }
1102    }
1103}