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
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#[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 Pending,
62 InProgress,
64 Running,
66 Deleted,
68 Failure,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "camelCase")]
76pub struct StackState {
77 pub platform: Platform,
79 pub resources: HashMap<String, StackResourceState>,
81 pub resource_prefix: String,
83}
84
85impl StackState {
86 pub fn new(platform: Platform) -> Self {
88 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 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 pub fn resource(&self, id: &str) -> Option<&StackResourceState> {
117 self.resources.get(id)
118 }
119
120 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 pub fn compute_stack_status_from_resources(
135 resource_statuses: &[ResourceStatus],
136 ) -> Result<StackStatus> {
137 if resource_statuses.is_empty() {
139 return Ok(StackStatus::Pending);
140 }
141
142 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
265#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
266#[serde(rename_all = "camelCase")]
267pub struct StackResourceState {
268 #[serde(rename = "type")]
270 pub resource_type: String,
271
272 #[serde(rename = "_internal", skip_serializing_if = "Option::is_none")]
276 pub internal_state: Option<serde_json::Value>,
277
278 pub status: ResourceStatus,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub outputs: Option<ResourceOutputs>,
284
285 pub config: Resource,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
291 pub previous_config: Option<Resource>,
292
293 #[serde(default, skip_serializing_if = "is_zero")]
295 #[builder(default)]
296 pub retry_attempt: u32,
297
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub error: Option<AlienError>,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
305 pub lifecycle: Option<ResourceLifecycle>,
306
307 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub controller_platform: Option<Platform>,
311
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 #[builder(default = vec![])]
316 pub dependencies: Vec<ResourceRef>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
322 pub last_failed_state: Option<serde_json::Value>,
323
324 #[serde(skip_serializing_if = "Option::is_none")]
329 pub remote_binding_params: Option<serde_json::Value>,
330}
331
332impl StackResourceState {
333 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 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 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
377fn 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 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 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 let result = stack_state.get_resource_outputs::<WorkerOutputs>("nonexistent-worker");
469 assert!(result.is_err());
470 let error = result.unwrap_err();
471
472 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 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 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 let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-worker");
519 assert!(result.is_err());
520 let error = result.unwrap_err();
521
522 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 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 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 let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-storage");
563 assert!(result.is_err());
564 let error = result.unwrap_err();
565
566 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 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 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 #[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 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 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 assert_eq!(
934 stack_state.compute_stack_status().unwrap(),
935 StackStatus::Pending
936 );
937
938 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 assert_eq!(
962 stack_state.compute_stack_status().unwrap(),
963 StackStatus::Running
964 );
965 }
966
967 #[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 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 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 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 let json_value = serde_json::to_value(&stack_state)
1045 .expect("StackState serialization to Value should not fail");
1046
1047 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 let json_string = serde_json::to_string(&deserialized_from_value)
1062 .expect("StackState serialization to String should not fail");
1063
1064 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 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 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]
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 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 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}