feat(ppal-aws): add Terraform configuration for Discord Bot

Add AWS infrastructure as code for PPAL Discord Bot:
- API Gateway (HTTP API) for Discord Interactions endpoint
- Lambda function (Node.js 20.x container image)
- DynamoDB tables (users, conversations) with PAY_PER_REQUEST
- Secrets Manager for tokens (Discord, GitHub, OpenAI)
- IAM role with inline policies for Lambda execution

Files:
- versions.tf: Terraform 1.0+ with AWS provider 5.x
- variables.tf: environment, project_name, lambda config
- api_gateway.tf: HTTP API with CloudWatch logging
- lambda.tf: ECR-based Lambda with function URL
- dynamodb.tf: GSI-enabled tables with PITR & encryption
- secrets.tf: 4 secrets with placeholder values
- iam.tf: Inline policies for DynamoDB, Secrets, Logs, ECR
- outputs.tf: Endpoint URLs, ARNs (sensitive)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-01-25 19:04:55 +09:00
parent 50bb418fe7
commit abed5cb5cb
10 changed files with 616 additions and 0 deletions

1
workers/ppal-aws/terraform/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.terraform/

View File

@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.100.0"
constraints = "~> 5.0"
hashes = [
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
]
}

View File

@ -0,0 +1,73 @@
resource "aws_apigatewayv2_api" "discord_interactions" {
name = "${var.project_name}-discord-interactions"
protocol_type = "HTTP"
description = "HTTP API for PPAL Discord Bot Interactions Endpoint"
tags = {
Name = "${var.project_name}-discord-interactions"
}
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.discord_interactions.id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_gateway.arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
caller = "$context.identity.caller"
user = "$context.identity.user"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
resourcePath = "$context.resourcePath"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
integrationErrorMessage = "$context.integrationErrorMessage"
})
}
tags = {
Name = "${var.project_name}-default-stage"
}
}
resource "aws_cloudwatch_log_group" "api_gateway" {
name = "/aws/apigateway/${var.project_name}-discord-interactions"
retention_in_days = 7
tags = {
Name = "${var.project_name}-api-gateway-logs"
}
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.discord_interactions.id
integration_type = "AWS_PROXY"
connection_type = "INTERNET"
description = "Lambda integration for Discord interactions"
integration_method = "POST"
integration_uri = aws_lambda_function.discord_handler.invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = var.lambda_timeout * 1000
}
resource "aws_apigatewayv2_route" "discord_interactions" {
api_id = aws_apigatewayv2_api.discord_interactions.id
route_key = "POST /interactions"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_lambda_permission" "apigateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.discord_handler.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.discord_interactions.execution_arn}/*/*"
}

View File

@ -0,0 +1,73 @@
resource "aws_dynamodb_table" "users" {
name = "${var.project_name}-users"
billing_mode = "PAY_PER_REQUEST"
hash_key = "user_id"
attribute {
name = "user_id"
type = "S"
}
attribute {
name = "guild_id"
type = "S"
}
global_secondary_index {
name = "GuildIndex"
hash_key = "guild_id"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
tags = {
Name = "${var.project_name}-users"
}
}
resource "aws_dynamodb_table" "conversations" {
name = "${var.project_name}-conversations"
billing_mode = "PAY_PER_REQUEST"
hash_key = "conversation_id"
attribute {
name = "conversation_id"
type = "S"
}
attribute {
name = "user_id"
type = "S"
}
attribute {
name = "created_at"
type = "S"
}
global_secondary_index {
name = "UserIndex"
hash_key = "user_id"
range_key = "created_at"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
tags = {
Name = "${var.project_name}-conversations"
}
}

View File

@ -0,0 +1,131 @@
resource "aws_iam_role" "lambda_role" {
name = "${var.project_name}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-lambda-role"
}
}
# Basic Lambda execution permissions
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# DynamoDB permissions
resource "aws_iam_role_policy" "lambda_dynamodb" {
name = "${var.project_name}-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem"
]
Resource = [
aws_dynamodb_table.users.arn,
aws_dynamodb_table.conversations.arn,
"${aws_dynamodb_table.users.arn}/index/*",
"${aws_dynamodb_table.conversations.arn}/index/*"
]
}
]
})
}
# Secrets Manager permissions
resource "aws_iam_role_policy" "lambda_secrets" {
name = "${var.project_name}-secrets-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = [
aws_secretsmanager_secret.discord_bot_token.arn,
aws_secretsmanager_secret.discord_public_key.arn,
aws_secretsmanager_secret.github_token.arn,
aws_secretsmanager_secret.openai_api_key.arn
]
}
]
})
}
# CloudWatch Logs permissions
resource "aws_iam_role_policy" "lambda_logs" {
name = "${var.project_name}-logs-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:${var.aws_region}:*:log-group:/aws/lambda/${var.project_name}*"
}
]
})
}
# ECR permissions for pulling Lambda image
resource "aws_iam_role_policy" "lambda_ecr" {
name = "${var.project_name}-ecr-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
Resource = "${data.aws_ecr_repository.lambda.arn}"
},
{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken"
]
Resource = "*"
}
]
})
}

View File

@ -0,0 +1,70 @@
data "aws_ecr_repository" "lambda" {
name = "${var.project_name}-lambda"
}
data "aws_ecr_image" "lambda" {
repository_name = data.aws_ecr_repository.lambda.name
image_tag = var.environment
}
resource "aws_lambda_function" "discord_handler" {
function_name = "${var.project_name}-discord-handler"
description = "PPAL Discord Bot command handler"
role = aws_iam_role.lambda_role.arn
package_type = "Image"
image_uri = "${data.aws_ecr_repository.lambda.repository_url}@${data.aws_ecr_image.lambda.image_digest}"
timeout = var.lambda_timeout
memory_size = var.lambda_memory_size
environment {
variables = {
ENVIRONMENT = var.environment
DISCORD_BOT_TOKEN_SECRET_ARN = aws_secretsmanager_secret.discord_bot_token.arn
DISCORD_PUBLIC_KEY_SECRET_ARN = aws_secretsmanager_secret.discord_public_key.arn
GITHUB_TOKEN_SECRET_ARN = aws_secretsmanager_secret.github_token.arn
OPENAI_API_KEY_SECRET_ARN = aws_secretsmanager_secret.openai_api_key.arn
USERS_TABLE_NAME = aws_dynamodb_table.users.name
CONVERSATIONS_TABLE_NAME = aws_dynamodb_table.conversations.name
}
}
logging_config {
log_format = "Text"
log_group = aws_cloudwatch_log_group.lambda_logs.name
}
depends_on = [
aws_cloudwatch_log_group.lambda_logs,
aws_iam_role_policy_attachment.lambda_basic,
aws_iam_role_policy.lambda_dynamodb,
aws_iam_role_policy.lambda_secrets,
]
tags = {
Name = "${var.project_name}-discord-handler"
}
}
resource "aws_cloudwatch_log_group" "lambda_logs" {
name = "/aws/lambda/${var.project_name}-discord-handler"
retention_in_days = 7
tags = {
Name = "${var.project_name}-lambda-logs"
}
}
resource "aws_lambda_function_url" "discord_handler" {
function_name = aws_lambda_function.discord_handler.function_name
authorization_type = "NONE"
invoke_mode = "RESPONSE_STREAM"
cors {
allow_credentials = true
allow_origins = ["https://discord.com"]
allow_methods = ["POST"]
allow_headers = ["Content-Type", "X-Discord-Timestamp", "X-Signature-Ed25519", "X-Signature-Timestamp"]
}
}

View File

@ -0,0 +1,78 @@
output "api_gateway_url" {
description = "HTTP API Gateway endpoint URL for Discord Interactions"
value = aws_apigatewayv2_api.discord_interactions.api_endpoint
}
output "api_gateway_stage_url" {
description = "Full stage URL for Discord Interactions endpoint"
value = "${aws_apigatewayv2_api.discord_interactions.api_endpoint}/${aws_apigatewayv2_stage.default.name}"
}
output "discord_interactions_endpoint" {
description = "Discord Interactions endpoint URL (POST /interactions)"
value = "${aws_apigatewayv2_api.discord_interactions.api_endpoint}/interactions"
}
output "lambda_function_name" {
description = "Lambda function name"
value = aws_lambda_function.discord_handler.function_name
}
output "lambda_function_arn" {
description = "Lambda function ARN"
value = aws_lambda_function.discord_handler.arn
}
output "lambda_function_url" {
description = "Lambda function URL"
value = aws_lambda_function_url.discord_handler.function_url
}
output "dynamodb_users_table_name" {
description = "DynamoDB users table name"
value = aws_dynamodb_table.users.name
}
output "dynamodb_users_table_arn" {
description = "DynamoDB users table ARN"
value = aws_dynamodb_table.users.arn
}
output "dynamodb_conversations_table_name" {
description = "DynamoDB conversations table name"
value = aws_dynamodb_table.conversations.name
}
output "dynamodb_conversations_table_arn" {
description = "DynamoDB conversations table ARN"
value = aws_dynamodb_table.conversations.arn
}
output "secrets_discord_bot_token_arn" {
description = "Secrets Manager ARN for Discord bot token"
value = aws_secretsmanager_secret.discord_bot_token.arn
sensitive = true
}
output "secrets_discord_public_key_arn" {
description = "Secrets Manager ARN for Discord public key"
value = aws_secretsmanager_secret.discord_public_key.arn
sensitive = true
}
output "secrets_github_token_arn" {
description = "Secrets Manager ARN for GitHub token"
value = aws_secretsmanager_secret.github_token.arn
sensitive = true
}
output "secrets_openai_api_key_arn" {
description = "Secrets Manager ARN for OpenAI API key"
value = aws_secretsmanager_secret.openai_api_key.arn
sensitive = true
}
output "lambda_role_arn" {
description = "Lambda execution role ARN"
value = aws_iam_role.lambda_role.arn
}

View File

@ -0,0 +1,84 @@
resource "aws_secretsmanager_secret" "discord_bot_token" {
name = var.discord_bot_token_secret_name
description = "Discord bot token for PPAL Discord Bot"
recovery_window_in_days = 7
tags = {
Name = "${var.project_name}-discord-bot-token"
}
}
resource "aws_secretsmanager_secret" "discord_public_key" {
name = var.discord_public_key_secret_name
description = "Discord public key for verifying interactions"
recovery_window_in_days = 7
tags = {
Name = "${var.project_name}-discord-public-key"
}
}
resource "aws_secretsmanager_secret" "github_token" {
name = var.github_token_secret_name
description = "GitHub token for PPAL Discord Bot"
recovery_window_in_days = 7
tags = {
Name = "${var.project_name}-github-token"
}
}
resource "aws_secretsmanager_secret" "openai_api_key" {
name = var.openai_api_key_secret_name
description = "OpenAI API key for PPAL Discord Bot"
recovery_window_in_days = 7
tags = {
Name = "${var.project_name}-openai-api-key"
}
}
# Secret versions (placeholder values - must be updated manually or via CI/CD)
resource "aws_secretsmanager_secret_version" "discord_bot_token" {
secret_id = aws_secretsmanager_secret.discord_bot_token.id
secret_string = jsonencode({
token = "PLACEHOLDER_DISCORD_BOT_TOKEN"
})
lifecycle {
ignore_changes = [secret_string]
}
}
resource "aws_secretsmanager_secret_version" "discord_public_key" {
secret_id = aws_secretsmanager_secret.discord_public_key.id
secret_string = jsonencode({
public_key = "PLACEHOLDER_DISCORD_PUBLIC_KEY"
})
lifecycle {
ignore_changes = [secret_string]
}
}
resource "aws_secretsmanager_secret_version" "github_token" {
secret_id = aws_secretsmanager_secret.github_token.id
secret_string = jsonencode({
token = "PLACEHOLDER_GITHUB_TOKEN"
})
lifecycle {
ignore_changes = [secret_string]
}
}
resource "aws_secretsmanager_secret_version" "openai_api_key" {
secret_id = aws_secretsmanager_secret.openai_api_key.id
secret_string = jsonencode({
api_key = "PLACEHOLDER_OPENAI_API_KEY"
})
lifecycle {
ignore_changes = [secret_string]
}
}

View File

@ -0,0 +1,59 @@
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "ap-northeast-1"
}
variable "environment" {
description = "Environment name (e.g., production, staging)"
type = string
default = "production"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "ppal"
}
variable "lambda_handler" {
description = "Lambda handler function name"
type = string
default = "index.handler"
}
variable "lambda_timeout" {
description = "Lambda function timeout in seconds"
type = number
default = 30
}
variable "lambda_memory_size" {
description = "Lambda function memory size in MB"
type = number
default = 256
}
variable "discord_bot_token_secret_name" {
description = "Secrets Manager secret name for Discord bot token"
type = string
default = "ppal/discord/bot-token"
}
variable "discord_public_key_secret_name" {
description = "Secrets Manager secret name for Discord public key"
type = string
default = "ppal/discord/public-key"
}
variable "github_token_secret_name" {
description = "Secrets Manager secret name for GitHub token"
type = string
default = "ppal/github/token"
}
variable "openai_api_key_secret_name" {
description = "Secrets Manager secret name for OpenAI API key"
type = string
default = "ppal/openai/api-key"
}

View File

@ -0,0 +1,22 @@
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
}
}
}