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