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