feat: webhook mode for running on a PaaS (orkestr) - #8
Open
Schroedingberg wants to merge 6 commits into
Open
Conversation
Long-polling assumes an always-on worker; a container PaaS like orkestr hands the app a public HTTPS URL instead. Setting BBLEDGER_WEBHOOK_URL now flips ledger.main to webhook mode: an http-kit server (already transitive via martian-httpkit) that setWebhooks the URL and receives updates as POSTs, verifying Telegram's secret-token header and serializing them through the existing pipeline under a lock — same single-writer discipline as the long-polling consumer. Unset, nothing changes; the Hetzner/systemd deploy keeps long-polling. - deps.edn: pin http-kit + cheshire (ledger.main now requires them directly) - deploy/Dockerfile: EXPOSE 8080 for orkestr's port auto-detection - docs: orkestr deploy section + webhook env vars; persistence push-back from the container flagged as a follow-up (volume persists, off-site mirror does not) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
Webhook mode (previous commit) runs on a PaaS with no cloud-init/systemd, so the Hetzner persistence story didn't carry over: a fresh /data volume started empty (no rules) and entries never reached the private data repo. Bring the same two mechanisms into the container, opt-in via env: - clone-on-boot: deploy/provision.clj — a babashka entrypoint (this is a bb-first repo, so bb not shell) that installs the git-over-ssh deploy key and clones BBLEDGER_DATA_REPO into /data when the volume is empty, then p/exec's the passed command (so SIGTERM reaches the JVM, and the summary override flows through). No-op passthrough when the envs are unset (Hetzner). - push-back: store/push! (tolerant git push origin HEAD), invoked by ledger.main after each record/undo when BBLEDGER_GIT_PUSH is set — the in-process equivalent of the bbledger-push systemd path unit. Dockerfile: add the babashka static-musl binary for the target arch (the published babashka:-alpine image is amd64-only; the prod server is arm64, so fetch the arch-appropriate -static release), plus openssh-client/curl, and set the bb entrypoint keeping the clojure -M:bot CMD. Hetzner is untouched: it sets none of the new envs, so /data is already a repo (clone skipped) and push stays with the systemd unit. Verified: bb test + clojure -M:test (55 tests, both runtimes); a real image build + run confirms bb present, passthrough exec, and clone-on-boot from a mounted bare origin. New store_test cases cover push! to a bare remote and its tolerance of a missing remote. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
Exercises the actual webhook transport end-to-end without Telegram or a tunnel: a real http-kit server on an ephemeral port, real HTTP POSTs (JDK java.net.http), and the real store (temp git repo) + bot/core pipeline. Only Telegram's outbound client is stubbed (redef of make-request!), and the handler is driven directly so the networked setWebhook is skipped. Covers: an expense POST → 200 + a real "expense:" git commit + ✓ reply and delete sent; a /bal command answered; wrong/absent secret → 403 with nothing recorded; health GET → 200; foreign chat / unknown sender silently ignored (200); malformed body → 200 (no 5xx, which would make Telegram redeliver and risk double-booking). New namespace ledger.webhook-test is JVM-only (it requires ledger.main), so it runs under `clojure -M:test` (auto-discovered) but not `bb test` (explicit list) — the same split integration_test documents for main. Frozen test files are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
Bind the ephemeral http-kit server to 127.0.0.1 (http-kit defaults to 0.0.0.0) and rm -rf the temp git repo in the finally, so the test touches nothing outside its own transient state: localhost-only, Telegram stubbed, no leftover temp dirs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
The webhook integration test is the first test to load ledger.main -> clj-tg-bot-api, which uses Java 21 virtual threads (Thread.Builder). The CI test job pinned no JDK, so clojure -M:test ran on ubuntu-latest's default (older) JVM and hit ClassNotFoundException: java.lang.Thread$Builder. Pin Temurin 21 — the same JDK deploy/Dockerfile ships. bb test is unaffected (babashka is native and never loads ledger.main). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
Run the webhook bot with nothing but env vars — no volume, no data repo, no config file — for manual testing against real Telegram (with a separate throwaway @Botfather bot; the prod token is never touched): - config is layered: baked defaults (resources/config.default.edn) <- a BBLEDGER_CONFIG file if present (the real deploy's config.edn) <- per-field env overrides. The test-specific fields are plain env vars, no EDN in the environment: BBLEDGER_CHAT_ID and BBLEDGER_USERS ("id:Name,id:Name"). - provision.clj: with no BBLEDGER_DATA_REPO and a bare /data, seed a throwaway ledger from the bundled sample.ledger. Skipped on Hetzner (/data has .git) and on a real orkestr deploy (clone runs instead). - Dockerfile: bake sample.ledger for that seed (config.default.edn ships as a normal resource). - README: "throwaway / ephemeral test" note + the token-isolation caveat. Safe for prod: env overrides are opt-in (Hetzner sets neither), so the layered load returns the data-repo config.edn unchanged there. Verified: bb test / clojure -M:test (63 JVM, incl. parse-users, env-override, and baked-default-validity unit tests); a container build+run confirms the ephemeral seed fires and the default+env-override config validates end-to-end (boot reaches the token check). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes bbledger runnable on a container PaaS (orkestr) via Telegram webhook, and brings the Hetzner persistence guarantees into the container so a PaaS deploy is fully self-sufficient. Two commits:
1. Webhook mode (coexists with long-polling)
Setting
BBLEDGER_WEBHOOK_URLflipsledger.mainfrom long-polling to webhook mode:martian-httpkit) listens on$PORT(default8080),setWebhooks the URL with a secret token (BBLEDGER_WEBHOOK_SECRET, or a random one per boot),X-Telegram-Bot-Api-Secret-Tokenheader on every POST,handle-updatealready consumes, andprocess-update!pipeline under alocking, preserving the single-writer discipline.GET is a health probe (200). A genuine Telegram POST always answers 200 — like the long-polling path's
falsereturn, this tells Telegram not to redeliver (a redelivery could double-book an expense).drop-pending-updatesavoids replaying a backlog on (re)deploy. Unset, nothing changes — the Hetzner/systemd deploy keeps long-polling.2. In-container persistence (PaaS parity with cloud-init + the push unit)
A PaaS has no cloud-init/systemd, so the two Hetzner persistence mechanisms are brought into the container, opt-in via env:
deploy/provision.clj, a babashka entrypoint (bb-first repo, so bb not shell): installs the git-over-ssh deploy key and clonesBBLEDGER_DATA_REPOinto/datawhen the volume is empty, thenp/execs the passed command (SIGTERM reaches the JVM; the summary-unit override flows straight through). No-op passthrough when the envs are unset.store/push!(tolerantgit push origin HEAD), called byledger.mainafter each record/undo whenBBLEDGER_GIT_PUSHis set — the in-process equivalent of thebbledger-pushsystemd path unit.deploy/Dockerfileadds the babashka static-musl binary for the target arch (the publishedbabashka:-alpineimage is amd64-only, but the prod server is arm64 — so it fetches the arch-appropriate-staticrelease), plusopenssh-client/curl, and sets thebbentrypoint while keeping theclojure -M:botCMD.Hetzner is untouched: it sets none of the new envs, so
/datais already a repo (clone skipped) and push stays with the systemd unit.Env vars (orkestr)
BBLEDGER_BOT_TOKEN,BBLEDGER_CONFIG=/data/config.edn,BBLEDGER_WEBHOOK_URL, optionalBBLEDGER_WEBHOOK_SECRET,BBLEDGER_DATA_REPO,BBLEDGER_DEPLOY_KEY(= infraDATA_DEPLOY_KEY), optionalBBLEDGER_GIT_HOST,BBLEDGER_GIT_PUSH=1.Changes
src/ledger/main.clj— webhook branch +run-webhook!/webhook-handler;push?threaded throughprocess-update!src/ledger/store.clj—push!(tolerant remote mirror)deploy/provision.clj(new) — bb entrypointdeploy/Dockerfile— bb static binary, ssh/curl,EXPOSE 8080,ENTRYPOINTdeps.edn— pinhttp-kit+cheshire(now required directly)test/ledger/store_test.clj—push!to a bare remote + missing-remote toleranceTesting
bb test+clojure -M:test— 55 tests / 178 assertions, green on both runtimes;bb lintclean.bb v1.12.218present in the image; no-env passthrough execs the command; clone-on-boot pulls a mounted bare origin into/databefore exec.Deploying the branch to test (no merge needed)
orkestr can deploy a non-default branch via a branch-bound environment (or per-branch preview).
BBLEDGER_WEBHOOK_URLmust equal the URL orkestr assigns that environment, so it's deploy-once-to-learn-the-URL, then set the env var + redeploy.🤖 Generated with Claude Code
https://claude.ai/code/session_01DM5jkMUP7zZrxAwZdBpUuL