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