diff --git a/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md b/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md index 7d1c2c0..0df3719 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md @@ -11,7 +11,7 @@ effort: "M" ## Overview -Add `mongodb` as a 4th `KVProvider`, modeled exactly on the existing `firestore` provider (collection-per-module isolation). Wire it into `buildProvider` and config via `KV_PROVIDER=mongodb`, `MONGO_URL`, `MONGO_DATABASE`. No module code changes — the `KVStore` interface is the only contract. +Add `mongodb` as a 4th `KVProvider`, modeled exactly on the existing `firestore` provider (collection-per-module isolation). Make it the **default backend when `MONGO_URL` is set** — no `KV_PROVIDER` needed. Config: `MONGO_URL` + `MONGO_DATABASE`. `KV_PROVIDER` stays as an optional override (e.g. `memory` for local dev). No module code changes — the `KVStore` interface is the only contract. ## Requirements @@ -47,7 +47,7 @@ Store `value` as BSON binary (`bson.Binary`) so non-UTF-8 round-trips; on read a - Create: `internal/storage/mongodb_provider.go` — `MongoProvider{ db *mongo.Database }`, `For(module)` returns `invalidStore` on bad name else `NewMongoKVStore`. Mirror `firestore_provider.go`. - Create: `internal/storage/mongodb_kv.go` — `MongoKVStore`, all methods. Mirror `firestore_kv.go`. - Create: `internal/storage/mongodb_kv_test.go` + `mongodb_provider_test.go` — parity tests, gated on `MONGODB_TEST_URL` (skip when unset), mirroring `dynamodb_kv_test.go` gating on `DYNAMODB_LOCAL_URL`. -- Modify: `cmd/server/main.go` — add `mongodb` case to `buildProvider`; add `MongoURL`, `MongoDatabase` to `config` + `loadConfig` (`MONGO_URL`, `MONGO_DATABASE`); update `buildProvider` doc comment + auto-detect note (mongo is explicit-only). +- Modify: `cmd/server/main.go` — add `mongodb` case to `buildProvider`; add `MongoURL`, `MongoDatabase` to `config` + `loadConfig` (`MONGO_URL`, `MONGO_DATABASE`). Change the auto-detect default: when `KV_PROVIDER` is empty, pick `mongodb` if `MONGO_URL` is set, else `memory` (the old `AWS_LAMBDA_FUNCTION_NAME → dynamodb` auto-detect is removed — AWS is gone; `dynamodb`/`firestore` remain reachable only via explicit `KV_PROVIDER`, kept for the migrator/tests). - Modify: `go.mod` / `go.sum` — add `go.mongodb.org/mongo-driver/v2`. - Modify: `Makefile` — add `mongo-local` (docker `mongo:7`) + `test-mongo` target gated by `MONGODB_TEST_URL`, mirroring `dynamodb-local`/`test-dynamodb`. - Modify: `README.md` — add `mongodb` to the storage backend list + local-run snippet. @@ -59,14 +59,14 @@ Store `value` as BSON binary (`bson.Binary`) so non-UTF-8 round-trips; on read a 3. Write `mongodb_kv.go`: implement methods per Architecture; reuse `validateKey`, `validatePrefix`, `prefixSuccessor`; constants `mongoValueField="value"`, `mongoUpdatedAtField="updatedAt"`. 4. Write `mongodb_provider.go`: `For` guards with `collectionNameRe`, returns `db.Collection(module)`-backed store. 5. Wire `buildProvider`: `case "mongodb"`: require `MONGO_URL` + `MONGO_DATABASE` (error if missing, mirror dynamodb's `DYNAMODB_TABLE` check); construct client under timeout; closer calls `client.Disconnect`. **The startup `log.Info("storage backend", …)` line MUST log only non-secret fields — `"backend","mongodb","database",cfg.MongoDatabase`. NEVER log `MONGO_URL`** (it is `mongodb+srv://user:pass@…`; the firestore/dynamodb cases at `main.go:234-253` log a benign identifier, but the mongo equivalent is a credential). If a host is wanted for diagnostics, parse and log only the host, never the userinfo. -6. Add config fields + env reads. Mongo is explicit-only (not in the auto-detect switch) to avoid surprising Lambda. +6. Add config fields + env reads. Set the auto-detect default to `mongodb` when `MONGO_URL` is present (else `memory`); `KV_PROVIDER` overrides. 7. Tests: replicate the firestore/dynamodb test bodies against a real Mongo (`mongo-local`), covering Get/Put/Delete/List/prefix/CAS-absent/CAS-match/CAS-conflict/ErrNotFound + cross-module isolation. 8. `make vet && make test && MONGODB_TEST_URL=mongodb://localhost:27017 make test-mongo`. ## Success Criteria - [ ] `internal/storage` exposes `MongoProvider`/`MongoKVStore` passing the same test matrix as `DynamoDBKVStore`. -- [ ] `KV_PROVIDER=mongodb` with `MONGO_URL`/`MONGO_DATABASE` boots; missing either errors clearly at startup. +- [ ] Setting `MONGO_URL` + `MONGO_DATABASE` (no `KV_PROVIDER`) boots as mongodb; `MONGO_URL` set but `MONGO_DATABASE` missing errors clearly; no env → memory. - [ ] Cross-module isolation verified (collection-per-module). - [ ] CompareAndSwap returns `ErrConflict` on stale expected + on absent-with-non-nil-expected; succeeds on nil-expected insert. - [ ] **(blocking)** Concurrent-writer CAS test: N goroutines race a nil-expected insert + a stale-update on the same key against a real Mongo; assert exactly one winner, losers get `ErrConflict`. Plus a "doc exists without value field" edge case. diff --git a/plans/260627-1849-selfhost-coolify-mongodb/phase-02-in-process-cron-scheduler.md b/plans/260627-1849-selfhost-coolify-mongodb/phase-02-in-process-cron-scheduler.md index 0b4410b..ff42be4 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/phase-02-in-process-cron-scheduler.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/phase-02-in-process-cron-scheduler.md @@ -11,12 +11,12 @@ effort: "S" ## Overview -Off AWS there is no EventBridge Scheduler to hit `/cron/{name}`. Add an opt-in in-process scheduler that reads each registered cron's existing `Cron.Schedule` field and fires `Cron.Handler` on time. Gated by `CRON_MODE=internal` so the Lambda/EventBridge path is unchanged by default. +Off AWS there is no EventBridge Scheduler to hit `/cron/{name}`. The long-lived container runs an in-process scheduler **by default** (no env toggle) that reads each registered cron's existing `Cron.Schedule` field and fires `Cron.Handler` on time. AWS/EventBridge is gone, so there is no "external" mode to preserve — the scheduler simply runs. ## Requirements -- Functional: when `CRON_MODE=internal`, parse every `reg.Crons()[i].Schedule` and invoke its handler on schedule, in UTC (match EventBridge's `ScheduleExpressionTimezone: UTC`). -- Functional: default (`CRON_MODE` unset/`external`) starts NO scheduler — preserves current Lambda behavior where EventBridge owns timing. +- Functional: at startup, parse every `reg.Crons()[i].Schedule` and invoke its handler on schedule, in UTC (match the old EventBridge `ScheduleExpressionTimezone: UTC`). No `CRON_MODE` env. +- Functional: cutover safety comes from ordering (EventBridge schedule disabled before the container starts, Phase 4) + the idempotency guard below — not from a gate. Locally (`go run`, memory KV) the scheduler also runs; harmless (no subscribers). - Functional: the existing `/cron/{name}` HTTP route stays as-is (still usable for manual/curl triggers and as the sidecar fallback). - Non-functional: each tick runs under the same 60s budget as the HTTP path (`defaultCronTimeout`); a panicking handler is recovered and logged, scheduler keeps running. - Non-functional: scheduler stops cleanly on `rootCtx` cancellation (graceful shutdown). @@ -40,26 +40,24 @@ func Run(ctx context.Context, reg *modules.Registry) (stop func(), err error) - Skips crons with empty `Schedule` (logs a warning — a self-hosted cron with no schedule never fires). - Validates schedule strings at startup; a bad expression is fatal (fail fast, like other config errors). -Wire in `cmd/server/main.go` after `modules.Install`, gated: +Wire in `cmd/server/main.go` after `modules.Install` (unconditional): ``` -if strings.EqualFold(cfg.CronMode, "internal") { - stop, err := cron.Run(rootCtx, reg) - if err != nil { log.Fatal("cron scheduler init failed", "err", err) } - defer stop() - log.Info("internal cron scheduler started", "crons", len(reg.Crons())) -} +stop, err := cron.Run(rootCtx, reg) +if err != nil { log.Fatal("cron scheduler init failed", "err", err) } +defer stop() +log.Info("cron scheduler started", "crons", len(reg.Crons())) ``` -Add `CronMode` to `config` from env `CRON_MODE`. +No `CronMode` config / `CRON_MODE` env. ## Related Code Files - Create: `internal/cron/scheduler.go` — scheduler lifecycle. - Create: `internal/cron/scheduler_test.go` — fake registry with a fast schedule (`@every 1s` or injected clock) asserts handler fires; bad-schedule errors; ctx-cancel stops. -- Modify: `cmd/server/main.go` — `CronMode` config + gated `cron.Run`. -- Modify: `internal/modules/module.go` doc comments — update `CronHandler`/`Cron.Schedule` text that currently says "real schedule lives in EventBridge" to note the `CRON_MODE=internal` path. (The scheduler calls the existing `modules.DispatchScheduled`; if the 60s-timeout/panic-recover wrapper is worth sharing with the HTTP `cronHandler`, lift it to a shared helper — but that is optional, not required.) +- Modify: `cmd/server/main.go` — unconditional `cron.Run(rootCtx, reg)` after `modules.Install`. +- Modify: `internal/modules/module.go` doc comments — update `CronHandler`/`Cron.Schedule` text that currently says "real schedule lives in EventBridge" to note the in-process scheduler drives it. (The scheduler calls the existing `modules.DispatchScheduled`; if the 60s-timeout/panic-recover wrapper is worth sharing with the HTTP `cronHandler`, lift it to a shared helper — optional.) - Modify: `go.mod` / `go.sum` — add `github.com/robfig/cron/v3`. -- Modify: `README.md` — document `CRON_MODE` (external default vs internal self-host). +- Modify: `README.md` — note the container runs an in-process scheduler for module crons (no config). - Modify: `internal/modules/lolschedule/cron.go` — add an idempotency guard: the daily-push handler reads/writes a KV "last push UTC date" key and no-ops if already pushed today (defends against all double-fire windows). This also makes the existing EventBridge path safe during cutover overlap. ## Implementation Steps @@ -73,8 +71,8 @@ Add `CronMode` to `config` from env `CRON_MODE`. ## Success Criteria -- [ ] With `CRON_MODE=internal`, lolschedule daily push fires at 01:00 UTC; observable in logs (`cron triggered`). -- [ ] With `CRON_MODE` unset, no scheduler starts (Lambda/EventBridge path byte-for-byte unchanged). +- [ ] lolschedule daily push fires at 01:00 UTC; observable in logs (`cron triggered`). +- [ ] Scheduler starts unconditionally at container boot (`cron scheduler started` logged). - [ ] Bad schedule string fails startup with a clear error. - [ ] Handler panic is recovered (scheduler-local `recover()`); scheduler survives and fires next tick. - [ ] Each fire runs under a 60s timeout (scheduler-local, not imported from `internal/server`). @@ -84,10 +82,10 @@ Add `CronMode` to `config` from env `CRON_MODE`. ## Risk Assessment - **Double-fire (Critical — the daily push is NOT idempotent)**: `lolschedule` `runDailyPush` (`cron.go:128-191`) fans out to every subscriber unconditionally — no "already sent today" marker. So ANY double-fire = every subscriber DM'd twice. Three concrete double-fire windows the opt-in flag does NOT cover: - 1. **Cutover overlap**: EventBridge `AWS::Scheduler::Schedule` (`template.yaml:271-289`) invokes the Lambda DIRECTLY, independent of the webhook URL — re-pointing the webhook does NOT stop it. It must be **disabled/deleted before** the Coolify container runs `CRON_MODE=internal` (ordered prerequisite in Phase 4, not "N days later" cleanup). + 1. **Cutover overlap**: EventBridge `AWS::Scheduler::Schedule` (`template.yaml:271-289`) invokes the Lambda DIRECTLY, independent of the webhook URL — re-pointing the webhook does NOT stop it. It must be **disabled/deleted before** the Coolify container starts (its scheduler runs by default) — ordered prerequisite in Phase 4, not "N days later" cleanup. 2. **Rolling deploy**: Coolify/compose may run old+new containers briefly; both run the in-process scheduler. A redeploy near 01:00 UTC double-fires. 3. Operator misconfig (`internal` set on a second instance). - **Primary mitigation (covers all three cheaply)**: add a KV "last push date" guard in `lolschedule` — the handler records the date it pushed and no-ops if already pushed for that UTC date. This makes the push idempotent regardless of trigger count. Secondary: prefer stop-first redeploy in Coolify; keep `CRON_MODE` opt-in and never set `internal` on Lambda. + **Primary mitigation (covers all three cheaply)**: add a KV "last push date" guard in `lolschedule` — the handler records the date it pushed and no-ops if already pushed for that UTC date. This makes the push idempotent regardless of trigger count. Secondary: prefer stop-first redeploy in Coolify; disable the EventBridge schedule before first container start. - **5-field vs 6-field cron**: `"0 1 * * *"` is 5-field standard. Mitigation: use robfig `cron/v3` default 5-field parser (not the seconds-enabled one). -- **Single-instance assumption**: if Coolify scales the service to >1 replica, crons fire per replica. Mitigation: document "run exactly 1 replica" (the bot is a single-instance webhook consumer anyway); revisit with a DB lock only if scaling is ever needed (YAGNI now). +- **Single-instance assumption**: if Coolify scales the service to >1 replica, crons fire per replica. Mitigation: document "run exactly 1 replica" (the bot must be single-instance anyway — Telegram allows only one long-polling consumer per token, Phase 3); revisit with a DB lock only if scaling is ever needed (YAGNI now). - **Missed fire while container restarts**: a deploy at 01:00 UTC could skip that day's push. Mitigation: accept (same risk class as Lambda cold-start miss); not data-loss. diff --git a/plans/260627-1849-selfhost-coolify-mongodb/phase-03-containerize-and-coolify-deploy.md b/plans/260627-1849-selfhost-coolify-mongodb/phase-03-containerize-and-coolify-deploy.md index 7f3d432..4aa9a8b 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/phase-03-containerize-and-coolify-deploy.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/phase-03-containerize-and-coolify-deploy.md @@ -1,28 +1,41 @@ --- phase: 3 -title: "Containerize and Coolify Deploy" +title: "Long-Polling Runtime + Containerize + Coolify Deploy" status: pending priority: P2 dependencies: [1, 2] effort: "M" --- -# Phase 3: Containerize and Coolify Deploy +# Phase 3: Long-Polling Runtime + Containerize + Coolify Deploy ## Overview -Ship the existing distroless image via a docker-compose stack on Coolify. MongoDB Atlas is external/managed, so compose runs only the bot service. Configuration (secrets + backend selection) is supplied as Coolify env vars. Document webhook registration against the Coolify public domain. +Two coupled changes: (1) switch the Telegram transport from webhook to **long polling** so the self-hosted container needs NO public inbound ingress, and (2) ship the existing distroless image via a docker-compose stack on Coolify. MongoDB Atlas is external/managed, so compose runs only the bot service. Config is supplied as Coolify env vars. + +Long polling is the key self-host simplification: the bot opens an OUTBOUND connection to `api.telegram.org` and pulls updates (`getUpdates`), instead of Telegram POSTing to a public URL. That removes the public HTTPS domain, TLS/Traefik routing for inbound, the `/webhook` route, the webhook secret, and the entire unauthenticated-ingress attack surface. The same library (`go-telegram/bot v1.20.0`) does this via `b.Start(ctx)` — no library change, handlers unchanged. + +**Polling is the ONLY transport — no toggle.** Webhook mode existed solely for Lambda, which is being decommissioned (Phase 5), so the webhook code path is removed entirely (YAGNI). The currently-deployed Lambda keeps running its own already-built webhook code until `sam delete`, so the short rollback window in Phase 4 is unaffected by deleting webhook code from this branch. ## Requirements -- Functional: `docker compose up` runs the bot on `:8080` with `KV_PROVIDER=mongodb` + `CRON_MODE=internal`, persistent storage in Atlas. -- Functional: Coolify exposes the service over HTTPS at a domain; `GET /` returns `text/plain` `miti99bot ok` (health, not JSON). -- Functional: Telegram webhook points at `https:///webhook` with the secret token. -- Non-functional: container healthcheck on `/`; restart policy `unless-stopped`; runs a single replica (cron correctness, see Phase 2). +- Functional: `docker compose up` runs the bot with persistent storage in Atlas — mongodb is auto-selected from `MONGO_URL` and the cron scheduler runs by default (no `KV_PROVIDER`/`CRON_MODE`). +- Functional: bot consumes updates via long polling (`b.Start(ctx)`) as the sole transport; no `/webhook` route, no webhook secret, no public domain. Only OUTBOUND HTTPS to Telegram is needed. +- Functional: the webhook code path (`internal/telegram/webhook.go`, the `/webhook` route, `TELEGRAM_WEBHOOK_SECRET` config + its startup fatal) is **deleted**, not gated. +- Non-functional: container restart policy `unless-stopped`; **exactly one replica** (Telegram allows only ONE polling consumer per bot — a second poller gets HTTP 409; also cron correctness, Phase 2). +- Non-functional: health server stays available internally (`GET /` → `text/plain` `miti99bot ok`) for Coolify's container healthcheck, but is NOT publicly routed. - Non-functional: no secrets committed — all via Coolify env / `.env` (gitignored). Provide `.env.example`. ## Architecture +### Telegram transport: long polling (only mode) +`go-telegram/bot` runs long polling when you call `b.Start(ctx)` (its `getUpdates` loop). In `cmd/server/main.go`, after `modules.Install`, run `b.Start(rootCtx)` in a goroutine; it returns when `rootCtx` is cancelled (graceful shutdown). Remove the webhook wiring: +- Delete `internal/telegram/webhook.go` + `webhook_test.go`, the `/webhook` route in `internal/server/router.go`, and the `WebhookSecret` config field + its `if cfg.WebhookSecret == ""` fatal (`main.go:85-88`). +- The HTTP server still runs for the `/` health route only (Coolify healthcheck) — bind it, do not publicly route it. (`/cron` stays mountable but is unused in polling+internal-cron mode and isn't exposed.) +- A one-time `deleteWebhook` is required before first poll (else `getUpdates` 409s if a webhook is still set — Phase 4 cutover). Only ONE polling process per bot token (second → 409) → single replica. +- `WithSkipGetMe()` (currently set for fast Lambda cold start) is harmless to keep. `WithNotAsyncHandlers` rationale was webhook-specific — re-evaluate, but handlers take `ctx` (not `r.Context()`) so leaving them as-is is safe. + +### Container + Coolify The existing `Dockerfile` (golang:1.25-alpine builder → distroless static nonroot, `ENTRYPOINT ["/server"]`, `EXPOSE 8080`) builds with `-ldflags="-s -w"` only — it does **not** inject `gitSHA`. Only the Makefile injects it (`Makefile:13`). `deploynotify` runs unconditionally at startup (`main.go:148`) and stays silent when `gitSHA` is empty (`main.go:40-41`), so as-is the "new version" owner DM is **silently dead on self-host** — a behavior regression from Lambda. Required (not optional): add `ARG GIT_SHA` + `-ldflags "-s -w -X main.gitSHA=$GIT_SHA"` to the Dockerfile and pass `--build-arg GIT_SHA=$(git rev-parse --short HEAD)` (Coolify exposes the commit SHA as a build var). If deploynotify parity is explicitly unwanted, instead document in acceptance criteria that the feature is disabled on self-host — do not leave it as undocumented breakage. `docker-compose.yml` (committed; Coolify consumes it): @@ -32,69 +45,79 @@ services: build: . # or image: ghcr.io/tiennm99/miti99bot:latest restart: unless-stopped environment: - PORT: "8080" - KV_PROVIDER: mongodb MONGO_URL: ${MONGO_URL} MONGO_DATABASE: ${MONGO_DATABASE} - CRON_MODE: internal MODULES: ${MODULES} - BOT_OWNER_ID: ${BOT_OWNER_ID} - ADMIN_USER_IDS: ${ADMIN_USER_IDS} + OWNER_ID: ${OWNER_ID} + ADMIN_IDS: ${ADMIN_IDS} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} - TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET} GEMINI_API_KEY: ${GEMINI_API_KEY} - # NOTE: CRON_SHARED_SECRET intentionally OMITTED — with CRON_MODE=internal - # the in-process scheduler owns cron timing, so leaving it unset makes the - # public /cron/{name} route 404 (router.go:59,66-69) and removes a redundant - # internet-reachable trigger surface. Set it only if you want manual curl triggers. - # NOTE: do NOT set any *_PARAMETER_NAME vars (see Secrets below). - # optional API overrides as needed + # Storage auto-selects mongodb because MONGO_URL is set (no KV_PROVIDER needed). + # The in-process cron scheduler runs by default (no CRON_MODE needed). + # PORT defaults to 8080 (health server) — omit unless overriding. + # Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no CRON_SHARED_SECRET. + # Do NOT set any *_PARAMETER_NAME vars (see Secrets below). + # No stock/coin/gold *_API_URL overrides — modules use their coded default + # providers (stock: SSI/FireAnt; coin: Binance→Coinbase→CoinGecko; gold: VNAppMob→spot). + # No published ports / domain: polling is outbound-only, nothing inbound to route. + # `expose` (internal-only) is enough for Coolify's container healthcheck on /. expose: ["8080"] # No compose healthcheck: distroless has no shell/curl AND cmd/server has no - # flag parsing (no -healthcheck flag exists). Use Coolify's native HTTP monitor + # flag parsing (no -healthcheck flag exists). Use Coolify's container/HTTP monitor # against GET / (returns text/plain "miti99bot ok", NOT JSON). ``` Healthcheck: distroless has no shell/curl/wget, and `cmd/server` has no flag parsing — `/server -healthcheck` does NOT exist and would just start the server. Use **Coolify's native HTTP monitor against `/`** (returns `text/plain` body `miti99bot ok`, per `internal/server/health.go` — not JSON). Only if a compose-level healthcheck is mandatory, first implement a real `-healthcheck` flag in `cmd/server` (localhost GET `/`, exit 0/1) — otherwise omit it. Note: a plain `/` check does NOT verify Mongo connectivity (see Risk Assessment). -Secrets: because `*_PARAMETER_NAME` env vars are NOT set, `cmd/server` reads `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `CRON_SHARED_SECRET`, `GEMINI_API_KEY` directly (verified: `resolveSSMSecrets` returns early when no param names are set, `main.go:355-366`). **Hard requirement: `.env.example` must list all six `*_PARAMETER_NAME` vars explicitly as "leave UNSET for self-host."** `resolveSSMSecrets` is called unconditionally (`main.go:79`); if even one `*_PARAMETER_NAME` is set with an unset target, it calls `awsconfig.LoadDefaultConfig` + `ssm.GetParameters` (`main.go:371-378`) which fails with no AWS creds → `log.Fatal` (`main.go:80`), bricking startup. With all six unset, no AWS credentials are needed in the container. +Secrets: because `*_PARAMETER_NAME` env vars are NOT set, `cmd/server` reads `TELEGRAM_BOT_TOKEN`, `GEMINI_API_KEY` directly (verified: `resolveSSMSecrets` returns early when no param names are set, `main.go:355-366`). `TELEGRAM_WEBHOOK_SECRET` is removed with the webhook code (no longer read), so the `if cfg.WebhookSecret == ""` fatal (`main.go:85-88`) is deleted — the bot boots without it. `CRON_SHARED_SECRET` is unused (internal cron) and left unset. **Hard requirement: `.env.example` must list all six `*_PARAMETER_NAME` vars explicitly as "leave UNSET for self-host."** `resolveSSMSecrets` is called unconditionally (`main.go:79`); if even one `*_PARAMETER_NAME` is set with an unset target, it calls `awsconfig.LoadDefaultConfig` + `ssm.GetParameters` (`main.go:371-378`) which fails with no AWS creds → `log.Fatal` (`main.go:80`), bricking startup. With all six unset, no AWS credentials are needed in the container. ## Related Code Files +- Modify: `cmd/server/main.go` — `go b.Start(rootCtx)` after `modules.Install`; delete the `WebhookSecret` config field, its load, and the `if cfg.WebhookSecret == ""` fatal (`main.go:85-88`). Rename the env keys read in `loadConfig`: `BOT_OWNER_ID` → `OWNER_ID`, `ADMIN_USER_IDS` → `ADMIN_IDS` (no backward-compat — AWS template that set the old names is being deleted). +- Delete: `internal/telegram/webhook.go` + `internal/telegram/webhook_test.go`. +- Modify: `internal/server/router.go` — remove the `/webhook` route; keep `/` health (and `/cron`, unused/unexposed). +- Modify: `internal/telegram/client.go` — update the "configured for webhook mode" doc to polling; keep `WithSkipGetMe`, re-evaluate `WithNotAsyncHandlers`. - Create: `docker-compose.yml` — bot service as above. - Create: `.env.example` — every env var with placeholder values + comments; real `.env` gitignored. - Modify: `.gitignore` — ensure `.env` ignored (verify; add if missing). - Modify (optional): `Dockerfile` — `ARG GIT_SHA` + `-ldflags "-X main.gitSHA=$GIT_SHA"` for deploynotify parity. - Modify (optional): `cmd/server/main.go` — `-healthcheck` flag (only if compose healthcheck chosen over Coolify HTTP monitor). -- Create: `docs/deploy-coolify-selfhosted.md` — full onboarding: create Atlas M0 cluster, get SRV URL, create a least-privilege DB user (`readWrite` on one DB, strong unique password), set network access to `0.0.0.0/0` (accepted trade-off — record it), Coolify new resource from compose/Git, set env vars, deploy, grab public domain, register webhook + command menu. -- Modify: `README.md` — add "Self-host (Coolify + MongoDB Atlas)" deploy option alongside the AWS path. +- Create: `docs/deploy-coolify-selfhosted.md` — full onboarding: create Atlas M0 cluster, get SRV URL, create a least-privilege DB user (`readWrite` on one DB, strong unique password), set network access to `0.0.0.0/0` (accepted trade-off — record it), Coolify new resource from compose/Git, set env vars, deploy (no public domain needed — polling is outbound-only), one-time `deleteWebhook`, register the command menu via `setMyCommands`. +- Modify: `README.md` — add "Self-host (Coolify + MongoDB Atlas)" deploy option alongside the AWS path; drop the stock income-events row from the module table. +- Delete: `internal/modules/stock/income_events.go` + `income_events_test.go`; remove the `stock_income_events` command registration (`stock/stock.go:45-48`), the `incomeEvents *IncomeEventClient` field + `NewIncomeEventClientFromEnv` wiring (`stock/handlers.go:25,43`), `handleIncomeEvents`, and the `STOCK_INCOME_EVENTS_API_URL`/`_TOKEN` config + `exportOptionalEnv` lines in `main.go`. Keep `stock_income_stock` / `stock_income_vnd`. +- Modify: `aws/telegram-commands.json` (the `setMyCommands` source) — remove the `stock_income_events` entry so the command menu matches. +- No change for gold: `gold/vnappmob_client.go` already auto-fetches + caches the VNAppMob key in KV (`vnappmob:api_key`); just leave `GOLD_VNAPP_API_KEY` unset. ## Implementation Steps 1. Write `docker-compose.yml` + `.env.example`; verify `.env` gitignored. 2. Decide healthcheck approach (Coolify HTTP monitor preferred); implement `-healthcheck` flag only if needed. -3. Local validation: `MONGO_URL=… MONGO_DATABASE=… docker compose up --build`; confirm boot logs show `storage backend backend=mongodb database=…` (NO connection string) and `internal cron scheduler started`; `curl localhost:8080/` returns `miti99bot ok` (plain text). +1b. Switch `cmd/server/main.go` to `b.Start(rootCtx)`; delete the webhook handler, `/webhook` route, and `WebhookSecret` config + fatal. +3. Local validation: `MONGO_URL=… MONGO_DATABASE=… docker compose up --build`; confirm boot logs show `storage backend backend=mongodb database=…` (NO connection string), `internal cron scheduler started`, and the polling loop started (getUpdates); `curl localhost:8080/` returns `miti99bot ok`. (Requires the bot's webhook to be unset — see Phase 4 / `deleteWebhook`, else getUpdates 409s.) 4. Atlas setup (M0): cluster, DB user, network access, copy `mongodb+srv://…` SRV URL. -5. Coolify: create resource (Git repo + compose, or prebuilt image), set env vars, deploy, note assigned HTTPS domain. -6. Register webhook + commands against the Coolify domain (reuse `make telegram-webhook`/`telegram-commands` with the URL, or the documented curl). Note: `make telegram-*` currently pulls token from SSM — for self-host, add env-var-based variants or document the direct curl. +5. Coolify: create resource (Git repo + compose, or prebuilt image), set env vars, deploy. No public domain/route needed (outbound-only); ensure exactly 1 replica. +6. Command menu only: `setMyCommands` (still an HTTPS POST to Telegram). `make telegram-commands` currently pulls the token from SSM — for self-host, add an env-var/token-arg variant or document the direct curl. No webhook registration step exists in polling mode. 7. Smoke test: send `/ping`, `/help`; confirm a persisted command (e.g. stock paper trade) survives a container restart. 8. Write `docs/deploy-coolify-selfhosted.md`; update README. ## Success Criteria -- [ ] `docker compose up --build` boots the bot with mongo + internal cron locally. -- [ ] Coolify deployment is reachable at its HTTPS domain; `/` health passes; restarts `unless-stopped`. -- [ ] Telegram webhook set to the Coolify domain; live commands respond. +- [ ] `docker compose up --build` boots the bot with mongo + internal cron + polling locally; logs show the getUpdates loop running. +- [ ] Coolify deployment runs with NO public domain/port (outbound-only); `/` health passes internally; restarts `unless-stopped`; exactly 1 replica. +- [ ] Bot receives messages via long polling (no webhook set); live commands respond. +- [ ] Webhook code removed (`webhook.go`, `/webhook` route, `WebhookSecret` config/fatal gone); bot boots with no `TELEGRAM_WEBHOOK_SECRET`. - [ ] No secret committed; `.env.example` documents every variable, lists all six `*_PARAMETER_NAME` as "leave UNSET", and omits `CRON_SHARED_SECRET` by default. - [ ] Container boots with zero `*_PARAMETER_NAME` set (no AWS creds present) and never attempts an SSM/AWS call. - [ ] `deploynotify` parity decided: either `gitSHA` injected via Dockerfile build arg (owner DM works) or the feature is documented as disabled on self-host. - [ ] `docs/deploy-coolify-selfhosted.md` is complete enough to redo from scratch. +- [ ] No stock/coin/gold `*_API_URL` overrides set; modules work on coded defaults. `stock_income_events` command + FireAnt client removed; command catalog updated; `make vet`/`make test` green after removal. +- [ ] Gold works with `GOLD_VNAPP_API_KEY` unset (auto-fetches + caches the VNAppMob key to Mongo at `vnappmob:api_key`). ## Risk Assessment - **Distroless healthcheck**: no shell, and no `-healthcheck` flag exists. Mitigation: use Coolify's HTTP monitor against `/` (text body `miti99bot ok`), or implement the flag first. Do not ship the bogus `["CMD","/server","-healthcheck"]` line. - **Atlas network access — `0.0.0.0/0` accepted (validated decision)**: the Coolify host has no stable egress IP, so the Atlas IP access list is `0.0.0.0/0` (internet-reachable). This widens the surface beyond the project's documented "no non-designed-surface public resource" boundary (`docs/deploy-aws-free-tier-guide.md:25,35`) — under DynamoDB the DB was IAM-gated and never internet-reachable — and is knowingly accepted for self-host. **Mandatory compensating controls (hard requirements):** (1) a strong unique DB password, (2) a least-privilege Atlas DB user scoped to `readWrite` on the single app database (never Atlas admin / cluster-wide), (3) the connection string is treated as a secret and never logged (see Phase 1 / Finding-3 constraint). Record the acceptance in `docs/deploy-coolify-selfhosted.md`. -- **Public ingress is now the bot's own process (Medium)**: a Function URL sat behind AWS edge (throttling, managed TLS, absorbed L3/4 floods); a Coolify domain exposes the single `:8080` Go process directly, and Phase 2 pins 1 replica. An unauthenticated flood saturates the box even though `/webhook` rejects via constant-time secret compare (`telegram/webhook.go:55-60`). Mitigation: front the service with Coolify's built-in Traefik rate-limit middleware (no extra cost); keep existing `ReadHeaderTimeout`/`ReadTimeout` (`main.go:165-166`). +- **No public ingress (resolved by polling)**: long polling is outbound-only, so there is NO public domain or `/webhook` to flood — the entire unauthenticated-ingress attack surface that webhook mode created is gone. (This supersedes the earlier "public ingress is the bot's own process" risk.) Residual: the `/` health server should stay internal (Coolify `expose`, not `ports`); never publish it. +- **Single polling consumer (409 conflict)**: Telegram permits only ONE `getUpdates` consumer per bot token. Two replicas, OR a leftover webhook still set, OR an overlapping old instance during redeploy → HTTP 409 / lost updates. Mitigation: exactly 1 replica; `deleteWebhook` before first poll (Phase 4); prefer stop-first redeploy (also covers the Phase 2 cron-overlap concern). - **Mongo runtime connection loss (Medium)**: unlike the per-call short-lived DynamoDB client (`dynamodb_client.go`), self-host holds one long-lived Mongo client for days; Atlas M0 idles/fails over. The v2 driver auto-reconnects on the next op (confirm against current driver docs) — set sane pool + server-selection timeout options. A plain `/` healthcheck reports healthy even when Mongo is unreachable, so Coolify won't restart a DB-wedged bot; either make the healthcheck DB-aware (lightweight `Ping`) or explicitly accept "stays up reporting healthy during DB outage" as the chosen trade-off. -- **Webhook tooling assumes SSM**: `make telegram-*` reads SSM. Mitigation: document env-var/curl variant for self-host; optionally add `telegram-webhook-url URL=…` Make target. -- **Single replica**: scaling >1 double-fires cron + duplicates webhook processing is fine but cron is not. Mitigation: pin 1 replica in Coolify; documented in Phase 2. +- **Telegram tooling assumes SSM**: `make telegram-commands` reads the token from SSM. Mitigation: document an env-var/token-arg variant for self-host (only `setMyCommands` is needed in polling mode; no webhook registration). diff --git a/plans/260627-1849-selfhost-coolify-mongodb/phase-04-data-migration-and-cutover.md b/plans/260627-1849-selfhost-coolify-mongodb/phase-04-data-migration-and-cutover.md index a0fe12f..6d75fa0 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/phase-04-data-migration-and-cutover.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/phase-04-data-migration-and-cutover.md @@ -11,7 +11,7 @@ effort: "M" ## Overview -Copy all existing items from the prod DynamoDB table (`miti99bot-data`) into MongoDB Atlas using the finalized Phase 1 document schema, verify parity, then flip the Telegram webhook from the Lambda Function URL to the Coolify domain. Idempotent and re-runnable. +Copy all existing items from the prod DynamoDB table (`miti99bot-data`) into MongoDB Atlas using the finalized Phase 1 document schema, verify parity, then switch Telegram from the Lambda webhook to the long-polling container (Phase 3). Idempotent and re-runnable. ## Requirements @@ -44,16 +44,16 @@ New one-off CLI `cmd/migrate-dynamo-to-mongo/main.go`: IAM (least privilege): exactly `dynamodb:Scan` on the specific table ARN (`arn:…:table/miti99bot-data`), nothing else. No write actions on the source — enforces the read-only requirement and removes the destructive-credential foot-gun. Cutover sequence (documented runbook — zero-loss): -1. Deploy Coolify stack (Phase 3) with a fresh/empty Atlas DB. Do NOT set the Telegram webhook to it yet. Do NOT set `CRON_MODE=internal` yet (avoids cron overlap, below). -2. **Disable/delete the EventBridge `LolscheduleDailyPushSchedule`** (`template.yaml:271-289`) — it invokes the Lambda directly, independent of the webhook, so it must be stopped before any internal scheduler runs. (The Phase 2 last-push-date guard is the belt-and-braces backup.) -3. `deleteWebhook` (MANDATORY, not optional) — Telegram now buffers incoming updates up to 24h. This is what makes the cut lossless; do NOT take an "accept the gap" path (a `/buy` between snapshot and flip would write to DynamoDB only and be lost — `coin/portfolio.go:64-90`). +1. Deploy the Coolify container (Phase 3, polling — the only transport), but keep it **stopped/scaled-to-0** for now (a running poller would 409 against the live Lambda webhook, and would start serving before data is migrated, and its always-on scheduler would overlap EventBridge). Fresh/empty Atlas DB. +2. **Disable/delete the EventBridge `LolscheduleDailyPushSchedule`** (`template.yaml:271-289`) — it invokes the Lambda directly, independent of transport, so it must be stopped before any internal scheduler runs. (The Phase 2 last-push-date guard is the belt-and-braces backup.) +3. `deleteWebhook` (MANDATORY) — does double duty: (a) Telegram now buffers incoming updates up to 24h, making the cut lossless (do NOT take an "accept the gap" path — a `/buy` in the gap would write to DynamoDB only and be lost, `coin/portfolio.go:64-90`); (b) it releases the webhook so the polling container's `getUpdates` won't 409. After this the Lambda stops receiving updates. 4. Run migrator `--dry-run` → review counts. Then run for real; run `--verify` (counts equal per `pk`, exit 0). Keep this window short (target minutes). -5. Set `CRON_MODE=internal` on the Coolify service (now safe — EventBridge disabled). -6. `setWebhook` → Coolify domain. Telegram delivers the buffered updates to the new host. -7. Verify `getWebhookInfo`: `pending_update_count` drains toward 0 and `last_error_date` is empty (same health signal as `docs/deploy-aws.md:77`). +5. Start the Coolify container (its in-process scheduler runs by default — safe now that EventBridge is disabled). Exactly 1 replica (single polling consumer). +6. The container's `getUpdates` loop drains Telegram's buffered queue automatically — no `setWebhook`, no public URL. Confirm in logs that updates are being received. +7. Verify `getWebhookInfo` shows `url` empty (webhook cleared) and `pending_update_count` draining toward 0 as the poller consumes the backlog (same queue signal as `docs/deploy-aws.md:77`). 8. Smoke `/ping`, `/stats`, a coin/stock balance command — confirm migrated state is visible from Atlas. 9. **Tear down AWS** (validated decision): after `--verify` passes and smoke tests are green, `sam delete` the stack (Lambda, DynamoDB, EventBridge, SQS, etc.) and disable the GitHub Actions `deploy.yml` workflow. The user coordinates users to avoid activity during the brief cutover window, so the simple cut is lossless; no reverse migrator is built. -10. **Last clean revert point:** rollback to AWS is only possible BEFORE `sam delete`. If you want a safety margin, defer step 9's `sam delete` by a short observation window (still keeping EventBridge disabled); re-pointing the webhook to the Function URL reverts with ~zero loss only until the first post-cutover Mongo write. After `sam delete`, MongoDB/Coolify is the sole system of record. +10. **Last clean revert point:** rollback to AWS is only possible BEFORE `sam delete`. To revert during the observation window: stop the polling container, then re-`setWebhook` to the Lambda Function URL — the still-deployed Lambda runs its own already-built webhook code (this branch's webhook removal doesn't touch the live function until `sam delete`). Lossless only until the first post-cutover Mongo write. After `sam delete`, MongoDB/Coolify is the sole system of record. ## Related Code Files @@ -71,7 +71,7 @@ Cutover sequence (documented runbook — zero-loss): 4. Add Make targets. 5. Dry-run against prod DynamoDB; review counts per module. 6. Execute migration + verify; record the count table in the cutover doc. -7. Flip webhook; smoke-test; monitor. +7. `deleteWebhook`, start the polling container, smoke-test, monitor. ## Success Criteria @@ -79,15 +79,16 @@ Cutover sequence (documented runbook — zero-loss): - [ ] Real run copies all items; `--verify` shows DynamoDB and Mongo counts equal for every `pk`, exit 0. - [ ] Re-running the migrator produces no duplicates and no value changes (idempotent). - [ ] Migrator runs `validateKey` per `sk` and writes through `MongoKVStore.Put`; a `Get` round-trip spot check returns byte-identical values. -- [ ] After webhook cutover, a previously-stored value (e.g. a user's paper-trade balance) is returned by the live bot from Atlas. -- [ ] EventBridge schedule disabled before `CRON_MODE=internal`; daily push fires exactly once on cutover day. -- [ ] Rollback truth documented: webhook re-point is lossless only before the first post-cutover Mongo write (RPO stated explicitly). +- [ ] After cutover (polling live, webhook cleared), a previously-stored value (e.g. a user's paper-trade balance) is returned by the live bot from Atlas. +- [ ] EventBridge schedule disabled before the container starts; daily push fires exactly once on cutover day. +- [ ] `getWebhookInfo` shows the webhook URL empty and the polling container is the sole update consumer (no 409). +- [ ] Rollback truth documented: reverting to the Lambda webhook is lossless only before the first post-cutover Mongo write (RPO stated explicitly). ## Risk Assessment -- **Rollback bound — accepted (validated decision)**: AWS is torn down after verify, so there is no long-term fallback by design and no reverse migrator is built. The user coordinates users to pause around the brief cutover window, so the simple cut loses no writes. The only clean-revert opportunity is the short observation window BEFORE `sam delete`, and even then re-pointing the webhook is lossless only until the first post-cutover Mongo write. This is an accepted RPO for a personal paper-trading bot, not an open risk. +- **Rollback bound — accepted (validated decision)**: AWS is torn down after verify, so there is no long-term fallback by design and no reverse migrator is built. The user coordinates users to pause around the brief cutover window, so the simple cut loses no writes. The only clean-revert opportunity is the short observation window BEFORE `sam delete` (stop the poller, re-`setWebhook` to Lambda), and even then it is lossless only until the first post-cutover Mongo write. This is an accepted RPO for a personal paper-trading bot, not an open risk. - **Migration window write-loss**: the user pauses user activity during cutover (coordinated), and the mandatory `deleteWebhook` buffers any stray updates (Telegram retains ~24h). The "accept the gap" option is removed. Keep the window short and confirm `pending_update_count` drains post-flip. -- **Cron double-fire during cutover**: EventBridge schedule is disabled BEFORE `CRON_MODE=internal` (runbook step 2); the Phase 2 last-push-date guard is the backup. Without both, subscribers get the daily push twice (`lolschedule/cron.go:128-191` is non-idempotent). +- **Cron double-fire during cutover**: EventBridge schedule is disabled BEFORE the container starts (runbook step 2); the Phase 2 last-push-date guard is the backup. Without both, subscribers get the daily push twice (`lolschedule/cron.go:128-191` is non-idempotent). - **`updatedAt` type**: store as int64 unix-nanos in Mongo (matches DynamoDB `dynamodb_kv.go:101`) — exact parity, no truncation. No code reads `updatedAt` today (write-only), so this is cheap insurance against a future TTL/sort. - **Value/encoding mismatch**: migrator writes through `MongoKVStore.Put` (same encoding as the app), not raw `UpdateOne` — guarantees byte-identical `value` and keeps re-runs + CAS correct. Spot-check with a `Get` round-trip. - **Key validity asymmetry**: migrator runs `validateKey(sk)` before writing and fails loud on any rejected key, so it never writes data the app's read path can't load. diff --git a/plans/260627-1849-selfhost-coolify-mongodb/plan.md b/plans/260627-1849-selfhost-coolify-mongodb/plan.md index 83d27e4..f48ac05 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/plan.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/plan.md @@ -18,7 +18,7 @@ source: skill Run miti99bot as a long-lived container on Coolify (docker-compose) instead of AWS Lambda, using MongoDB Atlas (`MONGO_URL` + `MONGO_DATABASE`) instead of DynamoDB. Existing DynamoDB data is migrated into Atlas. At the **code** level this is additive — a 4th KV backend + a self-host run mode; the DynamoDB/Lambda code path is NOT ripped out (kept for portability and to run the migrator). At the **infrastructure** level, the deployed AWS stack is fully decommissioned after a verified cutover (Phase 5, validated decision). -Why this is low-risk: storage is already a pluggable `KVProvider` interface with 3 backends (`memory`, `firestore`, `dynamodb`); adding `mongodb` follows the exact `firestore` collection-per-module pattern. Secrets already fall back to plain env vars when `*_PARAMETER_NAME` is unset, so Coolify env vars need no code change. The only genuine gap is cron: EventBridge Scheduler triggers `/cron/{name}` today, which does not exist off-AWS. +Why this is low-risk: storage is already a pluggable `KVProvider` interface with 3 backends (`memory`, `firestore`, `dynamodb`); adding `mongodb` follows the exact `firestore` collection-per-module pattern. Secrets already fall back to plain env vars when `*_PARAMETER_NAME` is unset, so Coolify env vars need no code change. Two gaps to close, both with minimal code: (1) **cron** — EventBridge Scheduler triggers `/cron/{name}` today, replaced by an in-process scheduler (Phase 2); (2) **transport** — the bot runs webhook-only today, switched to **long polling** (Phase 3) via the same `go-telegram/bot` library's built-in polling mode, which removes the need for any public inbound ingress on the self-hosted box. ## Phases @@ -26,7 +26,7 @@ Why this is low-risk: storage is already a pluggable `KVProvider` interface with |-------|------|--------| | 1 | [MongoDB Storage Provider](./phase-01-mongodb-storage-provider.md) | Pending | | 2 | [In-Process Cron Scheduler](./phase-02-in-process-cron-scheduler.md) | Pending | -| 3 | [Containerize and Coolify Deploy](./phase-03-containerize-and-coolify-deploy.md) | Pending | +| 3 | [Long-Polling Runtime + Containerize + Coolify Deploy](./phase-03-containerize-and-coolify-deploy.md) | Pending | | 4 | [Data Migration and Cutover](./phase-04-data-migration-and-cutover.md) | Pending | | 5 | [AWS Full Decommission](./phase-05-aws-decommission.md) | Pending | @@ -34,7 +34,7 @@ Why this is low-risk: storage is already a pluggable `KVProvider` interface with - Phase 2 is independent of Phase 1 (cron touches no storage). - Phase 3 depends on 1 + 2 (container must boot with mongo + internal cron). -- Phase 4 depends on Phase 1 (Mongo document schema must be final before copying data) and is the last step before flipping the Telegram webhook to the Coolify URL. +- Phase 4 depends on Phase 1 (Mongo document schema must be final before copying data) and Phase 3 (the polling container must exist before cutover); it is the last step before switching Telegram to the polling container. - Phase 5 (AWS decommission) depends on Phase 4 `--verify` passing — it destroys DynamoDB, so it runs only after data is migrated and the bot is confirmed live on Coolify. Suggested order: 1 → 2 → 3 → 4 → 5. Phases 1 and 2 can be done in parallel by separate developers (disjoint files). @@ -43,20 +43,21 @@ Suggested order: 1 → 2 → 3 → 4 → 5. Phases 1 and 2 can be done in parall ``` BEFORE (AWS) AFTER (Coolify self-host) - Telegram ──webhook──> Lambda Function URL Telegram ──webhook──> Coolify domain + Telegram ──webhook──> Lambda Function URL Telegram <──long poll── container (outbound only) EventBridge Scheduler ──> /cron/{name} in-process scheduler ──> cron.Handler DynamoDB (pk=module, sk=key) MongoDB Atlas (db / collection-per-module) SSM Parameter Store secrets Coolify env vars (plain) + public HTTPS ingress (Function URL) NO public ingress (polling = outbound only) ``` Same Go binary (`cmd/server`), same HTTP server on `:8080`, same module framework. Backend + cron + secret source are all selected by env vars at startup. ## Acceptance Criteria -- [ ] `KV_PROVIDER=mongodb MONGO_URL=… MONGO_DATABASE=…` boots and serves `/webhook` with persistent storage. +- [ ] `MONGO_URL=… MONGO_DATABASE=…` (mongodb auto-selected, no `KV_PROVIDER`) boots, runs the cron scheduler by default, and consumes updates via long polling (the only transport) with persistent storage; no `/webhook` route, no webhook secret. - [ ] `make test` green; new mongo provider has parity tests with the firestore/dynamodb suites. - [ ] Self-hosted container fires the lolschedule daily push at 01:00 UTC (08:00 ICT) without EventBridge. -- [ ] `docker compose up` (Coolify) brings the bot live behind a public HTTPS domain; `/` health check passes. +- [ ] `docker compose up` (Coolify) brings the bot live via long polling with NO public domain/ingress (outbound-only); `/` health check passes internally; exactly 1 replica. - [ ] All existing prod DynamoDB items present in Atlas with identical values; migration is idempotent + verifiable by per-module counts. - [ ] After cutover, ALL miti99bot AWS resources are deleted — CloudFormation stack AND the manually-created SSM secrets, GitHub OIDC role, and SAM S3 bucket; `app=miti99bot` tag sweep is empty and Cost Explorer trends to $0. - [ ] `.github/workflows/deploy.yml` is removed/disabled so `main` pushes no longer recreate the AWS stack. @@ -94,7 +95,7 @@ Re-read all phase files after applying findings. Reconciled: - Migrator writes **through `MongoKVStore.Put`** in Phase 4 architecture, risk, and success criteria (no raw `UpdateOne`). - Health endpoint described as **plain text `miti99bot ok`** in Phase 3 requirements, architecture, and risk (was "JSON"); bogus `-healthcheck` CMD removed from the compose snippet. - Cron dispatcher named **`DispatchScheduled`** in Phase 2 with scheduler-local timeout/recover. -- Cutover ordering (disable EventBridge → migrate → `CRON_MODE=internal` → `setWebhook`) consistent across Phase 2 risk and Phase 4 runbook. +- Cutover ordering (disable EventBridge → `deleteWebhook` → migrate → start polling container, whose scheduler runs by default) consistent across Phase 2 risk and Phase 4 runbook. No unresolved contradictions remain. ## Validation Log @@ -113,6 +114,23 @@ Verification pass skipped: `## Red Team Review` already carries full `file:line` | 7 | Atlas tier | Free **M0** (512 MB) — sufficient for the tiny paper-trading KV. | Phase 3 | | 8 | `updatedAt` reader | Confirmed write-only today; store as int64 for cheap parity. No TTL/sort planned. | Phase 1, Phase 4 | +### Session 2 — 2026-06-27 (Telegram transport) +User directive: use long polling, **as the only mode — no env toggle**. Decision: keep the existing `go-telegram/bot` library (native polling via `b.Start(ctx)` — no library swap, handlers unchanged) and **delete the webhook code path entirely** (webhook existed only for Lambda, which is being decommissioned — YAGNI). Removed: `internal/telegram/webhook.go` + test, the `/webhook` route, and the `WebhookSecret` config + its startup fatal. Consequences propagated to Phase 3 (no public ingress/domain/TLS/webhook secret; health server internal-only; single replica = single polling consumer) and Phase 4 (cutover = `deleteWebhook` → start polling container, which drains the buffered queue; no `setWebhook`). Rollback during the pre-`sam delete` window still works because the live Lambda keeps its own already-built webhook code until teardown. This eliminated the earlier "public ingress DDoS" risk entirely. + +### Session 3 — 2026-06-27 (minimize env surface) +User directive: don't require `KV_PROVIDER`/`CRON_MODE`; mongo + in-process cron are defaults; remove unneeded system envs. Decisions: (1) `buildProvider` auto-selects `mongodb` when `MONGO_URL` is set (else `memory`); `KV_PROVIDER` is an optional override; the old `AWS_LAMBDA_FUNCTION_NAME → dynamodb` auto-detect is removed. (2) The cron scheduler runs unconditionally at container start — `CRON_MODE` env removed (cutover safety is from ordering + the idempotency guard, not a gate). (3) Dropped from required env: `KV_PROVIDER`, `CRON_MODE`, `PORT` (defaults 8080). Final required env = `TELEGRAM_BOT_TOKEN`, `MONGO_URL`, `MONGO_DATABASE`; operational = `MODULES`, `OWNER_ID`, `ADMIN_IDS`; optional = `GEMINI_API_KEY`. Propagated to Phases 1-4. + +### Session 4 — 2026-06-27 (drop per-module API URL overrides + two credentials) +User directive: remove the stock/coin/gold URL envs; use the providers configured in code. Decisions: +- **URL overrides dropped** — self-host sets NONE of `STOCK_INCOME_EVENTS_API_URL`, `GOLD_PRICE_API_URL`, `GOLD_FX_API_URL`, `GOLD_VNAPP_API_URL`, `COIN_BINANCE_API_URL`, `COIN_COINBASE_API_URL`, `COIN_COINGECKO_API_URL`; modules use coded default endpoints. Override plumbing stays (dormant) unless later cleaned. +- **`GOLD_VNAPP_API_KEY` dropped** — already unnecessary: `gold/vnappmob_client.go` auto-fetches a VNAppMob API key via the refresh endpoint and caches it in KV (`vnappmob:api_key`, 24h refresh buffer). With Mongo as the KV the key auto-caches to the DB. The env var was only an optional override. No code change — just don't set it. +- **`STOCK_INCOME_EVENTS_API_TOKEN` dropped AND the income-events feature removed** — delete the `stock_income_events` command + its FireAnt `IncomeEventClient` (`internal/modules/stock/income_events.go` + test), the token env, and any user-facing notice/description. The other stock commands (`stock_income_stock`, `stock_income_vnd`) stay. Removing a public command requires updating the command catalog (`aws/telegram-commands.json` → the self-host `setMyCommands` source). Code task added to Phase 3. + +Final optional env reduces to `GEMINI_API_KEY` only. + +### Session 5 — 2026-06-27 (rename owner/admin envs) +User directive: cleaner names. `BOT_OWNER_ID` → `OWNER_ID`, `ADMIN_USER_IDS` → `ADMIN_IDS`. Rename the env keys in `cmd/server/main.go` `loadConfig`; no backward-compat (the AWS template + workflow that set the old names are being deleted). The Go config field names + the `Auth{BotOwnerID, AdminUserIDs}` struct can keep their internal names — only the env keys change. + ### Whole-Plan Consistency Sweep (post-validation) - Phase 4 rollback/teardown rewritten: AWS torn down after verify; reverse-migrator option removed; rollback framed as "coordinate users, short window" not "keep Lambda N days." - Phase 3 Atlas networking: `0.0.0.0/0` is now the chosen path (was "egress-IP default") with password + least-priv user as hard requirements.