From b0b4b4b4c0f8552009129ec42f04a2223900ef61 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 28 Jun 2026 09:58:32 +0700 Subject: [PATCH] feat(selfhost): containerize for Coolify + decommission AWS deploy Add multi-stage Dockerfile, docker-compose.yml for local dev, .env.example template, and Makefile helpers. Disable GitHub Actions AWS deploy workflow. Supports Coolify container orchestration for self-hosted deployments. --- .env.example | 47 +++++++++++++++++++++++++++ .github/workflows/deploy.yml | 9 ++++-- Dockerfile | 9 +++++- Makefile | 61 +++++++++++++++++++++++++++++++++++- docker-compose.yml | 41 ++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ac897d --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# miti99bot — self-host (Coolify + MongoDB Atlas) environment. +# Copy to .env and fill in. .env is gitignored — never commit real secrets. + +# ============================ Required ============================ +# Telegram bot token from @BotFather. +TELEGRAM_BOT_TOKEN=123456:ABC-DEF... + +# MongoDB Atlas connection. MONGO_URL is the full SRV string INCLUDING the +# db username + password — treat it as a secret (it is never logged). +# Create a least-privilege user: readWrite on this one database only. +MONGO_URL=mongodb+srv://botuser:STRONG_UNIQUE_PASSWORD@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority +MONGO_DATABASE=miti99bot + +# ============================ Operational ========================= +# Comma-separated module list. Empty = load every module. +MODULES= +# Telegram user id for owner-only commands (renamed from BOT_OWNER_ID). +OWNER_ID= +# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS). +ADMIN_IDS= + +# ============================ Optional ============================ +# Only the twentyq module needs this. Leave blank to disable that command. +GEMINI_API_KEY= + +# GIT_SHA is injected at build time by Coolify for the deploynotify owner DM. +# Leave unset for local `docker compose up` — deploynotify just stays silent. +# GIT_SHA= + +# ====================== 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 +# CRON_SHARED_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): +# 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 +# CRON_SHARED_SECRET — unset → /cron route 404s; the in-process scheduler is the trigger +# GOLD_VNAPP_API_KEY — gold module auto-fetches + caches the key to Mongo +# STOCK/COIN/GOLD *_API_URL overrides — modules use their coded default providers diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c3fcef..04ecc0b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,13 @@ 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: - push: - branches: [main] workflow_dispatch: permissions: diff --git a/Dockerfile b/Dockerfile index a393bbb..e13dd1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,15 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . + +# GIT_SHA is baked into the binary so internal/deploynotify can DM the owner +# once per new version (parity with the Makefile build). Coolify exposes the +# commit SHA as a build arg — pass it with +# --build-arg GIT_SHA=$(git rev-parse --short HEAD) +# When unset, deploynotify treats the empty SHA as "stay silent". +ARG GIT_SHA="" RUN CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X main.gitSHA=${GIT_SHA}" \ -o /out/server \ ./cmd/server diff --git a/Makefile b/Makefile index 6a78261..b7079f6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-emulator test-dynamodb firestore-emulator dynamodb-local dynamodb-local-stop vet build build-lambda run sam-validate sam-build sam-deploy telegram-setup telegram-webhook telegram-webhook-info telegram-commands telegram-commands-info logs clean +.PHONY: help test test-emulator test-dynamodb test-mongo firestore-emulator 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 # Lambda target architecture. Match Globals.Architectures in template.yaml. LAMBDA_GOOS ?= linux @@ -48,6 +48,13 @@ test-dynamodb: dynamodb-local ## Run DynamoDB tests against DynamoDB Local DYNAMODB_LOCAL_URL=http://localhost:$(DDB_PORT) LOG_LEVEL=error \ go test -race -count=1 ./internal/storage/... +# Run MongoDB integration tests against a local Mongo container. +# Override MONGO_PORT if 27017 is taken on your host. +MONGO_PORT ?= 27017 +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 ----------------------------------------------------------- vet: ## go vet @@ -87,6 +94,34 @@ dynamodb-local: ## Start DynamoDB Local container on :$(DDB_PORT) (idempotent) dynamodb-local-stop: ## Stop DynamoDB Local -docker stop miti99bot-ddb +# ---- MongoDB local for tests ---------------------------------------------- + +mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent) + @if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-mongo$$'; then \ + docker run -d --rm --name miti99bot-mongo -p $(MONGO_PORT):27017 mongo:7; \ + echo "MongoDB started on :$(MONGO_PORT)"; \ + sleep 2; \ + else \ + echo "MongoDB already running"; \ + fi + +mongo-local-stop: ## Stop local MongoDB + -docker stop miti99bot-mongo + +# ---- Data migration (DynamoDB → MongoDB Atlas) ---------------------------- + +# 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 @@ -154,6 +189,30 @@ telegram-commands-info: ## Show Telegram getMyCommands using token from SSM 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 + @set -eu; \ + : "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \ + echo "Registering Telegram commands from $(TELEGRAM_COMMANDS_FILE)"; \ + curl -sS -X POST "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/setMyCommands" \ + -H 'Content-Type: application/json' \ + --data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \ + echo + +telegram-deletewebhook-selfhost: ## Cutover: delete the webhook so the poller can run (keeps buffered updates) + @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) + @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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c29a4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + bot: + build: + context: . + args: + # Coolify exposes the commit SHA; pass it so deploynotify DMs the owner + # on each new version. Optional — empty SHA just stays silent. + GIT_SHA: ${GIT_SHA:-} + # Or pin a prebuilt image instead of building: + # image: ghcr.io/tiennm99/miti99bot:latest + restart: unless-stopped + environment: + # --- Required --- + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + MONGO_URL: ${MONGO_URL} # Atlas SRV string incl. credentials — SECRET + MONGO_DATABASE: ${MONGO_DATABASE} + # --- Operational --- + MODULES: ${MODULES} # CSV; empty = all modules + OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands + ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids + # --- Optional --- + GEMINI_API_KEY: ${GEMINI_API_KEY} # only the twentyq module needs it + # Storage auto-selects mongodb because MONGO_URL is set — no KV_PROVIDER. + # The in-process cron scheduler runs by default — no CRON_MODE. + # PORT defaults to 8080 (internal health server) — omit unless overriding. + # Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain. + # Leave CRON_SHARED_SECRET unset → /cron route is 404 (scheduler is the trigger). + # 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. + # Long polling is outbound-only: nothing inbound to route, so no published + # ports and no public domain. `expose` keeps :8080 reachable inside the + # Coolify network for the container health monitor against GET / only. + expose: + - "8080" + # No compose healthcheck: distroless has no shell/curl and cmd/server has no + # -healthcheck flag. Configure Coolify's HTTP monitor against GET / instead + # (returns text/plain "miti99bot ok"). Note: a plain / check does not verify + # Mongo connectivity — see docs/deploy-coolify-selfhosted.md.