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
4 changes: 2 additions & 2 deletions crates/alien-core/src/instance_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,8 +1215,8 @@ pub fn find_instance_type(platform: Platform, name: &str) -> Option<&'static Ins
.find(|spec| spec.platform == platform && spec.name == name)
}

/// Whether a capacity group may roll from `old` to `new` at runtime: both are AWS catalog
/// machines of one architecture, so the stack's images still run on the new machine.
/// Whether a capacity group may move from AWS machine `old` to `new` without setup: both are
/// catalog machines of one CPU architecture, so the stack's images still run on the new one.
pub fn is_same_architecture_aws_machine(old: &str, new: &str) -> bool {
match (
find_instance_type(Platform::Aws, old),
Expand Down
126 changes: 41 additions & 85 deletions crates/alien-core/src/resources/compute_cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ pub struct CapacityGroup {
///
/// ## Architecture
///
/// - **Setup** owns the identity and network boundary: IAM roles, instance profiles, security groups
/// - **Runtime** manages the worker fleet (ASGs/MIGs/VMSSs) inside that boundary: machine count,
/// machine image and, on AWS, machine type within one CPU architecture
/// - **Setup** creates cloud resources: ASGs/MIGs/VMSSs, IAM roles, security groups
/// - **Alien** manages allowed fleet operations: machine count, runtime
/// machine image rollout and, on AWS, the machine type within one CPU architecture
/// - A node agent runs on each machine from the selected runtime image channel
///
/// ## Example
Expand Down Expand Up @@ -380,21 +380,22 @@ impl ResourceDefinition for ComputeCluster {
.iter()
.find(|g| g.group_id == new_group.group_id)
{
// Without the platform, only the catalog rule is enforced here; the Frozen
// compatibility check also limits a runtime machine change to AWS.
if let Some((old, new)) = existing_group
.instance_type
.as_deref()
.zip(new_group.instance_type.as_deref())
.filter(|(old, new)| old != new && !is_same_architecture_aws_machine(old, new))
{
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: format!(
"instance type for capacity group '{}' can change only to an AWS machine of the same CPU architecture ('{}' -> '{}')",
new_group.group_id, old, new
),
}));
// The controller rolls the fleet onto another machine of the same architecture;
// the stack's images may not run on any other machine. Preflights also limit
// this to AWS, which this check can't see.
if let (Some(old), Some(new)) = (
existing_group.instance_type.as_deref(),
new_group.instance_type.as_deref(),
) {
if old != new && !is_same_architecture_aws_machine(old, new) {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: format!(
"capacity group '{}' can't change machine from '{old}' to '{new}': only an AWS machine of the same CPU architecture can replace it",
new_group.group_id
),
}));
}
}
}
}
Expand Down Expand Up @@ -569,81 +570,36 @@ mod tests {
assert!(result.is_ok());
}

fn machine_cluster(instance_type: &str) -> ComputeCluster {
ComputeCluster::new("compute".to_string())
.capacity_group(CapacityGroup {
group_id: "general".to_string(),
instance_type: Some(instance_type.to_string()),
profile: None,
min_size: 1,
max_size: 5,
scale_policy: None,
nested_virtualization: None,
})
.build()
}

fn machine_update(old: &str, new: &str) -> Result<()> {
machine_cluster(old).validate_update(&machine_cluster(new))
}

#[test]
fn an_aws_machine_change_within_one_architecture_is_a_valid_update() {
machine_update("t4g.micro", "c7g.medium").expect("arm64 to arm64 rolls at runtime");
machine_update("c7g.xlarge", "c7g.2xlarge").expect("arm64 to arm64 rolls at runtime");
}

/// GCP, Azure and uncatalogued names never resolve in the AWS catalog, so only name equality
/// keeps their scale-only updates valid.
#[test]
fn an_unchanged_machine_outside_the_aws_catalog_is_a_valid_update() {
for machine in ["n2-standard-2", "Standard_D2s_v5", "t4g.unknown"] {
let mut scaled = machine_cluster(machine);
scaled.capacity_groups[0].max_size = 10;
machine_cluster(machine)
.validate_update(&scaled)
.unwrap_or_else(|error| panic!("{machine}: {error:?}"));
}
}

