diff --git a/crates/alien-core/src/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index 0b53f4c45..57fbe5a9a 100644 --- a/crates/alien-core/src/deployment/state.rs +++ b/crates/alien-core/src/deployment/state.rs @@ -91,6 +91,12 @@ pub struct RuntimeMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub setup_update_authorization: Option, + /// Last generated CLI package revision whose direct setup was applied. + /// This lets a newer generated CLI refresh setup-owned infrastructure once + /// before handing runtime changes back to the hosted manager. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_setup_revision: 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-core/src/embedded_config.rs b/crates/alien-core/src/embedded_config.rs index 7a4e9a464..4c4219eac 100644 --- a/crates/alien-core/src/embedded_config.rs +++ b/crates/alien-core/src/embedded_config.rs @@ -39,6 +39,10 @@ pub struct DeployCliConfig { /// Unix install script URL for this CLI package. #[serde(skip_serializing_if = "Option::is_none")] pub install_script_url: Option, + /// Package build revision for setup-affecting code and artifacts. Existing + /// direct-setup deployments reconcile setup once when this value changes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup_revision: Option, /// Branded environment variable that contains the deployment token. #[serde(skip_serializing_if = "Option::is_none")] pub token_env_var: Option, @@ -195,6 +199,7 @@ mod tests { "https://packages.example.com/acme/machine-bundle.json".into(), ), install_script_url: Some("https://packages.example.com/acme/install.sh".into()), + setup_revision: Some("build-123".into()), token_env_var: Some("ACME_DEPLOYMENT_TOKEN".into()), name: Some("acme-deploy".into()), }; diff --git a/crates/alien-deploy-cli/src/commands/join.rs b/crates/alien-deploy-cli/src/commands/join.rs index 468c243ae..19dcfccbd 100644 --- a/crates/alien-deploy-cli/src/commands/join.rs +++ b/crates/alien-deploy-cli/src/commands/join.rs @@ -2944,6 +2944,7 @@ mod tests { "https://packages.example.com/machines/manifest.json".to_string(), ), install_script_url: None, + setup_revision: None, token_env_var: None, name: None, display_name: None, @@ -3061,6 +3062,7 @@ mod tests { "https://packages.example.com/machines/manifest.json".to_string(), ), install_script_url: None, + setup_revision: None, token_env_var: None, name: None, display_name: None, diff --git a/crates/alien-deploy-cli/src/commands/up.rs b/crates/alien-deploy-cli/src/commands/up.rs index 2e95fa42a..f901ef801 100644 --- a/crates/alien-deploy-cli/src/commands/up.rs +++ b/crates/alien-deploy-cli/src/commands/up.rs @@ -413,6 +413,29 @@ mod tests { } } + #[test] + fn hosted_setup_reconciles_each_packaged_revision_once() { + for status in ["running", "update-failed", "refresh-failed"] { + assert!(hosted_setup_reconcile_required( + status, + Some("package-b"), + Some("package-a") + )); + assert!(!hosted_setup_reconcile_required( + status, + Some("package-b"), + Some("package-b") + )); + } + + assert!(!hosted_setup_reconcile_required( + "updating", + Some("package-b"), + Some("package-a") + )); + assert!(!hosted_setup_reconcile_required("running", None, None)); + } + #[test] fn stable_channel_accepts_exact_semver_tag() { assert_eq!( @@ -1370,7 +1393,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) )?; // Check if the deployment is already active — nothing to do. - let current_deployment = client + let mut current_deployment = client .get_deployment() .id(&deployment_id) .send() @@ -1383,6 +1406,51 @@ 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('/'); + let setup_revision = embedded_config.and_then(|config| config.setup_revision.as_deref()); + let applied_setup_revision = current_deployment + .runtime_metadata + .as_ref() + .and_then(|metadata| serde_json::to_value(metadata).ok()) + .and_then(|metadata| { + metadata + .get("directSetupRevision") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }); + if init.deployment_model == DeploymentModel::Push + && hosted_platform + && requires_install_context(platform) + && hosted_setup_reconcile_required( + ¤t_deployment.status, + setup_revision, + applied_setup_revision.as_deref(), + ) + { + output::info("Refreshing setup-owned infrastructure for this CLI revision..."); + run_push_model( + &client, + &deployment_id, + platform, + base_platform, + &manager_url, + &effective_token, + install_management_config.clone(), + &args.network, + None, + setup_revision, + ) + .await?; + current_deployment = client + .get_deployment() + .id(&deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to refresh deployment after setup reconciliation".to_string(), + })? + .into_inner(); + } if supports_hosted_compute_update(¤t_deployment.status) && init.deployment_model == DeploymentModel::Push && hosted_platform @@ -1507,6 +1575,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) &effective_token, None, None, + setup_revision, ) .await?; @@ -1555,6 +1624,7 @@ pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) install_management_config, &args.network, Some(on_progress), + setup_revision, ) .await?; @@ -2808,6 +2878,16 @@ fn supports_hosted_compute_update(status: &str) -> bool { ) } +fn hosted_setup_reconcile_required( + status: &str, + packaged_revision: Option<&str>, + applied_revision: Option<&str>, +) -> bool { + matches!(status, "running" | "update-failed" | "refresh-failed") + && packaged_revision.is_some() + && packaged_revision != applied_revision +} + /// Whether the hosted platform must receive the requested compute target. /// /// Platform persists the desired settings before the deployment engine applies @@ -3766,6 +3846,7 @@ async fn run_push_model( management_config: Option, network_args: &NetworkArgs, on_progress: Option, + setup_revision: Option<&str>, ) -> Result<()> { let credential_platform = base_platform.unwrap_or(platform); let client_config = ClientConfig::from_std_env(credential_platform) @@ -3788,6 +3869,7 @@ async fn run_push_model( deployment_token, Some(network_args), on_progress, + setup_revision, ) .await } @@ -3819,6 +3901,7 @@ pub async fn push_initial_setup( deployment_token: &str, network_args: Option<&NetworkArgs>, on_progress: Option, + setup_revision: Option<&str>, ) -> Result<()> { let setup_management_config = management_config.clone(); @@ -4065,7 +4148,12 @@ pub async fn push_initial_setup( message: "Failed to deserialize runtime_metadata from manager".to_string(), })?; - if state.status == DeploymentStatus::Running { + if matches!( + state.status, + DeploymentStatus::Running + | DeploymentStatus::UpdateFailed + | DeploymentStatus::RefreshFailed + ) { let stack_state = state.stack_state.as_ref().ok_or_else(|| { AlienError::new(ErrorData::ConfigurationError { message: "A running deployment has no stack state for setup update".to_string(), @@ -4129,6 +4217,16 @@ pub async fn push_initial_setup( ) .await; + if let Ok(result) = &runner_result { + if matches!(result.loop_result.outcome, LoopOutcome::Success) { + if let (Some(revision), Some(metadata)) = + (setup_revision, state.runtime_metadata.as_mut()) + { + metadata.direct_setup_revision = Some(revision.to_string()); + } + } + } + // Always reconcile + release, even on error. final_reconcile(client, deployment_id, &session, &state).await; release_deployment(client, deployment_id, &session).await; diff --git a/crates/alien-deploy-cli/src/lib.rs b/crates/alien-deploy-cli/src/lib.rs index efb79a041..6a10e114f 100644 --- a/crates/alien-deploy-cli/src/lib.rs +++ b/crates/alien-deploy-cli/src/lib.rs @@ -183,6 +183,7 @@ mod tests { agent_binary_url: None, machine_bundle_url: None, install_script_url: None, + setup_revision: None, token_env_var: None, name: Some("acmectl".to_string()), display_name: Some("Acme Deployment CLI".to_string()), diff --git a/crates/alien-deployment/src/manager_api_transport.rs b/crates/alien-deployment/src/manager_api_transport.rs index 34a754dc0..bac36422f 100644 --- a/crates/alien-deployment/src/manager_api_transport.rs +++ b/crates/alien-deployment/src/manager_api_transport.rs @@ -314,6 +314,9 @@ pub async fn acquire_setup_run_deployment( "initial-setup".to_string(), "initial-setup-failed".to_string(), "waiting-for-machines".to_string(), + "running".to_string(), + "update-failed".to_string(), + "refresh-failed".to_string(), ]), ) .await