From b59429254b3f785f79854da35a81b85bc35b1f11 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 30 Jul 2026 19:52:24 +0300 Subject: [PATCH 1/5] fix(cloudformation): emit kv resource-scoped IAM policies Linking a kv to a permission profile emitted no IAM in the CloudFormation package, so a package install got no DynamoDB data-plane access while the identical stack worked on push (the runtime permissions generator supplies the grants there). Queue, storage, vault, email, and OpenSearch already emit these policies; kv was the one data resource that did not. The permission-owner scan the emitters share was duplicated byte-for-byte in queue and email; it now lives in helpers so kv reuses it instead of adding a third copy. --- .../src/emitters/aws/email.rs | 74 +--------- .../src/emitters/aws/helpers.rs | 50 ++++++- .../src/emitters/aws/kv.rs | 127 +++++++++++++++++- .../src/emitters/aws/queue.rs | 75 +---------- .../tests/generator/aws_data_layer_tests.rs | 74 ++++++++++ 5 files changed, 257 insertions(+), 143 deletions(-) diff --git a/crates/alien-cloudformation/src/emitters/aws/email.rs b/crates/alien-cloudformation/src/emitters/aws/email.rs index 8e6e51699..02bbae54d 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) = @@ -690,66 +693,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..f48965619 100644 --- a/crates/alien-cloudformation/src/emitters/aws/kv.rs +++ b/crates/alien-cloudformation/src/emitters/aws/kv.rs @@ -1,18 +1,35 @@ //! AWS KV — DynamoDB on-demand table with composite key, TTL, and SSE. +//! +//! When a service-account permission profile grants `kv/*` permission sets for +//! this store (directly by resource id or through a `*` wildcard), the matching +//! IAM policies are emitted against the owning roles, with every statement +//! pinned to this table's ARN (the physical table name is +//! CloudFormation-generated, so name-pattern bindings cannot match it). 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 +82,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 +104,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..b1b528f81 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) = @@ -204,66 +204,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/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()) From 98e1dc149ef6bf088f0d98c25b4042d481aedc21 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 30 Jul 2026 20:05:41 +0300 Subject: [PATCH 2/5] fix!(core): tag the external AI binding external-ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binding service tags must be globally unique across resource types — serde and every env-var scanner dispatch on the tag alone. The external AI binding and the external Postgres binding both claimed "external", so the gateway launcher grabbed a BYO database binding, failed to parse it as an AI key, and died at startup with "missing field 'provider'" for any workload that links both. Compatible only while 3.3.0 is unpublished; the dev prereleases carry the old tag and heal on their next publish. --- crates/alien-ai-gateway/src/config.rs | 18 +++++++++++++++++- crates/alien-ai-gateway/tests/launcher.rs | 2 +- crates/alien-core/src/bindings/ai.rs | 8 ++++++-- .../src/core/environment_variables.rs | 2 +- .../ai-gateway/src/__tests__/client.test.ts | 14 +++++++------- packages/ai-gateway/src/binding.ts | 8 ++++---- 6 files changed, 36 insertions(+), 16 deletions(-) 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-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-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/packages/ai-gateway/src/__tests__/client.test.ts b/packages/ai-gateway/src/__tests__/client.test.ts index db9f381b5..cc280d53e 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,7 @@ 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 +242,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 +286,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 Date: Thu, 30 Jul 2026 22:02:39 +0300 Subject: [PATCH 3/5] feat(worker): stream via API Gateway REST V1 with a custom domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A streaming worker (WORKER_RESPONSE_STREAMING=true) is served through an API Gateway REST (V1) API with responseTransferMode=STREAM — the only API Gateway flavor that both streams and carries a custom domain. Buffered workers stay on the V2 HTTP API. Adds an apigateway REST client, the controller create/update/delete states, the importer fields, and the setup-role /restapis + basepathmapping permissions. --- .../alien-aws-clients/src/aws/apigateway.rs | 553 +++++++++ crates/alien-aws-clients/src/aws/mod.rs | 1 + crates/alien-aws-clients/src/lib.rs | 1 + ..._aws_full_stack_tests__aws_full_stack.snap | 7 + .../alien-core/src/import/data/aws/worker.rs | 7 + .../alien-infra/src/core/service_provider.rs | 21 + crates/alien-infra/src/worker/aws.rs | 1091 ++++++++++++++--- crates/alien-infra/src/worker/aws_import.rs | 19 +- .../permission-sets/worker/management.jsonc | 28 +- .../permission-sets/worker/provision.jsonc | 30 +- ...r__generator__helpers__aws_full_stack.snap | 4 +- ..._helpers__aws_remote_stack_management.snap | 4 +- 12 files changed, 1608 insertions(+), 158 deletions(-) create mode 100644 crates/alien-aws-clients/src/aws/apigateway.rs 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..272e73d57 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/apigateway.rs @@ -0,0 +1,553 @@ +//! 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<()>; +} + +// --------------------------------------------------------------------------- +// 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, + }, + "TooManyRequestsException" | "ThrottlingException" => { + ErrorData::RateLimitExceeded { message } + } + "BadRequestException" | "ValidationException" => ErrorData::InvalidInput { + 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, + }, + StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => ErrorData::RemoteAccessDenied { + resource_type: "ApiGateway".into(), + resource_name: resource.into(), + }, + StatusCode::TOO_MANY_REQUESTS => ErrorData::RateLimitExceeded { message }, + StatusCode::SERVICE_UNAVAILABLE + | StatusCode::BAD_GATEWAY + | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable { 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 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/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..082e0bb39 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,6 +832,8 @@ 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 - Action: - apigateway:PUT @@ -846,6 +849,7 @@ 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: @@ -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/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-infra/src/core/service_provider.rs b/crates/alien-infra/src/core/service_provider.rs index 2219bd2b9..34705509c 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}, @@ -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/worker/aws.rs b/crates/alien-infra/src/worker/aws.rs index 06667ec93..186d99e62 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,19 @@ fn emit_aws_lambda_worker_heartbeat( }); } +/// Whether this worker should be exposed via a streaming REST (V1) API +/// (`responseTransferMode: STREAM`) instead of the buffered V2 HTTP API. +/// Signalled by `WORKER_RESPONSE_STREAMING=true` in the worker's environment. +/// (A typed builder field is the cleaner long-term surface; an env flag keeps +/// this change contained to the AWS controller.) +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 +553,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 +598,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 +693,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 +707,17 @@ impl AwsWorkerController { &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + + // Single chokepoint fork: streaming workers are served by REST V1 + // (see gateway_entry_state), so every path that lands here reroutes. + 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 +730,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,178 +1124,615 @@ 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", - ) - .await?; - } + if self.rest_deployment_id.is_none() { + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; - info!(worker=%config.id, "Successfully applied resource-scoped permissions"); + 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()), + }) + })?; + + self.rest_deployment_id = Some(deployment_id); + self.stage_name = Some("prod".to_string()); + } + + 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 = UpdatingEnvVarsWithSelfBinding, + 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); + + let request = AddPermissionRequest::builder() + .statement_id("ApiGatewayInvoke".to_string()) + .action("lambda:InvokeFunction".to_string()) + .principal("apigateway.amazonaws.com".to_string()) + .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, )] @@ -2121,12 +2614,15 @@ impl AwsWorkerController { }); } - if previous_config.public_endpoints.is_empty() && self.api_id.is_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() { + if self.api_id.is_some() || self.rest_api_id.is_some() { return Ok(HandlerAction::Continue { state: UpdateRunningReadinessProbe, suggested_delay: None, @@ -2135,6 +2631,8 @@ impl AwsWorkerController { let next_state = if has_domain_info { UpdateWaitingForCertificate + } else if worker_wants_streaming(¤t_config) { + UpdateCreatingRestApi } else { UpdateCreatingApiGateway }; @@ -2160,14 +2658,21 @@ impl AwsWorkerController { state: ImportingCertificate, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateImportingInitialCertificate, + state: UpdateImportingInitialCertificate, + suggested_delay, + }), + HandlerAction::Continue { + state: CreatingApiGateway, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingApiGateway, suggested_delay, }), HandlerAction::Continue { - state: CreatingApiGateway, + state: CreatingRestApi, suggested_delay, } => Ok(HandlerAction::Continue { - state: UpdateCreatingApiGateway, + state: UpdateCreatingRestApi, suggested_delay, }), HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( @@ -2203,6 +2708,13 @@ impl AwsWorkerController { 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", @@ -2423,6 +2935,211 @@ impl AwsWorkerController { } } + #[handler( + state = UpdateCreatingRestApi, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_api( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_api(ctx).await? { + HandlerAction::Continue { + state: CreatingRestResource, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestResource, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_api", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingRestResource, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_resource( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_resource(ctx).await? { + HandlerAction::Continue { + state: CreatingRestMethods, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestMethods, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_resource", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingRestMethods, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_methods( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_methods(ctx).await? { + HandlerAction::Continue { + state: CreatingRestDeployment, + suggested_delay, + } => Ok(HandlerAction::Continue { + 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 { + state: AddingApiGatewayPermission, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateAddingApiGatewayPermission, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_deployment", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingRestDomain, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_domain( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_domain(ctx).await? { + HandlerAction::Continue { + state: CreatingRestBasePathMapping, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateCreatingRestBasePathMapping, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_domain", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + + #[handler( + state = UpdateCreatingRestBasePathMapping, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_creating_rest_base_path_mapping( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let worker_config = ctx.desired_resource_config::()?; + match self.creating_rest_base_path_mapping(ctx).await? { + HandlerAction::Continue { + state: AddingApiGatewayPermission, + suggested_delay, + } => Ok(HandlerAction::Continue { + state: UpdateAddingApiGatewayPermission, + suggested_delay, + }), + HandlerAction::Continue { state, .. } => Err(Self::unexpected_update_wrapper_state( + &worker_config.id, + "creating_rest_base_path_mapping", + state, + )), + HandlerAction::Stay { + max_times, + suggested_delay, + } => Ok(HandlerAction::Stay { + max_times, + suggested_delay, + }), + } + } + #[handler( state = UpdateAddingApiGatewayPermission, on_failure = UpdateFailed, @@ -3033,58 +3750,120 @@ impl AwsWorkerController { let worker_config = ctx.desired_resource_config::()?; // Ordering matters: delete API mapping before domain name, domain name before API. - if let (Some(domain_name), Some(api_mapping_id)) = - (self.domain_name.as_ref(), self.api_mapping_id.as_ref()) + // 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), Some(base_path)) = + (self.domain_name.as_ref(), self.rest_base_path.as_ref()) { let client = ctx .service_provider - .get_aws_apigatewayv2_client(aws_cfg) + .get_aws_apigateway_client(aws_cfg) .await?; - match client.delete_api_mapping(domain_name, api_mapping_id).await { - Ok(()) => info!(worker=%worker_config.id, "API mapping deleted"), + 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 { .. }) + Some( + CloudClientErrorData::RemoteResourceNotFound { .. } + | CloudClientErrorData::RemoteAccessDenied { .. } + ) ) => { - info!(worker=%worker_config.id, "API mapping already gone"); + info!(worker=%worker_config.id, "REST base path mapping already gone or inaccessible"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { - message: "Failed to delete API mapping".to_string(), + message: "Failed to delete REST base path mapping".to_string(), resource_id: Some(worker_config.id.clone()), })); } } } - self.api_mapping_id = None; + self.rest_base_path = None; - if let Some(domain_name) = self.domain_name.as_ref() { + if let (Some(domain_name), Some(api_mapping_id)) = + (self.domain_name.as_ref(), self.api_mapping_id.as_ref()) + { 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") - } + match client.delete_api_mapping(domain_name, api_mapping_id).await { + Ok(()) => info!(worker=%worker_config.id, "API mapping deleted"), Err(e) if matches!( e.error, Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { - info!(worker=%worker_config.id, "Custom domain already gone"); + info!(worker=%worker_config.id, "API mapping already gone"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { - message: "Failed to delete custom domain".to_string(), + message: "Failed to delete API mapping".to_string(), resource_id: Some(worker_config.id.clone()), })); } } } + 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 { .. } + | CloudClientErrorData::RemoteAccessDenied { .. } + ) + ) => + { + 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. @@ -3116,6 +3895,39 @@ impl AwsWorkerController { self.api_id = None; self.integration_id = None; self.route_id = None; + + if let Some(rest_api_id) = self.rest_api_id.as_ref() { + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + match client.delete_rest_api(rest_api_id).await { + Ok(()) => { + info!(worker=%worker_config.id, rest_api_id=%rest_api_id, "REST API deleted") + } + Err(e) + if matches!( + e.error, + Some( + CloudClientErrorData::RemoteResourceNotFound { .. } + | CloudClientErrorData::RemoteAccessDenied { .. } + ) + ) => + { + info!(worker=%worker_config.id, "REST API already gone or inaccessible"); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: "Failed to delete REST API".to_string(), + resource_id: Some(worker_config.id.clone()), + })); + } + } + } + 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 +4794,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-permissions/permission-sets/worker/management.jsonc b/crates/alien-permissions/permission-sets/worker/management.jsonc index 08ca69ec5..8547994e3 100644 --- a/crates/alien-permissions/permission-sets/worker/management.jsonc +++ b/crates/alien-permissions/permission-sets/worker/management.jsonc @@ -209,7 +209,7 @@ } }, { - // 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.", "grant": { @@ -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,7 +248,7 @@ } }, { - // 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.", "grant": { @@ -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,7 +289,7 @@ } }, { - // 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.", "grant": { @@ -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/*" ], @@ -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..09c169958 100644 --- a/crates/alien-permissions/permission-sets/worker/provision.jsonc +++ b/crates/alien-permissions/permission-sets/worker/provision.jsonc @@ -311,7 +311,7 @@ } }, { - // 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.", "grant": { @@ -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,7 +350,7 @@ } }, { - // 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.", "grant": { @@ -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,7 +391,7 @@ } }, { - // 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.", "grant": { @@ -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,7 +434,7 @@ } }, { - // 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.", "grant": { @@ -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" { From cbcfb79136028e1fac3819f3b7ff291c0a460c6a Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 30 Jul 2026 23:23:01 +0300 Subject: [PATCH 4/5] fix(worker): apply review findings to the streaming path Teardown treated a permission error as a deleted resource, which would report a REST API and its custom domain cleaned up while both stayed live and billed; only a missing resource is tolerated now, matching the V2 path. A worker with no endpoint yet that reruns the update path after enabling streaming reached a state its wrapper rejected, wedging the resource in UpdateFailed on every retry. CreateDeployment takes no tags, so the stage it creates sat outside the deployment tag boundary that every management grant is conditioned on. An imported worker never learns its base path mapping key, so deletion skipped the mapping and then failed on the domain that still referenced it. Also regenerates the import-data schema snapshots for the new REST fields and the CloudFormation full-stack snapshot, names the failing operation in mapped API Gateway errors, and corrects the permission-set descriptions that still described the grants as HTTP-only. --- .../alien-aws-clients/src/aws/apigateway.rs | 103 ++++++++++++++---- crates/alien-cloudformation/src/built_ins.rs | 3 +- .../src/emitters/aws/ai.rs | 22 ++-- .../src/emitters/aws/email.rs | 1 - .../src/emitters/aws/kv.rs | 6 - .../src/emitters/aws/queue.rs | 1 - .../alien-cloudformation/tests/generator.rs | 2 +- .../tests/generator/aws_ai_tests.rs | 10 +- .../tests/generator/gating_matrix_tests.rs | 16 ++- ..._aws_full_stack_tests__aws_full_stack.snap | 22 ++-- ...schema_snapshots__import_data_schemas.snap | 16 ++- crates/alien-infra/src/ai/aws_import.rs | 4 +- crates/alien-infra/src/ai/azure.rs | 53 +++++---- crates/alien-infra/src/ai/gcp.rs | 32 ++---- crates/alien-infra/src/ai/gcp_import.rs | 6 +- crates/alien-infra/src/ai/local.rs | 27 +++-- crates/alien-infra/src/aws_importers.rs | 4 +- crates/alien-infra/src/core/executor.rs | 5 +- .../core/executor_tests/edge_cases_tests.rs | 18 ++- .../alien-infra/src/core/service_provider.rs | 2 +- crates/alien-infra/src/daemon/local.rs | 4 +- crates/alien-infra/src/lib.rs | 8 +- .../src/remote_stack_management/azure.rs | 34 +++--- crates/alien-infra/src/worker/aws.rs | 75 ++++++++----- crates/alien-infra/src/worker/local.rs | 7 +- crates/alien-infra/tests/importers.rs | 14 +-- .../permission-sets/worker/management.jsonc | 8 +- .../permission-sets/worker/provision.jsonc | 8 +- .../ai-gateway/src/__tests__/client.test.ts | 7 +- .../schemas/awsWorkerImportData.json | 2 +- .../zod/aws-worker-import-data-schema.ts | 2 + 31 files changed, 328 insertions(+), 194 deletions(-) diff --git a/crates/alien-aws-clients/src/aws/apigateway.rs b/crates/alien-aws-clients/src/aws/apigateway.rs index 272e73d57..2cf054158 100644 --- a/crates/alien-aws-clients/src/aws/apigateway.rs +++ b/crates/alien-aws-clients/src/aws/apigateway.rs @@ -73,6 +73,14 @@ pub trait ApiGatewayApi: Send + Sync + std::fmt::Debug { 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<()>; } // --------------------------------------------------------------------------- @@ -157,7 +165,11 @@ impl ApiGatewayClient { 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 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()); @@ -203,7 +215,7 @@ impl ApiGatewayClient { fn map_apigw_error( status: StatusCode, body: &str, - _operation: &str, + operation: &str, resource: &str, request_body: Option<&str>, ) -> Option { @@ -232,13 +244,13 @@ impl ApiGatewayClient { "ConflictException" => ErrorData::RemoteResourceConflict { resource_type: "ApiGateway".into(), resource_name: resource.into(), - message, + message: format!("{operation}: {message}"), + }, + "TooManyRequestsException" | "ThrottlingException" => ErrorData::RateLimitExceeded { + message: format!("{operation}: {message}"), }, - "TooManyRequestsException" | "ThrottlingException" => { - ErrorData::RateLimitExceeded { message } - } "BadRequestException" | "ValidationException" => ErrorData::InvalidInput { - message, + message: format!("{operation}: {message}"), field_name: None, }, _ => match status { @@ -249,18 +261,22 @@ impl ApiGatewayClient { StatusCode::CONFLICT => ErrorData::RemoteResourceConflict { resource_type: "ApiGateway".into(), resource_name: resource.into(), - message, + 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 }, + StatusCode::TOO_MANY_REQUESTS => ErrorData::RateLimitExceeded { + message: format!("{operation}: {message}"), + }, StatusCode::SERVICE_UNAVAILABLE | StatusCode::BAD_GATEWAY - | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable { message }, + | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable { + message: format!("{operation}: {message}"), + }, _ => ErrorData::HttpResponseError { - message: format!("ApiGateway operation failed: {}", message), + message: format!("ApiGateway {operation} failed: {message}"), url: "apigateway.amazonaws.com".to_string(), http_status: status.as_u16(), http_response_text: Some(body.into()), @@ -284,8 +300,14 @@ impl ApiGatewayClient { 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 + self.send_json( + Method::POST, + "/restapis", + Some(body), + "CreateRestApi", + &request.name, + ) + .await } async fn delete_rest_api(&self, rest_api_id: &str) -> Result<()> { @@ -302,8 +324,14 @@ impl ApiGatewayApi for ApiGatewayClient { ) -> 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 + self.send_json( + Method::POST, + &path, + Some(body), + "CreateResource", + rest_api_id, + ) + .await } async fn put_method( @@ -334,8 +362,14 @@ impl ApiGatewayApi for ApiGatewayClient { 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 + self.send_no_response( + Method::PUT, + &path, + Some(body), + "PutIntegration", + rest_api_id, + ) + .await } async fn create_deployment( @@ -345,8 +379,14 @@ impl ApiGatewayApi for ApiGatewayClient { ) -> 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 + self.send_json( + Method::POST, + &path, + Some(body), + "CreateDeployment", + rest_api_id, + ) + .await } async fn create_domain_name(&self, request: CreateDomainNameRequest) -> Result { @@ -374,12 +414,33 @@ impl ApiGatewayApi for ApiGatewayClient { ) -> 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) + 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); + let path = format!( + "/domainnames/{}/basepathmappings/{}", + domain_name, base_path + ); self.send_no_response( Method::DELETE, &path, 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 02bbae54d..20c66a167 100644 --- a/crates/alien-cloudformation/src/emitters/aws/email.rs +++ b/crates/alien-cloudformation/src/emitters/aws/email.rs @@ -692,4 +692,3 @@ fn email_iam_policies( Ok(resources) } - diff --git a/crates/alien-cloudformation/src/emitters/aws/kv.rs b/crates/alien-cloudformation/src/emitters/aws/kv.rs index f48965619..f441f13cc 100644 --- a/crates/alien-cloudformation/src/emitters/aws/kv.rs +++ b/crates/alien-cloudformation/src/emitters/aws/kv.rs @@ -1,10 +1,4 @@ //! AWS KV — DynamoDB on-demand table with composite key, TTL, and SSE. -//! -//! When a service-account permission profile grants `kv/*` permission sets for -//! this store (directly by resource id or through a `*` wildcard), the matching -//! IAM policies are emitted against the owning roles, with every statement -//! pinned to this table's ARN (the physical table name is -//! CloudFormation-generated, so name-pattern bindings cannot match it). use crate::{ emitter::CfEmitter, diff --git a/crates/alien-cloudformation/src/emitters/aws/queue.rs b/crates/alien-cloudformation/src/emitters/aws/queue.rs index b1b528f81..f020fa336 100644 --- a/crates/alien-cloudformation/src/emitters/aws/queue.rs +++ b/crates/alien-cloudformation/src/emitters/aws/queue.rs @@ -203,4 +203,3 @@ fn pin_statement_to_queue(statement: CfExpression, queue_id: &str) -> CfExpressi ); CfExpression::Object(statement_object) } - 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/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 082e0bb39..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 @@ -835,6 +835,17 @@ Resources: - 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 @@ -852,17 +863,6 @@ Resources: - 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 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/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 34705509c..a96ad8a76 100644 --- a/crates/alien-infra/src/core/service_provider.rs +++ b/crates/alien-infra/src/core/service_provider.rs @@ -25,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}, @@ -47,7 +48,6 @@ use alien_azure_clients::{ AzureServiceBusDataPlaneClient, AzureServiceBusManagementClient, ServiceBusDataPlaneApi, ServiceBusManagementApi, }, - cognitive_services::{AzureCognitiveServicesClient, CognitiveServicesAccountsApi}, storage_accounts::{AzureStorageAccountsClient, StorageAccountsApi}, tables::{AzureTableManagementClient, TableManagementApi}, AzureClientConfig, AzureTokenCache, 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 186d99e62..166b2c2e7 100644 --- a/crates/alien-infra/src/worker/aws.rs +++ b/crates/alien-infra/src/worker/aws.rs @@ -338,11 +338,8 @@ fn emit_aws_lambda_worker_heartbeat( }); } -/// Whether this worker should be exposed via a streaming REST (V1) API -/// (`responseTransferMode: STREAM`) instead of the buffered V2 HTTP API. -/// Signalled by `WORKER_RESPONSE_STREAMING=true` in the worker's environment. -/// (A typed builder field is the cleaner long-term surface; an env flag keeps -/// this change contained to the AWS controller.) +/// 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 @@ -709,8 +706,7 @@ impl AwsWorkerController { ) -> Result { let worker_config = ctx.desired_resource_config::()?; - // Single chokepoint fork: streaming workers are served by REST V1 - // (see gateway_entry_state), so every path that lands here reroutes. + // Streaming workers reroute to REST V1 (see gateway_entry_state). if worker_wants_streaming(&worker_config) { return Ok(HandlerAction::Continue { state: CreatingRestApi, @@ -1395,6 +1391,21 @@ impl AwsWorkerController { self.rest_deployment_id = Some(deployment_id); self.stage_name = Some("prod".to_string()); + + // CreateDeployment takes no tags, so the stage it creates would sit + // outside the deployment's tag boundary that every management grant + // is conditioned on. + 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() { @@ -1528,7 +1539,10 @@ impl AwsWorkerController { }) })?; - let stage = self.stage_name.clone().unwrap_or_else(|| "prod".to_string()); + let stage = self + .stage_name + .clone() + .unwrap_or_else(|| "prod".to_string()); let mapping = client .create_base_path_mapping( @@ -2748,6 +2762,15 @@ impl AwsWorkerController { 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", @@ -3753,25 +3776,31 @@ impl AwsWorkerController { // 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), Some(base_path)) = - (self.domain_name.as_ref(), self.rest_base_path.as_ref()) - { + 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 { + 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 { .. } - | CloudClientErrorData::RemoteAccessDenied { .. } - ) + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { - info!(worker=%worker_config.id, "REST base path mapping already gone or inaccessible"); + info!(worker=%worker_config.id, "REST base path mapping already gone"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { @@ -3823,10 +3852,7 @@ impl AwsWorkerController { Err(e) if matches!( e.error, - Some( - CloudClientErrorData::RemoteResourceNotFound { .. } - | CloudClientErrorData::RemoteAccessDenied { .. } - ) + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { info!(worker=%worker_config.id, "Custom domain already gone or inaccessible"); @@ -3908,13 +3934,10 @@ impl AwsWorkerController { Err(e) if matches!( e.error, - Some( - CloudClientErrorData::RemoteResourceNotFound { .. } - | CloudClientErrorData::RemoteAccessDenied { .. } - ) + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) ) => { - info!(worker=%worker_config.id, "REST API already gone or inaccessible"); + info!(worker=%worker_config.id, "REST API already gone"); } Err(e) => { return Err(e.context(ErrorData::CloudPlatformError { 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-permissions/permission-sets/worker/management.jsonc b/crates/alien-permissions/permission-sets/worker/management.jsonc index 8547994e3..75e4fd2cc 100644 --- a/crates/alien-permissions/permission-sets/worker/management.jsonc +++ b/crates/alien-permissions/permission-sets/worker/management.jsonc @@ -211,7 +211,7 @@ { // 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"] }, @@ -250,7 +250,7 @@ { // 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"] }, @@ -291,7 +291,7 @@ { // 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"] }, @@ -333,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"] }, diff --git a/crates/alien-permissions/permission-sets/worker/provision.jsonc b/crates/alien-permissions/permission-sets/worker/provision.jsonc index 09c169958..ad612316e 100644 --- a/crates/alien-permissions/permission-sets/worker/provision.jsonc +++ b/crates/alien-permissions/permission-sets/worker/provision.jsonc @@ -313,7 +313,7 @@ { // 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"] }, @@ -352,7 +352,7 @@ { // 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"] }, @@ -393,7 +393,7 @@ { // 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"] }, @@ -436,7 +436,7 @@ { // 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"] }, diff --git a/packages/ai-gateway/src/__tests__/client.test.ts b/packages/ai-gateway/src/__tests__/client.test.ts index cc280d53e..173b4923e 100644 --- a/packages/ai-gateway/src/__tests__/client.test.ts +++ b/packages/ai-gateway/src/__tests__/client.test.ts @@ -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-ai", 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", diff --git a/packages/core/src/generated/schemas/awsWorkerImportData.json b/packages/core/src/generated/schemas/awsWorkerImportData.json index cdfa9482c..d49e7660e 100644 --- a/packages/core/src/generated/schemas/awsWorkerImportData.json +++ b/packages/core/src/generated/schemas/awsWorkerImportData.json @@ -1 +1 @@ -{"type":"object","description":"AWS Worker ImportData.","required":["functionName","functionArn","eventSourceMappings","eventbridgeRuleNames","s3PermissionStatementIds","eventbridgePermissionStatementIds"],"properties":{"apiId":{"type":["string","null"],"description":"API Gateway HTTP API ID, when public ingress is enabled."},"eventSourceMappings":{"type":"array","items":{"type":"string"},"description":"Queue event-source mapping UUIDs."},"eventbridgePermissionStatementIds":{"type":"array","items":{"type":"string"},"description":"Lambda permission statement IDs granted for EventBridge schedule triggers."},"eventbridgeRuleNames":{"type":"array","items":{"type":"string"},"description":"EventBridge rule names for schedule triggers."},"functionArn":{"type":"string","description":"Lambda function ARN."},"functionName":{"type":"string","description":"Lambda function name."},"integrationId":{"type":["string","null"],"description":"API Gateway integration ID, when public ingress is enabled."},"routeId":{"type":["string","null"],"description":"API Gateway route ID, when public ingress is enabled."},"s3PermissionStatementIds":{"type":"array","items":{"type":"string"},"description":"Lambda permission statement IDs granted for S3 storage triggers."},"stageName":{"type":["string","null"],"description":"API Gateway stage name, when public ingress is enabled."},"url":{"type":["string","null"],"description":"Public HTTPS URL, when public ingress is enabled."}},"x-readme-ref-name":"AwsWorkerImportData"} \ No newline at end of file +{"type":"object","description":"AWS Worker ImportData.","required":["functionName","functionArn","eventSourceMappings","eventbridgeRuleNames","s3PermissionStatementIds","eventbridgePermissionStatementIds"],"properties":{"apiId":{"type":["string","null"],"description":"API Gateway HTTP API ID, when public ingress is enabled."},"eventSourceMappings":{"type":"array","items":{"type":"string"},"description":"Queue event-source mapping UUIDs."},"eventbridgePermissionStatementIds":{"type":"array","items":{"type":"string"},"description":"Lambda permission statement IDs granted for EventBridge schedule triggers."},"eventbridgeRuleNames":{"type":"array","items":{"type":"string"},"description":"EventBridge rule names for schedule triggers."},"functionArn":{"type":"string","description":"Lambda function ARN."},"functionName":{"type":"string","description":"Lambda function name."},"integrationId":{"type":["string","null"],"description":"API Gateway integration ID, when public ingress is enabled."},"restApiId":{"type":["string","null"],"description":"API Gateway REST (V1) API ID, when public ingress is enabled for a\nstreaming worker. Mutually exclusive with `api_id`: a worker is exposed\nthrough REST V1 xor the V2 HTTP API, so only one set is ever populated."},"restStageName":{"type":["string","null"],"description":"API Gateway REST (V1) stage name, when public ingress is enabled for a\nstreaming worker. Mutually exclusive with `stage_name` (V1 xor V2)."},"routeId":{"type":["string","null"],"description":"API Gateway route ID, when public ingress is enabled."},"s3PermissionStatementIds":{"type":"array","items":{"type":"string"},"description":"Lambda permission statement IDs granted for S3 storage triggers."},"stageName":{"type":["string","null"],"description":"API Gateway stage name, when public ingress is enabled."},"url":{"type":["string","null"],"description":"Public HTTPS URL, when public ingress is enabled."}},"x-readme-ref-name":"AwsWorkerImportData"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/aws-worker-import-data-schema.ts b/packages/core/src/generated/zod/aws-worker-import-data-schema.ts index 2073ab2ec..c533e53fb 100644 --- a/packages/core/src/generated/zod/aws-worker-import-data-schema.ts +++ b/packages/core/src/generated/zod/aws-worker-import-data-schema.ts @@ -16,6 +16,8 @@ export const AwsWorkerImportDataSchema = z.object({ "functionArn": z.string().describe("Lambda function ARN."), "functionName": z.string().describe("Lambda function name."), "integrationId": z.string().describe("API Gateway integration ID, when public ingress is enabled.").nullish(), +"restApiId": z.string().describe("API Gateway REST (V1) API ID, when public ingress is enabled for a\nstreaming worker. Mutually exclusive with `api_id`: a worker is exposed\nthrough REST V1 xor the V2 HTTP API, so only one set is ever populated.").nullish(), +"restStageName": z.string().describe("API Gateway REST (V1) stage name, when public ingress is enabled for a\nstreaming worker. Mutually exclusive with `stage_name` (V1 xor V2).").nullish(), "routeId": z.string().describe("API Gateway route ID, when public ingress is enabled.").nullish(), "s3PermissionStatementIds": z.array(z.string()).describe("Lambda permission statement IDs granted for S3 storage triggers."), "stageName": z.string().describe("API Gateway stage name, when public ingress is enabled.").nullish(), From 5cef58929c4001db091d7cb8106a47c8fd5cfb40 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 31 Jul 2026 00:42:49 +0300 Subject: [PATCH 5/5] fix(worker): scope the API Gateway invoke grant and keep stage tagging retryable The Lambda invoke grant named apigateway.amazonaws.com with no source, so any API Gateway in any account could call the function; it now names the API that fronts the worker. Stage tagging sat inside the deployment-creation guard, so a transient failure left the deployment id recorded and every retry skipped the tag. --- crates/alien-infra/src/worker/aws.rs | 57 ++++++++++++++++++---------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/alien-infra/src/worker/aws.rs b/crates/alien-infra/src/worker/aws.rs index 166b2c2e7..6105acb87 100644 --- a/crates/alien-infra/src/worker/aws.rs +++ b/crates/alien-infra/src/worker/aws.rs @@ -1363,12 +1363,12 @@ impl AwsWorkerController { }) })?; - if self.rest_deployment_id.is_none() { - let client = ctx - .service_provider - .get_aws_apigateway_client(aws_cfg) - .await?; + let client = ctx + .service_provider + .get_aws_apigateway_client(aws_cfg) + .await?; + if self.rest_deployment_id.is_none() { let deployment = client .create_deployment( &rest_api_id, @@ -1391,23 +1391,24 @@ impl AwsWorkerController { self.rest_deployment_id = Some(deployment_id); self.stage_name = Some("prod".to_string()); - - // CreateDeployment takes no tags, so the stage it creates would sit - // outside the deployment's tag boundary that every management grant - // is conditioned on. - 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()), - })?; } + // 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, @@ -1588,10 +1589,26 @@ impl AwsWorkerController { 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