Give the annia enqueuer a source tree and an image #100
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#100
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
#89closed with the cluster side of annia's enqueuer — the namespace, the two Vaultcredentials, the broker topology, the
annia-enqueuerService — and landed no source:there is no enqueuer tree, no derivation, no build step. The Deployment it left behind pins
zot.faun-snapper.ts.net/milner/annia-enqueuer:<annia-infra-pin>and is deliberately notlisted in its kustomization, because the registry has no such repository (zot answers
NAME_UNKNOWN). So nothing turns a Forgejo delivery into a message onannia.dispatch, andwhat makes that visible is a Deployment nobody applies.
The premise written into the merged tree — that
annia-infrabuilds this image — is nottrue. annia-infra #310 ("Create build pipeline") builds annia's runtime/workspace
image (
zot.faun-snapper.ts.net/annia:latest, thedockerTools.buildLayeredImageclosurethat carries
entrypoint.sh,.annia.tomland the skills); it never mentions the enqueuer.The counterpart issue, annia-infra #308, says explicitly who owns it:
This issue gives the enqueuer what #89 described and did not build: its source, its image,
and the pin that makes the Deployment runnable.
The contract it must satisfy — already merged, not a proposal
Read out of
main. None of this is this issue's to choose.annia-enqueuer, port 80 →targetPort: http; the Deployment declarescontainer port
http= 8080 and atcpSocketreadiness probe on it — so listening on8080 is the contract and no HTTP health endpoint is required.
/api/hook(#89). It is what#88's Cloudflare route publishes and what thezone's WAF rules already cover.
FORGEJO_WEBHOOK_SECRET(annia-hmac-secret/hmac-secret),RABBITMQ_USERNAMEandRABBITMQ_PASSWORD(annia-rabbitmq-credentials), andRABBITMQ_URL="amqp://…@rabbitmq.rabbitmq.svc.cluster.local:5672/annia".{repo, kind, number, event_id}— which is the contractwith #308. The pointer, not the payload.
X-Hub-Signature-256: sha256=<hex>and the bare-hexX-Forgejo-Signature/X-Gitea-Signatureon every delivery (docs/woodpecker-ci.md§9h),so this is HMAC-SHA256 over the raw body.
data/annia/rabbitmq.yaml: vhostannia,exchange
annia.events(topic, durable), queueannia.dispatch(quorum,x-delivery-limit: 2, DLXannia.dlx). The binding's key is*— one word per repo — sothe enqueuer publishes with routing key
milner, which is exactly what #308's consumerreads (
RABBITMQ_QUEUE="annia.dispatch",RABBITMQ_ROUTING_KEY="milner"). #308's ownwords: "take the names, the routing key and the pointer shape from that manifest rather
than declaring your own: two declarers of one queue fail with
PRECONDITION_FAILED" —publish, never declare.
Implementation steps
enqueuer/, shaped likerouter/:__init__.py,app.py,__main__.py, withmain()behind__name__ == "__main__"so the image check can importthe module graph without starting a server (
routerCheck's trick). It must:POST /api/hookand nothing else;unbounded read is the cheapest way to take it down;
FORGEJO_WEBHOOK_SECRETwithhmac.compare_digest, before any parse, acceptingX-Hub-Signature-256(sha256=<hex>)and the bare-hex variants; no secret, no publish, and 401 + no message on mismatch;
annia.eventswith routingkey
milner—{repo, kind, number, event_id}, wherekindisX-GitHub-Eventandevent_idis the delivery id the forge sets (it is #308's dedup key, so it has to be thesame value on a redelivery) — with publisher confirms on;
pikafrom the flake's nixpkgs pin. Verified available aspython313Packages.pika= 1.3.2 in this flake's nixpkgs.router/'s stdlib-only commentdoes not transfer — there is no AMQP client in the standard library — so the image carries
it and the app imports it. It must come from the pin, never a
pip installin a step (afetch and a container builder, which story 20 forbids). Say so where the image family is
described (
docs/nix.md§15), so the difference fromrouter/does not read as aninconsistency someone later tidies away.
enqueuerApp,enqueuerCheck,enqueuerImage, besiderouterApp/routerCheck/routerImage(flake.nix~190-270). Same shape —enqueuer/copied under/app;dockerTools.buildLayeredImage { name = "milner/annia-enqueuer"; contents = [ enqueuerApp pkgs.python313 pkgs.python313Packages.pika pkgs.pkgsStatic.busybox ]; config = { Cmd = [ "/bin/python" "-m" "enqueuer" ]; … User = "65535:65535"; ExposedPorts = { "8080/tcp" = {}; }; }; }.enqueuerCheckisrouterCheck's assertions asked of the image's own bytes:/bin/pythonresolves inside the layers,
/app/enqueuer/*.pyare links that resolve, the image's ownconfig.Cmdispython -m enqueuer, and the module graph imports from the extracted root.ci/enqueuer-image.sh— the reader of theimage:line, symmetric withci/router-image.sh/ci/oz-image.sh(including the single-quoted${…}forshellcheck SC2016, which is the reason it is a script and not a step's own commands).
Callers: the build step and the guard.
ci/enqueuer-tag-guard.sh— the fourth pair, holding the invariant that the tag movesif and only if the image's inputs do. It is
ci/router-tag-guard.sh's shape with thecompared path set widened by the flake:
git diff --name-only "$REF" HEAD -- enqueuer flake.nix flake.lock. Both directions thencover everything the image is built from — any of those moved and the tag did not fails
(the main-push build re-pushes a tag the kubelet will not re-pull), and the tag moved with
none of them fails (the path-filtered build never fires). No third condition is needed,
because the pin is in the set. Compared against the base branch's tip, since the
workspace is a shallow clone and
git merge-basefinds no ancestor — the failure pipeline#67 hit one step after passing the tag check. The reaper's guard is one-sided only because
its image has no source directory; this one has both inputs, so it keeps the router's
both-directions form.
.woodpecker/gates.yaml:build-enqueuer, shaped likebuild-reaper(the image nopreview deploys), with the path filter widened to the same set the guard compares —
when: push branch main path include [enqueuer/**, flake.nix, flake.lock]plusmanual;build
./#enqueuerImageand./#enqueuerCheck,catthe check,skopeo copythedocker-archive to
docker://zot.faun-snapper.ts.net/milner/annia-enqueuer:<pin>, thenverify the registry serves the digest that was pushed (the
curl … | sha256sumcomparisonevery build step ends with). The pull-request half (
when: event: pull_request, samefilter) builds and checks and pushes nothing: the enqueuer is one always-on service
pinned to one literal, no preview namespace runs it, and
preview/**is the previewsystem's namespace — a per-PR tag would have no consumer, so this half exists for build
validation alone. Plus an
enqueuer-tag-guardstep withwhen: event: pull_request, asall three existing guards have.
tests/test_static.pyholds the step-to-script mapping(~1035), the executable list (~61) and one
test_the_…_tag_guard_runs_on_pull_requestsperguard — each gains a fourth entry.
apps/annia/kustomization.yamlgainsdeployment.yaml;the Deployment's
image:becomes the literalzot.faun-snapper.ts.net/milner/annia-enqueuer:0.1.0(the bootstrap tag; the number is acounter of the image's revisions, not a compatibility claim —
x.y.zis all the reader'sgrep asks for). Nothing in zot changes:
milner/**already grantsannia-cicreate/update and anonymous read/create/update (
infrastructure/zot/values.yaml), whichis all the push and the kubelet need.
tests/test_gitops.py::test_no_rendered_layer_pins_a_placeholder_imageexists becauseof this Deployment and says in its docstring that "the image belongs to annia-infra
(#310)". The test stays — a placeholder reaching any layer is still the failure it
describes — but that prose is stale the moment the tag is real, and the
<annia-infra-pin>name goes with it.apps/annia/kustomization.yaml:15-17,apps/annia/set-credential.sh:21,375anddocs/rabbitmq.md:291all say annia-infrabuilds this image; each becomes the truth.
docs/rabbitmq.md:238's "when annia-infra#310 registers the webhook" is a second error in the same sentence: registration is a
Forgejo setting pointing at
annia.vando.dev/api/hookwith the Vault-held secret, and itis ours — and it waits on #88's route, not on annia-infra.
docs/rabbitmq.md§16e gains the source tree, the build step, the pin, thepointer/routing-key contract and the "declare nothing" rule;
docs/nix.md§15's imagefamily gains a fourth member, including the one sentence that the image tag is a counter
bumped whenever the image moves, not a compatibility claim; AGENTS.md's repo map describes
three tag-guard pairs in
ci/, so the fourth pair and the enqueuer's own row make thatdescription true again.
python3 -m pytest tests/ -q, with the new guard demonstrated tobite both ways;
nix build ./#enqueuerImage ./#enqueuerCheck. In-cluster, no tunnel —this is #89's own acceptance test: a signed POST from a pod in the namespace to
http://annia-enqueuer.annia.svc.cluster.local/api/hook→ 2xx and one message; thesame body with a wrong signature → 401 and no change in queue depth;
kubectl -n rabbitmq exec rabbitmq-server-0 -- rabbitmqctl list_queues name type argumentsshows
annia.dispatchas a quorum queue withx-delivery-limit2.Done when
{repo, kind, number, event_id}message to
annia.dispatchwith routing keymilner; an unsigned or wrongly signed one isrejected and publishes nothing — observed in-cluster, not inferred.
nix build ./#enqueuerImage ./#enqueuerCheckpasses, and the image carries the app, pythonand pika and nothing else (story 20).
maintouchingenqueuer/**rebuilds the image under the tag the manifest pins,and
appsreaches Ready with the Deployment listed (stories 12 and 17).flake.lockbump that changes the image moves the tag too, and that build fires withoutany change under
enqueuer/**.enqueuer/**or flake files moved without a tagbump, and one whose tag moved with neither — demonstrated, not asserted.
python3 -m pytest tests/ -q,kustomize buildforapps,conftest test --policy policy/.Out of scope
#88), and the Forgejo webhook registrationthat points at it — the end-to-end path from the internet waits on the tunnel, so these two
do not each wait on the other.
/nixclaim, KEDA and the ScaledJob (#25); the console's pauselever for the crash loop.
#89landed — namespace, credentials, broker topology, Service. The Deploymentis the only thing it left unfinished.
Decisions (settled)
pikafrom the pin, not hand-rolled AMQP. There is no AMQP client in the standardlibrary, so the router's stdlib-only rule does not transfer; pika is pure Python from the
nixpkgs revision
flake.lockalready pins (1.3.2, verified). The difference fromrouter/is written down in
docs/nix.md§15 rather than left to look like an oversight.always-on service pinned to one literal, and there is nothing about it to preview: no
preview namespace runs it, and
preview/**belongs to the preview system. Sobuild-enqueuer's pull-request half builds and checks the image and stops there; it existsfor build validation alone.
enqueuer/**and the flake pin. Aflake.lockbump that changespython or pika moves the tag without any app change — the reaper's invariant, applied to an
image that also has a source directory. It needs no third guard condition: putting
flake.nixandflake.lockin the compared path set lets the router's two directionscover it, and the build's path filter is that same set.
0.1.0to start, and the number is a counter. Any change that moves the image bumps thelast component, the way
ozis at0.1.7. Nothing reads the digits for meaning — thekubelet, the manifest and the guard do not — and
docs/nix.md§15 says so in a sentence,because under decision 3 a routine pin bump moves this tag too.
Naina:
Overview
Give the annia enqueuer the source tree, Nix image, CI build and tag-guard that
#89 described but did not land, then wire the Deployment into the
appslayerwith a real
0.1.0tag and retire every stale "built by annia-infra #310" claim.The contract is already merged: listen on 8080,
POST /api/hook, HMAC-SHA256over the raw body, publish one
{repo, kind, number, event_id}pointer toexchange
annia.eventswith routing keymilner, and declare nothing.Technical Approach
Source tree
enqueuer/, shaped likerouter/:__init__.py,app.py,__main__.py1.ThreadingHTTPServerservingPOST /api/hookand nothing else; every other path → 4042.Read the raw body once, bounded, before any parse.
GitHub caps webhook payloads at 25 MB3; Go's
http.NewRequestsetsContentLengthto the exact length for a*bytes.Readerbody4.Verify HMAC-SHA256 over
body, then publish; constant-time, no secret ⇒ no publish.Forgejo sends
X-Hub-Signature-256: sha256=<hex>and the bare-hexX-Forgejo-Signature/X-Gitea-Signature56, all HMAC-SHA256 over the raw body7;X-Hub-Signaturecarriessha1=and must never be accepted.Publish one persistent pointer; declare nothing.
The exchange, routing key and pointer shape are the already-merged topology8 and the cross-repo contract with annia-infra #3089, which is still open — its consumer adapter is unwritten.
pika is not thread-safe and does not auto-reconnect.
ThreadingHTTPServercalls the connection from many threads;BlockingConnectionallows onlyadd_callback_threadsafeoff its own thread10.Set
blocked_connection_timeoutonURLParametersso a blocked broker cannot wedge the receiver11; the rebuild loop is pika's own recovery pattern12.Flake:
enqueuerSrc,enqueuerApp,enqueuerCheck,enqueuerImagebesiderouter*(flake.nix~184–270)13 — the derivations sit at 184–270 whilerouterSrcis theletbinding at line 13414.python313.withPackagesreplaces the issue's literalpkgs.python313 pkgs.python313Packages.pika— the literal form lands pika in the image root but offsys.path(research §1)15.enqueuerCheckisrouterCheck's assertions plus a pika-enabled interpreter innativeBuildInputs, and it must assert the/bin/python3.13link resolves rather than equals${pkgs.python313}(the env path differs)16.ci/enqueuer-image.sh—sedreader for theimage:line,#!/bin/sh+set -eu, single-quoted${...}with# shellcheck disable=SC2016(Woodpecker pre-substitutes${...}). Symmetric withci/router-image.sh; shared by the build step and the guard.ci/enqueuer-tag-guard.sh—router-tag-guard.sh's both-directions shape over the widened input set.Confirmed in the existing guard, which compares against the base tip and documents the pipeline #67 merge-base failure17.
.woodpecker/gates.yaml:build-enqueuer+enqueuer-tag-guard, shaped likebuild-reaper.List and pin the Deployment.
At HEAD the Deployment still pins the
<annia-infra-pin>placeholder18 and is absent fromresources:19; the port, env names and vhost-anniaURL the plan relies on are already declared20.Extend
tests/test_static.py— shellcheck list (~60) gains both scripts;flake_steps(~1035) gains"enqueuer-tag-guard": ("ci/enqueuer-tag-guard.sh",); a fourthtest_the_enqueuer_tag_guard_runs_on_pull_requests; path-filter tracked-file checks; an image-closure test parallel totest_the_router_image_carries_its_own_closure. Add both-directions cases totests/test_ci_guards.py(demonstrated, not asserted).Retire every stale attribution (grep-verified inventory, all must change):
Every line matches HEAD:
deployment.yaml:2021,kustomization.yaml:15,1722,set-credential.sh:21,37523,docs/rabbitmq.md:238,291,295,29724,tests/test_gitops.py:119-12025 and theci/row of the AGENTS.md repo map26.Keep
docs/rabbitmq.md:139andset-credential.sh:123— both correctly name #308 as the consumer contract.Docs.
docs/rabbitmq.md§16e gains the source tree, build step, pin, pointer/routing-key contract and "declare nothing";docs/nix.md§15 gains the fourth image, the one sentence that pika comes from the flake pin (notpip install), and that the tag is a counter not a compatibility claim.Implementation Details
enqueuer/{__init__,app,__main__}.pyflake.nixenqueuerSrc/enqueuerApp/enqueuerCheck/enqueuerImageci/enqueuer-image.sh,ci/enqueuer-tag-guard.sh.woodpecker/gates.yamlbuild-enqueuer,enqueuer-tag-guardgitops/clusters/milner/apps/annia/{kustomization,deployment}.yaml0.1.0gitops/clusters/milner/apps/annia/set-credential.shdocs/rabbitmq.md,docs/nix.md,AGENTS.mdtests/test_static.py,tests/test_ci_guards.py,tests/test_gitops.pypkgs.python313Packages.pika= 1.3.2, already inflake.lock's nixpkgs pin27 (nix evalagainst the pin reports1.3.2; the package ispkgs/development/python-modules/pika/default.nix28). Nothing to add toflake.lock./api/hook; the Deployment's env names are fixed;RABBITMQ_URLalready contains vhostannia. Publish toannia.events, never declare.#308's planning comment sayskindis the singular"issue"andevent_idhas anevt-prefix29; this issue sayskind = X-GitHub-Eventand the raw delivery UUID. Implement #100 literally; the consumer's still-unwritten adapter normalises unverified (no such adapter exists onannia-infra@main). Flag for reviewer.propagatedBuildInputspulls gevent/tornado/twisted (~75 MiB)30. Default is the honest "nopip install" reading;pika.overridePythonAttrs (o: { propagatedBuildInputs = []; })is safe for a blocking publisher if "pika and nothing else" is read literally. Flag for reviewer.event_idis stable per attempt, not across redeliveries. Flag for reviewer; do not claim stability the forge does not provide.Acceptance Criteria
POST /api/hookpublishes exactly one{repo, kind, number, event_id}message toannia.dispatchwith routing keymilner; unsigned or wrong-signature → 401 and no message.nix build ./#enqueuerImage ./#enqueuerCheckpasses;enqueuerCheckreads the image's own layers.python3 -m pytest tests/ -q,kustomize buildforapps, andconftest test --policy policy/are green.maintouchingenqueuer/**or the flake files rebuilds the pinned tag;appsreaches Ready withdeployment.yamllisted.tests/test_ci_guards.py.Testing Plan
tests/test_ci_guards.py:enqueuer/**change without a tag bump → fail; tag bump with noenqueuer/**or flake change → fail;flake.lockbump without a tag bump → fail; shallow-workspace case.tests/test_static.py: shellcheck covers both new scripts;flake_stepsmapsenqueuer-tag-guard; a fourthtest_the_enqueuer_tag_guard_runs_on_pull_requests; everybuild-enqueuerpath filter matches a tracked file; image-closure test readscontents/Cmd/User/PYTHONPATH.tests/test_gitops.py: placeholder test stays and passes oncedeployment.yamlis listed with0.1.0.http://annia-enqueuer.annia.svc.cluster.local/api/hook→ 2xx and one message; wrong signature → 401, queue depth unchanged;rabbitmqctl list_queues name type argumentsshows quorum +x-delivery-limit 2.Related Issues
merge-basefailure the guard shape encodes.RABBITMQ_QUEUE=annia.dispatch,RABBITMQ_ROUTING_KEY=milner(open).Research Findings
contentsdoes not work.[ enqueuerApp pkgs.python313 pkgs.python313Packages.pika … ]leavesimport pikafailing under the image's bare/bin/python; use(pkgs.python313.withPackages (ps: [ ps.pika ])), verified by building and chroot-running both variants.BlockingConnectionpermits onlyadd_callback_threadsafeoff its own thread, so a single owner thread or callback hand-off is required; the reconnect loop catchesAMQPConnectionErrorand rebuilds connection + channel.UUID, and Forgejo has no automatic retry.X-Forgejo-Signature/X-Gitea-Signatureis hex HMAC-SHA256 over the raw body with no prefix, perservices/webhook/shared/payloader.go;X-Hub-Signature(sha1=) must never be accepted.Content-Length(Gobytes.Readerbodies), so aContent-Length-gated bounded read is the acceptedhttp.serveridiom.Pre-Mortem
contentslist.pkgs.python313pluspkgs.python313Packages.pikaleaves pika off the interpreter'ssys.path, so the pod starts but every signed hook dies withModuleNotFoundError, andenqueuerCheck's exact/bin/python3.13link assertion fails. The plan'swithPackagesfix is easy to skim past because the issue body still reads as the specification.BlockingConnectionacrossThreadingHTTPServerhandler threads. The connection is not thread-safe, so concurrent deliveries interleave frames, hang, or lose confirms; the plan names single-owner-thread vsadd_callback_threadsafewithout pinning one. The app then looks correct under a single-request smoke test and only breaks under real webhook bursts.appswith tag0.1.0before the image exists in Zot. Layers usedependsOn/wait: true, so a not-Ready enqueuer holds the wholeappslayer, turning one missing image into a cluster-wide stall. The tag-guard and path-filter tripwires only check the source/tag relationship, never that the tag was actually pushed.