CloudFormation's AWS::Scheduler::Schedule Target schema has no property
for HTTPS universal invocation (URL, method, headers) — confirmed
against AWS docs. Switch to the legacy EventBridge Rule path which
supports HTTP targets natively via ApiDestination:
- AWS::Events::Connection: API_KEY auth, presents X-Cron-Token header.
ApiKeyValue stored in EventBridge service-linked secret on stack
update (no per-invoke SSM fetch, AWS-managed secret fees).
- AWS::Events::ApiDestination: POST to ${FunctionUrl}cron/lolschedule_daily_push.
- AWS::Events::Rule: cron(0 1 * * ? *) — daily 01:00 UTC / 08:00 ICT.
Targets ApiDestination with retry x2, 600s max age, DLQ to CronDLQ.
- EventBridgeInvokeRole replaces SchedulerExecutionRole (events.amazonaws.com
principal, events:InvokeApiDestination scoped to this destination only).
NoEcho CronSharedSecret CFN parameter restored; GHA fetches the SSM
SecureString and passes via --parameter-overrides so the value never
appears in template source or stack events.
Free-tier preserved: 1 invocation/day, well under EventBridge Rules +
ApiDestinations free quotas.
9.0 KiB
type, date, slug, status, related
| type | date | slug | status | related | |
|---|---|---|---|---|---|
| code-review | 2026-05-18 | recent-changes-cron-regression | final |
|
Audit — Recent commits vs lolschedule_daily_push cron
TL;DR
Verdict: No regression. The cron was never wired in the first place.
The brainstorm at plans/reports/brainstorm-260517-1411-eventbridge-schedule-fix.md is correct: the AWS::Scheduler::Schedule resource did not exist on main until commit c70b9d0 (2026-05-18 11:46 +0700). Every commit in the user's "suspicion window" (1f5f304 → 8e7fdce) ran against a stack with zero EventBridge schedules. There was nothing for them to break.
Two commits after the suspicion window (c70b9d0, 585d996) execute the brainstorm. They are the path forward — not the cause of an outage.
Recommendation: PROCEED-WITH-FIX. Do not roll back. The fix is already on main (c70b9d0 + 585d996). What remains is deploy + post-deploy verification per the brainstorm's "Next steps".
Per-commit verdicts
| Hash | Subject | Verdict |
|---|---|---|
585d996 |
fix(deploy): pass cron secret via CFN parameter, not ssm-secure resolve | INNOCENT (corrects c70b9d0; required for sam deploy to succeed) |
c70b9d0 |
feat(deploy): wire EventBridge schedule for lolschedule daily push | INNOCENT (this is the fix — adds the missing schedule) |
8e7fdce |
feat(misc): /trongtruonghop disclaimer command | INNOCENT (touches internal/modules/misc only) |
3402ca9 |
refactor(plans): code-review audit reports | INNOCENT (docs-only, plans/reports/*) |
a8ed67a |
refactor: audit-driven hygiene pass | INNOCENT — see analysis below |
3a12615 |
fix(reply): forward message_thread_id |
INNOCENT (no cron-path edits; lolschedule/handlers.go change is /lolschedule command, not push) |
3235ee8 |
ci: bump golangci-lint-action v9 | INNOCENT (CI lint runner only) |
1e794d1 |
ci: bump actions to Node 24-native | INNOCENT (CI runner versions only) |
4c81dd6 |
fix(dispatcher): match /cmd@botname |
INNOCENT — see analysis below |
1f5f304 |
ci(deploy): auto-register Telegram webhook + commands after deploy | INNOCENT (post-deploy curl to Telegram setWebhook/setMyCommands; does not touch Scheduler) |
Closer look at the two commits with the largest blast radius
a8ed67a — audit-driven hygiene pass
This is the only commit in the window that touches cron-relevant Go files (cmd/server/main.go, internal/server/router.go, internal/server/log_middleware.go, internal/modules/lolschedule/cron.go). Every change reviewed:
cmd/server/main.go: WriteTimeout 6m → 75s. Lambda runtime ignoreshttp.Servertimeouts (Function URL closes the connection on its own 30s budget). Local-only effect. Cron handler internal deadline isdefaultCronTimeout(60s) — well inside 75s. NOT a regression.internal/server/router.gocron handler: rejection branches noww.WriteHeader(401|404|405)instead ofhttp.Error(..., "text", ...). Status codes preserved (EventBridge Scheduler only inspects status). Happy-path still returns200. Innerrecover()wrapsDispatchScheduledso a panic becomes a logged 500 instead of an opaque middleware-level recover. Cron name extraction (strings.TrimPrefix(r.URL.Path, "/cron/")) and regex (^[a-z0-9_]{1,32}$) unchanged. NOT a regression.internal/server/log_middleware.go: moves thereqlog line into adeferand addsrecoverPanicStatus. Purely additive observability; the chainLogRequests → cronHandleris unchanged. NOT a regression.internal/modules/lolschedule/cron.go: addsisTerminalSendError+pruneDeadSubscribersafter the existing fan-out.dailyPushCronName = "lolschedule_daily_push",dailyPushSchedule = "0 1 * * *",dailyPushHandler,runDailyPushall preserved. Pruning is best-effort and runs after the user-visible send loop — it cannot cause non-delivery. NOT a regression.
Red-team checks that came back clean:
cronAuthHeaderconstant name and value (X-Cron-Token) — unchanged.cronNameReregex — unchanged.defaultCronTimeout— unchanged.modules.Deps.Botfield — present (internal/modules/module.go:74);BuildOptions.Bot: bwiring incmd/server/main.go:108— present;dailyPushHandlernil-check atcron.go:88— present.cfg.CronSecretflow →server.New(... CronSecret: cfg.CronSecret)→cronHandler(reg, secret)— present (cmd/server/main.go:131).
4c81dd6 — dispatcher match-func swap
Touches internal/modules/dispatcher.go and internal/telegram/webhook.go. Both are on the Telegram update path, not the /cron/{name} HTTP path. modules.DispatchScheduled lives in a separate file (internal/modules/cron_dispatcher.go) and uses the cron registry, not the bot-handler registry. The cron HTTP route in internal/server/router.go:109 calls modules.DispatchScheduled directly — it never touches bot.RegisterHandlerMatchFunc. NOT a regression for cron.
Module-registry sanity check
cmd/server/main.go:43registers"lolschedule": lolschedule.Newin the factory map.template.yaml:16andsamconfig.toml:16both includelolscheduleinModulesCSVdefault. The deployed stack will instantiate the module.internal/modules/lolschedule/lolschedule.goregistersdailyPushCron(), which goes intoreg.Crons()(logged at startup:crons N).
The cron handler will fire when invoked. The only missing piece was the schedule, now added.
Recommendation
PROCEED-WITH-FIX. No rollback.
Action plan, in order:
- Confirm
c70b9d0+585d996are onmain(they are, as ofgit logat audit time). - Verify SSM parameter
/miti99bot/prod/cron-shared-secretexists and is non-empty before next deploy:aws ssm get-parameter --name /miti99bot/prod/cron-shared-secret --with-decryption. - Push triggers
.github/workflows/deploy.yml→ fetches secret →sam deploywithCronSharedSecret=...parameter override. - Post-deploy: AWS Console → EventBridge Scheduler →
${StackName}-lolschedule-daily-push→ Run now. Tail CloudWatch forcron triggered name=lolschedule_daily_push+lolschedule daily push complete sent=N. - Synthetic 401:
curl -X POST -H 'X-Cron-Token: wrong' <FunctionUrl>cron/lolschedule_daily_push→ expect HTTP 401 + logcron rejected reason=secret_mismatch. - Wait for the next 01:00 UTC fire and confirm auto-delivery.
If step 3 fails with a CFN error on HttpInvokeArgs, the brainstorm calls out a property-name iteration gate (candidates: HttpInvokeParameters, HttpParameters). The current HttpInvokeArgs is correct for current SAM transform per AWS docs at time of writing — but if it rejects, iterate.
Red-team angle — risks the brainstorm did not call out
| Risk | Severity | Note |
|---|---|---|
CronSharedSecret default is "" in template.yaml:53. If CI step that fetches the SSM secret runs but aws ssm get-parameter returns empty, set -euo pipefail will catch unset; but a blank SecureString value would pass through. Lambda would then start with CRON_SHARED_SECRET="" and cronHandler returns 404 on every request (router.go:cronDisabled). |
Med | Add pre-deploy guard: [ -n "$CRON_SECRET" ] || { echo "empty cron secret"; exit 1; } in the deploy step. |
EventBridge Scheduler IAM role uses Resource: !GetAtt BotFunction.Arn for lambda:InvokeFunctionUrl. AWS docs require the Function URL ARN form (...:function:name) — GetAtt BotFunction.Arn returns exactly that, so this is fine. Cross-check during sam validate. |
Low | Brainstorm got this right. |
Function URL AuthType: NONE is assumed (the cron handler does its own header-based auth). If AuthType: AWS_IAM were set, the scheduler invocation would 403 even with correct header. Verify in template.yaml BotFunctionUrl resource. |
Low | Already in brainstorm scope as "preserve route". |
Input: "{}" on the schedule: the Go cron handler does not parse a body, so any JSON-or-empty is fine. Confirmed by reading cronHandler in router.go. |
None | — |
Files cross-checked
template.yaml(lines 14–22 ModulesCSV; 40–55 CronSharedSecret param; 160–215 SchedulerRole + LolscheduleDailyPushSchedule)internal/modules/lolschedule/cron.go:1-145internal/modules/lolschedule/lolschedule.gointernal/modules/module.go:67-80(Deps + BuildOptions)internal/modules/cron_dispatcher.go:12-30(DispatchScheduled)internal/server/router.go:55-120(cronHandler)internal/server/log_middleware.gocmd/server/main.go:43, 100-135, 270-285(factories, BuildOptions, server.Config)samconfig.toml:16.github/workflows/deploy.ymldeploy step
Unresolved questions
- Has
sam deployactually been run since585d996landed? If not, the schedule does not yet exist in the deployed CloudFormation stack regardless ofmainstate. CI onmainpush should handle it; confirm CI run for585d996succeeded. - Is there pre-deploy validation that
aws ssm get-parameterreturned a non-empty value? (See red-team table row 1.) Worth adding a one-line guard.