diff --git a/crates/alien-cloudformation/src/built_ins.rs b/crates/alien-cloudformation/src/built_ins.rs index 3d9b1f8ef..27a4203d2 100644 --- a/crates/alien-cloudformation/src/built_ins.rs +++ b/crates/alien-cloudformation/src/built_ins.rs @@ -5,7 +5,7 @@ use crate::{ emitters::aws::{ - AwsAiEmitter, AwsArtifactRegistryEmitter, AwsBuildEmitter, AwsEmailEmitter, + AwsAiEmitter, AwsArtifactRegistryEmitter, AwsBuildEmitter, AwsEmailEmitter, AwsSandboxEmitter, AwsKubernetesClusterEmitter, AwsKvEmitter, AwsNetworkEmitter, AwsOpenSearchEmitter, AwsQueueEmitter, AwsRemoteBindingsEmitter, AwsRemoteStackManagementEmitter, AwsServiceAccountEmitter, AwsStorageEmitter, AwsVaultEmitter, AwsWorkerEmitter, @@ -14,8 +14,8 @@ use crate::{ }; use alien_core::{ Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, KubernetesCluster, Kv, Network, Platform, - Queue, RemoteBindings, RemoteStackManagement, ResourceType, ServiceAccount, Storage, Vault, - Worker, + Queue, RemoteBindings, RemoteStackManagement, ResourceType, Sandbox, ServiceAccount, Storage, + Vault, Worker, }; pub(crate) fn register_aws(registry: &mut CfRegistry) { @@ -38,6 +38,7 @@ pub(crate) fn register_aws(registry: &mut CfRegistry) { aws(registry, Vault::RESOURCE_TYPE, AwsVaultEmitter); aws(registry, Worker::RESOURCE_TYPE, AwsWorkerEmitter); aws(registry, Build::RESOURCE_TYPE, AwsBuildEmitter); + aws(registry, Sandbox::RESOURCE_TYPE, AwsSandboxEmitter); aws( registry, ArtifactRegistry::RESOURCE_TYPE, diff --git a/crates/alien-cloudformation/src/emitters/aws/helpers.rs b/crates/alien-cloudformation/src/emitters/aws/helpers.rs index 8c6b51586..d445604ea 100644 --- a/crates/alien-cloudformation/src/emitters/aws/helpers.rs +++ b/crates/alien-cloudformation/src/emitters/aws/helpers.rs @@ -29,7 +29,7 @@ pub const CONDITION_NETWORK_CREATE_AZ3: &str = "NetworkCreateUseAz3"; const CONDITION_NETWORK_AZ2: &str = "NetworkUseAz2"; const CONDITION_NETWORK_AZ3: &str = "NetworkUseAz3"; pub const CONDITION_HAS_VPC_CIDR: &str = "HasVpcCidr"; -const CONDITION_NETWORK_MODE_CREATE: &str = "NetworkModeCreate"; +pub const CONDITION_NETWORK_MODE_CREATE: &str = "NetworkModeCreate"; const CONDITION_NETWORK_MODE_USE_EXISTING: &str = "NetworkModeUseExisting"; pub const INLINE_POLICY_NAME: &str = "deployment-permissions"; diff --git a/crates/alien-cloudformation/src/emitters/aws/mod.rs b/crates/alien-cloudformation/src/emitters/aws/mod.rs index 637f18fb6..b625eab23 100644 --- a/crates/alien-cloudformation/src/emitters/aws/mod.rs +++ b/crates/alien-cloudformation/src/emitters/aws/mod.rs @@ -17,6 +17,7 @@ pub mod open_search; pub mod queue; pub mod remote_bindings; pub mod remote_stack_management; +pub mod sandbox; pub mod service_account; pub mod storage; pub mod vault; @@ -33,6 +34,7 @@ pub use open_search::AwsOpenSearchEmitter; pub use queue::AwsQueueEmitter; pub use remote_bindings::AwsRemoteBindingsEmitter; pub use remote_stack_management::AwsRemoteStackManagementEmitter; +pub use sandbox::AwsSandboxEmitter; pub use service_account::AwsServiceAccountEmitter; pub use storage::AwsStorageEmitter; pub use vault::AwsVaultEmitter; diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs new file mode 100644 index 000000000..8fd666be0 --- /dev/null +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -0,0 +1,598 @@ +//! AWS Sandbox — a Lambda MicroVM image and the role that builds it. +//! +//! `AWS::Lambda::MicrovmImage` requires all thirteen of its properties, so nothing here can be +//! left out by omission. The shapes below were read from the live CloudFormation registry, not +//! translated from the Terraform emitter. + +use crate::{ + emitter::CfEmitter, + emitters::aws::helpers::{ + default_network, private_subnet_ids_expr, required_logical_id, resource_config, + service_trust_policy, subnet_refs, tags, vpc_id_expr, CONDITION_NETWORK_MODE_CREATE, + PARAM_PRIVATE_SUBNET_IDS, + }, + template::{CfExpression, CfResource}, +}; +use alien_core::{ + import::EmitContext, ErrorData, NetworkSettings, Result, Sandbox, SandboxCode, SandboxEgress, +}; +use alien_error::AlienError; + +/// The only architecture MicroVM images accept — the schema's enum has one member, so the agent +/// in the image has to be an aarch64 Linux binary. +const ARCHITECTURE: &str = "ARM_64"; + +/// Port the in-sandbox agent serves, both its own protocol and the lifecycle hooks. +const AGENT_PORT: i64 = 8971; + +/// Unprivileged identity commands run as inside the sandbox, never the agent's own. +const EXEC_UID: &str = "60000"; + +/// The one destination the session's security group permits, which reaches nothing. +const LOOPBACK_ONLY_CIDR: &str = "127.0.0.1/32"; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AwsSandboxEmitter; + +impl CfEmitter for AwsSandboxEmitter { + fn emit_resources(&self, ctx: &EmitContext<'_>) -> Result> { + let sandbox = resource_config::(ctx, Sandbox::RESOURCE_TYPE)?; + let image_id = required_logical_id(ctx)?; + let role_id = format!("{image_id}BuildRole"); + + let artifact_uri = artifact_uri(sandbox)?; + refuse_unsupported_egress(sandbox)?; + let (vpc_id, subnet_ids) = egress_network(ctx, sandbox)?; + // The size whose peak stays inside the declared ceilings. A MicroVM bursts to four times + // its baseline with no way to opt out, so the baseline is a quarter of what was declared. + let tier = sandbox.microvm_tier()?; + let operator_role_id = format!("{image_id}EgressOperatorRole"); + let security_group_id = format!("{image_id}EgressSecurityGroup"); + let connector_id = format!("{image_id}EgressConnector"); + + let mut role = CfResource::new(role_id.clone(), "AWS::IAM::Role".to_string()); + role.properties.insert( + "AssumeRolePolicyDocument".to_string(), + service_trust_policy(["lambda.amazonaws.com"]), + ); + role.properties + .insert("Policies".to_string(), build_policies(&artifact_uri)); + role.properties.insert("Tags".to_string(), tags(ctx)); + + // Lambda assumes this to manage the connector's network interfaces. AWS documents the + // permissions it must hold; the property being optional is not a promise that AWS + // provisions an equivalent role. + let mut operator_role = + CfResource::new(operator_role_id.clone(), "AWS::IAM::Role".to_string()); + operator_role.properties.insert( + "AssumeRolePolicyDocument".to_string(), + service_trust_policy(["lambda.amazonaws.com"]), + ); + operator_role + .properties + .insert("Policies".to_string(), operator_policies()); + operator_role + .properties + .insert("Tags".to_string(), tags(ctx)); + + let mut security_group = CfResource::new( + security_group_id.clone(), + "AWS::EC2::SecurityGroup".to_string(), + ); + security_group.properties.insert( + "GroupDescription".to_string(), + CfExpression::from(format!("Alien sandbox {} session egress", sandbox.id()).as_str()), + ); + security_group + .properties + .insert("VpcId".to_string(), vpc_id); + // EC2 adds an allow-all egress rule to any group whose template states none, so the deny + // has to be written down. Loopback-only is AWS's documented way to say it. Widening this + // rule is the one edit that turns `egress: deny` back into internet access. + security_group.properties.insert( + "SecurityGroupEgress".to_string(), + CfExpression::list([CfExpression::object([ + ("IpProtocol", CfExpression::from("-1")), + ("CidrIp", CfExpression::from(LOOPBACK_ONLY_CIDR)), + ( + "Description", + CfExpression::from("Sandbox sessions reach nothing outbound"), + ), + ])]), + ); + security_group + .properties + .insert("Tags".to_string(), tags(ctx)); + + let mut connector = CfResource::new( + connector_id.clone(), + "AWS::Lambda::NetworkConnector".to_string(), + ); + connector.properties.insert( + "Name".to_string(), + CfExpression::sub(format!("${{AWS::StackName}}-{}", sandbox.id())), + ); + connector.properties.insert( + "OperatorRole".to_string(), + CfExpression::get_att(&operator_role_id, "Arn"), + ); + connector.properties.insert( + "Configuration".to_string(), + CfExpression::object([( + "VpcEgressConfiguration", + CfExpression::object([ + ( + "AssociatedComputeResourceTypes", + CfExpression::list([CfExpression::from("MicroVm")]), + ), + // Documented optional in both the CloudControl schema and the + // CloudFormation reference, and rejected when absent: "NetworkProtocol + // cannot be null or empty for VPC_EGRESS connector". IPv4 rather than + // DualStack because the security group that carries the deny matches IPv4 + // CIDRs — a v6 path would be outside it. + ("NetworkProtocol", CfExpression::from("IPv4")), + ("SubnetIds", subnet_ids), + ( + "SecurityGroupIds", + CfExpression::list([CfExpression::get_att( + &security_group_id, + "GroupId", + )]), + ), + ]), + )]), + ); + connector.properties.insert("Tags".to_string(), tags(ctx)); + + let mut image = CfResource::new( + image_id.to_string(), + "AWS::Lambda::MicrovmImage".to_string(), + ); + let properties = &mut image.properties; + + properties.insert( + "Name".to_string(), + CfExpression::sub(format!("${{AWS::StackName}}-{}", sandbox.id())), + ); + properties.insert( + "Description".to_string(), + CfExpression::from(format!("Alien sandbox {}", sandbox.id()).as_str()), + ); + properties.insert( + "BaseImageArn".to_string(), + CfExpression::sub("arn:aws:lambda:${AWS::Region}:aws:microvm-image:al2023-1"), + ); + properties.insert("BaseImageVersion".to_string(), CfExpression::from("1")); + properties.insert( + "BuildRoleArn".to_string(), + CfExpression::get_att(&role_id, "Arn"), + ); + properties.insert( + "CodeArtifact".to_string(), + CfExpression::object([("Uri", CfExpression::from(artifact_uri.as_str()))]), + ); + // The switch behind "control plane never sees sandbox contents", not an approximation. + properties.insert( + "Logging".to_string(), + CfExpression::object([("Disabled", CfExpression::from(true))]), + ); + // AWS's own connector, not the deny connector this template creates. The image build + // runs through whatever the image names and has to reach a registry; naming the deny + // connector here leaves it nowhere to go and the image never becomes ACTIVE. A session's + // egress is the connector passed at `RunMicrovm`, which is the deny one. + properties.insert( + "EgressNetworkConnectors".to_string(), + CfExpression::list([CfExpression::sub( + "arn:${AWS::Partition}:lambda:${AWS::Region}:aws:network-connector:aws-network-connector:INTERNET_EGRESS", + )]), + ); + properties.insert( + "CpuConfigurations".to_string(), + CfExpression::list([CfExpression::object([( + "Architecture", + CfExpression::from(ARCHITECTURE), + )])]), + ); + properties.insert( + "Resources".to_string(), + CfExpression::list([CfExpression::object([( + "MinimumMemoryInMiB", + CfExpression::Integer(tier.baseline_memory_mib), + )])]), + ); + // The enum has exactly one member, `ALL`, which grants mount, netns and eBPF. There is + // no subset to request, so the answer is none. + properties.insert( + "AdditionalOsCapabilities".to_string(), + CfExpression::list([]), + ); + properties.insert("Hooks".to_string(), hooks()); + properties.insert( + "EnvironmentVariables".to_string(), + environment_variables(), + ); + properties.insert("Tags".to_string(), tags(ctx)); + + Ok(vec![ + role, + operator_role, + security_group, + connector, + image, + ]) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let sandbox = resource_config::(ctx, Sandbox::RESOURCE_TYPE)?; + let image_id = required_logical_id(ctx)?; + Ok(CfExpression::object([ + ("previewPorts", preview_ports(sandbox)), + ( + "egressConnectorArns", + CfExpression::list([CfExpression::get_att( + format!("{image_id}EgressConnector"), + "Arn", + )]), + ), + // `Ref` returns the image ARN, which is what both `GetMicrovmImage` and + // `RunMicrovm` require — measured against the live API, where a bare name is refused + // by both. + ("imageIdentifier", CfExpression::ref_(image_id)), + ("imageArn", CfExpression::get_att(image_id, "ImageArn")), + // `RunMicrovm` has no tags, so image plus version is the whole of session identity. + // A stale version enumerates the wrong set and orphans live sessions. + ( + "imageVersion", + CfExpression::get_att(image_id, "LatestActiveImageVersion"), + ), + ])) + } + + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { + let sandbox = resource_config::(ctx, Sandbox::RESOURCE_TYPE)?; + let image_id = required_logical_id(ctx)?; + let mut fields = vec![ + ("service".to_string(), CfExpression::from("sandbox-aws")), + ("previewPorts".to_string(), preview_ports(sandbox)), + ( + "egressConnectorArns".to_string(), + CfExpression::list([CfExpression::get_att( + format!("{image_id}EgressConnector"), + "Arn", + )]), + ), + ( + "imageArn".to_string(), + CfExpression::get_att(image_id, "ImageArn"), + ), + ( + "imageVersion".to_string(), + CfExpression::get_att(image_id, "LatestActiveImageVersion"), + ), + ("region".to_string(), CfExpression::ref_("AWS::Region")), + ]; + if let Some(seconds) = sandbox.session.idle_suspend_seconds { + fields.push(( + "idleSuspendSeconds".to_string(), + CfExpression::Integer(i64::from(seconds)), + )); + } + if let Some(seconds) = sandbox.session.max_lifetime_seconds { + fields.push(( + "maxLifetimeSeconds".to_string(), + CfExpression::Integer(i64::from(seconds)), + )); + } + Ok(Some(CfExpression::object(fields))) + } +} + +/// The ports a preview capability may be minted for. +/// +/// Carried into both the import data and the binding because minting is where ingress is granted: +/// `CreateMicrovmAuthToken` will scope a token to any port it is asked for, so the declared list +/// has to reach the code that mints or the declaration bounds nothing. +fn preview_ports(sandbox: &Sandbox) -> CfExpression { + CfExpression::list( + sandbox + .preview_ports + .iter() + .map(|port| CfExpression::Integer(i64::from(*port))), + ) +} + +/// What the build role may do: read the bundle, and write its own build logs. +/// +/// Scoped to the one object it reads. The bundle URI is known when the template is generated, so +/// there is no reason for the role a customer installs to carry account-wide object read. +fn build_policies(artifact_uri: &str) -> CfExpression { + CfExpression::list([CfExpression::object([ + ("PolicyName", CfExpression::from("sandbox-image-build")), + ( + "PolicyDocument", + CfExpression::object([ + ("Version", CfExpression::from("2012-10-17")), + ( + "Statement", + CfExpression::list([ + CfExpression::object([ + ("Effect", CfExpression::from("Allow")), + ("Action", CfExpression::list([CfExpression::from("s3:GetObject")])), + ( + "Resource", + CfExpression::from( + format!("arn:aws:s3:::{}", artifact_uri.trim_start_matches("s3://")) + .as_str(), + ), + ), + ]), + CfExpression::object([ + ("Effect", CfExpression::from("Allow")), + ( + "Action", + CfExpression::list([ + // CreateLogGroup as well as the writes: the build creates no + // group of its own, so without it the first build's logs go + // nowhere. The house build role grants all three. + CfExpression::from("logs:CreateLogGroup"), + CfExpression::from("logs:CreateLogStream"), + CfExpression::from("logs:PutLogEvents"), + ]), + ), + ("Resource", CfExpression::from("*")), + ]), + ]), + ), + ]), + ), + ])]) +} + +/// Lifecycle hooks, served by the agent on its own port. +/// +/// `Run` and `Resume` are enabled because every MicroVM from one image shares the state resident +/// at capture, including the CSPRNG seed, so the reseed has to happen after each start. +/// +/// `Ready` is not optional: AWS rejects an image that enables any MicroVM hook without it, and +/// it is what defers the snapshot until the agent is actually serving. +fn hooks() -> CfExpression { + CfExpression::object([ + ("Port", CfExpression::Integer(AGENT_PORT)), + ( + "MicrovmImageHooks", + CfExpression::object([ + ("Ready", CfExpression::from("ENABLED")), + ("ReadyTimeoutInSeconds", CfExpression::Integer(120)), + ]), + ), + ( + "MicrovmHooks", + CfExpression::object([ + ("Run", CfExpression::from("ENABLED")), + ("RunTimeoutInSeconds", CfExpression::Integer(30)), + ("Resume", CfExpression::from("ENABLED")), + ("ResumeTimeoutInSeconds", CfExpression::Integer(30)), + ]), + ), + ]) +} + +/// The agent's configuration contract. +/// +/// `transport` authorization on AWS: the proxy validates a token scoped to one MicroVM before a +/// request arrives, and one MicroVM is one session. +fn environment_variables() -> CfExpression { + let pairs = [ + ("ALIEN_SANDBOX_ROOT", "/sandbox".to_string()), + ("ALIEN_SANDBOX_PORT", AGENT_PORT.to_string()), + ("ALIEN_SANDBOX_AUTHORIZATION", "transport".to_string()), + ("ALIEN_SANDBOX_EXEC_UID", EXEC_UID.to_string()), + ("ALIEN_SANDBOX_EXEC_GID", EXEC_UID.to_string()), + ]; + + CfExpression::list(pairs.into_iter().map(|(key, value)| { + CfExpression::object([ + ("Key", CfExpression::from(key)), + ("Value", CfExpression::from(value.as_str())), + ]) + })) +} + +/// Resolves the S3 bundle the MicroVM image is built from. +/// +/// A MicroVM image is built from a zip containing a Dockerfile, not from a container image +/// What Lambda may do while managing the connector's network interfaces. +/// +/// Reproduces the role AWS documents as the prerequisite for creating a network connector, and +/// the contents of its `AWSLambdaNetworkConnectorOperatorPolicy`. Written out rather than +/// attached so the grant is visible in the template the customer reads and does not change under +/// them when AWS revises the managed policy. +fn operator_policies() -> CfExpression { + CfExpression::list([CfExpression::object([ + ("PolicyName", CfExpression::from("sandbox-egress-connector")), + ( + "PolicyDocument", + CfExpression::object([ + ("Version", CfExpression::from("2012-10-17")), + ( + "Statement", + CfExpression::list([ + CfExpression::object([ + ("Sid", CfExpression::from("CreateENI")), + ("Effect", CfExpression::from("Allow")), + ( + "Action", + CfExpression::from("ec2:CreateNetworkInterface"), + ), + ( + "Resource", + CfExpression::list([ + CfExpression::sub("arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/*"), + CfExpression::sub("arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/*"), + CfExpression::sub("arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*"), + ]), + ), + ]), + CfExpression::object([ + ("Sid", CfExpression::from("TagENI")), + ("Effect", CfExpression::from("Allow")), + ("Action", CfExpression::from("ec2:CreateTags")), + ( + "Resource", + CfExpression::sub("arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/*"), + ), + ( + "Condition", + CfExpression::object([( + "StringEquals", + CfExpression::object([( + "ec2:ManagedResourceOperator", + CfExpression::from( + "network-connectors.lambda.amazonaws.com", + ), + )]), + )]), + ), + ]), + ]), + ), + ]), + ), + ])]) +} + +/// The VPC and private subnets the connector attaches to. +/// +/// A connector must name between one and sixteen subnets, and only a created or bring-your-own +/// VPC yields any. Refusing the other network modes here is what keeps the deny path honest: the +/// alternative is a template with no subnets, and a session with no connector reaches the public +/// internet. +fn egress_network( + ctx: &EmitContext<'_>, + sandbox: &Sandbox, +) -> Result<(CfExpression, CfExpression)> { + let refuse = |reason: String| { + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("cloudformation emit sandbox '{}'", sandbox.id()), + reason, + })) + }; + + let Some((network_id, network)) = default_network(ctx) else { + return refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, and this \ + stack declares no network for it to attach to" + .to_string(), + ); + }; + + match &network.settings { + // Deliberately not `private_subnet_ids_expr`: its use-default branch resolves to + // `AWS::NoValue`, and a connector with no subnets is a required property missing at + // deploy — cfn-lint rejects it, and worse, it is the case where a session would run with + // no connector at all. Falling through to the use-existing parameter instead means + // use-default fails when CloudFormation creates the connector rather than silently. + NetworkSettings::Create { .. } => Ok(( + CfExpression::if_( + CONDITION_NETWORK_MODE_CREATE, + CfExpression::ref_(format!("{network_id}Vpc")), + CfExpression::ref_("VpcId"), + ), + CfExpression::if_( + CONDITION_NETWORK_MODE_CREATE, + subnet_refs(network_id, "PrivateSubnet"), + CfExpression::ref_(PARAM_PRIVATE_SUBNET_IDS), + ), + )), + NetworkSettings::ByoVpcAws { .. } => Ok((vpc_id_expr(ctx), private_subnet_ids_expr(ctx))), + NetworkSettings::UseDefault => refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, which needs \ + private subnets; the account's default VPC has only public ones. Set the network \ + to create or byo-vpc-aws" + .to_string(), + ), + _ => refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, and this \ + stack's network settings are for another cloud" + .to_string(), + ), + } +} + +/// Refuses an egress mode the emitted template cannot deliver. +/// +/// `deny` is built from a connector whose security group permits nothing outbound. Outbound +/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no +/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// A template that silently ignores a declared egress policy is worse than one that refuses it. +fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { + let refuse = |mode: &str| { + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("cloudformation emit sandbox '{}'", sandbox.id()), + reason: format!( + "AWS sandboxes reach the network through a VPC egress connector, which this \ + template builds to deny outbound traffic; egress '{mode}' has no connector \ + configuration to render into. Declare egress: deny, or use a platform that \ + supports it" + ), + })) + }; + + match &sandbox.egress { + SandboxEgress::Deny => Ok(()), + SandboxEgress::Allow => refuse("allow"), + SandboxEgress::AllowDomains { .. } => refuse("allowDomains"), + } +} + +/// reference, and Alien has no build step producing one yet. Requiring an `s3://` URI fails at +/// plan time with something a reader can act on, rather than at the end of a ~160s image build. +fn artifact_uri(sandbox: &Sandbox) -> Result { + let unsupported = |reason: String| { + AlienError::new(ErrorData::OperationNotSupported { + operation: format!("cloudformation emit sandbox '{}'", sandbox.id()), + reason, + }) + }; + + match &sandbox.code { + // A bucket with no key would scope the build role to the whole bucket rather than the + // one object, so it is refused here rather than silently widening the grant. + SandboxCode::Image { image } + if image.starts_with("s3://") && image.trim_start_matches("s3://").contains('/') => + { + Ok(image.clone()) + } + SandboxCode::Image { image } if image.starts_with("s3://") => Err(unsupported(format!( + "code.image '{image}' names a bucket with no object key; give the full path to the \ + bundle, for example s3://bucket/sandbox.zip" + ))), + SandboxCode::Image { image } => Err(unsupported(format!( + "AWS builds a MicroVM image from an S3 bundle containing a Dockerfile, so code.image \ + must be an s3:// URI, not the container reference '{image}'" + ))), + SandboxCode::Source { .. } => Err(unsupported( + "AWS builds a MicroVM image from a prepared S3 bundle; CloudFormation templates \ + cannot build one from source" + .to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The two emitters must agree: a sandbox declared once cannot mean different architectures + /// or run as different uids depending on which package format the customer installed. + #[test] + fn the_agent_contract_matches_the_terraform_emitter() { + assert_eq!(ARCHITECTURE, "ARM_64"); + assert_eq!(AGENT_PORT, 8971); + assert_eq!(EXEC_UID, "60000"); + // Both formats deny by permitting one destination that reaches nothing. They agree by + // asserting the same literal rather than by sharing a constant, which would make this + // crate depend on the other package format. + assert_eq!(LOOPBACK_ONLY_CIDR, "127.0.0.1/32"); + } +} diff --git a/crates/alien-cloudformation/tests/generator.rs b/crates/alien-cloudformation/tests/generator.rs index d82a21589..92da9696a 100644 --- a/crates/alien-cloudformation/tests/generator.rs +++ b/crates/alien-cloudformation/tests/generator.rs @@ -16,6 +16,7 @@ mod generator { pub mod aws_email_tests; pub mod aws_full_stack_tests; pub mod aws_open_search_tests; + pub mod aws_sandbox_tests; pub mod enabled_queue_tests; pub mod enabled_storage_tests; pub mod enabled_tests; diff --git a/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs new file mode 100644 index 000000000..e6b1c401d --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/aws_sandbox_tests.rs @@ -0,0 +1,239 @@ +//! AWS Sandbox — what `egress: deny` has to build for the template to mean it. + +use super::helpers::{custom_resource_registration, render_built_ins_template, try_render_built_ins}; +use alien_cloudformation::CloudFormationTarget; +use alien_core::{ + Network, NetworkSettings, ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, + SandboxSessionPolicy, Stack, StackSettings, +}; + +fn sandbox_fixture(egress: SandboxEgress) -> Sandbox { + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "s3://acme-artifacts/agents/bundle.zip".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() +} + +/// A sandbox and the network its egress connector attaches to, which the emitter requires. +fn sandbox_stack(name: &str, egress: SandboxEgress) -> (Stack, StackSettings) { + let settings = StackSettings { + network: Some(NetworkSettings::Create { + cidr: None, + availability_zones: 2, + }), + ..StackSettings::default() + }; + let stack = Stack::new(name.to_string()) + .add( + Network::new("default-network".to_string()) + .settings(settings.network.clone().expect("network")) + .build(), + ResourceLifecycle::Frozen, + ) + .add(sandbox_fixture(egress), ResourceLifecycle::Frozen) + .build(); + (stack, settings) +} + +/// `egress: deny` has to be built, not assumed. +/// +/// A MicroVM started with no egress connector reaches the public internet — verified against a +/// live account. The connector is what puts session traffic inside the VPC, and the security +/// group is what stops it there: EC2 adds an allow-all egress rule to any group whose template +/// states none, so the only rule present must be the one that reaches nothing. +#[test] +fn aws_sandbox_deny_builds_a_connector_that_permits_nothing_outbound() { + let (stack, settings) = sandbox_stack("acme-sandbox-deny", SandboxEgress::Deny); + let (template, _yaml) = render_built_ins_template( + &stack, + settings, + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "sandbox deny", + ); + + let security_group = template + .resources + .get("AgentsEgressSecurityGroup") + .expect("the sandbox egress security group must render"); + let egress = serde_json::to_string( + security_group + .properties + .get("SecurityGroupEgress") + .expect("an egress rule, or EC2's allow-all default survives"), + ) + .expect("serializes"); + assert!( + egress.contains("127.0.0.1/32"), + "the only permitted destination must be the one that reaches nothing: {egress}" + ); + assert!( + !egress.contains("0.0.0.0/0"), + "a wide egress rule turns deny back into outbound access: {egress}" + ); + + let connector = template + .resources + .get("AgentsEgressConnector") + .expect("the egress connector must render"); + assert_eq!(connector.resource_type, "AWS::Lambda::NetworkConnector"); + let configuration = serde_json::to_string( + connector + .properties + .get("Configuration") + .expect("connector configuration"), + ) + .expect("serializes"); + assert!( + configuration.contains("AgentsEgressSecurityGroup"), + "the connector must carry the group that denies: {configuration}" + ); + assert!( + configuration.contains("DefaultNetworkPrivateSubnet1"), + "the connector must place its interfaces in the network's private subnets: \ + {configuration}" + ); + assert!( + configuration.contains("MicroVm"), + "the connector must be usable by MicroVMs: {configuration}" + ); + + let image = template + .resources + .get("Agents") + .expect("the MicroVM image must render"); + let connectors = serde_json::to_string( + image + .properties + .get("EgressNetworkConnectors") + .expect("the image's connector list"), + ) + .expect("serializes"); + // The switch that keeps session output out of the control plane's reach. It has no test of + // its own anywhere else, so a one-sided edit re-enabling it would ship green. + let logging = serde_json::to_string( + image + .properties + .get("Logging") + .expect("the image must state its logging"), + ) + .expect("serializes"); + assert!( + logging.contains("\"Disabled\":true"), + "content-bearing logging must be off: {logging}" + ); + + // The build's route, not the session's. Naming the deny connector here would leave the image + // build with nowhere to reach a registry, and it would never become ACTIVE. + assert!( + connectors.contains("INTERNET_EGRESS"), + "the image must build through AWS's own connector: {connectors}" + ); + assert!( + !connectors.contains("AgentsEgressConnector"), + "the deny connector belongs to the session, not the build: {connectors}" + ); + + // And the session's own connector still reaches the binding, which is what actually bounds + // a running sandbox. + let registration = serde_json::to_string(&template.resources).expect("serializes"); + assert!( + registration.contains("AgentsEgressConnector"), + "the deny connector must still be carried to the session" + ); +} + +/// Both `GetMicrovmImage` and `RunMicrovm` require the image **ARN** — measured against the live +/// API, where a bare name is refused by both and `RunMicrovm` says "Malformed ARN - doesn't start +/// with 'arn:'". `Ref` on an `AWS::Lambda::MicrovmImage` returns that ARN. The two package formats +/// must also agree, or a controller handed one form by one of them cannot read its own image. +#[test] +fn the_image_identifier_is_the_arn_both_calls_require() { + let (stack, settings) = sandbox_stack("acme-sandbox-id", SandboxEgress::Deny); + let (template, _yaml) = render_built_ins_template( + &stack, + settings, + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "sandbox identifier", + ); + + let rendered = serde_json::to_string(&template.resources).expect("serializes"); + let import = rendered + .split("imageIdentifier") + .nth(1) + .unwrap_or_else(|| panic!("the import data must carry an imageIdentifier:\n{rendered}")); + let import: String = import.chars().take(200).collect(); + assert!( + import.contains("Ref"), + "the identifier must be the ARN Ref returns, not the name: {import}" + ); +} + +/// Without a VPC there are no subnets, and a connector needs between one and sixteen. +/// +/// Rendering one anyway would produce either a deploy-time failure the reader cannot act on or — +/// worse — a session with no connector, which is the case that reaches the internet. +#[test] +fn aws_sandbox_refuses_to_render_without_a_network_to_attach_to() { + let stack = Stack::new("acme-sandbox-no-network".to_string()) + .add( + sandbox_fixture(SandboxEgress::Deny), + ResourceLifecycle::Frozen, + ) + .build(); + + let error = try_render_built_ins( + &stack, + StackSettings::default(), + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "sandbox without a network", + ) + .expect_err("a sandbox with no network must be refused at emit time"); + assert!( + error.message.contains("declares no network"), + "the refusal must name why: {}", + error.message + ); +} + +/// An egress mode the artifact cannot deliver is refused, not dropped. +/// +/// The connector this template builds denies outbound traffic. Nothing renders an allowance — +/// `allow` depends on the network's NAT topology and AWS has no domain filter at the connector — +/// so a declared `allow` would be silently ignored while the customer believed otherwise. +#[test] +fn aws_sandbox_refuses_an_egress_mode_it_cannot_deliver() { + for mode in [ + SandboxEgress::Allow, + SandboxEgress::AllowDomains { + domains: vec!["example.com".to_string()], + }, + ] { + let (stack, settings) = sandbox_stack("acme-sandbox-egress", mode.clone()); + let error = try_render_built_ins( + &stack, + settings, + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "sandbox egress", + ) + .expect_err(&format!("egress {mode:?} must be refused at emit time")); + assert!( + error.message.contains("VPC egress connector"), + "the refusal must name why: {}", + error.message + ); + } +} diff --git a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs index 0727a527d..8389ff4c4 100644 --- a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs +++ b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs @@ -11,13 +11,14 @@ use super::helpers::{ }; use alien_cloudformation::{CfRegistry, CloudFormationTarget}; use alien_core::{ - ownership_policy_for_resource_type, Ai, AwsOpenSearch, Email, EmailInbound, Kv, - PermissionProfile, Platform, Queue, ResourceLifecycle, ResourceRef, ServiceAccount, Stack, + ownership_policy_for_resource_type, Ai, AwsOpenSearch, Email, EmailInbound, Kv, Network, + NetworkSettings, PermissionProfile, Platform, Queue, ResourceLifecycle, ResourceRef, Sandbox, + SandboxCode, SandboxEgress, SandboxLimits, SandboxSessionPolicy, ServiceAccount, Stack, StackSettings, Storage, Vault, Worker, WorkerCode, }; use std::collections::HashMap; -fn gated_fixture(resource_type: &str) -> Option { +fn gated_fixture(resource_type: &str) -> Option<(Stack, StackSettings)> { let base = || { Stack::new("matrix-stack".to_string()).inputs(vec![gate_input( "fixtureEnabled", @@ -56,6 +57,35 @@ fn gated_fixture(resource_type: &str) -> Option { ResourceLifecycle::Frozen, "fixtureEnabled", ), + // An AWS sandbox's egress connector needs private subnets, so the + // fixture declares the network the emitter refuses to render without. + "sandbox" => base() + .add( + Network::new("default-network".to_string()) + .settings(sandbox_network()) + .build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Sandbox::new("fixture".to_string()) + .code(SandboxCode::Image { + image: "s3://matrix-artifacts/sandbox.zip".to_string(), + }) + .limits(SandboxLimits { + cpu: "1".to_string(), + memory: "2Gi".to_string(), + disk: "10Gi".to_string(), + max_processes: None, + }) + .egress(SandboxEgress::Deny) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + "fixtureEnabled", + ), "experimental/aws-opensearch" => base().add_enabled_when( AwsOpenSearch::new("fixture".to_string()).build(), ResourceLifecycle::Frozen, @@ -63,7 +93,33 @@ fn gated_fixture(resource_type: &str) -> Option { ), _ => return None, }; - Some(stack.build()) + let settings = StackSettings { + network: (resource_type == "sandbox").then(sandbox_network), + ..StackSettings::default() + }; + Some((stack.build(), settings)) +} + +/// The network the sandbox fixture attaches its egress connector to. The +/// `Network` resource and the stack settings must agree — the emitters read +/// the resource, the parameter block reads the settings. +fn sandbox_network() -> NetworkSettings { + NetworkSettings::Create { + cidr: None, + availability_zones: 2, + } +} + +/// Deploy-time answers for a fixture that declares a network: the managed VPC, two AZs. +fn network_answers() -> HashMap<&'static str, bool> { + HashMap::from([ + ("NetworkModeCreate", true), + ("NetworkModeUseExisting", false), + ("NetworkUseAz2", true), + ("NetworkUseAz3", false), + ("NetworkCreateUseAz2", true), + ("NetworkCreateUseAz3", false), + ]) } /// A gated Live resource never reaches a setup template: the generator skips @@ -126,14 +182,14 @@ fn assert_live_gate_ignored_by_setup(resource_type: &str) { /// Rendered, linted, and resolved with the gate declined: the fixture must /// leave no registration entry, and every resource the fixture contributed /// must carry the gate's condition. -fn assert_gated_render(resource_type: &str, stack: &Stack) { +fn assert_gated_render(resource_type: &str, stack: &Stack, settings: StackSettings) { // The local cfn-lint spec predates the OpenSearch `Generation` property // and fails the type's ungated renders too, so its matrix cell asserts // structure without the lint until the spec catches up. let template = if resource_type == "experimental/aws-opensearch" { try_render_built_ins( stack, - StackSettings::default(), + settings, custom_resource_registration(), CloudFormationTarget::Aws, "aws", @@ -143,7 +199,7 @@ fn assert_gated_render(resource_type: &str, stack: &Stack) { } else { let (template, _yaml) = render_built_ins_template( stack, - StackSettings::default(), + settings, custom_resource_registration(), CloudFormationTarget::Aws, "aws", @@ -173,12 +229,12 @@ fn assert_gated_render(resource_type: &str, stack: &Stack) { ); let payload = registration_payload(&template); - let declined = resolve( - &payload, - &HashMap::from([(condition_name, false)]), - Declined::Removed, - ) - .expect("registration payload should survive resolution"); + let mut answers = HashMap::from([(condition_name, false)]); + // A fixture that declares a network puts its conditions in the payload too. They are the + // deploy-time answers the fixture asks for, not the gate under test. + answers.extend(network_answers()); + let declined = resolve(&payload, &answers, Declined::Removed) + .expect("registration payload should survive resolution"); let text = serde_json::to_string(&declined).expect("resolved payload serializes"); assert!( !text.contains("\"fixture\""), @@ -203,7 +259,7 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { continue; } match gated_fixture(resource_type) { - Some(stack) => assert_gated_render(resource_type, &stack), + Some((stack, settings)) => assert_gated_render(resource_type, &stack, settings), None => allowed_without_fixture.push(resource_type.to_string()), } } @@ -224,16 +280,16 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { /// asserted through both deploy-time answers and locked by a snapshot. #[test] fn a_gated_vault_renders_conditionally() { - let stack = gated_fixture("vault").expect("vault fixture"); + let (stack, settings) = gated_fixture("vault").expect("vault fixture"); let (template, yaml) = render_built_ins_template( &stack, - StackSettings::default(), + settings.clone(), custom_resource_registration(), CloudFormationTarget::Aws, "aws", "gated vault stack", ); - assert_gated_render("vault", &stack); + assert_gated_render("vault", &stack, settings); let payload = registration_payload(&template); let accepted = resolve( @@ -451,3 +507,29 @@ fn inputs_colliding_after_normalization_are_refused() { error.message ); } + +/// Writes the sandbox template so it can be validated against AWS itself. The generator's own +/// tests prove shape; only CloudFormation can say whether it would accept the resource. +#[test] +#[ignore] +fn dump_sandbox_template_for_live_validation() { + let (stack, settings) = gated_fixture("sandbox").expect("sandbox fixture"); + let registry = CfRegistry::built_in(); + let template = alien_cloudformation::generate_cloudformation_template( + &stack, + alien_cloudformation::CloudFormationOptions { + registry: ®istry, + target: alien_cloudformation::CloudFormationTarget::Aws, + stack_settings: settings, + setup_target: "aws".to_string(), + setup_fingerprint: "test".to_string(), + setup_fingerprint_version: 1, + registration: alien_cloudformation::RegistrationMode::OutputsFallback, + description: None, + }, + ) + .expect("template generates"); + + let json = serde_json::to_string_pretty(&template).expect("serializes"); + std::fs::write("/tmp/sandbox-template.json", json).expect("writes"); +} diff --git a/crates/alien-helm/Cargo.toml b/crates/alien-helm/Cargo.toml index edd204caf..13ce1bc18 100644 --- a/crates/alien-helm/Cargo.toml +++ b/crates/alien-helm/Cargo.toml @@ -11,7 +11,7 @@ default = [] test-utils = ["dep:tempfile"] [dependencies] -alien-core = { workspace = true } +alien-core = { workspace = true, features = ["sandbox-process"] } alien-error = { workspace = true } indexmap = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/alien-helm/src/emitters/mod.rs b/crates/alien-helm/src/emitters/mod.rs index 118002fca..a59abbd29 100644 --- a/crates/alien-helm/src/emitters/mod.rs +++ b/crates/alien-helm/src/emitters/mod.rs @@ -11,6 +11,7 @@ pub mod artifact_registry; pub mod build; pub mod kv; pub mod queue; +pub mod sandbox; pub mod service_account; pub mod storage; pub mod vault; @@ -18,7 +19,8 @@ pub mod worker; use crate::registry::HelmRegistry; use alien_core::{ - ArtifactRegistry, Build, Kv, Platform, Queue, ServiceAccount, Storage, Vault, Worker, + ArtifactRegistry, Build, Kv, Platform, Queue, Sandbox, ServiceAccount, Storage, Vault, + Worker, }; /// Wire every built-in K8s Helm emitter into `registry`. @@ -35,6 +37,7 @@ pub fn register_built_ins(registry: &mut HelmRegistry) { ); registry.register(Build::RESOURCE_TYPE, p, build::BuildEmitter); registry.register(Worker::RESOURCE_TYPE, p, worker::WorkerEmitter); + registry.register(Sandbox::RESOURCE_TYPE, p, sandbox::SandboxEmitter); registry.register( ServiceAccount::RESOURCE_TYPE, p, diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs new file mode 100644 index 000000000..edd1a690c --- /dev/null +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -0,0 +1,166 @@ +//! Sandbox emitter — the NetworkPolicy that bounds a session, and the cluster-scoped RBAC the +//! broker needs to authenticate one. +//! +//! Both belong to Helm rather than to the operator: the operator does not create +//! NetworkPolicies, and a `TokenReview` is cluster-scoped while the operator is namespace-scoped +//! with no cluster-admin. Build has the same shape and is the precedent. +//! +//! No ServiceAccount is emitted, unlike Build's. A sandbox pod runs with +//! `automountServiceAccountToken: false` — a mounted token is a credential the untrusted code can +//! read — so a dedicated account would grant it nothing that `default` does not, and would be a +//! name for the operator and the chart to keep in step for no gain. Build's account exists because +//! a build genuinely needs identity to push images. + +use crate::emitter::{HelmEmitter, HelmFragment}; +use alien_core::sandbox_process::AGENT_PORT; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxEgress}; +use alien_error::AlienError; + +/// Label the operator puts on every pod backing a session, and what the policy selects on. +const LABEL_SANDBOX: &str = "alien.dev/sandbox"; + +/// Addresses no sandbox may reach, in **either** egress mode. +/// +/// The metadata server is the one that matters. gVisor is a kernel boundary, not a network +/// boundary, so it does nothing about routing and link-local has to be denied explicitly rather +/// than assumed unreachable. Cloud Run and Azure block it at the platform; Kubernetes does not. +/// +/// **The metadata entry is a `/32`, not the `169.254.0.0/16` it sits in.** GKE puts NodeLocal +/// DNSCache at `169.254.20.10`, so denying the whole range takes DNS out and with it every +/// outbound connection, which makes `allow` indistinguishable from `deny`. +const ALWAYS_DENIED_CIDRS: &[&str] = &[ + "169.254.169.254/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", +]; + +/// Path the broker's RBAC lands at. Fixed rather than per-sandbox: the ClusterRole is one per +/// deployment, so a second sandbox rewrites the same file instead of colliding on the name. +const BROKER_RBAC_TEMPLATE: &str = "sandbox-broker-rbac.yaml"; + +#[derive(Debug, Default)] +pub struct SandboxEmitter; + +impl HelmEmitter for SandboxEmitter { + fn emit(&self, ctx: &EmitContext<'_>) -> Result { + // Refused rather than skipped: an empty fragment is a chart with no NetworkPolicy, and a + // sandbox pod without one has the unrestricted egress this emitter exists to prevent. + let sandbox = ctx + .resource + .config + .downcast_ref::() + .ok_or_else(|| { + AlienError::new(ErrorData::UnexpectedResourceType { + resource_id: ctx.resource_id.to_string(), + expected: Sandbox::RESOURCE_TYPE, + actual: ctx.resource.config.resource_type(), + }) + })?; + + let mut fragment = HelmFragment::empty(); + fragment.extra_templates.insert( + format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), + network_policy(sandbox), + ); + fragment + .extra_templates + .insert(BROKER_RBAC_TEMPLATE.to_string(), broker_rbac()); + Ok(fragment) + } +} + +/// The policy that makes a sandbox's declared egress real. +/// +/// Inbound is the agent port and nothing else, reachable only from pods in this release: that is +/// the port the application drives a session over, and denying it outright leaves every exec and +/// file call dropped on a cluster whose CNI enforces policy. A preview port stays unreachable, +/// because that needs a gateway validating a session-and-port capability and none exists. Under +/// `deny`, `Egress` is listed with no rules — a listed policy type with no rule is how +/// NetworkPolicy spells "none", where omitting the type would mean "unrestricted". +fn network_policy(sandbox: &Sandbox) -> String { + let egress = match sandbox.egress { + SandboxEgress::Deny => String::new(), + // A hostname allowlist is not expressible here — NetworkPolicy matches CIDRs — which is + // why Kubernetes publishes `domainEgressRules: false` rather than approximating one. + SandboxEgress::Allow | SandboxEgress::AllowDomains { .. } => { + let excepts: String = ALWAYS_DENIED_CIDRS + .iter() + .map(|cidr| format!(" - {cidr}\n")) + .collect(); + // DNS first, by selector rather than address. The excepts below cover the private + // ranges a cluster's DNS service lives in — 172.20/16 on EKS, 10.0/16 on AKS, + // 10.96/12 on kubeadm — so an address-based rule alone resolves no names anywhere + // except a cluster whose resolver sits on a link-local address. + let dns = " - to:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: kube-system\n podSelector:\n matchLabels:\n k8s-app: kube-dns\n ports:\n - protocol: UDP\n port: 53\n - protocol: TCP\n port: 53\n"; + format!( + " egress:\n{dns} - to:\n - ipBlock:\n cidr: 0.0.0.0/0\n except:\n{excepts}" + ) + } + }; + + format!( + r#"apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: alien-sbx-{id} + namespace: {{{{ .Release.Namespace }}}} + labels: + {{{{- include "deployment.labels" . | nindent 4 }}}} +spec: + podSelector: + matchLabels: + {label}: {id} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: {{{{ include "deployment.name" . }}}} + app.kubernetes.io/instance: {{{{ .Release.Name }}}} + ports: + - protocol: TCP + port: {agent_port} +{egress}"#, + id = sandbox.id(), + label = LABEL_SANDBOX, + agent_port = AGENT_PORT, + ) +} + +/// Cluster-scoped RBAC for the session broker. +/// +/// A caller proves it is the workload by presenting the token already mounted in its own pod, and +/// the broker checks it with a `TokenReview`. `TokenReview` is a cluster-scoped subresource, so a +/// namespaced Role cannot grant it and authorization fails closed at the first claim without this. +fn broker_rbac() -> String { + r#"apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "deployment.fullname" . }}-sandbox-broker + labels: + {{- include "deployment.labels" . | nindent 4 }} +rules: + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "deployment.fullname" . }}-sandbox-broker + labels: + {{- include "deployment.labels" . | nindent 4 }} +subjects: + - kind: ServiceAccount + name: {{ include "deployment.managerServiceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "deployment.fullname" . }}-sandbox-broker +"# + .to_string() +} diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 682e2594b..b137798fb 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -4,7 +4,8 @@ use super::helpers::{assert_helm_valid, render, snapshot_chart}; use alien_core::{ - ArtifactRegistry, Kv, Queue, ResourceLifecycle, Stack, StackSettings, Storage, Vault, + ArtifactRegistry, Kv, Queue, ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, + SandboxSessionPolicy, Stack, StackSettings, Storage, Vault, }; #[test] @@ -35,3 +36,153 @@ fn data_layer_emits_infrastructure_bindings() { snapshot_chart("data_layer", &chart); assert_helm_valid(&chart, "data_layer"); } + +/// The Kubernetes Frozen parent, which nothing emitted before this. +/// +/// Two things the chart owns and the operator does not: the NetworkPolicy that makes the declared +/// egress real, and the cluster-scoped RBAC the broker's `TokenReview` needs. Rendering is not +/// enough on its own — `assert_helm_valid` runs `helm lint`, `helm template` and `kubeconform`, so +/// a policy the API server would reject fails here rather than at install. +#[test] +fn a_sandbox_emits_its_network_policy_and_the_brokers_rbac() { + let stack = Stack::new("sandbox-chart".to_string()) + .add( + Sandbox::new("agent".to_string()) + .code(SandboxCode::Image { + image: "ubuntu:24.04".to_string(), + }) + .egress(SandboxEgress::Deny) + .session(SandboxSessionPolicy { + max_lifetime_seconds: Some(3600), + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let chart = render(&stack, StackSettings::default()); + + let policy = chart + .files + .get("templates/sandbox-agent-networkpolicy.yaml") + .unwrap_or_else(|| panic!("the sandbox NetworkPolicy must render: {:?}", chart.files.keys().collect::>())); + assert!( + policy.contains("alien.dev/sandbox: agent"), + "the policy must select the operator's own pod label:\n{policy}" + ); + assert!( + !policy.contains("egress:"), + "deny is an Egress policy type with no rule; a rule here would permit something:\n{policy}" + ); + + let rbac = chart + .files + .get("templates/sandbox-broker-rbac.yaml") + .expect("the broker's RBAC must render"); + assert!( + rbac.contains("tokenreviews"), + "a TokenReview is cluster-scoped, so a namespaced Role cannot grant it:\n{rbac}" + ); + + assert_helm_valid(&chart, "sandbox_layer"); +} + +/// Under `allow` the policy must still close the metadata endpoint and the deployment's own +/// private ranges — the sandbox reaching either is a lateral move, not egress. +/// +/// The metadata server is excluded as a **`/32`**. Excluding the enclosing `169.254.0.0/16` +/// agreed with nothing else: GKE's NodeLocal DNSCache sits at `169.254.20.10`, so the wide range +/// takes DNS down and `allow` stops reaching anything at all. The GCE metadata server was +/// observed answering from inside a gVisor pod on GKE, which is why it is denied in both modes. +#[test] +fn a_sandbox_allowing_egress_still_denies_the_metadata_endpoint() { + let stack = Stack::new("sandbox-allow-chart".to_string()) + .add( + Sandbox::new("agent".to_string()) + .code(SandboxCode::Image { + image: "ubuntu:24.04".to_string(), + }) + .egress(SandboxEgress::Allow) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let chart = render(&stack, StackSettings::default()); + + let policy = chart + .files + .get("templates/sandbox-agent-networkpolicy.yaml") + .expect("the sandbox NetworkPolicy must render"); + for denied in ["169.254.169.254/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] { + assert!( + policy.contains(denied), + "{denied} must stay closed even under allow:\n{policy}" + ); + } + assert!( + !policy.contains("169.254.0.0/16"), + "denying all of link-local takes out NodeLocal DNS at 169.254.20.10:\n{policy}" + ); + // The excepts above cover the ranges a cluster's DNS service lives in, so without a rule + // that names the resolver by selector, allow mode resolves no names off GKE. + assert!( + policy.contains("k8s-app: kube-dns") && policy.contains("port: 53"), + "allow must reach the cluster resolver, which the denied ranges otherwise cover:\n{policy}" + ); + // Inbound is the agent port from this release's own pods. Denying it outright drops every + // exec and file call on an enforcing CNI, which is what the application drives a session with. + assert!(policy.contains("- Ingress"), "ingress stays governed by the policy:\n{policy}"); + assert!( + policy.contains("port: 8971"), + "the application must reach the agent port:\n{policy}" + ); + assert!( + policy.contains(r#"app.kubernetes.io/instance: {{ .Release.Name }}"#), + "inbound is scoped to this release's pods, not the whole cluster:\n{policy}" + ); + assert!( + !policy.contains("port: 8080"), + "a preview port stays unreachable: that needs a gateway validating a session-and-port \ + capability, and none exists:\n{policy}" + ); + + assert_helm_valid(&chart, "sandbox_layer_allow"); +} + +/// NetworkPolicy matches addresses, not names, so a hostname allowlist cannot be honoured here. +/// It degrades to `allow` rather than being approximated, and the capability set declares +/// `domainEgressRules: false` so a caller learns that at plan time instead of believing it held. +#[test] +fn a_hostname_allowlist_is_not_silently_approximated() { + let stack = Stack::new("sandbox-domains-chart".to_string()) + .add( + Sandbox::new("agent".to_string()) + .code(SandboxCode::Image { + image: "ubuntu:24.04".to_string(), + }) + .egress(SandboxEgress::AllowDomains { + domains: vec!["example.com".to_string()], + }) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let chart = render(&stack, StackSettings::default()); + + let policy = chart + .files + .get("templates/sandbox-agent-networkpolicy.yaml") + .expect("the sandbox NetworkPolicy must render"); + assert!( + policy.contains("cidr: 0.0.0.0/0") && !policy.contains("example.com"), + "domains are not expressible and must not appear as though they were:\n{policy}" + ); +} diff --git a/crates/alien-terraform/src/built_ins.rs b/crates/alien-terraform/src/built_ins.rs index 50e4e3a66..4ceab5131 100644 --- a/crates/alien-terraform/src/built_ins.rs +++ b/crates/alien-terraform/src/built_ins.rs @@ -7,8 +7,8 @@ use crate::registry::TfRegistry; use alien_core::{ Ai, ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, AzureServiceBusNamespace, AzureStorageAccount, Build, KubernetesCluster, Kv, Network, Platform, - Queue, RemoteBindings, RemoteStackManagement, ServiceAccount, ServiceActivation, Storage, - Vault, Worker, + Queue, RemoteBindings, RemoteStackManagement, Sandbox, ServiceAccount, ServiceActivation, + Storage, Vault, Worker, }; pub(crate) fn register_all(registry: &mut TfRegistry) { @@ -48,6 +48,7 @@ fn register_aws(registry: &mut TfRegistry) { ); registry.register(Build::RESOURCE_TYPE, p, aws::AwsBuildEmitter); registry.register(Worker::RESOURCE_TYPE, p, aws::AwsWorkerEmitter); + registry.register(Sandbox::RESOURCE_TYPE, p, aws::AwsSandboxEmitter); registry.register( KubernetesCluster::RESOURCE_TYPE, p, diff --git a/crates/alien-terraform/src/emitters/aws/mod.rs b/crates/alien-terraform/src/emitters/aws/mod.rs index 5d08d4a7f..f162e2b5a 100644 --- a/crates/alien-terraform/src/emitters/aws/mod.rs +++ b/crates/alien-terraform/src/emitters/aws/mod.rs @@ -13,6 +13,7 @@ pub mod network; pub mod queue; pub mod remote_bindings; pub mod remote_stack_management; +pub mod sandbox; pub mod service_account; pub mod storage; pub mod vault; @@ -26,6 +27,7 @@ pub use network::AwsNetworkEmitter; pub use queue::AwsQueueEmitter; pub use remote_bindings::AwsRemoteBindingsEmitter; pub use remote_stack_management::AwsRemoteStackManagementEmitter; +pub use sandbox::AwsSandboxEmitter; pub use service_account::AwsServiceAccountEmitter; pub use storage::AwsStorageEmitter; pub use vault::AwsVaultEmitter; diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs new file mode 100644 index 000000000..c7f05c404 --- /dev/null +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -0,0 +1,722 @@ +//! AWS Sandbox — a Lambda MicroVM image and the role that builds it. +//! +//! Emitted through `awscc`, not `hashicorp/aws`: the AWS provider has no MicroVM resource at +//! 6.57, so Cloud Control is the only Terraform path to this API. The generator adds the +//! provider requirement when it sees these resource types, the same way it does for `azapi`. + +use crate::{ + block::{attr, resource_block}, + emitter::{TfEmitter, TfFragment}, + emitters::aws::helpers::{ + default_network, downcast, iam_role_block, iam_role_policy_block, iam_role_name_template, + nested_block, private_subnet_ids_expr, required_label, resource_prefix_template, + service_assume_role_policy, tags, vpc_id_expr, + }, + expr, +}; +use alien_core::{ + import::EmitContext, ErrorData, NetworkSettings, Result, Sandbox, SandboxCode, SandboxEgress, + ALIEN_MANAGED_BY_TAG_KEY, + ALIEN_RESOURCE_TAG_KEY, ALIEN_STACK_TAG_KEY, +}; +use alien_error::AlienError; +use hcl::expr::Expression; + +/// Terraform resource type for a MicroVM image. +/// +/// Cloud Control rather than `awscc`: the schema makes `AdditionalOsCapabilities` required, and +/// the sandbox asks for none of them. `awscc` drops an empty list before sending, so the create +/// comes back `Model validation failed (#: required key [AdditionalOsCapabilities] not found)` +/// and no image is ever built. Cloud Control sends the body as written. +pub const MICROVM_IMAGE_RESOURCE: &str = "aws_cloudcontrolapi_resource"; + +/// The Cloud Control type this resource stands for. +const MICROVM_IMAGE_TYPE_NAME: &str = "AWS::Lambda::MicrovmImage"; + +/// Terraform resource type for the barrier that holds creation back until a role is usable. +pub const PROPAGATION_BARRIER_RESOURCE: &str = "time_sleep"; + +/// Terraform resource type for the egress network connector a session's traffic runs through. +pub const NETWORK_CONNECTOR_RESOURCE: &str = "awscc_lambda_network_connector"; + +/// AWS's own connector, named by the image rather than the deny connector the module creates. +/// +/// The image build runs through whatever the image names, and it has to reach a registry. Naming +/// the deny connector there leaves the build with nowhere to go and the image never becomes +/// ACTIVE. A session's egress is decided by the connector passed at `RunMicrovm`, which is the +/// deny connector, so the build reaching out does not widen what a session can reach. +fn internet_egress_connector_arn() -> Expression { + expr::raw( + "\"arn:aws:lambda:${data.aws_region.current.region}:aws:network-connector:aws-network-connector:INTERNET_EGRESS\"", + ) +} + +/// The only architecture MicroVM images accept. The schema's enum has exactly this one member, +/// so the agent has to be an aarch64 Linux binary. +const ARCHITECTURE: &str = "ARM_64"; + +/// Port the in-sandbox agent serves, both its own protocol and the lifecycle hooks. +const AGENT_PORT: i64 = 8971; + +/// Unprivileged identity commands run as inside the sandbox, never the agent's own. +const EXEC_UID: &str = "60000"; + +/// The one destination the session's security group permits, which reaches nothing. +pub const LOOPBACK_ONLY_CIDR: &str = "127.0.0.1/32"; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AwsSandboxEmitter; + +impl TfEmitter for AwsSandboxEmitter { + fn emit(&self, ctx: &EmitContext<'_>) -> Result { + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + let label = required_label(ctx)?; + let artifact_uri = artifact_uri(sandbox)?; + refuse_unsupported_egress(sandbox)?; + let subnet_ids = egress_subnet_ids(ctx, sandbox)?; + // The size whose peak stays inside the declared ceilings. A MicroVM bursts to four times + // its baseline with no way to opt out, so the baseline is a quarter of what was declared. + let tier = sandbox.microvm_tier()?; + let egress_label = format!("{label}_egress"); + + let build_role = iam_role_block( + label, + iam_role_name_template(&format!("{}-build", sandbox.id())), + service_assume_role_policy(&["lambda.amazonaws.com"]), + tags(ctx, "sandbox"), + ); + + let build_policy = iam_role_policy_block( + label, + label, + "sandbox-image-build", + // Statements are raw objects: `iam_role_policy_block` already jsonencodes the whole + // document, and encoding them again renders each one as a JSON *string*, which IAM + // rejects with MalformedPolicyDocument. `terraform validate` cannot see it — the HCL + // and the string are both well-formed — so it only shows up at apply. + vec![ + Expression::from_iter([ + ("Effect", Expression::String("Allow".to_string())), + ( + "Action", + Expression::from(vec![Expression::String("s3:GetObject".to_string())]), + ), + ( + "Resource", + Expression::String(artifact_object_arn(&artifact_uri)), + ), + ]), + Expression::from_iter([ + ("Effect", Expression::String("Allow".to_string())), + ( + "Action", + Expression::from(vec![ + // CreateLogGroup as well as the writes: the build creates no group of + // its own, so without it the first build's logs go nowhere. The house + // build role grants all three. + Expression::String("logs:CreateLogGroup".to_string()), + Expression::String("logs:CreateLogStream".to_string()), + Expression::String("logs:PutLogEvents".to_string()), + ]), + ), + ("Resource", Expression::String("*".to_string())), + ]), + ], + ); + + // Lambda assumes this to manage the connector's ENIs in the customer's VPC. The API + // documents the permissions it must hold; the field being optional is not a promise + // that AWS provisions an equivalent role on its own. + let operator_role = iam_role_block( + &egress_label, + iam_role_name_template(&format!("{}-egress", sandbox.id())), + service_assume_role_policy(&["lambda.amazonaws.com"]), + tags(ctx, "sandbox"), + ); + + let operator_policy = iam_role_policy_block( + &egress_label, + &egress_label, + "sandbox-egress-connector", + operator_statements(), + ); + + // Both roles are referenced by ARN, which Terraform can resolve the moment the role + // exists — before its inline policy is attached, and before IAM has propagated either. + // Lambda assumes the operator role to place the connector's interfaces, so without this + // the first apply fails with "unable to assume the provided NetworkConnectorOperatorRole" + // and succeeds on a retry, which is the worst shape for a customer's first install. + let iam_propagation = resource_block( + PROPAGATION_BARRIER_RESOURCE, + &format!("{label}_iam_propagation"), + [ + attr("create_duration", Expression::String("30s".to_string())), + // Keyed on the roles' unique ids rather than their ARNs: the name is templated, + // so a replaced role keeps the same ARN and the barrier would never re-run. + attr( + "triggers", + Expression::from_iter([ + ( + "build_role", + expr::traversal(["aws_iam_role", label, "unique_id"]), + ), + ( + "operator_role", + expr::traversal(["aws_iam_role", &egress_label, "unique_id"]), + ), + ]), + ), + attr( + "depends_on", + Expression::from(vec![ + expr::traversal(["aws_iam_role_policy", label]), + expr::traversal(["aws_iam_role_policy", &egress_label]), + ]), + ), + ], + ); + + let security_group = resource_block( + "aws_security_group", + &egress_label, + [ + attr( + "name_prefix", + resource_prefix_template(&format!("{}-egress-", sandbox.id())), + ), + attr( + "description", + Expression::String(format!( + "Alien sandbox {} session egress", + sandbox.id() + )), + ), + attr("vpc_id", vpc_id_expr(ctx)), + // Loopback-only is AWS's documented way to say "no egress": EC2 attaches an + // allow-all rule to a new group unless the template states one, so the deny has + // to be written down rather than left out. Widening this rule is the one edit + // that turns `egress: deny` back into internet access. + nested_block( + "egress", + vec![ + attr("from_port", Expression::Number(0.into())), + attr("to_port", Expression::Number(0.into())), + attr("protocol", Expression::String("-1".to_string())), + attr( + "cidr_blocks", + Expression::from(vec![Expression::String( + LOOPBACK_ONLY_CIDR.to_string(), + )]), + ), + ], + ), + attr("tags", tags(ctx, "sandbox")), + ], + ); + + let barrier_dependency = Expression::from(vec![expr::traversal([ + PROPAGATION_BARRIER_RESOURCE, + &format!("{label}_iam_propagation"), + ])]); + + let connector = resource_block( + NETWORK_CONNECTOR_RESOURCE, + label, + [ + attr("depends_on", barrier_dependency.clone()), + attr("name", resource_prefix_template(sandbox.id())), + attr( + "operator_role", + expr::traversal(["aws_iam_role", &egress_label, "arn"]), + ), + attr( + "configuration", + Expression::from_iter([( + "vpc_egress_configuration", + Expression::from_iter([ + ( + "associated_compute_resource_types", + Expression::from(vec![Expression::String( + "MicroVm".to_string(), + )]), + ), + // Documented optional in both the CloudControl schema and the + // CloudFormation reference, and rejected when absent: + // "NetworkProtocol cannot be null or empty for VPC_EGRESS connector". + // IPv4 rather than DualStack because the security group that carries + // the deny matches IPv4 CIDRs — a v6 path would be outside it. + ( + "network_protocol", + Expression::String("IPv4".to_string()), + ), + ("subnet_ids", subnet_ids), + ( + "security_group_ids", + Expression::from(vec![expr::traversal([ + "aws_security_group", + &egress_label, + "id", + ])]), + ), + ]), + )]), + ), + attr("tags", tag_objects(ctx, true)), + ], + ); + + // Cloud Control takes the whole body as one JSON document, so the property names here + // are the schema's own rather than the provider's snake_case rewriting of them. + let desired_state = Expression::from_iter([ + ("Name", resource_prefix_template(sandbox.id())), + ( + "Description", + Expression::String(format!("Sandbox {}", sandbox.id())), + ), + ("BaseImageArn", base_image_arn()), + ("BaseImageVersion", Expression::String("1".to_string())), + ( + "BuildRoleArn", + expr::traversal(["aws_iam_role", label, "arn"]), + ), + ( + "CodeArtifact", + Expression::from_iter([("Uri", Expression::String(artifact_uri.clone()))]), + ), + // Content-bearing logging off: the control plane must never see session contents, + // and this is the switch rather than an approximation of it. + ( + "Logging", + Expression::from_iter([("Disabled", Expression::Bool(true))]), + ), + // The build's route out, not the session's. See `internet_egress_connector_arn`. + ( + "EgressNetworkConnectors", + Expression::from(vec![internet_egress_connector_arn()]), + ), + ( + "CpuConfigurations", + Expression::from(vec![Expression::from_iter([( + "Architecture", + Expression::String(ARCHITECTURE.to_string()), + )])]), + ), + ( + "Resources", + Expression::from(vec![Expression::from_iter([( + "MinimumMemoryInMiB", + Expression::Number(tier.baseline_memory_mib.into()), + )])]), + ), + // The schema's enum is exactly ["ALL"], which grants mount, netns and eBPF. There is + // no subset to ask for, so the answer is none — and the key has to be present. + ( + "AdditionalOsCapabilities", + Expression::from(Vec::::new()), + ), + ("Hooks", hooks()), + ("EnvironmentVariables", environment_variables()), + ("Tags", tag_objects(ctx, false)), + ]); + + let image = resource_block( + MICROVM_IMAGE_RESOURCE, + label, + [ + attr("depends_on", barrier_dependency), + attr( + "type_name", + Expression::String(MICROVM_IMAGE_TYPE_NAME.to_string()), + ), + attr("desired_state", expr::jsonencode(desired_state)), + ], + ); + + Ok(TfFragment::empty() + .with_resource(build_role) + .with_resource(build_policy) + .with_resource(operator_role) + .with_resource(operator_policy) + .with_resource(iam_propagation) + .with_resource(security_group) + .with_resource(connector) + .with_resource(image)) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + let label = required_label(ctx)?; + Ok(expr::object([ + ("previewPorts", preview_ports(sandbox)), + ( + "egressConnectorArns", + Expression::from(vec![expr::traversal([ + NETWORK_CONNECTOR_RESOURCE, + label, + "arn", + ])]), + ), + // The ARN, not the name. Measured against the live API: `GetMicrovmImage` and + // `RunMicrovm` both refuse a bare name — the latter with "Malformed ARN - doesn't + // start with 'arn:'" — so a controller handed a name cannot read its own image or + // start a session. + ( + "imageIdentifier", + image_property(label, "ImageArn"), + ), + ( + "imageArn", + image_property(label, "ImageArn"), + ), + // The version the controller scopes sessions to. `RunMicrovm` has no tags, so a + // stale version here enumerates the wrong set and orphans live sessions. + ( + "imageVersion", + image_property(label, "LatestActiveImageVersion"), + ), + ])) + } + + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + let label = required_label(ctx)?; + let mut fields = vec![ + ("service", Expression::String("sandbox-aws".to_string())), + ("previewPorts", preview_ports(sandbox)), + ( + "egressConnectorArns", + Expression::from(vec![expr::traversal([ + NETWORK_CONNECTOR_RESOURCE, + label, + "arn", + ])]), + ), + ( + "imageArn", + image_property(label, "ImageArn"), + ), + ( + "imageVersion", + image_property(label, "LatestActiveImageVersion"), + ), + ("region", expr::traversal(["data", "aws_region", "current", "name"])), + ]; + if let Some(seconds) = sandbox.session.idle_suspend_seconds { + fields.push(( + "idleSuspendSeconds", + Expression::Number(i64::from(seconds).into()), + )); + } + if let Some(seconds) = sandbox.session.max_lifetime_seconds { + fields.push(( + "maxLifetimeSeconds", + Expression::Number(i64::from(seconds).into()), + )); + } + Ok(Some(expr::object(fields))) + } +} + +/// The ports a preview capability may be minted for. +/// +/// Carried into both the import data and the binding because minting is where ingress is granted: +/// `CreateMicrovmAuthToken` will scope a token to any port it is asked for, so the declared list +/// has to reach the code that mints or the declaration bounds nothing. +/// One attribute of the created image. +/// +/// Cloud Control returns the whole resource as a JSON string in `properties`, so an attribute is +/// read out of it rather than off the resource — there are no typed attributes to reach for. +fn image_property(label: &str, property: &str) -> Expression { + expr::raw(format!( + "jsondecode({MICROVM_IMAGE_RESOURCE}.{label}.properties)[\"{property}\"]" + )) +} + +fn preview_ports(sandbox: &Sandbox) -> Expression { + Expression::from( + sandbox + .preview_ports + .iter() + .map(|port| Expression::Number(i64::from(*port).into())) + .collect::>(), + ) +} + +/// The four tags every emitter writes, as `{key, value}` objects. +/// +/// `snake` picks the spelling: the connector goes through `awscc`, which rewrites the schema's +/// names, while the image body is Cloud Control and carries them as the schema declares them. +fn tag_objects(ctx: &EmitContext<'_>, snake: bool) -> Expression { + // The same keys every other emitter writes. Ownership and the setup-vs-runtime split key off + // `managed-by`, so a private spelling here would leave the image invisible to both — and make + // the two package formats disagree about the resource they each create. + let pairs: Vec<(&str, Expression)> = vec![ + ( + ALIEN_MANAGED_BY_TAG_KEY, + Expression::String("setup".to_string()), + ), + ( + ALIEN_STACK_TAG_KEY, + expr::raw("local.resource_prefix"), + ), + ( + ALIEN_RESOURCE_TAG_KEY, + Expression::String(ctx.resource_id.to_string()), + ), + ("resource-type", Expression::String("sandbox".to_string())), + ]; + + Expression::from( + pairs + .into_iter() + .map(|(key, value)| { + Expression::from_iter([ + (if snake { "key" } else { "Key" }, Expression::String(key.to_string())), + (if snake { "value" } else { "Value" }, value), + ]) + }) + .collect::>(), + ) +} + +/// The AWS-managed base image, which is region-scoped. +fn base_image_arn() -> Expression { + Expression::from(hcl::TemplateExpr::QuotedString( + "arn:aws:lambda:${data.aws_region.current.region}:aws:microvm-image:al2023-1".to_string(), + )) +} + +/// The one object the build reads, as an ARN. +/// +/// The bundle URI is known when the module is emitted, so the build role is scoped to it rather +/// than to every object in the account. `s3://bucket/key` maps to `arn:aws:s3:::bucket/key`; a +/// URI without a key would be a bucket ARN, which `artifact_uri` has already refused. +fn artifact_object_arn(uri: &str) -> String { + format!("arn:aws:s3:::{}", uri.trim_start_matches("s3://")) +} + +/// What Lambda may do while managing the connector's network interfaces. +/// +/// Reproduces the role AWS documents as the prerequisite for creating a network connector, and +/// the contents of its `AWSLambdaNetworkConnectorOperatorPolicy`. Written out rather than +/// attached so the grant is visible in the module the customer reads and does not change under +/// them when AWS revises the managed policy. +fn operator_statements() -> Vec { + vec![ + Expression::from_iter([ + ("Sid", Expression::String("CreateENI".to_string())), + ("Effect", Expression::String("Allow".to_string())), + ( + "Action", + Expression::String("ec2:CreateNetworkInterface".to_string()), + ), + ( + "Resource", + Expression::from( + [ + "arn:aws:ec2:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:network-interface/*", + "arn:aws:ec2:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:subnet/*", + "arn:aws:ec2:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:security-group/*", + ] + .map(|arn| Expression::String(arn.to_string())) + .to_vec(), + ), + ), + ]), + Expression::from_iter([ + ("Sid", Expression::String("TagENI".to_string())), + ("Effect", Expression::String("Allow".to_string())), + ("Action", Expression::String("ec2:CreateTags".to_string())), + ( + "Resource", + Expression::String( + "arn:aws:ec2:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:network-interface/*" + .to_string(), + ), + ), + ( + "Condition", + Expression::from_iter([( + "StringEquals", + Expression::from_iter([( + "ec2:ManagedResourceOperator", + Expression::String("network-connectors.lambda.amazonaws.com".to_string()), + )]), + )]), + ), + ]), + ] +} + +/// The private subnets the connector places its ENIs in. +/// +/// A connector must name between one and sixteen subnets, and only a created or bring-your-own +/// VPC yields any. Refusing the other network modes here is what keeps the deny path honest: the +/// alternative is a connector expression that resolves to an empty list, and a session with no +/// connector reaches the public internet. +fn egress_subnet_ids(ctx: &EmitContext<'_>, sandbox: &Sandbox) -> Result { + let refuse = |reason: String| { + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason, + })) + }; + + let Some((_label, network)) = default_network(ctx) else { + return refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, and this \ + stack declares no network for it to attach to" + .to_string(), + ); + }; + + match &network.settings { + NetworkSettings::Create { .. } | NetworkSettings::ByoVpcAws { .. } => { + Ok(private_subnet_ids_expr(ctx)) + } + NetworkSettings::UseDefault => refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, which needs \ + private subnets; the account's default VPC has only public ones. Set the network \ + to create or byo-vpc-aws" + .to_string(), + ), + _ => refuse( + "an AWS sandbox routes session traffic through a VPC egress connector, and this \ + stack's network settings are for another cloud" + .to_string(), + ), + } +} + +/// Refuses an egress mode the emitted artifact cannot deliver. +/// +/// `deny` is built from a connector whose security group carries no egress rule. Outbound +/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no +/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// Emitting a template that silently ignores a declared egress policy is worse than refusing it — +/// the customer would believe outbound access was configured. +fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { + let refuse = |mode: &str| { + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason: format!( + "AWS sandboxes reach the network through a VPC egress connector, which this \ + module builds to deny outbound traffic; egress '{mode}' has no connector \ + configuration to render into. Declare egress: deny, or use a platform that \ + supports it" + ), + })) + }; + + match &sandbox.egress { + SandboxEgress::Deny => Ok(()), + SandboxEgress::Allow => refuse("allow"), + SandboxEgress::AllowDomains { .. } => refuse("allowDomains"), + } +} + +/// Resolves the S3 bundle the MicroVM image is built from. +/// +/// A MicroVM image is built from a zip containing a Dockerfile, not from a container image +/// reference, and a Terraform module has nowhere to build one — the same reason the Worker +/// emitter refuses source. Requiring an `s3://` URI fails at plan time with something a reader +/// can act on, rather than at the end of a ~160s image build. +fn artifact_uri(sandbox: &Sandbox) -> Result { + let unsupported = |reason: String| { + AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason, + }) + }; + + match &sandbox.code { + // A bucket with no key would scope the build role to the whole bucket rather than the + // one object, so it is refused here rather than silently widening the grant. + SandboxCode::Image { image } + if image.starts_with("s3://") && image.trim_start_matches("s3://").contains('/') => + { + Ok(image.clone()) + } + SandboxCode::Image { image } if image.starts_with("s3://") => Err(unsupported(format!( + "code.image '{image}' names a bucket with no object key; give the full path to the \ + bundle, for example s3://bucket/sandbox.zip" + ))), + SandboxCode::Image { image } => Err(unsupported(format!( + "AWS builds a MicroVM image from an S3 bundle containing a Dockerfile, so code.image \ + must be an s3:// URI, not the container reference '{image}'" + ))), + SandboxCode::Source { .. } => Err(unsupported( + "AWS builds a MicroVM image from a prepared S3 bundle; Terraform modules cannot build \ + one from source" + .to_string(), + )), + } +} + +/// Lifecycle hooks, served by the agent on its own port. +/// +/// `Run` and `Resume` are enabled because every MicroVM from one image shares the state resident +/// at capture — including the CSPRNG seed — so the reseed has to happen after each start. +/// +/// `Ready` is not optional: AWS rejects an image that enables any MicroVM hook without it, and +/// it is what defers the snapshot until the agent is actually serving. +fn hooks() -> Expression { + Expression::from_iter([ + ("port", Expression::Number(AGENT_PORT.into())), + ( + "microvm_image_hooks", + Expression::from_iter([ + ("ready", Expression::String("ENABLED".to_string())), + ("ready_timeout_in_seconds", Expression::Number(120.into())), + ]), + ), + ( + "microvm_hooks", + Expression::from_iter([ + ("run", Expression::String("ENABLED".to_string())), + ("run_timeout_in_seconds", Expression::Number(30.into())), + ("resume", Expression::String("ENABLED".to_string())), + ("resume_timeout_in_seconds", Expression::Number(30.into())), + ]), + ), + ]) +} + +/// The agent's configuration contract. +/// +/// `ALIEN_SANDBOX_AUTHORIZATION` is `transport` on AWS: the proxy validates a token scoped to one +/// MicroVM before a request arrives, and one MicroVM is one session. +fn environment_variables() -> Expression { + let pairs = [ + ("ALIEN_SANDBOX_ROOT", "/sandbox".to_string()), + ("ALIEN_SANDBOX_PORT", AGENT_PORT.to_string()), + ("ALIEN_SANDBOX_AUTHORIZATION", "transport".to_string()), + ("ALIEN_SANDBOX_EXEC_UID", EXEC_UID.to_string()), + ("ALIEN_SANDBOX_EXEC_GID", EXEC_UID.to_string()), + ]; + + Expression::from( + pairs + .into_iter() + .map(|(key, value)| { + Expression::from_iter([ + ("key", Expression::String(key.to_string())), + ("value", Expression::String(value)), + ]) + }) + .collect::>(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The schema's architecture enum has exactly one member, and the agent binary in the image + /// has to match it. A change here without a matching build target is a ~160s image build + /// that fails at the end. + #[test] + fn the_architecture_is_the_only_one_microvm_images_accept() { + assert_eq!(ARCHITECTURE, "ARM_64"); + } +} diff --git a/crates/alien-terraform/src/generator.rs b/crates/alien-terraform/src/generator.rs index 14d0bfb65..4b53f6079 100644 --- a/crates/alien-terraform/src/generator.rs +++ b/crates/alien-terraform/src/generator.rs @@ -26,7 +26,8 @@ use alien_core::{ import::{EmitContext, CURRENT_SETUP_IMPORT_FORMAT_VERSION}, ownership_policy_for_resource_type, DeploymentModel, ErrorData, HeartbeatsMode, KubernetesCertificateMode, KubernetesExposureSettings, KubernetesSettings, Network, - NetworkSettings, RemoteBindings, RemoteStackManagement, ResourceLifecycle, Result, Stack, + NetworkSettings, RemoteBindings, RemoteStackManagement, ResourceLifecycle, Result, Sandbox, + Stack, StackInputDefaultValue, StackInputDefinition, StackInputKind, StackInputProvider, StackInputValidation, StackSettings, TelemetryMode, UpdatesMode, }; @@ -207,6 +208,15 @@ pub fn generate_terraform_module( continue; } + // A sandbox on a Kubernetes target is a pod bounded by the chart's NetworkPolicy, not + // cloud infrastructure. Emitter lookup keys off the cloud the cluster runs in, so + // without this an EKS install would provision the MicroVM image, connector, security + // group and roles of the AWS backend it never uses — and refuse `egress: allow`, which + // Kubernetes supports, with advice about a platform the customer did not choose. + if target.is_kubernetes() && resource_type.as_ref() == Sandbox::RESOURCE_TYPE.as_ref() { + continue; + } + let emitter = options.registry.require(&resource_type, platform)?; let ctx = EmitContext { stack, @@ -336,6 +346,21 @@ pub fn generate_terraform_module( target.is_kubernetes() && options.registration.is_some() && options.helm_install.is_some(); let include_azapi_provider = has_resource_type(&per_resource, "azapi_update_resource") || has_resource_type(&per_resource, "azapi_resource_action"); + // Cloud Control, for AWS APIs the main provider has not caught up with. Keyed off what was + // actually emitted, so a stack without one of those resources is unchanged. + // Keyed off the connector, not the image: the image is a Cloud Control resource from the + // `aws` provider, and only the connector still goes through `awscc`. + let include_awscc_provider = has_resource_type( + &per_resource, + crate::emitters::aws::sandbox::NETWORK_CONNECTOR_RESOURCE, + ); + // Any barrier at all needs the provider declared, not only GCP's: the AWS sandbox emits one + // of its own, and a missing declaration fails at init rather than at plan. + let include_time_provider = gcp_iam_propagation_barrier.is_some() + || has_resource_type( + &per_resource, + crate::emitters::aws::sandbox::PROPAGATION_BARRIER_RESOURCE, + ); let setup_only = stack .resources .values() @@ -363,10 +388,11 @@ pub fn generate_terraform_module( render_body(versions_body( target, options.registration.as_ref(), - gcp_iam_propagation_barrier.is_some(), + include_time_provider, include_kubernetes_provider, include_helm_provider, include_azapi_provider, + include_awscc_provider, ))?, ); files.insert( @@ -391,6 +417,7 @@ pub fn generate_terraform_module( include_kubernetes_provider, include_helm_provider, include_azapi_provider, + include_awscc_provider, ))?, ); files.insert( @@ -1043,6 +1070,7 @@ fn versions_body( include_kubernetes_provider: bool, include_helm_provider: bool, include_azapi_provider: bool, + include_awscc_provider: bool, ) -> Body { let required_version = if matches!(target, TerraformTarget::Eks) { ">= 1.9.0" @@ -1057,6 +1085,9 @@ fn versions_body( let mut provider_attrs: Vec = Vec::new(); if matches!(target.cloud_platform(), alien_core::Platform::Aws) { provider_attrs.push(attr("aws", provider_decl_attr("hashicorp/aws", ">= 5.0"))); + if include_awscc_provider { + provider_attrs.push(attr("awscc", provider_decl_attr("hashicorp/awscc", ">= 1.0"))); + } if matches!(target, TerraformTarget::Eks) { provider_attrs.push(attr("tls", provider_decl_attr("hashicorp/tls", ">= 4.0"))); } @@ -2121,6 +2152,7 @@ fn providers_body( include_kubernetes_provider: bool, include_helm_provider: bool, include_azapi_provider: bool, + include_awscc_provider: bool, ) -> Body { let mut structures: Vec = Vec::new(); match target.cloud_platform() { @@ -2146,6 +2178,17 @@ fn providers_body( ], body: Body::default(), })); + // awscc has no ambient default: declaring it in required_providers without a + // provider block makes `terraform plan` refuse the module outright. It also has to + // name the same region as the `aws` provider, or the image is built somewhere the + // binding will not look for it. + if include_awscc_provider { + structures.push(Structure::Block(Block { + identifier: Identifier::sanitized("provider"), + labels: vec![BlockLabel::String("awscc".to_string())], + body: Body::from(vec![attr("region", expr::raw("var.aws_region"))]), + })); + } } alien_core::Platform::Gcp => { structures.push(Structure::Block(Block { @@ -3253,6 +3296,7 @@ mod tests { false, false, false, + false, )) .expect("versions render"); assert!(versions.contains("example_app =")); diff --git a/crates/alien-terraform/tests/generator/aws_identity_tests.rs b/crates/alien-terraform/tests/generator/aws_identity_tests.rs index 560f8c1f8..f9e0cf268 100644 --- a/crates/alien-terraform/tests/generator/aws_identity_tests.rs +++ b/crates/alien-terraform/tests/generator/aws_identity_tests.rs @@ -1,10 +1,12 @@ //! AWS identity & network — service-account / management / //! network (Create + ByoVpcAws + UseDefault). -use super::helpers::{assert_terraform_valid, gate_input, render, snapshot_module}; +use super::helpers::{assert_terraform_valid, gate_input, render, snapshot_module, try_render}; use alien_core::{ ManagementPermissions, Network, NetworkSettings, PermissionProfile, RemoteStackManagement, - ResourceLifecycle, ServiceAccount, Stack, StackSettings, Worker, WorkerCode, + ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, SandboxSessionPolicy, ServiceAccount, + Stack, StackSettings, + Worker, WorkerCode, }; use alien_terraform::TerraformTarget; @@ -257,3 +259,278 @@ fn aws_network_byo_vpc_emits_no_resources() { snapshot_module("aws_network_byo_vpc", &module); assert_terraform_valid(&module, "aws_network_byo_vpc"); } + +/// The sandbox build role's policy must render as an IAM document, not as strings. +/// +/// `iam_role_policy_block` jsonencodes the whole document, so a statement that is itself +/// jsonencoded lands in `Statement` as a JSON string. `terraform validate` accepts that — the HCL +/// and the string are both well-formed — and IAM rejects it at apply with MalformedPolicyDocument. +/// Rendering and reading the statements back is the only place that shows up. +#[test] +fn aws_sandbox_build_policy_is_a_well_formed_scoped_document() { + let (stack, settings) = sandbox_stack("acme-sandbox", SandboxEgress::Deny); + let module = render(&stack, TerraformTarget::Aws, settings); + + let rendered: String = module.iter().map(|(_, contents)| contents).collect(); + let policy = rendered + .lines() + .find(|line| line.contains("Statement") && line.contains("s3:GetObject")) + .unwrap_or_else(|| panic!("the build policy must render:\n{rendered}")); + + assert!( + !policy.contains("Statement = [jsonencode"), + "statements must be objects, not encoded strings: {policy}" + ); + assert!( + policy.contains("arn:aws:s3:::acme-artifacts/agents/bundle.zip"), + "the build role reads one object and must be scoped to it: {policy}" + ); + assert!( + !policy.contains(r#""s3:GetObject"], "Resource" = "*""#) + && !policy.contains(r#"s3:GetObject"] Resource = "*""#), + "account-wide object read must not be emitted: {policy}" + ); +} + +/// A module that declares `awscc` must configure it, or the customer cannot plan. +/// +/// awscc has no ambient default, so a `required_providers` entry with no `provider` block makes +/// `terraform plan` refuse the module — and `terraform validate` passes, because it does not +/// evaluate provider configuration. The region must match the `aws` provider's, or the image is +/// built somewhere the binding will not look for it. +#[test] +fn aws_sandbox_module_configures_the_awscc_provider() { + let (stack, settings) = sandbox_stack("acme-sandbox-provider", SandboxEgress::Deny); + let module = render(&stack, TerraformTarget::Aws, settings); + let rendered: String = module.iter().map(|(_, contents)| contents).collect(); + + assert!( + rendered.contains("provider \"awscc\""), + "awscc is declared, so it must be configured:\n{rendered}" + ); + let block = rendered + .split("provider \"awscc\"") + .nth(1) + .expect("the block just asserted"); + assert!( + block + .lines() + .take(4) + .any(|line| line.contains("region") && line.contains("var.aws_region")), + "awscc must take the same region as the aws provider: {block}" + ); +} + +/// An egress mode the artifact cannot deliver is refused, not dropped. +/// +/// The connector this module builds denies outbound traffic. Nothing renders an allowance — +/// `allow` depends on the network's NAT topology and AWS has no domain filter at the connector — +/// so a declared `allow` would be silently ignored, and the customer would believe outbound +/// access was configured when it is not. +#[test] +fn aws_sandbox_refuses_an_egress_mode_it_cannot_deliver() { + for mode in [ + SandboxEgress::Allow, + SandboxEgress::AllowDomains { + domains: vec!["example.com".to_string()], + }, + ] { + let (stack, settings) = sandbox_stack("acme-sandbox-egress", mode.clone()); + let error = try_render(&stack, TerraformTarget::Aws, settings) + .expect_err(&format!("egress {mode:?} must be refused at emit time")); + assert!( + error.to_string().contains("VPC egress connector"), + "the refusal must name why: {error}" + ); + } +} + +fn sandbox_fixture(egress: SandboxEgress) -> Sandbox { + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "s3://acme-artifacts/agents/bundle.zip".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() +} + +/// A sandbox and the network its egress connector attaches to, which the emitter requires. +fn sandbox_stack(name: &str, egress: SandboxEgress) -> (Stack, StackSettings) { + let settings = StackSettings { + network: Some(NetworkSettings::Create { + cidr: None, + availability_zones: 2, + }), + ..StackSettings::default() + }; + let stack = Stack::new(name.to_string()) + .add( + Network::new("default-network".to_string()) + .settings(settings.network.clone().expect("network")) + .build(), + ResourceLifecycle::Frozen, + ) + .add(sandbox_fixture(egress), ResourceLifecycle::Frozen) + .build(); + (stack, settings) +} + +/// `egress: deny` has to be built, not assumed. +/// +/// A MicroVM started with no egress connector reaches the public internet — verified against a +/// live account. The connector is what puts session traffic inside the VPC, and the security +/// group is what stops it there: EC2 attaches an allow-all egress rule to any group whose +/// template states none, so the only rule present must be the one that reaches nothing. +/// `terraform validate` cannot see any of this. +/// Emitter lookup keys off the cloud a cluster runs in, so an EKS target resolves the AWS +/// sandbox emitter. A Kubernetes sandbox is a pod the chart bounds with a NetworkPolicy, and it +/// creates no cloud resource — provisioning a MicroVM image and its connector for one would be a +/// second backend nobody uses, billed and permissioned. +#[test] +fn a_kubernetes_target_emits_no_microvm_infrastructure_for_a_sandbox() { + let (stack, settings) = sandbox_stack("acme-sandbox-eks", SandboxEgress::Deny); + let module = render(&stack, TerraformTarget::Eks, settings); + let rendered: String = module.iter().map(|(_, contents)| contents).collect(); + + for absent in [ + "aws_cloudcontrolapi_resource", + "awscc_lambda_network_connector", + "AWS::Lambda::MicrovmImage", + ] { + assert!( + !rendered.contains(absent), + "an EKS module must not carry the AWS sandbox backend, found {absent}:\n{rendered}" + ); + } +} + +#[test] +fn aws_sandbox_deny_builds_a_connector_that_permits_nothing_outbound() { + let (stack, settings) = sandbox_stack("acme-sandbox-deny", SandboxEgress::Deny); + let module = render(&stack, TerraformTarget::Aws, settings); + let rendered: String = module.iter().map(|(_, contents)| contents).collect(); + + let security_group = rendered + .split("resource \"aws_security_group\" \"agents_egress\"") + .nth(1) + .unwrap_or_else(|| panic!("the sandbox egress security group must render:\n{rendered}")) + .split("\nresource \"") + .next() + .expect("the block runs to the next resource"); + assert_eq!( + security_group.matches("egress {").count(), + 1, + "exactly one egress rule, or the default allow-all survives:\n{security_group}" + ); + assert!( + security_group.contains("\"127.0.0.1/32\""), + "the only permitted destination must be the one that reaches nothing:\n{security_group}" + ); + assert!( + !security_group.contains("0.0.0.0/0"), + "a wide egress rule turns deny back into outbound access:\n{security_group}" + ); + + let connector = rendered + .split("resource \"awscc_lambda_network_connector\" \"agents\"") + .nth(1) + .unwrap_or_else(|| panic!("the egress connector must render:\n{rendered}")) + .split("\nresource \"") + .next() + .expect("the block runs to the next resource"); + assert!( + connector.contains("aws_security_group.agents_egress.id"), + "the connector must carry the group that denies:\n{connector}" + ); + assert!( + connector.contains("aws_subnet.default_network_private"), + "the connector must place its ENIs in the network's private subnets:\n{connector}" + ); + assert!( + connector.contains("\"MicroVm\""), + "the connector must be usable by MicroVMs:\n{connector}" + ); + + // Scoped to the image block rather than the whole module: the binding also names the + // connector, so a module-wide search passes even when the image has lost its own entry. + let image = rendered + .split("resource \"aws_cloudcontrolapi_resource\"") + .nth(1) + .expect("the image block must render"); + let image = image.split("\nresource ").next().expect("block ends"); + // The switch that keeps session output out of the control plane's reach. + assert!( + image.contains("\"Disabled\" = true") || image.contains("Disabled = true"), + "content-bearing logging must be off:\n{image}" + ); + + // Cloud Control rather than awscc: the schema requires AdditionalOsCapabilities and the + // sandbox asks for none, and awscc drops an empty list before sending it. + assert!( + image.contains("AWS::Lambda::MicrovmImage") && image.contains("AdditionalOsCapabilities"), + "the image must go through Cloud Control with the required empty list intact:\n{image}" + ); + assert!( + image.contains("INTERNET_EGRESS"), + "the image must build through AWS's own connector:\n{image}" + ); + assert!( + !image.contains("awscc_lambda_network_connector.agents.arn"), + "the deny connector belongs to the session, not the build:\n{image}" + ); + // The roles are referenced by ARN, which resolves before the inline policy is attached and + // before IAM has propagated it. Without a barrier the first apply fails and a retry works. + let barrier = rendered + .split("resource \"time_sleep\"") + .nth(1) + .expect("the sandbox must emit an IAM propagation barrier"); + let barrier = barrier.split("\nresource ").next().expect("block ends"); + assert!( + barrier.contains("aws_iam_role_policy.agents") + && barrier.contains("aws_iam_role_policy.agents_egress"), + "the barrier must wait for both inline policies:\n{barrier}" + ); + assert!( + barrier.contains("unique_id"), + "the barrier must re-run when a role is replaced, and a templated name keeps its ARN:\n{barrier}" + ); + + let connector_block = rendered + .split("resource \"awscc_lambda_network_connector\"") + .nth(1) + .expect("connector renders"); + let connector_block = connector_block.split("\nresource ").next().expect("ends"); + assert!( + connector_block.contains("time_sleep.agents_iam_propagation"), + "the connector must wait for the barrier:\n{connector_block}" + ); + + assert!( + rendered.contains("awscc_lambda_network_connector.agents.arn"), + "the binding must still carry the connector a session runs through:\n{rendered}" + ); +} + +/// Without a VPC there are no subnets, and a connector needs between one and sixteen. +/// +/// Rendering one anyway would produce either an apply-time failure the reader cannot act on or — +/// worse — a session with no connector, which is the mode that silently reaches the internet. +#[test] +fn aws_sandbox_refuses_to_render_without_a_network_to_attach_to() { + let stack = Stack::new("acme-sandbox-no-network".to_string()) + .add( + sandbox_fixture(SandboxEgress::Deny), + ResourceLifecycle::Frozen, + ) + .build(); + let error = try_render(&stack, TerraformTarget::Aws, StackSettings::default()) + .expect_err("a sandbox with no network must be refused at emit time"); + assert!( + error.to_string().contains("declares no network"), + "the refusal must name why: {error}" + ); +} diff --git a/crates/alien-terraform/tests/generator/gating_matrix_tests.rs b/crates/alien-terraform/tests/generator/gating_matrix_tests.rs index ba9c626cc..4539f0c7d 100644 --- a/crates/alien-terraform/tests/generator/gating_matrix_tests.rs +++ b/crates/alien-terraform/tests/generator/gating_matrix_tests.rs @@ -9,15 +9,18 @@ use super::helpers::{assert_terraform_valid, gate_input, render, snapshot_module}; use alien_core::{ ownership_policy_for_resource_type, Ai, AzureResourceGroup, AzureServiceBusNamespace, - AzureStorageAccount, Kv, PermissionProfile, Platform, Queue, ResourceLifecycle, ServiceAccount, - Stack, StackBuilder, StackSettings, Storage, Vault, Worker, WorkerCode, + AzureStorageAccount, Kv, Network, NetworkSettings, PermissionProfile, Platform, Queue, + ResourceLifecycle, ServiceAccount, + Sandbox, SandboxCode, SandboxEgress, SandboxLimits, SandboxSessionPolicy, Stack, StackBuilder, + StackSettings, Storage, Vault, Worker, WorkerCode, }; use alien_terraform::{TerraformTarget, TfRegistry}; /// One gated fixture stack per policy-allowed resource type with a setup -/// emitter. The resource under test is gated; auxiliary resources the type -/// needs (Azure's resource group and storage account) are not. -fn gated_fixture(resource_type: &str, platform: Platform) -> Option { +/// emitter, and the settings it renders under. The resource under test is +/// gated; auxiliary resources the type needs (Azure's resource group and +/// storage account, a sandbox's network) are not. +fn gated_fixture(resource_type: &str, platform: Platform) -> Option<(Stack, StackSettings)> { let base = || -> StackBuilder { let builder = Stack::new("matrix-stack".to_string()).inputs(vec![gate_input( "fixtureEnabled", @@ -75,9 +78,52 @@ fn gated_fixture(resource_type: &str, platform: Platform) -> Option { ResourceLifecycle::Frozen, "fixtureEnabled", ), + // An AWS sandbox's egress connector needs private subnets, so the + // fixture declares the network the emitter refuses to render without. + "sandbox" => base() + .add( + Network::new("default-network".to_string()) + .settings(sandbox_network()) + .build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Sandbox::new("fixture".to_string()) + .code(SandboxCode::Image { + image: "s3://matrix-artifacts/sandbox.zip".to_string(), + }) + .limits(SandboxLimits { + cpu: "1".to_string(), + memory: "2Gi".to_string(), + disk: "10Gi".to_string(), + max_processes: None, + }) + .egress(SandboxEgress::Deny) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + "fixtureEnabled", + ), _ => return None, }; - Some(stack.build()) + let settings = StackSettings { + network: (resource_type == "sandbox").then(sandbox_network), + ..StackSettings::default() + }; + Some((stack.build(), settings)) +} + +/// The network the sandbox fixture attaches its egress connector to. The +/// `Network` resource and the stack settings must agree — the emitters read +/// the resource, the variable block reads the settings. +fn sandbox_network() -> NetworkSettings { + NetworkSettings::Create { + cidr: None, + availability_zones: 2, + } } fn target_for(platform: Platform) -> Option { @@ -92,11 +138,16 @@ fn target_for(platform: Platform) -> Option { /// A declined fixture must leave no trace: its registration entry is spliced /// out and every one of its blocks carries the gate's count. Shared support /// blocks (custom roles, role definitions) may remain, unbound. -fn assert_gated_render(resource_type: &str, platform: Platform, stack: &Stack) { +fn assert_gated_render( + resource_type: &str, + platform: Platform, + stack: &Stack, + settings: StackSettings, +) { let Some(target) = target_for(platform) else { return; }; - let module = render(stack, target, StackSettings::default()); + let module = render(stack, target, settings); assert_terraform_valid( &module, &format!("gating matrix {resource_type} on {platform:?}"), @@ -133,8 +184,10 @@ fn assert_gated_render(resource_type: &str, platform: Platform, stack: &Stack) { // Every block that reads the gated resource's indexed address // must itself be gated, or the declined apply resolves an index - // into an empty list. - if block.contains("[0]") && !counted && !is_shared_support_block(header) { + // into an empty list. Every fixture names its gated resource + // `fixture`, so its address is what `[0]` has to be qualified by — + // an ungated auxiliary resource may carry a count of its own. + if block.contains(".fixture[0]") && !counted && !is_shared_support_block(header) { panic!( "{resource_type}/{platform:?}: `resource \"{header}` reads a gated \ address but carries no gate:\n{block}" @@ -148,7 +201,7 @@ fn assert_gated_render(resource_type: &str, platform: Platform, stack: &Stack) { // resource, though, something must be counted. let reaches_into_the_resource = module .iter() - .any(|(name, contents)| name.ends_with(".tf") && contents.contains("[0]")); + .any(|(name, contents)| name.ends_with(".tf") && contents.contains(".fixture[0]")); assert!( gated_blocks > 0 || !reaches_into_the_resource, "{resource_type}/{platform:?}: the render indexes the gated resource but no block \ @@ -182,7 +235,9 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { continue; } match gated_fixture(resource_type, platform) { - Some(stack) => assert_gated_render(resource_type, platform, &stack), + Some((stack, settings)) => { + assert_gated_render(resource_type, platform, &stack, settings) + } None => allowed_without_fixture.push(format!("{resource_type} ({platform:?})")), } } @@ -254,9 +309,9 @@ fn a_gated_vault_renders_conditionally_on_every_cloud() { (Platform::Gcp, "enabled_gated_vault_gcp"), (Platform::Azure, "enabled_gated_vault_azure"), ] { - let stack = gated_fixture("vault", platform).expect("vault fixture"); + let (stack, settings) = gated_fixture("vault", platform).expect("vault fixture"); let target = target_for(platform).expect("cloud target"); - let module = render(&stack, target, StackSettings::default()); + let module = render(&stack, target, settings); assert_terraform_valid(&module, name); snapshot_module(name, &module); }