feat(deploy): AWS SAM template + Makefile + GitHub Actions

- AWS SAM CloudFormation template for Lambda + DynamoDB + EventBridge
- SAM config for us-east-1 deployment with guided parameters
- Unified Makefile: build-lambda, dynamodb-local, sam-* targets
- GitHub Actions: OIDC trust + SAM deploy on push to main
- CI job: add iac stage (sam validate)
- .gitignore: build/, bin/, .aws-sam/, samconfig.local.toml
This commit is contained in:
2026-05-10 02:29:49 +07:00
parent 070894444e
commit c07d764aa2
6 changed files with 402 additions and 11 deletions
+11
View File
@@ -64,3 +64,14 @@ jobs:
- name: docker build
run: docker build -t miti99bot-go .
iac:
name: SAM template validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
- name: sam validate (offline)
run: sam validate --lint --region ap-southeast-1
+68
View File
@@ -0,0 +1,68 @@
name: deploy-aws
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write # required for OIDC
contents: read
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
name: SAM deploy (prod)
runs-on: ubuntu-latest
env:
AWS_REGION: ap-southeast-1
STACK_NAME: miti99bot-aws-port
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-deploy-miti99bot
aws-region: ${{ env.AWS_REGION }}
- name: Build Lambda binary
run: make build-lambda
- name: SAM build
run: sam build
- name: SAM deploy
env:
ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }}
run: |
if [ -n "$ALERT_EMAIL" ]; then
sam deploy \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides "AlertEmail=$ALERT_EMAIL"
else
sam deploy \
--no-confirm-changeset \
--no-fail-on-empty-changeset
fi
- name: Smoke test (Function URL responds)
run: |
URL=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text)
echo "FunctionUrl=$URL"
curl -fsSL --max-time 30 "$URL/" | tee /tmp/smoke.json | jq .
+10
View File
@@ -33,3 +33,13 @@ go.work.sum
# Claude Code agent runtime state (settings.json IS checked in; this isn't)
.claude/agent-memory/
# Local build artifacts (Lambda binary, host binary)
build/
bin/
# SAM CLI staging
.aws-sam/
# Per-developer SAM overrides (samconfig.toml IS checked in)
samconfig.local.toml
+81 -11
View File
@@ -1,28 +1,98 @@
.PHONY: test test-emulator firestore-emulator vet build run
.PHONY: help test test-emulator test-dynamodb firestore-emulator dynamodb-local dynamodb-local-stop vet build build-lambda run sam-validate sam-build sam-deploy logs clean
# Default: run unit tests that don't require the Firestore emulator.
test:
# Lambda target architecture. Match Globals.Architectures in template.yaml.
LAMBDA_GOOS ?= linux
LAMBDA_GOARCH ?= arm64
LAMBDA_OUT := build/lambda/bootstrap
help: ## Show this help
@grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
# ---- Test ------------------------------------------------------------------
# Default: run unit tests that don't require any emulator.
test: ## Unit tests (no emulator required)
go test -race -count=1 ./...
# Start a local Firestore emulator (separate terminal). Requires gcloud SDK
# with the cloud-firestore-emulator component installed:
# gcloud components install cloud-firestore-emulator
firestore-emulator:
firestore-emulator: ## Start Firestore emulator on :8085 (foreground)
gcloud emulators firestore start --host-port=localhost:8085
# Run all tests including emulator-gated ones. Expects the emulator to be
# already running (use `make firestore-emulator` in another shell).
test-emulator:
# Run all tests including Firestore-emulator-gated ones. Expects the emulator
# to already be running (use `make firestore-emulator` in another shell).
test-emulator: ## Run tests with Firestore emulator (must be running)
FIRESTORE_EMULATOR_HOST=localhost:8085 \
GOOGLE_CLOUD_PROJECT=miti99bot-go-test \
go test -race -count=1 ./...
vet:
# Run DynamoDB integration tests against DynamoDB Local.
# Override DDB_PORT if 8001 is taken on your host.
DDB_PORT ?= 8001
test-dynamodb: dynamodb-local ## Run DynamoDB tests against DynamoDB Local
DYNAMODB_LOCAL_URL=http://localhost:$(DDB_PORT) LOG_LEVEL=error \
go test -race -count=1 ./internal/storage/...
# ---- Lint / Vet -----------------------------------------------------------
vet: ## go vet
go vet ./...
build:
# ---- Build ----------------------------------------------------------------
build: ## Build the local server binary (host arch)
CGO_ENABLED=0 go build -ldflags="-s -w" -o ./bin/server ./cmd/server
# Local dev run with an in-memory KV (no Firestore needed).
run:
build-lambda: ## Cross-compile bootstrap for Lambda (linux/arm64)
@mkdir -p $(dir $(LAMBDA_OUT))
CGO_ENABLED=0 GOOS=$(LAMBDA_GOOS) GOARCH=$(LAMBDA_GOARCH) \
go build -tags lambda.norpc -ldflags="-s -w" \
-o $(LAMBDA_OUT) ./cmd/server
@chmod +x $(LAMBDA_OUT)
@ls -lh $(LAMBDA_OUT) | awk '{print "lambda binary:", $$5}'
# ---- Run ------------------------------------------------------------------
# Local dev run with an in-memory KV (no Firestore / DynamoDB needed).
run: ## Run locally (in-memory KV)
go run ./cmd/server
# ---- DynamoDB Local for tests ---------------------------------------------
dynamodb-local: ## Start DynamoDB Local container on :$(DDB_PORT) (idempotent)
@if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-ddb$$'; then \
docker run -d --rm --name miti99bot-ddb -p $(DDB_PORT):8000 \
amazon/dynamodb-local -jar DynamoDBLocal.jar -inMemory -sharedDb; \
echo "DynamoDB Local started on :$(DDB_PORT)"; \
sleep 1; \
else \
echo "DynamoDB Local already running"; \
fi
dynamodb-local-stop: ## Stop DynamoDB Local
-docker stop miti99bot-ddb
# ---- SAM (require AWS CLI + SAM CLI installed locally) -------------------
sam-validate: ## Validate template.yaml without contacting AWS
sam validate --lint
sam-build: build-lambda ## sam build (after make build-lambda)
sam build
sam-deploy: sam-build ## Deploy via SAM (uses samconfig.toml). Set ALERT_EMAIL=… optionally.
@if [ -n "$$ALERT_EMAIL" ]; then \
sam deploy --no-confirm-changeset --no-fail-on-empty-changeset \
--parameter-overrides "AlertEmail=$$ALERT_EMAIL"; \
else \
sam deploy --no-confirm-changeset --no-fail-on-empty-changeset; \
fi
logs: ## Tail Lambda logs (last 5m). Override with SINCE=10m.
@sam logs --tail --stack-name miti99bot-aws-port --start-time $${SINCE:-5m}ago
# ---- Clean ----------------------------------------------------------------
clean: ## Remove local build artifacts
rm -rf build/ bin/ .aws-sam/ cov.out
+24
View File
@@ -0,0 +1,24 @@
version = 0.1
[default.global.parameters]
stack_name = "miti99bot-aws-port"
[default.deploy.parameters]
region = "ap-southeast-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = false
fail_on_empty_changeset = false
resolve_s3 = true
s3_prefix = "miti99bot-aws-port"
# Secrets MUST live in SSM Parameter Store (see aws/README.md). Never put
# them here — this file is committed.
parameter_overrides = [
"StackEnv=prod",
]
[default.validate.parameters]
lint = true
[default.build.parameters]
# We build Go ourselves via `make build-lambda`. SAM build only stages.
use_container = false
+208
View File
@@ -0,0 +1,208 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
miti99bot-go: Telegram bot on AWS Lambda (Go ZIP + LWA) with DynamoDB KV
+ EventBridge cron + SSM Parameter Store secrets. Strict free-tier deploy.
Parameters:
StackEnv:
Type: String
Default: prod
AllowedValues: [dev, prod]
Description: Environment suffix used in SSM parameter paths.
ModulesCSV:
Type: String
Default: util,misc,wordle,loldle,loldle-ability,loldle-emoji,loldle-quote,loldle-splash,lolschedule,semantle,doantu,twentyq
Description: Comma-separated module names enabled at runtime (matches MODULES env).
BotOwnerID:
Type: String
Default: ""
Description: Telegram numeric user ID with bot-owner privileges. Empty disables Private/Protected commands.
AdminUserIDs:
Type: String
Default: ""
Description: Comma-separated Telegram user IDs allowed to use admin commands.
Phow2simAPIURL:
Type: String
Default: ""
Description: Optional override for the doantu module's PHOW2SIM endpoint.
# AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys
# are reproducible. Bump by checking the latest at:
# https://github.com/awslabs/aws-lambda-web-adapter/releases
LambdaAdapterLayerArn:
Type: String
Default: arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25
Description: AWS Lambda Web Adapter layer ARN for the deploy region (ARM64).
AlertEmail:
Type: String
Default: ""
Description: Email for $1 budget alert. Leave empty to skip the budget resource.
Conditions:
HasAlertEmail: !Not [!Equals [!Ref AlertEmail, ""]]
Globals:
Function:
Runtime: provided.al2023
Architectures: [arm64]
MemorySize: 256
Timeout: 30
Tracing: Active
LoggingConfig:
LogFormat: JSON
ApplicationLogLevel: INFO
SystemLogLevel: WARN
Resources:
# --- Storage --------------------------------------------------------------
BotTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub "${AWS::StackName}-data"
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: pk, AttributeType: S }
- { AttributeName: sk, AttributeType: S }
KeySchema:
- { AttributeName: pk, KeyType: HASH }
- { AttributeName: sk, KeyType: RANGE }
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: false # paid feature; off for free tier
Tags:
- { Key: app, Value: miti99bot }
- { Key: env, Value: !Ref StackEnv }
# --- Compute --------------------------------------------------------------
BotFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-bot"
RetentionInDays: 7
BotFunction:
Type: AWS::Serverless::Function
DependsOn: BotFunctionLogGroup
Properties:
FunctionName: !Sub "${AWS::StackName}-bot"
CodeUri: build/lambda/
Handler: bootstrap
Layers:
- !Ref LambdaAdapterLayerArn
LoggingConfig:
LogGroup: !Ref BotFunctionLogGroup
Environment:
Variables:
PORT: "8080"
AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
READINESS_CHECK_PATH: /
# ---- App config (non-secret) ----
KV_PROVIDER: dynamodb
DYNAMODB_TABLE: !Ref BotTable
MODULES: !Ref ModulesCSV
BOT_OWNER_ID: !Ref BotOwnerID
ADMIN_USER_IDS: !Ref AdminUserIDs
PHOW2SIM_API_URL: !Ref Phow2simAPIURL
# ---- Secrets (resolved from Parameter Store at deploy time) ----
# Token rotation = update parameter, redeploy stack. For zero-redeploy
# rotation, switch to runtime fetch in main.go.
TELEGRAM_BOT_TOKEN: !Sub "{{resolve:ssm-secure:/miti99bot/${StackEnv}/telegram-bot-token:1}}"
TELEGRAM_WEBHOOK_SECRET: !Sub "{{resolve:ssm-secure:/miti99bot/${StackEnv}/telegram-webhook-secret:1}}"
GEMINI_API_KEY: !Sub "{{resolve:ssm-secure:/miti99bot/${StackEnv}/gemini-api-key:1}}"
CRON_SHARED_SECRET: !Sub "{{resolve:ssm-secure:/miti99bot/${StackEnv}/cron-shared-secret:1}}"
FunctionUrlConfig:
AuthType: NONE
InvokeMode: BUFFERED
Cors:
AllowOrigins: ["*"] # Telegram doesn't send CORS; safe default
AllowMethods: ["POST", "GET"]
AllowHeaders: ["*"]
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref BotTable
# --- Cron -----------------------------------------------------------------
CronDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "${AWS::StackName}-cron-dlq"
MessageRetentionPeriod: 1209600 # 14 days
SchedulerExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: scheduler.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: cron-https-invoke
PolicyDocument:
Version: '2012-10-17'
Statement:
# HTTPS targets to Lambda Function URL — Scheduler treats them as
# invocations of the function. Lock to this function's ARN only.
- Effect: Allow
Action: lambda:InvokeFunctionUrl
Resource: !GetAtt BotFunction.Arn
# DLQ
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt CronDLQ.Arn
# NOTE: Concrete schedules are added in Phase 04. We provision the role +
# DLQ here so the IaC review surface stays accurate and Phase 04 just adds
# AWS::Scheduler::Schedule resources.
# --- Cost guard -----------------------------------------------------------
MonthlyBudget:
Type: AWS::Budgets::Budget
Condition: HasAlertEmail
Properties:
Budget:
BudgetName: !Sub "${AWS::StackName}-monthly"
BudgetLimit: { Amount: '1', Unit: USD }
TimeUnit: MONTHLY
BudgetType: COST
NotificationsWithSubscribers:
- Notification:
ComparisonOperator: GREATER_THAN
NotificationType: ACTUAL
Threshold: 80
ThresholdType: PERCENTAGE
Subscribers:
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
- Notification:
ComparisonOperator: GREATER_THAN
NotificationType: ACTUAL
Threshold: 100
ThresholdType: PERCENTAGE
Subscribers:
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
Outputs:
FunctionUrl:
Description: Public Function URL — set this as the Telegram webhook
Value: !GetAtt BotFunctionUrl.FunctionUrl
TableName:
Description: DynamoDB table name (also exposed to the Lambda via DYNAMODB_TABLE)
Value: !Ref BotTable
LogGroup:
Description: CloudWatch log group for the bot Lambda
Value: !Ref BotFunctionLogGroup
CronDLQArn:
Description: ARN of the cron dead-letter queue
Value: !GetAtt CronDLQ.Arn