diff --git a/crates/alien-ai-gateway/src/config.rs b/crates/alien-ai-gateway/src/config.rs index 79baa0459..d10352c81 100644 --- a/crates/alien-ai-gateway/src/config.rs +++ b/crates/alien-ai-gateway/src/config.rs @@ -108,7 +108,7 @@ pub fn bindings_from_env_map(env: &HashMap) -> Result_BINDING` namespace. -const AI_SERVICE_TAGS: [&str; 4] = ["bedrock", "vertex", "foundry", "external"]; +const AI_SERVICE_TAGS: [&str; 4] = ["bedrock", "vertex", "foundry", "external-ai"]; #[derive(serde::Deserialize)] struct ServiceTag { @@ -268,6 +268,22 @@ mod tests { assert!(gateway_binding("llm", AiBinding::external("openai", "sk-x")).is_none()); } + /// An external Postgres binding shares the `ALIEN_*_BINDING` namespace but + /// tags itself `external` — the gateway must leave it to its own resource + /// type instead of failing to parse it as an AI binding. + #[test] + fn ignores_another_resource_types_external_binding() { + temp_env::with_var( + "ALIEN_DB_BINDING", + Some(r#"{"service":"external","host":"db.example.com","port":5432}"#), + || { + let bindings = bindings_from_env() + .expect("a non-AI external binding must be skipped, not fatal"); + assert!(bindings.is_empty()); + }, + ); + } + #[test] fn parses_an_ai_binding_from_the_env_var() { temp_env::with_var( diff --git a/crates/alien-ai-gateway/tests/launcher.rs b/crates/alien-ai-gateway/tests/launcher.rs index f71e1de13..646905989 100644 --- a/crates/alien-ai-gateway/tests/launcher.rs +++ b/crates/alien-ai-gateway/tests/launcher.rs @@ -84,7 +84,7 @@ fn external_binding_is_passthrough() { .args(["--", "/bin/sh", "-c", "printf '%s' \"gw=${ALIEN_AI_GATEWAY_URL:-unset}\""]) .env( "ALIEN_LLM_BINDING", - r#"{"service":"external","provider":"openai","apiKey":"sk-x"}"#, + r#"{"service":"external-ai","provider":"openai","apiKey":"sk-x"}"#, ) .output() .expect("launcher should run"); diff --git a/crates/alien-aws-clients/src/aws/apigateway.rs b/crates/alien-aws-clients/src/aws/apigateway.rs new file mode 100644 index 000000000..2cf054158 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/apigateway.rs @@ -0,0 +1,614 @@ +//! AWS API Gateway V1 (REST API) Client +//! +//! Minimal REST API control-plane operations for a streaming Lambda proxy with a +//! REGIONAL custom domain. REST V1 (unlike the V2 HTTP API) supports response +//! streaming via `responseTransferMode: STREAM`, which is why a streaming worker +//! that needs a custom domain is served here rather than through a Function URL. + +use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig}; +use crate::aws::credential_provider::AwsCredentialProvider; +use alien_client_core::{ErrorData, Result}; +use alien_error::{Context, ContextError, IntoAlienError}; +use async_trait::async_trait; +use bon::Builder; +use reqwest::{Client, Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[cfg(feature = "test-utils")] +use mockall::automock; + +#[derive(Debug, Deserialize)] +struct ApiGatewayErrorResponse { + pub message: Option, + pub code: Option, + #[serde(rename = "__type")] + pub type_field: Option, +} + +// --------------------------------------------------------------------------- +// API Gateway V1 (REST) Trait +// --------------------------------------------------------------------------- + +#[cfg_attr(feature = "test-utils", automock)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait ApiGatewayApi: Send + Sync + std::fmt::Debug { + async fn create_rest_api(&self, request: CreateRestApiRequest) -> Result; + async fn delete_rest_api(&self, rest_api_id: &str) -> Result<()>; + + async fn create_resource( + &self, + rest_api_id: &str, + parent_id: &str, + request: CreateResourceRequest, + ) -> Result; + async fn put_method( + &self, + rest_api_id: &str, + resource_id: &str, + http_method: &str, + request: PutMethodRequest, + ) -> Result<()>; + async fn put_integration( + &self, + rest_api_id: &str, + resource_id: &str, + http_method: &str, + request: PutIntegrationRequest, + ) -> Result<()>; + async fn create_deployment( + &self, + rest_api_id: &str, + request: CreateDeploymentRequest, + ) -> Result; + + async fn create_domain_name(&self, request: CreateDomainNameRequest) -> Result; + async fn delete_domain_name(&self, domain_name: &str) -> Result<()>; + + async fn create_base_path_mapping( + &self, + domain_name: &str, + request: CreateBasePathMappingRequest, + ) -> Result; + async fn delete_base_path_mapping(&self, domain_name: &str, base_path: &str) -> Result<()>; + /// Tags any REST API object by ARN. A stage is created as a side effect of + /// `CreateDeployment`, which takes no tags, so it must be tagged after the + /// fact to carry the deployment's boundary tags. + async fn tag_resource( + &self, + resource_arn: &str, + tags: std::collections::HashMap, + ) -> Result<()>; +} + +// --------------------------------------------------------------------------- +// API Gateway V1 Client +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct ApiGatewayClient { + client: Client, + credentials: AwsCredentialProvider, +} + +impl ApiGatewayClient { + pub fn new(client: Client, credentials: AwsCredentialProvider) -> Self { + Self { + client, + credentials, + } + } + + fn sign_config(&self) -> AwsSignConfig { + AwsSignConfig { + service_name: "apigateway".into(), + region: self.credentials.region().to_string(), + credentials: self.credentials.get_credentials(), + signing_region: None, + } + } + + fn host(&self) -> String { + format!("apigateway.{}.amazonaws.com", self.credentials.region()) + } + + fn get_base_url(&self) -> String { + if let Some(override_url) = self.credentials.get_service_endpoint_option("apigateway") { + override_url.to_string() + } else { + format!("https://{}", self.host()) + } + } + + async fn send_json( + &self, + method: Method, + path: &str, + body: Option, + operation: &str, + resource: &str, + ) -> Result { + self.credentials.ensure_fresh().await?; + let base_url = self.get_base_url(); + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + + let mut builder = self + .client + .request(method, &url) + .host(&self.host()) + .content_type_json(); + + if let Some(body) = body { + builder = builder.content_sha256(&body).body(body.clone()); + let result = + crate::aws::aws_request_utils::sign_send_json(builder, &self.sign_config()).await; + return Self::map_result(result, operation, resource, Some(&body)); + } + + builder = builder.content_sha256(""); + let result = + crate::aws::aws_request_utils::sign_send_json(builder, &self.sign_config()).await; + Self::map_result(result, operation, resource, None) + } + + async fn send_no_response( + &self, + method: Method, + path: &str, + body: Option, + operation: &str, + resource: &str, + ) -> Result<()> { + self.credentials.ensure_fresh().await?; + let base_url = self.get_base_url(); + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + + let mut builder = self + .client + .request(method, &url) + .host(&self.host()) + .content_type_json(); + + let result = if let Some(body) = body { + builder = builder.content_sha256(&body).body(body.clone()); + crate::aws::aws_request_utils::sign_send_no_response(builder, &self.sign_config()).await + } else { + builder = builder.content_sha256(""); + crate::aws::aws_request_utils::sign_send_no_response(builder, &self.sign_config()).await + }; + Self::map_result(result, operation, resource, None) + } + + fn map_result( + result: Result, + operation: &str, + resource: &str, + request_body: Option<&str>, + ) -> Result { + match result { + Ok(v) => Ok(v), + Err(e) => { + if let Some(ErrorData::HttpResponseError { + http_status, + http_response_text: Some(ref text), + .. + }) = &e.error + { + let status = StatusCode::from_u16(*http_status) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + if let Some(mapped) = + Self::map_apigw_error(status, text, operation, resource, request_body) + { + Err(e.context(mapped)) + } else { + Err(e) + } + } else { + Err(e) + } + } + } + } + + fn map_apigw_error( + status: StatusCode, + body: &str, + operation: &str, + resource: &str, + request_body: Option<&str>, + ) -> Option { + let parsed: std::result::Result = serde_json::from_str(body); + let (code, message) = match parsed { + Ok(e) => { + let code = e + .type_field + .or(e.code) + .unwrap_or_else(|| "UnknownError".into()); + let message = e.message.unwrap_or_else(|| "Unknown error".into()); + (code, message) + } + Err(_) => return None, + }; + + Some(match code.as_str() { + "AccessDeniedException" | "UnauthorizedException" => ErrorData::RemoteAccessDenied { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + }, + "NotFoundException" => ErrorData::RemoteResourceNotFound { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + }, + "ConflictException" => ErrorData::RemoteResourceConflict { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + message: format!("{operation}: {message}"), + }, + "TooManyRequestsException" | "ThrottlingException" => ErrorData::RateLimitExceeded { + message: format!("{operation}: {message}"), + }, + "BadRequestException" | "ValidationException" => ErrorData::InvalidInput { + message: format!("{operation}: {message}"), + field_name: None, + }, + _ => match status { + StatusCode::NOT_FOUND => ErrorData::RemoteResourceNotFound { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + }, + StatusCode::CONFLICT => ErrorData::RemoteResourceConflict { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + message: format!("{operation}: {message}"), + }, + StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => ErrorData::RemoteAccessDenied { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + }, + StatusCode::TOO_MANY_REQUESTS => ErrorData::RateLimitExceeded { + message: format!("{operation}: {message}"), + }, + StatusCode::SERVICE_UNAVAILABLE + | StatusCode::BAD_GATEWAY + | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable { + message: format!("{operation}: {message}"), + }, + _ => ErrorData::HttpResponseError { + message: format!("ApiGateway {operation} failed: {message}"), + url: "apigateway.amazonaws.com".to_string(), + http_status: status.as_u16(), + http_response_text: Some(body.into()), + http_request_text: request_body.map(|s| s.to_string()), + }, + }, + }) + } + + fn serialize(request: &T, name: &str) -> Result { + serde_json::to_string(request) + .into_alien_error() + .context(ErrorData::SerializationError { + message: format!("Failed to serialize {name}"), + }) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl ApiGatewayApi for ApiGatewayClient { + async fn create_rest_api(&self, request: CreateRestApiRequest) -> Result { + let body = Self::serialize(&request, "CreateRestApiRequest")?; + self.send_json( + Method::POST, + "/restapis", + Some(body), + "CreateRestApi", + &request.name, + ) + .await + } + + async fn delete_rest_api(&self, rest_api_id: &str) -> Result<()> { + let path = format!("/restapis/{}", rest_api_id); + self.send_no_response(Method::DELETE, &path, None, "DeleteRestApi", rest_api_id) + .await + } + + async fn create_resource( + &self, + rest_api_id: &str, + parent_id: &str, + request: CreateResourceRequest, + ) -> Result { + let path = format!("/restapis/{}/resources/{}", rest_api_id, parent_id); + let body = Self::serialize(&request, "CreateResourceRequest")?; + self.send_json( + Method::POST, + &path, + Some(body), + "CreateResource", + rest_api_id, + ) + .await + } + + async fn put_method( + &self, + rest_api_id: &str, + resource_id: &str, + http_method: &str, + request: PutMethodRequest, + ) -> Result<()> { + let path = format!( + "/restapis/{}/resources/{}/methods/{}", + rest_api_id, resource_id, http_method + ); + let body = Self::serialize(&request, "PutMethodRequest")?; + self.send_no_response(Method::PUT, &path, Some(body), "PutMethod", rest_api_id) + .await + } + + async fn put_integration( + &self, + rest_api_id: &str, + resource_id: &str, + http_method: &str, + request: PutIntegrationRequest, + ) -> Result<()> { + let path = format!( + "/restapis/{}/resources/{}/methods/{}/integration", + rest_api_id, resource_id, http_method + ); + let body = Self::serialize(&request, "PutIntegrationRequest")?; + self.send_no_response( + Method::PUT, + &path, + Some(body), + "PutIntegration", + rest_api_id, + ) + .await + } + + async fn create_deployment( + &self, + rest_api_id: &str, + request: CreateDeploymentRequest, + ) -> Result { + let path = format!("/restapis/{}/deployments", rest_api_id); + let body = Self::serialize(&request, "CreateDeploymentRequest")?; + self.send_json( + Method::POST, + &path, + Some(body), + "CreateDeployment", + rest_api_id, + ) + .await + } + + async fn create_domain_name(&self, request: CreateDomainNameRequest) -> Result { + let body = Self::serialize(&request, "CreateDomainNameRequest")?; + self.send_json( + Method::POST, + "/domainnames", + Some(body), + "CreateDomainName", + &request.domain_name, + ) + .await + } + + async fn delete_domain_name(&self, domain_name: &str) -> Result<()> { + let path = format!("/domainnames/{}", domain_name); + self.send_no_response(Method::DELETE, &path, None, "DeleteDomainName", domain_name) + .await + } + + async fn create_base_path_mapping( + &self, + domain_name: &str, + request: CreateBasePathMappingRequest, + ) -> Result { + let path = format!("/domainnames/{}/basepathmappings", domain_name); + let body = Self::serialize(&request, "CreateBasePathMappingRequest")?; + self.send_json( + Method::POST, + &path, + Some(body), + "CreateBasePathMapping", + domain_name, + ) + .await + } + + async fn tag_resource( + &self, + resource_arn: &str, + tags: std::collections::HashMap, + ) -> Result<()> { + // The ARN is a path segment here, so its colons and slashes must be escaped. + let path = format!("/tags/{}", urlencoding::encode(resource_arn)); + let body = Self::serialize(&serde_json::json!({ "tags": tags }), "TagResourceRequest")?; + self.send_no_response(Method::PUT, &path, Some(body), "TagResource", resource_arn) + .await + } + + async fn delete_base_path_mapping(&self, domain_name: &str, base_path: &str) -> Result<()> { + let path = format!( + "/domainnames/{}/basepathmappings/{}", + domain_name, base_path + ); + self.send_no_response( + Method::DELETE, + &path, + None, + "DeleteBasePathMapping", + domain_name, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EndpointConfiguration { + pub types: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateRestApiRequest { + pub name: String, + pub endpoint_configuration: EndpointConfiguration, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestApi { + pub id: Option, + pub name: Option, + pub root_resource_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateResourceRequest { + pub path_part: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + pub id: Option, + pub path_part: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct PutMethodRequest { + pub authorization_type: String, +} + +/// The `type`/`httpMethod` fields carry the wire names REST V1 expects: `type` +/// is the integration type and `httpMethod` is the *backend* method (POST for a +/// Lambda proxy), distinct from the resource method (`ANY`) in the URL path. +/// `/response-streaming-invocations` + `responseTransferMode = STREAM` is what +/// makes the endpoint stream rather than buffer. +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct PutIntegrationRequest { + #[serde(rename = "type")] + pub integration_type: String, + #[serde(rename = "httpMethod")] + pub integration_http_method: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_transfer_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_in_millis: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateDeploymentRequest { + pub stage_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Deployment { + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateDomainNameRequest { + pub domain_name: String, + pub regional_certificate_arn: String, + pub endpoint_configuration: EndpointConfiguration, + pub security_policy: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainName { + pub domain_name: Option, + pub regional_domain_name: Option, + pub regional_hosted_zone_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateBasePathMappingRequest { + pub rest_api_id: String, + pub stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BasePathMapping { + pub base_path: Option, + pub rest_api_id: Option, + pub stage: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn put_integration_request_carries_streaming_fields() { + let request = PutIntegrationRequest { + integration_type: "AWS_PROXY".to_string(), + integration_http_method: "POST".to_string(), + uri: "arn:aws:apigateway:us-east-2:lambda:path/2021-11-15/functions/fn/response-streaming-invocations" + .to_string(), + response_transfer_mode: Some("STREAM".to_string()), + timeout_in_millis: Some(900_000), + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:us-east-2:lambda:path/2021-11-15/functions/fn/response-streaming-invocations", + "responseTransferMode": "STREAM", + "timeoutInMillis": 900_000 + }) + ); + } + + #[test] + fn create_rest_api_request_emits_regional_endpoint() { + let request = CreateRestApiRequest { + name: "my-app-proxy".to_string(), + endpoint_configuration: EndpointConfiguration { + types: vec!["REGIONAL".to_string()], + }, + tags: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "name": "my-app-proxy", + "endpointConfiguration": { "types": ["REGIONAL"] } + }) + ); + } +} diff --git a/crates/alien-aws-clients/src/aws/mod.rs b/crates/alien-aws-clients/src/aws/mod.rs index 48ce254aa..43aba7cb2 100644 --- a/crates/alien-aws-clients/src/aws/mod.rs +++ b/crates/alien-aws-clients/src/aws/mod.rs @@ -45,6 +45,7 @@ pub trait AwsClientConfigExt { } pub mod acm; +pub mod apigateway; pub mod apigatewayv2; pub mod autoscaling; pub mod aws_request_utils; diff --git a/crates/alien-aws-clients/src/lib.rs b/crates/alien-aws-clients/src/lib.rs index 953b21287..f5327e562 100644 --- a/crates/alien-aws-clients/src/lib.rs +++ b/crates/alien-aws-clients/src/lib.rs @@ -7,6 +7,7 @@ pub use aws::{AwsClientConfig, AwsClientConfigExt, AwsImpersonationConfig}; // Re-export all client APIs pub use aws::acm::{AcmApi, AcmClient}; +pub use aws::apigateway::{ApiGatewayApi, ApiGatewayClient}; pub use aws::apigatewayv2::{ApiGatewayV2Api, ApiGatewayV2Client}; pub use aws::cloudformation::{CloudFormationApi, CloudFormationClient}; pub use aws::cloudwatch::{CloudWatchApi, CloudWatchClient}; diff --git a/crates/alien-cloudformation/src/built_ins.rs b/crates/alien-cloudformation/src/built_ins.rs index d71535b14..5d60e922a 100644 --- a/crates/alien-cloudformation/src/built_ins.rs +++ b/crates/alien-cloudformation/src/built_ins.rs @@ -14,8 +14,7 @@ use crate::{ }; use alien_core::{ Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, KubernetesCluster, Kv, Network, Platform, - Queue, - RemoteStackManagement, ResourceType, ServiceAccount, Storage, Vault, Worker, + Queue, RemoteStackManagement, ResourceType, ServiceAccount, Storage, Vault, Worker, }; pub(crate) fn register_aws(registry: &mut CfRegistry) { diff --git a/crates/alien-cloudformation/src/emitters/aws/ai.rs b/crates/alien-cloudformation/src/emitters/aws/ai.rs index a9c33a2f6..90348ab74 100644 --- a/crates/alien-cloudformation/src/emitters/aws/ai.rs +++ b/crates/alien-cloudformation/src/emitters/aws/ai.rs @@ -20,7 +20,9 @@ use crate::{ }, template::{CfExpression, CfResource}, }; -use alien_core::{import::EmitContext, Ai, ErrorData, PermissionProfile, PermissionSetReference, Result}; +use alien_core::{ + import::EmitContext, Ai, ErrorData, PermissionProfile, PermissionSetReference, Result, +}; use alien_error::{AlienError, Context, IntoAlienError}; use alien_permissions::{generators::AwsCloudFormationPermissionsGenerator, BindingTarget}; @@ -35,7 +37,10 @@ impl CfEmitter for AwsAiEmitter { fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { resource_config::(ctx, Ai::RESOURCE_TYPE)?; - Ok(CfExpression::object([("region", CfExpression::ref_("AWS::Region"))])) + Ok(CfExpression::object([( + "region", + CfExpression::ref_("AWS::Region"), + )])) } } @@ -45,7 +50,9 @@ fn ai_iam_policies(ctx: &EmitContext<'_>) -> Result> { let logical_id = required_logical_id(ctx)?; let context = permission_context(); - for (owner_index, (role_id, permission_refs)) in ai_permission_owners(ctx).into_iter().enumerate() { + for (owner_index, (role_id, permission_refs)) in + ai_permission_owners(ctx).into_iter().enumerate() + { for (permission_index, permission_ref) in permission_refs.iter().enumerate() { let Some(permission_set) = permission_ref.resolve(|name| alien_permissions::get_permission_set(name).cloned()) @@ -79,11 +86,9 @@ fn ai_iam_policies(ctx: &EmitContext<'_>) -> Result> { continue; }; - let policy_id = format!( - "{logical_id}{role_id}AiPermission{owner_index}{permission_index}" - ); - let mut policy_resource = - CfResource::new(policy_id, "AWS::IAM::Policy".to_string()); + let policy_id = + format!("{logical_id}{role_id}AiPermission{owner_index}{permission_index}"); + let mut policy_resource = CfResource::new(policy_id, "AWS::IAM::Policy".to_string()); policy_resource.properties.insert( "PolicyName".to_string(), CfExpression::sub(format!( @@ -142,4 +147,3 @@ fn ai_permission_refs( } refs } - diff --git a/crates/alien-cloudformation/src/emitters/aws/email.rs b/crates/alien-cloudformation/src/emitters/aws/email.rs index 8e6e51699..20c66a167 100644 --- a/crates/alien-cloudformation/src/emitters/aws/email.rs +++ b/crates/alien-cloudformation/src/emitters/aws/email.rs @@ -25,7 +25,7 @@ use crate::{ emitters::aws::{ helpers::{ cf_from_json, logical_id_for_ref, required_logical_id, resource_config, - service_account_role_id, stack_name, tags, uniquify_iam_statement_sids, + resource_permission_owners, stack_name, tags, uniquify_iam_statement_sids, }, service_account::permission_context, }, @@ -33,8 +33,8 @@ use crate::{ template::{CfExpression, CfResource}, }; use alien_core::{ - import::EmitContext, ownership_policy_for_resource_type, Email, ErrorData, PermissionProfile, - PermissionSetReference, Queue, ResourceRef, ResourceType, Result, ServiceAccount, Storage, + import::EmitContext, ownership_policy_for_resource_type, Email, ErrorData, Queue, ResourceRef, + ResourceType, Result, Storage, }; use alien_error::{AlienError, Context, IntoAlienError}; use alien_permissions::{generators::AwsCloudFormationPermissionsGenerator, BindingTarget}; @@ -620,7 +620,10 @@ fn email_iam_policies( let context = permission_context().with_resource_name(format!("${{AWS::StackName}}-{}", email.id())); - for (owner_index, (role_id, permission_refs)) in permission_owners(ctx).into_iter().enumerate() + for (owner_index, (role_id, permission_refs)) in + resource_permission_owners(ctx, PERMISSION_SET_PREFIX) + .into_iter() + .enumerate() { for (permission_index, permission_ref) in permission_refs.iter().enumerate() { let Some(permission_set) = @@ -689,67 +692,3 @@ fn email_iam_policies( Ok(resources) } - -/// Service-account roles whose permission profile references an `email/*` -/// permission set for this resource (either directly by resource id or -/// through a `*` wildcard grant). -fn permission_owners(ctx: &EmitContext<'_>) -> Vec<(String, Vec)> { - let mut owners = Vec::new(); - for (profile_name, profile) in ctx.stack.permission_profiles() { - let refs = resource_permission_refs(profile, ctx.resource_id); - if refs.is_empty() { - continue; - } - - let service_account_id = format!("{profile_name}-sa"); - if service_account_for_id(ctx, &service_account_id).is_some() { - if let Some(role_id) = service_account_role_id(ctx, profile_name) { - owners.push((role_id, refs)); - } - } - } - owners -} - -fn resource_permission_refs( - profile: &PermissionProfile, - resource_id: &str, -) -> Vec { - let mut refs = Vec::new(); - let mut seen_ids = std::collections::HashSet::new(); - - if let Some(resource_refs) = profile.0.get(resource_id) { - for permission_ref in resource_refs - .iter() - .filter(|permission_ref| permission_ref.id().starts_with(PERMISSION_SET_PREFIX)) - { - if seen_ids.insert(permission_ref.id().to_string()) { - refs.push(permission_ref.clone()); - } - } - } - - if let Some(wildcard_refs) = profile.0.get("*") { - for permission_ref in wildcard_refs - .iter() - .filter(|permission_ref| permission_ref.id().starts_with(PERMISSION_SET_PREFIX)) - { - if seen_ids.insert(permission_ref.id().to_string()) { - refs.push(permission_ref.clone()); - } - } - } - - refs -} - -fn service_account_for_id<'a>( - ctx: &'a EmitContext<'_>, - service_account_id: &str, -) -> Option<&'a ServiceAccount> { - let (_id, entry) = ctx - .stack - .resources() - .find(|(id, _entry)| id.as_str() == service_account_id)?; - entry.config.downcast_ref::() -} diff --git a/crates/alien-cloudformation/src/emitters/aws/helpers.rs b/crates/alien-cloudformation/src/emitters/aws/helpers.rs index 759da5385..b48c6f08f 100644 --- a/crates/alien-cloudformation/src/emitters/aws/helpers.rs +++ b/crates/alien-cloudformation/src/emitters/aws/helpers.rs @@ -8,13 +8,14 @@ use crate::template::{CfExpression, CfResource}; use alien_core::{ import::EmitContext, ownership_policy_for_resource_type, ErrorData, Network, NetworkSettings, - RemoteStackManagement, ResourceDefinition, ResourceRef, ResourceType, Result, ServiceAccount, - Storage, Worker, ALIEN_MANAGED_BY_TAG_KEY, ALIEN_RESOURCE_TAG_KEY, ALIEN_STACK_TAG_KEY, + PermissionProfile, PermissionSetReference, RemoteStackManagement, ResourceDefinition, + ResourceRef, ResourceType, Result, ServiceAccount, Storage, Worker, ALIEN_MANAGED_BY_TAG_KEY, + ALIEN_RESOURCE_TAG_KEY, ALIEN_STACK_TAG_KEY, }; use alien_error::AlienError; use indexmap::IndexMap; use serde_json::Value as JsonValue; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; pub const PARAM_MANAGING_ROLE_ARN: &str = "ManagingRoleArn"; pub const PARAM_MANAGING_ACCOUNT_ID: &str = "ManagingAccountId"; @@ -620,3 +621,46 @@ fn storage_events(events: &[String]) -> Vec { }) .collect() } + +/// Service-account roles whose permission profile grants permission sets with +/// this prefix for the resource — directly by resource id or through a `*` +/// wildcard grant. +pub fn resource_permission_owners( + ctx: &EmitContext<'_>, + permission_set_prefix: &str, +) -> Vec<(String, Vec)> { + let mut owners = Vec::new(); + for (profile_name, profile) in ctx.stack.permission_profiles() { + let refs = prefixed_permission_refs(profile, ctx.resource_id, permission_set_prefix); + if refs.is_empty() { + continue; + } + if let Some(role_id) = service_account_role_id(ctx, profile_name) { + owners.push((role_id, refs)); + } + } + owners +} + +fn prefixed_permission_refs( + profile: &PermissionProfile, + resource_id: &str, + permission_set_prefix: &str, +) -> Vec { + let mut refs = Vec::new(); + let mut seen_ids = HashSet::new(); + for key in [resource_id, "*"] { + let Some(scoped_refs) = profile.0.get(key) else { + continue; + }; + for permission_ref in scoped_refs + .iter() + .filter(|permission_ref| permission_ref.id().starts_with(permission_set_prefix)) + { + if seen_ids.insert(permission_ref.id().to_string()) { + refs.push(permission_ref.clone()); + } + } + } + refs +} diff --git a/crates/alien-cloudformation/src/emitters/aws/kv.rs b/crates/alien-cloudformation/src/emitters/aws/kv.rs index 89c2f7a1e..f441f13cc 100644 --- a/crates/alien-cloudformation/src/emitters/aws/kv.rs +++ b/crates/alien-cloudformation/src/emitters/aws/kv.rs @@ -2,17 +2,28 @@ use crate::{ emitter::CfEmitter, - emitters::aws::helpers::{required_logical_id, resource_config, tags}, + emitters::aws::{ + helpers::{ + cf_from_json, required_logical_id, resource_config, resource_permission_owners, tags, + uniquify_iam_statement_sids, + }, + service_account::permission_context, + }, template::{CfExpression, CfResource}, }; -use alien_core::{import::EmitContext, Kv, Result}; +use alien_core::{import::EmitContext, ErrorData, Kv, Result}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_permissions::{generators::AwsCloudFormationPermissionsGenerator, BindingTarget}; + +/// Permission-set id prefix for this resource type. +const PERMISSION_SET_PREFIX: &str = "kv/"; #[derive(Debug, Clone, Copy, Default)] pub struct AwsKvEmitter; impl CfEmitter for AwsKvEmitter { fn emit_resources(&self, ctx: &EmitContext<'_>) -> Result> { - resource_config::(ctx, Kv::RESOURCE_TYPE)?; + let kv = resource_config::(ctx, Kv::RESOURCE_TYPE)?; let table_id = required_logical_id(ctx)?; let mut table = CfResource::new(table_id.to_string(), "AWS::DynamoDB::Table".to_string()); @@ -65,7 +76,9 @@ impl CfEmitter for AwsKvEmitter { table.deletion_policy = Some("Retain".to_string()); table.update_replace_policy = Some("Retain".to_string()); - Ok(vec![table]) + let mut resources = vec![table]; + resources.extend(kv_iam_policies(ctx, kv, table_id)?); + Ok(resources) } fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { @@ -85,3 +98,103 @@ impl CfEmitter for AwsKvEmitter { ]))) } } + +/// IAM policies attaching granted `kv/*` permission sets to the owning +/// service-account roles, scoped to this table's ARN. +fn kv_iam_policies(ctx: &EmitContext<'_>, kv: &Kv, table_id: &str) -> Result> { + let mut resources = Vec::new(); + let generator = AwsCloudFormationPermissionsGenerator::new(); + let context = + permission_context().with_resource_name(format!("${{AWS::StackName}}-{}", kv.id())); + + for (owner_index, (role_id, permission_refs)) in + resource_permission_owners(ctx, PERMISSION_SET_PREFIX) + .into_iter() + .enumerate() + { + for (permission_index, permission_ref) in permission_refs.iter().enumerate() { + let Some(permission_set) = + permission_ref.resolve(|name| alien_permissions::get_permission_set(name).cloned()) + else { + continue; + }; + if !permission_set.id.starts_with(PERMISSION_SET_PREFIX) { + continue; + } + + let policy = generator + .generate_policy(&permission_set, BindingTarget::Resource, &context) + .context(ErrorData::GenericError { + message: format!( + "failed to generate AWS CloudFormation kv IAM policy for '{}'", + kv.id() + ), + })?; + let policy_value = serde_json::to_value(policy).into_alien_error().context( + ErrorData::TemplateSerializationFailed { + format: "CloudFormation IAM policy".to_string(), + reason: "Failed to serialize IAM policy".to_string(), + }, + )?; + let CfExpression::Object(mut policy_object) = cf_from_json(policy_value)? else { + return Err(AlienError::new(ErrorData::TemplateSerializationFailed { + format: "CloudFormation IAM policy".to_string(), + reason: "policy did not serialize to a JSON object".to_string(), + })); + }; + let Some(CfExpression::List(policy_statements)) = + policy_object.shift_remove("Statement") + else { + continue; + }; + // The physical table name carries a CloudFormation-generated + // suffix, so name-pattern resource bindings cannot match it; pin + // every statement to this table's ARN. + let policy_statements = policy_statements + .into_iter() + .map(|statement| pin_statement_to_table(statement, table_id)) + .collect::>(); + + let policy_id = + format!("{table_id}{role_id}KvPermission{owner_index}{permission_index}"); + let mut policy_resource = CfResource::new(policy_id, "AWS::IAM::Policy".to_string()); + policy_resource.properties.insert( + "PolicyName".to_string(), + CfExpression::sub(format!( + "${{AWS::StackName}}-{}-kv-{owner_index}-{permission_index}", + kv.id() + )), + ); + policy_resource.properties.insert( + "PolicyDocument".to_string(), + CfExpression::object([ + ("Version", CfExpression::from("2012-10-17")), + ( + "Statement", + CfExpression::list(uniquify_iam_statement_sids(policy_statements)), + ), + ]), + ); + policy_resource.properties.insert( + "Roles".to_string(), + CfExpression::list([CfExpression::ref_(&role_id)]), + ); + policy_resource.depends_on.push(table_id.to_string()); + policy_resource.depends_on.push(role_id.clone()); + resources.push(policy_resource); + } + } + + Ok(resources) +} + +fn pin_statement_to_table(statement: CfExpression, table_id: &str) -> CfExpression { + let CfExpression::Object(mut statement_object) = statement else { + return statement; + }; + statement_object.insert( + "Resource".to_string(), + CfExpression::get_att(table_id, "Arn"), + ); + CfExpression::Object(statement_object) +} diff --git a/crates/alien-cloudformation/src/emitters/aws/queue.rs b/crates/alien-cloudformation/src/emitters/aws/queue.rs index ab17a092f..f020fa336 100644 --- a/crates/alien-cloudformation/src/emitters/aws/queue.rs +++ b/crates/alien-cloudformation/src/emitters/aws/queue.rs @@ -10,17 +10,14 @@ use crate::{ emitter::CfEmitter, emitters::aws::{ helpers::{ - cf_from_json, required_logical_id, resource_config, service_account_role_id, tags, + cf_from_json, required_logical_id, resource_config, resource_permission_owners, tags, uniquify_iam_statement_sids, }, service_account::permission_context, }, template::{CfExpression, CfResource}, }; -use alien_core::{ - import::EmitContext, ErrorData, PermissionProfile, PermissionSetReference, Queue, Result, - ServiceAccount, Worker, WorkerTrigger, -}; +use alien_core::{import::EmitContext, ErrorData, Queue, Result, Worker, WorkerTrigger}; use alien_error::{AlienError, Context, IntoAlienError}; use alien_permissions::{generators::AwsCloudFormationPermissionsGenerator, BindingTarget}; @@ -115,7 +112,10 @@ fn queue_iam_policies( let context = permission_context().with_resource_name(format!("${{AWS::StackName}}-{}", queue.id())); - for (owner_index, (role_id, permission_refs)) in permission_owners(ctx).into_iter().enumerate() + for (owner_index, (role_id, permission_refs)) in + resource_permission_owners(ctx, PERMISSION_SET_PREFIX) + .into_iter() + .enumerate() { for (permission_index, permission_ref) in permission_refs.iter().enumerate() { let Some(permission_set) = @@ -203,67 +203,3 @@ fn pin_statement_to_queue(statement: CfExpression, queue_id: &str) -> CfExpressi ); CfExpression::Object(statement_object) } - -/// Service-account roles whose permission profile references a `queue/*` -/// permission set for this resource (either directly by resource id or -/// through a `*` wildcard grant). -fn permission_owners(ctx: &EmitContext<'_>) -> Vec<(String, Vec)> { - let mut owners = Vec::new(); - for (profile_name, profile) in ctx.stack.permission_profiles() { - let refs = resource_permission_refs(profile, ctx.resource_id); - if refs.is_empty() { - continue; - } - - let service_account_id = format!("{profile_name}-sa"); - if service_account_for_id(ctx, &service_account_id).is_some() { - if let Some(role_id) = service_account_role_id(ctx, profile_name) { - owners.push((role_id, refs)); - } - } - } - owners -} - -fn resource_permission_refs( - profile: &PermissionProfile, - resource_id: &str, -) -> Vec { - let mut refs = Vec::new(); - let mut seen_ids = std::collections::HashSet::new(); - - if let Some(resource_refs) = profile.0.get(resource_id) { - for permission_ref in resource_refs - .iter() - .filter(|permission_ref| permission_ref.id().starts_with(PERMISSION_SET_PREFIX)) - { - if seen_ids.insert(permission_ref.id().to_string()) { - refs.push(permission_ref.clone()); - } - } - } - - if let Some(wildcard_refs) = profile.0.get("*") { - for permission_ref in wildcard_refs - .iter() - .filter(|permission_ref| permission_ref.id().starts_with(PERMISSION_SET_PREFIX)) - { - if seen_ids.insert(permission_ref.id().to_string()) { - refs.push(permission_ref.clone()); - } - } - } - - refs -} - -fn service_account_for_id<'a>( - ctx: &'a EmitContext<'_>, - service_account_id: &str, -) -> Option<&'a ServiceAccount> { - let (_id, entry) = ctx - .stack - .resources() - .find(|(id, _entry)| id.as_str() == service_account_id)?; - entry.config.downcast_ref::() -} diff --git a/crates/alien-cloudformation/tests/generator.rs b/crates/alien-cloudformation/tests/generator.rs index 31eac8858..d82a21589 100644 --- a/crates/alien-cloudformation/tests/generator.rs +++ b/crates/alien-cloudformation/tests/generator.rs @@ -17,9 +17,9 @@ mod generator { pub mod aws_full_stack_tests; pub mod aws_open_search_tests; pub mod enabled_queue_tests; - pub mod gating_matrix_tests; pub mod enabled_storage_tests; pub mod enabled_tests; + pub mod gating_matrix_tests; pub mod kubernetes_cluster_tests; pub mod network_tests; pub mod output_chunking_tests; diff --git a/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs b/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs index 653853941..f1ae0b372 100644 --- a/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs +++ b/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs @@ -15,7 +15,10 @@ fn aws_ai_invoke_permissions_attach_to_service_account_role() { ServiceAccount::new("execution-sa".to_string()).build(), ResourceLifecycle::Frozen, ) - .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .add( + Ai::new("llm".to_string()).build(), + ResourceLifecycle::Frozen, + ) .build(); let yaml = render_built_ins( @@ -56,7 +59,10 @@ fn aws_ai_without_permissions_emits_no_iam_policy() { // An Ai resource with no permission profile referencing it should emit // zero IAM resources (the AI itself creates no cloud resource on AWS). let stack = Stack::new("ai-no-permissions".to_string()) - .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .add( + Ai::new("llm".to_string()).build(), + ResourceLifecycle::Frozen, + ) .build(); let yaml = render_built_ins( diff --git a/crates/alien-cloudformation/tests/generator/aws_data_layer_tests.rs b/crates/alien-cloudformation/tests/generator/aws_data_layer_tests.rs index 5c556e826..e9a84153a 100644 --- a/crates/alien-cloudformation/tests/generator/aws_data_layer_tests.rs +++ b/crates/alien-cloudformation/tests/generator/aws_data_layer_tests.rs @@ -239,6 +239,80 @@ fn aws_queue_without_grants_emits_no_iam_policies() { assert!(!yaml.contains("QueuePermission")); } +#[test] +fn aws_kv_resource_permissions_attach_to_service_account_role() { + let stack = Stack::new("kv-permissions".to_string()) + .permission( + "execution", + PermissionProfile::new().resource("store", ["kv/data-read", "kv/data-write"]), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + Kv::new("store".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + + let yaml = render_built_ins( + &stack, + StackSettings::default(), + RegistrationMode::OutputsFallback, + "aws kv service account permissions", + ); + let template: serde_json::Value = + serde_yaml::from_str(&yaml).expect("template YAML should parse"); + + let read_policy = &template["Resources"]["StoreExecutionSaRoleKvPermission00"]; + let write_policy = &template["Resources"]["StoreExecutionSaRoleKvPermission01"]; + assert_eq!(read_policy["Type"], "AWS::IAM::Policy"); + assert_eq!(write_policy["Type"], "AWS::IAM::Policy"); + + let read_actions = read_policy["Properties"]["PolicyDocument"]["Statement"][0]["Action"] + .as_array() + .expect("read statement should list actions"); + assert!(read_actions.contains(&serde_json::json!("dynamodb:GetItem"))); + assert!(read_actions.contains(&serde_json::json!("dynamodb:Query"))); + let write_actions = write_policy["Properties"]["PolicyDocument"]["Statement"][0]["Action"] + .as_array() + .expect("write statement should list actions"); + assert!(write_actions.contains(&serde_json::json!("dynamodb:PutItem"))); + + // Statements must be pinned to the table ARN: the physical table name is + // CloudFormation-generated, so a name-pattern binding would never match. + for policy in [read_policy, write_policy] { + assert_eq!( + policy["Properties"]["PolicyDocument"]["Statement"][0]["Resource"]["Fn::GetAtt"], + serde_json::json!(["Store", "Arn"]) + ); + assert_eq!( + policy["Properties"]["Roles"][0]["Ref"], + serde_json::json!("ExecutionSaRole") + ); + } +} + +#[test] +fn aws_kv_without_grants_emits_no_iam_policies() { + let stack = Stack::new("kv-plain".to_string()) + .add( + Kv::new("store".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + + let yaml = render_built_ins( + &stack, + StackSettings::default(), + RegistrationMode::OutputsFallback, + "aws kv without grants", + ); + + assert!(!yaml.contains("KvPermission")); +} + #[test] fn aws_vault_resource_permissions_attach_to_service_account_role() { let stack = Stack::new("vault-permissions".to_string()) diff --git a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs index 4576ad794..0727a527d 100644 --- a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs +++ b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs @@ -99,7 +99,9 @@ fn assert_live_gate_ignored_by_setup(resource_type: &str) { ); assert!( - !template.conditions.contains_key("InputFixtureEnabledIsTrue"), + !template + .conditions + .contains_key("InputFixtureEnabledIsTrue"), "{resource_type}: setup never declares a condition for a Live gate" ); assert!( @@ -110,8 +112,7 @@ fn assert_live_gate_ignored_by_setup(resource_type: &str) { "{resource_type}: a Live resource contributes nothing to setup" ); let payload = registration_payload(&template); - let text = - serde_json::to_string(&payload).expect("registration payload should serialize"); + let text = serde_json::to_string(&payload).expect("registration payload should serialize"); assert!( !text.contains("\"fixture\""), "{resource_type}: a Live resource has no setup registration entry:\n{text}" @@ -161,8 +162,7 @@ fn assert_gated_render(resource_type: &str, stack: &Stack) { .resources .iter() .filter(|(logical_id, resource)| { - resource.condition.is_none() - && logical_id.to_ascii_lowercase().contains("fixture") + resource.condition.is_none() && logical_id.to_ascii_lowercase().contains("fixture") }) .map(|(logical_id, _)| logical_id.as_str()) .collect(); @@ -330,7 +330,11 @@ fn a_gate_on_a_policy_refused_type_fails_at_render() { ) .expect_err("the policy should refuse a gated service account at render"); assert_eq!(error.code, "OPERATION_NOT_SUPPORTED"); - assert!(error.message.contains("service-account"), "{}", error.message); + assert!( + error.message.contains("service-account"), + "{}", + error.message + ); assert!(error.message.contains("robot"), "{}", error.message); } diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap index 5b5f59945..8686437be 100644 --- a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap @@ -817,6 +817,7 @@ Resources: Resource: - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/apis - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/restapis - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/tags/* Sid: CreateHttpApiEndpoints - Action: @@ -831,7 +832,20 @@ Resources: - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/apis/* - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/* - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/apimappings + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/basepathmappings + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/restapis/* Sid: CreateHttpApiRoutes + Roles: + - Ref: ManagementRole + DependsOn: + - ManagementRole + ManagementRoleManagementPolicy2: + Type: AWS::IAM::ManagedPolicy + Properties: + Description: Application management permissions + PolicyDocument: + Version: 2012-10-17 + Statement: - Action: - apigateway:PUT - apigateway:TagResource @@ -846,19 +860,9 @@ Resources: - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/apis/*/stages - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/apis/*/stages/* - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/* + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/restapis/* - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/tags/* Sid: TagHttpApiEndpoints - Roles: - - Ref: ManagementRole - DependsOn: - - ManagementRole - ManagementRoleManagementPolicy2: - Type: AWS::IAM::ManagedPolicy - Properties: - Description: Application management permissions - PolicyDocument: - Version: 2012-10-17 - Statement: - Action: - apigateway:DELETE - apigateway:GET @@ -875,6 +879,9 @@ Resources: - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/* - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/apimappings - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/apimappings/* + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/basepathmappings + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/domainnames/*/basepathmappings/* + - Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}::/restapis/* Sid: ManageHttpApiEndpoints - Action: - events:PutRule diff --git a/crates/alien-core/src/bindings/ai.rs b/crates/alien-core/src/bindings/ai.rs index f2552cf1d..1edd7e3e9 100644 --- a/crates/alien-core/src/bindings/ai.rs +++ b/crates/alien-core/src/bindings/ai.rs @@ -19,7 +19,11 @@ pub enum AiBinding { Vertex(VertexAiBinding), /// Azure AI Foundry binding Foundry(FoundryAiBinding), - /// External provider binding (generic endpoint-based) + /// External provider binding (generic endpoint-based). The tag must stay + /// unique across every resource type's binding enum — a bare "external" + /// collides with the external Postgres binding in the shared + /// `ALIEN_*_BINDING` namespace. + #[serde(rename = "external-ai")] External(ExternalAiBinding), } @@ -180,7 +184,7 @@ mod tests { assert_eq!( json, serde_json::json!({ - "service": "external", + "service": "external-ai", "provider": "openai", "apiKey": "sk-test-key", }) diff --git a/crates/alien-core/src/import/data/aws/worker.rs b/crates/alien-core/src/import/data/aws/worker.rs index 7775baac2..007965d87 100644 --- a/crates/alien-core/src/import/data/aws/worker.rs +++ b/crates/alien-core/src/import/data/aws/worker.rs @@ -20,6 +20,13 @@ pub struct AwsWorkerImportData { pub route_id: Option, /// API Gateway stage name, when public ingress is enabled. pub stage_name: Option, + /// API Gateway REST (V1) API ID, when public ingress is enabled for a + /// streaming worker. Mutually exclusive with `api_id`: a worker is exposed + /// through REST V1 xor the V2 HTTP API, so only one set is ever populated. + pub rest_api_id: Option, + /// API Gateway REST (V1) stage name, when public ingress is enabled for a + /// streaming worker. Mutually exclusive with `stage_name` (V1 xor V2). + pub rest_stage_name: Option, /// Queue event-source mapping UUIDs. pub event_source_mappings: Vec, /// EventBridge rule names for schedule triggers. diff --git a/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap b/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap index 2054632d4..a27492af7 100644 --- a/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap +++ b/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap @@ -168,7 +168,7 @@ expression: schemas "type": "object" }, "ruleSetName": { - "description": "SES receipt rule set name, present only when inbound mail is configured. Activating the rule set is a manual post-deploy step.", + "description": "SES receipt rule set name, present only when inbound mail is configured.", "type": [ "string", "null" @@ -229,6 +229,20 @@ expression: schemas "null" ] }, + "restApiId": { + "description": "API Gateway REST (V1) API ID, when public ingress is enabled for a streaming worker. Mutually exclusive with `api_id`: a worker is exposed through REST V1 xor the V2 HTTP API, so only one set is ever populated.", + "type": [ + "string", + "null" + ] + }, + "restStageName": { + "description": "API Gateway REST (V1) stage name, when public ingress is enabled for a streaming worker. Mutually exclusive with `stage_name` (V1 xor V2).", + "type": [ + "string", + "null" + ] + }, "routeId": { "description": "API Gateway route ID, when public ingress is enabled.", "type": [ diff --git a/crates/alien-infra/src/ai/aws_import.rs b/crates/alien-infra/src/ai/aws_import.rs index ccc8e2269..1681e617d 100644 --- a/crates/alien-infra/src/ai/aws_import.rs +++ b/crates/alien-infra/src/ai/aws_import.rs @@ -8,10 +8,10 @@ use alien_core::{ Result, StackResourceState, }; -use crate::import::ResourceImporter; -use crate::import_helpers::make_imported_state; use crate::ai::aws::AwsAiState; use crate::ai::AwsAiController; +use crate::import::ResourceImporter; +use crate::import_helpers::make_imported_state; /// AWS Bedrock AI importer. #[derive(Debug, Default)] diff --git a/crates/alien-infra/src/ai/azure.rs b/crates/alien-infra/src/ai/azure.rs index 2859cb79b..804d99b82 100644 --- a/crates/alien-infra/src/ai/azure.rs +++ b/crates/alien-infra/src/ai/azure.rs @@ -32,7 +32,13 @@ fn make_account_name(prefix: &str, id: &str) -> String { // Keep only alphanumeric chars and hyphens; collapse leading/trailing hyphens. let cleaned: String = raw .chars() - .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' }) + .map(|c| { + if c.is_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) .collect(); let trimmed = cleaned.trim_matches('-').to_string(); if trimmed.len() > 64 { @@ -104,7 +110,10 @@ impl AzureAiController { .create_account(&resource_group_name, &account_name, ¶meters) .await .context(ErrorData::CloudPlatformError { - message: format!("Failed to create Azure AIServices account '{}'", account_name), + message: format!( + "Failed to create Azure AIServices account '{}'", + account_name + ), resource_id: Some(config.id.clone()), })?; @@ -115,9 +124,7 @@ impl AzureAiController { match operation_result { OperationResult::Completed(account) => { - self.endpoint = account - .properties - .and_then(|p| p.endpoint); + self.endpoint = account.properties.and_then(|p| p.endpoint); info!(account_name = %account_name, "Azure AIServices account created (synchronous)"); @@ -314,7 +321,8 @@ impl AzureAiController { // PUT is idempotent, so re-entering this state (e.g. after a retry) re-issues // the same deployments harmlessly. - for (deployment_name, model_name, model_version) in alien_core::ai_catalog::azure_deployments() + for (deployment_name, model_name, model_version) in + alien_core::ai_catalog::azure_deployments() { info!( account_name = %account_name, @@ -594,7 +602,10 @@ impl AzureAiController { }) } Err(e) => Err(e.context(ErrorData::CloudPlatformError { - message: format!("Failed to delete Azure AIServices account '{}'", account_name), + message: format!( + "Failed to delete Azure AIServices account '{}'", + account_name + ), resource_id: Some(config.id.clone()), })), } @@ -786,7 +797,9 @@ mod tests { } } - fn setup_mock_provider_for_deletion(expect_not_found: bool) -> Arc { + fn setup_mock_provider_for_deletion( + expect_not_found: bool, + ) -> Arc { let mut mock_provider = MockPlatformServiceProvider::new(); mock_provider @@ -796,27 +809,27 @@ mod tests { if expect_not_found { // Simulate account already deleted. - mock_cognitive - .expect_delete_account() - .returning(|_, _| { - Err(AlienError::new(CloudClientErrorData::RemoteResourceNotFound { + mock_cognitive.expect_delete_account().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { resource_type: "CognitiveServicesAccount".to_string(), resource_name: "my-ai".to_string(), - })) - }); + }, + )) + }); } else { // Successful delete then polling confirms deletion. mock_cognitive .expect_delete_account() .returning(|_, _| Ok(())); - mock_cognitive - .expect_get_account() - .returning(|_, _| { - Err(AlienError::new(CloudClientErrorData::RemoteResourceNotFound { + mock_cognitive.expect_get_account().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { resource_type: "CognitiveServicesAccount".to_string(), resource_name: "my-ai".to_string(), - })) - }); + }, + )) + }); } Ok(Arc::new(mock_cognitive)) diff --git a/crates/alien-infra/src/ai/gcp.rs b/crates/alien-infra/src/ai/gcp.rs index 278e451ea..8edd7ed00 100644 --- a/crates/alien-infra/src/ai/gcp.rs +++ b/crates/alien-infra/src/ai/gcp.rs @@ -33,10 +33,7 @@ impl GcpAiController { on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn create_start( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result { + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { let gcp_config = ctx.get_gcp_config()?; let config = ctx.desired_resource_config::()?; @@ -61,10 +58,7 @@ impl GcpAiController { on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn enabling_api( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result { + async fn enabling_api(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { let gcp_config = ctx.get_gcp_config()?; let config = ctx.desired_resource_config::()?; let client = ctx @@ -223,13 +217,11 @@ impl GcpAiController { controller_platform: Platform::Gcp, backend: HeartbeatBackend::Gcp, observed_at: Utc::now(), - data: ResourceHeartbeatData::Ai(AiHeartbeatData::GcpVertex( - GcpVertexAiHeartbeatData { - status: AiHeartbeatStatus::default(), - project: project.clone(), - location: location.clone(), - }, - )), + data: ResourceHeartbeatData::Ai(AiHeartbeatData::GcpVertex(GcpVertexAiHeartbeatData { + status: AiHeartbeatStatus::default(), + project: project.clone(), + location: location.clone(), + })), raw: vec![], }); Ok(HandlerAction::Continue { @@ -247,10 +239,7 @@ impl GcpAiController { on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_start( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result { + async fn update_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { let config = ctx.desired_resource_config::()?; info!(id = %config.id, "GCP AI update (no-op -- no mutable fields)"); Ok(HandlerAction::Continue { @@ -269,10 +258,7 @@ impl GcpAiController { on_failure = DeleteFailed, status = ResourceStatus::Deleting, )] - async fn delete_start( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result { + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { let config = ctx.desired_resource_config::()?; info!( id = %config.id, diff --git a/crates/alien-infra/src/ai/gcp_import.rs b/crates/alien-infra/src/ai/gcp_import.rs index 0de6725b7..9d3a52a99 100644 --- a/crates/alien-infra/src/ai/gcp_import.rs +++ b/crates/alien-infra/src/ai/gcp_import.rs @@ -20,11 +20,7 @@ pub struct GcpAiImporter; impl ResourceImporter for GcpAiImporter { type ImportData = GcpAiImportData; - fn import( - &self, - data: GcpAiImportData, - ctx: &ImportContext<'_>, - ) -> Result { + fn import(&self, data: GcpAiImportData, ctx: &ImportContext<'_>) -> Result { let controller = GcpAiController { state: GcpAiState::Ready, project: Some(data.project_id), diff --git a/crates/alien-infra/src/ai/local.rs b/crates/alien-infra/src/ai/local.rs index 3a06697b7..08046ee95 100644 --- a/crates/alien-infra/src/ai/local.rs +++ b/crates/alien-infra/src/ai/local.rs @@ -5,9 +5,9 @@ use tracing::info; use crate::core::ResourceControllerContext; use crate::error::{ErrorData, Result}; use alien_core::{ - bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, ExternalAiHeartbeatData, - HeartbeatBackend, Platform, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, - ResourceStatus, + bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, + ExternalAiHeartbeatData, HeartbeatBackend, Platform, ResourceHeartbeat, ResourceHeartbeatData, + ResourceOutputs, ResourceStatus, }; use alien_error::{AlienError, Context, IntoAlienError}; use alien_macros::controller; @@ -46,7 +46,10 @@ impl LocalAiController { // Fail loud if the developer hasn't supplied a key — local AI has no ambient // identity to fall back on, so this is a required, actionable precondition. - if std::env::var(API_KEY_ENV).map(|k| k.is_empty()).unwrap_or(true) { + if std::env::var(API_KEY_ENV) + .map(|k| k.is_empty()) + .unwrap_or(true) + { return Err(AlienError::new(ErrorData::ResourceConfigInvalid { resource_id: Some(config.id.clone()), message: format!( @@ -138,10 +141,16 @@ impl LocalAiController { // ─────────────── TERMINALS ──────────────────────────────── - terminal_state!(state = CreateFailed, status = ResourceStatus::ProvisionFailed); + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); - terminal_state!(state = RefreshFailed, status = ResourceStatus::RefreshFailed); + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); terminal_state!(state = Deleted, status = ResourceStatus::Deleted); fn build_outputs(&self) -> Option { @@ -172,7 +181,6 @@ impl LocalAiController { } Ok(Some(value)) } - } #[cfg(test)] @@ -194,7 +202,10 @@ mod tests { .get_binding_params() .expect("binding params serialize") .expect("a provisioned controller emits a binding"); - assert_eq!(value.get("provider").and_then(|v| v.as_str()), Some("openai")); + assert_eq!( + value.get("provider").and_then(|v| v.as_str()), + Some("openai") + ); assert!( value.get("apiKey").is_none(), "the synced binding must never carry the provider key: {value}" diff --git a/crates/alien-infra/src/aws_importers.rs b/crates/alien-infra/src/aws_importers.rs index 0de60ae47..7a27968d7 100644 --- a/crates/alien-infra/src/aws_importers.rs +++ b/crates/alien-infra/src/aws_importers.rs @@ -11,8 +11,8 @@ #[cfg(feature = "kubernetes")] use alien_core::KubernetesCluster; use alien_core::{ - Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, Kv, Network, Platform, Queue, Storage, Vault, - Worker, + Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, Kv, Network, Platform, Queue, Storage, + Vault, Worker, }; use alien_core::{RemoteStackManagement, ServiceAccount}; diff --git a/crates/alien-infra/src/core/environment_variables.rs b/crates/alien-infra/src/core/environment_variables.rs index 75a00ff6b..a07b2d888 100644 --- a/crates/alien-infra/src/core/environment_variables.rs +++ b/crates/alien-infra/src/core/environment_variables.rs @@ -691,7 +691,7 @@ mod tests { assert_eq!( injected, json!({ - "service": "external", + "service": "external-ai", "provider": "openai", "apiKey": "sk-test", }), diff --git a/crates/alien-infra/src/core/executor.rs b/crates/alien-infra/src/core/executor.rs index 3aa91dbb6..7deda97d2 100644 --- a/crates/alien-infra/src/core/executor.rs +++ b/crates/alien-infra/src/core/executor.rs @@ -620,7 +620,10 @@ impl StackExecutor { fn external_binding_state( &self, resource_id: &str, - ) -> Result<(Option, Option)> { + ) -> Result<( + Option, + Option, + )> { let Some(binding) = self.deployment_config.external_bindings.get(resource_id) else { return Ok((None, None)); }; diff --git a/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs b/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs index 0ac8ffed1..f69b3553e 100644 --- a/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs +++ b/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs @@ -599,7 +599,12 @@ async fn test_a_binding_on_a_provisioned_resource_does_not_replan_forever() -> R // Provisioned by Alien first, so it carries controller state and controller outputs. let state = run_to_synced(&new_executor(&stack)?, new_test_state()).await?; assert!( - state.resources.get("store").unwrap().internal_state.is_some(), + state + .resources + .get("store") + .unwrap() + .internal_state + .is_some(), "fixture must own a controller for this to mean anything" ); @@ -738,7 +743,10 @@ async fn test_external_binding_resource_adopts_config_changes() -> Result<()> { #[tokio::test] async fn test_a_controllerless_resource_fails_its_update() -> Result<()> { let stack = Stack::new("controllerless-update-test".to_owned()) - .add(test_storage_with_public_read("store", true), ResourceLifecycle::Live) + .add( + test_storage_with_public_read("store", true), + ResourceLifecycle::Live, + ) .build(); // Running, config differs from desired, and no controller state to update from. @@ -782,8 +790,10 @@ async fn test_pending_deletions_reports_a_deferred_delete() -> Result<()> { new_test_state(), ) .await?; - state.resources.get_mut("agent").unwrap().dependencies = - vec![ResourceRef::new(alien_core::Worker::RESOURCE_TYPE, "dropped")]; + state.resources.get_mut("agent").unwrap().dependencies = vec![ResourceRef::new( + alien_core::Worker::RESOURCE_TYPE, + "dropped", + )]; // The release drops `dropped` and changes the survivor, which is what a scrubbed link // looks like: an update is planned, and it is what releases the deferred delete. diff --git a/crates/alien-infra/src/core/service_provider.rs b/crates/alien-infra/src/core/service_provider.rs index 2219bd2b9..a96ad8a76 100644 --- a/crates/alien-infra/src/core/service_provider.rs +++ b/crates/alien-infra/src/core/service_provider.rs @@ -1,6 +1,7 @@ use crate::error::Result; use alien_aws_clients::{ acm::{AcmApi, AcmClient}, + apigateway::{ApiGatewayApi, ApiGatewayClient}, apigatewayv2::{ApiGatewayV2Api, ApiGatewayV2Client}, autoscaling::{AutoScalingApi, AutoScalingClient}, cloudformation::{CloudFormationApi, CloudFormationClient}, @@ -24,6 +25,7 @@ use alien_azure_clients::{ application_gateways::{ApplicationGatewayApi, AzureApplicationGatewayClient}, authorization::{AuthorizationApi, AzureAuthorizationClient}, blob_containers::{AzureBlobContainerClient, BlobContainerApi}, + cognitive_services::{AzureCognitiveServicesClient, CognitiveServicesAccountsApi}, compute::{AzureVmssClient, VirtualMachineScaleSetsApi}, container_apps::{AzureContainerAppsClient, ContainerAppsApi}, containerregistry::{AzureContainerRegistryClient, ContainerRegistryApi}, @@ -46,7 +48,6 @@ use alien_azure_clients::{ AzureServiceBusDataPlaneClient, AzureServiceBusManagementClient, ServiceBusDataPlaneApi, ServiceBusManagementApi, }, - cognitive_services::{AzureCognitiveServicesClient, CognitiveServicesAccountsApi}, storage_accounts::{AzureStorageAccountsClient, StorageAccountsApi}, tables::{AzureTableManagementClient, TableManagementApi}, AzureClientConfig, AzureTokenCache, @@ -121,6 +122,10 @@ pub trait PlatformServiceProvider: Send + Sync { async fn get_aws_elbv2_client(&self, config: &AwsClientConfig) -> Result>; async fn get_aws_eks_client(&self, config: &AwsClientConfig) -> Result>; async fn get_aws_acm_client(&self, config: &AwsClientConfig) -> Result>; + async fn get_aws_apigateway_client( + &self, + config: &AwsClientConfig, + ) -> Result>; async fn get_aws_apigatewayv2_client( &self, config: &AwsClientConfig, @@ -650,6 +655,22 @@ impl PlatformServiceProvider for DefaultPlatformServiceProvider { ))) } + async fn get_aws_apigateway_client( + &self, + config: &AwsClientConfig, + ) -> Result> { + let credentials = AwsCredentialProvider::from_config(config.clone()) + .await + .context(crate::error::ErrorData::CloudPlatformError { + message: "Failed to create AWS credential provider".to_string(), + resource_id: None, + })?; + Ok(Arc::new(ApiGatewayClient::new( + reqwest::Client::new(), + credentials, + ))) + } + async fn get_aws_apigatewayv2_client( &self, config: &AwsClientConfig, diff --git a/crates/alien-infra/src/daemon/local.rs b/crates/alien-infra/src/daemon/local.rs index da37cb104..de7164802 100644 --- a/crates/alien-infra/src/daemon/local.rs +++ b/crates/alien-infra/src/daemon/local.rs @@ -12,8 +12,8 @@ use crate::error::{ErrorData, Result}; use alien_core::{ Daemon, DaemonCode, DaemonHeartbeatData, DaemonOutputs, HeartbeatBackend, LocalDaemonHeartbeatData, LocalRuntimeUnitKind, LocalRuntimeUnitStatus, ObservedHealth, - Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, - ResourceOutputs, ResourceStatus, WorkloadHeartbeatStatus, + Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, + ResourceStatus, WorkloadHeartbeatStatus, }; use alien_error::{AlienError, Context}; use alien_macros::controller; diff --git a/crates/alien-infra/src/lib.rs b/crates/alien-infra/src/lib.rs index 14798bb21..af9531f41 100644 --- a/crates/alien-infra/src/lib.rs +++ b/crates/alien-infra/src/lib.rs @@ -63,14 +63,14 @@ mod ai; pub use ai::AwsAiController; #[cfg(feature = "aws")] pub use ai::AwsAiImporter; -#[cfg(feature = "gcp")] -pub use ai::GcpAiController; -#[cfg(feature = "gcp")] -pub use ai::GcpAiImporter; #[cfg(feature = "azure")] pub use ai::AzureAiController; #[cfg(feature = "azure")] pub use ai::AzureAiImporter; +#[cfg(feature = "gcp")] +pub use ai::GcpAiController; +#[cfg(feature = "gcp")] +pub use ai::GcpAiImporter; mod kv; #[cfg(feature = "aws")] diff --git a/crates/alien-infra/src/remote_stack_management/azure.rs b/crates/alien-infra/src/remote_stack_management/azure.rs index 3ebebd2dc..ea2fcb5cb 100644 --- a/crates/alien-infra/src/remote_stack_management/azure.rs +++ b/crates/alien-infra/src/remote_stack_management/azure.rs @@ -1074,9 +1074,12 @@ mod tests { PermissionSetReference::from_name("service-account/heartbeat"), ]); - let grant_plan = - generate_stack_management_grant_plan(&profile, &permission_context(), &Default::default()) - .unwrap(); + let grant_plan = generate_stack_management_grant_plan( + &profile, + &permission_context(), + &Default::default(), + ) + .unwrap(); assert!( grant_plan.custom_roles.iter().any(|role| role @@ -1134,8 +1137,9 @@ mod tests { [PermissionSetReference::from_name("worker/dispatch-command")], ); - let live: std::collections::HashSet = - ["api".to_string(), "jobs".to_string()].into_iter().collect(); + let live: std::collections::HashSet = ["api".to_string(), "jobs".to_string()] + .into_iter() + .collect(); let grant_plan = generate_stack_management_grant_plan(&profile, &permission_context(), &live).unwrap(); @@ -1161,9 +1165,12 @@ mod tests { [PermissionSetReference::from_name("worker/dispatch-command")], ); - let grant_plan = - generate_stack_management_grant_plan(&profile, &permission_context(), &Default::default()) - .unwrap(); + let grant_plan = generate_stack_management_grant_plan( + &profile, + &permission_context(), + &Default::default(), + ) + .unwrap(); assert!( grant_plan @@ -1439,12 +1446,11 @@ impl AzureRemoteStackManagementController { }; let generator = AzureRuntimePermissionsGenerator::new(); - let grant_plan = - generate_stack_management_grant_plan( - management_profile, - &permission_context, - &ctx.desired_stack.resources.keys().cloned().collect(), - )?; + let grant_plan = generate_stack_management_grant_plan( + management_profile, + &permission_context, + &ctx.desired_stack.resources.keys().cloned().collect(), + )?; custom_roles.extend(grant_plan.custom_roles); bindings.extend(grant_plan.bindings); diff --git a/crates/alien-infra/src/worker/aws.rs b/crates/alien-infra/src/worker/aws.rs index 06667ec93..6105acb87 100644 --- a/crates/alien-infra/src/worker/aws.rs +++ b/crates/alien-infra/src/worker/aws.rs @@ -11,6 +11,12 @@ use crate::error::{ErrorData, Result}; use crate::worker::readiness_probe::{ run_readiness_probe_with_dns_override, ReadinessProbeDnsOverride, READINESS_PROBE_MAX_ATTEMPTS, }; +use alien_aws_clients::apigateway::{ + CreateBasePathMappingRequest, CreateDeploymentRequest, + CreateDomainNameRequest as CreateRestDomainNameRequest, CreateResourceRequest, + CreateRestApiRequest, EndpointConfiguration as RestEndpointConfiguration, + PutIntegrationRequest, PutMethodRequest, +}; use alien_aws_clients::apigatewayv2::{ CreateApiMappingRequest, CreateApiRequest, CreateDomainNameRequest, CreateIntegrationRequest, CreateRouteRequest, CreateStageRequest, DomainNameConfiguration, @@ -182,6 +188,17 @@ impl AwsWorkerController { } } + /// Streaming forks to REST V1 — the only API Gateway flavor that both + /// streams and carries a custom domain — while buffered workers stay on + /// the V2 HTTP API. + fn gateway_entry_state(worker: &Worker) -> AwsWorkerState { + if worker_wants_streaming(worker) { + AwsWorkerState::CreatingRestApi + } else { + AwsWorkerState::CreatingApiGateway + } + } + fn unexpected_update_wrapper_state( resource_id: &str, handler: &str, @@ -218,6 +235,22 @@ pub struct AwsWorkerController { pub(crate) stage_name: Option, /// API Gateway API mapping ID pub(crate) api_mapping_id: Option, + /// API Gateway REST (V1) API ID, set for streaming workers + #[serde(default)] + pub(crate) rest_api_id: Option, + /// REST API root resource ID + #[serde(default)] + pub(crate) rest_root_resource_id: Option, + /// REST API `{proxy+}` resource ID + #[serde(default)] + pub(crate) rest_proxy_resource_id: Option, + /// REST API deployment ID + #[serde(default)] + pub(crate) rest_deployment_id: Option, + /// REST API base path mapping key, stored verbatim from the API (the empty + /// base path is the literal `(none)`, which deletion must address by key) + #[serde(default)] + pub(crate) rest_base_path: Option, /// API Gateway domain name pub(crate) domain_name: Option, /// Endpoint metadata for DNS controller @@ -305,6 +338,16 @@ fn emit_aws_lambda_worker_heartbeat( }); } +/// Streaming REST (V1) exposure is opt-in per worker through +/// `WORKER_RESPONSE_STREAMING=true` in its environment. +fn worker_wants_streaming(worker: &Worker) -> bool { + worker + .environment + .get("WORKER_RESPONSE_STREAMING") + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + #[controller] impl AwsWorkerController { // ─────────────── CREATE FLOW ────────────────────────────── @@ -507,7 +550,7 @@ impl AwsWorkerController { WaitingForCertificate } else { // Standalone mode: skip certificate/custom domain, use API Gateway default endpoint - CreatingApiGateway + Self::gateway_entry_state(&worker_config) }; Ok(HandlerAction::Continue { state: next_state, @@ -552,13 +595,13 @@ impl AwsWorkerController { let status = metadata.map(|m| &m.certificate_status); if !self.ensure_domain_info(ctx, &worker_config.id)? { return Ok(HandlerAction::Continue { - state: CreatingApiGateway, + state: Self::gateway_entry_state(&worker_config), suggested_delay: Some(Duration::from_secs(1)), }); } if self.uses_custom_domain && self.certificate_arn.is_some() { return Ok(HandlerAction::Continue { - state: CreatingApiGateway, + state: Self::gateway_entry_state(&worker_config), suggested_delay: Some(Duration::from_secs(1)), }); } @@ -647,7 +690,7 @@ impl AwsWorkerController { self.certificate_issued_at = resource.issued_at.clone(); Ok(HandlerAction::Continue { - state: CreatingApiGateway, + state: Self::gateway_entry_state(&worker_config), suggested_delay: None, }) } @@ -661,6 +704,16 @@ impl AwsWorkerController { &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + + // Streaming workers reroute to REST V1 (see gateway_entry_state). + if worker_wants_streaming(&worker_config) { + return Ok(HandlerAction::Continue { + state: CreatingRestApi, + suggested_delay: None, + }); + } + if self.api_id.is_some() { return Ok(HandlerAction::Continue { state: CreatingApiIntegration, @@ -673,7 +726,6 @@ impl AwsWorkerController { .service_provider .get_aws_apigatewayv2_client(aws_cfg) .await?; - let worker_config = ctx.desired_resource_config::()?; let api_tags = standard_resource_tags(ctx.resource_prefix, &worker_config.id); let api = client @@ -1068,193 +1120,665 @@ impl AwsWorkerController { } #[handler( - state = AddingApiGatewayPermission, + state = CreatingRestApi, on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn adding_api_gateway_permission( + async fn creating_rest_api( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { + if self.rest_api_id.is_some() { + return Ok(HandlerAction::Continue { + state: CreatingRestResource, + suggested_delay: Some(Duration::from_secs(1)), + }); + } + let aws_cfg = ctx.get_aws_config()?; - let client = ctx.service_provider.get_aws_lambda_client(aws_cfg).await?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; let worker_config = ctx.desired_resource_config::()?; - let aws_worker_name = get_aws_worker_name(ctx.resource_prefix, &worker_config.id); - - let request = AddPermissionRequest::builder() - .statement_id("ApiGatewayInvoke".to_string()) - .action("lambda:InvokeFunction".to_string()) - .principal("apigateway.amazonaws.com".to_string()) - .build(); + let api_tags = standard_resource_tags(ctx.resource_prefix, &worker_config.id); - client - .add_permission(&aws_worker_name, request) + let api = client + .create_rest_api( + CreateRestApiRequest::builder() + .name(format!("{}-{}-api", ctx.resource_prefix, worker_config.id)) + .endpoint_configuration(RestEndpointConfiguration { + types: vec!["REGIONAL".to_string()], + }) + .tags(api_tags) + .build(), + ) .await .context(ErrorData::CloudPlatformError { - message: "Failed to add API Gateway permission".to_string(), + message: "Failed to create API Gateway REST API".to_string(), resource_id: Some(worker_config.id.clone()), })?; - if self.fqdn.is_some() { - if self.uses_custom_domain { - // Custom domain: readiness probe then done - Ok(HandlerAction::Continue { - state: RunningReadinessProbe, - suggested_delay: None, - }) - } else { - // Platform-managed domain: wait for DNS propagation - Ok(HandlerAction::Continue { - state: WaitingForDns, - suggested_delay: Some(Duration::from_secs(5)), - }) - } - } else { - // Standalone mode: no custom domain, skip DNS and readiness probe - Ok(HandlerAction::Continue { - state: ApplyingResourcePermissions, - suggested_delay: None, + let rest_api_id = api.id.clone().ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "REST API ID not returned".to_string(), + resource_id: Some(worker_config.id.clone()), }) - } + })?; + let root_resource_id = api.root_resource_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "REST API root resource ID not returned".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + self.rest_api_id = Some(rest_api_id); + self.rest_root_resource_id = Some(root_resource_id); + + Ok(HandlerAction::Continue { + state: CreatingRestResource, + suggested_delay: Some(Duration::from_secs(1)), + }) } #[handler( - state = WaitingForDns, + state = CreatingRestResource, on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn waiting_for_dns( + async fn creating_rest_resource( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { + if self.rest_proxy_resource_id.is_some() { + return Ok(HandlerAction::Continue { + state: CreatingRestMethods, + suggested_delay: Some(Duration::from_secs(1)), + }); + } + + let aws_cfg = ctx.get_aws_config()?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; let worker_config = ctx.desired_resource_config::()?; - let metadata = ctx - .deployment_config - .domain_metadata - .as_ref() - .and_then(|meta| meta.resources.get(&worker_config.id)); - let status = metadata.map(|m| &m.dns_status); + let rest_api_id = self.rest_api_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API ID missing for proxy resource".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + let root_resource_id = self.rest_root_resource_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API root resource ID missing for proxy resource".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; - match status { - Some(DnsRecordStatus::Active) => Ok(HandlerAction::Continue { - state: RunningReadinessProbe, - suggested_delay: None, - }), - Some(DnsRecordStatus::Failed) => { - let fqdn = metadata.map(|m| m.fqdn.as_str()).unwrap_or("unknown"); - let detail = metadata - .and_then(|m| m.dns_error.as_deref()) - .unwrap_or("unknown error"); - Err(AlienError::new(ErrorData::CloudPlatformError { - message: format!("DNS record creation failed for {fqdn}: {detail}"), - resource_id: Some(worker_config.id.clone()), - })) - } - _ => Ok(HandlerAction::Stay { - max_times: Some(60), - suggested_delay: Some(Duration::from_secs(5)), - }), - } + let resource = client + .create_resource( + &rest_api_id, + &root_resource_id, + CreateResourceRequest { + path_part: "{proxy+}".to_string(), + }, + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to create REST API proxy resource".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; + + let proxy_resource_id = resource.id.clone().ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "REST API proxy resource ID not returned".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + self.rest_proxy_resource_id = Some(proxy_resource_id); + + Ok(HandlerAction::Continue { + state: CreatingRestMethods, + suggested_delay: Some(Duration::from_secs(1)), + }) } #[handler( - state = RunningReadinessProbe, + state = CreatingRestMethods, on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn running_readiness_probe( + async fn creating_rest_methods( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { + let aws_cfg = ctx.get_aws_config()?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; let worker_config = ctx.desired_resource_config::()?; - // Only run readiness probe if configured and we have a URL (for public workers) - if worker_config.readiness_probe.is_some() && !worker_config.public_endpoints.is_empty() { - if let Some(url) = &self.url { - let dns_override = readiness_probe_dns_override( - url, - self.fqdn.as_deref(), - self.load_balancer.as_ref(), - ); + let rest_api_id = self.rest_api_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API ID missing for methods".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + let root_resource_id = self.rest_root_resource_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API root resource ID missing for methods".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + let proxy_resource_id = self.rest_proxy_resource_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API proxy resource ID missing for methods".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + let function_arn = self.arn.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Worker ARN missing for REST integration".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; - match run_readiness_probe_with_dns_override(ctx, url, dns_override).await { - Ok(()) => { - // Probe succeeded, proceed to Ready - } - Err(_) => { - // Probe failed, let the framework handle retries - return Ok(HandlerAction::Stay { - max_times: Some(READINESS_PROBE_MAX_ATTEMPTS), - suggested_delay: Some(Duration::from_secs(5)), - }); - } + let integration_uri = format!( + "arn:aws:apigateway:{}:lambda:path/2021-11-15/functions/{}/response-streaming-invocations", + aws_cfg.region, function_arn + ); + + // Both the root resource and `{proxy+}` need the ANY method so the REST + // API catches every path, matching the V2 `$default` catch-all route. + // put_integration overwrites idempotently on retry, but put_method + // returns ConflictException once the method exists (which the client + // maps to a *retryable* error) — so a retry after a mid-loop throttle + // would loop to CreateFailed. Tolerate that conflict as success. + for resource_id in [&root_resource_id, &proxy_resource_id] { + if let Err(e) = client + .put_method( + &rest_api_id, + resource_id, + "ANY", + PutMethodRequest { + authorization_type: "NONE".to_string(), + }, + ) + .await + { + if !is_remote_resource_conflict(&e) { + return Err(e.context(ErrorData::CloudPlatformError { + message: "Failed to put REST API method".to_string(), + resource_id: Some(worker_config.id.clone()), + })); } } + + client + .put_integration( + &rest_api_id, + resource_id, + "ANY", + PutIntegrationRequest::builder() + .integration_type("AWS_PROXY".to_string()) + .integration_http_method("POST".to_string()) + .uri(integration_uri.clone()) + .response_transfer_mode("STREAM".to_string()) + .timeout_in_millis(900_000) + .build(), + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to put REST API streaming integration".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; } - // Either no readiness probe needed, or probe succeeded - proceed to ApplyingResourcePermissions Ok(HandlerAction::Continue { - state: ApplyingResourcePermissions, - suggested_delay: None, + state: CreatingRestDeployment, + suggested_delay: Some(Duration::from_secs(1)), }) } #[handler( - state = ApplyingResourcePermissions, + state = CreatingRestDeployment, on_failure = CreateFailed, status = ResourceStatus::Provisioning, )] - async fn applying_resource_permissions( + async fn creating_rest_deployment( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { - let config = ctx.desired_resource_config::()?; + let worker_config = ctx.desired_resource_config::()?; + let aws_cfg = ctx.get_aws_config()?; - info!(worker=%config.id, "Applying resource-scoped permissions for Lambda worker"); + let rest_api_id = self.rest_api_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API ID missing for deployment".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; - // Apply resource-scoped permissions from the stack using the centralized helper. - // This handles wildcard ("*") permissions and management SA permissions. - if let Some(worker_name) = &self - .arn - .as_ref() - .and_then(|arn| arn.split(':').last().map(|s| s.to_string())) - { - use crate::core::ResourcePermissionsHelper; - ResourcePermissionsHelper::apply_aws_resource_scoped_permissions( - ctx, - &config.id, - &worker_name, - "worker", - ) + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) .await?; - } - info!(worker=%config.id, "Successfully applied resource-scoped permissions"); + if self.rest_deployment_id.is_none() { + let deployment = client + .create_deployment( + &rest_api_id, + CreateDeploymentRequest { + stage_name: "prod".to_string(), + }, + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to create REST API deployment".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; - Ok(HandlerAction::Continue { - state: UpdatingEnvVarsWithSelfBinding, - suggested_delay: None, - }) - } + let deployment_id = deployment.id.clone().ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "REST API deployment ID not returned".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; - #[handler( - state = UpdatingEnvVarsWithSelfBinding, - on_failure = CreateFailed, - status = ResourceStatus::Provisioning, - )] - async fn updating_env_vars_with_self_binding( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result { - let config = ctx.desired_resource_config::()?; + self.rest_deployment_id = Some(deployment_id); + self.stage_name = Some("prod".to_string()); + } - // Skip this step if the worker doesn't have public ingress - // For private workers, the initial env vars already have complete self-binding - // (no URL to add later) - if config.public_endpoints.is_empty() { - info!(worker=%config.id, "Skipping env var update - no public URL to add"); - return Ok(HandlerAction::Continue { + // CreateDeployment takes no tags, so the stage it creates would sit outside + // the deployment's tag boundary that every management grant is conditioned + // on. Tagging runs outside the creation guard because it is idempotent and + // a retry must reach it even once the deployment id is recorded. + let region = &aws_cfg.region; + client + .tag_resource( + &format!("arn:aws:apigateway:{region}::/restapis/{rest_api_id}/stages/prod"), + standard_resource_tags(ctx.resource_prefix, &worker_config.id), + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to tag REST API stage".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; + + if self.fqdn.is_some() { + Ok(HandlerAction::Continue { + state: CreatingRestDomain, + suggested_delay: Some(Duration::from_secs(1)), + }) + } else { + // Standalone mode: the REST default endpoint serves under /{stage}, + // unlike the V2 `$default` stage which serves at the domain root. + self.url = Some(format!( + "https://{}.execute-api.{}.amazonaws.com/prod", + rest_api_id, aws_cfg.region + )); + Ok(HandlerAction::Continue { + state: AddingApiGatewayPermission, + suggested_delay: Some(Duration::from_secs(1)), + }) + } + } + + #[handler( + state = CreatingRestDomain, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn creating_rest_domain( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + if self.load_balancer.is_some() { + return Ok(HandlerAction::Continue { + state: CreatingRestBasePathMapping, + suggested_delay: Some(Duration::from_secs(1)), + }); + } + + let aws_cfg = ctx.get_aws_config()?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + let worker_config = ctx.desired_resource_config::()?; + + let fqdn = self.fqdn.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "FQDN missing for REST API domain".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + let cert_arn = self.certificate_arn.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Certificate ARN missing for REST API domain".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + let domain = client + .create_domain_name( + CreateRestDomainNameRequest::builder() + .domain_name(fqdn.clone()) + .regional_certificate_arn(cert_arn) + .endpoint_configuration(RestEndpointConfiguration { + types: vec!["REGIONAL".to_string()], + }) + .security_policy("TLS_1_2".to_string()) + .tags(standard_resource_tags( + ctx.resource_prefix, + &worker_config.id, + )) + .build(), + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to create REST API domain name".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; + + let endpoint = domain.regional_domain_name.clone().and_then(|dns_name| { + let hosted_zone_id = domain.regional_hosted_zone_id.clone()?; + Some(LoadBalancerEndpoint { + dns_name, + hosted_zone_id, + }) + }); + + self.domain_name = Some(fqdn); + self.load_balancer = Some(LoadBalancerState { endpoint }); + + Ok(HandlerAction::Continue { + state: CreatingRestBasePathMapping, + suggested_delay: Some(Duration::from_secs(1)), + }) + } + + #[handler( + state = CreatingRestBasePathMapping, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn creating_rest_base_path_mapping( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + if self.rest_base_path.is_some() { + return Ok(HandlerAction::Continue { + state: AddingApiGatewayPermission, + suggested_delay: Some(Duration::from_secs(1)), + }); + } + + let aws_cfg = ctx.get_aws_config()?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + let worker_config = ctx.desired_resource_config::()?; + + let rest_api_id = self.rest_api_id.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "REST API ID missing for base path mapping".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + let domain_name = self.domain_name.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Domain name missing for base path mapping".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + let stage = self + .stage_name + .clone() + .unwrap_or_else(|| "prod".to_string()); + + let mapping = client + .create_base_path_mapping( + &domain_name, + CreateBasePathMappingRequest { + rest_api_id, + stage, + base_path: None, + }, + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to create REST API base path mapping".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; + + // The API reports the empty base path as the literal `(none)`, which is + // also the key deletion must use. + self.rest_base_path = Some( + mapping + .base_path + .clone() + .unwrap_or_else(|| "(none)".to_string()), + ); + + Ok(HandlerAction::Continue { + state: AddingApiGatewayPermission, + suggested_delay: Some(Duration::from_secs(1)), + }) + } + + #[handler( + state = AddingApiGatewayPermission, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn adding_api_gateway_permission( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let aws_cfg = ctx.get_aws_config()?; + let client = ctx.service_provider.get_aws_lambda_client(aws_cfg).await?; + let worker_config = ctx.desired_resource_config::()?; + let aws_worker_name = get_aws_worker_name(ctx.resource_prefix, &worker_config.id); + + // Without a source ARN the grant lets any API Gateway in any account + // invoke this function, so scope it to whichever API fronts the worker. + let api_id = self + .rest_api_id + .as_ref() + .or(self.api_id.as_ref()) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "API Gateway permission needs the API it fronts".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + let request = AddPermissionRequest::builder() + .statement_id("ApiGatewayInvoke".to_string()) + .action("lambda:InvokeFunction".to_string()) + .principal("apigateway.amazonaws.com".to_string()) + .source_arn(format!( + "arn:aws:execute-api:{}:{}:{api_id}/*", + aws_cfg.region, aws_cfg.account_id + )) + .build(); + + client + .add_permission(&aws_worker_name, request) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to add API Gateway permission".to_string(), + resource_id: Some(worker_config.id.clone()), + })?; + + if self.fqdn.is_some() { + if self.uses_custom_domain { + // Custom domain: readiness probe then done + Ok(HandlerAction::Continue { + state: RunningReadinessProbe, + suggested_delay: None, + }) + } else { + // Platform-managed domain: wait for DNS propagation + Ok(HandlerAction::Continue { + state: WaitingForDns, + suggested_delay: Some(Duration::from_secs(5)), + }) + } + } else { + // Standalone mode: no custom domain, skip DNS and readiness probe + Ok(HandlerAction::Continue { + state: ApplyingResourcePermissions, + suggested_delay: None, + }) + } + } + + #[handler( + state = WaitingForDns, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn waiting_for_dns( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + let metadata = ctx + .deployment_config + .domain_metadata + .as_ref() + .and_then(|meta| meta.resources.get(&worker_config.id)); + + let status = metadata.map(|m| &m.dns_status); + + match status { + Some(DnsRecordStatus::Active) => Ok(HandlerAction::Continue { + state: RunningReadinessProbe, + suggested_delay: None, + }), + Some(DnsRecordStatus::Failed) => { + let fqdn = metadata.map(|m| m.fqdn.as_str()).unwrap_or("unknown"); + let detail = metadata + .and_then(|m| m.dns_error.as_deref()) + .unwrap_or("unknown error"); + Err(AlienError::new(ErrorData::CloudPlatformError { + message: format!("DNS record creation failed for {fqdn}: {detail}"), + resource_id: Some(worker_config.id.clone()), + })) + } + _ => Ok(HandlerAction::Stay { + max_times: Some(60), + suggested_delay: Some(Duration::from_secs(5)), + }), + } + } + + #[handler( + state = RunningReadinessProbe, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn running_readiness_probe( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + + // Only run readiness probe if configured and we have a URL (for public workers) + if worker_config.readiness_probe.is_some() && !worker_config.public_endpoints.is_empty() { + if let Some(url) = &self.url { + let dns_override = readiness_probe_dns_override( + url, + self.fqdn.as_deref(), + self.load_balancer.as_ref(), + ); + + match run_readiness_probe_with_dns_override(ctx, url, dns_override).await { + Ok(()) => { + // Probe succeeded, proceed to Ready + } + Err(_) => { + // Probe failed, let the framework handle retries + return Ok(HandlerAction::Stay { + max_times: Some(READINESS_PROBE_MAX_ATTEMPTS), + suggested_delay: Some(Duration::from_secs(5)), + }); + } + } + } + } + + // Either no readiness probe needed, or probe succeeded - proceed to ApplyingResourcePermissions + Ok(HandlerAction::Continue { + state: ApplyingResourcePermissions, + suggested_delay: None, + }) + } + + #[handler( + state = ApplyingResourcePermissions, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn applying_resource_permissions( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + + info!(worker=%config.id, "Applying resource-scoped permissions for Lambda worker"); + + // Apply resource-scoped permissions from the stack using the centralized helper. + // This handles wildcard ("*") permissions and management SA permissions. + if let Some(worker_name) = &self + .arn + .as_ref() + .and_then(|arn| arn.split(':').last().map(|s| s.to_string())) + { + use crate::core::ResourcePermissionsHelper; + ResourcePermissionsHelper::apply_aws_resource_scoped_permissions( + ctx, + &config.id, + &worker_name, + "worker", + ) + .await?; + } + + info!(worker=%config.id, "Successfully applied resource-scoped permissions"); + + Ok(HandlerAction::Continue { + state: UpdatingEnvVarsWithSelfBinding, + suggested_delay: None, + }) + } + + #[handler( + state = UpdatingEnvVarsWithSelfBinding, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn updating_env_vars_with_self_binding( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + + // Skip this step if the worker doesn't have public ingress + // For private workers, the initial env vars already have complete self-binding + // (no URL to add later) + if config.public_endpoints.is_empty() { + info!(worker=%config.id, "Skipping env var update - no public URL to add"); + return Ok(HandlerAction::Continue { state: CreatingEventSourceMappings, suggested_delay: None, }); @@ -2103,76 +2627,276 @@ impl AwsWorkerController { } #[handler( - state = UpdateEnsuringPublicExposure, + state = UpdateEnsuringPublicExposure, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_ensuring_public_exposure( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let current_config = ctx.desired_resource_config::()?; + let previous_config = ctx.previous_resource_config::()?; + + if current_config.public_endpoints.is_empty() { + return Ok(HandlerAction::Continue { + state: UpdateRunningReadinessProbe, + suggested_delay: None, + }); + } + + if previous_config.public_endpoints.is_empty() + && self.api_id.is_none() + && self.rest_api_id.is_none() + { + self.url = None; + } + + let has_domain_info = self.ensure_domain_info(ctx, ¤t_config.id)?; + if self.api_id.is_some() || self.rest_api_id.is_some() { + return Ok(HandlerAction::Continue { + state: UpdateRunningReadinessProbe, + suggested_delay: None, + }); + } + + let next_state = if has_domain_info { + UpdateWaitingForCertificate + } else if worker_wants_streaming(¤t_config) { + UpdateCreatingRestApi + } else { + UpdateCreatingApiGateway + }; + + Ok(HandlerAction::Continue { + state: next_state, + suggested_delay: Some(Duration::from_secs(2)), + }) + } + + #[handler( + state = UpdateWaitingForCertificate, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_waiting_for_certificate( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.waiting_for_certificate(ctx).await? { + HandlerAction::Continue { + state: ImportingCertificate, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateImportingInitialCertificate, + suggested_delay, + }), + HandlerAction::Continue { + state: CreatingApiGateway, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiGateway, + suggested_delay, + }), + HandlerAction::Continue { + state: CreatingRestApi, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestApi, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "waiting_for_certificate", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateImportingInitialCertificate, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_importing_initial_certificate( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.importing_certificate(ctx).await? { + HandlerAction::Continue { + state: CreatingApiGateway, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiGateway, + suggested_delay, + }), + HandlerAction::Continue { + state: CreatingRestApi, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestApi, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "importing_certificate", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingApiGateway, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_api_gateway( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_api_gateway(ctx).await? { + HandlerAction::Continue { + state: CreatingApiIntegration, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiIntegration, + suggested_delay, + }), + // Reached when a worker with no endpoint yet reruns this state and + // now wants streaming: creating_api_gateway reroutes to REST V1. + HandlerAction::Continue { + state: CreatingRestApi, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestApi, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_api_gateway", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingApiIntegration, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_ensuring_public_exposure( + async fn update_creating_api_integration( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { - let current_config = ctx.desired_resource_config::()?; - let previous_config = ctx.previous_resource_config::()?; - - if current_config.public_endpoints.is_empty() { - return Ok(HandlerAction::Continue { - state: UpdateRunningReadinessProbe, - suggested_delay: None, - }); - } - - if previous_config.public_endpoints.is_empty() && self.api_id.is_none() { - self.url = None; + let worker_config = ctx.desired_resource_config::()?; + match self.creating_api_integration(ctx).await? { + HandlerAction::Continue { + state: CreatingApiRoute, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiRoute, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_api_integration", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), } + } - let has_domain_info = self.ensure_domain_info(ctx, ¤t_config.id)?; - if self.api_id.is_some() { - return Ok(HandlerAction::Continue { - state: UpdateRunningReadinessProbe, - suggested_delay: None, - }); + #[handler( + state = UpdateCreatingApiRoute, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_api_route( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_api_route(ctx).await? { + HandlerAction::Continue { + state: CreatingApiStage, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiStage, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_api_route", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), } - - let next_state = if has_domain_info { - UpdateWaitingForCertificate - } else { - UpdateCreatingApiGateway - }; - - Ok(HandlerAction::Continue { - state: next_state, - suggested_delay: Some(Duration::from_secs(2)), - }) } #[handler( - state = UpdateWaitingForCertificate, + state = UpdateCreatingApiStage, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_waiting_for_certificate( + async fn update_creating_api_stage( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.waiting_for_certificate(ctx).await? { + match self.creating_api_stage(ctx).await? { HandlerAction::Continue { - state: ImportingCertificate, + state: CreatingApiDomain, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateImportingInitialCertificate, + state: UpdateCreatingApiDomain, suggested_delay, }), HandlerAction::Continue { - state: CreatingApiGateway, + state: AddingApiGatewayPermission, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiGateway, + state: UpdateAddingApiGatewayPermission, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "waiting_for_certificate", + "creating_api_stage", state, )), HandlerAction::Stay { @@ -2186,26 +2910,26 @@ impl AwsWorkerController { } #[handler( - state = UpdateImportingInitialCertificate, + state = UpdateCreatingApiDomain, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_importing_initial_certificate( + async fn update_creating_api_domain( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.importing_certificate(ctx).await? { + match self.creating_api_domain(ctx).await? { HandlerAction::Continue { - state: CreatingApiGateway, + state: CreatingApiMapping, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiGateway, + state: UpdateCreatingApiMapping, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "importing_certificate", + "creating_api_domain", state, )), HandlerAction::Stay { @@ -2219,26 +2943,26 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiGateway, + state = UpdateCreatingApiMapping, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_gateway( + async fn update_creating_api_mapping( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_gateway(ctx).await? { + match self.creating_api_mapping(ctx).await? { HandlerAction::Continue { - state: CreatingApiIntegration, + state: AddingApiGatewayPermission, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiIntegration, + state: UpdateAddingApiGatewayPermission, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_gateway", + "creating_api_mapping", state, )), HandlerAction::Stay { @@ -2252,26 +2976,26 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiIntegration, + state = UpdateCreatingRestApi, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_integration( + async fn update_creating_rest_api( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_integration(ctx).await? { + match self.creating_rest_api(ctx).await? { HandlerAction::Continue { - state: CreatingApiRoute, + state: CreatingRestResource, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiRoute, + state: UpdateCreatingRestResource, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_integration", + "creating_rest_api", state, )), HandlerAction::Stay { @@ -2285,26 +3009,26 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiRoute, + state = UpdateCreatingRestResource, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_route( + async fn update_creating_rest_resource( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_route(ctx).await? { + match self.creating_rest_resource(ctx).await? { HandlerAction::Continue { - state: CreatingApiStage, + state: CreatingRestMethods, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiStage, + state: UpdateCreatingRestMethods, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_route", + "creating_rest_resource", state, )), HandlerAction::Stay { @@ -2318,21 +3042,54 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiStage, + state = UpdateCreatingRestMethods, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_stage( + async fn update_creating_rest_methods( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_stage(ctx).await? { + match self.creating_rest_methods(ctx).await? { HandlerAction::Continue { - state: CreatingApiDomain, + state: CreatingRestDeployment, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiDomain, + state: UpdateCreatingRestDeployment, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_methods", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingRestDeployment, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_deployment( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_deployment(ctx).await? { + HandlerAction::Continue { + state: CreatingRestDomain, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestDomain, suggested_delay, }), HandlerAction::Continue { @@ -2344,7 +3101,7 @@ impl AwsWorkerController { }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_stage", + "creating_rest_deployment", state, )), HandlerAction::Stay { @@ -2358,26 +3115,26 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiDomain, + state = UpdateCreatingRestDomain, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_domain( + async fn update_creating_rest_domain( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_domain(ctx).await? { + match self.creating_rest_domain(ctx).await? { HandlerAction::Continue { - state: CreatingApiMapping, + state: CreatingRestBasePathMapping, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiMapping, + state: UpdateCreatingRestBasePathMapping, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_domain", + "creating_rest_domain", state, )), HandlerAction::Stay { @@ -2391,16 +3148,16 @@ impl AwsWorkerController { } #[handler( - state = UpdateCreatingApiMapping, + state = UpdateCreatingRestBasePathMapping, on_failure = UpdateFailed, status = ResourceStatus::Updating, )] - async fn update_creating_api_mapping( + async fn update_creating_rest_base_path_mapping( &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { let worker_config = ctx.desired_resource_config::()?; - match self.creating_api_mapping(ctx).await? { + match self.creating_rest_base_path_mapping(ctx).await? { HandlerAction::Continue { state: AddingApiGatewayPermission, suggested_delay, @@ -2410,7 +3167,7 @@ impl AwsWorkerController { }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( &worker_config.id, - "creating_api_mapping", + "creating_rest_base_path_mapping", state, )), HandlerAction::Stay { @@ -3033,6 +3790,45 @@ impl AwsWorkerController { let worker_config = ctx.desired_resource_config::()?; // Ordering matters: delete API mapping before domain name, domain name before API. + // A worker is V1 xor V2, so at most one branch of each pair runs. The REST V1 + // order is the same — base path mapping, then domain, then the REST API, whose + // deletion cascades to resources, methods, deployments, and stages. + if let (Some(domain_name), true) = (self.domain_name.as_ref(), self.rest_api_id.is_some()) { + // An imported worker never learns its mapping key, so fall back to the + // literal the API reports for an empty base path. Deleting the domain + // while a mapping still points at it fails. + let base_path = self + .rest_base_path + .clone() + .unwrap_or_else(|| "(none)".to_string()); + let base_path = &base_path; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + match client + .delete_base_path_mapping(domain_name, base_path) + .await + { + Ok(()) => info!(worker=%worker_config.id, "REST base path mapping deleted"), + Err(e) + if matches!( + e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(worker=%worker_config.id, "REST base path mapping already gone"); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: "Failed to delete REST base path mapping".to_string(), + resource_id: Some(worker_config.id.clone()), + })); + } + } + } + self.rest_base_path = None; + if let (Some(domain_name), Some(api_mapping_id)) = (self.domain_name.as_ref(), self.api_mapping_id.as_ref()) { @@ -3061,13 +3857,67 @@ impl AwsWorkerController { self.api_mapping_id = None; if let Some(domain_name) = self.domain_name.as_ref() { + if self.rest_api_id.is_some() { + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + match client.delete_domain_name(domain_name).await { + Ok(()) => { + info!(worker=%worker_config.id, domain=%domain_name, "Custom domain deleted") + } + Err(e) + if matches!( + e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(worker=%worker_config.id, "Custom domain already gone or inaccessible"); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: "Failed to delete custom domain".to_string(), + resource_id: Some(worker_config.id.clone()), + })); + } + } + } else { + let client = ctx + .service_provider + .get_aws_apigatewayv2_client(aws_cfg) + .await?; + match client.delete_domain_name(domain_name).await { + Ok(()) => { + info!(worker=%worker_config.id, domain=%domain_name, "Custom domain deleted") + } + Err(e) + if matches!( + e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(worker=%worker_config.id, "Custom domain already gone"); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: "Failed to delete custom domain".to_string(), + resource_id: Some(worker_config.id.clone()), + })); + } + } + } + } + self.domain_name = None; + + // Deleting the API cascades to routes, integrations, and stages. + if let Some(api_id) = self.api_id.as_ref() { let client = ctx .service_provider .get_aws_apigatewayv2_client(aws_cfg) .await?; - match client.delete_domain_name(domain_name).await { + match client.delete_api(api_id).await { Ok(()) => { - info!(worker=%worker_config.id, domain=%domain_name, "Custom domain deleted") + info!(worker=%worker_config.id, api_id=%api_id, "API Gateway deleted") } Err(e) if matches!( @@ -3075,27 +3925,28 @@ impl AwsWorkerController { Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { - info!(worker=%worker_config.id, "Custom domain already gone"); + info!(worker=%worker_config.id, "API Gateway already gone"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { - message: "Failed to delete custom domain".to_string(), + message: "Failed to delete API Gateway".to_string(), resource_id: Some(worker_config.id.clone()), })); } } } - self.domain_name = None; + self.api_id = None; + self.integration_id = None; + self.route_id = None; - // Deleting the API cascades to routes, integrations, and stages. - if let Some(api_id) = self.api_id.as_ref() { + if let Some(rest_api_id) = self.rest_api_id.as_ref() { let client = ctx .service_provider - .get_aws_apigatewayv2_client(aws_cfg) + .get_aws_apigateway_client(aws_cfg) .await?; - match client.delete_api(api_id).await { + match client.delete_rest_api(rest_api_id).await { Ok(()) => { - info!(worker=%worker_config.id, api_id=%api_id, "API Gateway deleted") + info!(worker=%worker_config.id, rest_api_id=%rest_api_id, "REST API deleted") } Err(e) if matches!( @@ -3103,19 +3954,20 @@ impl AwsWorkerController { Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { - info!(worker=%worker_config.id, "API Gateway already gone"); + info!(worker=%worker_config.id, "REST API already gone"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { - message: "Failed to delete API Gateway".to_string(), + message: "Failed to delete REST API".to_string(), resource_id: Some(worker_config.id.clone()), })); } } } - self.api_id = None; - self.integration_id = None; - self.route_id = None; + self.rest_api_id = None; + self.rest_root_resource_id = None; + self.rest_proxy_resource_id = None; + self.rest_deployment_id = None; self.stage_name = None; Ok(HandlerAction::Continue { @@ -3982,6 +4834,11 @@ impl AwsWorkerController { route_id: None, stage_name: None, api_mapping_id: None, + rest_api_id: None, + rest_root_resource_id: None, + rest_proxy_resource_id: None, + rest_deployment_id: None, + rest_base_path: None, domain_name: None, load_balancer: None, uses_custom_domain: false, diff --git a/crates/alien-infra/src/worker/aws_import.rs b/crates/alien-infra/src/worker/aws_import.rs index 5379f5b38..36afb6495 100644 --- a/crates/alien-infra/src/worker/aws_import.rs +++ b/crates/alien-infra/src/worker/aws_import.rs @@ -28,6 +28,13 @@ impl ResourceImporter for AwsWorkerImporter { data: AwsWorkerImportData, ctx: &ImportContext<'_>, ) -> Result { + // A worker is V1 xor V2: a REST (V1) worker reuses the controller's + // `stage_name` field for its REST stage. + let stage_name = if data.rest_api_id.is_some() { + data.rest_stage_name + } else { + data.stage_name + }; let controller = AwsWorkerController { state: AwsWorkerState::Ready, arn: Some(data.function_arn), @@ -36,15 +43,23 @@ impl ResourceImporter for AwsWorkerImporter { event_source_mappings: data.event_source_mappings, // Domain / TLS metadata is rebuilt by the controller at heartbeat // time from `DeploymentConfig::domain_metadata`; ImportData only - // carries identifiers, not certificate ARNs. + // carries identifiers, not certificate ARNs. The REST create-time + // ids (root/proxy resource, deployment, base path) are likewise + // left `None` — the heartbeat never reads them, same as the V2 + // mapping/domain fields. fqdn: None, certificate_id: None, certificate_arn: None, api_id: data.api_id, integration_id: data.integration_id, route_id: data.route_id, - stage_name: data.stage_name, + stage_name, api_mapping_id: None, + rest_api_id: data.rest_api_id, + rest_root_resource_id: None, + rest_proxy_resource_id: None, + rest_deployment_id: None, + rest_base_path: None, domain_name: None, load_balancer: None, certificate_issued_at: None, diff --git a/crates/alien-infra/src/worker/local.rs b/crates/alien-infra/src/worker/local.rs index 89dc36a40..4b8d707d0 100644 --- a/crates/alien-infra/src/worker/local.rs +++ b/crates/alien-infra/src/worker/local.rs @@ -9,10 +9,9 @@ use crate::core::{ use crate::error::{ErrorData, Result}; use alien_core::{ HeartbeatBackend, LocalRuntimeUnitKind, LocalRuntimeUnitStatus, LocalWorkerHeartbeatData, - ObservedHealth, Platform, ProviderLifecycleState, ResourceHeartbeat, - ResourceHeartbeatData, ResourceOutputs as CoreResourceOutputs, ResourceStatus, Worker, - WorkerCode, WorkerHeartbeatData, WorkerOutputs, WorkloadHeartbeatStatus, - ENV_ALIEN_COMMANDS_TOKEN, + ObservedHealth, Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, + ResourceOutputs as CoreResourceOutputs, ResourceStatus, Worker, WorkerCode, + WorkerHeartbeatData, WorkerOutputs, WorkloadHeartbeatStatus, ENV_ALIEN_COMMANDS_TOKEN, }; use alien_error::{AlienError, Context, IntoAlienError}; use alien_macros::controller; diff --git a/crates/alien-infra/tests/importers.rs b/crates/alien-infra/tests/importers.rs index ca93b4e02..34c04d9be 100644 --- a/crates/alien-infra/tests/importers.rs +++ b/crates/alien-infra/tests/importers.rs @@ -24,13 +24,13 @@ use alien_core::import::{ data::{ - AwsAiImportData, AwsKvImportData, AwsRemoteStackManagementImportData, AwsServiceAccountImportData, - AwsStorageImportData, AzureAiImportData, - AzureContainerAppsEnvironmentImportData, - AzureRemoteStackManagementImportData, AzureResourceGroupImportData, - AzureServiceAccountImportData, AzureStorageAccountImportData, AzureStorageImportData, - GcpAiImportData, GcpBuildImportData, GcpKvImportData, GcpNetworkImportData, GcpServiceActivationImportData, - GcpStorageImportData, KubernetesClusterImportData, + AwsAiImportData, AwsKvImportData, AwsRemoteStackManagementImportData, + AwsServiceAccountImportData, AwsStorageImportData, AzureAiImportData, + AzureContainerAppsEnvironmentImportData, AzureRemoteStackManagementImportData, + AzureResourceGroupImportData, AzureServiceAccountImportData, AzureStorageAccountImportData, + AzureStorageImportData, GcpAiImportData, GcpBuildImportData, GcpKvImportData, + GcpNetworkImportData, GcpServiceActivationImportData, GcpStorageImportData, + KubernetesClusterImportData, }, ImportContext, }; diff --git a/crates/alien-local/src/local_bindings_provider.rs b/crates/alien-local/src/local_bindings_provider.rs index 3d776e54f..b6a79e9b0 100644 --- a/crates/alien-local/src/local_bindings_provider.rs +++ b/crates/alien-local/src/local_bindings_provider.rs @@ -668,7 +668,7 @@ mod tests { let value: serde_json::Value = serde_json::from_str(raw).expect("binding is JSON"); assert_eq!( value.get("service").and_then(|v| v.as_str()), - Some("external") + Some("external-ai") ); assert_eq!( value.get("provider").and_then(|v| v.as_str()), diff --git a/crates/alien-permissions/permission-sets/worker/management.jsonc b/crates/alien-permissions/permission-sets/worker/management.jsonc index 08ca69ec5..75e4fd2cc 100644 --- a/crates/alien-permissions/permission-sets/worker/management.jsonc +++ b/crates/alien-permissions/permission-sets/worker/management.jsonc @@ -209,9 +209,9 @@ } }, { - // API Gateway V2 API/domain creation. The request must carry boundary tags. + // API Gateway V1/V2 API/domain creation. The request must carry boundary tags. "label": "create-http-api-endpoints", - "description": "Create API Gateway HTTP APIs and custom domain names for application-owned public endpoints.", + "description": "Create API Gateway HTTP (V2) and REST (V1) APIs and custom domain names for application-owned public endpoints.", "grant": { "actions": ["apigateway:POST", "apigateway:PUT"] }, @@ -219,6 +219,7 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis", + "arn:aws:apigateway:${awsRegion}::/restapis", "arn:aws:apigateway:${awsRegion}::/domainnames", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -232,6 +233,7 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis", + "arn:aws:apigateway:${awsRegion}::/restapis", "arn:aws:apigateway:${awsRegion}::/domainnames", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -246,9 +248,9 @@ } }, { - // API Gateway V2 child creates run under already-tagged APIs/domains. + // API Gateway V1/V2 child creates run under already-tagged APIs/domains. "label": "create-http-api-routes", - "description": "Create routes and mappings under API Gateway HTTP APIs and domain names owned by this application.", + "description": "Create routes and mappings under API Gateway HTTP (V2) and REST (V1) APIs and domain names owned by this application.", "grant": { "actions": ["apigateway:POST"] }, @@ -256,8 +258,10 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings" ], "condition": { "StringEquals": { @@ -269,8 +273,10 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings" ], "condition": { "StringEquals": { @@ -283,9 +289,9 @@ } }, { - // API Gateway V2 tagged creates can perform implicit TagResource calls. + // API Gateway V1/V2 tagged creates can perform implicit TagResource calls. "label": "tag-http-api-endpoints", - "description": "Apply deployment tags to API Gateway HTTP API resources created for public endpoints.", + "description": "Apply deployment tags to API Gateway HTTP (V2) and REST (V1) API resources created for public endpoints.", "grant": { "actions": ["apigateway:TagResource", "apigateway:PUT"] }, @@ -295,6 +301,7 @@ "arn:aws:apigateway:${awsRegion}::/apis/*", "arn:aws:apigateway:${awsRegion}::/apis/*/stages", "arn:aws:apigateway:${awsRegion}::/apis/*/stages/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -310,6 +317,7 @@ "arn:aws:apigateway:${awsRegion}::/apis/*", "arn:aws:apigateway:${awsRegion}::/apis/*/stages", "arn:aws:apigateway:${awsRegion}::/apis/*/stages/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -325,7 +333,7 @@ }, { "label": "manage-http-api-endpoints", - "description": "Read, update, and delete API Gateway HTTP APIs, custom domain names, and mappings owned by this application.", + "description": "Read, update, and delete API Gateway HTTP (V2) and REST (V1) APIs, custom domain names, and mappings owned by this application.", "grant": { "actions": ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"] }, @@ -333,9 +341,12 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings/*" ], "condition": { "StringEquals": { @@ -347,9 +358,12 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings/*" ], "condition": { "StringEquals": { diff --git a/crates/alien-permissions/permission-sets/worker/provision.jsonc b/crates/alien-permissions/permission-sets/worker/provision.jsonc index 9cfac7d55..ad612316e 100644 --- a/crates/alien-permissions/permission-sets/worker/provision.jsonc +++ b/crates/alien-permissions/permission-sets/worker/provision.jsonc @@ -311,9 +311,9 @@ } }, { - // API Gateway V2 API/domain creation. The request must carry boundary tags. + // API Gateway V1/V2 API/domain creation. The request must carry boundary tags. "label": "create-http-api-endpoints", - "description": "Create API Gateway HTTP APIs and custom domain names for application-owned public endpoints.", + "description": "Create API Gateway HTTP (V2) and REST (V1) APIs and custom domain names for application-owned public endpoints.", "grant": { "actions": ["apigateway:POST", "apigateway:PUT"] }, @@ -321,6 +321,7 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis", + "arn:aws:apigateway:${awsRegion}::/restapis", "arn:aws:apigateway:${awsRegion}::/domainnames", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -334,6 +335,7 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis", + "arn:aws:apigateway:${awsRegion}::/restapis", "arn:aws:apigateway:${awsRegion}::/domainnames", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -348,9 +350,9 @@ } }, { - // API Gateway V2 child creates run under already-tagged APIs/domains. + // API Gateway V1/V2 child creates run under already-tagged APIs/domains. "label": "create-http-api-routes", - "description": "Create routes and mappings under API Gateway HTTP APIs and domain names owned by this application.", + "description": "Create routes and mappings under API Gateway HTTP (V2) and REST (V1) APIs and domain names owned by this application.", "grant": { "actions": ["apigateway:POST"] }, @@ -358,8 +360,10 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings" ], "condition": { "StringEquals": { @@ -371,8 +375,10 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings" ], "condition": { "StringEquals": { @@ -385,9 +391,9 @@ } }, { - // API Gateway V2 tagged creates can perform implicit TagResource calls. + // API Gateway V1/V2 tagged creates can perform implicit TagResource calls. "label": "tag-http-api-endpoints", - "description": "Apply deployment tags to API Gateway HTTP API resources created for public endpoints.", + "description": "Apply deployment tags to API Gateway HTTP (V2) and REST (V1) API resources created for public endpoints.", "grant": { "actions": ["apigateway:TagResource", "apigateway:PUT"] }, @@ -397,6 +403,7 @@ "arn:aws:apigateway:${awsRegion}::/apis/*", "arn:aws:apigateway:${awsRegion}::/apis/*/stages", "arn:aws:apigateway:${awsRegion}::/apis/*/stages/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -412,6 +419,7 @@ "arn:aws:apigateway:${awsRegion}::/apis/*", "arn:aws:apigateway:${awsRegion}::/apis/*/stages", "arn:aws:apigateway:${awsRegion}::/apis/*/stages/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/tags/*" ], @@ -426,9 +434,9 @@ } }, { - // API Gateway V2 — manage existing APIs and domain names. + // API Gateway V1/V2 — manage existing APIs and domain names. "label": "manage-http-api-endpoints", - "description": "Read, update, and delete API Gateway HTTP APIs, custom domain names, and mappings owned by this application.", + "description": "Read, update, and delete API Gateway HTTP (V2) and REST (V1) APIs, custom domain names, and mappings owned by this application.", "grant": { "actions": ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"] }, @@ -436,9 +444,12 @@ "stack": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings/*" ], "condition": { "StringEquals": { @@ -450,9 +461,12 @@ "resource": { "resources": [ "arn:aws:apigateway:${awsRegion}::/apis/*", + "arn:aws:apigateway:${awsRegion}::/restapis/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*", "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings", - "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*" + "arn:aws:apigateway:${awsRegion}::/domainnames/*/apimappings/*", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings", + "arn:aws:apigateway:${awsRegion}::/domainnames/*/basepathmappings/*" ], "condition": { "StringEquals": { diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap index dc6484691..06eb859c3 100644 --- a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap @@ -472,7 +472,7 @@ resource "aws_iam_role" "management" { resource "aws_iam_policy" "management_managed_policy_0" { name = length(format("%s-%s", local.resource_prefix, "deployment-management-0")) <= 128 ? format("%s-%s", local.resource_prefix, "deployment-management-0") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "deployment-management-0"), 0, 119), substr(sha1(format("%s-%s", local.resource_prefix, "deployment-management-0")), 0, 8)) - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "UpdateLambdaFunctions", Effect = "Allow", Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration", "lambda:PublishVersion", "lambda:CreateAlias", "lambda:DeleteAlias", "lambda:UpdateAlias", "lambda:PutFunctionConcurrency", "lambda:DeleteFunctionConcurrency", "lambda:PutFunctionEventInvokeConfig", "lambda:DeleteFunctionEventInvokeConfig", "lambda:GetFunction", "lambda:GetFunctionConfiguration", "lambda:ListVersionsByFunction", "lambda:ListAliases", "lambda:GetPolicy", "lambda:ListTags", "lambda:GetFunctionUrlConfig", "lambda:GetCodeSigningConfig", "lambda:GetFunctionEventInvokeConfig", "lambda:AddPermission", "lambda:RemovePermission"], Resource = ["arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "ManageEventSourceMappings", Effect = "Allow", Action = ["lambda:GetEventSourceMapping", "lambda:UpdateEventSourceMapping"], Resource = ["*"], Condition = { StringLike = { "lambda:FunctionArn" = "arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*" } } }, { Sid = "ListEventSourceMappings", Effect = "Allow", Action = ["lambda:ListEventSourceMappings"], Resource = ["*"] }, { Sid = "PassLambdaExecutionRoles", Effect = "Allow", Action = ["iam:PassRole"], Resource = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.resource_prefix}-*-sa"] }, { Sid = "GetEcrLoginToken", Effect = "Allow", Action = ["ecr:GetAuthorizationToken"], Resource = ["*"] }, { Sid = "ReadDeploymentContainerImages", Effect = "Allow", Action = ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], Resource = ["arn:aws:ecr:*:${var.managing_account_id}:repository/*"] }, { Sid = "ImportTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate", "acm:DeleteCertificate", "acm:DescribeCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:POST", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiRoutes", Effect = "Allow", Action = ["apigateway:POST"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "TagHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:TagResource", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateEventbridgeSchedules", Effect = "Allow", Action = ["events:PutRule", "events:TagResource"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageEventbridgeSchedules", Effect = "Allow", Action = ["events:DeleteRule", "events:DescribeRule", "events:PutTargets", "events:RemoveTargets"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "UpdateLambdaFunctions", Effect = "Allow", Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration", "lambda:PublishVersion", "lambda:CreateAlias", "lambda:DeleteAlias", "lambda:UpdateAlias", "lambda:PutFunctionConcurrency", "lambda:DeleteFunctionConcurrency", "lambda:PutFunctionEventInvokeConfig", "lambda:DeleteFunctionEventInvokeConfig", "lambda:GetFunction", "lambda:GetFunctionConfiguration", "lambda:ListVersionsByFunction", "lambda:ListAliases", "lambda:GetPolicy", "lambda:ListTags", "lambda:GetFunctionUrlConfig", "lambda:GetCodeSigningConfig", "lambda:GetFunctionEventInvokeConfig", "lambda:AddPermission", "lambda:RemovePermission"], Resource = ["arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "ManageEventSourceMappings", Effect = "Allow", Action = ["lambda:GetEventSourceMapping", "lambda:UpdateEventSourceMapping"], Resource = ["*"], Condition = { StringLike = { "lambda:FunctionArn" = "arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*" } } }, { Sid = "ListEventSourceMappings", Effect = "Allow", Action = ["lambda:ListEventSourceMappings"], Resource = ["*"] }, { Sid = "PassLambdaExecutionRoles", Effect = "Allow", Action = ["iam:PassRole"], Resource = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.resource_prefix}-*-sa"] }, { Sid = "GetEcrLoginToken", Effect = "Allow", Action = ["ecr:GetAuthorizationToken"], Resource = ["*"] }, { Sid = "ReadDeploymentContainerImages", Effect = "Allow", Action = ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], Resource = ["arn:aws:ecr:*:${var.managing_account_id}:repository/*"] }, { Sid = "ImportTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate", "acm:DeleteCertificate", "acm:DescribeCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:POST", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiRoutes", Effect = "Allow", Action = ["apigateway:POST"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "TagHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:TagResource", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }] }) } resource "aws_iam_role_policy_attachment" "management_managed_policy_0_attachment" { @@ -482,7 +482,7 @@ resource "aws_iam_role_policy_attachment" "management_managed_policy_0_attachmen resource "aws_iam_policy" "management_managed_policy_1" { name = length(format("%s-%s", local.resource_prefix, "deployment-management-1")) <= 128 ? format("%s-%s", local.resource_prefix, "deployment-management-1") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "deployment-management-1"), 0, 119), substr(sha1(format("%s-%s", local.resource_prefix, "deployment-management-1")), 0, 8)) - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "CheckS3BucketHealth", Effect = "Allow", Action = ["s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", "s3:GetBucketPolicy", "s3:GetBucketAcl", "s3:GetEncryptionConfiguration", "s3:GetBucketPublicAccessBlock"], Resource = ["arn:aws:s3:::${local.resource_prefix}-*"] }, { Sid = "CheckSqsQueueHealth", Effect = "Allow", Action = ["sqs:GetQueueAttributes", "sqs:ListQueueTags"], Resource = ["arn:aws:sqs:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:${local.resource_prefix}-*"] }, { Sid = "CheckDynamodbTableHealth", Effect = "Allow", Action = ["dynamodb:DescribeTable", "dynamodb:ListTables", "dynamodb:DescribeTimeToLive", "dynamodb:ListTagsOfResource", "dynamodb:DescribeContinuousBackups"], Resource = ["arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/${local.resource_prefix}-*"] }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "CreateEventbridgeSchedules", Effect = "Allow", Action = ["events:PutRule", "events:TagResource"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageEventbridgeSchedules", Effect = "Allow", Action = ["events:DeleteRule", "events:DescribeRule", "events:PutTargets", "events:RemoveTargets"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CheckS3BucketHealth", Effect = "Allow", Action = ["s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", "s3:GetBucketPolicy", "s3:GetBucketAcl", "s3:GetEncryptionConfiguration", "s3:GetBucketPublicAccessBlock"], Resource = ["arn:aws:s3:::${local.resource_prefix}-*"] }, { Sid = "CheckSqsQueueHealth", Effect = "Allow", Action = ["sqs:GetQueueAttributes", "sqs:ListQueueTags"], Resource = ["arn:aws:sqs:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:${local.resource_prefix}-*"] }, { Sid = "CheckDynamodbTableHealth", Effect = "Allow", Action = ["dynamodb:DescribeTable", "dynamodb:ListTables", "dynamodb:DescribeTimeToLive", "dynamodb:ListTagsOfResource", "dynamodb:DescribeContinuousBackups"], Resource = ["arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/${local.resource_prefix}-*"] }] }) } resource "aws_iam_role_policy_attachment" "management_managed_policy_1_attachment" { diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_remote_stack_management.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_remote_stack_management.snap index 934c49a2e..02a385fd5 100644 --- a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_remote_stack_management.snap +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_remote_stack_management.snap @@ -174,7 +174,7 @@ resource "aws_iam_role" "management" { resource "aws_iam_policy" "management_managed_policy_0" { name = length(format("%s-%s", local.resource_prefix, "deployment-management-0")) <= 128 ? format("%s-%s", local.resource_prefix, "deployment-management-0") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "deployment-management-0"), 0, 119), substr(sha1(format("%s-%s", local.resource_prefix, "deployment-management-0")), 0, 8)) - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "UpdateLambdaFunctions", Effect = "Allow", Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration", "lambda:PublishVersion", "lambda:CreateAlias", "lambda:DeleteAlias", "lambda:UpdateAlias", "lambda:PutFunctionConcurrency", "lambda:DeleteFunctionConcurrency", "lambda:PutFunctionEventInvokeConfig", "lambda:DeleteFunctionEventInvokeConfig", "lambda:GetFunction", "lambda:GetFunctionConfiguration", "lambda:ListVersionsByFunction", "lambda:ListAliases", "lambda:GetPolicy", "lambda:ListTags", "lambda:GetFunctionUrlConfig", "lambda:GetCodeSigningConfig", "lambda:GetFunctionEventInvokeConfig", "lambda:AddPermission", "lambda:RemovePermission"], Resource = ["arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "ManageEventSourceMappings", Effect = "Allow", Action = ["lambda:GetEventSourceMapping", "lambda:UpdateEventSourceMapping"], Resource = ["*"], Condition = { StringLike = { "lambda:FunctionArn" = "arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*" } } }, { Sid = "ListEventSourceMappings", Effect = "Allow", Action = ["lambda:ListEventSourceMappings"], Resource = ["*"] }, { Sid = "PassLambdaExecutionRoles", Effect = "Allow", Action = ["iam:PassRole"], Resource = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.resource_prefix}-*-sa"] }, { Sid = "GetEcrLoginToken", Effect = "Allow", Action = ["ecr:GetAuthorizationToken"], Resource = ["*"] }, { Sid = "ReadDeploymentContainerImages", Effect = "Allow", Action = ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], Resource = ["arn:aws:ecr:*:${var.managing_account_id}:repository/*"] }, { Sid = "ImportTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate", "acm:DeleteCertificate", "acm:DescribeCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:POST", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiRoutes", Effect = "Allow", Action = ["apigateway:POST"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "TagHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:TagResource", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateEventbridgeSchedules", Effect = "Allow", Action = ["events:PutRule", "events:TagResource"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageEventbridgeSchedules", Effect = "Allow", Action = ["events:DeleteRule", "events:DescribeRule", "events:PutTargets", "events:RemoveTargets"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "UpdateLambdaFunctions", Effect = "Allow", Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration", "lambda:PublishVersion", "lambda:CreateAlias", "lambda:DeleteAlias", "lambda:UpdateAlias", "lambda:PutFunctionConcurrency", "lambda:DeleteFunctionConcurrency", "lambda:PutFunctionEventInvokeConfig", "lambda:DeleteFunctionEventInvokeConfig", "lambda:GetFunction", "lambda:GetFunctionConfiguration", "lambda:ListVersionsByFunction", "lambda:ListAliases", "lambda:GetPolicy", "lambda:ListTags", "lambda:GetFunctionUrlConfig", "lambda:GetCodeSigningConfig", "lambda:GetFunctionEventInvokeConfig", "lambda:AddPermission", "lambda:RemovePermission"], Resource = ["arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "ManageEventSourceMappings", Effect = "Allow", Action = ["lambda:GetEventSourceMapping", "lambda:UpdateEventSourceMapping"], Resource = ["*"], Condition = { StringLike = { "lambda:FunctionArn" = "arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${local.resource_prefix}-*" } } }, { Sid = "ListEventSourceMappings", Effect = "Allow", Action = ["lambda:ListEventSourceMappings"], Resource = ["*"] }, { Sid = "PassLambdaExecutionRoles", Effect = "Allow", Action = ["iam:PassRole"], Resource = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.resource_prefix}-*-sa"] }, { Sid = "GetEcrLoginToken", Effect = "Allow", Action = ["ecr:GetAuthorizationToken"], Resource = ["*"] }, { Sid = "ReadDeploymentContainerImages", Effect = "Allow", Action = ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], Resource = ["arn:aws:ecr:*:${var.managing_account_id}:repository/*"] }, { Sid = "ImportTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageTlsCertificates", Effect = "Allow", Action = ["acm:ImportCertificate", "acm:AddTagsToCertificate", "acm:DeleteCertificate", "acm:DescribeCertificate"], Resource = ["arn:aws:acm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:certificate/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:POST", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "CreateHttpApiRoutes", Effect = "Allow", Action = ["apigateway:POST"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "TagHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:TagResource", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages", "arn:aws:apigateway:${data.aws_region.current.region}::/apis/*/stages/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/tags/*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageHttpApiEndpoints", Effect = "Allow", Action = ["apigateway:GET", "apigateway:DELETE", "apigateway:PATCH", "apigateway:PUT"], Resource = ["arn:aws:apigateway:${data.aws_region.current.region}::/apis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/restapis/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/apimappings/*", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings", "arn:aws:apigateway:${data.aws_region.current.region}::/domainnames/*/basepathmappings/*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }] }) } resource "aws_iam_role_policy_attachment" "management_managed_policy_0_attachment" { @@ -184,7 +184,7 @@ resource "aws_iam_role_policy_attachment" "management_managed_policy_0_attachmen resource "aws_iam_policy" "management_managed_policy_1" { name = length(format("%s-%s", local.resource_prefix, "deployment-management-1")) <= 128 ? format("%s-%s", local.resource_prefix, "deployment-management-1") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "deployment-management-1"), 0, 119), substr(sha1(format("%s-%s", local.resource_prefix, "deployment-management-1")), 0, 8)) - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "CheckS3BucketHealth", Effect = "Allow", Action = ["s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", "s3:GetBucketPolicy", "s3:GetBucketAcl", "s3:GetEncryptionConfiguration", "s3:GetBucketPublicAccessBlock"], Resource = ["arn:aws:s3:::${local.resource_prefix}-*"] }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "CreateEventbridgeSchedules", Effect = "Allow", Action = ["events:PutRule", "events:TagResource"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:RequestTag/deployment" = "${local.resource_prefix}", "aws:RequestTag/managed-by" = "runtime" } } }, { Sid = "ManageEventbridgeSchedules", Effect = "Allow", Action = ["events:DeleteRule", "events:DescribeRule", "events:PutTargets", "events:RemoveTargets"], Resource = ["arn:aws:events:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:rule/${local.resource_prefix}-*"], Condition = { StringEquals = { "aws:ResourceTag/deployment" = "${local.resource_prefix}", "aws:ResourceTag/managed-by" = "runtime" } } }, { Sid = "CheckS3BucketHealth", Effect = "Allow", Action = ["s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", "s3:GetBucketPolicy", "s3:GetBucketAcl", "s3:GetEncryptionConfiguration", "s3:GetBucketPublicAccessBlock"], Resource = ["arn:aws:s3:::${local.resource_prefix}-*"] }] }) } resource "aws_iam_role_policy_attachment" "management_managed_policy_1_attachment" { diff --git a/packages/ai-gateway/src/__tests__/client.test.ts b/packages/ai-gateway/src/__tests__/client.test.ts index db9f381b5..173b4923e 100644 --- a/packages/ai-gateway/src/__tests__/client.test.ts +++ b/packages/ai-gateway/src/__tests__/client.test.ts @@ -13,7 +13,7 @@ import type { RawAiGatewayHandle } from "../loader.js" // binding resolution, including the URL seam (`//v1/...`). // ───────────────────────────────────────────────────────────────────────────── -const EXTERNAL = JSON.stringify({ service: "external", provider: "openai", apiKey: "sk-test" }) +const EXTERNAL = JSON.stringify({ service: "external-ai", provider: "openai", apiKey: "sk-test" }) const GATEWAY_URL = "http://127.0.0.1:41999" @@ -107,7 +107,7 @@ describe("getAiConnection", () => { it("resolves a BYO anthropic provider to the Anthropic API", async () => { vi.stubEnv( "ALIEN_LLM_BINDING", - JSON.stringify({ service: "external", provider: "anthropic", apiKey: "sk-ant" }), + JSON.stringify({ service: "external-ai", provider: "anthropic", apiKey: "sk-ant" }), ) expect((await getAiConnection("llm")).baseURL).toBe("https://api.anthropic.com/v1") }) @@ -117,7 +117,7 @@ describe("getAiConnection", () => { // instead of defaulting to OpenAI's endpoint. vi.stubEnv( "ALIEN_LLM_BINDING", - JSON.stringify({ service: "external", provider: "google", apiKey: "sk-test" }), + JSON.stringify({ service: "external-ai", provider: "google", apiKey: "sk-test" }), ) await expect(getAiConnection("llm")).rejects.toMatchObject({ code: "AI_UNSUPPORTED_PROVIDER", @@ -128,7 +128,7 @@ describe("getAiConnection", () => { it("lets ALIEN_AI_LOCAL_BASE_URL override an otherwise-unknown provider", async () => { vi.stubEnv( "ALIEN_LLM_BINDING", - JSON.stringify({ service: "external", provider: "google", apiKey: "sk-test" }), + JSON.stringify({ service: "external-ai", provider: "google", apiKey: "sk-test" }), ) vi.stubEnv("ALIEN_AI_LOCAL_BASE_URL", "http://localhost:8080") expect((await getAiConnection("llm")).baseURL).toBe("http://localhost:8080/v1") @@ -157,7 +157,12 @@ describe("getAiConnection", () => { it("rejects an external binding with an unexpected key (strict at the trust boundary)", async () => { vi.stubEnv( "ALIEN_LLM_BINDING", - JSON.stringify({ service: "external", provider: "openai", apiKey: "sk", extra: "tampered" }), + JSON.stringify({ + service: "external-ai", + provider: "openai", + apiKey: "sk", + extra: "tampered", + }), ) await expect(getAiConnection("llm")).rejects.toMatchObject({ code: "INVALID_BINDING_CONFIG", @@ -242,7 +247,7 @@ describe("Ai.getAvailableModels", () => { it("returns current-generation Anthropic ids for a BYO anthropic provider, no retired 3.5", async () => { vi.stubEnv( "ALIEN_LLM_BINDING", - JSON.stringify({ service: "external", provider: "anthropic", apiKey: "sk-ant" }), + JSON.stringify({ service: "external-ai", provider: "anthropic", apiKey: "sk-ant" }), ) const fetchMock = stubFetch({ data: [] }) const models = await ai("llm").getAvailableModels() @@ -286,7 +291,7 @@ describe("Ai.getAvailableModels", () => { describe("Ai.responses.create", () => { const ANTHROPIC_BYO = JSON.stringify({ - service: "external", + service: "external-ai", provider: "anthropic", apiKey: "sk-ant", }) diff --git a/packages/ai-gateway/src/binding.ts b/packages/ai-gateway/src/binding.ts index 1dcd59577..51077525a 100644 --- a/packages/ai-gateway/src/binding.ts +++ b/packages/ai-gateway/src/binding.ts @@ -1,6 +1,6 @@ /** * Parsing for the `ALIEN__BINDING` env var an `ai` resource projects. The client only - * routes on the binding: a BYO-key (`external`) binding is validated strictly here because the + * routes on the binding: a BYO-key (`external-ai`) binding is validated strictly here because the * client itself uses its fields, while every other service tag — the ambient variants, including * ones added to the platform after this SDK shipped — is passed through for the Rust gateway to * validate and serve. Mirrors the Rust `AiBinding` (serde tag "service", lowercase, camelCase @@ -14,7 +14,7 @@ import * as z from "zod/v4" // reads directly, so an unexpected key fails loudly rather than being silently dropped. const externalAiBindingSchema = z .object({ - service: z.literal("external"), + service: z.literal("external-ai"), provider: z.string(), apiKey: z.string(), }) @@ -32,7 +32,7 @@ export type AiBinding = ExternalAiBinding | AmbientAiBinding /** Narrow an `AiBinding` to the BYO-key variant the client handles itself. */ export function isExternalAiBinding(binding: AiBinding): binding is ExternalAiBinding { - return binding.service === "external" + return binding.service === "external-ai" } /** The env var an `ai` binding is projected into: `ALIEN__BINDING` (uppercased, `-`→`_`). */ @@ -63,7 +63,7 @@ export async function parseAiBinding(name: string): Promise