feat(aws): add ECS Fargate deployment for Clawdbot Discord bot

- Create VPC with public subnets for outbound internet access
- Create ECR repository for container images
- Create IAM roles (Task Role with DynamoDB/S3 access, Execution Role)
- Create ECS Task Definition (Fargate, 2048 MB memory, 256 CPU)
- Create ECS Service (1 desired count)
- Create CloudWatch Log Group
- Add Security Group (outbound only for Discord bot)
- Update Dockerfile with .buildstamp to prevent runtime rebuild
- Add .env.example with Discord webhook and channel configurations
- Update .gitignore to exclude Terraform state files

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-01-26 12:31:23 +09:00
parent 3c63ee1d6d
commit f56c9d712a
13 changed files with 728 additions and 0 deletions

View File

@ -3,3 +3,16 @@ TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
# Discord Webhook (for simple notifications)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
# Discord Notification Channel (for clawdbot connection)
# Default: #status channel (PPAL Server)
# Use Discord channel ID (e.g., "1465087451113722019")
DISCORD_NOTIFY_CHANNEL=1465087451113722019
# Discord Second Brain Channel (for memory/knowledge storage)
# Default: #general channel (PPAL Server)
# Use Discord channel ID (e.g., "1465087447225598207")
DISCORD_BRAIN_CHANNEL=1465087447225598207

8
.gitignore vendored
View File

@ -76,3 +76,11 @@ src/**/*.js.map
src/**/*.d.ts
src/**/*.d.ts.map
# Terraform
workers/clawdbot-aws/terraform/.terraform/
workers/clawdbot-aws/terraform/*.tfstate
workers/clawdbot-aws/terraform/*.tfstate.backup
workers/clawdbot-aws/terraform/*.tfplan
workers/clawdbot-aws/terraform/plan.tfplan
workers/clawdbot-aws/terraform/tfplan

View File

@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Create .buildstamp to prevent runtime rebuild
RUN mkdir -p dist && echo "$(date +%s%N)" > dist/.buildstamp
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install

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,124 @@
/**
* DynamoDB Tables for θ-cycle Session Persistence & Artifact Storage
*/
resource "aws_dynamodb_table" "sessions" {
name = var.sessions_table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "sessionId"
attribute {
name = "sessionId"
type = "S"
}
attribute {
name = "userId"
type = "S"
}
attribute {
name = "guildId"
type = "S"
}
attribute {
name = "status"
type = "S"
}
global_secondary_index {
name = "UserIndex"
hash_key = "userId"
range_key = "status"
projection_type = "ALL"
}
global_secondary_index {
name = "GuildIndex"
hash_key = "guildId"
range_key = "status"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
ttl {
attribute_name = "expiresAt"
}
tags = {
Name = "${var.project_name}-sessions"
}
}
resource "aws_dynamodb_table" "artifacts" {
name = var.artifacts_table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
attribute {
name = "sessionId"
type = "S"
}
attribute {
name = "userId"
type = "S"
}
attribute {
name = "type"
type = "S"
}
attribute {
name = "createdAt"
type = "N"
}
attribute {
name = "expiresAt"
type = "N"
}
global_secondary_index {
name = "SessionIndex"
hash_key = "sessionId"
range_key = "createdAt"
projection_type = "ALL"
}
global_secondary_index {
name = "UserIndex"
hash_key = "userId"
range_key = "createdAt"
projection_type = "ALL"
}
global_secondary_index {
name = "TypeIndex"
hash_key = "type"
range_key = "expiresAt"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
ttl {
attribute_name = "expiresAt"
}
tags = {
Name = "${var.project_name}-artifacts"
}
}

View File

@ -0,0 +1,36 @@
/**
* Amazon ECR Repository for Clawdbot Container Images
*/
resource "aws_ecr_repository" "clawdbot" {
name = "${var.project_name}/bot"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Name = "${var.project_name}-ecr"
}
}
# ECR Lifecycle Policy (keep last 10 images)
resource "aws_ecr_lifecycle_policy" "clawdbot" {
repository = aws_ecr_repository.clawdbot.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}]
})
}

View File

@ -0,0 +1,126 @@
/**
* ECS Fargate Configuration for Clawdbot Discord Bot
*/
# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "clawdbot" {
name = "/ecs/${var.project_name}"
retention_in_days = 7
tags = {
Name = "${var.project_name}-logs"
}
}
# ECS Cluster
resource "aws_ecs_cluster" "clawdbot" {
name = "${var.project_name}-cluster"
tags = {
Name = "${var.project_name}-ecs"
}
}
# Security Group (outbound only, no inbound needed for Discord bot)
resource "aws_security_group" "clawdbot" {
name = "${var.project_name}-sg"
description = "Security group for Clawdbot ECS tasks (outbound only)"
vpc_id = aws_vpc.clawdbot.id
# No ingress rules needed - Discord bot initiates outbound WebSocket connections
# Egress (outbound) for internet access
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-sg"
}
}
# ECS Task Definition
resource "aws_ecs_task_definition" "clawdbot" {
family = "${var.project_name}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.ecs_task_cpu
memory = var.ecs_task_memory
execution_role_arn = aws_iam_role.ecs_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_role.arn
container_definitions = jsonencode([
{
name = "${var.project_name}-container"
image = "${aws_ecr_repository.clawdbot.repository_url}:latest"
cpu = var.ecs_task_cpu
memory = var.ecs_task_memory
essential = true
command = ["node", "dist/entry.js", "gateway", "--allow-unconfigured"]
environment = [
{
name = "AWS_REGION"
value = var.aws_region
},
{
name = "SESSIONS_TABLE_NAME"
value = aws_dynamodb_table.sessions.name
},
{
name = "ARTIFACTS_TABLE_NAME"
value = aws_dynamodb_table.artifacts.name
},
{
name = "ARTIFACTS_S3_BUCKET"
value = aws_s3_bucket.artifacts.id
},
{
name = "DISCORD_BOT_TOKEN"
value = var.discord_token
},
{
name = "CLAWDBOT_FORCE_BUILD"
value = "0"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.clawdbot.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
"awslogs-create-group" = "true"
}
}
}
])
tags = {
Name = "${var.project_name}-taskdef"
}
}
# ECS Service
resource "aws_ecs_service" "clawdbot" {
name = "${var.project_name}-service"
cluster = aws_ecs_cluster.clawdbot.id
task_definition = aws_ecs_task_definition.clawdbot.arn
desired_count = var.ecs_service_desired_count
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.public[*].id
security_groups = [aws_security_group.clawdbot.id]
assign_public_ip = true
}
tags = {
Name = "${var.project_name}-service"
}
}

View File

@ -0,0 +1,83 @@
/**
* IAM Roles for ECS Fargate Task Execution
*/
# Assume role policy for ECS tasks
data "aws_iam_policy_document" "ecs_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# ECS Task Execution Role (for ECR pull and CloudWatch logs)
resource "aws_iam_role" "ecs_execution_role" {
name = "${var.project_name}-ecs-execution-role"
assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json
}
# Attach AmazonECSTaskExecutionRolePolicy (required for Fargate)
resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy" {
role = aws_iam_role.ecs_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# ECS Task Role (for application access to DynamoDB and S3)
resource "aws_iam_role" "ecs_task_role" {
name = "${var.project_name}-ecs-task-role"
assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json
}
# Task Role Policy: DynamoDB access
resource "aws_iam_role_policy" "ecs_task_dynamodb" {
name = "${var.project_name}-dynamodb-access"
role = aws_iam_role.ecs_task_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan"
]
Resource = [
aws_dynamodb_table.sessions.arn,
"${aws_dynamodb_table.sessions.arn}/index/*",
aws_dynamodb_table.artifacts.arn,
"${aws_dynamodb_table.artifacts.arn}/index/*"
]
}
]
})
}
# Task Role Policy: S3 access
resource "aws_iam_role_policy" "ecs_task_s3" {
name = "${var.project_name}-s3-access"
role = aws_iam_role.ecs_task_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
]
Resource = "${aws_s3_bucket.artifacts.arn}/*"
}
]
})
}

View File

