Task trackers¶
relayr's tracker connector lets rules' runs file work in a task tracker, and brings what people change there back into reflexr as tracker: events (lattice RFC-0004, relayr's guide). stackr runs the trackers beside the stack, from two opt-in recipes, Plane's Community edition and Vikunja; gives each tenant a tracker workspace and a tracker user of its own, by a checklist; and checks each tenant with make tracker-check. The template's tracker question wires an application to one (The application template). ADR-0039 records how it is built.
| Plane | Vikunja | |
|---|---|---|
| Recipe | trackers/plane/compose.yaml: ten services, from about 1.7 GiB |
trackers/vikunja/compose.yaml: one binary and PostgreSQL |
| A tenant's | Workspace, and a user who is a member of it | Top-level project, owned by its user |
| Provisioned | By hand, in Plane's web app: its API makes no workspaces, users, keys or webhooks | By a script, through Vikunja's API and its command line |
| Licence | AGPL-3.0-only | AGPL-3.0-or-later |
Run a tracker¶
Start the stack first: make up creates the stackr network the trackers join. Then:
make tracker-up TRACKER=plane # or TRACKER=vikunja
make tracker-down TRACKER=plane # stops it, keeping its data
Each recipe is a Compose project of its own (stackr-plane, stackr-vikunja), reading stackr's .env, where make env has generated their secrets. Each publishes one port, at STACKR_BIND (ADR-0006), and joins the stackr network with the services an application talks to:
| Setting | Default | What it is |
|---|---|---|
PLANE_PORT, PLANE_URL |
3800, http://localhost:3800 |
Where Plane is published, and where people reach it: its links, sign-in and CORS |
PLANE_API_KEY_RATE_LIMIT |
300/minute |
Each API key's limit, and so each tenant's; Plane's own default is 60. The template's TRACKER_BUDGET is 300 to match |
PLANE_WEBHOOK_HOSTS |
app.stackr.internal |
The one host Plane sends webhooks to: the application's name on the stackr network. Plane refuses private addresses unless their host is listed, and this lists no more than it needs. It takes only a URL whose host has a dot in it, so an application generated with tracker=plane has this name there beside app |
PLANE_WORKER_CONCURRENCY |
2 | Celery's worker processes, which deliver webhooks; Celery's default is one per CPU, 1.25 GiB on 16 of them |
VIKUNJA_PORT, VIKUNJA_URL |
3456, http://localhost:3456 |
Where Vikunja is published, and where people reach it |
VIKUNJA_REGISTRATION |
false |
Whether people may sign themselves up; the operator makes each tenant's user instead |
An application in its app profile reaches Plane at http://plane-proxy and Vikunja at http://vikunja:3456; Plane sends its webhooks to it at http://app.stackr.internal:8000, and Vikunja at http://app:8000. An application run with make serve, outside Docker, reaches them at their published ports, and gets no webhooks: its followers find each change at their next poll instead, thirty seconds later.
What Plane's recipe leaves out¶
The recipe runs ten of the thirteen services Plane's own Compose file does, unmodified, at v1.4.2, pinned by tag and digest:
- No object storage. MinIO's image is no longer published on Docker Hub, so uploads and attachments don't work. relayr writes text alone.
- No public pages and no live collaboration (
spaceandlive). Work items, comments and the API work without them. - RabbitMQ 4.1, not the 3.13 image Plane's file names, which stops at boot.
- The proxy's certificate settings are left unset: set but empty, they break its Caddyfile. It serves HTTP on its one published port; a single host's edge would serve it over HTTPS.
- The worker waits for RabbitMQ to be healthy. One started before it never takes a task until it is restarted.
Setting up Plane¶
Open PLANE_URL/god-mode/ and make the instance's admin, the operator. Leave telemetry off: Plane asks on that page, and the instance's settings can turn it off later. Plane's web app is then at PLANE_URL.
Provision a tenant on Plane¶
Plane's Community edition makes workspaces, users, API keys and webhooks only through its web app, so a tenant is set up by hand, as the operator, and checked after. For a tenant acme:
- The workspace. Create a workspace for the tenant,
acme: its slug is what the credentials name. - The tenant's user,
relayr-acme. Plane's Community edition can't make bot users, so it is an ordinary one. Inviterelayr-acme@<your domain>to the workspace as a Member, never an Admin; sign up as that user, in a private window, and accept the invitation. One user per tenant, not one for the deployment: a Plane key reaches every workspace its user belongs to. - Its projects. Add
relayr-acmeas a Member to each project a workspace of the tenant's is bound to, and to no other. A project's id is in its address,/<workspace>/projects/<id>/. - Its key. Signed in as
relayr-acme, create an API token in its profile's settings. Copy it: Plane shows it once. Sign out. - The webhook. As the operator, in the workspace's settings, add a webhook to
http://app.stackr.internal:8000/relayr/v1/trackers/acme/plane, for Work items and Work item comments. Copy its secret key. -
Store them in the application's settings, beside each tenant's gateway key:
TRACKER_CREDENTIALS={"acme": {"base_url": "http://plane-proxy", "api_key": "plane_api_...", "webhook_secret": "plane_wh_...", "workspace": "acme"}} TRACKER_BINDINGS={"acme/main": {"project": "<the project's id>", "labels": ["lattice"]}}TRACKER_CREDENTIALSis a JSON object by tenant, asLITELLM_TENANT_KEYSis; a deployment on AWS points the application's resolver at its secrets instead. Keys and secrets never reach the ledger, logs or spans. -
Check it, from stackr:
The guide's rules hold only if nothing but relayr uses relayr-acme's key: relayr tells its own writes from people's by the values it records, and a key used elsewhere looks like people.
A webhook Plane switched off¶
Plane retries a delivery only when it can't connect or times out: five times, the first after ten minutes. Then it deactivates the webhook and emails whoever made it. A delivery the application refuses is logged as sent, and one to a host PLANE_WEBHOOK_HOSTS doesn't allow is logged in the worker and never retried (docker compose -p stackr-plane logs plane-worker).
Nothing is lost either way: a webhook only wakes a follower, and the follower's next poll finds every change. To turn it back on, fix what failed (the application down, or a host that isn't allowed), then open the webhook in the workspace's settings and enable it. make tracker-check shows the changes no webhook announced, which a webhook that is off is.
Provision a tenant on Vikunja¶
Vikunja's users, projects, tokens and webhooks are all scriptable. Each tenant has a top-level project, owned by its user, relayr-acme; Vikunja's users are instance-wide, so this isolation is weaker than Plane's. And the recipe sets VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS=true, so that webhooks reach the application, which lets any Vikunja user point a webhook at any private address on the stackr network, the stack's services included. Vikunja suits a deployment of one tenant, or a few that trust each other. With jq, and a password of the operator's choosing:
cd packages/stackr
TENANT=acme PASSWORD=<a password> VIKUNJA=http://localhost:3456/api/v1
docker compose -p stackr-vikunja exec vikunja /app/vikunja/vikunja user create \
--username "relayr-$TENANT" --email "relayr-$TENANT@example.com" --password "$PASSWORD"
session=$(curl -fsS "$VIKUNJA/login" -H 'Content-Type: application/json' \
-d "{\"username\": \"relayr-$TENANT\", \"password\": \"$PASSWORD\"}" | jq -r .token)
auth=(-H "Authorization: Bearer $session" -H 'Content-Type: application/json')
project=$(curl -fsS -X PUT "$VIKUNJA/projects" "${auth[@]}" -d "{\"title\": \"$TENANT\"}" | jq -r .id)
expires=2027-01-01T00:00:00Z SECRET=$(openssl rand -hex 32)
curl -fsS -X PUT "$VIKUNJA/tokens" "${auth[@]}" -d '{"title": "relayr", "expires_at": "'"$expires"'",
"permissions": {"tasks": ["create", "read_one", "read_all", "update"],
"tasks_comments": ["create", "read_all"], "labels": ["create", "read_all"],
"tasks_labels": ["create", "delete"], "projects": ["read_all"],
"projects_webhooks": ["read_all"], "other": ["user"]}}' | jq -r .token
curl -fsS -X PUT "$VIKUNJA/projects/$project/webhooks" "${auth[@]}" \
-d '{"target_url": "http://app:8000/relayr/v1/trackers/'"$TENANT"'/vikunja", "secret": "'"$SECRET"'",
"events": ["task.created", "task.updated", "task.deleted", "task.comment.created"]}'
The token's scopes are what relayr needs, and what make tracker-check reads with:
| Scope | For |
|---|---|
tasks: create, read_one, read_all, update |
Filing and changing items, and reading them back |
tasks_comments: create, read_all |
Comments, both ways |
labels: create, read_all; tasks_labels: create, delete |
Labels, found or made by name |
projects: read_all; projects_webhooks: read_all; other: user |
make tracker-check: the user's projects, their webhooks, and whose the token is |
Vikunja's tokens must expire, and can't read their own expiry, so the credentials keep it, as expires_at, for the checker; the application doesn't read it:
TRACKER_CREDENTIALS={"acme": {"base_url": "http://vikunja:3456", "api_key": "tk_...", "webhook_secret": "<SECRET>", "expires_at": "2027-01-01T00:00:00Z"}}
TRACKER_BINDINGS={"acme/main": {"project": "<the project's id>"}}
Then make tracker-check TENANT=acme TRACKER=vikunja APP_ENV=../my-app/.env.
Check a tenant¶
make tracker-check reads the application's TRACKER_CREDENTIALS and TRACKER_BINDINGS (from APP_ENV, or the environment), and asks the tracker about each step, with the tenant's own key and GET requests only. It changes nothing.
ok: the key is relayr-acme's
ok: relayr-acme is a member of workspace acme
ok: relayr-acme is a member of project Ops
ok: relayr-acme is not in globex's workspace, globex
skip: Plane's API keys can't read webhooks: check it in the workspace's settings, ...
ok: every change in the last hour came with its webhook
| It checks | On Plane | On Vikunja |
|---|---|---|
| The key | Works, and is relayr-<tenant>'s |
Works, and is relayr-<tenant>'s |
| Membership | An ordinary member of the tenant's workspace, and of each bound project | Each bound project is the user's |
| Isolation | A member of no project that isn't bound, and refused in every other tenant's workspace the credentials name | Sees no project another user owns, such as one shared with it |
| The webhook | Not readable with an API key: it says so | Each bound project's webhook goes to the application's route, with relayr's events |
| Expiry | Warns 14 days before expires_at |
|
| Nudges | relayr.trackers.unnudged for the tenant, from Prometheus, over the last hour |
The same |
A missing step, or broken isolation, fails it, with exit status 1; a warning doesn't. Isolation is as far as the API shows it: Plane's API keys list no user's workspaces, so the checker asks for each other tenant's in turn, and a workspace no credentials name can't be asked about.
The nudges are read when the observability profile runs and the application exports its metrics: relayr's follower counts each change a poll found that no webhook announced, and the Collector labels it with the tenant. Changes with no webhook mean it isn't registered, is off, or goes to a host the tracker refuses. With no Prometheus, or no change counted yet, the checker says it couldn't tell.
Rotation¶
- A key, without a gap. Make a second key for the same user (Plane: another API token; Vikunja: another token, with its expiry), put it in
TRACKER_CREDENTIALS, restart the application, and revoke the first oncemake tracker-checkpasses. The template readsTRACKER_CREDENTIALSwhen it starts; a resolver over a secrets store is asked again every five minutes, and at once when the tracker refuses a key. - Vikunja's tokens expire, so rotate each before
make tracker-checkwarns of it. - A webhook's secret changed in the tracker before it is stored costs only nudges: until the application has it, its webhooks are refused and its followers poll.
Offboarding a tenant¶
- Remove the tenant from
TRACKER_BINDINGSandTRACKER_CREDENTIALS, and restart the application: its followers stop. - Shut
relayr-<tenant>out of the tracker (Plane: delete its API tokens and remove it from the workspace; Vikunja:vikunja user change-status --disablewith its id, whichvikunja user listshows), and delete the tenant's webhook. -
Delete its rows from relayr's tracker tables, titles and descriptions among them, as the tables' owner, in the application's schema:
relayr never deletes tracker data. The workspace is the tenant's, exported and deleted by the operator under the deployment's retention policy.
Licences¶
Plane's Community edition is AGPL-3.0-only, and Vikunja AGPL-3.0-or-later. lattice stays MIT: the recipes run their unmodified images, and relayr calls their REST APIs, and never vendors, patches or links their code, as stackr already runs Grafana, Loki and Tempo. Each recipe's NOTICE.md names every image it runs, with its licence and source. A deployment that modifies either tracker takes on the AGPL's obligations for it. This is a reading, not legal advice.