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