Skip to content

feat: webhook mode for running on a PaaS (orkestr) - #8

Open
Schroedingberg wants to merge 6 commits into
mainfrom
feat/telegram-webhook-orkestr
Open

feat: webhook mode for running on a PaaS (orkestr)#8
Schroedingberg wants to merge 6 commits into
mainfrom
feat/telegram-webhook-orkestr

Conversation

@Schroedingberg

@Schroedingberg Schroedingberg commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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_URL flips ledger.main from long-polling to webhook mode:

  • an http-kit server (already on the classpath via martian-httpkit) listens on $PORT (default 8080),
  • setWebhooks the URL with a secret token (BBLEDGER_WEBHOOK_SECRET, or a random one per boot),
  • verifies Telegram's X-Telegram-Bot-Api-Secret-Token header on every POST,
  • parses the POSTed JSON into the same snake_case wire-shape update handle-update already consumes, and
  • runs it through the existing process-update! pipeline under a locking, preserving the single-writer discipline.

GET is a health probe (200). A genuine Telegram POST always answers 200 — like the long-polling path's false return, this tells Telegram not to redeliver (a redelivery could double-book an expense). drop-pending-updates avoids 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:

  • clone-on-bootdeploy/provision.clj, a babashka entrypoint (bb-first repo, so bb not shell): installs the git-over-ssh deploy key and clones BBLEDGER_DATA_REPO into /data when the volume is empty, then p/execs the passed command (SIGTERM reaches the JVM; the summary-unit override flows straight through). No-op passthrough when the envs are unset.
  • push-backstore/push! (tolerant git push origin HEAD), called by ledger.main after each record/undo when BBLEDGER_GIT_PUSH is set — the in-process equivalent of the bbledger-push systemd path unit.

deploy/Dockerfile adds the babashka static-musl binary for the target arch (the published babashka:-alpine image is amd64-only, but the prod server is arm64 — so it fetches the arch-appropriate -static release), plus openssh-client/curl, and sets the bb entrypoint while 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.

Env vars (orkestr)

BBLEDGER_BOT_TOKEN, BBLEDGER_CONFIG=/data/config.edn, BBLEDGER_WEBHOOK_URL, optional BBLEDGER_WEBHOOK_SECRET, BBLEDGER_DATA_REPO, BBLEDGER_DEPLOY_KEY (= infra DATA_DEPLOY_KEY), optional BBLEDGER_GIT_HOST, BBLEDGER_GIT_PUSH=1.

Changes

  • src/ledger/main.clj — webhook branch + run-webhook!/webhook-handler; push? threaded through process-update!
  • src/ledger/store.cljpush! (tolerant remote mirror)
  • deploy/provision.clj (new) — bb entrypoint
  • deploy/Dockerfile — bb static binary, ssh/curl, EXPOSE 8080, ENTRYPOINT
  • deps.edn — pin http-kit + cheshire (now required directly)
  • docs — README orkestr section, infra/README env inventory, CLAUDE.md, CONTRACT.md
  • test/ledger/store_test.cljpush! to a bare remote + missing-remote tolerance

Testing

  • bb test + clojure -M:test55 tests / 178 assertions, green on both runtimes; bb lint clean.
  • Webhook handler smoke-tested in isolation (no token/network): health→200, wrong-secret→403, correct-secret POST parses to the exact wire shape and processes once, malformed JSON→200 (no redelivery).
  • Real image build + run: multi-arch build succeeds; bb v1.12.218 present in the image; no-env passthrough execs the command; clone-on-boot pulls a mounted bare origin into /data before exec.
  • Not yet exercised end-to-end against live Telegram (needs a bot token + public URL) — worth a real orkestr deploy check.

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_URL must 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

Schroedingberg and others added 6 commits July 21, 2026 17:00
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant