From 6268034ec2ef9782e1a866abfc1ee33d0db350ef Mon Sep 17 00:00:00 2001 From: Mike Hume Date: Wed, 10 Jun 2026 11:35:29 +0100 Subject: [PATCH 1/2] New serverless pattern - strands-agentcore-apigw --- strands-agentcore-apigw/.gitignore | 10 + .../04e6b3400353b141/fad6656e69bf4db9 | 1 + .../fad6656e69bf4db9/379d933ce63100a4 | Bin 0 -> 4 bytes .../.config.kiro | 1 + .../agentcore-apigw-weather-agent/design.md | 393 ++++++++++++++ .../requirements.md | 137 +++++ .../agentcore-apigw-weather-agent/tasks.md | 71 +++ .../steering/agentcore-apigw-weather-agent.md | 218 ++++++++ strands-agentcore-apigw/README.md | 243 +++++++++ .../architecture/apigateway-target.png | Bin 0 -> 71790 bytes strands-agentcore-apigw/example-pattern.json | 93 ++++ .../infrastructure/template.yaml | 320 ++++++++++++ strands-agentcore-apigw/requirements.txt | 4 + strands-agentcore-apigw/scripts/deploy.sh | 483 ++++++++++++++++++ strands-agentcore-apigw/scripts/test.sh | 56 ++ strands-agentcore-apigw/src/Makefile | 45 ++ strands-agentcore-apigw/src/__init__.py | 1 + strands-agentcore-apigw/src/agent/__init__.py | 1 + .../src/agent/agent_processor.py | 127 +++++ strands-agentcore-apigw/src/agent/handler.py | 143 ++++++ .../src/agent/strands_client.py | 77 +++ strands-agentcore-apigw/src/requirements.txt | 4 + .../src/shared/__init__.py | 58 +++ .../src/shared/error_utils.py | 180 +++++++ .../src/shared/jwt_utils.py | 104 ++++ .../src/shared/logging_utils.py | 122 +++++ strands-agentcore-apigw/src/shared/models.py | 77 +++ strands-agentcore-apigw/tests/__init__.py | 1 + .../tests/integration/__init__.py | 1 + .../tests/integration/test_e2e.py | 149 ++++++ .../tests/unit/__init__.py | 1 + .../tests/unit/conftest.py | 64 +++ .../tests/unit/test_properties.py | 337 ++++++++++++ .../tests/unit/test_sam_template.py | 454 ++++++++++++++++ 34 files changed, 3976 insertions(+) create mode 100644 strands-agentcore-apigw/.gitignore create mode 100644 strands-agentcore-apigw/.hypothesis/examples/04e6b3400353b141/fad6656e69bf4db9 create mode 100644 strands-agentcore-apigw/.hypothesis/examples/fad6656e69bf4db9/379d933ce63100a4 create mode 100644 strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/.config.kiro create mode 100644 strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/design.md create mode 100644 strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/requirements.md create mode 100644 strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/tasks.md create mode 100644 strands-agentcore-apigw/.kiro/steering/agentcore-apigw-weather-agent.md create mode 100644 strands-agentcore-apigw/README.md create mode 100644 strands-agentcore-apigw/architecture/apigateway-target.png create mode 100644 strands-agentcore-apigw/example-pattern.json create mode 100644 strands-agentcore-apigw/infrastructure/template.yaml create mode 100644 strands-agentcore-apigw/requirements.txt create mode 100755 strands-agentcore-apigw/scripts/deploy.sh create mode 100755 strands-agentcore-apigw/scripts/test.sh create mode 100644 strands-agentcore-apigw/src/Makefile create mode 100644 strands-agentcore-apigw/src/__init__.py create mode 100644 strands-agentcore-apigw/src/agent/__init__.py create mode 100644 strands-agentcore-apigw/src/agent/agent_processor.py create mode 100644 strands-agentcore-apigw/src/agent/handler.py create mode 100644 strands-agentcore-apigw/src/agent/strands_client.py create mode 100644 strands-agentcore-apigw/src/requirements.txt create mode 100644 strands-agentcore-apigw/src/shared/__init__.py create mode 100644 strands-agentcore-apigw/src/shared/error_utils.py create mode 100644 strands-agentcore-apigw/src/shared/jwt_utils.py create mode 100644 strands-agentcore-apigw/src/shared/logging_utils.py create mode 100644 strands-agentcore-apigw/src/shared/models.py create mode 100644 strands-agentcore-apigw/tests/__init__.py create mode 100644 strands-agentcore-apigw/tests/integration/__init__.py create mode 100644 strands-agentcore-apigw/tests/integration/test_e2e.py create mode 100644 strands-agentcore-apigw/tests/unit/__init__.py create mode 100644 strands-agentcore-apigw/tests/unit/conftest.py create mode 100644 strands-agentcore-apigw/tests/unit/test_properties.py create mode 100644 strands-agentcore-apigw/tests/unit/test_sam_template.py diff --git a/strands-agentcore-apigw/.gitignore b/strands-agentcore-apigw/.gitignore new file mode 100644 index 000000000..0a5148d8a --- /dev/null +++ b/strands-agentcore-apigw/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +*-deps/ +*-package/ +*.zip +venv/ +.venv/ +__pycache__/ +*.pyc +*.so +*.egg-info/ diff --git a/strands-agentcore-apigw/.hypothesis/examples/04e6b3400353b141/fad6656e69bf4db9 b/strands-agentcore-apigw/.hypothesis/examples/04e6b3400353b141/fad6656e69bf4db9 new file mode 100644 index 000000000..b2afe14f4 --- /dev/null +++ b/strands-agentcore-apigw/.hypothesis/examples/04e6b3400353b141/fad6656e69bf4db9 @@ -0,0 +1 @@ +ง๓rฆ6Œ๑คใวOT…ฎdยฏCฌฟR’_Aฅj’ะํ%็:?สเแึมพJ5V# \ No newline at end of file diff --git a/strands-agentcore-apigw/.hypothesis/examples/fad6656e69bf4db9/379d933ce63100a4 b/strands-agentcore-apigw/.hypothesis/examples/fad6656e69bf4db9/379d933ce63100a4 new file mode 100644 index 0000000000000000000000000000000000000000..3a3b8a27a0f240f0f8de33f24993c1c9aa16c650 GIT binary patch literal 4 LcmZQ{WMBXQ0LuVF literal 0 HcmV?d00001 diff --git a/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/.config.kiro b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/.config.kiro new file mode 100644 index 000000000..aeb501edd --- /dev/null +++ b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/.config.kiro @@ -0,0 +1 @@ +{"specId": "7a64bf38-3f0c-4e54-9f72-0827b62fe6c6", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/design.md b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/design.md new file mode 100644 index 000000000..2fd60580e --- /dev/null +++ b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/design.md @@ -0,0 +1,393 @@ +# Design Document: AgentCore API Gateway Weather Agent + +## Overview + +This design describes a serverless AI weather agent built on AWS Bedrock AgentCore Gateway using the API Gateway target type. The system enables users to ask natural language weather questions, which are processed by a Strands SDK agent (Claude Sonnet 4.6) that invokes weather tools discovered automatically from an API Gateway REST API via AgentCore's `GetExportAPI` mechanism. + +The architecture has five layers: + +1. **Agent Lambda** โ€” Strands SDK agent handling natural language โ†’ tool invocation โ†’ response +2. **Cognito** โ€” JWT-based user authentication for the AgentCore Gateway +3. **AgentCore Gateway** โ€” MCP-based gateway with API Gateway target, auto-discovering weather operations +4. **API Gateway REST API** โ€” Exposes `/weather/current` endpoint, validates API keys via usage plan +5. **WeatherAPI.com** โ€” External weather data provider, accessed via HTTP_PROXY integration + +Two independent API keys are in play: +- **AgentCore โ†’ API Gateway**: Managed by the AgentCore Identity credential provider, injected as `x-api-key` header +- **API Gateway โ†’ WeatherAPI.com**: Stored separately, injected by the API Gateway integration into downstream requests + +The credential provider cannot be created via CloudFormation and requires a multi-step deployment process. + +### Key Design Decisions + +1. **API Gateway target (not OpenAPI target)**: AgentCore auto-discovers the API spec via `GetExportAPI`, eliminating manual OpenAPI spec management. This is simpler but means the spec is derived from API Gateway's export, not a hand-crafted document. + +2. **HTTP_PROXY integration**: API Gateway proxies directly to WeatherAPI.com without Lambda intermediaries, keeping latency low and cost minimal. + +3. **WeatherAPI key via stage variable**: The WeatherAPI.com key is injected into the integration request via a stage variable referenced in a query string mapping. This avoids a Lambda authorizer or VTL complexity while keeping the key out of the template. + +4. **Reuse existing agent code**: The handler, agent_processor, and strands_client modules from `handoff/src/agent/` are used as-is. The shared utilities (models, JWT, logging, errors) from `handoff/src/shared/` are also reused unchanged. + +5. **us-east-1 deployment**: All resources deploy to us-east-1 due to BedrockAgentCore service availability constraints. + +## Architecture + +```mermaid +graph TB + User[User / Client] -->|POST with JWT| AgentLambda[Agent Lambda
Strands SDK + Claude Sonnet 4.6] + + AgentLambda -->|Validate JWT| Cognito[Cognito User Pool] + AgentLambda -->|MCP over HTTP
Bearer JWT| Gateway[AgentCore Gateway
CUSTOM_JWT Auth] + + Gateway -->|GetExportAPI| APIGW[API Gateway REST API
/weather/current] + Gateway -->|tools/call with x-api-key
from Credential Provider| APIGW + + APIGW -->|HTTP_PROXY
+WeatherAPI key| WeatherAPI[WeatherAPI.com
api.weatherapi.com] + + CredProvider[Credential Provider
API_KEY type
Manual creation] -.->|Provides x-api-key| Gateway + + SecretsManager[Secrets Manager
Two secrets] -.->|AgentCore API Key| CredProvider + SecretsManager -.->|WeatherAPI Key| APIGW + + subgraph CloudFormation Stack + AgentLambda + Cognito + Gateway + APIGW + end + + subgraph Manual Setup + CredProvider + end +``` + +### Deployment Sequence + +```mermaid +sequenceDiagram + participant Dev as Developer + participant SM as Secrets Manager + participant CFN as CloudFormation + participant APIGW as API Gateway + participant Console as AWS Console + participant Lambda as Agent Lambda + + Dev->>SM: 1. Create WeatherAPI key secret + Dev->>SM: 2. Create placeholder APIGW key secret + Dev->>CFN: 3. Deploy stack (all infra) + CFN-->>Dev: Stack outputs (API Key ID, Gateway ID, etc.) + Dev->>APIGW: 4. Retrieve actual API Gateway key value + Dev->>SM: 5. Update APIGW key secret with real value + Dev->>Console: 6. Create credential provider (manual) + Dev->>CFN: 7. Update stack with credential provider ARN + Dev->>Lambda: 8. Package & deploy Lambda code + Dev->>Dev: 9. Create test user & validate +``` + +## Components and Interfaces + +### 1. CloudFormation Template (`infrastructure/cloudformation-template.yaml`) + +The single CloudFormation template defines all provisionable resources. Key resource groups: + +#### API Gateway Resources +| Resource | Type | Purpose | +|----------|------|---------| +| `RestApi` | `AWS::ApiGateway::RestApi` | REST API with `ApiKeySourceType: HEADER` | +| `WeatherResource` | `AWS::ApiGateway::Resource` | `/weather` path segment | +| `WeatherCurrentResource` | `AWS::ApiGateway::Resource` | `/weather/current` path segment | +| `GetCurrentWeatherMethod` | `AWS::ApiGateway::Method` | GET method, `ApiKeyRequired: true`, HTTP_PROXY integration | +| `ApiDeployment` | `AWS::ApiGateway::Deployment` | Deployment (DependsOn all methods) | +| `ApiStage` | `AWS::ApiGateway::Stage` | Stage with WeatherAPI key stage variable | +| `ApiKey` | `AWS::ApiGateway::ApiKey` | API key for AgentCore access (DependsOn stage) | +| `UsagePlan` | `AWS::ApiGateway::UsagePlan` | Usage plan referencing stage | +| `UsagePlanKey` | `AWS::ApiGateway::UsagePlanKey` | Links ApiKey to UsagePlan | + +#### AgentCore Resources +| Resource | Type | Purpose | +|----------|------|---------| +| `AgentCoreGateway` | `AWS::BedrockAgentCore::Gateway` | Gateway with CUSTOM_JWT auth | +| `WeatherAPITarget` | `AWS::BedrockAgentCore::GatewayTarget` | API Gateway target with credential provider config | + +#### Auth Resources +| Resource | Type | Purpose | +|----------|------|---------| +| `CognitoUserPool` | `AWS::Cognito::UserPool` | User pool for JWT auth | +| `CognitoUserPoolClient` | `AWS::Cognito::UserPoolClient` | Client with USER_PASSWORD_AUTH + REFRESH_TOKEN_AUTH | + +#### Compute Resources +| Resource | Type | Purpose | +|----------|------|---------| +| `AgentLambdaFunction` | `AWS::Lambda::Function` | Python 3.12, x86_64, 120s timeout, 1024MB memory | +| `AgentLambdaRole` | `AWS::IAM::Role` | Lambda execution role | +| `GatewayExecutionRole` | `AWS::IAM::Role` | Gateway execution role | + +### 2. Agent Lambda (Existing Code โ€” Reused) + +The Lambda code is reused from `handoff/src/`: + +``` +src/ +โ”œโ”€โ”€ agent/ +โ”‚ โ”œโ”€โ”€ handler.py # Lambda entry point โ€” JWT validation, request/response +โ”‚ โ”œโ”€โ”€ agent_processor.py # Creates MCPClient + Strands Agent, runs agentic loop +โ”‚ โ””โ”€โ”€ strands_client.py # Factory: create_mcp_client(), create_agent() +โ””โ”€โ”€ shared/ + โ”œโ”€โ”€ models.py # UserContext, AgentRequest, AgentResponse + โ”œโ”€โ”€ logging_utils.py # StructuredLogger with request ID correlation + โ”œโ”€โ”€ error_utils.py # ErrorHandler with HTTP status codes + โ””โ”€โ”€ jwt_utils.py # JWT validation via Cognito JWKS +``` + +**Interface**: Lambda receives events with `Authorization` header (Bearer JWT) and JSON body `{"prompt": "...", "session_id": "..."}`. Returns `{"response": "...", "session_id": "...", "user_context": {...}}`. + +### 3. Deployment Script (`scripts/deploy.sh`) + +Shell script automating the multi-step deployment: + +``` +Input Parameters: + --environment-name Environment name (e.g., dev, staging, prod) + --weather-api-key WeatherAPI.com API key + --region AWS region (default: us-east-1) + --s3-bucket S3 bucket for Lambda code (if >50MB) + +Steps: + 1. Validate CloudFormation template + 2. Create/update Secrets Manager secrets + 3. Deploy CloudFormation stack + 4. Retrieve API Gateway key value + 5. Update Secrets Manager with real key + 6. Print credential provider setup instructions + 7. Package Lambda (pip install --platform manylinux2014_x86_64 --python-version 3.12, preserve .dist-info) + 8. Deploy Lambda code (S3 if >50MB, direct otherwise) +``` + +### 4. Credential Provider (Manual โ€” Not in CloudFormation) + +Created manually via AWS Console after initial stack deployment: +- Type: API Key +- Secret ARN: Points to the Secrets Manager secret holding the API Gateway API key +- Credential Location: HEADER +- Parameter Name: `x-api-key` + +The ARN is then passed back into the CloudFormation template as the `CredentialProviderArn` parameter for the GatewayTarget. + +## Data Models + +### CloudFormation Parameters + +```yaml +Parameters: + EnvironmentName: + Type: String + Description: Environment name for resource namespacing + Default: dev + WeatherApiKeySecretArn: + Type: String + Description: ARN of Secrets Manager secret containing WeatherAPI.com key + CredentialProviderArn: + Type: String + Description: ARN of the manually-created AgentCore credential provider + Default: '' # Empty on first deploy, populated after manual creation +``` + +### CloudFormation Outputs + +```yaml +Outputs: + GatewayId: + Value: !Ref AgentCoreGateway + RestApiId: + Value: !Ref RestApi + ApiKeyId: + Value: !Ref ApiKey + UserPoolId: + Value: !Ref CognitoUserPool + UserPoolClientId: + Value: !Ref CognitoUserPoolClient + CognitoJwksUrl: + Value: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/jwks.json' + AgentLambdaArn: + Value: !GetAtt AgentLambdaFunction.Arn + ApiEndpointUrl: + Value: !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/${EnvironmentName}' +``` + +### API Gateway Integration Request Mapping + +The GET method on `/weather/current` maps query parameters and injects the WeatherAPI key: + +```yaml +Integration: + Type: HTTP_PROXY + IntegrationHttpMethod: GET + Uri: 'https://api.weatherapi.com/v1/current.json' + RequestParameters: + integration.request.querystring.q: method.request.querystring.q + integration.request.querystring.key: stageVariables.weatherApiKey +``` + +The stage variable `weatherApiKey` is set during deployment from the Secrets Manager secret value. + +### Existing Data Models (Reused) + +From `handoff/src/shared/models.py`: + +- **UserContext**: `user_id`, `username`, `client_id` โ€” extracted from JWT claims +- **AgentRequest**: `prompt`, `jwt_token`, `session_id` โ€” parsed from Lambda event +- **AgentResponse**: `response`, `session_id`, `user_context` โ€” returned as Lambda response with 200 status + +### IAM Permission Model + +**Agent Lambda Role** permissions: +- `bedrock:InvokeModel`, `bedrock:InvokeModelWithResponseStream`, `bedrock:Converse`, `bedrock:ConverseStream` on `*` +- `bedrock-agentcore:GetGateway` on the Gateway resource +- `logs:CreateLogGroup`, `logs:CreateLogStream`, `logs:PutLogEvents` on CloudWatch Logs + +**Gateway Execution Role** permissions: +- `bedrock-agentcore:GetWorkloadAccessToken` on workload-identity-directory resources (2 ARN patterns) +- `bedrock-agentcore:GetResourceApiKey` on 4 ARN patterns (token-vault/default, token-vault/default/apikeycredentialprovider/*, workload-identity-directory/default, workload-identity-directory/default/workload-identity/{GatewayName}-*) +- `secretsmanager:GetSecretValue` on `bedrock-agentcore-identity!default/apikey/*` +- `apigateway:GET` on REST API exports resource (for GetExportAPI) + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system โ€” essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Deployment depends on all methods + +*For any* API Gateway Method resource defined in the CloudFormation template, the Deployment resource's `DependsOn` list must include that method's logical ID. This ensures no deployment occurs before all routes are configured โ€” a missing dependency causes an empty API. + +**Validates: Requirements 1.7** + +### Property 2: Agent Lambda role includes all required Bedrock actions + +*For any* action in the set {`bedrock:InvokeModel`, `bedrock:InvokeModelWithResponseStream`, `bedrock:Converse`, `bedrock:ConverseStream`}, the Agent Lambda execution role's policy must include that action. Missing `ConverseStream` specifically causes `AccessDeniedException` at runtime because the Strands SDK uses the ConverseStream API internally. + +**Validates: Requirements 6.1** + +### Property 3: Gateway role includes all four GetResourceApiKey ARN patterns + +*For any* ARN pattern in the set {`token-vault/default`, `token-vault/default/apikeycredentialprovider/*`, `workload-identity-directory/default`, `workload-identity-directory/default/workload-identity/{GatewayName}-*`}, the Gateway execution role's `GetResourceApiKey` statement must include a resource matching that pattern. Missing any single pattern causes "Internal Error" on `tools/call` while `tools/list` works fine โ€” a notoriously hard-to-debug failure. + +**Validates: Requirements 6.4** + +### Property 4: All named resources use EnvironmentName for namespacing + +*For any* resource in the CloudFormation template that has a `Name` property (or equivalent naming field), the name value must reference the `EnvironmentName` parameter (via `!Sub` or `!Ref`). This ensures multiple environments can be deployed without naming collisions. + +**Validates: Requirements 8.2** + +### Property 5: All required outputs are defined + +*For any* output name in the set {`GatewayId`, `RestApiId`, `ApiKeyId`, `UserPoolId`, `UserPoolClientId`, `CognitoJwksUrl`, `AgentLambdaArn`, `ApiEndpointUrl`}, the CloudFormation template's Outputs section must define that output. Missing outputs break the deployment script and downstream consumers. + +**Validates: Requirements 8.4** + +## Error Handling + +### Agent Lambda Errors + +The existing `ErrorHandler` class (from `handoff/src/shared/error_utils.py`) handles all Lambda error scenarios: + +| Scenario | HTTP Status | Error Code | Handler Method | +|----------|-------------|------------|----------------| +| Missing/invalid Authorization header | 401 | AuthenticationError | `handle_authentication_error` | +| JWT validation failure | 401 | AuthenticationError | `handle_authentication_error` | +| Missing `prompt` in body | 400 | MissingParameterError | `handle_missing_parameter_error` | +| Invalid JSON body | 400 | ValidationError | `handle_validation_error` | +| Agent processing failure | 500 | InternalError | `handle_generic_error` | +| AWS service error | Varies (400-500) | AWS error code | `handle_aws_error` | + +### API Gateway Errors + +| Scenario | HTTP Status | Response | +|----------|-------------|----------| +| Missing x-api-key header | 403 | `{"message": "Forbidden"}` | +| Invalid API key | 403 | `{"message": "Forbidden"}` | +| WeatherAPI.com unreachable | 502 | Bad Gateway (HTTP_PROXY passthrough) | +| WeatherAPI.com returns error | Passthrough | HTTP_PROXY forwards upstream status | + +### AgentCore Gateway Errors + +| Scenario | Root Cause | Symptom | +|----------|-----------|---------| +| Missing GetResourceApiKey ARN pattern | IAM policy incomplete | "Internal Error" on `tools/call`, `tools/list` works | +| Credential provider not created | Manual step skipped | Target cannot authenticate to API Gateway | +| API Gateway stage not deployed | Deployment ordering | GetExportAPI returns empty spec | +| Invalid JWT | Token expired or wrong issuer | 401 from Gateway | + +### Deployment Script Error Handling + +The deployment script should: +- Exit on any `aws cloudformation` failure (set -e) +- Validate template before deploy to catch syntax errors early +- Check stack status after create/update and report failures +- Verify the API Gateway key was retrieved successfully before updating Secrets Manager + +## Testing Strategy + +### Unit Tests (pytest) + +Unit tests validate the CloudFormation template structure by parsing the YAML and checking resource configurations. These are specific example-based checks: + +1. **Template structure tests** (`tests/unit/test_cloudformation_template.py`): + - RestApi has `ApiKeySourceType: HEADER` + - Resource path hierarchy: `/weather` โ†’ `/weather/current` + - GET method has `ApiKeyRequired: true` and `AuthorizationType: NONE` + - Integration type is `HTTP_PROXY` with correct URI + - Query parameter `q` is mapped through + - WeatherAPI key injection via stage variable + - Stage references Deployment + - ApiKey depends on Stage + - UsagePlan references Stage + - UsagePlanKey links ApiKey to UsagePlan + - Gateway has `AuthorizerType: CUSTOM_JWT` with correct JWT config + - GatewayTarget uses `ApiGateway` block (not `OpenApiSchema`) + - GatewayTarget references RestApi and Stage + - Credential provider config has `API_KEY` type + - Cognito UserPool and UserPoolClient exist with correct auth flows + - Lambda has `python3.12`, `x86_64`, timeout >= 120, memory >= 1024 + - Lambda environment variables include GATEWAY_ID, COGNITO_JWKS_URL, BEDROCK_MODEL_ID + - Template parameters include EnvironmentName, WeatherApiKeySecretArn, CredentialProviderArn + - Agent Lambda role includes `bedrock-agentcore:GetGateway` + - Gateway role includes `GetWorkloadAccessToken`, `secretsmanager:GetSecretValue`, `apigateway:GET` + - Agent Lambda role includes CloudWatch Logs permissions + +2. **Deployment script tests** (`tests/unit/test_deploy_script.py`): + - Script accepts `--environment-name` parameter + - Script contains credential provider setup instructions output + +### Property-Based Tests (pytest + hypothesis) + +Property-based tests use the `hypothesis` library to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations. + +Each property test must be tagged with a comment in the format: +**Feature: agentcore-apigw-weather-agent, Property {number}: {property_text}** + +1. **Property 1 test**: Generate random sets of API Gateway Method logical IDs. For each set, verify that a compliant template's Deployment `DependsOn` includes all of them. + +2. **Property 2 test**: Generate random subsets of the four required Bedrock actions. Verify that the Lambda role policy always contains the full required set (not just the generated subset). + +3. **Property 3 test**: Generate random subsets of the four required ARN patterns. Verify that the Gateway role's `GetResourceApiKey` statement always contains all four patterns (not just the generated subset). + +4. **Property 4 test**: Generate random resource names and verify that a compliant naming function always incorporates the EnvironmentName parameter. + +5. **Property 5 test**: Generate random subsets of the eight required output names. Verify that the template's Outputs section always contains the full required set. + +### Integration Tests (`tests/integration/test_e2e.py`) + +Integration tests run against a deployed stack: + +1. **API Gateway 403 test**: Call the API Gateway endpoint without an API key, verify 403 response. +2. **End-to-end agent test**: Authenticate via Cognito, obtain JWT, send weather query to Agent Lambda, verify response contains weather data. + +### Test Configuration + +- **Property-based testing library**: `hypothesis` (Python) +- **Minimum iterations**: 100 per property test (`@settings(max_examples=100)`) +- **Unit test framework**: `pytest` +- **Each property test references its design property via comment tag** +- **Each correctness property is implemented by a single property-based test** diff --git a/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/requirements.md b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/requirements.md new file mode 100644 index 000000000..89d650dbe --- /dev/null +++ b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/requirements.md @@ -0,0 +1,137 @@ +# Requirements Document + +## Introduction + +This feature builds a serverless AI agent that uses AWS Bedrock AgentCore Gateway with an API Gateway target type to proxy weather API requests. The Agent Lambda uses the Strands Agents SDK with Bedrock Claude Sonnet 4.6 for natural language processing. AgentCore Gateway auto-discovers operations from the API Gateway REST API via GetExportAPI, and uses an API Key credential provider for outbound authentication. Cognito provides JWT-based user authentication. The API Gateway REST API proxies requests to WeatherAPI.com, injecting the downstream API key separately from the AgentCore-to-API-Gateway key. + +Existing reusable code in `handoff/src/shared/` (models, error handling, logging, JWT utils) and `handoff/src/agent/` (handler, agent_processor, strands_client) is carried forward. The new work covers CloudFormation infrastructure, deployment scripts, and tests. + +## Glossary + +- **Agent_Lambda**: AWS Lambda function running the Strands Agents SDK with Bedrock Claude Sonnet 4.6 to process natural language weather queries +- **AgentCore_Gateway**: AWS Bedrock AgentCore Gateway configured with CUSTOM_JWT authorization and an API Gateway target +- **API_Gateway_REST_API**: AWS API Gateway REST API exposing weather endpoints that proxy to WeatherAPI.com +- **Credential_Provider**: AgentCore Identity credential provider (API_KEY type) that injects the x-api-key header into outbound requests from the Gateway to the API Gateway REST API +- **Cognito_User_Pool**: AWS Cognito User Pool providing JWT-based authentication for the AgentCore Gateway +- **AgentCore_API_Key**: API key managed by the Credential_Provider for authenticating AgentCore_Gateway requests to the API_Gateway_REST_API +- **WeatherAPI_Key**: API key for authenticating API_Gateway_REST_API requests to WeatherAPI.com +- **CloudFormation_Stack**: AWS CloudFormation stack defining all infrastructure resources for this feature +- **Deployment_Script**: Shell script automating the multi-step deployment process +- **Strands_SDK**: The strands-agents Python SDK used by the Agent_Lambda for agentic orchestration + +## Requirements + +### Requirement 1: API Gateway REST API with Weather Endpoints + +**User Story:** As a developer, I want an API Gateway REST API that exposes weather endpoints proxying to WeatherAPI.com, so that AgentCore Gateway can auto-discover and invoke weather operations. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::RestApi resource with ApiKeySourceType set to HEADER +2. THE CloudFormation_Stack SHALL define API Gateway resources for the path `/weather/current` +3. THE CloudFormation_Stack SHALL define a GET method on the `/weather/current` resource with ApiKeyRequired set to true and AuthorizationType set to NONE +4. THE CloudFormation_Stack SHALL define an HTTP_PROXY integration on the GET method that proxies requests to `https://api.weatherapi.com/v1/current.json` +5. WHEN a request includes the query parameter `q`, THE API_Gateway_REST_API SHALL forward the `q` parameter to WeatherAPI.com +6. THE CloudFormation_Stack SHALL define the WeatherAPI_Key injection into downstream requests to WeatherAPI.com via a stage variable or mapping template +7. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::Deployment resource with explicit DependsOn for every Method resource +8. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::Stage resource referencing the Deployment + +### Requirement 2: API Gateway API Key and Usage Plan + +**User Story:** As a developer, I want API Gateway to validate API keys via a usage plan, so that only authorized callers (AgentCore Gateway) can invoke the weather endpoints. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::ApiKey resource that depends on the Stage resource +2. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::UsagePlan resource referencing the Stage +3. THE CloudFormation_Stack SHALL define an AWS::ApiGateway::UsagePlanKey resource linking the ApiKey to the UsagePlan +4. WHEN a request to the API_Gateway_REST_API lacks a valid x-api-key header, THE API_Gateway_REST_API SHALL return a 403 Forbidden response + +### Requirement 3: AgentCore Gateway with API Gateway Target + +**User Story:** As a developer, I want an AgentCore Gateway configured with an API Gateway target, so that the Gateway auto-discovers weather operations and proxies tool calls through the API Gateway REST API. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an AWS::BedrockAgentCore::Gateway resource with AuthorizerType set to CUSTOM_JWT +2. THE CloudFormation_Stack SHALL configure JwtConfiguration on the Gateway with Issuer, Audience, and JwksUri referencing the Cognito_User_Pool +3. THE CloudFormation_Stack SHALL define an AWS::BedrockAgentCore::GatewayTarget resource with TargetConfiguration using the ApiGateway block (not OpenApiSchema) +4. THE GatewayTarget SHALL reference the API_Gateway_REST_API by ApiId and Stage +5. THE GatewayTarget SHALL configure CredentialProviderConfigurations with CredentialProviderType set to API_KEY and a ProviderArn referencing the Credential_Provider +6. THE CloudFormation_Stack SHALL output the Gateway ID for use by the Agent_Lambda and Deployment_Script + +### Requirement 4: Cognito User Pool for Authentication + +**User Story:** As a developer, I want a Cognito User Pool for JWT authentication, so that users can authenticate and the AgentCore Gateway can validate their tokens. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an AWS::Cognito::UserPool resource +2. THE CloudFormation_Stack SHALL define an AWS::Cognito::UserPoolClient resource with ExplicitAuthFlows including ALLOW_USER_PASSWORD_AUTH and ALLOW_REFRESH_TOKEN_AUTH +3. THE CloudFormation_Stack SHALL output the User Pool ID, Client ID, and JWKS URL for use by the Agent_Lambda and Deployment_Script + +### Requirement 5: Agent Lambda Function + +**User Story:** As a developer, I want a Lambda function running the Strands SDK agent, so that users can send natural language weather queries and receive AI-processed responses. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an AWS::Lambda::Function resource with Runtime set to python3.12 and Architecture set to x86_64 +2. THE CloudFormation_Stack SHALL configure the Agent_Lambda with a minimum timeout of 120 seconds and a minimum memory of 1024 MB +3. THE CloudFormation_Stack SHALL set environment variables on the Agent_Lambda for GATEWAY_ID, COGNITO_JWKS_URL, BEDROCK_MODEL_ID, and AWS_REGION +4. THE Agent_Lambda SHALL reuse the existing handler, agent_processor, and strands_client code from `handoff/src/agent/` +5. THE Agent_Lambda SHALL reuse the existing shared utilities from `handoff/src/shared/` + +### Requirement 6: IAM Roles and Permissions + +**User Story:** As a developer, I want correctly scoped IAM roles, so that each component has the minimum permissions required to function. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL define an IAM execution role for the Agent_Lambda with permissions for bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:Converse, and bedrock:ConverseStream +2. THE CloudFormation_Stack SHALL grant the Agent_Lambda role permission to call bedrock-agentcore:GetGateway on the Gateway resource +3. THE CloudFormation_Stack SHALL define an IAM execution role for the AgentCore_Gateway with permissions for bedrock-agentcore:GetWorkloadAccessToken on the workload-identity-directory resources +4. THE CloudFormation_Stack SHALL grant the Gateway execution role bedrock-agentcore:GetResourceApiKey permission on all four required ARN patterns: token-vault/default, token-vault/default/apikeycredentialprovider/*, workload-identity-directory/default, and workload-identity-directory/default/workload-identity/{GatewayName}-* +5. THE CloudFormation_Stack SHALL grant the Gateway execution role secretsmanager:GetSecretValue permission on the bedrock-agentcore-identity secret path +6. THE CloudFormation_Stack SHALL grant the Gateway execution role apigateway:GET permission on the REST API exports resource for GetExportAPI access +7. THE CloudFormation_Stack SHALL grant the Agent_Lambda role permission to write logs to CloudWatch Logs + +### Requirement 7: Deployment Script + +**User Story:** As a developer, I want an automated deployment script, so that I can deploy the full stack in the correct order accounting for resources that cannot be created via CloudFormation. + +#### Acceptance Criteria + +1. THE Deployment_Script SHALL validate the CloudFormation template before deploying +2. THE Deployment_Script SHALL create Secrets Manager secrets for the WeatherAPI_Key and a placeholder AgentCore_API_Key before stack creation +3. THE Deployment_Script SHALL deploy the CloudFormation_Stack and wait for completion +4. THE Deployment_Script SHALL retrieve the actual API Gateway API key value from the stack after deployment +5. THE Deployment_Script SHALL update the Secrets Manager secret for the AgentCore_API_Key with the real API Gateway key value +6. THE Deployment_Script SHALL output instructions for manually creating the Credential_Provider via the AWS Console, since the Credential_Provider cannot be created via CloudFormation +7. THE Deployment_Script SHALL package the Agent_Lambda code with dependencies targeting python3.12 and x86_64 platform, preserving .dist-info directories +8. WHEN the packaged Lambda zip exceeds 50 MB, THE Deployment_Script SHALL upload the zip to S3 and update the Lambda function code from S3 +9. THE Deployment_Script SHALL accept an environment name parameter to support multiple deployment environments + +### Requirement 8: CloudFormation Template Structure + +**User Story:** As a developer, I want a well-structured CloudFormation template, so that all resources are created with correct dependency ordering and parameterization. + +#### Acceptance Criteria + +1. THE CloudFormation_Stack SHALL accept parameters for EnvironmentName, WeatherApiKeySecretArn, and CredentialProviderArn +2. THE CloudFormation_Stack SHALL use the EnvironmentName parameter to namespace all resource names +3. THE CloudFormation_Stack SHALL define all resources in us-east-1 region due to BedrockAgentCore service availability +4. THE CloudFormation_Stack SHALL define Outputs for GatewayId, RestApiId, ApiKeyId, UserPoolId, UserPoolClientId, CognitoJwksUrl, AgentLambdaArn, and ApiEndpointUrl + +### Requirement 9: Testing + +**User Story:** As a developer, I want tests that verify the infrastructure configuration and agent integration, so that I can validate the deployment before and after going live. + +#### Acceptance Criteria + +1. THE test suite SHALL include unit tests that validate the CloudFormation template structure including resource types, dependency chains, and required properties +2. THE test suite SHALL include unit tests that verify the API Gateway deployment depends on all method resources +3. THE test suite SHALL include unit tests that verify the Gateway execution role includes all four required ARN patterns for GetResourceApiKey +4. THE test suite SHALL include an integration test script that authenticates via Cognito, obtains a JWT, sends a weather query to the Agent_Lambda, and validates the response contains weather data +5. THE test suite SHALL include a test that verifies the API Gateway returns 403 when called without an API key + diff --git a/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/tasks.md b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/tasks.md new file mode 100644 index 000000000..28f09d1f2 --- /dev/null +++ b/strands-agentcore-apigw/.kiro/specs/agentcore-apigw-weather-agent/tasks.md @@ -0,0 +1,71 @@ +# Implementation Tasks + +## Task 1: CloudFormation Template โ€” API Gateway Resources +- [x] 1.1 Create `infrastructure/cloudformation-template.yaml` with Parameters (EnvironmentName, WeatherApiKeySecretArn, CredentialProviderArn) +- [x] 1.2 Define RestApi resource with `ApiKeySourceType: HEADER` +- [x] 1.3 Define WeatherResource (`/weather`) and WeatherCurrentResource (`/weather/current`) path resources +- [x] 1.4 Define GetCurrentWeatherMethod (GET, `ApiKeyRequired: true`, `AuthorizationType: NONE`) with HTTP_PROXY integration to `https://api.weatherapi.com/v1/current.json`, mapping `q` query param and injecting WeatherAPI key via `stageVariables.weatherApiKey` +- [x] 1.5 Define ApiDeployment with `DependsOn: GetCurrentWeatherMethod` +- [x] 1.6 Define ApiStage with stage variable for WeatherAPI key +- [x] 1.7 Define ApiKey (DependsOn ApiStage), UsagePlan (DependsOn ApiStage), and UsagePlanKey + + +**Requirements: 1, 2, 8** + +## Task 2: CloudFormation Template โ€” AgentCore, Cognito, Lambda, IAM +- [x] 2.1 Define CognitoUserPool and CognitoUserPoolClient with ALLOW_USER_PASSWORD_AUTH and ALLOW_REFRESH_TOKEN_AUTH +- [x] 2.2 Define AgentCoreGateway with `AuthorizerType: CUSTOM_JWT` and JwtConfiguration referencing Cognito +- [x] 2.3 Define WeatherAPITarget with `ApiGateway` block (not OpenApiSchema), referencing RestApi and ApiStage, with API_KEY credential provider config +- [x] 2.4 Define AgentLambdaFunction (python3.12, x86_64, 120s timeout, 1024MB memory) with environment variables (GATEWAY_ID, COGNITO_JWKS_URL, BEDROCK_MODEL_ID, AWS_REGION) +- [x] 2.5 Define AgentLambdaRole with Bedrock permissions (InvokeModel, InvokeModelWithResponseStream, Converse, ConverseStream), bedrock-agentcore:GetGateway, and CloudWatch Logs permissions +- [x] 2.6 Define GatewayExecutionRole with GetWorkloadAccessToken (2 ARN patterns), GetResourceApiKey (all 4 ARN patterns), secretsmanager:GetSecretValue on bedrock-agentcore-identity path, and apigateway:GET on REST API exports +- [x] 2.7 Define all Outputs: GatewayId, RestApiId, ApiKeyId, UserPoolId, UserPoolClientId, CognitoJwksUrl, AgentLambdaArn, ApiEndpointUrl + +**Requirements: 3, 4, 5, 6, 8** + +## Task 3: Copy Existing Agent Code +- [x] 3.1 Copy `handoff/src/agent/` to `src/agent/` (handler.py, agent_processor.py, strands_client.py) +- [x] 3.2 Copy `handoff/src/shared/` to `src/shared/` (models.py, logging_utils.py, error_utils.py, jwt_utils.py) +- [x] 3.3 Create `requirements.txt` with `strands-agents>=1.0.0` and `mcp>=1.0.0` + +**Requirements: 5** + +## Task 4: Deployment Script +- [x] 4.1 Create `scripts/deploy.sh` accepting `--environment-name`, `--weather-api-key`, `--region` (default us-east-1), `--s3-bucket` parameters +- [x] 4.2 Implement template validation step +- [x] 4.3 Implement Secrets Manager secret creation/update for WeatherAPI key and placeholder APIGW key +- [x] 4.4 Implement CloudFormation stack deploy with wait +- [x] 4.5 Implement API Gateway key retrieval and Secrets Manager update with real value +- [x] 4.6 Implement credential provider manual setup instructions output +- [x] 4.7 Implement Lambda packaging (pip install --platform manylinux2014_x86_64 --python-version 3.12, preserve .dist-info, remove .egg-info only) +- [x] 4.8 Implement Lambda deploy with S3 fallback for packages >50MB + +**Requirements: 7** + +## Task 5: Unit Tests โ€” CloudFormation Template Validation +- [x] 5.1 Create `tests/unit/test_cloudformation_template.py` with pytest tests validating all API Gateway resources, dependencies, and properties +- [x] 5.2 Add tests for AgentCore Gateway, GatewayTarget (ApiGateway block, not OpenApiSchema), and Cognito resources +- [x] 5.3 Add tests for Lambda config (python3.12, x86_64, timeout >= 120, memory >= 1024, env vars) +- [x] 5.4 Add tests for IAM roles โ€” Lambda role has all 4 Bedrock actions + GetGateway + CloudWatch Logs; Gateway role has all 4 GetResourceApiKey ARN patterns + GetWorkloadAccessToken + secretsmanager + apigateway:GET +- [x] 5.5 Add tests for template parameters and all 8 outputs + +**Requirements: 9.1, 9.2, 9.3** + +## Task 6: Property-Based Tests +- [ ]* 6.1 Create `tests/unit/test_properties.py` โ€” Property 1: Deployment DependsOn includes all method logical IDs (hypothesis, 100 iterations) +- [ ]* 6.2 Property 2: Lambda role includes all 4 required Bedrock actions (hypothesis, 100 iterations) +- [ ]* 6.3 Property 3: Gateway role includes all 4 GetResourceApiKey ARN patterns (hypothesis, 100 iterations) +- [ ]* 6.4 Property 4: All named resources use EnvironmentName for namespacing (hypothesis, 100 iterations) +- [ ]* 6.5 Property 5: All 8 required outputs are defined (hypothesis, 100 iterations) + +**Requirements: 9.1, 9.2, 9.3 | Design Properties: 1-5** + +## Task 7: Integration Tests +- [x]* 7.1 Create `tests/integration/test_e2e.py` โ€” API Gateway 403 test (call without API key, verify 403) +- [x]* 7.2 End-to-end agent test (Cognito auth โ†’ JWT โ†’ Agent Lambda โ†’ weather response validation) + +**Requirements: 9.4, 9.5** + +## Task 8: Validation Checkpoint +- [x] 8.1 Run unit tests and property-based tests: `pytest tests/unit/ -v` +- [x] 8.2 Validate CloudFormation template: `aws cloudformation validate-template --template-body file://infrastructure/cloudformation-template.yaml` diff --git a/strands-agentcore-apigw/.kiro/steering/agentcore-apigw-weather-agent.md b/strands-agentcore-apigw/.kiro/steering/agentcore-apigw-weather-agent.md new file mode 100644 index 000000000..d72ae6bfc --- /dev/null +++ b/strands-agentcore-apigw/.kiro/steering/agentcore-apigw-weather-agent.md @@ -0,0 +1,218 @@ +--- +inclusion: auto +--- + +# AgentCore API Gateway Weather Agent โ€” Implementation Guide + +This project builds a serverless AI weather agent using AWS Bedrock AgentCore Gateway with an API Gateway target type. Follow these patterns strictly during implementation. + +## Architecture + +``` +User โ†’ Agent Lambda (Strands SDK / Bedrock Claude Sonnet 4.6) + โ†’ Cognito JWT auth + โ†’ AgentCore Gateway (CUSTOM_JWT, API Gateway target) + โ†’ API Gateway REST API (API key validated via usage plan) + โ†’ WeatherAPI.com (HTTP_PROXY integration) +``` + +## Target Type: API Gateway (NOT OpenAPI) + +- GatewayTarget TargetConfiguration must be nested: `Mcp.ApiGateway` (not top-level `ApiGateway`) +- Use `RestApiId` (not `ApiId`) inside the ApiGateway block +- `ApiGatewayToolConfiguration` is REQUIRED with `ToolFilters` array +- Each API method MUST have either an `operationId` in the OpenAPI export OR a `ToolOverrides` entry โ€” otherwise AgentCore fails with "no operationId and no override provided" +- API Gateway methods MUST have `MethodResponses` defined โ€” AgentCore parses the exported OpenAPI spec and fails with "responses is missing" if omitted +- AgentCore auto-discovers operations via `GetExportAPI` โ€” do NOT provide an OpenAPI spec manually +- Credential type must be `API_KEY` (not `GATEWAY_IAM_ROLE` or `OAUTH`) + +## Two Separate API Keys + +1. **AgentCore โ†’ API Gateway**: Managed by credential provider, injected as `x-api-key` header +2. **API Gateway โ†’ WeatherAPI.com**: Injected via stage variable in integration request parameters + +These are independent. Do not conflate them. + +## AgentCore Gateway Resource (AWS::BedrockAgentCore::Gateway) + +Required properties (case-sensitive): +- `ProtocolType: MCP` โ€” REQUIRED, not optional +- `RoleArn` โ€” NOT `ExecutionRoleArn` (extraneous key error) +- `AuthorizerType: CUSTOM_JWT` +- `AuthorizerConfiguration.CustomJWTAuthorizer` โ€” NOT `JwtConfiguration` (extraneous key error) + - Note: `CustomJWTAuthorizer` (all caps JWT), NOT `CustomJwtAuthorizer` + - `DiscoveryUrl` must end with `/.well-known/openid-configuration` + - `AllowedAudience` replaces the old `Audience` array + +## Credential Provider Configuration (GatewayTarget) + +The `CredentialProviderConfigurations` structure is nested: +```yaml +CredentialProviderConfigurations: + - CredentialProviderType: API_KEY + CredentialProvider: + ApiKeyCredentialProvider: + ProviderArn: + CredentialLocation: HEADER + CredentialParameterName: x-api-key +``` +Do NOT put `ProviderArn` directly under the configuration item โ€” it must be inside `CredentialProvider.ApiKeyCredentialProvider`. + +## Credential Provider โ€” CLI Detection and Create/Update + +The credential provider is NOT a CloudFormation resource. Manage via CLI (requires AWS CLI 2.28+). + +To detect CLI support, use `list-api-key-credential-providers` โ€” do NOT use `help` (it's buggy and returns "list index out of range" even when the CLI supports the commands): +```bash +# Detect support (correct) +aws bedrock-agentcore-control list-api-key-credential-providers --region us-east-1 + +# Detect support (WRONG โ€” help is buggy) +# aws bedrock-agentcore-control create-api-key-credential-provider help +``` + +Create or update: +```bash +# Create new +aws bedrock-agentcore-control create-api-key-credential-provider \ + --name --api-key --region us-east-1 + +# Update existing (e.g. after stack recreate with new API key) +aws bedrock-agentcore-control update-api-key-credential-provider \ + --name --api-key --region us-east-1 +``` +Or create manually in the AWS Console under Bedrock โ†’ AgentCore โ†’ Identity โ†’ Outbound Auth. + +## Stage Variable Secret Reference + +Use `!Sub` with dynamic reference to resolve the WeatherAPI key from Secrets Manager: +```yaml +Variables: + weatherApiKey: !Sub '{{resolve:secretsmanager:${WeatherApiKeySecretArn}}}' +``` +Do NOT hardcode `PLACEHOLDER` โ€” CloudFormation resolves dynamic references at deploy time. + +## CloudFormation Dependency Chain (CRITICAL) + +``` +RestApi โ†’ Resource(/weather) โ†’ Resource(/weather/current) โ†’ Method(GET) +Method โ†’ Deployment (DependsOn: ALL methods) โ†’ Stage +Stage โ†’ UsagePlan + ApiKey (both DependsOn: Stage) โ†’ UsagePlanKey +``` + +- `ApiDeployment` MUST have `DependsOn` for every Method resource +- `ApiKey` MUST depend on `ApiStage` +- `UsagePlan` references stage by name string, not `!Ref` +- Every method MUST have `ApiKeyRequired: true` + +## Gateway Execution Role โ€” All 4 ARN Patterns Required + +Missing any causes "Internal Error" on `tools/call` while `tools/list` works fine: + +```yaml +Resource: + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default/apikeycredentialprovider/*' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/${GatewayName}-*' +``` + +Also needs: `apigateway:GET` on REST API exports resource for GetExportAPI. + +## Agent Lambda โ€” Strands SDK Rules + +- Runtime: `python3.12`, Architecture: `x86_64` +- Timeout: minimum 120 seconds, Memory: minimum 1024 MB +- IAM must include ALL four Bedrock actions: `InvokeModel`, `InvokeModelWithResponseStream`, `Converse`, `ConverseStream` +- Do NOT use `with mcp_client:` context manager โ€” let Agent manage the session, clean up in `finally` block +- Do NOT remove `.dist-info` directories during packaging (opentelemetry needs them) +- If zip > 50MB, upload to S3 first then update Lambda from S3 + +## Lambda Dependencies (requirements.txt) + +All four are required: +``` +strands-agents>=1.0.0 +mcp>=1.0.0 +requests>=2.31.0 +PyJWT[crypto]>=2.8.0 +``` +`requests` and `PyJWT` are used by `jwt_utils.py` for JWKS fetching and token validation. `[crypto]` pulls in `cryptography` for RS256 signature verification. + +## Lambda Packaging โ€” Two-Step pip Install (CRITICAL) + +`--only-binary=:all:` with `--platform` silently skips pure Python packages like `requests`, `PyJWT`, `urllib3`, etc. You MUST use a two-step install: + +```bash +# Step 1: Binary packages (strands, mcp, and their native deps) +pip3 install --target $DIR --platform manylinux2014_x86_64 --python-version 3.12 --only-binary=:all: -r requirements.txt + +# Step 2: Pure Python packages (skipped by step 1) +pip3 install --target $DIR --platform manylinux2014_x86_64 --python-version 3.12 --only-binary=:all: --no-deps \ + requests urllib3 charset-normalizer idna certifi PyJWT cryptography cffi +``` +The `--no-deps` flag in step 2 prevents re-resolving dependencies already installed in step 1. + +## JWT Validation โ€” ID Tokens, Not Access Tokens + +Cognito `USER_PASSWORD_AUTH` flow returns an ID token. The JWT validation code MUST: +- Accept both `token_use: access` and `token_use: id` (AgentCore Gateway expects ID tokens) +- Disable audience verification (`verify_aud: False`) โ€” the ID token contains `aud` (client ID) which PyJWT tries to verify against nothing, causing "Invalid audience" errors. The Gateway handles audience validation via `AllowedAudience` in its config. +- Handle ID token claim names: `cognito:username` (not `username`), `aud` (not `client_id`) + +## Deployment Order + +1. Create Secrets Manager secrets (WeatherAPI key + placeholder APIGW key) +2. Deploy CloudFormation stack +3. Retrieve actual API Gateway key value from stack +4. Update Secrets Manager secret with real key +5. Create/update credential provider via CLI (auto-detected) +6. Update stack with credential provider ARN +7. Package and deploy Lambda code (two-step pip install) +8. Create test user +9. Generate test script (`scripts/test.sh`) + +## Test Script + +The deploy script generates `scripts/test.sh` with baked-in values (client ID, function name, region, credentials). This avoids shell escaping issues with nested JSON payloads. Usage: +```bash +./scripts/test.sh # default: London, UK +./scripts/test.sh 'What is the weather in Liverpool, UK?' # custom prompt +``` +Do NOT try to echo copy-paste test commands with nested JSON โ€” the escaping is fragile and breaks `${ID_TOKEN}` substitution. + +## Region + +All resources MUST be deployed in `us-east-1` (BedrockAgentCore availability). + +## Existing Code (Reuse As-Is) + +``` +src/agent/handler.py โ€” Lambda entry point +src/agent/agent_processor.py โ€” MCP client + Strands Agent lifecycle +src/agent/strands_client.py โ€” Factory functions +src/shared/models.py โ€” UserContext, AgentRequest, AgentResponse +src/shared/logging_utils.py โ€” Structured logging +src/shared/error_utils.py โ€” Error handling +src/shared/jwt_utils.py โ€” JWT validation +``` + +## Custom Resource Lambdas + +If writing any custom resource handlers: +- Use `urllib.request` (NOT `urllib3` โ€” not available in Python 3.12 Lambda runtime) +- MUST send responses for ALL request types (Create, Update, Delete) +- Failure to respond causes 1-hour timeout and stack failure + +## WeatherAPI Key Injection Pattern + +Inject the WeatherAPI.com key via stage variable in the integration request: + +```yaml +Integration: + Type: HTTP_PROXY + RequestParameters: + integration.request.querystring.key: stageVariables.weatherApiKey +``` + +The stage variable value is resolved from Secrets Manager at deploy time via `!Sub '{{resolve:secretsmanager:${WeatherApiKeySecretArn}}}'`. diff --git a/strands-agentcore-apigw/README.md b/strands-agentcore-apigw/README.md new file mode 100644 index 000000000..9952b228a --- /dev/null +++ b/strands-agentcore-apigw/README.md @@ -0,0 +1,243 @@ +# AgentCore API Gateway Weather Agent + +A serverless AI weather agent built on AWS Bedrock AgentCore. Uses the Strands SDK to orchestrate an LLM (Claude Sonnet 4.6) that calls weather tools exposed through an AgentCore Gateway backed by API Gateway and WeatherAPI.com. + +## Architecture + +![Architecture Diagram](architecture/apigateway-target.png) + +``` +User โ†’ Agent Lambda โ†’ AgentCore Gateway (MCP) โ†’ API Gateway โ†’ WeatherAPI.com + โ”‚ โ”‚ โ”‚ + Strands Agent CUSTOM_JWT Auth API Key Auth + + BedrockModel + MCP Routing (credential + + MCPClient + Tool Discovery provider) + โ”‚ + Cognito JWT + Validated +``` + +The LLM decides which tool to call. AgentCore auto-discovers available tools from the API Gateway's OpenAPI export and presents them to the agent via MCP `tools/list`. When the agent calls a tool, AgentCore routes the request to API Gateway, authenticating with an API key managed by a credential provider. + +## Prerequisites + +- AWS SAM CLI ([install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)) +- AWS CLI 2.28+ (required for `bedrock-agentcore-control` commands) +- Python 3 with `pip` (any recent 3.x โ€” used only to drive the build; the Lambda's Linux dependencies are downloaded as prebuilt wheels, so a local `python3.12` is not required) +- GNU Make (preinstalled on macOS and most Linux distros) +- A [WeatherAPI.com](https://www.weatherapi.com/) API key (free tier works) +- AWS account with Bedrock and AgentCore enabled in `us-east-1` + +> **No Docker required.** `sam build` uses a Makefile custom build (`src/Makefile`) that downloads `manylinux` wheels for the Lambda runtime, so the build works on any host OS without Docker or a matching local Python version. + +## Quick Start + +### Step 1: Open a Terminal + +Open a terminal on your machine and navigate to where you want to clone the project. + +### Step 2: Clone the Repository + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/strands-agentcore-apigw +``` + +### Step 3: Deploy + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY \ + --region us-east-1 +``` + +The default LLM is `us.anthropic.claude-sonnet-4-6`. To use a different model, add `--bedrock-model-id`: + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY \ + --bedrock-model-id us.anthropic.claude-haiku-4-5-20251001-v1:0 +``` + +See [Changing the Model](#changing-the-model) for available model IDs. + +The script handles everything in order: +1. Validates the SAM template (`sam validate`) +2. Creates Secrets Manager secrets (WeatherAPI key + API Gateway key) +3. Builds the application with `sam build` (a Makefile custom build downloads `manylinux` wheels matching the Lambda runtime โ€” no Docker needed) +4. Deploys the stack with `sam deploy` (API Gateway, AgentCore Gateway, Cognito, Lambda, IAM) +5. Retrieves the API Gateway key and updates Secrets Manager +6. Creates/updates the AgentCore credential provider via CLI +7. Re-deploys with the credential provider ARN +8. Creates a test user in Cognito +9. Generates `scripts/test.sh` with baked-in values + +### Step 4: Test + +```bash +./scripts/test.sh +./scripts/test.sh 'What is the weather in Liverpool, England?' +``` + +The test script authenticates via Cognito, gets an ID token, and invokes the Lambda with your prompt. + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--environment-name` | Yes | Environment name (e.g. `dev`, `staging`, `prod`). Used for resource namespacing. | +| `--weather-api-key` | Yes | Your WeatherAPI.com API key | +| `--region` | No | AWS region (default: `us-east-1`) | +| `--s3-bucket` | No | S3 bucket for SAM deployment artifacts. If omitted, SAM uses its own managed bucket (`--resolve-s3`). | +| `--bedrock-model-id` | No | Bedrock model ID (default: `us.anthropic.claude-sonnet-4-6`) | + + +## Project Structure + +``` +โ”œโ”€โ”€ infrastructure/ +โ”‚ โ””โ”€โ”€ template.yaml # SAM template: API GW, AgentCore, Cognito, Lambda, IAM +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ deploy.sh # One-command SAM deployment script +โ”‚ โ””โ”€โ”€ test.sh # Generated after deploy โ€” end-to-end test +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ requirements.txt # Lambda dependencies (used by the build) +โ”‚ โ”œโ”€โ”€ Makefile # SAM custom build โ€” downloads manylinux wheels (no Docker) +โ”‚ โ”œโ”€โ”€ agent/ +โ”‚ โ”‚ โ”œโ”€โ”€ handler.py # Lambda entry point +โ”‚ โ”‚ โ”œโ”€โ”€ agent_processor.py # MCP client + Strands Agent lifecycle +โ”‚ โ”‚ โ””โ”€โ”€ strands_client.py # Factory functions +โ”‚ โ””โ”€โ”€ shared/ +โ”‚ โ”œโ”€โ”€ models.py # UserContext, AgentRequest, AgentResponse +โ”‚ โ”œโ”€โ”€ jwt_utils.py # JWT validation (Cognito ID tokens) +โ”‚ โ”œโ”€โ”€ error_utils.py # Error handling +โ”‚ โ””โ”€โ”€ logging_utils.py # Structured logging +โ”œโ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ unit/ +โ”‚ โ”‚ โ”œโ”€โ”€ test_cloudformation_template.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_properties.py # Property-based tests +โ”‚ โ”‚ โ””โ”€โ”€ conftest.py +โ”‚ โ””โ”€โ”€ integration/ +โ”‚ โ””โ”€โ”€ test_e2e.py +โ”œโ”€โ”€ handoff/ # Reference patterns (do not modify) +โ”œโ”€โ”€ requirements.txt # Dev/test dependencies +โ””โ”€โ”€ README.md +``` + +## Changing the Model + +The model is controlled by the `--bedrock-model-id` parameter. Claude Sonnet 4.6 and newer models on Bedrock **require a cross-region inference profile ID** โ€” using the bare `anthropic.*` model ID will result in a `ValidationException`. + +Profile IDs follow the pattern `.`: +- `us.*` โ€” routes within the US (lower latency for US-based workloads) +- `global.*` โ€” routes globally (higher availability) + +### Available Claude 4.x inference profiles + +| Profile ID | Model | +|------------|-------| +| `us.anthropic.claude-sonnet-4-6` | Claude Sonnet 4.6 (US) โ€” **default** | +| `global.anthropic.claude-sonnet-4-6` | Claude Sonnet 4.6 (Global) | +| `us.anthropic.claude-sonnet-4-5-20250929-v1:0` | Claude Sonnet 4.5 (US) | +| `us.anthropic.claude-sonnet-4-20250514-v1:0` | Claude Sonnet 4 (US) | +| `us.anthropic.claude-opus-4-7` | Claude Opus 4.7 (US) | +| `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Claude Haiku 4.5 (US) โ€” fastest/cheapest | + +### Example + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY \ + --bedrock-model-id us.anthropic.claude-haiku-4-5-20251001-v1:0 +``` + +### Changing the model on an existing deployment + +Re-run `deploy.sh` with the new model ID โ€” no teardown needed. SAM rebuilds and redeploys the stack: + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY \ + --bedrock-model-id us.anthropic.claude-opus-4-7 +``` + +Alternatively, update just the Lambda environment variable directly (faster, skips infrastructure steps): + +```bash +# 1. Get current environment variables +CURRENT_ENV=$(aws lambda get-function-configuration \ + --function-name dev-weather-agent \ + --region us-east-1 \ + --query 'Environment.Variables' --output json) + +# 2. Update BEDROCK_MODEL_ID in place +NEW_ENV=$(echo $CURRENT_ENV | python3 -c " +import json, sys +env = json.load(sys.stdin) +env['BEDROCK_MODEL_ID'] = 'us.anthropic.claude-opus-4-7' +print(json.dumps({'Variables': env})) +") + +# 3. Apply +aws lambda update-function-configuration \ + --function-name dev-weather-agent \ + --environment "$NEW_ENV" \ + --region us-east-1 +``` + +To list all available inference profiles in your account: + +```bash +aws bedrock list-inference-profiles --region us-east-1 \ + --query "inferenceProfileSummaries[].{id:inferenceProfileId,name:inferenceProfileName}" \ + --output table +``` + +## Teardown + +```bash +# Delete credential provider (managed via CLI, not the stack) +aws bedrock-agentcore-control delete-api-key-credential-provider \ + --name dev-weather-apigw-key --region us-east-1 + +# Delete the stack +sam delete --stack-name dev-weather-agent --region us-east-1 --no-prompts + +# Delete secrets +aws secretsmanager delete-secret --secret-id "dev/weather-api-key" \ + --force-delete-without-recovery --region us-east-1 +aws secretsmanager delete-secret --secret-id "dev/apigw-api-key" \ + --force-delete-without-recovery --region us-east-1 +``` + +Replace `dev` with your environment name if different. + +## Running Tests + +```bash +# Unit tests +python3 -m pytest tests/unit/ -v + +# Property-based tests +python3 -m pytest tests/unit/test_properties.py -v +``` + +## Key Implementation Notes + +- **Two separate API keys**: One for AgentCore โ†’ API Gateway (managed by credential provider), another for API Gateway โ†’ WeatherAPI.com (injected via stage variable from Secrets Manager) +- **SAM build without Docker (Makefile custom build)**: The Agent Lambda uses `BuildMethod: makefile` (see `src/Makefile`). Instead of Docker or a host pip install, the Makefile runs a two-step `pip install --platform manylinux2014_x86_64 --python-version 3.12 --only-binary=:all:` that downloads prebuilt Linux wheels for binary dependencies (`cryptography`, `cffi`). This makes `sam build` work on any host OS with no Docker and no local `python3.12`. (The two-step install is needed because `--only-binary=:all:` with `--platform` silently skips pure-Python packages, so a second `--no-deps` pass installs those explicitly.) +- **JWT validation**: Accepts both access and ID tokens. Audience verification is disabled (AgentCore Gateway handles it via `AllowedAudience`) +- **Credential provider**: Provisioned via CLI (`bedrock-agentcore-control`), not the SAM stack. The deploy script auto-detects CLI support and creates/updates it between deploys, with a fallback to manual instructions. AgentCore reached GA with CloudFormation support in September 2025 and `AWS::BedrockAgentCore::ApiKeyCredentialProvider` is now an available resource type, so folding it into the template is a possible future simplification. +- **Region**: Must be `us-east-1` (AgentCore availability) +- **Bedrock model ID โ€” inference profile required**: Claude Sonnet 4.6 does not support direct on-demand invocation on Bedrock. You must use a cross-region inference profile ID. The default is `us.anthropic.claude-sonnet-4-6` (US profile). Using the bare `anthropic.claude-sonnet-4-6` ID will result in a `ValidationException`. If you need global routing, use `global.anthropic.claude-sonnet-4-6` instead. +- **SAM template**: The Lambda is an `AWS::Serverless::Function` โ€” SAM generates its execution role (from the inline `Policies`) and log group, and `sam build`/`sam deploy` handle packaging and upload. The remaining resources (AgentCore Gateway and target, API Gateway, Cognito, the Gateway execution role) are plain CloudFormation resources included in the SAM template unchanged, since SAM offers no shorthand for them. + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/strands-agentcore-apigw/architecture/apigateway-target.png b/strands-agentcore-apigw/architecture/apigateway-target.png new file mode 100644 index 0000000000000000000000000000000000000000..979ca068605460042ac0126faa11426f9c2ad7da GIT binary patch literal 71790 zcmeFZXH-+&w?2v>s34#sARP5RC<-(LX+NmFM=Y1(uL4LY0@DOS_nm?hF(Gk z=|Tv-7$9&r?>Xnb@cZA7_ro3I9>xgSV`XQpx#pVXna^4~;c6m<>g+h z4$gywfEc&}p49!&ChM1y zZFFk(>N%gj-;urtjb_y>*ZsbrS~I~2dwDNxhJ1(E-oB)`1pP#tqA~dUZ-!4<^f`m& zv+^UarEid|F7~90fQdj!3JI-}x$CgHw$Q&TNKesY$q!GlzvPk<^Laj2JYn)bKGJAn z%E9_7932OoZ>_Q37gIb{<}+m-3sZYJK7N#7dAGInHk1F43CErA75~ruDg1VI#oGzn zS<6ZI^VIX^jSMv+wd%8#;+6x$N90AQqDkrJiO)X8GfS^U*H2i25<&NG)NYt8{zepi zVT|CBp0_B}Rf@l_@|+=M>;zj^Kv|2Ippp)Y6NaMgsK0w<2Nm-X-`_i<-xCtrl3CsC~UJ^fzg>&uRX-U2d=IGFY#;C|M#`bwWm0E|Fq-c;Dp)WT>tNTRDk!3KRMua@tyyA$IZh1 zpF8l9v+(}s8aL?TR(fa8Uf}JPvz(qA4h|97#p@bQO4&|#4aHNnr<>Z0ayLIn-hBLcvvhU$ z96Q_Dx7oG1>=+M+Z7Di>E%l}fW=_mZ%rzzp>hY(1_<)0Zlm6Ym|0H>DnhU<7x*z;+ z(?3`5$mlzlLNDET(K=v{dp)^VMqcVl7dSv;1Hm0hoNL$r{gYKaW+*%VYt+(y1sZ)M0$a4AN$B>h<#7 zOM;m65>@?3?3tI%c&wMY$x-*pGa=b+walkPyS81cjcK>zBr`_pgq9uaDe}@&)bzs% z3cB~raWb2`fjJ>Ti>hh4!`K(@=V0j9|H{P!1o5w%-s@jo%zGYkSubm3*3YF@l6J_J zr*;s^TBdJIEd?3tX}^(1e{{Ad(ZZN* z3_8choc5&=o!uqnsRUv zrqZdAdu(RUnZQxHSb>G6p={c;8@`=A_r_cOwu9 zVl{L68Fg3!A@d`e89tF^_lbxE#}{ODBvW>jb-NoH=3s}97G?)O$S&EtgG`pR`{~}0 zA05IBUR??@{kBoF=jr+KE*7CXukqiRd4a*|u^@8Rc$X!% z{27g-FmDLT=8O+#U8w6$zmS1Y$Qe~}7N@||(9;(_g~f9<(Pej zV$U0xC6qIm$)KVrMyqXKRQL0ft;eghLAB;qeig^WM`BMFgOc^_Fn>M}A0G{8ZBGAT z^jX8IE`^^rK3;^0^NGPxs*7!c!B~~!g=gwUt*4^x4LxZqQOz< z2=mm(sh#``D)9#=2jT3n(%9VyLZ97b!D$YIex`g{V;zB-%~Wo2+1WGqueYg0jgaxRvdPt9OH>T9 zI-Zne0O?ik-w0pis?!!QENeR5iE|5Cw`R9mT&LseylnS3N7!R>uW2a5-FVI01Fejn zY<+pe#PX=BMie_nz2remOG4`sIxZjM>APwIk9w98RsWmK=MCPlL(TXIzUiQkfPYs( zNB4B-XHZtki>+X4Zg6F^7)P4_)T{HafGNp^*q!~PI%^(0L38!bc~nwQVSe4+ov4{( zu?(lCYn+hbIS!Sb;e++<{P|oOdu|ItDZtKc*)dxIH40#rS^l^dPc-B}4dcGAD?jZ5 z;-BlZqK#1&c%Y`^GGFB-exh(Ff>IYeL%ynkHH)R`bx2A}=i1GD={x>YDBaUyCbUN;cM}8 zWSHydM3ZOml2N|#Q#$`0<>~tBS$_u1u|!6|TNItn9fr%nPpwR{J(EiWi zkmmVetlPRI8Gdm1kiuMSdjoOcmK zW6wKlxW$=YK@SFoK&rodpFlI@osLh)xSB=Jn&F02MU6hHvp!2I6~nxo*j042`A}7| z~~@PT?~TfQm)VqCWKA|w2xU4*`*pI zKwTC1On0j?U7T7OiR--EGWrezwJ5$1~>1#Art{r~NJ>ihbX`v-waJo`2(WU7y zQ&r{Mr7m>ZAW@4mypk&4l|;H!CvvW;I@s%V&9dtB&-S__ipXp33F)kND7)>u>heC! zl7-LFAZ*$j&~OQ9>tziOluNeXNRDpW&m-DBKH7Zjw^bfFl`=j3NC=}=matJlITteo z`HQSDiHCWuF>+(k6cfq@*K20|$uQX&$5c75j;7_$+$V18l{q<0xDK~W9xvku#as2b z=C|rMj)^~7bDSG2akIj79Drdw8})4go~H@ZJ12YZznibyb0_KRyr;60{&E&@+^Z|b zf4~Vo+GOWW#M|H3RMgVa+FcFH^lU{!e6biSxua50jz-v_YoAM)jjSkToe!z_k?n(D z^|y{q4t%yMV1`9=&1V72U$RLO?{?75TN;?8b4*~Laq4}}9<8fbyEVh&M#1HZXgdEZ z2Eiu2GE`i(eCXVW?W8f2N{8waq~nWWCt*AeeWLI!OC29P zyycHW>yAaxGf&iH7kPRaeC2R+R=M7Dx1HMH{Ns9u=;b{AiL&z#{czt>z((5`C^9IZ z^K2d(Eik{&KC)F5b&N`MWmFe6L zK^of;nRj_@A-K21sWTw9$s4rk*?dWr^u0sf3+hK{f456B7X11Sf;{87MKKSAwsII+ zIo`4;#Vr?U*X_)uyZFBld+=4TW?P>k8ak z?Ds$})13#(s|cOG)88XN4&G9Mh2VOnSecm&mzHNCcLiE-XKEy{>PDE~yi|fV5!Gyc zF$uSV4V7t*VjhXiQwkmbw98T7n)2Ru6zNXaF}kDE*jqDF5a72qob4H7Q39p5U45Ay z_<-9}0%5IR=7mOfcnEvB>lJF${;F10bqYXs4ZIc`9ShlN&&k?Mwf<}lvWX`~dd0IS zr=s=kn=pe!JY!p9Fl|p5yz?XK(2Is@G^KznrM|=?VPgDvb;(=G2W0V__%a||z~>R} z^dE6*t73~zC91AW|%jf*u>o)&QQ)SFc|9_9ITzsk{z!Nzr}!LtL>+vM07rv z_~0gNjapVQaCq#u#wmWbn`C5=G!PqUk;d;747-sF)g+nnjuCb1h;%6_Pwdb?KWRicVz$Fq!ld>`6^3Ys&G zjPxFlj`BmyEGiJ%nLa9xSmJf%8Kf|36_%$TWY$xEKl$Uo=}U43_dxcA$ExvL4X`bX z0QY){EQj6=F;8L(v%(+6U-1I>L|8e?2Bwl5o*tdhz^`KA`*j_ZL_!?nZ`2JqKnO4Ok_dW|>1vKtj5$DIUnRCju80Ux| zoP1fCV-M@RCwRPkOF`L5NqIK;vWXk|?JG)C#52f(pC%)?YZQ`{;Yu zOOVkz68AvxamjK?z{-IaXxmy0V&r@50vX=^20h!zh>%6Y!iv@aP{%;=o8RhK&1u+l z?FD>mA;BthLH~4PUq#WA&^jT5(5^sTRfISry6%rYMnyFgN>QtGe#Wvk2g^?d|1BRp zo-N2jzH$VOeT^D1_|`LEC&9^7Jy0L6>|IXLHjK^iMQd}{9Jjcd#a4q>XWU__hCfPz z@{bYr8=1@}A!j%5*!#Kb5gBq4PkER7&B};?Ap2!Ha}+QVzLX8^CqTfBQn5XpXM>mW zr#v(`{{y+raH#^3Y{jLbHW%OhSyZ}$jJGpeKN!`pL`-jgqCS7(6m*!YYjV1gF4(=@D@d}(NaBtf(o+%kHWtJb z!f0cD3rnRwdu4!?ncmhBbZYfD>EbdVb!+X#I3LvIE6|}`Mu+&l#5e@cu`ZbT)vkHA zzziM`iP#pQk8#;&s5^#=rAs&+KC34Sps$RTR0gvA{;!BL|4i5>kM4FxkT~~N;2D&f z_^_8Lp9Uo?56QyhAe_ERD}w7V#(!)&^Re^ME#G_33^jkJYm0idn8D$Q-SX3Rq=aG2 zWf#38#}*TtkI--C_e~q?>MtXmC~Juve+!%EDG#En@ROJ=JL)+_hgZ-VQX4)CO-Ak& z;Oj}Krawk)y5zt@Bk+?hfTe|RL@CJ68a}A6m$kC~Kjy}qSR=eh zGgT8`L)-Z3Py(gyB@$=16`;G(kK13o!I3OB2kQ+_2v}%2>5Qbzpek+mg+Miu&i^*Y zyVejSlm+Z3+kZQv;t#8k@S}MM3YuLrgYW4>Xal!kE9ycg#}aiX5sg_#6r4kevkOD& zUP1j?zwJRzZh2>L-`WLxF}SDjYXZ_g+>1tj3aUPihS=0fm9L?fB7q%cXJ?|4$iZlw z2gL2~EKH!5bYp*XI9haI8DoT(Rt6PB+B?WseNER8vA&gOagqTDnW`|G${ zw1i=)*byJ`r(Mv1#S!duZtN|e9Mx00bCi=S+DX;zbJJ-H%RHQa=6XQ6-5qpFN2nrr z?3hTU5(;Vf* zt#Ipe#E*0cORrCMiR22@Pmk!inRh{!OM8Bum{0Pj&s0kcK&Kn?udN!1*pq>(^gg2X zK}3K3JdE@ERFdLGoAlVE9$!LzRd)gIK>(6HwqiMpUD|WqDe*#Nd7N~sI*BrPk`B=8 z<-gLI!DPXrJO)>tK5!Pc_+P29vn+0zFYzaHevX-BCL6c;d#7SJ23<9Y4RR|G!LN_6 zCNS87_cyE@y!JZ_m%VGk{N7yrQsK%e`7W`CqC8&z75Vw`pW#U}%)4=DZ1~#7ThfFR zr&|BW%qMBOr0tg?v}D#AK+7l#=6tx1pVyzk?&;K9P`P3w$qJbn=rs>3`Pg}kJcvz7z|HD6*I;@wY?^qyB9KuFO*aCFP;-uD`$1Tx9|%iK1cehw!EKzeyis z11KrETJ{I&mF9sEKuHGMec9`mOqKqfWFEk&Sx(Y^6TjL_L_yza-y#xScsbMI;3flP zS+b4ahvUo4APz34F2Ldae_ZgGR2y>J$b+@=tMkg-rQrIqh_ zJs}x7C|)%0!c%3s`7Gjc9}WOP${Xp|usFd|H>5awDz{=B==N}|$}_@fXVOo-(m&Mw z#omiG=X!$VXi;``dApU#3F>0*tIYX%nI zq0ADMJ|KzB%Ls)GyL6m#;vcROgSjP;c` z?2Xf2_BrbuoP319J&xrKJ-v!ze*TJiK4J?f`&SMucvDkDkBNVI`BqT{N(nkPywSR<~0bXeb}hh z%ujUK7{3tbaIwzB&-qtF>H?uxx<*g}p0&Ky>P(b~U6x|StWP;N)ZM5zI;LhFZ^6~? z`|RLo(EaLP$IpV^1Y$>`Si#hr=Wi3gMrI~ea~2xai#k|nJiF{xRV09AAS>uJW+Or( zZ^)luy}+lvg7;u(xzUg|_2cCSdYF|kb*Q;b_j1hLe^&&!`k@H83|E5o3r-j))*ZHK zX5+PO~^1-#@S+)1we+!XRPa6eR&+ki_5b(r9Jvc){%8;#)7$>gqs`*nU7 zp|f?)otUXtXvure;PXd7)wL?ET>H42NJ6D_WM^x9wepq_XYH;ZgT#%3!)(}gN$qaL0n=8vAs*y0eA$THjvxeBT8h> z^iI-#f7~BrqimTOoES=ZblwQ*|{M6gT~Kk2k8} zOC*M>yMm9NF30DMBscw?7&9M4lYZDc4m$j9zC1CS&UnR;Q9uy2`%SI}FMM72S#hX3 zuQ=uSa~};~b1w&W!_EO>PoKTQmdU48NXeP7;Rs?38t^?JNcNm`5U$BTD={Uxq{f@( z^?+Ac9@E;IVIiLh;rF@`8f*o5KM`OpwHWn4o~yeVZvcb(A;bQtG=+E0L2FiqnsG*0 zWLW^FLkcc)oejysbX`>nBaq~nBdQ=f3$eZ_#d)Xb8UVGyHLOrmzf?MCH%jMxV5&-sJL#{VbJzC_7KG=yXB&%!#3&2zUF?m=BT~XWE7VI z8Br-KDdNbxEUDxaIS^F%8@4J5i5Ou&tf5GKc%iPXdP6mjp_B)dqyCtC2({+NtHZUaqC?CDGlk2U`nLvWdo6y(ffruiv%Fr|H zaOfm>iqUC2k1J>`EEBQNBu-C0Y+3I@BlTPy!}Fm;NM-J-ahc0COJ%JD4mgHZ z@9Y%iQdEf&Mv0PF=jm$)Y$x9jdamy~>zjLj6B_R>x}{m5->8r%c3@^m^&bJnAV%R8?jBJbHeBZ&-sHI z#mpAGw$kO|r00s=?W%^@UEO<^jbQQvj?~J-+1@(1ubktY5pgNhcJN+7lDHzHh%6x% z#r7Vb&8k1X+lMG*uy*w@pN3OUNJw2MpqgWlzOtZeGePKNd@e#$O91*bQTE@!juR~K zL^7uCqnCRa-cd|nlL)K@TI##Qspmao%mfRF-9KIhx%hx*&ayZHHtK3^TI5t#RUDn$ zvilX3rKO+M+4NseQr>%e8GueE;f$G=uF+&9Bp~@Gp-Jp>XD>=nSQxvA5KyLOqXIxmG8aAeJ~c!i6l?w=Lo$oPLHHvjS zv3GHPyH32}aQqhOA{l#$D6{I82*FNpy8Uc6E-gg?v(z^PjvSY$=K z@G+C70}6VFt0xv1#jQU=6e}V_RPK4bHdH=k+OGTUQ9%#aQ#oMwN8A34040BPa@LQZl%QeNIsI0qbN9S4LH(VL zozX!Pw|U>2RRzqSin!bw%eCVU_MDl+)>LiQ#LERtFa;-o*z;y|guEI@G`)i@2M-PM zY&eyl3JB`d&$+TLxb9r7) zjiO4lqsy=ZBZY})%rUMKLI-LeDpvP3n}#;_()vhdIRFJ}bqSVc4CZjhUN|Jpa1GP! z5O-c=IwbOVfMeXf+~~axXVOEHzF`e>ly%GP2bF|oG`F=-9x4BivcT*Zi@+Ri2I)Xp zvqu%NS?qWOC9Dtepl{VQ5%IbCxl>Y_h?}LKGLk+kDWA@$;3)!gMJM#%xuo8kRkr|; zCQy;OC2D=`N1BN2X_TWMM+st;^wbKmbT@Q|*DJZtP&u z{V!h|uo8l0!%SB4M=0)u;_p9&_mD!*MP!jwJ9cZwK~2MF?;$y6J<*Uq)hFvc-LEgF zZfoibBYrC}22b!+K%LIbv9*;ouCCNbtVpgMb!QK)eZn#om1k{Q*xRaJ+pw%6i|qT+t7K|stJ_0l1l3DhtACL(IQrjOPtTa$luR6S2@ zgbOo8rY`QH;I17Xc8e!fN$zrbD(1iF;Lnso43(O>mvz}u|Fp_(&7<#};P;F$J1a41 z0og(@7r47?ILChfNd0u}DS&RD>E`HV0$nA<|GZ>ty4ip_S)FU30BaXlW%;;MiJd$2 z63G;!eNFgG{%RXg@i<(R%MNI#YKqGd^^RN-5K{GhSRY+!wf^t8QwhCn6YnJT0Saum zTXFts>(o8KW(`hKOQbKk)wSzGY(V1FHX^(X92S+@)Y{e{wFh}QkpsizC;}z5dzU)bRk;ZOMNR!yv5LPc zp*s+Y^AxMYmsQgF^TV0Sug>KAs|uVanYdj{qjja)xxP-BBl?`II1kNv6teY?;>sy9f+~#}=H6EL#k#lQxbH^J;x@c3L;mWjzrw9D7s#pnxGHV% z{JoadOFPRJ3O_j?i_oRj75RxHIs&c5E1L~>elSTYT5`rg1s_jFP>nnx)92-EuDUOJ z!{a~hlLP2wrTcn{i^vF{vH1S6xD5J*H^!!^PimE&d+*+TIc_`|DU(MGe|Q3J=-(&+7`5y;CW4;6H8lh%>iAB4ZtsEENM$?}jU6~jTF6)I0+>{o$+_5B1@DgCY|J(Jy z!R&t(^1l}HKYZW+y3GH^kpB*s|NjW8bzPM)*(JA5G4uK6%Y|{mf+zJbI+A=ZYhy;8 zG!OL`eOGW2{l$s>tig=H*Dd-i2xin%Pf9{-_jZw`5-BX8bisPztbM&Zd1gRE~zCH?0pfwydzp$o79yJVLY zv_)HL)Ur2OXbvh#t}-hvfw%Zxf?w8DxQ6k`!6$18O*70oX+Ck|dy`5YP|HbZis9!v zfg?iHCE5qrjD^_pS$L8c%uL?bE%y$;J$>>(PfLB>H!c16EC_&UDHywawO5Y*4}E2 z!iM~0%3h2OKU*^Kh&x#en}2rku!5xBMeWP!7l)S2io9tq7N|izF$9Gr^XG$Lp6tz& zV=0o#k?ks`YK-b7WIrV}R!0F$Uc7AOyhvdeiYK7iQR|@cLbmE=?^2`=%A{J*I+-yL z%P(ANN3@>JYv<1R;6NKS*1`SC?a%46Fin(qb>dy6UG0+Sz;Cm)rQ^oG44RB}4FlX$ z63N)RsmqYdhFNQr_e;fuH$ToGeV{}g4i7JgRO^dvAJ&awAqL-C!%ceIR~2t{oWBq% zc188*rQ8&lI0I?lCOa4*sbnSv7<)J`J_H2wTBW#yfO> z?pn8t?vKl6)g>Aw6a%%Ke%!nBgC-^|AI_1{==_Z_dGM2+1RC$}1;VD&*zSkspDqO2 zsr64ZUiGP59MiKC>eY9UC?e?M9u69M+3PH0FCpE+=@ZmV-e>>h(sJ$V3*wnj0_1lpW|}IB)@OAt)I>K<>%tcU zX}R+en-bQa-#!~8aJS9zMvw`XGTf@<3~Mskz7KANS`+9QVm2Qqb`ZD8U=P}ue<90; z8_s7VPVb(ptvadbrvBY2e-RY;Vw1A%;&GAz1)u=bn29|LekVDMSNYyXAGcTY=mzGW zdU%N8&Mkkl;Dj1B+}?MFu?^>E*PYCvxV>3&7w3V3wHbkOFe!bF&ifXh7$0tLdxLl9 zMP*oZ`Cj06Qr27+`iZ;zM%hjL({25vaBu7tzTxX zwcNGN+s1uDbRp6A>$aKQR9=rp9KK2Oj(_XDYfWIWb8%*&iuR($KeCv?9Hhr57Z~!pFPKB$;qwMa+Qb=e>Lo^owbLkC*?J-p^}* zN{v*OE;unBgJx2WR)u@k^{s|Wx?>HTDUbR7am^yYHI+qS9xokF2vhv`HDob$_Brz@%+QSmgnIwM0;FM$&lUnO}CXDcJ|>Pb+-cQ ze(A*ce86z{1bhHh4z^&5Uk3Y>b$jvBg6XWphXwAAuk&A!7KAk-@4TsIOw6bA+DV=Y z+*?8r5;Rz)CX^d`{Pdn~dCJpIEYrTYtG6cHpGwozr;o5fu0NEBZZceAEjH0j6g1JZ z9Tlrq5fo9sf!I(T&X>87QXx(Cl70`r{_v~O%!y^7Sixi5$a``P1o43CGTKQTXT5Jj zZGtU}AAP(d2X1~%{LIQmaV0K6!+a>j%c2kRW=)@b`N{9=t}b$1CO6_aj(#sIKdLG4 zeAh*|ASh7=wYJ0`)V*^qy|?B{VQqN>Q5RChLE{yF>U!Gc;^EvzfLTbaWb%*-(mT4( z+5yqeN=6HGB5c`-D*sY1L{8nNfjr?XB0g@Ny`G-8IM0|)7v+avYY+5<`jTRjF8obD zXtuLjG|`a2NzVy4`lflUq^Vz>Q!0F^fjF!#LWSLkD_Vt$g;a%x+l{iPd%Igh?0VeI z+I}Y@j)Mmjfr%%J^WVew?C2N@53*uu9`RK@5+N9WGsq<9*b(jAEJ4B)W#E{;vb>L4 zjP&cE5Jd1WJ<6^M^qsz??Q{{i&%Bq%I@ndl?iFMv>Xa;cZV*zqPCU8s(#epQAT@^! zVrCM1{O7jU?b{IF#nJjlP5VzCy!m2vRrQfq<;*W@0t3bu$A5y@B zCY>|ip)s99WVhj2a}^t@^_aZlwEvA{w*jhM#B<8-(TDFmvqO1@kC+eazkYGn_x$=8?T^ zN+Uo_l;sOE)Z6oAT2@L{4qsbUnEgFd{D2#?WAkisz|BxQD93d{R{eAn>w%o$6`JfXG&DhO~{1Y-uGIdO5x`357U=d z9*fjB$+IVRGiY>liD?9VU66JgGehg~;}#Ho2xAj82~MVMeL}W4b#iYN^-X2(V}}&T zfsHdo`k}6;XwZixbk+MJ>$c8*i}~c$jwoZtLY-Sp`ojIx#xoSXhgKO+@F_D^6}Vul zTk5BO;?Is3YM#w-rj+7}(#H@47p|Kni=MOUX`g0mYAvHsuy}*d8U1-qBZO3fDAu(} zQ--x+3raB*JvT9bR%bN_;QQ;dM^P3~T*P<_m$usUH335|pDaE1?cR!)DW*@Yw#C(V z5OM`cq5_^-hbXf9nJ&SWYR@z7cHX~6{O}x#ZghPK zILZvi%C9=Q&c5qi;vjxevoZ8aU}Id`amI}0=%_ai1393M4nx%9`$*Ei!vFayG$=+< zCrj@KCzyu&!DCFIGgbdL_9ImtV$|ZfK1!!ZKG<@zERejk^!Lv>+h&Y`OUuez)pdQO zNfs3{-y6@)%kR>@NZ}pe zn0Ec)USHn$d+7Z(vMkz$kPo#$^1jy(p4i)|m9{xDv2U2Ir=y(oQF8ct#KHb}rsX+E zfjzM<1d^+6+^TgfHqdyCI%pTRH>L(BD1o~sS=FQ*b=IaGTk}f3=HC4ToKcf`woZMU zv?{TM&P6qr3V3d^NaeOF)$X2eQ{?(5V0xJsRvs!J%in2_nSUq2$>W%8 z&lfZ#x0WT92en*rc&22)+e9?BrcI;QRKl!*+xv%^_e=BSy>WR1&ddQMLJFWfmHU-a z)q>tp^;)++{50zjG#9`DKwH#`%v@pk1eGj(_VuCr)|33;bZFbNie<=YWxqum{QMKt zcJjGGyw{JR4)^ORrCPE%>w9J96Lpkw`}-3iL{%0V=iPkcRYwjx+2`&D@*M@0r6#m@ ztYw02;VZ5!yZu#_nWR6}D|24rAr!DWzw?&O^_r7Qg7|stc2-NtvZAE%UN{Kcf16bR zm_WhJ_9!PK$E_c$G^eMgsq+L3i;gK#`46pqL;a$a(H_SC<-h z-n5tsuh-LnboBR-K9|>*DMlhLu${BsA^OPCx8u+BpPlZga+_OxZ`lnGI^)fP!domk z!YJ(pyt1e`Gfl6jyfW!hK#y3p5UFd|8M^NC>DdK#_6;TWq_FAuD<^`w?1Lt?GZPq? zCWQvRv}MY5e3yb49l;H0Y0Yq%n3#AhdmbbUIHc^Or*u4LJd#njfu9xluipX~8&~?c zPEOxh3}!Z!+4#HC`-%3l7VcU5;&;%dKD173@KDU*cl+VxkSC&s65h|Bs9&!~;`NTN z@P24cAsf(mTSnaoGpt7#-S;VDPEre)XFU%y>*IGR=iS+UwDx65-na{d$)R6neN)HbWwltlHO`!IfSeBBIPG=L)vxpPi`Rve4{Eh2ySC(c;*Oks(U|c_5qNHFz@Ocv zFOZ5JnE*C-83tYhQBge^z7&vL_g7J(d7rSyb1T9&phnZ~Bo{yFFr~(`=b@KDJW~-f z;wyfc!ckPJA}nNf_NAzBumvKqOG8zR$q9atxKX>P0u(}xTmZ)V;5uQ}A?U<<{$nZQ ziR_PGE{I27?})%fGgL7;&Kni1!IsI@mK69U$tNJzH0nVjR)V1B|yLP_p})X>;?Tlxi|h1 zW&OcWYv|c8k+Ve2U8~d(ZCS-UYua7(nYQ7yRePb9^X`iMNC00p3gQ5KxKb#a=`7Y) zEz;`y?I5J*mr3RRU{L5_L6P*5$yOvve*qU%R$7V_esG3EqeCTgM>ili`Sa|Ec2{(6 zH@p!7qPN+)TQ&nx?_fXuiH81R@(b6GYGY^aR&~pWD-HvkzO9wKkd1_FwE0Bb_vk9tC_0y-QbROQ|74adjJ8jWM?zH}@Hqcer&)wsRF0-5qujLvHg zN1gM2Xg1iHE#h{e)^O1ftQ^k1#6-*y?Y%X3Ot7uOYU|te^_`?5`M~=T+v_RP2E++m zlw)@d1BKNSCmdcj4!(_{b5B-UKr1g)HL(4;%l>Ga)F6DZ`m4&GtH6_iF9xX;Z$t)) z2NU1Gpy60(r9sAD&XhruO4t1d1&>bNv-gaCj}5kb$@xIq1d88Xq}^R5?bLO;ya;lF z9Qsy$%}qZY_xqLMmSbqZ7ey9 z48$x`PPigCEiwZRV~yax!mvN@XWovS$rar4UJcDntm$2W&~tOGc~3J2{SuS5WQ;PP z^4z!;FJbSr`RhJd)k;7&OfESbWq5FiXF7-+F4mexY?cih3%CdwbmI0tlir(n-4tR9 z9QErgb{-rT#>%G%0u}h5V>88PU%%m=Q}fGsiY)t2Ea%L1Ph|!se}fugZIwZ}@a?-M zrQ-C8rEjjC46eQnP!;fs2%zwzL(X@&Pp>wXj`o&w|7aJTl6ka^zvF_&3ivFx+-o^rMBYp7Qj;OfLBWyJ)F*psvQ@2_ z6%;%&5y*KCJB{u^oB{>B4#ITm)0l@1ByaoM$UmF^Obb%f!z{>Al>}|atBS`8&U%9y z&yMZ%>RhE!bHDk^Ljg~IBKTlQkLv7XSPXw_)Wp?%+9ERQq;>5!pQ3Toq&7;QZ%=Ui zr-b$7*B&Ci678HG^3L8amxL2dRgbB2!KQd)nt;TH{WF&1T+ZX~ebdJpyw~;PB_{*- z2W$4$2~t8u9JfAk)}{$t{~!Rb+Byp%MPAE@GvAZoL3(CU>Cn-HYmQ1-9&7A(>GN}r z7oRN@Z7(mCu7BZJBDlc6rk)Z5ksNnY+tdgMbI@?)m#KP){YR9tr^N(rSNEZ+=DxA_ z;aOp6MTU7BelljlVzXWe_L6r;OBV#*1u;-W-Y8stA4Mu z>oBB;es63sFpib0x`W7<7I2Hn{P!#w8@1O@8|HK`Sub^BAEz^5f!0EKyc%Z(PC+ZKPI z-=m=O9jLmr^t7^rjxDRltRhKJGviDwMM}a;iE^f%;Y%pzs5uB#Lsd~B`~GB)p!soJ zzsJO`%-Dc%ck=VMAA6E{W$|hfBK2oW{nCWos?U1Awtc1?J2rY_WW=?%+7I^GTdBhw zU_0Ud7+0{>tSAD7<8L`dSE%kPAAp?grmgTwr~9lI14fg=q&*rYN(a9n znII}6GSaHooXSZCmy0LQymLKqzSN>;Y zTc6L)s|z-mHEt@=0EZ`1AxFE1HhbCG3v&!5UZaV$lE%2*7OM0-HWTQ8U+Ey@ZhBJ+ zTQ#lQO*2N6kiiuH{N6-^KLrla|ymBj9BUN~*|H-Ab?VT0AlJ;yOa zjK+bRSOqUVOlH_2mfTnt!o3>_0Jtc#q_3v9HbG_lHhy;IK3%VqdA7(a?0DSvCl$Dp zMgq+bJZ!mFIp7xI@aW3@1G{04ZH}Ijn9~$OkPIGsmsg98hjK<^$lkP9Fn-ZWSCU@8 zt!kxMuJNkFL#GB%s@6zR5WMx_)8MuKUvN`TPJa|D$-3l<|xuJp(FF8l6V|mo-@Lv;EJpq+Ab*kXyXf_Dz8S7USinkS^xb*Pri- zU=Lthjq%gdx)^+X3}mw&RgdDjL1Jhu1_9Az`m6V==t9Ol{_@%(?R_xIIytJDvyipC z)NWv`f`bu~ulw&?#8oM|jh?8A?&Rn?+e|_Fgh8OjUO~Di!1&=st*T^!aTRlsqwjnY z12czIz*S#1XJAELcXZ}RXoPD|SN7M7%If_oeob1%BsffY?sl;gdJXIZnQb5o|Ti-SIGd;UpsMOFFHQ2d$Q|H{q)6dr9sGm09dJ8>e?E{@@3vG^M?Xea+mP#AIdUmMXe`TC{uE z>Lr&?ViYDkp2O@s(%x#3O#)g%(jD+M#N7|Xj*>6O zaBV4mX>>wk*P4v8w?JDR57X&R?<@uuS2UdddCN_NbtDtvT650ERrcEd)L5&{GVq0? z3`L43q9a6I)A}{_u87g-!y{hc=T%7f7>dG)FTe!neZi4&NnXc!3W1+~4(Rh{+x#k( zw3Dy1D0PFi=~TfN@^6B+2gZV;8jW2m(Z6(wxzO0G$mHeG35bG7vO}g01Abk?Lwo%J zFjK-q^Te_H>y>ApN_>KnP9)IRt;yCVTzKJd9oSdnbmWtx`7pY!T9-xh6Lbw2k5qI3 zUswBGZzcUmhKT1zj?|L&@$_JC*^eJFWsS$`v_4Cw6*%_e<=?oPE0@L3Pu=Q{W}CXk zE8g-u?^h3sw{i|tooE{cV#s;Cx9Jh32#I-L=WXUya4$=|GtXtqyghu)_6f!8(OOJxZC^_kHyulwZ!)?$&t>phM1qJ%y6 z@Xl)p;2DY4Ztw`gmK5GXag0$!W$_OKslAH7@+WW}$7WXP79X52_sn~s4%`Ji#;alc zF3pDg&unh|sg!|Qpx@8C>YBPSNRN`PpXWko7Am-ThJS;J<)k+3sJVApLd3i2a1)u` zv~iA%mmh;FHxXORcveh}8P^YPLm!&&W~&Z?s%3cjmSrwIg@;QR403>0rr{;FgCdF*J%O9_Tg8ciYuc;az{hdU;*-P-oz5r(SWk3;RM6oZ zUl<;iEpx!IR!9h|U6|kzXqH(88%w1AO#l^)`I{_daCRyRpfHoA8e3aOuik6$AkOIz zwr%JTjV2LhW|ct#w|5UbUodLWi$SY|d|2CuA%(!?{LazlHX+TlIqw&H(?w6(C6Y0R zEBosYwOKnao|W#aWxf=3k=4 zWiR@c4u(0TOA9uic)q`2HtZQ&Y?;?{UbLWUzWgW9WN-`v0P!Kj?i$*104C1x4+b#8?9HiANH~nSjKpj=UxKJ_VCOz zHngaK>(KSNQSX|aikVvd&ezKjfoDDTRabIc(5m+n(dFY&)sR2Jl@pIdGwD=iVFtHT zINhwW?cKaFx>Y@|y0#`#>*I?3P{pZ=(ULVXJ8%FcUN^=PSS~^2R6x7b!O*ZhT?_6k z+U^Z7aQ`r(%AijR!<^JR6_!5Zs!Hx-cBqY|!A`i;Q%OVL-xg(IQBPj`mS5F4YP-yD z_DeLoZ7gp97l8r1D4%%~uJCY?DRG*JyEc=Kc4Sfe6@CVXLM(t5w;JJnvNvgnP$Ghj zkf7;%ea7RgmoD0r-LV)%qY3>MrAklaVNkaTYyF)5$Nz35H9wbQ7nHqzm(r4ZkNWNI zv_4DQwRv^FIpKBF?PMLeQ}pw!54X6g{RQdQ_heASPDeXZ;MM5;BVV)eJbkT5BmZU9 z$!+pe!=lLR>ySqCiT|r7lXQUWk+C4U+T$ABa2t{FRj=w3!X71j zl2EgdpWRZ~5Ct-8z>dzvsVkm^-Arl;e3~I%)VKBaS*=xY)ck>yi7xa~B9-yX3729~ zTfWtPe1nd9MnCX6QvI&BJiHCk_TACHL(Rd_x;_g4o-@~qwHQiLF&l~xC)Cv1$duW8 zDUYX2!1bafhw#%Q{jxVDsxS6XdV4);N#Z`bH=i!9GVLdFaeSZdgb4k$U_WOB@>?1c zcsq7?tXC?MOFukSU8o1E^Nl$3B=;9pY1AmGRcI9onZ?1jS`fx3(RhwYVOmy0Yv7>P zj|$ZZt#2nhntP*}%h^Nw0vf)SbIMxd8kPN89t|XmC)`qCF=*0YPtW%n(q@Sm;t8>p zYTcv78elyYWUIJXkpg1=F5z%OW=bLmNz%Oo44{-cJ=v0EbY( z)&68*ghPb9O-V0IaXoE5m;Bu!=&(R=7x8l7L7wz%cy>~m;MDftFs4<+I!VXsgpQDP zxZaH2_0d|x<;49ZO1m%tAti0`%p+Q3tPaREuG6T%(7c~$ynK5e>-`?N1BkP`H`v@y zLZ+VOReSww1L9Di4}8F{B;*}7ef+6SVUx|MMf@n#)Y^2&SJHKjv} zm&j??r__7{`FYuQ{i$|q^TB>CsdYf}Lx?0Us#4YD*Ad!!C&wrBtb{7FXF|I8S#$4e zDO#I~o~fJs7?*)iBF;@XhRkJy2s3C>W#}J4<{-f-hWzKcIO~C=j|pf=KDTb1mUYXP z%YsRXKKwN7Vey3_TA*T?hTWr}vgDTMg<%R*d7&XQ8myaI8i~%^W@GsC*iuC{S!sCM7pGlD}lINFZ4L?%;5d$ z?Gm3i@5fQo$5*0Vx{tgrC&TF?Q6P&E?_ zlg00=G}lWFXc`fwUW53wbfl>9^^RH*#)=Z@*1ZkG-PI^Ue<>slPO?tNG&TybQFYl~t zL;m1;cyEjPd-W$HA;WY)IYOd4Mcq~X3bPygF_p*CQq?q=<|2PAE&g^kVU)zHO(fW7 zeSBs?hT-|WO2`o^EA}THOz(M|ugTwn#de>Dl8?OD_p*q2fU|(sulRgc4bw%f48y0s z$4TeWhHSORx&30qvjTFfI>{469YfH*y5sVz9_;v2N=i+@9J1xat1wsUHU9z2cA_Gr(fxGZfl11FSDRSb$TXX9Rtl0hRx4W zjI<}HhEn=5&Q?VP$olDzT61{K^aI!2@9p0gAr|8=fgpnT!w5rQOlwUqjV8+?PwpKi zF87Ef*7HaNO=~_U-txv(cO$;aj<;8jQ)hs)Zc!!;*stiOXR+1ACY9GM95Kp4cW5nz z$#|K%Xg1;3T8EbovVmf`*R6(aqrGV=52e#0)Pg>=E~yWX#jI%zfcy<;TPHx^eKo>S z-#ee}W6ca)xEw$0A1WmxnmU~Q*HWzX@6CCL!FXgV3)Bc5Nu z_`{}pRmn`6#ZVy@XcqSCgxolxO8Tuasy_9xAmDV6q~TXm+SRmXl_)Wb>iUF$-i$l< z^~Cz=T)H*6i%oO7_&Y4*r{U~wzT<|0T zGllTq`ejS!;QdBRcKiT~x72SwMkO2q)@~yN#7IuTUU3bAQC@^mze|Qr{e-1>t8S>%U*n1deILGm zH^-Kl=O{#nFX#CB1$yMAkZf3$8DE*3++ASTL=tqiEX%CLhmUuz*qu@k*n*p+*KKK; zQnAWHD0Duwdg<1E#!u%q1ExBokE)ja%G9%%g#+{LEjlC4#jrc^rMB<_VXX?!%Pvqz zV_U8THi4+k7D&$V`0e~!pW0mn!vIIWMxa6YOW!!GN%+~7vmM7_MuVggr8h_@3-IW&ojC)*z&LUf6ClRYki-kr* zXPKAs=@3Ujty9+{6xoJZEY1z5HHcdNog`v$&)X4Tf6Nj{n5zFwuln z39sW10?b21D3&Iv2l?eRZ#}zs&qI^u7p3BP=O*5{Nib8H5 zAK#q=`Ig)bOy@A)uWA(PJ4W0<7f3Y;OYF)#3YTtDO;rmoFSbK=#z(4>qut+zO-j_O z^iCgpxEyx>&E2}cmFLX`}LdF z(0VLc`cZ{kgCislu{*C`M+=F+`$U@m<(kDMt@H*1yt4q5p(E98aZKfXIi?Ph5C1(@b9rZHm&B8z(Pe53CLgDpQVS3N zbk?c|s-RkYMh%vF*gos2_{7iH^XZdQOoQ1j`m*pp$bPbt%!H@iRzuDI3kfX0qP;F- zL}ZZf{c^+80MiSH{cq@OufP#oth&!rTo$C*hLVnoh>gdlXEaPf@T|q%!++jw!_LkF8jl8 z0_BV3oQIVYhtue!i^)Q%l>5Ijy}9C``KO6??VB6(HR%&ajaG@GkvhEF%<3N4d9YN7 zU%u0$vX1qTJ22~3yg_Z4SywPyr&e()$;EWPE>p~)as3<5p2&jXIyE&y>a14d2z%$` zl$!qUF^Oo$?#vtZqk1*88Dbp{=f*eJqY94Y`uGN;qriUdPtvkqCABatx=U_g5?kBi zO~foS2pBv8b-gfhu`;jHj19-%mW-~Kkq^A zIpT#W^X{`9w=z*{wBSzuRQ(TLcL+9J^SMUx}8<#g!nV6&n`g7RK@ z7OjUx;06shp{MHn7ZO=0`tPQKt?mMQtNNNJGc6kK^U`^gbc{js{2IG6WC_JkTG}7u zrqGkytNS6bEGQvE-Cgm+_o2tSXC+tRoKyrT!40@!LFOMi2_|An)o2!-TNUpQb)SbM zWX2VIwsELky7d)MQ_ylKivO5!uE*m2F2#wh`Br0ABKX(B3@T&&u>;36f!vXwhuPp~ zJT`}T?QD;6jz78rMQLA#FNz8_`sj=1QKJ~vb3wb0^l8vLloo;bmb`!o_ zH*AvEEt|WYi?M1r-CvxVM@F?4w#U2;{IMNp=!%j6{H;*9(#ZMG1Uo*Xw4tUgxsjxO zZ)_c$emWutbCVfe=>ixaOT;$g=_LPIqzM|?b0sI^a`?L=ZI{P(lR~wB)ba*=WF@Tz z^+9T(uHkw`xtRIjpI(>z(dJogDi`lEI6bjkEUrwfvc(yKYQ?IPU zv0!36hYz09&{8OK4nOJgI|?Csph&XnEiB_(KeA`rW42>pB%n zbn`A9C;-&|&eQf*PWU*g?0!1z-vTa~vg+8Wdu~`huUst=@|j5*>t+xj#om*v@>md; zZ3ms(CO`3my<}alR{1O9@E~erAn60@SxKacAmf3pzVjWv}rz54cy_AQY(L$f6sMG**jjGIFBG*c|)V z|AO#|bqgL(>Zif;vbxD=I6`gdcYQUrokR1^)d(e?lakV!TN$1bHks>FJa+;tzV~}? z3$18frQwBq1yq7Ra_?3VL`!pWeiVOs|MS$Jx<%DtUt#FY7X#en+-k4FT9K%j~3+%!@XfCBB!Ke`A-=qkm0>y zO~p@S`~P8HIdHcHB?ohOe#Vl+-zka95;0^Fq)fmf<05M0kU|Yub72Vdk>A9MAtc1R z$feE|{XA$yBZpU^Q3F>sSilx9m&Uq*1=6Cncqvt@2u~AOqb?BrB9lZH7VIN`0bNur zQ|pEial_l=1xBYs*881geZ#?c(_lhzaq(OMLQtplB7NX2s8YZ4eZ`2i0l5gYv#F8% zVSJpCRUV4#{r2KAUk^^!KdM@Ncn-?9li~QDYEmA$_y9vO7jcVnU@;5*5gb~z^J@d2 zhX|Pr*<{pxX)Hg-j_YrBhl1zCJ4=b&7`H#Tu$?8dj`SL?ei}+Ap{wzqF-8~_`~>}2 zaGf?w>OZL627$3_d1Z(?<9c1cr@b!b)TfFv=U2Mn%SW~hYJG{uW6I`|DD4V7FroHE zy1o>8kc!mNgN$-F@2F$^(117U88@P9DeOlRglJ-)BNm=DakUmU-AGJ%Eg4#z@#Km; z&@g(g7`d7TT^UmCEicHhRwyB*i6$H8ax^}sZiY`8LWt%+y_C)b?4>n?9HD}`efIW-KAFUg%y@uzmZp0ne=)BHVyY8T$4Z<#Wb`eg>4 z+wgmuCcy`LoUSOID9+UQW_fO-81(NN+9*3=SLA1Xr*i9Z*tr$L%EDmcX|rSzf_D#8H*n37pab}h%4oYBhG z*zxE>G)W2e+=0MpC9ZAPGg&$+94Sj0Oifm_#BhqlMxJh<+$@pK=lY=r-;{w;`CvM4 zDPzr6XQJgdds_T1DL?7oG#@mxDAgOSb!TOZ3JTUceh(_vnaP2s{yyAW;?6-Wu$N8uz1R(aS7lVWd(@D60&X0x{bPWs>uft|^QHDexNEa)Dx! z+ENtb&QNFjr`r==FPF_fQl6fNC6Hlm1uQD$n$?G56ln+6WO-Oj5U~FW4UW3)4n`$d zht)J8INhit{W>#L?Rm5BbtMDu|MW2UTAK8m5P#*55!ve31 z7bhvcb~fzxBN$W7m0J?sLyz9wm-Ub6OlJhN)wt;`j== zxHq?+`G&7~ecCx|=?y(BsoTgTG$wN=8L1UH^G&ho@MU&|5^PS}Xc_*Sh*BJ}zg%a) zlv|mrnbTL3CJou!m;UP;_VW|qcso{Kh?EqbSpm}BfsIT?cJpIZxk)!zP{#YG+7Ca)yG8ViCH27;7*Re~ zP6B9-_y>{|+?0rgk&cDZA1^}6jt3mh`$@VOzyhBfRnA+D(Lm_*_@h(qXB?{-6c#A5 zpDpmIHIpiITiGG;V)OWHmglTWX?a=@c_NA|C(e;qi-|l09lJgp*?*v_;WZ!qcUQY3 zrf?Fg?6aA##@rpv445*; zJzpIE8$}>8mc=*W8`mFBzf;J*V5%|mMXCzK?BKL88YNzL%+^Z^4VzTEbwiAfY>Kl1 zS#$Fr(_8JT7LPnzlqUpQ%)Xqe=cne)nps+z(O9xDEj3Spb`RY{t4U+g;lE4wghtRC z_i|q=y_I^QH<|1+e;se_(^h1!%_0N!OTt55W-^6kMC5@b=$ zKMb2H4{**1#-YyosC~BDo=SSy25oQ{h|MkLD@Q}dr8glLWOvKAQkG1|m$lU2kxbxn zJj6+d3?~xjuZdj}b3r!V`3O?xghtc% zfyJfrrGq2(J*DbofSA)ohNFp=*?uOKfe}@X7$vkn%e!N!<3seoBT@U z`R~>(e+>nzm=zmf@Zz{t> z;da0YAWL!h>A__*M00O(2k2BbkRv#M?+u!ebp`D(*s2GpHC0G!gEU&`u6ruv_G4~) z3H>|_YrC^~t#geIv0_NqgWf6g9QB>GtzD=)@sF0RUiG*jwr2_NUxl#fel~T*C~r}k zVM@(7whJwwYCKTmEIr&K_D^lGWg={PA&#DV$*EZ2eYh~sXlS$p7qb#ykv&l4;Vsq> z5fXVaG>YBD(G`2kdZ>gHr3FPfE8TnY3OlP^8$VqTI(ca1hrMTU?D7<5Z6{4IsKk(z z6F^vsrt7^)qLw7J_+5~13Ne?B`s2DsQ;U@zEpAgP&LQmofQB{9y)cVSwD695&Ievv zf<1U^kSfPr^I0PRzrLNSvA4%(x4Yw>uV8Ka5p;mFGNbZjk-^=1^~Y>JaB&!bL;tT9 zZ4%%4a60U*EhyBN|Gewvc`tL92(VD8%K-a~6048lWD3l@68QF9_!I%2+WnqUdE`NF za&en#p4Ba|ro-oc{qFH7Rlk$y_@hlghsZiHf0AQkv-@vi!hini2WSc!4EF9d&}pSj zH6WIj5A>5U6AHo0?zt^Tc~}>$ku4SxA1b>ccC(t1oR&^Xxg@(59fAs+Xe9G>LutF6 zz1yrmv8Fx0hi2WeRdp7esF!Yj{b5so443+W#8*mv_2UVcy$915Z+4L~R3GCb;rk;~ z+3B&XS~a~0Iqtkllq`3~#e8#@e@yYKXI}D*C8a2pM2G8<>9&R4kse?Q!ln_j6pDg$ zEN3Q5_K8Aztzn7R?oG5cH*|`&D}JP~UusLD+6adJHmb1ftA7{a=IvQDN@JKH(b!Kt zY|$CFVX4};o5L$E$?NaS0CxaU-jIAo%=nw;d-UC|mJW-z#o_)m7oBq(f-bzTWEUop zT5LJqBppNe31HrpO;ecOZF+MS^g5C)JYu4-ud*fGRC%E2TKL4-R5am)frk}_FQ>(@ zKP^QY5ug~+boWPo-kxrcx~_$oD@*_5$3c{HcOLXwpr>=F8FgVFFPjx-fz@Lw0Sw`O z87IQ88ks(bVsOC!7p0~*q`)?2q5oMG5aT}xP5LPyspq6tu85j+O^l*@E*V`^7TtT3=(|nNaLUJg zj_pFnpedKd_T=0J?DRy>#a;^tQF}vc#omflPx3vo?Hn=XByw5&%|W0E`x7^zu4Iig zTt3|Xm9U#(+j+dxN41c!2eP!EoF}TSU#1O+-#~5n8b=y?7pO;`OcDk3uh>r<5HUi( z)p>=#9)S}Q$`*A8!{sbBqmk|MDP#Q?E&rV0LbEc1-}6Ym$KH}YS56fD1ozo55$a0e zNPD4a&l(P8VX25J@Q6B<@S>eyLO}h~Cpr@V!0{+u<|6Tzh8J-KS%jhhKNxmzJmX^G z2Z?*p^g4|M3pTc>=OVI=s48HbPPlPiU}&YT#5*8-k0XbJC>d?IPI`O#Of1fCZUn+A zE78o<5Yh{ULkEit>yWzz(^gTZwN@GI6>@o4wbG@ci$;Psrn#NfJnAzN)&XgdX>NIG zW2>UfnC}&g4<|WPbfO74Sn5rfDQz~ifpP@M43pRM<>fWOVqH9$?%lb1F2emOrT3AU z!EBbT2|P#aAx-b^x=%(X213oRs_T3vIaP08*n9+urX#%@zyT$l3|jdJI-S(=7z5~V z4}dghD!*6Xup)_Ap0qzR;EiqVv&)iL_PWD8W3o~?WC*&FQ!EqdNpzW0S{g`PXFeV> zeCRl738;EpS_v`CQ~vT=$~@C8|Ia0@;_9gE@)-eD@Z^43%)03M2GxA%eVt}4VKr3t;C5E3u8TKY_XS<6<8_IjNzkK zK%#@Pe4gJu#V-H;+YSXlkKWThp#?E6FK%aWE;n8YoOtxq9FYVs9kH^=kz%3#!>SGm4-1ckuV`=$b)x~Q zYll#N7Jf=J=ge!*eY$Gtu>}7~Tb@)O`Cq;dOH{qjR#OHuer`?dw}LgC9%QNSb;Ms7 zFO;J$3EAUR1iP_EYTNn1ObT*D)`w6SEEqp5KiLm52D1zbDq4pY#jq!QZXBK3Yh&t> z-u1=)nrESpdx1*~52}!suauSip%NbIud#f({kKp+mu8T`nosoC4pLyyRExVDaOyp%)Z&a@@VS#e=Af8${_Ojl_!N43Q~HXSP2{j zFg#36<1QcqnYYxa7BRai0tDsJh`>b6->tF4jk<6ETvYxr$SMC`I;oIOV8<@=D`H!` zR1dX?H-g_Ui~+%++Ka*ddvCkHPNQB4Qb!bIU+RA@W9^gm+FRY@DbQcDv$iE5E;5bdt#3DLWW#;@3C)mdIL^c0#HEHCK z&zgQ{i@auJa#AAZPm!bv3@yralrKq%CP~(Lh|Kt%T*MXIN592LINOx|DN7Itz!BDr zx0Az#5ldZwW+orkkknMqpoxWAVe;t>N0Wb1pm{g06ha<-m8>JJF#Ig6(Yw(0OhQ2?vkA^AbB_YXMmqeO7Z<61}g zh5x!jqlPa0WUj3~%GVIR?h(3rFX`KQ72?0M#ILBzu|csC6ZR<}BJvaW?O3ZJo?#g5 z@YiEvqnY)k!mkhRc1jbGqDWh;;Bou1x>Ab|h`p%%B49P+Z~q0}-tXN7&nukO>e_sx zCWHIwk&dC~h=&W!^Q8-o6QyA)sEz_$R_Dyq>Qm+s^bPO{Z%dv zd5l-92qF)7?GsIirY?IkbA!a>b!a(Rev_He$V@(LU{uQ_FXWkok%nPWn8weI~$aR?y#T2VtV$LvPa3infOD@&BIV^m~Z3Pz)H9@qC!m>}^P-XJQ4dD?#B7M)Vr|82I} zNB%Lx6ka?>h^P~O%Ci4pD`fpij{)i9l}vo`npE!!PKs4ZekMS`T4jgt?PGbq{~$3mc<_lg;>1=jM~&?=FFT;}9Y> zi?trkXQ+-Po_?vyMJzSC|F8jH8FfUS*If6CV2gtKE)H2;&2;cbY#CpQ0=69YAzNC; zrzw>%!U7VAxBU2b`SJt<94iV<`L}O*CEs=SWa>-oIq^O?D<+dk2$9ml``dcr1uofK zc;%mx)Z$?Hi@X05n_-4$DSJl1B#G2_mqyI~ThYXv(Y@=Sm34<@ z7VL42fW|W262LXN2><@qxLfQUK*B8xZGhM+f}{q<05aSmk=!5ZMtr$eZpgPWJW1I{7N=m9ovSvv0`MF@NSVTJ$a0fr)hY(#V=&9-Hhv(erGvDehSQ zzb87^Ni#|bRf!Ih!N`R@Lv|5i&u0;9!`iJBkj$Q#4?>o^i1C#$ z=T5pUE;I#2R3dfKnK|OAW|EsC$arwgHqo)e%LYZ<=u;v8OOf47Q2bYxmbanh(k7Cu zz0sNUwx0|xSiliAK`g20+sSJ{TER$Uw`2RtCrpKZxF08EQ*Q7Rh2o_uDNfTY!4qpGbeFLG`yxuf^0YC6H`wGo^9=B?$U;c;} z>(8drA5nR9c@N(`xVwV0~1J=e@**Cr?2lWE7TtXxqXfH-V1! z*$+gKUs2{P_V6mH2)+pGhY*J}FTElSQd8Lxr2Z&YYr%xmxt0R>6wo~<-!3=Aa(~)N zToYY@RklgDZ0@onZZ3z&tv-8QF=O9Zv!Y@zrW?I9x_tOll~RAn^g;PTt|~wzOsKCL zV{&`Kmz1yqaA{=^qM1fDT@-WD$_hJ=V;8AoasM7bO3o2sB_e}m6^uk0mq_W5$|<;)|3sNNtla7^}>7$npl|gTf9ET)6r?LWm5+Me+#aBH;IsK$(I45wGX6*>xlA^te*vkPkC9XnjDtM!843SmAo8`(iTyLg>Vow` zX#T#qaJk)airlqdDRLhL$nxPSHrC=24dyd3J`)%z^!&LY=?x|?(^%U7Jp+F*NCvk} z*zRo?EQhGYB{w45(`mAY&X)Yig6gM7BmURZw2wS7Hh<9Bi5CTB%Jwu+UNKZUgF*S-h@8SSpX?_= zux6&Dj{2d27u+cHTSN|ev<+CYmnKz=0_-v5WG7re47T?;`IvyHdS2V7a5Spn)ZrgJ zBmUaq=P+gOr8@-zCUy*@?t~bP-_Z7-_H^`n`R2p;r)!bhRt~YAwea8%KpMGfDhE25 z?3mMTxcaEcYJ$Cj3r*;D!SC_l4pj@$tQ8}iDE<;0Xxpu5!g~+$X2qijKSJ)HX%Ye^Btl&(YsfklBcl^xY)D)-Tsu7?U{S@~Prlk$mN zFIFN6ceoSY1MbP0ko**m2^Fpkxaw#@*0dIO?ou~XcTJ}2rc(8K4d@1LFerzDpjS!{&&F13FK07R?;wuRa z)mqk#Vvfpns8|JMP|eCF%gHJDccRk=!W5>BHMRK&WlOL)hjEmRt?ftAV9`A1I#kw* zakLA$o)^)W?r^>Ye@18TcO5mMq{c9BM!OF~w)(jkYWo%?WW;Ni+J&?6L&K z%#Z_-d%#V9vXHhFSe--H>~#59*j z=;uuyPR#6gcdOH#q~X7r!^&{-@}9UjStqVhhj?dt$9grVbC;$4#6WVJ{S4RFqbhC!qApf$ zK=mg4)YneKLdHb?P3im;<&)52$^i)XZT^Pmxn&FY2ToGh(F=0^EvNP!7mRKn zC{mebT&Dlv`So2Yt(7_V_)Xx%DFNS5u6gVG zMF=!?TQVK5h;GC_ur#4Xg-6!gE4pB0IvSe#cfm?=M-ktnqS{59?|)2PR;>97quJA9 z+O>Guva<;v0dD>VRZ6`y3^ka2X z0Q;wW82s6%1W;29^nxht-h4eaH0fd@Bg(`1P@K$s5Yg!};@HYH#x&}`PGvD`V>gOh z=r(-c^B@kh`{}&f+-8*HPOpHoGsV@p7<~tEE2NFd|0MKHC519FmVq~;fCndGB%xcH z*)&Z_aafW@NFk*_<*8h9Ye%?kiYY|9OQrCSQ3@B6qRKu=ZdXUdU zV<#>_e1$uGRAX9tinRD{1Lq1e`$dZMwQy+E0d9-G@K<>XLO*Vbb%7$0>J2u?He698 z?6IR4Wv{nLh(v7DQ67<1?Y`K&Dzc;8SzO;qJaHgiJ&K^qv-;=PY=x;n*$GzPSteQ? zN%5k4rb2oEZ2+lA8^iZUdV-UN>`j&Ehg84}#^M1eVFU`G-cx28SBgGSYL zqVon(wxuU^^IS2*l&MTRf=gM+jY8xocK+$*YgRUf{2}VEPtnONO$aqpgQ=3~sVx`g ztCs~TYY?)B&S*+aYpT~+A<{*K1Rck0^YwvHf*0sYr#Op@6#ysLikZZr;>x57$9b^% zlBD{3&(K{Rf8C~;m@WVO*gt4oF$b;awi9T=P6PF1DB zh>AwELh_AS64B?-mF>D*DSc!!=q%JH|QvOtxBDD(Ilpx}VQ z6?avCo!sl;3Ax%;F(8wg8{7FftrxGaGa=OF9i$wRtQ}Q!m&$$S@~lb(QSN|zRnI>B z7s)(7;rfUVK}ks96Q!s@ulo-4qDb1!jAoK&Qj<&6X-vq_N$o?9+P4jUx&O}eE5kW`e!6*U3>9MH<`?~B(g5ArG zillH^jfrPi`7og1FPY}>AKTb&P9DOK<~HSZ2@L-%U^-co`?bO1?BxMI#z~=P~c-ISi{(3rA(Y4{G@Eor4s-Ql7`|pVk zx(F!KT!~&1$C5XBD=YovpL+0HuTu)cpKRZ5&_vR*j~$K8PY3s|GDsP2{jH1#=KFLF zEK+BU_Nr$6ZPw>*buMKt_lYI>L8UJq712}wn`7$;|0&GO{hOR!{dONZ5!>XB>caH1 z$x7-Wgzqmjy7P{V!Y~bxwiB}Q9uwDP+wHJ1x##IFa<)&?`cO9nXqh=6fZn&AOegYxWKa~Xt_zf3W*<$=yz!H zp%n1q+{^eIaSb7QsK|-F&LLc{9! z^*4iw{f@E*Q{=tXUgNL#x2`>g6w6^~r=hJNE5y)6+snR(JI-<@I5%(O$auC1^yGLuPd29tAwZf0ykB`fVvHfravoI8Po!7u)h zx1N21yLF~5L9Zm>pI_lM(Wsl;yoWEVHbqt z7g%8O?dfDXPl%cA7q!$mt|dd5iFI@gYN&ypzjn0eLN3*Gboj2)tnn*B((6Y8wByOb z-9t|N?7FeVJKxsB`oPRjcE8T4zE+Hl(hDQ{fVy}#ug(m%ak1-WbpA#X2EV2#d6 zkQ`G)A|_}VhN!-6#(?YvarTIP)I;U{{-6YVV=OZZdllV`P(UD03E)c5| zH5bW_<`79`kg6@3Rz6h>L4hNP(yUwN9f&)&Yr`1N z5yfktLHJf`Q!ac~>JYOeA9le;+XZ-hvppcdM zs+}hD!q_$mO4*ER*=wkP~QzYiDL}hNsy##C?TIzyEl4Z%bj5l0woE&SJ*Y~*Yo4m?<>d}Whiwg>$`g4;^Y=7oIi1eKyYe*Ex@sO5 z_-_o9GDg$rdw&P(k_e!qS-(26>vdS`U0%}Tj(>&VJSCr}@mKiuFQkDSzj9D_dxZD9 zXaH`cO^zk7rMOFNcx5Gjk`3ZZe~+W)#9@DLHB7k)o&V#=j3b(_81DitAp0hQ@6CKa z)o_Slt9de%<0#nHhQi|F4MF$P)(BDCA+9`543Zmv0mAr)XCqJp3hB> z?c^(*Rz2g5#LdOAj4sER~i z;>1QM!wbJ;+_>R7&XS1U?UB`BSpc-($211lj#>>E)+%R7X-PfHhyzW9KUdUH*y9xp z6z7+qY{q;)ot@vp};vQGsM93GXR@A>&oQ5y>eDQGSZ|I3eAT!?zx4jr-^+11)J zRDSpo*RGcGeVB{pJ=HHQn|~;Ol#3h9qav6Z5PvW4 zo<)N5G5vDGxwmETQy_w6+508ZUccK3b3)V{D+BTW5Rvld9U!o1P=Z+YZWO{pt>90K zlpz`ct@JDq(6gzmaroaHRs5)z9E z>w(lG1wX2=Z#kkBDG?oG&4HvT;T7X-H#UCS+RMn8WRkbBq=@7txt)qiBxHNCg5UAd zzeFm7MGSgtKF&Kcquf#j*E-G1K1*-C4#)G!4mPnCIJ!!GoA!J+>qZ+tas~EO<<5h% zzQFQ33zVJ5YsC}?wxa_oJ%|7mKFH2Q`u!Ja`(TPs8`F_08c;B|XYD5Dz4u8Oqw_*Q zHO!YFDW}kP0-_O-9n{il(;ya0z#A|vO>rgFCeSb>F?(&v9G`B?6Tr+Z$f7`%HU z@vy-V)jt71RKzs$hg{nM4-0XeSt!{tG$ES{;Gkv=AOxYw_xWW%S{uQk8z_tg3BiNACOCn?-Ge8%OK^90hv4q+9^BnRa0YjRGq~Hp8SeY{NWJe< zb-uv)i=y`4vwQVwS*x#W^%is3$MrvrF!_rTDJrsRdL`So)Pm}Gn^w8WkbD9r+A~5w zVBnA|e~$>gZ+vo!`wQWZ!BM5_w0zF4`}9*tZH=AEU#8xqAUQhqsN!~}_pL|$c1;9w zyzNITA1#Wpl&6(hU+is<3Z@$(Mk4gG8#{7Uudjj-Bt@E+7eq`fxH>pJ8IHsGp~&-k zTcS)@;6k_Aa3z#}*pE}o2UCwGi3OPPO0uGhX*TIAvTgar{~cePM8;`*#hOCY0oSo_ z?j>{fIFG~s6^Mz`RU8ylFH*KH)vD$uc-24|#*FSMpWU!O{2-qozNyF-PtM4OQbJzx zBpX=7D3AJy$PY^@bo#yb=RbKSY>d$5$<@$N^iDySadUWl9?T~CFHX;t8oWMN@Y(cGTbjf{Gin9WLGtQ%>Lo8 zgA`!fM;Rng_H}L%k19=I!X~gh$7s+NsE}aj z<@6`p`>Y6UlKkC{N`3w$GKk*c0+;#QV~H2axot zjRh_h7*`>c*cSt2CI7AAku3Rq#*!5wWse~zO^XQGNekzyH~jgGtqRXnvw+LmCf++B zSkg0dLQjq(r66`_7xp>a2m)gR?G#2o0E&g%t>2ON781rjJB`*GkX{=4#edF94lc^Y zVWI$h47d7pu+YHiRJDeJ0#2mYgu>RhY@a=f(cJ#39y4#W9Urq*!4f5dCP5aiuPQ=8 zfUmf$LRq!IfU5#+&6pSz8Tk>hmv%@+dM*gL!g=^auMK~RWi`9l!%63>lwdf)HeLr) z^a%pxH!*$@R!7GzKJSoIdKTC9Fq0*a-rsZuSuw_9j>SM@`+dvi5rwQ>j{=;lwiFF% z%P;lp1ETTr8}L%e9QBD1^vWc01E5Xu^T*=#4PDeh&Agd~MhcONa2*5$!dEMG_?-5Z zYa%nV^dO~3vTs)CQ?Q#q97P77#rV!#A$E?m9d1Y_lYbZ$;j!?9DRZn+i7OcK01{IU z-LCG|4=Y|%F7b)X`_9Ru=8bnN3DEck{+uXlx+f^xM>7anvPnJMjn*goeuz*+Dx#pw z^KBRSR%Z2KR!a*x`MI8;DSI_k!W%PB8?X9FG|89+^~g+7jqWw>7IPB)6_Au?XM&WU zoMx5B@xEk}qiPYIsoWk~I|8oWFbY*1DV}X+{s_V*zEZ~~NVE-H873NVg;Q`FZaY5- zSsqq2LoZbX*F(A*z;$aJplJif0%<+U4 zj{1N)0O2%LGgU96{G}+MYENJC*b9lx-seJbtA*Un#rd`*|8 z$AbwGilaqUZ??&*?nd$Kf%!ArLNwPJ!OKPsZv@Q-t{u6&{f5B z11Nv_-mtuc$JP7uvD{lW&SB$VA#q-Ybi3SD?(4p$lt=#z1;OqWIxr>-fJ6!oHXOL*9q;I)9wgBZif$Z6@Co$zJyhAU!n+AjemM^|yKpk9c zJjCfp3*h^F(hh_^9#;okzmpN&rOkI(2qfqa^rcu_lbHiMc|dGq2^RX(O>T2;>Lgyh^PFxM~A z!{)>L6>lLmi8CwokACPO)87|n(|q%@h_sFi*NW{y3R!ODP{>M>TLzh66lj%{nZ&6T zFDYJN#b?15ZWQu2XWqmCLzSE1=ax_y&B!tnL`}~36+kA*=YXCip-r+=C8=t?Mvq^o z%k7)<#*UjcVBWf#HraQnuJ+6=`3{W9yos5Jj%X(n0x^Z}VIZIFo1|a9a1YCJ#ZwrH zFeCAJ;xCPWOVA$N&jt(|)^rTM1d{q3edA`M4r`<0oHh~$NzVQJ@=g{|#lxrjD#mt{ zpZqN(oLckyodc~*vipx;wXM|Y*<*jP;NGM9hkUuo)qTig!8;9Vh;#x7I1=3socM2t z4`0+4rNIk-V})x>-nA4NjQ@fjtz=~XgE3(uz%n?|Nq~}!ij7lk!-<40f!M`ESoU|e zS#tXpwbJ(V;h+4 z6jv$|-jm_W=w~;pFR;)63y{2QX&YudKM~B>Di+*_&iv`zjK51_Gxu)2BbV(IYfqMv zpz(y+P5;4b6*RmgIGQCa5+F1!+T+ArUVxNnHdLbyEBSgwqfj(Ql`tV7fg#UB;yFj2 z87dJ2gtSNcE~X?bezS6=l69+IB~k;11~3!IsbG`xQ9BOK5`1>wdiQNF&y&0|g1MHe zY*t)`>=gVZw!?!l2SI~LKQwqZ+KgujLY4mf74G+?IpXQ|S9)f1h0*xD?=2?3SP624 zRf;{T1HWD2<9NLsyK2PVo2cPv#mNnu|1k@qC1(7ST_gY1#HOa>kWX2H*UEzyGCM=_ zB0jG$xcYUU(MV;f!rj7(t>Yj~@_raWRJL96@UO~R9zDP|f8|px8naBv{toP=n=J=J z*Z^Ewh?{EbDRER?9XHIW(_3^0I*m-L>J?wEYu2H%b-M9h+D*uz{>+M2?E+?Xn}jH& zZ76NbXM8VY7rnj#QcXdMQv9Z9(7?`8=(2tAX`x%+BF%>b_Vg#ot!s5BgmXOa@ARLS z%Ce>{6urjkqeBv+hjqO(@;75=>>Px^T;3QjbR1Y)j`V&Lmq&XI&t%F&6oLux8v z{-I8C&^!P7pFQCPQ>GJYEl}M^Uh`cDv%~D^XAK>2r!^axd!*~gqDLJ2dxgd(eQY3{ zUf!Qx*>fDy#sNKP5th?{@dAmsB7S~!=BOo%kyj2G(>IPnm)%d0zoIC%4!imitpbo7-L?bc~N9@BO4 z@ZI;{q5%5qEA_-E6;0V{&5?8lNJ$YU@yrn|0*D6TCmFPv2gZ#PPZxT#(UXl7-lT1z zXbHc^R2V00{i;-5j@e%vcq3i^Xvs-FSm}=`SAz3K8)Jk)cC`(F_iXO%XXNb%G4ep; z!x1RrM#1E1IsQd#c=3KObi8#;sL@2hr`%4vsxe7kOeGr=$t9foi?XNY7KSQ4-EBr$ zlP1#)vGiKJD$i-S$%KH>Ktv3fvwf?c^$U2z)lFqfeIbf-KC8ha>GaZ&Oe@B)L0A=5 zG^0@dCBH&oEJrVYBr29vSP1=d6Jjbp>fr4Ma1Ndp?p+R5?KoB|^J*X#JLK8rWGSsM68sGle&ZmENn22_%ic!O~lkYP+ zAcTE5h~1=;k)@9bF;U$RYk2kU2*3c9>wuvLzy7FQEPLjz=Hj*%Q>``?_BE1J6<2tu zgwe`SO8`I?1MwV;yAvbSqTpn;EY#_NkxJE-`WYGs9iXnWvPmOJT^ESsJ(CyJ?wFaz zV>u@UuAcVFS5~#&yrj)}n|AV8J4_p}Get)@2+)1xhMh39HO|vB<$>F?M!(nZlqtbm z>0*edSo2O>xw@|6X63I!&%F+4t6zt3Yo`{r8i0fd6HE;4lZ2(ElvBW8<1)PO-t8Is z1&zi6g(jzjSoPguK9=h9JEWgZ@gWJ1$x2*<$=XQ3R{Oi324j^x*edi=wUtQ&eca}N zyLA^%WjvQpR4=WV@v2JRO+)c!`BlE(4CY+Qr&LHot}3Ss`v#C8oE@@nW9aCRO#g1Y z*l;AyOw(DcsfFl(nfY{}5SJ{xxcH=_xj!+D+-NL!)Y{i+_kw+?dp zGmAG?ky>UeDLaH&S$EQ;HT@StcEN)2C0#_SD)xqR1z4j|YU#XJp~%YKP>fe+uUEqL;tp%y{^e2u8iZ0(a|$q|Nf*Rf2TBRR_}+XpED>9Ck{r7a)6z+z2M zP4n@%bexv|n!Kcmr?X;8b>+#)-|JvV!p%4Ti5o*khKL9=hzR{g`IQBh5yaRIix3x* zyDJxC%dfV;QGAC`Uh271Vlt&=mxF+zk@V{M_?$QHVP>myiSqj*Oq^F`N=*!Vd=L`9 zw%;>k7O}Ds|A4HLoKOVkdjs{BJV$aXZw8Q!Pq3fgM zhVt_~pD|#{6+5Vg9ud+Agk@Pln*(a8Le1)iGrpGp5}x2)bLETGf(VQA-@sXN7=|Y4RefkA2-MX=WorYZ<5hDVea8L-A6PE-ZHW5f&~vrDoI=W1w*KYQ`kzW_Ox?(4(7|w)&1Ap6^n< z1LCa3X4p;a>9b@Tzm!mm;*=VBpx>`x=Wis|@bD&7!tooF08q8!hDO;-uWBiDZt^0R z)(2=O%mw!cNyqk$7@_Als%B~-6xlbgTqN6c-^a;N;(`6t2Uz_^Gp~50>GhfCPn&u> zF$YrDkY6dEnuheeQEfj>yo1I`J{h+xC&f^~jw}~~C?BK@_Up2C;eCB1v8s=#ZxvvH z`-cRnp^$Pa#Pv$a{6f6M95s}gk{j6cA%wOJK)TEDz;xlX^2C*et5^12yDJ`>e!k>7 zeI=&sG~F0?rEHf$3DF#pO4p~r5`L;sa1>6VALRJx_qXGt%AQpaEL?m2kf~IiLf=rR zSm!@+<(>?(=9{t{>5LFRs9YbKi~H`LYC_%KPa_;w9HJXmEFlsNaVavykMMAOS*hr9 z9VEpYssl6H`2iH^#X^w7BmZt?(c{xHWJZXi(B)9@`we*XKFy1_sHB?#*nj*-Q|FYM z9c>qCo&CXPf7zAPV~5=y6!&t>a9(&tG(SUZJwWIb1LCz%CS3zkkT*y1YPrT(PFAxL z*Q3itr4m6>CnRM&BXZR^KdYR%lBc_k%j&81h}EBOpw5UuuA##@MES*zL6KwShYOXV zCZiSCt4yn=lHCkO>?8)m$;?yZZZ@KS`R7hJf3`3`4@q8xXB7kT^sv^x0KTEo6p9>z zaW*&ZBC~()nRo{SsRsDBMWEZCQ4q+XkoIq#+6S9i*zNo)?RQb3&dDs;m-1f_oy5LJ zCQlbXDNeq94+Gf_hzUUmI@37$;eP)kNbZ{;Vg+fVEAj*X42H`R5sx?vTwQ|O{T_LG zK*r#aCos89pmo74yAhcl`=3Z=hx3n47Mb#cE~{Bw0r}IjEUZMuSI$4YFEI1VF5(OR zi4(!7&|aa0M}_PA6aH8&ts(#n_~5AEC)-Tpl%7XNeCr8`g!6Q%K zKR-<>e_O|(zu65gZm)PEOAh^5=i9`WwEtg%#UW2CtBHr1C^>moO~%t%bz&luwq zrYnZb8O!KT38nsY%ltC7Ki?s<5Anv$RkB#A)tRIFDa$^(Wo|o|=I`@n6d&FbA~QOJ zKYGv*Q!~7_w*FSE_(y4`IB&sqGXh*S=gx)S!HWpd2^vHvnj!raqL9fVQ?&#*OK?Lb z^v(P;3E_gwVD7+?0xtR!zWbV`sAEo+#}_p5A;}e>{VXQ2tJT%Te||ZW5l%W&!nKKx zzWk>0P%kNCB62kXA?Y{Mm^qGfol)l%Mt5C2w|?J{BY}feU_86V8-WX0yIjQlp_mZk z?a1E;bfX7u`VgJ(bUg_4y}YvSI$XIzb6+H#EdRB=H+lZ?ySG2$=#XKEpXW;@{9{%B zDB{fm*<3gQ(M)BWI{qx^PVTeKC^hVIg;rx61|9&|47X=wr{V!d7 z^OXPny8ryl|Jz#;M;I#AB$lcFN6!CxzYxZocVKjw%^3abi*IuN_lw`tL%a?dloaRk zkIDY8aYCN|1_!Ym4LIg>fX4qk@y|FR&-?fNgt-6zH&n!bW;MGg!ez&e;kovidGTdZ zfXn;wTBBU0{B0>o=+6Or8iXm=F7!1*#^9{C-zEP3&7q;tQ(s15e1;CV32*zf-hIpe zNdE8aefx;q6c#^@2#zgm*5e`z#cmA3->+iGMR*>oiz!!G@)d+ntq_)$dPbROYo!sB_-BD7v?93Oz$Fk?rH1g z{PiYNkuQ+KX&6ZUH6i~i1pgm0tg!gsT&(6`L_`A~ESR+$=g(AL&3jp~UV$Pp8)EsP zfW#dpt9isfiA;kpRqeMu!LX-(_6erF{sqHitP~%P=1br278H;Rd_gMhlCkYX6L{?f zk~xN{sq023Nb9IDaH7_hlXR;o!3FHcR~uLj)z*a(b$%NYOUS16dlWFvUi zTn21xBw{UgQ#{=sakO8ClRh+A z2@+i}C8`sl#8)(ZwZ`>ZXXbiB?KpDe)Ql*nUbiS*k;L^}5EVhrf)^GR_bssP*}p3Y z5rmA_X<4RXq)o~ku=#B)&+tkxfERzmKgp#4vj z&7Gn|S3J;44TbkW`)up;OwE={WM|B&7QV*Y6#R~Up)Y@~D}9XgRlTnc*3%`B+P5mu zJ2bkFSw>B0W0d)e@xahv?}k>BV~Zp%+DPULNF>_xWUGCXXUw)8C+ev8X;J_#%Z30x zt%S{Bs3yYTyl1^B>|X=LNacA&7CZ+y&n|CB(!hF~(S3)>g+;B_ z8#L_-nMsD=Bl1#%ZN)wD>>vYRw67mPV7EX<6yf>z&GFi6NQkpV-N9oO@76BbFvv=S zEUfw}6y5OF&hN@G1Ppd4*MdpT}JBY>bdQ=q1ey}UO;)W)Av@PPoQ z=7rGZL=cLRU_S6gP5Zs;nq3u6NcJ>1at7_o!e7Mn}|TuMJOY2@~cJ;uc9;c9V_ zf(E6)6HfA9HsQOgCg0n>!q)EIx^9lmt8cK?EiH_E_WR@a+DU$QUH2qD&xW*CJx}fO zjpZyx`lK!1J?sLzRodN1LvrcilZ!TsKX<2(Q$iXo`fk*8;QMWk5rl;&TyJnCqXMH= zTpxCtDG!!>!LyvYL>$>b3k}1e9wfj0l;9y{-T__xE&Cf+m^at~c+NPj$%n7(Un>t_4c%P0FZ3 z$yqN@DHLFOvXXSY9;#)p3)oLE{~|V}Hj@loAl+kxnh8VoJS1M=vGRyvP;Z6jn0QPF zL*4Q4^#IF{3fY9{H6W${zPfdmV-E5>yv{BA)TwiTRK{$H2{ z4BpO@!3FfhcfagCr5T0mbChc#FLT(!(8u0S4-@%PIgff1NgCbUk2f4uQbL!bwn1oS~49 z=izUH1`QVD^|N3188Nw1oy#;aZvQSpY!L5BJ8>`S$Hx8Q0ch9Gar06$iA$F4yqbRs zcRt=52G`HmeX{sGn#?ppbv67>M(5zU)(a)Hz0nrdATRG24HY$Hl&8U8qnkM-F2@%E z#d1O4S}IKQwG8?G@dUTwE|SQRt)&S5Ai9=}$IDyf1RiyZy{u3sFFF~1ZeuJRp&(Qc zUexxWV(^}8rVl+~*>tA}- z2-U+o-*@>Ok5B({JcFijkG%J_#{k|E!bvwA~o0 zJM^o}({IAFi_OPF*WvqE2@78lMT%A5E2x%6TLtY55*UT9D{{1B!7M-^JL4R^+O!{>|uJ5r;t}#PRme8DSc zaL%xlqfel9`<)2vaZLBaS%ll`C(zLYUJwCLP4E$>EAfolzHpf9}5!0%pO&l7}sA5rOIvcm#}W4fTe5ZCy>+zFzPda)zD3^Avf z_Adplgj2r4hT@L<>0Ev4E!`&xK8UA&crGotb!qE=J&8cmA1qYJ%b{4G-2!eV@jR~y zDg?|a(Fn<8CQDA}aewrs;=qv*0HY7oWFmO_WWjW{>yE+hBIx38EsF#+l!~!X_KCD& zDX6oPHjO`^Rv*vKd+|59`iy{Dzh?|&=^=WBfCkJoJ)-Eax88T;{GN7H*!j5%*AOD5 zc~MCuXX?FkOQ^BJz4ItA)$cLi?R`g0#McyDj!oZF(7X{8I4Pk$#<5!dwcWg%!RYK2 zrSl#9ZN~??WM+iM3N-_f#k2SO1@laqK9Wone36ZWga*EW<@h^FYegf?8c!%`)LNRA!s3j zf0SIzk52CHqsb&Kk5sE%E6+9mLTYJsVr0;QGd{1|%$YtQp>f4oT|F691IP%pr^3HRUDFX3d1SnE>);%8$S_n!q5}t@Q zNR2vT1TewROHX{rq1l3uzW}DI?cWGTrKRPA_cvY>cTY$yCZCZg{nrTr;@}C82iq1E z{*me6zxBmTjL$AqY|OCI6tO#B6H2nO=~n9NfztztjDf%<0>{f3+4`FGoex7E zPPdiAg<>;SFBfxWrF!)Qqw!4v^K$(6M_svOh4+%lHx zwg%v^4R>T`&I6|1z%Y|)P=G;Dpvn{d3;43S=xX2ydb%mFcNmzVY(<)A5^7fl?JNr7(;bvP5SZ2 zUi(K~jXzt;FUa--**~1CG1CNgTCz`AI#6N6spfW&_Gsq-)>aj#Z$)?NBi+_jAu$FQ zf8cJq4DO2{ObLQ=)ACn)z)kkNRYJi{?)vGay!AQ0Q?^>xe1i}1Esck{ zqI27Sic3t0A1e;)xKTcDq@AM>zUD&jc0ajyHCdbt{kh3#1yWe|DIjN=*YY!8#7eRr zi|Ud3ic*rIZEzr#EL4kIi_lCZBVM!5uIMaQezAHu)Z(ZVmO$QP(1eHN?`>hyDqjzm zo#7Vn2*Ew1i5Uy5|7GNdl1s zI4je*o4hrkL6AO@tpfo299>?D!gYb{?xW_>Kq%_*=p{q0&!vZr3;ti`7sTfPP@CB; zGB{)*}@!L7!Lya^KU%@PzXVSD?W|Thy<23)ZK- zWt)I{5ne5sn(YI(1!UXH#EW0C-?X(J2o!FBhYh5R-04X|>B_c}%ggX)*Fi@Org~Z<+rpQ9lv%J(*nfr(B=O7^{Q8}!N})QGjmS)I?PF#!FYJQJqa(8~ z7qr0BmZbdSb<%5~%yN@M_%s5Zg9iRkeuLdCL!8VIqjramYM`?%Hxe?;61h-Fz1ayl z_Y9L_PI3}q1gWDLe?xNw?fxr1CAa6TzpDU*Lr7Mop&O~X*;uekm&M!x)#loPxSoA3 zm_1VtypH<-ydk*dnnX04YZ34Y1a5SKfRo=!>soWKjwqwBe#enhJaQS@%Ql%*?fzSr z>Ujtes-;MK!2cGXv{YjeR%3SrSnN)NE25CCSS2mW8H5-73n7l&ZjeH8Ei=Q4`Lmt0r+907%M1gZu zj%=p?-6KVI-%;nFLO3wn+T|H1R&9HXwZ$2s$0E<(KrE=bs!XJ1}zky)rjMdv3(;LHg&x@e#iurW~Lonfae`T%b^` zJLR6Mkkpeg=7@He3s$Z-sf&@yybAyv$uKbO0^MC;kc-WQ5)}y@w}rM6Cq7&9!kgS! zU{p2#H2QgDCNbUjuKg$E(2)YA%jS{7P9yk2yHtKmu)9ecA+&#&Beslbsx zq^-yq>^qcIT176P!!U?x2SuG+I@-8!|7BiBjKwx&UB@OOf{R)I^4pX@N#F_Op*hpFvBx+R= zLp3p0bN3NQ-Dud;Vt98Cfd$|FvxwUmd;ZPt82V=}4F6)YOb2wp75K;Gz) zrv~or%q`+0eT{s5jLhsTNji^OEL_6TImxPGd*f};3>d|Rwu_8dV->j4v$!k&d}xZQ zxGxtCzrLGf4|nvtbIod%6X+7WwR(IT)@?_@VsrVy22UgPnq$$RIdvW}fj3y`v`cxO z`sDigzHvVL&(vf))7iTQ38_Fe2PxWU;rJUZ*FGq~bwWs>{&KldpXH=cnN*Q^N5jUa zR#OQdqxtCXZj=6^c9RijvA6`ASI$WCOSqp9KLTKk$P|3xX6MtvbHYzkx(?rEyUpcV0suwPP?p;GNfjy7!A;xTyK@ z%zRgeHwc1*=B|Vfz|5ce_?}B;<+~-1JO1Ln)txuJBytxa{WRHf3#(YK{VsJ^KSdMC zZRDLun`wUFP8V>5uAV-3@c*N99=pQ z)VWDY2X$AhZwFA{u280nlu4bGM zFBC)>#5WInqm0hm7@f>en(;~TF8}c_MisnU|1{NQ%x3sCybCiyMRx@|*|C0w>ZLCj z3Lm6i*0gn>(kRu|tvZw4WV9N3+e{RJ!*avhn`33OZN?kI)1rffPud1PRNDfEv$>CR zTBiuv$6*+Bs2-EqEX%`g?nA!IWwlgqFf3o{x{DHfWXiwm%-?n~yWX7Lf)IYhx^CA6 zz7e%Mag{+tLftaWHi4hIeSh_-W8alS+-6dI=N+^y+z`0|m82HP>6>awr?FFNB{6VW zj-tpY$g(GC?ak*J*rz7BVEwFUXFIH*Y*obI9G8}FVguNat~uy_yHTDiriQw&$@nEuHQ8p^ggxXp0@X&CA#QD z|GR49BE|TOLsg#(pJ-&Pnjp)1xji|ptJVO`>pe%cu>IrJ;c6%*$mfo~f5=;6%Vg?W z5{DvCRJ!E@dMrDyW=RsEE1C^dRu*ra8_gM3az<}ev&!P}pGmo8j@8jh^BS!@!_hrE zyoI`>s*faXH}GcDrP;I=A5RyWZ9u3^>nkps`s;}T)((UmWF zE{1}Oz6YN>u9jX?2ado@%vQ^Y>%O<-x!}q}*u4flocl$o#VAckL-x3D4mEsUxR|3J z=Xe&ZBhpOZY0q2~g0n~5(`K3ZJ?oSc_cGkjJNKoBLFTs#zmUC9E*yd0MHBb;oe9wE zIzFszUPvT-VkpkI6F%s|mGu>$%^x8#vB~T2r`S}%!OqOKIwIx(@e*1acUJV0Q`GJ|7q)ZY z!!xn@PkFP5>T2OqLGGWtHg}y0|J4hi$%#W@i~6z(p;EAR2^4{v)R3qSueQlGAiRX5$9N`|}b8G&GzxLvBsJAH09js_w5MS{M1d zgo)z?CLi9vUFAxEWS@ORXpixDY8h5c#Uhap6AYy$@W0>9Q7tPu0;AWoiM)HJVb25T z^!`BlYx2Sv8M{0QKHV3{v1M{A$AT(uT1khDSyq0bvNG&+A`~ab{R`X%KrOk^!c*0M z*8Z(@f8&yst_iHO4B{B}`S+k{){|7S37w@jeIkeikel!^Z<(59CV8T1|8dgm)TQJ);BLZuSQ*-|z5 z*VNS2*6c13G9L7@p*sZE_;ojBfQb4&1dMoNFfREz(_ddoKuAOs82rIQbTFRQ-^UE0HAaZno4qib;rzpeLUJ zi}nKY=#ElvaX{&KT5@H%Y8H=8&NeVQ@UlI1rvA{q?_#E2QXGyu*($96$c((X{FJ@- z=d3G_Inw)Zgn^Lt2sCouLr?i!9J(DEI73ajmA@yqb^D^$4@nxW*$JV#-e~Ku78Kgc zP1w&byJp?SM%b;^ce-w85ocQ&8iM_`37?|y_+xG?RYRfG7a$AXkn>+G3CX0BF}@7J zsddwkOjrN>k<$G?Ho*XA)K>W}#O#H{l6DBl&rQEv7^;F4J39~;6uNSJsujMd-6%Z~>Um>%o$pe0Kj^;L z!8m~zV9)!X1yl7^f6_wgxpVp&JR8gQaSDVqOj;WFrA;UnEvD^-R(x0qRx{m=^gG{~N>2;&uu59j@OV6~&8#joxHec_4(nhjx(JR4CgZ=p z9cu}R1bYN^0poTQryrf37Ql9_FUq|*3`IyVtzv#*P;y&^-OFO)=I(t~h5XtBq7N3(m|J{AT=dkqPs#w2ey7)6-725}r zMXiXIXi(F!--x4cAOyVdZwXSGdgx0&-`UjCX8rhfrl0R&!pzJM$kgs^2K(Aen6iU5Q+Bz#aKWS9coDI|!HqZAV%=={Y*pO1uVz$L z^mv5J>{~m0HElb*&8asv+&kmfwUdt)PNd%3-Y=h(>FC}I_&i9>zJK@Buoj*AQ|W8W zw#QIu7~;WF5o}X8ay5GfnqgPkd)Vq@lL!J|2Wh%*U8J>D-LI_#7Ah4V-cVgg`RQFu zH~V|KM0#Y%A?LB*38BUOGk`woiiO_dtoA&K-Jes%8ix1Xy&(CHK?!z2H3wFDYjPA4nS7SQQpRB;9 z%ukYNz8@i^h*V>Z>(z#VeXsp>u$O{+O_6r14_0Ib0L#pp9kNqpA($^mTF`UG_r1>D z_Zpq>)xUqr>0x3BT>Z^V(+AM5kp@j_aax-lO&o$Hlz!T}|E12|W^>(uuS$1YPXRTb z2BioRO&WJT%?5rW&XGFt(P+aIc(kDdRO(>qy5BQJ7w`LwaVqH}$MN>PLk;T@z(L0)N{F-xi-;@6DGDjVeTU-}68!j0tQG?!5gsW{QL#u%I`$OR z9Inu^`zm{lckDy`JGS=GiB~?z$BI|E?$$Smlil|-uSlNG_QAMXtwR7`0k=d5sY2xs z4&!Iy9LrPXLl15JfU`3>*KOH7TcaE%+qUE@IA}QZFe&JOvDDdHp5&$p0XQn?r}(c^)YhZ7(B>{g4-OZn!{sBK@I%9<2T|ivxVgx zguq}__t3*_KhKwTZ>=B|$CqZA)2QY0bmnFgRrqK^QCI0(ilkFarj=p&sN|qU?*%7K$^{?{2r1!>!(8Z@`UcVN| zWU!JNN|k`{X^zP+)GK$#uHJS|g7c1NcMU6Cqt`??EuylVD`1+hW9hV=lCfpg3SG-f z%OC*{QdAOd8v#Gtrm9lP?t>ozE1_1-uh}n?ZR;5{QF1f&_QNFY+P;T)tAaR;Dczjb z38b=j=8TRhHSxvFPtA{*g@kdXivwx=6-2{uBfzznpv-`&}lwf_7%d#st3<2W2sCh zj8{?YhTV+^-LCtc1mBJ8LvNb&ET}lDDo=Oog(-Kzw(v_h-|u!G&xExE_N&wxw>^EW zU|M`nJeQVCc0l~aB~Bp;G? zI{0XN7^SMPVhka-r2Jvj#RNa?>PAC8O8Hc*6Nel1qWdS+*YYtq>9hhVLf2+}YTLH* z^r`AWJ8!#yy%y`;n|U$%hjll_RMo$a-?pGH2Fj^+uPw}vwS4O$c&sO`D2Pei#7J#4 zDnbe?Udr*`rdh$MG_mz1BRD_!+7Ywi)@%6ar)M(GdA4MaQz{c`dg4}AUtv0;pyxy8 znqdy_qt~K+P3T&Z38)GoKs3+2{F;VY2ad_veco`~8CKXxkx8lH$yfuNDz&TYwmQuD z;DMIA8m$LAIIRgDoHoBc2}{G4qv`p`M}xWLZ>?V(@TEY=|y>Vqtk3&9%WI_{3Vg$$=$Cqon@dQ zIk_q0LpIACT-orkBRF4ZQZ#}_kGsLFZj4}grE4B-LG%4u&ZvOI^{86N`oX$riI3h{ zxTkEyWEB^e^U$57^%77q&kci6|HyHlzih-M`qk5s`P~{#UH(!~mQCHVgHMI=cC?Pm z0w&t3nRsMr^#_y67FWMA{8&$~*8&uljQMIB9s%`N@WJBrp!Kc<_F(dTi!TBWRm-^%Hwi(9ysM%aP*aWF~gdX%8#pj!j{R9Yl_Gj(;u_9FoD zaXDV_{W|~7L44WmhW7@O?b7LQTxPv5dI!Niv{ju<@ig*3hO0eZ$7g)^f&0vj&b)UE zU7NTth-BzMm|{XCY8*IOOOdbXb&VoB{?gSs4aSxsnLJl|44F~;oR9Ssb~@;DLEw)hV5rEYu%UX)y!Rl?7h`8-~Ud>CX^_w+|UR|>bNMh zYLc5yWM3_ooRx-GU!~T%wdyom^y4hlRd*t|TlwA`&Be|ZSuWq$`rc*@^LS6c?n}8q zKnk?r)OnHimtLBa<2sHk!HY%RnhL9e{gbvmV!;NGEGOraCh_%#(cFB^^1>mh7vF%9 z=FOsDKJMBRO5>Ap#SGbiEbjJl!}h-S$)(dS30kY&MZ9IJz;{yq(-ct&Plg3>#H_XX zg0XjDmt_{dscA|Pg9f9~!b@QF?xR>1t4gKe!s}TD>2aI0mdoOT#|L22q@gEJ@j#Q) zc;d%GTR7YP((Z!=hdm>(ZB7mJ@ZUaLh`(JBIj{BmgOe$;Z z^gwZ-vy?}70(KSVDL;VU>X--YYUR<=;Hi|}PJuj%- z(UW>xFR0k8_ck)*f0}r@j>_IxZ%BK|VqIGmg?RAn&DBak5&5HG@r1o~&6$6pc=@8^ z^sC|8=!7?d_C61261!SO9Mo7hDyfjRrCF?ye|2XKp3iDR1>fZsYsxmC#8QcqxDW%u z8`;00tqtYJJ z7bWEmt6`}l%PWJKxhk3V^|ajS?UG_C#C-AgY3udY1p&Qw<92T=&Gxo5y)&k#)rX>O z&c>TB9g58s;xQ_qrqf@HPLPAu`{^E)KwN9W`P%(zDKRG-X=`qE)4J~#?yZALx6=S9oVjdu5-F znRrwkSI-=^+bu_Pn`X>M?L&(d+LA$7#FDobqY@;e*TKM~#(eX%X&l`<&pbmTQnqcS z#se<)vzcE*1tbwOMN-y7ZR>_9S>2G}n{|iGpCcB2Eh|2u*Rs>eGqW(=8M=<`(f!G0 zxVQcE{??-d@-a31{6^=Fk?lF{l#;vl?8Bidajm%k-v9Z;~ zx$~Dx^l>Kpd_hJ>wCH@H@v?O3?(qZR+KmoxJAe4;Ml;33)z|FfyCt02C4-Tey$qXH z9z82qU_g0zTtjx-=$< z(WNWS;tczjkmp*rG)emFu#xT+YxBW|56>b{{^}p~Wlb11+hYc=1-!HgG7i0MI-)mV zNV{f+OexZHajO%RbpWcqy<>@XgQT{q0?nzC_nm&->ZHY{>Hv}W7ijD+nJ^lZv# za#i@f)#=cyXL(EkM(^+ds?ugMH{a0+=vn@V;iqwr!x^V)UygLVRspn|R=~%ss8sZ0 zmKd@4>q-N67u&gXo>o&TYLYcfB1;a5x4QmpwpRzxGjBjSh5OyEwU-(i3U_WAUL(DX1mI+EWxZC(DE(*X3;X zbah>P*s$4$^_u?(LLRG04}N?h(kQZcP^{J;(I8zLwn_u8xPMWr?tU1J9iVAV_Ceay zjV2MKO}|)%`copGlHuc*1nWtOyX12?v z9MzumJbC;e_Sh(r{Vgy_NsOz;JHut@jjML^zYsnK8zZ{eTCB4?zji0I7KxyK3@BUW zU$d;JR}dfMC%)K7Ri0D}*$3V69^daW6t1AX%sVopcFfh{0t$4{S}vkaAlq^BiTS7q zgolfI5_x+#3vb(aEla=teYhU&iD#@?*sI7eQ^hxr(Qbb{F9BYE%=SaRNUa!ZN;h1_ zyuujNRNYEcX_&tRUgv33u)N#9d*@;FN^Olq5;^DhZ_su56O5GZdRT1z5Yy4;j!J}t zFvnUdL#A8JVi{EjbQK!7I1wU&viiWLYy#q7fpJtotGA*XJ?-e3>>yLKh`eJDWNzP) zfNIg41&o4UTrJpby1)4sjeTU7dme=cpG)wyA4+}rmWmVW45;5AXi2cQ=M$~MH=Y&7 zh22U-9h<9G`8s(e^O`(z!Lh=P*I=|=sZq;ha&E?j4tvuie-jt}RzKj~8$x7Vf>@ax z1_6&>jzinuBo%Jn>wY-u>NK1-fbv$<<%s8=9-{*L1$K6Ke4v)U-^uZ?bR+6TtJ2Z< z0(H1PS2n%rQ15*~o(VPHwYt(zuF*9NOF{S7PM0QJvnkZkJ%(i?_M0II%ydbzcryv~ z4ilRj3j8Axs=plN$NKx>E94oLQ{K%o4GOJl6x0Ebw6T3jl8B zsHBke@Y;uD$5d26R&>5WXi{f&;W|t^)pfY%T2SHTjP8|@lfhEey5&aIUb@D9O{cXl zSVTQZX*O~2UPGLv(;1d{u+HXAB)fIhpOr~+#ZPGz?~3AF-t+m}IBeVKr6qOp)I7}S z)O~+PyGT_>OH;c`4~e@$*))OqSN&g?h>njoxLv zak9jg{F#W6MU11}EveZesIY%Gh#_(jNQROCTPd zJR)8jGHLw6v-6H;r1x%%H9 zR`-uUi*`@0{$5RIF{wtp%}#glWwc_^fNmsXZ9T+-Wv?5y2K#sHOpbhG@)}k{(sZAk zES^@Y4eR-j`ti2~lmrA~UPk)CIeZg0_Fw@pewJ}$ueVAo$c_92?}dMm$lxLS&H1kR zSQf3SN|C%zT3y<*8+KiJH`mVhB>PbbHQX%6p%A3S!{!I_i{*+tGC^&rnWmYZ^-5~B zS_n(~zC0jD9%GDd1rM%MYHa2Y4S$T_1VlP@u^w-84nukbbe+khnGMpS(Uv!T)$sS@kfq31$n#r zxDVC6uHrg?FCaXb7Paqs=L7w2AIkOgvG}oElHA0qjp zOY+VZu(;ZZcfML#^+a|7TD)?@ABnRQORk{9x zwX})E5ZXw1v7_pkgik^}UD7uqO;1rTPWN~3lxn=G6YcXJG4_aR$Y?HklMuBaAJfa> z$_oosi)Y8MD7?u))W#R00snyZ%5>DDTqYS{X(-!>k;31u1KhdcE)M`jSB9 z=>4|e8IezvOR)+eWh1mctpN=+tJ?JEpqq-W?tY2lO&j4?L>M{Ikx}DHF}B#4w8F!X zctMr-*e8mMUj~F8lLxGthKK*@9(_@-p!HT)kZ(9ouQ28jKcO|$Jt9gKyowAha2}PcarnMD%41 zFW>NJasM<{c}$3%ui$%-2-0P}_+g&so1qkxsT|bCU>DCM2mUqn(zsyO-4e<@*z)mnifqh3&D9D5dsk}}m$VdDnd z=-4~SeWm^sYc{Nj9Q3T#9K3joY6r0SES7F@M7t&``BDj`M6 zCRv!xDut7%kR_Vg+AWZk-%Fm)CFZoI-|H3Sn9iO^B5OO-j`zBPwW~W}tdzUHEcF8&y{loCif;xM z5TgrM-ageiK!M^Oy{hscYfI2)h`E!!4#11ZpF^qKbK%_#y=@$nZRXrJ6F}3mD8pX} zA?QO!t`McrWcqkfDLccKTZQl1U#ppti8$0h;+c>Qk2T}|k*VNRP$98rdo>0W>`1H2 zV_we7^F4sKe8?)7@~s8ucspJ1fya~oRO2)m^9%5!WNm=IeIcGn61as1GESyN+=g z+#dDOc6CcFxoY%3bFRfuu8P@!giUXqj$VtYteK^B{j5YJK$T$WMCG8U!Xy3JF>ByX zy)c{awO>m9SwpB+1!IZzxZszWDt#$qe*_gz-sXn4tO!|gNd>t9V43tjmGC0fgxgc$ z+pfn4dc0FGaErEuxemuMhwvlcmSBy@szz?gI|&a)HLzctb|jeTZpo|8pNiWBk@clA zH0p@Vzghx7x;gF<%M!DWjBS(8>`1R|FU1FRS0r3fWN()p*W&npVa?xUu$9v)Y+Qnz za5#He>j-8iK|QictQo=;?_AE@aWAS_5fq;9Ir?RAy9~3hSl5)L3jdT>8A5S$Iq?v5 z#URu9Ousj(6>%b7b~aJ6a_ElqHI6tGv0I&E!fvcjeUvRN`*;c&wBA~mK7$YDQ;zOx zo+&v8GdWF0EfN5si^7fGFpBU|@gOpCU830;p~D|WDv$2=!mUL1sEWVaLT{y(J~g{U zrqZXG3&fc*LVK7IrdLl#`3oIJYur2FHI6(9MeKz}D|>NWds+%%p?DSca?I4o&o3Q2 zCG!l8KkmkZEkai_=!dYvwVbo?2|j%ST8qd1_rWFQSa?LK|EfO2@UB*$^)KO??@Ic2 z6Je#fW|(lJvr|iu9?!^~%~(R7!ngw7(BzuW$XS4~dtC&XV!lW0>~WTfH&Y2-fNSs7OexQ#&LE6%oDKm7d{=bBsO@n`bt z#pW%(E1B@FhVP4VTUqp~w;7iY-l}^%#v9Ht=^`{i`QaT9N$_1>BT>zjVi}`-QPo>j zmMD{Vt@Js7&EV12-(&ExP1gi&FcpOl1zw8!Yi-BGkjG}~Hu5Cz8^mFdNrkt*GlV^` zy0?6tZLmuT7)=7-U}>vlZSQO)UbEuz2E#; z(=^F9E}upQtnr|!p@7ePVqB1mO)Rftt~nQvOxVXucnvHe06rMUsc@Bw)83YyPj;j~ zLy4@KMo$Px9fdA~xM#=NrauA8kGTqE9r}_*yPCEJ)PzUA03tc?^}M%X%Vsl=G8hRK zoUWsSB!5coYmNGWlh_Ps@_Bi`hY)bOm;xH5NBV=FCu-xho)uq!|Kh!lvhR(w-au|j z^Y0JIdskDEqDB44iYWd|``5RUn}o#1*Cxdl{YcJ{EaLxs_;{a_1ET&xog$@ayH)Gf zO@=%6CA`b$5d-#LDGX|~*VgPczS=K+O;=mczRMny$*M*f6a{^@VKGlCv-GP9# z!KtU>{eM|;ehZqn0Qqui`ah@#_ypX(8;SPK|HI~gv$P%{Uy+gj2lam$jmstC|7A3; zI^zGYM&s`L|Bp?_-Rrqv=2|2C+6SVb?!>C8ZTBhL_ijrMz(?rk=`d>6o_;Or#l zo5aSh{`k`?;h8;ciwwOx8m>Fk>!4#LT|5QyYs&v)(9f+{edZ;!iANzm>UU5NUw8yl z*emD0%Gz0bMcVN%kvKyFK0}IOhT4-|FWEj<${m6{83@zislcs zu}vWOY@SoRFfSD{nzn#!bX8=_M9> z-}A#F0jDyUb&`dMb@us(B~HOSnBgr72v2vI%24RPFvI`)$CRg%@?iCdk5&&MU975$ z6gmjvEzvIIFYP@}}8#ygIvddY%hDcd>6q=FAr2%7f+&;8lFW(hYEa1^60* zt8MHLKM5qrU-Xc8^nl-drn3w@8WiXMKfVDRB!P`^^gleL96)|kIN<-6oxl}-GT4*i;7u;g|hy zLwi>4Zg@}fswNDst_I>xp@11+T`tD{1I`kFC3^zVMoT^xF^D4&AaDWxJA@+V_|c;a z6Z>Weux)F_(~j?g&PwKpZh_4n4#>7r{q18f)@RAswk^j{CL^B6A#|!u*IkDLBh%JE0a_> z?zV{A9_zebf!iM65)!*G%h!O8KYd)pF5{~Z-n)pNiA;A(z7fW%^3)A%AG{u&8PyYl zWV|iyo-vId-wX~2+x_bJsyZLehqj|`4>hD}57B^s){BO{+UuCS?)b7cDXEO6ZHMRC z;FG_BGjSsxG&=Cz=H_iJ9%T{=y^KoZx)tkS#cU3&{&OX&E$xNv&r8om99ltGT861x z<`W3(sAp1mNpWth3NRT>lo>NU5)5TK&Hef9TKV8Y0BObCkn8g3N5%`PTi=BzaHKU+ z9YoJX6S6SD%AM(G3ESEX=dZ7ZuEn~SI1H{m9>I@SVJ9R-jK+3BytB}DQ9A@Ld+4Vb@syzmQW7viz)kk~ zf|m#dwyccYJ#Qj~4@>wl`MHT4Cqo=0TnNYasV~#(pm^WgH4AL_9+Bd%YbN$m-wW2s zP|qs< z-zti)nOXjOF@>>4Nk-x3u#{|~U_pd(^oYTs0?+;Mt(dK2#rk<1O_+KGWNIhhC}=g( zU(+YZYn4cZ2`;TsV~+Tk4qIRAfET6*@LvYUY(*;pFJ?~`Q?=|K9+SAnhjV*x$zB3t z zSfO{XBCdgNgcrP<<=ORHqy|3%hm5)blP9Bh9`9PAEbiRTMt z<=-|{{W}hD<@4uUp|n=-m!U4%LMvi1Q#CqPMA7ii*KBpv(!!V`R}h6CP0~j&6osu2 zN)lA^UHT}h^c(~h9EYDlzwL(*#A22!RAL)3WiQ-}ZL~8$S{|XIIv-Ylw9Wg6S~XkX zOdEg`z<_;?Q#`JsGohr=EUhf=)@ak%*c6LP>uMj_D=AX)?Ln95`dgwakd8G-*F(u= zx78Ig#ycIp{+X}a5VY{4r>>aOBl^OHBz+RdZ{Z7r*V_Q%_DtQ$erBt>q@EkL^Gr`F zjQ+-B--^|yx*O%XgAbXBgSyRyeWh6Z-QYBVqblb^z#ZhbL%s>R_()r|ixy=Z<{Z=srd zY}DhofmfmBi})44qvz9@eojkqLPEmO#5gUvtP!I*`tT?Fg)4)h}JN%{rCVWHWr?~i0683gPfUB#O~ha3KwYMR$e};v~K=n z-fOt>O#Ii2AcI;OQfAS2tr4YiVXSf^HYcv{)xY45UBC{^`bF84N_ekn2!h+K&U^`( zV)WoE-Rs6DwQq<7X!Tjg@Ud#g^c*)6Nq2Ps|v6Nm)>pzMmf zVLP~7S)5H(B^&0#U82XQqb-3hRISX@c|dEg!Y=44dHblMq&TYL{$t<6H(zqOy?VgW zZyoIOK)|U&yu=Eqn-o{$2&e#UzBIW)HW*tO#JM%)XkPrgGuY**+;T)wxwDv_8kVIT z-=Dwfi|O}+Ju*QdwPUD5k{_!xfSN>(wE%46Yes3w71Ow9!%&A?~E-j8HEc2f^MRAWZ5+I9y)h zSeJ7vS7qgGb70S6D*GLAh4waQ*Kp?~m75G$0uw>R$)R7xV3Vi+45IMXbpb;^#^+Ei z`*Yhgp)~A?hgtN%r6k9uLBlF$4e$w_Aw-{kF<-x6%tBx)X&(|9pw}Hcb6*J4WFPjy&MRD<@gsZLG%|qNv&Wxe zaqZre(zz{P69KpIdR91;B<#(?$asuLP9ce=(COS_T|tC3PwDU3LK)wV;oF?(p3Nh^ z@VUhR?A=6M98Z!MNF`f*;wB-xqD!R~@NY|BC=EtDjgul`MPJj&_HXiURU8K%ryE;F z>4~GHOE-TJdVhr&Fs1m=ofqr=jXS@61j5IAA$Ya2>Qyj{vN1C-#Dia}VNCesc@}cS zx}Z#hP~^gm&;4S{wr&k(jzq|H$-OwA8v+D`h=9v$+CPeqZJqNwM{2l69e!QGRiJ-C zCf*w^(NFKRw_4tZr3psUN~oqZ^;a}ChBeS#akWGgx0UUeNPon~C^Qtp`PNQjJuJ74 zV_h=_t)3_wBiQORb7`}bAKmDpwCq;t6b?Hs|8_CCwgyzx`oXCy<@=1y;jKv~gqss+ zNhK|%SioN2k<*Ahb6xx4`V($gmMmCY-Ow*x)~1Z2+S1obxR`N#%_FZZoat+T*QPC# zgR+caPHI_aXRD5BL%!V)PZ31y)5zSsp_fXA6|9kpD_}fQW4NG;wt->Mn<$vM{4-n= z>g`QiFA59#BthNvpu;`{jH#^Vy(Beq3y={$cxMufGVh;V?zJ`qAbWaUNc^B*%Moz~D;G^7ieLP!#{lS^|fR}u2$^XYYq zh~d1Pm&b!)=0V$fiBH3L-#rzw$l%H1UtuIcnrO=N@uathe#w`U;qfeXfL}L0Xqk5u zs>9K4Nt*KWV~Rhg8{ASlpR;_^ttVzMzDra&`er3&D<*->YIDO|UMY?YqkN@S4q6@Q zj)5oEt&}b&9LS?3T7Ro5tbcpUjt*@uc4jw++qjB?W7+6Tk4bIJL+jM7iR|s@RfgdK zlh_B(oQC;Hs6Jf)W{B^d8%_5(zQdk@+3WOaQrF$)?%KD0215c@MT;$SDb9v7Uy{kj z@xsucsuuBzRVN|$)wkttB51QlG>w(?gt~cpQC3IQs<=}NJF}ZILnxiB4SwuGg+6#l zc+7dD!xDEN6PzRG!{v)w`DnrA-L~+ZaV6BSA_H>mdk4AAqn?$KD^8QN6k+Khyp)nh z&l>lvqE0jBHL9Asv-pYRjY**BEs%H_o>|q32@ECM-?D_W1rkLa}3;d zU}95PnTz#uJOr6Un`@2|=!$g3^p~=jBHA#)vKl9+c*$ReF6>O?a4R72;=lOTHg_ChlIigjWWjBQ801w(lgu zY5r)o`wHD?8))C9H~;<8ZfC{?=4n>up%C>fR3E;=bitg|=bfh^exOIgi&^gU#$#&v z-(n6~QgwOdbED3;V8-`J*#wHz)zsRdncOmtgU`5848hA6fY>OteTH}DMWtWfeL2gY z)--)~Zih3b_J2VkooRo1x_=XbGGO(32=tQj4l zyr2nsG?aacA@YaY7>;Ac>z%<-wdvVyqt`g(3UXbZia!Tzde%UyU4?;2Rut1$wBw)k zKma*#A=w}gEUBhQ@oCP$UEh?NclmEdd2e!qD&`T!I}tXeXKYWNJW*v;bk)&K{d}yx zYaXheJYsgNx0{U4+2WbseLH-s(ZjQS4_&t*&QoO~w%k z;APY+kGy6b8^g_;xa=bS_T|#HvCI_~;JmZZ)at0zRZZH?s({yVu3uf#gc`YCRQ4 z;qmm9i5H@6f^ok~QVV%Muf9!!Rq?;RO*&qqTU`j{P;a;v*Zr<`q7o!*;Z32!e|~Ace^96Laa!#C>C>+A&ZPFiUue_aDOZ2j4Q->a<4!&!&L-x1?4?Wh zS!G{JsRQs4b$-2StEKS#ObHABH}`j2_eglI?IQA*41cpU^vE=rwY=K_OXOI(c;6$S3jreAW>PG7rJwdn31g^ykP_Pu; z2inKx#QMC^et)NQentEMu=(}nsw}w5O+y?3_#G*iu?jO>{~$@j36TGDw*FU^G|vG4 z=!+?}@59}e-jZ+6k40zY@-9-}02%wWdB|c4&eVZKz88vShqB8 z_;?A=2N1!n0G1M5L0`g?I!_ZL$`btlfuqv`itBPi)iyPkmM~nsWRi{AeZq!0!INn&KqJ{IPwRWeW346gWO0{ zzmS3w;O7IbX>-C2ei8NFthywXF)5pM5#u8;0K_#_vwvPofa4veoB%Da3s-$|fl6Er zh^xVEqr&cj6zewtvL|Eu^9xiO=W{9UPuS1n4t&lI0`3zfxz87zaX#6 zbvK)qXzbg^rNe%_HNhfJeL0Rjw;I&nmDyacVtuT($Yp|9RxL1hJGAP3UEz7+DyefV zFz$U%s@?YO%VJ9EEH+0&*Z15j_bBcS>JQ(>HG)rxzaga-dJR486OR65@yyrRFY4#J z+!ga#c`|)Hc#*>(BHdEp^`gO*xh&K~^fo!WOg_fU%0rB<$@=l#y$6B%R|>Oc?M^@k z;+KUuBXML)tbBFGD#T^3NfsvV^_@;G;!R;%omO{jz3D&%#nujg)40iePo(}{54L%? z$f!m8(7y9dfA*=-9vwrE4qc;H4as=xILwv~mzm%0;MwxGJ>pt&s;~e>Q?Y7lj|y16 z&GR~NQFZ4G9<+QN#hYc^5#P6K?-i7*n*37baI1)2HT5}#n>F%2>-RzXxhY(Be^|Uh z7nGAJ4xNFzSp;jab0|v~Xow$Jf6JXLRg20sV2qR_+ZJ5A@-?`9EXj+}rO+f21>y^{ z`4Sv{$Z{H`W_lX_syz~TniDGAuK5OHluaaUQkm4Gp`9;`#^<#QX}u>#M&#j#3N7cY zev`v@og5iAmt;l?EtAZ0EGh?_)&jXFZvf@a}KM{AZw?n%`_60dS#Ft$>jvU=k zl}3zbkwCe4)cgv6`?~K}x>r#Osxv;-{aY0aDN;#l@hj~KYvL}8hxajuKl(6>lPe}Y z5?|9^*NS3{cfY2ik-G_9HrRsKr=r}JONMr3$tz!Q?SCl0fd=DI=^vm-d@ z#cHV;TF=CcO7rP#O01t!Y$!6S@Yzd*0xh`VFnP_k>Upn@y3C%}6Y(QyyWu72E(w%j zobQ?@^N+bLYXeRoL3!&G$adXUJ;n>M;ZySRLsG9fEWW#cy5&$u6dJo~3)B=PiF}P< zI`#&hQSVQ9%7DISeCl>2L9yQ|N=lFswZ*g0-hFab-ffGmy@FZEmF_QFiR-V$YS|st z6~P^VhUaMMoT2uGrBrF%gW^ZRj(W^x&a#dbgUKDii3g}e2OZjFg4jM08=-#xm=c&8W!M`3SMhmJ6KE806Tqqr9l-_60BzZ=HD80;;!Sc^9fA(3b6SP>$tmCR zyA;JiWNr3dI=JLweAjbaE?7|#f|uj*qzyqndVI#ed38NL&q!N&XK@q**x@INZ3Pw- z6LuVnLhzU>OxbI@)?odtupR-oj{fLB9Ecb60L3~Szu}$;*96~LV>@Gwr!%a>7NXIF z5%HBr0Woi{bONX39slVmEi~Q3UmBZdlTF}G*Q<7K#@C%q=rZX#hlu?pxs9V9U??kGd@8cyC;t=7Pb~!SCWf) z5tSSK?aFtK-4)NN>|^pHt%_GqB$-i}{@4IwdhiN~d% zj=Q||Y;JscB*jRgLE`e9kPJx`wg6NH~;K}X#K(e@I(q};K>Ms}1(H=?|q&at5#M zQWlA;PQr6~=cG}>Vj1@A6Z;&MV9=7U;^@c`&}-e~aLeO8-cU$kU}D)ytkZEX+yXa@(; ztSF5P-Cs`T+OsOb(#xS+6K<=8+vFV1x&D8e^%3D?_oX2k14->*cT0FKtV1< zRLkn|N*St=oR_bJdbd{TzOX73oD@seF$yX1Us*_STRRw}1yi%F#SRn&cXQOVkW;#_ z+Ybo^JLG6W6GvPB#F}?3R_2MU)voVV()dt(!&*)z7TPapQ!D|c$7P>8(x!9t=P-jT zDf-6PIQF(mu?rSBg^_r%#rXCKeQ{21ONipcas8}C=^D}F%q$~zzGQi}@Oy1lbKOzR zMk;d11U31g&Ly#^m77H6?qrW?1wX9b)2Vn@WW_VfzaO{tY5fUAa4V0q~} z_sOt~j$clFZ!sg?)MN1ce2?Qm-fRtN?zk+Y(haex(PBB4o)z0X0=Ghs9?!ml1W<8R z0mJGR`=J6R;hS7-d?J4zA98!pMWSRp>14skq${FfaT;YcvJR>p5=C*`i?^+WlZjq9 z(R+P$Mm;1;sy#(xWmVRYn;K)vszbGYbw>aRJGjBUp%~Y)C{9TF5~7E0qF`9+7(*+B zIH8A9Av!LFlZ^C=m_&I7qve4^ucKVUR%!9R+SGUJILWlyzhNaG9#P5Cgo zkS$%6+hwA<9)|FdJ>s+&UBtilrmxnFS9EP4R^{&c>NA5~!?V2=( zJ+8R#EY{aqaF##>nOBVHEi21!l2GCk{=%Indpc{7V!0r0ARvwDHC9^RmEO_Qzc7(vbC`63V!CufEcbGUby$x=DyHvF<9yCLqdDMxe#2QddJnfQz_3rrzVR4w zm$>~o@9iSB2KvnsGF~n=?wl?eFN6)Gum$?IC>Hdb?8;}7^sN?vcr9~FBZMz!u7v^qY)wpUZJ9B4+Z!7r-tt(%Uwk)k6-qglq#hc!wY zm^hdK&-sFNxYNq?9d;ie$;Zc+$1AkF%ufuIUAB?pRGFbXxgr=W!$U|i)_!zfYo%LE zdJ&t3lfHOM4qTm42+6V+7tYgTTN%8uf1~*7a&mw3>QMuj`L;W6dKwY5cYFi#m7@`dPET1}AU^M;pgcybu<& zQc{NKc+967^p|#++M(H;p-OKL%cWE|PxUZLWA`x#ZxT!%A#)~-y-s?2(x#{kK30mf zK5FQI%)yR=g{lruRC)4H3?={M%h6H5GlnMwt3D+1dFE!1KjvSs{nIq5N9gFTD42{e zE~eYDO(!J%vL4E5mF;(rp`v@vGfAF;)XIQQYW6tNtK%n-Z4M*UQqa&l>@mhwO%`wY z8jZ_#6J?}zcX-EEfNn_ZBip-#9>+2wha=GDF5sy^W>I>Ok+HX+yL_963tsu{U-OF{ z^c{Zx3g2_kPe$EyJbj0n9_?!-ZEa{}?&JE^tnh2BUm>sXgRzA9;FN7}*Sc&1Xh`W) zWmHsWtk{Yn?6X&1q)>kJfo&S_KjmbW{m-wfT zl+mQM);HrbPo^1-6XU(LnJS|VDIwdV;yGSfZ{9wYm;97-Cp9iP#sZh)Koi#A;UkeO zS|&wHg>KEpt85`6nLVEr%G{Jzaq2)X;Ryi9k&X7|EnENqD8JypYQ3XV-Q_p@_~I9r z{#BX!SN8w9@&t}ZcOKG{;l`(cf)E>HsQ#W@{_AQp?i^`(mUZE@f>_dgzZRJ0JBJ=z3TZkYm1B`a(?8HYbLIPM(iHC2)@2sHl?QXz%&> z=OwwgVgN?11Ii|k0>k|NaW4XHmplJe{C`jFKoNkNY$DV@U0i`=2*T|zaQCmP|J0qN pJ^jbM&WnBjvCRL6Sn6|jMUZ5VN=m%s+a=&nR$A#L{Q29D{|EfAHh=&C literal 0 HcmV?d00001 diff --git a/strands-agentcore-apigw/example-pattern.json b/strands-agentcore-apigw/example-pattern.json new file mode 100644 index 000000000..96b0b9b20 --- /dev/null +++ b/strands-agentcore-apigw/example-pattern.json @@ -0,0 +1,93 @@ +{ + "title": "Serverless AI Weather Agent with AgentCore Gateway and API Gateway Target", + "description": "Serverless AI weather agent using Strands SDK and AgentCore Gateway with an API Gateway target. Uses MCP, Cognito JWT auth, and WeatherAPI.com.", + "language": "Python", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "The user authenticates with Amazon Cognito and receives a JWT ID token.", + "The JWT is passed to an Agent Lambda which uses the Strands Agents SDK to create an AI agent backed by Amazon Bedrock (us.anthropic.claude-sonnet-4-6 cross-region inference profile).", + "The Strands Agent connects to an AgentCore Gateway MCP endpoint, dynamically discovering available weather tools via the MCP tools/list protocol.", + "AgentCore Gateway auto-discovers operations from the API Gateway REST API via GetExportAPI and presents them as MCP tools to the agent.", + "The AgentCore Gateway validates the incoming JWT token using a CUSTOM_JWT authorizer backed by Cognito.", + "When the agent selects a tool, AgentCore routes the request to the API Gateway REST API, authenticating with an API key managed by a credential provider backed by Secrets Manager.", + "The API Gateway proxies the request to WeatherAPI.com, injecting the WeatherAPI key from a Secrets Manager-backed stage variable.", + "The Strands SDK handles the full agentic loop: tool discovery, Claude tool selection, MCP tool execution, and response formatting โ€” all in a single agent() call." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/strands-agentcore-apigw", + "templateURL": "serverless-patterns/strands-agentcore-apigw", + "projectFolder": "strands-agentcore-apigw", + "templateFile": "infrastructure/template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Strands Agents SDK", + "link": "https://github.com/strands-agents/sdk-python" + }, + { + "text": "Amazon Bedrock AgentCore Gateway", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html" + }, + { + "text": "Amazon Bedrock AgentCore Gateway", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html" + }, + { + "text": "AgentCore Gateway API Gateway Target", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-api-gateway.html" + }, + { + "text": "Model Context Protocol (MCP)", + "link": "https://modelcontextprotocol.io/" + }, + { + "text": "Amazon Cognito JWT Authentication", + "link": "https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html" + }, + { + "text": "Amazon Bedrock Cross-Region Inference Profiles", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html" + }, + { + "text": "WeatherAPI.com", + "link": "https://www.weatherapi.com/" + } + ] + }, + "deploy": { + "text": [ + "./scripts/deploy.sh --environment-name dev --weather-api-key YOUR_WEATHERAPI_KEY --region us-east-1" + ] + }, + "testing": { + "text": [ + "See the README for detailed testing and end-to-end validation instructions.", + "./scripts/test.sh", + "./scripts/test.sh 'What is the weather in London, UK?'" + ] + }, + "cleanup": { + "text": [ + "aws bedrock-agentcore-control delete-api-key-credential-provider --name dev-weather-apigw-key --region us-east-1", + "sam delete --stack-name dev-weather-agent --region us-east-1 --no-prompts", + "aws secretsmanager delete-secret --secret-id dev/weather-api-key --force-delete-without-recovery --region us-east-1", + "aws secretsmanager delete-secret --secret-id dev/apigw-api-key --force-delete-without-recovery --region us-east-1" + ] + }, + "authors": [ + { + "name": "Mike Hume", + "image": "https://serverlessland.com/assets/images/contributors/mike-hume.jpg", + "bio": "AWS Senior Solutions Architect & UKPS Serverless Lead.", + "linkedin": "michael-hume-4663bb64", + "twitter": "" + } + ] +} diff --git a/strands-agentcore-apigw/infrastructure/template.yaml b/strands-agentcore-apigw/infrastructure/template.yaml new file mode 100644 index 000000000..addf64dd8 --- /dev/null +++ b/strands-agentcore-apigw/infrastructure/template.yaml @@ -0,0 +1,320 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AgentCore API Gateway Weather Agent โ€” AWS SAM Stack + +Parameters: + EnvironmentName: + Type: String + Description: Environment name for resource namespacing + Default: dev + WeatherApiKeySecretArn: + Type: String + Description: ARN of Secrets Manager secret containing WeatherAPI.com key + CredentialProviderArn: + Type: String + Description: ARN of the manually-created AgentCore credential provider + Default: '' + BedrockModelId: + Type: String + Description: Bedrock model ID for the agent LLM + Default: 'us.anthropic.claude-sonnet-4-6' + +Conditions: + HasCredentialProvider: !Not [!Equals [!Ref CredentialProviderArn, '']] + +Resources: + # ============================================================ + # API Gateway Resources + # ============================================================ + + RestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Sub '${EnvironmentName}-weather-api' + Description: Weather API proxying to WeatherAPI.com + ApiKeySourceType: HEADER + + WeatherResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref RestApi + ParentId: !GetAtt RestApi.RootResourceId + PathPart: weather + + WeatherCurrentResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref RestApi + ParentId: !Ref WeatherResource + PathPart: current + + GetCurrentWeatherMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref RestApi + ResourceId: !Ref WeatherCurrentResource + HttpMethod: GET + AuthorizationType: NONE + ApiKeyRequired: true + RequestParameters: + method.request.querystring.q: true + MethodResponses: + - StatusCode: '200' + Integration: + Type: HTTP_PROXY + IntegrationHttpMethod: GET + Uri: 'https://api.weatherapi.com/v1/current.json' + RequestParameters: + integration.request.querystring.q: method.request.querystring.q + integration.request.querystring.key: stageVariables.weatherApiKey + + ApiDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - GetCurrentWeatherMethod + Properties: + RestApiId: !Ref RestApi + + ApiStage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: !Ref RestApi + DeploymentId: !Ref ApiDeployment + StageName: !Ref EnvironmentName + Variables: + weatherApiKey: !Sub '{{resolve:secretsmanager:${WeatherApiKeySecretArn}}}' + + ApiKey: + Type: AWS::ApiGateway::ApiKey + DependsOn: ApiStage + Properties: + Name: !Sub '${EnvironmentName}-agentcore-key' + Enabled: true + StageKeys: + - RestApiId: !Ref RestApi + StageName: !Ref EnvironmentName + + UsagePlan: + Type: AWS::ApiGateway::UsagePlan + DependsOn: ApiStage + Properties: + UsagePlanName: !Sub '${EnvironmentName}-agentcore-plan' + ApiStages: + - ApiId: !Ref RestApi + Stage: !Ref EnvironmentName + + UsagePlanKey: + Type: AWS::ApiGateway::UsagePlanKey + Properties: + KeyId: !Ref ApiKey + KeyType: API_KEY + UsagePlanId: !Ref UsagePlan + + # ============================================================ + # AgentCore Resources + # ============================================================ + + AgentCoreGateway: + Type: AWS::BedrockAgentCore::Gateway + Properties: + Name: !Sub '${EnvironmentName}-weather-gateway' + ProtocolType: MCP + AuthorizerType: CUSTOM_JWT + AuthorizerConfiguration: + CustomJWTAuthorizer: + DiscoveryUrl: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/openid-configuration' + AllowedAudience: + - !Ref CognitoUserPoolClient + RoleArn: !GetAtt GatewayExecutionRole.Arn + + WeatherAPITarget: + Type: AWS::BedrockAgentCore::GatewayTarget + Properties: + GatewayIdentifier: !Ref AgentCoreGateway + Name: !Sub '${EnvironmentName}-weather-api-target' + TargetConfiguration: + Mcp: + ApiGateway: + RestApiId: !Ref RestApi + Stage: !Ref EnvironmentName + ApiGatewayToolConfiguration: + ToolFilters: + - FilterPath: '/weather/*' + Methods: + - GET + ToolOverrides: + - Path: '/weather/current' + Method: GET + Name: getCurrentWeather + Description: Get current weather conditions for a location + CredentialProviderConfigurations: !If + - HasCredentialProvider + - - CredentialProviderType: API_KEY + CredentialProvider: + ApiKeyCredentialProvider: + ProviderArn: !Ref CredentialProviderArn + CredentialLocation: HEADER + CredentialParameterName: x-api-key + - !Ref 'AWS::NoValue' + + # ============================================================ + # Cognito Resources + # ============================================================ + + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: !Sub '${EnvironmentName}-weather-agent-users' + AutoVerifiedAttributes: + - email + Schema: + - Name: email + Required: true + Mutable: true + + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Sub '${EnvironmentName}-weather-agent-client' + UserPoolId: !Ref CognitoUserPool + ExplicitAuthFlows: + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + GenerateSecret: false + + # ============================================================ + # Lambda Resources (SAM) + # + # AWS::Serverless::Function auto-generates the execution role + # (including CloudWatch Logs permissions via the managed basic + # execution policy) and the log group. Additional permissions + # are declared inline via the Policies property below. + # + # `sam build` packages the code under CodeUri (including the + # dependencies in src/requirements.txt) and `sam deploy` uploads + # it โ€” no separate packaging/upload step is required. + # ============================================================ + + AgentLambdaFunction: + Type: AWS::Serverless::Function + # BuildMethod: makefile lets `sam build` use the Makefile in CodeUri + # (src/Makefile) instead of Docker or a host pip install. The Makefile + # downloads prebuilt manylinux wheels so binary dependencies match the + # Lambda runtime on any host OS โ€” no Docker and no local python3.12 needed. + Metadata: + BuildMethod: makefile + Properties: + FunctionName: !Sub '${EnvironmentName}-weather-agent' + Runtime: python3.12 + Architectures: + - x86_64 + Handler: agent/handler.lambda_handler + CodeUri: ../src/ + Timeout: 120 + MemorySize: 1024 + Environment: + Variables: + GATEWAY_ID: !Ref AgentCoreGateway + COGNITO_JWKS_URL: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/jwks.json' + BEDROCK_MODEL_ID: !Ref BedrockModelId + AWS_REGION_NAME: !Ref 'AWS::Region' + Policies: + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:Converse + - bedrock:ConverseStream + Resource: '*' + - Statement: + - Effect: Allow + Action: + - bedrock-agentcore:GetGateway + Resource: !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:gateway/${AgentCoreGateway}' + + # ============================================================ + # IAM Resources + # + # The Agent Lambda execution role is generated automatically by + # SAM from the Policies property above. Only the AgentCore Gateway + # execution role is declared explicitly here, as it is assumed by + # the bedrock-agentcore service principal (not a Lambda). + # ============================================================ + + GatewayExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${EnvironmentName}-weather-gateway-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: WorkloadAccessToken + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - bedrock-agentcore:GetWorkloadAccessToken + Resource: + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/${EnvironmentName}-weather-gateway-*' + - PolicyName: GetResourceApiKey + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - bedrock-agentcore:GetResourceApiKey + Resource: + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default/apikeycredentialprovider/*' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/${EnvironmentName}-weather-gateway-*' + - PolicyName: SecretsManagerAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:bedrock-agentcore-identity!default/apikey/*' + - PolicyName: ApiGatewayExport + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - apigateway:GET + Resource: !Sub 'arn:aws:apigateway:${AWS::Region}::/restapis/${RestApi}/stages/${EnvironmentName}/exports/*' + +Outputs: + GatewayId: + Description: AgentCore Gateway ID + Value: !Ref AgentCoreGateway + RestApiId: + Description: API Gateway REST API ID + Value: !Ref RestApi + ApiKeyId: + Description: API Gateway API Key ID + Value: !Ref ApiKey + UserPoolId: + Description: Cognito User Pool ID + Value: !Ref CognitoUserPool + UserPoolClientId: + Description: Cognito User Pool Client ID + Value: !Ref CognitoUserPoolClient + CognitoJwksUrl: + Description: Cognito JWKS URL for JWT validation + Value: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/jwks.json' + AgentLambdaArn: + Description: Agent Lambda function ARN + Value: !GetAtt AgentLambdaFunction.Arn + ApiEndpointUrl: + Description: API Gateway endpoint URL + Value: !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/${EnvironmentName}' diff --git a/strands-agentcore-apigw/requirements.txt b/strands-agentcore-apigw/requirements.txt new file mode 100644 index 000000000..4b8f188a5 --- /dev/null +++ b/strands-agentcore-apigw/requirements.txt @@ -0,0 +1,4 @@ +strands-agents>=1.0.0 +mcp>=1.0.0 +requests>=2.31.0 +PyJWT[crypto]>=2.8.0 diff --git a/strands-agentcore-apigw/scripts/deploy.sh b/strands-agentcore-apigw/scripts/deploy.sh new file mode 100755 index 000000000..28ec1963f --- /dev/null +++ b/strands-agentcore-apigw/scripts/deploy.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +# ============================================================================= +# AgentCore API Gateway Weather Agent โ€” Deployment Script (AWS SAM) +# +# Deploys the full stack in the correct order, handling resources that cannot +# be created via CloudFormation (the AgentCore credential provider). +# +# Uses AWS SAM: `sam build` packages the Lambda code + dependencies, and +# `sam deploy` provisions the stack and uploads the code in one step. +# +# Usage: +# ./scripts/deploy.sh \ +# --environment-name dev \ +# --weather-api-key YOUR_WEATHERAPI_KEY \ +# --region us-east-1 \ +# --s3-bucket my-deploy-bucket +# ============================================================================= +set -e + +# ------------------------------------------------------- +# Defaults +# ------------------------------------------------------- +REGION="us-east-1" +S3_BUCKET="" +ENVIRONMENT_NAME="" +WEATHER_API_KEY="" +BEDROCK_MODEL_ID="us.anthropic.claude-sonnet-4-6" +TEMPLATE_FILE="infrastructure/template.yaml" + +# ------------------------------------------------------- +# Parse arguments +# ------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --environment-name) + ENVIRONMENT_NAME="$2" + shift 2 + ;; + --weather-api-key) + WEATHER_API_KEY="$2" + shift 2 + ;; + --region) + REGION="$2" + shift 2 + ;; + --s3-bucket) + S3_BUCKET="$2" + shift 2 + ;; + --bedrock-model-id) + BEDROCK_MODEL_ID="$2" + shift 2 + ;; + *) + echo "Unknown parameter: $1" + echo "Usage: $0 --environment-name NAME --weather-api-key KEY [--region REGION] [--s3-bucket BUCKET] [--bedrock-model-id MODEL_ID]" + exit 1 + ;; + esac +done + +# ------------------------------------------------------- +# Validate required parameters +# ------------------------------------------------------- +if [[ -z "$ENVIRONMENT_NAME" ]]; then + echo "ERROR: --environment-name is required" + exit 1 +fi + +if [[ -z "$WEATHER_API_KEY" ]]; then + echo "ERROR: --weather-api-key is required" + exit 1 +fi + +if ! command -v sam > /dev/null 2>&1; then + echo "ERROR: AWS SAM CLI is not installed." + echo " Install it: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html" + exit 1 +fi + +STACK_NAME="${ENVIRONMENT_NAME}-weather-agent" +WEATHER_SECRET_NAME="${ENVIRONMENT_NAME}/weather-api-key" +APIGW_SECRET_NAME="${ENVIRONMENT_NAME}/apigw-api-key" +LAMBDA_FUNCTION_NAME="${ENVIRONMENT_NAME}-weather-agent" +BUILD_DIR=".aws-sam/build" + +echo "=============================================" +echo " AgentCore Weather Agent Deployment (SAM)" +echo "=============================================" +echo " Environment : ${ENVIRONMENT_NAME}" +echo " Region : ${REGION}" +echo " Stack : ${STACK_NAME}" +echo " Model : ${BEDROCK_MODEL_ID}" +echo " S3 Bucket : ${S3_BUCKET:-}" +echo "=============================================" + +# ------------------------------------------------------- +# Helper: run `sam deploy` with the given parameter overrides. +# Reuses the already-built artifacts in .aws-sam/build. +# +# sam_deploy "" +# ------------------------------------------------------- +sam_deploy() { + local param_overrides="$1" + local s3_args + + if [[ -n "${S3_BUCKET}" ]]; then + s3_args="--s3-bucket ${S3_BUCKET} --s3-prefix ${STACK_NAME}" + else + s3_args="--resolve-s3" + fi + + sam deploy \ + --template-file "${BUILD_DIR}/template.yaml" \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --capabilities CAPABILITY_NAMED_IAM \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + ${s3_args} \ + --parameter-overrides ${param_overrides} +} + +# ============================================================================= +# Step 1: Validate SAM template +# ============================================================================= +echo "" +echo ">>> Step 1: Validating SAM template..." +sam validate \ + --template-file "${TEMPLATE_FILE}" \ + --region "${REGION}" +echo " Template validation passed." + +# ============================================================================= +# Step 2: Create/update Secrets Manager secrets +# ============================================================================= +echo "" +echo ">>> Step 2: Creating/updating Secrets Manager secrets..." + +# --- WeatherAPI key secret --- +if aws secretsmanager describe-secret --secret-id "${WEATHER_SECRET_NAME}" --region "${REGION}" > /dev/null 2>&1; then + echo " Updating existing WeatherAPI key secret..." + aws secretsmanager put-secret-value \ + --secret-id "${WEATHER_SECRET_NAME}" \ + --secret-string "${WEATHER_API_KEY}" \ + --region "${REGION}" > /dev/null +else + echo " Creating WeatherAPI key secret..." + aws secretsmanager create-secret \ + --name "${WEATHER_SECRET_NAME}" \ + --description "WeatherAPI.com API key for ${ENVIRONMENT_NAME}" \ + --secret-string "${WEATHER_API_KEY}" \ + --region "${REGION}" > /dev/null +fi + +WEATHER_SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "${WEATHER_SECRET_NAME}" \ + --region "${REGION}" \ + --query 'ARN' --output text) +echo " WeatherAPI secret ARN: ${WEATHER_SECRET_ARN}" + +# --- Placeholder APIGW key secret --- +if aws secretsmanager describe-secret --secret-id "${APIGW_SECRET_NAME}" --region "${REGION}" > /dev/null 2>&1; then + echo " APIGW key secret already exists (will update after stack deploy)." +else + echo " Creating placeholder APIGW key secret..." + aws secretsmanager create-secret \ + --name "${APIGW_SECRET_NAME}" \ + --description "API Gateway API key for AgentCore credential provider (${ENVIRONMENT_NAME})" \ + --secret-string "PLACEHOLDER_WILL_BE_UPDATED" \ + --region "${REGION}" > /dev/null +fi + +APIGW_SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "${APIGW_SECRET_NAME}" \ + --region "${REGION}" \ + --query 'ARN' --output text) +echo " APIGW secret ARN: ${APIGW_SECRET_ARN}" + +# ============================================================================= +# Step 3: Build the SAM application +# +# The Agent Lambda uses a Makefile custom build (BuildMethod: makefile in the +# template). The Makefile downloads prebuilt manylinux wheels so binary +# dependencies (cryptography, cffi) match the Lambda runtime on any host OS โ€” +# no Docker and no local python3.12 required. +# ============================================================================= +echo "" +echo ">>> Step 3: Building SAM application..." +sam build --template-file "${TEMPLATE_FILE}" +echo " Build complete." + +# ============================================================================= +# Step 4: Initial deploy (without credential provider) +# ============================================================================= +echo "" +echo ">>> Step 4: Deploying stack '${STACK_NAME}' (initial)..." +sam_deploy "EnvironmentName=${ENVIRONMENT_NAME} WeatherApiKeySecretArn=${WEATHER_SECRET_ARN} BedrockModelId=${BEDROCK_MODEL_ID}" +echo " Stack deployment complete." + +# Retrieve stack outputs +get_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text +} + +GATEWAY_ID=$(get_output "GatewayId") +REST_API_ID=$(get_output "RestApiId") +API_KEY_ID=$(get_output "ApiKeyId") +USER_POOL_ID=$(get_output "UserPoolId") +USER_POOL_CLIENT_ID=$(get_output "UserPoolClientId") +COGNITO_JWKS_URL=$(get_output "CognitoJwksUrl") +LAMBDA_ARN=$(get_output "AgentLambdaArn") +API_ENDPOINT_URL=$(get_output "ApiEndpointUrl") + +echo "" +echo " Stack Outputs:" +echo " Gateway ID : ${GATEWAY_ID}" +echo " REST API ID : ${REST_API_ID}" +echo " API Key ID : ${API_KEY_ID}" +echo " User Pool ID : ${USER_POOL_ID}" +echo " Client ID : ${USER_POOL_CLIENT_ID}" +echo " JWKS URL : ${COGNITO_JWKS_URL}" +echo " Lambda ARN : ${LAMBDA_ARN}" +echo " API Endpoint : ${API_ENDPOINT_URL}" + +# ============================================================================= +# Step 5: Retrieve API Gateway key value and update Secrets Manager +# ============================================================================= +echo "" +echo ">>> Step 5: Retrieving API Gateway key value..." + +API_KEY_VALUE=$(aws apigateway get-api-key \ + --api-key "${API_KEY_ID}" \ + --include-value \ + --region "${REGION}" \ + --query 'value' --output text) + +if [[ -z "${API_KEY_VALUE}" || "${API_KEY_VALUE}" == "None" ]]; then + echo "ERROR: Failed to retrieve API Gateway key value." + exit 1 +fi + +echo " API key retrieved successfully." + +echo "" +echo ">>> Step 6: Updating Secrets Manager with real API Gateway key..." +aws secretsmanager put-secret-value \ + --secret-id "${APIGW_SECRET_NAME}" \ + --secret-string "${API_KEY_VALUE}" \ + --region "${REGION}" > /dev/null +echo " APIGW key secret updated." + +# ============================================================================= +# Step 7: Create/update credential provider (CLI or manual) +# +# The AgentCore credential provider is provisioned via CLI between stack +# operations: the API key only exists after the initial deploy, and its ARN +# must be fed back into the stack via a follow-up deploy (Step 7b). +# ============================================================================= +echo "" +echo ">>> Step 7: Creating/updating credential provider..." + +CRED_PROVIDER_NAME="${ENVIRONMENT_NAME}-weather-apigw-key" +CRED_PROVIDER_ARN="" + +# Detect CLI support by listing providers (help command is buggy in some CLI versions) +if aws bedrock-agentcore-control list-api-key-credential-providers --region "${REGION}" > /dev/null 2>&1; then + # Check if provider already exists + EXISTING_ARN=$(aws bedrock-agentcore-control list-api-key-credential-providers \ + --region "${REGION}" \ + --query "credentialProviders[?name=='${CRED_PROVIDER_NAME}'].credentialProviderArn" \ + --output text 2>/dev/null) + + if [[ -n "${EXISTING_ARN}" && "${EXISTING_ARN}" != "None" ]]; then + echo " Updating existing credential provider with new API key..." + UPDATE_OUTPUT=$(aws bedrock-agentcore-control update-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --api-key "${API_KEY_VALUE}" \ + --region "${REGION}" 2>&1) || { + echo " WARNING: update-api-key-credential-provider failed. Deleting and recreating..." + aws bedrock-agentcore-control delete-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --region "${REGION}" > /dev/null 2>&1 || true + # Small delay for eventual consistency + sleep 3 + EXISTING_ARN="" + } + if [[ -n "${EXISTING_ARN}" ]]; then + CRED_PROVIDER_ARN="${EXISTING_ARN}" + echo " Credential provider updated: ${CRED_PROVIDER_ARN}" + fi + fi + + if [[ -z "${CRED_PROVIDER_ARN}" || "${CRED_PROVIDER_ARN}" == "None" ]]; then + echo " Creating credential provider via CLI..." + CRED_PROVIDER_ARN=$(aws bedrock-agentcore-control create-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --api-key "${API_KEY_VALUE}" \ + --region "${REGION}" \ + --query 'credentialProviderArn' --output text 2>&1) || { + echo " ERROR: Failed to create credential provider: ${CRED_PROVIDER_ARN}" + CRED_PROVIDER_ARN="" + } + if [[ -n "${CRED_PROVIDER_ARN}" && "${CRED_PROVIDER_ARN}" != "None" ]]; then + echo " Credential provider created: ${CRED_PROVIDER_ARN}" + fi + fi +fi + +if [[ -n "${CRED_PROVIDER_ARN}" && "${CRED_PROVIDER_ARN}" != "None" ]]; then + # Verify the credential provider was updated correctly + VERIFY_TIME=$(aws bedrock-agentcore-control list-api-key-credential-providers \ + --region "${REGION}" \ + --query "credentialProviders[?name=='${CRED_PROVIDER_NAME}'].lastUpdatedTime" \ + --output text 2>/dev/null) + echo " Credential provider verified (last updated: ${VERIFY_TIME})" + echo "" + echo ">>> Step 7b: Re-deploying stack with credential provider ARN..." + sam_deploy "EnvironmentName=${ENVIRONMENT_NAME} WeatherApiKeySecretArn=${WEATHER_SECRET_ARN} BedrockModelId=${BEDROCK_MODEL_ID} CredentialProviderArn=${CRED_PROVIDER_ARN}" + echo " Stack updated with credential provider." +else + echo "" + echo "=============================================" + echo " MANUAL STEP: Create Credential Provider" + echo "=============================================" + echo "" + echo " Your AWS CLI does not support bedrock-agentcore-control." + echo " Upgrade to AWS CLI 2.28+ or create manually:" + echo "" + echo " Option A โ€” Upgrade CLI then run:" + echo "" + echo " aws bedrock-agentcore-control create-api-key-credential-provider \\" + echo " --name ${CRED_PROVIDER_NAME} \\" + echo " --api-key \$(aws apigateway get-api-key --api-key ${API_KEY_ID} --include-value --region ${REGION} --query 'value' --output text) \\" + echo " --region ${REGION}" + echo "" + echo " Option B โ€” AWS Console:" + echo "" + echo " 1. Open: https://console.aws.amazon.com/bedrock-agentcore/" + echo " 2. Go to Identity โ†’ Outbound Auth" + echo " 3. Click 'Add OAuth client/API Key' โ†’ select 'API Key'" + echo " 4. Name: ${CRED_PROVIDER_NAME}" + echo " 5. API Key: (run the command below to get the value)" + echo " aws apigateway get-api-key --api-key ${API_KEY_ID} --include-value --region ${REGION} --query 'value' --output text" + echo "" + echo " After creating, re-deploy with the credential provider ARN:" + echo "" + echo " sam deploy \\" + echo " --template-file ${BUILD_DIR}/template.yaml \\" + echo " --stack-name ${STACK_NAME} \\" + echo " --capabilities CAPABILITY_NAMED_IAM \\" + echo " --region ${REGION} \\" + echo " --resolve-s3 \\" + echo " --parameter-overrides \\" + echo " EnvironmentName=${ENVIRONMENT_NAME} \\" + echo " WeatherApiKeySecretArn=${WEATHER_SECRET_ARN} \\" + echo " CredentialProviderArn=" + echo "" + echo "=============================================" +fi + +# ============================================================================= +# Step 8: Create test user +# ============================================================================= +echo "" +echo ">>> Step 8: Creating test user..." + +TEST_USERNAME="testuser" +TEST_PASSWORD="TestPass123!" + +# Check if user already exists +if aws cognito-idp admin-get-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --region "${REGION}" > /dev/null 2>&1; then + echo " Test user '${TEST_USERNAME}' already exists." +else + echo " Creating test user '${TEST_USERNAME}'..." + aws cognito-idp admin-create-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --temporary-password "TempPass123!" \ + --message-action SUPPRESS \ + --region "${REGION}" > /dev/null + + echo " Setting permanent password..." + aws cognito-idp admin-set-user-password \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --password "${TEST_PASSWORD}" \ + --permanent \ + --region "${REGION}" > /dev/null + + echo " Test user created." +fi + +# ============================================================================= +# Done +# ============================================================================= +# Write a test script that can be run directly +TEST_SCRIPT="scripts/test.sh" +cat > "${TEST_SCRIPT}" << 'TESTEOF' +#!/usr/bin/env bash +set -e +TESTEOF + +cat >> "${TEST_SCRIPT}" << EOF +CLIENT_ID="${USER_POOL_CLIENT_ID}" +FUNCTION_NAME="${LAMBDA_FUNCTION_NAME}" +REGION="${REGION}" +USERNAME="${TEST_USERNAME}" +PASSWORD="${TEST_PASSWORD}" +EOF + +cat >> "${TEST_SCRIPT}" << 'TESTEOF' + +echo "Getting ID token..." +ID_TOKEN=$(aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --client-id "${CLIENT_ID}" \ + --auth-parameters USERNAME="${USERNAME}",PASSWORD="${PASSWORD}" \ + --region "${REGION}" \ + --query 'AuthenticationResult.IdToken' --output text) + +if [[ -z "${ID_TOKEN}" || "${ID_TOKEN}" == "None" ]]; then + echo "ERROR: Failed to get ID token" + exit 1 +fi +echo "Token obtained (${#ID_TOKEN} chars)" + +PROMPT="${1:-What is the weather in London, UK?}" +echo "Invoking agent with: ${PROMPT}" + +PAYLOAD=$(python3 -c " +import json +inner = json.dumps({'prompt': '${PROMPT}'}) +outer = json.dumps({'body': inner, 'headers': {'Authorization': 'Bearer ${ID_TOKEN}'}}) +print(outer) +") + +aws lambda invoke \ + --function-name "${FUNCTION_NAME}" \ + --region "${REGION}" \ + --cli-binary-format raw-in-base64-out \ + --payload "${PAYLOAD}" \ + /tmp/response.json + +echo "" +echo "=== Response ===" +python3 -c " +import json +with open('/tmp/response.json') as f: + resp = json.load(f) +if 'body' in resp: + body = json.loads(resp['body']) + if 'response' in body: + print(body['response']) + elif 'error' in body: + print(f\"ERROR: {body['error']}\") + else: + print(json.dumps(body, indent=2)) +else: + print(json.dumps(resp, indent=2)) +" +TESTEOF + +chmod +x "${TEST_SCRIPT}" + +echo "" +echo "=============================================" +echo " Deployment Complete!" +echo "=============================================" +echo "" +echo " Test the agent:" +echo "" +echo " ./scripts/test.sh" +echo " ./scripts/test.sh 'What is the weather in Liverpool, England?'" +echo "" diff --git a/strands-agentcore-apigw/scripts/test.sh b/strands-agentcore-apigw/scripts/test.sh new file mode 100755 index 000000000..45cd376cb --- /dev/null +++ b/strands-agentcore-apigw/scripts/test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -e +CLIENT_ID="4u8ka9e7dbmekuunrr9ikjna2c" +FUNCTION_NAME="dev-weather-agent" +REGION="us-east-1" +USERNAME="testuser" +PASSWORD="TestPass123!" + +echo "Getting ID token..." +ID_TOKEN=$(aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --client-id "${CLIENT_ID}" \ + --auth-parameters USERNAME="${USERNAME}",PASSWORD="${PASSWORD}" \ + --region "${REGION}" \ + --query 'AuthenticationResult.IdToken' --output text) + +if [[ -z "${ID_TOKEN}" || "${ID_TOKEN}" == "None" ]]; then + echo "ERROR: Failed to get ID token" + exit 1 +fi +echo "Token obtained (${#ID_TOKEN} chars)" + +PROMPT="${1:-What is the weather in London, UK?}" +echo "Invoking agent with: ${PROMPT}" + +PAYLOAD=$(python3 -c " +import json +inner = json.dumps({'prompt': '${PROMPT}'}) +outer = json.dumps({'body': inner, 'headers': {'Authorization': 'Bearer ${ID_TOKEN}'}}) +print(outer) +") + +aws lambda invoke \ + --function-name "${FUNCTION_NAME}" \ + --region "${REGION}" \ + --cli-binary-format raw-in-base64-out \ + --payload "${PAYLOAD}" \ + /tmp/response.json + +echo "" +echo "=== Response ===" +python3 -c " +import json +with open('/tmp/response.json') as f: + resp = json.load(f) +if 'body' in resp: + body = json.loads(resp['body']) + if 'response' in body: + print(body['response']) + elif 'error' in body: + print(f\"ERROR: {body['error']}\") + else: + print(json.dumps(body, indent=2)) +else: + print(json.dumps(resp, indent=2)) +" diff --git a/strands-agentcore-apigw/src/Makefile b/strands-agentcore-apigw/src/Makefile new file mode 100644 index 000000000..c3d62a295 --- /dev/null +++ b/strands-agentcore-apigw/src/Makefile @@ -0,0 +1,45 @@ +# ============================================================================= +# SAM custom build (BuildMethod: makefile) for the Agent Lambda. +# +# SAM calls the `build-AgentLambdaFunction` target and passes ARTIFACTS_DIR โ€” +# whatever lands there becomes the Lambda deployment package. +# +# We download prebuilt manylinux (Linux) wheels via pip's --platform flag so +# binary dependencies (cryptography, cffi) match the Lambda runtime regardless +# of the host OS. This needs no Docker and no local python3.12 โ€” pip resolves +# the wheels for the target platform from PyPI. +# +# Two-step install: `--only-binary=:all:` with `--platform` silently skips +# pure-Python packages, so a second `--no-deps` pass installs those explicitly. +# ============================================================================= + +PLATFORM := manylinux2014_x86_64 +PYVER := 3.12 +PIP := python3 -m pip + +build-AgentLambdaFunction: + # Step 1 โ€” binary packages (strands, mcp, and their native deps) + $(PIP) install \ + --target "$(ARTIFACTS_DIR)" \ + --platform $(PLATFORM) \ + --python-version $(PYVER) \ + --only-binary=:all: \ + --requirement requirements.txt + + # Step 2 โ€” pure-Python packages skipped by step 1 + $(PIP) install \ + --target "$(ARTIFACTS_DIR)" \ + --platform $(PLATFORM) \ + --python-version $(PYVER) \ + --only-binary=:all: \ + --no-deps \ + requests urllib3 charset-normalizer idna certifi PyJWT cryptography cffi + + # Remove .egg-info dirs only โ€” preserve .dist-info (opentelemetry needs them) + find "$(ARTIFACTS_DIR)" -type d -name '*.egg-info' -exec rm -rf {} + 2>/dev/null || true + + # Application code + cp -r agent "$(ARTIFACTS_DIR)/agent" + cp -r shared "$(ARTIFACTS_DIR)/shared" + touch "$(ARTIFACTS_DIR)/agent/__init__.py" + touch "$(ARTIFACTS_DIR)/shared/__init__.py" diff --git a/strands-agentcore-apigw/src/__init__.py b/strands-agentcore-apigw/src/__init__.py new file mode 100644 index 000000000..39494fe04 --- /dev/null +++ b/strands-agentcore-apigw/src/__init__.py @@ -0,0 +1 @@ +"""Source package.""" diff --git a/strands-agentcore-apigw/src/agent/__init__.py b/strands-agentcore-apigw/src/agent/__init__.py new file mode 100644 index 000000000..034e17efc --- /dev/null +++ b/strands-agentcore-apigw/src/agent/__init__.py @@ -0,0 +1 @@ +"""Agent Lambda implementation.""" diff --git a/strands-agentcore-apigw/src/agent/agent_processor.py b/strands-agentcore-apigw/src/agent/agent_processor.py new file mode 100644 index 000000000..08843ba44 --- /dev/null +++ b/strands-agentcore-apigw/src/agent/agent_processor.py @@ -0,0 +1,127 @@ +"""Agent processor orchestrating AI pipeline via Strands Agents SDK. + +Uses the official strands-agents SDK. The SDK's Agent class handles the +full agentic loop automatically โ€” multi-turn tool use, parallel tool +calls, and response generation are all managed by the SDK. +""" + +import uuid +from typing import Optional, Tuple + +import boto3 + +from shared.logging_utils import StructuredLogger +from .strands_client import create_mcp_client, create_agent + + +class AgentProcessor: + """Orchestrates AI agent processing via Strands Agents SDK + AgentCore Gateway.""" + + def __init__( + self, + gateway_id: str, + model_id: str, + region: str, + logger: StructuredLogger, + ): + self.gateway_id = gateway_id + self.model_id = model_id + self.region = region + self.logger = logger + + # Resolve Gateway MCP URL once + self._gateway_url: Optional[str] = None + + logger.info("Agent processor initialized") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def process_request( + self, + prompt: str, + jwt_token: str, + session_id: Optional[str] = None, + ) -> Tuple[str, str]: + """Process a user prompt end-to-end. + + 1. Resolve session + 2. Connect to Gateway via MCPClient + 3. Create Strands Agent with MCP tools + 4. Run the agent (SDK handles tool loop) + 5. Return response text + session_id + + Args: + prompt: User's natural language prompt + jwt_token: Cognito JWT for Gateway auth + session_id: Optional conversation session ID + + Returns: + Tuple of (response_text, session_id) + """ + if not session_id: + session_id = str(uuid.uuid4()) + self.logger.info("New conversation started", session_id=session_id) + else: + self.logger.info("Continuing conversation", session_id=session_id) + + gateway_url = self._get_gateway_url() + + # Create MCPClient for this invocation (per Lambda best practice). + # Do NOT use `with mcp_client:` โ€” the Agent's tool registry calls + # load_tools() โ†’ start() internally. Starting it beforehand causes + # "the client session is currently running" error. + mcp_client = create_mcp_client(gateway_url, jwt_token) + + try: + agent = create_agent( + model_id=self.model_id, + region=self.region, + mcp_client=mcp_client, + ) + + self.logger.info("Invoking Strands agent", prompt_length=len(prompt)) + + # The SDK handles the full agentic loop: + # prompt โ†’ model reasoning โ†’ tool selection โ†’ tool execution โ†’ repeat โ†’ final response + result = agent(prompt) + response_text = str(result) + + self.logger.info( + "Agent request processed successfully", + session_id=session_id, + response_length=len(response_text), + ) + + return response_text, session_id + + except Exception as e: + self.logger.error("Agent processing failed", error=str(e)) + raise + finally: + # Clean up MCP connection + try: + mcp_client.stop(None, None, None) + except Exception: + pass + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_gateway_url(self) -> str: + """Resolve and cache the Gateway MCP endpoint URL.""" + if self._gateway_url: + return self._gateway_url + + client = boto3.client("bedrock-agentcore-control", region_name=self.region) + response = client.get_gateway(gatewayIdentifier=self.gateway_id) + gateway_url = response.get("gatewayUrl") + + if not gateway_url: + raise RuntimeError("Gateway URL not found in get_gateway response") + + self._gateway_url = gateway_url + self.logger.info("Gateway URL resolved", gateway_url=gateway_url) + return gateway_url diff --git a/strands-agentcore-apigw/src/agent/handler.py b/strands-agentcore-apigw/src/agent/handler.py new file mode 100644 index 000000000..344a386ad --- /dev/null +++ b/strands-agentcore-apigw/src/agent/handler.py @@ -0,0 +1,143 @@ +"""Agent Lambda handler โ€” entry point for AI agent requests. + +Validates Cognito JWT, extracts user context, and delegates to +AgentProcessor which uses the Strands Agents SDK for orchestration. +""" + +import os +import json +import uuid +from typing import Dict, Any + +from shared.models import AgentRequest, AgentResponse, UserContext +from shared.jwt_utils import validate_jwt, extract_user_context +from shared.logging_utils import get_logger, StructuredLogger +from shared.error_utils import ErrorHandler + +from .agent_processor import AgentProcessor + + +# Environment variables +COGNITO_JWKS_URL = os.environ.get("COGNITO_JWKS_URL", "") +GATEWAY_ID = os.environ.get("GATEWAY_ID", "") +BEDROCK_MODEL_ID = os.environ.get( + "BEDROCK_MODEL_ID", "us.anthropic.claude-sonnet-4-6" +) +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") + +base_logger = get_logger(__name__, LOG_LEVEL) + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Lambda entry point. + + 1. Extract + validate JWT + 2. Parse prompt / session_id from body + 3. Run AgentProcessor (Strands SDK handles the agentic loop) + 4. Return AgentResponse + """ + request_id = str(uuid.uuid4()) + logger = StructuredLogger(base_logger, request_id) + + try: + # --- JWT extraction --- + headers = event.get("headers", {}) + auth_header = headers.get("Authorization", "") + + if not auth_header or not auth_header.startswith("Bearer "): + logger.warning("Missing or malformed Authorization header") + return ErrorHandler.handle_authentication_error( + ValueError("Missing or invalid Authorization header") + ) + + jwt_token = auth_header[len("Bearer "):].strip() + if not jwt_token: + logger.warning("Empty JWT token") + return ErrorHandler.handle_authentication_error( + ValueError("Empty JWT token") + ) + + # --- JWT validation --- + try: + claims = validate_jwt(jwt_token, COGNITO_JWKS_URL) + logger.info("JWT validated") + except ValueError as e: + logger.warning("JWT validation failed", error=str(e)) + return ErrorHandler.handle_authentication_error(e) + + # --- User context --- + try: + user_context = extract_user_context(claims) + logger.info("User context extracted", user_id=user_context.user_id) + except ValueError as e: + logger.error("User context extraction failed", error=str(e)) + return ErrorHandler.handle_validation_error(e) + + logger.user_context = user_context + + # --- Request body --- + body_str = event.get("body", "{}") + try: + body = json.loads(body_str) if isinstance(body_str, str) else body_str + except json.JSONDecodeError: + return ErrorHandler.handle_validation_error( + ValueError("Invalid JSON in request body") + ) + + prompt = body.get("prompt") + if not prompt: + return ErrorHandler.handle_missing_parameter_error("prompt") + + session_id = body.get("session_id") + + logger.info( + "Agent request received", + prompt_length=len(prompt), + has_session_id=bool(session_id), + ) + + # --- Agent processing (Strands SDK) --- + processor = AgentProcessor( + gateway_id=GATEWAY_ID, + model_id=BEDROCK_MODEL_ID, + region=AWS_REGION, + logger=logger, + ) + + try: + response_text, final_session_id = processor.process_request( + prompt=prompt, + jwt_token=jwt_token, + session_id=session_id, + ) + except Exception as e: + logger.error( + "Agent processing failed", + error_type=type(e).__name__, + error_message=str(e), + ) + return ErrorHandler.handle_generic_error(e) + + # --- Response --- + agent_response = AgentResponse( + response=response_text, + session_id=final_session_id, + user_context=user_context, + ) + + logger.info( + "Agent request completed", + session_id=final_session_id, + response_length=len(response_text), + ) + + return agent_response.to_lambda_response() + + except Exception as e: + logger.error( + "Unexpected error", + error_type=type(e).__name__, + error_message=str(e), + ) + return ErrorHandler.handle_generic_error(e) diff --git a/strands-agentcore-apigw/src/agent/strands_client.py b/strands-agentcore-apigw/src/agent/strands_client.py new file mode 100644 index 000000000..669c29d0f --- /dev/null +++ b/strands-agentcore-apigw/src/agent/strands_client.py @@ -0,0 +1,77 @@ +"""Strands Agents SDK client for AI agent orchestration. + +Uses the official strands-agents SDK (pip install strands-agents) with +MCPClient to connect to the AgentCore Gateway MCP endpoint. The SDK +handles the full agentic loop automatically: reasoning, tool selection, +tool execution, and response generation. + +References: +- https://strandsagents.com/docs/user-guide/concepts/agents/agent-loop/ +- https://strandsagents.com/docs/user-guide/concepts/tools/mcp-tools/ +- https://strandsagents.com/docs/user-guide/deploy/deploy_to_aws_lambda/ +""" + +from typing import Optional + +from strands import Agent +from strands.models.bedrock import BedrockModel +from strands.tools.mcp import MCPClient +from mcp.client.streamable_http import streamablehttp_client + +from shared.logging_utils import StructuredLogger + + +SYSTEM_PROMPT = """You are a helpful AI assistant that can interact with APIs to help users. +When retrieving information, use the available tools to fetch real data. +Format responses in a clear, human-readable way. +If a tool call fails, explain the error and suggest alternatives.""" + + +def create_mcp_client(gateway_url: str, jwt_token: str) -> MCPClient: + """Create an MCPClient connected to the AgentCore Gateway MCP endpoint. + + Args: + gateway_url: Full Gateway MCP endpoint URL + jwt_token: JWT token for authorization + + Returns: + MCPClient configured for the Gateway + """ + return MCPClient( + lambda: streamablehttp_client( + url=gateway_url, + headers={"Authorization": f"Bearer {jwt_token}"} + ) + ) + + +def create_agent( + model_id: str, + region: str, + mcp_client: MCPClient, + system_prompt: Optional[str] = None, + max_tokens: int = 4096, +) -> Agent: + """Create a Strands Agent with Bedrock model and MCP tools. + + Args: + model_id: Bedrock model ID + region: AWS region + mcp_client: MCPClient connected to Gateway + system_prompt: Optional system prompt override + max_tokens: Maximum tokens in response + + Returns: + Configured Strands Agent + """ + model = BedrockModel( + model_id=model_id, + region_name=region, + max_tokens=max_tokens, + ) + + return Agent( + model=model, + tools=[mcp_client], + system_prompt=system_prompt or SYSTEM_PROMPT, + ) diff --git a/strands-agentcore-apigw/src/requirements.txt b/strands-agentcore-apigw/src/requirements.txt new file mode 100644 index 000000000..4b8f188a5 --- /dev/null +++ b/strands-agentcore-apigw/src/requirements.txt @@ -0,0 +1,4 @@ +strands-agents>=1.0.0 +mcp>=1.0.0 +requests>=2.31.0 +PyJWT[crypto]>=2.8.0 diff --git a/strands-agentcore-apigw/src/shared/__init__.py b/strands-agentcore-apigw/src/shared/__init__.py new file mode 100644 index 000000000..bfe7571d9 --- /dev/null +++ b/strands-agentcore-apigw/src/shared/__init__.py @@ -0,0 +1,58 @@ +"""Shared utilities and data models.""" + +from .models import ( + UserContext, + AgentRequest, + AgentResponse, +) + +from .logging_utils import ( + get_logger, + log_with_user_context, + sanitize_log_data, + StructuredLogger, +) + +from .error_utils import ( + format_error_response, + get_user_friendly_message, + is_transient_error, + retry_with_backoff, + timeout_wrapper, + ErrorHandler, + TimeoutError, + TransientError, +) + +from .jwt_utils import ( + validate_jwt, + extract_user_context, + decode_jwt_payload, + get_jwks, +) + +__all__ = [ + # Models + 'UserContext', + 'AgentRequest', + 'AgentResponse', + # Logging + 'get_logger', + 'log_with_user_context', + 'sanitize_log_data', + 'StructuredLogger', + # Error handling + 'format_error_response', + 'get_user_friendly_message', + 'is_transient_error', + 'retry_with_backoff', + 'timeout_wrapper', + 'ErrorHandler', + 'TimeoutError', + 'TransientError', + # JWT + 'validate_jwt', + 'extract_user_context', + 'decode_jwt_payload', + 'get_jwks', +] diff --git a/strands-agentcore-apigw/src/shared/error_utils.py b/strands-agentcore-apigw/src/shared/error_utils.py new file mode 100644 index 000000000..ca65001b0 --- /dev/null +++ b/strands-agentcore-apigw/src/shared/error_utils.py @@ -0,0 +1,180 @@ +"""Error handling utilities with retry logic, timeout management, and HTTP status codes.""" + +import time +import json +from typing import Callable, Any, Optional, Dict +from functools import wraps +import signal + + +class TimeoutError(Exception): + """Raised when an operation times out.""" + pass + + +class TransientError(Exception): + """Raised for transient errors that should be retried.""" + pass + + +def format_error_response( + status_code: int, + error_message: str, + error_code: Optional[str] = None +) -> dict: + """Format error response for Lambda with appropriate HTTP status codes.""" + body = {'error': error_message} + if error_code: + body['error_code'] = error_code + return { + 'statusCode': status_code, + 'body': json.dumps(body) + } + + +def get_user_friendly_message(error_code: str) -> str: + """Get user-friendly error message for AWS error codes.""" + error_messages = { + 'AccessDenied': 'You do not have permission to perform this operation.', + 'AccessDeniedException': 'You do not have permission to perform this operation.', + 'Throttling': 'The service is temporarily busy. Please try again.', + 'ThrottlingException': 'The service is temporarily busy. Please try again.', + 'ServiceUnavailable': 'The service is temporarily unavailable. Please try again.', + 'InternalError': 'An internal error occurred. Please try again.', + 'InvalidParameterValue': 'Invalid parameter provided.', + 'ResourceNotFoundException': 'The requested resource was not found.', + 'ValidationException': 'Invalid request parameters.', + 'RequestTimeout': 'The request timed out. Please try again.', + 'NetworkingError': 'Network connection error. Please try again.', + } + return error_messages.get( + error_code, + 'An error occurred while processing your request. Please try again.' + ) + + +def is_transient_error(error_code: str) -> bool: + """Check if an error code represents a transient error.""" + transient_codes = { + 'Throttling', + 'ThrottlingException', + 'ServiceUnavailable', + 'InternalError', + 'RequestTimeout', + 'NetworkingError', + 'TooManyRequestsException', + 'ProvisionedThroughputExceededException', + } + return error_code in transient_codes + + +def retry_with_backoff( + func: Callable, + max_attempts: int = 3, + initial_delay: float = 1.0, + backoff_factor: float = 2.0, + max_delay: float = 10.0 +) -> Any: + """Retry function with exponential backoff.""" + delay = initial_delay + last_exception = None + + for attempt in range(max_attempts): + try: + return func() + except Exception as e: + last_exception = e + error_code = getattr(e, 'response', {}).get('Error', {}).get('Code', '') + if not is_transient_error(error_code) and attempt == 0: + raise + + if attempt < max_attempts - 1: + wait_time = min(delay, max_delay) + time.sleep(wait_time) + delay *= backoff_factor + else: + raise last_exception + + raise last_exception + + +def timeout_wrapper(timeout_seconds: int): + """Decorator to add timeout to function execution.""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + def timeout_handler(signum, frame): + raise TimeoutError(f"Function {func.__name__} timed out after {timeout_seconds} seconds") + + old_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(timeout_seconds) + + try: + result = func(*args, **kwargs) + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + + return result + + return wrapper + + return decorator + + +class ErrorHandler: + """Centralized error handler for Lambda functions with appropriate HTTP status codes.""" + + @staticmethod + def handle_authentication_error(error: Exception) -> dict: + """Handle authentication errors with 401 status code.""" + return format_error_response( + 401, + 'Invalid credentials', + 'AuthenticationError' + ) + + @staticmethod + def handle_aws_error(error: Exception) -> dict: + """Handle AWS service errors with appropriate status codes.""" + error_code = getattr(error, 'response', {}).get('Error', {}).get('Code', 'Unknown') + message = get_user_friendly_message(error_code) + + status_code = 500 + if error_code in ['ValidationException', 'InvalidParameterValue']: + status_code = 400 + elif error_code in ['AccessDenied', 'AccessDeniedException']: + status_code = 403 + elif error_code in ['ResourceNotFoundException']: + status_code = 404 + elif error_code in ['Throttling', 'ThrottlingException', 'TooManyRequestsException']: + status_code = 429 + + return format_error_response(status_code, message, error_code) + + @staticmethod + def handle_validation_error(error: Exception) -> dict: + """Handle validation errors with 400 status code.""" + return format_error_response( + 400, + str(error), + 'ValidationError' + ) + + @staticmethod + def handle_missing_parameter_error(parameter_name: str) -> dict: + """Handle missing parameter errors with 400 status code.""" + return format_error_response( + 400, + f'Missing required parameter: {parameter_name}', + 'MissingParameterError' + ) + + @staticmethod + def handle_generic_error(error: Exception) -> dict: + """Handle generic errors with 500 status code.""" + return format_error_response( + 500, + 'An unexpected error occurred. Please try again.', + 'InternalError' + ) diff --git a/strands-agentcore-apigw/src/shared/jwt_utils.py b/strands-agentcore-apigw/src/shared/jwt_utils.py new file mode 100644 index 000000000..45952b6de --- /dev/null +++ b/strands-agentcore-apigw/src/shared/jwt_utils.py @@ -0,0 +1,104 @@ +"""JWT validation and user context extraction utilities.""" + +import json +import time +from typing import Dict, Optional +from functools import lru_cache + +import jwt +import requests +from jwt import PyJWK + +from .models import UserContext + + +@lru_cache(maxsize=1) +def _get_jwks_with_cache(jwks_url: str, cache_time: int) -> tuple: + """Fetch and cache JWKS keys.""" + response = requests.get(jwks_url, timeout=5) + response.raise_for_status() + jwks = response.json() + return jwks, cache_time + + +def get_jwks(jwks_url: str, ttl: int = 3600) -> dict: + """Get JWKS with caching and TTL.""" + current_time = int(time.time()) + cache_time = current_time - (current_time % ttl) + jwks, _ = _get_jwks_with_cache(jwks_url, cache_time) + return jwks + + +def validate_jwt(token: str, jwks_url: str) -> dict: + """Validate JWT token using JWKS from Cognito. + + Args: + token: JWT access token + jwks_url: Cognito JWKS URL + + Returns: + Decoded JWT claims + + Raises: + ValueError: If token is invalid, expired, or malformed + """ + try: + jwks = get_jwks(jwks_url) + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get('kid') + + if not kid: + raise ValueError("Token missing 'kid' in header") + + key = next((k for k in jwks['keys'] if k['kid'] == kid), None) + if not key: + raise ValueError("Key not found in JWKS") + + public_key = PyJWK.from_dict(key).key + + claims = jwt.decode( + token, + public_key, + algorithms=['RS256'], + options={'verify_exp': True, 'verify_aud': False} + ) + + token_use = claims.get('token_use') + if token_use not in ('access', 'id'): + raise ValueError("Token must be an access or ID token") + + return claims + + except jwt.ExpiredSignatureError: + raise ValueError("Token has expired") + except jwt.InvalidTokenError as e: + raise ValueError(f"Invalid token: {e}") + except requests.RequestException as e: + raise ValueError(f"Failed to fetch JWKS: {e}") + except Exception as e: + raise ValueError(f"Token validation failed: {e}") + + +def extract_user_context(claims: dict) -> UserContext: + """Extract user context from JWT claims (supports both access and ID tokens).""" + sub = claims.get('sub') + if not sub: + raise ValueError("Missing required claim: sub") + + username = claims.get('username') or claims.get('cognito:username', '') + client_id = claims.get('client_id') or claims.get('aud', '') + + return UserContext( + user_id=sub, + username=username, + client_id=client_id, + ) + + +def decode_jwt_payload(token: str) -> dict: + """Decode JWT payload without verification (for Interceptor use).""" + try: + claims = jwt.decode(token, options={"verify_signature": False}) + return claims + except Exception as e: + raise ValueError(f"Failed to decode JWT payload: {e}") diff --git a/strands-agentcore-apigw/src/shared/logging_utils.py b/strands-agentcore-apigw/src/shared/logging_utils.py new file mode 100644 index 000000000..b9e6cfac8 --- /dev/null +++ b/strands-agentcore-apigw/src/shared/logging_utils.py @@ -0,0 +1,122 @@ +"""Logging utilities with user context, request ID correlation, and security.""" + +import logging +import json +import re +from typing import Optional, Dict, Any +from datetime import datetime + +from .models import UserContext + + +# Patterns for sensitive data that should not be logged +SENSITIVE_PATTERNS = [ + r'Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+', # JWT tokens + r'password["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # Passwords + r'secret["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # Secrets + r'api[_-]?key["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # API keys +] + + +def sanitize_log_data(data: Any) -> Any: + """Remove sensitive information from log data.""" + if isinstance(data, str): + sanitized = data + for pattern in SENSITIVE_PATTERNS: + sanitized = re.sub(pattern, '[REDACTED]', sanitized, flags=re.IGNORECASE) + return sanitized + elif isinstance(data, dict): + return {k: sanitize_log_data(v) for k, v in data.items()} + elif isinstance(data, list): + return [sanitize_log_data(item) for item in data] + return data + + +def get_logger(name: str, level: str = 'INFO') -> logging.Logger: + """Get configured logger with structured formatting.""" + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, level.upper())) + + if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(getattr(logging, level.upper())) + formatter = logging.Formatter( + '{"timestamp": "%(asctime)s", "level": "%(levelname)s", ' + '"logger": "%(name)s", "message": "%(message)s"}' + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger + + +def log_with_user_context( + logger: logging.Logger, + level: str, + message: str, + user_context: Optional[UserContext] = None, + request_id: Optional[str] = None, + **extra_fields +) -> None: + """Log message with user context, request ID, and additional fields.""" + log_data = { + 'timestamp': datetime.utcnow().isoformat(), + 'message': message + } + + if request_id: + log_data['request_id'] = request_id + else: + log_data['request_id'] = 'MISSING' + log_data['warning'] = 'request_id not provided for log correlation' + + if user_context: + log_data['user_id'] = user_context.user_id + log_data['username'] = user_context.username + log_data['client_id'] = user_context.client_id + + log_data.update(extra_fields) + sanitized_data = sanitize_log_data(log_data) + + log_method = getattr(logger, level.lower()) + log_method(json.dumps(sanitized_data)) + + +class StructuredLogger: + """Structured logger with automatic user context and request ID inclusion.""" + + def __init__( + self, + logger: logging.Logger, + request_id: str, + user_context: Optional[UserContext] = None + ): + self.logger = logger + self.request_id = request_id + self.user_context = user_context + + def _log(self, level: str, message: str, **extra_fields) -> None: + """Internal log method that ensures request_id is always included.""" + log_with_user_context( + self.logger, + level, + message, + self.user_context, + self.request_id, + **extra_fields + ) + + def debug(self, message: str, **extra_fields) -> None: + self._log('debug', message, **extra_fields) + + def info(self, message: str, **extra_fields) -> None: + self._log('info', message, **extra_fields) + + def warning(self, message: str, **extra_fields) -> None: + self._log('warning', message, **extra_fields) + + def error(self, message: str, **extra_fields) -> None: + self._log('error', message, **extra_fields) + + def critical(self, message: str, **extra_fields) -> None: + self._log('critical', message, **extra_fields) diff --git a/strands-agentcore-apigw/src/shared/models.py b/strands-agentcore-apigw/src/shared/models.py new file mode 100644 index 000000000..44ecbafbd --- /dev/null +++ b/strands-agentcore-apigw/src/shared/models.py @@ -0,0 +1,77 @@ +"""Data models for Agent Gateway.""" + +import json +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class UserContext: + """User identity information extracted from JWT token.""" + user_id: str + username: str + client_id: str + + def to_dict(self) -> dict: + return { + 'user_id': self.user_id, + 'username': self.username, + 'client_id': self.client_id + } + + @classmethod + def from_jwt_claims(cls, claims: dict) -> 'UserContext': + return cls( + user_id=claims['sub'], + username=claims['username'], + client_id=claims['client_id'] + ) + + @classmethod + def from_dict(cls, data: dict) -> 'UserContext': + return cls( + user_id=data.get('user_id', 'unknown'), + username=data.get('username', 'unknown'), + client_id=data.get('client_id', 'unknown') + ) + + +@dataclass +class AgentRequest: + """Request to Agent Lambda with authentication.""" + prompt: str + jwt_token: str + session_id: Optional[str] = None + + @classmethod + def from_event(cls, event: dict) -> 'AgentRequest': + headers = event.get('headers', {}) + body_str = event.get('body', '{}') + body = json.loads(body_str) if isinstance(body_str, str) else body_str + + auth_header = headers.get('Authorization', '') + jwt_token = auth_header.replace('Bearer ', '') + + return cls( + prompt=body['prompt'], + jwt_token=jwt_token, + session_id=body.get('session_id') + ) + + +@dataclass +class AgentResponse: + """Response from Agent Lambda.""" + response: str + session_id: str + user_context: UserContext + + def to_lambda_response(self) -> dict: + return { + 'statusCode': 200, + 'body': json.dumps({ + 'response': self.response, + 'session_id': self.session_id, + 'user_context': self.user_context.to_dict() + }) + } diff --git a/strands-agentcore-apigw/tests/__init__.py b/strands-agentcore-apigw/tests/__init__.py new file mode 100644 index 000000000..65140f2e3 --- /dev/null +++ b/strands-agentcore-apigw/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/strands-agentcore-apigw/tests/integration/__init__.py b/strands-agentcore-apigw/tests/integration/__init__.py new file mode 100644 index 000000000..1eb7ed3dd --- /dev/null +++ b/strands-agentcore-apigw/tests/integration/__init__.py @@ -0,0 +1 @@ +# Integration tests for the AgentCore API Gateway Weather Agent diff --git a/strands-agentcore-apigw/tests/integration/test_e2e.py b/strands-agentcore-apigw/tests/integration/test_e2e.py new file mode 100644 index 000000000..2e0a0ea0e --- /dev/null +++ b/strands-agentcore-apigw/tests/integration/test_e2e.py @@ -0,0 +1,149 @@ +"""Integration tests for the AgentCore API Gateway Weather Agent. + +These tests run against a deployed stack and require the following +environment variables to be set: + + API_ENDPOINT_URL โ€“ API Gateway stage URL (e.g. https://xxx.execute-api.us-east-1.amazonaws.com/dev) + USER_POOL_ID โ€“ Cognito User Pool ID + USER_POOL_CLIENT_ID โ€“ Cognito User Pool Client ID + LAMBDA_FUNCTION_NAME โ€“ Agent Lambda function name or ARN + TEST_USERNAME โ€“ Cognito test user username + TEST_PASSWORD โ€“ Cognito test user password + AWS_REGION โ€“ AWS region (default: us-east-1) + +If any required variable is missing the test is skipped gracefully. +""" + +import json +import os + +import pytest + +boto3 = pytest.importorskip("boto3", reason="boto3 is required for integration tests") +requests = pytest.importorskip("requests", reason="requests is required for integration tests") + + +# --------------------------------------------------------------------------- +# Environment helpers +# --------------------------------------------------------------------------- + +def _env(name: str, default: str | None = None) -> str | None: + return os.environ.get(name, default) + + +_REQUIRED_VARS = [ + "API_ENDPOINT_URL", + "USER_POOL_ID", + "USER_POOL_CLIENT_ID", + "LAMBDA_FUNCTION_NAME", + "TEST_USERNAME", + "TEST_PASSWORD", +] + + +def _skip_if_missing() -> dict[str, str]: + """Return a dict of env values or skip the test when any is absent.""" + values: dict[str, str] = {} + missing: list[str] = [] + for var in _REQUIRED_VARS: + val = _env(var) + if val is None: + missing.append(var) + else: + values[var] = val + if missing: + pytest.skip( + f"Integration test skipped โ€” missing env vars: {', '.join(missing)}" + ) + values["AWS_REGION"] = _env("AWS_REGION", "us-east-1") + return values + + +# --------------------------------------------------------------------------- +# 7.1 โ€“ API Gateway returns 403 without an API key +# --------------------------------------------------------------------------- + +class TestApiGateway403: + """Validates: Requirement 2.4 โ€” requests without a valid x-api-key get 403.""" + + def test_get_weather_without_api_key_returns_403(self): + """Call /weather/current without an API key and expect 403 Forbidden.""" + env = _skip_if_missing() + url = f"{env['API_ENDPOINT_URL'].rstrip('/')}/weather/current" + + response = requests.get(url, params={"q": "London"}, timeout=10) + + assert response.status_code == 403, ( + f"Expected 403 Forbidden but got {response.status_code}: {response.text}" + ) + + +# --------------------------------------------------------------------------- +# 7.2 โ€“ End-to-end: Cognito auth โ†’ JWT โ†’ Agent Lambda โ†’ weather response +# --------------------------------------------------------------------------- + +class TestEndToEndAgent: + """Validates: Requirement 9.4 โ€” authenticate via Cognito, invoke Agent Lambda, + verify weather data in response.""" + + @pytest.fixture() + def env(self): + return _skip_if_missing() + + @pytest.fixture() + def jwt_token(self, env): + """Authenticate against Cognito and return an ID token (JWT).""" + client = boto3.client("cognito-idp", region_name=env["AWS_REGION"]) + resp = client.admin_initiate_auth( + UserPoolId=env["USER_POOL_ID"], + ClientId=env["USER_POOL_CLIENT_ID"], + AuthFlow="ADMIN_USER_PASSWORD_AUTH", + AuthParameters={ + "USERNAME": env["TEST_USERNAME"], + "PASSWORD": env["TEST_PASSWORD"], + }, + ) + return resp["AuthenticationResult"]["IdToken"] + + def test_agent_returns_weather_data(self, env, jwt_token): + """Invoke the Agent Lambda with a weather prompt and verify the response.""" + lambda_client = boto3.client("lambda", region_name=env["AWS_REGION"]) + + payload = { + "headers": {"Authorization": f"Bearer {jwt_token}"}, + "body": json.dumps({ + "prompt": "What is the current weather in London?", + "session_id": "integration-test-session", + }), + } + + response = lambda_client.invoke( + FunctionName=env["LAMBDA_FUNCTION_NAME"], + InvocationType="RequestResponse", + Payload=json.dumps(payload), + ) + + response_payload = json.loads(response["Payload"].read()) + + # The Lambda should return a 200-level status + status_code = response_payload.get("statusCode", response.get("StatusCode")) + assert status_code == 200, ( + f"Expected 200 but got {status_code}: {json.dumps(response_payload, indent=2)}" + ) + + # Parse the body and verify it contains weather-related content + body = response_payload.get("body") + if isinstance(body, str): + body = json.loads(body) + + agent_response = body.get("response", "") if isinstance(body, dict) else str(body) + + # The response should mention weather-related terms + weather_terms = ["temperature", "weather", "degrees", "celsius", "fahrenheit", "wind", "humidity", "london"] + response_lower = agent_response.lower() + matches = [t for t in weather_terms if t in response_lower] + + assert len(matches) >= 1, ( + f"Expected weather data in response but found none of {weather_terms}. " + f"Response: {agent_response[:500]}" + ) diff --git a/strands-agentcore-apigw/tests/unit/__init__.py b/strands-agentcore-apigw/tests/unit/__init__.py new file mode 100644 index 000000000..5b51909b2 --- /dev/null +++ b/strands-agentcore-apigw/tests/unit/__init__.py @@ -0,0 +1 @@ +# unit tests package diff --git a/strands-agentcore-apigw/tests/unit/conftest.py b/strands-agentcore-apigw/tests/unit/conftest.py new file mode 100644 index 000000000..89e5725b1 --- /dev/null +++ b/strands-agentcore-apigw/tests/unit/conftest.py @@ -0,0 +1,64 @@ +"""Shared fixtures for CloudFormation template unit tests.""" + +import os +import pytest +import yaml + + +TEMPLATE_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "infrastructure", "template.yaml" +) + + +class CfnLoader(yaml.SafeLoader): + """YAML loader that handles CloudFormation intrinsic function tags.""" + pass + + +def _multi_constructor(loader, tag_suffix, node): + """Handle multi-value CloudFormation tags like !Sub, !If, etc.""" + if isinstance(node, yaml.ScalarNode): + return {tag_suffix: loader.construct_scalar(node)} + elif isinstance(node, yaml.SequenceNode): + return {tag_suffix: loader.construct_sequence(node, deep=True)} + elif isinstance(node, yaml.MappingNode): + return {tag_suffix: loader.construct_mapping(node, deep=True)} + + +# Register all CloudFormation intrinsic functions +_CFN_TAGS = [ + "Ref", "Sub", "If", "Not", "Equals", "GetAtt", "Join", + "Select", "Split", "FindInMap", "ImportValue", "Condition", + "And", "Or", "Base64", "Cidr", "GetAZs", "Transform", +] + +for tag in _CFN_TAGS: + CfnLoader.add_constructor( + f"!{tag}", + lambda loader, node, t=tag: _multi_constructor(loader, t, node), + ) + + +@pytest.fixture(scope="session") +def template(): + """Load and parse the CloudFormation template once for all tests.""" + with open(TEMPLATE_PATH, "r") as f: + return yaml.load(f, Loader=CfnLoader) + + +@pytest.fixture(scope="session") +def resources(template): + """Return the Resources section of the template.""" + return template["Resources"] + + +@pytest.fixture(scope="session") +def parameters(template): + """Return the Parameters section of the template.""" + return template["Parameters"] + + +@pytest.fixture(scope="session") +def outputs(template): + """Return the Outputs section of the template.""" + return template["Outputs"] diff --git a/strands-agentcore-apigw/tests/unit/test_properties.py b/strands-agentcore-apigw/tests/unit/test_properties.py new file mode 100644 index 000000000..086f8c3be --- /dev/null +++ b/strands-agentcore-apigw/tests/unit/test_properties.py @@ -0,0 +1,337 @@ +"""Property-based tests for CloudFormation template compliance. + +Uses hypothesis to verify universal properties hold across all valid inputs. +Each property test runs 100 iterations with generated inputs. +""" + +import pytest +from hypothesis import given, settings, assume +from hypothesis import strategies as st + + +# --------------------------------------------------------------------------- +# Helpers to extract data from the parsed CloudFormation template +# --------------------------------------------------------------------------- + + +def _get_method_logical_ids(resources): + """Return the set of logical IDs for all API Gateway Method resources.""" + return { + lid for lid, res in resources.items() + if res.get("Type") == "AWS::ApiGateway::Method" + } + + +def _get_deployment_depends_on(resources): + """Return the DependsOn list for the ApiDeployment resource.""" + depends = resources["ApiDeployment"].get("DependsOn", []) + if isinstance(depends, str): + depends = [depends] + return set(depends) + + +def _collect_actions_from_policies(role_resource): + """Extract all actions from all policy statements in a role.""" + actions = set() + for policy in role_resource["Properties"].get("Policies", []): + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + actions.update(stmt_actions) + return actions + + +def _collect_actions_from_sam_function_policies(function_resource): + """Extract all actions from the Policies list of an AWS::Serverless::Function. + + SAM function Policies are a list of {"Statement": [...]} blocks rather than + the {"PolicyName", "PolicyDocument"} shape used by AWS::IAM::Role. + """ + actions = set() + for policy in function_resource["Properties"].get("Policies", []): + statements = policy.get("Statement", []) if isinstance(policy, dict) else [] + for stmt in statements: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + actions.update(stmt_actions) + return actions + + +def _collect_resources_from_policies(role_resource): + """Extract all resource ARNs (as strings) from all policy statements.""" + arns = [] + for policy in role_resource["Properties"].get("Policies", []): + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_resources = stmt.get("Resource", []) + if isinstance(stmt_resources, str): + stmt_resources = [stmt_resources] + elif isinstance(stmt_resources, dict): + stmt_resources = [str(stmt_resources)] + else: + stmt_resources = [str(r) for r in stmt_resources] + arns.extend(stmt_resources) + return arns + + +def _get_get_resource_api_key_arns(role_resource): + """Extract resource ARNs specifically from GetResourceApiKey statements.""" + arns = [] + for policy in role_resource["Properties"].get("Policies", []): + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + if "bedrock-agentcore:GetResourceApiKey" in stmt_actions: + res = stmt.get("Resource", []) + if isinstance(res, list): + arns.extend([str(r) for r in res]) + elif isinstance(res, dict): + arns.append(str(res)) + else: + arns.append(str(res)) + return arns + + +def _get_named_resources(resources): + """Return resources that have a Name property (or equivalent naming field).""" + named = {} + # Fields that represent a resource name in CloudFormation + name_fields = [ + "Name", "FunctionName", "RoleName", "UserPoolName", + "ClientName", "UsagePlanName", "StageName", + ] + for lid, res in resources.items(): + props = res.get("Properties", {}) + for field in name_fields: + if field in props: + named[lid] = (field, props[field]) + break + return named + + +# --------------------------------------------------------------------------- +# Compliance functions โ€” these encode what "correct" looks like +# --------------------------------------------------------------------------- + + +def deployment_depends_on_all_methods(method_ids, depends_on): + """A compliant deployment's DependsOn must include every method logical ID.""" + return method_ids.issubset(depends_on) + + +def role_includes_all_bedrock_actions(role_actions, required_actions): + """A compliant Lambda role must include all required Bedrock actions.""" + return required_actions.issubset(role_actions) + + +def gateway_role_includes_all_arn_patterns(arn_strings, required_patterns): + """A compliant Gateway role must include all required ARN patterns.""" + combined = " ".join(arn_strings) + return all(pattern in combined for pattern in required_patterns) + + +def resource_name_uses_environment(name_value): + """A compliant resource name must reference EnvironmentName.""" + name_str = str(name_value) + return "EnvironmentName" in name_str + + +def outputs_include_all_required(output_keys, required_outputs): + """A compliant template must define all required outputs.""" + return required_outputs.issubset(output_keys) + + +# --------------------------------------------------------------------------- +# Property-Based Tests +# --------------------------------------------------------------------------- + + +# Feature: agentcore-apigw-weather-agent, Property 1: Deployment DependsOn includes all method logical IDs +class TestProperty1DeploymentDependsOn: + """**Validates: Requirements 1.7**""" + + @given( + subset_indices=st.lists( + st.integers(min_value=0, max_value=99), + min_size=1, + max_size=10, + ) + ) + @settings(max_examples=100) + def test_deployment_depends_on_all_methods(self, resources, subset_indices): + """For any random set of method logical IDs, verify the deployment's + DependsOn includes all actual method logical IDs from the template.""" + actual_method_ids = _get_method_logical_ids(resources) + actual_depends_on = _get_deployment_depends_on(resources) + + # Generate a random subset of the actual method IDs to query about + method_list = sorted(actual_method_ids) + assume(len(method_list) > 0) + selected = {method_list[i % len(method_list)] for i in subset_indices} + + # The compliance function must hold: every selected method is in DependsOn + assert deployment_depends_on_all_methods(selected, actual_depends_on), ( + f"Deployment DependsOn {actual_depends_on} is missing methods: " + f"{selected - actual_depends_on}" + ) + + +# Feature: agentcore-apigw-weather-agent, Property 2: Lambda role includes all 4 required Bedrock actions +class TestProperty2BedrockActions: + """**Validates: Requirements 6.1**""" + + REQUIRED_BEDROCK_ACTIONS = frozenset({ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:Converse", + "bedrock:ConverseStream", + }) + + @given( + subset=st.frozensets( + st.sampled_from([ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:Converse", + "bedrock:ConverseStream", + ]), + min_size=1, + max_size=4, + ) + ) + @settings(max_examples=100) + def test_lambda_role_includes_all_bedrock_actions(self, resources, subset): + """For any random subset of the 4 required Bedrock actions, verify the + Lambda function's inline policies always contain the full required set + (not just the subset). SAM generates the execution role from these.""" + role_actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + + # Regardless of which subset we picked, the full set must be present + assert role_includes_all_bedrock_actions(role_actions, self.REQUIRED_BEDROCK_ACTIONS), ( + f"Lambda role is missing Bedrock actions: " + f"{self.REQUIRED_BEDROCK_ACTIONS - role_actions}" + ) + + # Additionally, the queried subset must also be present + assert subset.issubset(role_actions), ( + f"Lambda role is missing queried actions: {subset - role_actions}" + ) + + +# Feature: agentcore-apigw-weather-agent, Property 3: Gateway role includes all 4 GetResourceApiKey ARN patterns +class TestProperty3GatewayArnPatterns: + """**Validates: Requirements 6.4**""" + + REQUIRED_ARN_PATTERNS = [ + "token-vault/default", + "token-vault/default/apikeycredentialprovider/*", + "workload-identity-directory/default", + "workload-identity-directory/default/workload-identity/", + ] + + @given( + subset=st.frozensets( + st.sampled_from([ + "token-vault/default", + "token-vault/default/apikeycredentialprovider/*", + "workload-identity-directory/default", + "workload-identity-directory/default/workload-identity/", + ]), + min_size=1, + max_size=4, + ) + ) + @settings(max_examples=100) + def test_gateway_role_includes_all_arn_patterns(self, resources, subset): + """For any random subset of the 4 required ARN patterns, verify the + Gateway role always contains all 4 patterns (not just the subset).""" + arn_strings = _get_get_resource_api_key_arns(resources["GatewayExecutionRole"]) + + # All 4 patterns must always be present regardless of which subset we queried + assert gateway_role_includes_all_arn_patterns( + arn_strings, self.REQUIRED_ARN_PATTERNS + ), ( + f"Gateway role GetResourceApiKey ARNs missing patterns. " + f"ARNs: {arn_strings}" + ) + + # The queried subset must also be present + combined = " ".join(arn_strings) + for pattern in subset: + assert pattern in combined, ( + f"Gateway role missing queried ARN pattern: {pattern}" + ) + + +# Feature: agentcore-apigw-weather-agent, Property 4: All named resources use EnvironmentName for namespacing +class TestProperty4EnvironmentNameNamespacing: + """**Validates: Requirements 8.2**""" + + @given( + resource_index=st.integers(min_value=0, max_value=99) + ) + @settings(max_examples=100) + def test_named_resources_use_environment_name(self, resources, resource_index): + """For any named resource in the template, verify its name value + references the EnvironmentName parameter.""" + named_resources = _get_named_resources(resources) + assume(len(named_resources) > 0) + + resource_ids = sorted(named_resources.keys()) + selected_id = resource_ids[resource_index % len(resource_ids)] + field_name, name_value = named_resources[selected_id] + + assert resource_name_uses_environment(name_value), ( + f"Resource {selected_id}.{field_name} = {name_value} " + f"does not reference EnvironmentName parameter" + ) + + +# Feature: agentcore-apigw-weather-agent, Property 5: All 8 required outputs are defined +class TestProperty5RequiredOutputs: + """**Validates: Requirements 8.4**""" + + REQUIRED_OUTPUTS = frozenset({ + "GatewayId", + "RestApiId", + "ApiKeyId", + "UserPoolId", + "UserPoolClientId", + "CognitoJwksUrl", + "AgentLambdaArn", + "ApiEndpointUrl", + }) + + @given( + subset=st.frozensets( + st.sampled_from([ + "GatewayId", + "RestApiId", + "ApiKeyId", + "UserPoolId", + "UserPoolClientId", + "CognitoJwksUrl", + "AgentLambdaArn", + "ApiEndpointUrl", + ]), + min_size=1, + max_size=8, + ) + ) + @settings(max_examples=100) + def test_all_required_outputs_defined(self, outputs, subset): + """For any random subset of the 8 required output names, verify the + template's Outputs section always contains the full required set.""" + output_keys = set(outputs.keys()) + + # All 8 must always be present regardless of which subset we queried + assert outputs_include_all_required(output_keys, self.REQUIRED_OUTPUTS), ( + f"Template outputs missing: {self.REQUIRED_OUTPUTS - output_keys}" + ) + + # The queried subset must also be present + assert subset.issubset(output_keys), ( + f"Template outputs missing queried outputs: {subset - output_keys}" + ) diff --git a/strands-agentcore-apigw/tests/unit/test_sam_template.py b/strands-agentcore-apigw/tests/unit/test_sam_template.py new file mode 100644 index 000000000..98df03700 --- /dev/null +++ b/strands-agentcore-apigw/tests/unit/test_sam_template.py @@ -0,0 +1,454 @@ +"""Unit tests for CloudFormation template validation. + +Validates API Gateway resources, AgentCore resources, Cognito, Lambda, +IAM roles, parameters, and outputs defined in the CloudFormation template. +""" + +import pytest + + +# ============================================================ +# 5.1 โ€” API Gateway Resources, Dependencies, and Properties +# ============================================================ + + +class TestRestApi: + def test_rest_api_exists(self, resources): + assert "RestApi" in resources + assert resources["RestApi"]["Type"] == "AWS::ApiGateway::RestApi" + + def test_api_key_source_type_is_header(self, resources): + props = resources["RestApi"]["Properties"] + assert props["ApiKeySourceType"] == "HEADER" + + +class TestWeatherResources: + def test_weather_resource_path(self, resources): + props = resources["WeatherResource"]["Properties"] + assert props["PathPart"] == "weather" + + def test_weather_current_resource_path(self, resources): + props = resources["WeatherCurrentResource"]["Properties"] + assert props["PathPart"] == "current" + + def test_weather_current_parent_is_weather(self, resources): + parent_ref = resources["WeatherCurrentResource"]["Properties"]["ParentId"] + assert parent_ref == {"Ref": "WeatherResource"} + + +class TestGetCurrentWeatherMethod: + def test_method_exists(self, resources): + assert "GetCurrentWeatherMethod" in resources + assert resources["GetCurrentWeatherMethod"]["Type"] == "AWS::ApiGateway::Method" + + def test_http_method_is_get(self, resources): + props = resources["GetCurrentWeatherMethod"]["Properties"] + assert props["HttpMethod"] == "GET" + + def test_api_key_required(self, resources): + props = resources["GetCurrentWeatherMethod"]["Properties"] + assert props["ApiKeyRequired"] is True + + def test_authorization_type_none(self, resources): + props = resources["GetCurrentWeatherMethod"]["Properties"] + assert props["AuthorizationType"] == "NONE" + + def test_integration_type_http_proxy(self, resources): + integration = resources["GetCurrentWeatherMethod"]["Properties"]["Integration"] + assert integration["Type"] == "HTTP_PROXY" + + def test_integration_uri_weatherapi(self, resources): + integration = resources["GetCurrentWeatherMethod"]["Properties"]["Integration"] + assert "api.weatherapi.com" in integration["Uri"] + + def test_integration_maps_q_param(self, resources): + integration = resources["GetCurrentWeatherMethod"]["Properties"]["Integration"] + req_params = integration["RequestParameters"] + assert "integration.request.querystring.q" in req_params + assert req_params["integration.request.querystring.q"] == "method.request.querystring.q" + + def test_integration_injects_weather_api_key_via_stage_variable(self, resources): + integration = resources["GetCurrentWeatherMethod"]["Properties"]["Integration"] + req_params = integration["RequestParameters"] + assert "integration.request.querystring.key" in req_params + assert "stageVariables.weatherApiKey" in str(req_params["integration.request.querystring.key"]) + + +class TestApiDeployment: + def test_deployment_exists(self, resources): + assert "ApiDeployment" in resources + assert resources["ApiDeployment"]["Type"] == "AWS::ApiGateway::Deployment" + + def test_deployment_depends_on_method(self, resources): + depends = resources["ApiDeployment"]["DependsOn"] + if isinstance(depends, str): + depends = [depends] + assert "GetCurrentWeatherMethod" in depends + + +class TestApiStage: + def test_stage_exists(self, resources): + assert "ApiStage" in resources + assert resources["ApiStage"]["Type"] == "AWS::ApiGateway::Stage" + + def test_stage_has_weather_api_key_variable(self, resources): + variables = resources["ApiStage"]["Properties"]["Variables"] + assert "weatherApiKey" in variables + + def test_stage_weather_api_key_uses_secret_reference(self, resources): + value = resources["ApiStage"]["Properties"]["Variables"]["weatherApiKey"] + # Should be !Sub with secretsmanager dynamic reference using the parameter + assert isinstance(value, dict) and "Sub" in value + assert "resolve:secretsmanager" in value["Sub"] + assert "WeatherApiKeySecretArn" in value["Sub"] + + +class TestApiKeyAndUsagePlan: + def test_api_key_depends_on_stage(self, resources): + assert resources["ApiKey"].get("DependsOn") == "ApiStage" or "ApiStage" in ( + resources["ApiKey"].get("DependsOn") or [] + ) + + def test_usage_plan_depends_on_stage(self, resources): + assert resources["UsagePlan"].get("DependsOn") == "ApiStage" or "ApiStage" in ( + resources["UsagePlan"].get("DependsOn") or [] + ) + + def test_usage_plan_key_links_key_to_plan(self, resources): + props = resources["UsagePlanKey"]["Properties"] + assert props["KeyId"] == {"Ref": "ApiKey"} + assert props["UsagePlanId"] == {"Ref": "UsagePlan"} + + +# ============================================================ +# 5.2 โ€” AgentCore Gateway, GatewayTarget, and Cognito +# ============================================================ + + +class TestAgentCoreGateway: + def test_gateway_exists(self, resources): + assert "AgentCoreGateway" in resources + assert resources["AgentCoreGateway"]["Type"] == "AWS::BedrockAgentCore::Gateway" + + def test_protocol_type_mcp(self, resources): + props = resources["AgentCoreGateway"]["Properties"] + assert props["ProtocolType"] == "MCP" + + def test_authorizer_type_custom_jwt(self, resources): + props = resources["AgentCoreGateway"]["Properties"] + assert props["AuthorizerType"] == "CUSTOM_JWT" + + def test_authorizer_configuration_present(self, resources): + props = resources["AgentCoreGateway"]["Properties"] + auth_config = props["AuthorizerConfiguration"] + assert "CustomJWTAuthorizer" in auth_config + jwt = auth_config["CustomJWTAuthorizer"] + assert "DiscoveryUrl" in jwt + assert "AllowedAudience" in jwt + + def test_role_arn_present(self, resources): + props = resources["AgentCoreGateway"]["Properties"] + assert "RoleArn" in props + + +class TestGatewayTarget: + def test_target_exists(self, resources): + assert "WeatherAPITarget" in resources + assert resources["WeatherAPITarget"]["Type"] == "AWS::BedrockAgentCore::GatewayTarget" + + def test_target_uses_mcp_api_gateway_block(self, resources): + target_config = resources["WeatherAPITarget"]["Properties"]["TargetConfiguration"] + assert "Mcp" in target_config + assert "ApiGateway" in target_config["Mcp"] + + def test_target_does_not_use_openapi_schema(self, resources): + mcp = resources["WeatherAPITarget"]["Properties"]["TargetConfiguration"]["Mcp"] + assert "OpenApiSchema" not in mcp + + def test_target_references_rest_api(self, resources): + api_gw = resources["WeatherAPITarget"]["Properties"]["TargetConfiguration"]["Mcp"]["ApiGateway"] + assert "RestApiId" in api_gw + + def test_target_references_stage(self, resources): + api_gw = resources["WeatherAPITarget"]["Properties"]["TargetConfiguration"]["Mcp"]["ApiGateway"] + assert "Stage" in api_gw + + def test_target_has_tool_configuration(self, resources): + api_gw = resources["WeatherAPITarget"]["Properties"]["TargetConfiguration"]["Mcp"]["ApiGateway"] + assert "ApiGatewayToolConfiguration" in api_gw + tool_config = api_gw["ApiGatewayToolConfiguration"] + assert "ToolFilters" in tool_config + assert len(tool_config["ToolFilters"]) >= 1 + + +class TestCognito: + def test_user_pool_exists(self, resources): + assert "CognitoUserPool" in resources + assert resources["CognitoUserPool"]["Type"] == "AWS::Cognito::UserPool" + + def test_user_pool_client_exists(self, resources): + assert "CognitoUserPoolClient" in resources + assert resources["CognitoUserPoolClient"]["Type"] == "AWS::Cognito::UserPoolClient" + + def test_client_auth_flows(self, resources): + auth_flows = resources["CognitoUserPoolClient"]["Properties"]["ExplicitAuthFlows"] + assert "ALLOW_USER_PASSWORD_AUTH" in auth_flows + assert "ALLOW_REFRESH_TOKEN_AUTH" in auth_flows + + +# ============================================================ +# 5.3 โ€” Lambda Configuration +# ============================================================ + + +class TestLambdaConfig: + def test_lambda_exists(self, resources): + assert "AgentLambdaFunction" in resources + assert resources["AgentLambdaFunction"]["Type"] == "AWS::Serverless::Function" + + def test_runtime_python312(self, resources): + props = resources["AgentLambdaFunction"]["Properties"] + assert props["Runtime"] == "python3.12" + + def test_architecture_x86_64(self, resources): + props = resources["AgentLambdaFunction"]["Properties"] + assert "x86_64" in props["Architectures"] + + def test_timeout_at_least_120(self, resources): + props = resources["AgentLambdaFunction"]["Properties"] + assert props["Timeout"] >= 120 + + def test_memory_at_least_1024(self, resources): + props = resources["AgentLambdaFunction"]["Properties"] + assert props["MemorySize"] >= 1024 + + def test_code_uri_present(self, resources): + # SAM packages the code from CodeUri (the execution role and log + # group are generated automatically by the AWS::Serverless::Function + # transform). + props = resources["AgentLambdaFunction"]["Properties"] + assert "CodeUri" in props + + def test_env_var_gateway_id(self, resources): + env_vars = resources["AgentLambdaFunction"]["Properties"]["Environment"]["Variables"] + assert "GATEWAY_ID" in env_vars + + def test_env_var_cognito_jwks_url(self, resources): + env_vars = resources["AgentLambdaFunction"]["Properties"]["Environment"]["Variables"] + assert "COGNITO_JWKS_URL" in env_vars + + def test_env_var_bedrock_model_id(self, resources): + env_vars = resources["AgentLambdaFunction"]["Properties"]["Environment"]["Variables"] + assert "BEDROCK_MODEL_ID" in env_vars + + +# ============================================================ +# 5.4 โ€” IAM Roles +# ============================================================ + + +def _collect_actions_from_policies(role_resource): + """Extract all actions from all policy statements in a role.""" + actions = [] + for policy in role_resource["Properties"].get("Policies", []): + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + actions.extend(stmt_actions) + return actions + + +def _collect_resources_from_policies(role_resource): + """Extract all resource ARNs (as strings) from all policy statements in a role. + + CloudFormation intrinsic functions like !Sub are parsed as dicts, e.g. + {"Sub": "arn:aws:..."}. We stringify everything so pattern matching works. + """ + arns = [] + for policy in role_resource["Properties"].get("Policies", []): + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_resources = stmt.get("Resource", []) + if isinstance(stmt_resources, str): + stmt_resources = [stmt_resources] + elif isinstance(stmt_resources, dict): + # e.g. {"Sub": "arn:aws:bedrock-agentcore:..."} + stmt_resources = [str(stmt_resources)] + else: + stmt_resources = [str(r) for r in stmt_resources] + arns.extend(stmt_resources) + return arns + + +def _collect_actions_from_sam_function_policies(function_resource): + """Extract all actions from the Policies list of an AWS::Serverless::Function. + + SAM function Policies are a list of policy documents or statement blocks; + here each entry is a {"Statement": [...]} block. + """ + actions = [] + for policy in function_resource["Properties"].get("Policies", []): + statements = policy.get("Statement", []) if isinstance(policy, dict) else [] + for stmt in statements: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + actions.extend(stmt_actions) + return actions + + +class TestAgentLambdaPermissions: + """The Agent Lambda execution role is generated by SAM from the + function's Policies property; CloudWatch Logs permissions are provided + automatically by the AWS::Serverless::Function transform via the managed + basic execution policy.""" + + def test_bedrock_invoke_model(self, resources): + actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + assert "bedrock:InvokeModel" in actions + + def test_bedrock_invoke_model_with_response_stream(self, resources): + actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + assert "bedrock:InvokeModelWithResponseStream" in actions + + def test_bedrock_converse(self, resources): + actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + assert "bedrock:Converse" in actions + + def test_bedrock_converse_stream(self, resources): + actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + assert "bedrock:ConverseStream" in actions + + def test_get_gateway(self, resources): + actions = _collect_actions_from_sam_function_policies(resources["AgentLambdaFunction"]) + assert "bedrock-agentcore:GetGateway" in actions + + +class TestGatewayExecutionRole: + def test_role_exists(self, resources): + assert "GatewayExecutionRole" in resources + assert resources["GatewayExecutionRole"]["Type"] == "AWS::IAM::Role" + + def test_get_workload_access_token(self, resources): + actions = _collect_actions_from_policies(resources["GatewayExecutionRole"]) + assert "bedrock-agentcore:GetWorkloadAccessToken" in actions + + def test_get_resource_api_key(self, resources): + actions = _collect_actions_from_policies(resources["GatewayExecutionRole"]) + assert "bedrock-agentcore:GetResourceApiKey" in actions + + def test_get_resource_api_key_has_token_vault_default(self, resources): + arns = _collect_resources_from_policies(resources["GatewayExecutionRole"]) + arn_str = " ".join(arns) + assert "token-vault/default" in arn_str + + def test_get_resource_api_key_has_token_vault_provider_wildcard(self, resources): + arns = _collect_resources_from_policies(resources["GatewayExecutionRole"]) + arn_str = " ".join(arns) + assert "token-vault/default/apikeycredentialprovider/*" in arn_str + + def test_get_resource_api_key_has_workload_identity_default(self, resources): + arns = _collect_resources_from_policies(resources["GatewayExecutionRole"]) + arn_str = " ".join(arns) + assert "workload-identity-directory/default" in arn_str + + def test_get_resource_api_key_has_workload_identity_gateway_wildcard(self, resources): + arns = _collect_resources_from_policies(resources["GatewayExecutionRole"]) + arn_str = " ".join(arns) + assert "workload-identity-directory/default/workload-identity/" in arn_str + + def test_secrets_manager_get_secret_value(self, resources): + actions = _collect_actions_from_policies(resources["GatewayExecutionRole"]) + assert "secretsmanager:GetSecretValue" in actions + + def test_apigateway_get(self, resources): + actions = _collect_actions_from_policies(resources["GatewayExecutionRole"]) + assert "apigateway:GET" in actions + + def test_four_get_resource_api_key_arn_patterns(self, resources): + """Verify all 4 required ARN patterns exist for GetResourceApiKey.""" + role = resources["GatewayExecutionRole"] + api_key_resources = [] + for policy in role["Properties"]["Policies"]: + for stmt in policy["PolicyDocument"]["Statement"]: + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + if "bedrock-agentcore:GetResourceApiKey" in stmt_actions: + res = stmt.get("Resource", []) + if isinstance(res, list): + api_key_resources.extend([str(r) for r in res]) + else: + api_key_resources.append(str(res)) + + arn_str = " ".join(api_key_resources) + assert "token-vault/default" in arn_str + assert "apikeycredentialprovider/*" in arn_str + assert "workload-identity-directory/default" in arn_str + assert "workload-identity/" in arn_str + assert len(api_key_resources) >= 4 + + +# ============================================================ +# 5.5 โ€” Template Parameters and Outputs +# ============================================================ + + +class TestParameters: + def test_environment_name_param(self, parameters): + assert "EnvironmentName" in parameters + assert parameters["EnvironmentName"]["Type"] == "String" + + def test_weather_api_key_secret_arn_param(self, parameters): + assert "WeatherApiKeySecretArn" in parameters + assert parameters["WeatherApiKeySecretArn"]["Type"] == "String" + + def test_credential_provider_arn_param(self, parameters): + assert "CredentialProviderArn" in parameters + assert parameters["CredentialProviderArn"]["Type"] == "String" + + +class TestOutputs: + REQUIRED_OUTPUTS = [ + "GatewayId", + "RestApiId", + "ApiKeyId", + "UserPoolId", + "UserPoolClientId", + "CognitoJwksUrl", + "AgentLambdaArn", + "ApiEndpointUrl", + ] + + @pytest.mark.parametrize("output_name", REQUIRED_OUTPUTS) + def test_required_output_exists(self, outputs, output_name): + assert output_name in outputs, f"Missing required output: {output_name}" + + def test_all_eight_outputs_present(self, outputs): + for name in self.REQUIRED_OUTPUTS: + assert name in outputs + assert len(self.REQUIRED_OUTPUTS) == 8 + + def test_gateway_id_references_gateway(self, outputs): + value = outputs["GatewayId"]["Value"] + assert value == {"Ref": "AgentCoreGateway"} + + def test_rest_api_id_references_rest_api(self, outputs): + value = outputs["RestApiId"]["Value"] + assert value == {"Ref": "RestApi"} + + def test_api_key_id_references_api_key(self, outputs): + value = outputs["ApiKeyId"]["Value"] + assert value == {"Ref": "ApiKey"} + + def test_user_pool_id_references_cognito(self, outputs): + value = outputs["UserPoolId"]["Value"] + assert value == {"Ref": "CognitoUserPool"} + + def test_user_pool_client_id_references_client(self, outputs): + value = outputs["UserPoolClientId"]["Value"] + assert value == {"Ref": "CognitoUserPoolClient"} + + def test_agent_lambda_arn_uses_getatt(self, outputs): + value = outputs["AgentLambdaArn"]["Value"] + assert value == {"GetAtt": "AgentLambdaFunction.Arn"} From 1a43ba0b2b1df530429f4886b0133ea34c409c0d Mon Sep 17 00:00:00 2001 From: Mike Hume Date: Wed, 10 Jun 2026 12:05:57 +0100 Subject: [PATCH 2/2] Update strands-agentcore-apigw pattern --- strands-agentcore-apigw/.gitignore | 27 ++++++++++++++++--- strands-agentcore-apigw/README.md | 2 +- .../tests/unit/conftest.py | 2 +- .../tests/unit/test_properties.py | 2 +- .../tests/unit/test_sam_template.py | 4 +-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/strands-agentcore-apigw/.gitignore b/strands-agentcore-apigw/.gitignore index 0a5148d8a..fac7ffe74 100644 --- a/strands-agentcore-apigw/.gitignore +++ b/strands-agentcore-apigw/.gitignore @@ -1,10 +1,31 @@ .DS_Store -*-deps/ -*-package/ -*.zip + +# Editor / IDE +.vscode/ + +# Kiro (local spec/steering tooling โ€” not part of the deployable pattern) +.kiro/ + +# Python venv/ .venv/ __pycache__/ *.pyc *.so *.egg-info/ + +# Test caches (regenerated on every run) +.hypothesis/ +.pytest_cache/ + +# AWS SAM build output +.aws-sam/ + +# Legacy manual-packaging artifacts (pre-SAM deploy) +*-deps/ +*-package/ +*.zip + +# Generated at deploy time by scripts/deploy.sh (contains environment-specific +# values and the test user's password) +scripts/test.sh diff --git a/strands-agentcore-apigw/README.md b/strands-agentcore-apigw/README.md index 9952b228a..ae143850d 100644 --- a/strands-agentcore-apigw/README.md +++ b/strands-agentcore-apigw/README.md @@ -116,7 +116,7 @@ The test script authenticates via Cognito, gets an ID token, and invokes the Lam โ”‚ โ””โ”€โ”€ logging_utils.py # Structured logging โ”œโ”€โ”€ tests/ โ”‚ โ”œโ”€โ”€ unit/ -โ”‚ โ”‚ โ”œโ”€โ”€ test_cloudformation_template.py +โ”‚ โ”‚ โ”œโ”€โ”€ test_sam_template.py โ”‚ โ”‚ โ”œโ”€โ”€ test_properties.py # Property-based tests โ”‚ โ”‚ โ””โ”€โ”€ conftest.py โ”‚ โ””โ”€โ”€ integration/ diff --git a/strands-agentcore-apigw/tests/unit/conftest.py b/strands-agentcore-apigw/tests/unit/conftest.py index 89e5725b1..8318314b3 100644 --- a/strands-agentcore-apigw/tests/unit/conftest.py +++ b/strands-agentcore-apigw/tests/unit/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for CloudFormation template unit tests.""" +"""Shared fixtures for SAM template unit tests.""" import os import pytest diff --git a/strands-agentcore-apigw/tests/unit/test_properties.py b/strands-agentcore-apigw/tests/unit/test_properties.py index 086f8c3be..3552c7a2a 100644 --- a/strands-agentcore-apigw/tests/unit/test_properties.py +++ b/strands-agentcore-apigw/tests/unit/test_properties.py @@ -1,4 +1,4 @@ -"""Property-based tests for CloudFormation template compliance. +"""Property-based tests for SAM template compliance. Uses hypothesis to verify universal properties hold across all valid inputs. Each property test runs 100 iterations with generated inputs. diff --git a/strands-agentcore-apigw/tests/unit/test_sam_template.py b/strands-agentcore-apigw/tests/unit/test_sam_template.py index 98df03700..0596b808c 100644 --- a/strands-agentcore-apigw/tests/unit/test_sam_template.py +++ b/strands-agentcore-apigw/tests/unit/test_sam_template.py @@ -1,7 +1,7 @@ -"""Unit tests for CloudFormation template validation. +"""Unit tests for SAM template validation. Validates API Gateway resources, AgentCore resources, Cognito, Lambda, -IAM roles, parameters, and outputs defined in the CloudFormation template. +IAM, parameters, and outputs defined in the SAM template. """ import pytest