From 80936acf9a70be562e8bf512823bad3807949975 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 29 Aug 2026 21:50:35 -0700 Subject: [PATCH 1/4] fix: reconcile hosted setup updates through platform --- crates/alien-core/src/deployment/state.rs | 21 ++++ crates/alien-deploy-cli/src/commands/up.rs | 40 ++++++- crates/alien-deployment/src/lib.rs | 1 + crates/alien-deployment/src/pending.rs | 1 + crates/alien-deployment/src/provisioning.rs | 1 + crates/alien-deployment/src/updating.rs | 106 +++++++++++++++++- .../alien-deployment/tests/test_platform.rs | 51 ++++++++- 7 files changed, 211 insertions(+), 10 deletions(-) diff --git a/crates/alien-core/src/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index 0b53f4c45..675988c56 100644 --- a/crates/alien-core/src/deployment/state.rs +++ b/crates/alien-core/src/deployment/state.rs @@ -42,6 +42,21 @@ pub struct SetupUpdateAuthorization { pub setup_fingerprint_version: u32, } +/// One-shot request for Alien to rerun setup-owned reconciliation. +/// +/// The control plane may issue this only for an operation it explicitly +/// authorizes. The deployment engine additionally requires a direct-setup +/// deployment and an unchanged release before honoring it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DirectSetupUpdateAuthorization { + /// Platform operation that owns this reconciliation and fences stale writes. + pub operation_id: String, + /// Release whose setup-owned resources may be reconciled. + pub release_id: String, +} + /// Runtime metadata for deployment /// /// Stores deployment state that needs to persist across step calls. @@ -91,6 +106,12 @@ pub struct RuntimeMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub setup_update_authorization: Option, + /// One-shot authority for an Alien-owned setup update. Unlike an imported + /// setup authorization, the deployment engine prepares and applies the + /// setup-owned target itself under administrator credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_setup_update_authorization: Option, + /// Whether cross-account registry access has been successfully granted. /// Set to true after the manager successfully sets the ECR/GAR repo policy /// for this deployment's target account. Prevents redundant API calls on 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/lib.rs b/crates/alien-deployment/src/lib.rs index 6b04a253b..7b4abdd85 100644 --- a/crates/alien-deployment/src/lib.rs +++ b/crates/alien-deployment/src/lib.rs @@ -130,6 +130,7 @@ pub async fn step( info!("A newer target release is available; starting update reconciliation"); if let Some(metadata) = current.runtime_metadata.as_mut() { metadata.setup_update_authorization = None; + metadata.direct_setup_update_authorization = None; } current.status = DeploymentStatus::UpdatePending; } diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index 00db40c55..de0f290c5 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -41,6 +41,7 @@ pub async fn prepare_direct_setup_update( let mut metadata = existing_metadata.clone(); metadata.initial_setup_authority = alien_core::InitialSetupAuthority::DirectSetup; metadata.prepared_stack = Some(mutated_stack); + metadata.pending_prepared_stack = None; metadata.persisted_gate_answers = persisted_gate_answers; metadata.setup_update_authorization = None; Ok(metadata) diff --git a/crates/alien-deployment/src/provisioning.rs b/crates/alien-deployment/src/provisioning.rs index 208a36f52..e2a81acb0 100644 --- a/crates/alien-deployment/src/provisioning.rs +++ b/crates/alien-deployment/src/provisioning.rs @@ -156,6 +156,7 @@ pub async fn handle_provisioning( } else if stack_status == StackStatus::Running { info!("All live resources deployed successfully, transitioning to Running"); + runtime_metadata.direct_setup_update_authorization = None; next.status = DeploymentStatus::Running; next.stack_state = Some(step_result.next_state); next.error = None; diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index 02b41d964..b4ec5c3b2 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -2,8 +2,8 @@ use crate::{ DeploymentConfig, DeploymentState, DeploymentStatus, DeploymentStepResult, ErrorData, Result, }; use alien_core::{ - ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, Stack, StackState, - StackStatus, + ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, RuntimeMetadata, Stack, + StackState, StackStatus, }; use alien_error::{AlienError, Context}; use alien_infra::{state_utils::StackStateExt, RunningResourcePolicy, StackExecutor}; @@ -21,6 +21,23 @@ fn machines_deployment_has_zero_machines(platform: Platform, stack_state: &Stack }) } +fn direct_setup_update_is_authorized( + metadata: Option<&RuntimeMetadata>, + current_release_id: Option<&str>, + target_release_id: Option<&str>, +) -> bool { + metadata.is_some_and(|metadata| { + metadata.initial_setup_authority == alien_core::InitialSetupAuthority::DirectSetup + && metadata + .direct_setup_update_authorization + .as_ref() + .is_some_and(|authorization| { + Some(authorization.release_id.as_str()) == target_release_id + && current_release_id == target_release_id + }) + }) +} + fn compute_update_status( stack_state: &StackState, target_stack: &Stack, @@ -166,12 +183,47 @@ pub async fn handle_update_pending( .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()); + let direct_setup_update_authorized = direct_setup_update_is_authorized( + current.runtime_metadata.as_ref(), + current + .current_release + .as_ref() + .and_then(|release| release.release_id.as_deref()), + target_release_id, + ); + if direct_setup_update_authorized { + let existing_metadata = current.runtime_metadata.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::MissingConfiguration { + message: "Runtime metadata required for direct setup update".to_string(), + }) + })?; + let runtime_metadata = crate::pending::prepare_direct_setup_update( + target_stack, + &stack_state, + &config, + &client_config, + existing_metadata, + ) + .await?; + next.status = DeploymentStatus::InitialSetup; + next.stack_state = Some(stack_state); + next.error = None; + next.runtime_metadata = Some(runtime_metadata); + return Ok(DeploymentStepResult { + state: next, + suggested_delay_ms: None, + update_heartbeat: false, + heartbeats: vec![], + observed_inventory_batches: vec![], + }); + } + + // 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() @@ -383,6 +435,7 @@ pub async fn handle_updating( next.error = None; runtime_metadata.prepared_stack = runtime_metadata.pending_prepared_stack.take(); runtime_metadata.setup_update_authorization = None; + runtime_metadata.direct_setup_update_authorization = None; next.runtime_metadata = Some(runtime_metadata); // Promote target to current: update successful next.current_release = next.target_release.clone(); @@ -550,7 +603,50 @@ fn prune_deprovisioned_resources( #[cfg(test)] mod tests { use super::*; - use alien_core::{Kv, Resource, ResourceLifecycle, StackResourceState, Worker, WorkerCode}; + use alien_core::{ + DirectSetupUpdateAuthorization, InitialSetupAuthority, Kv, Resource, ResourceLifecycle, + StackResourceState, Worker, WorkerCode, + }; + + #[test] + fn direct_setup_update_requires_explicit_same_release_authority() { + let mut metadata = RuntimeMetadata { + initial_setup_authority: InitialSetupAuthority::DirectSetup, + direct_setup_update_authorization: Some(DirectSetupUpdateAuthorization { + operation_id: "operation".to_string(), + release_id: "release-a".to_string(), + }), + ..RuntimeMetadata::default() + }; + + assert!(direct_setup_update_is_authorized( + Some(&metadata), + Some("release-a"), + Some("release-a") + )); + assert!(!direct_setup_update_is_authorized( + Some(&metadata), + Some("release-a"), + Some("release-b") + )); + assert!(!direct_setup_update_is_authorized( + Some(&metadata), + Some("release-b"), + Some("release-a") + )); + + metadata.initial_setup_authority = InitialSetupAuthority::ImportedHandoff; + assert!(!direct_setup_update_is_authorized( + Some(&metadata), + Some("release-a"), + Some("release-a") + )); + assert!(!direct_setup_update_is_authorized( + None, + Some("release-a"), + Some("release-a") + )); + } fn state_entry(resource: Resource, status: ResourceStatus) -> StackResourceState { let mut entry = StackResourceState::new_pending( diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index 74676c23e..04af920b5 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -3,10 +3,11 @@ //! These tests exercise the full alien_deployment::step() lifecycle with no cloud I/O. use alien_core::{ - ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus, EnvironmentVariable, - EnvironmentVariableType, EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry, - ResourceLifecycle, RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState, - Storage, Worker, WorkerCode, + ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus, + DirectSetupUpdateAuthorization, EnvironmentVariable, EnvironmentVariableType, + EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry, ResourceLifecycle, + RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState, Storage, Worker, + WorkerCode, }; use chrono::Utc; use indexmap::IndexMap; @@ -786,6 +787,48 @@ async fn setup_authorized_update_clears_authority_only_on_success() { ); } +#[tokio::test] +async fn direct_setup_update_reruns_setup_before_live_reconciliation() { + 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; + let current_release = state.current_release.clone().expect("current release"); + let release_id = current_release + .release_id + .clone() + .expect("current release id"); + state.target_release = Some(current_release); + state.status = DeploymentStatus::UpdatePending; + state + .runtime_metadata + .as_mut() + .expect("runtime metadata") + .direct_setup_update_authorization = Some(DirectSetupUpdateAuthorization { + operation_id: "direct-setup-operation".to_string(), + release_id, + }); + + let setup_step = alien_deployment::step(state, config.clone(), ClientConfig::Test, None) + .await + .expect("direct setup update should prepare setup-owned resources"); + assert_eq!(setup_step.state.status, DeploymentStatus::InitialSetup); + + let completed = run_to_completion(setup_step.state, config).await; + assert_eq!(completed.status, DeploymentStatus::Running); + assert!( + completed + .runtime_metadata + .as_ref() + .expect("runtime metadata") + .direct_setup_update_authorization + .is_none(), + "direct setup authority must be consumed only after provisioning returns to Running" + ); +} + #[tokio::test] async fn update_completes_after_removed_resource_is_deleted() { let _vault_env = test_vault_env().await; From e71b2f5335406e247345871ca10da8391f046656 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 29 Aug 2026 23:11:21 -0700 Subject: [PATCH 2/4] fix: reconcile managed compute capacity during updates --- crates/alien-core/src/deployment/state.rs | 21 --- crates/alien-deployment/src/lib.rs | 1 - crates/alien-deployment/src/pending.rs | 1 - crates/alien-deployment/src/provisioning.rs | 1 - crates/alien-deployment/src/updating.rs | 123 ++++-------------- .../alien-deployment/tests/test_platform.rs | 51 +------- .../frozen_resources_unchanged.rs | 100 +++++++++++++- 7 files changed, 123 insertions(+), 175 deletions(-) diff --git a/crates/alien-core/src/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index 675988c56..0b53f4c45 100644 --- a/crates/alien-core/src/deployment/state.rs +++ b/crates/alien-core/src/deployment/state.rs @@ -42,21 +42,6 @@ pub struct SetupUpdateAuthorization { pub setup_fingerprint_version: u32, } -/// One-shot request for Alien to rerun setup-owned reconciliation. -/// -/// The control plane may issue this only for an operation it explicitly -/// authorizes. The deployment engine additionally requires a direct-setup -/// deployment and an unchanged release before honoring it. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct DirectSetupUpdateAuthorization { - /// Platform operation that owns this reconciliation and fences stale writes. - pub operation_id: String, - /// Release whose setup-owned resources may be reconciled. - pub release_id: String, -} - /// Runtime metadata for deployment /// /// Stores deployment state that needs to persist across step calls. @@ -106,12 +91,6 @@ pub struct RuntimeMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub setup_update_authorization: Option, - /// One-shot authority for an Alien-owned setup update. Unlike an imported - /// setup authorization, the deployment engine prepares and applies the - /// setup-owned target itself under administrator credentials. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direct_setup_update_authorization: Option, - /// Whether cross-account registry access has been successfully granted. /// Set to true after the manager successfully sets the ECR/GAR repo policy /// for this deployment's target account. Prevents redundant API calls on diff --git a/crates/alien-deployment/src/lib.rs b/crates/alien-deployment/src/lib.rs index 7b4abdd85..6b04a253b 100644 --- a/crates/alien-deployment/src/lib.rs +++ b/crates/alien-deployment/src/lib.rs @@ -130,7 +130,6 @@ pub async fn step( info!("A newer target release is available; starting update reconciliation"); if let Some(metadata) = current.runtime_metadata.as_mut() { metadata.setup_update_authorization = None; - metadata.direct_setup_update_authorization = None; } current.status = DeploymentStatus::UpdatePending; } diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index de0f290c5..00db40c55 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -41,7 +41,6 @@ pub async fn prepare_direct_setup_update( let mut metadata = existing_metadata.clone(); metadata.initial_setup_authority = alien_core::InitialSetupAuthority::DirectSetup; metadata.prepared_stack = Some(mutated_stack); - metadata.pending_prepared_stack = None; metadata.persisted_gate_answers = persisted_gate_answers; metadata.setup_update_authorization = None; Ok(metadata) diff --git a/crates/alien-deployment/src/provisioning.rs b/crates/alien-deployment/src/provisioning.rs index e2a81acb0..208a36f52 100644 --- a/crates/alien-deployment/src/provisioning.rs +++ b/crates/alien-deployment/src/provisioning.rs @@ -156,7 +156,6 @@ pub async fn handle_provisioning( } else if stack_status == StackStatus::Running { info!("All live resources deployed successfully, transitioning to Running"); - runtime_metadata.direct_setup_update_authorization = None; next.status = DeploymentStatus::Running; next.stack_state = Some(step_result.next_state); next.error = None; diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index b4ec5c3b2..55af6aa12 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -2,8 +2,8 @@ use crate::{ DeploymentConfig, DeploymentState, DeploymentStatus, DeploymentStepResult, ErrorData, Result, }; use alien_core::{ - ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, RuntimeMetadata, Stack, - StackState, StackStatus, + ComputeClusterOutputs, Platform, ResourceLifecycle, ResourceStatus, Stack, StackState, + StackStatus, }; use alien_error::{AlienError, Context}; use alien_infra::{state_utils::StackStateExt, RunningResourcePolicy, StackExecutor}; @@ -21,23 +21,6 @@ fn machines_deployment_has_zero_machines(platform: Platform, stack_state: &Stack }) } -fn direct_setup_update_is_authorized( - metadata: Option<&RuntimeMetadata>, - current_release_id: Option<&str>, - target_release_id: Option<&str>, -) -> bool { - metadata.is_some_and(|metadata| { - metadata.initial_setup_authority == alien_core::InitialSetupAuthority::DirectSetup - && metadata - .direct_setup_update_authorization - .as_ref() - .is_some_and(|authorization| { - Some(authorization.release_id.as_str()) == target_release_id - && current_release_id == target_release_id - }) - }) -} - fn compute_update_status( stack_state: &StackState, target_stack: &Stack, @@ -182,45 +165,10 @@ 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)); - let target_release_id = current .target_release .as_ref() .and_then(|release| release.release_id.as_deref()); - let direct_setup_update_authorized = direct_setup_update_is_authorized( - current.runtime_metadata.as_ref(), - current - .current_release - .as_ref() - .and_then(|release| release.release_id.as_deref()), - target_release_id, - ); - if direct_setup_update_authorized { - let existing_metadata = current.runtime_metadata.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::MissingConfiguration { - message: "Runtime metadata required for direct setup update".to_string(), - }) - })?; - let runtime_metadata = crate::pending::prepare_direct_setup_update( - target_stack, - &stack_state, - &config, - &client_config, - existing_metadata, - ) - .await?; - next.status = DeploymentStatus::InitialSetup; - next.stack_state = Some(stack_state); - next.error = None; - next.runtime_metadata = Some(runtime_metadata); - return Ok(DeploymentStepResult { - state: next, - suggested_delay_ms: None, - update_heartbeat: false, - heartbeats: vec![], - observed_inventory_batches: vec![], - }); - } // Run deployment-time preflights: compatibility checks + mutations + runtime checks // Store the mutated stack to use for the actual update and for future compatibility checks @@ -332,6 +280,23 @@ 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()); + } + } + } // Inject environment variables into the prepared stack crate::helpers::inject_environment_variables(&mut target_stack, &config, current.platform)?; @@ -347,7 +312,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, @@ -366,7 +331,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 { @@ -435,7 +400,6 @@ pub async fn handle_updating( next.error = None; runtime_metadata.prepared_stack = runtime_metadata.pending_prepared_stack.take(); runtime_metadata.setup_update_authorization = None; - runtime_metadata.direct_setup_update_authorization = None; next.runtime_metadata = Some(runtime_metadata); // Promote target to current: update successful next.current_release = next.target_release.clone(); @@ -603,50 +567,7 @@ fn prune_deprovisioned_resources( #[cfg(test)] mod tests { use super::*; - use alien_core::{ - DirectSetupUpdateAuthorization, InitialSetupAuthority, Kv, Resource, ResourceLifecycle, - StackResourceState, Worker, WorkerCode, - }; - - #[test] - fn direct_setup_update_requires_explicit_same_release_authority() { - let mut metadata = RuntimeMetadata { - initial_setup_authority: InitialSetupAuthority::DirectSetup, - direct_setup_update_authorization: Some(DirectSetupUpdateAuthorization { - operation_id: "operation".to_string(), - release_id: "release-a".to_string(), - }), - ..RuntimeMetadata::default() - }; - - assert!(direct_setup_update_is_authorized( - Some(&metadata), - Some("release-a"), - Some("release-a") - )); - assert!(!direct_setup_update_is_authorized( - Some(&metadata), - Some("release-a"), - Some("release-b") - )); - assert!(!direct_setup_update_is_authorized( - Some(&metadata), - Some("release-b"), - Some("release-a") - )); - - metadata.initial_setup_authority = InitialSetupAuthority::ImportedHandoff; - assert!(!direct_setup_update_is_authorized( - Some(&metadata), - Some("release-a"), - Some("release-a") - )); - assert!(!direct_setup_update_is_authorized( - None, - Some("release-a"), - Some("release-a") - )); - } + use alien_core::{Kv, Resource, ResourceLifecycle, StackResourceState, Worker, WorkerCode}; fn state_entry(resource: Resource, status: ResourceStatus) -> StackResourceState { let mut entry = StackResourceState::new_pending( diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index 04af920b5..74676c23e 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -3,11 +3,10 @@ //! These tests exercise the full alien_deployment::step() lifecycle with no cloud I/O. use alien_core::{ - ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus, - DirectSetupUpdateAuthorization, EnvironmentVariable, EnvironmentVariableType, - EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry, ResourceLifecycle, - RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState, Storage, Worker, - WorkerCode, + ClientConfig, DeploymentConfig, DeploymentState, DeploymentStatus, EnvironmentVariable, + EnvironmentVariableType, EnvironmentVariablesSnapshot, Platform, ReleaseInfo, ResourceEntry, + ResourceLifecycle, RuntimeMetadata, SetupUpdateAuthorization, Stack, StackSettings, StackState, + Storage, Worker, WorkerCode, }; use chrono::Utc; use indexmap::IndexMap; @@ -787,48 +786,6 @@ async fn setup_authorized_update_clears_authority_only_on_success() { ); } -#[tokio::test] -async fn direct_setup_update_reruns_setup_before_live_reconciliation() { - 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; - let current_release = state.current_release.clone().expect("current release"); - let release_id = current_release - .release_id - .clone() - .expect("current release id"); - state.target_release = Some(current_release); - state.status = DeploymentStatus::UpdatePending; - state - .runtime_metadata - .as_mut() - .expect("runtime metadata") - .direct_setup_update_authorization = Some(DirectSetupUpdateAuthorization { - operation_id: "direct-setup-operation".to_string(), - release_id, - }); - - let setup_step = alien_deployment::step(state, config.clone(), ClientConfig::Test, None) - .await - .expect("direct setup update should prepare setup-owned resources"); - assert_eq!(setup_step.state.status, DeploymentStatus::InitialSetup); - - let completed = run_to_completion(setup_step.state, config).await; - assert_eq!(completed.status, DeploymentStatus::Running); - assert!( - completed - .runtime_metadata - .as_ref() - .expect("runtime metadata") - .direct_setup_update_authorization - .is_none(), - "direct setup authority must be consumed only after provisioning returns to Running" - ); -} - #[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); + } } From 54f84a8ddd91ae887603233db818635aed2159dd Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 29 Aug 2026 23:13:47 -0700 Subject: [PATCH 3/4] docs: clarify update reconciliation scope --- crates/alien-deployment/src/updating.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index 55af6aa12..d0e99543b 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -717,9 +717,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(); From 5ae7f5c217f374d203e27a1db911d6a62e438f04 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 29 Aug 2026 23:36:44 -0700 Subject: [PATCH 4/4] fix: retain frozen resources across updates --- crates/alien-deployment/src/updating.rs | 4 + .../alien-deployment/tests/test_platform.rs | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index d0e99543b..bf0101caf 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -297,6 +297,10 @@ pub async fn handle_updating( } } } + // 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)?; 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;