Fix AWS Lambda deploy setup

This commit is contained in:
2026-05-15 22:18:27 +07:00
parent ac435aa090
commit ecf92faf22
9 changed files with 259 additions and 25 deletions
+57 -1
View File
@@ -1,10 +1,17 @@
.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
.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 telegram-setup telegram-webhook telegram-webhook-info telegram-commands telegram-commands-info logs clean
# Lambda target architecture. Match Globals.Architectures in template.yaml.
LAMBDA_GOOS ?= linux
LAMBDA_GOARCH ?= arm64
LAMBDA_OUT := build/lambda/bootstrap
# AWS deploy defaults. Override as needed:
# make telegram-webhook AWS_PROFILE=admin STACK_NAME=miti99bot STACK_ENV=prod
AWS_PROFILE ?= admin
STACK_NAME ?= miti99bot
STACK_ENV ?= prod
TELEGRAM_COMMANDS_FILE ?= aws/telegram-commands.json
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}'
@@ -91,6 +98,55 @@ sam-deploy: build-lambda ## Deploy via SAM (uses samconfig.toml). Set ALERT_EMAI
--no-confirm-changeset --no-fail-on-empty-changeset; \
fi
telegram-setup: telegram-webhook telegram-commands ## Register Telegram webhook and command menu
telegram-webhook: ## Register Telegram webhook from stack FunctionUrl + SSM secrets
@set -eu; \
URL=$$(aws --profile "$(AWS_PROFILE)" cloudformation describe-stacks \
--stack-name "$(STACK_NAME)" \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text); \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
SECRET=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-webhook-secret" \
--with-decryption --query Parameter.Value --output text); \
case "$$URL" in */) WEBHOOK_URL="$${URL}webhook" ;; *) WEBHOOK_URL="$${URL}/webhook" ;; esac; \
echo "Setting Telegram webhook to $$WEBHOOK_URL"; \
curl -sS -X POST "https://api.telegram.org/bot$${TOKEN}/setWebhook" \
-d "url=$${WEBHOOK_URL}" \
-d "secret_token=$${SECRET}" \
-d 'allowed_updates=["message","callback_query"]'; \
echo
telegram-webhook-info: ## Show Telegram getWebhookInfo using token from SSM
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
curl -sS "https://api.telegram.org/bot$${TOKEN}/getWebhookInfo"; \
echo
telegram-commands: ## Register Telegram command menu from TELEGRAM_COMMANDS_FILE
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
echo "Registering Telegram commands from $(TELEGRAM_COMMANDS_FILE)"; \
curl -sS -X POST "https://api.telegram.org/bot$${TOKEN}/setMyCommands" \
-H 'Content-Type: application/json' \
--data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \
echo
telegram-commands-info: ## Show Telegram getMyCommands using token from SSM
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
curl -sS "https://api.telegram.org/bot$${TOKEN}/getMyCommands"; \
echo
logs: ## Tail Lambda logs (last 5m). Override with SINCE=10m.
@sam logs --tail --stack-name miti99bot --start-time $${SINCE:-5m}ago
+96
View File
@@ -0,0 +1,96 @@
{
"commands": [
{
"command": "help",
"description": "Show all available commands"
},
{
"command": "info",
"description": "Show chat, thread, and sender IDs"
},
{
"command": "ping",
"description": "Health check; replies pong"
},
{
"command": "wordle",
"description": "Classic wordle; guess the 5-letter word"
},
{
"command": "wordle_new",
"description": "Start a new wordle round"
},
{
"command": "wordle_giveup",
"description": "Reveal the current wordle answer"
},
{
"command": "wordle_stats",
"description": "Show your wordle stats"
},
{
"command": "loldle",
"description": "Classic loldle; guess the champion"
},
{
"command": "loldle_giveup",
"description": "Reveal the current loldle answer"
},
{
"command": "loldle_stats",
"description": "Show your loldle stats"
},
{
"command": "lolschedule",
"description": "LoL matches for a date"
},
{
"command": "lolschedule_today",
"description": "Today's LoL esports matches"
},
{
"command": "lolschedule_week",
"description": "LoL matches for the next 7 days"
},
{
"command": "lolschedule_subscribe",
"description": "Get the daily LoL schedule digest"
},
{
"command": "lolschedule_unsubscribe",
"description": "Stop the daily LoL schedule digest"
},
{
"command": "twentyq",
"description": "20 questions; ask yes/no questions"
},
{
"command": "twentyq_giveup",
"description": "Reveal the current twentyq answer"
},
{
"command": "twentyq_stats",
"description": "Show your twentyq stats"
},
{
"command": "trade_topup",
"description": "Top up VND to your trading account"
},
{
"command": "trade_buy",
"description": "Buy VN stock at market price"
},
{
"command": "trade_sell",
"description": "Sell VN stock back to VND"
},
{
"command": "trade_convert",
"description": "Currency exchange"
},
{
"command": "trade_stats",
"description": "Show portfolio summary with P&L"
}
]
}
+80 -5
View File
@@ -12,6 +12,9 @@ import (
"syscall"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ssm"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/metrics"
@@ -52,8 +55,18 @@ const firestoreInitTimeout = 10 * time.Second
// has a 10s init phase; we want to leave headroom for module wiring.
const dynamodbInitTimeout = 5 * time.Second
// ssmInitTimeout caps cold-start secret resolution. Secrets are fetched once
// at startup from Parameter Store when *_PARAMETER_NAME env vars are set.
const ssmInitTimeout = 5 * time.Second
func main() {
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg := loadConfig()
if err := resolveSSMSecrets(rootCtx, &cfg); err != nil {
log.Fatal("ssm secret resolution failed", "err", err)
}
if cfg.TelegramBotToken == "" {
log.Fatal("missing required env", "key", "TELEGRAM_BOT_TOKEN")
}
@@ -62,9 +75,6 @@ func main() {
"why", "non-empty secret is the only auth on /webhook")
}
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Periodic metrics flush. Cancels with rootCtx and emits one final
// flush on shutdown so the trailing window isn't lost.
go metrics.Run(rootCtx)
@@ -223,7 +233,7 @@ type config struct {
TelegramBotToken string
WebhookSecret string
CronSecret string
FirestoreProject string
FirestoreProject string
FirestoreEmulatorHost string
GeminiAPIKey string
Modules []string
@@ -231,6 +241,10 @@ type config struct {
AdminUserIDs map[int64]bool
KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb"
DynamoDBTable string // required when KVProvider=dynamodb
TelegramBotTokenParam string
WebhookSecretParam string
CronSecretParam string
GeminiAPIKeyParam string
}
func loadConfig() config {
@@ -255,7 +269,7 @@ func loadConfig() config {
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
WebhookSecret: envMap["TELEGRAM_WEBHOOK_SECRET"],
CronSecret: envMap["CRON_SHARED_SECRET"],
FirestoreProject: envMap["GOOGLE_CLOUD_PROJECT"],
FirestoreProject: envMap["GOOGLE_CLOUD_PROJECT"],
FirestoreEmulatorHost: envMap["FIRESTORE_EMULATOR_HOST"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
Modules: splitCSV(envMap["MODULES"]),
@@ -263,9 +277,70 @@ func loadConfig() config {
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
KVProvider: envMap["KV_PROVIDER"],
DynamoDBTable: envMap["DYNAMODB_TABLE"],
TelegramBotTokenParam: strings.TrimSpace(envMap["TELEGRAM_BOT_TOKEN_PARAMETER_NAME"]),
WebhookSecretParam: strings.TrimSpace(envMap["TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME"]),
CronSecretParam: strings.TrimSpace(envMap["CRON_SHARED_SECRET_PARAMETER_NAME"]),
GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]),
}
}
func resolveSSMSecrets(ctx context.Context, cfg *config) error {
bindings := []struct {
name string
target *string
}{
{name: cfg.TelegramBotTokenParam, target: &cfg.TelegramBotToken},
{name: cfg.WebhookSecretParam, target: &cfg.WebhookSecret},
{name: cfg.CronSecretParam, target: &cfg.CronSecret},
{name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey},
}
targetsByName := map[string][]*string{}
names := make([]string, 0, len(bindings))
for _, b := range bindings {
if b.name == "" || *b.target != "" {
continue
}
if _, ok := targetsByName[b.name]; !ok {
names = append(names, b.name)
}
targetsByName[b.name] = append(targetsByName[b.name], b.target)
}
if len(names) == 0 {
return nil
}
initCtx, cancel := context.WithTimeout(ctx, ssmInitTimeout)
defer cancel()
awsCfg, err := awsconfig.LoadDefaultConfig(initCtx, awsconfig.WithHTTPClient(&http.Client{
Timeout: ssmInitTimeout,
}))
if err != nil {
return fmt.Errorf("load AWS config: %w", err)
}
client := ssm.NewFromConfig(awsCfg)
out, err := client.GetParameters(initCtx, &ssm.GetParametersInput{
Names: names,
WithDecryption: aws.Bool(true),
})
if err != nil {
return fmt.Errorf("get parameters: %w", err)
}
if len(out.InvalidParameters) > 0 {
return fmt.Errorf("missing SSM parameters: %s", strings.Join(out.InvalidParameters, ","))
}
for _, p := range out.Parameters {
name := aws.ToString(p.Name)
value := aws.ToString(p.Value)
for _, target := range targetsByName[name] {
*target = value
}
}
log.Info("loaded secrets from ssm", "count", len(out.Parameters))
return nil
}
func splitCSV(s string) []string {
if s == "" {
return nil
+4 -4
View File
@@ -143,7 +143,7 @@ aws ssm put-parameter --profile admin --type SecureString --overwrite \
unset GEMINI_KEY
```
> Skip the Gemini one if you're not using the `twentyq` module — store `"unused"` so CloudFormation can still resolve it, then drop `twentyq` from `MODULES`.
> Skip the Gemini one if you're not using the `twentyq` module — store `"unused"` so the Lambda startup secret fetch still succeeds, then drop `twentyq` from `MODULES`.
Generated secrets — random hex, no input needed:
@@ -372,8 +372,8 @@ To find the last good SHA quickly: `git log --oneline -- template.yaml cmd/serve
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/telegram-webhook-secret \
--value "$(openssl rand -hex 32)"
make sam-deploy # picks up the latest SSM value
# Then re-run setWebhook (Step 5) with the new secret_token.
# Recycle the Lambda before switching Telegram to the new secret, or wait for
# AWS to create a fresh execution environment naturally.
```
> **The `:1` in `template.yaml` is not "version 1 forever".** `{{resolve:ssm-secure:…:1}}` is a CloudFormation pin to *whatever version 1 means at deploy time* — the literal integer is a required syntax element, not a frozen index. After `put-parameter --overwrite`, SSM bumps the version number; CloudFormation reads the latest at the next `sam deploy` and updates the Lambda env. If you want zero-redeploy rotation, switch `main.go` to fetch from SSM at startup or per-request instead of consuming the env var.
> `template.yaml` passes only SSM parameter names to Lambda. The app fetches the current SecureString values during cold start, so no secret values are embedded in CloudFormation. Existing warm Lambda environments keep the old value until AWS recycles them or you force a function update; rotate the Telegram webhook secret by refreshing Lambda first, then re-running Step 5 with the new `secret_token`.
+2 -2
View File
@@ -107,12 +107,12 @@ make sam-deploy
```sh
# Errors / warnings in last 24h
aws logs filter-log-events --log-group-name /aws/lambda/miti99bot-bot \
aws logs filter-log-events --log-group-name /aws/lambda/miti99bot \
--start-time $(($(date +%s%3N) - 86400000)) \
--filter-pattern '{ $.level = "ERROR" }' --max-items 20
# Cold start P95
aws logs start-query --log-group-name /aws/lambda/miti99bot-bot \
aws logs start-query --log-group-name /aws/lambda/miti99bot \
--start-time $(($(date +%s) - 86400)) --end-time $(date +%s) \
--query-string 'filter @type = "REPORT" | stats avg(@initDuration), pct(@initDuration, 95)'
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6
github.com/go-telegram/bot v1.20.0
golang.org/x/time v0.15.0
google.golang.org/api v0.274.0
+2
View File
@@ -34,6 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/ku
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6 h1:0LPJjbSNEDHidGOXa0LfvSVbdn9/GdlJUQTgE0kFpso=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6/go.mod h1:SrZAopBP5/lyQ6NBVXKlRp8wPIXhzBCZU98sEozmv8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
+4 -4
View File
@@ -2,19 +2,19 @@ version = 0.1
[default.global.parameters]
stack_name = "miti99bot"
region = "ap-southeast-1"
[default.deploy.parameters]
region = "ap-southeast-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = false
confirm_changeset = true
fail_on_empty_changeset = false
resolve_s3 = true
s3_prefix = "miti99bot"
# Secrets MUST live in SSM Parameter Store (see aws/README.md). Never put
# them here — this file is committed.
parameter_overrides = [
"StackEnv=prod",
]
parameter_overrides = "StackEnv=\"prod\" ModulesCSV=\"util,misc,wordle,loldle,lolschedule,twentyq,trading\" BotOwnerID=\"1064111334\" AdminUserIDs=\"1064111334\" LambdaAdapterLayerArn=\"arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25\" AlertEmail=\"minhtienit99@gmail.com\""
image_repositories = []
[default.validate.parameters]
lint = true
+13 -9
View File
@@ -81,7 +81,7 @@ Resources:
BotFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-bot"
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}"
RetentionInDays: 7
# Lambda emits a synthetic REPORT line at the end of every invocation.
@@ -102,7 +102,7 @@ Resources:
BotFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-bot"
FunctionName: !Ref AWS::StackName
CodeUri: build/lambda/
Handler: bootstrap
Layers:
@@ -120,13 +120,11 @@ Resources:
MODULES: !Ref ModulesCSV
BOT_OWNER_ID: !Ref BotOwnerID
ADMIN_USER_IDS: !Ref AdminUserIDs
# ---- 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}}"
# ---- Secrets (fetched from Parameter Store at Lambda cold start) ----
TELEGRAM_BOT_TOKEN_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-bot-token"
TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-webhook-secret"
GEMINI_API_KEY_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/gemini-api-key"
CRON_SHARED_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/cron-shared-secret"
FunctionUrlConfig:
AuthType: NONE
InvokeMode: BUFFERED
@@ -137,6 +135,12 @@ Resources:
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref BotTable
- Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- ssm:GetParameters
Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/miti99bot/${StackEnv}/*"
# --- Cron -----------------------------------------------------------------