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