diff --git a/workers/ppal-aws/terraform/.gitignore b/workers/ppal-aws/terraform/.gitignore new file mode 100644 index 000000000..1c99dc138 --- /dev/null +++ b/workers/ppal-aws/terraform/.gitignore @@ -0,0 +1 @@ +.terraform/ diff --git a/workers/ppal-aws/terraform/.terraform.lock.hcl b/workers/ppal-aws/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..cdc1668d4 --- /dev/null +++ b/workers/ppal-aws/terraform/.terraform.lock.hcl @@ -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", + ] +} diff --git a/workers/ppal-aws/terraform/api_gateway.tf b/workers/ppal-aws/terraform/api_gateway.tf new file mode 100644 index 000000000..264c20037 --- /dev/null +++ b/workers/ppal-aws/terraform/api_gateway.tf @@ -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}/*/*" +} diff --git a/workers/ppal-aws/terraform/dynamodb.tf b/workers/ppal-aws/terraform/dynamodb.tf new file mode 100644 index 000000000..c7e743fff --- /dev/null +++ b/workers/ppal-aws/terraform/dynamodb.tf @@ -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" + } +} diff --git a/workers/ppal-aws/terraform/iam.tf b/workers/ppal-aws/terraform/iam.tf new file mode 100644 index 000000000..d9b5be924 --- /dev/null +++ b/workers/ppal-aws/terraform/iam.tf @@ -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 = "*" + } + ] + }) +} diff --git a/workers/ppal-aws/terraform/lambda.tf b/workers/ppal-aws/terraform/lambda.tf new file mode 100644 index 000000000..5db9c6475 --- /dev/null +++ b/workers/ppal-aws/terraform/lambda.tf @@ -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"] + } +} diff --git a/workers/ppal-aws/terraform/outputs.tf b/workers/ppal-aws/terraform/outputs.tf new file mode 100644 index 000000000..8fd52a911 --- /dev/null +++ b/workers/ppal-aws/terraform/outputs.tf @@ -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 +} diff --git a/workers/ppal-aws/terraform/secrets.tf b/workers/ppal-aws/terraform/secrets.tf new file mode 100644 index 000000000..9ae19e00a --- /dev/null +++ b/workers/ppal-aws/terraform/secrets.tf @@ -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] + } +} diff --git a/workers/ppal-aws/terraform/variables.tf b/workers/ppal-aws/terraform/variables.tf new file mode 100644 index 000000000..f4ef52154 --- /dev/null +++ b/workers/ppal-aws/terraform/variables.tf @@ -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" +} diff --git a/workers/ppal-aws/terraform/versions.tf b/workers/ppal-aws/terraform/versions.tf new file mode 100644 index 000000000..f4610523b --- /dev/null +++ b/workers/ppal-aws/terraform/versions.tf @@ -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" + } + } +}