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 )
164 }) {
165 return Ok(StackStatus::InProgress);
166 }
167
168 if resource_statuses
170 .iter()
171 .all(|status| matches!(status, ResourceStatus::Running))
172 {
173 return Ok(StackStatus::Running);
174 }
175
176 if resource_statuses
177 .iter()
178 .all(|status| matches!(status, ResourceStatus::Deleted))
179 {
180 return Ok(StackStatus::Deleted);
181 }
182
183 let has_running = resource_statuses
187 .iter()
188 .any(|status| matches!(status, ResourceStatus::Running));
189 let has_deleted = resource_statuses
190 .iter()
191 .any(|status| matches!(status, ResourceStatus::Deleted));
192 let only_running_or_deleted = resource_statuses
193 .iter()
194 .all(|status| matches!(status, ResourceStatus::Running | ResourceStatus::Deleted));
195
196 if has_running && has_deleted && only_running_or_deleted {
197 return Ok(StackStatus::InProgress);
198 }
199
200 let status_strings: Vec<String> = resource_statuses
202 .iter()
203 .map(|status| format!("{:?}", status).to_lowercase().replace('_', "-"))
204 .collect();
205
206 Err(AlienError::new(
207 ErrorData::UnexpectedResourceStatusCombination {
208 resource_statuses: status_strings,
209 operation: "stack status computation".to_string(),
210 },
211 ))
212 }
213
214 pub fn get_resource_outputs<T: ResourceOutputsDefinition + 'static>(
236 &self,
237 resource_id: &str,
238 ) -> Result<&T> {
239 let resource_state = self.resources.get(resource_id).ok_or_else(|| {
240 AlienError::new(ErrorData::ResourceNotFound {
241 resource_id: resource_id.to_string(),
242 available_resources: self.resources.keys().cloned().collect(),
243 })
244 })?;
245
246 let outputs = resource_state.outputs.as_ref().ok_or_else(|| {
247 AlienError::new(ErrorData::ResourceHasNoOutputs {
248 resource_id: resource_id.to_string(),
249 })
250 })?;
251
252 outputs.downcast_ref::<T>().ok_or_else(|| {
253 AlienError::new(ErrorData::UnexpectedResourceType {
254 resource_id: resource_id.to_string(),
255 expected: ResourceType::from_static(std::any::type_name::<T>()),
256 actual: resource_state.resource_type.clone().into(),
257 })
258 })
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
264#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
265#[serde(rename_all = "camelCase")]
266pub struct StackResourceState {
267 #[serde(rename = "type")]
269 pub resource_type: String,
270
271 #[serde(rename = "_internal", skip_serializing_if = "Option::is_none")]
275 pub internal_state: Option<serde_json::Value>,
276
277 pub status: ResourceStatus,
279
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub outputs: Option<ResourceOutputs>,
283
284 pub config: Resource,
286
287 #[serde(skip_serializing_if = "Option::is_none")]
290 pub previous_config: Option<Resource>,
291
292 #[serde(default, skip_serializing_if = "is_zero")]
294 #[builder(default)]
295 pub retry_attempt: u32,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub error: Option<AlienError>,
300
301 #[serde(skip_serializing_if = "Option::is_none")]
304 pub lifecycle: Option<ResourceLifecycle>,
305
306 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub controller_platform: Option<Platform>,
310
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 #[builder(default = vec![])]
315 pub dependencies: Vec<ResourceRef>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
321 pub last_failed_state: Option<serde_json::Value>,
322
323 #[serde(skip_serializing_if = "Option::is_none")]
328 pub remote_binding_params: Option<serde_json::Value>,
329}
330
331impl StackResourceState {
332 pub fn new_pending(
334 resource_type: String,
335 config: Resource,
336 lifecycle: Option<ResourceLifecycle>,
337 dependencies: Vec<ResourceRef>,
338 ) -> Self {
339 Self {
340 resource_type,
341 internal_state: None,
342 status: ResourceStatus::Pending,
343 outputs: None,
344 config,
345 previous_config: None,
346 retry_attempt: 0,
347 error: None,
348 lifecycle,
349 controller_platform: None,
350 dependencies,
351 last_failed_state: None,
352 remote_binding_params: None,
353 }
354 }
355
356 pub fn with_updates<F>(&self, update_fn: F) -> Self
358 where
359 F: FnOnce(&mut Self),
360 {
361 let mut new_state = self.clone();
362 update_fn(&mut new_state);
363 new_state
364 }
365
366 pub fn with_failure(&self, status: ResourceStatus, error: AlienError) -> Self {
368 self.with_updates(|state| {
369 state.status = status;
370 state.error = Some(error);
371 state.retry_attempt = 0;
372 })
373 }
374}
375
376fn is_zero(num: &u32) -> bool {
378 *num == 0
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::{ResourceType, Storage, StorageOutputs, Worker, WorkerCode, WorkerOutputs};
385
386 #[test]
387 fn resource_prefix_validation_accepts_canonical_prefixes() {
388 for prefix in ["abc", "a-b", "acme-prod", "a1-b2-c3", "a1234567890"] {
389 assert!(is_valid_resource_prefix(prefix), "{prefix}");
390 }
391 }
392
393 #[test]
394 fn resource_prefix_validation_rejects_non_canonical_prefixes() {
395 for prefix in [
396 "",
397 "ab",
398 "a-",
399 "-ab",
400 "Aab",
401 "a_b",
402 "a--b",
403 "a.b",
404 "a1234567890123456789012345678901234567890",
405 ] {
406 assert!(!is_valid_resource_prefix(prefix), "{prefix}");
407 }
408 }
409
410 #[test]
411 fn test_get_resource_outputs_success() {
412 let mut stack_state = StackState::new(Platform::Aws);
413
414 let worker_outputs = WorkerOutputs {
416 worker_name: "test-worker".to_string(),
417 url: Some("https://example.lambda-url.us-east-1.on.aws/".to_string()),
418 identifier: Some(
419 "arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string(),
420 ),
421 load_balancer_endpoint: None,
422 commands_push_target: None,
423 };
424
425 let test_worker = Worker::new("test-worker".to_string())
426 .code(WorkerCode::Image {
427 image: "test:latest".to_string(),
428 })
429 .permissions("test-profile".to_string())
430 .build();
431
432 let resource_state = StackResourceState::new_pending(
433 "worker".to_string(),
434 Resource::new(test_worker),
435 None,
436 Vec::new(),
437 )
438 .with_updates(|state| {
439 state.status = ResourceStatus::Running;
440 state.outputs = Some(ResourceOutputs::new(worker_outputs.clone()));
441 });
442
443 stack_state
444 .resources
445 .insert("test-worker".to_string(), resource_state);
446
447 let retrieved_outputs = stack_state
449 .get_resource_outputs::<WorkerOutputs>("test-worker")
450 .unwrap();
451 assert_eq!(retrieved_outputs.worker_name, "test-worker");
452 assert_eq!(
453 retrieved_outputs.url,
454 Some("https://example.lambda-url.us-east-1.on.aws/".to_string())
455 );
456 assert_eq!(
457 retrieved_outputs.identifier,
458 Some("arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string())
459 );
460 }
461
462 #[test]
463 fn test_get_resource_outputs_resource_not_found() {
464 let stack_state = StackState::new(Platform::Aws);
465
466 let result = stack_state.get_resource_outputs::<WorkerOutputs>("nonexistent-worker");
468 assert!(result.is_err());
469 let error = result.unwrap_err();
470
471 let error_data = &error.error;
473 if let Some(ErrorData::ResourceNotFound {
474 resource_id,
475 available_resources,
476 }) = error_data
477 {
478 assert_eq!(resource_id, "nonexistent-worker");
479 assert_eq!(available_resources, &Vec::<String>::new());
480 } else {
481 panic!("Expected ResourceNotFound error, got: {:?}", error_data);
482 }
483
484 let error_message = error.to_string();
486 assert!(error_message.contains("Resource 'nonexistent-worker' not found in stack state"));
487 assert!(error_message.contains("Available resources: []"));
488 }
489
490 #[test]
491 fn test_get_resource_outputs_no_outputs() {
492 let mut stack_state = StackState::new(Platform::Aws);
493
494 let test_worker_2 = Worker::new("test-worker".to_string())
496 .code(WorkerCode::Image {
497 image: "test:latest".to_string(),
498 })
499 .permissions("test-profile".to_string())
500 .build();
501
502 let resource_state = StackResourceState::new_pending(
503 "worker".to_string(),
504 Resource::new(test_worker_2),
505 None,
506 Vec::new(),
507 )
508 .with_updates(|state| {
509 state.status = ResourceStatus::Provisioning;
510 });
511
512 stack_state
513 .resources
514 .insert("test-worker".to_string(), resource_state);
515
516 let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-worker");
518 assert!(result.is_err());
519 let error = result.unwrap_err();
520
521 let error_data = &error.error;
523 if let Some(ErrorData::ResourceHasNoOutputs { resource_id, .. }) = error_data {
524 assert_eq!(resource_id, "test-worker");
525 } else {
526 panic!("Expected ResourceHasNoOutputs error, got: {:?}", error_data);
527 }
528
529 let error_message = error.to_string();
531 assert!(error_message.contains("Resource 'test-worker' has no outputs"));
532 }
533
534 #[test]
535 fn test_get_resource_outputs_wrong_type() {
536 let mut stack_state = StackState::new(Platform::Aws);
537
538 let storage_outputs = StorageOutputs {
540 bucket_name: "test-bucket".to_string(),
541 };
542
543 let test_storage = Storage::new("test-storage".to_string()).build();
544
545 let resource_state = StackResourceState::new_pending(
546 "storage".to_string(),
547 Resource::new(test_storage),
548 None,
549 Vec::new(),
550 )
551 .with_updates(|state| {
552 state.status = ResourceStatus::Running;
553 state.outputs = Some(ResourceOutputs::new(storage_outputs));
554 });
555
556 stack_state
557 .resources
558 .insert("test-storage".to_string(), resource_state);
559
560 let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-storage");
562 assert!(result.is_err());
563 let error = result.unwrap_err();
564
565 let error_data = &error.error;
567 if let Some(ErrorData::UnexpectedResourceType {
568 resource_id,
569 expected,
570 actual,
571 }) = error_data
572 {
573 assert_eq!(resource_id, "test-storage");
574 assert!(
575 expected.0.contains("WorkerOutputs"),
576 "expected should reference WorkerOutputs, got: {}",
577 expected.0
578 );
579 assert_eq!(*actual, ResourceType::from_static("storage"));
580 } else {
581 panic!(
582 "Expected UnexpectedResourceType error, got: {:?}",
583 error_data
584 );
585 }
586 }
587
588 #[test]
589 fn test_get_resource_outputs_usage_example() {
590 let mut stack_state = StackState::new(Platform::Aws);
591
592 let worker_outputs = WorkerOutputs {
594 worker_name: "test-alien-worker".to_string(),
595 url: Some("https://test.lambda-url.us-east-1.on.aws/".to_string()),
596 identifier: Some(
597 "arn:aws:lambda:us-east-1:123456789012:function:test-alien-worker".to_string(),
598 ),
599 load_balancer_endpoint: None,
600 commands_push_target: None,
601 };
602
603 let test_alien_worker = Worker::new("test-alien-worker".to_string())
604 .code(WorkerCode::Image {
605 image: "test:latest".to_string(),
606 })
607 .permissions("test-profile".to_string())
608 .build();
609
610 let resource_state = StackResourceState {
611 resource_type: "worker".to_string(),
612 internal_state: None,
613 status: ResourceStatus::Running,
614 outputs: Some(ResourceOutputs::new(worker_outputs)),
615 config: Resource::new(test_alien_worker),
616 previous_config: None,
617 retry_attempt: 0,
618 error: None,
619 lifecycle: None,
620 dependencies: Vec::new(),
621 last_failed_state: None,
622 remote_binding_params: None,
623 controller_platform: None,
624 };
625
626 stack_state
627 .resources
628 .insert("test-alien-worker".to_string(), resource_state);
629
630 let worker_outputs = stack_state
632 .get_resource_outputs::<WorkerOutputs>("test-alien-worker")
633 .unwrap();
634
635 let worker_url = worker_outputs
636 .url
637 .as_ref()
638 .ok_or_else(|| "Worker URL not found in stack state")
639 .unwrap();
640
641 assert_eq!(worker_url, "https://test.lambda-url.us-east-1.on.aws/");
642 }
643
644 #[cfg(test)]
646 mod stack_status_tests {
647 use super::*;
648
649 #[test]
650 fn test_compute_stack_status_empty_resources() {
651 let result = StackState::compute_stack_status_from_resources(&[]).unwrap();
652 assert_eq!(result, StackStatus::Pending);
653 }
654
655 #[test]
656 fn test_compute_stack_status_single_pending() {
657 let result =
658 StackState::compute_stack_status_from_resources(&[ResourceStatus::Pending])
659 .unwrap();
660 assert_eq!(result, StackStatus::InProgress);
661 }
662
663 #[test]
664 fn test_compute_stack_status_single_provisioning() {
665 let result =
666 StackState::compute_stack_status_from_resources(&[ResourceStatus::Provisioning])
667 .unwrap();
668 assert_eq!(result, StackStatus::InProgress);
669 }
670
671 #[test]
672 fn test_compute_stack_status_single_updating() {
673 let result =
674 StackState::compute_stack_status_from_resources(&[ResourceStatus::Updating])
675 .unwrap();
676 assert_eq!(result, StackStatus::InProgress);
677 }
678
679 #[test]
680 fn test_compute_stack_status_single_deleting() {
681 let result =
682 StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleting])
683 .unwrap();
684 assert_eq!(result, StackStatus::InProgress);
685 }
686
687 #[test]
688 fn test_compute_stack_status_single_provision_failed() {
689 let result =
690 StackState::compute_stack_status_from_resources(&[ResourceStatus::ProvisionFailed])
691 .unwrap();
692 assert_eq!(result, StackStatus::Failure);
693 }
694
695 #[test]
696 fn test_compute_stack_status_single_update_failed() {
697 let result =
698 StackState::compute_stack_status_from_resources(&[ResourceStatus::UpdateFailed])
699 .unwrap();
700 assert_eq!(result, StackStatus::Failure);
701 }
702
703 #[test]
704 fn test_compute_stack_status_single_delete_failed() {
705 let result =
706 StackState::compute_stack_status_from_resources(&[ResourceStatus::DeleteFailed])
707 .unwrap();
708 assert_eq!(result, StackStatus::Failure);
709 }
710
711 #[test]
712 fn test_compute_stack_status_single_refresh_failed() {
713 let result =
714 StackState::compute_stack_status_from_resources(&[ResourceStatus::RefreshFailed])
715 .unwrap();
716 assert_eq!(result, StackStatus::Failure);
717 }
718
719 #[test]
720 fn test_compute_stack_status_single_running() {
721 let result =
722 StackState::compute_stack_status_from_resources(&[ResourceStatus::Running])
723 .unwrap();
724 assert_eq!(result, StackStatus::Running);
725 }
726
727 #[test]
728 fn test_compute_stack_status_single_deleted() {
729 let result =
730 StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleted])
731 .unwrap();
732 assert_eq!(result, StackStatus::Deleted);
733 }
734
735 #[test]
736 fn test_compute_stack_status_all_running() {
737 let statuses = vec![
738 ResourceStatus::Running,
739 ResourceStatus::Running,
740 ResourceStatus::Running,
741 ];
742 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
743 assert_eq!(result, StackStatus::Running);
744 }
745
746 #[test]
747 fn test_compute_stack_status_all_deleted() {
748 let statuses = vec![
749 ResourceStatus::Deleted,
750 ResourceStatus::Deleted,
751 ResourceStatus::Deleted,
752 ];
753 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
754 assert_eq!(result, StackStatus::Deleted);
755 }
756
757 #[test]
758 fn test_compute_stack_status_all_pending() {
759 let statuses = vec![
760 ResourceStatus::Pending,
761 ResourceStatus::Pending,
762 ResourceStatus::Pending,
763 ];
764 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
765 assert_eq!(result, StackStatus::InProgress);
766 }
767
768 #[test]
769 fn test_compute_stack_status_all_provisioning() {
770 let statuses = vec![
771 ResourceStatus::Provisioning,
772 ResourceStatus::Provisioning,
773 ResourceStatus::Provisioning,
774 ];
775 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
776 assert_eq!(result, StackStatus::InProgress);
777 }
778
779 #[test]
780 fn test_compute_stack_status_all_provision_failed() {
781 let statuses = vec![
782 ResourceStatus::ProvisionFailed,
783 ResourceStatus::ProvisionFailed,
784 ResourceStatus::ProvisionFailed,
785 ];
786 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
787 assert_eq!(result, StackStatus::Failure);
788 }
789
790 #[test]
791 fn test_compute_stack_status_mixed_with_failure() {
792 let statuses = vec![
793 ResourceStatus::Running,
794 ResourceStatus::ProvisionFailed,
795 ResourceStatus::Updating,
796 ];
797 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
798 assert_eq!(result, StackStatus::Failure);
799 }
800
801 #[test]
802 fn test_compute_stack_status_failure_with_success() {
803 let statuses = vec![
804 ResourceStatus::Running,
805 ResourceStatus::UpdateFailed,
806 ResourceStatus::Running,
807 ];
808 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
809 assert_eq!(result, StackStatus::Failure);
810 }
811
812 #[test]
813 fn test_compute_stack_status_failure_with_in_progress() {
814 let statuses = vec![
815 ResourceStatus::Provisioning,
816 ResourceStatus::DeleteFailed,
817 ResourceStatus::Deleting,
818 ];
819 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
820 assert_eq!(result, StackStatus::Failure);
821 }
822
823 #[test]
824 fn test_compute_stack_status_any_in_progress() {
825 let statuses = vec![
826 ResourceStatus::Running,
827 ResourceStatus::Updating,
828 ResourceStatus::Running,
829 ];
830 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
831 assert_eq!(result, StackStatus::InProgress);
832 }
833
834 #[test]
835 fn test_compute_stack_status_mixed_in_progress_states() {
836 let statuses = vec![
837 ResourceStatus::Pending,
838 ResourceStatus::Provisioning,
839 ResourceStatus::Updating,
840 ResourceStatus::Deleting,
841 ];
842 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
843 assert_eq!(result, StackStatus::InProgress);
844 }
845
846 #[test]
847 fn test_compute_stack_status_deletion_in_progress() {
848 let statuses = vec![ResourceStatus::Running, ResourceStatus::Deleted];
851 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
852 assert_eq!(result, StackStatus::InProgress);
853 }
854
855 #[test]
856 fn test_compute_stack_status_deletion_in_progress_many_resources() {
857 let statuses = vec![
859 ResourceStatus::Running,
860 ResourceStatus::Running,
861 ResourceStatus::Deleted,
862 ResourceStatus::Deleted,
863 ResourceStatus::Running,
864 ResourceStatus::Running,
865 ResourceStatus::Running,
866 ResourceStatus::Running,
867 ResourceStatus::Running,
868 ];
869 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
870 assert_eq!(result, StackStatus::InProgress);
871 }
872
873 #[test]
874 fn test_compute_stack_status_mixed_terminal_with_in_progress() {
875 let statuses = vec![
876 ResourceStatus::Running,
877 ResourceStatus::Deleted,
878 ResourceStatus::Pending,
879 ];
880 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
881 assert_eq!(result, StackStatus::InProgress);
882 }
883
884 #[test]
885 fn test_compute_stack_status_large_number_of_resources() {
886 let statuses: Vec<ResourceStatus> = (0..100).map(|_| ResourceStatus::Running).collect();
887 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
888 assert_eq!(result, StackStatus::Running);
889 }
890
891 #[test]
892 fn test_compute_stack_status_single_failure_among_many() {
893 let mut statuses: Vec<ResourceStatus> =
894 (0..50).map(|_| ResourceStatus::Running).collect();
895 statuses.push(ResourceStatus::ProvisionFailed);
896 statuses.extend((0..49).map(|_| ResourceStatus::Provisioning));
897
898 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
899 assert_eq!(result, StackStatus::Failure);
900 }
901
902 #[test]
903 fn test_compute_stack_status_failure_priority_over_in_progress() {
904 let statuses = vec![
905 ResourceStatus::ProvisionFailed,
906 ResourceStatus::UpdateFailed,
907 ResourceStatus::DeleteFailed,
908 ResourceStatus::Provisioning,
909 ResourceStatus::Updating,
910 ResourceStatus::Deleting,
911 ];
912 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
913 assert_eq!(result, StackStatus::Failure);
914 }
915
916 #[test]
917 fn test_compute_stack_status_mixed_success_and_in_progress() {
918 let statuses = vec![
919 ResourceStatus::Running,
920 ResourceStatus::Provisioning,
921 ResourceStatus::Running,
922 ];
923 let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
924 assert_eq!(result, StackStatus::InProgress);
925 }
926
927 #[test]
928 fn test_stack_state_status_computation() {
929 let mut stack_state = StackState::new(Platform::Aws);
930
931 assert_eq!(
933 stack_state.compute_stack_status().unwrap(),
934 StackStatus::Pending
935 );
936
937 let test_worker = Worker::new("test-worker".to_string())
939 .code(WorkerCode::Image {
940 image: "test:latest".to_string(),
941 })
942 .permissions("test-profile".to_string())
943 .build();
944
945 let resource_state = StackResourceState::new_pending(
946 "worker".to_string(),
947 Resource::new(test_worker),
948 None,
949 Vec::new(),
950 )
951 .with_updates(|state| {
952 state.status = ResourceStatus::Running;
953 });
954
955 stack_state
956 .resources
957 .insert("test-worker".to_string(), resource_state);
958
959 assert_eq!(
961 stack_state.compute_stack_status().unwrap(),
962 StackStatus::Running
963 );
964 }
965
966 #[test]
970 fn test_external_container_env_survives_json_roundtrip() {
971 use crate::resources::AzureContainerAppsEnvironmentOutputs;
972 use crate::AzureContainerAppsEnvironment;
973
974 let mut stack_state = StackState::new(Platform::Azure);
975
976 let env_config =
979 AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
980 let env_outputs = AzureContainerAppsEnvironmentOutputs {
981 environment_name: "test-env".to_string(),
982 resource_id: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.App/managedEnvironments/test-env".to_string(),
983 resource_group_name: "shared-rg".to_string(),
984 default_domain: "test-env.azurecontainerapps.io".to_string(),
985 static_ip: Some("10.0.0.1".to_string()),
986 custom_domain_verification_id: None,
987 };
988
989 let env_state = StackResourceState::new_pending(
990 AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
991 Resource::new(env_config),
992 Some(ResourceLifecycle::Frozen),
993 Vec::new(),
994 )
995 .with_updates(|state| {
996 state.status = ResourceStatus::Running;
997 state.outputs = Some(ResourceOutputs::new(env_outputs.clone()));
998 });
999
1000 stack_state
1001 .resources
1002 .insert("default-container-env".to_string(), env_state);
1003
1004 let test_worker = Worker::new("alien-rs-worker".to_string())
1006 .code(WorkerCode::Image {
1007 image: "test:latest".to_string(),
1008 })
1009 .permissions("execution".to_string())
1010 .build();
1011
1012 let fn_state = StackResourceState::new_pending(
1013 "worker".to_string(),
1014 Resource::new(test_worker),
1015 Some(ResourceLifecycle::Live),
1016 vec![crate::ResourceRef::new(
1017 AzureContainerAppsEnvironment::RESOURCE_TYPE,
1018 "default-container-env",
1019 )],
1020 )
1021 .with_updates(|state| {
1022 state.status = ResourceStatus::Running;
1023 });
1024
1025 stack_state
1026 .resources
1027 .insert("alien-rs-worker".to_string(), fn_state);
1028
1029 assert!(
1031 stack_state.resources.contains_key("default-container-env"),
1032 "default-container-env should be in state before roundtrip"
1033 );
1034 assert_eq!(stack_state.resources.len(), 2);
1035
1036 let json_value = serde_json::to_value(&stack_state)
1044 .expect("StackState serialization to Value should not fail");
1045
1046 let deserialized_from_value: StackState = serde_json::from_value(json_value)
1048 .expect("StackState deserialization from Value should not fail");
1049
1050 assert!(
1051 deserialized_from_value
1052 .resources
1053 .contains_key("default-container-env"),
1054 "default-container-env lost during to_value/from_value roundtrip! \
1055 Available: {:?}",
1056 deserialized_from_value.resources.keys().collect::<Vec<_>>()
1057 );
1058
1059 let json_string = serde_json::to_string(&deserialized_from_value)
1061 .expect("StackState serialization to String should not fail");
1062
1063 let deserialized_from_str: StackState = serde_json::from_str(&json_string)
1065 .expect("StackState deserialization from String should not fail");
1066
1067 assert!(
1068 deserialized_from_str
1069 .resources
1070 .contains_key("default-container-env"),
1071 "default-container-env lost during to_string/from_str roundtrip! \
1072 Available: {:?}",
1073 deserialized_from_str.resources.keys().collect::<Vec<_>>()
1074 );
1075
1076 let outputs = deserialized_from_str
1078 .get_resource_outputs::<AzureContainerAppsEnvironmentOutputs>(
1079 "default-container-env",
1080 )
1081 .expect("Should be able to get container env outputs after roundtrip");
1082 assert_eq!(outputs.environment_name, "test-env");
1083 assert_eq!(outputs.resource_group_name, "shared-rg");
1084 assert_eq!(outputs.static_ip, Some("10.0.0.1".to_string()));
1085
1086 let env_resource = deserialized_from_str
1088 .resources
1089 .get("default-container-env")
1090 .unwrap();
1091 assert_eq!(env_resource.status, ResourceStatus::Running);
1092 assert_eq!(env_resource.lifecycle, Some(ResourceLifecycle::Frozen));
1093 }
1094
1095 #[test]
1098 fn test_deployment_state_roundtrip_preserves_external_binding() {
1099 use crate::resources::AzureContainerAppsEnvironmentOutputs;
1100 use crate::{AzureContainerAppsEnvironment, DeploymentState, DeploymentStatus};
1101
1102 let mut stack_state = StackState::new(Platform::Azure);
1103
1104 let env_config =
1105 AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
1106 let env_outputs = AzureContainerAppsEnvironmentOutputs {
1107 environment_name: "test-env".to_string(),
1108 resource_id: "/subscriptions/sub/rg/env".to_string(),
1109 resource_group_name: "shared-rg".to_string(),
1110 default_domain: "test.io".to_string(),
1111 static_ip: None,
1112 custom_domain_verification_id: None,
1113 };
1114
1115 let env_state = StackResourceState::new_pending(
1116 AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
1117 Resource::new(env_config),
1118 Some(ResourceLifecycle::Frozen),
1119 Vec::new(),
1120 )
1121 .with_updates(|state| {
1122 state.status = ResourceStatus::Running;
1123 state.outputs = Some(ResourceOutputs::new(env_outputs));
1124 });
1125
1126 stack_state
1127 .resources
1128 .insert("default-container-env".to_string(), env_state);
1129
1130 let deployment_state = DeploymentState {
1132 status: DeploymentStatus::Provisioning,
1133 platform: Platform::Azure,
1134 current_release: None,
1135 target_release: None,
1136 stack_state: Some(stack_state),
1137 environment_info: None,
1138 runtime_metadata: None,
1139 retry_requested: false,
1140 protocol_version: 1,
1141 };
1142
1143 let json_value =
1145 serde_json::to_value(&deployment_state).expect("DeploymentState to_value failed");
1146
1147 let deserialized: DeploymentState =
1148 serde_json::from_value(json_value).expect("DeploymentState from_value failed");
1149
1150 let ss = deserialized
1151 .stack_state
1152 .as_ref()
1153 .expect("stack_state should be present");
1154
1155 assert!(
1156 ss.resources.contains_key("default-container-env"),
1157 "default-container-env lost in DeploymentState roundtrip! \
1158 Available: {:?}",
1159 ss.resources.keys().collect::<Vec<_>>()
1160 );
1161 }
1162 }
1163}