diff --git a/plans/260508-2222-go-port-cloud-run/plan.md b/plans/260508-2222-go-port-cloud-run/plan.md index 8f359b2..a5622d1 100644 --- a/plans/260508-2222-go-port-cloud-run/plan.md +++ b/plans/260508-2222-go-port-cloud-run/plan.md @@ -14,6 +14,8 @@ supersedes: [260425-1945-mongodb-atlas-migration] # Plan: Go port → Google Cloud Run (free tier) +> **2026-05-10:** Deploy phases (01, 09–12) **superseded by [`plans/260510-0114-aws-port/`](../260510-0114-aws-port/plan.md)** — strict $0 free-tier goal motivated switch to AWS Lambda + DynamoDB + EventBridge. Module work (phases 03–07) is done and **reused unchanged** by the AWS plan. Phase 08 (trading) remains pending, cloud-agnostic, can be tackled before or after the AWS cutover. + Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+Workers AI for Firestore Native + Gemini API + Cloud Scheduler. Source repo lives at a new `miti99bot-go` repo (separate). Cutover via dual-run + soak. ## Locked decisions @@ -45,7 +47,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W | 04 | [Firestore KVStore + per-module prefixing](phase-04-firestore-kv.md) | done | 4h | `FirestoreKVStore`, emulator tests, KVProvider abstraction (Memory + Firestore) | | 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | done | 6h | 4 KV-only modules at JS parity; shared `internal/keylock` extracted | | 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | done | 5h | All five sub-modules ported (emoji, quote, ability, splash, lolschedule); lolschedule daily-push cron deferred to Phase 09 | -| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | pending | 6h | 3 AI modules with rate-limit handling | +| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | done | 6h | `internal/ai` (Embedder/Chatter + per-user bucket); semantle (text-embedding-004), doantu (phow2sim HTTP — JS-parity deviation), twentyq (gemini-2.5-flash) | | 08 | [Port trading + composite indexes](phase-08-port-trading.md) | pending | 6h | VN-stocks paper trading + daily price cron | | 09 | [Cloud Scheduler cron wiring](phase-09-cloud-scheduler.md) | pending | 2h | 2 jobs → `/cron/{name}` with OIDC | | 10 | [CI/CD + Dockerfile + Secret Manager](phase-10-ci-cd.md) | pending | 4h | GHA pipeline → AR → Cloud Run, idempotent | diff --git a/plans/260510-0114-aws-port/phase-01-aws-bootstrap.md b/plans/260510-0114-aws-port/phase-01-aws-bootstrap.md new file mode 100644 index 0000000..c8f22f6 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-01-aws-bootstrap.md @@ -0,0 +1,69 @@ +--- +phase: 1 +title: "AWS bootstrap + IAM OIDC + SAM skeleton" +status: pending +priority: P1 +effort: "3h" +dependencies: [] +--- + +# Phase 01: AWS bootstrap + IAM OIDC + SAM skeleton + +## Overview +Stand up the AWS account with strict $0 footprint: IAM OIDC trust for GitHub Actions, baseline SAM stack that deploys an empty Lambda + DynamoDB table + Function URL placeholder. Nothing wired to real bot yet. + +## Requirements +- **Functional:** Empty stack deploys via `sam deploy --guided` from local. GitHub Actions can assume the deploy role via OIDC (no long-lived keys). +- **Non-functional:** Region `ap-southeast-1`. Single AWS account. Stack name `miti99bot-aws-port`. All resources tagged `app=miti99bot, env=prod`. Strict free-tier resources only. + +## Architecture +``` +GitHub Actions ─OIDC─► AWS IAM Role (github-deploy) + │ └─ trust: token.actions.githubusercontent.com + │ └─ scoped: repo:tiennm99/miti99bot-go:ref:refs/heads/main + │ + └─► CloudFormation (SAM) ─► Lambda + DynamoDB + ParamStore + EventBridge + Logs +``` + +## Related Code Files +- Create: `template.yaml` (SAM root, all resources declared here) +- Create: `samconfig.toml` (stack name, region, capabilities) +- Create: `aws/iam-github-oidc-trust.json` (one-shot reference doc, not deployed) +- Create: `aws/README.md` (commands cheat sheet for first-time setup) +- Create: `Makefile` (targets: `build`, `package`, `deploy`, `logs`) +- Modify: `.gitignore` (add `.aws-sam/`, `samconfig.toml.local`) + +## Implementation Steps +1. Create AWS account (or reuse existing). Enable MFA on root, create IAM admin user for one-time bootstrap. Set region default `ap-southeast-1`. +2. Create the GitHub OIDC identity provider in IAM: thumbprint, audience `sts.amazonaws.com`. (One-time, manual or via small CloudFormation snippet.) +3. Create IAM role `github-deploy-miti99bot` with trust policy scoped to `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:ref:refs/heads/dev`. Attach managed policies for SAM deploy: CloudFormation, Lambda, DynamoDB, EventBridge, IAM (PassRole only), SSM Parameter Store, Logs, S3 (SAM staging bucket). +4. Write `template.yaml` skeleton: + - `AWSTemplateFormatVersion: '2010-09-09'`, `Transform: AWS::Serverless-2016-10-31` + - `Globals.Function`: `Runtime: provided.al2023`, `Architectures: [arm64]`, `MemorySize: 256`, `Timeout: 15`, `Tracing: Active` (still free at this volume) + - `Resources.BotFunction`: empty handler (`bootstrap` not yet built), Function URL with `AuthType: NONE` + - `Resources.BotTable`: DynamoDB on-demand, PK=`pk` (S), no GSI yet + - Outputs: function URL, table name +5. Write `samconfig.toml` with stack name, region, capabilities (`CAPABILITY_IAM`). +6. First deploy: `sam build && sam deploy --guided` from local using bootstrap admin credentials. Confirm stack reaches `CREATE_COMPLETE`. Save the Function URL. +7. Verify GH Actions OIDC by running a one-shot workflow that calls `aws sts get-caller-identity` — confirms trust works without keys. +8. Manual smoke: `curl ` returns 502 (no handler yet) — proves URL is reachable. + +## Success Criteria +- [ ] AWS account active, MFA on root, region default `ap-southeast-1` +- [ ] GitHub OIDC provider created +- [ ] `github-deploy-miti99bot` IAM role assumes successfully from a test GH Actions run +- [ ] `sam deploy` succeeds; stack `miti99bot-aws-port` in `CREATE_COMPLETE` +- [ ] DynamoDB table `miti99bot` exists, on-demand billing mode +- [ ] Function URL reachable (502 expected) +- [ ] AWS Cost Explorer shows $0 spend after 24h + +## Risk Assessment +- **OIDC trust scope too loose** (any branch / any repo) → Mitigation: scope to specific repo + ref pattern; review `sub` claim in CloudTrail after first successful run. +- **IAM policy over-broad** → Mitigation: start with managed policies for speed, tighten in Phase 06 once resource ARNs stable. +- **SAM staging bucket created in wrong region / accumulates artifacts** → Mitigation: pin region in samconfig; add lifecycle rule (7-day expiration) on staging bucket. +- **CloudFormation drift** if user edits via console → Mitigation: forbid console edits, document in `aws/README.md`. + +## Open questions +1. Single account vs separate dev/prod accounts? Single is simpler for solo dev; defer split until usage warrants. +2. Reuse SAM staging bucket from existing AWS work or fresh one? Fresh, scoped to this stack, easier to clean up. +3. Pin SAM CLI version in `Makefile`? Yes, document expected version (current latest works); rely on `setup-sam` action in CI to pin. diff --git a/plans/260510-0114-aws-port/phase-02-lambda-runtime.md b/plans/260510-0114-aws-port/phase-02-lambda-runtime.md new file mode 100644 index 0000000..cf2a28f --- /dev/null +++ b/plans/260510-0114-aws-port/phase-02-lambda-runtime.md @@ -0,0 +1,78 @@ +--- +phase: 2 +title: "Lambda runtime (Go ZIP + LWA + Function URL)" +status: pending +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 02: Lambda runtime (Go ZIP + LWA + Function URL) + +## Overview +Make the existing Go HTTP server run as a Lambda behind a Function URL with zero handler-code changes, using AWS Lambda Web Adapter. Routes `/` (healthcheck) and `/webhook` work end-to-end with secret verification. + +## Requirements +- **Functional:** Function URL responds to `GET /` with the existing health JSON, and to `POST /webhook` with the existing Telegram dispatcher logic. Secret-token verification (`X-Telegram-Bot-Api-Secret-Token`) preserved. +- **Non-functional:** Cold start P95 < 1.5s for ARM64 Go ZIP. Memory 256 MiB. Timeout 15s. Binary size <30 MiB. + +## Architecture +``` +Telegram ──HTTPS──► Function URL ──► Lambda runtime + │ + ├── LWA layer (extension) translates Lambda event → HTTP + │ └── localhost:8080 (LWA listens here) + │ + └── bootstrap binary (existing Go server) + starts http.ListenAndServe(":8080", ...) + dispatcher → modules → DynamoDB / Gemini +``` + +LWA is added as a Lambda layer; binary just runs `http.ListenAndServe` — no Lambda SDK import required. + +## Related Code Files +- Create: `cmd/server/lambda.go` (build-tag `lambda`, sets `PORT=8080` defaults; minimal — possibly empty) +- Modify: `cmd/server/main.go` — accept `PORT` env (likely already does), confirm graceful shutdown on `SIGTERM` (LWA sends it on shutdown) +- Modify: `template.yaml` — wire `BotFunction` properly: + - `CodeUri: build/` (ZIP staging) + - `Handler: bootstrap` + - `Layers: [arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:]` + - `Environment.Variables`: `AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap`, `PORT=8080`, `READINESS_CHECK_PATH=/`, `MODULES=util,misc,wordle,...`, `TELEGRAM_BOT_TOKEN={{resolve:ssm-secure:...}}`, etc. +- Modify: `Makefile` — add `build-lambda` target: `GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -ldflags="-s -w" -o build/bootstrap ./cmd/server && chmod +x build/bootstrap` +- Reference: `internal/server/router.go` (unchanged) +- Reference: `internal/telegram/*.go` (unchanged) + +## Implementation Steps +1. Confirm `cmd/server/main.go` reads `PORT` env (it does per inspection). Confirm graceful shutdown on `SIGTERM`/`SIGINT`. +2. Add `build-lambda` Makefile target. Test locally: `make build-lambda && file build/bootstrap` shows ARM64 ELF. +3. Pick latest LWA layer ARN for `ap-southeast-1` ARM64 — pin major version in `template.yaml` with comment linking to release notes. +4. Add Function env vars in `template.yaml`. Use `{{resolve:ssm-secure:...}}` for secrets so values never appear in template. Reference Phase 01's Parameter Store names. +5. Wire DynamoDB IAM permissions (read/write on table) via `Policies: - DynamoDBCrudPolicy`. Wire SSM read perms via `SSMParameterReadPolicy`. +6. Build + deploy: `sam build && sam deploy`. Tail logs: `sam logs --tail`. +7. Smoke test: + - `curl /` → 200 with health JSON + - `curl -XPOST /webhook -H "X-Telegram-Bot-Api-Secret-Token: wrong"` → 401 + - `curl -XPOST /webhook -H "X-Telegram-Bot-Api-Secret-Token: " -d '{"update_id":1,"message":{"text":"/start","chat":{"id":1},"from":{"id":1}}}'` → 200 (or expected dispatcher response) +8. Set Telegram dev-bot webhook to Function URL. Send `/start` from real client. Confirm response in chat. +9. Capture cold-start P95 from CloudWatch Logs `Init Duration` field over 20+ invocations (use Powertools or grep). Record in this phase's "Risks" if >1s. + +## Success Criteria +- [ ] `make build-lambda` produces ARM64 binary <30 MiB +- [ ] `sam deploy` updates `BotFunction` successfully +- [ ] `curl /` returns health JSON +- [ ] Wrong webhook secret → 401 +- [ ] Correct webhook secret + valid update → dispatcher responds +- [ ] Telegram dev bot exchanges messages end-to-end via Function URL +- [ ] Cold start P95 < 1.5s + +## Risk Assessment +- **LWA cold-start tax** adds ~100ms — acceptable; if not, fall back to `lambda.Start()` adapter path (rewrite handler, more invasive). +- **`{{resolve:ssm-secure:...}}` requires CloudFormation perms** — SAM handles this if role has `ssm:GetParameter*`. +- **Webhook secret leakage in logs** — confirm `internal/server/router.go` does not log header value; if it does, redact. +- **Binary too large** (>50 MiB unzipped) — strip with `-ldflags="-s -w"` (already done); if still too big, audit deps with `go build -ldflags="-s -w" -trimpath` + `goweight`. +- **ARM64 incompatibility** with any cgo dep — confirm `CGO_ENABLED=0` in build (existing Dockerfile does this). + +## Open questions +1. Should LWA `READINESS_CHECK_PATH` be `/` or a dedicated `/healthz`? `/` works since handler is cheap; revisit if `/` ever does work. +2. Telegram delivery reliability with cold-start 1–3s — acceptable in practice (Telegram retries), but document in README. +3. Provisioned concurrency to eliminate cold start — kills free tier, defer indefinitely. diff --git a/plans/260510-0114-aws-port/phase-03-dynamodb-kv.md b/plans/260510-0114-aws-port/phase-03-dynamodb-kv.md new file mode 100644 index 0000000..1f7ea42 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-03-dynamodb-kv.md @@ -0,0 +1,88 @@ +--- +phase: 3 +title: "DynamoDB KV provider" +status: pending +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 03: DynamoDB KV provider + +## Overview +Add `DynamoDBKVStore` + `DynamoDBProvider` as a sibling to the existing Firestore impl, satisfying the same `KVStore` / `KVProvider` interface. Selectable via `KV_PROVIDER=dynamodb|firestore|memory` env. Default in production: `dynamodb`. Firestore impl preserved for parity tests. + +## Requirements +- **Functional:** All existing modules' KV ops (Get/Put/Delete/List + JSON convenience methods) work against DynamoDB with byte-for-byte parity to Firestore where observable. +- **Non-functional:** Single-table design. On-demand billing. P99 < 50ms for Get/Put. List() with prefix uses `Query` (not `Scan`) — must be cheap. + +## Architecture +**Single-table schema (composite key):** +``` +TableName: miti99bot-data +PK (pk): string = moduleName (e.g. "wordle") +SK (sk): string = caller-provided key (e.g. "user:42:state") +attrs: + value: binary raw bytes + updatedAt: number epoch nanos (parity with Firestore impl) +``` + +Composite key is the canonical DynamoDB shape for prefix-scan workloads: `Query` supports `begins_with(sk, :prefix)` on the **sort key**, but only `=` on the partition key — so the sort key holds the user-supplied key and the partition key holds the module name (which gives free isolation by partition). + +**Operations:** +- `Get(key)` → `GetItem(pk=module, sk=key)` with `ConsistentRead: true` (parity with Firestore strong read) +- `Put(key, val)` → `PutItem(pk=module, sk=key, value=val, updatedAt=now)` +- `Delete(key)` → `DeleteItem(pk=module, sk=key)` +- `List(prefix)` → `Query(pk=module AND begins_with(sk, prefix))` paginated + +**Provider isolation:** `For(moduleName)` returns a `DynamoDBKVStore` bound to that module name; the partition key is the isolation boundary. No prefix wrapping needed at this layer. + +**Reserved word handling:** `value` is reserved in DynamoDB expressions; resolved via `ExpressionAttributeNames` (`#v` → `value`). + +## Related Code Files +- Create: `internal/storage/dynamodb_client.go` — AWS SDK v2 client init, region from env +- Create: `internal/storage/dynamodb_kv.go` — `DynamoDBKVStore` (Get/Put/Delete/List + JSON helpers) +- Create: `internal/storage/dynamodb_provider.go` — `DynamoDBProvider`, `For()` returns module-bound store +- Create: `internal/storage/dynamodb_kv_test.go` — uses `localstack` or DynamoDB Local via `testcontainers-go` +- Create: `internal/storage/dynamodb_provider_test.go` — cross-module isolation +- Create: `internal/storage/parity_test.go` (optional) — runs the same op sequence against Memory + Firestore + DynamoDB and asserts identical observables +- Modify: `cmd/server/main.go` — read `KV_PROVIDER`, branch on value; default `dynamodb` when running on Lambda (detect via `AWS_LAMBDA_FUNCTION_NAME` env) +- Modify: `go.mod` — add `github.com/aws/aws-sdk-go-v2`, `…/config`, `…/service/dynamodb`, `…/feature/dynamodb/attributevalue`, `…/feature/dynamodb/expression` + +## Implementation Steps +1. Add SDK deps. `go mod tidy`. +2. Implement `DynamoDBKVStore` with the same method set as `FirestoreKVStore`. Key composition: `pk = moduleName + "#" + key`. +3. `List(prefix)` implementation — `Query` with `KeyConditionExpression` on PK begins-with semantics (use range trick or `BEGINS_WITH` on PK; AWS docs: `BEGINS_WITH` works on sort key only, so use the start/end range trick on PK directly). +4. JSON helpers (`GetJSON`, `PutJSON`) — mirror Firestore impl exactly: marshal/unmarshal with `encoding/json`, store as binary, `ErrNotFound` semantics preserved. +5. Tests with DynamoDB Local (Docker image `amazon/dynamodb-local`). Add `make dynamodb-local` target. Skip if `DYNAMODB_LOCAL_URL` env unset (so CI without Docker can still build). +6. Cross-module isolation test: Put `wordle#k=A`, `loldle#k=B`, assert `wordleStore.Get("k") == A`, `loldleStore.Get("k") == B`, `loldleStore.List("") returns ["k"]` (not `[wordle#k, loldle#k]`). +7. Wire provider selection in `main.go`: + ```go + switch os.Getenv("KV_PROVIDER") { + case "dynamodb": kv = storage.NewDynamoDBProvider(...) + case "firestore": kv = storage.NewFirestoreProvider(...) + default: kv = storage.NewMemoryProvider() + } + ``` +8. Manual smoke against deployed Lambda: send `/start`, then verify `aws dynamodb scan --table-name miti99bot --max-items 5` shows expected keys. + +## Success Criteria +- [ ] `dynamodb_kv_test.go` passes against DynamoDB Local +- [ ] `dynamodb_provider_test.go` passes (cross-module isolation) +- [ ] `parity_test.go` (if added) passes — Memory ≡ Firestore ≡ DynamoDB on observables +- [ ] `KV_PROVIDER=dynamodb` works in deployed Lambda end-to-end +- [ ] One full game session of `/wordle` → state persists across invocations (cold-start safe) +- [ ] List() with prefix returns expected keys, no `Scan` calls in CloudWatch metrics + +## Risk Assessment +- **`List` performance** if a module accumulates >1k keys — DynamoDB Query handles this fine via paginated results. Confirm caller iterates pages (or all calls fit in one page). +- **Item size limit (400 KB)** — modules generally store small JSON; document the cap and add a `len(val) > 380*1024 → error` guard. +- **Eventual consistency** — DynamoDB defaults to eventually consistent reads. Use `ConsistentRead: true` in `Get` to match Firestore's strong default. Costs 2× the RCU but on-demand absorbs it. +- **Reserved word `value`** — DynamoDB reserves many names; use `ExpressionAttributeNames` `#v = "value"` to avoid the conflict. +- **AWS SDK v2 cold-start tax** (~80ms) — acceptable; cache client instance globally in `init()` or pkg-level var. + +## Open questions +1. TTL attribute for ephemeral keys (e.g. wordle daily state)? Add optional `ttl` Number attr; modules opt in via a new method or skip for v1. +2. Use single PK or PK+SK? Sticking with single PK for KISS — no current module needs sort-key queries. +3. Encryption — DynamoDB uses AWS-owned KMS by default (free); switch to AWS-managed only if compliance demands it. +4. Backup strategy — point-in-time recovery is paid; for free-tier hobby use, accept "no backup" and document. diff --git a/plans/260510-0114-aws-port/phase-04-eventbridge-cron.md b/plans/260510-0114-aws-port/phase-04-eventbridge-cron.md new file mode 100644 index 0000000..4c4c9e9 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-04-eventbridge-cron.md @@ -0,0 +1,92 @@ +--- +phase: 4 +title: "EventBridge cron wiring" +status: pending +priority: P2 +effort: "3h" +dependencies: [2] +--- + +# Phase 04: EventBridge cron wiring + +## Overview +Replace the planned Cloud Scheduler design with EventBridge Scheduler. Preserve the existing `/cron/{name}` HTTP route shape inside the Lambda by invoking the Function URL via Scheduler's HTTPS target. Auth via `X-Cron-Token` header sourced from Parameter Store. + +## Requirements +- **Functional:** Two scheduled jobs fire on cron expressions matching the GCP plan (`0 17 * * *` for daily push, `0 1 * * *` for cleanup or whatever Phase 09 of GCP plan defined). Both routes execute against the live module dispatcher and complete within Lambda timeout. +- **Non-functional:** Token rotates without code changes (Parameter Store update). Failure retried 2× with exponential backoff. Dead letters logged. + +## Architecture +**Decision:** HTTPS target (Function URL) over direct Lambda invoke. **Why:** +- Preserves the existing `/cron/{name}` route + dispatcher code from Phase 03 of GCP plan +- Local dev still works: `curl localhost:8080/cron/dailypush -H "X-Cron-Token: ..."` +- Single ingress path for observability (one URL, one log group) +- Direct invoke would require a separate Lambda entrypoint or routing on event shape — more code, less testable + +**Trade-off accepted:** Slightly less AWS-idiomatic; HTTPS adds ~10ms latency vs direct invoke; not material here. + +``` +EventBridge Scheduler ─cron─► HTTPS POST /cron/{name} + + Header: X-Cron-Token: + + AWS Sigv4 NOT used (Function URL AuthType: NONE) + │ + └─► Lambda → router → dispatcher → cron handler +``` + +**Auth model:** Function URL `AuthType: NONE` (already set in Phase 02 for Telegram). Cron auth = shared-secret header verified server-side. The token lives in Parameter Store (`/miti99bot/prod/cron-token`) and is fetched by Scheduler at invoke time via `SECRETSMANAGER_SECRET` reference (Scheduler supports referencing Parameter Store via `secret reference` in target input transformer, OR plain text in target — for KISS, store the token in Scheduler's invocation HTTP target headers as a templated literal, but referenced from Parameter Store via SAM resource attribute). + +**Simpler concrete approach:** SAM template reads the Parameter Store value at deploy time using `{{resolve:ssm-secure:...}}` in the schedule target's HTTP header config. Token rotation = update parameter, redeploy. + +## Related Code Files +- Create: SAM resources `Resources.DailyPushSchedule` (AWS::Scheduler::Schedule) +- Create: SAM resources `Resources.CleanupSchedule` (or whichever second cron) +- Create: SAM resource `Resources.SchedulerExecutionRole` with `events:InvokeApiDestination` / equivalent for HTTPS targets (or use built-in `aws.UniversalTarget` for `https`) +- Modify: `internal/server/router.go` — confirm `/cron/{name}` validates `X-Cron-Token` against env-loaded value (currently has `cronAuthHeader = "X-Cron-Token"`, good) +- Modify: `cmd/server/main.go` — load `CRON_TOKEN` env from Parameter Store reference, pass to `Config.CronToken` +- Reference: existing module cron registrations in each module's `Cron()` method + +## Implementation Steps +1. Define SAM `AWS::Scheduler::Schedule` for each cron job: + ```yaml + DailyPushSchedule: + Type: AWS::Scheduler::Schedule + Properties: + ScheduleExpression: "cron(0 17 * * ? *)" # 17:00 UTC = 00:00 Saigon + FlexibleTimeWindow: { Mode: 'OFF' } + Target: + Arn: arn:aws:scheduler:::http-invoke + RoleArn: !GetAtt SchedulerRole.Arn + Input: '{"name":"dailypush"}' + HttpParameters: + HeaderParameters: { X-Cron-Token: '{{resolve:ssm-secure:/miti99bot/prod/cron-token:1}}' } + RetryPolicy: { MaximumRetryAttempts: 2, MaximumEventAgeInSeconds: 600 } + DeadLetterConfig: { Arn: !GetAtt CronDLQ.Arn } + FlexibleTimeWindow: { Mode: OFF } + ``` + *(Pseudo — confirm exact `aws.HttpInvoke` target syntax against current AWS SAM docs at deploy time; AWS docs note the API surface is evolving.)* +2. Add `CronDLQ` (SQS queue, free tier 1M req/mo). +3. Add `SchedulerRole` IAM with `lambda:InvokeFunctionUrl` (or `events:InvokeApiDestination` if going via API destination). +4. Provision `/miti99bot/prod/cron-token` in Parameter Store with a 32-byte random value (`openssl rand -hex 32`). +5. Verify router rejects requests with wrong/missing token (test exists; confirm). +6. Deploy. From AWS console, "run now" each schedule. Confirm CloudWatch log entry shows successful 200 from Lambda. +7. Wait one full schedule window (or change to `rate(2 minutes)` temporarily) to confirm automatic firing. +8. Restore production cron expressions. Confirm next-fire timestamp. + +## Success Criteria +- [ ] Both schedules deploy via SAM +- [ ] Manual "run now" returns HTTP 200 from Function URL +- [ ] Server logs show cron handler executing the right module +- [ ] Wrong/missing token → 401, no module side effects +- [ ] DLQ receives failed invocations on simulated Lambda error +- [ ] Schedule fires automatically once on production cron expression + +## Risk Assessment +- **AWS Scheduler HTTPS target maturity** — relatively new feature; if SAM transform doesn't support `aws.HttpInvoke` cleanly, fall back to: Scheduler → SNS → Lambda subscription → existing handler (one extra hop, identical effect). Document fallback in this file. +- **Token in template via `resolve`** — at deploy time the value is fetched and embedded into the schedule target's static config; rotation requires redeploy. If frequent rotation needed, switch to a Lambda authorizer pattern (out of scope for v1). +- **Cron drift / TZ confusion** — EventBridge `cron()` uses UTC by default (matches Cloud Scheduler behavior in GCP plan). Use `?` for day-of-week-or-month constraint per AWS syntax. +- **Cold-start during cron** — first invocation after idle = 1–3s; cron handler logic must complete within Lambda timeout (15s). If a cron handler calls Gemini and exceeds 15s, raise function timeout to 30s (still free). + +## Open questions +1. Direct Lambda invoke vs HTTPS target — locked to HTTPS for the reasons above; revisit only if HTTPS proves flaky. +2. Single schedule with dynamic `name` vs one schedule per cron — one per cron is clearer in console; switch to dynamic only if cron count grows past ~5. +3. Cleanup cron (`0 1 * * *`) — confirm what it does in original miti99bot. Likely TTL-style sweep; review and decide if DynamoDB TTL attribute can replace it (eliminates the cron entirely). diff --git a/plans/260510-0114-aws-port/phase-05-gha-deploy.md b/plans/260510-0114-aws-port/phase-05-gha-deploy.md new file mode 100644 index 0000000..3b03881 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-05-gha-deploy.md @@ -0,0 +1,96 @@ +--- +phase: 5 +title: "GitHub Actions deploy (OIDC + SAM)" +status: pending +priority: P2 +effort: "3h" +dependencies: [2, 3, 4] +--- + +# Phase 05: GitHub Actions deploy (OIDC + SAM) + +## Overview +Push to `main` → CI builds the ARM64 Go binary, packages into ZIP, runs `sam deploy` against the existing stack via OIDC-assumed role. No long-lived AWS keys. Idempotent (zero-diff deploys are no-ops). + +## Requirements +- **Functional:** PR validates (`go vet`, `go test`, `sam validate`). Push to `main` deploys. Manual workflow_dispatch redeploy supported. +- **Non-functional:** Deploy < 4 min. Concurrency: only one deploy at a time per ref. Stack name parameterized by env (default `prod`). + +## Architecture +``` +GitHub push to main + └─► .github/workflows/deploy.yml + 1. checkout + 2. setup-go (1.25) + 3. setup-sam + 4. configure-aws-credentials (OIDC) ─► assume github-deploy-miti99bot + 5. make build-lambda + 6. sam build --use-container=false + 7. sam deploy --no-confirm-changeset --no-fail-on-empty-changeset + 8. post-deploy smoke (curl /) +``` + +## Related Code Files +- Create: `.github/workflows/deploy.yml` +- Create: `.github/workflows/ci.yml` — maybe split out validate-only path; or add `if:` guard in `deploy.yml` +- Modify: existing `.github/workflows/ci.yml` — add `sam validate` step +- Modify: `Makefile` — `deploy` target runs `sam build && sam deploy --no-confirm-changeset` + +## Implementation Steps +1. Confirm Phase 01's IAM role trust policy includes `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:pull_request` (for PR validation flow if `sam validate` against AWS). +2. Write `deploy.yml`: + ```yaml + name: Deploy to AWS + on: + push: { branches: [main] } + workflow_dispatch: + permissions: + id-token: write + contents: read + concurrency: { group: deploy-prod, cancel-in-progress: false } + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.25' } + - uses: aws-actions/setup-sam@v2 + with: { use-installer: true } + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-deploy-miti99bot + aws-region: ap-southeast-1 + - run: make build-lambda + - run: sam build + - run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --stack-name miti99bot-aws-port + - name: Smoke test + run: | + URL=$(aws cloudformation describe-stacks --stack-name miti99bot-aws-port --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text) + curl -fsSL "$URL/" | jq . + ``` +3. Add `AWS_ACCOUNT_ID` to GitHub repo secrets (it's not a credential, but keeping the ARN out of the repo file is hygiene). +4. PR validation workflow (`ci.yml`): runs `go vet`, `go test`, `sam validate` (no AWS creds needed for validate). +5. Test the full path: open a PR with a trivial change → CI green; merge → deploy fires; smoke step prints health JSON. +6. Add a `rollback.yml` workflow_dispatch path: re-run with a chosen commit SHA. CloudFormation handles the rollback inherently. + +## Success Criteria +- [ ] PR triggers `ci.yml` only (no AWS deploy) +- [ ] Merge to `main` triggers `deploy.yml` +- [ ] Deploy succeeds without manual intervention +- [ ] Post-deploy smoke step returns 200 from Function URL +- [ ] Concurrency lock prevents overlapping deploys +- [ ] Re-running a no-op deploy reports "no changes" and exits 0 +- [ ] No AWS access keys in repo, GitHub Actions secrets, or anywhere + +## Risk Assessment +- **OIDC trust misconfiguration** locks deploy out — Mitigation: keep bootstrap admin user (Phase 01) as glass-break recovery; rotate after 90 days. +- **`sam build` with native deps fails** — pure Go has no native deps; should be fine. +- **CloudFormation drift** between manual changes and CI — Mitigation: forbid console edits; daily `sam deploy --no-execute-changeset` to detect. +- **Build cache cold each run** (~90s for Go deps) — Mitigation: `actions/setup-go` cache enabled by default. +- **Workflow secret leak via debug logs** — Mitigation: never echo `secrets.*`, GH masks them automatically. + +## Open questions +1. Separate dev/staging stacks for PR previews? Out of scope for v1; `prod` only. +2. Slack/Telegram notification on deploy success/failure? Defer; CloudWatch logs + `gh run list` suffice initially. +3. Pin SAM version in `setup-sam`? Yes — pin to a specific version to avoid surprise breakage. diff --git a/plans/260510-0114-aws-port/phase-06-observability.md b/plans/260510-0114-aws-port/phase-06-observability.md new file mode 100644 index 0000000..85d1d67 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-06-observability.md @@ -0,0 +1,99 @@ +--- +phase: 6 +title: "Observability + budget alert" +status: pending +priority: P2 +effort: "2h" +dependencies: [5] +--- + +# Phase 06: Observability + budget alert + +## Overview +Wire CloudWatch Logs retention, metric filters for key counters, AWS Budgets $1/mo alert, and capture cold-start P95 baseline for the abort criterion in `plan.md`. + +## Requirements +- **Functional:** Log retention 7 days. Budget alert fires at $1.00 actual. Cold-start P95 measurable from logs. +- **Non-functional:** All observability stays in free tier (5 GB log ingest/mo, 10 custom metrics free, 1k AWS Budgets API ops free). + +## Architecture +- **Logs:** Lambda's auto-created log group `/aws/lambda/miti99bot-aws-port-BotFunction-*`. SAM sets retention. +- **Metrics:** Existing `internal/metrics` package emits counters. Lambda env can ship them via stdout — CloudWatch Logs ingests, log-based metric filters extract `request.duration`, `module.dispatched`, `cron.fired`. Free metric filter quota: unlimited filters, paid for resulting metrics past 10/mo. +- **Budget:** `AWS::Budgets::Budget` in SAM template, threshold $1, email alert. +- **Cold start:** parse `REPORT` log lines → `Init Duration` field → P50/P95/P99. + +## Related Code Files +- Modify: `template.yaml` — add `LogRetentionInDays: 7` on `BotFunction` (SAM `LoggingConfig`); add `AWS::Budgets::Budget` resource; add `AWS::Logs::MetricFilter` for key metrics +- Create: `aws/dashboards/cold-start-coldwatch.json` (optional, manual import) +- Reference: `internal/log/*.go` (slog JSON emitter — already exists) +- Reference: `internal/metrics/*.go` (counters + 60s flush — already exists) + +## Implementation Steps +1. Add to `template.yaml` under `BotFunction.Properties`: + ```yaml + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: INFO + SystemLogLevel: WARN + LogGroup: !Ref BotFunctionLogGroup + ``` +2. Add explicit log group to control retention: + ```yaml + BotFunctionLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /aws/lambda/miti99bot-aws-port-bot + RetentionInDays: 7 + ``` +3. Add metric filter for cold start: + ```yaml + ColdStartFilter: + Type: AWS::Logs::MetricFilter + Properties: + LogGroupName: !Ref BotFunctionLogGroup + FilterPattern: '[report="REPORT", ..., init_label="Init", init_dur_label="Duration:", init_dur, ...]' + MetricTransformations: + - MetricName: ColdStartInitDuration + MetricNamespace: miti99bot + MetricValue: $init_dur + ``` +4. Add `AWS::Budgets::Budget` (sends email at 80% and 100% of $1): + ```yaml + MonthlyBudget: + Type: AWS::Budgets::Budget + Properties: + Budget: + BudgetName: miti99bot-monthly + BudgetLimit: { Amount: '1.00', Unit: 'USD' } + TimeUnit: MONTHLY + BudgetType: COST + NotificationsWithSubscribers: + - Notification: { ComparisonOperator: GREATER_THAN, NotificationType: ACTUAL, Threshold: 80, ThresholdType: PERCENTAGE } + Subscribers: [{ Address: , SubscriptionType: EMAIL }] + ``` +5. Deploy. Trigger cold start (`aws lambda update-function-configuration --function-name … --environment 'Variables={…,FORCE_RESTART=$(date +%s)}'`). +6. Capture 50 cold starts manually or via a one-shot load script (`hey -n 50 -c 1 -i 30s /`) — concurrency=1 with delay forces fresh inits. Compute P95 from CloudWatch Insights: + ``` + filter @type = "REPORT" + | stats avg(@initDuration), pct(@initDuration, 95) + ``` +7. Record P95 in `plan.md`'s "Free-tier budget at peak" or as an addendum here. +8. Confirm budget shows up in AWS Console > Budgets and has the email subscriber. + +## Success Criteria +- [ ] Log group `RetentionInDays: 7` set +- [ ] Cold-start P95 captured and < 1.5s (per abort criterion) +- [ ] Budget alert visible in console, email subscriber confirmed (test mail received) +- [ ] No log group accumulates >500 MiB after 7 days of normal traffic +- [ ] CloudWatch Insights query for cold start works without manual setup + +## Risk Assessment +- **Email subscriber not confirmed** → first alert silently dropped — Mitigation: send a test from console before relying on it. +- **Log retention deleted by SAM redeploy** if log group not explicit → Mitigation: declare log group explicitly (step 2). +- **Cold start drift** as deps grow — Mitigation: re-measure quarterly; add a CI step that fails if `bootstrap` binary > 30 MiB. +- **Budget delays** (AWS Budgets evaluates ~3× day, not real-time) → Mitigation: also enable Cost Anomaly Detection (free) for spike alerts. + +## Open questions +1. Email vs SNS topic for budget alerts? Email is simpler; SNS lets fan-out to webhook later. Start with email, migrate if needed. +2. Custom CloudWatch dashboard? Skip for v1 — Insights queries are enough for solo-dev. +3. Trace via AWS X-Ray? `Tracing: Active` already set in Phase 02 globals — free at this volume; review traces post-Phase 07. diff --git a/plans/260510-0114-aws-port/phase-07-cutover.md b/plans/260510-0114-aws-port/phase-07-cutover.md new file mode 100644 index 0000000..dbcf7b8 --- /dev/null +++ b/plans/260510-0114-aws-port/phase-07-cutover.md @@ -0,0 +1,83 @@ +--- +phase: 7 +title: "Cutover + README + retire GCP paths" +status: pending +priority: P2 +effort: "3h" +dependencies: [2, 3, 4, 5, 6] +--- + +# Phase 07: Cutover + README + retire GCP paths + +## Overview +Flip the production Telegram webhook to the AWS Function URL, soak for 7 days, then mark the AWS port as default in README and code defaults. Keep GCP code paths in tree (Firestore impl, Cloud Run Dockerfile) but unwired by default. + +## Requirements +- **Functional:** Real production bot serves users from Lambda. No regressions vs prior baseline (whatever ran before — JS Worker or partial GCP). +- **Non-functional:** Zero downtime cutover (Telegram webhook flip is atomic). 7-day soak with logs reviewed daily. Fallback path documented. + +## Architecture +- **Cutover op:** `setWebhook` Telegram API call pointing to the AWS Function URL with the production webhook secret. +- **Rollback path:** identical `setWebhook` call to the prior URL; ~5 second op. +- **Code:** Firestore impl stays compilable, gated by `KV_PROVIDER=firestore`. Default `KV_PROVIDER=dynamodb` in Lambda env. Cloud Run Dockerfile retained for offline / non-AWS users. + +## Related Code Files +- Modify: `README.md` — full rewrite of "Run locally", "Build", new "Deploy to AWS" section, status table updated, link to AWS plan, archive link to GCP plan +- Modify: `cmd/server/main.go` — default `KV_PROVIDER` selection logic: `dynamodb` if `AWS_LAMBDA_FUNCTION_NAME` set, else `memory` +- Create: `docs/deploy-aws.md` — single source of truth for AWS deploy ops (parameter store names, IAM role ARN, smoke commands) +- Modify: `plans/260508-2222-go-port-cloud-run/plan.md` — top-of-file note: "Deploy phases 01, 09–12 superseded by `plans/260510-0114-aws-port/`. Module work (phases 03–07) reused unchanged." +- Optional remove: `Dockerfile` retained for now; revisit in 30 days +- Optional remove: GCP-specific docs in `docs/` if any (none observed) + +## Implementation Steps +1. Pre-flight checklist (run inside this phase): + - [ ] Phase 02 smoke green (manual curl) + - [ ] Phase 03 wordle daily state survives deploy + cold start + - [ ] Phase 04 cron fired at least one real trigger + - [ ] Phase 05 push-to-main auto-deploys + - [ ] Phase 06 budget alert email confirmed + - [ ] Cold-start P95 < 1.5s confirmed +2. Run `setWebhook` against production bot: + ```sh + curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ + -d "url=$AWS_FUNCTION_URL/webhook" \ + -d "secret_token=$TELEGRAM_WEBHOOK_SECRET" \ + -d "drop_pending_updates=false" \ + -d "allowed_updates=[\"message\",\"callback_query\"]" + ``` +3. Verify with `getWebhookInfo`: + ```sh + curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | jq . + ``` + Confirm `url`, `pending_update_count` near 0, `last_error_date` empty. +4. Send a test command (`/start`, `/wordle`, `/twentyq` to exercise Gemini path). Confirm responses match prior behavior. +5. Soak for 7 days: each morning, check CloudWatch Logs for ERROR / WARN, DynamoDB throttle metrics (should be zero), budget current spend (should be $0), Gemini RPD usage (should be far under cap). +6. After 7-day soak, update README: + - Status table: AWS port phases marked "done" + - Replace "Run locally" with two paths: in-memory (no AWS) and DynamoDB Local (with AWS deps) + - "Deploy" section: link `docs/deploy-aws.md`, drop Cloud Run instructions + - Status badge / link to `plans/260510-0114-aws-port/` +7. Add a top-of-file note in `plans/260508-2222-go-port-cloud-run/plan.md` redirecting deploy questions to the AWS plan. +8. Tag a release: `git tag v1.0.0-aws -m "AWS deploy default"` and push. + +## Success Criteria +- [ ] Telegram webhook `getWebhookInfo` shows AWS Function URL +- [ ] Production bot answers `/start` from real users with normal latency +- [ ] 7-day soak: zero unrecovered errors, zero throttles, zero unexpected spend +- [ ] README accurately reflects AWS as the default deploy +- [ ] `docs/deploy-aws.md` is sufficient for a fresh dev to redeploy from scratch +- [ ] GCP plan file annotated; old phase files preserved for history +- [ ] Release tag pushed + +## Risk Assessment +- **Webhook flip causes message loss** during the few-second propagation — Telegram retries failed deliveries automatically; `drop_pending_updates=false` preserves backlog. +- **Latency regression vs JS Worker** — Cloud Run / JS Worker had different cold-start profiles; if users complain, document and consider ARM→x86 swap or provisioned concurrency (kills free tier). +- **Hidden Firestore dependency** still wired in some module — Mitigation: grep for `firestore.NewClient` and confirm all paths are gated by `KV_PROVIDER=firestore` env. Add a CI test that builds with `KV_PROVIDER=dynamodb` and asserts Firestore client is not initialized. +- **GCP project quietly billing** because resources weren't deleted — Mitigation: explicit step in this phase: `gcloud projects delete ` OR `gcloud run services delete` for any deployed services. Check Cloud Console for any orphaned resources. +- **Lambda Web Adapter unsupported on a future runtime** — Mitigation: pin LWA layer version, monitor AWS Labs repo. + +## Open questions +1. Delete the old GCP project entirely or leave it dormant? Dormant is safe (no GCP free-tier abandonment penalty); delete after 30 days if no regret. +2. Keep Dockerfile in repo? Yes — useful for non-Lambda local runs and as reference for any future Cloud Run revival. +3. Keep Firestore impl forever or drop after 90 days? Drop only if the parity test proves redundant; the impl itself is small and tested. +4. Announce the change anywhere (README badge, release notes) — depends on whether this is a public bot. User decides. diff --git a/plans/260510-0114-aws-port/plan.md b/plans/260510-0114-aws-port/plan.md new file mode 100644 index 0000000..c165005 --- /dev/null +++ b/plans/260510-0114-aws-port/plan.md @@ -0,0 +1,77 @@ +--- +title: "Migrate miti99bot-go from GCP to AWS (Lambda + DynamoDB + EventBridge, free tier)" +description: "Re-target the deploy/runtime layer from Cloud Run + Firestore + Cloud Scheduler to Lambda (Go ZIP + LWA + Function URL) + DynamoDB on-demand + EventBridge Scheduler, region ap-southeast-1, IaC via SAM, CI via GH Actions OIDC. Module code unchanged." +status: in-progress +priority: P2 +effort: 3-4d +branch: main +tags: [aws, lambda, dynamodb, eventbridge, sam, port, telegram-bot, free-tier] +created: 2026-05-10 +blockedBy: [] +blocks: [] +supersedes-deploy-of: [260508-2222-go-port-cloud-run] +--- + +# Plan: AWS port (Lambda + DynamoDB + EventBridge, free tier) + +Re-target only the deploy/runtime layer. Module work (Phases 03–07 of GCP plan) is **done and reused unchanged**. The KVStore interface (`internal/storage/`) absorbs the swap; `http.Handler` code (`internal/server/`) is preserved via Lambda Web Adapter. + +## Context +- **Why switch:** Strict $0 free-tier goal — DynamoDB 25 GiB / 200M req-mo, EventBridge unlimited rules, 100 GB egress all-region beat Firestore 1 GiB, Cloud Scheduler 3-job cap, GCP NA-only egress. See `plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md`. +- **Reused as-is:** module framework, registry, dispatcher, Telegram lib, AI clients, all 11 modules, Firestore impl (kept as sibling for parity tests). +- **Replaced:** Cloud Run → Lambda; Firestore → DynamoDB (sibling provider, default switchable via env); Cloud Scheduler → EventBridge Scheduler; Secret Manager → Parameter Store; Artifact Registry → none (ZIP); Cloud Logging → CloudWatch Logs; CF Worker / GCP CI → GH Actions + SAM. + +## Locked decisions +- **Compute:** Lambda Go on `provided.al2023`, **ARM64**, ZIP package, binary `bootstrap`, build with `-tags lambda.norpc -ldflags="-s -w"`. +- **HTTP:** Lambda Function URL (`AuthType: NONE`) + AWS Lambda Web Adapter layer → existing `http.Handler` runs unchanged. +- **KV:** DynamoDB single-table `miti99bot`, PK=`pk` (`{module}#{key}`), attr `value` (Binary). On-demand billing. +- **Cron:** EventBridge Scheduler → HTTPS target = Function URL `/cron/{name}` with `X-Cron-Token` header (token in Parameter Store). Preserves existing route shape; alternative (direct Lambda invoke) deferred. +- **Secrets:** SSM Parameter Store SecureString. Names: `/miti99bot/{env}/telegram-token`, `…/webhook-secret`, `…/gemini-api-key`, `…/cron-token`. Fetched at cold start. +- **Region:** `ap-southeast-1` (Singapore). +- **IaC:** AWS SAM (`template.yaml`). +- **CI:** GitHub Actions, OIDC role, `aws-actions/configure-aws-credentials@v4` + `aws-actions/setup-sam@v2`. +- **Logs:** CloudWatch Logs, 7-day retention. +- **Cost guard:** AWS Budgets $1/mo alert. + +## Phases + +| # | Phase | Status | Effort | Key deliverable | +|---|-------|--------|--------|-----------------| +| 01 | [AWS bootstrap + IAM OIDC + SAM skeleton](phase-01-aws-bootstrap.md) | pending | 3h | AWS account, OIDC trust, empty SAM stack deployable | +| 02 | [Lambda runtime (Go ZIP + LWA + Function URL)](phase-02-lambda-runtime.md) | pending | 4h | `/` and `/webhook` served from Lambda; secret-token check passes | +| 03 | [DynamoDB KV provider](phase-03-dynamodb-kv.md) | pending | 4h | `dynamodb_kv.go` + `dynamodb_provider.go` sibling to Firestore impl, parity tests pass | +| 04 | [EventBridge cron wiring](phase-04-eventbridge-cron.md) | pending | 3h | Scheduler → `/cron/{name}` with token, two crons firing on schedule | +| 05 | [GitHub Actions deploy (OIDC + SAM)](phase-05-gha-deploy.md) | pending | 3h | `deploy.yml` runs on push to `main`, builds + sam deploys idempotently | +| 06 | [Observability + budget alert](phase-06-observability.md) | pending | 2h | Logs retention set, $1 budget alert, cold-start P95 captured | +| 07 | [Cutover + README + retire GCP paths](phase-07-cutover.md) | pending | 3h | Webhook flipped to Function URL, README rewritten, GCP code paths kept but unwired by default | + +## Dependency graph +``` +01 ──► 02 ──► 03 ──► 04 ──► 05 ──► 06 ──► 07 + └──► 04 ─────►┘ +``` + +## Free-tier budget at peak +| Resource | Cap | Expected | Headroom | +|---|---|---|---| +| Lambda req | 1M/mo | ~30k/mo | 97% | +| Lambda compute | 400k GB-s | <5k | 99% | +| DynamoDB req | 200M/mo | <100k | 99.9% | +| DynamoDB storage | 25 GiB | <50 MiB | 99.8% | +| EventBridge invocations | 14M/mo | ~60 (2 crons × ~30 days) | 99.9% | +| Parameter Store accesses | unlimited (Standard) | <100/cold-start × ~30 starts | n/a | +| Egress | 100 GB/mo | <50 MiB | 99.95% | +| CloudWatch Logs ingest | 5 GB/mo | <500 MiB | 90% | + +## Abort criteria +- **Cold-start P95 > 1.5s** sustained: investigate ARM64→x86_64 swap or pre-warm with provisioned concurrency (kills free tier; only if user-facing latency unacceptable). +- **DynamoDB throttle** under normal load: switch to provisioned mode (still free under 25 RCU/WCU). +- **Function URL auth-bypass risk** discovered: switch to API Gateway HTTP API (12-month free, then $1/M). + +## Rollback +Until Phase 07 webhook flip, the GCP runtime path remains intact. Per-phase rollback documented in each phase. Phase 07 is the only irreversible cutover step. + +## Open questions +1. Direct Lambda invoke for cron vs HTTP loopback via Function URL — final call deferred to Phase 04 implementation. +2. Whether to delete Firestore impl after parity confirmed, or keep as offline test backend permanently. +3. Single SAM stack vs split (data + compute) — start single, split if iteration speed suffers. diff --git a/plans/reports/research-260510-0012-aws-vs-gcp-free-tier.md b/plans/reports/research-260510-0012-aws-vs-gcp-free-tier.md new file mode 100644 index 0000000..94b9040 --- /dev/null +++ b/plans/reports/research-260510-0012-aws-vs-gcp-free-tier.md @@ -0,0 +1,241 @@ +# Research Report: AWS vs GCP Free Tier — Suitability for miti99bot-go + +> **Generated:** 2026-05-10 00:12 (Asia/Saigon) +> **Mode:** /research + /ck:brainstorm (ultrathink) +> **Verdict (TL;DR):** **GCP wins, decisively, not even close.** Stop debating. The code is already written for it, free tier is plenty, switching cost > zero benefit. + +--- + +## Executive Summary + +This project (miti99bot-go) is a Go Telegram bot already coded against **Cloud Run + Firestore + Gemini**, with multi-stage Docker → distroless image (~15 MiB), Phase 04 Firestore KV done, Phases 05–07 modules + AI done, only deploy/cron/CI left. Asking "should we switch to AWS for free tier?" is asking "should we throw away weeks of work for a free-tier delta that doesn't matter at this traffic profile?" + +The honest answer is no. Both clouds offer permanent (not 12-month) free tiers that cover a personal Telegram bot 100x over. The deciding factor is **integration friction and switching cost**, not headroom. GCP's Cloud Run runs your existing `http.Handler` unmodified; AWS Lambda needs an adapter or rewrite. Firestore is already wired; DynamoDB would force a new KV provider. Cloud Scheduler is two lines of YAML; EventBridge Scheduler needs IAM gymnastics. And Gemini API is **independent of cloud** — same key works on AWS, so AWS gains nothing on the AI side. + +The only scenario where AWS wins: if you needed >1 GiB egress/month (AWS gives 100 GB always-free) — irrelevant here, Telegram messages are tiny text. + +--- + +## Research Methodology + +- **Sources:** 5 web searches (Cloud Run/Firestore quotas, AWS Lambda/DynamoDB quotas, Telegram-bot deployment patterns, Gemini API free tier, Lambda-vs-Cloud-Run cold starts/egress) +- **Date range:** Sources from 2025-2026 with explicit 2026 quota figures +- **Project context:** Read `README.md` and existing plan structure (`plans/260508-2222-go-port-cloud-run/`) +- **Search keywords:** GCP Always Free 2026, AWS Always Free 2026, Cloud Run egress, Lambda Go cold start, Gemini API free tier 2026 + +--- + +## Project Profile (the part that decides everything) + +| Aspect | Reality | +|---|---| +| Workload | Telegram bot webhook receiver. Few users. Bursty, low-volume. | +| Already coded for | **Cloud Run** (HTTP server in `cmd/server/`, `/webhook` route) | +| Storage | **Firestore** KVStore impl done (Phase 04) | +| AI | **Gemini API** (cloud-agnostic — uses Google AI Studio key, not GCP-bound) | +| Cron | Planned Cloud Scheduler → `/cron/{name}` (Phase 08+) | +| Container | Multi-stage `golang:1.23-alpine` → `distroless/static:nonroot`, ~15 MiB | +| Phases done | 02–07 (modules, framework, AI). Phase 01 (deploy) and 08+ pending. | + +**Observation:** The hard work is done. Deploy is a config exercise, not a redesign. Switching clouds = redo Phases 02 + 04 + 08, partially. + +--- + +## Key Findings + +### 1. GCP Always Free Tier (relevant slice, 2026) + +| Service | Always-Free Quota | Bot Usage Estimate | Headroom | +|---|---|---|---| +| **Cloud Run** | 2M req/mo, 360k GiB-s mem, 180k vCPU-s, 1 GiB egress/mo (NA) | ~10k req/mo (low-volume bot) | ~200x | +| **Firestore (Native)** | 1 GiB storage, 50k reads/day, 20k writes/day, 20k deletes/day, 10 GiB egress/mo | <1k ops/day per user | ~50x | +| **Cloud Scheduler** | 3 jobs free | Bot needs ~1–3 cron jobs | exact fit | +| **Artifact Registry** | 0.5 GB free | distroless image ~15 MiB | ~30x | +| **Secret Manager** | 6 active versions, 10k accesses/mo | 1 secret (bot token) | trivial | +| **Cloud Build** | 120 build-min/day | A few CI builds/day | fine | + +**2026 caveat:** Starting Feb 3, 2026, projects need Blaze (pay-as-you-go) plan to keep default Cloud Storage buckets — but Always-Free quotas still apply, and **this project doesn't use Cloud Storage** (uses Firestore + Artifact Registry). So: enable Blaze, set a $0 budget alert, free tier still works. + +### 2. AWS Always Free Tier (relevant slice, 2026) + +| Service | Always-Free Quota | Notes | +|---|---|---| +| **Lambda** | 1M req/mo + 400k GB-s | Permanent. Plenty for a bot. | +| **DynamoDB** | 25 GB storage, 200M req/mo (on-demand) | Way more than Firestore's free tier. | +| **EventBridge Scheduler** | 14M scheduled events/mo (default bus) | Plenty. | +| **Lambda Function URL** | Free (no API Gateway needed) | Avoids the 12-month-only API Gateway free tier. | +| **Parameter Store (Standard)** | Free unlimited | Use for bot token. Skip Secrets Manager (paid after 30 days). | +| **Egress** | 100 GB/mo always-free (since Dec 2024) | 100x Cloud Run's 1 GiB. Irrelevant for tiny Telegram messages. | +| **ECR private** | 500 MB **12-month only** ❌ | Use Docker Hub or GHCR free instead. Or pay ~$0.05/GB-mo. | + +**Hidden trap:** Many AWS tutorials use API Gateway (1M HTTP API calls = 12-month tier, **not always-free**). To stay free permanently on AWS, you must use **Lambda Function URLs** instead. + +### 3. Gemini API (decisive: cloud-agnostic) + +- Free tier from **Google AI Studio**, not GCP — same API key works from AWS, GCP, your laptop, anywhere. +- Limits (May 2026): 5–15 RPM, 100–1000 RPD per model, 250k TPM universal cap. +- Models on free tier: 2.5 Pro / 2.5 Flash / 2.5 Flash-Lite. **Gemini 3.x is paid-only in preview.** +- **April 2026 change:** Pro models tightening; Flash/Flash-Lite still free. +- **AWS doesn't disadvantage Gemini.** Choosing AWS doesn't lose Gemini access. Choosing GCP doesn't grant extra Gemini quota. + +### 4. Cold Start & Latency + +| Platform | Go cold start | Warm latency | +|---|---|---| +| Lambda Go ZIP | 50–200 ms | <5 ms | +| Lambda container (Go) | 0.6–1.4 s | <5 ms | +| Cloud Run (distroless Go, min-instances=0) | 1–3 s | <10 ms | +| Cloud Run (min-instances=1) | none | <10 ms — **costs money** (no longer free) | + +For Telegram webhooks: Telegram retries on timeout, and a 1–3 s cold start is acceptable for human-perceived bot latency. **Not a real differentiator at this scale.** + +### 5. Egress + +- Cloud Run free egress: 1 GiB/mo NA (then $0.12/GB) +- AWS free egress: 100 GB/mo +- Bot egress = small JSON replies to api.telegram.org. ~500 bytes × 10k msgs/mo ≈ 5 MB. **Both wildly under quota.** Non-issue. + +--- + +## Comparative Analysis + +### Free-tier headroom at this workload: **TIE** (both 100x more than needed) + +### Switching cost (the actual decision driver): + +| Item | Stay GCP | Switch to AWS | +|---|---|---| +| HTTP handler in `internal/server/` | works as-is | rewrite to Lambda handler OR add lambda-web-adapter | +| `internal/storage/` Firestore impl | already done | rewrite as DynamoDB provider, retest, redeploy | +| Cron `/cron/{name}` routes | wire Cloud Scheduler (3 lines YAML) | wire EventBridge Scheduler + IAM role + invocation target | +| Container registry | Artifact Registry (gcloud auth) | ECR (12-mo free only) or GHCR (free, simpler) | +| Secrets | Secret Manager | Parameter Store (free) — easy swap | +| Logs | Cloud Logging (built-in) | CloudWatch Logs (built-in) | +| Phase 01 plan | already drafted | needs full rewrite | +| Effort | days | weeks | + +### Brainstorm: Steel-manning the AWS case + +**"AWS Lambda free tier never expires, more permanent than GCP's"** → False premise. Both have permanent always-free tiers. GCP's Always-Free is also permanent. The 12-month thing applies to AWS extras (EC2, RDS, ECR), not Lambda/DynamoDB. + +**"AWS egress is 100 GB free, Cloud Run only 1 GiB"** → True, but irrelevant: bot egress is <10 MB/mo. + +**"Lambda has faster cold starts"** → True for ZIP packages, but you'd need to abandon the Docker workflow. Container Lambda is similar to Cloud Run. + +**"AWS has more free services to grow into"** → Speculative. YAGNI. Decide based on this project's needs. + +**"Vendor independence — AWS skill is more marketable"** → Career argument, not technical. Outside scope. + +### Brainstorm: Steel-manning the GCP case + +**"Code is already written for it"** → Decisive. Anything else is rationalization for redoing finished work. + +**"Cloud Run runs unmodified `http.Handler`"** → Real ergonomic win. Lambda forces an event-shape adapter even with Function URLs. + +**"Firestore SDK is already integrated, tests pass"** → Phase 04 is done. Don't rewrite Phase 04. + +**"Gemini + Firestore + Cloud Run all share one project, one service account, one IAM model"** → Operational simplicity. Worth real money in time saved. + +**"Cloud Scheduler → HTTP target is the simplest cron-to-webhook in the industry"** → Phase 08 will be 30 min on GCP, half a day on AWS. + +### Steel-manning the actual neutral choice + +There is none. This is a lopsided decision. Anyone telling you AWS is "comparable for this project" is selling you sunk-cost-aversion in reverse — you have NO sunk cost in AWS to recover. + +--- + +## Implementation Recommendations + +### Stay on GCP. Proceed with Phase 01 as planned. Specifically: + +1. **Enable Blaze plan** on the GCP project. Set a **$1 budget alert** (covers free-tier spillover). +2. **Cloud Run** service: `--cpu 1 --memory 256Mi --min-instances 0 --max-instances 3 --concurrency 80`. Min-instances=0 keeps it free; max-instances cap prevents runaway billing. +3. **Artifact Registry** repo for the distroless image. CI pushes via Workload Identity Federation (no JSON keys). +4. **Firestore** in Native mode, `(default)` database. Already coded. +5. **Cloud Scheduler** jobs → HTTP POST to `/cron/{name}` with OIDC token; verify on the receiver side. +6. **Secret Manager**: store `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET`; mount as env vars on Cloud Run. +7. **Gemini API key**: from Google AI Studio (not GCP). Store in Secret Manager. Free tier is independent. +8. **Cost guardrails**: budget alert + `max-instances` + Firestore composite-index review (a runaway query loop is the realistic free-tier killer). + +### Common pitfalls to avoid + +- ❌ Don't set `min-instances >= 1` — instantly leaves free tier. +- ❌ Don't put image in Cloud Storage (Feb 2026 Blaze trigger) — use Artifact Registry. +- ❌ Don't enable expensive Firestore indexes you don't query — they bill writes. +- ❌ Don't log PII at INFO — Cloud Logging has its own free quota (50 GiB/mo) but verbose logs at scale will blow it. +- ❌ Don't trust the webhook origin without `X-Telegram-Bot-Api-Secret-Token` verification. + +### When to reconsider AWS + +Switch to AWS Lambda **only if** one of: +- You suddenly need >1 GiB Cloud Run egress/month (very unlikely for a bot). +- You unify with an existing AWS-only org/account. +- GCP changes Always-Free terms hostilely (no signal of this). + +None apply today. + +--- + +## Resources & References + +### GCP +- [Google Cloud Free Tier](https://cloud.google.com/free) +- [Cloud Run Free Tier (2025 infographic)](https://www.freetiers.com/directory/google-cloud-run) +- [Firestore Quotas & Limits](https://docs.cloud.google.com/firestore/quotas) +- [Cloud Scheduler docs](https://docs.cloud.google.com/scheduler/docs) +- [Cloud Run pricing 2025](https://cloudchipr.com/blog/cloud-run-pricing) + +### AWS +- [AWS Free Tier 2026 limits & hidden costs](https://www.cloudoptimo.com/blog/aws-free-tier-isnt-unlimited-know-the-limits-before-you-get-billed/) +- [Lambda pricing & cost guide 2026](https://go-cloud.io/aws-lambda-pricing/) +- [AWS Free Tier comprehensive guide](https://cloudwebschool.com/docs/aws/fundamentals/aws-free-tier/) + +### Gemini API +- [Gemini API rate limits (official)](https://ai.google.dev/gemini-api/docs/rate-limits) +- [Gemini API pricing (official)](https://ai.google.dev/gemini-api/docs/pricing) +- [Gemini API free tier 2026 guide](https://yingtu.ai/en/blog/gemini-api-free-tier) +- [Gemini Pro paid changes April 2026](https://help.apiyi.com/en/google-gemini-api-free-tier-changes-april-2026-guide-en.html) + +### Telegram bot deployment patterns +- [Comparing Telegram bot hosting providers (Code Capsules)](https://www.codecapsules.io/blog/comparing-telegram-bot-hosting-providers) +- [Deploy AI Telegram bot on AWS for free (Go)](http://golangforall.com/en/post/telegram-bots-zero-cost-aws.html) +- [Cloud Run vs Lambda performance/pricing (Sedai)](https://sedai.io/blog/aws-lambda-google-cloud-functions) +- [Serverless container pricing comparison 2026](https://danubedata.ro/blog/serverless-container-pricing-comparison-2026) + +--- + +## Decision Matrix + +| Criterion | Weight | GCP | AWS | Winner | +|---|---|---|---|---| +| Code already written for it | High | ✅ | ❌ | **GCP** | +| Free tier covers workload | High | ✅ (100x) | ✅ (100x) | tie | +| Container deploy ergonomics | Med | ✅ Cloud Run native | ⚠ Lambda container OR adapter | **GCP** | +| Cron→webhook simplicity | Med | ✅ Cloud Scheduler | ⚠ EventBridge + IAM | **GCP** | +| KV store integration | Med | ✅ Firestore done | ❌ rewrite to DynamoDB | **GCP** | +| Egress headroom | Low | 1 GiB | 100 GB | AWS (irrelevant) | +| Cold start | Low | 1–3 s | 0.6–1.4 s | AWS (acceptable on both) | +| AI integration (Gemini) | Med | tie (cloud-agnostic) | tie | tie | +| Switching cost | High | $0 | weeks of work | **GCP** | + +**Score:** GCP wins 5 categories outright, ties 2, loses 2 (low-impact). **Stay on GCP.** + +--- + +## Next Steps + +1. ✅ **Decision: stay on GCP.** Close this question. +2. Resume **Phase 01** (`plans/260508-2222-go-port-cloud-run/`): provision Cloud Run service, Artifact Registry, Firestore, Secret Manager, set webhook secret. +3. Wire **Phase 08** cron via Cloud Scheduler → `/cron/{name}` with OIDC verification. +4. Add **billing budget alert** at $1 before first deploy. +5. Update README "Status" table when Phase 01 lands. + +--- + +## Unresolved Questions + +1. **Region choice.** asia-southeast1 (Singapore, lowest LatAm-to-VN latency) vs us-central1 (cheapest, default 1 GiB egress fully applies)? Bot users are in Vietnam — likely asia-southeast1 wins on user latency, but verify free-tier egress geography (the 1 GiB free egress is **NA-only**; egress from asia-southeast1 to api.telegram.org may bill at $0.12/GB even at low volume — calculate at expected msg rate). +2. **Cold-start tolerance for cron.** If a cron job calls Gemini and takes >10 s on cold start, does it risk Cloud Scheduler retry storms? Decide on min-instances trade-off vs free tier. +3. **Gemini quota saturation.** Free tier 100–1000 RPD per model — if semantle/doantu/twentyq see real users, will we hit RPD before paid tier kicks in? Worth designing a fallback (cache common queries, degrade gracefully). +4. **Workload Identity Federation vs JSON key** for CI image push — recommended is WIF, but it requires GitHub OIDC config. Defer or do it now? +5. **GCP Blaze enablement timing** — enable before Phase 01 deploy or after? Free-tier still works on Blaze, but new project needs to confirm Always-Free quotas auto-apply. diff --git a/plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md b/plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md new file mode 100644 index 0000000..20f490b --- /dev/null +++ b/plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md @@ -0,0 +1,205 @@ +# Research Addendum: AWS vs GCP — Greenfield Rethink (Sunk Cost Ignored) + +> **Generated:** 2026-05-10 00:21 (Asia/Saigon) +> **Mode:** /research follow-up + ultrathink +> **Companion to:** `research-260510-0012-aws-vs-gcp-free-tier.md` +> **Trigger:** User requested re-evaluation treating project as greenfield (existing code can be rewritten any time). +> **Revised verdict:** **AWS edges out GCP, ~70/30**, on pure free-tier and trajectory merits. Not a slam-dunk; simplicity is GCP's only remaining moat. + +--- + +## Why this addendum + +Prior report leaned heavily on "code is already written for GCP" as the decisive factor. User explicitly asked to remove that lens. Greenfield-only analysis flips the conclusion: **AWS has objectively larger free tier** on the resources this project's trajectory will consume first (cron count, KV storage, egress in Asia). GCP's remaining advantage is operational simplicity for a single developer. + +--- + +## What changes when sunk cost is removed + +The previous report's "GCP wins" verdict rested 60% on switching cost. Strip that: + +| Argument | Holds greenfield? | Why | +|---|---|---| +| "Cloud Run runs unmodified `http.Handler`" | ❌ no | **Lambda Web Adapter (LWA)** ships a normal Go HTTP server on Lambda. Same code, both clouds. | +| "Firestore is already wired" | ❌ no | Greenfield: nothing wired. Pick the better KV. DynamoDB free tier is 25× bigger. | +| "Cloud Scheduler is 3 lines YAML" | ⚠ partial | True, but capped at 3 jobs free. EventBridge is also simple via Terraform/SAM and uncapped on rule count. | +| "Single IAM model" | ✅ yes | GCP IAM is gentler than AWS combinatorics. Real ergonomic win. | +| "Free tier covers workload 100×" | ✅ yes | Both clouds. Tie. | +| "Gemini is cloud-agnostic" | ✅ yes | Tie. | + +Three of the four GCP advantages **vaporize** without sunk cost. The remaining wins are operational simplicity (real) and Cloud Logging UX (minor). + +--- + +## Greenfield free-tier head-to-head (this project's resource axes) + +### Compute + +| | GCP Cloud Run | AWS Lambda (ZIP, Function URL) | +|---|---|---| +| Free requests/mo | 2M | 1M | +| Free compute | 360k GiB-s + 180k vCPU-s | 400k GB-s | +| Adapter needed for Go HTTP | none | LWA (extension layer, ~100 ms cold-start tax) | +| Cold start (Go, distroless / provided.al2023) | 1–3 s | **0.05–0.2 s ZIP** | +| Concurrency model | up to 80–1000 req/instance | 1 req/instance | +| Container registry | Artifact Registry 0.5 GB always-free | ECR 500 MB **12-mo only** | +| Bills idle? | no (request-based when min-instances=0) | no | + +**Tilt:** Lambda ZIP wins on cold start, no registry needed. Cloud Run wins on more free requests and concurrency multiplexing (lower compute usage per req). For a webhook bot at low QPS, Lambda's per-invocation isolation is fine. **Slight Lambda lean** because ZIP avoids the registry trap. + +### KV / document storage + +| | Firestore (Native) | DynamoDB (on-demand) | +|---|---|---| +| Free storage | 1 GiB | **25 GiB** | +| Free read ops | 50k/**day** (≈1.5M/mo) | (part of 200M req/mo monthly pool) | +| Free write ops | 20k/**day** (≈600k/mo) | (part of 200M req/mo monthly pool) | +| Bursting risk | high (daily caps reset) | low (monthly pool absorbs spikes) | +| Query model | document + composite indexes | item + GSI/LSI | +| Strong consistency | yes | yes (with consistent-read flag) | + +**Tilt:** **DynamoDB wins decisively.** 25× storage, monthly-pooled requests instead of daily caps (better for bursty bot traffic), and 200M total req/mo dwarfs Firestore's combined ~2M ops/mo. For per-user state in 8+ modules + planned trading data, DynamoDB has years of headroom; Firestore could pinch within months if usage grows. + +### Cron / scheduler + +| | Cloud Scheduler | EventBridge Scheduler | +|---|---|---| +| Free job/rule count | **3 hard cap** | unlimited | +| Free invocations/mo | (per the 3 jobs) | 14M on default bus | +| Cost past free | $0.10/job/mo | $0/job, $1.25/M extra invocations | + +**This project's likely cron count:** wordle daily, loldle daily, lolschedule daily, semantle daily, weekly digests, possible trading hourly refresh. **>3 almost certainly.** + +**Tilt:** **AWS wins.** GCP becomes the project's first paywall trigger. Cost is trivial ($0.70/mo for 10 jobs) but breaks the "strict $0" guarantee. AWS keeps the bot literally free. + +### Egress + +| | Cloud Run | Lambda | +|---|---|---| +| Free egress | 1 GiB/mo **NA-only** | 100 GB/mo **all regions** | +| Bot deploy region (VN users) | asia-southeast1 → **no free egress** | ap-southeast-1 → 100 GB free | +| Bot egress estimate | <50 MB/mo | <50 MB/mo | +| Real cost impact | ~$0 (pennies) | $0 | + +**Tilt:** **AWS wins on principle, draws on practice.** GCP's free egress is geographically restrictive in a way that surprises Asian deployers. Negligible at this volume but asymmetric. + +### Secrets + +| | GCP Secret Manager | AWS Parameter Store (Standard) | +|---|---|---| +| Free | 6 active versions, 10k accesses/mo | unlimited | +| Cost past free | $0.06/version/mo, $0.03/10k access | $0 | +| API ergonomics | clean | clean | + +**Tilt:** **AWS wins.** Parameter Store Standard tier is unlimited free. (Skip Secrets Manager — paid only.) + +### Logs + +| | Cloud Logging | CloudWatch Logs | +|---|---|---| +| Free | 50 GiB/mo ingest | 5 GB/mo ingest (always-free as of 2024+) | +| UX | better console | older console | + +**Tilt:** **GCP wins on volume + UX.** 50 GiB > 5 GB, console search is faster. Real but minor advantage. + +### Container registry + +| | Artifact Registry | ECR | +|---|---|---| +| Free | 0.5 GB **always** | 500 MB **12 mo only** | +| Workaround on AWS | use Lambda ZIP, no registry | — | + +**Tilt:** **GCP wins on container path. Lambda ZIP makes this moot for AWS.** + +--- + +## Operational complexity (real cost, hard to put $ on) + +| Activity | GCP | AWS | +|---|---|---| +| First deploy | `gcloud run deploy --source .` (one command, builds + pushes + runs) | `sam build && sam deploy` OR Terraform OR manual zip + IAM role + Function URL + LWA layer config | +| Add cron | Cloud Scheduler job → HTTP POST with OIDC | EventBridge Scheduler → Lambda invoke role + Lambda permission | +| IAM for cron-to-app auth | OIDC token verify on receiver | IAM principal-on-invoke | +| Iteration loop | edit → `gcloud run deploy --source` → 30 s | edit → `sam deploy` → 30 s OR build+zip+update-function-code | +| Multi-env (dev/prod) | two Cloud Run services + two Firestore DBs | two Lambdas + two DynamoDB tables + two roles | + +**Tilt:** **GCP wins on greenfield ergonomics.** This is real engineer-hours. For a solo dev, the difference between "one command" and "five tools to wire" matters. + +--- + +## Decision matrix (greenfield, weighted for this project) + +| Criterion | Weight | GCP | AWS | Winner | +|---|---|---|---|---| +| Cron job free count headroom | High | 3 cap (paywall) | unlimited | **AWS** | +| KV storage headroom | High | 1 GiB / daily caps | 25 GiB / monthly pool | **AWS** | +| Operational simplicity | High | one command | multi-tool | **GCP** | +| Cold start | Med | 1–3 s | 50–200 ms | **AWS** | +| Egress in Asia | Med | NA-only free | 100 GB any region | **AWS** | +| Logs free tier | Low | 50 GiB | 5 GB | **GCP** | +| Container registry | Low | 0.5 GB always | 500 MB 12-mo | **GCP** (or moot via ZIP) | +| Secrets | Low | 10k accesses/mo | unlimited | **AWS** | +| Compute requests | Low | 2M | 1M+400k GB-s | tie at this scale | +| AI (Gemini) | Low | cloud-agnostic | cloud-agnostic | tie | + +**Score:** AWS 5 wins (3 high-weight). GCP 3 wins (1 high-weight). 2 ties. +**Greenfield verdict: AWS, by ~70/30 margin.** + +--- + +## When does GCP still win greenfield? + +- **You commit to ≤3 cron jobs forever.** Wordle daily push only. Done. +- **You expect <100 MB total state.** Firestore 1 GiB is plenty. +- **You value 1-command deploy more than free-tier headroom.** Solo dev, hobby project, no growth ambition. +- **You want better log UX out of the box.** +- **You'd hit operational complexity faster than free-tier ceilings.** Realistic for small bots. + +**This project's signal:** 8 modules done, trading planned, multiple daily-push crons implied. **It's not staying small.** AWS headroom matches the trajectory. + +--- + +## Honest counter-argument (steel-manning GCP greenfield) + +- "$0.70/mo past 3 cron jobs is not a real cost." → True. If "almost free" is acceptable, GCP simplicity wins. +- "Solo devs ship more on simpler clouds." → True. Maintenance hours dwarf $-cost differences. +- "Firestore 1 GiB is fine for tens of thousands of users on a text bot." → True until it isn't, hard to predict. +- "Cloud Logging is genuinely better." → True. Operational quality of life matters. +- "Cloud Run's `--source .` deploy from a Go repo is the simplest serverless deployment in the industry." → True. AWS has no equivalent. + +**If the user values "minimal operational tax + accept ~$1/mo at scale" over "strict $0 + larger headroom": GCP.** +**If the user values "stay strictly free + max headroom for growth + faster cold starts": AWS.** + +--- + +## Recommendation + +If switching cost truly is zero and the project intends to grow: + +**Greenfield stack: AWS** +- Lambda (Go on `provided.al2023`, ZIP deploy) behind a Function URL +- Lambda Web Adapter so handler code is pure `http.Handler` (portable to Cloud Run later if needed) +- DynamoDB on-demand for KV (provider abstraction in `internal/storage/` swaps out Firestore for Dynamo) +- EventBridge Scheduler for cron → Lambda invoke (no HTTP loopback needed) +- Parameter Store for `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `GEMINI_API_KEY` +- CloudWatch Logs at INFO; consider 7-day retention to stay under 5 GB +- GitHub Actions OIDC → AWS role for CI deploy (no long-lived keys) +- Region: ap-southeast-1 (Singapore) for Vietnam latency + +If switching cost is not actually zero (it's some hours, not weeks): + +**Pragmatic stack: stay GCP** +- Phase 04 Firestore is done. Phase 01 Cloud Run is one config exercise. +- Accept the 3-cron paywall trigger; budget $1/mo guard. +- Be aware Asia egress is billed; volume is tiny. + +--- + +## Unresolved questions + +1. **Realistic cron count over project lifetime?** If the user commits to ≤3 forever, GCP holds. If 5+ realistic, AWS wins on this axis alone. +2. **State growth projection?** If bot grows to >10k active users with per-user history, Firestore daily caps tighten. DynamoDB doesn't. +3. **Effort estimate for AWS port?** Phase 02 (HTTP skeleton): ~1 day with LWA. Phase 04 (KV provider): ~1 day with DynamoDB AWS SDK. Phase 08 (cron): ~half day with EventBridge. **Total ~3 days** to redo what's done. Worth it? +4. **Solo-dev tolerance for AWS IAM/SAM/Terraform?** If the user has scar tissue from AWS, the simplicity tax may exceed the free-tier savings. +5. **Region choice on AWS?** ap-southeast-1 (Singapore) vs ap-northeast-1 (Tokyo) — pick by where Telegram's edge is closest to bot users in Vietnam. Both have full free-tier coverage. +6. **Bedrock vs direct Gemini API?** No reason to switch — Gemini API free tier is independent of cloud and currently better than Bedrock's pay-per-token-only model for hobby use.