Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 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) -> None: | |
| """Start an existing CodeBuild project build.""" | |
| 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" | |
| ) | |
| 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}") | |
| 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" | |
| # Fields preserved from describe_user_pool_client when updating CallbackURLs only. | |
| _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 SUMMARISATION_* 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 { | |
| "SUMMARISATION_USER_POOL_ID": user_pool_id, | |
| "SUMMARISATION_CLIENT_ID": client_id, | |
| "SUMMARISATION_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 print_express_mode_next_steps( | |
| values: Dict[str, str], | |
| *, | |
| stack_name: str = "SummarisationStack", | |
| 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 | |
| main_raw = (values.get("ECS_EXPRESS_COGNITO_REDIRECT_BASE") or "").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.") | |
| pi_express = values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True" | |
| if pi_express: | |
| 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 Pi agent URL below. The main topic-modelling backend runs without " | |
| "in-app login so the Pi agent can call it over Service Connect. You can disable " | |
| "Cognito login by setting COGNITO_AUTH to False in the ECS task definition / " | |
| "ECS service options." | |
| ) | |
| else: | |
| print( | |
| " - If you have enabled Cognito login in the 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: | |
| print(f" - The main topic modelling app can be accessed at {main_url}") | |
| else: | |
| print( | |
| " - The main topic modelling app URL: see ExpressServiceEndpoint stack output" | |
| ) | |
| if values.get("ENABLE_PI_AGENT_EXPRESS_SERVICE") == "True": | |
| pi_raw = get_stack_output(stack_name, "PiExpressEndpoint", aws_region) or "" | |
| pi_url = ( | |
| format_express_pi_public_url(normalize_https_redirect_url(pi_raw)) | |
| if pi_raw | |
| else "" | |
| ) | |
| if pi_url: | |
| print(f" - The Pi agent app can be accessed at {pi_url}") | |
| else: | |
| print(" - The Pi agent app URL: see PiExpressEndpoint stack output") | |
| 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, | |
| log_bucket: str = "", | |
| default_params_key: str = "general-config/app_defaults.env", | |
| app_defaults_local_path: str = "", | |
| s3_outputs_bucket_name: str = "", | |
| ) -> None: | |
| """ | |
| Ensure headless batch prefixes and example job .env exist on the output bucket. | |
| When ``log_bucket`` is set, also ensure durable ``app_defaults.env`` exists on | |
| the log/config bucket (so it is not deleted by output-bucket lifecycle rules). | |
| 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}") | |
| elif not os.path.isfile(example_env_local_path): | |
| print(f"Skipping example job .env upload: {example_env_local_path} not found.") | |
| else: | |
| 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}") | |
| if not log_bucket: | |
| return | |
| defaults_key = (default_params_key or "general-config/app_defaults.env").lstrip("/") | |
| if _s3_object_exists(s3_client, log_bucket, defaults_key): | |
| print(f"app_defaults.env already present: s3://{log_bucket}/{defaults_key}") | |
| return | |
| defaults_path = app_defaults_local_path | |
| if not defaults_path: | |
| defaults_path = os.path.join( | |
| os.path.dirname(__file__), | |
| "config", | |
| "headless_s3_seed", | |
| "general-config", | |
| "app_defaults.env", | |
| ) | |
| # Prefer rendered content (fills S3_OUTPUTS_BUCKET) when seed helpers are available. | |
| body: Optional[bytes] = None | |
| try: | |
| from cdk_functions import build_headless_app_defaults_env_content | |
| seed_dir = os.path.join(os.path.dirname(__file__), "config", "headless_s3_seed") | |
| rendered = build_headless_app_defaults_env_content( | |
| seed_dir, | |
| s3_outputs_bucket_name=s3_outputs_bucket_name or output_bucket, | |
| ) | |
| body = rendered.encode("utf-8") | |
| except Exception as exc: | |
| print(f"Warning: could not render app_defaults.env ({exc}); using local file.") | |
| if body is None: | |
| if not os.path.isfile(defaults_path): | |
| print(f"Skipping app_defaults.env upload: {defaults_path} not found.") | |
| return | |
| with open(defaults_path, "rb") as handle: | |
| body = handle.read() | |
| s3_client.put_object(Bucket=log_bucket, Key=defaults_key, Body=body) | |
| print(f"Uploaded app_defaults.env to s3://{log_bucket}/{defaults_key}") | |
| def print_headless_deployment_next_steps( | |
| values: Dict[str, str], | |
| *, | |
| stack_name: str = "SummarisationStack", | |
| 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() | |
| log_bucket = (values.get("S3_LOG_CONFIG_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("/") | |
| defaults_key = ( | |
| (values.get("S3_BATCH_DEFAULT_PARAMS_KEY") or "general-config/app_defaults.env") | |
| .strip() | |
| .lstrip("/") | |
| ) | |
| 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>/" | |
| ) | |
| defaults_uri = ( | |
| f"s3://{log_bucket}/{defaults_key}" | |
| if log_bucket | |
| else f"<log-bucket>/{defaults_key}" | |
| ) | |
| example_name = "example_headless_env_file.env" | |
| print("\nDone. Next steps:") | |
| print( | |
| f" - Upload a consultation spreadsheet (.xlsx) to {input_uri}. " | |
| "You can use dummy_consultation_response.xlsx as a placeholder filename " | |
| "in the example job .env below." | |
| ) | |
| print( | |
| f" - Create a job .env file for submitting a task to {config_uri}. " | |
| f"You can copy the example at cdk/config/headless_s3_seed/input/config/{example_name} " | |
| f"(also seeded at {config_uri}{example_name}) and adjust DIRECT_MODE_INPUT_FILE " | |
| "and DIRECT_MODE_TEXT_COLUMN. Further DIRECT_MODE_* variables are documented in " | |
| "tools/config.py and cli_topics.py." | |
| ) | |
| print(f" - Upload your job .env file to {config_uri}") | |
| print( | |
| f" - Durable batch defaults (app_defaults.env) live at {defaults_uri} " | |
| "on the log/config bucket (not deleted by the output bucket lifecycle rule)." | |
| ) | |
| print( | |
| f" - AWS Lambda function {lambda_name} will start an ECS task to run " | |
| "topic modelling according to your job .env (RUN_DIRECT_MODE=1)." | |
| ) | |
| print( | |
| f" - 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)." | |
| ) | |
| def print_headless_output_notification_steps(values: Dict[str, str]) -> None: | |
| """Print follow-on steps for S3 output alarms, SNS, and IAM reader user.""" | |
| email = (values.get("HEADLESS_OUTPUT_NOTIFY_EMAIL") or "").strip() | |
| iam_user = (values.get("HEADLESS_OUTPUT_IAM_USER_NAME") or "").strip() | |
| output_bucket = (values.get("S3_OUTPUT_BUCKET_NAME") or "").strip() | |
| output_prefix = (values.get("HEADLESS_OUTPUT_S3_PREFIX") or "output/").strip() | |
| alarm_name = (values.get("HEADLESS_OUTPUT_ALARM_NAME") or "").strip() | |
| secret_name = (values.get("HEADLESS_OUTPUT_IAM_SECRET_NAME") or "").strip() | |
| print("\nHeadless output notifications:") | |
| if email: | |
| print( | |
| f" - Confirm the SNS email subscription sent to {email} " | |
| "(check spam; status shows Pending until confirmed)." | |
| ) | |
| if output_bucket: | |
| print( | |
| f" - CloudWatch alarm fires when objects are uploaded under " | |
| f"s3://{output_bucket}/{output_prefix.rstrip('/')}/" | |
| ) | |
| if alarm_name: | |
| print(f" - Alarm name: {alarm_name}") | |
| if iam_user: | |
| print( | |
| f" - IAM user {iam_user} can list/get/put/delete objects in the " | |
| f"output bucket for programmatic result download." | |
| ) | |
| if secret_name: | |
| print( | |
| " - If an access key was created, credentials are stored in " | |
| "the relevantSecrets Manager secret." | |
| ) | |
| def provision_headless_output_reader_access_key( | |
| values: Dict[str, str], | |
| *, | |
| region: Optional[str] = None, | |
| ) -> Optional[str]: | |
| """ | |
| Create an IAM access key for the headless output reader user. | |
| Stores JSON credentials in Secrets Manager when HEADLESS_OUTPUT_IAM_SECRET_NAME | |
| is set in cdk_config.env. | |
| """ | |
| from botocore.exceptions import ClientError | |
| iam_user = (values.get("HEADLESS_OUTPUT_IAM_USER_NAME") or "").strip() | |
| if not iam_user: | |
| print("Warning: HEADLESS_OUTPUT_IAM_USER_NAME not set; skipping access key.") | |
| return None | |
| aws_region = region or values.get("AWS_REGION") or AWS_REGION | |
| iam_client = boto3.client("iam", region_name=aws_region) | |
| secret_name = (values.get("HEADLESS_OUTPUT_IAM_SECRET_NAME") or "").strip() | |
| try: | |
| response = iam_client.create_access_key(UserName=iam_user) | |
| except ClientError as exc: | |
| print(f"Could not create IAM access key for {iam_user}: {exc}") | |
| return None | |
| access_key = response["AccessKey"] | |
| payload = json.dumps( | |
| { | |
| "aws_access_key_id": access_key["AccessKeyId"], | |
| "aws_secret_access_key": access_key["SecretAccessKey"], | |
| } | |
| ) | |
| if secret_name: | |
| sm_client = boto3.client("secretsmanager", region_name=aws_region) | |
| try: | |
| sm_client.create_secret(Name=secret_name, SecretString=payload) | |
| print("Stored output-reader credentials in Secrets Manager secret") | |
| except ClientError as exc: | |
| if exc.response["Error"]["Code"] == "ResourceExistsException": | |
| sm_client.put_secret_value(SecretId=secret_name, SecretString=payload) | |
| print("Updated output-reader credentials in Secrets Manager secret") | |
| else: | |
| print(f"Warning: could not store credentials in Secrets Manager: {exc}") | |
| print("IAM access key created (view in IAM console if needed):") | |
| else: | |
| print("IAM access key created (view in IAM console if needed):") | |
| return access_key["AccessKeyId"] | |