From c07d764aa2ca8fdc7608eb4b3a44f5a71e97328c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 02:29:49 +0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 11 ++ .github/workflows/deploy.yml | 68 ++++++++++++ .gitignore | 10 ++ Makefile | 92 ++++++++++++++-- samconfig.toml | 24 ++++ template.yaml | 208 +++++++++++++++++++++++++++++++++++ 6 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 samconfig.toml create mode 100644 template.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5f159d..27028bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e6cba17 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 . diff --git a/.gitignore b/.gitignore index 4e58423..3402d99 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 6c24b02..166876b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/samconfig.toml b/samconfig.toml new file mode 100644 index 0000000..8a51c9c --- /dev/null +++ b/samconfig.toml @@ -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 diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..4672193 --- /dev/null +++ b/template.yaml @@ -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