diff --git a/crates/alien-infra/src/core/executor.rs b/crates/alien-infra/src/core/executor.rs index a24e6783b..1daa25458 100644 --- a/crates/alien-infra/src/core/executor.rs +++ b/crates/alien-infra/src/core/executor.rs @@ -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 { + // 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!( diff --git a/crates/alien-infra/src/core/executor_tests/update_tests.rs b/crates/alien-infra/src/core/executor_tests/update_tests.rs index 838c43046..46852f2f3 100644 --- a/crates/alien-infra/src/core/executor_tests/update_tests.rs +++ b/crates/alien-infra/src/core/executor_tests/update_tests.rs @@ -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::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. diff --git a/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs b/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs index dc06c6550..097c2a91a 100644 --- a/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs +++ b/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs @@ -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. @@ -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 { @@ -115,6 +115,7 @@ impl CompileTimeCheck for FrozenResourceLifecycleCheck { && target.lifecycle == ResourceLifecycle::Live && resource_entry.config.downcast_ref::().is_none() && resource_entry.config.downcast_ref::().is_none() + && resource_entry.config.downcast_ref::().is_none() { errors.push(format!( "Resource '{}' links sandbox '{}', which uses the Live lifecycle; its \ @@ -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 { @@ -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, @@ -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(