From 6e35c222d37a6edfdd2dbdb4c915f6948ed75e5e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 29 Jun 2026 00:11:42 +0700 Subject: [PATCH] refactor(deploy): remove retired aws support --- .env.example | 13 +- .github/workflows/ci.yml | 16 +- .github/workflows/deploy.yml | 130 ------ Makefile | 166 +------ README.md | 24 +- aws/README.md | 164 ------- aws/iam-github-deploy-policy.json | 236 ---------- aws/iam-github-oidc-trust.json | 22 - aws/iam-rollback-fullaccess.sh | 51 --- cmd/migrate-dynamo-to-mongo/README.md | 83 ---- cmd/migrate-dynamo-to-mongo/encode.go | 109 ----- cmd/migrate-dynamo-to-mongo/encode_test.go | 87 ---- cmd/migrate-dynamo-to-mongo/main.go | 225 ---------- cmd/migrate-dynamo-to-mongo/main_test.go | 158 ------- cmd/server/main.go | 159 ++----- compose.yml | 2 - docs/aws-decommission-runbook.md | 126 ------ docs/deploy-aws-free-tier-guide.md | 415 ------------------ docs/deploy-aws.md | 216 --------- docs/deploy-coolify-selfhosted.md | 83 +--- go.mod | 17 - go.sum | 34 -- internal/ai/client.go | 11 +- internal/ai/ratelimit.go | 9 +- internal/cron/scheduler.go | 7 +- internal/keylock/keylock.go | 5 +- internal/log/log.go | 5 +- internal/metrics/counters.go | 21 +- internal/modules/coin/portfolio_test.go | 2 +- internal/modules/coin/views.go | 3 +- internal/modules/gold/portfolio_test.go | 2 +- internal/modules/gold/price_providers.go | 2 +- internal/modules/gold/vnappmob_client.go | 5 +- internal/modules/loldle/state_test.go | 7 - internal/modules/lolschedule/api_client.go | 7 +- internal/modules/lolschedule/cron.go | 9 +- internal/modules/lolschedule/cron_test.go | 2 +- internal/modules/lolschedule/handlers.go | 2 +- internal/modules/stats/stats.go | 2 +- internal/modules/wordle/state_test.go | 7 - internal/server/health.go | 6 +- internal/storage/doc_store.go | 2 +- internal/storage/dynamodb_client.go | 50 --- internal/storage/mongo_doc_store_test.go | 2 +- internal/telegram/webhook.go | 2 +- samconfig.toml | 24 - ...am-commands.json => telegram-commands.json | 0 template.yaml | 330 -------------- 48 files changed, 142 insertions(+), 2918 deletions(-) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 aws/README.md delete mode 100644 aws/iam-github-deploy-policy.json delete mode 100644 aws/iam-github-oidc-trust.json delete mode 100755 aws/iam-rollback-fullaccess.sh delete mode 100644 cmd/migrate-dynamo-to-mongo/README.md delete mode 100644 cmd/migrate-dynamo-to-mongo/encode.go delete mode 100644 cmd/migrate-dynamo-to-mongo/encode_test.go delete mode 100644 cmd/migrate-dynamo-to-mongo/main.go delete mode 100644 cmd/migrate-dynamo-to-mongo/main_test.go delete mode 100644 docs/aws-decommission-runbook.md delete mode 100644 docs/deploy-aws-free-tier-guide.md delete mode 100644 docs/deploy-aws.md delete mode 100644 internal/storage/dynamodb_client.go delete mode 100644 samconfig.toml rename aws/telegram-commands.json => telegram-commands.json (100%) delete mode 100644 template.yaml diff --git a/.env.example b/.env.example index 0c6c8ba..1bd121a 100644 --- a/.env.example +++ b/.env.example @@ -25,22 +25,13 @@ GEMINI_API_KEY= # SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM. # Do NOT set it here. On Coolify (Docker Compose) it is a predefined variable -# that reaches the container only because docker-compose.yml references it +# that reaches the container only because compose.yml references it # (`SOURCE_COMMIT: ${SOURCE_COMMIT:-}`) — Coolify supplies the value via # --env-file. Local `docker compose up` has none, so deploynotify reports # "unknown". # ====================== Leave UNSET on self-host ================== -# These are AWS-only. cmd/server reads secrets directly from the plain env -# vars above; *_PARAMETER_NAME would force an SSM lookup that FAILS with no AWS -# credentials and bricks startup. Do NOT set any of them: -# TELEGRAM_BOT_TOKEN_PARAMETER_NAME -# TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME -# GEMINI_API_KEY_PARAMETER_NAME -# STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME -# GOLD_VNAPP_API_KEY_PARAMETER_NAME -# -# Also leave unset (defaults are correct for self-host): +# Defaults are correct for self-host: # KV_PROVIDER — auto-selects mongodb because MONGO_URL is set # PORT — defaults to 8080 (internal health server) # TELEGRAM_WEBHOOK_SECRET — long polling has no webhook diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba9a11c..d46a663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,8 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - # No DB emulator step: storage integration tests (mongodb, dynamodb) skip - # gracefully when MONGODB_TEST_URL / DYNAMODB_LOCAL_URL are unset. Run them - # locally via `make test-mongo` / `make test-dynamodb`. + # No DB emulator step: MongoDB integration tests skip gracefully when + # MONGODB_TEST_URL is unset. Run them locally via `make test-mongo`. - name: go test env: # Quiet test logs so real failures stand out. @@ -64,14 +63,3 @@ jobs: - name: docker build run: docker build -t miti99bot . - - iac: - name: SAM template validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: aws-actions/setup-sam@v3 - 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 deleted file mode 100644 index 04ecc0b..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: deploy-aws - -# RETIRED: miti99bot is self-hosted on Coolify + MongoDB Atlas (see -# docs/deploy-coolify-selfhosted.md). The AWS stack is decommissioned -# (docs/aws-decommission-runbook.md). The automatic push-to-main trigger is -# removed so a merge can never recreate the AWS stack. Kept as manual-only -# (workflow_dispatch) for reference; it requires the github-deploy-miti99bot -# OIDC role, which the decommission deletes — so a manual run fails until AWS -# is intentionally re-bootstrapped. -on: - 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 - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version: '1.25' - cache: true - - - uses: aws-actions/setup-sam@v3 - with: - use-installer: true - - - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: arn:aws:iam::225603493174:role/github-deploy-miti99bot - aws-region: ${{ env.AWS_REGION }} - - - name: Build Lambda binary - run: make build-lambda - - - name: SAM deploy - env: - ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }} - STACK_ENV: prod - run: | - set -euo pipefail - # EventBridge Connection consumes ApiKeyValue at stack-update time - # and stores it in a service-linked secret. SSM holds the canonical - # value; fetch here and pass as a NoEcho CFN parameter so it never - # appears in template source or stack events. - CRON_SECRET=$(aws ssm get-parameter \ - --name "/miti99bot/${STACK_ENV}/cron-shared-secret" \ - --with-decryption --query Parameter.Value --output text) - echo "::add-mask::$CRON_SECRET" - # Non-secret CFN params. SAM CLI's --parameter-overrides REPLACES - # samconfig.toml's parameter_overrides (does not merge), so anything - # CI needs in the deployed stack must be listed here explicitly. - # Telegram user IDs are public (visible to anyone the bot DMs), so - # they live in this committed workflow rather than a secret. - # Keep this module list in sync with samconfig.toml's ModulesCSV. - OVERRIDES="CronSharedSecret=$CRON_SECRET BotOwnerID=1064111334 AdminUserIDs=1064111334 ModulesCSV=util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin" - if [ -n "$ALERT_EMAIL" ]; then - OVERRIDES="$OVERRIDES AlertEmail=$ALERT_EMAIL" - fi - sam deploy --template-file template.yaml \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides "$OVERRIDES" - - - 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 . - - - name: Register Telegram webhook - env: - STACK_ENV: prod - run: | - set -euo pipefail - URL=$(aws cloudformation describe-stacks \ - --stack-name "$STACK_NAME" \ - --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \ - --output text) - TOKEN=$(aws ssm get-parameter \ - --name "/miti99bot/${STACK_ENV}/telegram-bot-token" \ - --with-decryption --query Parameter.Value --output text) - echo "::add-mask::$TOKEN" - SECRET=$(aws ssm get-parameter \ - --name "/miti99bot/${STACK_ENV}/telegram-webhook-secret" \ - --with-decryption --query Parameter.Value --output text) - echo "::add-mask::$SECRET" - WEBHOOK_URL="${URL%/}/webhook" - echo "Setting Telegram webhook to ${WEBHOOK_URL}" - RESP=$(curl -fsS --max-time 30 -X POST \ - "https://api.telegram.org/bot${TOKEN}/setWebhook" \ - -d "url=${WEBHOOK_URL}" \ - -d "secret_token=${SECRET}" \ - -d 'allowed_updates=["message","callback_query"]') - echo "$RESP" | jq -e '.ok == true' >/dev/null \ - || { echo "setWebhook failed: $RESP"; exit 1; } - echo "$RESP" | jq '{ok, result, description}' - - - name: Register Telegram command menu - env: - STACK_ENV: prod - run: | - set -euo pipefail - TOKEN=$(aws ssm get-parameter \ - --name "/miti99bot/${STACK_ENV}/telegram-bot-token" \ - --with-decryption --query Parameter.Value --output text) - echo "::add-mask::$TOKEN" - echo "Registering Telegram commands from aws/telegram-commands.json" - RESP=$(curl -fsS --max-time 30 -X POST \ - "https://api.telegram.org/bot${TOKEN}/setMyCommands" \ - -H 'Content-Type: application/json' \ - --data-binary "@aws/telegram-commands.json") - echo "$RESP" | jq -e '.ok == true' >/dev/null \ - || { echo "setMyCommands failed: $RESP"; exit 1; } - echo "$RESP" | jq '{ok, result, description}' diff --git a/Makefile b/Makefile index c7a20db..26e4f61 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,20 @@ -.PHONY: help test test-dynamodb test-mongo dynamodb-local dynamodb-local-stop mongo-local mongo-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 telegram-commands-selfhost telegram-deletewebhook-selfhost telegram-webhook-info-selfhost migrate-dynamo-to-mongo migrate-verify logs clean +.PHONY: help test test-mongo mongo-local mongo-local-stop vet build run telegram-commands telegram-commands-info telegram-deletewebhook telegram-webhook-info clean -# Lambda target architecture. Match Globals.Architectures in template.yaml. -LAMBDA_GOOS ?= linux -LAMBDA_GOARCH ?= arm64 -LAMBDA_OUT := build/lambda/bootstrap - -# Short git SHA baked into the binary at link time. Consumed by -# internal/deploynotify to DM the owner once per new version. Falls back to -# empty string outside a git checkout (tarball, fresh clone without history) -# — deploynotify treats empty as "stay silent". +# Short git SHA baked into local binaries. Coolify sets SOURCE_COMMIT at +# runtime; this ldflags value is the fallback for local builds. GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null) LDFLAGS := -s -w -X main.gitSHA=$(GIT_SHA) -# 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 +TELEGRAM_COMMANDS_FILE ?= 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}' # ---- Test ------------------------------------------------------------------ -# Default: run unit tests that don't require any emulator. test: ## Unit tests (no emulator required) go test -race -count=1 ./... -# Run DynamoDB integration tests against DynamoDB Local. -# Override DDB_PORT if 8001 is taken on your host. -DDB_PORT ?= 8001 -test-dynamodb: dynamodb-local mongo-local ## Run the DynamoDB→Mongo migrator e2e against local emulators - DYNAMODB_LOCAL_URL=http://localhost:$(DDB_PORT) \ - MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) \ - MONGO_DATABASE=migrate_test LOG_LEVEL=error \ - go test -race -count=1 ./cmd/migrate-dynamo-to-mongo/... - # Run MongoDB integration tests against a local Mongo container. # Override MONGO_PORT if 27017 is taken on your host. MONGO_PORT ?= 27017 @@ -44,46 +22,22 @@ test-mongo: mongo-local ## Run MongoDB tests against a local Mongo container MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) LOG_LEVEL=error \ go test -race -count=1 ./internal/storage/... -# ---- Lint / Vet ----------------------------------------------------------- +# ---- Lint / Vet ------------------------------------------------------------ vet: ## go vet go vet ./... -# ---- Build ---------------------------------------------------------------- +# ---- Build ----------------------------------------------------------------- build: ## Build the local server binary (host arch) CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o ./bin/server ./cmd/server -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="$(LDFLAGS)" \ - -o $(LAMBDA_OUT) ./cmd/server - @chmod +x $(LAMBDA_OUT) - @ls -lh $(LAMBDA_OUT) | awk '{print "lambda binary:", $$5}' +# ---- Run ------------------------------------------------------------------- -# ---- Run ------------------------------------------------------------------ - -# Local dev run with an in-memory KV (no database needed). -run: ## Run locally (in-memory KV) +run: ## Run locally (in-memory storage unless MONGO_URL is set) 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 - -# ---- MongoDB local for tests ---------------------------------------------- +# ---- MongoDB local for tests ------------------------------------------------ mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent) @if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-mongo$$'; then \ @@ -97,90 +51,9 @@ mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent) mongo-local-stop: ## Stop local MongoDB -docker stop miti99bot-mongo -# ---- Data migration (DynamoDB → MongoDB Atlas) ---------------------------- +# ---- Telegram operations ---------------------------------------------------- -# Requires MONGO_URL + MONGO_DATABASE in the environment and AWS credentials -# for a READ-ONLY profile with dynamodb:Scan on the table. Use DRY_RUN=1 first. -# make migrate-dynamo-to-mongo DRY_RUN=1 MONGO_URL=… MONGO_DATABASE=… -# make migrate-dynamo-to-mongo MONGO_URL=… MONGO_DATABASE=… -# make migrate-verify MONGO_URL=… MONGO_DATABASE=… -MIGRATE_TABLE ?= miti99bot-data -migrate-dynamo-to-mongo: ## Copy DynamoDB → Mongo (DRY_RUN=1 for a dry run) - go run ./cmd/migrate-dynamo-to-mongo --dynamodb-table $(MIGRATE_TABLE) $(if $(DRY_RUN),--dry-run,) - -migrate-verify: ## Verify per-module counts DynamoDB vs Mongo (exit non-zero on mismatch) - go run ./cmd/migrate-dynamo-to-mongo --dynamodb-table $(MIGRATE_TABLE) --verify - -# ---- SAM (require AWS CLI + SAM CLI installed locally) ------------------- - -sam-validate: ## Validate template.yaml without contacting AWS - sam validate --lint - -sam-build: build-lambda ## Produce the Lambda artifact (alias for build-lambda; sam deploy uses raw template directly) - @echo "Artifact ready at build/lambda/bootstrap; sam deploy --template-file template.yaml will zip it." - -sam-deploy: build-lambda ## Deploy via SAM (uses samconfig.toml). Set ALERT_EMAIL=… optionally. - @if [ -n "$$ALERT_EMAIL" ]; then \ - sam deploy --template-file template.yaml \ - --no-confirm-changeset --no-fail-on-empty-changeset \ - --parameter-overrides "AlertEmail=$$ALERT_EMAIL"; \ - else \ - sam deploy --template-file template.yaml \ - --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 - -# ---- Telegram (self-host: token from TELEGRAM_BOT_TOKEN env, no AWS/SSM) --- - -telegram-commands-selfhost: ## Register command menu using TELEGRAM_BOT_TOKEN env +telegram-commands: ## Register command menu using TELEGRAM_BOT_TOKEN env @set -eu; \ : "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \ echo "Registering Telegram commands from $(TELEGRAM_COMMANDS_FILE)"; \ @@ -189,23 +62,26 @@ telegram-commands-selfhost: ## Register command menu using TELEGRAM_BOT_TOKEN en --data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \ echo -telegram-deletewebhook-selfhost: ## Cutover: delete the webhook so the poller can run (keeps buffered updates) +telegram-commands-info: ## Show Telegram getMyCommands using TELEGRAM_BOT_TOKEN env + @set -eu; \ + : "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \ + curl -sS "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/getMyCommands"; \ + echo + +telegram-deletewebhook: ## Delete webhook so the poller can run @set -eu; \ : "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \ curl -sS -X POST "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/deleteWebhook" \ -d 'drop_pending_updates=false'; \ echo -telegram-webhook-info-selfhost: ## getWebhookInfo using TELEGRAM_BOT_TOKEN env (confirm url empty + pending draining) +telegram-webhook-info: ## Show Telegram getWebhookInfo using TELEGRAM_BOT_TOKEN env @set -eu; \ : "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \ curl -sS "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/getWebhookInfo"; \ echo -logs: ## Tail Lambda logs (last 5m). Override with SINCE=10m. - @sam logs --tail --stack-name miti99bot --start-time $${SINCE:-5m}ago - -# ---- Clean ---------------------------------------------------------------- +# ---- Clean ----------------------------------------------------------------- clean: ## Remove local build artifacts - rm -rf build/ bin/ .aws-sam/ cov.out + rm -rf build/ bin/ cov.out diff --git a/README.md b/README.md index 48d4d04..e74506b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # miti99bot -Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB Atlas via long polling and an in-process cron scheduler. (Previously ran on AWS Lambda + DynamoDB + EventBridge — now retired; see [`docs/deploy-aws.md`](docs/deploy-aws.md) for history.) +Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB +Atlas via long polling and an in-process cron scheduler. ## Modules @@ -17,28 +18,26 @@ Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB Atlas | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | | `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | -Disable any module by editing `MODULES` in `template.yaml`. +Disable modules with the `MODULES` environment variable. ## Layout ``` cmd/server/ entrypoint (long polling + in-process cron + HTTP health) -cmd/migrate-dynamo-to-mongo/ one-off DynamoDB → MongoDB Atlas data migrator internal/server/ HTTP route (/ health only; cron has no HTTP route) internal/telegram/ Telegram long-polling bot wrapper -internal/cron/ in-process cron scheduler (replaces EventBridge) +internal/cron/ in-process cron scheduler internal/modules/ Module framework, registry, dispatchers, modules internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents internal/ai/ Gemini client (used by twentyq) compose.yml Coolify self-host stack (single bot service) -docs/deploy-coolify-selfhosted.md Self-host onboarding + cutover runbook -docs/aws-decommission-runbook.md AWS teardown (post-cutover) -template.yaml, aws/ Retired AWS SAM IaC + setup (kept for history) +telegram-commands.json Telegram command menu source +docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide ``` ## Run locally -In-memory KV (no database required): +In-memory storage (no database required): ```sh TELEGRAM_BOT_TOKEN=… \ @@ -62,7 +61,6 @@ For integration tests (each skips when its emulator env var is unset): ```sh make mongo-local # docker run mongo:7 on :27017 make test-mongo # internal/storage typed-store tests against local MongoDB -make test-dynamodb # DynamoDB→Mongo migrator e2e against DynamoDB Local + local Mongo ``` ## Test @@ -71,14 +69,14 @@ make test-dynamodb # DynamoDB→Mongo migrator e2e against DynamoDB Local make vet # go vet make test # full unit suite (no emulator) make test-mongo # typed-store integration tests against local Mongo (requires Docker) -make test-dynamodb # migrator e2e against DynamoDB Local + Mongo (requires Docker) ``` ## Deploy -**Self-host (current):** [`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) — Coolify + MongoDB Atlas (free M0), long polling (no public ingress), in-process cron. Storage auto-selects `mongodb` when `MONGO_URL` is set; the cron scheduler runs by default. Migrate existing data with [`cmd/migrate-dynamo-to-mongo`](cmd/migrate-dynamo-to-mongo/README.md), then tear down AWS via [`docs/aws-decommission-runbook.md`](docs/aws-decommission-runbook.md). - -**AWS (retired):** [`docs/deploy-aws.md`](docs/deploy-aws.md) — kept for history. +[`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) covers +Coolify + MongoDB Atlas (free M0), long polling (no public ingress), and +in-process cron. Storage auto-selects `mongodb` when `MONGO_URL` is set; the +cron scheduler runs by default. ## License diff --git a/aws/README.md b/aws/README.md deleted file mode 100644 index b4bfef5..0000000 --- a/aws/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# AWS account setup - -One-time setup steps for a fresh AWS account. After this is done, every push to `main` deploys via GitHub Actions OIDC; no human-in-loop AWS commands needed. - -> For the full onboarding walkthrough (prerequisites, Telegram wiring, cost guardrails), see [`../docs/deploy-aws-free-tier-guide.md`](../docs/deploy-aws-free-tier-guide.md). This file is the condensed cheatsheet. - -> **Region:** `ap-southeast-1` (Singapore). Change in `samconfig.toml` if needed. -> **Stack name:** `miti99bot`. Change in `samconfig.toml`. - ---- - -## 1. AWS account hygiene - -1. Enable MFA on the root user. -2. Create an IAM admin user `admin` (CLI access keys). Use only for the first `sam deploy --guided`. -3. Set CLI default region: - ```sh - aws configure set region ap-southeast-1 --profile admin - aws configure set aws_access_key_id AKIA… --profile admin - aws configure set aws_secret_access_key … --profile admin - ``` - -## 2. SSM Parameter Store secrets - -Create the four required secrets. **Names must match `template.yaml`** (`/miti99bot/${StackEnv}/…`). - -```sh -aws ssm put-parameter --name /miti99bot/prod/telegram-bot-token \ - --value "" --type SecureString --profile admin - -aws ssm put-parameter --name /miti99bot/prod/telegram-webhook-secret \ - --value "$(openssl rand -hex 32)" --type SecureString --profile admin - -aws ssm put-parameter --name /miti99bot/prod/gemini-api-key \ - --value "" --type SecureString --profile admin - -aws ssm put-parameter --name /miti99bot/prod/cron-shared-secret \ - --value "$(openssl rand -hex 32)" --type SecureString --profile admin -``` - -Save the webhook + cron secrets locally — you'll set them on the Telegram side and on the EventBridge schedule headers. - -## 3. GitHub OIDC identity provider - -One-time per AWS account: - -```sh -aws iam create-open-id-connect-provider \ - --url https://token.actions.githubusercontent.com \ - --client-id-list sts.amazonaws.com \ - --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \ - --profile admin -``` - -(GitHub publishes the canonical thumbprint; verify on docs.github.com if rotated.) - -## 4. Deploy IAM role for GitHub Actions - -Edit `aws/iam-github-oidc-trust.json` if you are changing the AWS account or GitHub repo. This repo is already prefilled for account `225603493174` and `tiennm99/miti99bot`, and the trust allowlist is narrowed to `refs/heads/main` only (see "Trust policy invariants" below). If you change accounts, update `.github/workflows/deploy.yml` to match the same role ARN, then: - -```sh -aws iam create-role \ - --role-name github-deploy-miti99bot \ - --assume-role-policy-document file://aws/iam-github-oidc-trust.json \ - --profile admin - -# Permissions: stack-scoped inline policy committed at aws/iam-github-deploy-policy.json. -aws iam put-role-policy \ - --role-name github-deploy-miti99bot \ - --policy-name miti99bot-deploy \ - --policy-document file://aws/iam-github-deploy-policy.json \ - --profile admin -``` - -> Scoped to stacks/resources named `miti99bot*`. See [security audit](../plans/reports/code-reviewer-260518-1019-security-aws-infra.md) F1 for rationale and [plan](../plans/260518-1019-iam-least-privilege/) for the cutover record. - -### Updating the deploy policy - -When `template.yaml` adds a new resource type, the deploy role may need new IAM actions. Workflow: - -1. Edit `aws/iam-github-deploy-policy.json` — add the action(s) + ARN pattern. -2. Apply out-of-band from a maintainer's `admin` profile (NOT via the workflow): - ```sh - aws iam put-role-policy --role-name github-deploy-miti99bot \ - --policy-name miti99bot-deploy \ - --policy-document file://aws/iam-github-deploy-policy.json --profile admin - ``` -3. Commit the JSON. Next deploy uses the new permissions. - -Drift check — structural compare, not byte-diff. `aws iam get-role-policy` returns JSON whose key ordering / whitespace differs from the local file but may be semantically identical. Compare normalized: -```sh -diff <(aws iam get-role-policy --role-name github-deploy-miti99bot \ - --policy-name miti99bot-deploy --profile admin \ - --query PolicyDocument | jq -S .) \ - <(jq -S . aws/iam-github-deploy-policy.json) -``` -Non-empty output = INVESTIGATE before reapplying. AWS-side may have been intentionally patched during an outage; blindly re-applying overwrites that fix. - -### Trust policy invariants - -`aws/iam-github-oidc-trust.json` constrains which GitHub Actions contexts can assume `github-deploy-miti99bot`. The current allowlist is intentionally narrow: only pushes to `main` can deploy. - -**To add a new branch / context** (e.g., a future `dev` preview deploy): - -1. Edit `aws/iam-github-oidc-trust.json` — add the new `sub` claim to the `StringLike` array. Examples: - - `repo:tiennm99/miti99bot:ref:refs/heads/dev` — pushes to `dev` branch - - `repo:tiennm99/miti99bot:environment:preview` — workflows scoped to a GitHub Environment named `preview` (requires `permissions: id-token: write`) -2. Apply out-of-band: - ```sh - aws iam update-assume-role-policy --role-name github-deploy-miti99bot \ - --policy-document file://aws/iam-github-oidc-trust.json --profile admin - ``` -3. Commit. Test by triggering the new workflow path. - -**Reasons `pull_request` is NOT in the allowlist** (do not re-add without reviewing): PR-context OIDC tokens are derivable from any contributor's PR. Granting the deploy role to PRs is equivalent to granting deploy access to every contributor. Combined with the inline policy's IAM/Lambda/DynamoDB actions, an attacker-controlled PR could exfiltrate or alter prod state. - -## 5. Add GitHub repo secrets - -In GitHub repo settings → Secrets and variables → Actions: - -| Secret | Value | -|---|---| -| `ALERT_EMAIL` (optional) | Email for the $1 budget alert | - -The deploy workflow now uses the repo's fixed AWS account ID directly for the OIDC role ARN, so `AWS_ACCOUNT_ID` no longer needs to be stored in GitHub. - -## 6. First deploy (manual) - -```sh -make build-lambda -AWS_PROFILE=admin sam deploy --template-file template.yaml --guided -``` - -Confirm: -- Stack name: `miti99bot` -- Region: `ap-southeast-1` -- Capabilities: `CAPABILITY_IAM` -- Save to `samconfig.toml`: yes (already committed; this just confirms) - -After `CREATE_COMPLETE`: -```sh -aws cloudformation describe-stacks --stack-name miti99bot \ - --query "Stacks[0].Outputs" --output table --profile admin -``` -Note the `FunctionUrl` — point the Telegram webhook at it (see [`../docs/deploy-aws-free-tier-guide.md`](../docs/deploy-aws-free-tier-guide.md) Step 5). - -## 7. Tighten — optional but recommended - -Once the first deploy succeeds: -1. Rotate / delete `admin` CLI keys (use only via console for emergencies). -2. Trigger a workflow_dispatch deploy via GH Actions to confirm OIDC path works without the bootstrap user. -3. ~~Replace the broad managed policies on `github-deploy-miti99bot` with stack-scoped custom policies.~~ **Done 2026-05-18** — see step 4 (`aws/iam-github-deploy-policy.json`) and [plan](../plans/260518-1019-iam-least-privilege/). - ---- - -## Lambda Web Adapter layer ARN - -Pinned in `template.yaml` parameter `LambdaAdapterLayerArn`. Bump by checking: -- https://github.com/awslabs/aws-lambda-web-adapter/releases (look at the `Releases` page for the latest layer version) -- Format: `arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:` - -## Cost expectations - -After the stack is up but idle, monthly cost should be **$0**. If you ever see >$0.01 in Cost Explorer, investigate — most likely culprits: CloudWatch Logs ingestion volume, DynamoDB writes from a runaway loop, or accidental egress past the 100 GB free tier. diff --git a/aws/iam-github-deploy-policy.json b/aws/iam-github-deploy-policy.json deleted file mode 100644 index 8e7fa1e..0000000 --- a/aws/iam-github-deploy-policy.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "Budgets", - "Effect": "Allow", - "Action": [ - "budgets:CreateBudget", - "budgets:CreateNotification", - "budgets:CreateSubscriber", - "budgets:DeleteBudget", - "budgets:DeleteNotification", - "budgets:DeleteSubscriber", - "budgets:DescribeBudget", - "budgets:DescribeNotificationsForBudget", - "budgets:ModifyBudget" - ], - "Resource": "arn:aws:budgets::225603493174:budget/miti99bot*" - }, - { - "Sid": "CloudFormation", - "Effect": "Allow", - "Action": [ - "cloudformation:CancelUpdateStack", - "cloudformation:ContinueUpdateRollback", - "cloudformation:CreateChangeSet", - "cloudformation:CreateStack", - "cloudformation:DeleteChangeSet", - "cloudformation:DeleteStack", - "cloudformation:DescribeChangeSet", - "cloudformation:DescribeStackEvents", - "cloudformation:DescribeStackResources", - "cloudformation:DescribeStacks", - "cloudformation:ExecuteChangeSet", - "cloudformation:GetTemplate", - "cloudformation:GetTemplateSummary", - "cloudformation:ListChangeSets", - "cloudformation:ListStackResources", - "cloudformation:RollbackStack", - "cloudformation:TagResource", - "cloudformation:UntagResource", - "cloudformation:UpdateStack" - ], - "Resource": [ - "arn:aws:cloudformation:ap-southeast-1:225603493174:stack/miti99bot*/*", - "arn:aws:cloudformation:ap-southeast-1:225603493174:changeSet/*/miti99bot*/*" - ] - }, - { - "Sid": "CloudFormationGlobalRead", - "Effect": "Allow", - "Action": [ - "cloudformation:ListStacks", - "cloudformation:ValidateTemplate" - ], - "Resource": "*" - }, - { - "Sid": "DynamoDB", - "Effect": "Allow", - "Action": [ - "dynamodb:CreateTable", - "dynamodb:DeleteTable", - "dynamodb:DescribeContinuousBackups", - "dynamodb:DescribeTable", - "dynamodb:ListTables", - "dynamodb:ListTagsOfResource", - "dynamodb:TagResource", - "dynamodb:UntagResource", - "dynamodb:UpdateTable" - ], - "Resource": "arn:aws:dynamodb:ap-southeast-1:225603493174:table/miti99bot*" - }, - { - "Sid": "EventBridgeScheduler", - "Effect": "Allow", - "Action": [ - "scheduler:CreateSchedule", - "scheduler:DeleteSchedule", - "scheduler:GetSchedule", - "scheduler:ListSchedules", - "scheduler:ListTagsForResource", - "scheduler:TagResource", - "scheduler:UntagResource", - "scheduler:UpdateSchedule" - ], - "Resource": "arn:aws:scheduler:ap-southeast-1:225603493174:schedule/*/miti99bot*" - }, - { - "Sid": "IAMRolesScoped", - "Effect": "Allow", - "Action": [ - "iam:CreateRole", - "iam:DeleteRole", - "iam:DeleteRolePolicy", - "iam:GetRole", - "iam:GetRolePolicy", - "iam:ListAttachedRolePolicies", - "iam:ListInstanceProfilesForRole", - "iam:ListRolePolicies", - "iam:ListRoleTags", - "iam:ListRoles", - "iam:PutRolePolicy", - "iam:TagRole", - "iam:UntagRole" - ], - "Resource": "arn:aws:iam::225603493174:role/miti99bot*" - }, - { - "Sid": "IAMPassRole", - "Effect": "Allow", - "Action": "iam:PassRole", - "Resource": "arn:aws:iam::225603493174:role/miti99bot*", - "Condition": { - "ForAllValues:StringEquals": { - "iam:PassedToService": [ - "lambda.amazonaws.com", - "scheduler.amazonaws.com" - ] - } - } - }, - { - "Sid": "Lambda", - "Effect": "Allow", - "Action": [ - "lambda:AddPermission", - "lambda:CreateFunction", - "lambda:CreateFunctionUrlConfig", - "lambda:DeleteFunction", - "lambda:DeleteFunctionUrlConfig", - "lambda:GetFunction", - "lambda:GetFunctionCodeSigningConfig", - "lambda:GetFunctionConfiguration", - "lambda:GetFunctionUrlConfig", - "lambda:GetPolicy", - "lambda:ListTags", - "lambda:ListVersionsByFunction", - "lambda:PublishVersion", - "lambda:RemovePermission", - "lambda:TagResource", - "lambda:UntagResource", - "lambda:UpdateFunctionCode", - "lambda:UpdateFunctionConfiguration", - "lambda:UpdateFunctionUrlConfig" - ], - "Resource": "arn:aws:lambda:ap-southeast-1:225603493174:function:miti99bot*" - }, - { - "Sid": "LambdaLayerRead", - "Effect": "Allow", - "Action": "lambda:GetLayerVersion", - "Resource": "arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:*" - }, - { - "Sid": "Logs", - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:DeleteLogGroup", - "logs:DeleteMetricFilter", - "logs:DeleteRetentionPolicy", - "logs:DescribeLogGroups", - "logs:DescribeMetricFilters", - "logs:ListTagsForResource", - "logs:PutMetricFilter", - "logs:PutRetentionPolicy", - "logs:TagResource", - "logs:UntagResource" - ], - "Resource": [ - "arn:aws:logs:ap-southeast-1:225603493174:log-group:/aws/lambda/miti99bot*", - "arn:aws:logs:ap-southeast-1:225603493174:log-group:/aws/lambda/miti99bot*:*" - ] - }, - { - "Sid": "S3SamArtifacts", - "Effect": "Allow", - "Action": [ - "s3:CreateBucket", - "s3:DeleteObject", - "s3:GetBucketLocation", - "s3:GetBucketPolicy", - "s3:GetBucketVersioning", - "s3:GetEncryptionConfiguration", - "s3:GetObject", - "s3:ListBucket", - "s3:PutBucketPolicy", - "s3:PutBucketVersioning", - "s3:PutEncryptionConfiguration", - "s3:PutObject" - ], - "Resource": [ - "arn:aws:s3:::aws-sam-cli-managed-default-*", - "arn:aws:s3:::aws-sam-cli-managed-default-*/*" - ] - }, - { - "Sid": "S3GlobalList", - "Effect": "Allow", - "Action": "s3:ListAllMyBuckets", - "Resource": "*" - }, - { - "Sid": "SQS", - "Effect": "Allow", - "Action": [ - "sqs:CreateQueue", - "sqs:DeleteQueue", - "sqs:GetQueueAttributes", - "sqs:GetQueueUrl", - "sqs:ListQueueTags", - "sqs:ListQueues", - "sqs:SetQueueAttributes", - "sqs:TagQueue", - "sqs:UntagQueue" - ], - "Resource": "arn:aws:sqs:ap-southeast-1:225603493174:miti99bot*" - }, - { - "Sid": "SSMRead", - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters" - ], - "Resource": "arn:aws:ssm:ap-southeast-1:225603493174:parameter/miti99bot/*/*" - }, - { - "Sid": "STS", - "Effect": "Allow", - "Action": "sts:GetCallerIdentity", - "Resource": "*" - } - ] -} diff --git a/aws/iam-github-oidc-trust.json b/aws/iam-github-oidc-trust.json deleted file mode 100644 index b21576d..0000000 --- a/aws/iam-github-oidc-trust.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Federated": "arn:aws:iam::225603493174:oidc-provider/token.actions.githubusercontent.com" - }, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": { - "StringEquals": { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" - }, - "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:tiennm99/miti99bot:ref:refs/heads/main" - ] - } - } - } - ] -} diff --git a/aws/iam-rollback-fullaccess.sh b/aws/iam-rollback-fullaccess.sh deleted file mode 100755 index 263c98c..0000000 --- a/aws/iam-rollback-fullaccess.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh -# Re-attaches the 10 FullAccess managed policies to github-deploy-miti99bot. -# Idempotent: attach-role-policy succeeds even if policy already attached. -# Use as emergency rollback during Phase 4 cutover (plans/260518-1019-iam-least-privilege). -# -# Usage: -# bash aws/iam-rollback-fullaccess.sh -# AWS_PROFILE=admin bash aws/iam-rollback-fullaccess.sh -ROLE=github-deploy-miti99bot -POLICIES=" - arn:aws:iam::aws:policy/AWSCloudFormationFullAccess - arn:aws:iam::aws:policy/AWSLambda_FullAccess - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess - arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess - arn:aws:iam::aws:policy/AmazonSQSFullAccess - arn:aws:iam::aws:policy/AmazonSSMFullAccess - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess - arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess - arn:aws:iam::aws:policy/IAMFullAccess - arn:aws:iam::aws:policy/AmazonS3FullAccess -" - -PROFILE_FLAG="" -if [ -n "$AWS_PROFILE" ]; then - PROFILE_FLAG="--profile $AWS_PROFILE" -fi - -for arn in $POLICIES; do - for try in 1 2 3 4 5; do - if aws iam attach-role-policy --role-name "$ROLE" --policy-arn "$arn" $PROFILE_FLAG 2>&1; then - break - fi - echo "retry $try for $arn after throttle..." - sleep $((try * 2)) - done -done - -# Verify final state -- exit non-zero if anything is missing. -ATTACHED=$(aws iam list-attached-role-policies --role-name "$ROLE" $PROFILE_FLAG \ - --query 'AttachedPolicies[].PolicyArn' --output text) -MISSING=0 -for arn in $POLICIES; do - echo "$ATTACHED" | grep -q "$arn" || { echo "MISSING: $arn"; MISSING=1; } -done - -if [ "$MISSING" = 0 ]; then - echo "Rollback complete -- all 10 FullAccess policies attached." -else - echo "Rollback INCOMPLETE -- see MISSING lines above. Re-run or attach via console." - exit 1 -fi diff --git a/cmd/migrate-dynamo-to-mongo/README.md b/cmd/migrate-dynamo-to-mongo/README.md deleted file mode 100644 index 30bdcda..0000000 --- a/cmd/migrate-dynamo-to-mongo/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# migrate-dynamo-to-mongo - -One-off CLI that copies every item from the prod DynamoDB KV table -(`miti99bot-data`) into MongoDB Atlas using the exact document schema the live -app writes, then verifies per-module parity. Idempotent and re-runnable. - -## What it does - -- Full-table `Scan` of DynamoDB (the table is a small KV) → group by `pk`. -- Writes each item through the typed Mongo store (`storage.Typed[bson.M]`) as a - **flattened native document** — the value's JSON fields are hoisted to the - document root alongside `_id`/`version`/`updatedAt`, with no `value` envelope. - This is the exact shape the running bot writes, so the app reads migrated docs - directly. Integers keep int64 fidelity (decoded with `UseNumber`). -- The two non-object values are wrapped into named root fields to match the - module's typed shape: lolschedule `subscribers` (a JSON array) → `{subscribers: [...]}` - and `daily_push:last_date` (a bare date string) → `{date: "..."}`. Any other - non-object value fails loud so a missing wrap rule is obvious (see `encode.go`). -- Writing through the store validates the module/collection name and key and - upserts by `_id`, so a re-run produces no duplicates and bad input fails loud. - -| DynamoDB | MongoDB | -|---|---| -| `pk` (module name) | collection name | -| `sk` (user key) | document `_id` | -| `value` (JSON object) | payload fields hoisted to the document root (no `value` field) | -| `value` (array/scalar, lolschedule) | wrapped in a named root field (`subscribers` / `date`) | -| — | `version` = 1, `updatedAt` = migration time (write-only; nothing reads it) | - -## Usage - -```sh -export MONGO_URL='mongodb+srv://botuser:PASS@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority' -export MONGO_DATABASE=miti99bot -export AWS_PROFILE=miti99bot-migrate # READ-ONLY profile (see IAM below) - -# 1. Dry run — report per-module counts, write nothing. -go run ./cmd/migrate-dynamo-to-mongo --dry-run -# or: make migrate-dynamo-to-mongo DRY_RUN=1 - -# 2. Real migration. -go run ./cmd/migrate-dynamo-to-mongo -# or: make migrate-dynamo-to-mongo - -# 3. Verify — per-module counts must match; exits non-zero on mismatch. -go run ./cmd/migrate-dynamo-to-mongo --verify -# or: make migrate-verify -``` - -Flags: `--dynamodb-table` (default `miti99bot-data`), `--dry-run`, `--verify`. - -For a local end-to-end test, point at DynamoDB Local + a local Mongo: - -```sh -DYNAMODB_LOCAL_URL=http://localhost:8001 \ -MONGODB_TEST_URL=mongodb://127.0.0.1:27017 \ -MONGO_DATABASE=migrate_test \ -go test ./cmd/migrate-dynamo-to-mongo/ -run TestMigrateAndVerify -``` - -## IAM — least privilege - -The runner needs **exactly** `dynamodb:Scan` on the table ARN and nothing else. -Verify uses a Scan tally (not Query), so no `dynamodb:Query` is needed; there -are **no write actions on the source**, enforcing the read-only requirement and -removing the destructive-credential foot-gun. - -```json -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": "dynamodb:Scan", - "Resource": "arn:aws:dynamodb:ap-southeast-1:225603493174:table/miti99bot-data" - }] -} -``` - -## Cutover runbook - -The full zero-loss cutover (disable EventBridge → `deleteWebhook` → migrate → -verify → start the polling container) lives in -[`docs/deploy-coolify-selfhosted.md`](../../docs/deploy-coolify-selfhosted.md#cutover-runbook). diff --git a/cmd/migrate-dynamo-to-mongo/encode.go b/cmd/migrate-dynamo-to-mongo/encode.go deleted file mode 100644 index 41ded4b..0000000 --- a/cmd/migrate-dynamo-to-mongo/encode.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// reservedRootFields are owned by the storage layer (see internal/storage); a -// flattened object payload must not collide with them. -var reservedRootFields = map[string]bool{"_id": true, "version": true, "updatedAt": true} - -// wrapRule names the root field a non-object DynamoDB value must be wrapped in -// to match the typed Mongo store's named-struct shape. rawString treats the -// DynamoDB value as a bare (non-JSON) string; otherwise it is JSON-decoded. -type wrapRule struct { - field string - rawString bool -} - -// wrapRules covers the only KV entries whose value is not a JSON object. They -// mirror the named-struct wrappers the lolschedule module persists: -// - subscribers: a JSON array → {subscribers: [...]} -// - daily_push:last_date: a bare date string → {date: "..."} -// -// Any other non-object value fails loud in payloadForItem so a missing rule is -// obvious rather than silently dropped. -var wrapRules = map[[2]string]wrapRule{ - {"lolschedule", "subscribers"}: {field: "subscribers"}, - {"lolschedule", "daily_push:last_date"}: {field: "date", rawString: true}, -} - -// payloadForItem converts one migrated KV row's value into the flattened payload -// map the typed Mongo store stores at the document root. The store adds _id, -// version, and updatedAt; this returns only the payload fields. -func payloadForItem(module, key string, value []byte) (bson.M, error) { - if rule, ok := wrapRules[[2]string{module, key}]; ok { - if rule.rawString { - return bson.M{rule.field: string(value)}, nil - } - decoded, err := decodeJSONNumber(value) - if err != nil { - return nil, fmt.Errorf("%s/%s: decode value: %w", module, key, err) - } - return bson.M{rule.field: decoded}, nil - } - - decoded, err := decodeJSONNumber(value) - if err != nil { - return nil, fmt.Errorf("%s/%s: decode value: %w", module, key, err) - } - obj, ok := decoded.(bson.M) - if !ok { - return nil, fmt.Errorf("%s/%s: value is not a JSON object (type %T) and has no wrap rule — add one to wrapRules", module, key, decoded) - } - for k := range obj { - if reservedRootFields[k] { - return nil, fmt.Errorf("%s/%s: payload key %q collides with a reserved root field — add a wrap rule", module, key, k) - } - } - return obj, nil -} - -// decodeJSONNumber decodes JSON into a BSON-native value, preserving integral -// numbers as int64 (UseNumber) so migrated numbers keep int64 fidelity, matching -// the live store's value codec. -func decodeJSONNumber(value []byte) (any, error) { - dec := json.NewDecoder(bytes.NewReader(value)) - dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { - return nil, err - } - if dec.More() { - return nil, fmt.Errorf("trailing data after JSON value") - } - return numberToBSON(v), nil -} - -// numberToBSON walks a json.Unmarshal(UseNumber) tree into bson.M/bson.A, -// converting json.Number to int64 when integral, else float64. -func numberToBSON(v any) any { - switch t := v.(type) { - case map[string]any: - m := make(bson.M, len(t)) - for k, val := range t { - m[k] = numberToBSON(val) - } - return m - case []any: - a := make(bson.A, len(t)) - for i, val := range t { - a[i] = numberToBSON(val) - } - return a - case json.Number: - if i, err := t.Int64(); err == nil { - return i - } - if f, err := t.Float64(); err == nil { - return f - } - return t.String() - default: - return v - } -} diff --git a/cmd/migrate-dynamo-to-mongo/encode_test.go b/cmd/migrate-dynamo-to-mongo/encode_test.go deleted file mode 100644 index cc01070..0000000 --- a/cmd/migrate-dynamo-to-mongo/encode_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPayloadForItem_Object(t *testing.T) { - got, err := payloadForItem("coin", "user:1", []byte(`{"bal":100,"meta":{"createdAt":5}}`)) - if err != nil { - t.Fatalf("payloadForItem: %v", err) - } - if got["bal"] != int64(100) { - t.Errorf("bal = %v (%T), want int64(100)", got["bal"], got["bal"]) - } - if _, ok := got["value"]; ok { - t.Error("payload must not contain a 'value' envelope field") - } - meta, ok := got["meta"].(bson.M) - if !ok || meta["createdAt"] != int64(5) { - t.Errorf("nested meta = %v, want {createdAt: int64(5)}", got["meta"]) - } -} - -func TestPayloadForItem_LolscheduleSubscribers(t *testing.T) { - got, err := payloadForItem("lolschedule", "subscribers", []byte(`[{"chatId":1},{"chatId":2}]`)) - if err != nil { - t.Fatalf("payloadForItem: %v", err) - } - arr, ok := got["subscribers"].(bson.A) - if !ok || len(arr) != 2 { - t.Fatalf("subscribers = %v (%T), want 2-element bson.A", got["subscribers"], got["subscribers"]) - } -} - -func TestPayloadForItem_LolscheduleLastPushRawString(t *testing.T) { - // last-push date is stored as a bare (non-JSON) string in DynamoDB. - got, err := payloadForItem("lolschedule", "daily_push:last_date", []byte(`2026-06-28`)) - if err != nil { - t.Fatalf("payloadForItem: %v", err) - } - if got["date"] != "2026-06-28" { - t.Errorf("date = %v, want 2026-06-28", got["date"]) - } -} - -func TestPayloadForItem_UnknownNonObjectFailsLoud(t *testing.T) { - if _, err := payloadForItem("coin", "user:1", []byte(`"a-bare-string"`)); err == nil { - t.Error("non-object value with no wrap rule must fail loud") - } - if _, err := payloadForItem("coin", "user:1", []byte(`[1,2,3]`)); err == nil { - t.Error("array value with no wrap rule must fail loud") - } -} - -func TestPayloadForItem_ReservedKeyCollision(t *testing.T) { - if _, err := payloadForItem("coin", "user:1", []byte(`{"version":7}`)); err == nil { - t.Error("payload key colliding with reserved root field must fail loud") - } -} - -// TestPayloadForItem_CamelCaseFidelity guards the invariant that lets migration -// work: the migrator preserves the original (camelCase) JSON keys, so a typed -// store struct must declare bson tags matching those names. A struct whose bson -// tags drifted to the driver's lowercased default would read these back empty. -func TestPayloadForItem_CamelCaseFidelity(t *testing.T) { - payload, err := payloadForItem("lolschedule", "events", []byte(`{"startTime":"t","gameWins":3,"blockName":"Week 1"}`)) - if err != nil { - t.Fatalf("payloadForItem: %v", err) - } - raw, err := bson.Marshal(payload) - if err != nil { - t.Fatalf("bson.Marshal: %v", err) - } - var got struct { - StartTime string `bson:"startTime"` - GameWins int `bson:"gameWins"` - BlockName string `bson:"blockName"` - } - if err := bson.Unmarshal(raw, &got); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - if got.StartTime != "t" || got.GameWins != 3 || got.BlockName != "Week 1" { - t.Fatalf("camelCase round-trip lost data: %+v", got) - } -} diff --git a/cmd/migrate-dynamo-to-mongo/main.go b/cmd/migrate-dynamo-to-mongo/main.go deleted file mode 100644 index 5f82b5b..0000000 --- a/cmd/migrate-dynamo-to-mongo/main.go +++ /dev/null @@ -1,225 +0,0 @@ -// Command migrate-dynamo-to-mongo copies every item from the prod DynamoDB KV -// table into MongoDB Atlas using the same document schema the live app writes, -// then verifies per-module parity. It is idempotent (re-runs overwrite by key, -// never duplicate) and read-only on DynamoDB (Scan only). -// -// Usage: -// -// migrate-dynamo-to-mongo [--dynamodb-table miti99bot-data] [--dry-run] [--verify] -// -// Required env: MONGO_URL, MONGO_DATABASE. DynamoDB credentials come from the -// AWS default chain — use a DEDICATED READ-ONLY profile whose policy grants -// exactly `dynamodb:Scan` on the table ARN (nothing else, no write actions). -// For local testing, set DYNAMODB_LOCAL_URL to point at DynamoDB Local. -// -// - default: scan + write every item through the typed Mongo store as a -// flattened native document (payload fields hoisted to the root, no `value` -// envelope) — the exact shape the live app writes — then report per-module -// counts. -// - --dry-run: scan + report counts, write nothing. -// - --verify: tally DynamoDB per pk via Scan vs Mongo CountDocuments per -// collection; print a table and exit non-zero on any mismatch. -// -// Note: each migrated doc gets a fresh updatedAt and version=1; nothing in the -// app reads updatedAt, and --verify compares counts, so this is intentional and -// harmless. The two non-object values (lolschedule subscribers array and -// last-push date) are wrapped into named root fields (see encode.go). -package main - -import ( - "context" - "flag" - "fmt" - "os" - "sort" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - - "github.com/tiennm99/miti99bot/internal/storage" -) - -const defaultTable = "miti99bot-data" - -func main() { - table := flag.String("dynamodb-table", defaultTable, "source DynamoDB table name") - dryRun := flag.Bool("dry-run", false, "scan and report counts without writing") - verify := flag.Bool("verify", false, "compare per-module counts DynamoDB vs Mongo and exit non-zero on mismatch") - flag.Parse() - - if err := run(*table, *dryRun, *verify); err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } -} - -func run(table string, dryRun, verify bool) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - mongoURL := os.Getenv("MONGO_URL") - mongoDB := os.Getenv("MONGO_DATABASE") - if mongoURL == "" || mongoDB == "" { - return fmt.Errorf("MONGO_URL and MONGO_DATABASE are required") - } - - ddb, err := storage.NewDynamoDBClient(ctx, storage.DynamoDBEndpointFromEnv()) - if err != nil { - return fmt.Errorf("dynamodb client: %w", err) - } - - mclient, err := storage.NewMongoClient(ctx, mongoURL) - if err != nil { - return fmt.Errorf("mongo client: %w", err) - } - defer func() { _ = mclient.Disconnect(context.Background()) }() - mdb, err := storage.NewMongoDatabase(mclient, mongoDB) - if err != nil { - return err - } - - if verify { - return runVerify(ctx, ddb, mdb, table) - } - return runMigrate(ctx, ddb, mdb, table, dryRun) -} - -// item is one decoded DynamoDB KV row. -type item struct { - pk string // module name → collection - sk string // user key → _id - value []byte // raw value bytes -} - -// scanTable reads every row from the table via a full Scan (the KV table is -// small) and decodes pk/sk/value. Scan is the ONLY DynamoDB action used, so the -// runner needs just `dynamodb:Scan` on the table ARN. -func scanTable(ctx context.Context, ddb *dynamodb.Client, table string) ([]item, error) { - var items []item - pager := dynamodb.NewScanPaginator(ddb, &dynamodb.ScanInput{TableName: aws.String(table)}) - for pager.HasMorePages() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("scan %s: %w", table, err) - } - for _, raw := range page.Items { - it, err := decodeItem(raw) - if err != nil { - return nil, err - } - items = append(items, it) - } - } - return items, nil -} - -func decodeItem(raw map[string]types.AttributeValue) (item, error) { - pk, ok := raw["pk"].(*types.AttributeValueMemberS) - if !ok { - return item{}, fmt.Errorf("item missing string pk: %v", raw) - } - sk, ok := raw["sk"].(*types.AttributeValueMemberS) - if !ok { - return item{}, fmt.Errorf("item %s missing string sk", pk.Value) - } - val, ok := raw["value"].(*types.AttributeValueMemberS) - if !ok { - return item{}, fmt.Errorf("item %s/%s missing string value", pk.Value, sk.Value) - } - return item{pk: pk.Value, sk: sk.Value, value: []byte(val.Value)}, nil -} - -// sortedKeys returns the map keys sorted for stable report output. -func sortedKeys(m map[string]int) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// runMigrate scans the table, then (unless dry-run) writes every item through -// the typed Mongo store as a flattened native document (payload fields hoisted -// to the root, no `value` envelope) — the exact shape the live app writes. -// payloadForItem wraps the two non-object values (lolschedule subscribers array -// and last-push date) into named-struct fields. Writing through -// storage.Typed[bson.M] reuses the store's module/key validation and version -// semantics, so a re-run produces no duplicates (upsert by _id) and an invalid -// module name or key fails loud rather than writing data the app cannot load. -func runMigrate(ctx context.Context, ddb *dynamodb.Client, mdb *mongo.Database, table string, dryRun bool) error { - items, err := scanTable(ctx, ddb, table) - if err != nil { - return err - } - - counts := map[string]int{} - provider := storage.NewMongoProvider(mdb) - for _, it := range items { - counts[it.pk]++ - if dryRun { - continue - } - payload, err := payloadForItem(it.pk, it.sk, it.value) - if err != nil { - return err - } - if err := storage.Typed[bson.M](provider.Collection(it.pk)).Put(ctx, it.sk, payload); err != nil { - return fmt.Errorf("put %s/%s: %w", it.pk, it.sk, err) - } - } - - mode := "MIGRATED" - if dryRun { - mode = "DRY-RUN (no writes)" - } - fmt.Printf("%s — %d items across %d modules\n", mode, len(items), len(counts)) - for _, pk := range sortedKeys(counts) { - fmt.Printf(" %-20s %d\n", pk, counts[pk]) - } - return nil -} - -// runVerify tallies DynamoDB per pk via a Scan (so only dynamodb:Scan is -// needed — never Query) and compares against Mongo CountDocuments per -// collection. Prints a table and returns an error on any mismatch so the -// process exits non-zero. -func runVerify(ctx context.Context, ddb *dynamodb.Client, mdb *mongo.Database, table string) error { - items, err := scanTable(ctx, ddb, table) - if err != nil { - return err - } - ddbCounts := map[string]int{} - for _, it := range items { - ddbCounts[it.pk]++ - } - - mongoCounts := map[string]int{} - for pk := range ddbCounts { - n, err := mdb.Collection(pk).CountDocuments(ctx, bson.M{}) - if err != nil { - return fmt.Errorf("count mongo collection %s: %w", pk, err) - } - mongoCounts[pk] = int(n) - } - - fmt.Printf("%-20s %10s %10s %s\n", "MODULE", "DYNAMODB", "MONGO", "STATUS") - mismatch := false - for _, pk := range sortedKeys(ddbCounts) { - status := "OK" - if ddbCounts[pk] != mongoCounts[pk] { - status = "MISMATCH" - mismatch = true - } - fmt.Printf("%-20s %10d %10d %s\n", pk, ddbCounts[pk], mongoCounts[pk], status) - } - if mismatch { - return fmt.Errorf("verification failed: per-module counts differ") - } - fmt.Println("verification OK: all per-module counts match") - return nil -} diff --git a/cmd/migrate-dynamo-to-mongo/main_test.go b/cmd/migrate-dynamo-to-mongo/main_test.go deleted file mode 100644 index f88de8c..0000000 --- a/cmd/migrate-dynamo-to-mongo/main_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package main - -import ( - "context" - "os" - "reflect" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - - "github.com/tiennm99/miti99bot/internal/storage" -) - -func TestDecodeItem(t *testing.T) { - raw := map[string]types.AttributeValue{ - "pk": &types.AttributeValueMemberS{Value: "coin"}, - "sk": &types.AttributeValueMemberS{Value: "user:1"}, - "value": &types.AttributeValueMemberS{Value: `{"x":1}`}, - } - it, err := decodeItem(raw) - if err != nil { - t.Fatalf("decodeItem: %v", err) - } - if it.pk != "coin" || it.sk != "user:1" || string(it.value) != `{"x":1}` { - t.Errorf("decoded %+v", it) - } - - // Missing value attribute is a hard error. - if _, err := decodeItem(map[string]types.AttributeValue{ - "pk": &types.AttributeValueMemberS{Value: "coin"}, - "sk": &types.AttributeValueMemberS{Value: "user:1"}, - }); err == nil { - t.Error("decodeItem with missing value: want error, got nil") - } -} - -func TestSortedKeys(t *testing.T) { - got := sortedKeys(map[string]int{"b": 1, "a": 2, "c": 3}) - if !reflect.DeepEqual(got, []string{"a", "b", "c"}) { - t.Errorf("sortedKeys = %v", got) - } -} - -// TestMigrateAndVerify is the end-to-end gate: seed DynamoDB Local across two -// modules, migrate into Mongo, assert values round-trip byte-identically, and -// confirm --verify reports matching counts. Skips unless BOTH emulators are -// configured. -func TestMigrateAndVerify(t *testing.T) { - ddbURL := os.Getenv("DYNAMODB_LOCAL_URL") - mongoURL := os.Getenv("MONGODB_TEST_URL") - mongoDB := os.Getenv("MONGO_DATABASE") - if ddbURL == "" || mongoURL == "" || mongoDB == "" { - t.Skip("set DYNAMODB_LOCAL_URL, MONGODB_TEST_URL, MONGO_DATABASE to run the migrator e2e test") - } - t.Setenv("AWS_ACCESS_KEY_ID", "test") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test") - t.Setenv("AWS_REGION", "ap-southeast-1") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - ddb, err := storage.NewDynamoDBClient(ctx, ddbURL) - if err != nil { - t.Fatalf("dynamodb client: %v", err) - } - table := "migrate-test" - createTable(t, ctx, ddb, table) - defer func() { - _, _ = ddb.DeleteTable(ctx, &dynamodb.DeleteTableInput{TableName: aws.String(table)}) - }() - - seed := []item{ - {pk: "coin", sk: "user:1", value: []byte(`{"bal":100}`)}, - {pk: "coin", sk: "user:2", value: []byte(`{"bal":200}`)}, - {pk: "stock", sk: "user:1", value: []byte(`{"vnd":5000}`)}, - } - for _, it := range seed { - putDynamoItem(t, ctx, ddb, table, it) - } - - // Migrate. - if err := runMigrate(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table, false); err != nil { - t.Fatalf("runMigrate: %v", err) - } - // Re-run must stay idempotent. - if err := runMigrate(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table, false); err != nil { - t.Fatalf("runMigrate re-run: %v", err) - } - // Verify passes. - if err := runVerify(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table); err != nil { - t.Fatalf("runVerify: %v", err) - } - - // Spot-check the migrated value round-trips through the typed store as a - // flattened native doc: bal hoisted to the root, preserved as int64. - db := mongoDatabase(t, ctx, mongoURL, mongoDB) - provider := storage.NewMongoProvider(db) - got, _, err := storage.Typed[bson.M](provider.Collection("coin")).Get(ctx, "user:1") - if err != nil { - t.Fatalf("Get migrated value: %v", err) - } - if got["bal"] != int64(100) { - t.Errorf("migrated value bal = %v (%T), want int64(100)", got["bal"], got["bal"]) - } -} - -func mongoDatabase(t *testing.T, ctx context.Context, uri, db string) *mongo.Database { - t.Helper() - client, err := storage.NewMongoClient(ctx, uri) - if err != nil { - t.Fatalf("NewMongoClient: %v", err) - } - t.Cleanup(func() { _ = client.Disconnect(context.Background()) }) - mdb, err := storage.NewMongoDatabase(client, db) - if err != nil { - t.Fatalf("NewMongoDatabase: %v", err) - } - return mdb -} - -func createTable(t *testing.T, ctx context.Context, c *dynamodb.Client, table string) { - t.Helper() - _, err := c.CreateTable(ctx, &dynamodb.CreateTableInput{ - TableName: aws.String(table), - BillingMode: types.BillingModePayPerRequest, - AttributeDefinitions: []types.AttributeDefinition{ - {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, - {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, - }, - KeySchema: []types.KeySchemaElement{ - {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, - {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, - }, - }) - if err != nil { - t.Fatalf("CreateTable: %v", err) - } -} - -func putDynamoItem(t *testing.T, ctx context.Context, c *dynamodb.Client, table string, it item) { - t.Helper() - _, err := c.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(table), - Item: map[string]types.AttributeValue{ - "pk": &types.AttributeValueMemberS{Value: it.pk}, - "sk": &types.AttributeValueMemberS{Value: it.sk}, - "value": &types.AttributeValueMemberS{Value: string(it.value)}, - }, - }) - if err != nil { - t.Fatalf("PutItem: %v", err) - } -} diff --git a/cmd/server/main.go b/cmd/server/main.go index b562162..9c75250 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,9 +12,6 @@ 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/cron" "github.com/tiennm99/miti99bot/internal/deploynotify" @@ -79,18 +76,11 @@ func factories() map[string]modules.Factory { // container; 10s leaves headroom without hiding a wedged cluster. const mongodbInitTimeout = 10 * 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") } @@ -144,10 +134,8 @@ func main() { "commands", len(reg.AllCommands), "crons", len(reg.Crons())) - // In-process cron scheduler. Replaces EventBridge Scheduler off AWS; runs - // unconditionally so the long-lived container fires module crons (e.g. the - // lolschedule daily push) on their Schedule. Cutover safety comes from - // ordering + the per-date idempotency guard, not from a gate. + // In-process cron scheduler runs unconditionally so the long-lived container + // fires module crons (e.g. the lolschedule daily push) on their Schedule. stopCron, err := cron.Run(rootCtx, reg) if err != nil { log.Fatal("cron scheduler init failed", "err", err) @@ -158,10 +146,10 @@ func main() { log.Warn("OWNER_ID unset; all Private + Protected commands will be denied") } - // Clear any webhook left over from the AWS deployment at startup, before the - // owner DM and before polling. getUpdates (long polling, below) returns HTTP - // 409 while a webhook is set, so a stuck webhook silently breaks the bot. - // Best-effort, one shot: a real failure here is logged, not retried. + // Clear any existing webhook at startup before the owner DM and before + // polling. getUpdates returns HTTP 409 while a webhook is set, so a stuck + // webhook silently breaks the bot. Best-effort, one shot: a real failure + // here is logged, not retried. if err := telegram.DeleteWebhook(rootCtx, cfg.TelegramBotToken); err != nil { log.Warn("deleteWebhook failed; getUpdates may 409 if a webhook is set", "err", err) } else { @@ -218,8 +206,7 @@ func main() { // // The self-host default is mongodb (just set MONGO_URL + MONGO_DATABASE — no // KV_PROVIDER needed). The memory backend is for tests and local no-database -// runs (MODULES=). DynamoDB is no longer a runtime backend — it survives only -// as the one-off migration source (cmd/migrate-dynamo-to-mongo). +// runs (MODULES=). // // Returned closer is always non-nil and safe to call exactly once. func buildProvider(ctx context.Context, cfg config) (storage.Provider, func(), error) { @@ -265,33 +252,28 @@ func buildProvider(ctx context.Context, cfg config) (storage.Provider, func(), e return storage.NewMongoProvider(db), closer, nil default: - // DynamoDB is no longer a runtime backend — it survives only as the - // one-off migration source (cmd/migrate-dynamo-to-mongo). return nil, func() {}, fmt.Errorf("unknown KV_PROVIDER %q (want memory|mongodb)", backend) } } type config struct { - Port string - TelegramBotToken string - SourceCommit string // Coolify-injected commit SHA (runtime env) for deploynotify - GeminiAPIKey string - GoldPriceAPIURL string - GoldFXAPIURL string - GoldVNAppAPIURL string - GoldVNAppAPIKey string - CoinBinanceAPIURL string - CoinCoinbaseAPIURL string - CoinCoinGeckoAPIURL string - Modules []string - BotOwnerID int64 - AdminUserIDs map[int64]bool - KVProvider string // empty = auto-detect; or "memory"|"mongodb" - MongoURL string // required when KVProvider=mongodb (Atlas SRV connection string; SECRET — never log) - MongoDatabase string // required when KVProvider=mongodb - TelegramBotTokenParam string - GeminiAPIKeyParam string - GoldVNAppAPIKeyParam string + Port string + TelegramBotToken string + SourceCommit string // Coolify-injected commit SHA (runtime env) for deploynotify + GeminiAPIKey string + GoldPriceAPIURL string + GoldFXAPIURL string + GoldVNAppAPIURL string + GoldVNAppAPIKey string + CoinBinanceAPIURL string + CoinCoinbaseAPIURL string + CoinCoinGeckoAPIURL string + Modules []string + BotOwnerID int64 + AdminUserIDs map[int64]bool + KVProvider string // empty = auto-detect; or "memory"|"mongodb" + MongoURL string // required when KVProvider=mongodb (Atlas SRV connection string; SECRET — never log) + MongoDatabase string // required when KVProvider=mongodb } func loadConfig() config { @@ -312,85 +294,26 @@ func loadConfig() config { log.Fatal("invalid PORT", "value", port) } return config{ - Port: port, - TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"], - SourceCommit: envMap["SOURCE_COMMIT"], - GeminiAPIKey: envMap["GEMINI_API_KEY"], - GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"], - GoldFXAPIURL: envMap["GOLD_FX_API_URL"], - GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"], - GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"], - CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"], - CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"], - CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"], - Modules: splitCSV(envMap["MODULES"]), - BotOwnerID: parseInt64(envMap["OWNER_ID"]), - AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]), - KVProvider: envMap["KV_PROVIDER"], - MongoURL: envMap["MONGO_URL"], - MongoDatabase: envMap["MONGO_DATABASE"], - TelegramBotTokenParam: strings.TrimSpace(envMap["TELEGRAM_BOT_TOKEN_PARAMETER_NAME"]), - GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]), - GoldVNAppAPIKeyParam: strings.TrimSpace(envMap["GOLD_VNAPP_API_KEY_PARAMETER_NAME"]), + Port: port, + TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"], + SourceCommit: envMap["SOURCE_COMMIT"], + GeminiAPIKey: envMap["GEMINI_API_KEY"], + GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"], + GoldFXAPIURL: envMap["GOLD_FX_API_URL"], + GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"], + GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"], + CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"], + CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"], + CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"], + Modules: splitCSV(envMap["MODULES"]), + BotOwnerID: parseInt64(envMap["OWNER_ID"]), + AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]), + KVProvider: envMap["KV_PROVIDER"], + MongoURL: envMap["MONGO_URL"], + MongoDatabase: envMap["MONGO_DATABASE"], } } -func resolveSSMSecrets(ctx context.Context, cfg *config) error { - bindings := []struct { - name string - target *string - }{ - {name: cfg.TelegramBotTokenParam, target: &cfg.TelegramBotToken}, - {name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey}, - {name: cfg.GoldVNAppAPIKeyParam, target: &cfg.GoldVNAppAPIKey}, - } - - 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 exportOptionalEnv(key, value string) { if strings.TrimSpace(value) == "" { return diff --git a/compose.yml b/compose.yml index 86b5072..1ff48de 100644 --- a/compose.yml +++ b/compose.yml @@ -28,8 +28,6 @@ services: # PORT defaults to 8080 (internal health server) — omit unless overriding. # Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain. # Cron is in-process only — there is no /cron HTTP route and no secret. - # Do NOT set any *_PARAMETER_NAME vars (those force an SSM/AWS lookup that - # fails with no AWS creds and bricks startup). See .env.example. # No stock/coin/gold *_API_URL overrides — modules use their coded default # providers (stock: SSI/VCI/KBS; coin: Binance→Coinbase→CoinGecko; # gold: VNAppMob→spot). GOLD_VNAPP_API_KEY auto-fetches + caches to Mongo. diff --git a/docs/aws-decommission-runbook.md b/docs/aws-decommission-runbook.md deleted file mode 100644 index 4a01b8f..0000000 --- a/docs/aws-decommission-runbook.md +++ /dev/null @@ -1,126 +0,0 @@ -# AWS Decommission Runbook - -Delete **everything** `miti99bot` ever deployed to AWS, after the migration + -cutover to Coolify is verified. Run by the operator with the `admin` profile. - -> **Precondition (hard):** run ONLY after the Phase 4 cutover — -> `make migrate-verify` green, bot confirmed live on Coolify via long polling, -> and the EventBridge schedule already disabled at cutover. `sam delete` -> destroys the DynamoDB table, which is the sole copy of prod data until -> migrated. Never run this standalone. - -Account `225603493174`, region `ap-southeast-1` (verified live 2026-06-27). - -## What `sam delete` removes (CloudFormation-managed) - -DynamoDB table `miti99bot-data`, the Lambda + Function URL + invoke -permissions, the Lambda execution role and `SchedulerExecutionRole`, the -`/aws/lambda/miti99bot` log group + metric filter, the `miti99bot-cron-dlq` -SQS queue, the `miti99bot-lolschedule-daily-push` schedule, and the -`miti99bot-monthly` budget (if `AlertEmail` was set). - -## What it does NOT remove (created manually, outside CloudFormation) - -These linger — and the SSM secrets keep your bot token / Gemini key in the -cloud — unless deleted separately: - -- **SSM SecureStrings** (exactly 4): `/miti99bot/prod/telegram-bot-token`, - `/miti99bot/prod/telegram-webhook-secret`, `/miti99bot/prod/gemini-api-key`, - `/miti99bot/prod/cron-shared-secret`. **Deleting these is the security step.** -- **IAM role** `github-deploy-miti99bot` + inline policy `miti99bot-deploy`. -- **IAM OIDC provider** `token.actions.githubusercontent.com` — verified the - account's only OIDC provider and used solely by miti99bot → safe to delete. -- **SAM deploy bucket** `aws-sam-cli-managed-default-samclisourcebucket-ctwpsmoxnwvm` - + bootstrap stack `aws-sam-cli-managed-default` — verified miti99bot is the - sole SAM project → safe to delete. - -## Runbook - -```sh -AWS_PROFILE=admin; REGION=ap-southeast-1; ACCT=225603493174 - -# 1. Safety check — stack still exists (about to be deleted). -aws --profile $AWS_PROFILE cloudformation describe-stacks --stack-name miti99bot \ - --query "Stacks[0].StackStatus" - -# 2. Delete the CloudFormation stack. -aws --profile $AWS_PROFILE sam delete --stack-name miti99bot --region $REGION --no-prompts -aws --profile $AWS_PROFILE cloudformation wait stack-delete-complete --stack-name miti99bot - -# 3. Delete SSM secrets (NOT CFN-managed). List first, then delete. -aws --profile $AWS_PROFILE ssm get-parameters-by-path --path /miti99bot --recursive \ - --query "Parameters[].Name" --output text -for P in telegram-bot-token telegram-webhook-secret gemini-api-key cron-shared-secret; do - aws --profile $AWS_PROFILE ssm delete-parameter --name /miti99bot/prod/$P -done -# delete any extra /miti99bot/* the list revealed - -# 4. Delete the GitHub deploy IAM role (inline policy first). -aws --profile $AWS_PROFILE iam delete-role-policy \ - --role-name github-deploy-miti99bot --policy-name miti99bot-deploy -aws --profile $AWS_PROFILE iam delete-role --role-name github-deploy-miti99bot - -# 5. OIDC provider — re-confirm it's the only one, then delete. -aws --profile $AWS_PROFILE iam list-open-id-connect-providers -aws --profile $AWS_PROFILE iam delete-open-id-connect-provider \ - --open-id-connect-provider-arn arn:aws:iam::$ACCT:oidc-provider/token.actions.githubusercontent.com - -# 6. SAM deploy bucket + bootstrap stack — re-confirm only miti99bot + -# aws-sam-cli-managed-default stacks exist first. -aws --profile $AWS_PROFILE cloudformation list-stacks \ - --query "StackSummaries[?StackStatus!='DELETE_COMPLETE'].StackName" --output text -aws --profile $AWS_PROFILE s3 rb \ - s3://aws-sam-cli-managed-default-samclisourcebucket-ctwpsmoxnwvm --force -aws --profile $AWS_PROFILE cloudformation delete-stack --stack-name aws-sam-cli-managed-default - -# 7. Confirm nothing tagged app=miti99bot remains. -aws --profile $AWS_PROFILE resourcegroupstaggingapi get-resources \ - --tag-filters Key=app,Values=miti99bot --region $REGION -aws --profile $AWS_PROFILE cloudformation list-stacks \ - --query "StackSummaries[?contains(StackName,'miti99bot')].[StackName,StackStatus]" --output table -``` - -Then in the repo: the `.github/workflows/deploy.yml` AWS deploy is disabled on -the `feature/selfhosted` branch (the trigger is removed so a `main` push can't -recreate the stack). - -## Verification checklist - -Teardown executed and verified 2026-06-28 (account `225603493174`, region -`ap-southeast-1`). - -- [x] `describe-stacks --stack-name miti99bot` → does not exist. -- [x] No `/miti99bot/*` SSM parameters remain (secrets purged). -- [x] `github-deploy-miti99bot` role gone; OIDC provider gone. -- [x] SAM bucket + bootstrap stack deleted. -- [x] `resourcegroupstaggingapi` for `app=miti99bot` returns empty. -- [x] `deploy.yml` no longer recreates the stack on `main` (trigger is - `workflow_dispatch`-only and depends on the deleted OIDC role). -- [ ] Cost Explorer shows $0 the following billing period. -- [x] Cloudflare verified clean (2026-06-27): legacy KV/D1 already gone; the 4 - remaining Workers are separate active projects. No action. -- [x] GCP: project never used GCP (README lists AWS + Cloudflare only); `gcloud` - not installed in the teardown environment, nothing to clean. - -## Deviations during execution (2026-06-28) - -Two steps differed from the assumptions above; recorded for future reference: - -- **IAM role policies:** `github-deploy-miti99bot` had **10 AWS-managed - policies attached** (`AmazonSSMFullAccess`, `IAMFullAccess`, ...), not the - inline `miti99bot-deploy` policy this runbook assumed — likely the broad-access - fallback from `aws/iam-rollback-fullaccess.sh`. Had to `detach-role-policy` - for all 10 (AWS-managed, so detach only unlinks them) before `delete-role`. -- **Versioned SAM bucket:** `s3 rb --force` failed with `BucketNotEmpty` because - the bucket had versioning on — `--force` removes only current versions, leaving - 126 non-current versions + 126 delete markers. Purged every version + delete - marker via `s3api list-object-versions` + `delete-objects`, then - `delete-bucket`, then retried the bootstrap `delete-stack` (it had gone - `DELETE_FAILED` solely because the bucket couldn't be emptied). - -## Notes - -- **Secret hygiene (optional):** rotate the Telegram bot token + Gemini key - after teardown — they lived in SSM/CloudWatch under accepted trade-offs. -- **Keep in git:** the `aws/` dir + `template.yaml` history cost nothing and are - useful if AWS is ever revisited. diff --git a/docs/deploy-aws-free-tier-guide.md b/docs/deploy-aws-free-tier-guide.md deleted file mode 100644 index 1854592..0000000 --- a/docs/deploy-aws-free-tier-guide.md +++ /dev/null @@ -1,415 +0,0 @@ -# Deploy miti99bot to AWS (Free Tier) - -> **RETIRED.** `miti99bot` is now self-hosted on Coolify + MongoDB Atlas — see -> [`deploy-coolify-selfhosted.md`](./deploy-coolify-selfhosted.md). The AWS stack -> is decommissioned ([`aws-decommission-runbook.md`](./aws-decommission-runbook.md)). -> Kept for historical reference / if AWS is ever revisited. - -End-to-end onboarding guide for deploying `miti99bot` on AWS. Everything below stays inside the AWS free tier in region `ap-southeast-1` (Singapore). - -Related docs: -- One-time bootstrap reference: [`aws/README.md`](../aws/README.md) -- Steady-state operations: [`deploy-aws.md`](./deploy-aws.md) - ---- - -## Project Standards (Non-Negotiables) - -### Standard 1: Free Tier Is a Hard Requirement - -Any AWS line item that incurs a cost is disqualified, even if monthly usage rounds the bill to $0.00. The principle: **no per-invocation or per-request charges**, period. Free-tier qualification is evaluated per service (not aggregate). - -**Disqualified resources:** EventBridge ApiDestination invocations ($0.20/M, no free tier). - -**Free-tier-covered resources:** Lambda (1M req + 400k GB-s/mo), DynamoDB on-demand (2.5M read + 1M write units/mo), EventBridge Scheduler (14M invocations/mo), SQS (1M req/mo), CloudWatch Logs (5 GB ingest/mo), CloudFormation, IAM, Budgets, SSM Parameter Store Standard tier. - -When evaluating new AWS resources, explicitly verify per-service free-tier coverage in the AWS pricing docs. The `MonthlyBudget` resource (see below) is a safety net, but design must not depend on it. If you hit the $1 threshold, something violated this standard. - -### Standard 2: Security Trade-Offs Accepted - -Non-negotiable security boundary: AWS resources must not be publicly reachable except via the designed surface — Lambda Function URL serving `/webhook` (Telegram) and `/cron/{name}` (EventBridge Scheduler). Everything else is private or IAM-gated. - -Trade-offs explicitly accepted in this project (do NOT flag as "harden this" unless they breach the boundary above): -- Secrets in CloudWatch Logs / Lambda environment (visible to anyone with logs:GetLogEvents — IAM gates that). -- Secrets embedded in EventBridge Scheduler Target.Input (visible to anyone with scheduler:GetSchedule — IAM gates that). -- NoEcho CloudFormation parameters instead of Secrets Manager (simplifies deployment, avoids storage cost). -- SSM SecureString fetched at Lambda cold start instead of Secrets Manager with rotation (faster, no API call overhead, acceptable risk given Lambda is not a long-lived server). - -Rationale: in this project, **free-tier compliance outweighs secret-storage maturity**. Architectural change (Secrets Manager, rotation, KMS key rotation) would either cost money or require additional infrastructure to stay free-tier. Rejected in favor of free tier: EventBridge Connection's service-linked Secrets Manager secret (would cost). - -**Red flags that should trigger review:** Any change that (a) makes a non-Function-URL AWS resource publicly reachable, (b) changes Function URL's AuthType from NONE (mitigated by app-level token check), (c) adds a second public-facing Lambda endpoint, (d) ships `cronDisabled=true` due to empty CronSharedSecret. - ---- - -## What you get (all free-tier) - -| Resource | Free quota | This bot's usage | -|---|---|---| -| Lambda (ARM64, 256 MB) | 1M req + 400k GB-s / mo, **always-free** | far below | -| Lambda Function URL | included with Lambda | the Telegram webhook entrypoint | -| DynamoDB on-demand | 25 GiB storage + 2.5M read / 1M write request units / mo **always-free** | far below | -| EventBridge Scheduler | 14M invocations / mo always-free | a few crons | -| SSM Parameter Store (Standard) | unlimited free | 4 SecureString params | -| CloudWatch Logs | 5 GB ingest, 5 GB storage / mo | well below at 7-day retention | -| SQS (cron DLQ) | 1M req / mo always-free | near zero | -| CloudFormation, IAM, Budgets | free | | -| Egress | 100 GB / mo always-free | tiny | - -Paid traps the template already avoids: DynamoDB PITR disabled, no NAT Gateway, no API Gateway, no provisioned concurrency, no VPC, log retention pinned to 7 days, X-Ray "Active" tracing stays within the 100k traces/mo free tier. - ---- - -## Prerequisites (Ubuntu 24.04 ARM64) - -Host arch matches Lambda's `arm64` target, so `make build-lambda` is a native build (still pinned to `GOARCH=arm64` for reproducibility). - -```sh -sudo apt update -sudo apt install -y curl jq make git python3 python3-venv python3-pip -``` - -### AWS CLI + SAM CLI (project-local venv via pip) - -Ubuntu 24.04 enforces PEP 668 (externally-managed system Python), so we install both tools inside a project-local `.venv`. From the repo root: - -```sh -cd /path/to/miti99bot -python3 -m venv .venv -source .venv/bin/activate - -pip install --upgrade pip -pip install awscli aws-sam-cli - -aws --version # aws-cli/1.x (pip ships v1; v2 is not on PyPI) -sam --version -``` - -Activate the venv at the start of every shell session you use for AWS commands: - -```sh -source /path/to/miti99bot/.venv/bin/activate -``` - -If you want auto-activation, install [`direnv`](https://direnv.net/) (`sudo apt install direnv`, then `eval "$(direnv hook bash)"` in `~/.bashrc`) and drop a `.envrc` in the repo containing `source .venv/bin/activate`. Don't override the shell built-in `cd` — that affects every directory you ever enter. - -> **Note:** PyPI's `awscli` is **v1** (v2 is only distributed as the standalone bundle). v1 covers every command used in this guide — `ssm`, `iam`, `cloudformation`, `lambda`, `logs`, `ce`, `cloudwatch`. If you later need v2-only features (e.g. SSO login, new `aws configure sso` flows), install v2 separately from the official ARM zip and keep both. SAM CLI on PyPI tracks upstream releases — `pip install -U aws-sam-cli` to bump. - -Add `.venv/` to `.gitignore` if not already there: - -```sh -grep -qxF '.venv/' .gitignore || echo '.venv/' >> .gitignore -``` - -### Go 1.25 (ARM64) - -Ubuntu 24.04 ships an older Go. Install the upstream tarball: - -```sh -GO_VERSION=1.25.0 -curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-arm64.tar.gz" -o /tmp/go.tgz -sudo rm -rf /usr/local/go -sudo tar -C /usr/local -xzf /tmp/go.tgz -echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc -source ~/.bashrc -go version # expect go1.25.x linux/arm64 -``` - -### GitHub CLI (`gh`) - -```sh -curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg -sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null -sudo apt update -sudo apt install -y gh -``` - -### Docker (optional — only for DynamoDB Local tests) - -```sh -sudo apt install -y docker.io -sudo usermod -aG docker "$USER" -newgrp docker -``` - -### Accounts / keys - -- AWS account (root access). -- Telegram bot token from BotFather. -- Gemini API key from Google AI Studio (free). -- This repo cloned locally. - ---- - -## Step 1 — AWS account hygiene - -1. Log in to the AWS root user, enable MFA. -2. Create an IAM user `admin` with `AdministratorAccess` and CLI access keys. This is used only for the first deploy. -3. Configure the CLI: - ```sh - aws configure set region ap-southeast-1 --profile admin - aws configure set aws_access_key_id AKIA… --profile admin - aws configure set aws_secret_access_key … --profile admin - ``` - ---- - -## Step 2 — Store the 4 secrets in SSM Parameter Store - -Parameter Store Standard tier is free; SecureString uses the AWS-managed KMS key, also free. `--tier Standard` is the default — never pass `--tier Advanced` (that costs $0.05/param/month). - -> **Shell-history hygiene.** Long-lived tokens (BotFather, Gemini) are passed below via `read -s` so they never appear in `~/.bash_history`, `ps`, or backups of either. The two short-lived random tokens (webhook + cron) are generated inline with `openssl rand`. - -Real secrets — read each value interactively, no echo: - -```sh -read -rsp 'BotFather token: ' BOT_TOKEN && echo -aws ssm put-parameter --profile admin --type SecureString --overwrite \ - --name /miti99bot/prod/telegram-bot-token --value "$BOT_TOKEN" -unset BOT_TOKEN - -read -rsp 'Gemini API key: ' GEMINI_KEY && echo -aws ssm put-parameter --profile admin --type SecureString --overwrite \ - --name /miti99bot/prod/gemini-api-key --value "$GEMINI_KEY" -unset GEMINI_KEY -``` - -> 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: - -```sh -aws ssm put-parameter --profile admin --type SecureString --overwrite \ - --name /miti99bot/prod/telegram-webhook-secret --value "$(openssl rand -hex 32)" -aws ssm put-parameter --profile admin --type SecureString --overwrite \ - --name /miti99bot/prod/cron-shared-secret --value "$(openssl rand -hex 32)" -``` - -The webhook + cron values are fetched back in Step 5 via `aws ssm get-parameter` — no need to copy them out by hand. - -Confirm all four exist (names only, no values): - -```sh -aws ssm get-parameters-by-path --profile admin \ - --path /miti99bot/prod/ --query 'Parameters[].Name' --output table -``` - ---- - -## Step 3 — Register GitHub OIDC + deploy role (one-time) - -> Run all commands in this step from the repo root — `aws/iam-github-oidc-trust.json` is referenced as a relative path. - -```sh -cd /path/to/miti99bot -``` - -Register the OIDC identity provider (idempotent — skips creation if it already exists): - -```sh -ACCT=$(aws sts get-caller-identity --profile admin --query Account --output text) -OIDC_ARN="arn:aws:iam::${ACCT}:oidc-provider/token.actions.githubusercontent.com" - -aws iam get-open-id-connect-provider --profile admin \ - --open-id-connect-provider-arn "$OIDC_ARN" >/dev/null 2>&1 \ - || aws iam create-open-id-connect-provider --profile admin \ - --url https://token.actions.githubusercontent.com \ - --client-id-list sts.amazonaws.com \ - --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 -``` - -Edit `aws/iam-github-oidc-trust.json` if you're deploying from a different AWS account or repo fork. This repo is already prefilled for account `225603493174` and `tiennm99/miti99bot`: - -```sh -sed -i "s|225603493174|$ACCT|" aws/iam-github-oidc-trust.json # only if you are changing accounts -``` - -If you change accounts, update `.github/workflows/deploy.yml` to match the same role ARN. - -Create the role (idempotent — `update-assume-role-policy` if it already exists): - -```sh -aws iam get-role --profile admin --role-name github-deploy-miti99bot >/dev/null 2>&1 \ - && aws iam update-assume-role-policy --profile admin \ - --role-name github-deploy-miti99bot \ - --policy-document file://aws/iam-github-oidc-trust.json \ - || aws iam create-role --profile admin \ - --role-name github-deploy-miti99bot \ - --assume-role-policy-document file://aws/iam-github-oidc-trust.json -``` - -Attach the managed policies (re-attaching the same policy is a no-op, so this loop is safely re-runnable): - -```sh -for arn in \ - arn:aws:iam::aws:policy/AWSCloudFormationFullAccess \ - arn:aws:iam::aws:policy/AWSLambda_FullAccess \ - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess \ - arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess \ - arn:aws:iam::aws:policy/AmazonSQSFullAccess \ - arn:aws:iam::aws:policy/AmazonSSMFullAccess \ - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess \ - arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess \ - arn:aws:iam::aws:policy/IAMFullAccess \ - arn:aws:iam::aws:policy/AmazonS3FullAccess; do - aws iam attach-role-policy --profile admin \ - --role-name github-deploy-miti99bot --policy-arn "$arn" -done -``` - -These managed policies are intentionally broad for the first deploy. `IAMFullAccess` and `AmazonS3FullAccess` are the widest blast radius — tighten them first when you reach Step 7. - ---- - -## Step 4 — First deploy (manual) - -```sh -cd /path/to/miti99bot -make build-lambda # cross-compiles Go → linux/arm64 -AWS_PROFILE=admin sam deploy --template-file template.yaml --guided # accept samconfig.toml defaults -``` - -> **Why `--template-file template.yaml`.** By default `sam deploy` looks for `.aws-sam/build/template.yaml` (output of `sam build`). We skip `sam build` because SAM's default builder for `provided.al2023` expects a `Makefile` inside `CodeUri: build/lambda/` — which is the *output* directory of `make build-lambda`, not a source dir. Pointing `sam deploy` at the raw source template tells it to read `CodeUri: build/lambda/` directly, zip the bootstrap binary, upload it, and deploy. The `make build-lambda` step above is the actual compile. - -Confirm at the SAM prompt: -- Stack name: `miti99bot` -- Region: `ap-southeast-1` -- Capabilities: `CAPABILITY_IAM` -- Save to `samconfig.toml`: yes - -After `CREATE_COMPLETE`, grab the Function URL: - -```sh -aws cloudformation describe-stacks --profile admin \ - --stack-name miti99bot \ - --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text -``` - ---- - -## Step 5 — Point Telegram at the webhook - -> For first-time setup only. After `Step 6` wires the GitHub workflow, every push to `main` auto-runs `setWebhook` + `setMyCommands`; this manual block is the break-glass path. - -```sh -URL=… # from previous command -TOKEN=$(aws ssm get-parameter --profile admin \ - --name /miti99bot/prod/telegram-bot-token --with-decryption \ - --query Parameter.Value --output text) -SECRET=$(aws ssm get-parameter --profile admin \ - --name /miti99bot/prod/telegram-webhook-secret --with-decryption \ - --query Parameter.Value --output text) - -curl -X POST "https://api.telegram.org/bot$TOKEN/setWebhook" \ - -d "url=${URL}webhook" \ - -d "secret_token=$SECRET" \ - -d "allowed_updates=[\"message\",\"callback_query\"]" - -curl "https://api.telegram.org/bot$TOKEN/getWebhookInfo" | jq . -``` - -Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_date` empty. Send `/start` to the bot — response should arrive within a couple seconds. - ---- - -## Step 6 — Wire GitHub Actions for future deploys - -In GitHub → repo Settings → Secrets and variables → Actions: - -| Secret | Value | -|---|---| -| `ALERT_EMAIL` (optional) | Email for the $1 budget alert | - -After this, every push to `main` triggers `.github/workflows/deploy.yml`: -1. OIDC assume `github-deploy-miti99bot` role -2. `make build-lambda` -3. `sam deploy --template-file template.yaml` -4. Smoke `curl /` - -No long-lived keys live in GitHub. The deploy workflow now uses the repo's fixed AWS account ID directly for the OIDC role ARN, so `AWS_ACCOUNT_ID` no longer needs to be stored in GitHub. - ---- - -## Step 7 — Lock down (recommended once it works) - -1. Rotate / delete `admin` CLI keys (keep the user for console-only emergencies). -2. Trigger a `workflow_dispatch` deploy via GH Actions to confirm OIDC path works without the bootstrap user. -3. Replace the broad managed policies on `github-deploy-miti99bot` with stack-scoped custom policies (resource ARNs from your stack). - ---- - -## Step 8 — Cost guardrails - -- Set `ALERT_EMAIL` → enables a $1/mo AWS Budgets alarm at 80% and 100%. -- Daily checks (logs, DDB throttle, cold-start P95, MTD spend) → see [`deploy-aws.md`](./deploy-aws.md) "Operational checks". -- Idle steady-state cost is **$0**. If you ever see >$0.01 in Cost Explorer, investigate. Most likely culprits: - - CloudWatch Logs ingestion volume (verbose logging, hot loops). - - DynamoDB writes from a runaway loop. - - Accidental egress past the 100 GB free tier. - ---- - -## Free-tier watch table - -| Resource | Free | Watch when | -|---|---|---| -| Lambda req / GB-s | 1M / 400k | Past 50% mid-month | -| DynamoDB req | 200M | Past 5% (sign of runaway loop) | -| DynamoDB storage | 25 GiB | Past 100 MiB (suspect leaks) | -| EventBridge invocations | 14M | Past 1k/mo (suspect mis-config) | -| CloudWatch Logs ingest | 5 GB | Past 50% mid-month | -| Egress | 100 GB | Past 1 GB (wildly high) | - -The $1 budget alarm catches all of these via cost-side fallout. - ---- - -## Rollback - -CloudFormation has three rollback flavors. Pick the one that matches your situation. - -### Case A — `sam deploy` is currently failing - -CloudFormation auto-initiates a rollback. Nothing to do. If the rollback itself fails (`UPDATE_ROLLBACK_FAILED`): - -```sh -aws cloudformation continue-update-rollback --profile admin \ - --stack-name miti99bot -``` - -### Case B — `sam deploy` is running and you want to abort - -```sh -aws cloudformation cancel-update-stack --profile admin \ - --stack-name miti99bot -``` - -CloudFormation rolls back to the prior `CREATE_COMPLETE` / `UPDATE_COMPLETE` state. - -### Case C — Deploy succeeded but the code is bad - -CloudFormation has no "redeploy previous template" command (`--use-previous-template` re-applies the *current* template, not an older one — it does **not** roll back). Redeploy from the last known-good commit: - -```sh -git checkout -make build-lambda -make sam-deploy -``` - -To find the last good SHA quickly: `git log --oneline -- template.yaml cmd/server` and pick the commit that matches a passing deploy. - ---- - -## Rotating secrets - -```sh -aws ssm put-parameter --profile admin --type SecureString --overwrite \ - --name /miti99bot/prod/telegram-webhook-secret \ - --value "$(openssl rand -hex 32)" -# Recycle the Lambda before switching Telegram to the new secret, or wait for -# AWS to create a fresh execution environment naturally. -``` - -> `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`. diff --git a/docs/deploy-aws.md b/docs/deploy-aws.md deleted file mode 100644 index 01faccc..0000000 --- a/docs/deploy-aws.md +++ /dev/null @@ -1,216 +0,0 @@ -# Deploy: AWS (Lambda + DynamoDB + EventBridge) - -> **RETIRED.** `miti99bot` is now self-hosted on Coolify + MongoDB Atlas — see -> [`deploy-coolify-selfhosted.md`](./deploy-coolify-selfhosted.md). The AWS stack -> is decommissioned ([`aws-decommission-runbook.md`](./aws-decommission-runbook.md)). -> Kept for historical reference / if AWS is ever revisited. - -This is the production deploy path for `miti99bot`. Strict free-tier targets, region `ap-southeast-1`. - -> **First-time setup:** see `aws/README.md`. This doc is for steady-state operations. - -## Architecture (one diagram) - -``` -Telegram ──HTTPS──► Lambda Function URL (AuthType: NONE) - └─► AWS Lambda Web Adapter ──► localhost:8080 - └─► Go http.Handler (cmd/server) - ├─► DynamoDB (KV) - ├─► Gemini API (AI modules) - └─► Telegram Bot API (replies) - -EventBridge Scheduler ──cron──► HTTPS POST /cron/{name} - + Header X-Cron-Token (from SSM) -``` - -## Deploy - -### Via GitHub Actions (canonical) - -``` -git push origin main -``` - -Triggers `.github/workflows/deploy.yml`: -1. OIDC assume `github-deploy-miti99bot` role -2. `make build-lambda` (Go ARM64 ZIP-ready binary) -3. `sam deploy --template-file template.yaml` -4. Smoke `curl /` - -### Manual (emergency / staging) - -```sh -make build-lambda -make sam-deploy # uses samconfig.toml defaults -ALERT_EMAIL=you@example.com make sam-deploy # with budget alert wired -``` - -## Verify - -```sh -make logs SINCE=10m - -aws cloudformation describe-stacks --stack-name miti99bot \ - --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text - -curl -fsSL "$(...)/" | jq . # health JSON -``` - -## Set the Telegram webhook - -> `.github/workflows/deploy.yml` auto-runs `setWebhook` + `setMyCommands` after every push to `main`. The snippet below is the break-glass equivalent for manual / out-of-band fixes (e.g. rerun from a workstation when CI is unavailable). - -```sh -URL=$(aws cloudformation describe-stacks --stack-name miti99bot \ - --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text) -SECRET=$(aws ssm get-parameter --name /miti99bot/prod/telegram-webhook-secret \ - --with-decryption --query 'Parameter.Value' --output text) -TOKEN=$(aws ssm get-parameter --name /miti99bot/prod/telegram-bot-token \ - --with-decryption --query 'Parameter.Value' --output text) - -curl -X POST "https://api.telegram.org/bot$TOKEN/setWebhook" \ - -d "url=${URL}webhook" \ - -d "secret_token=$SECRET" \ - -d "drop_pending_updates=false" \ - -d "allowed_updates=[\"message\",\"callback_query\"]" -``` - -Verify: -```sh -curl "https://api.telegram.org/bot$TOKEN/getWebhookInfo" | jq . -``` -Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_date` empty. - -## Adding a module or command (registration checklist) - -A module only runs in production if its name is in **both** `ModulesCSV` sources — the -`template.yaml` default is ignored once an override is passed, so editing one place is -not enough. A command only appears in the Telegram menu if it is in -`aws/telegram-commands.json`. Missing either is silent: no error, the command just -never dispatches (this is how `coin_*` shipped dark until `coin` was added to the CSVs). - -When **adding a new module**, register it in all of: - -1. `cmd/server/main.go` — add the factory to the catalog (`"name": pkg.New`). -2. `.github/workflows/deploy.yml` — append the name to `ModulesCSV=…` (CI override). -3. `samconfig.toml` — append the name to `ModulesCSV=…` (manual-deploy override; keep in sync with the workflow). -4. `template.yaml` — append to the `ModulesCSV` `Default` (documents the full set). -5. `aws/telegram-commands.json` — add each new command + description for the Telegram menu. - -When **adding a command to an existing, already-enabled module**, only step 5 applies. - -**On push to `main`:** CI redeploys and re-runs `setMyCommands` from -`aws/telegram-commands.json` automatically. The Telegram client caches the command -menu, so a changed menu may not show until the chat is reopened — confirm with -`make telegram-commands-info` (calls `getMyCommands`) rather than trusting the app UI. -Only when a push introduces **new public commands** (`VisibilityPublic`) does the menu -need attention — re-confirm registration for those pushes; routine pushes (refactors, -fixes, non-public commands) need no menu action. - -## Stock income events API - -`/stock_income_events` uses a FireAnt REST API, configured at Lambda runtime: - -- `STOCK_INCOME_EVENTS_API_URL`: FireAnt base URL; defaults to `https://restv2.fireant.vn`. The bot calls `/symbols/{symbol}/timescale-marks` with `startDate` and `endDate`. -- `STOCK_INCOME_EVENTS_API_TOKEN`: bearer token for FireAnt. Store it directly only for local dev; in AWS prefer `STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME`. - -FireAnt response is an array of timescale marks with `id`, `label`, `date`, `title`, and `color`. The bot keeps marks whose label/title indicate dividends, ex-right dates, final registration dates, rights issues, or bonus/share dividends. - -## Stock price providers - -`/stock_buy`, `/stock_sell`, and `/stock_stats` use unofficial public quote endpoints. Zero-value provider order is: - -1. KBS current price board (`/stock/iss`). -2. VCI current quote board (`/price/symbols/getList`). -3. SSI iBoard direct quote. - -This order is intentional for current-price commands. KBS and VCI both support batch current quotes, while SSI can return a Cloudflare security page. Treat all three as unofficial app-internal endpoints and keep provider/source errors visible in Lambda logs. - -## Gold module - -`gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold`. - -Commands: - -- `/gold_price` shows current gold price. When VNAppMob SJC is available it prints buy/sell/mid VND/lượng; otherwise it falls back to world spot XAU in USD/oz plus USD/VND rate. -- `/gold_topup ` credits VND. No currency argument is accepted. -- `/gold_buy ` buys gold in Vietnamese `luong`. No symbol or unit argument is accepted. -- `/gold_sell ` sells gold in Vietnamese `luong`. -- `/gold_stats` shows VND balance, gold holding, current price, total value, invested amount, and P&L. - -Price source: primary is VNAppMob Vietnam SJC price feed (`api.vnappmob.com/api/v2/gold/sjc`), which returns VND/lượng directly. The client auto-refreshes a free JWT API key and caches it in KV. If VNAppMob fails, the bot falls back to world spot XAU from GoldPrice.org converted through USD/VND from ExchangeRate-API open endpoint. The defaults require no secrets. Optional overrides: - -- `GOLD_VNAPP_API_URL`: VNAppMob API base URL override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests. -- `GOLD_VNAPP_API_KEY`: pre-issued VNAppMob JWT key. When set, auto-refresh is skipped. Useful for local dev or SSM injection. -- `GOLD_VNAPP_API_KEY_PARAMETER_NAME`: SSM SecureString parameter name containing the pre-issued key. Fetched at Lambda cold start. -- `GOLD_PRICE_API_URL`: fallback gold spot JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests. -- `GOLD_FX_API_URL`: fallback USD/VND FX JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests. - -ExchangeRate-API open endpoint requires attribution if surfaced publicly and updates once per day; the bot caches FX responses until the provider `time_next_update_unix` when available. - -## Rotate secrets - -```sh -aws ssm put-parameter --name /miti99bot/prod/telegram-webhook-secret \ - --value "$(openssl rand -hex 32)" --type SecureString --overwrite -# template.yaml uses ":1" version pin; redeploy to pick up the new value: -make sam-deploy -# Then re-run setWebhook (above) with the new secret_token. -``` - -> The `:1` in `{{resolve:ssm-secure:…:1}}` is the parameter **version** — it pins to the latest version at deploy time, not version 1 forever. To force a refresh after rotation, redeploy. - -## Rollback - -CloudFormation handles failed deploys: a failing `sam deploy` triggers automatic rollback to the prior version. To roll back a successful-but-bad deploy: - -```sh -aws cloudformation update-stack \ - --stack-name miti99bot \ - --use-previous-template \ - --capabilities CAPABILITY_IAM -``` - -Or redeploy from a known-good commit: -```sh -git checkout -make sam-deploy -``` - -## Operational checks (daily during 7-day soak) - -```sh -# Errors / warnings in last 24h -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 \ - --start-time $(($(date +%s) - 86400)) --end-time $(date +%s) \ - --query-string 'filter @type = "REPORT" | stats avg(@initDuration), pct(@initDuration, 95)' - -# DynamoDB throttle -aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB \ - --metric-name ThrottledRequests --dimensions Name=TableName,Value=miti99bot-data \ - --statistics Sum --start-time $(date -u -d '24 hours ago' +%FT%TZ) \ - --end-time $(date -u +%FT%TZ) --period 3600 - -# Current month spend -aws ce get-cost-and-usage --granularity MONTHLY \ - --time-period Start=$(date -u +%Y-%m-01),End=$(date -u +%F) \ - --metrics UnblendedCost -``` - -## Free-tier guardrails - -| Resource | Free | Watch when | -|---|---|---| -| Lambda req / GB-s | 1M / 400k | Past 50% mid-month | -| DynamoDB req | 200M | Past 5% (sign of runaway loop) | -| DynamoDB storage | 25 GiB | Past 100 MiB (suspect leaks) | -| EventBridge invocations | 14M | Past 1k/mo (suspect mis-config) | -| CloudWatch Logs ingest | 5 GB | Past 50% mid-month | -| Egress | 100 GB | Past 1 GB (wildly high) | - -A `$1` budget alert at 80%/100% catches all of these via cost-side fallout. diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index c903b68..b3c76c6 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -1,8 +1,7 @@ # Deploy: Self-host (Coolify + MongoDB Atlas) Run `miti99bot` as a long-lived container on [Coolify](https://coolify.io) with -[MongoDB Atlas](https://www.mongodb.com/atlas) (free M0) for storage. This -replaces the AWS Lambda + DynamoDB + EventBridge path. +[MongoDB Atlas](https://www.mongodb.com/atlas) (free M0) for storage. ## Architecture @@ -14,12 +13,9 @@ replaces the AWS Lambda + DynamoDB + EventBridge path. NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in) ``` -Same Go binary (`cmd/server`) and module framework as AWS. Three things differ, -all selected automatically from env: - - **Storage** — `mongodb` auto-selected when `MONGO_URL` is set (no `KV_PROVIDER`). - **Cron** — an in-process scheduler (`internal/cron`) runs unconditionally and - fires each module cron on its `Schedule` (UTC). No EventBridge. + fires each module cron on its `Schedule` (UTC). - **Transport** — long polling (`b.Start`) is the **only** transport. The bot opens an outbound connection to Telegram and pulls updates, so there is no public domain, no `/webhook`, and no webhook secret. The container clears any @@ -39,10 +35,9 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. | `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) | | `GEMINI_API_KEY` | optional | only the `twentyq` module needs it | -**Leave UNSET on self-host:** all `*_PARAMETER_NAME` vars (they force an -SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`, -`PORT`, `TELEGRAM_WEBHOOK_SECRET`, `GOLD_VNAPP_API_KEY`, and the -`STOCK/COIN/GOLD *_API_URL` overrides (modules use coded defaults). +**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`, +`TELEGRAM_WEBHOOK_SECRET`, `GOLD_VNAPP_API_KEY`, and the `STOCK/COIN/GOLD +*_API_URL` overrides (modules use coded defaults). > Cron runs in-process (`internal/cron`) — there is no `/cron` HTTP route and no > `CRON_SHARED_SECRET`. The scheduler is the sole trigger; nothing inbound. @@ -57,11 +52,10 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`, > **Accepted trade-off (validated decision).** The Coolify host has no stable > egress IP, so the Atlas IP allow-list is open to the internet. This widens - > the surface beyond DynamoDB's IAM-gated posture (where the DB was never - > internet-reachable). It is knowingly accepted for self-host. The mandatory - > compensating controls are: (1) strong unique password, (2) least-privilege - > `readWrite`-on-one-db user, (3) the connection string is a secret and is - > never logged (the bot logs only the database name on startup). + > the database surface. The mandatory compensating controls are: (1) strong + > unique password, (2) least-privilege `readWrite`-on-one-db user, (3) the + > connection string is a secret and is never logged (the bot logs only the + > database name on startup). 4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the db name in `MONGO_DATABASE`. @@ -72,8 +66,7 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`, > expand and are queryable in Compass. The two non-object values are wrapped in a > named field: lolschedule subscribers under `subscribers` (array) and the daily > push date under `date`. Concurrency uses the `version` field (optimistic lock); -> `updatedAt` is a BSON Date. The DynamoDB→Mongo migrator writes this shape -> directly, and is idempotent. +> `updatedAt` is a BSON Date. ## 2. Coolify @@ -108,57 +101,23 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`, Long polling needs no webhook registration — only the command menu: ```sh -TELEGRAM_BOT_TOKEN=… make telegram-commands-selfhost +TELEGRAM_BOT_TOKEN=… make telegram-commands ``` -## Cutover runbook +## Operations -Zero-loss switch from the live AWS Lambda to the Coolify poller. Coordinate -users to pause activity during the brief window. +The live deployment is the Coolify container and MongoDB is the sole system of +record. Keep exactly one replica running. To confirm Telegram is in polling mode: -1. **Deploy the Coolify container but keep it stopped/scaled-to-0.** A running - poller would 409 against the live Lambda webhook and its scheduler would - overlap EventBridge. Use a fresh, empty Atlas DB. -2. **Disable/delete the EventBridge schedule** `miti99bot-lolschedule-daily-push` - (it invokes the Lambda directly, independent of transport). The in-process - scheduler's per-UTC-date idempotency guard is the backup. -3. **`deleteWebhook` (mandatory):** - ```sh - TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook-selfhost - ``` - This buffers incoming updates (Telegram retains ~24h) so nothing is lost, and - releases the webhook so the poller won't 409. After this the Lambda stops - receiving updates. -4. **Migrate + verify** (read-only on DynamoDB — see - [`cmd/migrate-dynamo-to-mongo`](../cmd/migrate-dynamo-to-mongo/README.md)): - ```sh - export MONGO_URL=… MONGO_DATABASE=… AWS_PROFILE=miti99bot-migrate - make migrate-dynamo-to-mongo DRY_RUN=1 # review counts - make migrate-dynamo-to-mongo # real run - make migrate-verify # counts must match, exit 0 - ``` - Keep this window short (target minutes). -5. **Start the Coolify container** (1 replica). Its scheduler runs by default - (safe now that EventBridge is off). On startup it `deleteWebhook`s again - (idempotent) and begins polling, draining Telegram's buffered queue. -6. **Confirm:** - ```sh - TELEGRAM_BOT_TOKEN=… make telegram-webhook-info-selfhost - ``` - `url` should be empty and `pending_update_count` should drain toward 0. - Smoke `/ping`, `/stats`, and a coin/stock balance command — migrated state - should be visible from Atlas. -7. **Tear down AWS** once verified — see - [`aws-decommission-runbook.md`](./aws-decommission-runbook.md). +```sh +TELEGRAM_BOT_TOKEN=… make telegram-webhook-info +``` -### Rollback +`url` should be empty. If needed, clear the webhook explicitly: -The only clean revert is **before `sam delete`**: stop the poller, then -re-`setWebhook` to the Lambda Function URL (the still-deployed Lambda runs its -own webhook code until teardown). Lossless **only until the first post-cutover -Mongo write** — after that, MongoDB/Coolify is the sole system of record. This -short-window RPO was an explicitly accepted decision; no reverse migrator -exists. +```sh +TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook +``` ## Local smoke test diff --git a/go.mod b/go.mod index 2c76607..c878dd3 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,6 @@ module github.com/tiennm99/miti99bot go 1.25.0 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 github.com/robfig/cron/v3 v3.0.1 go.mongodb.org/mongo-driver/v2 v2.7.0 @@ -18,19 +14,6 @@ require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect diff --git a/go.sum b/go.sum index a226ef9..496d27d 100644 --- a/go.sum +++ b/go.sum @@ -4,40 +4,6 @@ cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3 h1:XgjzLEE8CrNYnr4Xmi1W5PfKsKMjp4Pu1rWkJNO43JI= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3/go.mod h1:r7sfLXEN8RUA89tAHy1E7lCtVOOWIkqVy/FbnUdxW1E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 h1:3Eo/PBBnjFi1+gYfaL286dpmFSW3mTfodBIybq36Qv4= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23/go.mod h1:3oh+5xGSd1iuxonVb3Qbm+WJYlbhczT9kbzr6doJLzY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -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= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/ai/client.go b/internal/ai/client.go index 8fe232b..3c28e8a 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -25,18 +25,17 @@ var ErrRateLimited = errors.New("ai: rate limited") var ErrNotConfigured = errors.New("ai: GEMINI_API_KEY not set") // Client wraps a *genai.Client with the small surface the bot needs. The -// underlying gRPC connection is reused across requests — Lambda cold-start -// budget makes a per-request handshake intolerable. +// underlying gRPC connection is reused across requests so each command does not +// pay a fresh handshake cost. // // Safe for concurrent use; *genai.Client is itself goroutine-safe. type Client struct { g *genai.Client } -// NewClient constructs a *Client backed by the Gemini API (not Vertex AI — -// Vertex requires a service-account flow incompatible with the free-tier -// Lambda baseline). A blank apiKey returns ErrNotConfigured so callers -// can decide whether to skip AI-dependent module loading. +// NewClient constructs a *Client backed by the Gemini API. A blank apiKey +// returns ErrNotConfigured so callers can decide whether to skip AI-dependent +// module loading. func NewClient(ctx context.Context, apiKey string) (*Client, error) { if strings.TrimSpace(apiKey) == "" { return nil, ErrNotConfigured diff --git a/internal/ai/ratelimit.go b/internal/ai/ratelimit.go index e957e14..8f68d66 100644 --- a/internal/ai/ratelimit.go +++ b/internal/ai/ratelimit.go @@ -15,13 +15,12 @@ import ( // // Why we don't enforce daily caps here: x/time/rate is a token bucket, not // a fixed-window counter. Per-day caps need a different abstraction; if we -// hit RPD limits in practice we'll add a DynamoDB-backed counter. +// hit RPD limits in practice we'll add a persistent counter. // // Memory bound: the buckets map is never evicted. Each entry costs ~120 B. -// On Lambda the container recycles every ~hour, so the map size is bounded -// by "distinct subjects within one container lifetime" — small enough to -// not justify an LRU. Same rationale as keylock.Map; if either ever runs -// outside Lambda for long-lived processes, both will need eviction. +// The bot's expected audience is small enough that the distinct-subject working +// set does not justify an LRU. Same rationale as keylock.Map; add eviction if +// either map starts growing materially in production. type PerUserLimiter struct { mu sync.Mutex buckets map[string]*rate.Limiter diff --git a/internal/cron/scheduler.go b/internal/cron/scheduler.go index 30f72c4..cf36560 100644 --- a/internal/cron/scheduler.go +++ b/internal/cron/scheduler.go @@ -1,7 +1,6 @@ -// Package cron runs module crons in-process for the self-hosted (long-lived -// container) deployment. On AWS, EventBridge Scheduler hit /cron/{name}; off -// AWS there is no external trigger, so this scheduler reads each registered -// cron's Schedule field and fires its handler on time, in UTC. +// Package cron runs module crons in-process for the self-hosted long-lived +// container. It reads each registered cron's Schedule field and fires its +// handler on time, in UTC. package cron import ( diff --git a/internal/keylock/keylock.go b/internal/keylock/keylock.go index fbab6fc..6a0f981 100644 --- a/internal/keylock/keylock.go +++ b/internal/keylock/keylock.go @@ -8,9 +8,8 @@ // race and drop one write. // // Trade-off: the underlying sync.Map grows unboundedly with distinct keys -// (~32 B each). At 1M keys that's ~32 MB — acceptable for the lifetime of -// a Lambda instance, which restarts well before reaching that scale. -// Eviction is intentionally deferred — restart frequency keeps the working set bounded. +// (~32 B each). At the current bot scale, that is acceptable; add eviction if +// production cardinality starts growing materially. package keylock import "sync" diff --git a/internal/log/log.go b/internal/log/log.go index 328bab5..2fb1d89 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -1,7 +1,6 @@ // Package log is a thin facade over stdlib log/slog with a JSON handler -// preconfigured for CloudWatch Logs. Lambda reads stdout line-by-line; with -// a JSON line, CloudWatch Logs picks up `severity`, `message`, and `time` and -// surfaces remaining fields as structured labels for filtering. +// configured for container stdout. JSON lines keep fields structured in the +// hosting log sink. // // Why a facade instead of importing slog directly: (1) callers stay // log-package-agnostic (we can swap to logrus/zap later by editing one file); diff --git a/internal/metrics/counters.go b/internal/metrics/counters.go index 94428c4..e4d9603 100644 --- a/internal/metrics/counters.go +++ b/internal/metrics/counters.go @@ -1,16 +1,9 @@ -// Package metrics is a tiny in-memory counter store with periodic flush -// to CloudWatch Logs via the project's structured logger. +// Package metrics is a tiny in-memory counter store with periodic flush to the +// project's structured logger. // -// Why not Prometheus / OpenTelemetry: the project runs on Lambda free -// tier with scale-to-zero. A pull-based exporter would be scraped from -// outside the instance and routinely hit a cold pod, defeating the point. -// Push-based exporters (StatsD, OTLP) require a paid sink. -// -// CloudWatch Logs is already free up to a generous quota and supports -// log-based metrics (count over `jsonPayload.msg=metrics`) for dashboards -// and alerts. Per-instance counters are reset on flush so the log line -// represents a delta, which CloudWatch Logs's count aggregation can sum -// across instances and time windows. +// The project intentionally avoids running a metrics sidecar or external +// exporter. Per-instance counters are reset on flush so each log line +// represents a delta that can be aggregated by the hosting log sink. package metrics import ( @@ -24,7 +17,7 @@ import ( // DefaultFlushInterval is how often Run flushes counters to the log. 60s // keeps log volume modest (1 metrics line per minute per active instance) -// while still surfacing minute-scale traffic shifts in CloudWatch. +// while still surfacing minute-scale traffic shifts. const DefaultFlushInterval = 60 * time.Second // Registry holds named counters across three categories: command @@ -32,7 +25,7 @@ const DefaultFlushInterval = 60 * time.Second // // Counters use atomic.Int64 so increments don't lock; the per-name map // itself is guarded by an RWMutex for the rare add path. Names should be -// short and stable — they become CloudWatch Logs label values. +// short and stable — they become log label values. type Registry struct { mu sync.RWMutex commands map[string]*atomic.Int64 diff --git a/internal/modules/coin/portfolio_test.go b/internal/modules/coin/portfolio_test.go index a4384c7..cccb389 100644 --- a/internal/modules/coin/portfolio_test.go +++ b/internal/modules/coin/portfolio_test.go @@ -80,7 +80,7 @@ func (s *conflictOnceStore) PutVersioned(ctx context.Context, key string, expect s.conflicted = true competing := NewPortfolio(1) competing.AddUSD(10) - if err := s.Store.Put(ctx, key, competing); err != nil { + if err := s.Put(ctx, key, competing); err != nil { return err } return storage.ErrConflict diff --git a/internal/modules/coin/views.go b/internal/modules/coin/views.go index 0f1f503..404e8a0 100644 --- a/internal/modules/coin/views.go +++ b/internal/modules/coin/views.go @@ -27,8 +27,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda // Fetch sequentially (not concurrently) so the price client's keep-alive // connection pool is reused across coins rather than opening N simultaneous - // TLS handshakes — the latter thrashes the CPU-constrained Lambda and times - // out. The reply-reserved sub-context bounds the whole loop so the final + // TLS handshakes. The reply-reserved sub-context bounds the whole loop so the final // Reply keeps its budget; a slow/failed provider degrades to "(price // unavailable)" instead of failing the summary. fetchCtx, cancel := chathelper.FetchContext(ctx) diff --git a/internal/modules/gold/portfolio_test.go b/internal/modules/gold/portfolio_test.go index 0828314..1d9466b 100644 --- a/internal/modules/gold/portfolio_test.go +++ b/internal/modules/gold/portfolio_test.go @@ -88,7 +88,7 @@ func (s *conflictOnceStore) PutVersioned(ctx context.Context, key string, expect s.conflicted = true competing := NewPortfolio(1) competing.AddVND(10) - if err := s.PortfolioStore.Put(ctx, key, competing); err != nil { + if err := s.Put(ctx, key, competing); err != nil { return err } return storage.ErrConflict diff --git a/internal/modules/gold/price_providers.go b/internal/modules/gold/price_providers.go index 71720de..f10d6c8 100644 --- a/internal/modules/gold/price_providers.go +++ b/internal/modules/gold/price_providers.go @@ -10,7 +10,7 @@ import ( ) // XAU/USD provider defaults. All three are free, keyless, and verified to -// answer datacenter IPs (goldprice.org was dropped: it 403s AWS/cloud IPs). +// answer datacenter IPs (goldprice.org was dropped: it 403s cloud/datacenter IPs). const ( goldAPIDefaultURL = "https://api.gold-api.com/price/XAU" swissquoteDefaultURL = "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD" diff --git a/internal/modules/gold/vnappmob_client.go b/internal/modules/gold/vnappmob_client.go index ccd04bd..93c3344 100644 --- a/internal/modules/gold/vnappmob_client.go +++ b/internal/modules/gold/vnappmob_client.go @@ -45,9 +45,8 @@ type VNAppMobClient struct { } // NewVNAppMobClientFromEnv creates a client reading GOLD_VNAPP_API_URL and -// GOLD_VNAPP_API_KEY from the environment. The API key env var is intended -// for local dev or SSM injection; when empty the client refreshes the key -// automatically via the VNAppMob refresh endpoint. +// GOLD_VNAPP_API_KEY from the environment. When the key is empty, the client +// refreshes it automatically via the VNAppMob refresh endpoint. func NewVNAppMobClientFromEnv(coll storage.Collection) *VNAppMobClient { return &VNAppMobClient{ BaseURL: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_URL")), diff --git a/internal/modules/loldle/state_test.go b/internal/modules/loldle/state_test.go index c2c236f..46da2e8 100644 --- a/internal/modules/loldle/state_test.go +++ b/internal/modules/loldle/state_test.go @@ -20,13 +20,6 @@ func newLoldleConfig() ConfigStore { return storage.Typed[roundConfig](storage.NewMemoryProvider().Collection("loldle")) } -// newLoldleStores returns all three stores backed by the same collection -// (disjoint key prefixes: "game:", "stats:", "config:"). -func newLoldleStores() (GameStore, StatsStore, ConfigStore) { - c := storage.NewMemoryProvider().Collection("loldle") - return storage.Typed[gameState](c), storage.Typed[stats](c), storage.Typed[roundConfig](c) -} - func TestGameState_StartedAtNullByDefault(t *testing.T) { g := gameState{Target: "Aatrox", Guesses: []string{}} b, err := json.Marshal(g) diff --git a/internal/modules/lolschedule/api_client.go b/internal/modules/lolschedule/api_client.go index 37ddb7e..9e829ed 100644 --- a/internal/modules/lolschedule/api_client.go +++ b/internal/modules/lolschedule/api_client.go @@ -39,15 +39,14 @@ const ( // upstream call fails outright. staleMaxAge = 60 * 60 * time.Second // httpTimeout: keep upstream calls bounded so a hung lolesports edge - // can't hold a Lambda instance. + // can't hold a worker goroutine indefinitely. httpTimeout = 8 * time.Second ) // Team is one side of a match. JSON shape matches the lolesports response. // bson tags mirror the json names exactly: this tree is persisted inside -// cacheRecord, and the DynamoDB→Mongo migrator preserves the original (camelCase) -// JSON keys, so the store must read those keys back verbatim — not the driver's -// lowercased default. +// cacheRecord, so the store must read those keys back verbatim — not the +// driver's lowercased default. type Team struct { Name string `json:"name,omitempty" bson:"name,omitempty"` Code string `json:"code,omitempty" bson:"code,omitempty"` diff --git a/internal/modules/lolschedule/cron.go b/internal/modules/lolschedule/cron.go index 80252d5..5ac74e2 100644 --- a/internal/modules/lolschedule/cron.go +++ b/internal/modules/lolschedule/cron.go @@ -81,18 +81,15 @@ func classifyTerminal(err error) terminalKind { // Must match the regex in internal/server/router.go (^[a-z0-9_]{1,32}$). const dailyPushCronName = "lolschedule_daily_push" -// dailyPushSchedule drives the in-process scheduler (internal/cron) on -// self-host; it was also the documented EventBridge time on AWS. Cron +// dailyPushSchedule drives the in-process scheduler (internal/cron). Cron // expression is UTC; 01:00 UTC == 08:00 ICT. const dailyPushSchedule = "0 1 * * *" // lastPushDateKey records the UTC date (YYYY-MM-DD) of the most recent // completed daily push. The handler claims this key before fanning out and // no-ops if it is already today's date, making the push idempotent per UTC -// date. This defends against every double-fire window — cutover overlap -// (EventBridge still live while the container's scheduler runs), rolling -// deploys that briefly run two containers, and operator misconfiguration — -// none of which a single trigger source can prevent. +// date. This defends against double-fire windows from rolling deploys that +// briefly run two containers or operator misconfiguration. const lastPushDateKey = "daily_push:last_date" // telegramRateLimitThreshold is the subscriber count above which we throttle diff --git a/internal/modules/lolschedule/cron_test.go b/internal/modules/lolschedule/cron_test.go index 0cd6c23..a97fa85 100644 --- a/internal/modules/lolschedule/cron_test.go +++ b/internal/modules/lolschedule/cron_test.go @@ -181,7 +181,7 @@ func TestRunDailyPush_ForwardsMessageThreadID(t *testing.T) { // TestRunDailyPush_IdempotentPerDate locks in the double-fire guard: invoking // the handler twice on the same UTC date sends each subscriber exactly one -// digest. Defends against cutover overlap, rolling-deploy overlap, and operator +// digest. Defends against rolling-deploy overlap and operator // misconfiguration (all double-fire windows the daily push must survive). func TestRunDailyPush_IdempotentPerDate(t *testing.T) { s := newTestState(t) diff --git a/internal/modules/lolschedule/handlers.go b/internal/modules/lolschedule/handlers.go index 1e840e6..6872d5c 100644 --- a/internal/modules/lolschedule/handlers.go +++ b/internal/modules/lolschedule/handlers.go @@ -93,7 +93,7 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa } // handleSubscribe is /lolschedule_subscribe — opt the chat into the daily -// digest delivered by the EventBridge Scheduler cron handler. +// digest delivered by the in-process cron handler. func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { msg := update.Message if msg == nil { diff --git a/internal/modules/stats/stats.go b/internal/modules/stats/stats.go index e0c31ad..9a70dd0 100644 --- a/internal/modules/stats/stats.go +++ b/internal/modules/stats/stats.go @@ -57,7 +57,7 @@ func pairKey(cmd string, id int64) string { // username, also increments user: (refreshing the cached username) and // pair::. Errors are logged and swallowed; concurrent invocations of // the same (cmd, user) may lose updates — stats are best-effort. A future -// atomic increment (e.g. DynamoDB UpdateItem ADD) would close the race. +// backend atomic increment would close the race. func (c *counter) Inc(ctx context.Context, name string, update *models.Update) { var ( userID int64 diff --git a/internal/modules/wordle/state_test.go b/internal/modules/wordle/state_test.go index 86f02a5..1007f77 100644 --- a/internal/modules/wordle/state_test.go +++ b/internal/modules/wordle/state_test.go @@ -16,13 +16,6 @@ func newWordleStats() StatsStore { return storage.Typed[Stats](storage.NewMemoryProvider().Collection("wordle")) } -// newWordleStores returns a games + stats store backed by the same collection -// (disjoint key prefixes: "game:" vs "stats:"). -func newWordleStores() (GameStore, StatsStore) { - c := storage.NewMemoryProvider().Collection("wordle") - return storage.Typed[GameState](c), storage.Typed[Stats](c) -} - func TestStats_DefaultLastResultAtIsNull(t *testing.T) { // Go's *int64 must marshal as null when nil, so unplayed accounts emit // `"lastResultAt": null` and the field stays distinguishable from diff --git a/internal/server/health.go b/internal/server/health.go index 64eaff6..6b588e5 100644 --- a/internal/server/health.go +++ b/internal/server/health.go @@ -2,9 +2,9 @@ package server import "net/http" -// HealthHandler answers GET / with a stable string so Lambda's HTTP probe -// and any uptime monitor can distinguish "process up" from "process listening -// but routing broken". +// HealthHandler answers GET / with a stable string so the container health +// monitor can distinguish "process up" from "process listening but routing +// broken". func HealthHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/storage/doc_store.go b/internal/storage/doc_store.go index 3fd7884..7945afc 100644 --- a/internal/storage/doc_store.go +++ b/internal/storage/doc_store.go @@ -78,7 +78,7 @@ func Typed[T any](c Collection) DocStore[T] { case *memoryCollection: return &memoryDocStore[T]{c: h} case invalidCollection: - return invalidDocStore[T]{name: h.name} + return invalidDocStore[T](h) default: panic(fmt.Sprintf("storage: unknown collection type %T", c)) } diff --git a/internal/storage/dynamodb_client.go b/internal/storage/dynamodb_client.go deleted file mode 100644 index 46b46b9..0000000 --- a/internal/storage/dynamodb_client.go +++ /dev/null @@ -1,50 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "net/http" - "os" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" -) - -// dynamoDBHTTPTimeout caps individual SDK HTTP calls. DynamoDB on-demand is -// fast (typically <50ms); a 10s budget absorbs cold-start TLS handshake on -// Lambda + retries without hiding pathological hangs. -const dynamoDBHTTPTimeout = 10 * time.Second - -// NewDynamoDBClient constructs a DynamoDB client using AWS standard credential -// resolution: Lambda execution role → env vars → shared config. Region is -// resolved by the SDK from AWS_REGION (set by Lambda) or AWS_DEFAULT_REGION. -// -// The caller may pass a non-empty endpoint override (e.g. "http://localhost:8000") -// to point at DynamoDB Local for tests. An empty endpoint uses the AWS default. -func NewDynamoDBClient(ctx context.Context, endpoint string) (*dynamodb.Client, error) { - httpClient := &http.Client{Timeout: dynamoDBHTTPTimeout} - - loadOpts := []func(*config.LoadOptions) error{ - config.WithHTTPClient(httpClient), - } - cfg, err := config.LoadDefaultConfig(ctx, loadOpts...) - if err != nil { - return nil, fmt.Errorf("storage: load AWS config: %w", err) - } - - clientOpts := []func(*dynamodb.Options){} - if endpoint != "" { - clientOpts = append(clientOpts, func(o *dynamodb.Options) { - o.BaseEndpoint = aws.String(endpoint) - }) - } - return dynamodb.NewFromConfig(cfg, clientOpts...), nil -} - -// DynamoDBEndpointFromEnv returns the override endpoint for tests / local dev. -// Empty string means "use AWS default endpoint." -func DynamoDBEndpointFromEnv() string { - return os.Getenv("DYNAMODB_LOCAL_URL") -} diff --git a/internal/storage/mongo_doc_store_test.go b/internal/storage/mongo_doc_store_test.go index 128b90c..58947aa 100644 --- a/internal/storage/mongo_doc_store_test.go +++ b/internal/storage/mongo_doc_store_test.go @@ -15,7 +15,7 @@ import ( // mongoLocalSetup connects to a local MongoDB and returns a fresh, uniquely // named database plus cleanup. Tests skip if MONGODB_TEST_URL is unset so CI -// without a Mongo container still builds (mirrors the DynamoDB Local gating). +// without a Mongo container still builds. func mongoLocalSetup(t *testing.T) (*mongo.Database, func()) { t.Helper() uri := os.Getenv("MONGODB_TEST_URL") diff --git a/internal/telegram/webhook.go b/internal/telegram/webhook.go index 17c7fbf..c7db3a9 100644 --- a/internal/telegram/webhook.go +++ b/internal/telegram/webhook.go @@ -23,7 +23,7 @@ const webhookDeleteTimeout = 10 * time.Second // response body — decoded as "unexpected end of JSON input" — so the webhook is // never actually removed and getUpdates keeps returning 409. A GET with no body // sidesteps that request shape. Pending updates are intentionally kept (the API -// default) so the long poller drains the buffered queue for a lossless cutover. +// default) so the long poller drains any buffered queue. func DeleteWebhook(ctx context.Context, token string) error { return deleteWebhookAt(ctx, telegramAPIBase, token) } diff --git a/samconfig.toml b/samconfig.toml deleted file mode 100644 index f4448d6..0000000 --- a/samconfig.toml +++ /dev/null @@ -1,24 +0,0 @@ -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 = 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\" ModulesCSV=\"util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin\" 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 - -[default.build.parameters] -# We build Go ourselves via `make build-lambda`. SAM build only stages. -use_container = false diff --git a/aws/telegram-commands.json b/telegram-commands.json similarity index 100% rename from aws/telegram-commands.json rename to telegram-commands.json diff --git a/template.yaml b/template.yaml deleted file mode 100644 index 8332b0e..0000000 --- a/template.yaml +++ /dev/null @@ -1,330 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 - -Description: > - miti99bot: 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,lolschedule,twentyq,stock,coin,stats - 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. - - StockIncomeEventsAPIURL: - Type: String - Default: "https://restv2.fireant.vn" - Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted. - - StockIncomeEventsAPITokenParameterName: - Type: String - Default: "" - Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API. - - GoldPriceAPIURL: - Type: String - Default: "" - Description: Optional gold spot price API URL override. Empty uses the built-in GoldPrice.org endpoint. - - GoldFXAPIURL: - Type: String - Default: "" - Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint. - - GoldVNAppAPIURL: - Type: String - Default: "" - Description: Optional VNAppMob API base URL override. Empty uses https://api.vnappmob.com. - - GoldVNAppAPIKeyParameterName: - Type: String - Default: "" - Description: Optional SSM SecureString parameter name containing a pre-issued VNAppMob JWT API key. - - CoinBinanceAPIURL: - Type: String - Default: "" - Description: Optional Binance ticker price API URL override. Empty uses the public market-data endpoint https://data-api.binance.vision/api/v3/ticker/price. - - CoinCoinbaseAPIURL: - Type: String - Default: "" - Description: Optional Coinbase exchange-rates API URL override. Empty uses the built-in public endpoint. - - CoinCoinGeckoAPIURL: - Type: String - Default: "" - Description: Optional CoinGecko simple price API URL override. Empty uses the built-in public 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. - - # SSM holds the canonical value; CI fetches and passes via --parameter-overrides - # because EventBridge Connection's ApiKeyValue is consumed at stack-update time - # and stored in a service-linked secret (no per-invoke SSM fetch). NoEcho keeps - # the value out of CFN events / console / drift detection. - CronSharedSecret: - Type: String - NoEcho: true - Default: "" - Description: X-Cron-Token header value the EventBridge Rule presents to /cron/{name}. Must match the SSM-stored value the Lambda loads at cold start. - -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}" - RetentionInDays: 7 - - # Lambda emits a synthetic REPORT line at the end of every invocation. - # On a cold start that line includes "Init Duration: ". This filter - # parses that field into a custom metric so the AWS-port plan's "P95 < 1.5s" - # cold-start abort criterion is observable from day one. - ColdStartMetricFilter: - Type: AWS::Logs::MetricFilter - Properties: - LogGroupName: !Ref BotFunctionLogGroup - FilterPattern: '[report="REPORT", reqid_label="RequestId:", reqid, dur_label="Duration:", dur, dur_unit="ms", bill_label="Billed", bill_dur_label, bill_dur, bill_unit, mem_label, mem_size_label, mem_size, mem_unit, max_label="Max", max_used_label="Memory", max_used_label2="Used:", max_used, max_used_unit, init_label="Init", init_dur_label="Duration:", init_dur, init_unit="ms"]' - MetricTransformations: - - MetricName: ColdStartInitDuration - MetricNamespace: miti99bot - MetricValue: $init_dur - Unit: Milliseconds - - BotFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Ref AWS::StackName - 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 - STOCK_INCOME_EVENTS_API_URL: !Ref StockIncomeEventsAPIURL - STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName - GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL - GOLD_FX_API_URL: !Ref GoldFXAPIURL - GOLD_VNAPP_API_URL: !Ref GoldVNAppAPIURL - GOLD_VNAPP_API_KEY_PARAMETER_NAME: !Ref GoldVNAppAPIKeyParameterName - COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL - COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL - COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL - # ---- 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 - Cors: - AllowOrigins: ["*"] # Telegram doesn't send CORS; safe default - AllowMethods: ["POST", "GET"] - AllowHeaders: ["*"] - 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 ----------------------------------------------------------------- - # - # Why EventBridge Scheduler + direct Lambda invoke with a synthetic - # Function-URL-v2 event in Input: - # - # 1. CloudFormation's AWS::Scheduler::Schedule has no schema slot for HTTPS - # universal-target invocation, so we can't have Scheduler POST to the - # Function URL the usual way. - # 2. The legacy AWS::Events::Rule + ApiDestination path works but bills - # $0.20 per million ApiDestination invocations (no free tier). - # 3. Scheduler → Lambda direct-invoke is fully free-tier (14M/mo). Lambda - # Web Adapter normally expects an HTTP-shaped event; we synthesise one - # in Target.Input so LWA proxies it to the local Go server as if it - # came from the Function URL. The /cron/{name} handler is unchanged. - # - # Trade-off accepted: the X-Cron-Token value is embedded plain in the - # schedule's Input and visible to anyone with scheduler:GetSchedule on - # this schedule. Mitigation: CronSharedSecret remains a NoEcho CFN - # parameter (out of template source + stack events), and rotation = - # SSM update + redeploy (same workflow as before). - - CronDLQ: - Type: AWS::SQS::Queue - Properties: - QueueName: !Sub "${AWS::StackName}-cron-dlq" - MessageRetentionPeriod: 1209600 # 14 days - - # Role Scheduler assumes to invoke the Lambda and write to the DLQ. - # lambda:InvokeFunction is the action for direct invoke (vs InvokeFunctionUrl - # which is HTTPS-only and Scheduler can't use via CFN anyway). - 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-invoke-lambda - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt BotFunction.Arn - - Effect: Allow - Action: sqs:SendMessage - Resource: !GetAtt CronDLQ.Arn - - # cron(0 1 * * ? *) is 01:00 UTC = 08:00 ICT — matches dailyPushSchedule - # in internal/modules/lolschedule/cron.go. - # - # Input is a minimal Lambda Function URL v2 event payload. LWA detects this - # shape via requestContext.http and proxies it to the local :8080 server - # exactly as if the Function URL had been hit over HTTPS. The Go router - # reads X-Cron-Token from headers (case-insensitive) and routes by rawPath. - # - # $default and $context literals are not !Sub interpolation targets (no - # matching logical ID), so they pass through verbatim. Only ${CronSharedSecret} - # is interpolated. - LolscheduleDailyPushSchedule: - Type: AWS::Scheduler::Schedule - Properties: - Name: !Sub "${AWS::StackName}-lolschedule-daily-push" - Description: Fires lolschedule daily-push handler at 01:00 UTC (08:00 ICT) - ScheduleExpression: "cron(0 1 * * ? *)" - ScheduleExpressionTimezone: UTC - FlexibleTimeWindow: { Mode: "OFF" } # quoted: bare OFF → YAML 1.1 boolean false → EarlyValidation rejects - State: ENABLED - Target: - Arn: !GetAtt BotFunction.Arn - RoleArn: !GetAtt SchedulerExecutionRole.Arn - RetryPolicy: - MaximumRetryAttempts: 2 - MaximumEventAgeInSeconds: 600 - DeadLetterConfig: - Arn: !GetAtt CronDLQ.Arn - Input: !Sub | - {"version":"2.0","routeKey":"$default","rawPath":"/cron/lolschedule_daily_push","rawQueryString":"","headers":{"x-cron-token":"${CronSharedSecret}","content-type":"application/json","user-agent":"aws-scheduler"},"requestContext":{"http":{"method":"POST","path":"/cron/lolschedule_daily_push","protocol":"HTTP/1.1","sourceIp":"127.0.0.1","userAgent":"aws-scheduler"},"requestId":"scheduler-invoke","stage":"$default","time":"00:00:00","timeEpoch":0,"routeKey":"$default"},"body":"","isBase64Encoded":false} - - # --- 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