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