Work Queue Service
Generic work queue service for Hummingbird. Manages work items through type-specific lifecycles with a shared processing framework. Currently handles MR creation and updates; designed to support CVE remediation, advisory tracking, and other work item types.
For design decisions and architectural rationale, see Work Queue Service Design.
Architecture
The service runs as a Kubernetes deployment sharing the same PostgreSQL database as hummingbird-status and hummingbird-dashboard (with a service-specific Alembic migration chain).
flowchart TD
clients["Internal services"] -- "POST /work-items/mr" --> svc["Work Queue Service"]
gl_events["GitLab\n(events)"] -- webhook --> fwd["gitlab-event-forwarder"]
fwd --> sns["SNS"]
sns -->|"filter: source=gitlab,\nevent_type=[merge_request, build]"| sqs["SQS Queue"]
sqs --> svc
svc -- "Commits API\n(create + update)\nJobs API\n(trace + retry)" --> gl["GitLab\n(API)"]
svc -- "shared DB" --> pg[("PostgreSQL")]
The SQS consumer thread receives GitLab merge_request and pipeline
events from the SNS topic via a filtered SQS subscription. Events are
stored in the pending_events table and processed by existing work-item
threads, which map MR state changes to phase transitions and trigger CI
job retries on pipeline failure.
Design
Generic + detail tables
The schema separates queue machinery from type-specific data:
work_items— generic: id, type, phase, actionable, attempts, leased_until, error, timestamps. Shared by all work item types.mr_details— MR-specific: package, project, file_actions, commit_message, content_hash, mr_iid, etc. Joined to work_items via foreign key.work_item_statuses— independent readiness dimensions with composite key (work_item_id, dimension, variant). Includespending_untilfor deferred re-evaluation.
New work item types add their own detail table (e.g. cve_details) —
no changes to the generic layer. SQLAlchemy joined table inheritance
loads the correct subclass automatically via polymorphic_load="selectin".
Phases
Four generic phases track coarse lifecycle status:
| Phase | Description |
|---|---|
active |
Being processed — the only mutable phase |
completed |
All lifecycle steps done (terminal) |
failed |
Error occurred (terminal, retryable) |
cancelled |
Cancelled (terminal, retryable) |
Items start as active. Only active items accept event-driven phase
transitions and executor actions. The three terminal phases
(completed, failed, cancelled) are immutable to events — recovery
requires explicit action via the retry API. GitLab merge_request events
drive transitions from active: merge -> completed, close ->
cancelled.
Dimensions
Independent readiness signals stored in work_item_statuses. All
dimensions follow a uniform needs_* trigger model: status is
"true" (action needed) or "false" (handled). Absent dimensions
are treated as actionable by the decider (!= "false"). Each
dimension has an optional variant for multi-instance signals (e.g.
per-component Konflux builds) and an optional pending_until
timestamp for deferred re-evaluation (see
Deferred dimensions below).
Dimensions are set by executors or event handlers via
EventResult.dimensions; the generic handle_event layer applies
event-driven dimensions and emits audit entries.
Dimension schema
work_item_statuses:
work_item_id: UUID (PK, FK)
dimension: Text (PK)
variant: Text (PK, default="")
status: Text (NOT NULL) -- "true" or "false"
updated_at: DateTime(tz)
detail: JSONB (nullable)
pending_until: DateTime(tz, nullable)
Unified state diagram
All dimensions share the same state machine:
stateDiagram-v2
[*] --> absent : item created
absent --> true : decider treats absent as actionable
true --> true_deferred : executor (defer for later)
true --> false : executor (done, no further checks)
true_deferred --> true : sweep notifies (pending_until expired)
true_deferred --> true : webhook/handler (clears pending_until)
false --> true : webhook/handler (re-activates)
note right of absent : no dimension row exists
note right of true : status="true", pending_until=NULL
note right of true_deferred : status="true", pending_until=future
note right of false : status="false", decider ignores
Per-dimension transitions
| Dimension | On success (→ "false") |
On error / re-trigger (→ "true") |
Deferred? |
|---|---|---|---|
needs_mr |
MR created or already exists | — | No |
needs_dashboard |
Dashboard note posted | — | No |
needs_reconcile |
Reconciler completed | Webhook (approved, pipeline, update), sweep expiry | Yes — 30 min cycle / 5 min error backoff |
needs_empty_check |
Empty-diff check completed | Sweep expiry | Yes — 30 min cycle / 5 min error backoff |
needs_ci_retry |
All jobs processed | Build event (job failed), sweep expiry | Yes — per-job not_before delay |
Detail JSONB varies by dimension: needs_dashboard stores
{"note_id": int, "url": str}, needs_ci_retry stores a jobs list,
others use null.
Deferred dimensions
When an executor or handler sets pending_until to a future timestamp
alongside status="true", the decider treats the dimension as "false"
(not actionable) until that time. The periodic sweep finds items with
expired pending_until and notifies them — no synthetic events are
inserted.
status |
pending_until |
Decider sees | Meaning |
|---|---|---|---|
"true" |
NULL |
actionable | Needs action now |
"true" |
future | not actionable | Deferred — sweep will notify |
"true" |
past | actionable | Sweep notified, ready to act |
"false" |
any | not actionable | Done |
| (absent) | — | actionable | No row — needs action |
Decider
A pure function registered per type that derives the next action from
the work item’s current state. The decider reads the selectin-loaded
item.statuses list (in-memory, no DB call) to check dimensions,
pre-filtering deferred dimensions (where pending_until is in the
future). See
workqueue_service/mr/decider.py
for the MR decider implementation.
The decider scans dimensions in priority order
(DDR-45, DDR-48).
Absent dimensions are treated as actionable (!= "false"). Deferred
dimensions (pending_until in the future) are excluded from the
scan. Adding a new concern = new dimension + handler, no decider
changes.
The decider determines the next action to execute — it does not set phases or modify state.
MR upsert semantics
The MR submission endpoint uses upsert logic keyed on
(project, source_branch). Callers always submit the current desired
state; the service decides what to do based on existing work items:
stateDiagram-v2
[*] --> created : no active item
[*] --> unchanged : active, same hash
[*] --> updated : active, different hash
[*] --> retried : failed item
created --> active : new work item
unchanged --> active : no-op
updated --> active : refresh fields
retried --> active : reset phase
| Upsert action | Condition | HTTP status | Effect |
|---|---|---|---|
created |
No active/failed item for (project, branch) | 201 | New work item, actionable = true |
unchanged |
Active item exists, same content_hash |
200 | No-op, return existing item |
updated |
Active item exists, different content_hash |
200 | Refresh file_actions/title/description, set actionable = true |
retried |
Failed item exists for (project, branch) | 200 | Reset to active, clear error, update content, set actionable = true |
Content hash. A SHA-256 of the normalized file_actions JSON
(sorted by file_path + action, compact separators, sorted keys).
Enables cheap same-content detection without comparing the full payload.
Idempotency key. Derived server-side as
mr:{project}:{source_branch}:{generation} where generation is a
counter per (project, source_branch) pair. A partial unique index
ensures uniqueness only among non-terminal phases — completed and
cancelled items can share a key prefix without blocking new items for
the same branch.
Race handling. If two concurrent inserts race for the same
idempotency key, the loser catches the IntegrityError, rolls back,
and retries the full upsert — the winner is now visible and the
correct action (unchanged/updated) is taken.
MR action chain
The MR work item lifecycle chains executor actions. After upsert, the processing loop calls the decider to select the first action; subsequent actions are also selected by the decider after each executor completes:
ensure_mr— creates or updates the MR. Ifmr_iidis not set, creates the branch, commit, and MR via the GitLab Commits API and applies themanaged-by::workqueuescoped label. Ifmr_iidis already set, force-pushes updated content to the existing MR branch and syncs MR metadata (title, description). Detects empty diffs (before creation) or closes an empty MR after force-push. Setsneeds_mrto"false"on success.ensure_dashboard_note— posts an internal note with the Hummingbird dashboard link on the MR and stores theneeds_dashboarddimension as"false"with detail{"note_id": int, "url": str}. Selected by the decider aftercreate_mrorupdate_mrcompletes whenneeds_dashboard != "false".check_empty_diff— checks whether an active MR’s diff became empty (target branch moved). Triggered by theneeds_empty_checkdimension viapending_untilexpiry (~every 30 minutes per item). If the diff is empty, closes the MR with an explanatory note, deletes the branch, and cancels the item. Setsneeds_empty_checkto"false"on completion. Transient GitLab API errors are logged but do not fail the item.reconcile_mr— reconciles desired vs actual MR state. Triggered by theneeds_reconciledimension, set by webhooks (approved, unapproved, update, pipeline success/failure) andpending_untilexpiry. Merges directly whendetailed_merge_statusismergeableandauto_mergeis enabled. Syncs title and description if drifted. Setsneeds_reconcileto"false"on completion (withpending_untilfor the next cycle).
Draft state. The draft submission option controls initial MR
creation only. After creation, GitLab owns Ready/Draft state;
update_mr and reconcile_mr preserve the current GitLab state while
synchronizing title text. The details.draft response field reports the
initial creation setting, not the live GitLab status.
After all actions complete, the decider returns None and the item
sits in active phase waiting for event-driven lifecycle tracking
(merge → completed, close → cancelled).
The ensure_dashboard_note executor sets the needs_dashboard
dimension to "false" with detail {"note_id": int, "url": str}.
The note_id is the GitLab note ID used for future status updates.
Label coordination with dashboard-mr-linker. The managed-by::workqueue
label causes the dashboard-mr-linker Lambda to skip the MR, so the
workqueue-service creates the dashboard note for its MRs. When
dashboard_base_url is not configured, ensure_dashboard_note returns
immediately without posting a note.
head_pipeline_id on mr_details
The current head pipeline ID is stored as a typed integer column
(head_pipeline_id) on the mr_details table rather than as a
dimension. Build events and the reconciler update it directly. The CI
retry executor uses it to filter stale jobs from previous pipelines.
Processing loop
The ProcessingManager uses a bounded ThreadPoolExecutor (10 workers):
- Claims items where
actionable IS TRUEusingSELECT ... FOR UPDATE SKIP LOCKED - Serial per item — one action at a time per work item
- Parallel across items — independent items processed concurrently
- Per-item deadline — processing loops exit after 60 seconds to prevent stuck items from holding pool workers
- Heartbeat context manager extends
leased_untilduring execution - Executors apply state updates directly (phase, MR fields) and must
not call
session.commit()orsession.rollback() - After execution, the loop re-derives the next action via the decider
- Periodic sweep reclaims items with expired leases and notifies
items with expired
pending_untildimensions (no synthetic events)
MR labeling
The executor always adds the managed-by::workqueue scoped label to
every MR it creates, merged with any caller-provided labels. This label
is a coordination signal: other services (e.g.
dashboard-mr-linker) check for this label and
skip managed MRs, since the workqueue-service handles dashboard note
creation itself via the ensure_dashboard_note action.
Event processing
The SQS consumer thread receives merge_request events from the SNS
topic and stores them in the pending_events table. Work-item threads
drain and process these events as part of their main loop.
Consumer thread. A single daemon thread (poll_loop) long-polls SQS
with MaxNumberOfMessages=10 and WaitTimeSeconds=20. For each
message it decodes the SNS envelope (handles gzip+base64), resolves
matching work items via the resolver registry, INSERTs into
pending_events, commits, deletes the SQS message, and calls
manager.notify() for each affected work item. The consumer is started
only when WORKQUEUE_SERVICE_EVENTS_QUEUE_URL is set.
State mapping. MR state changes are mapped to phases:
| GitLab state | Phase | Notes |
|---|---|---|
merged |
completed |
Terminal |
closed |
cancelled |
Terminal (recover via retry API) |
opened |
— | Ignored (only active items accept events) |
locked |
— | Ignored (transient during merge) |
Only items in active phase accept event-driven transitions. Events
for terminal items (completed, failed, cancelled) are silently
discarded.
Out-of-order guard. The last_event_at column on work_items
rejects events with updated_at older than the last processed event.
Same-timestamp events pass the <= guard (processing is idempotent).
Advisory lock. A per-work-item pg_advisory_xact_lock coordinates
the consumer’s INSERT with the work-item thread’s exit decision,
preventing events from being orphaned between the final drain check
and thread exit.
Sweep extension. The periodic sweep (every 5 minutes) also checks
for work items with unprocessed pending_events, providing startup
recovery and a safety net behind the advisory lock.
Adding a new work item type
- Create a detail table and SQLAlchemy model (e.g.
CveWorkItem) - Register a decider function:
decider.register("cve", _cve_decide) - Register executor functions for each action
- Add a submission endpoint:
POST /api/work-items/cve
No changes needed to the processing loop, claiming, or generic API.
API
Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/api/work-items/mr |
Upsert an MR work item (create, update, or skip) |
GET |
/api/work-items/mr |
List MR items (source, project, etc.) |
GET |
/api/work-items |
List all items (type, phase filters) |
GET |
/api/work-items/{id} |
Get item with nested details |
POST |
/api/work-items/{id}/retry |
Retry a failed item |
GET |
/api/whoami |
Introspect caller identity and permissions |
GET |
/oauth/start |
Initiate OAuth login (handled by oauth-proxy) |
GET |
/oauth/callback |
OAuth provider callback (handled by oauth-proxy) |
POST |
/oauth/token |
Exchange OAuth session cookie for Bearer token |
GET |
/healthz |
Health check |
GET |
/metrics |
Prometheus metrics |
Responses include a nested details dict with type-specific fields:
id: "..."
type: mr
phase: active
details:
source: rpms-ci
mr_iid: 42
mr_url: https://gitlab.com/org/group/project/-/merge_requests/42
Authentication
All callers authenticate via Authorization: Bearer tokens. The
service validates each token in one of two ways:
| Caller | Token type | Validation |
|---|---|---|
| K8s SA | JWT (projected) | JWKS signature verification |
| GitLab CI | JWT (OIDC) | JWKS signature verification |
| Human (web/CLI) | sha256~... (OCP) |
OCP users/~ API |
| Local dev | none | WORKQUEUE_SERVICE_LOCAL_AUTH_USER env var |
X-Forwarded-* headers are never trusted on API paths. The proxy
protects only the OAuth login flow (/oauth/start, /oauth/callback)
and the POST /oauth/token endpoint, which bridges cookie sessions to Bearer
tokens for browser and CLI users. The /oauth/token endpoint uses POST (not
GET) to prevent CSRF — browser-initiated GETs via <img> or
<script> tags cannot trigger it.
OAuth proxy deployment requirements
The oauth-proxy sidecar handles only the OAuth login flow. All /api/
paths bypass the proxy — the app handles auth itself. The proxy must be
configured with these flags:
| Flag | Purpose |
|---|---|
--bypass-auth-except-for=^/oauth/ |
Proxy only protects the OAuth flow (login + token exchange) |
--pass-access-token |
Sets X-Forwarded-Access-Token on cookie-authenticated requests (required for /oauth/token). Cookie secret must be exactly 16, 24, or 32 bytes (AES key size) |
--pass-user-headers |
Sets X-Forwarded-User on protected paths |
--cookie-refresh=1h |
Refreshes the OCP token inside the cookie before it expires (see below) |
--cookie-expire=24h |
Hard session limit, forces re-login |
--cookie-samesite=lax |
CSRF defense-in-depth for /oauth/token (POST already blocks the main vectors) |
Why --cookie-refresh is critical
The proxy manages two independent lifetimes that are easy to confuse:
- Session cookie — controlled by the proxy (
--cookie-expire, defaults to browser session). Contains the encrypted OCP access token. - OCP access token — controlled by OCP (default 24h). The actual
credential the app uses to call
users/~.
The proxy does not know when the OCP token inside its cookie expires.
It only checks whether the cookie is valid (signature, expiry).
Without --cookie-refresh, the cookie outlives the token:
| Time | What happens |
|---|---|
| T+0h | User logs in. Proxy creates cookie with fresh OCP token (expires T+24h) |
| T+24h | OCP token expires. Cookie still valid. |
| T+25h | User makes request. Proxy sets X-Forwarded-User (valid cookie) and X-Forwarded-Access-Token (expired token). App calls users/~ with expired token → 401 → anonymous → “not authorized”. |
--cookie-refresh=1h tells the proxy to silently refresh the OCP token
inside the cookie every hour, keeping it well within the 24h expiry.
The workqueue-service also uses a TTL-bounded cache (5 minutes) for
users/~ results, so expired-token failures are never permanent.
Authorization
Access rules are defined in the config file. Each rule maps a remote
identity to permissions (read, retry, submit), optionally scoped
to work item types. Rules use either groups (OCP group membership) or
issuer+claims (JWT bearer). Claim matching follows Vault’s
bound_claims pattern: all conditions AND, list values OR.
All permissions (including read) must be granted by a matching rule.
Authenticated callers with no matching rules get 403. Multiple matching
rules are merged (union of permissions).
See Configuration for the config file format.
Whoami introspection
GET /api/whoami returns the caller’s identity and effective
permissions. Example response:
authentication:
tier: ocp-user
identity: admin-user
groups:
- konflux-hummingbird-admin-access
authorization:
effective_permissions:
mr:
- read
- retry
- submit
matching_rules:
- groups:
- konflux-hummingbird-admin-access
permissions:
- read
- submit
- retry
item_types: []
API Documentation
Interactive API documentation available without authentication:
/docs (Swagger UI), /redoc (ReDoc), /openapi.json.
Audit Log
State-changing operations (processing, API calls, auth decisions)
emit audit entries. Every entry is logged at INFO level. When
WORKQUEUE_SERVICE_AUDIT_S3_BUCKET is set, entries are additionally
archived to S3 as gzipped JSON objects for long-term querying via
Athena.
Audited events
| Event type | Source | Description |
|---|---|---|
action_started |
Worker | Action execution began |
action_completed |
Worker | Action finished (success or failure) |
action_decided |
Decider | Decider assigned next action |
action_no_op |
Decider | Decider returned no next action |
phase_change |
Worker / API / Events | Phase transition (max attempts, submit, event) |
item_updated |
API | Work item content updated via upsert |
dimension_change |
Events / Executor | Dimension upsert (needs_dashboard posted, etc.) |
retry_requested |
API | Retry endpoint reset a failed item |
auth_failed |
Auth | Anonymous request rejected (401) |
auth_denied |
Auth | Authenticated caller lacks permission (403) |
S3 key format
When S3 archival is enabled, entries are stored as gzipped JSON with an Athena-partitionable key layout:
{prefix}/YYYY/MM/DD/HH/MM/{timestamp}#{work_item_id}::{event_type}.json.gz
The ContentEncoding: gzip header is set so S3 API consumers get
transparent decompression.
Field conventions
Follow these conventions when adding new audit calls:
actor: caller identity for API/auth events; thread name for worker events;"system"for decider/system-triggered transitions.old_state: state before the change. Includephaseandactionwhen both are relevant. Omit on creation (no prior state) and on auth events (no work item state).new_state: state after the change. Same keys asold_state. Omit fields that areNone(filtered byto_dict()).trigger: what caused this event. Always includesource("api","worker", or"decider"). Addactionfor deliberate operations,reasonfor error/system transitions,errorfor failure details.metadata: context orthogonal to the state transition — request info on auth events, type-specific data on submit. Not for duplicating state already inold_state/new_state.
CI Job Retry
When a CI pipeline on a managed MR fails, the service can automatically retry jobs that match known transient failure patterns. This is ported from the CKI pipeline-herder’s proven regex-based job log matching engine.
How it works
- GitLab sends a
build(job) webhook when a CI job finishes - The event handler appends the failed job to the
needs_ci_retrydimension’s jobs list and sets status to"true" - The decider selects the
retry_ci_jobsexecutor - The executor processes all jobs in the list, matching each against the config tree and retrying matching jobs via the GitLab Jobs API
- Jobs with non-zero delay set
pending_untilto the earliest per-jobnot_beforetimestamp — the sweep notifies when it expires - Multiple failed jobs in the same pipeline accumulate in the list
needs_ci_retry dimension
The needs_ci_retry dimension follows the unified state machine (see
Unified state diagram). Jobs with non-zero
delay use the deferred path: status="true" with pending_until set
to the earliest per-job not_before timestamp. The sweep notifies
when any job’s delay elapses.
The true → true self-loop fires when another job in the same
pipeline fails — the build event handler appends the new job to the
existing list (clearing pending_until so the decider picks it up
immediately).
The detail JSONB stores a jobs list. Each entry carries build_id,
build_name, build_failure_reason, build_finished_at,
retries_count, and pipeline_id — all from the build webhook
payload. The executor deduplicates by build_id and skips jobs whose
pipeline_id does not match the MR’s current head_pipeline_id.
When a job has a non-zero delay, the executor stamps a per-job
not_before ISO timestamp based on build_finished_at + retry_delays[retries_count]. The dimension’s pending_until is set
to the earliest per-job not_before so the sweep fires as soon as
any job is ready.
Matcher configuration
CI job retry is configured per type under handled_types.mr.job_retry.
The config uses a recursive matcher tree where parent fields are
inherited by children:
handled_types:
mr:
job_retry:
retry_delays: ["0", "5m", "10m"]
retry_limit: 5
matchers:
- name: runner-system-failure
description: Job failed with runner_system_failure
failure_reason: runner_system_failure
- name: git-clone
description: Git clone failure
messages:
- "fatal: unable to access"
- '/error: RPC failed; curl 92 HTTP\/2 stream \d+/'
Matcher fields
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "" |
Matcher name (used in metrics and audit) |
description |
string | "" |
Human-readable description |
failure_reason |
string | "" |
Exact match on GitLab job failure_reason |
job_name |
string | "" |
Prefix match on job name |
messages |
list[str] | [] |
Literal substring or /regex/ on job trace |
tail_lines |
int | 300 |
Number of trace lines to scan (from the end) |
retry_delays |
list[str] | ["5m"] |
Escalating delays indexed by attempt ("0", "5m") |
retry_limit |
int | 3 |
Maximum retry count per job name |
matchers |
list | [] |
Nested child matchers (recursive) |
Message matching
- Literal:
"fatal: unable to access"→ substring match per line - Regex:
'/error: RPC failed; curl \d+/'→re.searchon joined lines (slash-delimited)
Retry safety
Before retrying a job, the executor checks:
- No newer job with the same name exists (prevents double retries)
- Retry limit not exceeded (count of jobs with the same name)
Retry timing
The retry_delays list is indexed by the job’s attempt count
(0-based). When the index exceeds the list length, the last entry
repeats. A delay of "0" means immediate retry; non-zero delays set
pending_until to the computed not_before timestamp. The sweep
notifies the item when pending_until expires. Due to sweep cycle
alignment, actual delay precision is ±5 minutes.
Prometheus Metrics
The service exposes Prometheus metrics at /metrics on the same port
as the API (no sidecar). Metrics follow the repo naming convention:
generic operational metrics use the shared hummingbird_ prefix,
service-specific metrics use hummingbird_workqueue_.
Generic operational metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
hummingbird_http_requests_total |
Counter | method, path, status | Total HTTP requests |
hummingbird_http_request_duration_seconds |
Histogram | method, path | HTTP request latency |
hummingbird_sqs_messages_received_total |
Counter | — | SQS messages pulled |
hummingbird_sqs_messages_processed_total |
Counter | status | SQS messages processed |
Service-specific metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
hummingbird_workqueue_work_items_created_total |
Counter | item_type | Work items submitted via API |
hummingbird_workqueue_work_items_completed_total |
Counter | item_type, result | Action executions completed (success/failed) |
hummingbird_workqueue_work_items_retried_total |
Counter | item_type | Work items retried via API |
hummingbird_workqueue_action_duration_seconds |
Histogram | action | Executor action duration |
hummingbird_workqueue_active_processing_threads |
Gauge | — | Running work item threads |
hummingbird_workqueue_pending_events_inserted_total |
Counter | — | Pending events inserted into DB |
hummingbird_workqueue_ci_jobs_retried_total |
Counter | project, matcher_name | CI jobs retried by the herder |
hummingbird_workqueue_ci_retry_skipped_total |
Counter | project, matcher_name, reason | CI job retries skipped |
UUID path segments in HTTP metrics are normalized to /{id} to limit
label cardinality. The /healthz and /metrics paths are excluded
from HTTP metrics.
CLI
export WORKQUEUE_SERVICE_URL=https://mr.apps.cluster.example.com
export WORKQUEUE_SERVICE_TOKEN=$(oc whoami -t)
# Update an existing file (local_path:repo_path)
workqueue-service submit \
--package kernel \
--project org/group/project \
--change-kind sync \
--file /path/to/sources.spec:sources.spec \
--commit-message "Sync sources from upstream" \
--source-branch mr-service/sync-1 --title "Sync sources"
# Create a new file
workqueue-service submit \
--package kernel \
--project org/group/project \
--change-kind sync \
--create /path/to/new-file.txt:new-file.txt \
--commit-message "Add new file" \
--source-branch mr-service/add-1 --title "Add file"
# Check status
workqueue-service status --id <uuid>
# List active MR items
workqueue-service status --phase active
# Show identity and permissions
workqueue-service whoami
# Raw JSON output
workqueue-service whoami --json
--url overrides WORKQUEUE_SERVICE_URL. --no-auth skips all
credential loading and sends unauthenticated requests (like
aws --no-sign-request). Useful with whoami to verify anonymous
access.
CLI authentication
The CLI resolves credentials in this order:
WORKQUEUE_SERVICE_TOKENenv var — used directly as a Bearer token. Recommended for CI (OIDC/projected SA tokens).- Cached token — if a valid token exists in the per-server cache, it is reused automatically.
- OAuth/Kerberos login — when neither env var nor cache is
available, the CLI performs a synchronous headless SPNEGO login
against the oauth-proxy, exchanges the session cookie for an OCP
Bearer token via
POST /oauth/token, and caches it for subsequent calls. Requiresrequests-gssapiand a valid Kerberos ticket (kinit).
Tokens are cached per-server under
$XDG_CACHE_HOME/workqueue-service/tokens/ (defaults to
~/.cache/workqueue-service/tokens/) with 0600 permissions and a
20-hour TTL. If a cached token is rejected (HTTP 401), the CLI
invalidates the cache and retries once with a fresh OAuth login.
Configuration
Config file
The service reads its configuration from a YAML file at startup. The
path defaults to /etc/workqueue/config.yml and can be overridden with
WORKQUEUE_SERVICE_CONFIG. The service refuses to start if the file is missing
or invalid (fail-closed).
authorization:
rules:
# Any in-cluster SA with the right audience can read
- issuer: k8s
claims:
aud: workqueue-service
permissions: [read]
# Human users -- OCP group membership
- groups:
- konflux-hummingbird-admin-access
permissions: [read, submit, retry]
# Specific K8s SA for CI automation
- issuer: k8s
claims:
aud: workqueue-service
sub: system:serviceaccount:hummingbird--internal:rpms-ci
permissions: [submit, retry]
item_types: [mr]
# GitLab CI OIDC
- issuer: https://gitlab.com
claims:
aud: workqueue-service
project_path: redhat/hummingbird/rpms
permissions: [read, submit, retry]
item_types: [mr]
handled_types:
mr:
gitlab_url: https://gitlab.com
projects:
redhat/hummingbird/rpms:
token_env: GITLAB_TOKEN_RPMS
redhat/hummingbird/containers:
token_env: GITLAB_TOKEN_CONTAINERS
redhat/rhel/src:
token_env: GITLAB_TOKEN_RHEL
Authorization rules
Each rule has either groups (OCP group membership from users/~
response) or issuer+claims (JWT bearer), not both.
issuerdetermines which JWKS keys validate the token signature. Usek8sas shorthand for the auto-detected cluster OIDC issuer.claimsmatches JWT payload fields. All conditions must match (AND); list values match any (OR). Values are compared case-insensitively with string coercion (handles numericproject_id, booleanref_protected).permissionsgrantsread,retry, and/orsubmit.item_typesoptionally restricts the rule to specific work item types. Omit to apply to all types.- Every issuer rule must include
audinclaimsto prevent cross-service token reuse.
Handled types
handled_types declares which work item types this instance serves.
Only listed types get their routers and executors registered. The
processing sweep only picks up items matching these types. Empty or
omitted = handle nothing.
Each type can have per-type operational config:
| Type | Field | Description |
|---|---|---|
mr |
gitlab_url |
GitLab instance URL (default: https://gitlab.com) |
mr |
dashboard_base_url |
Dashboard URL for note creation (when unset, notes are not created) |
mr |
job_retry |
CI job retry matcher config (when unset, retries are disabled) |
mr |
projects |
Per-project/group token and URL configuration |
Per-project token configuration
The projects map under handled_types.mr controls which GitLab API
token the executor uses for each target project. Keys are GitLab
project paths or group prefixes. Every project submitted to the service
must match an entry – there is no default fallback token.
Matching order:
- Exact match on the full project path
- Longest group-prefix match on a
/boundary (e.g.redhat/rhel/srcmatchesredhat/rhel/src/kernel)
Each entry requires token_env (the name of the environment variable
holding the GitLab API token) and optionally overrides gitlab_url:
handled_types:
mr:
gitlab_url: https://gitlab.com
projects:
redhat/hummingbird/rpms:
token_env: GITLAB_TOKEN_RPMS
redhat/hummingbird/containers:
token_env: GITLAB_TOKEN_CONTAINERS
gitlab_url: https://gitlab.example.com
redhat/rhel/src:
token_env: GITLAB_TOKEN_RHEL
At startup, the service validates that all referenced token_env
variables exist in the process environment and refuses to start if any
are missing.
Adding a new issuer
Add a rule with the issuer’s OIDC discovery URL. The service discovers the JWKS endpoint lazily on the first token from that issuer:
- issuer: https://token.actions.githubusercontent.com
claims:
aud: workqueue-service
repository: org/repo
permissions: [submit]
types: [mr]
Environment variables
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
— | PostgreSQL connection URL (required) |
SENTRY_DSN |
— | Sentry DSN for error reporting |
WORKQUEUE_SERVICE_CONFIG |
/etc/workqueue/config.yml |
Config file path |
WORKQUEUE_SERVICE_LOCAL_AUTH_USER |
— | Dev-only auth bypass (warns at startup) |
WORKQUEUE_SERVICE_URL |
— | CLI: service URL |
WORKQUEUE_SERVICE_TOKEN |
— | CLI: Bearer token |
(per-project token_env) |
— | GitLab API token(s) for MR executor (set in config projects) |
WORKQUEUE_SERVICE_EVENTS_QUEUE_URL |
— | SQS queue URL for events (when unset, event consumer is disabled) |
WORKQUEUE_SERVICE_AUDIT_S3_BUCKET |
— | S3 bucket for audit log (production) |
WORKQUEUE_SERVICE_AUDIT_S3_PREFIX |
workqueue-service-audit |
Key prefix within the audit S3 bucket |
AWS_ACCESS_KEY_ID |
— | AWS credentials for S3 audit archival |
AWS_SECRET_ACCESS_KEY |
— | AWS credentials for S3 audit archival |
AWS_DEFAULT_REGION |
— | AWS region for S3 audit archival |
Development
Local Development
cd workqueue-service
./dev.sh db-start # Start local PostgreSQL
./dev.sh db-migrate # Run Alembic migrations
./dev.sh start # Start the service
./dev.sh db-shell # PostgreSQL shell
./dev.sh db-stop # Stop PostgreSQL
./dev.sh db-reset # Stop and delete volume
Running Tests
cd workqueue-service
pip install -e ".[dev]"
python -m unittest discover tests
Tests use testcontainers[postgres] locally (auto-detects Podman) or a
CI-provided PostgreSQL sidecar via DATABASE_URL.
License
This project is licensed under the GNU General Public License v3.0 or later — see the LICENSE file for details.