use crate::{
Platform, Resource, ResourceLifecycle, ResourceOutputs, ResourceOutputsDefinition, ResourceRef,
ResourceStatus, ResourceType,
};
use alien_error::AlienError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Debug;
use uuid::Uuid;
use crate::{ErrorData, Result};
pub 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";
pub fn is_valid_resource_prefix(value: &str) -> bool {
if !(3..=40).contains(&value.len()) {
return false;
}
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
let Some(last) = value.chars().next_back() else {
return false;
};
if !(last.is_ascii_lowercase() || last.is_ascii_digit()) {
return false;
}
let mut previous_was_hyphen = false;
for c in value.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return false;
}
if c == '-' && previous_was_hyphen {
return false;
}
previous_was_hyphen = c == '-';
}
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum StackStatus {
Pending,
InProgress,
Running,
Deleted,
Failure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackState {
pub platform: Platform,
pub resources: HashMap<String, StackResourceState>,
pub resource_prefix: String,
}
impl StackState {
pub fn new(platform: Platform) -> Self {
let letters = "abcdefghijklmnopqrstuvwxyz";
let first_char = letters
.chars()
.nth(Uuid::new_v4().as_bytes()[0] as usize % 26)
.unwrap();
let uuid_part = Uuid::new_v4().simple().to_string()[..7].to_string();
let prefix = format!("{}{}", first_char, uuid_part);
StackState {
platform,
resources: HashMap::new(),
resource_prefix: prefix,
}
}
pub fn with_resource_prefix(platform: Platform, resource_prefix: String) -> Self {
StackState {
platform,
resources: HashMap::new(),
resource_prefix,
}
}
pub fn resource(&self, id: &str) -> Option<&StackResourceState> {
self.resources.get(id)
}
pub fn compute_stack_status(&self) -> Result<StackStatus> {
let resource_statuses: Vec<ResourceStatus> = self
.resources
.values()
.map(|resource| resource.status)
.collect();
Self::compute_stack_status_from_resources(&resource_statuses)
}
pub fn compute_stack_status_from_resources(
resource_statuses: &[ResourceStatus],
) -> Result<StackStatus> {
if resource_statuses.is_empty() {
return Ok(StackStatus::Pending);
}
if resource_statuses.iter().any(|status| {
matches!(
status,
ResourceStatus::ProvisionFailed
| ResourceStatus::UpdateFailed
| ResourceStatus::DeleteFailed
| ResourceStatus::RefreshFailed
)
}) {
return Ok(StackStatus::Failure);
}
if resource_statuses.iter().any(|status| {
matches!(
status,
ResourceStatus::Pending
| ResourceStatus::Provisioning
| ResourceStatus::Updating
| ResourceStatus::Deleting
)
}) {
return Ok(StackStatus::InProgress);
}
if resource_statuses
.iter()
.all(|status| matches!(status, ResourceStatus::Running))
{
return Ok(StackStatus::Running);
}
if resource_statuses
.iter()
.all(|status| matches!(status, ResourceStatus::Deleted))
{
return Ok(StackStatus::Deleted);
}
let has_running = resource_statuses
.iter()
.any(|status| matches!(status, ResourceStatus::Running));
let has_deleted = resource_statuses
.iter()
.any(|status| matches!(status, ResourceStatus::Deleted));
let only_running_or_deleted = resource_statuses
.iter()
.all(|status| matches!(status, ResourceStatus::Running | ResourceStatus::Deleted));
if has_running && has_deleted && only_running_or_deleted {
return Ok(StackStatus::InProgress);
}
let status_strings: Vec<String> = resource_statuses
.iter()
.map(|status| format!("{:?}", status).to_lowercase().replace('_', "-"))
.collect();
Err(AlienError::new(
ErrorData::UnexpectedResourceStatusCombination {
resource_statuses: status_strings,
operation: "stack status computation".to_string(),
},
))
}
pub fn get_resource_outputs<T: ResourceOutputsDefinition + 'static>(
&self,
resource_id: &str,
) -> Result<&T> {
let resource_state = self.resources.get(resource_id).ok_or_else(|| {
AlienError::new(ErrorData::ResourceNotFound {
resource_id: resource_id.to_string(),
available_resources: self.resources.keys().cloned().collect(),
})
})?;
let outputs = resource_state.outputs.as_ref().ok_or_else(|| {
AlienError::new(ErrorData::ResourceHasNoOutputs {
resource_id: resource_id.to_string(),
})
})?;
outputs.downcast_ref::<T>().ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: resource_id.to_string(),
expected: ResourceType::from_static(std::any::type_name::<T>()),
actual: resource_state.resource_type.clone().into(),
})
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackResourceState {
#[serde(rename = "type")]
pub resource_type: String,
#[serde(rename = "_internal", skip_serializing_if = "Option::is_none")]
pub internal_state: Option<serde_json::Value>,
pub status: ResourceStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub outputs: Option<ResourceOutputs>,
pub config: Resource,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_config: Option<Resource>,
#[serde(default, skip_serializing_if = "is_zero")]
#[builder(default)]
pub retry_attempt: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<AlienError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<ResourceLifecycle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller_platform: Option<Platform>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default = vec![])]
pub dependencies: Vec<ResourceRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_failed_state: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_binding_params: Option<serde_json::Value>,
}
impl StackResourceState {
pub fn new_pending(
resource_type: String,
config: Resource,
lifecycle: Option<ResourceLifecycle>,
dependencies: Vec<ResourceRef>,
) -> Self {
Self {
resource_type,
internal_state: None,
status: ResourceStatus::Pending,
outputs: None,
config,
previous_config: None,
retry_attempt: 0,
error: None,
lifecycle,
controller_platform: None,
dependencies,
last_failed_state: None,
remote_binding_params: None,
}
}
pub fn with_updates<F>(&self, update_fn: F) -> Self
where
F: FnOnce(&mut Self),
{
let mut new_state = self.clone();
update_fn(&mut new_state);
new_state
}
pub fn with_failure(&self, status: ResourceStatus, error: AlienError) -> Self {
self.with_updates(|state| {
state.status = status;
state.error = Some(error);
state.retry_attempt = 0;
})
}
}
fn is_zero(num: &u32) -> bool {
*num == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ResourceType, Storage, StorageOutputs, Worker, WorkerCode, WorkerOutputs};
#[test]
fn resource_prefix_validation_accepts_canonical_prefixes() {
for prefix in ["abc", "a-b", "acme-prod", "a1-b2-c3", "a1234567890"] {
assert!(is_valid_resource_prefix(prefix), "{prefix}");
}
}
#[test]
fn resource_prefix_validation_rejects_non_canonical_prefixes() {
for prefix in [
"",
"ab",
"a-",
"-ab",
"Aab",
"a_b",
"a--b",
"a.b",
"a1234567890123456789012345678901234567890",
] {
assert!(!is_valid_resource_prefix(prefix), "{prefix}");
}
}
#[test]
fn test_get_resource_outputs_success() {
let mut stack_state = StackState::new(Platform::Aws);
let worker_outputs = WorkerOutputs {
worker_name: "test-worker".to_string(),
url: Some("https://example.lambda-url.us-east-1.on.aws/".to_string()),
identifier: Some(
"arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string(),
),
load_balancer_endpoint: None,
commands_push_target: None,
};
let test_worker = Worker::new("test-worker".to_string())
.code(WorkerCode::Image {
image: "test:latest".to_string(),
})
.permissions("test-profile".to_string())
.build();
let resource_state = StackResourceState::new_pending(
"worker".to_string(),
Resource::new(test_worker),
None,
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
state.outputs = Some(ResourceOutputs::new(worker_outputs.clone()));
});
stack_state
.resources
.insert("test-worker".to_string(), resource_state);
let retrieved_outputs = stack_state
.get_resource_outputs::<WorkerOutputs>("test-worker")
.unwrap();
assert_eq!(retrieved_outputs.worker_name, "test-worker");
assert_eq!(
retrieved_outputs.url,
Some("https://example.lambda-url.us-east-1.on.aws/".to_string())
);
assert_eq!(
retrieved_outputs.identifier,
Some("arn:aws:lambda:us-east-1:123456789012:function:test-worker".to_string())
);
}
#[test]
fn test_get_resource_outputs_resource_not_found() {
let stack_state = StackState::new(Platform::Aws);
let result = stack_state.get_resource_outputs::<WorkerOutputs>("nonexistent-worker");
assert!(result.is_err());
let error = result.unwrap_err();
let error_data = &error.error;
if let Some(ErrorData::ResourceNotFound {
resource_id,
available_resources,
}) = error_data
{
assert_eq!(resource_id, "nonexistent-worker");
assert_eq!(available_resources, &Vec::<String>::new());
} else {
panic!("Expected ResourceNotFound error, got: {:?}", error_data);
}
let error_message = error.to_string();
assert!(error_message.contains("Resource 'nonexistent-worker' not found in stack state"));
assert!(error_message.contains("Available resources: []"));
}
#[test]
fn test_get_resource_outputs_no_outputs() {
let mut stack_state = StackState::new(Platform::Aws);
let test_worker_2 = Worker::new("test-worker".to_string())
.code(WorkerCode::Image {
image: "test:latest".to_string(),
})
.permissions("test-profile".to_string())
.build();
let resource_state = StackResourceState::new_pending(
"worker".to_string(),
Resource::new(test_worker_2),
None,
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Provisioning;
});
stack_state
.resources
.insert("test-worker".to_string(), resource_state);
let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-worker");
assert!(result.is_err());
let error = result.unwrap_err();
let error_data = &error.error;
if let Some(ErrorData::ResourceHasNoOutputs { resource_id, .. }) = error_data {
assert_eq!(resource_id, "test-worker");
} else {
panic!("Expected ResourceHasNoOutputs error, got: {:?}", error_data);
}
let error_message = error.to_string();
assert!(error_message.contains("Resource 'test-worker' has no outputs"));
}
#[test]
fn test_get_resource_outputs_wrong_type() {
let mut stack_state = StackState::new(Platform::Aws);
let storage_outputs = StorageOutputs {
bucket_name: "test-bucket".to_string(),
};
let test_storage = Storage::new("test-storage".to_string()).build();
let resource_state = StackResourceState::new_pending(
"storage".to_string(),
Resource::new(test_storage),
None,
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
state.outputs = Some(ResourceOutputs::new(storage_outputs));
});
stack_state
.resources
.insert("test-storage".to_string(), resource_state);
let result = stack_state.get_resource_outputs::<WorkerOutputs>("test-storage");
assert!(result.is_err());
let error = result.unwrap_err();
let error_data = &error.error;
if let Some(ErrorData::UnexpectedResourceType {
resource_id,
expected,
actual,
}) = error_data
{
assert_eq!(resource_id, "test-storage");
assert!(
expected.0.contains("WorkerOutputs"),
"expected should reference WorkerOutputs, got: {}",
expected.0
);
assert_eq!(*actual, ResourceType::from_static("storage"));
} else {
panic!(
"Expected UnexpectedResourceType error, got: {:?}",
error_data
);
}
}
#[test]
fn test_get_resource_outputs_usage_example() {
let mut stack_state = StackState::new(Platform::Aws);
let worker_outputs = WorkerOutputs {
worker_name: "test-alien-worker".to_string(),
url: Some("https://test.lambda-url.us-east-1.on.aws/".to_string()),
identifier: Some(
"arn:aws:lambda:us-east-1:123456789012:function:test-alien-worker".to_string(),
),
load_balancer_endpoint: None,
commands_push_target: None,
};
let test_alien_worker = Worker::new("test-alien-worker".to_string())
.code(WorkerCode::Image {
image: "test:latest".to_string(),
})
.permissions("test-profile".to_string())
.build();
let resource_state = StackResourceState {
resource_type: "worker".to_string(),
internal_state: None,
status: ResourceStatus::Running,
outputs: Some(ResourceOutputs::new(worker_outputs)),
config: Resource::new(test_alien_worker),
previous_config: None,
retry_attempt: 0,
error: None,
lifecycle: None,
dependencies: Vec::new(),
last_failed_state: None,
remote_binding_params: None,
controller_platform: None,
};
stack_state
.resources
.insert("test-alien-worker".to_string(), resource_state);
let worker_outputs = stack_state
.get_resource_outputs::<WorkerOutputs>("test-alien-worker")
.unwrap();
let worker_url = worker_outputs
.url
.as_ref()
.ok_or_else(|| "Worker URL not found in stack state")
.unwrap();
assert_eq!(worker_url, "https://test.lambda-url.us-east-1.on.aws/");
}
#[cfg(test)]
mod stack_status_tests {
use super::*;
#[test]
fn test_compute_stack_status_empty_resources() {
let result = StackState::compute_stack_status_from_resources(&[]).unwrap();
assert_eq!(result, StackStatus::Pending);
}
#[test]
fn test_compute_stack_status_single_pending() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Pending])
.unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_single_provisioning() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Provisioning])
.unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_single_updating() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Updating])
.unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_single_deleting() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleting])
.unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_single_provision_failed() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::ProvisionFailed])
.unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_single_update_failed() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::UpdateFailed])
.unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_single_delete_failed() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::DeleteFailed])
.unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_single_refresh_failed() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::RefreshFailed])
.unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_single_running() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Running])
.unwrap();
assert_eq!(result, StackStatus::Running);
}
#[test]
fn test_compute_stack_status_single_deleted() {
let result =
StackState::compute_stack_status_from_resources(&[ResourceStatus::Deleted])
.unwrap();
assert_eq!(result, StackStatus::Deleted);
}
#[test]
fn test_compute_stack_status_all_running() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::Running,
ResourceStatus::Running,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Running);
}
#[test]
fn test_compute_stack_status_all_deleted() {
let statuses = vec![
ResourceStatus::Deleted,
ResourceStatus::Deleted,
ResourceStatus::Deleted,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Deleted);
}
#[test]
fn test_compute_stack_status_all_pending() {
let statuses = vec![
ResourceStatus::Pending,
ResourceStatus::Pending,
ResourceStatus::Pending,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_all_provisioning() {
let statuses = vec![
ResourceStatus::Provisioning,
ResourceStatus::Provisioning,
ResourceStatus::Provisioning,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_all_provision_failed() {
let statuses = vec![
ResourceStatus::ProvisionFailed,
ResourceStatus::ProvisionFailed,
ResourceStatus::ProvisionFailed,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_mixed_with_failure() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::ProvisionFailed,
ResourceStatus::Updating,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_failure_with_success() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::UpdateFailed,
ResourceStatus::Running,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_failure_with_in_progress() {
let statuses = vec![
ResourceStatus::Provisioning,
ResourceStatus::DeleteFailed,
ResourceStatus::Deleting,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_any_in_progress() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::Updating,
ResourceStatus::Running,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_mixed_in_progress_states() {
let statuses = vec![
ResourceStatus::Pending,
ResourceStatus::Provisioning,
ResourceStatus::Updating,
ResourceStatus::Deleting,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_deletion_in_progress() {
let statuses = vec![ResourceStatus::Running, ResourceStatus::Deleted];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_deletion_in_progress_many_resources() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::Running,
ResourceStatus::Deleted,
ResourceStatus::Deleted,
ResourceStatus::Running,
ResourceStatus::Running,
ResourceStatus::Running,
ResourceStatus::Running,
ResourceStatus::Running,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_mixed_terminal_with_in_progress() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::Deleted,
ResourceStatus::Pending,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_compute_stack_status_large_number_of_resources() {
let statuses: Vec<ResourceStatus> = (0..100).map(|_| ResourceStatus::Running).collect();
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Running);
}
#[test]
fn test_compute_stack_status_single_failure_among_many() {
let mut statuses: Vec<ResourceStatus> =
(0..50).map(|_| ResourceStatus::Running).collect();
statuses.push(ResourceStatus::ProvisionFailed);
statuses.extend((0..49).map(|_| ResourceStatus::Provisioning));
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_failure_priority_over_in_progress() {
let statuses = vec![
ResourceStatus::ProvisionFailed,
ResourceStatus::UpdateFailed,
ResourceStatus::DeleteFailed,
ResourceStatus::Provisioning,
ResourceStatus::Updating,
ResourceStatus::Deleting,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::Failure);
}
#[test]
fn test_compute_stack_status_mixed_success_and_in_progress() {
let statuses = vec![
ResourceStatus::Running,
ResourceStatus::Provisioning,
ResourceStatus::Running,
];
let result = StackState::compute_stack_status_from_resources(&statuses).unwrap();
assert_eq!(result, StackStatus::InProgress);
}
#[test]
fn test_stack_state_status_computation() {
let mut stack_state = StackState::new(Platform::Aws);
assert_eq!(
stack_state.compute_stack_status().unwrap(),
StackStatus::Pending
);
let test_worker = Worker::new("test-worker".to_string())
.code(WorkerCode::Image {
image: "test:latest".to_string(),
})
.permissions("test-profile".to_string())
.build();
let resource_state = StackResourceState::new_pending(
"worker".to_string(),
Resource::new(test_worker),
None,
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
});
stack_state
.resources
.insert("test-worker".to_string(), resource_state);
assert_eq!(
stack_state.compute_stack_status().unwrap(),
StackStatus::Running
);
}
#[test]
fn test_external_container_env_survives_json_roundtrip() {
use crate::resources::AzureContainerAppsEnvironmentOutputs;
use crate::AzureContainerAppsEnvironment;
let mut stack_state = StackState::new(Platform::Azure);
let env_config =
AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
let env_outputs = AzureContainerAppsEnvironmentOutputs {
environment_name: "test-env".to_string(),
resource_id: "/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.App/managedEnvironments/test-env".to_string(),
resource_group_name: "shared-rg".to_string(),
default_domain: "test-env.azurecontainerapps.io".to_string(),
static_ip: Some("10.0.0.1".to_string()),
custom_domain_verification_id: None,
};
let env_state = StackResourceState::new_pending(
AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
Resource::new(env_config),
Some(ResourceLifecycle::Frozen),
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
state.outputs = Some(ResourceOutputs::new(env_outputs.clone()));
});
stack_state
.resources
.insert("default-container-env".to_string(), env_state);
let test_worker = Worker::new("alien-rs-worker".to_string())
.code(WorkerCode::Image {
image: "test:latest".to_string(),
})
.permissions("execution".to_string())
.build();
let fn_state = StackResourceState::new_pending(
"worker".to_string(),
Resource::new(test_worker),
Some(ResourceLifecycle::Live),
vec![crate::ResourceRef::new(
AzureContainerAppsEnvironment::RESOURCE_TYPE,
"default-container-env",
)],
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
});
stack_state
.resources
.insert("alien-rs-worker".to_string(), fn_state);
assert!(
stack_state.resources.contains_key("default-container-env"),
"default-container-env should be in state before roundtrip"
);
assert_eq!(stack_state.resources.len(), 2);
let json_value = serde_json::to_value(&stack_state)
.expect("StackState serialization to Value should not fail");
let deserialized_from_value: StackState = serde_json::from_value(json_value)
.expect("StackState deserialization from Value should not fail");
assert!(
deserialized_from_value
.resources
.contains_key("default-container-env"),
"default-container-env lost during to_value/from_value roundtrip! \
Available: {:?}",
deserialized_from_value.resources.keys().collect::<Vec<_>>()
);
let json_string = serde_json::to_string(&deserialized_from_value)
.expect("StackState serialization to String should not fail");
let deserialized_from_str: StackState = serde_json::from_str(&json_string)
.expect("StackState deserialization from String should not fail");
assert!(
deserialized_from_str
.resources
.contains_key("default-container-env"),
"default-container-env lost during to_string/from_str roundtrip! \
Available: {:?}",
deserialized_from_str.resources.keys().collect::<Vec<_>>()
);
let outputs = deserialized_from_str
.get_resource_outputs::<AzureContainerAppsEnvironmentOutputs>(
"default-container-env",
)
.expect("Should be able to get container env outputs after roundtrip");
assert_eq!(outputs.environment_name, "test-env");
assert_eq!(outputs.resource_group_name, "shared-rg");
assert_eq!(outputs.static_ip, Some("10.0.0.1".to_string()));
let env_resource = deserialized_from_str
.resources
.get("default-container-env")
.unwrap();
assert_eq!(env_resource.status, ResourceStatus::Running);
assert_eq!(env_resource.lifecycle, Some(ResourceLifecycle::Frozen));
}
#[test]
fn test_deployment_state_roundtrip_preserves_external_binding() {
use crate::resources::AzureContainerAppsEnvironmentOutputs;
use crate::{AzureContainerAppsEnvironment, DeploymentState, DeploymentStatus};
let mut stack_state = StackState::new(Platform::Azure);
let env_config =
AzureContainerAppsEnvironment::new("default-container-env".to_string()).build();
let env_outputs = AzureContainerAppsEnvironmentOutputs {
environment_name: "test-env".to_string(),
resource_id: "/subscriptions/sub/rg/env".to_string(),
resource_group_name: "shared-rg".to_string(),
default_domain: "test.io".to_string(),
static_ip: None,
custom_domain_verification_id: None,
};
let env_state = StackResourceState::new_pending(
AzureContainerAppsEnvironment::RESOURCE_TYPE.to_string(),
Resource::new(env_config),
Some(ResourceLifecycle::Frozen),
Vec::new(),
)
.with_updates(|state| {
state.status = ResourceStatus::Running;
state.outputs = Some(ResourceOutputs::new(env_outputs));
});
stack_state
.resources
.insert("default-container-env".to_string(), env_state);
let deployment_state = DeploymentState {
status: DeploymentStatus::Provisioning,
platform: Platform::Azure,
current_release: None,
target_release: None,
stack_state: Some(stack_state),
environment_info: None,
runtime_metadata: None,
retry_requested: false,
protocol_version: 1,
};
let json_value =
serde_json::to_value(&deployment_state).expect("DeploymentState to_value failed");
let deserialized: DeploymentState =
serde_json::from_value(json_value).expect("DeploymentState from_value failed");
let ss = deserialized
.stack_state
.as_ref()
.expect("stack_state should be present");
assert!(
ss.resources.contains_key("default-container-env"),
"default-container-env lost in DeploymentState roundtrip! \
Available: {:?}",
ss.resources.keys().collect::<Vec<_>>()
);
}
}
}