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