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
12 changes: 12 additions & 0 deletions crates/alien-core/src/instance_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,18 @@ 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.
pub fn is_same_architecture_aws_machine(old: &str, new: &str) -> bool {
match (
find_instance_type(Platform::Aws, old),
find_instance_type(Platform::Aws, new),
) {
(Some(old), Some(new)) => old.architecture == new.architecture,
_ => false,
}
}

// ---------------------------------------------------------------------------
// Instance type selection
// ---------------------------------------------------------------------------
Expand Down
98 changes: 88 additions & 10 deletions crates/alien-core/src/resources/compute_cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! - Launch templates/instance configurations

use crate::error::{ErrorData, Result};
use crate::instance_catalog::Architecture;
use crate::instance_catalog::{is_same_architecture_aws_machine, Architecture};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
use crate::ResourceType;
use alien_error::AlienError;
Expand Down Expand Up @@ -183,9 +183,9 @@ pub struct CapacityGroup {
///
/// ## Architecture
///
/// - **Setup** creates cloud resources: ASGs/MIGs/VMSSs, IAM roles, security groups
/// - **Alien** manages allowed fleet operations: machine count and runtime
/// machine image rollout
/// - **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
/// - A node agent runs on each machine from the selected runtime image channel
///
/// ## Example
Expand Down Expand Up @@ -380,16 +380,19 @@ impl ResourceDefinition for ComputeCluster {
.iter()
.find(|g| g.group_id == new_group.group_id)
{
// Instance type is immutable for existing groups
if existing_group.instance_type.is_some()
&& new_group.instance_type.is_some()
&& existing_group.instance_type != new_group.instance_type
// 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 '{}' is immutable",
new_group.group_id
"instance type for capacity group '{}' can change only to an AWS machine of the same CPU architecture ('{}' -> '{}')",
new_group.group_id, old, new
),
}));
}
Expand Down Expand Up @@ -566,6 +569,81 @@ 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() {
for (old, new) in [
("t4g.micro", "m7i.large"),
("t4g.micro", "t4g.unknown"),
("t4g.unknown", "t4g.micro"),
("n2-standard-2", "n2-standard-4"),
] {
let error = machine_update(old, 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
4 changes: 2 additions & 2 deletions crates/alien-deployment/src/updating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ pub async fn handle_updating(
// 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.
// resources (ComputeCluster capacity and, on AWS, a same-architecture machine
// 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
53 changes: 53 additions & 0 deletions crates/alien-infra/src/core/executor_tests/update_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,56 @@ async fn imported_kubernetes_compute_retries_namespace_verification() -> Result<
assert!(state.resources["compute"].outputs.is_none());
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,
})
.build()
}

/// 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<()> {
let mut state = StackState::new(Platform::Kubernetes);
let mut installed = StackResourceState::new_pending(
ComputeCluster::RESOURCE_TYPE.to_string(),
Resource::new(machine_cluster("c7g.xlarge")),
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()
};

let plan = executor("c7g.2xlarge")?.plan(&state)?;
assert!(plan.updates.contains_key("compute"));

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(())
}
Loading
Loading