mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-06 02:23:48 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user