chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,21 @@
# Terraform Infrastructure
Equivalent of `../infra-cdk/` using Terraform.
## Usage
```bash
cd infra-terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars — set stack_name_base, backend_pattern, aws_region
terraform init
terraform plan
terraform apply
```
After apply, run the frontend deploy from the repo root:
```bash
python3 scripts/deploy-frontend.py
```
@@ -0,0 +1,32 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# S3 Remote Backend Configuration
# =============================================================================
#
# To enable remote state storage:
# 1. Create S3 bucket and DynamoDB table (see commands below)
# 2. Copy this file: cp backend.tf.example backend.tf
# 3. Update bucket/table names below
# 4. Run: terraform init -migrate-state
#
# Prerequisites (run once):
# aws s3 mb s3://YOUR-BUCKET-NAME --region us-east-1
# aws dynamodb create-table \
# --table-name terraform-locks \
# --attribute-definitions AttributeName=LockID,AttributeType=S \
# --key-schema AttributeName=LockID,KeyType=HASH \
# --billing-mode PAY_PER_REQUEST \
# --region us-east-1
# =============================================================================
terraform {
backend "s3" {
bucket = "YOUR-TERRAFORM-STATE-BUCKET" # Change this
key = "fast/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
@@ -0,0 +1,41 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Data Sources
# =============================================================================
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# Local Values
# =============================================================================
locals {
# Account and region information
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
# Common tags applied to all resources via provider default_tags
common_tags = {
Project = var.stack_name_base
ManagedBy = "Terraform"
Repository = "fullstack-agentcore-solution-template"
}
# SSM parameter paths
ssm_parameter_prefix = "/${var.stack_name_base}"
# Log retention in days
log_retention_days = 7
# S3 lifecycle rules
staging_bucket_expiry_days = 30
access_logs_expiry_days = 90
# API Gateway settings
api_throttling_rate_limit = 100
api_throttling_burst_limit = 200
}
@@ -0,0 +1,99 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Provider Configuration
# =============================================================================
provider "aws" {
default_tags {
tags = local.common_tags
}
}
# =============================================================================
# DEPLOYMENT ORDER:
# 1. Amplify Hosting - Creates app and gets predictable URL
# 2. Cognito - Uses Amplify URL for callback URLs
# 3. Backend Resources (Memory, Gateway, Runtime, Feedback API)
# =============================================================================
# =============================================================================
# Module: Amplify Hosting (Frontend)
# =============================================================================
# Creates:
# - S3 bucket for access logs
# - S3 bucket for frontend staging
# - Amplify App (WEB platform)
# - Amplify Branch (main, PRODUCTION)
module "amplify_hosting" {
source = "./modules/amplify-hosting"
stack_name_base = var.stack_name_base
staging_bucket_expiry_days = local.staging_bucket_expiry_days
access_logs_expiry_days = local.access_logs_expiry_days
}
# =============================================================================
# Module: Cognito (Authentication)
# =============================================================================
# Creates:
# - User Pool with password policy and invitation templates
# - User Pool Domain with managed login V2 branding
# - Web Client (for frontend OAuth)
# - Admin User (optional)
module "cognito" {
source = "./modules/cognito"
stack_name_base = var.stack_name_base
admin_user_email = var.admin_user_email
# Use the predictable Amplify URL from the app_url output
amplify_url = module.amplify_hosting.app_url
depends_on = [module.amplify_hosting]
}
# =============================================================================
# Module: Backend (AgentCore + Feedback API)
# =============================================================================
# Creates:
# - AgentCore Memory (IAM role + memory resource)
# - M2M Authentication (resource server + machine client)
# - AgentCore Gateway (Lambda, IAM, gateway, target)
# - AgentCore Runtime (ECR, IAM, runtime)
# - Feedback API (DynamoDB, Lambda, API Gateway)
# - SSM Parameters & Secrets Manager
module "backend" {
source = "./modules/backend"
stack_name_base = var.stack_name_base
backend_pattern = var.backend_pattern
backend_deployment_type = var.backend_deployment_type
backend_network_mode = var.backend_network_mode
# VPC configuration (for VPC mode)
backend_vpc_id = var.backend_vpc_id
backend_vpc_subnet_ids = var.backend_vpc_subnet_ids
backend_vpc_security_group_ids = var.backend_vpc_security_group_ids
# Cognito configuration
user_pool_id = module.cognito.user_pool_id
user_pool_arn = module.cognito.user_pool_arn
web_client_id = module.cognito.web_client_id
cognito_domain_url = module.cognito.cognito_domain_url
# Frontend URL for CORS
frontend_url = module.amplify_hosting.app_url
# Optional overrides
log_retention_days = local.log_retention_days
throttling_rate_limit = local.api_throttling_rate_limit
throttling_burst_limit = local.api_throttling_burst_limit
depends_on = [module.cognito, module.amplify_hosting]
}
@@ -0,0 +1,169 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Data Sources
# =============================================================================
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# Local Values
# =============================================================================
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
app_name = "${var.stack_name_base}-frontend"
}
# =============================================================================
# S3 Bucket for Access Logs
# =============================================================================
resource "aws_s3_bucket" "access_logs" {
bucket_prefix = "${lower(var.stack_name_base)}-access-logs-"
force_destroy = true
}
resource "aws_s3_bucket_public_access_block" "access_logs" {
bucket = aws_s3_bucket.access_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "access_logs" {
bucket = aws_s3_bucket.access_logs.id
rule {
id = "DeleteOldAccessLogs"
status = "Enabled"
expiration {
days = var.access_logs_expiry_days
}
}
}
# =============================================================================
# S3 Bucket for Staging (Amplify Deployments)
# =============================================================================
resource "aws_s3_bucket" "staging" {
bucket_prefix = "${lower(var.stack_name_base)}-staging-"
force_destroy = true
}
resource "aws_s3_bucket_versioning" "staging" {
bucket = aws_s3_bucket.staging.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "staging" {
bucket = aws_s3_bucket.staging.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "staging" {
bucket = aws_s3_bucket.staging.id
rule {
id = "DeleteOldDeployments"
status = "Enabled"
expiration {
days = var.staging_bucket_expiry_days
}
}
}
resource "aws_s3_bucket_logging" "staging" {
bucket = aws_s3_bucket.staging.id
target_bucket = aws_s3_bucket.access_logs.id
target_prefix = "staging-bucket-access-logs/"
}
# Bucket policy: Allow Amplify service access
resource "aws_s3_bucket_policy" "staging" {
bucket = aws_s3_bucket.staging.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AmplifyAccess"
Effect = "Allow"
Principal = {
Service = "amplify.amazonaws.com"
}
Action = [
"s3:GetObject",
"s3:GetObjectVersion"
]
Resource = "${aws_s3_bucket.staging.arn}/*"
},
{
Sid = "DenyInsecureConnections"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.staging.arn,
"${aws_s3_bucket.staging.arn}/*"
]
Condition = {
Bool = {
"aws:SecureTransport" = "false"
}
}
}
]
})
}
# =============================================================================
# Amplify App
# =============================================================================
# Note: This creates a manual deployment app (no Git integration)
# Frontend deployments are handled via the deploy-frontend.py script
# Environment variables are set at deployment time, not at app creation
resource "aws_amplify_app" "frontend" {
name = local.app_name
platform = var.platform
description = "${var.stack_name_base} - React/Next.js Frontend"
}
# =============================================================================
# Amplify Branch (main)
# =============================================================================
resource "aws_amplify_branch" "main" {
app_id = aws_amplify_app.frontend.id
branch_name = "main"
stage = "PRODUCTION"
description = "Main production branch"
# Enable auto-build on push (if using Git integration)
enable_auto_build = false
}
@@ -0,0 +1,37 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
output "app_id" {
description = "Amplify App ID"
value = aws_amplify_app.frontend.id
}
output "app_arn" {
description = "Amplify App ARN"
value = aws_amplify_app.frontend.arn
}
output "default_domain" {
description = "Amplify default domain"
value = aws_amplify_app.frontend.default_domain
}
output "app_url" {
description = "Full Amplify app URL (main branch) - predictable format"
value = "https://main.${aws_amplify_app.frontend.id}.amplifyapp.com"
}
output "branch_name" {
description = "Main branch name"
value = aws_amplify_branch.main.branch_name
}
output "staging_bucket_name" {
description = "S3 staging bucket name for deployments"
value = aws_s3_bucket.staging.bucket
}
output "staging_bucket_arn" {
description = "S3 staging bucket ARN"
value = aws_s3_bucket.staging.arn
}
@@ -0,0 +1,25 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
variable "stack_name_base" {
description = "Base name for all resources."
type = string
}
variable "platform" {
description = "Platform type for Amplify app (WEB or WEB_COMPUTE)."
type = string
default = "WEB"
}
variable "staging_bucket_expiry_days" {
description = "Number of days before staging bucket objects expire."
type = number
default = 30
}
variable "access_logs_expiry_days" {
description = "Number of days before access log objects expire."
type = number
default = 90
}
@@ -0,0 +1,13 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.82.0"
}
}
}
@@ -0,0 +1,59 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Machine-to-Machine (M2M) Authentication
# Maps to: backend-stack.ts createMachineAuthentication()
# =============================================================================
# Resource Server for M2M Authentication
# Defines API scopes that machine clients can request access to
resource "aws_cognito_resource_server" "gateway" {
identifier = "${var.stack_name_base}-gateway"
name = "${var.stack_name_base}-gateway-resource-server"
user_pool_id = var.user_pool_id
scope {
scope_name = "read"
scope_description = "Read access to gateway"
}
scope {
scope_name = "write"
scope_description = "Write access to gateway"
}
}
# Machine Client for AgentCore Gateway authentication
# Uses OAuth2 Client Credentials flow for service-to-service auth
resource "aws_cognito_user_pool_client" "machine" {
name = "${var.stack_name_base}-machine-client"
user_pool_id = var.user_pool_id
# Secret required for client credentials flow
generate_secret = true
# OAuth configuration for M2M
allowed_oauth_flows = ["client_credentials"]
allowed_oauth_flows_user_pool_client = true
# Resource server scopes
allowed_oauth_scopes = [
"${aws_cognito_resource_server.gateway.identifier}/read",
"${aws_cognito_resource_server.gateway.identifier}/write"
]
# Supported identity providers
supported_identity_providers = ["COGNITO"]
# Token validity for M2M
access_token_validity = 1
token_validity_units {
access_token = "hours"
}
depends_on = [aws_cognito_resource_server.gateway]
}
@@ -0,0 +1,171 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
resource "aws_cloudwatch_log_group" "copilotkit_runtime" {
name = "/aws/lambda/${var.stack_name_base}-copilotkit-runtime"
retention_in_days = local.log_retention_days
}
data "aws_ssm_parameter" "langgraph_runtime_arn" {
name = "/langgraph-stack/runtime-arn"
}
data "aws_ssm_parameter" "strands_runtime_arn" {
name = "/strands-stack/runtime-arn"
}
data "aws_iam_policy_document" "copilotkit_runtime_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "copilotkit_runtime" {
name = "${var.stack_name_base}-copilotkit-runtime-role"
assume_role_policy = data.aws_iam_policy_document.copilotkit_runtime_assume_role.json
description = "Execution role for CopilotKit runtime Lambda"
}
data "aws_iam_policy_document" "copilotkit_runtime_policy" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["${aws_cloudwatch_log_group.copilotkit_runtime.arn}:*"]
}
}
resource "aws_iam_role_policy" "copilotkit_runtime" {
name = "${var.stack_name_base}-copilotkit-runtime-policy"
role = aws_iam_role.copilotkit_runtime.id
policy = data.aws_iam_policy_document.copilotkit_runtime_policy.json
}
resource "null_resource" "copilotkit_runtime_build" {
triggers = {
source_hash = sha256(join("", [
for f in fileset(local.copilotkit_runtime_source_path, "**") :
filesha256("${local.copilotkit_runtime_source_path}/${f}")
if !endswith(f, "/")
]))
}
provisioner "local-exec" {
command = <<-EOT
set -e
BUILD_DIR="${path.module}/artifacts/copilotkit_runtime_build"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
cp -R ${local.copilotkit_runtime_source_path}/src "$BUILD_DIR/"
cp ${local.copilotkit_runtime_source_path}/package.json "$BUILD_DIR/"
cp ${local.copilotkit_runtime_source_path}/package-lock.json "$BUILD_DIR/"
cp ${local.copilotkit_runtime_source_path}/tsconfig.json "$BUILD_DIR/"
cd "$BUILD_DIR"
npm ci --no-audit --no-fund
npm run build
npm prune --omit=dev
EOT
}
}
data "archive_file" "copilotkit_runtime" {
type = "zip"
source_dir = "${path.module}/artifacts/copilotkit_runtime_build"
output_path = "${path.module}/artifacts/copilotkit_runtime.zip"
excludes = ["__pycache__", "*.pyc"]
depends_on = [null_resource.copilotkit_runtime_build]
}
resource "aws_lambda_function" "copilotkit_runtime" {
function_name = "${var.stack_name_base}-copilotkit-runtime"
role = aws_iam_role.copilotkit_runtime.arn
handler = "dist/index.handler"
runtime = "nodejs20.x"
architectures = ["arm64"]
timeout = 30
memory_size = 1024
filename = data.archive_file.copilotkit_runtime.output_path
source_code_hash = data.archive_file.copilotkit_runtime.output_base64sha256
environment {
variables = {
AGENTCORE_AG_UI_URL = "https://bedrock-agentcore.${local.region}.amazonaws.com/runtimes/${urlencode(var.backend_pattern == "strands-single-agent" ? data.aws_ssm_parameter.strands_runtime_arn.value : data.aws_ssm_parameter.langgraph_runtime_arn.value)}/invocations"
COPILOTKIT_AGENT_NAME = var.backend_pattern
LANGGRAPH_AGENTCORE_AG_UI_URL = "https://bedrock-agentcore.${local.region}.amazonaws.com/runtimes/${urlencode(data.aws_ssm_parameter.langgraph_runtime_arn.value)}/invocations"
STRANDS_AGENTCORE_AG_UI_URL = "https://bedrock-agentcore.${local.region}.amazonaws.com/runtimes/${urlencode(data.aws_ssm_parameter.strands_runtime_arn.value)}/invocations"
}
}
depends_on = [
aws_cloudwatch_log_group.copilotkit_runtime,
aws_iam_role_policy.copilotkit_runtime
]
}
resource "aws_apigatewayv2_api" "copilotkit_runtime" {
name = "${var.stack_name_base}-copilotkit-runtime-api"
protocol_type = "HTTP"
cors_configuration {
allow_headers = ["content-type", "authorization"]
allow_methods = ["GET", "OPTIONS", "POST"]
allow_origins = [var.frontend_url, "http://localhost:3000"]
max_age = 300
}
}
resource "aws_apigatewayv2_integration" "copilotkit_runtime" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.copilotkit_runtime.invoke_arn
integration_method = "POST"
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "copilotkit_runtime_root_get" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
route_key = "GET /copilotkit"
target = "integrations/${aws_apigatewayv2_integration.copilotkit_runtime.id}"
}
resource "aws_apigatewayv2_route" "copilotkit_runtime_root_post" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
route_key = "POST /copilotkit"
target = "integrations/${aws_apigatewayv2_integration.copilotkit_runtime.id}"
}
resource "aws_apigatewayv2_route" "copilotkit_runtime_proxy_get" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
route_key = "GET /copilotkit/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.copilotkit_runtime.id}"
}
resource "aws_apigatewayv2_route" "copilotkit_runtime_proxy_post" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
route_key = "POST /copilotkit/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.copilotkit_runtime.id}"
}
resource "aws_apigatewayv2_stage" "copilotkit_runtime" {
api_id = aws_apigatewayv2_api.copilotkit_runtime.id
name = "prod"
auto_deploy = true
}
resource "aws_lambda_permission" "copilotkit_runtime" {
statement_id = "AllowCopilotKitRuntimeInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.copilotkit_runtime.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.copilotkit_runtime.execution_arn}/*/*"
}
@@ -0,0 +1,123 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# AgentCore Gateway
# Maps to: backend-stack.ts createAgentCoreGateway()
# =============================================================================
# -----------------------------------------------------------------------------
# IAM Role for Gateway
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "gateway_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["bedrock-agentcore.amazonaws.com"]
}
}
}
resource "aws_iam_role" "gateway" {
name = "${var.stack_name_base}-gateway-role"
assume_role_policy = data.aws_iam_policy_document.gateway_assume_role.json
description = "Role for AgentCore Gateway"
}
data "aws_iam_policy_document" "gateway_policy" {
# Bedrock permissions (region-agnostic)
statement {
sid = "BedrockInvoke"
effect = "Allow"
actions = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
]
resources = [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:*:${local.account_id}:inference-profile/*"
]
}
# SSM parameter access
statement {
sid = "SSMAccess"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters"
]
resources = ["arn:aws:ssm:${local.region}:${local.account_id}:parameter/${var.stack_name_base}/*"]
}
# Cognito permissions
statement {
sid = "CognitoAccess"
effect = "Allow"
actions = [
"cognito-idp:DescribeUserPoolClient",
"cognito-idp:InitiateAuth"
]
resources = [var.user_pool_arn]
}
# CloudWatch Logs
statement {
sid = "CloudWatchLogs"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["arn:aws:logs:${local.region}:${local.account_id}:log-group:/aws/bedrock-agentcore/*"]
}
}
resource "aws_iam_role_policy" "gateway" {
name = "${var.stack_name_base}-gateway-policy"
role = aws_iam_role.gateway.id
policy = data.aws_iam_policy_document.gateway_policy.json
}
# -----------------------------------------------------------------------------
# Wait for IAM permission propagation
# -----------------------------------------------------------------------------
resource "time_sleep" "gateway_iam_propagation" {
create_duration = "10s"
depends_on = [aws_iam_role_policy.gateway]
}
# -----------------------------------------------------------------------------
# AgentCore Gateway
# -----------------------------------------------------------------------------
resource "aws_bedrockagentcore_gateway" "main" {
name = "${var.stack_name_base}-gateway"
role_arn = aws_iam_role.gateway.arn
description = "AgentCore Gateway with MCP protocol and JWT authentication"
protocol_type = "MCP"
protocol_configuration {
mcp {
supported_versions = ["2025-03-26"]
}
}
authorizer_type = "CUSTOM_JWT"
authorizer_configuration {
custom_jwt_authorizer {
discovery_url = local.oidc_discovery_url
allowed_clients = [aws_cognito_user_pool_client.machine.id]
}
}
depends_on = [time_sleep.gateway_iam_propagation]
}
@@ -0,0 +1,73 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Data Sources (shared across all backend resources)
# =============================================================================
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# Local Values
# =============================================================================
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
# Normalized stack name (lowercase, hyphens only)
stack_name_normalized = lower(replace(var.stack_name_base, "_", "-"))
# Stack name for resource naming (underscores for some AWS resources)
stack_name_underscore = replace(var.stack_name_base, "-", "_")
# Agent name used in runtime naming
agent_name = "FASTAgent"
# Runtime name (underscores required by AgentCore)
runtime_name = "${local.stack_name_underscore}_${local.agent_name}"
# Memory name (unique within account/region)
# Must match ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ - no hyphens allowed
memory_name = "${local.stack_name_underscore}_memory"
# OIDC discovery URL for Cognito JWT authorizer
oidc_discovery_url = "https://cognito-idp.${local.region}.amazonaws.com/${var.user_pool_id}/.well-known/openid-configuration"
# Lambda Powertools layer ARN (region-specific, Python 3.13, ARM64)
powertools_layer_arn = "arn:aws:lambda:${local.region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python313-arm64:18"
# Deployment type flags
is_docker = var.backend_deployment_type == "docker"
is_zip = var.backend_deployment_type == "zip"
# Pattern flags
is_claude_agent_sdk = contains(["claude-agent-sdk-single-agent", "claude-agent-sdk-multi-agent"], var.backend_pattern)
# Project paths (for zip packaging)
project_root = "${path.module}/../../.."
pattern_dir = "${local.project_root}/patterns/${var.backend_pattern}"
# Zip deployment configuration
zip_entry_point = ["opentelemetry-instrument", "basic_agent.py"]
zip_packager_lambda_source_path = "${path.module}/../../lambdas/zip-packager"
# Lambda source paths
feedback_lambda_source_path = "${path.module}/../../../infra-cdk/lambdas/feedback"
copilotkit_runtime_source_path = "${path.module}/../../../infra-cdk/lambdas/copilotkit-runtime"
# SSM parameter paths
ssm_parameter_prefix = "/${var.stack_name_base}"
# Log retention in days
log_retention_days = var.log_retention_days
# API Gateway settings
api_throttling_rate_limit = var.throttling_rate_limit
api_throttling_burst_limit = var.throttling_burst_limit
api_cache_ttl_seconds = 300
# Memory event expiry (hardcoded in CDK backend-stack.ts)
memory_event_expiry_days = 30
}
@@ -0,0 +1,50 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# AgentCore Memory
# Maps to: backend-stack.ts createAgentCoreRuntime() - memory section
# =============================================================================
# IAM Role for Memory Execution
# Role assumed by AgentCore Memory service for processing operations
data "aws_iam_policy_document" "memory_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["bedrock-agentcore.amazonaws.com"]
}
}
}
resource "aws_iam_role" "memory_execution" {
name = "${var.stack_name_base}-memory-execution-role"
assume_role_policy = data.aws_iam_policy_document.memory_assume_role.json
description = "Execution role for AgentCore Memory"
}
# Attach the AWS managed policy for Bedrock model inference
# Required for long-term memory strategies that use model processing
resource "aws_iam_role_policy_attachment" "memory_bedrock_policy" {
role = aws_iam_role.memory_execution.name
policy_arn = "arn:aws:iam::aws:policy/AmazonBedrockAgentCoreMemoryBedrockModelInferenceExecutionRolePolicy"
}
# Persistent memory resource for AI agent interactions
# Configured with short-term memory (conversation history) as default
resource "aws_bedrockagentcore_memory" "main" {
name = local.memory_name
event_expiry_duration = local.memory_event_expiry_days
description = "Short-term memory for ${var.stack_name_base} agent"
# Memory execution role for model processing (required for long-term strategies)
memory_execution_role_arn = aws_iam_role.memory_execution.arn
tags = {
Name = "${var.stack_name_base}_Memory"
}
}
@@ -0,0 +1,253 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# OAuth2 Credential Provider
# Maps to: backend-stack.ts createOAuth2CredentialProvider()
# =============================================================================
# Creates Lambda function that manages OAuth2 Credential Provider lifecycle
# for AgentCore Runtime to authenticate with AgentCore Gateway.
# Uses CloudFormation Custom Resource pattern via null_resource invocation.
#
# Background:
# AgentCore doesn't have a native Terraform/CloudFormation resource for OAuth2
# Credential Provider yet. This Lambda calls the bedrock-agentcore-control API
# directly to create/update/delete the provider. The Custom Resource pattern
# is used to avoid logging sensitive credentials in CloudWatch (client secret
# is read from Secrets Manager at runtime).
# -----------------------------------------------------------------------------
# CloudWatch Log Group for OAuth2 Provider Lambda
# -----------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "oauth2_provider" {
name = "/aws/lambda/${var.stack_name_base}-oauth2-provider"
retention_in_days = 7
}
# -----------------------------------------------------------------------------
# IAM Role for OAuth2 Provider Lambda
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "oauth2_provider_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "oauth2_provider" {
name = "${var.stack_name_base}-oauth2-provider-role"
assume_role_policy = data.aws_iam_policy_document.oauth2_provider_assume_role.json
}
# IAM Policy for OAuth2 Provider Lambda
data "aws_iam_policy_document" "oauth2_provider_policy" {
# CloudWatch Logs
statement {
sid = "CloudWatchLogsAccess"
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["${aws_cloudwatch_log_group.oauth2_provider.arn}:*"]
}
# Read Machine Client Secret
# Lambda needs to read the machine client secret to register OAuth2 provider
statement {
sid = "ReadMachineClientSecret"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [aws_secretsmanager_secret.machine_client_secret.arn]
}
# OAuth2 Credential Provider Operations
# Note: Need both vault-level and nested resource permissions because:
# - CreateOauth2CredentialProvider checks permission on vault itself (token-vault/default)
# - Also checks permission on the nested resource path (token-vault/default/oauth2credentialprovider/*)
statement {
sid = "OAuth2CredentialProviderOperations"
effect = "Allow"
actions = [
"bedrock-agentcore:CreateOauth2CredentialProvider",
"bedrock-agentcore:GetOauth2CredentialProvider",
"bedrock-agentcore:UpdateOauth2CredentialProvider",
"bedrock-agentcore:DeleteOauth2CredentialProvider"
]
resources = [
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:token-vault/default",
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:token-vault/default/oauth2credentialprovider/*"
]
}
# Token Vault Operations
# Note: Need both exact match (default) and wildcard (default/*) because:
# - AWS checks permission on the vault container itself (token-vault/default)
# - AWS also checks permission on resources inside (token-vault/default/*)
statement {
sid = "TokenVaultOperations"
effect = "Allow"
actions = [
"bedrock-agentcore:CreateTokenVault",
"bedrock-agentcore:GetTokenVault",
"bedrock-agentcore:DeleteTokenVault"
]
resources = [
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:token-vault/default",
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:token-vault/default/*"
]
}
# Token Vault Secret Management
# Lambda creates secrets in AgentCore Identity namespace for Token Vault
statement {
sid = "TokenVaultSecretManagement"
effect = "Allow"
actions = [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:PutSecretValue"
]
resources = [
"arn:aws:secretsmanager:${local.region}:${local.account_id}:secret:bedrock-agentcore-identity!default/oauth2/*"
]
}
}
resource "aws_iam_role_policy" "oauth2_provider" {
name = "${var.stack_name_base}-oauth2-provider-policy"
role = aws_iam_role.oauth2_provider.id
policy = data.aws_iam_policy_document.oauth2_provider_policy.json
}
# -----------------------------------------------------------------------------
# Lambda Function for OAuth2 Provider Lifecycle
# -----------------------------------------------------------------------------
# Package the Lambda code
data "archive_file" "oauth2_provider" {
type = "zip"
source_file = "${path.module}/../../../infra-cdk/lambdas/oauth2-provider/index.py"
output_path = "${path.module}/artifacts/oauth2-provider.zip"
}
resource "aws_lambda_function" "oauth2_provider" {
filename = data.archive_file.oauth2_provider.output_path
function_name = "${var.stack_name_base}-oauth2-provider"
role = aws_iam_role.oauth2_provider.arn
handler = "index.handler"
source_code_hash = data.archive_file.oauth2_provider.output_base64sha256
runtime = "python3.13"
timeout = 300 # 5 minutes
depends_on = [
aws_cloudwatch_log_group.oauth2_provider,
aws_iam_role_policy.oauth2_provider
]
}
# -----------------------------------------------------------------------------
# Custom Resource Invocation via null_resource
# Simulates CloudFormation Custom Resource by invoking Lambda directly
# -----------------------------------------------------------------------------
resource "null_resource" "invoke_oauth2_provider" {
# Recreate when any of these values change
triggers = {
provider_name = "${var.stack_name_base}-runtime-gateway-auth"
client_id = aws_cognito_user_pool_client.machine.id
client_secret = aws_secretsmanager_secret_version.machine_client_secret.version_id
discovery_url = local.oidc_discovery_url
function_name = aws_lambda_function.oauth2_provider.function_name
region = local.region
}
provisioner "local-exec" {
interpreter = ["bash", "-c"]
command = <<-EOT
set -e
# Build the CloudFormation Custom Resource payload
PAYLOAD=$(cat <<'PAYLOAD_EOF'
{
"RequestType": "Create",
"ResourceProperties": {
"ProviderName": "${self.triggers.provider_name}",
"ClientSecretArn": "${aws_secretsmanager_secret.machine_client_secret.arn}",
"DiscoveryUrl": "${self.triggers.discovery_url}",
"ClientId": "${self.triggers.client_id}"
}
}
PAYLOAD_EOF
)
echo "Invoking OAuth2 provider Lambda: ${self.triggers.function_name}"
# Invoke Lambda (--cli-binary-format raw-in-base64-out ensures JSON payload is accepted)
aws lambda invoke \
--function-name ${self.triggers.function_name} \
--cli-binary-format raw-in-base64-out \
--payload "$PAYLOAD" \
--region ${self.triggers.region} \
/tmp/oauth2_provider_response.json
# Check for errors
if grep -q "FunctionError" /tmp/oauth2_provider_response.json; then
echo "ERROR: OAuth2 provider creation failed"
cat /tmp/oauth2_provider_response.json
exit 1
fi
echo "OAuth2 provider created successfully"
cat /tmp/oauth2_provider_response.json
EOT
}
provisioner "local-exec" {
when = destroy
interpreter = ["bash", "-c"]
command = <<-EOT
# Build the CloudFormation Custom Resource Delete payload
PAYLOAD=$(cat <<'PAYLOAD_EOF'
{
"RequestType": "Delete",
"PhysicalResourceId": "${self.triggers.provider_name}",
"ResourceProperties": {
"ProviderName": "${self.triggers.provider_name}"
}
}
PAYLOAD_EOF
)
echo "Deleting OAuth2 provider: ${self.triggers.provider_name}"
# Invoke Lambda for deletion (ignore errors if already deleted)
aws lambda invoke \
--function-name ${self.triggers.function_name} \
--cli-binary-format raw-in-base64-out \
--payload "$PAYLOAD" \
--region ${self.triggers.region} \
/tmp/oauth2_provider_delete.json || true
echo "OAuth2 provider deletion completed"
cat /tmp/oauth2_provider_delete.json || true
EOT
}
depends_on = [
aws_lambda_function.oauth2_provider,
aws_cognito_user_pool_client.machine,
aws_secretsmanager_secret_version.machine_client_secret
]
}
@@ -0,0 +1,73 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Memory Outputs
# =============================================================================
output "memory_arn" {
description = "AgentCore Memory ARN"
value = aws_bedrockagentcore_memory.main.arn
}
# =============================================================================
# Gateway Outputs
# =============================================================================
output "gateway_id" {
description = "AgentCore Gateway ID"
value = aws_bedrockagentcore_gateway.main.gateway_id
}
output "gateway_arn" {
description = "AgentCore Gateway ARN"
value = aws_bedrockagentcore_gateway.main.gateway_arn
}
output "gateway_url" {
description = "AgentCore Gateway URL"
value = aws_bedrockagentcore_gateway.main.gateway_url
}
output "gateway_target_id" {
description = "AgentCore Gateway Target ID"
value = aws_bedrockagentcore_gateway_target.sample_tool.target_id
}
output "tool_lambda_arn" {
description = "Sample tool Lambda function ARN"
value = aws_lambda_function.sample_tool.arn
}
# =============================================================================
# Runtime Outputs
# =============================================================================
output "runtime_id" {
description = "AgentCore Runtime ID"
value = aws_bedrockagentcore_agent_runtime.main.agent_runtime_id
}
output "runtime_arn" {
description = "AgentCore Runtime ARN"
value = aws_bedrockagentcore_agent_runtime.main.agent_runtime_arn
}
output "runtime_role_arn" {
description = "AgentCore Runtime execution role ARN"
value = aws_iam_role.runtime.arn
}
output "copilotkit_runtime_url" {
description = "CopilotKit runtime endpoint URL"
value = "${aws_apigatewayv2_stage.copilotkit_runtime.invoke_url}/copilotkit"
}
# =============================================================================
# Machine Client Outputs
# =============================================================================
output "machine_client_id" {
description = "Cognito Machine Client ID (for M2M authentication)"
value = aws_cognito_user_pool_client.machine.id
}
@@ -0,0 +1,512 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# AgentCore Runtime
# Maps to: backend-stack.ts createAgentCoreRuntime() - runtime section
# =============================================================================
# -----------------------------------------------------------------------------
# ECR Repository (for container image)
# Only created if container_uri is not provided
# -----------------------------------------------------------------------------
resource "aws_ecr_repository" "agent" {
count = local.is_docker && var.container_uri == null ? 1 : 0
name = "${var.stack_name_base}-agent-runtime"
image_tag_mutability = "MUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
}
# ECR Lifecycle policy to keep only recent images
resource "aws_ecr_lifecycle_policy" "agent" {
count = local.is_docker && var.container_uri == null ? 1 : 0
repository = aws_ecr_repository.agent[0].name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep only 5 most recent images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 5
}
action = {
type = "expire"
}
}
]
})
}
# -----------------------------------------------------------------------------
# Docker Build & Push (docker mode only)
# Automatically builds and pushes the agent container image during apply
# -----------------------------------------------------------------------------
# Content hash for Docker image change detection — triggers rebuild and runtime replacement.
# Always created (no count) so the runtime's replace_triggered_by can reference it in both modes.
# In zip mode the value is static ("zip"), so it never triggers a replacement.
resource "terraform_data" "docker_image_hash" {
input = local.is_docker && var.container_uri == null ? sha256(join("", concat(
[filesha256("${local.pattern_dir}/Dockerfile")],
[filesha256("${local.pattern_dir}/requirements.txt")],
[for f in fileset(local.pattern_dir, "**/*.py") : filesha256("${local.pattern_dir}/${f}")],
[for f in fileset("${local.project_root}/patterns/utils", "**/*.py") : filesha256("${local.project_root}/patterns/utils/${f}")],
[for f in fileset("${local.project_root}/gateway", "**/*.py") : filesha256("${local.project_root}/gateway/${f}")],
[for f in fileset("${local.project_root}/tools", "**/*.py") : filesha256("${local.project_root}/tools/${f}")],
[filesha256("${local.project_root}/pyproject.toml")],
))) : "zip"
}
resource "null_resource" "docker_build_push" {
count = local.is_docker && var.container_uri == null ? 1 : 0
triggers = {
content_hash = terraform_data.docker_image_hash.output
repository_url = aws_ecr_repository.agent[0].repository_url
}
provisioner "local-exec" {
interpreter = ["bash", "-c"]
command = <<-EOT
set -e
ECR_REPO="${aws_ecr_repository.agent[0].repository_url}"
REGION="${local.region}"
ACCOUNT_ID="${local.account_id}"
PROJECT_ROOT="${local.project_root}"
DOCKERFILE="patterns/${var.backend_pattern}/Dockerfile"
# Verify Docker is running
if ! docker info >/dev/null 2>&1; then
echo "ERROR: Docker is not running. Please start Docker Desktop." >&2
exit 1
fi
# ECR login
echo "Logging into ECR..."
aws ecr get-login-password --region "$REGION" | \
docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
# Build ARM64 image
echo "Building Docker image (ARM64)..."
cd "$PROJECT_ROOT"
docker build \
--platform linux/arm64 \
-f "$DOCKERFILE" \
-t "$ECR_REPO:latest" \
.
# Push to ECR
echo "Pushing image to ECR..."
docker push "$ECR_REPO:latest"
echo "SUCCESS: Image pushed to $ECR_REPO:latest"
EOT
}
depends_on = [
aws_ecr_repository.agent[0],
aws_ecr_lifecycle_policy.agent[0]
]
}
# -----------------------------------------------------------------------------
# IAM Role for AgentCore Runtime
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "runtime_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["bedrock-agentcore.amazonaws.com"]
}
}
}
resource "aws_iam_role" "runtime" {
name = "${var.stack_name_base}-agentcore-runtime-role"
assume_role_policy = data.aws_iam_policy_document.runtime_assume_role.json
description = "Execution role for AgentCore Runtime"
}
# -----------------------------------------------------------------------------
# IAM Policy Document
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "runtime_policy" {
# ECRImageAccess (docker mode only)
dynamic "statement" {
for_each = local.is_docker ? [1] : []
content {
sid = "ECRImageAccess"
effect = "Allow"
actions = [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability"
]
resources = ["arn:aws:ecr:${local.region}:${local.account_id}:repository/*"]
}
}
# ECRTokenAccess (docker mode only)
dynamic "statement" {
for_each = local.is_docker ? [1] : []
content {
sid = "ECRTokenAccess"
effect = "Allow"
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}
}
# S3 Agent Code Access (zip mode only)
dynamic "statement" {
for_each = local.is_zip ? [1] : []
content {
sid = "S3AgentCodeAccess"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:GetBucketLocation"
]
resources = [
aws_s3_bucket.agent_code[0].arn,
"${aws_s3_bucket.agent_code[0].arn}/*"
]
}
}
# CloudWatchLogsGroupAccess
statement {
sid = "CloudWatchLogsGroupAccess"
effect = "Allow"
actions = [
"logs:DescribeLogStreams",
"logs:CreateLogGroup"
]
resources = ["arn:aws:logs:${local.region}:${local.account_id}:log-group:/aws/bedrock-agentcore/runtimes/*"]
}
# CloudWatchLogsDescribeGroups
statement {
sid = "CloudWatchLogsDescribeGroups"
effect = "Allow"
actions = ["logs:DescribeLogGroups"]
resources = ["arn:aws:logs:${local.region}:${local.account_id}:log-group:*"]
}
# CloudWatchLogsStreamAccess
statement {
sid = "CloudWatchLogsStreamAccess"
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["arn:aws:logs:${local.region}:${local.account_id}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*"]
}
# X-Ray Tracing
statement {
sid = "XRayTracing"
effect = "Allow"
actions = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets"
]
resources = ["*"]
}
# CloudWatch Metrics
statement {
sid = "CloudWatchMetrics"
effect = "Allow"
actions = ["cloudwatch:PutMetricData"]
resources = ["*"]
condition {
test = "StringEquals"
variable = "cloudwatch:namespace"
values = ["bedrock-agentcore"]
}
}
# GetAgentAccessToken
statement {
sid = "GetAgentAccessToken"
effect = "Allow"
actions = [
"bedrock-agentcore:GetWorkloadAccessToken",
"bedrock-agentcore:GetWorkloadAccessTokenForJWT",
"bedrock-agentcore:GetWorkloadAccessTokenForUserId"
]
resources = [
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:workload-identity-directory/default",
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:workload-identity-directory/default/workload-identity/*"
]
}
# BedrockModelInvocation
statement {
sid = "BedrockModelInvocation"
effect = "Allow"
actions = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
]
resources = [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:${local.region}:${local.account_id}:*"
]
}
# SecretsManagerOAuth2Access
# Runtime needs to read OAuth2 credentials from Token Vault secret
# created by AgentCore Identity (not the machine client secret directly)
statement {
sid = "SecretsManagerOAuth2Access"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [
"arn:aws:secretsmanager:${local.region}:${local.account_id}:secret:bedrock-agentcore-identity!default/oauth2/${var.stack_name_base}-runtime-gateway-auth*"
]
}
# MemoryResourceAccess - references memory resource directly (no variable passing)
statement {
sid = "MemoryResourceAccess"
effect = "Allow"
actions = [
"bedrock-agentcore:CreateEvent",
"bedrock-agentcore:GetEvent",
"bedrock-agentcore:ListEvents",
"bedrock-agentcore:RetrieveMemoryRecords"
]
resources = [aws_bedrockagentcore_memory.main.arn]
}
# SSMParameterAccess
statement {
sid = "SSMParameterAccess"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters"
]
resources = ["arn:aws:ssm:${local.region}:${local.account_id}:parameter/${var.stack_name_base}/*"]
}
# CodeInterpreterAccess
statement {
sid = "CodeInterpreterAccess"
effect = "Allow"
actions = [
"bedrock-agentcore:StartCodeInterpreterSession",
"bedrock-agentcore:StopCodeInterpreterSession",
"bedrock-agentcore:InvokeCodeInterpreter"
]
resources = ["arn:aws:bedrock-agentcore:${local.region}:aws:code-interpreter/*"]
}
# OAuth2CredentialProviderAccess
# The @requires_access_token decorator performs a two-stage process:
# GetOauth2CredentialProvider - Looks up provider metadata
# GetResourceOauth2Token - Fetches the actual access token from Token Vault
statement {
sid = "OAuth2CredentialProviderAccess"
effect = "Allow"
actions = [
"bedrock-agentcore:GetOauth2CredentialProvider",
"bedrock-agentcore:GetResourceOauth2Token"
]
resources = [
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:oauth2-credential-provider/*",
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:token-vault/*",
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:workload-identity-directory/*"
]
}
}
resource "aws_iam_role_policy" "runtime" {
name = "${var.stack_name_base}-agentcore-runtime-policy"
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_policy.json
}
# -----------------------------------------------------------------------------
# Default Security Group (for VPC mode, when none provided)
# -----------------------------------------------------------------------------
locals {
# Use user-provided security groups, or fall back to the auto-created default
effective_security_group_ids = (
var.backend_network_mode == "VPC" && length(var.backend_vpc_security_group_ids) == 0
? [aws_security_group.runtime_default[0].id]
: var.backend_vpc_security_group_ids
)
}
resource "aws_security_group" "runtime_default" {
count = var.backend_network_mode == "VPC" && length(var.backend_vpc_security_group_ids) == 0 ? 1 : 0
name = "${var.stack_name_base}-agentcore-runtime-sg"
description = "Default security group for AgentCore Runtime VPC deployment"
vpc_id = var.backend_vpc_id
tags = {
Name = "${var.stack_name_base}-agentcore-runtime-sg"
}
}
# Self-referencing ingress rule: allows HTTPS traffic between runtime and VPC endpoints
resource "aws_vpc_security_group_ingress_rule" "runtime_default_https" {
count = var.backend_network_mode == "VPC" && length(var.backend_vpc_security_group_ids) == 0 ? 1 : 0
security_group_id = aws_security_group.runtime_default[0].id
referenced_security_group_id = aws_security_group.runtime_default[0].id
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "Allow HTTPS from self (VPC endpoint access)"
}
# Egress rule: allow all outbound traffic (matches CDK allowAllOutbound: true)
resource "aws_vpc_security_group_egress_rule" "runtime_default_all" {
count = var.backend_network_mode == "VPC" && length(var.backend_vpc_security_group_ids) == 0 ? 1 : 0
security_group_id = aws_security_group.runtime_default[0].id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
description = "Allow all outbound traffic"
}
# -----------------------------------------------------------------------------
# AgentCore Runtime
# -----------------------------------------------------------------------------
resource "aws_bedrockagentcore_agent_runtime" "main" {
agent_runtime_name = local.runtime_name
role_arn = aws_iam_role.runtime.arn
description = "${var.backend_pattern} agent runtime for ${var.stack_name_base}"
# Artifact configuration (docker or zip)
agent_runtime_artifact {
# Docker mode: container image from ECR or custom URI
dynamic "container_configuration" {
for_each = local.is_docker ? [1] : []
content {
container_uri = var.container_uri != null ? var.container_uri : "${aws_ecr_repository.agent[0].repository_url}:latest"
}
}
# Zip mode: S3 Python package
dynamic "code_configuration" {
for_each = local.is_zip ? [1] : []
content {
runtime = "PYTHON_3_12"
entry_point = local.zip_entry_point
code {
s3 {
bucket = aws_s3_bucket.agent_code[0].id
prefix = "deployment_package.zip"
}
}
}
}
}
# Network configuration
# PUBLIC: Runtime is accessible over the public internet (default).
# VPC: Runtime is deployed into a user-provided VPC for private network isolation.
# The user must ensure their VPC has the necessary VPC endpoints for AWS services.
# See docs/DEPLOYMENT.md for the full list of required VPC endpoints.
network_configuration {
network_mode = var.backend_network_mode
dynamic "network_mode_config" {
for_each = var.backend_network_mode == "VPC" ? [1] : []
content {
subnets = var.backend_vpc_subnet_ids
security_groups = local.effective_security_group_ids
}
}
}
# JWT authorizer configuration (Cognito)
authorizer_configuration {
custom_jwt_authorizer {
discovery_url = local.oidc_discovery_url
allowed_clients = [var.web_client_id]
}
}
# Protocol configuration (HTTP for agent communication)
protocol_configuration {
server_protocol = "HTTP"
}
# Request header configuration (allowlist Authorization for JWT sub claim)
request_header_configuration {
request_header_allowlist = ["Authorization"]
}
# Environment variables for the runtime
environment_variables = merge(
{
AWS_REGION = local.region
AWS_DEFAULT_REGION = local.region
MEMORY_ID = aws_bedrockagentcore_memory.main.id
STACK_NAME = var.stack_name_base
GATEWAY_CREDENTIAL_PROVIDER_NAME = "${var.stack_name_base}-runtime-gateway-auth"
},
# claude-agent-sdk patterns require CLAUDE_CODE_USE_BEDROCK=1
local.is_claude_agent_sdk ? { CLAUDE_CODE_USE_BEDROCK = "1" } : {}
)
# Force runtime replacement when agent code changes (zip or docker)
lifecycle {
precondition {
condition = !local.is_claude_agent_sdk || local.is_docker
error_message = "claude-agent-sdk patterns require Docker deployment (backend_deployment_type = \"docker\") because they need Node.js and the claude-code CLI installed at build time."
}
precondition {
condition = var.backend_network_mode != "VPC" || (var.backend_vpc_id != null && var.backend_vpc_id != "")
error_message = "backend_vpc_id is required when backend_network_mode is 'VPC'."
}
precondition {
condition = var.backend_network_mode != "VPC" || length(var.backend_vpc_subnet_ids) > 0
error_message = "backend_vpc_subnet_ids must contain at least one subnet ID when backend_network_mode is 'VPC'."
}
replace_triggered_by = [
terraform_data.agent_code_hash,
terraform_data.docker_image_hash,
]
}
depends_on = [
aws_iam_role_policy.runtime,
null_resource.invoke_zip_packager,
null_resource.docker_build_push,
null_resource.invoke_oauth2_provider # Ensure provider is registered before Runtime starts
]
}
@@ -0,0 +1,103 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# SSM Parameters & Secrets Manager
# Maps to: backend-stack.ts createRuntimeSSMParameters() + createCognitoSSMParameters()
# =============================================================================
# -----------------------------------------------------------------------------
# SSM Parameters
# Store configuration values for cross-stack references and frontend access
# -----------------------------------------------------------------------------
resource "aws_ssm_parameter" "runtime_arn" {
name = "${local.ssm_parameter_prefix}/runtime-arn"
description = "AgentCore Runtime ARN"
type = "String"
value = aws_bedrockagentcore_agent_runtime.main.agent_runtime_arn
}
resource "aws_ssm_parameter" "cognito_user_pool_id" {
name = "${local.ssm_parameter_prefix}/cognito-user-pool-id"
description = "Cognito User Pool ID"
type = "String"
value = var.user_pool_id
}
resource "aws_ssm_parameter" "cognito_user_pool_client_id" {
name = "${local.ssm_parameter_prefix}/cognito-user-pool-client-id"
description = "Cognito User Pool Client ID"
type = "String"
value = var.web_client_id
}
resource "aws_ssm_parameter" "machine_client_id" {
name = "${local.ssm_parameter_prefix}/machine_client_id"
description = "Machine Client ID for M2M authentication"
type = "String"
value = aws_cognito_user_pool_client.machine.id
}
resource "aws_ssm_parameter" "cognito_provider" {
name = "${local.ssm_parameter_prefix}/cognito_provider"
description = "Cognito domain URL for token endpoint"
type = "String"
value = var.cognito_domain_url
}
resource "aws_ssm_parameter" "feedback_api_url" {
name = "${local.ssm_parameter_prefix}/feedback-api-url"
description = "Feedback API Gateway URL"
type = "String"
value = "${aws_api_gateway_stage.prod.invoke_url}/feedback"
}
resource "aws_ssm_parameter" "copilotkit_runtime_url" {
name = "${local.ssm_parameter_prefix}/copilotkit-runtime-url"
description = "CopilotKit runtime API URL"
type = "String"
value = "${aws_apigatewayv2_stage.copilotkit_runtime.invoke_url}/copilotkit"
}
resource "aws_ssm_parameter" "gateway_url" {
name = "${local.ssm_parameter_prefix}/gateway_url"
description = "AgentCore Gateway URL"
type = "String"
value = aws_bedrockagentcore_gateway.main.gateway_url
}
# Agent Code Bucket (zip mode only) - matches CDK's AgentCodeBucketNameParam
resource "aws_ssm_parameter" "agent_code_bucket" {
count = local.is_zip ? 1 : 0
name = "${local.ssm_parameter_prefix}/agent-code-bucket"
description = "S3 bucket for agent code deployment packages"
type = "String"
value = aws_s3_bucket.agent_code[0].id
}
# -----------------------------------------------------------------------------
# Secrets Manager - Machine Client Secret
# Store the machine client secret securely
# -----------------------------------------------------------------------------
resource "aws_secretsmanager_secret" "machine_client_secret" {
name = "${local.ssm_parameter_prefix}/machine_client_secret"
description = "Machine Client Secret for M2M authentication"
}
resource "aws_secretsmanager_secret_version" "machine_client_secret" {
secret_id = aws_secretsmanager_secret.machine_client_secret.id
secret_string = aws_cognito_user_pool_client.machine.client_secret
}
@@ -0,0 +1,114 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Core Configuration
# =============================================================================
variable "stack_name_base" {
description = "Base name for all resources."
type = string
}
variable "backend_pattern" {
description = "Agent pattern to deploy."
type = string
default = "strands-single-agent"
}
variable "backend_deployment_type" {
description = "Deployment type: 'docker' (container via ECR) or 'zip' (Python package via S3). Note: claude-agent-sdk patterns require 'docker'."
type = string
default = "docker"
}
variable "backend_network_mode" {
description = "Network mode for AgentCore Runtime (PUBLIC or VPC)."
type = string
default = "PUBLIC"
}
# =============================================================================
# VPC Configuration (Required if backend_network_mode = VPC)
# =============================================================================
variable "backend_vpc_id" {
description = "VPC ID for VPC network mode. Required when backend_network_mode is 'VPC'."
type = string
default = null
}
variable "backend_vpc_subnet_ids" {
description = "List of subnet IDs for VPC network mode. Required when backend_network_mode is 'VPC'."
type = list(string)
default = []
}
variable "backend_vpc_security_group_ids" {
description = "List of security group IDs for VPC network mode. Optional when backend_network_mode is 'VPC'. If omitted, a default security group is created."
type = list(string)
default = []
}
# =============================================================================
# Cognito Configuration (passed from cognito module)
# =============================================================================
variable "user_pool_id" {
description = "Cognito User Pool ID."
type = string
}
variable "user_pool_arn" {
description = "Cognito User Pool ARN."
type = string
}
variable "web_client_id" {
description = "Cognito Web Client ID (for frontend OAuth)."
type = string
}
# =============================================================================
# Amplify Configuration (passed from amplify module)
# =============================================================================
variable "frontend_url" {
description = "Frontend URL for CORS and callback configuration."
type = string
}
variable "cognito_domain_url" {
description = "Cognito domain URL for OAuth token endpoint."
type = string
}
# =============================================================================
# Optional Configuration
# =============================================================================
variable "container_uri" {
description = "Container image URI. If not provided, ECR repository will be created."
type = string
default = null
}
variable "log_retention_days" {
description = "CloudWatch log retention in days."
type = number
default = 7
}
variable "throttling_rate_limit" {
description = "API Gateway throttling rate limit."
type = number
default = 100
}
variable "throttling_burst_limit" {
description = "API Gateway throttling burst limit."
type = number
default = 200
}
@@ -0,0 +1,25 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.82.0"
}
archive = {
source = "hashicorp/archive"
version = ">= 2.4.0"
}
null = {
source = "hashicorp/null"
version = ">= 3.2.0"
}
time = {
source = "hashicorp/time"
version = ">= 0.9.0"
}
}
}
@@ -0,0 +1,199 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Data Sources
# =============================================================================
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# Local Values
# =============================================================================
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.id
# Cognito domain prefix (must be globally unique and lowercase)
domain_prefix = "${lower(replace(var.stack_name_base, "_", "-"))}-${local.account_id}-${local.region}"
# Callback URLs (hardcoded to match CDK cognito-stack.ts defaults)
default_callback_urls = ["http://localhost:3000", "https://localhost:3000"]
# Combine callback URLs with Amplify URL if provided
all_callback_urls = var.amplify_url != null ? concat(local.default_callback_urls, [var.amplify_url]) : local.default_callback_urls
# Password minimum length (hardcoded to match CDK cognito-stack.ts)
password_minimum_length = 8
# User invitation email template
invitation_email_subject = "Welcome to ${var.stack_name_base}!"
invitation_email_body = <<-EOF
<p>Hello {username},</p>
<p>Welcome to ${var.stack_name_base}! Your username is <strong>{username}</strong> and your temporary password is: <strong>{####}</strong></p>
<p>Please use this temporary password to log in and set your permanent password.</p>
<p>The CloudFront URL to your application is stored as an output in the "${var.stack_name_base}" stack, and will be printed to your terminal once the deployment process completes.</p>
<p>Thanks,</p>
<p>Fullstack AgentCore Solution Template Team</p>
EOF
}
# =============================================================================
# Cognito User Pool
# =============================================================================
# Main user pool for authentication with password policy and invitation templates
resource "aws_cognito_user_pool" "main" {
name = "${var.stack_name_base}-user-pool"
# Admin-only user creation (self sign-up disabled)
admin_create_user_config {
allow_admin_create_user_only = true
# User invitation email template
invite_message_template {
email_subject = local.invitation_email_subject
email_message = local.invitation_email_body
sms_message = "Your username is {username} and temporary password is {####}"
}
}
# Sign-in with email
username_attributes = ["email"]
# Auto-verify email
auto_verified_attributes = ["email"]
# Account recovery via email only
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
# Password policy
password_policy {
minimum_length = local.password_minimum_length
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = true
temporary_password_validity_days = 7
}
# Email attribute (required and immutable)
schema {
name = "email"
attribute_data_type = "String"
required = true
mutable = false
developer_only_attribute = false
string_attribute_constraints {
min_length = 0
max_length = 2048
}
}
# Email configuration
email_configuration {
email_sending_account = "COGNITO_DEFAULT"
}
# Allow deletion (no protection)
deletion_protection = "INACTIVE"
}
# =============================================================================
# Cognito User Pool Domain
# =============================================================================
# Domain for hosted UI with managed login V2
resource "aws_cognito_user_pool_domain" "main" {
domain = local.domain_prefix
user_pool_id = aws_cognito_user_pool.main.id
}
# =============================================================================
# Cognito Managed Login Branding (V2)
# =============================================================================
# Required for the v2 managed login to display properly
resource "aws_cognito_managed_login_branding" "main" {
user_pool_id = aws_cognito_user_pool.main.id
client_id = aws_cognito_user_pool_client.web.id
# Use Cognito's default styles
use_cognito_provided_values = true
depends_on = [aws_cognito_user_pool_domain.main]
}
# =============================================================================
# Cognito User Pool Client - Web (Frontend)
# =============================================================================
# OAuth client for frontend application
resource "aws_cognito_user_pool_client" "web" {
name = "${var.stack_name_base}-client"
user_pool_id = aws_cognito_user_pool.main.id
# No secret for public client
generate_secret = false
# Auth flows
explicit_auth_flows = [
"ALLOW_USER_PASSWORD_AUTH",
"ALLOW_USER_SRP_AUTH",
"ALLOW_REFRESH_TOKEN_AUTH"
]
# OAuth configuration
allowed_oauth_flows = ["code"]
allowed_oauth_flows_user_pool_client = true
allowed_oauth_scopes = ["openid", "email", "profile"]
# Callback and logout URLs
callback_urls = local.all_callback_urls
logout_urls = local.all_callback_urls
# Supported identity providers
supported_identity_providers = ["COGNITO"]
# Prevent user existence errors
prevent_user_existence_errors = "ENABLED"
# Token validity
access_token_validity = 1
id_token_validity = 1
refresh_token_validity = 30
token_validity_units {
access_token = "hours"
id_token = "hours"
refresh_token = "days"
}
}
# =============================================================================
# Cognito Admin User (Conditional)
# =============================================================================
# Creates admin user if email is provided
resource "aws_cognito_user" "admin" {
count = var.admin_user_email != null ? 1 : 0
user_pool_id = aws_cognito_user_pool.main.id
username = var.admin_user_email
attributes = {
email = var.admin_user_email
email_verified = true
}
# Send invitation email with temporary password
desired_delivery_mediums = ["EMAIL"]
}
@@ -0,0 +1,67 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# User Pool Outputs
# =============================================================================
output "user_pool_id" {
description = "Cognito User Pool ID"
value = aws_cognito_user_pool.main.id
}
output "user_pool_arn" {
description = "Cognito User Pool ARN"
value = aws_cognito_user_pool.main.arn
}
output "user_pool_endpoint" {
description = "Cognito User Pool endpoint"
value = aws_cognito_user_pool.main.endpoint
}
# =============================================================================
# Web Client Outputs
# =============================================================================
output "web_client_id" {
description = "Cognito Web Client ID (for frontend)"
value = aws_cognito_user_pool_client.web.id
}
# =============================================================================
# Domain Outputs
# =============================================================================
output "domain_name" {
description = "Cognito domain name"
value = aws_cognito_user_pool_domain.main.domain
}
output "cognito_domain_url" {
description = "Full Cognito domain URL for OAuth token endpoint"
value = "${aws_cognito_user_pool_domain.main.domain}.auth.${local.region}.amazoncognito.com"
}
# =============================================================================
# OIDC Configuration Outputs
# =============================================================================
output "oidc_issuer_url" {
description = "OIDC issuer URL for JWT validation"
value = "https://cognito-idp.${local.region}.amazonaws.com/${aws_cognito_user_pool.main.id}"
}
output "oidc_discovery_url" {
description = "OIDC discovery URL (well-known configuration)"
value = "https://cognito-idp.${local.region}.amazonaws.com/${aws_cognito_user_pool.main.id}/.well-known/openid-configuration"
}
# =============================================================================
# Admin User Outputs
# =============================================================================
output "admin_user_created" {
description = "Whether admin user was created"
value = var.admin_user_email != null
}
@@ -0,0 +1,19 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
variable "stack_name_base" {
description = "Base name for all resources."
type = string
}
variable "admin_user_email" {
description = "Email address for the admin user. If provided, creates an admin user."
type = string
default = null
}
variable "amplify_url" {
description = "Amplify app URL to add to callback URLs."
type = string
default = null
}
@@ -0,0 +1,13 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.82.0"
}
}
}
@@ -0,0 +1,133 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Cognito Outputs
# =============================================================================
output "cognito_user_pool_id" {
description = "Cognito User Pool ID"
value = module.cognito.user_pool_id
}
output "cognito_web_client_id" {
description = "Cognito Web Client ID (for frontend)"
value = module.cognito.web_client_id
}
output "cognito_machine_client_id" {
description = "Cognito Machine Client ID (for M2M authentication)"
value = module.backend.machine_client_id
}
output "cognito_domain_url" {
description = "Cognito domain URL for OAuth"
value = module.cognito.cognito_domain_url
}
# =============================================================================
# Amplify Outputs
# =============================================================================
output "amplify_app_id" {
description = "Amplify App ID"
value = module.amplify_hosting.app_id
}
output "amplify_app_url" {
description = "Amplify App URL (frontend)"
value = module.amplify_hosting.app_url
}
output "amplify_staging_bucket" {
description = "S3 bucket for frontend staging deployments"
value = module.amplify_hosting.staging_bucket_name
}
# =============================================================================
# AgentCore Memory Outputs
# =============================================================================
output "memory_arn" {
description = "AgentCore Memory ARN"
value = module.backend.memory_arn
}
# =============================================================================
# AgentCore Gateway Outputs
# =============================================================================
output "gateway_id" {
description = "AgentCore Gateway ID"
value = module.backend.gateway_id
}
output "gateway_arn" {
description = "AgentCore Gateway ARN"
value = module.backend.gateway_arn
}
output "gateway_url" {
description = "AgentCore Gateway URL"
value = module.backend.gateway_url
}
output "gateway_target_id" {
description = "AgentCore Gateway Target ID"
value = module.backend.gateway_target_id
}
output "tool_lambda_arn" {
description = "Sample tool Lambda function ARN"
value = module.backend.tool_lambda_arn
}
# =============================================================================
# AgentCore Runtime Outputs
# =============================================================================
output "runtime_id" {
description = "AgentCore Runtime ID"
value = module.backend.runtime_id
}
output "runtime_arn" {
description = "AgentCore Runtime ARN"
value = module.backend.runtime_arn
}
output "runtime_role_arn" {
description = "AgentCore Runtime execution role ARN"
value = module.backend.runtime_role_arn
}
output "copilotkit_runtime_url" {
description = "CopilotKit runtime API URL"
value = module.backend.copilotkit_runtime_url
}
# =============================================================================
# SSM Parameter Paths (for reference)
# =============================================================================
output "ssm_parameter_prefix" {
description = "SSM parameter prefix for this deployment"
value = local.ssm_parameter_prefix
}
# =============================================================================
# Summary Output
# =============================================================================
output "deployment_summary" {
description = "Summary of deployed resources"
value = {
stack_name = var.stack_name_base
region = local.region
account_id = local.account_id
deployment_type = var.backend_deployment_type
frontend_url = module.amplify_hosting.app_url
gateway_url = module.backend.gateway_url
copilotkit_url = module.backend.copilotkit_runtime_url
}
}
@@ -0,0 +1,214 @@
#!/bin/bash
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Build and Push Docker Image to ECR for AgentCore Runtime
# =============================================================================
#
# NOTE: This script is OPTIONAL. Running `terraform apply` with docker mode
# automatically builds and pushes the image. Use this script only if you
# prefer to build separately (e.g., in CI/CD pipelines) or need to rebuild
# the image without a full terraform apply.
#
# Usage:
# ./scripts/build-and-push-image.sh [options]
#
# Options:
# -p, --pattern Agent pattern to build (default: strands-single-agent)
# -r, --region AWS region (default: from terraform.tfvars or us-east-1)
# -s, --stack Stack name (default: from terraform.tfvars)
# -h, --help Show this help message
#
# =============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
PATTERN="strands-single-agent"
REGION=""
STACK_NAME=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TERRAFORM_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_ROOT="$(dirname "$TERRAFORM_DIR")"
# Print usage
usage() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -p, --pattern Agent pattern to build (default: strands-single-agent)"
echo " -r, --region AWS region (default: from terraform.tfvars or us-east-1)"
echo " -s, --stack Stack name (default: from terraform.tfvars)"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Use defaults from terraform.tfvars"
echo " $0 -p langgraph-single-agent # Build LangGraph agent"
echo " $0 -s my-stack -r us-west-2 # Custom stack and region"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--pattern)
PATTERN="$2"
shift 2
;;
-r|--region)
REGION="$2"
shift 2
;;
-s|--stack)
STACK_NAME="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo -e "${RED}Error: Unknown option $1${NC}"
usage
exit 1
;;
esac
done
# Read values from terraform.tfvars if not provided
if [[ -z "$STACK_NAME" || -z "$REGION" ]]; then
TFVARS_FILE="$TERRAFORM_DIR/terraform.tfvars"
if [[ -f "$TFVARS_FILE" ]]; then
echo -e "${BLUE}Reading configuration from terraform.tfvars...${NC}"
if [[ -z "$STACK_NAME" ]]; then
STACK_NAME=$(grep -E '^stack_name_base\s*=' "$TFVARS_FILE" | awk -F'"' '{print $2}')
fi
# Region is resolved from AWS_REGION env var or AWS CLI profile (not in tfvars)
fi
fi
# Check deployment type - this script is for docker mode only
TFVARS_FILE="$TERRAFORM_DIR/terraform.tfvars"
if [[ -f "$TFVARS_FILE" ]]; then
DEPLOYMENT_TYPE=$(grep -E '^backend_deployment_type\s*=' "$TFVARS_FILE" | awk -F'"' '{print $2}')
if [[ "$DEPLOYMENT_TYPE" == "zip" ]]; then
echo -e "${YELLOW}===========================================${NC}"
echo -e "${YELLOW} backend_deployment_type is set to 'zip' ${NC}"
echo -e "${YELLOW}===========================================${NC}"
echo ""
echo -e "This script is only needed for ${GREEN}docker${NC} deployment mode."
echo -e "With ${GREEN}zip${NC} mode, agent code is packaged automatically during ${BLUE}terraform apply${NC}."
echo ""
exit 0
fi
fi
# Resolve region: CLI flag > AWS_REGION env > AWS_DEFAULT_REGION env > AWS CLI config
if [[ -z "$REGION" ]]; then
REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region 2>/dev/null || echo "")}}"
fi
# Validate required values
if [[ -z "$STACK_NAME" ]]; then
echo -e "${RED}Error: Stack name not found. Please specify with -s or set in terraform.tfvars${NC}"
exit 1
fi
if [[ -z "$REGION" ]]; then
echo -e "${RED}Error: AWS region not found. Set AWS_REGION environment variable or configure via 'aws configure'.${NC}"
exit 1
fi
# Get AWS account ID
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null)
if [[ -z "$AWS_ACCOUNT_ID" ]]; then
echo -e "${RED}Error: Could not get AWS account ID. Check your AWS credentials.${NC}"
exit 1
fi
# Construct ECR repository URL
ECR_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${STACK_NAME}-agent-runtime"
DOCKERFILE="patterns/${PATTERN}/Dockerfile"
# Print configuration
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Docker Image Build & Push for ECR ${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e " Stack Name: ${GREEN}${STACK_NAME}${NC}"
echo -e " AWS Account: ${GREEN}${AWS_ACCOUNT_ID}${NC}"
echo -e " Region: ${GREEN}${REGION}${NC}"
echo -e " Pattern: ${GREEN}${PATTERN}${NC}"
echo -e " ECR Repo: ${GREEN}${ECR_REPO}${NC}"
echo ""
# Verify Dockerfile exists
if [[ ! -f "$PROJECT_ROOT/$DOCKERFILE" ]]; then
echo -e "${RED}Error: Dockerfile not found at $PROJECT_ROOT/$DOCKERFILE${NC}"
echo -e "${YELLOW}Available patterns:${NC}"
ls -1 "$PROJECT_ROOT/patterns/" 2>/dev/null || echo " No patterns found"
exit 1
fi
# Check if Docker is running
if ! docker info >/dev/null 2>&1; then
echo -e "${RED}Error: Docker is not running. Please start Docker Desktop.${NC}"
exit 1
fi
# Step 1: Login to ECR
echo -e "${BLUE}Step 1/3: Logging into ECR...${NC}"
aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
if [[ $? -ne 0 ]]; then
echo -e "${RED}Error: ECR login failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ ECR login successful${NC}"
echo ""
# Step 2: Build Docker image with ARM64 architecture (required by AgentCore Runtime)
echo -e "${BLUE}Step 2/3: Building Docker image (ARM64 architecture)...${NC}"
cd "$PROJECT_ROOT"
docker build \
--platform linux/arm64 \
-f "$DOCKERFILE" \
-t "${ECR_REPO}:latest" \
.
if [[ $? -ne 0 ]]; then
echo -e "${RED}Error: Docker build failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Docker build successful${NC}"
echo ""
# Step 3: Push to ECR
echo -e "${BLUE}Step 3/3: Pushing image to ECR...${NC}"
docker push "${ECR_REPO}:latest"
if [[ $? -ne 0 ]]; then
echo -e "${RED}Error: Docker push failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Docker push successful${NC}"
echo ""
# Success message
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Image successfully pushed to ECR! ${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Image URI: ${BLUE}${ECR_REPO}:latest${NC}"
echo ""
echo -e "${YELLOW}Next step:${NC} Run 'terraform apply' again to create the AgentCore Runtime"
echo ""
@@ -0,0 +1,614 @@
#!/usr/bin/env python3
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Cross-platform frontend deployment script for Terraform deployments.
Deploys the Next.js frontend to AWS Amplify by:
1. Fetching configuration from Terraform outputs
2. Generating aws-exports.json
3. Building the frontend
4. Packaging and uploading to S3
5. Triggering Amplify deployment
Requires: Python 3.8+, AWS CLI, npm, Node.js, Terraform
No external Python dependencies - uses standard library only.
Usage:
cd infra-terraform
python scripts/deploy-frontend.py
# Or with pattern override
python scripts/deploy-frontend.py --pattern langgraph-single-agent
"""
import argparse
import atexit
import json
import os
import re
import shutil
import subprocess # nosec B404 - subprocess used securely with explicit parameters
import sys
import time
from pathlib import Path
from typing import Dict, Optional
# Minimum Python version check
if sys.version_info < (3, 8):
print("Error: Python 3.8 or higher is required")
sys.exit(1)
# Constants
BRANCH_NAME = "main"
NEXT_BUILD_DIR = "build"
CLEANUP_FILES: list = []
# --- Logging helpers ---
def log_info(message: str) -> None:
"""Print an info message."""
print(f" {message}")
def log_success(message: str) -> None:
"""Print a success message."""
print(f"{message}")
def log_error(message: str) -> None:
"""Print an error message to stderr."""
print(f"{message}", file=sys.stderr)
def log_warning(message: str) -> None:
"""Print a warning message."""
print(f"{message}")
# --- Utility functions ---
def cleanup() -> None:
"""Remove temporary files created during deployment."""
for filepath in CLEANUP_FILES:
if os.path.exists(filepath):
os.remove(filepath)
log_info(f"Cleaned up {filepath}")
def run_command(
command: list,
capture_output: bool = True,
check: bool = True,
cwd: Optional[str] = None,
) -> subprocess.CompletedProcess:
"""
Execute a command securely via subprocess.
Args:
command: List of command arguments
capture_output: Whether to capture stdout/stderr
check: Whether to raise on non-zero exit
cwd: Working directory for the command
Returns:
CompletedProcess instance with command results
"""
return subprocess.run( # nosec B603 - command constructed from safe list
command,
capture_output=capture_output,
text=True,
check=check,
shell=False,
timeout=300,
cwd=cwd,
)
def check_prerequisite(command: str) -> bool:
"""
Check if a command is available in PATH.
Args:
command: Name of the command to check
Returns:
True if command exists, False otherwise
"""
return shutil.which(command) is not None
def parse_tfvars(tfvars_path: Path) -> Dict[str, str]:
"""
Parse terraform.tfvars using regex (no HCL parser dependency).
Args:
tfvars_path: Path to terraform.tfvars file
Returns:
Dictionary with parsed values
"""
config = {"backend_pattern": "strands-single-agent"}
if not tfvars_path.exists():
return config
content = tfvars_path.read_text()
# Extract backend_pattern
match = re.search(r'^backend_pattern\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
config["backend_pattern"] = match.group(1)
return config
def get_file_size_human(filepath: str) -> str:
"""
Get human-readable file size.
Args:
filepath: Path to the file
Returns:
Human-readable size string (e.g., "1.5MB")
"""
size = os.path.getsize(filepath)
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
return f"{size:.1f}{unit}"
size /= 1024
return f"{size:.1f}TB"
# --- Terraform output functions ---
def get_terraform_outputs(terraform_dir: Path) -> Dict[str, str]:
"""
Fetch Terraform outputs via terraform output command.
Args:
terraform_dir: Path to the Terraform directory
Returns:
Dictionary mapping output keys to values
"""
result = run_command(["terraform", "output", "-json"], cwd=str(terraform_dir))
raw_outputs = json.loads(result.stdout)
# Flatten the Terraform output format (each value has a "value" key)
outputs = {}
for key, data in raw_outputs.items():
if isinstance(data, dict) and "value" in data:
outputs[key] = data["value"]
else:
outputs[key] = data
return outputs
# --- AWS CLI wrappers ---
def upload_to_s3(local_path: str, bucket: str, key: str) -> None:
"""
Upload a file to S3 via AWS CLI.
Args:
local_path: Path to local file
bucket: S3 bucket name
key: S3 object key
"""
run_command(
["aws", "s3", "cp", local_path, f"s3://{bucket}/{key}", "--no-progress"]
)
def start_amplify_deployment(app_id: str, branch: str, source_url: str) -> Dict:
"""
Start an Amplify deployment via AWS CLI.
Args:
app_id: Amplify application ID
branch: Branch name to deploy
source_url: S3 URL of deployment package
Returns:
Deployment response as dictionary
"""
result = run_command(
[
"aws",
"amplify",
"start-deployment",
"--app-id",
app_id,
"--branch-name",
branch,
"--source-url",
source_url,
"--output",
"json",
]
)
return json.loads(result.stdout)
def get_amplify_job_status(app_id: str, branch: str, job_id: str) -> str:
"""
Get the status of an Amplify deployment job.
Args:
app_id: Amplify application ID
branch: Branch name
job_id: Deployment job ID
Returns:
Job status string
"""
result = run_command(
[
"aws",
"amplify",
"get-job",
"--app-id",
app_id,
"--branch-name",
branch,
"--job-id",
job_id,
"--output",
"json",
]
)
return json.loads(result.stdout)["job"]["summary"]["status"]
def get_amplify_app_domain(app_id: str) -> str:
"""
Get the default domain for an Amplify app.
Args:
app_id: Amplify application ID
Returns:
Default domain string
"""
result = run_command(
[
"aws",
"amplify",
"get-app",
"--app-id",
app_id,
"--query",
"app.defaultDomain",
"--output",
"text",
]
)
return result.stdout.strip()
# --- Main deployment logic ---
def generate_aws_exports(
outputs: Dict[str, str], pattern: str, frontend_dir: Path
) -> None:
"""
Generate aws-exports.json configuration file.
Args:
outputs: Terraform outputs dictionary
pattern: Agent pattern name
frontend_dir: Path to frontend directory
"""
# Map Terraform output names to required values
required_mappings = {
"cognito_web_client_id": "client_id",
"cognito_user_pool_id": "user_pool_id",
"amplify_app_url": "app_url",
"runtime_arn": "runtime_arn",
"feedback_api_url": "feedback_api_url",
"copilotkit_runtime_url": "copilotkit_runtime_url",
}
missing = [k for k in required_mappings.keys() if k not in outputs]
if missing:
raise ValueError(f"Missing required Terraform outputs: {', '.join(missing)}")
# Get region from deployment_summary or default
region = "us-east-1"
if "deployment_summary" in outputs and isinstance(
outputs["deployment_summary"], dict
):
region = outputs["deployment_summary"].get("region", region)
aws_exports = {
"authority": f"https://cognito-idp.{region}.amazonaws.com/{outputs['cognito_user_pool_id']}",
"client_id": outputs["cognito_web_client_id"],
"redirect_uri": outputs["amplify_app_url"],
"post_logout_redirect_uri": outputs["amplify_app_url"],
"response_type": "code",
"scope": "email openid profile",
"automaticSilentRenew": True,
"agentRuntimeArn": outputs["runtime_arn"],
"awsRegion": region,
"feedbackApiUrl": outputs["feedback_api_url"],
"copilotKitRuntimeUrl": outputs["copilotkit_runtime_url"],
"agentPattern": pattern,
}
public_dir = frontend_dir / "public"
public_dir.mkdir(parents=True, exist_ok=True)
output_path = public_dir / "aws-exports.json"
output_path.write_text(json.dumps(aws_exports, indent=2))
log_success(f"Generated aws-exports.json at {output_path}")
def create_deployment_zip(build_dir: Path, output_path: Path) -> None:
"""
Create a zip archive of the build directory.
Args:
build_dir: Path to the build directory
output_path: Path for the output zip file (without .zip extension)
"""
# shutil.make_archive adds .zip automatically
shutil.make_archive(
str(output_path.with_suffix("")), "zip", root_dir=str(build_dir)
)
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Deploy frontend to AWS Amplify using Terraform outputs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/deploy-frontend.py
python scripts/deploy-frontend.py --pattern langgraph-single-agent
""",
)
parser.add_argument(
"--pattern",
"-p",
type=str,
help="Override agent pattern (default: from terraform.tfvars)",
)
return parser.parse_args()
def main() -> int:
"""
Main deployment function.
Returns:
Exit code (0 for success, 1 for failure)
"""
atexit.register(cleanup)
args = parse_args()
# Determine paths
script_dir = Path(__file__).parent.resolve()
terraform_dir = script_dir.parent
project_root = terraform_dir.parent
frontend_dir = project_root / "frontend"
tfvars_path = terraform_dir / "terraform.tfvars"
print()
print("========================================")
print(" Frontend Deployment (Terraform) ")
print("========================================")
print()
log_info("🚀 Starting frontend deployment process...")
print()
# Validate prerequisites
log_info("Validating prerequisites...")
prerequisites = ["npm", "aws", "node", "terraform"]
for prereq in prerequisites:
if not check_prerequisite(prereq):
log_error(f"{prereq} is not installed")
return 1
log_success("All prerequisites found")
# Verify AWS credentials are configured
log_info("Verifying AWS credentials...")
try:
run_command(["aws", "sts", "get-caller-identity"], capture_output=True)
log_success("AWS credentials configured")
except subprocess.CalledProcessError:
log_error("AWS credentials not configured or invalid")
log_info("Run 'aws configure' to set up your AWS credentials")
return 1
# Verify Terraform state exists
if not (terraform_dir / "terraform.tfstate").exists():
log_error("Terraform state not found. Run 'terraform apply' first.")
return 1
# Fetch Terraform outputs
log_info("Fetching configuration from Terraform outputs...")
try:
outputs = get_terraform_outputs(terraform_dir)
except subprocess.CalledProcessError as e:
log_error(f"Failed to fetch Terraform outputs: {e.stderr}")
return 1
except json.JSONDecodeError as e:
log_error(f"Failed to parse Terraform outputs: {e}")
return 1
# Validate required outputs
app_id = outputs.get("amplify_app_id")
deployment_bucket = outputs.get("amplify_staging_bucket")
region = "us-east-1"
if "deployment_summary" in outputs and isinstance(
outputs["deployment_summary"], dict
):
region = outputs["deployment_summary"].get("region", region)
if not app_id:
log_error("Could not find amplify_app_id in Terraform outputs")
return 1
if not deployment_bucket:
log_error("Could not find amplify_staging_bucket in Terraform outputs")
return 1
log_success(f"App ID: {app_id}")
log_success(f"Staging Bucket: {deployment_bucket}")
log_success(f"Region: {region}")
# Get agent pattern
if args.pattern:
pattern = args.pattern
else:
tfvars = parse_tfvars(tfvars_path)
pattern = tfvars.get("backend_pattern", "strands-single-agent")
log_info(f"Agent pattern: {pattern}")
# Generate aws-exports.json
log_info("Generating aws-exports.json...")
try:
generate_aws_exports(outputs, pattern, frontend_dir)
except ValueError as e:
log_error(str(e))
return 1
# Change to frontend directory
os.chdir(frontend_dir)
log_info(f"Working directory: {frontend_dir}")
# Install dependencies if needed
node_modules = frontend_dir / "node_modules"
package_json = frontend_dir / "package.json"
if not node_modules.exists() or (
node_modules.exists()
and package_json.exists()
and package_json.stat().st_mtime > node_modules.stat().st_mtime
):
log_info("Installing dependencies...")
try:
run_command(["npm", "install"], capture_output=False)
log_success("Dependencies installed")
except subprocess.CalledProcessError:
log_error("Failed to install dependencies")
return 1
else:
log_success("Dependencies are up to date")
# Build frontend
log_info("Building Next.js app...")
try:
run_command(["npm", "run", "build"], capture_output=False)
log_success("Build completed")
except subprocess.CalledProcessError:
log_error("Build failed")
return 1
# Verify build directory
build_dir = frontend_dir / NEXT_BUILD_DIR
if not build_dir.exists():
log_error(f"Build directory '{NEXT_BUILD_DIR}' not found")
return 1
# Copy aws-exports.json to build
aws_exports_src = frontend_dir / "public" / "aws-exports.json"
aws_exports_dst = build_dir / "aws-exports.json"
shutil.copy2(aws_exports_src, aws_exports_dst)
log_success("Added aws-exports.json to build directory")
# Create deployment zip
log_info("Creating deployment package...")
zip_path = frontend_dir / "amplify-deploy.zip"
CLEANUP_FILES.append(str(zip_path))
create_deployment_zip(build_dir, zip_path)
zip_size = get_file_size_human(str(zip_path))
log_success(f"Package created ({zip_size})")
# Upload to S3
s3_key = f"amplify-deploy-{int(time.time())}.zip"
log_info(f"Uploading to S3 (s3://{deployment_bucket}/{s3_key})...")
try:
upload_to_s3(str(zip_path), deployment_bucket, s3_key)
log_success("Upload completed")
except subprocess.CalledProcessError as e:
log_error(f"S3 upload failed: {e.stderr}")
return 1
# Start Amplify deployment
log_info("Starting Amplify deployment...")
source_url = f"s3://{deployment_bucket}/{s3_key}"
try:
deployment = start_amplify_deployment(app_id, BRANCH_NAME, source_url)
job_id = deployment["jobSummary"]["jobId"]
log_success(f"Deployment initiated (Job ID: {job_id})")
except subprocess.CalledProcessError as e:
log_error(f"Amplify deployment failed: {e.stderr}")
return 1
# Poll deployment status
log_info("Monitoring deployment status...")
while True:
try:
status = get_amplify_job_status(app_id, BRANCH_NAME, job_id)
except subprocess.CalledProcessError as e:
log_error(f"Failed to get deployment status: {e.stderr}")
return 1
print(f" Status: {status}")
if status == "SUCCEED":
log_success("Deployment completed successfully!")
break
elif status in ("FAILED", "CANCELLED"):
log_error(f"Deployment {status.lower()}")
return 1
time.sleep(10)
# Cleanup
log_info("Cleaned up temporary files")
# Print final info
print()
log_info(f"S3 Package: s3://{deployment_bucket}/{s3_key}")
log_info("Console: https://console.aws.amazon.com/amplify/apps")
try:
app_domain = get_amplify_app_domain(app_id)
log_info(f"App URL: https://{BRANCH_NAME}.{app_domain}")
except subprocess.CalledProcessError:
log_warning("Could not retrieve app URL - check Amplify console")
print()
print("========================================")
print(" Frontend Deployment Complete! ")
print("========================================")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,309 @@
#!/bin/bash
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Deploy Frontend to AWS Amplify
# =============================================================================
#
# This script deploys the Next.js frontend to Amplify using Terraform outputs.
#
# Usage:
# ./scripts/deploy-frontend.sh [options]
#
# Options:
# -p, --pattern Agent pattern (default: from terraform.tfvars or strands-single-agent)
# -h, --help Show this help message
#
# =============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
PATTERN=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TERRAFORM_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_ROOT="$(dirname "$TERRAFORM_DIR")"
FRONTEND_DIR="$PROJECT_ROOT/frontend"
BRANCH_NAME="main"
BUILD_DIR="build"
# Print usage
usage() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -p, --pattern Agent pattern (default: from terraform.tfvars or strands-single-agent)"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Use defaults from Terraform"
echo " $0 -p langgraph-single-agent # Use LangGraph pattern"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--pattern)
PATTERN="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo -e "${RED}Error: Unknown option $1${NC}"
usage
exit 1
;;
esac
done
# Helper functions
log_info() {
echo -e "${BLUE}${NC} $1"
}
log_success() {
echo -e "${GREEN}${NC} $1"
}
log_error() {
echo -e "${RED}${NC} $1" >&2
}
log_warning() {
echo -e "${YELLOW}${NC} $1"
}
# Get human-readable file size
get_file_size() {
local size=$(stat -f%z "$1" 2>/dev/null || stat --printf="%s" "$1" 2>/dev/null)
if [[ $size -lt 1024 ]]; then
echo "${size}B"
elif [[ $size -lt 1048576 ]]; then
echo "$(echo "scale=1; $size/1024" | bc)KB"
else
echo "$(echo "scale=1; $size/1048576" | bc)MB"
fi
}
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Frontend Deployment (Terraform) ${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
log_info "🚀 Starting frontend deployment process..."
echo ""
# Validate prerequisites
log_info "Validating prerequisites..."
for cmd in npm aws node terraform jq; do
if ! command -v $cmd &> /dev/null; then
log_error "$cmd is not installed"
exit 1
fi
done
log_success "All prerequisites found"
# Verify AWS credentials
log_info "Verifying AWS credentials..."
if ! aws sts get-caller-identity &> /dev/null; then
log_error "AWS credentials not configured or invalid"
log_info "Run 'aws configure' to set up your AWS credentials"
exit 1
fi
log_success "AWS credentials configured"
# Change to Terraform directory to get outputs
cd "$TERRAFORM_DIR"
# Verify Terraform state exists
if [[ ! -f "terraform.tfstate" ]]; then
log_error "Terraform state not found. Run 'terraform apply' first."
exit 1
fi
# Get Terraform outputs
log_info "Fetching configuration from Terraform outputs..."
# Get all required outputs
AMPLIFY_APP_ID=$(terraform output -raw amplify_app_id 2>/dev/null)
STAGING_BUCKET=$(terraform output -raw amplify_staging_bucket 2>/dev/null)
COGNITO_CLIENT_ID=$(terraform output -raw cognito_web_client_id 2>/dev/null)
COGNITO_USER_POOL_ID=$(terraform output -raw cognito_user_pool_id 2>/dev/null)
AMPLIFY_URL=$(terraform output -raw amplify_app_url 2>/dev/null)
RUNTIME_ARN=$(terraform output -raw runtime_arn 2>/dev/null)
FEEDBACK_API_URL=$(terraform output -raw feedback_api_url 2>/dev/null)
COPILOTKIT_RUNTIME_URL=$(terraform output -raw copilotkit_runtime_url 2>/dev/null)
AWS_REGION=$(terraform output -json deployment_summary 2>/dev/null | jq -r '.region')
# Validate required outputs
if [[ -z "$AMPLIFY_APP_ID" ]]; then
log_error "Could not find Amplify App ID in Terraform outputs"
exit 1
fi
if [[ -z "$STAGING_BUCKET" ]]; then
log_error "Could not find Staging Bucket in Terraform outputs"
exit 1
fi
if [[ -z "$COGNITO_CLIENT_ID" ]]; then
log_error "Could not find Cognito Client ID in Terraform outputs"
exit 1
fi
if [[ -z "$RUNTIME_ARN" ]]; then
log_error "Could not find Runtime ARN in Terraform outputs"
exit 1
fi
if [[ -z "$COPILOTKIT_RUNTIME_URL" ]]; then
log_error "Could not find CopilotKit runtime URL in Terraform outputs"
exit 1
fi
log_success "App ID: $AMPLIFY_APP_ID"
log_success "Staging Bucket: $STAGING_BUCKET"
log_success "Region: $AWS_REGION"
# Get pattern from terraform.tfvars if not provided
if [[ -z "$PATTERN" ]]; then
TFVARS_FILE="$TERRAFORM_DIR/terraform.tfvars"
if [[ -f "$TFVARS_FILE" ]]; then
PATTERN=$(grep -E '^backend_pattern\s*=' "$TFVARS_FILE" | awk -F'"' '{print $2}')
fi
fi
PATTERN="${PATTERN:-strands-single-agent}"
log_info "Agent pattern: $PATTERN"
# Generate aws-exports.json
log_info "Generating aws-exports.json..."
AWS_EXPORTS=$(cat <<EOF
{
"authority": "https://cognito-idp.${AWS_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID}",
"client_id": "${COGNITO_CLIENT_ID}",
"redirect_uri": "${AMPLIFY_URL}",
"post_logout_redirect_uri": "${AMPLIFY_URL}",
"response_type": "code",
"scope": "email openid profile",
"automaticSilentRenew": true,
"agentRuntimeArn": "${RUNTIME_ARN}",
"awsRegion": "${AWS_REGION}",
"feedbackApiUrl": "${FEEDBACK_API_URL}",
"copilotKitRuntimeUrl": "${COPILOTKIT_RUNTIME_URL}",
"agentPattern": "${PATTERN}"
}
EOF
)
# Write aws-exports.json to frontend/public
mkdir -p "$FRONTEND_DIR/public"
echo "$AWS_EXPORTS" > "$FRONTEND_DIR/public/aws-exports.json"
log_success "Generated aws-exports.json at $FRONTEND_DIR/public/aws-exports.json"
# Change to frontend directory
cd "$FRONTEND_DIR"
log_info "Working directory: $FRONTEND_DIR"
# Install dependencies if needed
if [[ ! -d "node_modules" ]] || [[ "package.json" -nt "node_modules" ]]; then
log_info "Installing dependencies..."
npm install
log_success "Dependencies installed"
else
log_success "Dependencies are up to date"
fi
# Build frontend
log_info "Building Next.js app..."
npm run build
log_success "Build completed"
# Verify build directory exists
if [[ ! -d "$BUILD_DIR" ]]; then
log_error "Build directory '$BUILD_DIR' not found"
exit 1
fi
# Copy aws-exports.json to build directory
cp "$FRONTEND_DIR/public/aws-exports.json" "$FRONTEND_DIR/$BUILD_DIR/aws-exports.json"
log_success "Added aws-exports.json to build directory"
# Create deployment zip
log_info "Creating deployment package..."
ZIP_FILE="$FRONTEND_DIR/amplify-deploy.zip"
cd "$FRONTEND_DIR/$BUILD_DIR"
zip -r "$ZIP_FILE" . -x "*.DS_Store" > /dev/null
cd "$FRONTEND_DIR"
ZIP_SIZE=$(get_file_size "$ZIP_FILE")
log_success "Package created ($ZIP_SIZE)"
# Upload to S3
S3_KEY="amplify-deploy-$(date +%s).zip"
log_info "Uploading to S3 (s3://${STAGING_BUCKET}/${S3_KEY})..."
aws s3 cp "$ZIP_FILE" "s3://${STAGING_BUCKET}/${S3_KEY}" --no-progress
log_success "Upload completed"
# Start Amplify deployment
log_info "Starting Amplify deployment..."
DEPLOYMENT=$(aws amplify start-deployment \
--app-id "$AMPLIFY_APP_ID" \
--branch-name "$BRANCH_NAME" \
--source-url "s3://${STAGING_BUCKET}/${S3_KEY}" \
--output json)
JOB_ID=$(echo "$DEPLOYMENT" | jq -r '.jobSummary.jobId')
log_success "Deployment initiated (Job ID: $JOB_ID)"
# Monitor deployment status
log_info "Monitoring deployment status..."
while true; do
STATUS=$(aws amplify get-job \
--app-id "$AMPLIFY_APP_ID" \
--branch-name "$BRANCH_NAME" \
--job-id "$JOB_ID" \
--query 'job.summary.status' \
--output text)
echo " Status: $STATUS"
if [[ "$STATUS" == "SUCCEED" ]]; then
log_success "Deployment completed successfully!"
break
elif [[ "$STATUS" == "FAILED" ]] || [[ "$STATUS" == "CANCELLED" ]]; then
log_error "Deployment ${STATUS,,}"
exit 1
fi
sleep 10
done
# Cleanup
rm -f "$ZIP_FILE"
log_info "Cleaned up temporary files"
# Print final info
echo ""
log_info "S3 Package: s3://${STAGING_BUCKET}/${S3_KEY}"
log_info "Console: https://console.aws.amazon.com/amplify/apps"
# Get app domain
APP_DOMAIN=$(aws amplify get-app --app-id "$AMPLIFY_APP_ID" --query 'app.defaultDomain' --output text 2>/dev/null || echo "")
if [[ -n "$APP_DOMAIN" ]]; then
log_info "App URL: https://${BRANCH_NAME}.${APP_DOMAIN}"
fi
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Frontend Deployment Complete! ${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Test Agent - AgentCore Runtime CLI Tester (Terraform)
Tests the deployed agent using Terraform outputs. Authenticates via Cognito
and invokes the agent with streaming response display.
Prerequisites:
- Terraform infrastructure deployed (terraform apply)
- AgentCore Runtime created
- Dependencies: pip install boto3 requests colorama
Usage:
cd infra-terraform
python scripts/test-agent.py [message]
Examples:
python scripts/test-agent.py 'Hello' # Test with message
python scripts/test-agent.py # Uses default message
"""
import getpass
import json
import subprocess # nosec B404
import sys
import time
import uuid
from pathlib import Path
from typing import Dict, Tuple
from urllib.parse import quote
import boto3
import requests
from colorama import Fore, Style, init
# Initialize colorama for cross-platform colored output
init()
def log_info(msg: str) -> None:
"""Print info message."""
print(f"{Fore.BLUE}{Style.RESET_ALL} {msg}")
def log_success(msg: str) -> None:
"""Print success message."""
print(f"{Fore.GREEN}{Style.RESET_ALL} {msg}")
def log_error(msg: str) -> None:
"""Print error message."""
print(f"{Fore.RED}{Style.RESET_ALL} {msg}", file=sys.stderr)
def get_terraform_outputs() -> Dict[str, str]:
"""
Get outputs from Terraform state.
Returns:
Dict with runtime_arn, cognito_user_pool_id, cognito_web_client_id, region
"""
terraform_dir = Path(__file__).parent.parent
try:
# Get individual outputs
result = subprocess.run( # nosec B603, B607
["terraform", "output", "-json"],
cwd=terraform_dir,
capture_output=True,
text=True,
check=True,
)
outputs = json.loads(result.stdout)
return {
"runtime_arn": outputs.get("runtime_arn", {}).get("value", ""),
"cognito_user_pool_id": outputs.get("cognito_user_pool_id", {}).get(
"value", ""
),
"cognito_web_client_id": outputs.get("cognito_web_client_id", {}).get(
"value", ""
),
"region": outputs.get("deployment_summary", {})
.get("value", {})
.get("region", "us-east-1"),
}
except subprocess.CalledProcessError as e:
log_error(f"Failed to get Terraform outputs: {e.stderr}")
sys.exit(1)
except json.JSONDecodeError as e:
log_error(f"Failed to parse Terraform outputs: {e}")
sys.exit(1)
def authenticate_cognito(
user_pool_id: str, client_id: str, username: str, password: str, region: str
) -> Tuple[str, str]:
"""
Authenticate with Cognito and return ID token.
Args:
user_pool_id: Cognito User Pool ID
client_id: Cognito Client ID
username: User's email/username
password: User's password
region: AWS region
Returns:
Tuple of (id_token, access_token)
"""
client = boto3.client("cognito-idp", region_name=region)
try:
response = client.initiate_auth(
AuthFlow="USER_PASSWORD_AUTH",
ClientId=client_id,
AuthParameters={
"USERNAME": username,
"PASSWORD": password,
},
)
# Handle NEW_PASSWORD_REQUIRED challenge
if response.get("ChallengeName") == "NEW_PASSWORD_REQUIRED":
log_info("New password required for first-time login")
new_password = getpass.getpass("Set New Password: ")
response = client.respond_to_auth_challenge(
ClientId=client_id,
ChallengeName="NEW_PASSWORD_REQUIRED",
ChallengeResponses={
"USERNAME": username,
"NEW_PASSWORD": new_password,
},
Session=response["Session"],
)
auth_result = response.get("AuthenticationResult", {})
id_token = auth_result.get("IdToken")
access_token = auth_result.get("AccessToken")
if not id_token:
log_error("Failed to get ID token from authentication response")
sys.exit(1)
return id_token, access_token
except client.exceptions.NotAuthorizedException as e:
log_error(f"Authentication failed: {e}")
sys.exit(1)
except client.exceptions.UserNotFoundException as e:
log_error(f"User not found: {e}")
sys.exit(1)
except Exception as e:
log_error(f"Authentication error: {e}")
sys.exit(1)
def generate_session_id() -> str:
"""Generate a session ID (must be >= 33 characters)."""
return f"test-session-{int(time.time())}-{uuid.uuid4().hex[:16]}"
def generate_trace_id() -> str:
"""Generate X-Amzn-Trace-Id header value."""
timestamp_hex = format(int(time.time()), "x")
return f"1-{timestamp_hex}-{uuid.uuid4().hex[:24]}"
def sanitize_user_id(email: str) -> str:
"""
Sanitize user ID for Memory API (replace @ and . with allowed characters).
Args:
email: User's email address
Returns:
Sanitized user ID matching regex [a-zA-Z0-9][a-zA-Z0-9-_/]*
"""
return email.replace("@", "-at-").replace(".", "-")
def invoke_agent(
runtime_arn: str,
region: str,
id_token: str,
prompt: str,
session_id: str,
user_id: str,
) -> None:
"""
Invoke the agent and stream the response.
Args:
runtime_arn: AgentCore Runtime ARN
region: AWS region
id_token: Cognito ID token
prompt: User's message
session_id: Session ID for conversation
user_id: Sanitized user ID
"""
# Build URL
endpoint = f"https://bedrock-agentcore.{region}.amazonaws.com"
encoded_arn = quote(runtime_arn, safe="")
url = f"{endpoint}/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT"
# Generate trace ID
trace_id = generate_trace_id()
# Headers
headers = {
"Authorization": f"Bearer {id_token}",
"Content-Type": "application/json",
"X-Amzn-Trace-Id": trace_id,
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": session_id,
}
# Payload
payload = {
"prompt": prompt,
"runtimeSessionId": session_id,
"userId": user_id,
}
log_info(f"Invoking agent at: {url}")
print()
print(f"{Fore.GREEN}Agent Response:{Style.RESET_ALL}")
print()
try:
# Stream the response
response = requests.post(
url, headers=headers, json=payload, stream=True, timeout=120
)
if response.status_code != 200:
log_error(f"HTTP {response.status_code}: {response.text}")
return
# Process streaming response
for line in response.iter_lines(decode_unicode=True):
if line:
# Parse SSE format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
parsed = json.loads(data)
# Check if parsed is a dict (not a string)
if isinstance(parsed, dict):
# Extract text from contentBlockDelta events
if "event" in parsed:
event = parsed["event"]
if (
isinstance(event, dict)
and "contentBlockDelta" in event
):
delta = event["contentBlockDelta"].get("delta", {})
if isinstance(delta, dict):
text = delta.get("text", "")
if text:
print(text, end="", flush=True)
# Check for final message
if "message" in parsed:
message = parsed["message"]
if isinstance(message, dict):
content = message.get("content", [])
if isinstance(content, list):
for block in content:
if (
isinstance(block, dict)
and "text" in block
):
# Don't print final message - we already streamed it
pass
except json.JSONDecodeError:
# Not JSON, skip internal debug strings
pass
print()
except requests.exceptions.ConnectionError as e:
log_error(f"Connection error: {e}")
except requests.exceptions.Timeout:
log_error("Request timed out")
except Exception as e:
log_error(f"Error: {e}")
def main():
"""Main entry point."""
print()
print(f"{Fore.BLUE}========================================{Style.RESET_ALL}")
print(f"{Fore.BLUE} Agent Test (Terraform - Python) {Style.RESET_ALL}")
print(f"{Fore.BLUE}========================================{Style.RESET_ALL}")
print()
# Get message from args or use default
message = sys.argv[1] if len(sys.argv) > 1 else "Hello! What are you capable of?"
# Get Terraform outputs
log_info("Fetching configuration from Terraform outputs...")
outputs = get_terraform_outputs()
runtime_arn = outputs["runtime_arn"]
if not runtime_arn:
log_error("Could not find Runtime ARN in Terraform outputs")
sys.exit(1)
log_success(f"Runtime ARN: {runtime_arn}")
log_success(f"Region: {outputs['region']}")
# Get credentials
print()
log_info("Enter your Cognito credentials (admin user created during deployment)")
email = input("Email: ").strip()
password = getpass.getpass("Password: ")
# Authenticate
log_info("Authenticating with Cognito...")
id_token, _ = authenticate_cognito(
user_pool_id=outputs["cognito_user_pool_id"],
client_id=outputs["cognito_web_client_id"],
username=email,
password=password,
region=outputs["region"],
)
log_success("Authentication successful!")
# Generate session ID
session_id = generate_session_id()
# Sanitize user ID
user_id = sanitize_user_id(email)
# Invoke agent
print()
log_info("Sending message to agent...")
print(f"{Fore.CYAN}You:{Style.RESET_ALL} {message}")
print()
start_time = time.time()
invoke_agent(
runtime_arn=runtime_arn,
region=outputs["region"],
id_token=id_token,
prompt=message,
session_id=session_id,
user_id=user_id,
)
elapsed = time.time() - start_time
print()
print(f"{Fore.CYAN}[Completed in {elapsed:.2f}s]{Style.RESET_ALL}")
print()
print(f"{Fore.GREEN}========================================{Style.RESET_ALL}")
print(f"{Fore.GREEN} Agent Test Complete! {Style.RESET_ALL}")
print(f"{Fore.GREEN}========================================{Style.RESET_ALL}")
print()
if __name__ == "__main__":
main()
@@ -0,0 +1,64 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Terraform Configuration Example
# =============================================================================
# Copy this file to terraform.tfvars and customize the values.
#
# Region: Set via AWS_REGION environment variable or AWS CLI profile.
# Tags: Add custom tags via the provider's default_tags block in main.tf.
# =============================================================================
# -----------------------------------------------------------------------------
# Required Variables
# -----------------------------------------------------------------------------
# Base name for all resources
stack_name_base = "fast-demo-stack"
# -----------------------------------------------------------------------------
# Optional: Admin User
# -----------------------------------------------------------------------------
# Set to automatically create an admin user and email credentials
# If not provided (null), you'll need to manually create users via AWS Console
admin_user_email = null # Example: "admin@example.com"
# -----------------------------------------------------------------------------
# Backend Configuration
# -----------------------------------------------------------------------------
# Agent pattern to deploy
# Available patterns: strands-single-agent, langgraph-single-agent, claude-agent-sdk-single-agent, claude-agent-sdk-multi-agent
backend_pattern = "strands-single-agent"
# Deployment type for AgentCore Runtime
# "docker" - Container image via ECR (requires Docker + separate build step)
# Deployment: terraform apply -> ./scripts/build-and-push-image.sh -> terraform apply
# "zip" - Python package via S3 (no Docker required, single-step deploy)
# Deployment: terraform apply (packages and deploys automatically)
backend_deployment_type = "docker"
# Network mode for AgentCore Runtime
# PUBLIC: Runtime is accessible over the public internet (default)
# VPC: Runtime is deployed into a user-provided VPC for private network isolation
backend_network_mode = "PUBLIC"
# -----------------------------------------------------------------------------
# VPC Configuration (Required only if backend_network_mode = "VPC")
# -----------------------------------------------------------------------------
# Uncomment and configure if using VPC network mode.
# Your VPC must have the necessary VPC endpoints for AWS services.
# See README.md VPC Deployment Mode section for full requirements.
#
# Security groups are optional. If omitted, a default security group is created
# with HTTPS (443) self-referencing ingress and all-traffic egress.
#
# IMPORTANT: AgentCore Runtime is only available in specific Availability Zones
# per region. Ensure your subnets are in supported AZs. See:
# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs
# backend_vpc_id = "vpc-xxxxxxxxxxxxxxxxx"
# backend_vpc_subnet_ids = ["subnet-xxxxxxxxxxxxxxxxx", "subnet-yyyyyyyyyyyyyyyyy"]
# backend_vpc_security_group_ids = ["sg-xxxxxxxxxxxxxxxxx"] # Optional
@@ -0,0 +1,91 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# Required Variables
# =============================================================================
variable "stack_name_base" {
description = "Base name for all resources. Used as prefix for resource naming."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,34}$", var.stack_name_base))
error_message = "Stack name must start with a lowercase letter, be 3-35 characters, and contain only lowercase alphanumeric characters and hyphens."
}
}
# =============================================================================
# Optional Variables - Admin User
# =============================================================================
variable "admin_user_email" {
description = "Email address for the admin user. If provided, creates an admin user and sends credentials via email. Set to null to skip admin user creation."
type = string
default = null
validation {
condition = var.admin_user_email == null || can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.admin_user_email))
error_message = "Must be a valid email address or null."
}
}
# =============================================================================
# Backend Configuration
# =============================================================================
variable "backend_pattern" {
description = "Agent pattern to deploy. Available patterns: strands-single-agent, langgraph-single-agent, claude-agent-sdk-single-agent, claude-agent-sdk-multi-agent"
type = string
default = "strands-single-agent"
validation {
condition = contains(["strands-single-agent", "langgraph-single-agent", "claude-agent-sdk-single-agent", "claude-agent-sdk-multi-agent"], var.backend_pattern)
error_message = "Backend pattern must be one of: strands-single-agent, langgraph-single-agent, claude-agent-sdk-single-agent, claude-agent-sdk-multi-agent."
}
}
variable "backend_deployment_type" {
description = "Deployment type for AgentCore Runtime. 'docker' uses ECR container image (requires Docker + separate build step). 'zip' uses S3 Python package (no Docker required, single-step deploy)."
type = string
default = "docker"
validation {
condition = contains(["docker", "zip"], var.backend_deployment_type)
error_message = "Deployment type must be 'docker' or 'zip'."
}
}
variable "backend_network_mode" {
description = "Network mode for AgentCore Runtime. PUBLIC (default) uses public internet. VPC deploys into a user-provided VPC for private network isolation."
type = string
default = "PUBLIC"
validation {
condition = contains(["PUBLIC", "VPC"], var.backend_network_mode)
error_message = "Network mode must be 'PUBLIC' or 'VPC'."
}
}
# =============================================================================
# VPC Configuration (Required if backend_network_mode = VPC)
# =============================================================================
variable "backend_vpc_id" {
description = "VPC ID for VPC network mode. Required when backend_network_mode is 'VPC'."
type = string
default = null
}
variable "backend_vpc_subnet_ids" {
description = "List of subnet IDs for VPC network mode. Required when backend_network_mode is 'VPC'. Subnets should be in at least two Availability Zones."
type = list(string)
default = []
}
variable "backend_vpc_security_group_ids" {
description = "List of security group IDs for VPC network mode. Optional when backend_network_mode is 'VPC'. If omitted, a default security group is created with HTTPS self-referencing ingress and all-traffic egress."
type = list(string)
default = []
}
@@ -0,0 +1,25 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.35.1"
}
random = {
source = "hashicorp/random"
version = ">= 3.5.0"
}
archive = {
source = "hashicorp/archive"
version = ">= 2.4.0"
}
null = {
source = "hashicorp/null"
version = ">= 3.2.0"
}
}
}