From 712181ebbaf4b9b0db0cf761a70cbca64bc1f390 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:20:41 +0300 Subject: [PATCH 1/7] fix(release): cache and rebase a sandbox image like every other compute type A sandbox whose image is a local artifact directory is pushed, but never recorded in the push cache, never matched against it, and never rebased under `--prebuilt`. So an unchanged sandbox is re-pushed on every release, and a prebuilt release from another checkout carries a path that no longer exists. The three sites that make a push cheap enumerated the worker, the container and the daemon. The sandbox now joins them, reading the same local-directory test the push path already applies. A bundle URI and a catalog name reach these sites too and name no artifact, so they pass through untouched. The note that tells the next person where to add a compute type pointed at two places and left these three out, which is how the sandbox came to be handled on one side of the release and not the other. --- crates/alien-build/src/lib.rs | 10 +- crates/alien-cli/src/commands/release.rs | 120 ++++++++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/alien-build/src/lib.rs b/crates/alien-build/src/lib.rs index d1d2a791e5..00212ab030 100644 --- a/crates/alien-build/src/lib.rs +++ b/crates/alien-build/src/lib.rs @@ -1167,8 +1167,10 @@ fn add_push_target_resource( /// Returns an error if any compute resource still has unbuilt source code — that means /// `alien build` was not run first. /// -/// To add support for a new compute resource type, add an `else if` branch here and in -/// [`apply_pushed_images`]. +/// To add support for a new compute resource type, add an `else if` branch here, in +/// [`apply_pushed_images`], and in the three push-cache sites in the CLI's `release`: the prebuilt +/// rebase, the cache apply, and the cache collect. A type missing from those three still pushes, +/// but re-pushes on every release. fn collect_push_targets(stack: &Stack) -> Result> { let mut targets = Vec::new(); @@ -1300,8 +1302,8 @@ fn collect_push_targets(stack: &Stack) -> Result> { /// Applies pushed registry URIs back to their respective resources in the stack. /// -/// To add support for a new compute resource type, add an `else if` branch here and in -/// [`collect_push_targets`]. +/// To add support for a new compute resource type, add an `else if` branch here, in +/// [`collect_push_targets`], and in the three push-cache sites in the CLI's `release`. fn apply_pushed_images(stack: &mut Stack, updates: Vec<(String, String)>) { for (resource_id, image_uri) in updates { if let Some(resource_entry) = stack.resources_mut().find(|(id, _)| *id == &resource_id) { diff --git a/crates/alien-cli/src/commands/release.rs b/crates/alien-cli/src/commands/release.rs index b6dfb100a1..77a2def02f 100644 --- a/crates/alien-cli/src/commands/release.rs +++ b/crates/alien-cli/src/commands/release.rs @@ -6,8 +6,9 @@ use crate::ui::{command, contextual_heading, dim_label, success_line}; use crate::{ErrorData, Result}; use alien_build::settings::PushSettings; use alien_core::{ - alien_event, AlienEvent, Container, ContainerCode, Daemon, DaemonCode, Platform, Stack, - StackInputDefinition, StackInputKind, StackInputProvider, Worker, WorkerCode, + alien_event, AlienEvent, Container, ContainerCode, Daemon, DaemonCode, Platform, Sandbox, + SandboxCode, Stack, StackInputDefinition, StackInputKind, StackInputProvider, Worker, + WorkerCode, }; use alien_error::{AlienError, Context, IntoAlienError}; use alien_manager_api::types::{ @@ -1582,6 +1583,21 @@ fn rebase_prebuilt_stack_image_paths(stack: &mut Stack, output_dir: &Path) -> Re return Err(prebuilt_source_error("Daemon", &daemon.id)); } } + } else if let Some(sandbox) = resource_entry.config.downcast_ref::() { + match &sandbox.code { + SandboxCode::Image { image } => { + if let Some(rebased) = + rebase_prebuilt_image_path("sandbox", &sandbox.id, image, output_dir)? + { + let mut updated = sandbox.clone(); + updated.code = SandboxCode::Image { image: rebased }; + resource_entry.config = alien_core::Resource::new(updated); + } + } + SandboxCode::Source { .. } => { + return Err(prebuilt_source_error("Sandbox", &sandbox.id)); + } + } } } Ok(()) @@ -1793,6 +1809,24 @@ fn apply_push_cache(stack: &mut Stack, cache: &HashMap, reposito } } } + } else if let Some(sandbox) = resource_entry.config.downcast_mut::() { + if let SandboxCode::Image { ref image } = sandbox.code { + if let Some(key) = cache_key_from_path(image) { + if let Some(cached_uri) = cache + .get(&key) + .filter(|uri| image_uri_belongs_to_repository(uri, repository)) + { + info!( + "Push cache hit for sandbox '{}': {} → {}", + sandbox.id, key, cached_uri + ); + sandbox.code = SandboxCode::Image { + image: cached_uri.clone(), + }; + hits += 1; + } + } + } } } @@ -1831,6 +1865,11 @@ fn collect_push_cache_entries( return Some((id.clone(), image.clone())); } } + if let Some(sandbox) = entry.config.downcast_ref::() { + if let SandboxCode::Image { ref image } = sandbox.code { + return Some((id.clone(), image.clone())); + } + } None }) .collect(); @@ -1854,6 +1893,12 @@ fn collect_push_cache_entries( } else { None } + } else if let Some(sandbox) = resource_entry.config.downcast_ref::() { + if let SandboxCode::Image { ref image } = sandbox.code { + Some(image.clone()) + } else { + None + } } else { None }; @@ -1901,6 +1946,77 @@ mod tests { .build() } + fn sandbox_with_image(image: &str) -> Sandbox { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: image.to_string(), + }) + .egress(alien_core::SandboxEgress::Allow) + .lifecycle(alien_core::SandboxLifecyclePolicy { + max_lifetime_seconds: None, + idle_pause_seconds: None, + }) + .build() + } + + /// A sandbox earns the same cache the other compute types do, so an unchanged one is not + /// re-pushed on every release. A bundle URI is left alone: it names no local artifact. + #[test] + fn push_cache_applies_and_collects_for_sandboxes() { + let local_dir = tempfile::tempdir().unwrap(); + let artifact_dir = local_dir.path().join("sbx-9f8e7d6c"); + std::fs::create_dir_all(&artifact_dir).unwrap(); + let local_path = artifact_dir.to_string_lossy().into_owned(); + + let mut stack = Stack::new("cache-test".to_string()) + .add(sandbox_with_image(&local_path), ResourceLifecycle::Live) + .build(); + let cache = HashMap::from([( + "sbx-9f8e7d6c".to_string(), + "registry.example.com/base:tag".to_string(), + )]); + let hits = apply_push_cache(&mut stack, &cache, "registry.example.com/base"); + assert_eq!(hits, 1, "sandbox local path should hit the cache"); + let sandbox = stack + .resources() + .find_map(|(_, e)| e.config.downcast_ref::().cloned()) + .expect("sandbox should exist"); + assert_eq!( + sandbox.code, + SandboxCode::Image { + image: "registry.example.com/base:tag".to_string() + } + ); + + let pre_push = Stack::new("cache-test".to_string()) + .add(sandbox_with_image(&local_path), ResourceLifecycle::Live) + .build(); + let pushed = Stack::new("cache-test".to_string()) + .add( + sandbox_with_image("registry.example.com/base:pushed"), + ResourceLifecycle::Live, + ) + .build(); + let mut collected = HashMap::new(); + collect_push_cache_entries(&pushed, &pre_push, &mut collected); + assert_eq!( + collected.get("sbx-9f8e7d6c").map(String::as_str), + Some("registry.example.com/base:pushed") + ); + + let mut bundle_stack = Stack::new("cache-test".to_string()) + .add( + sandbox_with_image("s3://bundles-us-east-1/sandbox-bundle/abc/bundle.zip"), + ResourceLifecycle::Frozen, + ) + .build(); + assert_eq!( + apply_push_cache(&mut bundle_stack, &cache, "registry.example.com/base"), + 0, + "a bundle URI names no artifact the cache could have pushed" + ); + } + #[test] fn push_cache_applies_and_collects_for_daemons() { let local_dir = tempfile::tempdir().unwrap(); From 91c66749a85d27fb3fd3e05327a653185e0f75bd Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:35:09 +0300 Subject: [PATCH 2/7] feat(build): build a sandbox base image from source A sandbox was the only compute resource `alien build` ignored. Declaring one from source meant a refusal at plan time, so the base image had to be built and pushed by hand while every other resource was built by the CLI. `alien build` now collects a sandbox declared from source and produces an OCI artifact directory for it, and the release pushes that directory the way it already pushed a hand-built one. The bundle still layers the agent on top, and AWS still builds the MicroVM from it inside the customer's account. Source is refused only where nothing can build it. On AWS it is a declaration; elsewhere the image is pulled as written, so an unbuilt source would schedule a workload with no image and the refusal names the platform. The base image is a root filesystem rather than a binary on a base Alien picks, so it is built from a Dockerfile and the other toolchains are refused by name. --- crates/alien-build/src/lib.rs | 291 +++++++++++++++++++++ crates/alien-build/src/toolchain/mod.rs | 4 + crates/alien-core/src/resources/sandbox.rs | 34 ++- 3 files changed, 316 insertions(+), 13 deletions(-) diff --git a/crates/alien-build/src/lib.rs b/crates/alien-build/src/lib.rs index 00212ab030..6fbf613214 100644 --- a/crates/alien-build/src/lib.rs +++ b/crates/alien-build/src/lib.rs @@ -213,6 +213,7 @@ pub async fn build_stack(mut stack: Stack, settings: &BuildSettings) -> Result = Vec::new(); + let mut sandboxes_to_build: Vec<(String, Sandbox, String, ToolchainConfig)> = Vec::new(); for (id, resource_entry) in stack.resources() { if let Some(func) = resource_entry.config.downcast_ref::() { @@ -253,6 +254,40 @@ pub async fn build_stack(mut stack: Stack, settings: &BuildSettings) -> Result() { + info!("Processing sandbox: {}", sandbox.id); + match &sandbox.code { + SandboxCode::Source { src, toolchain } => { + // A sandbox base image is a root filesystem, not a compiled binary laid on one, + // so the other toolchains have no meaning here rather than a missing + // implementation. + if !matches!(toolchain, ToolchainConfig::Docker { .. }) { + return Err(AlienError::new(ErrorData::BuildConfigInvalid { + message: format!( + "Sandbox '{}' is built from source with a {} toolchain. A sandbox \ + base image is a root filesystem, so it is built from a \ + Dockerfile; give it a docker toolchain, or name a prebuilt image \ + in code.image.", + sandbox.id, + toolchain_name(toolchain) + ), + })); + } + info!( + "Sandbox '{}' has source code. Queued for parallel build.", + sandbox.id + ); + sandboxes_to_build.push(( + id.clone(), + sandbox.clone(), + src.clone(), + toolchain.clone(), + )); + } + SandboxCode::Image { .. } => { + info!("Sandbox '{}' already has an image. Skipping.", sandbox.id); + } + } } } @@ -625,6 +660,157 @@ pub async fn build_stack(mut stack: Stack, settings: &BuildSettings) -> Result = sandboxes_to_build + .into_iter() + .map(|(resource_id, sandbox, src, toolchain)| { + let sandbox_id = sandbox.id.clone(); + let stack_id = stack_id.clone(); + let settings = settings.clone(); + let output_dir = output_dir.clone(); + let bus = current_bus.clone(); + let cancel_token = cancel_token.clone(); + + tokio::spawn(async move { + let sandbox_id_for_warning = sandbox_id.clone(); + + let build_work = async move { + info!("Starting parallel build for resource: {}", sandbox_id); + + if cancel_token.is_cancelled() { + return ( + resource_id.clone(), + sandbox, + Err(AlienError::new(ErrorData::BuildCanceled { + resource_name: sandbox_id.clone(), + })), + ); + } + + let result = tokio::select! { + result = build_resource( + &src, + &toolchain, + &sandbox_id, + &stack_id, + &settings, + &output_dir, + toolchain::WorkloadKind::SandboxBase, + &[], + ) => result, + _ = cancel_token.cancelled() => { + info!("Build for sandbox '{}' was cancelled", sandbox_id); + Err(AlienError::new(ErrorData::BuildCanceled { + resource_name: sandbox_id.clone() + })) + } + }; + + match &result { + Ok(image_uri) => { + info!( + "Successfully built OCI image for resource '{}' to: {}", + sandbox_id, image_uri + ); + } + Err(e) => { + info!("Failed to build sandbox '{}': {}", sandbox_id, e); + } + } + + (resource_id, sandbox, result) + }; + + match bus { + Some(bus) => bus.run(|| build_work).await, + None => { + tracing::debug!( + "No event bus context available for parallel build of sandbox '{}'", + sandbox_id_for_warning + ); + build_work.await + } + } + }) + }) + .collect(); + + let mut build_results: Vec<(String, Sandbox)> = Vec::new(); + let mut completed_tasks = 0; + let mut remaining_tasks = build_tasks; + let mut first_error: Option> = None; + + while !remaining_tasks.is_empty() { + let (result, _index, rest) = futures::future::select_all(remaining_tasks).await; + remaining_tasks = rest; + + match result { + Ok((resource_id, sandbox, build_result)) => match build_result { + Ok(image_uri) => { + let mut updated_sandbox = sandbox; + updated_sandbox.code = SandboxCode::Image { image: image_uri }; + build_results.push((resource_id, updated_sandbox)); + completed_tasks += 1; + } + Err(e) => { + if first_error.is_none() { + first_error = Some(e); + cancel_token.cancel(); + for task in remaining_tasks { + task.abort(); + } + break; + } + } + }, + Err(join_error) => { + if join_error.is_cancelled() { + info!("Build task was cancelled"); + } else { + tracing::warn!("Build task failed: {}", join_error); + if first_error.is_none() { + first_error = Some(AlienError::new(ErrorData::BuildConfigInvalid { + message: format!("Build task failed: {}", join_error), + })); + cancel_token.cancel(); + } + } + } + } + } + + if let Some(error) = first_error { + return Err(error); + } + + for (resource_id, updated_sandbox) in build_results { + if let Some(resource_entry) = stack.resources_mut().find(|(id, _)| *id == &resource_id) + { + resource_entry.1.config = alien_core::Resource::new(updated_sandbox); + } + } + + info!( + "Completed parallel building of {} sandbox base images", + completed_tasks + ); + } + // Build all containers in parallel with fail-fast behavior // OPTIMIZATION: Deduplicate containers that use the same binary (same src + toolchain + binary_name) if !containers_to_build.is_empty() { @@ -3024,6 +3210,9 @@ fn effective_source_base_images( .map(|image| (*image).to_string()) .collect() } + // A sandbox base image is only built from a Dockerfile, which returned above, and its + // `FROM` is the developer's to choose. Alien picking one would decide what a session runs. + (toolchain::WorkloadKind::SandboxBase, _) => Vec::new(), }; base_images_for_workload( @@ -3038,6 +3227,15 @@ fn effective_source_base_images( /// Workers. TypeScript has its own language-base override, resolved by /// `typescript_worker_base_images`, so a mixed-language stack never places /// Rust applications on the TypeScript image or vice versa. +fn toolchain_name(toolchain: &ToolchainConfig) -> &'static str { + match toolchain { + ToolchainConfig::Rust { .. } => "rust", + ToolchainConfig::TypeScript { .. } => "typescript", + ToolchainConfig::Docker { .. } => "docker", + ToolchainConfig::Python { .. } => "python", + } +} + fn base_images_for_workload( base_images: &[String], override_base_image: Option<&str>, @@ -5401,6 +5599,99 @@ mod tests { } } +#[cfg(test)] +mod sandbox_build_tests { + use super::*; + use alien_core::{ResourceLifecycle, SandboxEgress, SandboxLifecyclePolicy}; + + fn sandbox_from_source(toolchain: ToolchainConfig) -> Sandbox { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Source { + src: "./sandbox".to_string(), + toolchain, + }) + .egress(SandboxEgress::Allow) + .lifecycle(SandboxLifecyclePolicy { + max_lifetime_seconds: None, + idle_pause_seconds: None, + }) + .build() + } + + fn settings() -> BuildSettings { + BuildSettings { + output_directory: std::env::temp_dir().to_string_lossy().into_owned(), + platform: PlatformBuildSettings::Aws { + managing_account_id: None, + }, + targets: None, + cache_url: None, + override_base_image: None, + debug_mode: false, + } + } + + /// A root filesystem is built from a Dockerfile. The other toolchains lay a compiled binary on + /// a base image Alien chooses, which is a different artifact, so they are refused by name + /// rather than half-built. + #[tokio::test] + async fn a_sandbox_built_from_source_requires_a_docker_toolchain() { + for toolchain in [ + ToolchainConfig::Rust { + binary_name: "sbx".to_string(), + }, + ToolchainConfig::TypeScript { + binary_name: Some("sbx".to_string()), + }, + ] { + let stack = Stack::new("sandbox-build".to_string()) + .add(sandbox_from_source(toolchain), ResourceLifecycle::Live) + .build(); + + let error = build_stack(stack, &settings()) + .await + .expect_err("a non-docker sandbox toolchain must be refused"); + let message = error.to_string(); + assert!( + message.contains("root filesystem") && message.contains("docker"), + "the refusal must name the reason and the fix: {message}" + ); + } + } + + /// A sandbox that already names an image is left alone, the way a worker's is. + #[tokio::test] + async fn a_sandbox_with_an_image_is_not_rebuilt() { + let sandbox = Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: "public.ecr.aws/acme/base:v1".to_string(), + }) + .egress(SandboxEgress::Allow) + .lifecycle(SandboxLifecyclePolicy { + max_lifetime_seconds: None, + idle_pause_seconds: None, + }) + .build(); + let stack = Stack::new("sandbox-build".to_string()) + .add(sandbox, ResourceLifecycle::Live) + .build(); + + let built = build_stack(stack, &settings()) + .await + .expect("an already-imaged sandbox needs no build"); + let code = built + .resources() + .find_map(|(_, e)| e.config.downcast_ref::().map(|s| s.code.clone())) + .expect("sandbox should survive the build"); + assert_eq!( + code, + SandboxCode::Image { + image: "public.ecr.aws/acme/base:v1".to_string() + } + ); + } +} + #[cfg(test)] mod sandbox_push_tests { use super::*; diff --git a/crates/alien-build/src/toolchain/mod.rs b/crates/alien-build/src/toolchain/mod.rs index 2e7a772b2e..cc96c9c4de 100644 --- a/crates/alien-build/src/toolchain/mod.rs +++ b/crates/alien-build/src/toolchain/mod.rs @@ -28,6 +28,9 @@ pub enum WorkloadKind { /// Long-lived native process (DaemonSet on Kubernetes, host process on /// Local); its binary is the image entrypoint. Daemon, + /// Root filesystem a sandbox session runs in. It sets no entrypoint: the bundle layers the + /// sandbox agent on afterwards, and the agent is what runs. + SandboxBase, } impl WorkloadKind { @@ -37,6 +40,7 @@ impl WorkloadKind { Self::Worker => "worker", Self::Container => "container", Self::Daemon => "daemon", + Self::SandboxBase => "sandbox", } } } diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 6ab60ae437..6540203a53 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -567,17 +567,18 @@ impl Sandbox { pub fn validate_for_platform(&self, platform: Platform) -> Result<()> { let capabilities = SandboxCapabilities::for_platform(platform)?; - // No backend builds a sandbox image from source: an empty image string schedules a pod - // that can never run, the silent no-op the capability contract forbids — the failure - // has to land here instead. - if let SandboxCode::Source { .. } = &self.code { + // `alien build` builds an AWS sandbox's base image, so source is a declaration there and + // the emitters refuse it only if it reaches them unbuilt. Everywhere else the image is + // pulled as declared, and an empty image string would schedule a pod that can never run. + if matches!(&self.code, SandboxCode::Source { .. }) && platform != Platform::Aws { return Err(AlienError::new(ErrorData::SandboxLimitInvalid { resource_id: self.id.clone(), field: "code".to_string(), value: "source".to_string(), - reason: "no sandbox backend builds an image from source yet; give code.image a \ - prebuilt reference" - .to_string(), + reason: format!( + "no sandbox backend builds an image from source on {platform}; give \ + code.image a prebuilt reference" + ), })); } @@ -1904,11 +1905,11 @@ mod tests { ); } - /// `Source` is a public part of the type that no backend builds: an empty image string - /// schedules a pod that can never run, so the refusal has to happen at plan time and on - /// every platform, not in one emitter. + /// `alien build` builds an AWS sandbox's base image, so source is a declaration there. On + /// every other platform the image is pulled as declared, and an unbuilt source would schedule + /// a pod that can never run, so the refusal still has to happen at plan time. #[test] - fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() { + fn source_code_is_refused_off_aws_rather_than_producing_a_broken_manifest() { let sandbox = Sandbox::new("agent".to_string()) .code(SandboxCode::Source { src: "./sandbox".to_string(), @@ -1925,8 +1926,11 @@ mod tests { }) .build(); + sandbox + .validate_for_platform(Platform::Aws) + .expect("an AWS sandbox base image is built by `alien build`"); + for platform in [ - Platform::Aws, Platform::Azure, Platform::Gcp, Platform::Kubernetes, @@ -1934,12 +1938,16 @@ mod tests { ] { let error = sandbox .validate_for_platform(platform) - .expect_err("no backend builds a sandbox image from source"); + .expect_err("no backend builds a sandbox image from source here"); assert_eq!(error.code, "SANDBOX_LIMIT_INVALID"); assert!( error.to_string().contains("code.image"), "the refusal must say what to write instead: {error}" ); + assert!( + error.to_string().contains(&platform.to_string()), + "the refusal must name the platform that cannot build it: {error}" + ); } } From 7bd9ca55c6f9c52edd3379b1ea831b25d40a0e36 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:15:01 +0300 Subject: [PATCH 3/7] feat(build): carry a sandbox through the CI merge, and say what source means A partitioned CI build writes one artifact directory per runner group and merges them afterwards. The merge read and wrote the image of a worker, a container and a daemon, so a sandbox built on one runner was dropped on the way out. The planner needs no arm. It asks which resources produce a host binary, and a sandbox base image is built from a Dockerfile, so it never does. That is now written where someone would otherwise go looking for the missing branch. The declaration's own documentation said source was built at deploy time, which was never true and is now wrong in a second way. --- crates/alien-build/src/merge.rs | 58 ++++++++++++++++++- crates/alien-build/src/plan.rs | 2 + crates/alien-core/src/resources/sandbox.rs | 5 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/alien-build/src/merge.rs b/crates/alien-build/src/merge.rs index f94c069c60..c027c7b3c1 100644 --- a/crates/alien-build/src/merge.rs +++ b/crates/alien-build/src/merge.rs @@ -8,7 +8,9 @@ //! into one dir per resource and rewrites the stack to point at it. use crate::error::{ErrorData, Result}; -use alien_core::{Container, ContainerCode, Daemon, DaemonCode, Stack, Worker, WorkerCode}; +use alien_core::{ + Container, ContainerCode, Daemon, DaemonCode, Sandbox, SandboxCode, Stack, Worker, WorkerCode, +}; use alien_error::{AlienError, Context, IntoAlienError}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -280,7 +282,7 @@ fn blank_images(stack: &Stack) -> Stack { blanked } -/// Read a compute resource's `code.image` (Worker, Container, or Daemon), if it is one. +/// Read a compute resource's `code.image` (Worker, Container, Daemon, or Sandbox), if it is one. fn compute_image(stack: &Stack, resource_id: &str) -> Option { let entry = stack.resources().find(|(id, _)| *id == resource_id)?.1; if let Some(worker) = entry.config.downcast_ref::() { @@ -295,11 +297,15 @@ fn compute_image(stack: &Stack, resource_id: &str) -> Option { if let DaemonCode::Image { image } = &daemon.code { return Some(image.clone()); } + } else if let Some(sandbox) = entry.config.downcast_ref::() { + if let SandboxCode::Image { image } = &sandbox.code { + return Some(image.clone()); + } } None } -/// Point a compute resource (Worker, Container, or Daemon) at `image`, converting it to +/// Point a compute resource (Worker, Container, Daemon, or Sandbox) at `image`, converting it to /// `Image` code regardless of its current variant. No-op for non-compute resources. /// /// Variant-insensitive on purpose — the write counterpart to the variant-sensitive @@ -318,6 +324,8 @@ fn set_compute_image(stack: &mut Stack, resource_id: &str, image: String) { container.code = ContainerCode::Image { image }; } else if let Some(daemon) = entry.config.downcast_mut::() { daemon.code = DaemonCode::Image { image }; + } else if let Some(sandbox) = entry.config.downcast_mut::() { + sandbox.code = SandboxCode::Image { image }; } } @@ -663,6 +671,50 @@ mod tests { assert!(merged_dir.join("linux-x64.oci.tar").exists()); } + #[test] + fn merges_sandbox_artifact_dirs_across_partials() { + let root = tempdir().unwrap(); + let out = tempdir().unwrap(); + let write_sandbox_partial = |partial: &str, dir_name: &str, tarball: &str| { + let platform_dir = root.path().join(partial).join("build").join("aws"); + let artifact_dir = platform_dir.join(dir_name); + std::fs::create_dir_all(&artifact_dir).unwrap(); + std::fs::write(artifact_dir.join(tarball), tarball.as_bytes()).unwrap(); + let image = artifact_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + let sandbox = Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { image }) + .egress(alien_core::SandboxEgress::Allow) + .lifecycle(alien_core::SandboxLifecyclePolicy { + max_lifetime_seconds: None, + idle_pause_seconds: None, + }) + .build(); + let stack = Stack::new("merge-test".to_string()) + .add(sandbox, ResourceLifecycle::Live) + .build(); + std::fs::write( + platform_dir.join("stack.json"), + serde_json::to_string_pretty(&stack).unwrap(), + ) + .unwrap(); + }; + write_sandbox_partial("arm", "sbx-aaaa1111", "linux-aarch64.oci.tar"); + write_sandbox_partial("x64", "sbx-bbbb2222", "linux-x64.oci.tar"); + + let platforms = merge_build_outputs(root.path(), out.path()).unwrap(); + assert_eq!(platforms, vec!["aws".to_string()]); + + let merged: Stack = read_stack(&out.path().join("build/aws/stack.json")).unwrap(); + let image = compute_image(&merged, "sbx").expect("sandbox should have a merged image dir"); + let merged_dir = PathBuf::from(&image); + assert!(merged_dir.join("linux-aarch64.oci.tar").exists()); + assert!(merged_dir.join("linux-x64.oci.tar").exists()); + } + #[test] fn merges_two_arches_into_one_dir() { let root = tempdir().unwrap(); diff --git a/crates/alien-build/src/plan.rs b/crates/alien-build/src/plan.rs index 03b0f546c1..24e99db837 100644 --- a/crates/alien-build/src/plan.rs +++ b/crates/alien-build/src/plan.rs @@ -77,6 +77,8 @@ pub fn stack_targets_native_host_binaries(stack: &Stack) -> bool { DaemonCode::Image { .. } => None, } } else { + // A sandbox belongs to no runner group: its base image is built from a Dockerfile, + // which produces a linux image and never a host binary. None }; matches!( diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 6540203a53..df28daca8e 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -31,7 +31,10 @@ pub enum SandboxCode { /// bare catalog name such as `ubuntu`. Each refuses the other's shape while planning. image: String, }, - /// Source built into a sandbox image at deploy time. + /// A Dockerfile `alien build` builds into the sandbox's base image. + /// + /// AWS only, and docker only: the base image is a root filesystem, not a binary laid on one. + /// `alien release` pushes it and the bundle layers the sandbox agent on afterwards. #[serde(rename_all = "camelCase")] Source { /// The source directory to build from diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index 1c02c4d48b..ae998b34e5 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created at runtime.","required":["id","code","egress","lifecycle"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"lifecycle":{"description":"Sandbox lifetime ceiling and idle behaviour","type":"object","properties":{"idlePauseSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the sandbox is paused, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single sandbox, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxLifecyclePolicy"},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. An application reaches its sandbox through the\nprovider, so it cannot widen its own ingress at runtime; a holder of a remote binding's\ncredentials is bounded by no port condition, which is why a remote sandbox declares none."},"privateBaseImage":{"type":["string","null"],"description":"Private ECR base image an AWS build pulls; `code.image` names only the S3 bundle, so this is\nwhat the cross-account grant opens. Live only: the grant needs the customer account,\nwhich registration reports. Absent means the base image is pulled anonymously."}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created at runtime.","required":["id","code","egress","lifecycle"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"A Dockerfile `alien build` builds into the sandbox's base image.\n\nAWS only, and docker only: the base image is a root filesystem, not a binary laid on one.\n`alien release` pushes it and the bundle layers the sandbox agent on afterwards.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"lifecycle":{"description":"Sandbox lifetime ceiling and idle behaviour","type":"object","properties":{"idlePauseSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the sandbox is paused, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single sandbox, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxLifecyclePolicy"},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. An application reaches its sandbox through the\nprovider, so it cannot widen its own ingress at runtime; a holder of a remote binding's\ncredentials is bounded by no port condition, which is why a remote sandbox declares none."},"privateBaseImage":{"type":["string","null"],"description":"Private ECR base image an AWS build pulls; `code.image` names only the S3 bundle, so this is\nwhat the cross-account grant opens. Live only: the grant needs the customer account,\nwhich registration reports. Absent means the base image is pulled anonymously."}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index 6c8802b704..fc746d71ef 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"A Dockerfile `alien build` builds into the sandbox's base image.\n\nAWS only, and docker only: the base image is a root filesystem, not a binary laid on one.\n`alien release` pushes it and the bundle layers the sandbox agent on afterwards.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file From b4c4ab7128116b994d173ece621ea71ea6d4fe7c Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:15:01 +0300 Subject: [PATCH 4/7] test(sandbox): cover the remote grant a Live sandbox receives Four tests render a Live sandbox and three render a remote one, and none render both, so the grant a remotely published Live sandbox depends on was asserted nowhere. --- .../tests/generator/aws_sandbox_tests.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs index 7128551ad5..7a1672ea76 100644 --- a/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs +++ b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs @@ -1092,3 +1092,51 @@ fn aws_remote_storage_management_role_keeps_its_heartbeat() { "heartbeat must not reach object contents: {management}" ); } + +/// A Live sandbox is reachable remotely too: setup renders its build role either way +/// (`SetupEmission::Always`), so the grant the preflight publishes has somewhere to attach. +#[test] +fn aws_remote_sandbox_grants_a_live_sandbox_the_same_execute_set() { + let stack = Stack::new("byo-sandbox".to_string()) + .add_with_remote_access( + sandbox_fixture_with(SandboxEgress::Allow, LIVE_BUNDLE), + ResourceLifecycle::Live, + ) + .add( + RemoteBindings::new("access".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + let (template, _yaml) = render_built_ins_template( + &stack, + StackSettings::default(), + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "live remote sandbox", + ); + + let policy = template + .resources + .get("AgentsRemoteExecutePolicy") + .expect("a Live remote sandbox must still receive the remote grant"); + let document = serde_json::to_string( + policy + .properties + .get("PolicyDocument") + .expect("PolicyDocument"), + ) + .expect("serializes"); + for action in [ + "lambda:RunMicrovm", + "lambda:TerminateMicrovm", + "lambda:CreateMicrovmAuthToken", + "lambda:PassNetworkConnector", + ] { + assert!(document.contains(action), "{action} is missing: {document}"); + } + assert!( + document.contains("microvm-image:${AWS::StackName}-agents"), + "the grant must name this sandbox's own image: {document}" + ); +} From eead77231a31a9afe4025c56a7d250b522c22ed9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:45:50 +0300 Subject: [PATCH 5/7] feat(preflight): name the way out when a resource links a Live sandbox The refusal said a Live sandbox's image is built after setup, so setup cannot bind to it. That is the diagnosis and it stopped there, leaving a developer who hit it with nothing to try. It now names the two ways through: add the sandbox with `remoteAccess` and reach it through a remote binding, or give it a base image that needs no build so it can stay Frozen and be linked. A sandbox built from source is always the first case, because its image is private and a private base image is Live only. --- ..._tests__remote_sandbox_grant_live_aws.snap | 252 ++++++++++++++++++ .../compile_time/frozen_resource_lifecycle.rs | 4 +- 2 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_sandbox_tests__remote_sandbox_grant_live_aws.snap diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_sandbox_tests__remote_sandbox_grant_live_aws.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_sandbox_tests__remote_sandbox_grant_live_aws.snap new file mode 100644 index 0000000000..4d1ad4860a --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_sandbox_tests__remote_sandbox_grant_live_aws.snap @@ -0,0 +1,252 @@ +--- +source: crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs +expression: yaml +--- +AWSTemplateFormatVersion: 2010-09-09 +Description: live remote sandbox. The sandbox image is built after the deployment registers, so a completed stack does not mean the sandbox can accept sessions yet. +Transform: +- AWS::LanguageExtensions +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Registration + Parameters: + - Token + - ManagingRoleArn + - ManagingAccountId + - Label: + default: Operations + Parameters: + - UpdatesMode + - TelemetryMode + - HeartbeatsMode + ParameterLabels: + HeartbeatsMode: + default: Heartbeats + ManagingAccountId: + default: Image account ID + ManagingRoleArn: + default: Management role ARN + TelemetryMode: + default: Telemetry + Token: + default: Install token + UpdatesMode: + default: Updates +Parameters: + Token: + Type: String + Description: Install token from the application setup page. + NoEcho: true + ManagingRoleArn: + Type: String + Description: ARN of the management identity allowed to assume setup-created roles. + Default: '' + ManagingAccountId: + Type: String + Description: AWS account ID for the management account that hosts application container images. + Default: '' + NetworkMode: + Type: String + Description: Choose create-new for a managed VPC, use-existing for your VPC, or use-default for the account default VPC. + Default: create-new + AllowedValues: + - create-new + - use-existing + - use-default + UpdatesMode: + Type: String + Description: How updates are applied after setup registration. + Default: auto + AllowedValues: + - auto + - approval-required + TelemetryMode: + Type: String + Description: Telemetry collection behavior. + Default: auto + AllowedValues: + - auto + HeartbeatsMode: + Type: String + Description: Heartbeat health-check behavior. + Default: "on" + AllowedValues: + - "off" + - "on" +Resources: + AgentsBuildRole: + Type: AWS::IAM::Role + Properties: + RoleName: + Fn::Sub: ${AWS::StackName}-agents-build + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Policies: + - PolicyName: sandbox-image-build + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: ReadSandboxBundlePrefix + Effect: Allow + Action: + - s3:GetObject + Resource: + Fn::Sub: arn:${AWS::Partition}:s3:::acme-artifacts/sandbox-bundle/* + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: '*' + - Sid: PullSandboxBaseImage + Effect: Allow + Action: + - ecr:GetAuthorizationToken + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: '*' + - Sid: DenySameAccountImagePull + Effect: Deny + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: arn:${AWS::Partition}:ecr:*:${AWS::AccountId}:repository/* + Tags: + - Key: managed-by + Value: setup + - Key: deployment + Value: + Ref: AWS::StackName + - Key: resource + Value: agents + - Key: resource-type + Value: sandbox + AgentsRemoteExecutePolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: + Fn::Sub: ${AWS::StackName}-agents-sandbox-access + Roles: + - Ref: AccessRole + PolicyDocument: + Statement: + - Action: + - lambda:ResumeMicrovm + - lambda:RunMicrovm + - lambda:SuspendMicrovm + - lambda:TerminateMicrovm + Effect: Allow + Resource: + - Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:microvm-image:${AWS::StackName}-agents + Sid: SandboxLifecycle + - Action: + - lambda:PassNetworkConnector + Effect: Allow + Resource: + - Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:aws:network-connector:aws-network-connector:* + Sid: PassTheManagedConnectorsASandboxStartsOn + - Action: + - lambda:CreateMicrovmAuthToken + - lambda:GetMicrovm + Effect: Allow + Resource: + - Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:microvm-image:${AWS::StackName}-agents + Sid: ReachInsideASandbox + Version: 2012-10-17 + AccessRole: + Type: AWS::IAM::Role + Properties: + RoleName: + Fn::Sub: ${AWS::StackName}-access + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: AllowManagingRole + Effect: Allow + Principal: + AWS: + Ref: ManagingRoleArn + Action: sts:AssumeRole + Condition: + StringEquals: + aws:PrincipalArn: + Ref: ManagingRoleArn + Tags: + - Key: managed-by + Value: setup + - Key: deployment + Value: + Ref: AWS::StackName + - Key: resource + Value: access + - Key: resource-type + Value: resource-access + DeploymentRegistration: + Type: AWS::CloudFormation::CustomResource + Properties: + ServiceToken: arn:aws:lambda:us-east-1:123456789012:function:register + Token: + Ref: Token + DeploymentName: + Ref: AWS::StackName + ResourcePrefix: + Ref: AWS::StackName + SourceKind: cloudformation + Platform: aws + Region: + Ref: AWS::Region + SetupTarget: aws + SetupImportFormatVersion: 1 + SetupFingerprint: test + SetupFingerprintVersion: 1 + ManagementConfig: + platform: aws + managingRoleArn: + Ref: ManagingRoleArn + StackSettings: + deploymentModel: push + updates: + Ref: UpdatesMode + telemetry: + Ref: TelemetryMode + heartbeats: + Ref: HeartbeatsMode + network: + Ref: AWS::NoValue + Resources: + - id: agents + type: sandbox + importData: + previewPorts: [] + egressConnectorArns: [] + allowEgress: true + buildRoleArn: + Fn::GetAtt: + - AgentsBuildRole + - Arn + bundleUri: s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip + - id: access + type: resource-access + importData: + roleName: + Ref: AccessRole + roleArn: + Fn::GetAtt: + - AccessRole + - Arn + DependsOn: + - AgentsBuildRole + - AgentsRemoteExecutePolicy + - AccessRole 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 eb3e47b569..e5bd46c09b 100644 --- a/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs +++ b/crates/alien-preflights/src/compile_time/frozen_resource_lifecycle.rs @@ -107,7 +107,9 @@ impl CompileTimeCheck for FrozenResourceLifecycleCheck { errors.push(format!( "Resource '{}' links sandbox '{}', which uses the Live lifecycle; its \ image is built after setup, but this resource requires its binding \ - during setup", + during setup. Add the sandbox with `remoteAccess` and reach it through a \ + remote binding, or give it a base image that needs no build so it can stay \ + Frozen and be linked", resource_id, link.id() )); From efa521d8f820ad9e77675994cbde9f35e02e8aad Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:46:01 +0300 Subject: [PATCH 6/7] test(sandbox): close the Live grant set, and drive the source gate through the runner The Live grant test asserted with `contains`, which is monotone: it passes just as happily on a widened action list or a wildcarded image ARN, and it checked four of the seven actions. It now carries the snapshot its Frozen sibling already had, asserts the policy lands on the Remote Bindings identity, and names every action. The snapshot's grant is byte-identical to the Frozen one, which is what the test claims and could not previously show. The per-platform source gate was covered by calling `validate_for_platform` directly. That check once sat unregistered and method-level tests missed it, which is why this file exists at all, and the Kubernetes runtime's empty-image fallback rests on the gate being registered rather than on the method refusing. The new case runs the registry for both Kubernetes and AWS. A comment in that fallback justified itself with a guarantee this branch removes, that Source is refused on every platform. The permission set's header made a claim a Live sandbox on AWS breaks, since its image lands after the stack. --- crates/alien-build/src/lib.rs | 15 +++---- crates/alien-build/src/plan.rs | 4 +- crates/alien-cli/src/commands/release.rs | 2 +- .../tests/generator/aws_sandbox_tests.rs | 20 ++++++++- .../src/sandbox/kubernetes_spec.rs | 7 ++- .../sandbox/remote-execute.jsonc | 6 +-- .../tests/sandbox_platform_gate.rs | 45 ++++++++++++++++++- 7 files changed, 79 insertions(+), 20 deletions(-) diff --git a/crates/alien-build/src/lib.rs b/crates/alien-build/src/lib.rs index 6fbf613214..1c6cf1a603 100644 --- a/crates/alien-build/src/lib.rs +++ b/crates/alien-build/src/lib.rs @@ -1353,10 +1353,9 @@ fn add_push_target_resource( /// Returns an error if any compute resource still has unbuilt source code — that means /// `alien build` was not run first. /// -/// To add support for a new compute resource type, add an `else if` branch here, in -/// [`apply_pushed_images`], and in the three push-cache sites in the CLI's `release`: the prebuilt -/// rebase, the cache apply, and the cache collect. A type missing from those three still pushes, -/// but re-pushes on every release. +/// To add a compute resource type, branch here, in [`apply_pushed_images`], and in the CLI +/// `release` push-cache sites: prebuilt rebase, cache apply, cache collect. One missing from +/// those three still pushes, but re-pushes on every release. fn collect_push_targets(stack: &Stack) -> Result> { let mut targets = Vec::new(); @@ -3223,10 +3222,6 @@ fn effective_source_base_images( ) } -/// Apply a feature-versioned generic runtime base only to non-TypeScript -/// Workers. TypeScript has its own language-base override, resolved by -/// `typescript_worker_base_images`, so a mixed-language stack never places -/// Rust applications on the TypeScript image or vice versa. fn toolchain_name(toolchain: &ToolchainConfig) -> &'static str { match toolchain { ToolchainConfig::Rust { .. } => "rust", @@ -3236,6 +3231,10 @@ fn toolchain_name(toolchain: &ToolchainConfig) -> &'static str { } } +/// Apply a feature-versioned generic runtime base only to non-TypeScript +/// Workers. TypeScript has its own language-base override, resolved by +/// `typescript_worker_base_images`, so a mixed-language stack never places +/// Rust applications on the TypeScript image or vice versa. fn base_images_for_workload( base_images: &[String], override_base_image: Option<&str>, diff --git a/crates/alien-build/src/plan.rs b/crates/alien-build/src/plan.rs index 24e99db837..d5e1871848 100644 --- a/crates/alien-build/src/plan.rs +++ b/crates/alien-build/src/plan.rs @@ -77,8 +77,8 @@ pub fn stack_targets_native_host_binaries(stack: &Stack) -> bool { DaemonCode::Image { .. } => None, } } else { - // A sandbox belongs to no runner group: its base image is built from a Dockerfile, - // which produces a linux image and never a host binary. + // No other resource contributes a host binary. A sandbox is the one worth naming: + // its base image is a Dockerfile build, so the missing arm is deliberate. None }; matches!( diff --git a/crates/alien-cli/src/commands/release.rs b/crates/alien-cli/src/commands/release.rs index 77a2def02f..b3460aea64 100644 --- a/crates/alien-cli/src/commands/release.rs +++ b/crates/alien-cli/src/commands/release.rs @@ -2006,7 +2006,7 @@ mod tests { let mut bundle_stack = Stack::new("cache-test".to_string()) .add( - sandbox_with_image("s3://bundles-us-east-1/sandbox-bundle/abc/bundle.zip"), + sandbox_with_image("s3://acme-bundles-us-east-1/sandbox-bundle/abc/bundle.zip"), ResourceLifecycle::Frozen, ) .build(); diff --git a/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs index 7a1672ea76..34ecfb0975 100644 --- a/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs +++ b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs @@ -1107,7 +1107,7 @@ fn aws_remote_sandbox_grants_a_live_sandbox_the_same_execute_set() { ResourceLifecycle::Frozen, ) .build(); - let (template, _yaml) = render_built_ins_template( + let (template, yaml) = render_built_ins_template( &stack, StackSettings::default(), custom_resource_registration(), @@ -1115,11 +1115,26 @@ fn aws_remote_sandbox_grants_a_live_sandbox_the_same_execute_set() { "aws", "live remote sandbox", ); + // The snapshot is what holds the set closed. A `contains` assertion is monotone, so it + // passes just as happily on a widened action list or a wildcarded image ARN. + insta::assert_snapshot!("remote_sandbox_grant_live_aws", yaml); let policy = template .resources .get("AgentsRemoteExecutePolicy") .expect("a Live remote sandbox must still receive the remote grant"); + assert_eq!(policy.resource_type, "AWS::IAM::Policy"); + let roles = + serde_json::to_string(policy.properties.get("Roles").expect("Roles")).expect("serializes"); + assert!( + roles.contains("AccessRole"), + "the grant belongs to the shared Remote Bindings identity: {roles}" + ); + assert!( + template.resources.contains_key("AgentsBuildRole"), + "setup renders the build role for a Live sandbox too, which is what the grant attaches beside" + ); + let document = serde_json::to_string( policy .properties @@ -1130,6 +1145,9 @@ fn aws_remote_sandbox_grants_a_live_sandbox_the_same_execute_set() { for action in [ "lambda:RunMicrovm", "lambda:TerminateMicrovm", + "lambda:SuspendMicrovm", + "lambda:ResumeMicrovm", + "lambda:GetMicrovm", "lambda:CreateMicrovmAuthToken", "lambda:PassNetworkConnector", ] { diff --git a/crates/alien-infra/src/sandbox/kubernetes_spec.rs b/crates/alien-infra/src/sandbox/kubernetes_spec.rs index a9788c8c6a..314907a5a0 100644 --- a/crates/alien-infra/src/sandbox/kubernetes_spec.rs +++ b/crates/alien-infra/src/sandbox/kubernetes_spec.rs @@ -64,10 +64,9 @@ pub(crate) fn sandbox_pod_with_labels( ) -> Pod { let image = match &sandbox.code { SandboxCode::Image { image } => image.clone(), - // Unreachable through any supported path: `Sandbox::validate_for_platform` refuses - // Source on every platform, because no backend builds a sandbox image. Left as an empty - // string rather than a panic — the API server rejects a pod with no image at create, - // which fails an operator loudly without taking it down. + // `validate_for_platform` refuses Source off AWS and Kubernetes preflights against this + // platform, so this is unreachable. Empty rather than a panic: the API server rejects a + // pod with no image at create, failing an operator loudly without taking it down. SandboxCode::Source { .. } => String::new(), }; diff --git a/crates/alien-permissions/permission-sets/sandbox/remote-execute.jsonc b/crates/alien-permissions/permission-sets/sandbox/remote-execute.jsonc index 3c78cbe083..e3a4af0979 100644 --- a/crates/alien-permissions/permission-sets/sandbox/remote-execute.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/remote-execute.jsonc @@ -2,9 +2,9 @@ "id": "sandbox/remote-execute", "description": "Allows a remote caller to create sandboxes and run arbitrary code inside them", "platforms": { - // Every cloud here creates the sandbox's parent in setup, and can therefore scope a grant to - // it. All three refuse an assignment naming a resource that doesn't exist yet, so parent and - // grant must come from the same template. + // Every cloud here creates the sandbox's parent in setup and scopes the grant to it, and an + // assignment naming a resource that does not exist yet is refused, so the two come from one + // template. AWS Live is the exception: an inline ARN policy needs no target to exist. "aws": [ { "label": "sandbox-lifecycle", diff --git a/crates/alien-preflights/tests/sandbox_platform_gate.rs b/crates/alien-preflights/tests/sandbox_platform_gate.rs index 2db68c7cc5..fc0a133209 100644 --- a/crates/alien-preflights/tests/sandbox_platform_gate.rs +++ b/crates/alien-preflights/tests/sandbox_platform_gate.rs @@ -7,7 +7,7 @@ use alien_core::{ PermissionProfile, PermissionsConfig, Platform, Sandbox, SandboxCode, SandboxEgress, - SandboxLifecyclePolicy, SandboxLimits, Stack, Worker, WorkerCode, + SandboxLifecyclePolicy, SandboxLimits, Stack, ToolchainConfig, Worker, WorkerCode, }; use alien_preflights::runner::PreflightRunner; @@ -90,3 +90,46 @@ async fn the_same_stack_without_ceilings_passes_preflight() { "no declaration, nothing to refuse: {summary:?}" ); } + +fn source_sandbox() -> Sandbox { + Sandbox::new("agent".to_string()) + .code(SandboxCode::Source { + src: "./sandbox".to_string(), + toolchain: ToolchainConfig::Docker { + dockerfile: None, + target: None, + build_args: None, + }, + }) + .egress(SandboxEgress::Deny) + .lifecycle(SandboxLifecyclePolicy { + max_lifetime_seconds: None, + idle_pause_seconds: None, + }) + .build() +} + +/// `alien build` turns a sandbox's source into an image on AWS, and nowhere else. Driven through +/// the runner rather than the method, because the runtime's empty-image fallback on Kubernetes +/// rests on this gate being registered, not merely on the method refusing when called. +#[tokio::test] +async fn source_reaches_no_platform_but_aws_through_the_runner() { + let summary = PreflightRunner::new() + .run_compile_time_checks(&stack_with(source_sandbox()), Platform::Kubernetes) + .await + .expect("compile-time checks run"); + + assert!(!summary.success, "source has no builder off AWS: {summary:?}"); + let rendered = format!("{summary:?}"); + assert!( + rendered.contains("agent"), + "the failure must name the sandbox: {rendered}" + ); + + let on_aws = PreflightRunner::new() + .run_compile_time_checks(&stack_with(source_sandbox()), Platform::Aws) + .await + .expect("compile-time checks run"); + + assert!(on_aws.success, "AWS builds it: {on_aws:?}"); +} From c4302617a3163bf2e35b2e9b8f1ddf3c9f3c3b56 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:01:21 +0300 Subject: [PATCH 7/7] fix(preflight): refuse a sandbox built from source on a Frozen lifecycle A source build is pushed to the project's own repository, so it is private by the time anything reads the field that says so. The refusal keyed on that field, which meant a Frozen source sandbox passed the build-time gate and failed later, when setup tried to pull an image the registry had not opened to an account the deployment had not yet reported. It is refused on the declaration instead, where the developer is. --- .../compile_time/frozen_resource_lifecycle.rs | 80 ++++++++++++++++--- .../tests/sandbox_platform_gate.rs | 17 +++- 2 files changed, 84 insertions(+), 13 deletions(-) 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 e5bd46c09b..2e83f36081 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, - Stack, Storage, + SandboxCode, Stack, Storage, }; /// Ensures each resource uses a lifecycle allowed by the ownership policy. @@ -79,15 +79,27 @@ impl CompileTimeCheck for FrozenResourceLifecycleCheck { // Setup builds a Frozen image before the deployment registers, and only registration // tells the registry which customer account to open the base image's repository to. if let Some(sandbox) = resource_entry.config.downcast_ref::() { - if sandbox.private_base_image.is_some() - && resource_entry.lifecycle == ResourceLifecycle::Frozen - { - errors.push(format!( - "Sandbox '{}' declares a private base image, which requires the Live \ - lifecycle; a Frozen image is built before the registry can grant the \ - customer's account access to it", - resource_id - )); + if resource_entry.lifecycle == ResourceLifecycle::Frozen { + if sandbox.private_base_image.is_some() { + errors.push(format!( + "Sandbox '{}' declares a private base image, which requires the Live \ + lifecycle; a Frozen image is built before the registry can grant the \ + customer's account access to it", + resource_id + )); + } + // Refused on the declaration rather than on the field it becomes: the release + // pushes a source build to the project's own repository, so it is private by + // the time anything reads it, and by then setup has already tried to pull. + if matches!(&sandbox.code, SandboxCode::Source { .. }) { + errors.push(format!( + "Sandbox '{}' is built from source, which requires the Live \ + lifecycle; the release pushes it to a private repository the \ + registry cannot open to a customer account setup has not yet \ + reported", + resource_id + )); + } } } @@ -460,6 +472,54 @@ mod tests { } } + /// The release makes a source build private, so the declaration is refused rather than the + /// field it becomes — by the time that field exists, setup has already tried to pull. + #[tokio::test] + async fn source_is_refused_on_a_frozen_sandbox() { + let with_source = |lifecycle| { + let mut stack = sandbox_stack(lifecycle); + let entry = stack.resources.get_mut("agents").expect("sandbox entry"); + let mut sandbox = entry + .config + .downcast_ref::() + .expect("sandbox config") + .clone(); + sandbox.code = SandboxCode::Source { + src: "./sandbox".to_string(), + toolchain: alien_core::ToolchainConfig::Docker { + dockerfile: None, + target: None, + build_args: None, + }, + }; + entry.config = alien_core::Resource::new(sandbox); + stack + }; + + let frozen = FrozenResourceLifecycleCheck + .check(&with_source(ResourceLifecycle::Frozen), Platform::Aws) + .await + .expect("the check runs"); + assert!(!frozen.success); + assert!( + frozen + .errors + .iter() + .any(|error| error.contains("built from source")), + "the refusal must name the declaration: {:?}", + frozen.errors + ); + + let live = FrozenResourceLifecycleCheck + .check(&with_source(ResourceLifecycle::Live), Platform::Aws) + .await + .expect("the check runs"); + assert!( + live.success, + "Live is where a source build belongs: {live:?}" + ); + } + #[tokio::test] async fn a_private_base_image_is_refused_on_a_frozen_sandbox() { let with_private_base = |lifecycle| { diff --git a/crates/alien-preflights/tests/sandbox_platform_gate.rs b/crates/alien-preflights/tests/sandbox_platform_gate.rs index fc0a133209..a07c177ae8 100644 --- a/crates/alien-preflights/tests/sandbox_platform_gate.rs +++ b/crates/alien-preflights/tests/sandbox_platform_gate.rs @@ -12,6 +12,10 @@ use alien_core::{ use alien_preflights::runner::PreflightRunner; fn stack_with(sandbox: Sandbox) -> Stack { + stack_with_lifecycle(sandbox, alien_core::ResourceLifecycle::Frozen) +} + +fn stack_with_lifecycle(sandbox: Sandbox, lifecycle: alien_core::ResourceLifecycle) -> Stack { // Azure's sandbox ceilings are unenforceable, so a declared ceiling must fail the gate. The // worker rounds out the stack; the gate under test is the sandbox capability one. Stack::new("sandbox-gate".to_string()) @@ -25,7 +29,7 @@ fn stack_with(sandbox: Sandbox) -> Stack { .build(), alien_core::ResourceLifecycle::Live, ) - .add(sandbox, alien_core::ResourceLifecycle::Frozen) + .add(sandbox, lifecycle) .build() } @@ -112,10 +116,17 @@ fn source_sandbox() -> Sandbox { /// `alien build` turns a sandbox's source into an image on AWS, and nowhere else. Driven through /// the runner rather than the method, because the runtime's empty-image fallback on Kubernetes /// rests on this gate being registered, not merely on the method refusing when called. +/// +/// Live, because the release pushes a source build to a private repository and a Frozen one is +/// built before the registry can open it. #[tokio::test] async fn source_reaches_no_platform_but_aws_through_the_runner() { + let live = alien_core::ResourceLifecycle::Live; let summary = PreflightRunner::new() - .run_compile_time_checks(&stack_with(source_sandbox()), Platform::Kubernetes) + .run_compile_time_checks( + &stack_with_lifecycle(source_sandbox(), live), + Platform::Kubernetes, + ) .await .expect("compile-time checks run"); @@ -127,7 +138,7 @@ async fn source_reaches_no_platform_but_aws_through_the_runner() { ); let on_aws = PreflightRunner::new() - .run_compile_time_checks(&stack_with(source_sandbox()), Platform::Aws) + .run_compile_time_checks(&stack_with_lifecycle(source_sandbox(), live), Platform::Aws) .await .expect("compile-time checks run");