| """ |
| Post-deploy helpers (boto3 only). |
| |
| Use this module from post_cdk_build_quickstart.py so you do not need Node.js or |
| aws-cdk-lib installed to start CodeBuild / ECS after deployment. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import copy |
| import json |
| import os |
| import re |
| import time |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Union |
|
|
| import boto3 |
| from cdk_config import ( |
| AWS_REGION, |
| CLOUDFRONT_DOMAIN, |
| ) |
|
|
| _TASK_DEF_REGISTER_KEYS = ( |
| "family", |
| "taskRoleArn", |
| "executionRoleArn", |
| "networkMode", |
| "containerDefinitions", |
| "volumes", |
| "placementConstraints", |
| "requiresCompatibilities", |
| "cpu", |
| "memory", |
| "pidMode", |
| "ipcMode", |
| "proxyConfiguration", |
| "inferenceAccelerators", |
| "ephemeralStorage", |
| "runtimePlatform", |
| ) |
|
|
| _CONTAINER_REGISTER_OMIT_KEYS = frozenset( |
| { |
| "containerArn", |
| "taskDefinitionArn", |
| "status", |
| "lastStatus", |
| "managedAgents", |
| "networkInterfaces", |
| "healthStatus", |
| "cpu", |
| "memory", |
| "gpu", |
| } |
| ) |
|
|
|
|
| def start_codebuild_build( |
| project_name: str, aws_region: str = AWS_REGION |
| ) -> Optional[str]: |
| """Start an existing CodeBuild project build. Returns the build ID, or None on failure.""" |
| client = boto3.client("codebuild", region_name=aws_region) |
|
|
| try: |
| print(f"Attempting to start build for project: {project_name}") |
| response = client.start_build(projectName=project_name) |
| build_id = response["build"]["id"] |
| print(f"Successfully started build with ID: {build_id}") |
| print(f"Build ARN: {response['build']['arn']}") |
| print( |
| f"https://{aws_region}.console.aws.amazon.com/codesuite/codebuild/projects/" |
| f"{project_name}/build/{build_id.split(':')[-1]}/detail" |
| ) |
| return build_id |
| except client.exceptions.ResourceNotFoundException: |
| print(f"Error: Project '{project_name}' not found in region '{aws_region}'.") |
| except Exception as e: |
| print(f"An unexpected error occurred: {e}") |
| return None |
|
|
|
|
| _CODEBUILD_TERMINAL_STATUSES = frozenset( |
| {"SUCCEEDED", "FAILED", "FAULT", "STOPPED", "TIMED_OUT"} |
| ) |
|
|
|
|
| def wait_for_codebuild_build( |
| build_id: str, |
| *, |
| aws_region: str = AWS_REGION, |
| poll_interval_sec: int = 15, |
| timeout_sec: int = 3600, |
| ) -> bool: |
| """Poll a CodeBuild build until it finishes. Returns True if it succeeded.""" |
| client = boto3.client("codebuild", region_name=aws_region) |
| deadline = time.time() + timeout_sec |
| short_id = build_id.split(":")[-1] |
|
|
| while time.time() < deadline: |
| builds = client.batch_get_builds(ids=[build_id]).get("builds") or [] |
| if not builds: |
| print(f" CodeBuild build {short_id} not found.") |
| return False |
| status = builds[0]["buildStatus"] |
| print(f" CodeBuild {short_id}: {status}") |
| if status in _CODEBUILD_TERMINAL_STATUSES: |
| return status == "SUCCEEDED" |
| time.sleep(poll_interval_sec) |
|
|
| print(f" Timed out waiting for CodeBuild build {short_id}.") |
| return False |
|
|
|
|
| def get_latest_codebuild_build_id( |
| project_name: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> Optional[str]: |
| """Return the most recent build ID for a CodeBuild project, if any.""" |
| client = boto3.client("codebuild", region_name=aws_region) |
| try: |
| resp = client.list_builds_for_project( |
| projectName=project_name, |
| sortOrder="DESCENDING", |
| ) |
| ids = resp.get("ids") or [] |
| return ids[0] if ids else None |
| except client.exceptions.ResourceNotFoundException: |
| return None |
|
|
|
|
| def ecr_image_with_tag_exists( |
| repository_name: str, |
| tag: str = "latest", |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> bool: |
| """Return True when the ECR repository has an image with the given tag.""" |
| client = boto3.client("ecr", region_name=aws_region) |
| try: |
| resp = client.describe_images( |
| repositoryName=repository_name, |
| imageIds=[{"imageTag": tag}], |
| ) |
| return bool(resp.get("imageDetails")) |
| except client.exceptions.RepositoryNotFoundException: |
| return False |
| except Exception as exc: |
| if exc.__class__.__name__ == "ImageNotFoundException": |
| return False |
| raise |
|
|
|
|
| def wait_for_agentcore_ecr_image( |
| *, |
| repository_name: str, |
| codebuild_project: str, |
| build_id: Optional[str] = None, |
| aws_region: str = AWS_REGION, |
| timeout_sec: int = 3600, |
| ) -> bool: |
| """ |
| Wait for the AgentCore runtime image in ECR. |
| |
| If the image is not present yet, polls the given (or latest) CodeBuild build |
| until it succeeds, then re-checks ECR. |
| """ |
| if ecr_image_with_tag_exists(repository_name, aws_region=aws_region): |
| print(f" AgentCore ECR image already present in {repository_name}:latest") |
| return True |
|
|
| resolved_build_id = build_id or get_latest_codebuild_build_id( |
| codebuild_project, |
| aws_region=aws_region, |
| ) |
| if not resolved_build_id: |
| print( |
| f" No CodeBuild history for {codebuild_project}; " |
| "cannot wait for AgentCore image." |
| ) |
| return False |
|
|
| print(f" Waiting for AgentCore CodeBuild ({codebuild_project})...") |
| if not wait_for_codebuild_build( |
| resolved_build_id, |
| aws_region=aws_region, |
| timeout_sec=timeout_sec, |
| ): |
| return False |
|
|
| ready = ecr_image_with_tag_exists(repository_name, aws_region=aws_region) |
| if not ready: |
| print(f" CodeBuild finished but {repository_name}:latest is not in ECR yet.") |
| return ready |
|
|
|
|
| def resolve_agent_env_path(override: Optional[Path], base_dir: Path) -> Path: |
| """ |
| Resolve the agent config file, preferring ``agent.env`` over legacy ``pi_agent.env``. |
| |
| Returns *override* when provided. Otherwise searches ``<base_dir>/config`` first |
| (the CDK deploy location — ``cdk/config/agent.env``), then ``<base_dir>/../config`` |
| (repo-root ``config/`` for non-CDK layouts), preferring ``agent.env`` over the |
| legacy ``pi_agent.env`` in each. If none exist, returns the primary |
| ``<base_dir>/config/agent.env`` so callers can report/create it. |
| """ |
| if override is not None: |
| return override |
| search_dirs: List[Path] = [] |
| for candidate in (base_dir / "config", base_dir.parent / "config"): |
| if candidate not in search_dirs: |
| search_dirs.append(candidate) |
| for directory in search_dirs: |
| agent_env = directory / "agent.env" |
| if agent_env.is_file(): |
| return agent_env |
| for directory in search_dirs: |
| legacy = directory / "pi_agent.env" |
| if legacy.is_file(): |
| return legacy |
| return search_dirs[0] / "agent.env" |
|
|
|
|
| def upload_file_to_s3( |
| local_file_paths: Union[str, List[str]], |
| s3_key: str, |
| s3_bucket: str, |
| run_aws_functions: str = "1", |
| aws_region: str = AWS_REGION, |
| ) -> str: |
| """Upload local file(s) to S3.""" |
| final_out_message: List[str] = [] |
| final_out_message_str = "" |
|
|
| if run_aws_functions != "1": |
| return "App not set to run AWS functions" |
|
|
| try: |
| if not (s3_bucket and local_file_paths): |
| return "At least one essential variable is empty, could not upload to S3" |
|
|
| s3_client = boto3.client("s3", region_name=aws_region) |
| paths = ( |
| [local_file_paths] |
| if isinstance(local_file_paths, str) |
| else list(local_file_paths) |
| ) |
|
|
| for file_path in paths: |
| try: |
| file_name = os.path.basename(file_path) |
| s3_key_full = s3_key + file_name |
| print("S3 key: ", s3_key_full) |
| s3_client.upload_file(file_path, s3_bucket, s3_key_full) |
| out_message = f"File {file_name} uploaded successfully!" |
| print(out_message) |
| except Exception as e: |
| out_message = f"Error uploading file(s): {e}" |
| print(out_message) |
| final_out_message.append(out_message) |
|
|
| final_out_message_str = "\n".join(final_out_message) |
| except Exception as e: |
| final_out_message_str = "Could not upload files to S3 due to: " + str(e) |
| print(final_out_message_str) |
|
|
| return final_out_message_str |
|
|
|
|
| def start_ecs_task( |
| cluster_name: str, |
| service_name: str, |
| aws_region: str = AWS_REGION, |
| ) -> dict: |
| """Scale a legacy Fargate ECS service to one running task.""" |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
|
|
| try: |
| ecs_client.update_service( |
| cluster=cluster_name, service=service_name, desiredCount=1 |
| ) |
| return { |
| "statusCode": 200, |
| "body": ( |
| f"Service {service_name} in cluster {cluster_name} " |
| "has been updated to 1 task." |
| ), |
| } |
| except Exception as e: |
| return {"statusCode": 500, "body": f"Error updating service: {str(e)}"} |
|
|
|
|
| EXPRESS_GATEWAY_ACTIVE_SCALING_TARGET = { |
| "minTaskCount": 1, |
| "maxTaskCount": 1, |
| "autoScalingMetric": "AVERAGE_CPU", |
| "autoScalingTargetValue": 60, |
| } |
|
|
|
|
| def resolve_express_gateway_service_arn( |
| cluster_name: str, |
| service_name: str, |
| aws_region: str = AWS_REGION, |
| ) -> str: |
| """Look up an Express gateway service ARN by cluster and service name.""" |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| paginator = ecs_client.get_paginator("list_services") |
| for page in paginator.paginate(cluster=cluster_name): |
| for arn in page.get("serviceArns", []): |
| if arn.rstrip("/").split("/")[-1] == service_name: |
| return arn |
| raise ValueError( |
| f"Express gateway service '{service_name}' not found in cluster " |
| f"'{cluster_name}'." |
| ) |
|
|
|
|
| def _task_definition_has_port_name( |
| task_definition: Dict[str, Any], port_name: str |
| ) -> bool: |
| for container in task_definition.get("containerDefinitions", []): |
| for mapping in container.get("portMappings") or []: |
| if mapping.get("name") == port_name: |
| return True |
| return False |
|
|
|
|
| def _container_definitions_with_named_port( |
| container_definitions: List[Dict[str, Any]], |
| *, |
| port_name: str, |
| container_port: int, |
| ) -> List[Dict[str, Any]]: |
| updated: List[Dict[str, Any]] = [] |
| has_matching_port = any( |
| mapping.get("containerPort") == container_port |
| for container in container_definitions |
| for mapping in container.get("portMappings") or [] |
| ) |
| for index, container in enumerate(container_definitions): |
| container = { |
| key: value |
| for key, value in container.items() |
| if key not in _CONTAINER_REGISTER_OMIT_KEYS |
| } |
| port_mappings = [ |
| dict(mapping) for mapping in container.get("portMappings") or [] |
| ] |
| matched = False |
| for mapping in port_mappings: |
| if mapping.get("containerPort") == container_port: |
| matched = True |
| mapping["name"] = port_name |
| mapping.setdefault("protocol", "tcp") |
| if not matched and not has_matching_port and index == 0: |
| port_mappings.append( |
| { |
| "name": port_name, |
| "containerPort": container_port, |
| "protocol": "tcp", |
| } |
| ) |
| container["portMappings"] = port_mappings |
| updated.append(container) |
| return updated |
|
|
|
|
| def resolve_service_task_definition_arn( |
| cluster_name: str, |
| service_name: str, |
| aws_region: str = AWS_REGION, |
| ) -> str: |
| """ |
| Resolve the task definition ARN for a Fargate or Express gateway ECS service. |
| |
| Express gateway services omit ``taskDefinition`` on ``describe_services``; use the |
| active service revision from ``describe_express_gateway_service`` instead. |
| """ |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| services = ecs_client.describe_services( |
| cluster=cluster_name, services=[service_name] |
| ).get("services", []) |
| if services: |
| task_definition_arn = services[0].get("taskDefinition") |
| if task_definition_arn: |
| return task_definition_arn |
| service_arn = services[0].get("serviceArn") |
| else: |
| service_arn = None |
|
|
| if not service_arn: |
| service_arn = resolve_express_gateway_service_arn( |
| cluster_name, service_name, aws_region |
| ) |
|
|
| express = ecs_client.describe_express_gateway_service(serviceArn=service_arn) |
| active_configs = (express.get("service") or {}).get("activeConfigurations") or [] |
| if not active_configs: |
| raise ValueError( |
| f"Could not resolve task definition for service '{service_name}' in " |
| f"cluster '{cluster_name}' (no active Express gateway configuration)." |
| ) |
| revision_arn = active_configs[0].get("serviceRevisionArn") |
| if not revision_arn: |
| raise ValueError( |
| f"Could not resolve task definition for service '{service_name}' " |
| "(active Express configuration has no serviceRevisionArn)." |
| ) |
| revisions = ecs_client.describe_service_revisions( |
| serviceRevisionArns=[revision_arn] |
| ).get("serviceRevisions", []) |
| if not revisions: |
| raise ValueError( |
| f"Service revision '{revision_arn}' not found for service " |
| f"'{service_name}'." |
| ) |
| task_definition_arn = revisions[0].get("taskDefinition") |
| if not task_definition_arn: |
| raise ValueError( |
| f"Service revision '{revision_arn}' has no taskDefinition for service " |
| f"'{service_name}'." |
| ) |
| return task_definition_arn |
|
|
|
|
| def ensure_ecs_service_port_mapping_name( |
| cluster_name: str, |
| service_name: str, |
| port_name: str, |
| container_port: int, |
| aws_region: str = AWS_REGION, |
| ) -> str: |
| """ |
| Service Connect requires a named portMapping in the task definition. |
| Express gateway services only set containerPort at create time. |
| """ |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| task_definition_arn = resolve_service_task_definition_arn( |
| cluster_name, service_name, aws_region |
| ) |
| task_definition = ecs_client.describe_task_definition( |
| taskDefinition=task_definition_arn |
| )["taskDefinition"] |
| if _task_definition_has_port_name(task_definition, port_name): |
| return task_definition_arn |
|
|
| new_containers = _container_definitions_with_named_port( |
| task_definition["containerDefinitions"], |
| port_name=port_name, |
| container_port=container_port, |
| ) |
| register_kwargs = { |
| key: copy.deepcopy(task_definition[key]) |
| for key in _TASK_DEF_REGISTER_KEYS |
| if key in task_definition |
| } |
| register_kwargs["containerDefinitions"] = new_containers |
| if task_definition.get("tags"): |
| register_kwargs["tags"] = [ |
| {"key": tag["key"], "value": tag["value"]} |
| for tag in task_definition["tags"] |
| ] |
|
|
| new_task_definition = ecs_client.register_task_definition(**register_kwargs)[ |
| "taskDefinition" |
| ] |
| new_arn = new_task_definition["taskDefinitionArn"] |
| ecs_client.update_service( |
| cluster=cluster_name, |
| service=service_name, |
| taskDefinition=new_arn, |
| forceNewDeployment=True, |
| ) |
| print( |
| f"Registered task definition {new_arn} with Service Connect port " |
| f"name {port_name!r} on container port {container_port}." |
| ) |
| return new_arn |
|
|
|
|
| def apply_ecs_service_connect( |
| cluster_name: str, |
| service_name: str, |
| service_connect_configuration: Dict[str, Any], |
| aws_region: str = AWS_REGION, |
| ) -> None: |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| ecs_client.update_service( |
| cluster=cluster_name, |
| service=service_name, |
| serviceConnectConfiguration=service_connect_configuration, |
| forceNewDeployment=True, |
| ) |
| print(f"Applied Service Connect to {service_name} in cluster {cluster_name}.") |
|
|
|
|
| def configure_express_pi_service_connect( |
| cluster_name: str, |
| main_service_name: str, |
| pi_service_name: str, |
| namespace: str, |
| main_port_name: str, |
| discovery_name: str, |
| main_port: int, |
| aws_region: str = AWS_REGION, |
| ) -> None: |
| """Enable Service Connect for Pi Express -> main Express (post image build).""" |
| ensure_ecs_service_port_mapping_name( |
| cluster_name, |
| main_service_name, |
| main_port_name, |
| main_port, |
| aws_region=aws_region, |
| ) |
| apply_ecs_service_connect( |
| cluster_name, |
| main_service_name, |
| { |
| "enabled": True, |
| "namespace": namespace, |
| "services": [ |
| { |
| "portName": main_port_name, |
| "discoveryName": discovery_name, |
| "clientAliases": [ |
| {"port": int(main_port), "dnsName": discovery_name} |
| ], |
| } |
| ], |
| }, |
| aws_region=aws_region, |
| ) |
| apply_ecs_service_connect( |
| cluster_name, |
| pi_service_name, |
| {"enabled": True, "namespace": namespace}, |
| aws_region=aws_region, |
| ) |
|
|
|
|
| def start_express_gateway_service( |
| cluster_name: str, |
| service_name: str, |
| aws_region: str = AWS_REGION, |
| ) -> dict: |
| """Scale an ECS Express gateway service to one running task after image build.""" |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
|
|
| try: |
| service_arn = resolve_express_gateway_service_arn( |
| cluster_name, service_name, aws_region=aws_region |
| ) |
| ecs_client.update_express_gateway_service( |
| serviceArn=service_arn, |
| scalingTarget=EXPRESS_GATEWAY_ACTIVE_SCALING_TARGET, |
| ) |
| return { |
| "statusCode": 200, |
| "body": ( |
| f"Express service {service_name} in cluster {cluster_name} " |
| "has been updated to run 1 task." |
| ), |
| } |
| except Exception as e: |
| return { |
| "statusCode": 500, |
| "body": f"Error updating Express gateway service: {str(e)}", |
| } |
|
|
|
|
| _ALB_COGNITO_CALLBACK_SUFFIX = "/oauth2/idpresponse" |
|
|
| |
| _USER_POOL_CLIENT_UPDATE_PASSTHROUGH_KEYS = ( |
| "ClientName", |
| "RefreshTokenValidity", |
| "AccessTokenValidity", |
| "IdTokenValidity", |
| "TokenValidityUnits", |
| "ReadAttributes", |
| "WriteAttributes", |
| "ExplicitAuthFlows", |
| "SupportedIdentityProviders", |
| "DefaultRedirectURI", |
| "AllowedOAuthFlows", |
| "AllowedOAuthScopes", |
| "AllowedOAuthFlowsUserPoolClient", |
| "AnalyticsConfiguration", |
| "PreventUserExistenceErrors", |
| "EnableTokenRevocation", |
| "EnablePropagateAdditionalUserContextData", |
| "AuthSessionValidity", |
| "RefreshTokenRotation", |
| ) |
|
|
|
|
| def cognito_https_callback_urls(redirect_base: str) -> List[str]: |
| """ |
| ALB authenticate-cognito requires the app URL and ``/oauth2/idpresponse``. |
| """ |
| base = (redirect_base or "").strip().rstrip("/") |
| if not base: |
| raise ValueError("redirect_base is required for Cognito callback URLs") |
| if not base.startswith("https://"): |
| base = f"https://{base.lstrip('/')}" |
| return [base, f"{base}{_ALB_COGNITO_CALLBACK_SUFFIX}"] |
|
|
|
|
| def cognito_callback_urls_match( |
| existing_callbacks: List[str], |
| desired_callbacks: List[str], |
| ) -> bool: |
| return set(existing_callbacks or []) == set(desired_callbacks) |
|
|
|
|
| def get_user_pool_client_callback_urls( |
| user_pool_id: str, |
| client_id: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> List[str]: |
| cognito_client = boto3.client("cognito-idp", region_name=aws_region) |
| existing = cognito_client.describe_user_pool_client( |
| UserPoolId=user_pool_id, |
| ClientId=client_id, |
| )["UserPoolClient"] |
| return list(existing.get("CallbackURLs") or []) |
|
|
|
|
| def cognito_alb_callbacks_need_update( |
| user_pool_id: str, |
| client_id: str, |
| redirect_base: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> bool: |
| desired = cognito_https_callback_urls(redirect_base) |
| current = get_user_pool_client_callback_urls( |
| user_pool_id, client_id, aws_region=aws_region |
| ) |
| return not cognito_callback_urls_match(current, desired) |
|
|
|
|
| def update_user_pool_client_callback_urls( |
| user_pool_id: str, |
| client_id: str, |
| callback_urls: List[str], |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> None: |
| """ |
| Set Cognito app client callback URLs without a CDK redeploy. |
| |
| Merges existing client settings from ``describe_user_pool_client`` so OAuth |
| flows/scopes and token validity are not reset. |
| """ |
| cognito_client = boto3.client("cognito-idp", region_name=aws_region) |
| existing = cognito_client.describe_user_pool_client( |
| UserPoolId=user_pool_id, |
| ClientId=client_id, |
| )["UserPoolClient"] |
|
|
| update_kwargs: Dict[str, Any] = { |
| "UserPoolId": user_pool_id, |
| "ClientId": client_id, |
| "CallbackURLs": callback_urls, |
| } |
| for key in _USER_POOL_CLIENT_UPDATE_PASSTHROUGH_KEYS: |
| value = existing.get(key) |
| if value is not None: |
| update_kwargs[key] = value |
| logout_urls = existing.get("LogoutURLs") |
| if logout_urls: |
| update_kwargs["LogoutURLs"] = logout_urls |
|
|
| cognito_client.update_user_pool_client(**update_kwargs) |
| print("Updated Cognito app client callback URLs: " + ", ".join(callback_urls)) |
|
|
|
|
| def apply_cognito_alb_callback_fixup( |
| *, |
| user_pool_id: str, |
| client_id: str, |
| redirect_base: str, |
| aws_region: str = AWS_REGION, |
| ) -> bool: |
| """ |
| Update Cognito callbacks when they differ from ``redirect_base``. |
| |
| Returns True if URLs were updated, False if already correct. |
| """ |
| desired = cognito_https_callback_urls(redirect_base) |
| cognito_client = boto3.client("cognito-idp", region_name=aws_region) |
| existing = cognito_client.describe_user_pool_client( |
| UserPoolId=user_pool_id, |
| ClientId=client_id, |
| )["UserPoolClient"] |
| current = existing.get("CallbackURLs") or [] |
| if cognito_callback_urls_match(current, desired): |
| print("Cognito app client callback URLs already match the target endpoint.") |
| return False |
| update_user_pool_client_callback_urls( |
| user_pool_id, |
| client_id, |
| desired, |
| aws_region=aws_region, |
| ) |
| return True |
|
|
|
|
| _TARGET_GROUP_REGISTER_EVENT = re.compile( |
| r"target-group (arn:aws:elasticloadbalancing:[^\s)]+)", |
| re.IGNORECASE, |
| ) |
|
|
| EXPRESS_TARGET_GROUP_RESOLVE_MAX_WAIT_SECONDS = 600 |
| EXPRESS_TARGET_GROUP_RESOLVE_POLL_INTERVAL_SECONDS = 15 |
|
|
|
|
| def target_group_arn_from_ecs_register_event(message: str) -> Optional[str]: |
| """Parse target group ARN from ECS ``registered N targets in (target-group ...)``.""" |
| if "registered" not in (message or "").lower(): |
| return None |
| match = _TARGET_GROUP_REGISTER_EVENT.search(message) |
| return match.group(1) if match else None |
|
|
|
|
| def _target_group_arn_from_service_events( |
| events: List[Dict[str, Any]], |
| ) -> Optional[str]: |
| for event in events: |
| target_group_arn = target_group_arn_from_ecs_register_event( |
| event.get("message", "") |
| ) |
| if target_group_arn: |
| return target_group_arn |
| return None |
|
|
|
|
| def _task_private_ipv4_addresses( |
| ecs_client: Any, |
| cluster_name: str, |
| service_name: str, |
| ) -> set[str]: |
| task_arns = ecs_client.list_tasks( |
| cluster=cluster_name, serviceName=service_name |
| ).get("taskArns", []) |
| if not task_arns: |
| return set() |
| tasks = ecs_client.describe_tasks(cluster=cluster_name, tasks=task_arns).get( |
| "tasks", [] |
| ) |
| addresses: set[str] = set() |
| for task in tasks: |
| for attachment in task.get("attachments", []): |
| for detail in attachment.get("details", []): |
| if detail.get("name") == "privateIPv4Address" and detail.get("value"): |
| addresses.add(detail["value"]) |
| return addresses |
|
|
|
|
| def _target_group_arn_from_task_ips( |
| elbv2_client: Any, |
| load_balancer_arn: str, |
| task_ips: set[str], |
| ) -> Optional[str]: |
| if not task_ips: |
| return None |
| target_groups = elbv2_client.describe_target_groups( |
| LoadBalancerArn=load_balancer_arn |
| ).get("TargetGroups", []) |
| for target_group in target_groups: |
| health = elbv2_client.describe_target_health( |
| TargetGroupArn=target_group["TargetGroupArn"] |
| ).get("TargetHealthDescriptions", []) |
| for description in health: |
| target_id = (description.get("Target") or {}).get("Id") |
| if target_id in task_ips: |
| return target_group["TargetGroupArn"] |
| return None |
|
|
|
|
| def resolve_express_service_target_group_arn( |
| cluster_name: str, |
| service_name: str, |
| *, |
| aws_region: str = AWS_REGION, |
| load_balancer_arn: Optional[str] = None, |
| max_wait_seconds: int = EXPRESS_TARGET_GROUP_RESOLVE_MAX_WAIT_SECONDS, |
| poll_interval_seconds: int = EXPRESS_TARGET_GROUP_RESOLVE_POLL_INTERVAL_SECONDS, |
| ) -> str: |
| """ |
| Target group where Express most recently registered tasks. |
| |
| After post-deploy scaling, this ARN can differ from the TG baked into the CDK |
| Cognito listener custom resource at deploy time. Polls until a registration |
| event appears or running tasks are visible in an Express ALB target group. |
| """ |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| elbv2_client = ( |
| boto3.client("elbv2", region_name=aws_region) if load_balancer_arn else None |
| ) |
| deadline = time.monotonic() + max_wait_seconds |
| attempt = 0 |
| while True: |
| attempt += 1 |
| services = ecs_client.describe_services( |
| cluster=cluster_name, services=[service_name] |
| ).get("services", []) |
| if not services: |
| raise ValueError( |
| f"ECS service '{service_name}' not found in cluster '{cluster_name}'." |
| ) |
| service = services[0] |
| target_group_arn = _target_group_arn_from_service_events( |
| service.get("events", []) |
| ) |
| if target_group_arn: |
| if attempt > 1: |
| print( |
| f"Resolved target group for '{service_name}' " |
| f"after {attempt} poll(s)." |
| ) |
| return target_group_arn |
|
|
| if elbv2_client and load_balancer_arn: |
| task_ips = _task_private_ipv4_addresses( |
| ecs_client, cluster_name, service_name |
| ) |
| target_group_arn = _target_group_arn_from_task_ips( |
| elbv2_client, load_balancer_arn, task_ips |
| ) |
| if target_group_arn: |
| print( |
| f"Resolved target group for '{service_name}' from running task IPs." |
| ) |
| return target_group_arn |
|
|
| if time.monotonic() >= deadline: |
| running = service.get("runningCount", 0) |
| desired = service.get("desiredCount", 0) |
| raise ValueError( |
| f"No target group registration event found for service " |
| f"'{service_name}' after {max_wait_seconds}s " |
| f"(running {running}/{desired} tasks). " |
| "Ensure CodeBuild finished and the service scaled to at least one task." |
| ) |
|
|
| if attempt == 1: |
| print( |
| f"Waiting for target group registration for '{service_name}' " |
| f"(up to {max_wait_seconds}s)..." |
| ) |
| time.sleep(poll_interval_seconds) |
|
|
|
|
| def find_express_gateway_https_listener( |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> Dict[str, str]: |
| """Return Express-managed ALB HTTPS listener metadata.""" |
| elbv2 = boto3.client("elbv2", region_name=aws_region) |
| for load_balancer in elbv2.describe_load_balancers().get("LoadBalancers", []): |
| if not load_balancer["LoadBalancerName"].startswith("ecs-express-gateway-alb"): |
| continue |
| listeners = elbv2.describe_listeners( |
| LoadBalancerArn=load_balancer["LoadBalancerArn"] |
| ).get("Listeners", []) |
| https_listener = next( |
| (listener for listener in listeners if listener.get("Port") == 443), |
| None, |
| ) |
| if https_listener: |
| return { |
| "load_balancer_arn": load_balancer["LoadBalancerArn"], |
| "listener_arn": https_listener["ListenerArn"], |
| "dns_name": load_balancer["DNSName"], |
| } |
| raise ValueError( |
| "Express gateway ALB (ecs-express-gateway-alb-*) with HTTPS listener not found." |
| ) |
|
|
|
|
| def listener_actions_with_target_group( |
| existing_actions: List[Dict[str, Any]], |
| target_group_arn: str, |
| ) -> List[Dict[str, Any]]: |
| """Copy listener/rule actions, replacing the forward target group ARN.""" |
| updated_actions: List[Dict[str, Any]] = [] |
| for action in sorted(existing_actions, key=lambda item: item.get("Order", 0)): |
| action_copy = copy.deepcopy(action) |
| if action_copy.get("Type") == "forward": |
| action_copy["TargetGroupArn"] = target_group_arn |
| forward_config = action_copy.setdefault("ForwardConfig", {}) |
| forward_config["TargetGroups"] = [ |
| {"TargetGroupArn": target_group_arn, "Weight": 1} |
| ] |
| updated_actions.append(action_copy) |
| return updated_actions |
|
|
|
|
| def apply_express_alb_listener_target_group_fixup( |
| *, |
| cluster_name: str, |
| main_service_name: str, |
| pi_service_name: Optional[str] = None, |
| pi_path_prefixes: Optional[List[str]] = None, |
| aws_region: str = AWS_REGION, |
| ) -> bool: |
| """ |
| Point ALB Cognito listener actions at the target groups Express tasks use. |
| |
| Express creates fresh target groups when a service scales up after deploy; the |
| CDK custom resource may still forward authenticated traffic to an empty TG. |
| """ |
| ingress = find_express_gateway_https_listener(aws_region=aws_region) |
| load_balancer_arn = ingress["load_balancer_arn"] |
| main_target_group_arn = resolve_express_service_target_group_arn( |
| cluster_name, |
| main_service_name, |
| aws_region=aws_region, |
| load_balancer_arn=load_balancer_arn, |
| ) |
| pi_target_group_arn = None |
| if pi_service_name: |
| try: |
| pi_target_group_arn = resolve_express_service_target_group_arn( |
| cluster_name, |
| pi_service_name, |
| aws_region=aws_region, |
| load_balancer_arn=load_balancer_arn, |
| ) |
| except ValueError as exc: |
| print(f"Note: skipping Pi listener rule TG fixup: {exc}") |
|
|
| elbv2 = boto3.client("elbv2", region_name=aws_region) |
| listener_arn = ingress["listener_arn"] |
| listener = elbv2.describe_listeners(ListenerArns=[listener_arn])["Listeners"][0] |
| current_default = listener.get("DefaultActions", []) |
| current_forward_arn = next( |
| ( |
| action.get("TargetGroupArn") |
| for action in current_default |
| if action.get("Type") == "forward" |
| ), |
| None, |
| ) |
| changed = current_forward_arn != main_target_group_arn |
|
|
| if changed: |
| elbv2.modify_listener( |
| ListenerArn=listener_arn, |
| DefaultActions=listener_actions_with_target_group( |
| current_default, main_target_group_arn |
| ), |
| ) |
| print( |
| "Updated Express ALB default listener forward target group to " |
| f"{main_target_group_arn}." |
| ) |
| else: |
| print( |
| "Express ALB default listener already forwards to the active target group." |
| ) |
|
|
| if pi_target_group_arn and pi_path_prefixes: |
| rules = elbv2.describe_rules(ListenerArn=listener_arn).get("Rules", []) |
| prefixes = {prefix.rstrip("/") for prefix in pi_path_prefixes} |
| for rule in rules: |
| if rule.get("IsDefault"): |
| continue |
| path_values = [] |
| for condition in rule.get("Conditions", []): |
| if condition.get("Field") == "path-pattern": |
| path_values.extend(condition.get("Values", [])) |
| if not prefixes.intersection({value.rstrip("/") for value in path_values}): |
| continue |
| current_actions = rule.get("Actions", []) |
| current_pi_forward = next( |
| ( |
| action.get("TargetGroupArn") |
| for action in current_actions |
| if action.get("Type") == "forward" |
| ), |
| None, |
| ) |
| if current_pi_forward == pi_target_group_arn: |
| continue |
| elbv2.modify_rule( |
| RuleArn=rule["RuleArn"], |
| Actions=listener_actions_with_target_group( |
| current_actions, pi_target_group_arn |
| ), |
| ) |
| print( |
| "Updated Pi ALB listener rule forward target group to " |
| f"{pi_target_group_arn}." |
| ) |
| changed = True |
|
|
| return changed |
|
|
|
|
| def listener_rule_has_cognito_auth(actions: List[Dict[str, Any]]) -> bool: |
| return any(action.get("Type") == "authenticate-cognito" for action in actions) |
|
|
|
|
| def _listener_rule_is_cloudfront_bypass_without_cognito( |
| rule: Dict[str, Any], |
| *, |
| cloudfront_host_header: str, |
| ) -> bool: |
| """True for legacy forward-only host-header rules matching the CloudFront domain.""" |
| if listener_rule_has_cognito_auth(rule.get("Actions", [])): |
| return False |
| host = (cloudfront_host_header or "").strip() |
| if not host or host == "cloudfront_placeholder.net": |
| return False |
| for condition in rule.get("Conditions") or []: |
| if condition.get("Field") != "host-header": |
| continue |
| values = (condition.get("HostHeaderConfig") or {}).get("Values") or [] |
| if host in values: |
| return True |
| return False |
|
|
|
|
| def remove_express_listener_rules_without_cognito( |
| *, |
| aws_region: str = AWS_REGION, |
| cloudfront_host_header: Optional[str] = None, |
| ) -> bool: |
| """ |
| Delete legacy Express ALB rules that forward CloudFront host traffic without Cognito. |
| |
| Only removes host-header rules whose value matches ``CLOUDFRONT_DOMAIN`` (or the |
| supplied override). ECS Express-managed ``*.ecs.*.on.aws`` host rules are left |
| intact. |
| """ |
| cloudfront_host = (cloudfront_host_header or CLOUDFRONT_DOMAIN or "").strip() |
| ingress = find_express_gateway_https_listener(aws_region=aws_region) |
| elbv2 = boto3.client("elbv2", region_name=aws_region) |
| listener_arn = ingress["listener_arn"] |
| changed = False |
| for rule in elbv2.describe_rules(ListenerArn=listener_arn).get("Rules", []): |
| if rule.get("IsDefault"): |
| continue |
| if not _listener_rule_is_cloudfront_bypass_without_cognito( |
| rule, cloudfront_host_header=cloudfront_host |
| ): |
| continue |
| rule_arn = rule["RuleArn"] |
| elbv2.delete_rule(RuleArn=rule_arn) |
| print( |
| "Removed legacy Express CloudFront bypass ALB listener rule " |
| f"(host {cloudfront_host!r}): {rule_arn}" |
| ) |
| changed = True |
| return changed |
|
|
|
|
| def build_cognito_secret_payload( |
| user_pool_id: str, |
| client_id: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> Dict[str, str]: |
| """Build Secrets Manager JSON for REDACTION_* Cognito keys.""" |
| cognito_client = boto3.client("cognito-idp", region_name=aws_region) |
| client = cognito_client.describe_user_pool_client( |
| UserPoolId=user_pool_id, |
| ClientId=client_id, |
| )["UserPoolClient"] |
| client_secret = client.get("ClientSecret") or "" |
| return { |
| "REDACTION_USER_POOL_ID": user_pool_id, |
| "REDACTION_CLIENT_ID": client_id, |
| "REDACTION_CLIENT_SECRET": client_secret, |
| } |
|
|
|
|
| def cognito_secret_payload_matches( |
| existing_secret_string: str, |
| desired_payload: Dict[str, str], |
| ) -> bool: |
| try: |
| current = json.loads(existing_secret_string or "{}") |
| except json.JSONDecodeError: |
| return False |
| return all(current.get(key) == value for key, value in desired_payload.items()) |
|
|
|
|
| def apply_cognito_secret_fixup( |
| *, |
| secret_name: str, |
| user_pool_id: str, |
| client_id: str, |
| aws_region: str = AWS_REGION, |
| recycle_express_service: Optional[Dict[str, str]] = None, |
| ) -> bool: |
| """ |
| Sync imported Secrets Manager JSON with the stack's Cognito pool and app client. |
| |
| Express tasks read ``AWS_USER_POOL_ID`` / ``AWS_CLIENT_*`` from this secret. |
| When the secret predates a redeploy, values can reference a deleted user pool. |
| """ |
| desired_payload = build_cognito_secret_payload( |
| user_pool_id, client_id, aws_region=aws_region |
| ) |
| secrets_client = boto3.client("secretsmanager", region_name=aws_region) |
| current = secrets_client.get_secret_value(SecretId=secret_name) |
| current_string = current.get("SecretString") or "" |
| if cognito_secret_payload_matches(current_string, desired_payload): |
| print( |
| "Cognito secret already matches Cognito user pool ID " |
| "and Cognito app client ID." |
| ) |
| return False |
|
|
| secrets_client.put_secret_value( |
| SecretId=secret_name, |
| SecretString=json.dumps(desired_payload), |
| ) |
| print( |
| "Updated Cognito secret for Cognito user pool ID " "and Cognito app client ID." |
| ) |
| if recycle_express_service: |
| recycle_express_gateway_tasks( |
| recycle_express_service["cluster_name"], |
| recycle_express_service["service_name"], |
| aws_region=aws_region, |
| ) |
| return True |
|
|
|
|
| def recycle_express_gateway_tasks( |
| cluster_name: str, |
| service_name: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> None: |
| """Stop running Express tasks so replacements pick up updated secrets/env.""" |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| task_arns = ecs_client.list_tasks( |
| cluster=cluster_name, |
| serviceName=service_name, |
| ).get("taskArns", []) |
| for task_arn in task_arns: |
| ecs_client.stop_task( |
| cluster=cluster_name, |
| task=task_arn, |
| reason="Recycle task after Cognito secret/config sync", |
| ) |
| if task_arns: |
| print( |
| f"Stopped {len(task_arns)} task(s) for {service_name} to pick up Cognito updates." |
| ) |
|
|
|
|
| def apply_express_disable_in_app_cognito_auth( |
| cluster_name: str, |
| service_name: str, |
| *, |
| aws_region: str = AWS_REGION, |
| ) -> bool: |
| """ |
| Set ``COGNITO_AUTH=False`` on a running Express service revision. |
| |
| ALB ``authenticate-cognito`` already gates access; in-app Gradio login is redundant |
| and fails when Secrets Manager still references an old user pool. |
| """ |
| ecs_client = boto3.client("ecs", region_name=aws_region) |
| service_arn = resolve_express_gateway_service_arn( |
| cluster_name, service_name, aws_region=aws_region |
| ) |
| express = ecs_client.describe_express_gateway_service(serviceArn=service_arn)[ |
| "service" |
| ] |
| active_configs = express.get("activeConfigurations") or [] |
| if not active_configs: |
| raise ValueError( |
| f"No active configuration for Express service '{service_name}'." |
| ) |
| active = active_configs[0] |
| primary = copy.deepcopy(active.get("primaryContainer") or {}) |
| environment = { |
| item["name"]: item["value"] |
| for item in primary.get("environment") or [] |
| if item.get("name") |
| } |
| if environment.get("COGNITO_AUTH") == "False": |
| print(f"{service_name} already has COGNITO_AUTH=False.") |
| return False |
| environment["COGNITO_AUTH"] = "False" |
| primary["environment"] = [ |
| {"name": name, "value": value} for name, value in sorted(environment.items()) |
| ] |
| update_kwargs: Dict[str, Any] = { |
| "serviceArn": service_arn, |
| "primaryContainer": primary, |
| } |
| for key in ( |
| "executionRoleArn", |
| "taskRoleArn", |
| "cpu", |
| "memory", |
| "healthCheckPath", |
| "networkConfiguration", |
| ): |
| value = active.get(key) |
| if value is not None: |
| update_kwargs[key] = value |
| scaling = express.get("scalingTarget") or active.get("scalingTarget") |
| if scaling is not None: |
| update_kwargs["scalingTarget"] = scaling |
| ecs_client.update_express_gateway_service(**update_kwargs) |
| print(f"Set COGNITO_AUTH=False on Express service {service_name}.") |
| return True |
|
|
|
|
| def apply_cognito_secret_fixup_from_stack( |
| *, |
| stack_name: str, |
| secret_name: str, |
| cluster_name: str, |
| main_service_name: str, |
| aws_region: str = AWS_REGION, |
| recycle_tasks: bool = True, |
| ) -> bool: |
| """Read Cognito outputs from CloudFormation and sync the app client secret.""" |
| cfn_client = boto3.client("cloudformation", region_name=aws_region) |
| stacks = cfn_client.describe_stacks(StackName=stack_name).get("Stacks", []) |
| outputs = { |
| item["OutputKey"]: item["OutputValue"] |
| for item in (stacks[0].get("Outputs") or []) |
| } |
| user_pool_id = outputs.get("CognitoPoolId") |
| client_id = outputs.get("CognitoAppClientId") |
| if not user_pool_id or not client_id: |
| raise ValueError( |
| f"Stack '{stack_name}' is missing CognitoPoolId or CognitoAppClientId outputs." |
| ) |
| return apply_cognito_secret_fixup( |
| secret_name=secret_name, |
| user_pool_id=user_pool_id, |
| client_id=client_id, |
| aws_region=aws_region, |
| recycle_express_service=( |
| {"cluster_name": cluster_name, "service_name": main_service_name} |
| if recycle_tasks |
| else None |
| ), |
| ) |
|
|
|
|
| def get_stack_output( |
| stack_name: str, |
| output_key: str, |
| region: str, |
| ) -> Optional[str]: |
| """Return a CloudFormation stack output value, or None if missing.""" |
| from botocore.exceptions import ClientError |
|
|
| cfn = boto3.client("cloudformation", region_name=region) |
| try: |
| response = cfn.describe_stacks(StackName=stack_name) |
| except ClientError: |
| return None |
| for stack in response.get("Stacks", []): |
| for output in stack.get("Outputs", []): |
| if output.get("OutputKey") == output_key: |
| return output.get("OutputValue") |
| return None |
|
|
|
|
| def resolve_cloudfront_origin_prefix_list_id( |
| region: str, |
| fallback: Optional[str] = None, |
| ) -> Optional[str]: |
| """Resolve the region's CloudFront origin-facing managed prefix list id. |
| |
| The managed prefix list ``com.amazonaws.global.cloudfront.origin-facing`` has a |
| different id per region, so it is resolved dynamically. Falls back to the supplied |
| value (e.g. ``CLOUDFRONT_PREFIX_LIST_ID``) if the lookup fails. |
| """ |
| ec2 = boto3.client("ec2", region_name=region) |
| try: |
| response = ec2.describe_managed_prefix_lists( |
| Filters=[ |
| { |
| "Name": "prefix-list-name", |
| "Values": ["com.amazonaws.global.cloudfront.origin-facing"], |
| } |
| ] |
| ) |
| for prefix_list in response.get("PrefixLists", []): |
| pl_id = prefix_list.get("PrefixListId") |
| if pl_id: |
| return pl_id |
| except Exception as exc: |
| print(f" Warning: could not resolve CloudFront prefix list: {exc}") |
| return fallback |
|
|
|
|
| |
| _SG_RULES_QUOTA_SERVICE_CODE = "vpc" |
| _SG_RULES_QUOTA_CODE = "L-0EA8095F" |
|
|
|
|
| def _get_managed_prefix_list_max_entries( |
| ec2_client: Any, |
| prefix_list_id: str, |
| ) -> Optional[int]: |
| """MaxEntries for a managed prefix list (the rule-slot cost of referencing it).""" |
| try: |
| response = ec2_client.describe_managed_prefix_lists( |
| PrefixListIds=[prefix_list_id] |
| ) |
| for prefix_list in response.get("PrefixLists", []): |
| max_entries = prefix_list.get("MaxEntries") |
| if max_entries is not None: |
| return int(max_entries) |
| except Exception as exc: |
| print(f" Warning: could not read prefix list {prefix_list_id} size: {exc}") |
| return None |
|
|
|
|
| def get_security_group_rules_quota(region: str) -> Optional[float]: |
| """Current 'Inbound or outbound rules per security group' quota, or None.""" |
| try: |
| sq = boto3.client("service-quotas", region_name=region) |
| try: |
| resp = sq.get_service_quota( |
| ServiceCode=_SG_RULES_QUOTA_SERVICE_CODE, |
| QuotaCode=_SG_RULES_QUOTA_CODE, |
| ) |
| except Exception: |
| resp = sq.get_aws_default_service_quota( |
| ServiceCode=_SG_RULES_QUOTA_SERVICE_CODE, |
| QuotaCode=_SG_RULES_QUOTA_CODE, |
| ) |
| value = resp.get("Quota", {}).get("Value") |
| return float(value) if value is not None else None |
| except Exception as exc: |
| print(f" Warning: could not read security-group rules quota: {exc}") |
| return None |
|
|
|
|
| def request_security_group_rules_quota_increase( |
| region: str, |
| desired_value: int, |
| ) -> bool: |
| """Request a Service Quotas increase for SG rules. Returns True if submitted. |
| |
| The increase is not effective immediately (AWS may auto-approve or require review); |
| re-run the lockdown once it is approved. |
| """ |
| try: |
| sq = boto3.client("service-quotas", region_name=region) |
| sq.request_service_quota_increase( |
| ServiceCode=_SG_RULES_QUOTA_SERVICE_CODE, |
| QuotaCode=_SG_RULES_QUOTA_CODE, |
| DesiredValue=float(desired_value), |
| ) |
| print( |
| f" Requested increase of 'Inbound or outbound rules per security group' " |
| f"to {desired_value} in {region}. This needs AWS approval; re-run the " |
| "lockdown once it is granted." |
| ) |
| return True |
| except Exception as exc: |
| msg = str(exc) |
| if "ResourceAlreadyExists" in msg or "DependencyAccessDenied" in msg: |
| print( |
| " Note: a quota increase request may already be open, or Service " |
| f"Quotas access is restricted: {exc}" |
| ) |
| else: |
| print(f" Warning: could not request quota increase: {exc}") |
| return False |
|
|
|
|
| def _collect_open_ingress( |
| permissions: List[Dict[str, Any]], |
| ) -> tuple[List[Dict[str, Any]], List[tuple]]: |
| """From SG IpPermissions, return (revoke_permissions, open_port_keys). |
| |
| ``revoke_permissions`` is the set of IpPermissions to revoke (only the open |
| 0.0.0.0/0 and ::/0 ranges), and ``open_port_keys`` are the distinct |
| ``(protocol, from_port, to_port)`` tuples that had open ingress. |
| """ |
| revoke_permissions: List[Dict[str, Any]] = [] |
| port_keys: List[tuple] = [] |
| seen: set = set() |
| for perm in permissions: |
| proto = perm.get("IpProtocol") |
| from_port = perm.get("FromPort") |
| to_port = perm.get("ToPort") |
| open_v4 = [ |
| {"CidrIp": r["CidrIp"]} |
| for r in perm.get("IpRanges", []) |
| if r.get("CidrIp") == "0.0.0.0/0" |
| ] |
| open_v6 = [ |
| {"CidrIpv6": r["CidrIpv6"]} |
| for r in perm.get("Ipv6Ranges", []) |
| if r.get("CidrIpv6") == "::/0" |
| ] |
| if not (open_v4 or open_v6): |
| continue |
| revoke_perm: Dict[str, Any] = {"IpProtocol": proto} |
| if from_port is not None: |
| revoke_perm["FromPort"] = from_port |
| if to_port is not None: |
| revoke_perm["ToPort"] = to_port |
| if open_v4: |
| revoke_perm["IpRanges"] = open_v4 |
| if open_v6: |
| revoke_perm["Ipv6Ranges"] = open_v6 |
| revoke_permissions.append(revoke_perm) |
| port_key = (proto, from_port, to_port) |
| if port_key not in seen: |
| seen.add(port_key) |
| port_keys.append(port_key) |
| return revoke_permissions, port_keys |
|
|
|
|
| def _get_load_balancer_for_security_group( |
| elbv2_client: Any, |
| sg_id: str, |
| ) -> Optional[Dict[str, Any]]: |
| """Find the load balancer whose security groups include ``sg_id``.""" |
| try: |
| paginator = elbv2_client.get_paginator("describe_load_balancers") |
| pages = paginator.paginate() |
| except Exception: |
| pages = [elbv2_client.describe_load_balancers()] |
| for page in pages: |
| for lb in page.get("LoadBalancers", []): |
| if sg_id in (lb.get("SecurityGroups") or []): |
| return { |
| "arn": lb.get("LoadBalancerArn"), |
| "vpc_id": lb.get("VpcId"), |
| "security_groups": list(lb.get("SecurityGroups") or []), |
| } |
| return None |
|
|
|
|
| def _port_label(from_port: Optional[int], to_port: Optional[int]) -> str: |
| if from_port is None: |
| return "all" |
| if to_port is None or to_port == from_port: |
| return str(from_port) |
| return f"{from_port}-{to_port}" |
|
|
|
|
| def _ensure_cloudfront_port_security_group( |
| ec2_client: Any, |
| *, |
| vpc_id: str, |
| prefix_list_id: str, |
| port_key: tuple, |
| base_sg_id: str, |
| ) -> str: |
| """Create (or reuse) a dedicated SG holding the CloudFront prefix list on one port. |
| |
| Splitting the prefix list into one dedicated SG *per listener port* keeps each SG |
| under the per-SG rule quota (the prefix list counts as ~55 entries, and referencing |
| it on two ports in one SG would exceed the 60-rule default). Deterministic naming |
| makes re-runs idempotent. Raises ClientError if authorization fails for a non- |
| duplicate reason (e.g. a single port still exceeds the quota). |
| """ |
| from botocore.exceptions import ClientError |
|
|
| proto, from_port, to_port = port_key |
| label = _port_label(from_port, to_port) |
| name = f"cf-lockdown-{base_sg_id}-{proto}-{label}"[:255] |
|
|
| existing = ec2_client.describe_security_groups( |
| Filters=[ |
| {"Name": "group-name", "Values": [name]}, |
| {"Name": "vpc-id", "Values": [vpc_id]}, |
| ] |
| ).get("SecurityGroups", []) |
| if existing: |
| cf_sg_id = existing[0]["GroupId"] |
| else: |
| created = ec2_client.create_security_group( |
| GroupName=name, |
| Description=( |
| f"CloudFront origin-facing ingress {proto}/{label} for {base_sg_id}" |
| )[:255], |
| VpcId=vpc_id, |
| TagSpecifications=[ |
| { |
| "ResourceType": "security-group", |
| "Tags": [ |
| {"Key": "Name", "Value": name}, |
| { |
| "Key": "doc-redaction:cloudfront-lockdown", |
| "Value": base_sg_id, |
| }, |
| ], |
| } |
| ], |
| ) |
| cf_sg_id = created["GroupId"] |
|
|
| perm: Dict[str, Any] = {"IpProtocol": proto} |
| if from_port is not None: |
| perm["FromPort"] = from_port |
| if to_port is not None: |
| perm["ToPort"] = to_port |
| perm["PrefixListIds"] = [ |
| {"PrefixListId": prefix_list_id, "Description": "CloudFront origin-facing"} |
| ] |
| try: |
| ec2_client.authorize_security_group_ingress( |
| GroupId=cf_sg_id, IpPermissions=[perm] |
| ) |
| except ClientError as exc: |
| if "InvalidPermission.Duplicate" not in str(exc): |
| raise |
| return cf_sg_id |
|
|
|
|
| def _restrict_single_alb_to_cloudfront_split( |
| ec2_client: Any, |
| elbv2_client: Any, |
| sg_id: str, |
| prefix_list_id: str, |
| ) -> str: |
| """Lock an ALB to CloudFront by attaching dedicated per-port CloudFront SGs. |
| |
| Rather than stuffing the CloudFront prefix list into the ECS-managed ALB SG (which |
| overflows the per-SG rule quota once the ~55-entry list is referenced on multiple |
| ports), this creates one dedicated SG per open listener port containing only the |
| CloudFront prefix list, attaches them to the load balancer alongside the existing |
| SGs, then revokes the open internet ingress from the managed SG. The ALB's effective |
| ingress is the union of all attached SGs, so there is no downtime window: CloudFront |
| access is added *before* open access is removed. |
| |
| Returns ``"ok"``, ``"quota"`` (a single port still exceeds the quota), |
| ``"error"``, or ``"noop"``. |
| """ |
| from botocore.exceptions import ClientError |
|
|
| lb = _get_load_balancer_for_security_group(elbv2_client, sg_id) |
| if not lb or not lb.get("arn"): |
| print( |
| f" Warning: no load balancer found using SG {sg_id}; cannot attach " |
| "dedicated CloudFront security groups." |
| ) |
| return "error" |
|
|
| try: |
| groups = ec2_client.describe_security_groups(GroupIds=[sg_id]).get( |
| "SecurityGroups", [] |
| ) |
| except ClientError as exc: |
| print(f" Warning: cannot describe SG {sg_id}: {exc}") |
| return "error" |
| if not groups: |
| print(f" SG {sg_id} not found; skipping.") |
| return "noop" |
|
|
| vpc_id = groups[0].get("VpcId") or lb.get("vpc_id") |
| if not vpc_id: |
| print(f" Warning: could not determine VPC for SG {sg_id}; skipping.") |
| return "error" |
|
|
| revoke_permissions, port_keys = _collect_open_ingress( |
| groups[0].get("IpPermissions", []) |
| ) |
| extra_attached = [s for s in lb["security_groups"] if s != sg_id] |
| if not port_keys: |
| if extra_attached: |
| print( |
| f" SG {sg_id}: already locked down (CloudFront SG(s) attached, no " |
| "open internet ingress)." |
| ) |
| return "noop" |
| port_keys = [("tcp", 80, 80), ("tcp", 443, 443)] |
| print( |
| f" SG {sg_id}: no open ingress detected; defaulting CloudFront SGs to " |
| "tcp 80 and 443." |
| ) |
|
|
| cf_sg_ids: List[str] = [] |
| try: |
| for port_key in port_keys: |
| cf_sg_ids.append( |
| _ensure_cloudfront_port_security_group( |
| ec2_client, |
| vpc_id=vpc_id, |
| prefix_list_id=prefix_list_id, |
| port_key=port_key, |
| base_sg_id=sg_id, |
| ) |
| ) |
| except ClientError as exc: |
| if "RulesPerSecurityGroupLimitExceeded" in str(exc): |
| max_entries = _get_managed_prefix_list_max_entries( |
| ec2_client, prefix_list_id |
| ) |
| size = f" (~{max_entries} entries)" if max_entries else "" |
| print( |
| f" Warning: the CloudFront prefix list{size} does not fit even a " |
| "single dedicated security group. Raise the 'Inbound or outbound rules " |
| "per security group' quota (Service Quotas > VPC, L-0EA8095F), or switch " |
| "to enumerated CIDR ranges." |
| ) |
| return "quota" |
| print( |
| f" Warning: could not create CloudFront security group for {sg_id}: {exc}" |
| ) |
| return "error" |
|
|
| desired = list(dict.fromkeys(lb["security_groups"] + cf_sg_ids)) |
| try: |
| elbv2_client.set_security_groups( |
| LoadBalancerArn=lb["arn"], SecurityGroups=desired |
| ) |
| print( |
| f" LB for {sg_id}: attached CloudFront SG(s) {', '.join(cf_sg_ids)} " |
| f"({len(port_keys)} port(s))." |
| ) |
| except ClientError as exc: |
| print( |
| f" Warning: could not attach CloudFront SG(s) to the load balancer " |
| f"({lb['arn']}): {exc}" |
| ) |
| return "error" |
|
|
| if revoke_permissions: |
| try: |
| ec2_client.revoke_security_group_ingress( |
| GroupId=sg_id, IpPermissions=revoke_permissions |
| ) |
| print( |
| f" SG {sg_id}: revoked open internet ingress (CloudFront SGs now gate " |
| "access)." |
| ) |
| except ClientError as exc: |
| if "InvalidPermission.NotFound" not in str(exc): |
| print(f" Warning: could not revoke open ingress on {sg_id}: {exc}") |
| return "error" |
| return "ok" |
|
|
|
|
| def _restrict_single_alb_sg_to_cloudfront( |
| ec2_client: Any, |
| sg_id: str, |
| prefix_list_id: str, |
| ) -> str: |
| """Replace open (0.0.0.0/0, ::/0) ingress on ``sg_id`` with the CloudFront prefix list. |
| |
| The CloudFront managed prefix list expands to ~55 entries, each counting toward the |
| per-SG rule quota (default 60). To avoid ``RulesPerSecurityGroupLimitExceeded``, the |
| open internet rules are revoked **first** (freeing capacity), then the prefix-list |
| rule is authorized. If the authorize still fails, the open rules are restored so the |
| ALB is never left unreachable. There is a brief window between revoke and authorize |
| where the ALB rejects all ingress (safe fail-closed; this runs during initial deploy |
| while the app is still building). Idempotent: re-running is a no-op once locked down. |
| |
| Returns a status string: ``"ok"``, ``"quota"`` (rule quota too small), |
| ``"error"``, or ``"noop"``. |
| """ |
| from botocore.exceptions import ClientError |
|
|
| try: |
| response = ec2_client.describe_security_groups(GroupIds=[sg_id]) |
| except ClientError as exc: |
| print(f" Warning: cannot describe SG {sg_id}: {exc}") |
| return "error" |
|
|
| groups = response.get("SecurityGroups", []) |
| if not groups: |
| print(f" SG {sg_id} not found; skipping.") |
| return "noop" |
|
|
| permissions = groups[0].get("IpPermissions", []) |
| revoke_permissions: List[Dict[str, Any]] = [] |
| authorize_permissions: List[Dict[str, Any]] = [] |
| preexisting_prefix_ports = set() |
| queued_prefix_ports = set() |
|
|
| for perm in permissions: |
| proto = perm.get("IpProtocol") |
| from_port = perm.get("FromPort") |
| to_port = perm.get("ToPort") |
| port_key = (proto, from_port, to_port) |
|
|
| for pl in perm.get("PrefixListIds", []): |
| if pl.get("PrefixListId") == prefix_list_id: |
| preexisting_prefix_ports.add(port_key) |
|
|
| open_v4 = [ |
| {"CidrIp": r["CidrIp"]} |
| for r in perm.get("IpRanges", []) |
| if r.get("CidrIp") == "0.0.0.0/0" |
| ] |
| open_v6 = [ |
| {"CidrIpv6": r["CidrIpv6"]} |
| for r in perm.get("Ipv6Ranges", []) |
| if r.get("CidrIpv6") == "::/0" |
| ] |
| if not (open_v4 or open_v6): |
| continue |
|
|
| revoke_perm: Dict[str, Any] = {"IpProtocol": proto} |
| if from_port is not None: |
| revoke_perm["FromPort"] = from_port |
| if to_port is not None: |
| revoke_perm["ToPort"] = to_port |
| if open_v4: |
| revoke_perm["IpRanges"] = open_v4 |
| if open_v6: |
| revoke_perm["Ipv6Ranges"] = open_v6 |
| revoke_permissions.append(revoke_perm) |
|
|
| if ( |
| port_key not in preexisting_prefix_ports |
| and port_key not in queued_prefix_ports |
| ): |
| auth_perm: Dict[str, Any] = {"IpProtocol": proto} |
| if from_port is not None: |
| auth_perm["FromPort"] = from_port |
| if to_port is not None: |
| auth_perm["ToPort"] = to_port |
| auth_perm["PrefixListIds"] = [ |
| { |
| "PrefixListId": prefix_list_id, |
| "Description": "CloudFront origin-facing", |
| } |
| ] |
| authorize_permissions.append(auth_perm) |
| queued_prefix_ports.add(port_key) |
|
|
| if not revoke_permissions and not authorize_permissions: |
| print(f" SG {sg_id}: already restricted to CloudFront (no change needed).") |
| return "noop" |
|
|
| |
| |
| revoked_open = False |
| if revoke_permissions: |
| try: |
| ec2_client.revoke_security_group_ingress( |
| GroupId=sg_id, IpPermissions=revoke_permissions |
| ) |
| revoked_open = True |
| print(f" SG {sg_id}: revoked open internet ingress.") |
| except ClientError as exc: |
| if "InvalidPermission.NotFound" in str(exc): |
| revoked_open = True |
| print(f" SG {sg_id}: open ingress already removed.") |
| else: |
| print(f" Warning: could not revoke open ingress on {sg_id}: {exc}") |
| return "error" |
|
|
| if not authorize_permissions: |
| print(f" SG {sg_id}: CloudFront prefix list already present.") |
| return "ok" |
|
|
| try: |
| ec2_client.authorize_security_group_ingress( |
| GroupId=sg_id, IpPermissions=authorize_permissions |
| ) |
| print(f" SG {sg_id}: authorized CloudFront prefix list {prefix_list_id}.") |
| return "ok" |
| except ClientError as exc: |
| if "InvalidPermission.Duplicate" in str(exc): |
| print(f" SG {sg_id}: CloudFront prefix list rule already present.") |
| return "ok" |
|
|
| |
| if revoked_open and revoke_permissions: |
| try: |
| ec2_client.authorize_security_group_ingress( |
| GroupId=sg_id, IpPermissions=revoke_permissions |
| ) |
| print( |
| f" SG {sg_id}: restored open internet ingress after failed lockdown." |
| ) |
| except ClientError as restore_exc: |
| print( |
| f" Warning: could not restore open ingress on {sg_id} " |
| f"(SG may now reject traffic): {restore_exc}" |
| ) |
| if "RulesPerSecurityGroupLimitExceeded" in str(exc): |
| max_entries = _get_managed_prefix_list_max_entries( |
| ec2_client, prefix_list_id |
| ) |
| needed = (max_entries + 10) if max_entries else None |
| size_note = ( |
| f"The CloudFront prefix list needs up to {max_entries} rule slots" |
| if max_entries |
| else "The CloudFront prefix list is larger than the per-SG rule quota" |
| ) |
| target = ( |
| f"at least {needed}" if needed else "above the prefix list size (~60+)" |
| ) |
| print( |
| f" Warning: SG {sg_id} cannot fit the CloudFront prefix list within the " |
| f"per-security-group rule quota. {size_note}. Raise the 'Inbound or " |
| f"outbound rules per security group' quota (Service Quotas > VPC, " |
| f"L-0EA8095F) to {target}, then re-run the lockdown; or set " |
| "USE_ECS_EXPRESS_MODE=False to use an ALB you fully control." |
| ) |
| return "quota" |
| print(f" Warning: could not authorize prefix list on {sg_id}: {exc}") |
| return "error" |
|
|
|
|
| def restrict_express_albs_to_cloudfront( |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| *, |
| prefix_list_id: Optional[str] = None, |
| output_keys: Optional[List[str]] = None, |
| auto_raise_quota: bool = False, |
| ) -> List[str]: |
| """Lock the Express-managed ALB security group(s) down to CloudFront only. |
| |
| Discovers the Express managed ALB security group ids from stack outputs |
| (``AlbSecurityGroupIdOutput`` for the main service, ``AgenticAlbSecurityGroupIdOutput`` |
| for the agentic service). For each, it creates one dedicated security group *per open |
| listener port* containing only the CloudFront origin-facing managed prefix list, |
| attaches those SGs to the load balancer, and revokes the open internet ingress from |
| the managed SG. Splitting by port keeps every SG within the per-SG rule quota |
| (default 60) without any quota increase, since the prefix list counts as ~55 entries. |
| |
| ``auto_raise_quota`` is only a last-resort fallback: it is used if even a single |
| dedicated per-port SG cannot fit the prefix list (very unlikely). It requests a |
| Service Quotas increase (needs AWS approval; re-run once granted). |
| |
| NOTE: The Express ALB and its security-group membership are owned by ECS Express |
| Mode. If ECS reconciles the load balancer it may revert these attachments; re-run |
| the quickstart to re-apply, or use ``USE_ECS_EXPRESS_MODE=False`` for full control. |
| |
| Returns the list of security group ids that were processed. |
| """ |
| region = region or AWS_REGION |
| if prefix_list_id is None: |
| from cdk_config import CLOUDFRONT_PREFIX_LIST_ID |
|
|
| prefix_list_id = resolve_cloudfront_origin_prefix_list_id( |
| region, fallback=CLOUDFRONT_PREFIX_LIST_ID |
| ) |
| if not prefix_list_id: |
| print( |
| "Skipping Express ALB CloudFront lockdown: no CloudFront prefix list id " |
| "resolved (set CLOUDFRONT_PREFIX_LIST_ID)." |
| ) |
| return [] |
|
|
| keys = output_keys or [ |
| "AlbSecurityGroupIdOutput", |
| "AgenticAlbSecurityGroupIdOutput", |
| ] |
| sg_ids: List[str] = [] |
| for key in keys: |
| value = (get_stack_output(stack_name, key, region) or "").strip() |
| if value.startswith("sg-") and value not in sg_ids: |
| sg_ids.append(value) |
|
|
| if not sg_ids: |
| print( |
| "Skipping Express ALB CloudFront lockdown: no ALB security group ids " |
| f"found in stack outputs {keys}." |
| ) |
| return [] |
|
|
| print( |
| f"Restricting Express ALB security group(s) to CloudFront " |
| f"(prefix list {prefix_list_id}): {', '.join(sg_ids)}" |
| ) |
| print( |
| " Using dedicated per-port CloudFront security groups (attached to the load " |
| "balancer) to stay within the per-SG rule quota without any quota increase." |
| ) |
| print( |
| " NOTE: ECS Express owns the load balancer's security-group membership; if it " |
| "reconciles the ALB these attachments may be reverted — re-run this if the " |
| "lockdown is lost, or set USE_ECS_EXPRESS_MODE=False for an ALB you fully own." |
| ) |
| ec2_client = boto3.client("ec2", region_name=region) |
| elbv2_client = boto3.client("elbv2", region_name=region) |
| hit_quota_limit = False |
| for sg_id in sg_ids: |
| status = _restrict_single_alb_to_cloudfront_split( |
| ec2_client, elbv2_client, sg_id, prefix_list_id |
| ) |
| if status == "quota": |
| hit_quota_limit = True |
|
|
| if hit_quota_limit and auto_raise_quota: |
| max_entries = _get_managed_prefix_list_max_entries(ec2_client, prefix_list_id) |
| current_quota = get_security_group_rules_quota(region) |
| desired = (max_entries + 10) if max_entries else 100 |
| if current_quota is not None: |
| print(f" Current 'rules per security group' quota: {int(current_quota)}.") |
| desired = max(desired, int(current_quota) + 10) |
| request_security_group_rules_quota_increase(region, desired) |
|
|
| return sg_ids |
|
|
|
|
| def print_express_mode_next_steps( |
| values: Dict[str, str], |
| *, |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| ) -> None: |
| """Print user-facing next steps after Express mode deploy + quickstart.""" |
| from cdk_config import normalize_https_redirect_url |
| from cdk_functions import format_express_pi_public_url |
|
|
| aws_region = region or values.get("AWS_REGION") or AWS_REGION |
| use_cloudfront = values.get("USE_CLOUDFRONT") == "True" |
| magic_link = values.get("CLOUDFRONT_AUTH_MODE") == "magic-link" |
|
|
| main_raw = (values.get("ECS_EXPRESS_COGNITO_REDIRECT_BASE") or "").strip() |
| if use_cloudfront: |
| cf_domain = ( |
| get_stack_output(stack_name, "CloudFrontDistributionURL", aws_region) or "" |
| ) |
| if cf_domain: |
| main_raw = f"https://{cf_domain.strip()}" |
| if not main_raw: |
| main_raw = ( |
| get_stack_output(stack_name, "ExpressServiceEndpoint", aws_region) or "" |
| ) |
| main_url = normalize_https_redirect_url(main_raw) if main_raw else "" |
|
|
| print("\nDone. Next steps:") |
| print(" - Wait 10 minutes for app deployment to finish.") |
| if magic_link: |
| login_url = get_stack_output(stack_name, "RedactionLoginUrl", aws_region) or "" |
| if login_url: |
| print( |
| " - Open RedactionLoginUrl from stack outputs once (sets a 7-day cookie), " |
| "then use RedactionUrl for normal browsing." |
| ) |
| print(f" - RedactionLoginUrl: {login_url}") |
| else: |
| print( |
| " - Magic-link auth: see RedactionLoginUrl and RedactionAuthToken stack outputs." |
| ) |
| prefix = (values.get("AGENT_ALB_PATH_PREFIX") or "/agent").strip() |
| if not prefix.startswith("/"): |
| prefix = f"/{prefix}" |
| if values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True": |
| agent_login_url = ( |
| get_stack_output(stack_name, "AgentRedactionLoginUrl", aws_region) or "" |
| ) |
| if agent_login_url: |
| print( |
| " - Open AgentRedactionLoginUrl to unlock and open the agent (Pi) " |
| "app directly (7-day cookie)." |
| ) |
| print(f" - AgentRedactionLoginUrl: {agent_login_url}") |
| elif main_url: |
| print(f" - Agentic UI (after unlock): {main_url.rstrip('/')}{prefix}/") |
| elif values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True": |
| print( |
| "- Register a Cognito user in AWS Console, change password at the login page URL " |
| "(available in the Cognito AWS console -> App Clients -> Login pages -> View login page " |
| "and sign in at the agentic redaction URL below. The main redaction backend runs without " |
| "in-app login so the agentic app can call it over Service Connect. You can disable " |
| "Cognito authorisation by setting COGNITO_AUTH to False in the ECS task definition / " |
| "ECS service options." |
| ) |
| else: |
| print( |
| " - If you have enabled Cognito authorisation in app, register a new user " |
| "to your Cognito user pool in AWS Console and complete sign up with the app " |
| "client login. If you do not want Cognito login, then set COGNITO_AUTH to " |
| "False in the ECS task definition / ECS service options." |
| ) |
| if main_url: |
| label = "CloudFront URL" if use_cloudfront else "main redaction app" |
| print(f" - The {label} can be accessed at {main_url}") |
| elif use_cloudfront: |
| print(" - App URL: see CloudFrontDistributionURL stack output") |
| else: |
| print(" - The main redaction app URL: see ExpressServiceEndpoint stack output") |
|
|
| if values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" and not magic_link: |
| agentic_raw = ( |
| get_stack_output(stack_name, "AgenticExpressEndpoint", aws_region) or "" |
| ) |
| agentic_url = ( |
| format_express_pi_public_url(normalize_https_redirect_url(agentic_raw)) |
| if agentic_raw |
| else "" |
| ) |
| if agentic_url: |
| print(f" - The agentic redaction app can be accessed at {agentic_url}") |
| else: |
| print( |
| " - The agentic redaction app URL: see AgenticExpressEndpoint stack output" |
| ) |
|
|
|
|
| def resolve_agentcore_backend_env(stack_name: str, aws_region: str) -> Dict[str, str]: |
| """Backend env for the AgentCore runtime: CloudFront (or Express) URL + magic-link token. |
| |
| Returns a dict with ``DOC_REDACTION_GRADIO_URL`` and, when the backend is fronted by |
| CloudFront magic-link, ``DOC_REDACTION_AUTH_TOKEN`` (+ ``DOC_REDACTION_AUTH_COOKIE_NAME`` |
| if non-default). Empty dict if no endpoint can be resolved. |
| """ |
| from cdk_config import ( |
| CLOUDFRONT_AUTH_MODE, |
| CLOUDFRONT_MAGIC_LINK_COOKIE_NAME, |
| USE_CLOUDFRONT, |
| normalize_https_redirect_url, |
| ) |
|
|
| url = "" |
| via_cloudfront = False |
| if USE_CLOUDFRONT == "True": |
| cf_domain = get_stack_output( |
| stack_name, "CloudFrontDistributionURL", aws_region |
| ) |
| if cf_domain: |
| url = normalize_https_redirect_url(f"https://{cf_domain.strip()}") |
| via_cloudfront = True |
| if not url: |
| endpoint = get_stack_output(stack_name, "ExpressServiceEndpoint", aws_region) |
| if not endpoint: |
| return {} |
| url = normalize_https_redirect_url(endpoint) |
|
|
| result: Dict[str, str] = {"DOC_REDACTION_GRADIO_URL": url} |
| if via_cloudfront and str(CLOUDFRONT_AUTH_MODE).strip().lower() == "magic-link": |
| token = get_stack_output(stack_name, "RedactionAuthToken", aws_region) |
| if token and token.strip(): |
| result["DOC_REDACTION_AUTH_TOKEN"] = token.strip() |
| cookie_name = (CLOUDFRONT_MAGIC_LINK_COOKIE_NAME or "").strip() |
| if cookie_name and cookie_name != "doc-redaction-auth": |
| result["DOC_REDACTION_AUTH_COOKIE_NAME"] = cookie_name |
| else: |
| print( |
| "Warning: RedactionAuthToken output not found; AgentCore requests " |
| "through CloudFront magic-link will be blocked without the token." |
| ) |
| return result |
|
|
|
|
| def sync_pi_agent_doc_redaction_url_for_agentcore( |
| *, |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| pi_agent_env_path: Optional[Path] = None, |
| upload_and_recycle: bool = True, |
| ) -> Optional[str]: |
| """ |
| Set ``config/agent.env`` ``DOC_REDACTION_GRADIO_URL`` to the CloudFront (or main |
| Express HTTPS) URL, plus the magic-link token when CloudFront magic-link is on. |
| |
| AgentCore runtime tools cannot use ECS Service Connect DNS. When ``upload_and_recycle`` |
| is set, the updated ``agent.env`` is uploaded to S3 and the Pi Express service is |
| recycled so its startup S3 overlay picks up the new backend URL + token. |
| """ |
| from cdk_config import ENABLE_AGENTCORE_RUNTIME |
|
|
| if ENABLE_AGENTCORE_RUNTIME != "True": |
| return None |
| aws_region = region or AWS_REGION |
| updates = resolve_agentcore_backend_env(stack_name, aws_region) |
| if not updates: |
| print( |
| "Warning: ExpressServiceEndpoint / CloudFrontDistributionURL not found; " |
| "agent.env DOC_REDACTION_GRADIO_URL not updated for AgentCore." |
| ) |
| return None |
| url = updates["DOC_REDACTION_GRADIO_URL"] |
| env_path = resolve_agent_env_path( |
| pi_agent_env_path, Path(__file__).resolve().parent |
| ) |
| if not env_path.is_file(): |
| print(f"Note: {env_path} not found; skipping AgentCore backend URL sync.") |
| return url |
|
|
| _patch_env_key_values(env_path, updates) |
| print( |
| f"Updated {env_path} DOC_REDACTION_GRADIO_URL for AgentCore: {url}" |
| + ( |
| " (CloudFront + magic-link token)" |
| if "DOC_REDACTION_AUTH_TOKEN" in updates |
| else "" |
| ) |
| ) |
|
|
| if upload_and_recycle: |
| from cdk_config import ( |
| CLUSTER_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| ENABLE_PI_AGENT_EXPRESS_SERVICE, |
| S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
|
|
| if S3_LOG_CONFIG_BUCKET_NAME: |
| upload_file_to_s3( |
| local_file_paths=str(env_path), |
| s3_key="", |
| s3_bucket=S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
| if ENABLE_PI_AGENT_EXPRESS_SERVICE == "True": |
| recycle_express_gateway_tasks( |
| CLUSTER_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| aws_region=aws_region, |
| ) |
| return url |
|
|
|
|
| def _patch_env_key_values(path: Union[str, Path], updates: Dict[str, str]) -> None: |
| """Merge key=value pairs into a dotenv-style file (creates keys if missing).""" |
| path = Path(path) |
| lines: List[str] = [] |
| seen: set[str] = set() |
| if path.is_file(): |
| lines = path.read_text(encoding="utf-8").splitlines() |
| out: List[str] = [] |
| for line in lines: |
| if "=" in line and not line.lstrip().startswith("#"): |
| key = line.split("=", 1)[0].strip() |
| if key in updates: |
| out.append(f"{key}={updates[key]}") |
| seen.add(key) |
| continue |
| out.append(line) |
| for key, value in updates.items(): |
| if key not in seen: |
| out.append(f"{key}={value}") |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text("\n".join(out) + ("\n" if out else ""), encoding="utf-8") |
|
|
|
|
| def sync_agentcore_runtime_url_from_stack( |
| *, |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| pi_agent_env_path: Optional[Path] = None, |
| cdk_env_path: Optional[Path] = None, |
| recycle_agent_service: bool = True, |
| ) -> Optional[str]: |
| """ |
| Read ``AgentCoreRuntimeArn`` from the stack, derive ``AGENTCORE_RUNTIME_URL``, |
| patch local env files, re-upload ``agent.env`` to S3, and recycle the agent |
| Express service so the new URL is picked up. |
| """ |
| from cdk_config import ( |
| CLUSTER_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| ENABLE_AGENTCORE_CDK_RUNTIME, |
| ENABLE_PI_AGENT_EXPRESS_SERVICE, |
| S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
| from cdk_functions import ( |
| derive_agentcore_runtime_url, |
| normalize_agentcore_runtime_url, |
| ) |
|
|
| if ENABLE_AGENTCORE_CDK_RUNTIME != "True": |
| return None |
|
|
| aws_region = region or AWS_REGION |
| runtime_arn = get_stack_output(stack_name, "AgentCoreRuntimeArn", aws_region) |
| if not runtime_arn: |
| print( |
| "Warning: AgentCoreRuntimeArn stack output not found; " |
| "AGENTCORE_RUNTIME_URL not updated." |
| ) |
| return None |
|
|
| url = normalize_agentcore_runtime_url( |
| derive_agentcore_runtime_url(runtime_arn, aws_region) |
| ) |
| if not url: |
| print("Warning: could not derive AGENTCORE_RUNTIME_URL from runtime ARN.") |
| return None |
|
|
| cdk_dir = Path(__file__).resolve().parent |
| pi_env = resolve_agent_env_path(pi_agent_env_path, cdk_dir) |
| cdk_env = cdk_env_path or (cdk_dir / "config" / "cdk_config.env") |
| updates = { |
| "AGENTCORE_RUNTIME_URL": url, |
| "AGENT_ORCHESTRATOR": "agentcore", |
| "ENABLE_AGENTCORE_RUNTIME": "True", |
| } |
| if pi_env.is_file() or pi_agent_env_path is not None: |
| _patch_env_key_values(pi_env, updates) |
| print(f"Updated {pi_env} AGENTCORE_RUNTIME_URL={url}") |
| if cdk_env.is_file() or cdk_env_path is not None: |
| _patch_env_key_values( |
| cdk_env, |
| { |
| **updates, |
| "ENABLE_AGENTCORE_CDK_RUNTIME": "True", |
| }, |
| ) |
| print(f"Updated {cdk_env} AGENTCORE_RUNTIME_URL={url}") |
|
|
| if pi_env.is_file() and S3_LOG_CONFIG_BUCKET_NAME: |
| upload_file_to_s3( |
| local_file_paths=str(pi_env), |
| s3_key="", |
| s3_bucket=S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
|
|
| if recycle_agent_service and ENABLE_PI_AGENT_EXPRESS_SERVICE == "True": |
| recycle_express_gateway_tasks( |
| CLUSTER_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| aws_region=aws_region, |
| ) |
|
|
| return url |
|
|
|
|
| def _sanitize_agentcore_runtime_name(raw: str) -> str: |
| """Coerce a name to the AgentCore pattern ``[a-zA-Z][a-zA-Z0-9_]{0,47}``. |
| |
| Bedrock AgentCore runtime names allow only letters, digits and underscores |
| and must start with a letter (e.g. ``Demo-Redaction-RedactionAgent`` -> |
| ``Demo_Redaction_RedactionAgent``). |
| """ |
| cleaned = re.sub(r"[^a-zA-Z0-9_]", "_", (raw or "").strip()) |
| cleaned = cleaned.lstrip("0123456789_") |
| if not cleaned: |
| cleaned = "RedactionAgent" |
| return cleaned[:48] |
|
|
|
|
| def _resolve_agentcore_execution_role_arn( |
| stack_name: str, aws_region: str |
| ) -> Optional[str]: |
| """Execution role ARN for the AgentCore runtime. |
| |
| Prefers ``AGENTCORE_RUNTIME_ROLE_ARN`` from config; otherwise resolves the |
| stack-managed ``AgentCoreRuntimeExecutionRole`` from CloudFormation. |
| """ |
| from cdk_config import AGENTCORE_RUNTIME_ROLE_ARN |
|
|
| external = (AGENTCORE_RUNTIME_ROLE_ARN or "").strip() |
| if external: |
| return external |
|
|
| try: |
| cfn = boto3.client("cloudformation", region_name=aws_region) |
| resources = cfn.describe_stack_resources(StackName=stack_name)["StackResources"] |
| except Exception as exc: |
| print(f"Warning: could not read stack resources for {stack_name}: {exc}") |
| return None |
|
|
| role_name = next( |
| ( |
| r["PhysicalResourceId"] |
| for r in resources |
| if r.get("ResourceType") == "AWS::IAM::Role" |
| and r.get("LogicalResourceId", "").startswith( |
| "AgentCoreRuntimeExecutionRole" |
| ) |
| ), |
| None, |
| ) |
| if not role_name: |
| print( |
| "Warning: AgentCoreRuntimeExecutionRole not found in " |
| f"{stack_name}. Deploy with AGENTCORE_CDK_DEPLOY=True first, or set " |
| "AGENTCORE_RUNTIME_ROLE_ARN in cdk_config.env." |
| ) |
| return None |
|
|
| try: |
| iam = boto3.client("iam", region_name=aws_region) |
| return iam.get_role(RoleName=role_name)["Role"]["Arn"] |
| except Exception as exc: |
| print(f"Warning: could not resolve ARN for role {role_name}: {exc}") |
| return None |
|
|
|
|
| def _resolve_agentcore_container_uri( |
| repository_name: str, aws_region: str |
| ) -> Optional[str]: |
| """``<repo-uri>:latest`` for the AgentCore runtime image, if the repo exists.""" |
| try: |
| ecr = boto3.client("ecr", region_name=aws_region) |
| repos = ecr.describe_repositories(repositoryNames=[repository_name])[ |
| "repositories" |
| ] |
| except Exception as exc: |
| print(f"Warning: could not resolve ECR repository '{repository_name}': {exc}") |
| return None |
| if not repos: |
| return None |
| return f"{repos[0]['repositoryUri']}:latest" |
|
|
|
|
| def _find_existing_agentcore_runtime_arn(client, runtime_name: str) -> Optional[str]: |
| """Return the ARN of an existing AgentCore runtime with ``runtime_name``.""" |
| try: |
| paginator = None |
| try: |
| paginator = client.get_paginator("list_agent_runtimes") |
| except Exception: |
| paginator = None |
| pages = paginator.paginate() if paginator else [client.list_agent_runtimes()] |
| for page in pages: |
| for rt in page.get("agentRuntimes", []) or []: |
| if rt.get("agentRuntimeName") == runtime_name: |
| return rt.get("agentRuntimeArn") |
| except Exception as exc: |
| print(f"Warning: could not list existing AgentCore runtimes: {exc}") |
| return None |
|
|
|
|
| def create_agentcore_runtime_from_ecr( |
| *, |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| pi_agent_env_path: Optional[Path] = None, |
| cdk_env_path: Optional[Path] = None, |
| recycle_agent_service: bool = True, |
| ) -> Optional[str]: |
| """Create the Bedrock AgentCore runtime from an existing ECR image (no CDK). |
| |
| This is the no-stack-update phase 2: it calls the bedrock-agentcore-control |
| API directly (mirroring the CDK ``CfnRuntime`` parameters), so it never runs |
| ``cdk deploy`` and cannot mutate/delete the RedactionStack's managed |
| resources. It then derives ``AGENTCORE_RUNTIME_URL``, patches the local env |
| files, re-uploads ``agent.env`` to S3, and recycles the agent Express |
| service. Idempotent: reuses an existing runtime of the same name. |
| """ |
| from cdk_config import ( |
| AGENTCORE_BEDROCK_MODEL, |
| AGENTCORE_NETWORK_MODE, |
| AGENTCORE_RUNTIME_NAME, |
| CLUSTER_NAME, |
| ECR_AGENTCORE_REPO_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| ENABLE_PI_AGENT_EXPRESS_SERVICE, |
| S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
| from cdk_functions import ( |
| derive_agentcore_runtime_url, |
| normalize_agentcore_runtime_url, |
| ) |
|
|
| aws_region = region or AWS_REGION |
| runtime_name = _sanitize_agentcore_runtime_name( |
| AGENTCORE_RUNTIME_NAME or "RedactionAgent" |
| ) |
|
|
| container_uri = _resolve_agentcore_container_uri( |
| ECR_AGENTCORE_REPO_NAME, aws_region |
| ) |
| if not container_uri: |
| print( |
| "AgentCore runtime image is not available in ECR yet " |
| f"({ECR_AGENTCORE_REPO_NAME}:latest). Build/push it first, then re-run." |
| ) |
| return None |
|
|
| role_arn = _resolve_agentcore_execution_role_arn(stack_name, aws_region) |
| if not role_arn: |
| return None |
|
|
| try: |
| client = boto3.client("bedrock-agentcore-control", region_name=aws_region) |
| except Exception as exc: |
| print( |
| "Could not create a 'bedrock-agentcore-control' client " |
| f"(upgrade boto3?): {exc}" |
| ) |
| return None |
|
|
| bedrock_model = (AGENTCORE_BEDROCK_MODEL or "anthropic.claude-sonnet-4-6").strip() |
| runtime_env_vars = { |
| "AGENT_DEFAULT_PROVIDER": "amazon-bedrock", |
| "AGENT_DEFAULT_MODEL": bedrock_model, |
| "AWS_REGION": aws_region, |
| "AWS_DEFAULT_REGION": aws_region, |
| "AGENT_WORKSPACE_DIR": "/tmp/agentcore-workspace", |
| } |
|
|
| runtime_arn = _find_existing_agentcore_runtime_arn(client, runtime_name) |
| if runtime_arn: |
| print(f"Reusing existing AgentCore runtime '{runtime_name}': {runtime_arn}") |
| print( |
| " Note: an existing runtime's model is not changed here. To switch the " |
| f"Bedrock model (env AGENT_DEFAULT_MODEL='{bedrock_model}'), delete and " |
| "recreate the runtime." |
| ) |
| else: |
| print(f"Creating AgentCore runtime '{runtime_name}' from {container_uri} ...") |
| print(f" Bedrock model: {bedrock_model} (AGENT_DEFAULT_MODEL)") |
| try: |
| resp = client.create_agent_runtime( |
| agentRuntimeName=runtime_name, |
| agentRuntimeArtifact={ |
| "containerConfiguration": {"containerUri": container_uri} |
| }, |
| roleArn=role_arn, |
| networkConfiguration={ |
| "networkMode": (AGENTCORE_NETWORK_MODE or "PUBLIC").upper() |
| }, |
| environmentVariables=runtime_env_vars, |
| ) |
| except Exception as exc: |
| print(f"Failed to create AgentCore runtime: {exc}") |
| return None |
| runtime_arn = resp.get("agentRuntimeArn") |
| if not runtime_arn: |
| print( |
| "create_agent_runtime returned no agentRuntimeArn; " |
| f"response keys: {sorted(resp.keys())}" |
| ) |
| return None |
| print(f"Created AgentCore runtime: {runtime_arn}") |
|
|
| url = normalize_agentcore_runtime_url( |
| derive_agentcore_runtime_url(runtime_arn, aws_region) |
| ) |
| if not url: |
| print("Warning: could not derive AGENTCORE_RUNTIME_URL from runtime ARN.") |
| return None |
|
|
| cdk_dir = Path(__file__).resolve().parent |
| pi_env = resolve_agent_env_path(pi_agent_env_path, cdk_dir) |
| cdk_env = cdk_env_path or (cdk_dir / "config" / "cdk_config.env") |
| updates = { |
| "AGENTCORE_RUNTIME_URL": url, |
| "AGENT_ORCHESTRATOR": "agentcore", |
| "ENABLE_AGENTCORE_RUNTIME": "True", |
| } |
| |
| backend_env = resolve_agentcore_backend_env(stack_name, aws_region) |
| pi_updates = {**updates, **backend_env} |
| if pi_env.is_file() or pi_agent_env_path is not None: |
| _patch_env_key_values(pi_env, pi_updates) |
| print(f"Updated {pi_env} AGENTCORE_RUNTIME_URL={url}") |
| if backend_env.get("DOC_REDACTION_GRADIO_URL"): |
| print( |
| f"Updated {pi_env} DOC_REDACTION_GRADIO_URL=" |
| f"{backend_env['DOC_REDACTION_GRADIO_URL']}" |
| + ( |
| " (+ magic-link token)" |
| if "DOC_REDACTION_AUTH_TOKEN" in backend_env |
| else "" |
| ) |
| ) |
| if cdk_env.is_file() or cdk_env_path is not None: |
| |
| _patch_env_key_values(cdk_env, updates) |
| print(f"Updated {cdk_env} AGENTCORE_RUNTIME_URL={url}") |
|
|
| if pi_env.is_file() and S3_LOG_CONFIG_BUCKET_NAME: |
| upload_file_to_s3( |
| local_file_paths=str(pi_env), |
| s3_key="", |
| s3_bucket=S3_LOG_CONFIG_BUCKET_NAME, |
| ) |
|
|
| if recycle_agent_service and ENABLE_PI_AGENT_EXPRESS_SERVICE == "True": |
| recycle_express_gateway_tasks( |
| CLUSTER_NAME, |
| ECS_PI_EXPRESS_SERVICE_NAME, |
| aws_region=aws_region, |
| ) |
|
|
| print( |
| "\nAgentCore runtime is wired up (no stack update performed).\n" |
| f" AGENTCORE_RUNTIME_URL={url}" |
| ) |
| return url |
|
|
|
|
| def _s3_object_exists(s3_client, bucket: str, key: str) -> bool: |
| from botocore.exceptions import ClientError |
|
|
| try: |
| s3_client.head_object(Bucket=bucket, Key=key) |
| return True |
| except ClientError as exc: |
| code = exc.response.get("Error", {}).get("Code", "") |
| if code in ("404", "NoSuchKey", "NotFound"): |
| return False |
| raise |
|
|
|
|
| def seed_headless_batch_s3_layout( |
| output_bucket: str, |
| *, |
| input_prefix: str = "input/", |
| env_prefix: str = "input/config/", |
| example_env_local_path: str, |
| example_env_basename: str = "example_headless_env_file.env", |
| aws_region: str = AWS_REGION, |
| ) -> None: |
| """ |
| Ensure headless batch prefixes and example job .env exist on the output bucket. |
| |
| Idempotent: existing keys are left unchanged (safe to run from quickstart). |
| """ |
| if not output_bucket: |
| print("Skipping headless S3 layout seed: output bucket name is empty.") |
| return |
|
|
| input_prefix_norm = ( |
| input_prefix if input_prefix.endswith("/") else f"{input_prefix}/" |
| ) |
| env_prefix_norm = env_prefix if env_prefix.endswith("/") else f"{env_prefix}/" |
| if not env_prefix_norm.startswith(input_prefix_norm): |
| env_prefix_norm = f"{input_prefix_norm}{env_prefix_norm.lstrip('/')}" |
| if not env_prefix_norm.endswith("/"): |
| env_prefix_norm += "/" |
|
|
| s3_client = boto3.client("s3", region_name=aws_region) |
| markers = ( |
| input_prefix_norm, |
| env_prefix_norm, |
| ) |
| for key in markers: |
| if _s3_object_exists(s3_client, output_bucket, key): |
| print(f"S3 prefix marker already present: s3://{output_bucket}/{key}") |
| continue |
| s3_client.put_object(Bucket=output_bucket, Key=key, Body=b"") |
| print(f"Created S3 prefix marker: s3://{output_bucket}/{key}") |
|
|
| example_key = f"{env_prefix_norm}{example_env_basename}" |
| if _s3_object_exists(s3_client, output_bucket, example_key): |
| print(f"Example job .env already present: s3://{output_bucket}/{example_key}") |
| return |
|
|
| if not os.path.isfile(example_env_local_path): |
| print(f"Skipping example job .env upload: {example_env_local_path} not found.") |
| return |
|
|
| with open(example_env_local_path, "rb") as handle: |
| s3_client.put_object(Bucket=output_bucket, Key=example_key, Body=handle.read()) |
| print(f"Uploaded example job .env to s3://{output_bucket}/{example_key}") |
|
|
|
|
| def print_headless_deployment_next_steps( |
| values: Dict[str, str], |
| *, |
| stack_name: str = "RedactionStack", |
| region: Optional[str] = None, |
| ) -> None: |
| """Print user-facing next steps after headless deploy + quickstart.""" |
| aws_region = region or values.get("AWS_REGION") or AWS_REGION |
| output_bucket = (values.get("S3_OUTPUT_BUCKET_NAME") or "").strip() |
| input_prefix = (values.get("S3_BATCH_INPUT_PREFIX") or "input/").strip("/") |
| config_prefix = (values.get("S3_BATCH_ENV_PREFIX") or "input/config/").strip("/") |
|
|
| lambda_name = (values.get("S3_BATCH_LAMBDA_FUNCTION_NAME") or "").strip() |
| if not lambda_name: |
| lambda_name = ( |
| get_stack_output(stack_name, "BatchEcsTriggerLambdaName", aws_region) or "" |
| ).strip() |
| if not lambda_name: |
| prefix = (values.get("CDK_PREFIX") or "").strip() |
| lambda_name = f"{prefix}S3BatchEcsTrigger" if prefix else "S3BatchEcsTrigger" |
|
|
| input_uri = ( |
| f"s3://{output_bucket}/{input_prefix}/" |
| if output_bucket |
| else f"<output-bucket>/{input_prefix}/" |
| ) |
| config_uri = ( |
| f"s3://{output_bucket}/{config_prefix}/" |
| if output_bucket |
| else f"<output-bucket>/{config_prefix}/" |
| ) |
| output_uri = ( |
| f"s3://{output_bucket}/output/<session-folder>/" |
| if output_bucket |
| else "<output-bucket>/output/<session-folder>/" |
| ) |
| example_name = "example_headless_env_file.env" |
|
|
| print("\nDone. Next steps:") |
| print( |
| f" - Upload a PDF file for redaction to {input_uri}. You can use the example file " |
| "'example_of_emails_sent_to_a_professor_before_applying.pdf' from doc_redaction/example_data " |
| "if you want to use the example .env file below." |
| ) |
| print( |
| f" - Create a .env file for submitting a task to the folder {config_uri}." |
| "You can resubmit the file '{example_name}' in the repo at cdk\config\headless_s3_seed\input\config, " |
| f"(also at {config_uri}{example_name}) and change the PDF filename inside. " |
| "Further direct-mode config variables are in tools/config.py (DIRECT_MODE_*)." |
| ) |
| print(f" - Upload your job .env file to {config_uri}") |
| print( |
| f" - AWS Lambda function {lambda_name} will start an ECS task to redact your " |
| "document according to your instructions." |
| ) |
| print( |
| f" - Redaction outputs are written to {output_uri} when SAVE_OUTPUTS_TO_S3=True " |
| "in your job .env (see the example file)." |
| ) |
| log_group = (values.get("ECS_LOG_GROUP_NAME") or "").strip() |
| if log_group: |
| print( |
| f" - Batch task logs: CloudWatch log group {log_group} " |
| "(streams appear once the container starts)." |
| ) |
|
|