diff --git a/crates/alien-deploy-cli/src/commands/up.rs b/crates/alien-deploy-cli/src/commands/up.rs index 792540e7f..8e0e474f8 100644 --- a/crates/alien-deploy-cli/src/commands/up.rs +++ b/crates/alien-deploy-cli/src/commands/up.rs @@ -371,6 +371,28 @@ mod tests { )); } + #[test] + fn hosted_compute_updates_cover_active_and_retryable_lifecycle_states() { + for status in [ + "running", + "update-pending", + "updating", + "update-failed", + "refresh-failed", + "initial-setup", + "initial-setup-failed", + "provisioning", + "waiting-for-machines", + "provisioning-failed", + ] { + assert!(supports_hosted_compute_update(status), "{status}"); + } + + for status in ["pending", "delete-pending", "deleted"] { + assert!(!supports_hosted_compute_update(status), "{status}"); + } + } + #[test] fn stable_channel_accepts_exact_semver_tag() { assert_eq!( @@ -1341,7 +1363,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) let hosted_platform = manager_url.trim_end_matches('/') != resolved.base_url.trim_end_matches('/'); - if current_deployment.status == "running" + if supports_hosted_compute_update(¤t_deployment.status) && init.deployment_model == DeploymentModel::Push && hosted_platform && stack_settings.compute.is_some() @@ -2747,6 +2769,22 @@ fn has_explicit_non_compute_changes( || (telemetry && requested.telemetry != current.telemetry) } +fn supports_hosted_compute_update(status: &str) -> bool { + matches!( + status, + "running" + | "update-pending" + | "updating" + | "update-failed" + | "refresh-failed" + | "initial-setup" + | "initial-setup-failed" + | "provisioning" + | "waiting-for-machines" + | "provisioning-failed" + ) +} + async fn update_hosted_compute_settings( base_url: &str, token: &str, diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index 02b41d964..bf0101caf 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -165,13 +165,13 @@ pub async fn handle_update_pending( .as_ref() .and_then(|m| m.prepared_stack.as_ref()) .or(current.current_release.as_ref().map(|r| &r.stack)); - - // Run deployment-time preflights: compatibility checks + mutations + runtime checks - // Store the mutated stack to use for the actual update and for future compatibility checks let target_release_id = current .target_release .as_ref() .and_then(|release| release.release_id.as_deref()); + + // Run deployment-time preflights: compatibility checks + mutations + runtime checks + // Store the mutated stack to use for the actual update and for future compatibility checks let setup_update_authorization = current .runtime_metadata .as_ref() @@ -280,6 +280,27 @@ pub async fn handle_updating( message: "Pending prepared stack not found in runtime metadata".to_string(), }) })?; + + // Frozen resources omitted by a newer release remain setup-owned and must + // not be deleted by an ordinary update. Keep their installed definitions + // in the execution target while allowing explicitly runtime-managed frozen + // resources (currently ComputeCluster capacity) to reconcile changed + // configuration through their management controller. + if let Some(installed_stack) = runtime_metadata.prepared_stack.as_ref() { + for (resource_id, entry) in installed_stack.resources() { + if entry.lifecycle == ResourceLifecycle::Frozen + && !target_stack.resources.contains_key(resource_id) + { + target_stack + .resources + .insert(resource_id.clone(), entry.clone()); + } + } + } + // The effective target is the new durable baseline. Persist it before + // executor-only environment injection so a second release that also omits + // a setup-owned resource cannot lose ownership information and delete it. + runtime_metadata.pending_prepared_stack = Some(target_stack.clone()); // Inject environment variables into the prepared stack crate::helpers::inject_environment_variables(&mut target_stack, &config, current.platform)?; @@ -295,7 +316,7 @@ pub async fn handle_updating( // Sync secrets to vault before updating workload resources. // The vault is Running and secrets may have been updated // This checks the hash and only syncs if needed - info!("Syncing secrets to vault before updating live resources"); + info!("Syncing secrets to vault before updating managed resources"); let synced = crate::helpers::sync_secrets_to_vault( &target_stack, &stack_state, @@ -314,7 +335,7 @@ pub async fn handle_updating( let executor = StackExecutor::builder(&target_stack, client_config) .deployment_config(&config) .running_resource_policy(RunningResourcePolicy::OptIn) - .lifecycle_filter(vec![ResourceLifecycle::Live]) + .lifecycle_filter(vec![ResourceLifecycle::Live, ResourceLifecycle::Frozen]) .service_provider(service_provider) .build() .context(ErrorData::StackExecutionFailed { @@ -700,9 +721,8 @@ mod tests { ); } - /// A setup-owned resource is excluded by the executor's lifecycle filter, so nothing will - /// ever reconcile it. Holding the update open until its recorded config matches the - /// declared one would never finish. + /// A resource not reported as reconciled by the executor cannot hold an + /// update open merely because its recorded configuration differs. #[test] fn a_resource_the_executor_does_not_reconcile_cannot_hold_the_update_open() { let declared = Kv::new("store".to_string()).build(); diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index 74676c23e..f6dc0dd58 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -786,6 +786,96 @@ async fn setup_authorized_update_clears_authority_only_on_success() { ); } +#[tokio::test] +async fn consecutive_updates_cannot_delete_an_omitted_frozen_resource() { + let config = create_test_config("hash_v1", false); + let mut state = run_to_completion( + create_initial_state(create_test_stack("test-stack", "test-function")), + config.clone(), + ) + .await; + assert_eq!(state.status, DeploymentStatus::Running); + + // Model setup-owned infrastructure imported alongside a release that does + // not declare it. This is the ownership shape the runtime must preserve. + let frozen = Storage::new("setup-storage".to_string()).build(); + state + .runtime_metadata + .as_mut() + .and_then(|metadata| metadata.prepared_stack.as_mut()) + .expect("running deployment has prepared stack") + .resources + .insert( + "setup-storage".to_string(), + ResourceEntry { + config: alien_core::Resource::new(frozen.clone()), + lifecycle: ResourceLifecycle::Frozen, + dependencies: Vec::new(), + remote_access: false, + enabled_when: None, + }, + ); + let mut frozen_state = alien_core::StackResourceState::new_pending( + "storage".to_string(), + alien_core::Resource::new(frozen), + Some(ResourceLifecycle::Frozen), + Vec::new(), + ); + frozen_state.status = alien_core::ResourceStatus::Running; + frozen_state.internal_state = Some(serde_json::json!({ + "type": "TestStorageController", + "_controllerStateVersion": 1, + "state": "ready", + "bucketName": "test-setup-storage", + })); + state + .stack_state + .as_mut() + .expect("running deployment has stack state") + .resources + .insert("setup-storage".to_string(), frozen_state); + + for release_number in [2, 3] { + let mut target = create_test_stack("test-stack", "test-function"); + target.permissions = state + .current_release + .as_ref() + .expect("running deployment has current release") + .stack + .permissions + .clone(); + start_update( + &mut state, + ReleaseInfo { + release_id: Some(format!("rel_v{release_number}")), + version: Some(format!("{release_number}.0.0")), + description: None, + // Both later releases omit the setup-owned frozen resource. + stack: target, + }, + ); + state = run_to_completion(state, config.clone()).await; + assert_eq!(state.status, DeploymentStatus::Running); + assert_eq!( + state + .stack_state + .as_ref() + .and_then(|stack| stack.resources.get("setup-storage")) + .map(|resource| resource.status), + Some(alien_core::ResourceStatus::Running), + "release {release_number} must retain the installed frozen resource" + ); + assert!( + state + .runtime_metadata + .as_ref() + .and_then(|metadata| metadata.prepared_stack.as_ref()) + .is_some_and(|stack| stack.resources.contains_key("setup-storage")), + "release {release_number} must retain frozen ownership in the promoted baseline" + ); + } +} + #[tokio::test] async fn update_completes_after_removed_resource_is_deleted() { let _vault_env = test_vault_env().await; diff --git a/crates/alien-preflights/src/compatibility/frozen_resources_unchanged.rs b/crates/alien-preflights/src/compatibility/frozen_resources_unchanged.rs index 9ab53ca43..1e4dd2bfb 100644 --- a/crates/alien-preflights/src/compatibility/frozen_resources_unchanged.rs +++ b/crates/alien-preflights/src/compatibility/frozen_resources_unchanged.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::{CheckResult, StackCompatibilityCheck}; -use alien_core::{ResourceLifecycle, Stack}; +use alien_core::{ComputeCluster, Resource, ResourceLifecycle, Stack}; use std::collections::{HashMap, HashSet}; /// Validates that frozen resources haven't been added or modified during stack updates. @@ -12,6 +12,37 @@ use std::collections::{HashMap, HashSet}; /// 3. Modifying frozen resources risks breaking security/permission models pub struct FrozenResourcesUnchangedCheck; +/// Setup owns the ComputeCluster identity and network boundary, but its +/// registered runtime controller deliberately owns fleet capacity. Keep this +/// exception structural and narrow: changing groups, profiles, placement, or +/// networking still requires setup. +fn runtime_managed_frozen_change(old: &Resource, new: &Resource) -> bool { + let (Some(old_cluster), Some(new_cluster)) = ( + old.downcast_ref::(), + new.downcast_ref::(), + ) else { + return false; + }; + if old_cluster.capacity_groups.len() != new_cluster.capacity_groups.len() { + return false; + } + + let mut normalized = old_cluster.clone(); + for (old_group, new_group) in normalized + .capacity_groups + .iter_mut() + .zip(&new_cluster.capacity_groups) + { + if old_group.group_id != new_group.group_id { + return false; + } + old_group.min_size = new_group.min_size; + old_group.max_size = new_group.max_size; + old_group.scale_policy = new_group.scale_policy.clone(); + } + normalized == *new_cluster +} + #[async_trait::async_trait] impl StackCompatibilityCheck for FrozenResourcesUnchangedCheck { fn description(&self) -> &'static str { @@ -69,7 +100,9 @@ impl StackCompatibilityCheck for FrozenResourcesUnchangedCheck { } // Check if configuration changed (only check if still frozen) - if old_entry.config != new_entry.config { + if old_entry.config != new_entry.config + && !runtime_managed_frozen_change(&old_entry.config, &new_entry.config) + { errors.push(format!( "Frozen resource '{}' was modified. \ Frozen resources are setup-owned. Rerun setup with the updated stack.", @@ -92,7 +125,9 @@ impl StackCompatibilityCheck for FrozenResourcesUnchangedCheck { mod tests { use super::*; use alien_core::permissions::PermissionsConfig; - use alien_core::{Resource, ResourceEntry, ResourceLifecycle, Stack, Storage}; + use alien_core::{ + CapacityGroup, ComputeCluster, Resource, ResourceEntry, ResourceLifecycle, Stack, Storage, + }; use indexmap::IndexMap; #[tokio::test] @@ -357,4 +392,63 @@ mod tests { assert!(result.success); assert!(result.errors.is_empty()); } + + fn compute_stack(cluster: ComputeCluster) -> Stack { + let mut resources = IndexMap::new(); + resources.insert( + "compute".to_string(), + ResourceEntry { + config: Resource::new(cluster), + lifecycle: ResourceLifecycle::Frozen, + dependencies: vec![], + remote_access: false, + enabled_when: None, + }, + ); + Stack { + id: "test-stack".to_string(), + resources, + permissions: PermissionsConfig::new(), + supported_platforms: None, + inputs: vec![], + } + } + + fn compute_cluster(size: u32) -> ComputeCluster { + ComputeCluster::new("compute".to_string()) + .capacity_group(CapacityGroup { + group_id: "workers".to_string(), + instance_type: Some("m8i.2xlarge".to_string()), + profile: None, + min_size: size, + max_size: size, + scale_policy: None, + nested_virtualization: Some(true), + }) + .build() + } + + #[tokio::test] + async fn compute_capacity_is_runtime_manageable() { + let result = FrozenResourcesUnchangedCheck + .check( + &compute_stack(compute_cluster(2)), + &compute_stack(compute_cluster(3)), + ) + .await + .unwrap(); + assert!(result.success, "{:?}", result.errors); + } + + #[tokio::test] + async fn compute_boundary_change_remains_frozen() { + let old = compute_cluster(2); + let mut changed = compute_cluster(2); + changed.capacity_groups[0].instance_type = Some("m8i.4xlarge".to_string()); + let result = FrozenResourcesUnchangedCheck + .check(&compute_stack(old), &compute_stack(changed)) + .await + .unwrap(); + assert!(!result.success); + } }