import ipaddress import json import os import re from typing import Any, Dict, FrozenSet, List, Literal, Optional, Tuple, Union S3BucketAvailability = Literal["owned", "available", "globally_taken"] CognitoDomainAvailability = Literal["available", "taken"] import boto3 import pandas as pd from aws_cdk import ( App, CfnOutput, CfnTag, CustomResource, Duration, Fn, RemovalPolicy, Stack, Tags, ) from aws_cdk import aws_cloudwatch as cloudwatch from aws_cdk import aws_cloudwatch_actions as cloudwatch_actions from aws_cdk import aws_codebuild as codebuild from aws_cdk import aws_cognito as cognito from aws_cdk import aws_dynamodb as dynamodb from aws_cdk import aws_ec2 as ec2 from aws_cdk import aws_ecs as ecs from aws_cdk import aws_elasticloadbalancingv2 as elb from aws_cdk import aws_elasticloadbalancingv2_actions as elb_act from aws_cdk import aws_events as events from aws_cdk import aws_events_targets as targets from aws_cdk import aws_iam as iam from aws_cdk import aws_kms as kms from aws_cdk import aws_lambda as lambda_ from aws_cdk import aws_logs as logs from aws_cdk import aws_s3 as s3 from aws_cdk import aws_s3_notifications as s3n from aws_cdk import aws_secretsmanager as secretsmanager from aws_cdk import aws_sns as sns from aws_cdk import aws_sns_subscriptions as sns_subscriptions from aws_cdk import aws_wafv2 as wafv2 from aws_cdk import custom_resources as cr from botocore.exceptions import ClientError, NoCredentialsError from cdk_config import ( ACCESS_LOG_DYNAMODB_TABLE_NAME, APP_CONFIG_ENV_BASENAME, AWS_REGION, ECS_AVAILABILITY_ZONE_REBALANCING, ENABLE_RESOURCE_DELETE_PROTECTION, FEEDBACK_LOG_DYNAMODB_TABLE_NAME, NAT_GATEWAY_EIP_NAME, PRIVATE_SUBNET_AVAILABILITY_ZONES, PRIVATE_SUBNET_CIDR_BLOCKS, PRIVATE_SUBNETS_TO_USE, PUBLIC_SUBNET_AVAILABILITY_ZONES, PUBLIC_SUBNET_CIDR_BLOCKS, PUBLIC_SUBNETS_TO_USE, S3_LOG_CONFIG_BUCKET_NAME, S3_OUTPUT_BUCKET_NAME, USAGE_LOG_DYNAMODB_TABLE_NAME, ) from constructs import Construct from dotenv import dotenv_values, set_key # CDK CLI stores lookup-provider results under these key prefixes in cdk.context.json. _CDK_LOOKUP_CONTEXT_PREFIXES = ( "vpc-provider:", "load-balancer:", "availability-zones:", "hosted-zone:", "security-group:", "key-provider:", "ami:", ) def _ensure_folder_exists(output_folder: str) -> None: if not os.path.exists(output_folder): os.makedirs(output_folder, exist_ok=True) print(f"Created the {output_folder} folder.") else: print(f"The {output_folder} folder already exists.") def is_resource_delete_protection_enabled() -> bool: """Whether stack and resource delete protection is enabled (see ENABLE_RESOURCE_DELETE_PROTECTION).""" return str(ENABLE_RESOURCE_DELETE_PROTECTION).strip().lower() in ( "true", "1", "yes", ) def resource_deletion_protection_flag() -> bool: """AWS deletion_protection attribute (ALB, DynamoDB tables, Cognito user pools).""" return is_resource_delete_protection_enabled() def ecs_availability_zone_rebalancing( setting: str, ) -> ecs.AvailabilityZoneRebalancing: """Map ``ECS_AVAILABILITY_ZONE_REBALANCING`` env value to the CDK enum.""" if setting == "ENABLED": return ecs.AvailabilityZoneRebalancing.ENABLED return ecs.AvailabilityZoneRebalancing.DISABLED def managed_resource_removal_policy() -> RemovalPolicy: """Removal policy for CDK-managed resources without a native deletion_protection flag.""" return ( RemovalPolicy.RETAIN if is_resource_delete_protection_enabled() else RemovalPolicy.DESTROY ) def s3_auto_delete_objects_on_stack_destroy() -> bool: """Empty S3 buckets automatically when the stack is destroyed (dev/teardown only).""" return not is_resource_delete_protection_enabled() def ecr_empty_on_delete() -> bool: """Force-delete ECR images when the repository is removed on stack destroy.""" return not is_resource_delete_protection_enabled() def purge_cdk_lookup_context(file_path: str) -> int: """Remove stale CDK lookup cache entries that require the bootstrap lookup role.""" if not os.path.exists(file_path): return 0 with open(file_path, "r", encoding="utf-8") as f: context_data = json.load(f) cleaned = { key: value for key, value in context_data.items() if not key.startswith(_CDK_LOOKUP_CONTEXT_PREFIXES) } removed = len(context_data) - len(cleaned) if removed: with open(file_path, "w", encoding="utf-8") as f: json.dump(cleaned, f, indent=2) print(f"Removed {removed} stale CDK lookup context key(s) from {file_path}.") return removed def log_aws_credential_context( expected_account_id: Optional[str] = None, expected_region: Optional[str] = None, ) -> Dict[str, Any]: """ Print the active AWS identity and non-secret credential hints for CDK debugging. Helps distinguish SSO/assumed-role sessions from long-lived access keys in ~/.aws/credentials or environment variables. """ profile = os.environ.get("AWS_PROFILE") or "(not set — using default profile chain)" default_region = ( os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "(not set in environment)" ) env_access_key_set = bool(os.environ.get("AWS_ACCESS_KEY_ID")) env_secret_key_set = bool(os.environ.get("AWS_SECRET_ACCESS_KEY")) env_session_token_set = bool(os.environ.get("AWS_SESSION_TOKEN")) print("\n--- AWS credential context (CDK / boto3) ---") print(f"AWS_PROFILE: {profile}") print(f"AWS_REGION / AWS_DEFAULT_REGION (env): {default_region}") print( "Environment credential variables: " f"AWS_ACCESS_KEY_ID={'set' if env_access_key_set else 'not set'}, " f"AWS_SECRET_ACCESS_KEY={'set' if env_secret_key_set else 'not set'}, " f"AWS_SESSION_TOKEN={'set' if env_session_token_set else 'not set'}" ) if expected_account_id: print(f"Configured CDK target account (AWS_ACCOUNT_ID): {expected_account_id}") if expected_region: print(f"Configured CDK target region (AWS_REGION): {expected_region}") session = boto3.Session() active_profile = session.profile_name or "(default)" print(f"boto3 session profile: {active_profile}") print(f"boto3 session region: {session.region_name or '(not set)'}") credentials = session.get_credentials() credential_summary: Dict[str, Any] = { "profile": profile, "session_profile": active_profile, } if credentials is None: print("WARNING: No AWS credentials found in the default provider chain.") print("--- End AWS credential context ---\n") credential_summary["error"] = "no_credentials" return credential_summary frozen = credentials.get_frozen_credentials() access_key = frozen.access_key or "" access_key_prefix = (access_key[:4] + "...") if len(access_key) >= 4 else "(none)" credential_summary["access_key_prefix"] = access_key_prefix if env_access_key_set: credential_source = "environment variables (highest precedence)" elif access_key.startswith("AKIA"): credential_source = "long-lived access key (likely ~/.aws/credentials [default] or named profile)" elif access_key.startswith("ASIA"): credential_source = "temporary credentials (SSO, assumed role, or STS session)" else: credential_source = ( "resolved credentials (source could not be classified from key prefix)" ) print(f"Inferred credential type: {credential_source}") credential_summary["inferred_credential_type"] = credential_source if env_access_key_set and profile != "(not set — using default profile chain)": print( "NOTE: AWS_ACCESS_KEY_ID is set in the environment, so it overrides " f"profile '{profile}' and SSO." ) try: sts = session.client("sts", region_name=session.region_name or expected_region) identity = sts.get_caller_identity() except (ClientError, NoCredentialsError) as exc: print(f"WARNING: sts:GetCallerIdentity failed: {exc}") print("--- End AWS credential context ---\n") credential_summary["error"] = str(exc) return credential_summary account = identity.get("Account", "") arn = identity.get("Arn", "") user_id = identity.get("UserId", "") print(f"Caller account: {account}") print(f"Caller ARN: {arn}") print(f"Caller UserId: {user_id}") if ":assumed-role/" in arn: principal_kind = "assumed IAM role (typical for SSO or role chaining)" elif ":user/" in arn: principal_kind = "IAM user (typical for static access keys in credentials file)" elif ":federated-user/" in arn: principal_kind = "federated user" else: principal_kind = "other IAM principal" print(f"Principal kind: {principal_kind}") credential_summary.update( { "account": account, "arn": arn, "user_id": user_id, "principal_kind": principal_kind, } ) if expected_account_id and account and account != str(expected_account_id): print( "WARNING: Caller account does not match configured AWS_ACCOUNT_ID. " "CDK will target the configured account but act as this identity — " "deployments and lookups may fail. Set AWS_PROFILE to your SSO profile " "and unset AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY if needed." ) credential_summary["account_mismatch"] = True elif expected_account_id and account == str(expected_account_id): print("Caller account matches configured AWS_ACCOUNT_ID.") if profile == "(not set — using default profile chain)": print( "TIP: Set AWS_PROFILE to your SSO profile name so Python and the CDK CLI " "(Node) use the same session. Example: " '$env:AWS_PROFILE = "YourSsoProfileName"' ) print("--- End AWS credential context ---\n") return credential_summary # --- Function to load context from file --- def _context_value_for_cdk(value): """CDK/JSII context cannot use JSON null; normalize for Windows synth.""" if value is None: return "" if isinstance(value, dict): return {k: _context_value_for_cdk(v) for k, v in value.items()} if isinstance(value, list): return [_context_value_for_cdk(v) for v in value] return value def load_context_from_file(app: App, file_path: str): if os.path.exists(file_path): with open(file_path, "r", encoding="utf-8") as f: context_data = json.load(f) for key, value in context_data.items(): app.node.set_context(key, _context_value_for_cdk(value)) print(f"Loaded context from {file_path}") else: print(f"Context file not found: {file_path}") # --- Helper to parse environment variables into lists --- def _get_env_list(env_var_name: str) -> List[str]: """Parses a comma-separated environment variable into a list of strings.""" value = env_var_name[1:-1].strip().replace('"', "").replace("'", "") if not value: return [] # Split by comma and filter out any empty strings that might result from extra commas return [s.strip() for s in value.split(",") if s.strip()] # 1. Try to load CIDR/AZs from environment variables if PUBLIC_SUBNETS_TO_USE: PUBLIC_SUBNETS_TO_USE = _get_env_list(PUBLIC_SUBNETS_TO_USE) if PRIVATE_SUBNETS_TO_USE: PRIVATE_SUBNETS_TO_USE = _get_env_list(PRIVATE_SUBNETS_TO_USE) if PUBLIC_SUBNET_CIDR_BLOCKS: PUBLIC_SUBNET_CIDR_BLOCKS = _get_env_list("PUBLIC_SUBNET_CIDR_BLOCKS") if PUBLIC_SUBNET_AVAILABILITY_ZONES: PUBLIC_SUBNET_AVAILABILITY_ZONES = _get_env_list("PUBLIC_SUBNET_AVAILABILITY_ZONES") if PRIVATE_SUBNET_CIDR_BLOCKS: PRIVATE_SUBNET_CIDR_BLOCKS = _get_env_list("PRIVATE_SUBNET_CIDR_BLOCKS") if PRIVATE_SUBNET_AVAILABILITY_ZONES: PRIVATE_SUBNET_AVAILABILITY_ZONES = _get_env_list( "PRIVATE_SUBNET_AVAILABILITY_ZONES" ) def resolve_policy_file_paths( paths: List[str], *, cdk_folder: str = "", ) -> List[str]: """Resolve JSON policy paths relative to ``CDK_FOLDER`` when not absolute.""" resolved: List[str] = [] base = (cdk_folder or "").strip().rstrip("/\\") for raw in paths: path = (raw or "").strip() if not path: continue if os.path.isabs(path): resolved.append(os.path.normpath(path)) elif base: resolved.append(os.path.normpath(os.path.join(base, path))) else: resolved.append(os.path.normpath(path)) return resolved def attach_managed_policy_arns(role: iam.IRole, policy_arns: List[str]) -> None: """Attach existing customer-managed policies by full ARN.""" for arn in policy_arns: arn = (arn or "").strip() if not arn: continue role.add_managed_policy( iam.ManagedPolicy.from_managed_policy_arn(role, arn, arn) ) print(f"Attached managed policy ARN to {role.node.id}: {arn}") def check_for_existing_role(role_name: str): try: iam = boto3.client("iam") # iam.get_role(RoleName=role_name) response = iam.get_role(RoleName=role_name) role = response["Role"]["Arn"] print("Response Role:", role) return True, role, "" except iam.exceptions.NoSuchEntityException: return False, "", "" except Exception as e: raise Exception("Getting information on IAM role failed due to:", e) from typing import List def add_statement_to_policy(role: iam.IRole, policy_document: Dict[str, Any]): """ Adds individual policy statements from a parsed policy document to a CDK Role. Args: role: The CDK Role construct to attach policies to. policy_document: A Python dictionary representing an IAM policy document. """ # Ensure the loaded JSON is a valid policy document structure if "Statement" not in policy_document or not isinstance( policy_document["Statement"], list ): print("Warning: Policy document does not contain a 'Statement' list. Skipping.") return # Do not return role, just log and exit for statement_dict in policy_document["Statement"]: try: # Create a CDK PolicyStatement from the dictionary cdk_policy_statement = iam.PolicyStatement.from_json(statement_dict) # Add the policy statement to the role role.add_to_policy(cdk_policy_statement) print(f" - Added statement: {statement_dict.get('Sid', 'No Sid')}") except Exception as e: print( f"Warning: Could not process policy statement: {statement_dict}. Error: {e}" ) def vpc_endpoint_aws_service_name(service_suffix: str, region: str) -> str: """Full EC2 ``ServiceName`` for a VPC endpoint (matches describe_vpc_endpoints).""" return f"com.amazonaws.{region}.{service_suffix}" def list_existing_vpc_endpoint_service_names( vpc_id: str, *, region_name: Optional[str] = None, ) -> FrozenSet[str]: """Return AWS service names for non-deleted VPC endpoints in the given VPC.""" ec2_client = boto3.client("ec2", region_name=region_name) service_names: set[str] = set() paginator = ec2_client.get_paginator("describe_vpc_endpoints") for page in paginator.paginate( Filters=[{"Name": "vpc-id", "Values": [vpc_id]}], ): for endpoint in page.get("VpcEndpoints", []): state = endpoint.get("State") if state in ("available", "pending", "pendingAcceptance"): service_name = endpoint.get("ServiceName") if service_name: service_names.add(service_name) return frozenset(service_names) def resolve_ecs_vpc_endpoint_subnet_selection( *, use_express_ingress: bool, express_use_public_subnets: bool, public_subnets: List[ec2.ISubnet], private_subnets: List[ec2.ISubnet], ) -> Optional[ec2.SubnetSelection]: """ Choose subnets for ECS-related **interface** VPC endpoints. Interface ENIs must sit in the same subnets ECS tasks use. Express Mode with ``ECS_EXPRESS_USE_PUBLIC_SUBNETS=True`` runs tasks in public subnets; legacy Fargate and Express-on-private use private. S3 gateway routes use ``resolve_ecs_s3_gateway_subnet_selection`` instead. """ if use_express_ingress: if express_use_public_subnets: if not public_subnets: return None return ec2.SubnetSelection(subnets=public_subnets) if not private_subnets: return None return ec2.SubnetSelection(subnets=private_subnets) if private_subnets: return ec2.SubnetSelection(subnets=private_subnets) if public_subnets: return ec2.SubnetSelection(subnets=public_subnets) return None def resolve_ecs_s3_gateway_subnet_selection( *, public_subnets: List[ec2.ISubnet], private_subnets: List[ec2.ISubnet], ) -> Optional[ec2.SubnetSelection]: """ All stack-managed subnets for the S3 **gateway** endpoint. Gateway endpoints attach to route tables; every public and private subnet the stack imports or creates should get the S3 prefix-list route, not only the ECS task tier. """ all_subnets: List[ec2.ISubnet] = [] seen_subnet_ids: set[str] = set() for subnet in public_subnets + private_subnets: subnet_id = subnet.subnet_id if subnet_id in seen_subnet_ids: continue seen_subnet_ids.add(subnet_id) all_subnets.append(subnet) if not all_subnets: return None return ec2.SubnetSelection(subnets=all_subnets) def list_vpc_associated_cidr_blocks(vpc: dict) -> List[str]: """All associated IPv4 CIDR blocks for a VPC (primary and secondary).""" cidrs: List[str] = [] seen: set[str] = set() for assoc in vpc.get("CidrBlockAssociationSet") or []: state = (assoc.get("CidrBlockState") or {}).get("State") if state and state != "associated": continue cidr = assoc.get("CidrBlock") if cidr and cidr not in seen: seen.add(cidr) cidrs.append(cidr) primary = vpc.get("CidrBlock") if primary and primary not in seen: cidrs.insert(0, primary) return cidrs def resolve_vpc_endpoint_ingress_cidr_blocks( *, vpc_cidr_block: Optional[str] = None, vpc_cidr_blocks: Optional[List[str]] = None, fallback_vpc_cidr: Optional[str] = None, ) -> List[str]: """CIDR list for VPC endpoint SG ingress (deduplicated, stable order).""" resolved: List[str] = [] seen: set[str] = set() for cidr in vpc_cidr_blocks or []: if cidr and cidr not in seen: seen.add(cidr) resolved.append(cidr) if not resolved: single = vpc_cidr_block or fallback_vpc_cidr if single: resolved = [single] return resolved def add_vpc_endpoint_https_ingress_from_vpc_cidrs( endpoint_sg: ec2.SecurityGroup, *, vpc_cidr_block: Optional[str] = None, vpc_cidr_blocks: Optional[List[str]] = None, fallback_vpc_cidr: Optional[str] = None, ) -> None: """Allow HTTPS from every CIDR associated with the VPC.""" cidrs = resolve_vpc_endpoint_ingress_cidr_blocks( vpc_cidr_block=vpc_cidr_block, vpc_cidr_blocks=vpc_cidr_blocks, fallback_vpc_cidr=fallback_vpc_cidr, ) if not cidrs: raise ValueError( "VPC CIDR block(s) are required for the VPC endpoint security group. " "Re-run check_resources.py so vpc_cidr_blocks is stored in precheck context." ) for cidr in cidrs: endpoint_sg.add_ingress_rule( peer=ec2.Peer.ipv4(cidr), connection=ec2.Port.tcp(443), description=( "HTTPS from VPC workloads" if len(cidrs) == 1 else f"HTTPS from VPC workloads ({cidr})" ), ) def create_ecs_vpc_endpoints_for_private_subnets( scope: Construct, *, vpc: ec2.IVpc, subnets: Optional[ec2.SubnetSelection], logical_id_prefix: str = "Ecs", include_secrets_and_kms: bool = True, vpc_cidr_block: Optional[str] = None, vpc_cidr_blocks: Optional[List[str]] = None, skip_service_names: Optional[FrozenSet[str]] = None, aws_region: str, s3_gateway_subnets: Optional[ec2.SubnetSelection] = None, ) -> None: """ Interface and S3 gateway VPC endpoints for ECS workloads. Interface endpoints use ``subnets`` (ECS task tier). The S3 gateway uses ``s3_gateway_subnets`` when provided, otherwise ``subnets``. Without ``ecr.api`` / ``ecr.dkr`` endpoints (or a working NAT path), tasks fail with ``GetAuthorizationToken`` timeouts. ``skip_service_names`` should list full AWS endpoint service names already present in the VPC (from pre-check) so shared VPCs do not fail on duplicate private DNS. """ s3_subnets = s3_gateway_subnets or subnets if not subnets and not s3_subnets: return skip = skip_service_names or frozenset() interface_services: List[Tuple[str, str, ec2.InterfaceVpcEndpointAwsService]] = [ ("EcrApi", "ecr.api", ec2.InterfaceVpcEndpointAwsService.ECR), ("EcrDkr", "ecr.dkr", ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER), ("CloudWatchLogs", "logs", ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS), ] if include_secrets_and_kms: interface_services.extend( [ ( "SecretsManager", "secretsmanager", ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, ), ("Kms", "kms", ec2.InterfaceVpcEndpointAwsService.KMS), ] ) interface_services_to_create = [] for suffix, service_suffix, service in interface_services: service_name = vpc_endpoint_aws_service_name(service_suffix, aws_region) if service_name in skip: print( f"Skipping {logical_id_prefix}{suffix}Endpoint " f"({service_name} already exists in this VPC)." ) continue interface_services_to_create.append((suffix, service)) s3_service_name = vpc_endpoint_aws_service_name("s3", aws_region) s3_service = ec2.GatewayVpcEndpointAwsService.S3 create_s3_gateway = s3_service_name not in skip if not create_s3_gateway: print( f"Skipping {logical_id_prefix}S3GatewayEndpoint " f"({s3_service_name} already exists in this VPC)." ) if not interface_services_to_create and not create_s3_gateway: print( "All ECS-related VPC endpoints already exist in this VPC; " "nothing to create." ) return endpoint_sg = None if interface_services_to_create and subnets: endpoint_sg = ec2.SecurityGroup( scope, f"{logical_id_prefix}VpcEndpointSecurityGroup", vpc=vpc, description="HTTPS ingress for ECS-related VPC interface endpoints", allow_all_outbound=True, ) add_vpc_endpoint_https_ingress_from_vpc_cidrs( endpoint_sg, vpc_cidr_block=vpc_cidr_block, vpc_cidr_blocks=vpc_cidr_blocks, fallback_vpc_cidr=vpc.vpc_cidr_block, ) if subnets: for suffix, service in interface_services_to_create: vpc.add_interface_endpoint( f"{logical_id_prefix}{suffix}Endpoint", service=service, subnets=subnets, security_groups=[endpoint_sg], private_dns_enabled=True, ) elif interface_services_to_create: print( "Skipping ECS interface VPC endpoints: no task-tier subnet selection " "was provided." ) if create_s3_gateway and s3_subnets: try: vpc.add_gateway_endpoint( f"{logical_id_prefix}S3GatewayEndpoint", service=s3_service, subnets=[s3_subnets], ) except Exception as exc: print( "Note: could not add S3 gateway VPC endpoint (one may already exist on " f"this VPC): {exc}" ) def add_s3_enforce_ssl_policy(bucket: s3.IBucket) -> None: """Deny non-TLS S3 requests (Security Hub S3.5). Compatible with all CDK versions.""" bucket.add_to_resource_policy( iam.PolicyStatement( effect=iam.Effect.DENY, principals=[iam.AnyPrincipal()], actions=["s3:*"], resources=[bucket.bucket_arn, f"{bucket.bucket_arn}/*"], conditions={"Bool": {"aws:SecureTransport": "false"}}, ) ) def add_custom_policies( scope: Construct, # Not strictly used here, but good practice if you expand to ManagedPolicies role: iam.IRole, policy_file_locations: Optional[List[str]] = None, custom_policy_text: Optional[str] = None, ) -> iam.IRole: """ Loads custom policies from JSON files or a string and attaches them to a CDK Role. Args: scope: The scope in which to define constructs (if needed, e.g., for iam.ManagedPolicy). role: The CDK Role construct to attach policies to. policy_file_locations: List of file paths to JSON policy documents. custom_policy_text: A JSON string representing a policy document. Returns: The modified CDK Role construct. """ if policy_file_locations is None: policy_file_locations = [] current_source = "unknown source" # For error messages try: if policy_file_locations: print(f"Attempting to add policies from files to role {role.node.id}...") for path in policy_file_locations: current_source = f"file: {path}" try: with open(path, "r") as f: policy_document = json.load(f) print(f"Processing policy from {current_source}...") add_statement_to_policy(role, policy_document) except FileNotFoundError: print(f"Warning: Policy file not found at {path}. Skipping.") except json.JSONDecodeError as e: print( f"Warning: Invalid JSON in policy file {path}: {e}. Skipping." ) except Exception as e: print( f"An unexpected error occurred processing policy from {path}: {e}. Skipping." ) if custom_policy_text: current_source = "custom policy text string" print( f"Attempting to add policy from custom text to role {role.node.id}..." ) try: # *** FIX: Parse the JSON string into a Python dictionary *** policy_document = json.loads(custom_policy_text) print(f"Processing policy from {current_source}...") add_statement_to_policy(role, policy_document) except json.JSONDecodeError as e: print(f"Warning: Invalid JSON in custom_policy_text: {e}. Skipping.") except Exception as e: print( f"An unexpected error occurred processing policy from custom_policy_text: {e}. Skipping." ) # You might want a final success message, but individual processing messages are also good. print(f"Finished processing custom policies for role {role.node.id}.") except Exception as e: print( f"An unhandled error occurred during policy addition for {current_source}: {e}" ) return role # Import the S3 Bucket class if you intend to return a CDK object later # from aws_cdk import aws_s3 as s3 def _s3_bucket_listed_in_account(s3_client: Any, bucket_name: str) -> bool: token = None while True: kwargs: Dict[str, Any] = {} if token: kwargs["ContinuationToken"] = token response = s3_client.list_buckets(**kwargs) for entry in response.get("Buckets", []): if entry.get("Name") == bucket_name: return True token = response.get("NextContinuationToken") if not token: return False def resolve_s3_bucket_availability( bucket_name: str, *, s3_client: Any = None, ) -> Tuple[S3BucketAvailability, str]: """ Resolve whether an S3 bucket name is owned in this account, globally free, or taken by another AWS account. S3 names are globally unique. ``head_bucket`` returns 404 when the name is free, 200 when this principal can access it, and 403 when the name is taken elsewhere (or access is denied in-account). """ client = s3_client or boto3.client("s3") try: client.head_bucket(Bucket=bucket_name) print(f"Bucket '{bucket_name}' exists and is accessible in this account.") return "owned", bucket_name except ClientError as e: error_code = e.response["Error"]["Code"] if error_code in ("404", "NoSuchBucket", "NotFound"): print(f"Bucket '{bucket_name}' is available (name not taken globally).") return "available", bucket_name if error_code in ("403", "AccessDenied"): if _s3_bucket_listed_in_account(client, bucket_name): print( f"Bucket '{bucket_name}' exists in this account " "(head_bucket denied; confirmed via list_buckets)." ) return "owned", bucket_name print( f"Bucket '{bucket_name}' is taken globally by another AWS account " f"(head_bucket returned {error_code}; not listed in this account)." ) return "globally_taken", bucket_name print( f"An unexpected AWS ClientError occurred checking bucket '{bucket_name}': {e}" ) raise except Exception as e: print( f"An unexpected non-ClientError occurred checking bucket '{bucket_name}': {e}" ) raise def check_s3_bucket_exists( bucket_name: str, ) -> Tuple[bool, Optional[str]]: """ Return whether the bucket exists in **this** AWS account (for CDK import). For global availability (including cross-account collisions), use ``resolve_s3_bucket_availability``. """ status, name = resolve_s3_bucket_availability(bucket_name) if status == "owned": return True, name return False, None def resolve_cognito_domain_prefix_availability( domain_prefix: str, *, region_name: Optional[str] = None, cognito_client: Any = None, ) -> CognitoDomainAvailability: """Return whether a Cognito hosted UI domain prefix is free in the region. Cognito prefixes are unique per region across all AWS accounts. When a prefix is owned elsewhere, ``describe_user_pool_domain`` raises ``ResourceNotFoundException`` ("does not exist in this account") — the same symptom CloudFormation reports as ``AlreadyExists`` on create. """ prefix = (domain_prefix or "").strip().lower() if not prefix: return "available" region = region_name or os.environ.get("AWS_REGION") client = cognito_client or boto3.client("cognito-idp", region_name=region) try: response = client.describe_user_pool_domain(Domain=prefix) except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": print( f"Cognito domain prefix {prefix!r} is not available in {region} " f"(likely taken by another AWS account; " f"{e.response['Error'].get('Message', 'ResourceNotFoundException')})." ) return "taken" raise description = response.get("DomainDescription") or {} if description.get("UserPoolId"): print( f"Cognito domain prefix {prefix!r} is in use by user pool " f"{description['UserPoolId']} in this account." ) return "taken" print(f"Cognito domain prefix {prefix!r} is available in {region}.") return "available" def default_secrets_manager_kms_key_arn(region: str, account_id: str) -> str: """AWS managed CMK alias used by Secrets Manager when no customer key is set.""" return f"arn:aws:kms:{region}:{account_id}:key/aws/secretsmanager" def get_secret_kms_key_arn( secret_id: str, *, region_name: Optional[str] = None, ) -> Optional[str]: """Return the KMS key ARN that encrypts a Secrets Manager secret.""" client = boto3.client("secretsmanager", region_name=region_name) try: description = client.describe_secret(SecretId=secret_id) except Exception as exc: print(f"Warning: could not describe secret for KMS key: {exc}") return None kms_key_id = description.get("KmsKeyId") if not kms_key_id: return None if str(kms_key_id).startswith("arn:"): return str(kms_key_id) region = region_name or AWS_REGION account_id = ( description.get("OwningAccount") or boto3.client("sts", region_name=region).get_caller_identity()["Account"] ) return f"arn:aws:kms:{region}:{account_id}:key/{kms_key_id}" ECS_TASK_ROLE_S3_ACTIONS = [ "s3:GetObject*", "s3:GetBucket*", "s3:PutObject", "s3:DeleteObject", "s3:List*", ] def build_ecs_task_role_s3_statement(*, sid: str, bucket_name: str) -> Dict[str, Any]: """Scoped S3 access for one bucket (identity policy on the ECS task role).""" return { "Sid": sid, "Effect": "Allow", "Action": list(ECS_TASK_ROLE_S3_ACTIONS), "Resource": [ f"arn:aws:s3:::{bucket_name}", f"arn:aws:s3:::{bucket_name}/*", ], } def build_ecs_task_role_inline_policy( *, output_bucket_name: str, log_config_bucket_name: Optional[str] = None, shared_kms_key_arn: Optional[str] = None, ) -> Dict[str, Any]: """Task role inline policy: STS, scoped S3 buckets, optional shared CMK for S3.""" statements: List[Dict[str, Any]] = [ { "Sid": "STSCallerIdentity", "Effect": "Allow", "Action": ["sts:GetCallerIdentity"], "Resource": "*", }, build_ecs_task_role_s3_statement( sid="S3Output", bucket_name=output_bucket_name, ), ] if log_config_bucket_name: statements.append( build_ecs_task_role_s3_statement( sid="S3LogConfig", bucket_name=log_config_bucket_name, ) ) if shared_kms_key_arn: statements.append( { "Sid": "KMSS3Access", "Effect": "Allow", "Action": [ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey", ], "Resource": shared_kms_key_arn, } ) return {"Version": "2012-10-17", "Statement": statements} def build_ecs_task_role_kms_policy( *, shared_kms_key_arn: Optional[str] = None, ) -> Dict[str, Any]: """Task role KMS-only policy (tests / legacy callers without S3 bucket names).""" statements: List[Dict[str, Any]] = [ { "Sid": "STSCallerIdentity", "Effect": "Allow", "Action": ["sts:GetCallerIdentity"], "Resource": "*", }, ] if shared_kms_key_arn: statements.append( { "Sid": "KMSS3Access", "Effect": "Allow", "Action": [ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey", ], "Resource": shared_kms_key_arn, } ) return {"Version": "2012-10-17", "Statement": statements} def build_ecs_execution_role_kms_policy( *, secret_kms_key_arn: str, ) -> Dict[str, Any]: """Execution role: decrypt the CMK that encrypts the Cognito secret only.""" return { "Version": "2012-10-17", "Statement": [ { "Sid": "STSCallerIdentity", "Effect": "Allow", "Action": ["sts:GetCallerIdentity"], "Resource": "*", }, { "Sid": "KMSSecretDecrypt", "Effect": "Allow", "Action": ["kms:Decrypt"], "Resource": secret_kms_key_arn, }, ], } # Example usage in your check_resources.py: # exists, bucket_name_if_exists = check_s3_bucket_exists(log_bucket_name) # context_data[f"exists:{log_bucket_name}"] = exists # # You don't necessarily need to store the name in context if using from_bucket_name # Delete an S3 bucket def delete_s3_bucket(bucket_name: str): s3 = boto3.client("s3") try: # List and delete all objects response = s3.list_object_versions(Bucket=bucket_name) versions = response.get("Versions", []) + response.get("DeleteMarkers", []) for version in versions: s3.delete_object( Bucket=bucket_name, Key=version["Key"], VersionId=version["VersionId"] ) # Delete the bucket s3.delete_bucket(Bucket=bucket_name) return {"Status": "SUCCESS"} except Exception as e: return {"Status": "FAILED", "Reason": str(e)} # Function to get subnet ID from subnet name def get_subnet_id(vpc: str, ec2_client: str, subnet_name: str): response = ec2_client.describe_subnets( Filters=[{"Name": "vpc-id", "Values": [vpc.vpc_id]}] ) for subnet in response["Subnets"]: if subnet["Tags"] and any( tag["Key"] == "Name" and tag["Value"] == subnet_name for tag in subnet["Tags"] ): return subnet["SubnetId"] return None def check_ecr_repo_exists(repo_name: str) -> tuple[bool, dict]: """ Checks if an ECR repository with the given name exists. Args: repo_name: The name of the ECR repository to check. Returns: True if the repository exists, False otherwise. """ ecr_client = boto3.client("ecr") try: print("ecr repo_name to check:", repo_name) response = ecr_client.describe_repositories(repositoryNames=[repo_name]) # If describe_repositories succeeds and returns a list of repositories, # and the list is not empty, the repository exists. return len(response["repositories"]) > 0, response["repositories"][0] except ClientError as e: # Check for the specific error code indicating the repository doesn't exist if e.response["Error"]["Code"] == "RepositoryNotFoundException": return False, {} else: # Re-raise other exceptions to handle unexpected errors raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, {} def check_codebuild_project_exists( project_name: str, ): # Adjust return type hint as needed """ Checks if a CodeBuild project with the given name exists. Args: project_name: The name of the CodeBuild project to check. Returns: A tuple: - The first element is True if the project exists, False otherwise. - The second element is the project object (dictionary) if found, None otherwise. """ codebuild_client = boto3.client("codebuild") try: # Use batch_get_projects with a list containing the single project name response = codebuild_client.batch_get_projects(names=[project_name]) # The response for batch_get_projects includes 'projects' (found) # and 'projectsNotFound' (not found). if response["projects"]: # If the project is found in the 'projects' list print(f"CodeBuild project '{project_name}' found.") project = response["projects"][0] return ( True, project["arn"], project.get("serviceRole"), ) elif ( response["projectsNotFound"] and project_name in response["projectsNotFound"] ): # If the project name is explicitly in the 'projectsNotFound' list print(f"CodeBuild project '{project_name}' not found.") return False, None, None else: # This case is less expected for a single name lookup, # but could happen if there's an internal issue or the response # structure is slightly different than expected for an error. # It's safer to assume it wasn't found if not in 'projects'. print( f"CodeBuild project '{project_name}' not found (not in 'projects' list)." ) return False, None, None except ClientError as e: # Catch specific ClientErrors. batch_get_projects might not throw # 'InvalidInputException' for a non-existent project name if the # name format is valid. It typically just lists it in projectsNotFound. # However, other ClientErrors are possible (e.g., permissions). print( f"An AWS ClientError occurred checking CodeBuild project '{project_name}': {e}" ) # Decide how to handle other ClientErrors - raising might be safer raise # Re-raise the original exception except Exception as e: print( f"An unexpected non-ClientError occurred checking CodeBuild project '{project_name}': {e}" ) # Decide how to handle other errors raise # Re-raise the original exception def public_github_repository_url(owner: str, repo: str) -> str: """HTTPS clone URL for a public GitHub repository.""" owner_clean = owner.strip().strip("/") repo_clean = repo.strip().strip("/") return f"https://github.com/{owner_clean}/{repo_clean}.git" def public_github_codebuild_source( owner: str, repo: str, branch_or_ref: str, ) -> codebuild.ISource: """CodeBuild source for a public GitHub repo (no CodeConnections/OAuth).""" return codebuild.Source.git_hub( owner=owner, repo=repo, branch_or_ref=branch_or_ref, webhook=False, report_build_status=False, ) def configure_public_github_codebuild_source( project: codebuild.Project, owner: str, repo: str, branch_or_ref: str = "main", ) -> None: """Ensure CodeBuild clones a public repo without account-level GitHub credentials.""" cfn = project.node.default_child if not isinstance(cfn, codebuild.CfnProject): return location = public_github_repository_url(owner, repo) cfn.add_property_deletion_override("Source.Auth") cfn.add_property_override("Source.Type", "GITHUB") cfn.add_property_override("Source.Location", location) cfn.add_property_override("Source.ReportBuildStatus", False) cfn.add_property_override("Source.GitCloneDepth", 1) cfn.add_property_override("SourceVersion", branch_or_ref) cfn.add_property_override("Triggers.Webhook", False) def ensure_codebuild_public_github_source( project_name: str, owner: str, repo: str, branch_or_ref: str = "main", *, aws_region: Optional[str] = None, ) -> bool: """ Point an existing CodeBuild project at a public GitHub HTTPS URL. Use after deploy or when a pre-existing project was imported via ``from_project_arn`` (CDK does not manage its source). Removes CodeConnections / OAuth ``auth`` blocks so clones work without account-linked GitHub. """ from botocore.exceptions import ClientError region = aws_region or AWS_REGION if not (project_name and owner and repo): return False client = boto3.client("codebuild", region_name=region) try: response = client.batch_get_projects(names=[project_name]) except ClientError as exc: print( f"Warning: could not read CodeBuild project '{project_name}' " f"for public GitHub source fixup: {exc}" ) return False projects = response.get("projects") or [] if not projects: return False project = projects[0] source = project.get("source") or {} desired_location = public_github_repository_url(owner, repo) auth = source.get("auth") needs_update = ( source.get("type") != "GITHUB" or source.get("location") != desired_location or bool(auth) or source.get("reportBuildStatus") or (project.get("sourceVersion") or "") != branch_or_ref ) if not needs_update: print( f"CodeBuild project '{project_name}' already uses public GitHub source " f"({desired_location})." ) return False new_source: Dict[str, Any] = { "type": "GITHUB", "location": desired_location, "gitCloneDepth": source.get("gitCloneDepth") or 1, "reportBuildStatus": False, "insecureSsl": False, } if source.get("buildspec"): new_source["buildspec"] = source["buildspec"] update_kwargs: Dict[str, Any] = { "name": project_name, "source": new_source, "sourceVersion": branch_or_ref, } triggers = project.get("triggers") or {} if triggers.get("webhook"): update_kwargs["triggers"] = {"webhook": False} try: client.update_project(**update_kwargs) except ClientError as exc: print( f"Warning: could not update CodeBuild project '{project_name}' " f"to public GitHub source: {exc}" ) return False print( f"Updated CodeBuild project '{project_name}' to public GitHub source " f"({desired_location}, ref {branch_or_ref!r})." ) return True def get_vpc_id_by_name(vpc_name: str): """ Finds a VPC ID by its 'Name' tag. Returns ``(vpc_id, nat_gateways, vpc_cidr_block, vpc_cidr_blocks)`` or ``None`` if not found. ``vpc_cidr_block`` is the primary block; ``vpc_cidr_blocks`` lists every associated IPv4 CIDR (primary + secondary). """ ec2_client = boto3.client("ec2") try: response = ec2_client.describe_vpcs( Filters=[{"Name": "tag:Name", "Values": [vpc_name]}] ) if response and response["Vpcs"]: vpc = response["Vpcs"][0] vpc_id = vpc["VpcId"] vpc_cidr_block = vpc.get("CidrBlock") vpc_cidr_blocks = list_vpc_associated_cidr_blocks(vpc) cidr_summary = ( ", ".join(vpc_cidr_blocks) if vpc_cidr_blocks else vpc_cidr_block ) print( f"VPC '{vpc_name}' found with ID: {vpc_id}" + (f", CIDR(s): {cidr_summary}" if cidr_summary else "") ) # In get_vpc_id_by_name, after finding VPC ID: # Look for NAT Gateways in this VPC ec2_client = boto3.client("ec2") nat_gateways = [] try: response = ec2_client.describe_nat_gateways( Filters=[ {"Name": "vpc-id", "Values": [vpc_id]}, # Optional: Add a tag filter if you consistently tag your NATs # {'Name': 'tag:Name', 'Values': [f"{prefix}-nat-gateway"]} ] ) nat_gateways = response.get("NatGateways", []) except Exception as e: print( f"Warning: Could not describe NAT Gateways in VPC '{vpc_id}': {e}" ) # Decide how to handle this error - proceed or raise? # Decide how to identify the specific NAT Gateway you want to check for. return vpc_id, nat_gateways, vpc_cidr_block, vpc_cidr_blocks else: print(f"VPC '{vpc_name}' not found.") return None except Exception as e: print(f"An unexpected error occurred finding VPC '{vpc_name}': {e}") raise # --- Helper to fetch all existing subnets in a VPC once --- def _get_existing_subnets_in_vpc(vpc_id: str) -> Dict[str, Any]: """ Fetches all subnets in a given VPC. Returns a dictionary with 'by_name' (map of name to subnet data), 'by_id' (map of id to subnet data), and 'cidr_networks' (list of ipaddress.IPv4Network). """ ec2_client = boto3.client("ec2") existing_subnets_data = { "by_name": {}, # {subnet_name: {'id': 'subnet-id', 'cidr': 'x.x.x.x/x'}} "by_id": {}, # {subnet_id: {'name': 'subnet-name', 'cidr': 'x.x.x.x/x/x'}} "cidr_networks": [], # List of ipaddress.IPv4Network objects } try: subnet_to_route_table: Dict[str, str] = {} rt_response = ec2_client.describe_route_tables( Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] ) for route_table in rt_response.get("RouteTables", []): route_table_id = route_table["RouteTableId"] for association in route_table.get("Associations", []): associated_subnet_id = association.get("SubnetId") if associated_subnet_id: subnet_to_route_table[associated_subnet_id] = route_table_id response = ec2_client.describe_subnets( Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] ) for s in response.get("Subnets", []): subnet_id = s["SubnetId"] cidr_block = s.get("CidrBlock") # Extract 'Name' tag, which is crucial for lookup by name name_tag = next( (tag["Value"] for tag in s.get("Tags", []) if tag["Key"] == "Name"), None, ) subnet_info = { "id": subnet_id, "cidr": cidr_block, "name": name_tag, "az": s.get("AvailabilityZone"), "route_table_id": subnet_to_route_table.get(subnet_id), } if name_tag: existing_subnets_data["by_name"][name_tag] = subnet_info existing_subnets_data["by_id"][subnet_id] = subnet_info if cidr_block: try: existing_subnets_data["cidr_networks"].append( ipaddress.ip_network(cidr_block, strict=False) ) except ValueError: print( f"Warning: Existing subnet {subnet_id} has an invalid CIDR: {cidr_block}. Skipping for overlap check." ) print( f"Fetched {len(response.get('Subnets', []))} existing subnets from VPC '{vpc_id}'." ) except Exception as e: print( f"Error describing existing subnets in VPC '{vpc_id}': {e}. Cannot perform full validation." ) raise # Re-raise if this essential step fails return existing_subnets_data def get_internet_gateways_attached_to_vpc(vpc_id: str) -> List[str]: """Return Internet Gateway IDs currently attached to the VPC.""" ec2_client = boto3.client("ec2") response = ec2_client.describe_internet_gateways( Filters=[{"Name": "attachment.vpc-id", "Values": [vpc_id]}] ) return [ igw["InternetGatewayId"] for igw in response.get("InternetGateways", []) if igw.get("InternetGatewayId") ] def internet_gateway_exists(igw_id: str) -> bool: ec2_client = boto3.client("ec2") response = ec2_client.describe_internet_gateways(InternetGatewayIds=[igw_id]) return bool(response.get("InternetGateways")) def route_table_default_internet_gateway(route_table_id: str) -> Optional[str]: """ Return the Internet Gateway ID for 0.0.0.0/0 on this route table, if any. """ ec2_client = boto3.client("ec2") response = ec2_client.describe_route_tables(RouteTableIds=[route_table_id]) tables = response.get("RouteTables", []) if not tables: return None for route in tables[0].get("Routes", []): if route.get("DestinationCidrBlock") != "0.0.0.0/0": continue gateway_id = route.get("GatewayId") or "" if gateway_id.startswith("igw-"): return gateway_id return None def route_table_has_non_igw_default_route(route_table_id: str) -> bool: """True if 0.0.0.0/0 exists but does not target an Internet Gateway.""" ec2_client = boto3.client("ec2") response = ec2_client.describe_route_tables(RouteTableIds=[route_table_id]) tables = response.get("RouteTables", []) if not tables: return False for route in tables[0].get("Routes", []): if route.get("DestinationCidrBlock") != "0.0.0.0/0": continue gateway_id = route.get("GatewayId") or "" if gateway_id.startswith("igw-"): return False # Active default route via NAT instance, TGW, etc. if ( route.get("NatGatewayId") or route.get("TransitGatewayId") or route.get("GatewayId") ): return True return False def audit_public_subnet_internet_connectivity( vpc_id: str, configured_igw_id: str, public_subnet_entries: List[Dict[str, Any]], ) -> Dict[str, Any]: """ Validate / discover Internet Gateway usage for legacy public subnets. Returns context fields for CDK: internet_gateway_id, internet_gateway_needs_vpc_attachment, public_subnets_needing_igw_route (list of {name, subnet_id, route_table_id}). """ configured_igw_id = (configured_igw_id or "").strip() attached_igws = get_internet_gateways_attached_to_vpc(vpc_id) resolved_igw_id = configured_igw_id needs_attachment = False if configured_igw_id: if not internet_gateway_exists(configured_igw_id): raise ValueError( f"EXISTING_IGW_ID '{configured_igw_id}' was not found in this account/region." ) if configured_igw_id not in attached_igws: # Ensure it is not attached to another VPC ec2_client = boto3.client("ec2") detail = ec2_client.describe_internet_gateways( InternetGatewayIds=[configured_igw_id] ) for attachment in detail.get("InternetGateways", [{}])[0].get( "Attachments", [] ): other_vpc = attachment.get("VpcId") if other_vpc and other_vpc != vpc_id: raise ValueError( f"EXISTING_IGW_ID '{configured_igw_id}' is attached to VPC " f"'{other_vpc}', not target VPC '{vpc_id}'. Detach it or choose " "the IGW attached to this VPC." ) needs_attachment = True elif attached_igws: if len(attached_igws) > 1: raise ValueError( f"VPC '{vpc_id}' has multiple attached Internet Gateways " f"({', '.join(attached_igws)}). Set EXISTING_IGW_ID to the one to use." ) resolved_igw_id = attached_igws[0] print( f"EXISTING_IGW_ID not set; using Internet Gateway attached to VPC: " f"{resolved_igw_id}" ) elif public_subnet_entries: raise ValueError( f"VPC '{vpc_id}' has no Internet Gateway attached and EXISTING_IGW_ID is " "empty. Set EXISTING_IGW_ID to an existing IGW for this VPC (CDK will " "attach it if detached)." ) subnets_needing_route: List[Dict[str, str]] = [] for entry in public_subnet_entries: name = entry.get("name") or "unknown" route_table_id = entry.get("route_table_id") subnet_id = entry.get("subnet_id") or entry.get("id") or "" if not route_table_id: print( f"Warning: public subnet '{name}' has no route table association in " "pre-check; skipping IGW route audit (CDK may still add routes after create)." ) continue existing_igw = route_table_default_internet_gateway(route_table_id) if existing_igw: if resolved_igw_id and existing_igw != resolved_igw_id: raise ValueError( f"Public subnet '{name}' route table '{route_table_id}' has " f"0.0.0.0/0 -> {existing_igw}, but EXISTING_IGW_ID / resolved IGW " f"is '{resolved_igw_id}'. Fix the route table manually or align " "EXISTING_IGW_ID." ) continue if route_table_has_non_igw_default_route(route_table_id): raise ValueError( f"Public subnet '{name}' route table '{route_table_id}' has a default " "route that does not use an Internet Gateway (e.g. NAT/TGW). Remove " "or change it before adding 0.0.0.0/0 -> IGW for an internet-facing ALB." ) subnets_needing_route.append( { "name": name, "subnet_id": subnet_id, "route_table_id": route_table_id, } ) return { "internet_gateway_id": resolved_igw_id, "internet_gateway_needs_vpc_attachment": needs_attachment, "public_subnets_needing_igw_route": subnets_needing_route, } def wire_public_subnet_internet_access( scope: Construct, logical_id_prefix: str, *, vpc_id: str, internet_gateway_id: str, needs_igw_vpc_attachment: bool, subnets_needing_route: List[Dict[str, str]], ) -> Optional[ec2.CfnVPCGatewayAttachment]: """ Attach the Internet Gateway to the VPC (if needed) and add 0.0.0.0/0 routes on imported public subnet route tables that lack an IGW default route. """ if not internet_gateway_id: return None attachment = None if needs_igw_vpc_attachment: attachment = ec2.CfnVPCGatewayAttachment( scope, f"{logical_id_prefix}IgwVpcAttachment", vpc_id=vpc_id, internet_gateway_id=internet_gateway_id, ) print( f"CDK: will attach Internet Gateway '{internet_gateway_id}' to VPC '{vpc_id}'." ) seen_route_tables: set[str] = set() for i, entry in enumerate(subnets_needing_route): route_table_id = entry.get("route_table_id") if not route_table_id or route_table_id in seen_route_tables: continue seen_route_tables.add(route_table_id) safe_name = (entry.get("name") or f"rt{i}").replace("-", "")[:40] route = ec2.CfnRoute( scope, f"{logical_id_prefix}IgwRoute{safe_name}{i}", route_table_id=route_table_id, destination_cidr_block="0.0.0.0/0", gateway_id=internet_gateway_id, ) if attachment is not None: route.add_dependency(attachment) print( f"CDK: will add 0.0.0.0/0 -> {internet_gateway_id} on route table " f"'{route_table_id}' (subnet '{entry.get('name', '')}')." ) return attachment # --- Modified validate_subnet_creation_parameters to take pre-fetched data --- def validate_subnet_creation_parameters( vpc_id: str, proposed_subnets_data: List[ Dict[str, str] ], # e.g., [{'name': 'my-public-subnet', 'cidr': '10.0.0.0/24', 'az': 'us-east-1a'}] existing_aws_subnets_data: Dict[ str, Any ], # Pre-fetched data from _get_existing_subnets_in_vpc ) -> None: """ Validates proposed subnet names and CIDR blocks against existing AWS subnets in the specified VPC and against each other. This function uses pre-fetched AWS subnet data. Args: vpc_id: The ID of the VPC (for logging/error messages). proposed_subnets_data: A list of dictionaries, where each dict represents a proposed subnet with 'name', 'cidr', and 'az'. existing_aws_subnets_data: Dictionary containing existing AWS subnet data (e.g., from _get_existing_subnets_in_vpc). Raises: ValueError: If any proposed subnet name or CIDR block conflicts with existing AWS resources or other proposed resources. """ if not proposed_subnets_data: print("No proposed subnet data provided for validation. Skipping.") return print( f"--- Starting pre-synth validation for VPC '{vpc_id}' with proposed subnets ---" ) print("Existing subnet data:", pd.DataFrame(existing_aws_subnets_data["by_name"])) existing_aws_subnet_names = set(existing_aws_subnets_data["by_name"].keys()) existing_aws_cidr_networks = existing_aws_subnets_data["cidr_networks"] # Sets to track names and list to track networks for internal batch consistency proposed_names_seen: set[str] = set() proposed_cidr_networks_seen: List[ipaddress.IPv4Network] = [] for i, proposed_subnet in enumerate(proposed_subnets_data): subnet_name = proposed_subnet.get("name") cidr_block_str = proposed_subnet.get("cidr") availability_zone = proposed_subnet.get("az") if not all([subnet_name, cidr_block_str, availability_zone]): raise ValueError( f"Proposed subnet at index {i} is incomplete. Requires 'name', 'cidr', and 'az'." ) # 1. Check for duplicate names within the proposed batch if subnet_name in proposed_names_seen: raise ValueError( f"Proposed subnet name '{subnet_name}' is duplicated within the input list." ) proposed_names_seen.add(subnet_name) # 2. Check for duplicate names against existing AWS subnets if subnet_name in existing_aws_subnet_names: print( f"Proposed subnet name '{subnet_name}' already exists in VPC '{vpc_id}'." ) # Parse proposed CIDR try: proposed_net = ipaddress.ip_network(cidr_block_str, strict=False) except ValueError as e: raise ValueError( f"Invalid CIDR format '{cidr_block_str}' for proposed subnet '{subnet_name}': {e}" ) # 3. Check for overlapping CIDRs within the proposed batch for existing_proposed_net in proposed_cidr_networks_seen: if proposed_net.overlaps(existing_proposed_net): raise ValueError( f"Proposed CIDR '{cidr_block_str}' for subnet '{subnet_name}' " f"overlaps with another proposed CIDR '{str(existing_proposed_net)}' " f"within the same batch." ) # 4. Check for overlapping CIDRs against existing AWS subnets for existing_aws_net in existing_aws_cidr_networks: if proposed_net.overlaps(existing_aws_net): raise ValueError( f"Proposed CIDR '{cidr_block_str}' for subnet '{subnet_name}' " f"overlaps with an existing AWS subnet CIDR '{str(existing_aws_net)}' " f"in VPC '{vpc_id}'." ) # If all checks pass for this subnet, add its network to the list for subsequent checks proposed_cidr_networks_seen.append(proposed_net) print( f"Validation successful for proposed subnet '{subnet_name}' with CIDR '{cidr_block_str}'." ) print( f"--- All proposed subnets passed pre-synth validation checks for VPC '{vpc_id}'. ---" ) # --- Modified check_subnet_exists_by_name (Uses pre-fetched data) --- def check_subnet_exists_by_name( subnet_name: str, existing_aws_subnets_data: Dict[str, Any] ) -> Tuple[bool, Optional[str]]: """ Checks if a subnet with the given name exists within the pre-fetched data. Args: subnet_name: The 'Name' tag value of the subnet to check. existing_aws_subnets_data: Dictionary containing existing AWS subnet data (e.g., from _get_existing_subnets_in_vpc). Returns: A tuple: - The first element is True if the subnet exists, False otherwise. - The second element is the Subnet ID if found, None otherwise. """ subnet_info = existing_aws_subnets_data["by_name"].get(subnet_name) if subnet_info: print(f"Subnet '{subnet_name}' found with ID: {subnet_info['id']}") return True, subnet_info["id"] else: print(f"Subnet '{subnet_name}' not found.") return False, None def create_nat_gateway( scope: Construct, public_subnet_for_nat: ec2.ISubnet, # Expects a proper ISubnet nat_gateway_name: str, nat_gateway_id_context_key: str, ) -> str: """ Creates a single NAT Gateway in the specified public subnet. It does not handle lookup from context; the calling stack should do that. Returns the CloudFormation Ref of the NAT Gateway ID. """ print( f"Defining a new NAT Gateway '{nat_gateway_name}' in subnet '{public_subnet_for_nat.subnet_id}'." ) # Create an Elastic IP for the NAT Gateway eip = ec2.CfnEIP( scope, NAT_GATEWAY_EIP_NAME, tags=[CfnTag(key="Name", value=NAT_GATEWAY_EIP_NAME)], ) # Create the NAT Gateway nat_gateway_logical_id = nat_gateway_name.replace("-", "") + "NatGateway" nat_gateway = ec2.CfnNatGateway( scope, nat_gateway_logical_id, subnet_id=public_subnet_for_nat.subnet_id, # Associate with the public subnet allocation_id=eip.attr_allocation_id, # Associate with the EIP tags=[CfnTag(key="Name", value=nat_gateway_name)], ) # The NAT GW depends on the EIP. The dependency on the subnet is implicit via subnet_id. nat_gateway.add_dependency(eip) # *** CRUCIAL: Use CfnOutput to export the ID after deployment *** # This is how you will get the ID to put into cdk.context.json CfnOutput( scope, "SingleNatGatewayIdOutput", value=nat_gateway.ref, description=f"Physical ID of the Single NAT Gateway. Add this to cdk.context.json under the key '{nat_gateway_id_context_key}'.", export_name=f"{scope.stack_name}-NatGatewayId", # Make export name unique ) print( f"CDK: Defined new NAT Gateway '{nat_gateway.ref}'. Its physical ID will be available in the stack outputs after deployment." ) # Return the tokenised reference for use within this synthesis return nat_gateway.ref def create_subnets( scope: Construct, vpc: ec2.IVpc, prefix: str, subnet_names: List[str], cidr_blocks: List[str], availability_zones: List[str], is_public: bool, internet_gateway_id: Optional[str] = None, single_nat_gateway_id: Optional[str] = None, internet_gateway_attachment: Optional[ec2.CfnVPCGatewayAttachment] = None, ) -> Tuple[List[ec2.CfnSubnet], List[ec2.CfnRouteTable]]: """ Creates subnets using L2 constructs but returns the underlying L1 Cfn objects for backward compatibility. """ # --- Validations remain the same --- if not (len(subnet_names) == len(cidr_blocks) == len(availability_zones) > 0): raise ValueError( "Subnet names, CIDR blocks, and Availability Zones lists must be non-empty and match in length." ) if is_public and not internet_gateway_id: raise ValueError("internet_gateway_id must be provided for public subnets.") if not is_public and not single_nat_gateway_id: raise ValueError( "single_nat_gateway_id must be provided for private subnets when using a single NAT Gateway." ) # --- We will populate these lists with the L1 objects to return --- created_subnets: List[ec2.CfnSubnet] = [] created_route_tables: List[ec2.CfnRouteTable] = [] subnet_type_tag = "public" if is_public else "private" for i, subnet_name in enumerate(subnet_names): logical_id = f"{prefix}{subnet_type_tag.capitalize()}Subnet{i+1}" # 1. Create the L2 Subnet (this is the easy part) subnet = ec2.Subnet( scope, logical_id, vpc_id=vpc.vpc_id, cidr_block=cidr_blocks[i], availability_zone=availability_zones[i], map_public_ip_on_launch=is_public, ) Tags.of(subnet).add("Name", subnet_name) Tags.of(subnet).add("Type", subnet_type_tag) if is_public and internet_gateway_attachment is not None: subnet.node.add_dependency(internet_gateway_attachment) if is_public: try: subnet.add_route( "DefaultInternetRoute", router_id=internet_gateway_id, router_type=ec2.RouterType.GATEWAY, ) except Exception as e: raise RuntimeError( f"Could not create 0.0.0.0/0 -> Internet Gateway route for public " f"subnet '{subnet_name}'. Ensure EXISTING_IGW_ID is attached to this " f"VPC ({internet_gateway_id}): {e}" ) from e print(f"CDK: Defined public L2 subnet '{subnet_name}' and added IGW route.") else: try: subnet.add_route( "DefaultNatRoute", router_id=single_nat_gateway_id, router_type=ec2.RouterType.NAT_GATEWAY, ) except Exception as e: raise RuntimeError( f"Could not create 0.0.0.0/0 -> NAT Gateway route for private " f"subnet '{subnet_name}': {e}" ) from e print( f"CDK: Defined private L2 subnet '{subnet_name}' and added NAT GW route." ) route_table = subnet.route_table created_subnets.append(subnet) created_route_tables.append(route_table) return created_subnets, created_route_tables def ingress_rule_exists(security_group: str, peer: str, port: str): for rule in security_group.connections.security_groups: if port: if rule.peer == peer and rule.connection == port: return True else: if rule.peer == peer: return True return False def check_for_existing_user_pool(user_pool_name: str): cognito_client = boto3.client("cognito-idp") list_pools_response = cognito_client.list_user_pools( MaxResults=60 ) # MaxResults up to 60 # ListUserPools might require pagination if you have more than 60 pools # This simple example doesn't handle pagination, which could miss your pool existing_user_pool_id = "" for pool in list_pools_response.get("UserPools", []): if pool.get("Name") == user_pool_name: existing_user_pool_id = pool["Id"] print( f"Found existing user pool by name '{user_pool_name}' with ID: {existing_user_pool_id}" ) break # Found the one we're looking for if existing_user_pool_id: return True, existing_user_pool_id, pool else: return False, "", "" def check_for_existing_user_pool_client(user_pool_id: str, user_pool_client_name: str): """ Checks if a Cognito User Pool Client with the given name exists in the specified User Pool. Args: user_pool_id: The ID of the Cognito User Pool. user_pool_client_name: The name of the User Pool Client to check for. Returns: A tuple: - True, client_id, client_details if the client exists. - False, "", {} otherwise. """ cognito_client = boto3.client("cognito-idp") next_token = None while True: try: kwargs = {"UserPoolId": user_pool_id, "MaxResults": 60} if next_token: kwargs["NextToken"] = next_token response = cognito_client.list_user_pool_clients(**kwargs) except cognito_client.exceptions.ResourceNotFoundException: print(f"Error: User pool with ID '{user_pool_id}' not found.") return False, "", {} except Exception as e: print( f"Could not list app clients for pool '{user_pool_id}' " f"(client name '{user_pool_client_name}'): {e}" ) return False, "", {} for client in response.get("UserPoolClients", []): if client.get("ClientName") == user_pool_client_name: print( f"Found existing user pool client '{user_pool_client_name}' with ID: {client['ClientId']}" ) return True, client["ClientId"], client next_token = response.get("NextToken") if not next_token: break print( f"No app client named '{user_pool_client_name}' in user pool '{user_pool_id}'." ) return False, "", {} def check_for_secret(secret_name: str, secret_value: dict = ""): """ Checks if a Secrets Manager secret with the given name exists. If it doesn't exist, it creates the secret. Args: secret_name: The name of the Secrets Manager secret. secret_value: A dictionary containing the key-value pairs for the secret. Returns: Tuple of (exists, response). When exists is True, response is the ``get_secret_value`` API dict (includes ``ARN`` for IAM grants). """ secretsmanager_client = boto3.client("secretsmanager") try: # Try to get the secret. If it doesn't exist, a ResourceNotFoundException will be raised. secret_response = secretsmanager_client.get_secret_value(SecretId=secret_name) print("Secret already exists.") return True, secret_response except secretsmanager_client.exceptions.ResourceNotFoundException: print("Secret not found") return False, {} except Exception as e: # Handle other potential exceptions during the get operation print(f"Error checking for secret: {e}") return False, {} def get_security_group_id_by_name( group_name: str, vpc_id: str, region_name: str = AWS_REGION, ) -> Tuple[bool, str]: """Look up a security group ID by name within a VPC.""" if not group_name or not vpc_id: return False, "" try: ec2_client = boto3.client("ec2", region_name=region_name) response = ec2_client.describe_security_groups( Filters=[ {"Name": "group-name", "Values": [group_name]}, {"Name": "vpc-id", "Values": [vpc_id]}, ] ) groups = response.get("SecurityGroups") or [] if groups: return True, groups[0]["GroupId"] return False, "" except ClientError as e: print(f"Error looking up security group '{group_name}': {e}") return False, "" def resolve_service_connect_client_security_group_ids( explicit_ids: List[str], security_group_names: List[str], get_context_str, ) -> List[str]: """ Merge explicit sg- IDs with IDs resolved from pre-check context (security_group_id:{name}). """ resolved: List[str] = [] for sg_id in explicit_ids: if not sg_id.startswith("sg-"): raise ValueError( f"ECS_SERVICE_CONNECT_CLIENT_SECURITY_GROUP_IDS entry '{sg_id}' " "must be a security group ID (sg-...)." ) if sg_id not in resolved: resolved.append(sg_id) missing_names: List[str] = [] for sg_name in security_group_names: sg_id = get_context_str(f"security_group_id:{sg_name}") if sg_id: if sg_id not in resolved: resolved.append(sg_id) else: missing_names.append(sg_name) if missing_names: raise ValueError( "Could not resolve Service Connect client security group(s) in VPC " f"{get_context_str('vpc_id') or '(unknown)'}: " + ", ".join(missing_names) + ". Set ECS_SERVICE_CONNECT_CLIENT_SECURITY_GROUP_IDS, fix " "ECS_SERVICE_CONNECT_CLIENT_SECURITY_GROUP_NAMES / " "ECS_SERVICE_CONNECT_CLIENT_CDK_PREFIXES, and re-run check_resources.py." ) return resolved def check_alb_exists( load_balancer_name: str, region_name: str = None ) -> tuple[bool, dict]: """ Checks if an Application Load Balancer (ALB) with the given name exists. Args: load_balancer_name: The name of the ALB to check. region_name: The AWS region to check in. If None, uses the default session region. Returns: A tuple: - The first element is True if the ALB exists, False otherwise. - The second element is the ALB object (dictionary) if found, None otherwise. Specifically, it returns the first element of the LoadBalancers list from the describe_load_balancers response. """ if region_name: elbv2_client = boto3.client("elbv2", region_name=region_name) else: elbv2_client = boto3.client("elbv2") try: response = elbv2_client.describe_load_balancers(Names=[load_balancer_name]) if response["LoadBalancers"]: return ( True, response["LoadBalancers"][0], ) # Return True and the first ALB object else: return False, {} except ClientError as e: # If the error indicates the ALB doesn't exist, return False if e.response["Error"]["Code"] == "LoadBalancerNotFound": return False, {} else: # Re-raise other exceptions raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, {} def check_fargate_task_definition_exists( task_definition_name: str, region_name: str = None ) -> tuple[bool, dict]: """ Checks if a Fargate task definition with the given name exists. Args: task_definition_name: The name or ARN of the task definition to check. region_name: The AWS region to check in. If None, uses the default session region. Returns: A tuple: - The first element is True if the task definition exists, False otherwise. - The second element is the task definition object (dictionary) if found, None otherwise. Specifically, it returns the first element of the taskDefinitions list from the describe_task_definition response. """ if region_name: ecs_client = boto3.client("ecs", region_name=region_name) else: ecs_client = boto3.client("ecs") try: response = ecs_client.describe_task_definition( taskDefinition=task_definition_name ) # If describe_task_definition succeeds, it returns the task definition. # We can directly return True and the task definition. return True, response["taskDefinition"] except ClientError as e: # Check for the error code indicating the task definition doesn't exist. if ( e.response["Error"]["Code"] == "ClientException" and "Task definition" in e.response["Message"] and "does not exist" in e.response["Message"] ): return False, {} else: # Re-raise other exceptions. raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, {} def check_ecs_service_exists( cluster_name: str, service_name: str, region_name: str = None ) -> tuple[bool, dict]: """ Checks if an ECS service with the given name exists in the specified cluster. Args: cluster_name: The name or ARN of the ECS cluster. service_name: The name of the ECS service to check. region_name: The AWS region to check in. If None, uses the default session region. Returns: A tuple: - The first element is True if the service exists, False otherwise. - The second element is the service object (dictionary) if found, None otherwise. """ if region_name: ecs_client = boto3.client("ecs", region_name=region_name) else: ecs_client = boto3.client("ecs") try: response = ecs_client.describe_services( cluster=cluster_name, services=[service_name] ) if response["services"]: return ( True, response["services"][0], ) # Return True and the first service object else: return False, {} except ClientError as e: # Check for the error code indicating the service doesn't exist. if e.response["Error"]["Code"] == "ClusterNotFoundException": return False, {} elif e.response["Error"]["Code"] == "ServiceNotFoundException": return False, {} else: # Re-raise other exceptions. raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, {} def check_cloudfront_distribution_exists( distribution_name: str, region_name: str = None ) -> tuple[bool, dict | None]: """ Checks if a CloudFront distribution with the given name exists. Args: distribution_name: The name of the CloudFront distribution to check. region_name: The AWS region to check in. If None, uses the default session region. Note: CloudFront is a global service, so the region is usually 'us-east-1', but this parameter is included for completeness. Returns: A tuple: - The first element is True if the distribution exists, False otherwise. - The second element is the distribution object (dictionary) if found, None otherwise. Specifically, it returns the first element of the DistributionList from the ListDistributions response. """ if region_name: cf_client = boto3.client("cloudfront", region_name=region_name) else: cf_client = boto3.client("cloudfront") try: response = cf_client.list_distributions() if "Items" in response["DistributionList"]: for distribution in response["DistributionList"]["Items"]: # CloudFront doesn't directly filter by name, so we have to iterate. if ( distribution["AliasSet"]["Items"] and distribution["AliasSet"]["Items"][0] == distribution_name ): return True, distribution return False, None else: return False, None except ClientError as e: # If the error indicates the Distribution doesn't exist, return False if e.response["Error"]["Code"] == "NoSuchDistribution": return False, None else: # Re-raise other exceptions raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, None def create_web_acl_with_common_rules( scope: Construct, web_acl_name: str, waf_scope: str = "CLOUDFRONT" ): """ Use CDK to create a web ACL based on an AWS common rule set with overrides. This function now expects a 'scope' argument, typically 'self' from your stack, as CfnWebACL requires a construct scope. """ # Create full list of rules rules = [] aws_ruleset_names = [ "AWSManagedRulesCommonRuleSet", "AWSManagedRulesKnownBadInputsRuleSet", "AWSManagedRulesAmazonIpReputationList", ] # Use a separate counter to assign unique priorities sequentially priority_counter = 1 for aws_rule_name in aws_ruleset_names: current_rule_action_overrides = None # All managed rule groups need an override_action. # 'none' means use the managed rule group's default action. current_override_action = wafv2.CfnWebACL.OverrideActionProperty(none={}) current_priority = priority_counter priority_counter += 1 if aws_rule_name == "AWSManagedRulesCommonRuleSet": current_rule_action_overrides = [ wafv2.CfnWebACL.RuleActionOverrideProperty( name="SizeRestrictions_BODY", action_to_use=wafv2.CfnWebACL.RuleActionProperty(allow={}), ) ] # No need to set current_override_action here, it's already set above. # If you wanted this specific rule to have a *fixed* priority, you'd handle it differently # For now, it will get priority 1 from the counter. rule_property = wafv2.CfnWebACL.RuleProperty( name=aws_rule_name, priority=current_priority, statement=wafv2.CfnWebACL.StatementProperty( managed_rule_group_statement=wafv2.CfnWebACL.ManagedRuleGroupStatementProperty( vendor_name="AWS", name=aws_rule_name, rule_action_overrides=current_rule_action_overrides, ) ), visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( cloud_watch_metrics_enabled=True, metric_name=aws_rule_name, sampled_requests_enabled=True, ), override_action=current_override_action, # THIS IS THE CRUCIAL PART FOR ALL MANAGED RULES ) rules.append(rule_property) # Add the rate limit rule rate_limit_priority = priority_counter # Use the next available priority rules.append( wafv2.CfnWebACL.RuleProperty( name="RateLimitRule", priority=rate_limit_priority, statement=wafv2.CfnWebACL.StatementProperty( rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty( limit=1000, aggregate_key_type="IP" ) ), visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( cloud_watch_metrics_enabled=True, metric_name="RateLimitRule", sampled_requests_enabled=True, ), action=wafv2.CfnWebACL.RuleActionProperty(block={}), ) ) web_acl = wafv2.CfnWebACL( scope, "WebACL", name=web_acl_name, default_action=wafv2.CfnWebACL.DefaultActionProperty(allow={}), scope=waf_scope, visibility_config=wafv2.CfnWebACL.VisibilityConfigProperty( cloud_watch_metrics_enabled=True, metric_name="webACL", sampled_requests_enabled=True, ), rules=rules, ) CfnOutput(scope, "WebACLArn", value=web_acl.attr_arn) web_acl.apply_removal_policy(managed_resource_removal_policy()) return web_acl def check_web_acl_exists( web_acl_name: str, scope: str, region_name: str = None ) -> tuple[bool, dict]: """ Checks if a Web ACL with the given name and scope exists. Args: web_acl_name: The name of the Web ACL to check. scope: The scope of the Web ACL ('CLOUDFRONT' or 'REGIONAL'). region_name: The AWS region to check in. Required for REGIONAL scope. If None, uses the default session region. For CLOUDFRONT, the region should be 'us-east-1'. Returns: A tuple: - The first element is True if the Web ACL exists, False otherwise. - The second element is the Web ACL object (dictionary) if found, None otherwise. """ if scope not in ["CLOUDFRONT", "REGIONAL"]: raise ValueError("Scope must be either 'CLOUDFRONT' or 'REGIONAL'") if scope == "REGIONAL" and not region_name: raise ValueError("Region name is required for REGIONAL scope") if scope == "CLOUDFRONT": region_name = "us-east-1" # CloudFront scope requires us-east-1 if region_name: waf_client = boto3.client("wafv2", region_name=region_name) else: waf_client = boto3.client("wafv2") try: response = waf_client.list_web_acls(Scope=scope) if "WebACLs" in response: for web_acl in response["WebACLs"]: if web_acl["Name"] == web_acl_name: # Describe the Web ACL to get the full object. describe_response = waf_client.describe_web_acl( Name=web_acl_name, Scope=scope ) return True, describe_response["WebACL"] return False, {} else: return False, {} except ClientError as e: # Check for the error code indicating the web ACL doesn't exist. if e.response["Error"]["Code"] == "ResourceNotFoundException": return False, {} else: # Re-raise other exceptions. raise except Exception as e: print(f"An unexpected error occurred: {e}") return False, {} def add_alb_https_listener_with_cert( scope: Construct, logical_id: str, # A unique ID for this listener construct alb: elb.ApplicationLoadBalancer, acm_certificate_arn: Optional[ str ], # Optional: If None, no HTTPS listener will be created default_target_group: elb.ITargetGroup, # Mandatory: The target group to forward traffic to listener_port_https: int = 443, listener_open_to_internet: bool = False, # Be cautious with True, ensure ALB security group restricts access # --- Cognito Authentication Parameters --- enable_cognito_auth: bool = False, cognito_user_pool: Optional[cognito.IUserPool] = None, cognito_user_pool_client: Optional[cognito.IUserPoolClient] = None, cognito_user_pool_domain: Optional[ str ] = None, # E.g., "my-app-domain" for "my-app-domain.auth.region.amazoncognito.com" cognito_auth_scope: Optional[ str ] = "openid profile email", # Default recommended scope cognito_auth_on_unauthenticated_request: elb.UnauthenticatedAction = elb.UnauthenticatedAction.AUTHENTICATE, stickiness_cookie_duration=None, # --- End Cognito Parameters --- ) -> Optional[elb.ApplicationListener]: """ Conditionally adds an HTTPS listener to an ALB with an ACM certificate, and optionally enables Cognito User Pool authentication. Args: scope (Construct): The scope in which to define this construct (e.g., your CDK Stack). logical_id (str): A unique logical ID for the listener construct within the stack. alb (elb.ApplicationLoadBalancer): The Application Load Balancer to add the listener to. acm_certificate_arn (Optional[str]): The ARN of the ACM certificate to attach. If None, the HTTPS listener will NOT be created. default_target_group (elb.ITargetGroup): The default target group for the listener to forward traffic to. This is mandatory for a functional listener. listener_port_https (int): The HTTPS port to listen on (default: 443). listener_open_to_internet (bool): Whether the listener should allow connections from all sources. If False (recommended), ensure your ALB's security group allows inbound traffic on this port from desired sources. enable_cognito_auth (bool): Set to True to enable Cognito User Pool authentication. cognito_user_pool (Optional[cognito.IUserPool]): The Cognito User Pool object. Required if enable_cognito_auth is True. cognito_user_pool_client (Optional[cognito.IUserPoolClient]): The Cognito User Pool App Client object. Required if enable_cognito_auth is True. cognito_user_pool_domain (Optional[str]): The domain prefix for your Cognito User Pool. Required if enable_cognito_auth is True. cognito_auth_scope (Optional[str]): The scope for the Cognito authentication. cognito_auth_on_unauthenticated_request (elb.UnauthenticatedAction): Action for unauthenticated requests. Defaults to AUTHENTICATE (redirect to login). Returns: Optional[elb.ApplicationListener]: The created ApplicationListener if successful, None if no ACM certificate ARN was provided. """ https_listener = None if acm_certificate_arn: certificates_list = [elb.ListenerCertificate.from_arn(acm_certificate_arn)] print( f"Attempting to add ALB HTTPS listener on port {listener_port_https} with ACM certificate: {acm_certificate_arn}" ) # Determine the default action based on whether Cognito auth is enabled default_action = None if enable_cognito_auth is True: if not all( [cognito_user_pool, cognito_user_pool_client, cognito_user_pool_domain] ): raise ValueError( "Cognito User Pool, Client, and Domain must be provided if enable_cognito_auth is True." ) print( f"Enabling Cognito authentication with User Pool: {cognito_user_pool.user_pool_id}" ) default_action = elb_act.AuthenticateCognitoAction( next=elb.ListenerAction.forward( [default_target_group] ), # After successful auth, forward to TG user_pool=cognito_user_pool, user_pool_client=cognito_user_pool_client, user_pool_domain=cognito_user_pool_domain, scope=cognito_auth_scope, on_unauthenticated_request=cognito_auth_on_unauthenticated_request, session_timeout=stickiness_cookie_duration, # Additional options you might want to configure: # session_cookie_name="AWSELBCookies" ) else: default_action = elb.ListenerAction.forward([default_target_group]) print("Cognito authentication is NOT enabled for this listener.") # Add the HTTPS listener https_listener = alb.add_listener( logical_id, port=listener_port_https, open=listener_open_to_internet, certificates=certificates_list, default_action=default_action, # Use the determined default action ) print(f"ALB HTTPS listener on port {listener_port_https} defined.") else: print("ACM_CERTIFICATE_ARN is not provided. Skipping HTTPS listener creation.") return https_listener def create_ecs_express_infrastructure_role( scope: Construct, logical_id: str, role_name: str, ) -> iam.Role: """IAM role for ECS Express Mode to provision ALB, ACM cert, and autoscaling.""" role = iam.Role( scope, logical_id, role_name=role_name, assumed_by=iam.ServicePrincipal("ecs.amazonaws.com"), ) role.add_managed_policy( iam.ManagedPolicy.from_aws_managed_policy_name( "service-role/AmazonECSInfrastructureRoleforExpressGatewayServices" ) ) return role def _secret_value_from_arn(secret_arn: str, json_key: str) -> str: return f"{secret_arn}:{json_key}::" def express_ingress_listener_arn( express_service: ecs.CfnExpressGatewayService, ) -> str: return express_service.attr_ecs_managed_resource_arns_ingress_path_listener_arn def express_ingress_load_balancer_arn( express_service: ecs.CfnExpressGatewayService, ) -> str: return express_service.attr_ecs_managed_resource_arns_ingress_path_load_balancer_arn def express_ingress_first_target_group_arn( express_service: ecs.CfnExpressGatewayService, ) -> str: """First target group ARN; use typed list attr (get_att returns a scalar Reference).""" return Fn.select( 0, express_service.attr_ecs_managed_resource_arns_ingress_path_target_group_arns, ) def express_ingress_first_load_balancer_security_group_arn( express_service: ecs.CfnExpressGatewayService, ) -> str: """First Express-managed ALB security group ARN.""" return Fn.select( 0, express_service.attr_ecs_managed_resource_arns_ingress_path_load_balancer_security_groups, ) def _security_group_id_from_arn(security_group_arn: str) -> str: """EC2 APIs expect sg- IDs; Express managed-resource attrs return full ARNs.""" return Fn.select(1, Fn.split("security-group/", security_group_arn)) def express_ingress_first_load_balancer_security_group( express_service: ecs.CfnExpressGatewayService, ) -> str: """First ALB security group ID (sg-...) for EC2/ELB imports.""" return _security_group_id_from_arn( express_ingress_first_load_balancer_security_group_arn(express_service) ) # Injected via Express `secrets`, not plain environment (avoid duplication/leakage). _EXPRESS_SECRET_ENV_NAMES = frozenset( {"AWS_USER_POOL_ID", "AWS_CLIENT_ID", "AWS_CLIENT_SECRET"} ) def create_basic_config_env( out_dir: str = "config", s3_log_config_bucket_name: str = S3_LOG_CONFIG_BUCKET_NAME, s3_output_bucket_name: str = S3_OUTPUT_BUCKET_NAME, access_log_dynamodb_table_name: str = ACCESS_LOG_DYNAMODB_TABLE_NAME, feedback_log_dynamodb_table_name: str = FEEDBACK_LOG_DYNAMODB_TABLE_NAME, usage_log_dynamodb_table_name: str = USAGE_LOG_DYNAMODB_TABLE_NAME, *, headless: bool = False, alb_cognito: bool = False, pi_express_backend: bool = False, ): """ Create a basic app_config.env file for the deployed llm_topic_modeller app. ``alb_cognito=True`` disables in-app Gradio Cognito login when the ALB ``authenticate-cognito`` action already protects the service (Express Mode). ``pi_express_backend=True`` disables in-app login on the main app when Pi Express calls it over Service Connect (users authenticate on the Pi UI only). """ variables = { "COGNITO_AUTH": ( "False" if headless or alb_cognito or pi_express_backend else "True" ), "RUN_AWS_FUNCTIONS": "True", "RUN_AWS_BEDROCK_MODELS": "True", "RUN_LOCAL_MODEL": "False", "RUN_GEMINI_MODELS": "False", "RUN_AZURE_MODELS": "False", "PRIORITISE_SSO_OVER_AWS_ENV_ACCESS_KEYS": "True", "DISPLAY_FILE_NAMES_IN_LOGS": "False", "SESSION_OUTPUT_FOLDER": "True", "SAVE_LOGS_TO_CSV": "True", "SAVE_LOGS_TO_DYNAMODB": "True", "SHOW_COSTS": "True", "S3_LOG_BUCKET": s3_log_config_bucket_name, "S3_OUTPUTS_BUCKET": s3_output_bucket_name if headless else "", "S3_OUTPUTS_FOLDER": "output/" if headless else "", "SAVE_OUTPUTS_TO_S3": "True" if headless else "False", "ACCESS_LOG_DYNAMODB_TABLE_NAME": access_log_dynamodb_table_name, "FEEDBACK_LOG_DYNAMODB_TABLE_NAME": feedback_log_dynamodb_table_name, "USAGE_LOG_DYNAMODB_TABLE_NAME": usage_log_dynamodb_table_name, } _ensure_folder_exists(out_dir + "/") env_file_path = os.path.abspath(os.path.join(out_dir, APP_CONFIG_ENV_BASENAME)) if not os.path.exists(env_file_path): with open(env_file_path, "w", encoding="utf-8"): pass for key, value in variables.items(): set_key(env_file_path, key, str(value), quote_mode="never") return variables def load_app_config_env_for_express( config_env_path: str, *, exclude_names: Optional[FrozenSet[str]] = None, overrides: Optional[Dict[str, str]] = None, ) -> List[ecs.CfnExpressGatewayService.KeyValuePairProperty]: """ Load KEY=VALUE pairs from config/app_config.env for Express PrimaryContainer.environment. Uses the same file written by create_basic_config_env() and uploaded to S3 on the legacy Fargate path (environmentFiles). ``overrides`` replace keys after loading (e.g. ``COGNITO_AUTH=False`` when ALB handles Cognito). """ exclude = exclude_names or _EXPRESS_SECRET_ENV_NAMES path = os.path.abspath(config_env_path) if not os.path.isfile(path): print( f"Warning: app config env file not found at {path}; " "Express container will not receive app config environment variables." ) return [] raw = dict(dotenv_values(path)) if overrides: raw.update(overrides) environment: List[ecs.CfnExpressGatewayService.KeyValuePairProperty] = [] for name, value in sorted(raw.items()): if not name or value is None or name in exclude: continue environment.append( ecs.CfnExpressGatewayService.KeyValuePairProperty( name=name, value=str(value), ) ) print( f"Loaded {len(environment)} environment variables from {path} for ECS Express Mode." ) return environment def build_express_gateway_primary_container( *, image_uri: str, container_port: int, log_group_name: str, aws_region: str, secret: secretsmanager.ISecret, environment: Optional[ List[ecs.CfnExpressGatewayService.KeyValuePairProperty] ] = None, ) -> ecs.CfnExpressGatewayService.ExpressGatewayContainerProperty: secret_arn = secret.secret_arn return ecs.CfnExpressGatewayService.ExpressGatewayContainerProperty( image=image_uri, container_port=container_port, aws_logs_configuration=ecs.CfnExpressGatewayService.ExpressGatewayServiceAwsLogsConfigurationProperty( log_group=log_group_name, log_stream_prefix="ecs", ), environment=environment or None, secrets=[ ecs.CfnExpressGatewayService.SecretProperty( name="AWS_USER_POOL_ID", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_USER_POOL_ID" ), ), ecs.CfnExpressGatewayService.SecretProperty( name="AWS_CLIENT_ID", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_CLIENT_ID" ), ), ecs.CfnExpressGatewayService.SecretProperty( name="AWS_CLIENT_SECRET", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_CLIENT_SECRET" ), ), ], ) def express_gateway_idle_scaling_target( *, max_task_count: int = 1, ) -> ecs.CfnExpressGatewayService.ExpressGatewayScalingTargetProperty: """Defer running tasks until post-deploy image build (legacy Fargate uses desired_count=0).""" return ecs.CfnExpressGatewayService.ExpressGatewayScalingTargetProperty( min_task_count=0, max_task_count=max_task_count, auto_scaling_metric="AVERAGE_CPU", auto_scaling_target_value=60, ) def create_express_gateway_service( scope: Construct, logical_id: str, *, service_name: str, cluster_name: str, execution_role_arn: str, infrastructure_role_arn: str, task_role_arn: str, cpu: str, memory: str, health_check_path: str, primary_container: ecs.CfnExpressGatewayService.ExpressGatewayContainerProperty, subnet_ids: List[str], security_group_ids: List[str], scaling_target: Optional[ ecs.CfnExpressGatewayService.ExpressGatewayScalingTargetProperty ] = None, ) -> ecs.CfnExpressGatewayService: network = None if subnet_ids or security_group_ids: network = ecs.CfnExpressGatewayService.ExpressGatewayServiceNetworkConfigurationProperty( subnets=subnet_ids or None, security_groups=security_group_ids or None, ) express_service = ecs.CfnExpressGatewayService( scope, logical_id, service_name=service_name, cluster=cluster_name, execution_role_arn=execution_role_arn, infrastructure_role_arn=infrastructure_role_arn, task_role_arn=task_role_arn, cpu=cpu, memory=memory, health_check_path=health_check_path, primary_container=primary_container, network_configuration=network, scaling_target=scaling_target or express_gateway_idle_scaling_target(), ) return express_service def _forward_target_group_action( target_group_arn: str, stickiness_seconds: int, ) -> Dict[str, Any]: action: Dict[str, Any] = { "Type": "forward", "Order": 2, "ForwardConfig": { "TargetGroups": [{"TargetGroupArn": target_group_arn}], }, } if stickiness_seconds > 0: action["ForwardConfig"]["TargetGroupStickinessConfig"] = { "Enabled": True, "DurationSeconds": stickiness_seconds, } return action def elbv2_cognito_auth_custom_resource_policy() -> cr.AwsCustomResourcePolicy: """ IAM policy for AwsCustomResource calls that configure authenticate-cognito on ALB. ELB validates the user pool client during modifyListener/createRule; the caller (the custom-resource Lambda) needs cognito-idp:DescribeUserPoolClient. """ return cr.AwsCustomResourcePolicy.from_statements( [ iam.PolicyStatement( actions=[ "elasticloadbalancing:DescribeRules", "elasticloadbalancing:ModifyListener", "elasticloadbalancing:CreateRule", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:DeleteRule", ], resources=["*"], ), iam.PolicyStatement( actions=["cognito-idp:DescribeUserPoolClient"], resources=["*"], ), ] ) def build_cognito_default_listener_actions( *, user_pool_arn: str, user_pool_client_id: str, user_pool_domain_prefix: str, target_group_arn: str, stickiness_seconds: int = 28800, scope: str = "openid email profile", ) -> List[Dict[str, Any]]: """Default actions for ELBv2 ModifyListener (authenticate-cognito + forward).""" return [ { "Type": "authenticate-cognito", "Order": 1, "AuthenticateCognitoConfig": { "UserPoolArn": user_pool_arn, "UserPoolClientId": user_pool_client_id, "UserPoolDomain": user_pool_domain_prefix, "Scope": scope, "OnUnauthenticatedRequest": "authenticate", "SessionTimeout": stickiness_seconds, }, }, _forward_target_group_action(target_group_arn, stickiness_seconds), ] def configure_express_listener_cognito_and_cloudfront( scope: Construct, logical_id_prefix: str, *, express_service: ecs.CfnExpressGatewayService, user_pool_arn: str, user_pool_client_id: str, user_pool_domain_prefix: str, use_cloudfront: bool, cloudfront_host_header: str, stickiness_seconds: int = 28800, allow_cloudfront_origin_without_cognito: bool = False, ) -> None: """ Attach Cognito auth to the Express-managed HTTPS listener. By default, **no** forward-only host-header rules are added. CloudFront (and all other) traffic uses the listener default action: ``authenticate-cognito`` then forward. Set ``allow_cloudfront_origin_without_cognito=True`` only when the ALB must accept CloudFront origin requests without a Cognito session (legacy pattern). """ listener_arn = express_ingress_listener_arn(express_service) target_group_arn = express_ingress_first_target_group_arn(express_service) default_actions = build_cognito_default_listener_actions( user_pool_arn=user_pool_arn, user_pool_client_id=user_pool_client_id, user_pool_domain_prefix=user_pool_domain_prefix, target_group_arn=target_group_arn, stickiness_seconds=stickiness_seconds, ) modify_listener = cr.AwsCustomResource( scope, f"{logical_id_prefix}ModifyExpressListener", on_create=cr.AwsSdkCall( service="ELBv2", action="modifyListener", parameters={ "ListenerArn": listener_arn, "DefaultActions": default_actions, }, physical_resource_id=cr.PhysicalResourceId.of( f"express-listener-cognito-{logical_id_prefix}" ), ), on_update=cr.AwsSdkCall( service="ELBv2", action="modifyListener", parameters={ "ListenerArn": listener_arn, "DefaultActions": default_actions, }, physical_resource_id=cr.PhysicalResourceId.of( f"express-listener-cognito-{logical_id_prefix}" ), ), policy=elbv2_cognito_auth_custom_resource_policy(), ) modify_listener.node.add_dependency(express_service) if ( use_cloudfront and cloudfront_host_header and allow_cloudfront_origin_without_cognito ): forward_only = [ { "Type": "forward", "Order": 1, "ForwardConfig": { "TargetGroups": [{"TargetGroupArn": target_group_arn}], "TargetGroupStickinessConfig": { "Enabled": True, "DurationSeconds": stickiness_seconds, }, }, } ] _elbv2_listener_rule_custom_resource( scope, f"{logical_id_prefix}ExpressCloudFrontHostRule", listener_arn=listener_arn, priority=1, conditions=[ { "Field": "host-header", "HostHeaderConfig": {"Values": [cloudfront_host_header]}, } ], rule_actions=forward_only, dependencies=[modify_listener], ) def allow_express_load_balancer_to_ecs_security_group( scope: Construct, logical_id: str, *, express_service: ecs.CfnExpressGatewayService, ecs_security_group: ec2.ISecurityGroup, container_port: int, ) -> None: """Allow traffic from the Express-managed ALB security group to the task SG.""" lb_sg_id = express_ingress_first_load_balancer_security_group(express_service) ec2.CfnSecurityGroupIngress( scope, logical_id, group_id=ecs_security_group.security_group_id, ip_protocol="tcp", from_port=container_port, to_port=container_port, source_security_group_id=lb_sg_id, description="Express Mode ALB to ECS tasks", ) def _dict_env_to_express_key_value_pairs( environment: Dict[str, str], ) -> List[ecs.CfnExpressGatewayService.KeyValuePairProperty]: return [ ecs.CfnExpressGatewayService.KeyValuePairProperty(name=k, value=str(v)) for k, v in environment.items() if v is not None and str(v) != "" ] def normalize_pi_alb_path_prefix(raw: str, *, default: str = "pi") -> str: """Return a leading-slash path prefix (no trailing slash), e.g. '/pi'.""" segment = (raw or default).strip().strip("/") return f"/{segment}" if segment else f"/{default}" def normalize_pi_alb_routing_mode(raw: str) -> str: mode = (raw or "path").strip().lower() allowed = frozenset({"path", "host", "both"}) if mode not in allowed: raise ValueError( f"PI_ALB_ROUTING must be one of {sorted(allowed)}; got '{raw}'." ) return mode def pi_alb_path_patterns(path_prefix: str) -> List[str]: """ALB path-pattern values for a Pi path prefix (exact + subtree).""" prefix = normalize_pi_alb_path_prefix(path_prefix) return [prefix, f"{prefix}/*"] def pi_alb_health_check_path(path_prefix: str, routing_mode: str) -> str: if normalize_pi_alb_routing_mode(routing_mode) in ("path", "both"): return f"{normalize_pi_alb_path_prefix(path_prefix)}/" return "/" def pi_alb_root_path_for_container(path_prefix: str, routing_mode: str) -> str: """Gradio/FastAPI ROOT_PATH to set on Pi tasks when path routing is enabled.""" if normalize_pi_alb_routing_mode(routing_mode) in ("path", "both"): return normalize_pi_alb_path_prefix(path_prefix) return "" def pi_listener_rule_count(routing_mode: str) -> int: mode = normalize_pi_alb_routing_mode(routing_mode) count = 0 if mode in ("path", "both"): count += 1 if mode in ("host", "both"): count += 1 return count def format_express_pi_public_url(express_endpoint: str) -> str: """Public Pi UI URL for a dedicated ECS Express managed HTTPS endpoint.""" base = (express_endpoint or "").strip().rstrip("/") return f"{base}/" if base else "" def format_pi_public_urls( *, routing_mode: str, path_prefix: str, host_header: str, cloudfront_domain: str = "", use_https: bool = True, ) -> List[str]: """Human-facing Pi UI URLs for stack outputs.""" scheme = "https" if use_https else "http" urls: List[str] = [] mode = normalize_pi_alb_routing_mode(routing_mode) prefix = normalize_pi_alb_path_prefix(path_prefix) if mode in ("path", "both"): if cloudfront_domain.strip(): urls.append(f"{scheme}://{cloudfront_domain.strip()}{prefix}/") else: urls.append(f"{scheme}://{prefix}/") if mode in ("host", "both") and host_header.strip(): urls.append(f"{scheme}://{host_header.strip()}/") return urls def _apply_pi_root_path_env(env: Dict[str, str], pi_root_path: str) -> None: if pi_root_path: env["PI_ROOT_PATH"] = pi_root_path env["ROOT_PATH"] = pi_root_path env["FASTAPI_ROOT_PATH"] = pi_root_path def build_pi_express_container_environment( *, service_connect_discovery_name: str, main_app_port: Union[str, int], pi_gradio_port: Union[str, int], cognito_auth: bool = True, ) -> Dict[str, str]: """Inline env for Pi on Express (no volume mounts; workspace under /tmp).""" port = int(main_app_port) pi_port = int(pi_gradio_port) env = { "APP_TYPE": "pi", "APP_CONFIG_PATH": "/workspace/doc_summarisation/config/pi_agent.env.example", "PI_DEPLOYMENT_PROFILE": "aws-ecs", "PI_DEFAULT_PROVIDER": "amazon-bedrock", "DOC_SUMMARISATION_GRADIO_URL": f"http://{service_connect_discovery_name}:{port}", "PI_GRADIO_PORT": str(pi_port), "GRADIO_SERVER_PORT": str(pi_port), "GRADIO_SERVER_NAME": "0.0.0.0", "PI_WORKSPACE_DIR": "/tmp/pi-workspace", "PI_WORKDIR": "/workspace/doc_summarisation", "PI_UPLOAD_ROOT": "/tmp/gradio", "PI_SESSION_DIR": "/tmp/pi-sessions", "PI_CODING_AGENT_DIR": "/tmp/pi-agent", "ACCESS_LOGS_FOLDER": "/tmp/pi-logs/", "USAGE_LOGS_FOLDER": "/tmp/pi-usage/", "FEEDBACK_LOGS_FOLDER": "/tmp/pi-feedback/", "RUN_FASTAPI": "True", "RUN_AWS_FUNCTIONS": "True", "COGNITO_AUTH": "True" if cognito_auth else "False", } return env def build_express_pi_primary_container( *, image_uri: str, container_port: int, log_group_name: str, aws_region: str, environment: Optional[Dict[str, str]] = None, secret: Optional[secretsmanager.ISecret] = None, cognito_auth: bool = True, ) -> ecs.CfnExpressGatewayService.ExpressGatewayContainerProperty: """Express PrimaryContainer for Pi (inline env; Cognito creds from Secrets Manager).""" env_pairs = ( _dict_env_to_express_key_value_pairs(environment) if environment else None ) secrets: Optional[List[ecs.CfnExpressGatewayService.SecretProperty]] = None if secret is not None and cognito_auth: secret_arn = secret.secret_arn secrets = [ ecs.CfnExpressGatewayService.SecretProperty( name="AWS_USER_POOL_ID", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_USER_POOL_ID" ), ), ecs.CfnExpressGatewayService.SecretProperty( name="AWS_CLIENT_ID", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_CLIENT_ID" ), ), ecs.CfnExpressGatewayService.SecretProperty( name="AWS_CLIENT_SECRET", value_from=_secret_value_from_arn( secret_arn, "SUMMARISATION_CLIENT_SECRET" ), ), ] return ecs.CfnExpressGatewayService.ExpressGatewayContainerProperty( image=image_uri, container_port=container_port, aws_logs_configuration=ecs.CfnExpressGatewayService.ExpressGatewayServiceAwsLogsConfigurationProperty( log_group=log_group_name, log_stream_prefix="ecs-pi", ), environment=env_pairs, secrets=secrets, ) _ELBV2_RULE_DELETE_IGNORE = ( ".*(not a valid listener rule ARN|RuleNotFound|ResourceNotFound|ValidationError).*" ) _ELBV2_LISTENER_RULE_UPSERT_PROVIDER_ID = "Elbv2ListenerRuleUpsertProvider" def _elbv2_listener_rule_upsert_provider(scope: Construct) -> cr.Provider: """Shared Provider for upserting ALB listener rules (one Lambda per stack).""" stack = Stack.of(scope) existing = stack.node.try_find_child(_ELBV2_LISTENER_RULE_UPSERT_PROVIDER_ID) if existing is not None: return existing asset_dir = os.path.join( os.path.dirname(__file__), "lambda_elbv2_listener_rule_upsert" ) fn = lambda_.Function( stack, "Elbv2ListenerRuleUpsertFn", runtime=lambda_.Runtime.PYTHON_3_12, handler="lambda_function.handler", code=lambda_.Code.from_asset(asset_dir), timeout=Duration.seconds(120), description="Upsert ALB listener rules for Express Cognito routing", ) fn.add_to_role_policy( iam.PolicyStatement( actions=[ "elasticloadbalancing:DescribeRules", "elasticloadbalancing:CreateRule", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:DeleteRule", ], resources=["*"], ) ) fn.add_to_role_policy( iam.PolicyStatement( actions=["cognito-idp:DescribeUserPoolClient"], resources=["*"], ) ) return cr.Provider( stack, _ELBV2_LISTENER_RULE_UPSERT_PROVIDER_ID, on_event_handler=fn, ) def _elbv2_listener_rule_custom_resource( scope: Construct, logical_id: str, *, listener_arn: str, priority: int, conditions: List[Dict[str, Any]], rule_actions: List[Dict[str, Any]], dependencies: Optional[List[Any]] = None, ) -> CustomResource: """ Create or update a numbered listener rule (upsert by priority + conditions). Reuses an existing rule at the same priority when conditions match, which avoids PriorityInUse failures after partial deploy rollbacks. """ provider = _elbv2_listener_rule_upsert_provider(scope) resource = CustomResource( scope, logical_id, service_token=provider.service_token, resource_type="Custom::Elbv2ListenerRuleUpsert", properties={ "ListenerArn": listener_arn, "Priority": priority, "Conditions": conditions, "Actions": rule_actions, }, ) for dep in dependencies or []: resource.node.add_dependency(dep) return resource def _express_pi_listener_rule_custom_resource( scope: Construct, logical_id: str, *, listener_arn: str, priority: int, conditions: List[Dict[str, Any]], rule_actions: List[Dict[str, Any]], express_main_service: ecs.CfnExpressGatewayService, express_pi_service: ecs.CfnExpressGatewayService, ) -> CustomResource: return _elbv2_listener_rule_custom_resource( scope, logical_id, listener_arn=listener_arn, priority=priority, conditions=conditions, rule_actions=rule_actions, dependencies=[express_pi_service, express_main_service], ) def configure_express_pi_listener_rules( scope: Construct, logical_id_prefix: str, *, express_main_service: ecs.CfnExpressGatewayService, express_pi_service: ecs.CfnExpressGatewayService, routing_mode: str, path_prefix: str, pi_host_header: str, rule_priority: int, user_pool_arn: str, user_pool_client_id: str, user_pool_domain_prefix: str, stickiness_seconds: int = 28800, ) -> int: """ Path and/or host-header rules on the shared Express HTTPS listener → Pi TG. Returns the next free listener rule priority after Pi rules. """ mode = normalize_pi_alb_routing_mode(routing_mode) listener_arn = express_ingress_listener_arn(express_main_service) pi_target_group_arn = express_ingress_first_target_group_arn(express_pi_service) rule_actions = build_cognito_default_listener_actions( user_pool_arn=user_pool_arn, user_pool_client_id=user_pool_client_id, user_pool_domain_prefix=user_pool_domain_prefix, target_group_arn=pi_target_group_arn, stickiness_seconds=stickiness_seconds, ) priority = rule_priority if mode in ("path", "both"): path_patterns = pi_alb_path_patterns(path_prefix) _express_pi_listener_rule_custom_resource( scope, f"{logical_id_prefix}ExpressPiPathRule", listener_arn=listener_arn, priority=priority, conditions=[ { "Field": "path-pattern", "PathPatternConfig": {"Values": path_patterns}, } ], rule_actions=rule_actions, express_main_service=express_main_service, express_pi_service=express_pi_service, ) priority += 1 if mode in ("host", "both") and pi_host_header.strip(): _express_pi_listener_rule_custom_resource( scope, f"{logical_id_prefix}ExpressPiHostRule", listener_arn=listener_arn, priority=priority, conditions=[ { "Field": "host-header", "HostHeaderConfig": {"Values": [pi_host_header.strip()]}, } ], rule_actions=rule_actions, express_main_service=express_main_service, express_pi_service=express_pi_service, ) priority += 1 return priority def _express_service_connect_configuration( *, namespace: str, port_name: Optional[str] = None, discovery_name: Optional[str] = None, port: Optional[int] = None, ) -> Dict[str, Any]: """ECS API serviceConnectConfiguration payload for updateService.""" cfg: Dict[str, Any] = {"enabled": True, "namespace": namespace} if port_name and discovery_name and port is not None: cfg["services"] = [ { "portName": port_name, "discoveryName": discovery_name, "clientAliases": [ {"port": int(port), "dnsName": discovery_name}, ], } ] return cfg def apply_service_connect_to_express_service( scope: Construct, logical_id: str, *, cluster_name: str, service_name: str, namespace: str, express_service: ecs.CfnExpressGatewayService, port_name: Optional[str] = None, discovery_name: Optional[str] = None, port: Optional[int] = None, ) -> cr.AwsCustomResource: """ Enable Service Connect on an Express gateway service after create (AWS does not support SC at Express create time). Server config when port_name/discovery_name/port are set; client-only when they are omitted. """ sc_cfg = _express_service_connect_configuration( namespace=namespace, port_name=port_name, discovery_name=discovery_name, port=port, ) physical_id = f"{cluster_name}/{service_name}/service-connect" custom = cr.AwsCustomResource( scope, logical_id, on_create=cr.AwsSdkCall( service="ECS", action="updateService", parameters={ "cluster": cluster_name, "service": service_name, "serviceConnectConfiguration": sc_cfg, "forceNewDeployment": True, }, physical_resource_id=cr.PhysicalResourceId.of(physical_id), ), on_update=cr.AwsSdkCall( service="ECS", action="updateService", parameters={ "cluster": cluster_name, "service": service_name, "serviceConnectConfiguration": sc_cfg, "forceNewDeployment": True, }, physical_resource_id=cr.PhysicalResourceId.of(physical_id), ), policy=cr.AwsCustomResourcePolicy.from_sdk_calls( resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE ), ) custom.node.add_dependency(express_service) return custom def create_s3_batch_ecs_trigger_lambda( scope: Construct, logical_id: str, *, function_name: Optional[str], lambda_asset_path: str, output_bucket: s3.IBucket, config_bucket: s3.IBucket, cluster_name: str, task_definition_arn: str, container_name: str, subnet_ids: List[str], security_group_id: str, execution_role: iam.IRole, task_role: iam.IRole, env_prefix: str, env_suffix: str, input_prefix: str, config_prefix: str, default_params_key: str, general_env_prefix: str = "general-config/", default_task_type: str = "extract", default_input_s3_uri: str = "", assign_public_ip: bool = False, ) -> lambda_.Function: """ Lambda triggered by job .env uploads on the output bucket; runs one-shot Fargate tasks. """ lambda_role = iam.Role( scope, f"{logical_id}Role", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), managed_policies=[ iam.ManagedPolicy.from_aws_managed_policy_name( "service-role/AWSLambdaBasicExecutionRole" ) ], ) lambda_role.add_to_policy( iam.PolicyStatement( actions=["ecs:RunTask"], resources=[task_definition_arn], ) ) lambda_role.add_to_policy( iam.PolicyStatement( actions=["ecs:RunTask"], resources=[ f"arn:aws:ecs:*:*:cluster/{cluster_name}", ], ) ) lambda_role.add_to_policy( iam.PolicyStatement( actions=["iam:PassRole"], resources=[execution_role.role_arn, task_role.role_arn], conditions={ "StringEquals": {"iam:PassedToService": "ecs-tasks.amazonaws.com"} }, ) ) # Job .env uploads and inputs live on the output bucket (short retention). output_bucket.grant_read(lambda_role, f"{env_prefix}*") # Durable defaults (app_defaults.env) live on the log/config bucket. config_bucket.grant_read(lambda_role) if general_env_prefix: config_bucket.grant_read(lambda_role, f"{general_env_prefix}*") if default_params_key: config_bucket.grant_read(lambda_role, default_params_key) bucket_name = output_bucket.bucket_name log_bucket_name = config_bucket.bucket_name if not default_input_s3_uri: default_input_s3_uri = f"s3://{bucket_name}/{input_prefix.rstrip('/')}/dummy_consultation_response.xlsx" fn_kwargs: Dict[str, Any] = { "runtime": lambda_.Runtime.PYTHON_3_12, "handler": "lambda_function.lambda_handler", "code": lambda_.Code.from_asset(lambda_asset_path), "role": lambda_role, "timeout": Duration.seconds(60), "memory_size": 256, "environment": { "BUCKET": bucket_name, "LOG_BUCKET": log_bucket_name, "INPUT_PREFIX": input_prefix, "ENV_PREFIX": env_prefix, "GENERAL_ENV_PREFIX": general_env_prefix, "ENV_SUFFIX": env_suffix, "DEFAULT_PARAMS_KEY": default_params_key, "ECS_CLUSTER": cluster_name, "ECS_TASK_DEF": task_definition_arn, "SUBNETS": ",".join(subnet_ids), "SECURITY_GROUPS": security_group_id, "CONTAINER_NAME": container_name, "DEFAULT_TASK_TYPE": default_task_type, "DEFAULT_INPUT_S3_URI": default_input_s3_uri, "ECS_ASSIGN_PUBLIC_IP": "ENABLED" if assign_public_ip else "DISABLED", }, } if function_name: fn_kwargs["function_name"] = function_name batch_fn = lambda_.Function(scope, logical_id, **fn_kwargs) output_bucket.add_event_notification( s3.EventType.OBJECT_CREATED, s3n.LambdaDestination(batch_fn), s3.NotificationKeyFilter(prefix=env_prefix, suffix=env_suffix), ) return batch_fn def create_dynamo_usage_log_export_lambda( scope: Construct, logical_id: str, *, function_name: Optional[str], lambda_asset_path: str, dynamodb_table: dynamodb.ITable, output_bucket: s3.IBucket, s3_output_key: str, schedule_expression: str, dynamodb_table_name: str, date_attribute: str = "timestamp", output_filename: str = "dynamodb_logs_export.csv", shared_kms_key_arn: Optional[str] = None, ) -> lambda_.Function: """ Scheduled Lambda: scan usage-log DynamoDB table, export CSV, upload to S3. Triggered by EventBridge on ``schedule_expression`` (cron or rate). """ lambda_role = iam.Role( scope, f"{logical_id}Role", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), managed_policies=[ iam.ManagedPolicy.from_aws_managed_policy_name( "service-role/AWSLambdaBasicExecutionRole" ) ], ) dynamodb_table.grant_read_data(lambda_role) output_bucket.grant_put(lambda_role, s3_output_key) if shared_kms_key_arn: lambda_role.add_to_policy( iam.PolicyStatement( actions=[ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey", ], resources=[shared_kms_key_arn], ) ) fn_kwargs: Dict[str, Any] = { "runtime": lambda_.Runtime.PYTHON_3_12, "handler": "lambda_function.lambda_handler", "code": lambda_.Code.from_asset(lambda_asset_path), "role": lambda_role, "timeout": Duration.minutes(15), "memory_size": 512, "environment": { "DYNAMODB_TABLE_NAME": dynamodb_table_name, "USAGE_LOG_DYNAMODB_TABLE_NAME": dynamodb_table_name, "OUTPUT_FOLDER": "/tmp", "OUTPUT_FILENAME": output_filename, "DATE_ATTRIBUTE": date_attribute, "S3_OUTPUT_BUCKET": output_bucket.bucket_name, "S3_OUTPUT_KEY": s3_output_key, }, } if function_name: fn_kwargs["function_name"] = function_name export_fn = lambda_.Function(scope, logical_id, **fn_kwargs) events.Rule( scope, f"{logical_id}Schedule", schedule=events.Schedule.expression(schedule_expression), description=( "Export DynamoDB usage logs to CSV in S3 " f"({schedule_expression})" ), targets=[targets.LambdaFunction(export_fn)], ) CfnOutput( scope, f"{logical_id}LambdaArn", value=export_fn.function_arn, description="Lambda ARN for scheduled DynamoDB usage log export to S3", ) CfnOutput( scope, f"{logical_id}S3Uri", value=f"s3://{output_bucket.bucket_name}/{s3_output_key}", description="S3 URI for the scheduled DynamoDB usage log CSV export", ) return export_fn def sanitize_headless_metric_filter_id(raw: str) -> str: """S3 request metrics configuration Id (alphanumeric, hyphen, underscore).""" cleaned = re.sub(r"[^A-Za-z0-9_-]", "-", (raw or "").strip()) cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-") return (cleaned or "s3-output-put")[:64] def create_headless_output_notifications( scope: Construct, logical_id_prefix: str, *, output_bucket: s3.IBucket, output_prefix: str, notify_email: str, iam_user_name: str, metric_filter_id: str, sns_topic_name: str, alarm_name: str, kms_key_arn: Optional[str] = None, ) -> Dict[str, str]: """ Headless follow-on: S3 PutRequests metric on ``output_prefix`` -> CloudWatch alarm -> SNS email, plus an IAM user that can list/get/put/delete objects in the bucket. Mirrors the pattern documented under cdk/alarms_and_user/. """ output_bucket_name = output_bucket.bucket_name metric_id = sanitize_headless_metric_filter_id(metric_filter_id) prefix = (output_prefix or "output/").strip() if prefix and not prefix.endswith("/"): prefix += "/" email = (notify_email or "").strip() if not email: raise ValueError("notify_email is required for headless output notifications") metrics_resource = cr.AwsCustomResource( scope, f"{logical_id_prefix}OutputBucketPutMetrics", on_create=cr.AwsSdkCall( service="S3", action="putBucketMetricsConfiguration", parameters={ "Bucket": output_bucket_name, "Id": metric_id, "MetricsConfiguration": { "Id": metric_id, "Filter": {"Prefix": prefix}, }, }, physical_resource_id=cr.PhysicalResourceId.of( f"{output_bucket_name}-{metric_id}" ), ), on_update=cr.AwsSdkCall( service="S3", action="putBucketMetricsConfiguration", parameters={ "Bucket": output_bucket_name, "Id": metric_id, "MetricsConfiguration": { "Id": metric_id, "Filter": {"Prefix": prefix}, }, }, physical_resource_id=cr.PhysicalResourceId.of( f"{output_bucket_name}-{metric_id}" ), ), on_delete=cr.AwsSdkCall( service="S3", action="deleteBucketMetricsConfiguration", parameters={"Bucket": output_bucket_name, "Id": metric_id}, ), policy=cr.AwsCustomResourcePolicy.from_statements( [ iam.PolicyStatement( actions=[ "s3:PutMetricsConfiguration", "s3:GetMetricsConfiguration", "s3:DeleteMetricsConfiguration", "s3:ListBucket", ], resources=[ f"arn:aws:s3:::{output_bucket_name}", f"arn:aws:s3:::{output_bucket_name}/*", ], ) ] ), ) topic = sns.Topic( scope, f"{logical_id_prefix}OutputNotifyTopic", topic_name=sns_topic_name[:256], display_name=sns_topic_name[:100], ) topic.add_to_resource_policy( iam.PolicyStatement( sid="AllowPublishAlarms", effect=iam.Effect.ALLOW, principals=[iam.ServicePrincipal("cloudwatch.amazonaws.com")], actions=["sns:Publish"], resources=[topic.topic_arn], ) ) topic.add_subscription(sns_subscriptions.EmailSubscription(email)) alarm = cloudwatch.Alarm( scope, f"{logical_id_prefix}OutputPutAlarm", alarm_name=alarm_name[:255], metric=cloudwatch.Metric( namespace="AWS/S3", metric_name="PutRequests", dimensions_map={ "BucketName": output_bucket_name, "FilterId": metric_id, }, statistic="Sum", period=Duration.minutes(1), ), threshold=0, evaluation_periods=1, comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, alarm_description=( f"Notify when new analysis outputs are written under s3://" f"{output_bucket_name}/{prefix}" ), ) alarm.add_alarm_action(cloudwatch_actions.SnsAction(topic)) alarm.node.add_dependency(metrics_resource) reader_user = iam.User( scope, f"{logical_id_prefix}OutputReaderUser", user_name=iam_user_name[:64], ) reader_user.add_to_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", ], resources=[ f"arn:aws:s3:::{output_bucket_name}", f"arn:aws:s3:::{output_bucket_name}/*", ], ) ) if kms_key_arn: reader_user.add_to_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey", ], resources=[kms_key_arn], ) ) # Resource-based policy on the bucket (matches TaskRole grants; required for some # KMS-encrypted buckets and org policies that expect explicit bucket principals). output_bucket.add_to_resource_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, principals=[reader_user], actions=["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], resources=[f"{output_bucket.bucket_arn}/*"], ) ) output_bucket.add_to_resource_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, principals=[reader_user], actions=["s3:ListBucket"], resources=[output_bucket.bucket_arn], ) ) CfnOutput( scope, f"{logical_id_prefix}OutputNotifyTopicArn", value=topic.topic_arn, description="SNS topic for headless output-bucket PutRequests alarms", ) CfnOutput( scope, f"{logical_id_prefix}OutputReaderUserArn", value=reader_user.user_arn, description="IAM user for programmatic download of headless analysis outputs", ) CfnOutput( scope, f"{logical_id_prefix}OutputPutAlarmName", value=alarm.alarm_name, description="CloudWatch alarm on S3 PutRequests for new analysis outputs", ) return { "sns_topic_arn": topic.topic_arn, "iam_user_name": reader_user.user_name, "alarm_name": alarm.alarm_name, "metric_filter_id": metric_id, } def build_headless_app_defaults_env_content( seed_asset_directory: str, *, s3_outputs_bucket_name: str, ) -> str: """ Render general-config/app_defaults.env for headless batch jobs. Merges the static seed template with deployment-specific values (notably ``S3_OUTPUTS_BUCKET`` from the stack output bucket). The rendered file is uploaded to the log/config bucket so it is not deleted by the output bucket object-expiration lifecycle rule. """ template_path = os.path.join( seed_asset_directory, "general-config", "app_defaults.env" ) out_lines: List[str] = [] wrote_outputs_bucket = False if os.path.isfile(template_path): with open(template_path, encoding="utf-8") as handle: for raw_line in handle: line = raw_line.rstrip("\n") stripped = line.strip() if ( stripped and not stripped.startswith("#") and "=" in stripped and stripped.split("=", 1)[0].strip() == "S3_OUTPUTS_BUCKET" ): # Keep a single S3_OUTPUTS_BUCKET entry (templates may list it twice). if wrote_outputs_bucket: continue out_lines.append(f"S3_OUTPUTS_BUCKET={s3_outputs_bucket_name}") wrote_outputs_bucket = True else: out_lines.append(line) if not wrote_outputs_bucket: if out_lines and out_lines[-1].strip(): out_lines.append("") out_lines.append(f"S3_OUTPUTS_BUCKET={s3_outputs_bucket_name}") return "\n".join(out_lines).rstrip() + "\n" def create_headless_s3_batch_seed( scope: Construct, logical_id: str, *, output_bucket: s3.IBucket, log_bucket: s3.IBucket, seed_asset_directory: str, s3_outputs_bucket_name: str, default_params_key: str = "general-config/app_defaults.env", ) -> None: """ Seed headless batch S3 layout across two buckets: - Output bucket: ``input/`` markers and example job ``.env`` (short retention OK). - Log/config bucket: durable ``general-config/app_defaults.env`` (no object expiration), so defaults survive the output bucket lifecycle rule. """ from aws_cdk import aws_s3_deployment as s3deploy app_defaults_content = build_headless_app_defaults_env_content( seed_asset_directory, s3_outputs_bucket_name=s3_outputs_bucket_name, ) # Job inputs / example .env stay on the output bucket (triggered by Lambda). s3deploy.BucketDeployment( scope, f"{logical_id}Output", sources=[ s3deploy.Source.asset( seed_asset_directory, exclude=["general-config/**"], ), ], destination_bucket=output_bucket, prune=False, ) # Durable defaults on the log/config bucket (no object-expiration lifecycle). defaults_key = (default_params_key or "general-config/app_defaults.env").lstrip("/") s3deploy.BucketDeployment( scope, f"{logical_id}Defaults", sources=[ s3deploy.Source.data(defaults_key, app_defaults_content), ], destination_bucket=log_bucket, prune=False, ) def build_pi_agent_container_environment( *, service_connect_discovery_name: str, main_app_port: Union[str, int], pi_gradio_port: Union[str, int], pi_root_path: str = "", ) -> Dict[str, str]: """Inline env for Pi agent tasks (overrides image defaults; SC URL for main app).""" port = int(main_app_port) pi_port = int(pi_gradio_port) env = { "APP_TYPE": "pi", "APP_CONFIG_PATH": "/workspace/doc_summarisation/config/pi_agent.env", "PI_DEPLOYMENT_PROFILE": "aws-ecs", "PI_DEFAULT_PROVIDER": "amazon-bedrock", "DOC_SUMMARISATION_GRADIO_URL": f"http://{service_connect_discovery_name}:{port}", "PI_GRADIO_PORT": str(pi_port), "GRADIO_SERVER_PORT": str(pi_port), "GRADIO_SERVER_NAME": "0.0.0.0", "PI_WORKSPACE_DIR": "/home/user/app/workspace", "PI_WORKDIR": "/workspace/doc_summarisation", "PI_UPLOAD_ROOT": "/tmp/gradio", "PI_SESSION_DIR": "/tmp/pi-sessions", "PI_CODING_AGENT_DIR": "/tmp/pi-agent", "ACCESS_LOGS_FOLDER": "/tmp/pi-logs/", "USAGE_LOGS_FOLDER": "/tmp/pi-usage/", "FEEDBACK_LOGS_FOLDER": "/tmp/pi-feedback/", "RUN_FASTAPI": "True", "RUN_AWS_FUNCTIONS": "True", "SAVE_OUTPUTS_TO_S3": "True", "S3_OUTPUTS_BUCKET": S3_OUTPUT_BUCKET_NAME, "COGNITO_AUTH": "False", } _apply_pi_root_path_env(env, pi_root_path) return env # Gradio mounted on FastAPI (tools.gradio_platform.mount_or_launch); matches agent-redact/pi/start.sh. def _bedrock_source_arn(region: str, account: str) -> str: return f"arn:aws:bedrock:{region}:{account}:*" def grant_bedrock_kms_generate_data_key( key: kms.IKey, *, region: str, account: str, ) -> None: """Allow Bedrock to encrypt invocation-log objects on an SSE-KMS bucket.""" key.add_to_resource_policy( iam.PolicyStatement( sid="BedrockModelInvocationLogsKms", effect=iam.Effect.ALLOW, principals=[iam.ServicePrincipal("bedrock.amazonaws.com")], actions=["kms:GenerateDataKey*", "kms:DescribeKey"], resources=["*"], conditions={ "StringEquals": {"aws:SourceAccount": account}, "ArnLike": {"aws:SourceArn": _bedrock_source_arn(region, account)}, }, ) ) def add_bedrock_model_invocation_s3_bucket_policy( bucket: s3.IBucket, *, region: str, account: str, key_prefix: str = "bedrock-logs", ) -> None: """ Allow bedrock.amazonaws.com to PutObject invocation logs under the prefix. Path shape matches AWS docs: ``s3://{bucket}/{prefix}/AWSLogs/{account}/BedrockModelInvocationLogs/*`` Plus a slightly broader ``{prefix}/*`` grant so console validation and large binary/data objects under the same prefix succeed. """ prefix = (key_prefix or "bedrock-logs").strip().strip("/") source_arn = _bedrock_source_arn(region, account) common_conditions = { "StringEquals": {"aws:SourceAccount": account}, "ArnLike": {"aws:SourceArn": source_arn}, } # Exact AWS-documented path used by invocation logging delivery. bucket.add_to_resource_policy( iam.PolicyStatement( sid="AmazonBedrockLogsWrite", effect=iam.Effect.ALLOW, principals=[iam.ServicePrincipal("bedrock.amazonaws.com")], actions=["s3:PutObject"], resources=[ f"{bucket.bucket_arn}/{prefix}/AWSLogs/{account}/BedrockModelInvocationLogs/*" ], conditions=common_conditions, ) ) # Broader PutObject under the prefix (large payloads / console validation paths). bucket.add_to_resource_policy( iam.PolicyStatement( sid="AmazonBedrockLogsWritePrefix", effect=iam.Effect.ALLOW, principals=[iam.ServicePrincipal("bedrock.amazonaws.com")], actions=["s3:PutObject"], resources=[f"{bucket.bucket_arn}/{prefix}/*"], conditions=common_conditions, ) ) def create_bedrock_model_invocation_logging( scope: Construct, logical_id: str, *, log_bucket: s3.IBucket, region: str, account: str, key_prefix: str = "bedrock-logs", log_group_name: str, log_retention_days: int = 90, kms_key: Optional[kms.IKey] = None, text_enabled: bool = True, image_enabled: bool = True, embedding_enabled: bool = True, video_enabled: bool = True, ) -> Dict[str, Any]: """ Enable Bedrock account-level model invocation logging to S3 and CloudWatch. There is no CloudFormation L1 for PutModelInvocationLoggingConfiguration, so this uses AwsCustomResource. Requires bedrock.amazonaws.com PutObject on the log bucket and, when the bucket uses SSE-KMS, kms:GenerateDataKey on the key. """ prefix = (key_prefix or "bedrock-logs").strip().strip("/") add_bedrock_model_invocation_s3_bucket_policy( log_bucket, region=region, account=account, key_prefix=prefix, ) if kms_key is not None: grant_bedrock_kms_generate_data_key(kms_key, region=region, account=account) retention_by_days = { 1: logs.RetentionDays.ONE_DAY, 3: logs.RetentionDays.THREE_DAYS, 5: logs.RetentionDays.FIVE_DAYS, 7: logs.RetentionDays.ONE_WEEK, 14: logs.RetentionDays.TWO_WEEKS, 30: logs.RetentionDays.ONE_MONTH, 60: logs.RetentionDays.TWO_MONTHS, 90: logs.RetentionDays.THREE_MONTHS, 120: logs.RetentionDays.FOUR_MONTHS, 150: logs.RetentionDays.FIVE_MONTHS, 180: logs.RetentionDays.SIX_MONTHS, 365: logs.RetentionDays.ONE_YEAR, 400: logs.RetentionDays.THIRTEEN_MONTHS, 545: logs.RetentionDays.EIGHTEEN_MONTHS, 731: logs.RetentionDays.TWO_YEARS, 1096: logs.RetentionDays.THREE_YEARS, 1827: logs.RetentionDays.FIVE_YEARS, 2192: logs.RetentionDays.SIX_YEARS, 2557: logs.RetentionDays.SEVEN_YEARS, 2922: logs.RetentionDays.EIGHT_YEARS, 3288: logs.RetentionDays.NINE_YEARS, 3653: logs.RetentionDays.TEN_YEARS, } try: retention = retention_by_days.get( int(log_retention_days), logs.RetentionDays.THREE_MONTHS ) except (TypeError, ValueError): retention = logs.RetentionDays.THREE_MONTHS log_group_kwargs: Dict[str, Any] = { "log_group_name": log_group_name, "retention": retention, "removal_policy": RemovalPolicy.RETAIN, } if kms_key is not None: log_group_kwargs["encryption_key"] = kms_key # CloudWatch Logs needs decrypt/encrypt on the CMK for log-group encryption. kms_key.add_to_resource_policy( iam.PolicyStatement( sid="CloudWatchLogsBedrockInvocationGroup", effect=iam.Effect.ALLOW, principals=[iam.ServicePrincipal(f"logs.{region}.amazonaws.com")], actions=[ "kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*", ], resources=["*"], conditions={ "ArnLike": { "kms:EncryptionContext:aws:logs:arn": ( f"arn:aws:logs:{region}:{account}:log-group:{log_group_name}" ) } }, ) ) log_group = logs.LogGroup(scope, f"{logical_id}LogGroup", **log_group_kwargs) logging_role = iam.Role( scope, f"{logical_id}Role", assumed_by=iam.PrincipalWithConditions( iam.ServicePrincipal("bedrock.amazonaws.com"), { "StringEquals": {"aws:SourceAccount": account}, "ArnLike": {"aws:SourceArn": _bedrock_source_arn(region, account)}, }, ), description="Bedrock model invocation logging to CloudWatch Logs", ) stream_arn = ( f"arn:aws:logs:{region}:{account}:log-group:{log_group_name}" f":log-stream:aws/bedrock/modelinvocations" ) logging_role.add_to_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=["logs:CreateLogStream", "logs:PutLogEvents"], resources=[stream_arn, f"{log_group.log_group_arn}:*"], ) ) if kms_key is not None: logging_role.add_to_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*", "kms:DescribeKey", ], resources=[kms_key.key_arn], ) ) logging_config: Dict[str, Any] = { "cloudWatchConfig": { "logGroupName": log_group.log_group_name, "roleArn": logging_role.role_arn, "largeDataDeliveryS3Config": { "bucketName": log_bucket.bucket_name, "keyPrefix": prefix, }, }, "s3Config": { "bucketName": log_bucket.bucket_name, "keyPrefix": prefix, }, "textDataDeliveryEnabled": bool(text_enabled), "imageDataDeliveryEnabled": bool(image_enabled), "embeddingDataDeliveryEnabled": bool(embedding_enabled), "videoDataDeliveryEnabled": bool(video_enabled), } put_call = cr.AwsSdkCall( service="Bedrock", action="putModelInvocationLoggingConfiguration", parameters={"loggingConfig": logging_config}, physical_resource_id=cr.PhysicalResourceId.of( f"{logical_id}-ModelInvocationLogging" ), ) delete_call = cr.AwsSdkCall( service="Bedrock", action="deleteModelInvocationLoggingConfiguration", parameters={}, physical_resource_id=cr.PhysicalResourceId.of( f"{logical_id}-ModelInvocationLogging" ), ) custom = cr.AwsCustomResource( scope, f"{logical_id}Config", on_create=put_call, on_update=put_call, on_delete=delete_call, policy=cr.AwsCustomResourcePolicy.from_statements( [ iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "bedrock:PutModelInvocationLoggingConfiguration", "bedrock:DeleteModelInvocationLoggingConfiguration", "bedrock:GetModelInvocationLoggingConfiguration", ], resources=["*"], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=["iam:PassRole"], resources=[logging_role.role_arn], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "s3:GetBucketPolicy", "s3:PutBucketPolicy", "s3:GetBucketLocation", "s3:ListBucket", ], resources=[log_bucket.bucket_arn], ), ] ), timeout=Duration.minutes(2), install_latest_aws_sdk=True, ) custom.node.add_dependency(log_group) custom.node.add_dependency(logging_role) custom.node.add_dependency(log_bucket) return { "log_group": log_group, "logging_role": logging_role, "custom_resource": custom, "s3_key_prefix": prefix, } PI_ECS_APP_START_CMD = ( "python3 agent-redact/pi/pi_agent_config.py && " "exec uvicorn gradio_app:app --app-dir agent-redact/pi " "--host 0.0.0.0 --port ${PI_GRADIO_PORT:-7862} " '--proxy-headers --forwarded-allow-ips "*"' ) # Fargate volume mounts are root-owned; chown as root, then run the app as user (see entrypoint-ecs.sh). PI_ECS_CONTAINER_USER = "root" PI_ECS_CONTAINER_COMMAND = [ "/usr/local/bin/entrypoint-ecs.sh", PI_ECS_APP_START_CMD, ] # Inline fallback when the image predates entrypoint-ecs.sh (same behaviour via bash). PI_ECS_CONTAINER_COMMAND_FALLBACK = [ "bash", "-c", "mkdir -p /tmp/pi-agent /tmp/pi-logs /tmp/pi-usage /tmp/pi-feedback " "/home/user/app/workspace /tmp/gradio /tmp/pi-sessions && " "chown -R user:user /tmp/pi-agent /tmp/pi-logs /tmp/pi-usage /tmp/pi-feedback " "/home/user/app/workspace /tmp/gradio /tmp/pi-sessions && " "cd /workspace/doc_summarisation && " f"exec su -s /bin/bash user -c '{PI_ECS_APP_START_CMD}'", ] def create_pi_agent_ecs_resources( scope: Construct, logical_id_prefix: str, *, vpc: ec2.IVpc, cluster: ecs.ICluster, private_subnets: List[ec2.ISubnet], pi_ecr_image_uri: str, container_name: str, task_role: iam.IRole, execution_role: iam.IRole, config_bucket: s3.IBucket, pi_agent_env_s3_key: str, service_name: str, task_family: str, security_group_name: str, log_group_name: str, cpu: int, memory_mib: int, pi_gradio_port: int, service_connect_namespace: str, service_connect_discovery_name: str, main_app_port: int, use_fargate_spot: str, pi_root_path: str = "", ) -> Tuple[ecs.FargateService, ec2.SecurityGroup, ecs.FargateTaskDefinition]: """Second Fargate service for the Pi agent (joins Service Connect namespace as a client).""" pi_security_group = ec2.SecurityGroup( scope, f"{logical_id_prefix}SecurityGroup", vpc=vpc, security_group_name=security_group_name, description="Pi agent ECS tasks", ) pi_log_group = logs.LogGroup( scope, f"{logical_id_prefix}LogGroup", log_group_name=log_group_name, retention=logs.RetentionDays.ONE_MONTH, removal_policy=managed_resource_removal_policy(), ) pi_volume = ecs.Volume(name="piEphemeralVolume") pi_task_definition = ecs.FargateTaskDefinition( scope, f"{logical_id_prefix}TaskDefinition", family=task_family, cpu=cpu, memory_limit_mib=memory_mib, task_role=task_role, execution_role=execution_role, runtime_platform=ecs.RuntimePlatform( cpu_architecture=ecs.CpuArchitecture.X86_64, operating_system_family=ecs.OperatingSystemFamily.LINUX, ), ephemeral_storage_gib=21, volumes=[pi_volume], ) env_files: List[ecs.EnvironmentFile] = [] if pi_agent_env_s3_key: env_files.append( ecs.EnvironmentFile.from_bucket(config_bucket, pi_agent_env_s3_key) ) pi_container = pi_task_definition.add_container( container_name, image=ecs.ContainerImage.from_registry(f"{pi_ecr_image_uri}:latest"), logging=ecs.LogDriver.aws_logs( stream_prefix="ecs-pi", log_group=pi_log_group, ), environment_files=env_files if env_files else None, environment=build_pi_agent_container_environment( service_connect_discovery_name=service_connect_discovery_name, main_app_port=main_app_port, pi_gradio_port=pi_gradio_port, pi_root_path=pi_root_path, ), command=PI_ECS_CONTAINER_COMMAND_FALLBACK, user=PI_ECS_CONTAINER_USER, essential=True, ) pi_container.add_mount_points( ecs.MountPoint( source_volume=pi_volume.name, container_path="/home/user/app/workspace", read_only=False, ), ecs.MountPoint( source_volume=pi_volume.name, container_path="/tmp/gradio", read_only=False, ), ecs.MountPoint( source_volume=pi_volume.name, container_path="/tmp/pi-sessions", read_only=False, ), ) pi_container.add_port_mappings( ecs.PortMapping( container_port=pi_gradio_port, host_port=pi_gradio_port, name=f"port-{pi_gradio_port}", protocol=ecs.Protocol.TCP, app_protocol=ecs.AppProtocol.http, ) ) pi_service = ecs.FargateService( scope, f"{logical_id_prefix}Service", service_name=service_name, cluster=cluster, task_definition=pi_task_definition, security_groups=[pi_security_group], vpc_subnets=ec2.SubnetSelection(subnets=private_subnets), platform_version=ecs.FargatePlatformVersion.LATEST, capacity_provider_strategies=[ ecs.CapacityProviderStrategy( capacity_provider=use_fargate_spot, base=0, weight=1, ) ], min_healthy_percent=0, max_healthy_percent=100, desired_count=0, availability_zone_rebalancing=ecs_availability_zone_rebalancing( ECS_AVAILABILITY_ZONE_REBALANCING ), service_connect_configuration=ecs.ServiceConnectProps( namespace=service_connect_namespace, ), propagate_tags=ecs.PropagatedTagSource.SERVICE, ) return pi_service, pi_security_group, pi_task_definition def attach_pi_agent_to_shared_alb( scope: Construct, logical_id_prefix: str, *, vpc: ec2.IVpc, alb_security_group: ec2.ISecurityGroup, pi_security_group: ec2.SecurityGroup, pi_service: ecs.FargateService, pi_port: int, routing_mode: str, path_prefix: str, pi_host_header: str, listener_rule_priority: int, target_group_name: str, stickiness_cookie_duration: Duration, https_listener: Optional[elb.IApplicationListener], http_listener: Optional[elb.IApplicationListener], acm_certificate_arn: str, enable_cognito_auth: bool, cognito_user_pool: Optional[cognito.IUserPool], cognito_user_pool_client: Optional[cognito.IUserPoolClient], cognito_user_pool_domain: Optional[cognito.IUserPoolDomain], ) -> Tuple[elb.ApplicationTargetGroup, int]: """Register Pi on the shared legacy ALB (path and/or host-header listener rules).""" pi_security_group.add_ingress_rule( peer=alb_security_group, connection=ec2.Port.tcp(pi_port), description="Shared ALB to Pi agent", ) pi_target_group = elb.ApplicationTargetGroup( scope, f"{logical_id_prefix}TargetGroup", target_group_name=target_group_name, port=pi_port, protocol=elb.ApplicationProtocol.HTTP, targets=[pi_service], stickiness_cookie_duration=stickiness_cookie_duration, vpc=vpc, health_check=elb.HealthCheck( path=pi_alb_health_check_path(path_prefix, routing_mode), healthy_http_codes="200-399", ), ) if ( enable_cognito_auth and acm_certificate_arn and cognito_user_pool and cognito_user_pool_client and cognito_user_pool_domain and https_listener ): forward_action = elb_act.AuthenticateCognitoAction( next=elb.ListenerAction.forward( [pi_target_group], stickiness_duration=stickiness_cookie_duration, ), user_pool=cognito_user_pool, user_pool_client=cognito_user_pool_client, user_pool_domain=cognito_user_pool_domain, scope="openid profile email", on_unauthenticated_request=elb.UnauthenticatedAction.AUTHENTICATE, session_timeout=stickiness_cookie_duration, ) else: forward_action = elb.ListenerAction.forward( [pi_target_group], stickiness_duration=stickiness_cookie_duration, ) mode = normalize_pi_alb_routing_mode(routing_mode) priority = listener_rule_priority def _add_rules(listener: elb.IApplicationListener, id_prefix: str) -> None: nonlocal priority if mode in ("path", "both"): listener.add_action( f"{id_prefix}PathRule", priority=priority, conditions=[ elb.ListenerCondition.path_patterns( pi_alb_path_patterns(path_prefix) ) ], action=forward_action, ) priority += 1 if mode in ("host", "both") and pi_host_header.strip(): listener.add_action( f"{id_prefix}HostRule", priority=priority, conditions=[ elb.ListenerCondition.host_headers([pi_host_header.strip()]) ], action=forward_action, ) priority += 1 if https_listener: _add_rules(https_listener, f"{logical_id_prefix}Https") elif http_listener: _add_rules(http_listener, f"{logical_id_prefix}Http") if ( http_listener and acm_certificate_arn and pi_host_header.strip() and mode in ("host", "both") ): redirect_priority = listener_rule_priority if mode in ("path", "both"): redirect_priority += 1 http_listener.add_action( f"{logical_id_prefix}HttpRedirectRule", priority=redirect_priority, conditions=[elb.ListenerCondition.host_headers([pi_host_header.strip()])], action=elb.ListenerAction.redirect( protocol="HTTPS", port="443", host="#{host}", path="/#{path}", query="#{query}", ), ) return pi_target_group, priority def ensure_folder_exists(output_folder: str): """Checks if the specified folder exists, creates it if not.""" if not os.path.exists(output_folder): # Create the folder if it doesn't exist os.makedirs(output_folder, exist_ok=True) print(f"Created the {output_folder} folder.") else: print(f"The {output_folder} folder already exists.") # Re-export for app.py and other CDK entrypoints (implementation is boto3-only).