1use 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
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22#[serde(rename_all = "snake_case")]
23pub enum StackStatus {
24 Pending,
26 InProgress,
28 Running,
30 Deleted,
32 Failure,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39#[serde(rename_all = "camelCase")]
40pub struct StackState {
41 pub platform: Platform,
43 pub resources: HashMap<String, StackResourceState>,
45 pub resource_prefix: String,
47}
48
49impl StackState {
50 pub fn new(platform: Platform) -> Self {
52 let letters = "abcdefghijklmnopqrstuvwxyz";
55 let first_char = letters
56 .chars()
57 .nth(Uuid::new_v4().as_bytes()[0] as usize % 26)
58 .unwrap();
59 let uuid_part = Uuid::new_v4().simple().to_string()[..7].to_string();
60 let prefix = format!("{}{}", first_char, uuid_part);
61
62 StackState {
63 platform,
64 resources: HashMap::new(),
65 resource_prefix: prefix,
66 }
67 }
68
69 pub fn resource(&self, id: &str) -> Option<&StackResourceState> {
71 self.resources.get(id)
72 }
73
74 pub fn compute_stack_status(&self) -> Result<StackStatus> {
77 let resource_statuses: Vec<ResourceStatus> = self
78 .resources
79 .values()
80 .map(|resource| resource.status)
81 .collect();
82
83 Self::compute_stack_status_from_resources(&resource_statuses)
84 }
85
86 pub fn compute_stack_status_from_resources(
89 resource_statuses: &[ResourceStatus],
90 ) -> Result<StackStatus> {
91 if resource_statuses.is_empty() {
93 return Ok(StackStatus::Pending);
94 }
95
96 if resource_statuses.iter().any(|status| {
98 matches!(
99 status,
100 ResourceStatus::ProvisionFailed
101 | ResourceStatus::UpdateFailed
102 | ResourceStatus::DeleteFailed
103 | ResourceStatus::RefreshFailed
104 )
105 }) {
106 return Ok(StackStatus::Failure);
107 }
108
109 if resource_statuses.iter().any(|status| {
111 matches!(
112 status,
113 ResourceStatus::Pending
114 | ResourceStatus::Provisioning
115 | ResourceStatus::Updating
116 | ResourceStatus::Deleting
117 )
118 }) {
119 return Ok(StackStatus::InProgress);
120 }
121
122 if resource_statuses
124 .iter()
125 .all(|status| matches!(status, ResourceStatus::Running))
126 {
127 return Ok(StackStatus::Running);
128 }
129
130 if resource_statuses
131 .iter()
132 .all(|status| matches!(status, ResourceStatus::Deleted))
133 {
134 return Ok(StackStatus::Deleted);
135 }
136
137 let has_running = resource_statuses
141 .iter()
142 .any(|status| matches!(status, ResourceStatus::Running));
143 let has_deleted = resource_statuses
144 .iter()
145 .any(|status| matches!(status, ResourceStatus::Deleted));
146 let only_running_or_deleted = resource_statuses
147 .iter()
148 .all(|status| matches!(status, ResourceStatus::Running | ResourceStatus::Deleted));
149
150 if has_running && has_deleted && only_running_or_deleted {
151 return Ok(StackStatus::InProgress);
152 }
153
154 let status_strings: Vec<String> = resource_statuses
156 .iter()
157 .map(|status| format!("{:?}", status).to_lowercase().replace('_', "-"))
158 .collect();
159
160 Err(AlienError::new(
161 ErrorData::UnexpectedResourceStatusCombination {
162 resource_statuses: status_strings,
163 operation: "stack status computation".to_string(),
164 },
165 ))
166 }
167
168 pub fn get_resource_outputs<T: ResourceOutputsDefinition + 'static>(
190 &self,
191 resource_id: &str,
192 ) -> Result<&T> {
193 let resource_state = self.resources.get(resource_id).ok_or_else(|| {
194 AlienError::new(ErrorData::ResourceNotFound {
195 resource_id: resource_id.to_string(),
196 available_resources: self.resources.keys().cloned().collect(),
197 })
198 })?;
199
200 let outputs = resource_state.outputs.as_ref().ok_or_else(|| {
201 AlienError::new(ErrorData::ResourceHasNoOutputs {
202 resource_id: resource_id.to_string(),
203 })
204 })?;
205
206 outputs.downcast_ref::<T>().ok_or_else(|| {
207 AlienError::new(ErrorData::UnexpectedResourceType {
208 resource_id: resource_id.to_string(),
209 expected: ResourceType::from_static(std::any::type_name::<T>()),
210 actual: resource_state.resource_type.clone().into(),
211 })
212 })
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "camelCase")]
220pub struct StackResourceState {
221 #[serde(rename = "type")]
223 pub resource_type: String,
224
225 #[serde(rename = "_internal", skip_serializing_if = "Option::is_none")]
229 pub internal_state: Option<serde_json::Value>,
230
231 pub status: ResourceStatus,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub outputs: Option<ResourceOutputs>,
237
238 pub config: Resource,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
244 pub previous_config: Option<Resource>,
245
246 #[serde(default, skip_serializing_if = "is_zero")]
248 #[builder(default)]
249 pub retry_attempt: u32,
250
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub error: Option<AlienError>,
254
255 #[serde(default, skip_serializing_if = "is_false")]
258 #[builder(default)]
259 pub is_externally_provisioned: bool,
260
261 #[serde(skip_serializing_if = "Option::is_none")]
264 pub lifecycle: Option<ResourceLifecycle>,
265
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 #[builder(default = vec![])]
270 pub dependencies: Vec<ResourceRef>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
276 pub last_failed_state: Option<serde_json::Value>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
283 pub remote_binding_params: Option<serde_json::Value>,
284}
285
286impl StackResourceState {
287 pub fn new_pending(
289 resource_type: String,
290 config: Resource,
291 lifecycle: Option<ResourceLifecycle>,
292 dependencies: Vec<ResourceRef>,
293 ) -> Self {
294 Self {
295 resource_type,
296 internal_state: None,
297 status: ResourceStatus::Pending,
298 outputs: None,
299 config,
300 previous_config: None,
301 retry_attempt: 0,
302 error: None,
303 is_externally_provisioned: false,
304 lifecycle,
305 dependencies,
306 last_failed_state: None,
307 remote_binding_params: None,
308 }
309 }
310
311 pub fn with_updates<F>(&self, update_fn: F) -> Self
313 where
314 F: FnOnce(&mut Self),
315 {
316 let mut new_state = self.clone();
317 update_fn(&mut new_state);
318 new_state
319 }
320
321 pub fn with_failure(&self, status: ResourceStatus, error: AlienError) -> Self {
323 self.with_updates(|state| {
324 state.status = status;
325 state.error = Some(error);
326 state.retry_attempt = 0;
327 })
328 }
329}
330
331fn is_zero(num: &u32) -> bool {
333 *num == 0
334}
335
336fn is_false(b: &bool) -> bool {
338 !*b
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::{Function, FunctionCode, FunctionOutputs, ResourceType, Storage, StorageOutputs};
345
346 #[test]
347 fn test_get_resource_outputs_success() {
348 let mut stack_state = StackState::new(Platform::Aws);
349
350 let function_outputs = FunctionOutputs {
352 function_name: "test-function".to_string(),
353 url: Some("https://example.lambda-url.us-east-1.on.aws/".to_string()),
354 identifier: Some(
355 "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
356 ),
357 load_balancer_endpoint: None,
358 commands_push_target: None,
359 };
360
361 let test_function = Function::new("test-function".to_string())
362 .code(FunctionCode::Image {
363 image: "test:latest".to_string(),
364 })
365 .permissions("test-profile".to_string())
366 .build();
367
368 let resource_state = StackResourceState::new_pending(
369 "function".to_string(),
370 Resource::new(test_function),
371 None,
372 Vec::new(),
373 )
374 .with_updates(|state| {
375 state.status = ResourceStatus::Running;
376 state.outputs = Some(ResourceOutputs::new(function_outputs.clone()));
377 });
378
379 stack_state
380 .resources
381 .insert("test-function".to_string(), resource_state);
382
383 let retrieved_outputs = stack_state
385 .get_resource_outputs::<FunctionOutputs>("test-function")
386 .unwrap();
387 assert_eq!(retrieved_outputs.function_name, "test-function");
388 assert_eq!(
389 retrieved_outputs.url,
390 Some("https://example.lambda-url.us-east-1.on.aws/".to_string())
391 );
392 assert_eq!(
393 retrieved_outputs.identifier,
394 Some("arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string())
395 );
396 }
397
398 #[test]
399 fn test_get_resource_outputs_resource_not_found() {
400 let stack_state = StackState::new(Platform::Aws);
401
402 let result = stack_state.get_resource_outputs::<FunctionOutputs>("nonexistent-function");
404 assert!(result.is_err());
405 let error = result.unwrap_err();
406
407 let error_data = &error.error;
409 if let Some(ErrorData::ResourceNotFound {
410 resource_id,
411 available_resources,
412 }) = error_data
413 {
414 assert_eq!(resource_id, "nonexistent-function");
415 assert_eq!(available_resources, &Vec::<String>::new());
416 } else {
417 panic!("Expected ResourceNotFound error, got: {:?}", error_data);
418 }
419
420 let error_message = error.to_string();
422 assert!(error_message.contains("Resource 'nonexistent-function' not found in stack state"));
423 assert!(error_message.contains("Available resources: []"));
424 }
425
426 #[test]
427 fn test_get_resource_outputs_no_outputs() {
428 let mut stack_state = StackState::new(Platform::Aws);
429
430 let test_function_2 = Function::new("test-function".to_string())
432 .code(FunctionCode::Image {
433 image: "test:latest".to_string(),
434 })
435 .permissions("test-profile".to_string())
436 .build();
437
438 let resource_state = StackResourceState::new_pending(
439 "function".to_string(),
440 Resource::new(test_function_2),
441 None,
442 Vec::new(),
443 )
444 .with_updates(|state| {
445 state.status = ResourceStatus::Provisioning;
446 });
447
448 stack_state
449 .resources
450 .insert("test-function".to_string(), resource_state);
451
452 let result = stack_state.get_resource_outputs::<FunctionOutputs>("test-function");
454 assert!(result.is_err());
455 let error = result.unwrap_err();
456
457 let error_data = &error.error;
459 if let Some(ErrorData::ResourceHasNoOutputs { resource_id, .. }) = error_data {
460 assert_eq!(resource_id, "test-function");
461 } else {
462 panic!("Expected ResourceHasNoOutputs error, got: {:?}", error_data);
463 }
464
465 let error_message = error.to_string();
467 assert!(error_message.contains("Resource 'test-function' has no outputs"));
468 }
469
470 #[test]
471 fn test_get_resource_outputs_wrong_type() {
472 let mut stack_state = StackState::new(Platform::Aws);
473
474 let storage_outputs = StorageOutputs {
476 bucket_name: "test-bucket".to_string(),
477 };
478
479 let test_storage = Storage::new("test-storage".to_string()).build();
480
481 let resource_state = StackResourceState::new_pending(
482 "storage".to_string(),
483 Resource::new(test_storage),
484 None,
485 Vec::new(),
486 )
487 .with_updates(|state| {
488 state.status = ResourceStatus::Running;
489 state.outputs = Some(ResourceOutputs::new(storage_outputs));
490 });
491
492 stack_state
493 .resources
494 .insert("test-storage".to_string(), resource_state);
495
496 let result = stack_state.get_resource_outputs::<FunctionOutputs>("test-storage");
498 assert!(result.is_err());
499 let error = result.unwrap_err();
500
501 let error_data = &error.error;
503 if let Some(ErrorData::UnexpectedResourceType {
504 resource_id,
505 expected,
506 actual,
507 }) = error_data
508 {
509 assert_eq!(resource_id, "test-storage");
510 assert!(
511 expected.0.contains("FunctionOutputs"),
512 "expected should reference FunctionOutputs, got: {}",
513 expected.0
514 );
515 assert_eq!(*actual, ResourceType::from_static("storage"));
516 } else {
517 panic!(
518 "Expected UnexpectedResourceType error, got: {:?}",
519 error_data
520 );
521 }
522 }
523
524 #[test]
525 fn test_get_resource_outputs_usage_example() {
526 let mut stack_state = StackState::new(Platform::Aws);
527
528 let function_outputs = FunctionOutputs {
530 function_name: "test-alien-function".to_string(),
531 url: Some("https://test.lambda-url.us-east-1.on.aws/".to_string()),
532 identifier: Some(
533 "arn:aws:lambda:us-east-1:123456789012:function:test-alien-function".to_string(),
534 ),
535 load_balancer_endpoint: None,
536 commands_push_target: None,
537 };
538
539 let test_alien_function = Function::new("test-alien-function".to_string())
540 .code(FunctionCode::Image {
541 image: "test:latest".to_string(),
542 })
543 .permissions("test-profile".to_string())
544 .build();
545
546 let resource_state = StackResourceState {
547 resource_type: "function".to_string(),
548 internal_state: None,
549 status: ResourceStatus::Running,
550 outputs: Some(ResourceOutputs::new(function_outputs)),
551 config: Resource::new(test_alien_function),
552 previous_config: None,
553 retry_attempt: 0,
554 error: None,
555 is_externally_provisioned: false,
556 lifecycle: None,
557 dependencies: Vec::new(),
558 last_failed_state: None,
559 remote_binding_params: None,
560 };
561
562 stack_state
563 .resources
564 .insert("test-alien-function".to_string(), resource_state);
565
566 let function_outputs = stack_state
568 .get_resource_outputs::<FunctionOutputs>("test-alien-function")
569 .unwrap();
570
571 let function_url = function_outputs
572 .url
573 .as_ref()
574 .ok_or_else(|| "Function URL not found in stack state")
575 .unwrap();
576
577 assert_eq!(function_url, "https://test.lambda-url.us-east-1.on.aws/");
578 }
579
580 #[cfg(test)]
582 mod stack_status_tests {
583 use super::*;
584
585 #[test]
586 fn test_compute_stack_status_empty_resources() {
587 let result = StackState::compute_stack_status_from_resources(&[]).unwrap();
588 assert_eq!(result, StackStatus::Pending);
589 }
590
591 #[test]
592 fn test_compute_stack_status_single_pending() {
593 let result =
594 StackState::compute_stack_status_from_resources(&[ResourceStatus::Pending])
595 .unwrap();
596 assert_eq!(result, StackStatus::InProgress);
597 }
598
599 #[test]
600 fn test_compute_stack_status_single_provisioning() {
601 let result =
602 StackState::compute_stack_status_from_resources(&[ResourceStatus::Provisioning])
603 .unwrap();
604 assert_eq!(result, StackStatus::InProgress);
605 }
606
607 #[test]
608 fn test_compute_stack_status_single_updating() {
609 let result =
610 StackState::compute_stack_status_from_resources(&[ResourceStatus::Updating])
611 .unwrap();
612 assert_eq!(result, StackStatus::InProgress);
613 }
614
615 #[test]
616 fn test_compute_stack_status_single_deleting() {
617 let result =
618 StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleting])
619 .unwrap();
620 assert_eq!(result, StackStatus::InProgress);
621 }
622
623 #[test]
624 fn test_compute_stack_status_single_provision_failed() {
625 let result =
626 StackState::compute_stack_status_from_resources(&[ResourceStatus::ProvisionFailed])
627 .unwrap();
628 assert_eq!(result, StackStatus::Failure);
629 }
630
631 #[test]
632 fn test_compute_stack_status_single_update_failed() {
633 let result =
634 StackState::compute_stack_status_from_resources(&[ResourceStatus::UpdateFailed])
635 .unwrap();
636 assert_eq!(result, StackStatus::Failure);
637 }
638
639 #[test]
640 fn test_compute_stack_status_single_delete_failed() {
641 let result =
642 StackState::compute_stack_status_from_resources(&[ResourceStatus::DeleteFailed])
643 .unwrap();
644 assert_eq!(result, StackStatus::Failure);
645 }
646
647 #[test]
648 fn test_compute_stack_status_single_refresh_failed() {
649 let result =
650 StackState::compute_stack_status_from_resources(&[ResourceStatus::RefreshFailed])
651 .unwrap();
652 assert_eq!(result, StackStatus::Failure);
653 }
654
655 #[test]
656 fn test_compute_stack_status_single_running() {
657 let result =
658 StackState::compute_stack_status_from_resources(&[ResourceStatus::Running])
659 .unwrap();
660 assert_eq!(result, StackStatus::Running);
661 }
662
663 #[test]
664 fn test_compute_stack_status_single_deleted() {
665 let result =
666 StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleted])
667 .unwrap();
668 assert_eq!(result, StackStatus::Deleted);
669 }
670
671 #[test]
672 fn test_compute_stack_status_all_running() {
673 let statuses = vec![
674 ResourceStatus::Running,
675 ResourceStatus::Running,
676 ResourceStatus::Running,
677 ];
678 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
679 assert_eq!(result, StackStatus::Running);
680 }
681
682 #[test]
683 fn test_compute_stack_status_all_deleted() {
684 let statuses = vec![
685 ResourceStatus::Deleted,
686 ResourceStatus::Deleted,
687 ResourceStatus::Deleted,
688 ];
689 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
690 assert_eq!(result, StackStatus::Deleted);
691 }
692
693 #[test]
694 fn test_compute_stack_status_all_pending() {
695 let statuses = vec![
696 ResourceStatus::Pending,
697 ResourceStatus::Pending,
698 ResourceStatus::Pending,
699 ];
700 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
701 assert_eq!(result, StackStatus::InProgress);
702 }
703
704 #[test]
705 fn test_compute_stack_status_all_provisioning() {
706 let statuses = vec![
707 ResourceStatus::Provisioning,
708 ResourceStatus::Provisioning,
709 ResourceStatus::Provisioning,
710 ];
711 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
712 assert_eq!(result, StackStatus::InProgress);
713 }
714
715 #[test]
716 fn test_compute_stack_status_all_provision_failed() {
717 let statuses = vec![
718 ResourceStatus::ProvisionFailed,
719 ResourceStatus::ProvisionFailed,
720 ResourceStatus::ProvisionFailed,
721 ];
722 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
723 assert_eq!(result, StackStatus::Failure);
724 }
725
726 #[test]
727 fn test_compute_stack_status_mixed_with_failure() {
728 let statuses = vec![
729 ResourceStatus::Running,
730 ResourceStatus::ProvisionFailed,
731 ResourceStatus::Updating,
732 ];
733 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
734 assert_eq!(result, StackStatus::Failure);
735 }
736
737 #[test]
738 fn test_compute_stack_status_failure_with_success() {
739 let statuses = vec![
740 ResourceStatus::Running,
741 ResourceStatus::UpdateFailed,
742 ResourceStatus::Running,
743 ];
744 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
745 assert_eq!(result, StackStatus::Failure);
746 }
747
748 #[test]
749 fn test_compute_stack_status_failure_with_in_progress() {
750 let statuses = vec![
751 ResourceStatus::Provisioning,
752 ResourceStatus::DeleteFailed,
753 ResourceStatus::Deleting,
754 ];
755 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
756 assert_eq!(result, StackStatus::Failure);
757 }
758
759 #[test]
760 fn test_compute_stack_status_any_in_progress() {
761 let statuses = vec![
762 ResourceStatus::Running,
763 ResourceStatus::Updating,
764 ResourceStatus::Running,
765 ];
766 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
767 assert_eq!(result, StackStatus::InProgress);
768 }
769
770 #[test]
771 fn test_compute_stack_status_mixed_in_progress_states() {
772 let statuses = vec![
773 ResourceStatus::Pending,
774 ResourceStatus::Provisioning,
775 ResourceStatus::Updating,
776 ResourceStatus::Deleting,
777 ];
778 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
779 assert_eq!(result, StackStatus::InProgress);
780 }
781
782 #[test]
783 fn test_compute_stack_status_deletion_in_progress() {
784 let statuses = vec![ResourceStatus::Running, ResourceStatus::Deleted];
787 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
788 assert_eq!(result, StackStatus::InProgress);
789 }
790
791 #[test]
792 fn test_compute_stack_status_deletion_in_progress_many_resources() {
793 let statuses = vec![
795 ResourceStatus::Running,
796 ResourceStatus::Running,
797 ResourceStatus::Deleted,
798 ResourceStatus::Deleted,
799 ResourceStatus::Running,
800 ResourceStatus::Running,
801 ResourceStatus::Running,
802 ResourceStatus::Running,
803 ResourceStatus::Running,
804 ];
805 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
806 assert_eq!(result, StackStatus::InProgress);
807 }
808
809 #[test]
810 fn test_compute_stack_status_mixed_terminal_with_in_progress() {
811 let statuses = vec![
812 ResourceStatus::Running,
813 ResourceStatus::Deleted,
814 ResourceStatus::Pending,
815 ];
816 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
817 assert_eq!(result, StackStatus::InProgress);
818 }
819
820 #[test]
821 fn test_compute_stack_status_large_number_of_resources() {
822 let statuses: Vec<ResourceStatus> = (0..100).map(|_| ResourceStatus::Running).collect();
823 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
824 assert_eq!(result, StackStatus::Running);
825 }
826
827 #[test]
828 fn test_compute_stack_status_single_failure_among_many() {
829 let mut statuses: Vec<ResourceStatus> =
830 (0..50).map(|_| ResourceStatus::Running).collect();
831 statuses.push(ResourceStatus::ProvisionFailed);
832 statuses.extend((0..49).map(|_| ResourceStatus::Provisioning));
833
834 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
835 assert_eq!(result, StackStatus::Failure);
836 }
837
838 #[test]
839 fn test_compute_stack_status_failure_priority_over_in_progress() {
840 let statuses = vec![
841 ResourceStatus::ProvisionFailed,
842 ResourceStatus::UpdateFailed,
843 ResourceStatus::DeleteFailed,
844 ResourceStatus::Provisioning,
845 ResourceStatus::Updating,
846 ResourceStatus::Deleting,
847 ];
848 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
849 assert_eq!(result, StackStatus::Failure);
850 }
851
852 #[test]
853 fn test_compute_stack_status_mixed_success_and_in_progress() {
854 let statuses = vec![
855 ResourceStatus::Running,
856 ResourceStatus::Provisioning,
857 ResourceStatus::Running,
858 ];
859 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
860 assert_eq!(result, StackStatus::InProgress);
861 }
862
863 #[test]
864 fn test_stack_state_status_computation() {
865 let mut stack_state = StackState::new(Platform::Aws);
866
867 assert_eq!(
869 stack_state.compute_stack_status().unwrap(),
870 StackStatus::Pending
871 );
872
873 let test_function = Function::new("test-function".to_string())
875 .code(FunctionCode::Image {
876 image: "test:latest".to_string(),
877 })
878 .permissions("test-profile".to_string())
879 .build();
880
881 let resource_state = StackResourceState::new_pending(
882 "function".to_string(),
883 Resource::new(test_function),
884 None,
885 Vec::new(),
886 )
887 .with_updates(|state| {
888 state.status = ResourceStatus::Running;
889 });
890
891 stack_state
892 .resources
893 .insert("test-function".to_string(), resource_state);
894
895 assert_eq!(
897 stack_state.compute_stack_status().unwrap(),
898 StackStatus::Running
899 );
900 }
901
902 #[test]
906 fn test_external_container_env_survives_json_roundtrip() {
907 use crate::resources::AzureContainerAppsEnvironmentOutputs;
908 use crate::AzureContainerAppsEnvironment;
909
910 let mut stack_state = StackState::new(Platform::Azure);
911
912 let env_config =
915 AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
916 let env_outputs = AzureContainerAppsEnvironmentOutputs {
917 environment_name: "test-env".to_string(),
918 resource_id: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.App/managedEnvironments/test-env".to_string(),
919 resource_group_name: "shared-rg".to_string(),
920 default_domain: "test-env.azurecontainerapps.io".to_string(),
921 static_ip: Some("10.0.0.1".to_string()),
922 };
923
924 let env_state = StackResourceState::new_pending(
925 AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
926 Resource::new(env_config),
927 Some(ResourceLifecycle::Frozen),
928 Vec::new(),
929 )
930 .with_updates(|state| {
931 state.status = ResourceStatus::Running;
932 state.is_externally_provisioned = true;
933 state.outputs = Some(ResourceOutputs::new(env_outputs.clone()));
934 });
935
936 stack_state
937 .resources
938 .insert("default-container-env".to_string(), env_state);
939
940 let test_function = Function::new("alien-rs-fn".to_string())
942 .code(FunctionCode::Image {
943 image: "test:latest".to_string(),
944 })
945 .permissions("execution".to_string())
946 .build();
947
948 let fn_state = StackResourceState::new_pending(
949 "function".to_string(),
950 Resource::new(test_function),
951 Some(ResourceLifecycle::Live),
952 vec![crate::ResourceRef::new(
953 AzureContainerAppsEnvironment::RESOURCE_TYPE,
954 "default-container-env",
955 )],
956 )
957 .with_updates(|state| {
958 state.status = ResourceStatus::Running;
959 });
960
961 stack_state
962 .resources
963 .insert("alien-rs-fn".to_string(), fn_state);
964
965 assert!(
967 stack_state.resources.contains_key("default-container-env"),
968 "default-container-env should be in state before roundtrip"
969 );
970 assert_eq!(stack_state.resources.len(), 2);
971
972 let json_value = serde_json::to_value(&stack_state)
980 .expect("StackState serialization to Value should not fail");
981
982 let deserialized_from_value: StackState = serde_json::from_value(json_value)
984 .expect("StackState deserialization from Value should not fail");
985
986 assert!(
987 deserialized_from_value
988 .resources
989 .contains_key("default-container-env"),
990 "default-container-env lost during to_value/from_value roundtrip! \
991 Available: {:?}",
992 deserialized_from_value.resources.keys().collect::<Vec<_>>()
993 );
994
995 let json_string = serde_json::to_string(&deserialized_from_value)
997 .expect("StackState serialization to String should not fail");
998
999 let deserialized_from_str: StackState = serde_json::from_str(&json_string)
1001 .expect("StackState deserialization from String should not fail");
1002
1003 assert!(
1004 deserialized_from_str
1005 .resources
1006 .contains_key("default-container-env"),
1007 "default-container-env lost during to_string/from_str roundtrip! \
1008 Available: {:?}",
1009 deserialized_from_str.resources.keys().collect::<Vec<_>>()
1010 );
1011
1012 let outputs = deserialized_from_str
1014 .get_resource_outputs::<AzureContainerAppsEnvironmentOutputs>(
1015 "default-container-env",
1016 )
1017 .expect("Should be able to get container env outputs after roundtrip");
1018 assert_eq!(outputs.environment_name, "test-env");
1019 assert_eq!(outputs.resource_group_name, "shared-rg");
1020 assert_eq!(outputs.static_ip, Some("10.0.0.1".to_string()));
1021
1022 let env_resource = deserialized_from_str
1024 .resources
1025 .get("default-container-env")
1026 .unwrap();
1027 assert!(
1028 env_resource.is_externally_provisioned,
1029 "is_externally_provisioned flag should survive roundtrip"
1030 );
1031 assert_eq!(env_resource.status, ResourceStatus::Running);
1032 assert_eq!(env_resource.lifecycle, Some(ResourceLifecycle::Frozen));
1033 }
1034
1035 #[test]
1038 fn test_deployment_state_roundtrip_preserves_external_binding() {
1039 use crate::resources::AzureContainerAppsEnvironmentOutputs;
1040 use crate::{AzureContainerAppsEnvironment, DeploymentState, DeploymentStatus};
1041
1042 let mut stack_state = StackState::new(Platform::Azure);
1043
1044 let env_config =
1045 AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
1046 let env_outputs = AzureContainerAppsEnvironmentOutputs {
1047 environment_name: "test-env".to_string(),
1048 resource_id: "/subscriptions/sub/rg/env".to_string(),
1049 resource_group_name: "shared-rg".to_string(),
1050 default_domain: "test.io".to_string(),
1051 static_ip: None,
1052 };
1053
1054 let env_state = StackResourceState::new_pending(
1055 AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
1056 Resource::new(env_config),
1057 Some(ResourceLifecycle::Frozen),
1058 Vec::new(),
1059 )
1060 .with_updates(|state| {
1061 state.status = ResourceStatus::Running;
1062 state.is_externally_provisioned = true;
1063 state.outputs = Some(ResourceOutputs::new(env_outputs));
1064 });
1065
1066 stack_state
1067 .resources
1068 .insert("default-container-env".to_string(), env_state);
1069
1070 let deployment_state = DeploymentState {
1072 status: DeploymentStatus::Provisioning,
1073 platform: Platform::Azure,
1074 current_release: None,
1075 target_release: None,
1076 stack_state: Some(stack_state),
1077 environment_info: None,
1078 runtime_metadata: None,
1079 retry_requested: false,
1080 protocol_version: 1,
1081 };
1082
1083 let json_value =
1085 serde_json::to_value(&deployment_state).expect("DeploymentState to_value failed");
1086
1087 let deserialized: DeploymentState =
1088 serde_json::from_value(json_value).expect("DeploymentState from_value failed");
1089
1090 let ss = deserialized
1091 .stack_state
1092 .as_ref()
1093 .expect("stack_state should be present");
1094
1095 assert!(
1096 ss.resources.contains_key("default-container-env"),
1097 "default-container-env lost in DeploymentState roundtrip! \
1098 Available: {:?}",
1099 ss.resources.keys().collect::<Vec<_>>()
1100 );
1101 }
1102 }
1103}