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