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