Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion crates/alien-deploy-cli/src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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(&current_deployment.status)
&& init.deployment_model == DeploymentModel::Push
&& hosted_platform
&& stack_settings.compute.is_some()
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 28 additions & 8 deletions crates/alien-deployment/src/updating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)?;

Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
90 changes: 90 additions & 0 deletions crates/alien-deployment/tests/test_platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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::<ComputeCluster>(),
new.downcast_ref::<ComputeCluster>(),
) 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 {
Expand Down Expand Up @@ -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.",
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
}
Loading