#[test]
fn other_machine_changes_stay_immutable() {
fn machine_changes_stay_within_one_aws_architecture() {
let cluster = |machine: &str| {
ComputeCluster::new("compute".to_string())
.capacity_group(CapacityGroup {
group_id: "general".to_string(),
instance_type: Some(machine.to_string()),
profile: None,
min_size: 1,
max_size: 5,
scale_policy: None,
nested_virtualization: None,
})
.build()
};
cluster("t4g.small")
.validate_update(&cluster("t4g.medium"))
.expect("arm64 to arm64");
for (old, new) in [
("t4g.micro", "m7i.large"),
("t4g.micro", "t4g.unknown"),
("t4g.unknown", "t4g.micro"),
("t4g.small", "m7i.large"),
("t4g.small", "t4g.unknown"),
("n2-standard-2", "n2-standard-4"),
] {
let error = machine_update(old, new).expect_err("needs setup");
let error = cluster(old)
.validate_update(&cluster(new))
.expect_err("needs setup");
assert_eq!(error.code, "INVALID_RESOURCE_UPDATE", "{old} -> {new}");
assert!(
error.message.contains("same CPU architecture"),
"{old} -> {new}"
);
}
}

#[test]
fn a_machine_change_cannot_carry_an_id_or_cidr_change() {
let renamed = ComputeCluster {
id: "other".to_string(),
..machine_cluster("c7g.medium")
};
assert!(machine_cluster("t4g.micro")
.validate_update(&renamed)
.is_err());

let with_cidr = |cidr: &str| ComputeCluster {
container_cidr: Some(cidr.to_string()),
..machine_cluster("t4g.micro")
};
let moved = ComputeCluster {
container_cidr: Some("10.250.0.0/16".to_string()),
..machine_cluster("c7g.medium")
};
assert!(with_cidr("10.244.0.0/16").validate_update(&moved).is_err());
}

#[test]
fn test_compute_cluster_serialization() {
let cluster = ComputeCluster::new("test-cluster".to_string())
Expand Down
3 changes: 2 additions & 1 deletion crates/alien-deployment/src/updating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,8 @@ pub async fn handle_updating(
// not be deleted by an ordinary update. Keep their installed definitions
// in the execution target while allowing explicitly runtime-managed frozen
// resources (ComputeCluster capacity and, on AWS, a same-architecture machine
// type) to reconcile changed configuration through their management controller.
// type) 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
Expand Down
170 changes: 126 additions & 44 deletions crates/alien-infra/src/core/executor_tests/update_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ use super::helpers::*;
use crate::core::{MockPlatformServiceProvider, StackExecutor, StackStateExt};
use crate::error::Result;
use alien_core::{
ClientConfig, ComputeCluster, KubernetesClientConfig, Platform, Resource, ResourceLifecycle,
ResourceRef, ResourceStatus, Stack, StackResourceState, StackState,
ClientConfig, ComputeCluster, ComputePoolSelection, ComputeSettings, Container, ContainerCode,
DeploymentConfig, KubernetesClientConfig, Platform, Resource, ResourceLifecycle, ResourceRef,
ResourceSpec, ResourceStatus, Stack, StackResourceState, StackState,
};
use alien_error::AlienError;
use alien_k8s_clients::kubernetes::deployments::MockDeploymentApi;
use alien_preflights::{mutations::ComputeClusterMutation, runner::PreflightRunner, StackMutation};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
Expand Down Expand Up @@ -415,55 +417,135 @@ async fn imported_kubernetes_compute_retries_namespace_verification() -> Result<
Ok(())
}

fn machine_cluster(instance_type: &str) -> ComputeCluster {
ComputeCluster::new("compute".to_string())
.capacity_group(alien_core::CapacityGroup {
group_id: "general".to_string(),
instance_type: Some(instance_type.to_string()),
profile: None,
min_size: 1,
max_size: 1,
scale_policy: None,
nested_virtualization: None,
/// Exercise derived workload profiles through compatibility and the executor's real planner.
#[tokio::test]
async fn mutated_machine_change_passes_compatibility_and_plans_update() {
let workload = Container::new("api".to_string())
.code(ContainerCode::Image {
image: "test:latest".to_string(),
})
.build()
}
.cpu(ResourceSpec {
min: "0.25".to_string(),
desired: "0.25".to_string(),
})
.memory(ResourceSpec {
min: "256Mi".to_string(),
desired: "256Mi".to_string(),
})
.ephemeral_storage("40Gi".to_string())
.port(8080)
.permissions("execution".to_string())
.build();
let release = Stack::new("machine-change".to_string())
.add(workload, ResourceLifecycle::Live)
.build();
let installed = materialize_machine(release.clone(), "t4g.micro").await;
let resized = materialize_machine(release.clone(), "t4g.small").await;
let config = default_deployment_config();
assert!(!config.allow_frozen_changes);
let compatibility = PreflightRunner::new()
.run_compatibility_checks(&installed, &resized, &config, Platform::Aws)
.await
.expect("compatibility checks should run");
assert!(compatibility.success, "{compatibility:?}");

let cluster = |stack: &Stack| {
stack
.resources
.values()
.find_map(|entry| entry.config.downcast_ref::<ComputeCluster>().cloned())
.expect("mutation should produce a compute cluster")
};
let old_cluster = cluster(&installed);
let new_cluster = cluster(&resized);
assert_eq!(
old_cluster.capacity_groups[0].instance_type.as_deref(),
Some("t4g.micro")
);
assert_eq!(
new_cluster.capacity_groups[0].instance_type.as_deref(),
Some("t4g.small")
);
assert_eq!(
new_cluster.capacity_groups[0]
.profile
.as_ref()
.unwrap()
.ephemeral_storage_bytes,
40 * 1024 * 1024 * 1024
);

/// Planning runs `validate_update`, which ignores the platform; this executor's only fixture is
/// Kubernetes, so it stands in for AWS here (the AWS-only rule lives in the Frozen preflight).
#[tokio::test]
async fn a_same_architecture_machine_change_plans_an_update() -> Result<()> {
// The planner invokes ComputeCluster::validate_update independently of its controller.
// Use the built-in Kubernetes controller for planning only; no provider is contacted.
let mut state = StackState::new(Platform::Kubernetes);
let mut installed = StackResourceState::new_pending(
let mut resource = StackResourceState::new_pending(
ComputeCluster::RESOURCE_TYPE.to_string(),
Resource::new(machine_cluster("c7g.xlarge")),
Resource::new(old_cluster.clone()),
Some(ResourceLifecycle::Frozen),
vec![],
);
installed.status = ResourceStatus::Running;
state.resources.insert("compute".to_string(), installed);
let executor = |machine| {
let stack = Stack::new("machine-change".to_string())
.add(machine_cluster(machine), ResourceLifecycle::Frozen)
.build();
StackExecutor::builder(
&stack,
ClientConfig::Kubernetes(Box::new(KubernetesClientConfig::InCluster {
namespace: Some("application".to_string()),
additional_headers: None,
})),
)
.deployment_config(&default_deployment_config())
.service_provider(Arc::new(MockPlatformServiceProvider::new()))
.build()
};
resource.status = ResourceStatus::Running;
state.resources.insert(old_cluster.id.clone(), resource);
let plan = plan_machine_cluster(new_cluster, &state, &config)
.expect("same-architecture machine should plan");
assert_eq!(plan.updates.len(), 1);
assert!(plan.updates.contains_key(&old_cluster.id));
assert!(plan.creates.is_empty());
assert!(plan.deletes.is_empty());

let cross_arch = materialize_machine(release, "m7i.large").await;
let compatibility = PreflightRunner::new()
.run_compatibility_checks(&installed, &cross_arch, &config, Platform::Aws)
.await
.expect("compatibility checks should run");
assert!(!compatibility.success);
assert!(compatibility
.results
.iter()
.flat_map(|result| &result.errors)
.any(|error| error.contains("same CPU architecture")));
let error = plan_machine_cluster(cluster(&cross_arch), &state, &config)
.expect_err("planner must also refuse a cross-architecture machine");
assert_eq!(error.code, "RESOURCE_CONFIG_INVALID");
}

let plan = executor("c7g.2xlarge")?.plan(&state)?;
assert!(plan.updates.contains_key("compute"));
async fn materialize_machine(stack: Stack, machine: &str) -> Stack {
let mut config = default_deployment_config();
config.stack_settings.compute = Some(ComputeSettings {
pools: [(
"general".to_string(),
ComputePoolSelection::Fixed {
machines: 1,
machine: Some(machine.to_string()),
failure_domains: None,
},
)]
.into_iter()
.collect(),
});
ComputeClusterMutation
.mutate(stack, &StackState::new(Platform::Aws), &config)
.await
.expect("compute selection should materialize")
}

let error = executor("m7i.large")?
.plan(&state)
.expect_err("a cross-architecture machine change cannot be updated");
assert_eq!(error.code, "RESOURCE_CONFIG_INVALID");
Ok(())
fn plan_machine_cluster(
cluster: ComputeCluster,
state: &StackState,
config: &DeploymentConfig,
) -> Result<crate::core::PlanResult> {
let stack = Stack::new("machine-change".to_string())
.add(cluster, ResourceLifecycle::Frozen)
.build();
StackExecutor::builder(
&stack,
ClientConfig::Kubernetes(Box::new(KubernetesClientConfig::InCluster {
namespace: Some("application".to_string()),
additional_headers: None,
})),
)
.deployment_config(config)
.service_provider(Arc::new(MockPlatformServiceProvider::new()))
.build()?
.plan(state)
}
Loading
Loading