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
22 changes: 20 additions & 2 deletions crates/alien-infra/src/core/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1348,10 +1348,28 @@ impl StackExecutor {
/// without planning structural changes. Logical Kubernetes compute pools are
/// initialized here because cloud setup engines do not provision them.
pub async fn continue_imported(&self, mut state: StackState) -> Result<StepResult> {
// Setup also registers each Live resource it renders scaffolding for (today a Live
// sandbox) as Provisioning with controller state, for the runtime controller to finish.
// Any other record outside the lifecycle filter didn't come from setup.
let left_for_runtime = |resource_id: &str, imported: &StackResourceState| {
self.desired_stack
.resources
.get(resource_id)
.is_some_and(|entry| {
ownership_policy_for_resource_type(entry.config.resource_type().as_ref())
.emits_setup_scaffolding(entry.lifecycle)
&& imported.status == ResourceStatus::Provisioning
&& imported.has_internal_state()
})
};
if let Some(resource_id) = state
.resources
.keys()
.find(|resource_id| !self.resources.contains_key(*resource_id))
.iter()
.find(|(resource_id, imported)| {
!self.resources.contains_key(*resource_id)
&& !left_for_runtime(resource_id, imported)
})
.map(|(resource_id, _)| resource_id)
{
return Err(AlienError::new(ErrorData::ImportedSetupStateInvalid {
message: format!(
Expand Down
189 changes: 189 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 @@ -163,6 +163,195 @@ async fn imported_continuation_refuses_missing_setup_resource() -> Result<()> {
Ok(())
}

/// Built the way `initial_setup.rs` builds it for an imported handoff.
fn new_imported_setup_executor(stack: &Stack) -> Result<StackExecutor> {
StackExecutor::builder(stack, ClientConfig::Test)
.deployment_config(&default_deployment_config())
.lifecycle_filter(vec![ResourceLifecycle::Frozen])
.step_running_resources(false)
.step_out_of_scope_resources(false)
.build()
}

fn entry(resource: Resource, lifecycle: ResourceLifecycle) -> alien_core::ResourceEntry {
alien_core::ResourceEntry {
config: resource,
lifecycle,
dependencies: Vec::new(),
remote_access: false,
enabled_when: None,
}
}

/// A stack of one Frozen storage plus `extra`, and imported state that holds the synced storage
/// and `imported` for `extra`.
async fn imported_beside_storage(
extra: (&str, alien_core::ResourceEntry),
imported: StackResourceState,
) -> Result<(Stack, StackState)> {
let mut stack = Stack::new("imported".to_owned())
.add(test_storage("assets"), ResourceLifecycle::Frozen)
.build();
let mut state = run_to_synced(&new_executor(&stack)?, new_test_state()).await?;
state.resources.insert(extra.0.to_string(), imported);
stack.resources.insert(extra.0.to_string(), extra.1);
Ok((stack, state))
}

#[cfg(feature = "aws")]
fn live_sandbox_entry() -> alien_core::ResourceEntry {
let sandbox = alien_core::Sandbox::new("agents".to_string())
.code(alien_core::SandboxCode::Image {
image: "s3://example-artifacts/agents/bundle.zip".to_string(),
})
.egress(alien_core::SandboxEgress::Allow)
.lifecycle(alien_core::SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
entry(Resource::new(sandbox), ResourceLifecycle::Live)
}

/// The record the AWS importer builds from a sandbox registration, so these tests follow the
/// importer if what it produces changes.
#[cfg(feature = "aws")]
fn import_aws_sandbox(
entry: &alien_core::ResourceEntry,
image: Option<(&str, &str, &str)>,
) -> StackResourceState {
use crate::import::ResourceImporter;

let settings = alien_core::StackSettings::default();
let ctx = alien_core::import::ImportContext {
resource_id: "agents",
platform: Platform::Aws,
region: "us-east-2",
stack_settings: &settings,
management_config: None,
resource: entry,
};
let data = alien_core::import::data::AwsSandboxImportData {
image_identifier: image.map(|(identifier, _, _)| identifier.to_string()),
image_arn: image.map(|(_, arn, _)| arn.to_string()),
image_version: image.map(|(_, _, version)| version.to_string()),
build_role_arn: Some("arn:aws:iam::123456789012:role/agents-build".to_string()),
bundle_uri: Some("s3://example-artifacts/agents/bundle.zip".to_string()),
egress_connector_arns: Vec::new(),
preview_ports: Vec::new(),
allow_egress: true,
};
crate::sandbox::AwsSandboxImporter
.import(data, &ctx)
.expect("the AWS sandbox registration imports")
}

/// A Live sandbox's build role is rendered by setup, so the import carries the sandbox outside
/// the Frozen filter. Setup must leave it for the runtime controller rather than refuse the
/// whole handoff.
#[cfg(feature = "aws")]
#[tokio::test]
async fn imported_continuation_leaves_a_registered_live_sandbox_to_runtime() -> Result<()> {
let sandbox = live_sandbox_entry();
let imported = import_aws_sandbox(&sandbox, None);
let (stack, state) = imported_beside_storage(("agents", sandbox), imported).await?;

let continued = new_imported_setup_executor(&stack)?
.continue_imported(state)
.await?
.next_state;

assert_eq!(
continued.resources["assets"].status,
ResourceStatus::Running
);
assert_eq!(
continued.resources["agents"].status,
ResourceStatus::Provisioning,
"initial setup must neither refuse nor advance the Live sandbox"
);
Ok(())
}

/// A Live sandbox registered with its image already built claims an image the setup stack owns;
/// its runtime controller would then update or delete it.
#[cfg(feature = "aws")]
#[tokio::test]
async fn imported_continuation_refuses_a_live_sandbox_registered_as_built() -> Result<()> {
let sandbox = live_sandbox_entry();
let imported = import_aws_sandbox(
&sandbox,
Some((
"agents",
"arn:aws:lambda:us-east-2:123456789012:microvm-image:agents",
"1",
)),
);
let (stack, state) = imported_beside_storage(("agents", sandbox), imported).await?;

let error = new_imported_setup_executor(&stack)?
.continue_imported(state)
.await
.expect_err("a Live sandbox setup claims to have built must fail the handoff");

assert!(error.message.contains("unexpected resource 'agents'"));
assert_eq!(error.code, "IMPORTED_SETUP_STATE_INVALID");
Ok(())
}

/// Setup never renders a Worker, so a registered one is not something setup did, even in the
/// shape a registered Live sandbox has.
#[tokio::test]
async fn imported_continuation_refuses_a_live_resource_setup_does_not_render() -> Result<()> {
let worker = Stack::new("worker".to_owned())
.add(test_function("func"), ResourceLifecycle::Live)
.build();
let synced = run_to_synced(&new_executor(&worker)?, new_test_state()).await?;
let mut imported = synced.resources["func"].clone();
imported.status = ResourceStatus::Provisioning;
let (stack, state) = imported_beside_storage(
(
"func",
entry(
Resource::new(test_function("func")),
ResourceLifecycle::Live,
),
),
imported,
)
.await?;

let error = new_imported_setup_executor(&stack)?
.continue_imported(state)
.await
.expect_err("a registered Worker must fail the handoff");

assert!(error.message.contains("unexpected resource 'func'"));
assert_eq!(error.code, "IMPORTED_SETUP_STATE_INVALID");
Ok(())
}

#[tokio::test]
async fn imported_continuation_refuses_a_resource_outside_the_stack() -> Result<()> {
let stack = Stack::new("imported-stray".to_owned())
.add(test_storage("assets"), ResourceLifecycle::Frozen)
.build();
let mut state = run_to_synced(&new_executor(&stack)?, new_test_state()).await?;
state.resources.insert(
"stray".to_string(),
create_running_function_state("stray", "image"),
);

let error = new_imported_setup_executor(&stack)?
.continue_imported(state)
.await
.expect_err("a resource the stack does not declare must fail the handoff");

assert!(error.message.contains("unexpected resource 'stray'"));
assert_eq!(error.code, "IMPORTED_SETUP_STATE_INVALID");
Ok(())
}

/// Tests that config changes while a resource is still provisioning do not
/// interrupt the in-flight create. The update should happen after create reaches
/// a stable state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::error::Result;
use crate::{CheckResult, CompileTimeCheck};
use alien_core::{
ownership_policy_for_resource_type, Container, Daemon, Platform, ResourceLifecycle, Sandbox,
SandboxCode, Stack, Storage,
SandboxCode, Stack, Storage, Worker,
};

/// Ensures each resource uses a lifecycle allowed by the ownership policy.
Expand Down Expand Up @@ -105,7 +105,7 @@ impl CompileTimeCheck for FrozenResourceLifecycleCheck {

// Setup-rendered consumers need the complete binding while the package is applied.
// A Live sandbox has no image ARN/version until its runtime controller finishes.
// Containers and Daemons are also runtime-provisioned: their controllers wait for
// Containers, Daemons and Workers are runtime-provisioned: their controllers wait for
// dependencies and resolve the completed sandbox binding from controller state.
for link in alien_core::links_of(&resource_entry.config) {
let Some(target) = stack.resources.get(link.id()) else {
Expand All @@ -115,6 +115,7 @@ impl CompileTimeCheck for FrozenResourceLifecycleCheck {
&& target.lifecycle == ResourceLifecycle::Live
&& resource_entry.config.downcast_ref::<Container>().is_none()
&& resource_entry.config.downcast_ref::<Daemon>().is_none()
&& resource_entry.config.downcast_ref::<Worker>().is_none()
{
errors.push(format!(
"Resource '{}' links sandbox '{}', which uses the Live lifecycle; its \
Expand Down Expand Up @@ -570,17 +571,14 @@ mod tests {
assert!(live.success, "{:?}", live.errors);
}

/// A Worker's binding to a sandbox carries `imageArn` and `imageVersion`, both required fields
/// of `AwsSandboxBinding`. A Live sandbox has neither at setup time, so the binding would fail
/// to deserialize at Worker startup instead of at plan time — the wrong end to discover it.
/// A Build is rendered by setup, and a binding to a sandbox carries `imageArn` and
/// `imageVersion`, both required fields of `AwsSandboxBinding`. A Live sandbox has neither
/// while setup applies, so the link is refused at plan time.
#[tokio::test]
async fn a_setup_rendered_consumer_cannot_link_a_live_sandbox() {
let mut stack = sandbox_stack(ResourceLifecycle::Live);
let worker = alien_core::Worker::new("api".to_string())
let build = Build::new("image-build".to_string())
.permissions("execution".to_string())
.code(alien_core::WorkerCode::Image {
image: "example.com/api:latest".to_string(),
})
.link(
&alien_core::Sandbox::new("agents".to_string())
.code(alien_core::SandboxCode::Image {
Expand All @@ -595,10 +593,10 @@ mod tests {
)
.build();
stack.resources.insert(
"api".to_string(),
"image-build".to_string(),
ResourceEntry {
config: alien_core::Resource::new(worker),
lifecycle: ResourceLifecycle::Live,
config: alien_core::Resource::new(build),
lifecycle: ResourceLifecycle::Frozen,
dependencies: Vec::new(),
remote_access: false,
enabled_when: None,
Expand Down Expand Up @@ -661,9 +659,18 @@ mod tests {
.link(&linked_sandbox())
.build();

let worker = alien_core::Worker::new("handler".to_string())
.code(alien_core::WorkerCode::Image {
image: "example.com/handler:latest".to_string(),
})
.permissions("execution".to_string())
.link(&linked_sandbox())
.build();

for (id, resource) in [
("api", alien_core::Resource::new(container)),
("scheduler", alien_core::Resource::new(daemon)),
("handler", alien_core::Resource::new(worker)),
] {
let mut stack = sandbox_stack(ResourceLifecycle::Live);
stack.resources.insert(
Expand Down
Loading