From 80936acf9a70be562e8bf512823bad3807949975 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 29 Aug 2026 21:50:35 -0700 Subject: [PATCH] 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;