@ -0,0 +1,99 @@
/**
* Deployment Outputs
*/
output "sessions_table_name" {
description = "DynamoDB table name for session storage"
value = aws_dynamodb_table.sessions.name
}
output "artifacts_table_name" {
description = "DynamoDB table name for artifacts metadata"
value = aws_dynamodb_table.artifacts.name
}
output "artifacts_bucket_name" {
description = "S3 bucket name for artifact storage"
value = aws_s3_bucket.artifacts.id
}
output "artifacts_bucket_arn" {
description = "S3 bucket ARN"
value = aws_s3_bucket.artifacts.arn
}
output "sessions_table_arn" {
description = "DynamoDB sessions table ARN"
value = aws_dynamodb_table.sessions.arn
}
output "artifacts_table_arn" {
description = "DynamoDB artifacts table ARN"
value = aws_dynamodb_table.artifacts.arn
}
output "environment_variables" {
description = "Required environment variables for clawdbot"
value = {
SESSIONS_TABLE_NAME = aws_dynamodb_table.sessions.name
ARTIFACTS_TABLE_NAME = aws_dynamodb_table.artifacts.name
ARTIFACTS_S3_BUCKET = aws_s3_bucket.artifacts.id
AWS_REGION = var.aws_region
}
}
# ECS Outputs
output "ecs_cluster_name" {
description = "ECS cluster name"
value = aws_ecs_cluster.clawdbot.name
}
output "ecs_service_name" {
description = "ECS service name"
value = aws_ecs_service.clawdbot.name
}
output "ecs_task_definition_family" {
description = "ECS task definition family"
value = aws_ecs_task_definition.clawdbot.family
}
output "ecr_repository_url" {
description = "ECR repository URL for container images"
value = aws_ecr_repository.clawdbot.repository_url
}
output "ecr_repository_name" {
description = "ECR repository name"
value = aws_ecr_repository.clawdbot.name
}
output "cloudwatch_log_group_name" {
description = "CloudWatch Log Group name"
value = aws_cloudwatch_log_group.clawdbot.name
}
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.clawdbot.id
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "security_group_id" {
description = "Security Group ID"
value = aws_security_group.clawdbot.id
}
output "ecs_task_role_arn" {
description = "ECS Task Role ARN"
value = aws_iam_role.ecs_task_role.arn
}
output "ecs_execution_role_arn" {
description = "ECS Execution Role ARN"
value = aws_iam_role.ecs_execution_role.arn
}

View File

@ -0,0 +1,60 @@
/**
* S3 Bucket for Artifact Storage
*/
resource "aws_s3_bucket" "artifacts" {
bucket = var.artifacts_bucket_name
tags = {
Name = "${var.project_name}-artifacts"
}
}
# S3 bucket versioning (disabled for cost optimization)
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.bucket
versioning_configuration {
status = "Suspended"
}
}
# S3 bucket server-side encryption (SSE-S3)
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# S3 bucket public access block (private bucket)
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# S3 bucket lifecycle configuration (auto-expire old artifacts)
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-old-artifacts"
status = "Enabled"
filter {}
expiration {
days = var.artifacts_bucket_ttl_days
}
noncurrent_version_expiration {
noncurrent_days = 1
}
}
}

View File

@ -0,0 +1,66 @@
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 = "clawdbot"
}
variable "sessions_table_name" {
description = "DynamoDB table name for session storage"
type = string
default = "clawdbot-sessions"
}
variable "artifacts_table_name" {
description = "DynamoDB table name for artifacts metadata"
type = string
default = "clawdbot-artifacts"
}
variable "artifacts_bucket_name" {
description = "S3 bucket name for artifact storage"
type = string
default = "clawdbot-artifacts"
}
variable "artifacts_bucket_ttl_days" {
description = "S3 bucket lifecycle TTL in days"
type = number
default = 7
}
# ECS Fargate Configuration
variable "ecs_task_cpu" {
description = "ECS task CPU units (256 = 0.25 vCPU)"
type = number
default = 256
}
variable "ecs_task_memory" {
description = "ECS task memory in MB"
type = number
default = 2048
}
variable "ecs_service_desired_count" {
description = "Desired number of ECS tasks"
type = number
default = 1
}
variable "discord_token" {
description = "Discord bot token (DISCORD_BOT_TOKEN) - sensitive, do not commit to version control"
type = string
sensitive = true
}

View File

@ -0,0 +1,23 @@
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"
Component = "clawdbot-backend"
}
}
}

View File

@ -0,0 +1,63 @@
/**
* VPC for Clawdbot ECS Fargate Deployment
* Discord bot requires outbound internet access for WebSocket connections
*/
# VPC
resource "aws_vpc" "clawdbot" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Internet Gateway for outbound internet access
resource "aws_internet_gateway" "clawdbot" {
vpc_id = aws_vpc.clawdbot.id
tags = {
Name = "${var.project_name}-igw"
}
}
# Public Subnets (for Fargate tasks with public IP)
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.clawdbot.id
cidr_block = cidrsubnet(aws_vpc.clawdbot.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
}
}
# Route Table for public subnets
resource "aws_route_table" "public" {
vpc_id = aws_vpc.clawdbot.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.clawdbot.id
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
# Route Table Association for public subnets
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Data source for available AZs
data "aws_availability_zones" "available" {
state = "available"
}