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        ExposeProtocol, PublicEndpointOutput, ResourceType, Storage, StorageOutputs, Worker,
387        WorkerCode, 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                    protocol: ExposeProtocol::Http,
427                    port: 443,
428                    wildcard_host: None,
429                    load_balancer_endpoint: None,
430                },
431            )]),
432            identifier: Some(
433                "arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string(),
434            ),
435            commands_push_target: None,
436        };
437
438        let test_worker = Worker::new("test-worker".to_string())
439            .code(WorkerCode::Image {
440                image: "test:latest".to_string(),
441            })
442            .permissions("test-profile".to_string())
443            .build();
444
445        let resource_state = StackResourceState::new_pending(
446            "worker".to_string(),
447            Resource::new(test_worker),
448            None,
449            Vec::new(),
450        )
451        .with_updates(|state| {
452            state.status = ResourceStatus::Running;
453            state.outputs = Some(ResourceOutputs::new(worker_outputs.clone()));
454        });
455
456        stack_state
457            .resources
458            .insert("test-worker".to_string(), resource_state);
459
460        // Test successful retrieval
461        let retrieved_outputs = stack_state
462            .get_resource_outputs::<WorkerOutputs>("test-worker")
463            .unwrap();
464        assert_eq!(retrieved_outputs.worker_name, "test-worker");
465        assert_eq!(
466            retrieved_outputs.public_endpoints["api"].url,
467            "https://example.lambda-url.us-east-1.on.aws/"
468        );
469        assert_eq!(
470            retrieved_outputs.identifier,
471            Some("arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string())
472        );
473    }
474
475    #[test]
476    fn test_get_resource_outputs_resource_not_found() {
477        let stack_state = StackState::new(Platform::Aws);
478
479        // Test resource not found
480        let result = stack_state.get_resource_outputs::<WorkerOutputs>("nonexistent-worker");
481        assert!(result.is_err());
482        let error = result.unwrap_err();
483
484        // Assert on the specific error variant
485        let error_data = &error.error;
486        if let Some(ErrorData::ResourceNotFound {
487            resource_id,
488            available_resources,
489        }) = error_data
490        {
491            assert_eq!(resource_id, "nonexistent-worker");
492            assert_eq!(available_resources, &Vec::<String>::new());
493        } else {
494            panic!("Expected ResourceNotFound error, got: {:?}", error_data);
495        }
496
497        // Also check the string representation
498        let error_message = error.to_string();
499        assert!(error_message.contains("Resource 'nonexistent-worker' not found in stack state"));
500        assert!(error_message.contains("Available resources: []"));
501    }
502
503    #[test]
504    fn test_get_resource_outputs_no_outputs() {
505        let mut stack_state = StackState::new(Platform::Aws);
506
507        // Create a resource without outputs
508        let test_worker_2 = Worker::new("test-worker".to_string())
509            .code(WorkerCode::Image {
510                image: "test:latest".to_string(),
511            })
512            .permissions("test-profile".to_string())
513            .build();
514
515        let resource_state = StackResourceState::new_pending(
516            "worker".to_string(),
517            Resource::new(test_worker_2),
518            None,
519            Vec::new(),
520        )
521        .with_updates(|state| {
522            state.status = ResourceStatus::Provisioning;
523        });
524
525        stack_state
526            .resources
527            .insert("test-worker".to_string(), resource_state);
528
529        // Test no outputs
530        let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-worker");
531        assert!(result.is_err());
532        let error = result.unwrap_err();
533
534        // Assert on the specific error variant
535        let error_data = &error.error;
536        if let Some(ErrorData::ResourceHasNoOutputs { resource_id, .. }) = error_data {
537            assert_eq!(resource_id, "test-worker");
538        } else {
539            panic!("Expected ResourceHasNoOutputs error, got: {:?}", error_data);
540        }
541
542        // Also check the string representation
543        let error_message = error.to_string();
544        assert!(error_message.contains("Resource 'test-worker' has no outputs"));
545    }
546
547    #[test]
548    fn test_get_resource_outputs_wrong_type() {
549        let mut stack_state = StackState::new(Platform::Aws);
550
551        // Create a storage resource with storage outputs
552        let storage_outputs = StorageOutputs {
553            bucket_name: "test-bucket".to_string(),
554        };
555
556        let test_storage = Storage::new("test-storage".to_string()).build();
557
558        let resource_state = StackResourceState::new_pending(
559            "storage".to_string(),
560            Resource::new(test_storage),
561            None,
562            Vec::new(),
563        )
564        .with_updates(|state| {
565            state.status = ResourceStatus::Running;
566            state.outputs = Some(ResourceOutputs::new(storage_outputs));
567        });
568
569        stack_state
570            .resources
571            .insert("test-storage".to_string(), resource_state);
572
573        // Try to get worker outputs from a storage resource
574        let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-storage");
575        assert!(result.is_err());
576        let error = result.unwrap_err();
577
578        // Assert on the specific error variant
579        let error_data = &error.error;
580        if let Some(ErrorData::UnexpectedResourceType {
581            resource_id,
582            expected,
583            actual,
584        }) = error_data
585        {
586            assert_eq!(resource_id, "test-storage");
587            assert!(
588                expected.0.contains("WorkerOutputs"),
589                "expected should reference WorkerOutputs, got: {}",
590                expected.0
591            );
592            assert_eq!(*actual, ResourceType::from_static("storage"));
593        } else {
594            panic!(
595                "Expected UnexpectedResourceType error, got: {:?}",
596                error_data
597            );
598        }
599    }
600
601    #[test]
602    fn test_get_resource_outputs_usage_example() {
603        let mut stack_state = StackState::new(Platform::Aws);
604
605        // Create a worker with outputs (similar to your original sketch)
606        let worker_outputs = WorkerOutputs {
607            worker_name: "test-alien-worker".to_string(),
608            public_endpoints: HashMap::from([(
609                "api".to_string(),
610                PublicEndpointOutput {
611                    url: "https://test.lambda-url.us-east-1.on.aws/".to_string(),
612                    host: "test.lambda-url.us-east-1.on.aws".to_string(),
613                    protocol: ExposeProtocol::Http,
614                    port: 443,
615                    wildcard_host: None,
616                    load_balancer_endpoint: None,
617                },
618            )]),
619            identifier: Some(
620                "arn:aws:lambda:us-east-1:123456789012:function:test-alien-worker".to_string(),
621            ),
622            commands_push_target: None,
623        };
624
625        let test_alien_worker = Worker::new("test-alien-worker".to_string())
626            .code(WorkerCode::Image {
627                image: "test:latest".to_string(),
628            })
629            .permissions("test-profile".to_string())
630            .build();
631
632        let resource_state = StackResourceState {
633            resource_type: "worker".to_string(),
634            internal_state: None,
635            status: ResourceStatus::Running,
636            outputs: Some(ResourceOutputs::new(worker_outputs)),
637            config: Resource::new(test_alien_worker),
638            previous_config: None,
639            retry_attempt: 0,
640            error: None,
641            lifecycle: None,
642            dependencies: Vec::new(),
643            last_failed_state: None,
644            remote_binding_params: None,
645            controller_platform: None,
646        };
647
648        stack_state
649            .resources
650            .insert("test-alien-worker".to_string(), resource_state);
651
652        // Test the usage pattern from your original sketch
653        let worker_outputs = stack_state
654            .get_resource_outputs::<WorkerOutputs>("test-alien-worker")
655            .unwrap();
656
657        let worker_url = &worker_outputs
658            .public_endpoints
659            .get("api")
660            .ok_or_else(|| "Worker API endpoint not found in stack state")
661            .unwrap()
662            .url;
663
664        assert_eq!(worker_url, "https://test.lambda-url.us-east-1.on.aws/");
665    }
666
667    // Tests for StackStatus computation - ported from TypeScript
668    #[cfg(test)]
669    mod stack_status_tests {
670        use super::*;
671
672        #[test]
673        fn test_compute_stack_status_empty_resources() {
674            let result = StackState::compute_stack_status_from_resources(&[]).unwrap();
675            assert_eq!(result, StackStatus::Pending);
676        }
677
678        #[test]
679        fn test_compute_stack_status_single_pending() {
680            let result =
681                StackState::compute_stack_status_from_resources(&[ResourceStatus::Pending])
682                    .unwrap();
683            assert_eq!(result, StackStatus::InProgress);
684        }
685
686        #[test]
687        fn test_compute_stack_status_single_provisioning() {
688            let result =
689                StackState::compute_stack_status_from_resources(&[ResourceStatus::Provisioning])
690                    .unwrap();
691            assert_eq!(result, StackStatus::InProgress);
692        }
693
694        #[test]
695        fn test_compute_stack_status_single_updating() {
696            let result =
697                StackState::compute_stack_status_from_resources(&[ResourceStatus::Updating])
698                    .unwrap();
699            assert_eq!(result, StackStatus::InProgress);
700        }
701
702        #[test]
703        fn test_compute_stack_status_single_deleting() {
704            let result =
705                StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleting])
706                    .unwrap();
707            assert_eq!(result, StackStatus::InProgress);
708        }
709
710        #[test]
711        fn test_compute_stack_status_single_provision_failed() {
712            let result =
713                StackState::compute_stack_status_from_resources(&[ResourceStatus::ProvisionFailed])
714                    .unwrap();
715            assert_eq!(result, StackStatus::Failure);
716        }
717
718        #[test]
719        fn test_compute_stack_status_single_update_failed() {
720            let result =
721                StackState::compute_stack_status_from_resources(&[ResourceStatus::UpdateFailed])
722                    .unwrap();
723            assert_eq!(result, StackStatus::Failure);
724        }
725
726        #[test]
727        fn test_compute_stack_status_single_delete_failed() {
728            let result =
729                StackState::compute_stack_status_from_resources(&[ResourceStatus::DeleteFailed])
730                    .unwrap();
731            assert_eq!(result, StackStatus::Failure);
732        }
733
734        #[test]
735        fn test_compute_stack_status_single_refresh_failed() {
736            let result =
737                StackState::compute_stack_status_from_resources(&[ResourceStatus::RefreshFailed])
738                    .unwrap();
739            assert_eq!(result, StackStatus::Failure);
740        }
741
742        #[test]
743        fn test_compute_stack_status_single_running() {
744            let result =
745                StackState::compute_stack_status_from_resources(&[ResourceStatus::Running])
746                    .unwrap();
747            assert_eq!(result, StackStatus::Running);
748        }
749
750        #[test]
751        fn test_compute_stack_status_single_deleted() {
752            let result =
753                StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleted])
754                    .unwrap();
755            assert_eq!(result, StackStatus::Deleted);
756        }
757
758        #[test]
759        fn test_compute_stack_status_all_running() {
760            let statuses = vec![
761                ResourceStatus::Running,
762                ResourceStatus::Running,
763                ResourceStatus::Running,
764            ];
765            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
766            assert_eq!(result, StackStatus::Running);
767        }
768
769        #[test]
770        fn test_compute_stack_status_all_deleted() {
771            let statuses = vec![
772                ResourceStatus::Deleted,
773                ResourceStatus::Deleted,
774                ResourceStatus::Deleted,
775            ];
776            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
777            assert_eq!(result, StackStatus::Deleted);
778        }
779
780        #[test]
781        fn test_compute_stack_status_all_pending() {
782            let statuses = vec![
783                ResourceStatus::Pending,
784                ResourceStatus::Pending,
785                ResourceStatus::Pending,
786            ];
787            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
788            assert_eq!(result, StackStatus::InProgress);
789        }
790
791        #[test]
792        fn test_compute_stack_status_all_provisioning() {
793            let statuses = vec![
794                ResourceStatus::Provisioning,
795                ResourceStatus::Provisioning,
796                ResourceStatus::Provisioning,
797            ];
798            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
799            assert_eq!(result, StackStatus::InProgress);
800        }
801
802        #[test]
803        fn test_compute_stack_status_all_provision_failed() {
804            let statuses = vec![
805                ResourceStatus::ProvisionFailed,
806                ResourceStatus::ProvisionFailed,
807                ResourceStatus::ProvisionFailed,
808            ];
809            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
810            assert_eq!(result, StackStatus::Failure);
811        }
812
813        #[test]
814        fn test_compute_stack_status_mixed_with_failure() {
815            let statuses = vec![
816                ResourceStatus::Running,
817                ResourceStatus::ProvisionFailed,
818                ResourceStatus::Updating,
819            ];
820            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
821            assert_eq!(result, StackStatus::Failure);
822        }
823
824        #[test]
825        fn test_compute_stack_status_failure_with_success() {
826            let statuses = vec![
827                ResourceStatus::Running,
828                ResourceStatus::UpdateFailed,
829                ResourceStatus::Running,
830            ];
831            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
832            assert_eq!(result, StackStatus::Failure);
833        }
834
835        #[test]
836        fn test_compute_stack_status_failure_with_in_progress() {
837            let statuses = vec![
838                ResourceStatus::Provisioning,
839                ResourceStatus::DeleteFailed,
840                ResourceStatus::Deleting,
841            ];
842            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
843            assert_eq!(result, StackStatus::Failure);
844        }
845
846        #[test]
847        fn test_compute_stack_status_any_in_progress() {
848            let statuses = vec![
849                ResourceStatus::Running,
850                ResourceStatus::Updating,
851                ResourceStatus::Running,
852            ];
853            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
854            assert_eq!(result, StackStatus::InProgress);
855        }
856
857        #[test]
858        fn test_compute_stack_status_mixed_in_progress_states() {
859            let statuses = vec![
860                ResourceStatus::Pending,
861                ResourceStatus::Provisioning,
862                ResourceStatus::Updating,
863                ResourceStatus::Deleting,
864            ];
865            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
866            assert_eq!(result, StackStatus::InProgress);
867        }
868
869        #[test]
870        fn test_compute_stack_status_deletion_in_progress() {
871            // During deletion, some resources are deleted while others are still running
872            // (waiting for dependencies to clear). This should be InProgress, not an error.
873            let statuses = vec![ResourceStatus::Running, ResourceStatus::Deleted];
874            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
875            assert_eq!(result, StackStatus::InProgress);
876        }
877
878        #[test]
879        fn test_compute_stack_status_deletion_in_progress_many_resources() {
880            // Test with a more realistic scenario: 9 resources, 2 deleted, 7 still running
881            let statuses = vec![
882                ResourceStatus::Running,
883                ResourceStatus::Running,
884                ResourceStatus::Deleted,
885                ResourceStatus::Deleted,
886                ResourceStatus::Running,
887                ResourceStatus::Running,
888                ResourceStatus::Running,
889                ResourceStatus::Running,
890                ResourceStatus::Running,
891            ];
892            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
893            assert_eq!(result, StackStatus::InProgress);
894        }
895
896        #[test]
897        fn test_compute_stack_status_mixed_terminal_with_in_progress() {
898            let statuses = vec![
899                ResourceStatus::Running,
900                ResourceStatus::Deleted,
901                ResourceStatus::Pending,
902            ];
903            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
904            assert_eq!(result, StackStatus::InProgress);
905        }
906
907        #[test]
908        fn test_compute_stack_status_large_number_of_resources() {
909            let statuses: Vec<ResourceStatus> = (0..100).map(|_| ResourceStatus::Running).collect();
910            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
911            assert_eq!(result, StackStatus::Running);
912        }
913
914        #[test]
915        fn test_compute_stack_status_single_failure_among_many() {
916            let mut statuses: Vec<ResourceStatus> =
917                (0..50).map(|_| ResourceStatus::Running).collect();
918            statuses.push(ResourceStatus::ProvisionFailed);
919            statuses.extend((0..49).map(|_| ResourceStatus::Provisioning));
920
921            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
922            assert_eq!(result, StackStatus::Failure);
923        }
924
925        #[test]
926        fn test_compute_stack_status_failure_priority_over_in_progress() {
927            let statuses = vec![
928                ResourceStatus::ProvisionFailed,
929                ResourceStatus::UpdateFailed,
930                ResourceStatus::DeleteFailed,
931                ResourceStatus::Provisioning,
932                ResourceStatus::Updating,
933                ResourceStatus::Deleting,
934            ];
935            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
936            assert_eq!(result, StackStatus::Failure);
937        }
938
939        #[test]
940        fn test_compute_stack_status_mixed_success_and_in_progress() {
941            let statuses = vec![
942                ResourceStatus::Running,
943                ResourceStatus::Provisioning,
944                ResourceStatus::Running,
945            ];
946            let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
947            assert_eq!(result, StackStatus::InProgress);
948        }
949
950        #[test]
951        fn test_stack_state_status_computation() {
952            let mut stack_state = StackState::new(Platform::Aws);
953
954            // Initially should be pending (no resources)
955            assert_eq!(
956                stack_state.compute_stack_status().unwrap(),
957                StackStatus::Pending
958            );
959
960            // Add a running resource
961            let test_worker = Worker::new("test-worker".to_string())
962                .code(WorkerCode::Image {
963                    image: "test:latest".to_string(),
964                })
965                .permissions("test-profile".to_string())
966                .build();
967
968            let resource_state = StackResourceState::new_pending(
969                "worker".to_string(),
970                Resource::new(test_worker),
971                None,
972                Vec::new(),
973            )
974            .with_updates(|state| {
975                state.status = ResourceStatus::Running;
976            });
977
978            stack_state
979                .resources
980                .insert("test-worker".to_string(), resource_state);
981
982            // Compute status
983            assert_eq!(
984                stack_state.compute_stack_status().unwrap(),
985                StackStatus::Running
986            );
987        }
988
989        /// Regression test: externally provisioned AzureContainerAppsEnvironment
990        /// must survive a JSON serialization roundtrip (simulates push model's
991        /// state transfer through the manager API and SQLite).
992        #[test]
993        fn test_external_container_env_survives_json_roundtrip() {
994            use crate::resources::AzureContainerAppsEnvironmentOutputs;
995            use crate::AzureContainerAppsEnvironment;
996
997            let mut stack_state = StackState::new(Platform::Azure);
998
999            // 1. Create the container env resource
1000            //    (mirrors what executor.step() does for external bindings)
1001            let env_config =
1002                AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
1003            let env_outputs = AzureContainerAppsEnvironmentOutputs {
1004                environment_name: "test-env".to_string(),
1005                resource_id: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.App/managedEnvironments/test-env".to_string(),
1006                resource_group_name: "shared-rg".to_string(),
1007                default_domain: "test-env.azurecontainerapps.io".to_string(),
1008                static_ip: Some("10.0.0.1".to_string()),
1009                custom_domain_verification_id: None,
1010            };
1011
1012            let env_state = StackResourceState::new_pending(
1013                AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
1014                Resource::new(env_config),
1015                Some(ResourceLifecycle::Frozen),
1016                Vec::new(),
1017            )
1018            .with_updates(|state| {
1019                state.status = ResourceStatus::Running;
1020                state.outputs = Some(ResourceOutputs::new(env_outputs.clone()));
1021            });
1022
1023            stack_state
1024                .resources
1025                .insert("default-container-env".to_string(), env_state);
1026
1027            // 2. Also add a worker that depends on it (like the real stack)
1028            let test_worker = Worker::new("alien-rs-worker".to_string())
1029                .code(WorkerCode::Image {
1030                    image: "test:latest".to_string(),
1031                })
1032                .permissions("execution".to_string())
1033                .build();
1034
1035            let fn_state = StackResourceState::new_pending(
1036                "worker".to_string(),
1037                Resource::new(test_worker),
1038                Some(ResourceLifecycle::Live),
1039                vec![crate::ResourceRef::new(
1040                    AzureContainerAppsEnvironment::RESOURCE_TYPE,
1041                    "default-container-env",
1042                )],
1043            )
1044            .with_updates(|state| {
1045                state.status = ResourceStatus::Running;
1046            });
1047
1048            stack_state
1049                .resources
1050                .insert("alien-rs-worker".to_string(), fn_state);
1051
1052            // 3. Verify before roundtrip
1053            assert!(
1054                stack_state.resources.contains_key("default-container-env"),
1055                "default-container-env should be in state before roundtrip"
1056            );
1057            assert_eq!(stack_state.resources.len(), 2);
1058
1059            // 4. Simulate the push model roundtrip:
1060            //    push client: serde_json::to_value(state) → send to manager API
1061            //    manager API: serde_json::from_value(json) → DeploymentState
1062            //    manager store: serde_json::to_string(stack_state) → SQLite TEXT
1063            //    manager read: serde_json::from_str(text) → StackState
1064
1065            // Step A: to_value (what ManagerApiTransport.reconcile_step does)
1066            let json_value = serde_json::to_value(&stack_state)
1067                .expect("StackState serialization to Value should not fail");
1068
1069            // Step B: from_value (what the manager reconcile handler does)
1070            let deserialized_from_value: StackState = serde_json::from_value(json_value)
1071                .expect("StackState deserialization from Value should not fail");
1072
1073            assert!(
1074                deserialized_from_value
1075                    .resources
1076                    .contains_key("default-container-env"),
1077                "default-container-env lost during to_value/from_value roundtrip! \
1078                 Available: {:?}",
1079                deserialized_from_value.resources.keys().collect::<Vec<_>>()
1080            );
1081
1082            // Step C: to_string (what SQLite store does)
1083            let json_string = serde_json::to_string(&deserialized_from_value)
1084                .expect("StackState serialization to String should not fail");
1085
1086            // Step D: from_str (what SQLite store does on read)
1087            let deserialized_from_str: StackState = serde_json::from_str(&json_string)
1088                .expect("StackState deserialization from String should not fail");
1089
1090            assert!(
1091                deserialized_from_str
1092                    .resources
1093                    .contains_key("default-container-env"),
1094                "default-container-env lost during to_string/from_str roundtrip! \
1095                 Available: {:?}",
1096                deserialized_from_str.resources.keys().collect::<Vec<_>>()
1097            );
1098
1099            // 5. Verify the outputs survived too
1100            let outputs = deserialized_from_str
1101                .get_resource_outputs::<AzureContainerAppsEnvironmentOutputs>(
1102                    "default-container-env",
1103                )
1104                .expect("Should be able to get container env outputs after roundtrip");
1105            assert_eq!(outputs.environment_name, "test-env");
1106            assert_eq!(outputs.resource_group_name, "shared-rg");
1107            assert_eq!(outputs.static_ip, Some("10.0.0.1".to_string()));
1108
1109            // 6. Verify status and lifecycle survived
1110            let env_resource = deserialized_from_str
1111                .resources
1112                .get("default-container-env")
1113                .unwrap();
1114            assert_eq!(env_resource.status, ResourceStatus::Running);
1115            assert_eq!(env_resource.lifecycle, Some(ResourceLifecycle::Frozen));
1116        }
1117
1118        /// Test the full DeploymentState roundtrip (not just StackState),
1119        /// since the push model serializes the entire DeploymentState.
1120        #[test]
1121        fn test_deployment_state_roundtrip_preserves_external_binding() {
1122            use crate::resources::AzureContainerAppsEnvironmentOutputs;
1123            use crate::{AzureContainerAppsEnvironment, DeploymentState, DeploymentStatus};
1124
1125            let mut stack_state = StackState::new(Platform::Azure);
1126
1127            let env_config =
1128                AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
1129            let env_outputs = AzureContainerAppsEnvironmentOutputs {
1130                environment_name: "test-env".to_string(),
1131                resource_id: "/subscriptions/sub/rg/env".to_string(),
1132                resource_group_name: "shared-rg".to_string(),
1133                default_domain: "test.io".to_string(),
1134                static_ip: None,
1135                custom_domain_verification_id: None,
1136            };
1137
1138            let env_state = StackResourceState::new_pending(
1139                AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
1140                Resource::new(env_config),
1141                Some(ResourceLifecycle::Frozen),
1142                Vec::new(),
1143            )
1144            .with_updates(|state| {
1145                state.status = ResourceStatus::Running;
1146                state.outputs = Some(ResourceOutputs::new(env_outputs));
1147            });
1148
1149            stack_state
1150                .resources
1151                .insert("default-container-env".to_string(), env_state);
1152
1153            // Build DeploymentState (what final_reconcile serializes)
1154            let deployment_state = DeploymentState {
1155                status: DeploymentStatus::Provisioning,
1156                platform: Platform::Azure,
1157                current_release: None,
1158                target_release: None,
1159                stack_state: Some(stack_state),
1160                error: None,
1161                environment_info: None,
1162                runtime_metadata: None,
1163                retry_requested: false,
1164                protocol_version: 1,
1165            };
1166
1167            // Roundtrip through serde_json::Value (what the push client does)
1168            let json_value =
1169                serde_json::to_value(&deployment_state).expect("DeploymentState to_value failed");
1170
1171            let deserialized: DeploymentState =
1172                serde_json::from_value(json_value).expect("DeploymentState from_value failed");
1173
1174            let ss = deserialized
1175                .stack_state
1176                .as_ref()
1177                .expect("stack_state should be present");
1178
1179            assert!(
1180                ss.resources.contains_key("default-container-env"),
1181                "default-container-env lost in DeploymentState roundtrip! \
1182                 Available: {:?}",
1183                ss.resources.keys().collect::<Vec<_>>()
1184            );
1185        }
1186    }
1187}