This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Lot's of background information

Background and explanation documentation for understanding Project Hummingbird systems and concepts.

1 - RPMs repository

Background documentation and contributing guide for Project Hummingbird RPM packages.

Thank you for your interest in contributing! This repository contains RPM packaging, minimal tests, and CI plumbing used by Hummingbird to build, lint, and validate RPMs in containers and Testing Farm.

This guide is adapted from the Hummingbird containers contribution guide and aligned with our workflows and tooling. See the original for broader context: Hummingbird Containers CONTRIBUTING.

Code of Conduct

Be respectful and constructive. By participating, you agree to uphold a professional and inclusive environment.

Repository layout

  • rpms/<package>/ – RPM dist-gits (spec, sources), mostly auto-imported
  • ci/ – helper scripts and default tests
    • build_rpms.sh – build RPMs using Mock in the Konflux-compatible container
    • run_tests_rpm.sh – run rpmlint and install/rebuild tests in a container
    • default-tests/tests-rpm.yml – default tests included for all packages
    • run_tests_rpm.fmf – tmt/Testing Farm entry point
  • konflux-templates/ – Konflux/PAC resources
  • mock/ – mock configuration
  • test/rpms/<package>.yml – package-specific tests

Prerequisites

  • Fedora or RHEL-like environment
  • Podman
  • rpmlint
  • tmt (optional, for local TF-style runs)
  • jq, python3, python3-yaml (used by run_tests_rpm.sh)

Build locally

Build a package’s SRPM and RPMs using the Konflux-aligned environment:

./ci/build_rpms.sh <package_name>
# Results are written to: /tmp/konflux-build-<package_name>-*/results/

Building in Lima VM

When building inside a Lima VM (macOS users), use the --build-dir flag to specify a directory on the VM’s native filesystem. This avoids permission issues with mock’s bootstrap process on macOS mounts:

limactl shell fedora bash -c 'cd /path/to/repo && ./ci/build_rpms.sh --build-dir /tmp/rpm-build <package_name>'

The build directory must be on the VM’s native filesystem (not a macOS mount) to ensure Linux file ownership, permissions, and symlinks work correctly with /var/lib/mock.

Interactive repository debugging

To investigate package/dependency/installability issues, you can run an interactive shell in the same environment used by the package builds:

./ci/build_rpms.sh --shell-before setup

This will show you the mock command that would be used to build the setup package, and drop you into a shell in the same environment before executing the build, with dnf available. You can then run the command manually and inspect the environment, repositories, and package metadata.

If you want it to actually build the local package first, so that you can include it in your investigation, use --shell-after instead:

./ci/build_rpms.sh --shell-after setup

Test locally (containerized)

Run the default and package-specific tests against one or more built RPMs:

# Basic usage (binary rpm)
./ci/run_tests_rpm.sh --rpm /path/to/pkg-1.2-1.fcXX.x86_64.rpm <package_name>

# Include source RPM tests
./ci/run_tests_rpm.sh \
  --rpm /path/to/pkg-1.2-1.fcXX.x86_64.rpm \
  --src-rpm /path/to/pkg-1.2-1.fcXX.src.rpm \
  <package_name>

# Test all RPMs in the build output directory after build_rpms.sh
./ci/run_tests_rpm.sh $(printf -- '--rpm %s ' builds/<package_name>/RPMS/*.rpm) \
  --repo-dir builds/<package_name>/RPMS/ \
  --src-rpm builds/<package_name>/SRPMS/*.src.rpm \
  <package_name>

Notes:

  • Tests run inside a Podman container.
  • The test image defaults to registry.access.redhat.com/hi/core-runtime:latest-builder. You can override via TEST_IMAGE:
TEST_IMAGE=registry.access.redhat.com/hi/core-runtime:specific-tag ./ci/run_tests_rpm.sh --rpm /path/to/pkg.rpm <package_name>
  • Container execution is performed as root with HOME=/root to avoid XDG state permission issues in dnf5.

Test in tmt/Testing Farm locally

You can drive the same FMF test locally with tmt. The FMF test expects an OCI artifact that contains RPMs, referenced via IMAGE_URL (Testing Farm sets this automatically). For local trials you can point to any compatible OCI artifact or skip the ORAS pull logic and directly call the script.

# If using an OCI artifact with RPMs
IMAGE_URL="oci://registry/namespace/artifact:tag_or_digest" tmt run -a

# Or run the script directly with built RPMs (bypassing the FMF wrapper)
./ci/run_tests_rpm.sh --rpm /path/to/pkg.rpm --src-rpm /path/to/pkg.src.rpm <package_name>

If you need longer time in Testing Farm, the FMF test includes duration, which you can adjust in ci/run_tests_rpm.fmf.

Dist-git Imports

The ci/dist_git.py tool imports Fedora/CentOS dist-git packages into the rpms/ directory. We expect most packages to not have (permanent) Hummingbird specific changes, so most of them will keep syncing with upstream dist-gits. In most cases that will be Fedora rawhide, but for some packages we may pick a different upstream, e.g. stable Fedora or even CentOS Stream.

The status of all imports is tracked in imports.json. Active Fedora releases are tracked in upstream-releases.json, which is updated from the Bodhi API.

Note that rawhide is automatically resolved to the highest numbered Fedora release at runtime and is not stored in the JSON file.

See ./ci/dist_git.py --help for all available options. Some examples:

  • Update upstream-releases.json from Bodhi API (should be done periodically, e.g., when new Fedora versions are released):
./ci/dist_git.py update-releases

This fetches the latest Fedora releases from Bodhi. The rawhide branch automatically resolves to the highest numbered Fedora version (e.g., if f43 and f44 are available, rawhide uses f44).

  • Import a new package. This requires specifying the dist-git URL (with fedora/ being a shortcut for the Fedora dist-git URL) and optionally a branch (default: rawhide):
./ci/dist_git.py import fedora/bash

./ci/dist_git.py import --branch f42 fedora/glibc
./ci/dist_git.py import --branch c10s https://gitlab.com/redhat/centos-stream/rpms/postfix.git
  • Update all or a single package:
./ci/dist_git.py update

./ci/dist_git.py update bash

This only imports changes if these were actually built in Koji, to ensure we only import changes which are meant to be released. You can disable this check with --skip-build-check.

  • Re-sync a package to upstream, discarding any local modifications. We use this after Fedora adopted our change, or it is no longer relevant:
./ci/dist_git.py sync bash

All of these commands automatically commit changes with descriptive commit messages including the upstream SHA. To avoid that, you can use the --dry-run option.

  • Enable upstream version tracking for a package (used by check_upstream_versions.py check). These commands modify the metadata file but do not create a git commit:
./ci/dist_git.py set-upstream bash --track
./ci/dist_git.py set-upstream bash --no-track
  • Check upstream version status. The check subcommand checks tracked packages; list shows all packages in a table:
./ci/check_upstream_versions.py check
./ci/check_upstream_versions.py list
./ci/check_upstream_versions.py list --json

Package-specific overrides

Per-package build configuration can be customized in ci/package-overrides.yaml. If a package is not listed, it uses default settings.

Available options:

Option Description Default
timeout_hours Build timeout in hours 4
build_platforms List of MPLs (instance sizes) for multi-platform builds Pipeline defaults

Example configuration:

# Long-running package with custom timeout
setup:
  timeout_hours: 12

# Package requiring larger build instances
llvm:
  timeout_hours: 12
  build_platforms:
    - "linux-d160-c8xlarge/arm64"
    - "linux-d160-c8xlarge/amd64"

All available MPLs can be found in the Konflux multi-platform builds documentation.

After modifying overrides, regenerate the pipeline files:

make generate

This updates .tekton/rpms-on-push.yaml and .tekton/rpms-on-pull-request.yaml with the new configuration.

Branching and pull requests

  • Create feature branches from the default branch.
  • Keep edits focused and small; separate unrelated changes into separate PRs.
  • Include meaningful commit messages (why + what). Reference related issues if applicable.
  • PRs must pass CI (build + tests). Fix lint/test failures or mark known failures properly.

Commit message conventions

  • First line: short imperative summary (≤ 72 chars)
  • Body (optional): context, rationale, and user/ops impact
  • Reference issues using standard notation (e.g. “Fixes: #123”)

Spec guidelines

  • Don’t use %changelog for Hummingbird specific changes. It’s just a point of conflict with Fedora imports, commit messages are good enough.
  • Hummingbird specific changes increase Release: number in steps of 0.1 (e.g. 2 → 2.1 → 2.2). This avoids colliding with Fedora’s Release number namespace and allows us to sync again to a future -3.

Packaging and testing guidelines

  • Spec files should be reproducible and minimal.
  • Prefer pinned container digests for CI images where feasible. If a tag and digest are both present (name:tag@sha256:<digest>), the digest is authoritative for content selection.
  • Default tests in ci/default-tests/tests-rpm.yml must be fast, deterministic, and safe for all packages.
  • Package-specific tests (rpms/<package>/tests-rpm.yml) can override or extend defaults. Keep them bounded in runtime and dependencies.
  • When possible, capture flaky or environmental issues under known_issues in test YAML with clear matching patterns and descriptions.

Style

  • Shell: bash with set -euo pipefail, readable variable names, and early returns where reasonable.
  • YAML: consistent indentation and quoting; prefer explicitness over magic.
  • Comments: add when necessary to explain non-obvious rationale; avoid restating the code.

Security and supply chain

  • Avoid embedding credentials or secrets; use environment variables or CI secret stores.
  • Prefer pulling images by digest for stability and repeatability in CI.
  • Validate inputs and sanitize any paths used by scripts.

Resolving CVEs

When manually fixing a CVE (e.g., adding a patch or performing a manual update), a Fixes: CVE-YYYY-XXXX must be added to the MR description and/or a commit. This will indicate to the build system that a CVE fix has been submitted for review.

Reporting issues

Open an issue describing the problem, reproduction steps, and environment. Attach logs (build/test) when possible. If the failure is intermittent, call that out explicitly.

Licensing

Ensure files include appropriate licenses and that any third-party content is compatible with the project’s license.

Thank you

Your contributions make the Hummingbird RPMs better for everyone. We appreciate your time and feedback!

1.1 - RPM Pipeline

How an RPM spec change flows through the build pipeline to the package repository

Explanation of the RPM build pipeline, from spec file changes through validation, building, signing, and publishing to the Hummingbird package repository.

Overview

The RPM pipeline consists of five main stages:

  1. Spec Change - A package spec file is modified in rpms/<package>/
  2. Merge Request & Validation - CI validates the change
  3. Build - Konflux builds RPMs per architecture via mock in hermetic mode
  4. Signing - Built RPMs are cryptographically signed
  5. Publishing - Signed RPMs are uploaded to the Hummingbird Pulp repository

After publishing, updated RPMs are picked up by the container image pipeline via lockfile updates.

flowchart TD
    A["Spec Change<br/>rpms/&lt;package&gt;/"] --> B["Merge Request"]
    B --> C["CI Validation<br/>(check, tree_status)"]
    C --> D["Merge to main"]
    D --> E["Konflux RPM Build<br/>Tekton PipelineRun per package<br/>mock hermetic build per arch"]
    E --> F["RPM Signing<br/>Kerberos-based"]
    F --> G["Publish to Pulp<br/>packages.redhat.com<br/>/public-hummingbird/&lt;arch&gt;/"]
    G --> H["Container lockfile updates<br/>(see Image Pipeline)"]

Stage 1: Spec Change

An RPM change begins as a commit in rpms/<package>/. The change typically includes a modified .spec file (updated version, new patch, or Release bump) and any associated source files or patches.

How changes originate

Method Description Modification Status
Automated Fedora sync ci/dist_git.py update merges upstream Fedora changes No independent packages
Upstream version update ci/check_upstream_versions.py --update bumps to new upstream release No independent packages
Manual patch backport Developer adds a CVE patch and references it in the .spec Marked modified afterward
No-change rebuild ci/dist_git.py rebuild <package> bumps the Release field Status unchanged
Reverse dependency rebuild ci/dist_git.py rebuild-rev-deps <package> rebuilds all dependents Status unchanged

See Rebuilding Packages and Updating Dist-git Packages for detailed workflows.

Package metadata

Each package has a metadata file at metadata/<package>.json that tracks its relationship to upstream:

{
  "source": "https://src.fedoraproject.org/rpms/curl.git",
  "branch": "rawhide",
  "sha": "abc123...",
  "version": "8.12.1",
  "release": "1",
  "modification_status": "clean",
  "upstream_repo": "https://github.com/curl/curl"
}

Key fields:

Field Purpose
modification_status clean (auto-updates enabled), modified (auto-updates blocked), or independent (no upstream)
modification_reason Explanation of local modifications (when modified)
release Fedora release at last import/update (not the spec Release:), without dist tag; present only while shipping Fedora’s release
upstream_repo Canonical upstream git repository URL
track_upstream Version prefix constraint (e.g., "1.26" for golang1.26)
version_transform Version mapping rule for CVE analysis (e.g., dotnet_sdk_to_runtime)
cve_product CVE vendor/product override (string, or a list for multiple products; e.g. "Oracle Corporation / Oracle Java SE")

See Package Metadata Fields for modification_status and release configuration, and Package Modification Tracking for managing modification status day to day.

Stage 2: Merge Request & Validation

Changes reach main via merge requests. Automated MRs are created using:

  • ci/create_mr.sh - Single MR with configurable options
  • ci/rebuild_multi_mr.sh - One MR per package with auto-merge enabled
  • ci/dist_git_update_multi_mr.sh - Automated Fedora update MRs (scheduled CI job)
  • ci/upstream_update_multi_mr.sh - Automated upstream version update MRs (scheduled CI job)

The scheduled upstream-version update job creates normal MRs for successful package updates. If a package update fails after a new version has been identified, it creates (or reuses) a draft diagnostic MR instead. The diagnostic MR includes the original error but no partial package changes, and must be resolved manually before a package update can be proposed.

Both scheduled update jobs send a Slack completion message when SLACK_WEBHOOK_URL is configured: success sends a plain success message, including when upstream warnings were logged, while failure includes actionable package failures and links to MRs needing attention. Routine upstream warnings remain in the job log and summary, not in Slack.

These completion messages also act as a heartbeat. The notification runs in the job’s after_script, so a job that never starts, or a runner failure before after_script can run, may send no message. A missing expected message is a signal to check the schedule and runner availability; it is not an automatic missed-run alert.

CI validation

GitLab CI runs validation jobs on every merge request:

  • check - Linting, type checking, and spec validation
  • check_nevr_conflicts - Predicts the NEVR(A) each changed package’s spec would build (using rpmspec with the real dist-tag macros, without actually building) and checks it against the public Pulp repo. Catches the case where a Konflux build succeeds and the MR merges, but the resulting NEVR was already published (typically two independent Release bumps based on stale main, e.g. two rebuild MRs), which fails the post-merge build+sign+publish pipeline since Pulp treats each NEVRA as unique and immutable. Only scheduled for MRs that touch rpms/**/*, and within that, only checks packages whose rpms/<package>/ directory itself changed – matching Konflux’s own build trigger – not packages whose only change is under metadata/, since a metadata-only change (e.g. ci/dist_git.py mark-modified) never causes a rebuild/republish. Run locally with make check-nevr-conflicts ARGS='<package>', or against real built RPMs with make check-nevr-conflicts ARGS='--rpms-dir builds/<package>' after ci/build_rpms.sh. See ci/check_nevr_conflicts.py’s module docstring for full details. Known limitations: only the public signed Pulp domain is checked (packages routed to a private_product are noted but not fully verified, since their domains require Pulp API credentials this tool doesn’t have); and since it reflects Pulp’s state at job-run time, it reduces but cannot fully close the race where another MR publishes the same NEVR between this check passing and this MR’s eventual merge – only a hard check inside the actual Konflux publish pipeline (a separate pipeline-bundle repository, quay.io/hummingbird-ci/rpmbuild-pipeline, not covered by this job) could close that window completely; and in spec mode some OK results can be for a package name a real build never actually produces (e.g. a base package with no top-level %files section, roughly 14% of packages in this repo) – expected and harmless, and never a reason to discount a CONFLICT found elsewhere in the same run.
  • tree_status - Repository consistency checks (metadata integrity, spec file presence)
  • Testing Farm - Integration tests run via an IntegrationTestScenario that exercises built RPMs on a real RHEL compose (see Testing below)

Auto-approval for chore MRs

Automated update MRs (chore/* branches) follow an accelerated path:

  1. MR is created with auto-merge enabled
  2. Konflux builds the package and posts commit statuses
  3. After a delay, the chore_mr_approval CI job auto-approves the MR
  4. GitLab merges the MR once the pipeline succeeds

Stage 3: Build

After merging to main, Konflux builds RPMs automatically.

Build triggers

Each package has a Tekton PipelineRun definition generated from its directory in rpms/<package>/ (see .tekton/rpms-on-pull-request.yaml.j2 and the rendered .tekton/rpms-on-pull-request.yaml). PipelinesAsCode triggers a build when changes to that package’s directory are pushed to main. When a single push touches multiple packages, one PipelineRun is triggered per affected package directory. Merge request pushes also trigger builds for validation.

The setup package acts as a canary: changes to ci/ or mock/ directories also trigger a setup build on pull requests, validating build infrastructure changes before they reach main.

Build process

The build pipeline (build-rpm-package) executes for each target architecture:

  1. Checks out the repository at the merge commit
  2. Calculates build dependencies
  3. Runs mock in hermetic mode (network-isolated with pre-fetched dependencies)
  4. Produces RPM artifacts and build logs
  5. Generates an SBOM (Software Bill of Materials)
  6. Stores artifacts as Trusted Artifacts in the Konflux OCI registry

Build architectures

Packages are built for the architectures specified in their PipelineRun definition. Packages build for x86_64, aarch64, and s390x by default. Per-package overrides can select other architectures or build platforms.

Build output

Build artifacts are stored in the Konflux OCI registry:

quay.io/redhat-user-workloads/hummingbird-tenant/<package>--main

Konflux resources

Build infrastructure is defined across two repositories:

  • rpms repo (konflux-templates/) - Per-package Component and ImageRepository resources, ReleasePlanAdmission, EnterpriseContractPolicy
  • infrastructure repo (kubernetes/rpms-main/) - Application, ReleasePlan, IntegrationTestScenario, ServiceAccount, and Secret resources

See Konflux Resource Deployment for details on how these resources are managed and deployed.

Testing

RPM packages are validated through integration tests that run on Testing Farm infrastructure.

Tests run via Testing Farm on RHEL-9-Nightly systems for both x86_64 and aarch64 architectures. Test results appear as external jobs in GitLab CI pipelines, providing pass/fail status and links to the Konflux PipelineRun.

Test triggering

Tests are only triggered on merge requests, not on main branch builds. For a package at rpms/<package>/, tests are triggered for all changes below that directory.

Integration Test Scenario

Tests are triggered via an IntegrationTestScenario resource defined in the infrastructure repository:

Configuration: infrastructure/kubernetes/rpms-main/10-integration-test-scenarios-testing-farm.yml.j2

The scenario uses the upstream Testing Farm pipeline for Konflux CI and is parameterized as follows:

Scenario parameters:

  • COMPOSE: RHEL-9-Nightly - Test environment OS/compose
  • PIPELINE_MODE: rpm - Configures RPM testing mode (vs. container)
  • ARCH: x86_64,aarch64 - Architectures to test (creates separate test runs per arch)
  • PASS_SNAPSHOT_TO_TF: false - Don’t pass full snapshot JSON (package info comes via IMAGE_NAME/IMAGE_URL instead)
  • IMAGE_TAG: - Version of the tmt-via-testing-farm pipeline bundle

Key characteristics:

  • Single scenario for all packages - Not per-package, runs for every package build
  • Context-based triggering - Runs on pull_request snapshots only
  • Package identification - Konflux automatically provides IMAGE_NAME (e.g., openssl-main) and IMAGE_URL (OCI image with built RPMs) as environment variables

FMF test plan

The root folder of the rpms repository is marked with a .fmf directory to identify it as an FMF metadata tree for tmt. The test plan in ci/run_tests_rpm.fmf sets up the test instance and runs default and package-specific tests via ci/run_tests_rpm.sh.

The plan configures Podman to use public DNS servers 1.1.1.1 and 8.8.8.8 inside test containers. This selects the public Pulp endpoint to match customer access. Containers use their original CA trust stores.

Test environment

These environment variables are available to the tmt test execution:

Variable Description
IMAGE_NAME Component name (e.g., openssl-main)
IMAGE_URL OCI image URL with built RPMs (e.g., quay.io/.../openssl-main@sha...)
SNAPSHOT Snapshot metadata (if PASS_SNAPSHOT_TO_TF is true)
COMPOSE OS/compose being tested (RHEL-9-Nightly)
ARCH Architectures being tested (x86_64,aarch64)
TMT_VERSION tmt version running the tests

Stage 4: Signing

The Konflux Release Service signs built RPMs before publishing:

  • Method: Kerberos-based signing via konflux-release-signing-prod@IPA.REDHAT.COM
  • Pipeline: Signing runs as part of the release pipeline, before the Pulp upload step
  • Signing image: quay.io/konflux-ci/signing:latest (verify pinned digest in ReleasePlanAdmission)

Stage 5: Publishing to Pulp

After signing, RPMs are uploaded to the Hummingbird Pulp repository.

Release pipeline

The push-rpms-to-pulp release pipeline handles the upload. Configuration is defined in the ReleasePlanAdmission resource (releng/hummingbird-rpms-tech-preview-staging.yaml):

mapping:
  rpm-repositories:
    - name: x86_64
      repository_id: public-hummingbird-x86_64-rpms
    - name: aarch64
      repository_id: public-hummingbird-aarch64-rpms
    - name: src
      repository_id: public-hummingbird-source-rpms
    # See ReleasePlanAdmission for the full list of published architectures

Pulp repository

Published RPMs are available in the Pulp content index:

Pulp automatically regenerates repository metadata (repodata) after each upload, making new packages immediately resolvable by DNF/YUM clients.

What Happens Next

Once RPMs are available in Pulp, the container image pipeline picks them up via lockfile updates. See the Image Pipeline documentation in the containers repo for the full container lifecycle.

1.2 - Konflux Resource Deployment

How Konflux resources are defined and deployed across repositories

Konflux resources for RPM packages are split between two repositories:

  • rpms: Per-package resources generated from package directories
  • infrastructure: Application-level resources and deployment to the Konflux cluster

This separation allows independent iteration on each concern.

Deployment Matrix

Resource Type Source RPMs MR RPMs Push Infra MR Infra Push
Component rpms manual manual - -
ImageRepository rpms manual manual - -
ReleasePlanAdmission rpms - automatic manual automatic
EnterpriseContractPolicy rpms - automatic manual automatic
Application infrastructure - - manual automatic
ReleasePlan infrastructure - - manual automatic
IntegrationTestScenario infrastructure - - manual automatic
ServiceAccount, Secret infrastructure - - manual automatic

Legend:

  • RPMs MR: Downstream pipeline triggered from rpms MR (deploys from MR commit)
  • RPMs Push: Downstream pipeline triggered from rpms push to main
  • Infra MR: Infrastructure MR pipeline (manual trigger)
  • Infra Push: Infrastructure push to main or web pipeline

Resource Locations and Rationale

RPMs Repo

Resources are defined in konflux-templates/ and rendered to konflux-templates/rendered.yml.

Component and ImageRepository:

  1. Ownership: Components are managed by the rpms repo. The infrastructure pipeline uses ONLY_DOWNSTREAM so only explicit downstream triggers from rpms deploy changes, giving the rpms repo full control over the component lifecycle.
  2. Dynamic generation: Generated from rpms/*/ directories via ci/generate_konflux_resources.sh, depending on package presence and configuration.
  3. Timing: Must be deployed early during MR review so Konflux can build and test new packages.

ReleasePlanAdmission:

  1. Static configuration: Unlike containers (where RPA contains per-image tag mappings), the RPMs ReleasePlanAdmission is static—it configures the Pulp publishing pipeline without per-package data. It stays in the rpms repo for consistency with the containers pattern.
  2. Main branch only: Deployed only on push to main to maintain deployment consistency.

EnterpriseContractPolicy:

  1. Consistency: Follows the containers repo pattern of keeping policy definitions alongside the resources they govern.

Infrastructure Repo

Application, ReleasePlan, and IntegrationTestScenario are defined in kubernetes/rpms-main/:

  1. Independent iteration: Changes are decoupled from rpms repo activity—they can be modified, test-deployed via manual trigger in an infrastructure MR, verified, and merged without touching the rpms repo.
  2. Testable before merge: If defined in the rpms repo, these would only deploy after merging to main, making iteration difficult.
  3. Static configuration: These resources don’t depend on per-package data.

ServiceAccount and Secret for releases are defined in kubernetes/setup-konflux/:

  1. Security: Secret specifications (names, structure, credential references) should not be exposed in the rpms repo.
  2. Independent iteration: Like other infrastructure resources, these can be modified and test-deployed without touching the rpms repo.

The service account is referenced by ReleasePlanAdmission to authorize publishing RPMs to Pulp.

1.3 - Source Pipeline Tool

Source Pipeline Tool

Codename: Gorget — the iridescent throat patch that makes hummingbirds distinctive. In ornithology, the gorget is the defining feature used to identify species; in this project, it represents the verification layer that distinguishes independently-sourced packages from unverified ones.

Problem Statement

RPM-based distributions that derive packages from upstream sources face a common set of supply chain challenges:

  1. Supply chain trust gap. Distributions that consume source tarballs from another distribution’s lookaside cache (e.g., Fedora’s) inherit an unverified trust boundary. Package maintainers download upstream release artifacts, may modify them, and upload to a lookaside cache. Downstream consumers use those tarballs without verifying they match upstream. Any maintainer could, intentionally or unintentionally, introduce modifications that compromise packages.

  2. Version dependency on upstream packagers. Downstream distributions are blocked on upstream maintainers to package new releases. If upstream ships a critical fix, downstream must wait for the intermediate distribution to update before consuming it.

  3. Non-durable security transforms. When a distribution applies a security fix to vendored dependencies (e.g., patching a lockfile to bump a vulnerable transitive dependency), a future upstream update regenerates vendor artifacts from the upstream baseline. The fix silently disappears. The update succeeds, CI passes, and the vulnerable dependency is back.

  4. Unverified patches. Patches (.patch files) from upstream distributions flow into packages without verification. There is no check whether patches correspond to upstream commits, whether they are needed for the target build environment, or whether new patches have been introduced.

Tool Overview

A containerized pipeline tool that reads a declarative per-package YAML definition, fetches source tarballs directly from upstream, applies transforms, verifies integrity, enforces policies, and emits artifacts ready for a lookaside cache.

                          ┌──────────────────────────────┐
                          │     source-pipeline tool     │
                          │      (container image)       │
                          │                              │
  ┌───────────┐           │  ┌───────┐    ┌───────────┐  │    ┌───────────────┐
  │  package  │           │  │ fetch │───▸│ transform │  │    │   tarballs    │
  │   dir     │──────────▸│  └───────┘    └─────┬─────┘  │───▸│   sources     │
  │ (spec,    │           │                     │        │    │   report      │
  │  patches, │           │  ┌───────────┐      │        │    └───────┬───────┘
  │  config)  │           │  │  verify   │◂─────┘        │            │
  │           │           │  └─────┬─────┘               │            ▼
  └───────────┘           │        │                     │    ┌───────────────┐
                          │  ┌─────▼─────┐               │    │   lookaside   │
  ┌───────────┐           │  │  enforce  │               │    │    cache      │
  │ pipeline  │           │  │  policy   │               │    └───────────────┘
  │   yaml    │──────────▸│  └─────┬─────┘               │
  │           │           │        │                     │
  └───────────┘           │  ┌─────▼─────┐               │
                          │  │   emit    │               │
                          │  └───────────┘               │
                          └──────────────────────────────┘

Design principles

  • Runs anywhere. Same container image via podman — locally for development, in CI for automation, in SLSA-attested build environments for provenance.
  • Declarative. Per-package behavior is defined in YAML, not imperative scripts. The YAML serves as an attestation artifact — anyone can read it to understand exactly how a package’s sources are produced.
  • Fail-closed. Verification failures and policy violations fail the pipeline. No silent fallbacks. On failure, the package is skipped (stays at its current version) and the automation continues to the next package.
  • Ecosystem-aware. Built-in support for Go (vendor archives), npm (node_modules), cargo (vendor), and Composer (PHP) workflows, with escape hatches for custom transforms.

Container interface

podman run --rm \
  -v ./<package-dir>:/package:ro \
  -v ./pipeline.yaml:/pipeline.yaml:ro \
  -v ./gpg-keys:/gpg-keys:ro \
  -v ./output:/output \
  source-pipeline:latest \
  --version <new-version> \
  [--old-version <old-version>] \
  [--verify-patches /patches-from-upstream] \
  [--dry-run]

Inputs (mounted read-only):

  • /package — the package directory (spec file, patches, existing sources file)
  • /pipeline.yaml — the declarative pipeline definition
  • /gpg-keys — centralized GPG keyring directory

Outputs (written to /output):

  • Source tarballs (ready for lookaside upload)
  • sources — updated sources manifest with checksums
  • report.json — verification and policy results (pass/fail per check, patch classifications)

Exit codes:

  • 0 — success, all checks passed
  • 1 — error (download failure, tool error)
  • 2 — policy violation (verification or constraint failure)

--dry-run runs all stages through Verify and Policy but skips Emit — no tarballs are written to /output. Exit codes and report.json behave identically, so developers can preview what would happen without producing artifacts. Useful for validating a new pipeline YAML before committing.

On any non-zero exit, report.json is still written with the failure details (stage, error type, message). The calling automation uses this to log the failure and skip the package — no package update commit is created, the package stays at its current version, and the automation continues to the next package. In the scheduled upstream-version-update job, the wrapper also creates (or reuses) a draft diagnostic MR for a per-package update failure. That MR contains the full error and no partial package changes, so a maintainer can reproduce and resolve the problem safely. Transient failures (exit 1) may self-heal on the next scheduled run; verification and policy failures (exit 2) require human intervention. When a later scheduled run successfully processes the same package, it closes and removes the still-empty diagnostic MR branch. A diagnostic MR with maintainer changes is left open for manual handling.

Pipeline Stages

1. Fetch

Downloads source artifacts directly from upstream.

  • Parses Source: URLs from the spec file (resolving RPM macros like %{version}, %{name}, %{url})
  • Downloads each source from the upstream URL
  • Supports fetching from git repositories at a tag, branch, or commit
  • For packages without a pipeline YAML, uses a default fetch-from-spec behavior (covering the “trivial” package case)

2. Transform

Applies per-package source modifications.

  • Vendor archive generation — runs ecosystem-specific tooling (e.g., go_vendor_archive for Go, npm pack for Node.js, cargo vendor for Rust)
  • Vendor dependency pinning — modifies lockfiles (go.mod/go.sum, package-lock.json, Cargo.lock) to bump specific dependencies to required versions before vendoring. This is the enforcement counterpart to policy’s validation: pins are applied during transform, then policy confirms the result. Solves the non-durable security transforms problem — a declarative pin is re-applied on every source generation, so upstream updates cannot silently revert a CVE fix.
  • Source stripping — removes content that cannot be distributed (crypto, bundled pre-built binaries, non-free assets)
  • UI asset builds — builds JavaScript/TypeScript UI assets from source (for packages like Prometheus, Jaeger, Grafana that currently vendor pre-built UI)
  • Custom transforms — escape hatch for arbitrary commands when built-in stages are insufficient
  • Toolchain versioning — packages can declare required toolchain versions (Node.js, Go, Rust, etc.) via a toolchain: section. The container ships defaults; per-package overrides ensure that build-ui, vendor, and run: steps use the correct toolchain without requiring separate container images per package

Known sharp edge: patch-list duplication

Two independent mechanisms hand-apply changes outside of %prep, and neither reads its list from the spec’s PatchN: declarations — so both can silently drift out of sync with what the spec actually declares. This is the canonical writeup; rebuilding-packages.md and the /cve skill point back here instead of re-telling the story.

Custom run: transforms that hand-apply patches before resolving a lockfile (patch -p1 < ... followed by yarn install/pnpm fetch/etc., as grafana’s yarn-cache generation does) maintain their own copy of “which patches touch this source tree” rather than reading it from the spec’s PatchN: declarations. Nothing enforces these two lists stay in sync. A patch added to the spec through the normal backport workflow (see Rebuilding Packages) is invisible to the transform unless a human also updates the pipeline YAML — and if that patch touches a file the transform’s install command resolves against (a lockfile or manifest), the generated artifact silently drifts from what %build actually applies. This broke grafana12.4 and grafana13.1: four separate CVE backports bumped yarn.lock without updating the pipeline’s yarn-cache generation step, and the mismatch didn’t surface until the next automated version bump re-ran the transform from a pristine checkout. test/test_source_pipeline_patches.py checks for this drift across all packages with a pipeline definition, but it’s a safety net for a design gap, not a fix for it: a built-in transform primitive that reads PatchN: from the spec directly, instead of requiring the YAML to hand-duplicate the list, would close this class of bug at the source rather than relying on the check to catch it after the fact.

go-vendor-tools.toml’s [archive] pre_commands have the same shape of problem for Go packages, whether or not they’re migrated to gorget: pre_commands (sed edits, go get bumps, go mod tidy) only ever run against the vendor archive’s own checkout, never against the plain source tarball (Source0), which is fetched separately. A patch that bumps a vendored dependency’s version must be mirrored into go.mod/go.sum by a spec patch — nothing keeps pre_commands and the spec’s patches in sync. This broke trivy: a CVE backport added go get calls to pre_commands to bump vendored dependencies (CVE-2026-15788/15792/56852) without a matching spec patch, so go.mod in the build tree and vendor/modules.txt in the generated vendor archive ended up requiring different versions of the same package — go build -mod=vendor rejected it as inconsistent vendoring. The mismatch sat latent for over a week until an unrelated version bump’s %check run finally caught it. test/test_govendortools_gomod_patch_sync.py checks for this drift, same caveat as above.

3. Verify

Validates integrity and authenticity of fetched sources.

  • GPG signature verification — downloads signature files (.asc, .sig) declared in the spec and verifies against upstream keys stored in a centralized keyring directory. Keys are organized by upstream project (e.g., gpg-keys/curl.gpg). Centralized storage means the full set of trusted keys is auditable in one directory, and key rotation or revocation is a single-commit operation.
  • Checksum verification — compares against published checksums where available
  • Reproducibility check — for packages with existing tarballs from another distribution, optionally compares the independently-fetched tarball to identify divergences
  • Re-publication detection — if a previously committed checksum exists in the sources file for the same version and the freshly downloaded artifact does not match, the pipeline fails (exit code 2). This catches upstream projects that silently re-publish release artifacts under the same version. The pipeline will continue to fail on automated retries until a human explicitly updates an accepted-checksums entry in the pipeline YAML with the new hash and a reason. This forces investigation, prevents automated retries from silently accepting changed content, and provides a committed audit trail of what changed and why.

4. Enforce policy

Validates the final artifacts. Acts as a safety net for vendor-pin (confirms pins took effect) and catches violations in packages that don’t use vendor-pin.

  • Vendor dependency constraints — ensures vendored dependencies meet version requirements (e.g., sanitize-html >= 2.17.5 for a CVE fix)
  • Ecosystem-specific checks — Go module verification, npm audit, cargo audit
  • License compliance — flags vendored dependencies with incompatible licenses
  • Fail-closed: any policy violation exits with code 2

5. Emit

Produces final artifacts.

  • Writes tarballs to the output directory
  • Generates sources file in dist-git format (SHA512 (filename) = hash)
  • Writes report.json with verification results, policy check results, and patch classification

Pipeline Schema

Per-package pipeline definitions are YAML files that declare the full source generation workflow.

Packages without a pipeline definition use a built-in default that fetches sources from the spec’s Source: URLs — no transforms, no verification, no policy.

Schema definition

# Spec preparation (optional)
# Runs before fetch — fixes version macros that the specfile library cannot
# trace, so Source URLs resolve correctly.
spec-update:
  # Macro substitutions applied before Source URL resolution
  macros:
    - name: go_patch                     # %global go_patch <new-value>
      value: "${VERSION_PATCH}"          # extracted from VERSION (e.g., 1.25.3 → 3)

    - name: k8s_ver                      # %global k8s_ver <new-value>
      value: "${VERSION}"

  # Reset Release to 0.1%{?dist} on version bump (common for versioned packages)
  reset-release: true

# Source fetching
fetch:
  sources:
    # Fetch Source0 from the URL declared in the spec
    - spec-source: 0

    # Fetch Source1 (e.g., a GPG signature file)
    - spec-source: 1

    # Or fetch from an explicit URL (for sources not in the spec)
    - url: "https://example.com/extra-source-${VERSION}.tar.gz"

    # Or fetch from a git repository at a tag/commit
    - git:
        repo: "https://github.com/example/project"
        ref: "v${VERSION}"              # tag, branch, or commit hash
        include-history: false           # include .git dir in tarball (default: false)

  # Vendor archive generation (optional)
  vendor:
    ecosystem: go                        # go | npm | cargo | composer
    config: go-vendor-tools.toml         # ecosystem-specific config file in the package dir
    source-dir: .                        # directory to vendor from (default: extracted source root)
    submodules:                          # (optional) vendor multiple Go submodules independently
      - server
      - etcdctl
      - etcdutl

# Source transforms (optional, ordered)
transform:
  # Built-in transform types
  - strip-tarball:
      source: "node-v${VERSION}.tar.gz"
      remove:
        - "deps/openssl/"
        - "deps/ngtcp2/ngtcp2/crypto/"
      output: "node-v${VERSION}-stripped.tar.gz"

  # Pin vendored dependency versions before vendor archive generation.
  # Modifies lockfiles (go.mod/go.sum, package-lock.json, Cargo.lock) to
  # bump specific dependencies, then re-resolves the dependency graph.
  # Runs before the vendor stage so the pinned versions are included in
  # the vendor archive. Re-applied on every source generation, so upstream
  # updates cannot silently revert a security fix.
  #
  # Version constraints are always minimum versions — the version field
  # means "at least this version." The tool translates to ecosystem-native
  # operations:
  #   Go:    go get <package>@v<version> (minimum version selection)
  #   npm:   npm install <package>@">= <version>"
  #   Cargo: set dependency requirement to ">= <version>" in Cargo.toml
  - vendor-pin:
      - package: golang.org/x/crypto
        ecosystem: go
        version: "0.31.0"
        reason: "CVE-2024-45337"

      - package: sanitize-html
        ecosystem: npm
        version: "2.17.5"
        reason: "CVE-2024-XXXXX"

      - package: tokio
        ecosystem: cargo
        version: "1.38.1"
        reason: "CVE-2024-YYYYY"

  # Custom command (escape hatch)
  - run: "./packaging/make-tarball.sh ${VERSION}"
    outputs:
      - "package-${VERSION}-stripped.tar.gz"

  # UI asset build
  - build-ui:
      ecosystem: npm                     # npm | yarn
      source-dir: "web/ui"
      output: "${PACKAGE}-${VERSION}-ui.tar.gz"

# Toolchain requirements (optional)
# Declares the toolchain versions a package needs during source generation.
# The container ships default toolchain versions; this section overrides them
# when a package requires something different. Any primitive that invokes a
# toolchain (vendor, build-ui, run) uses the versions declared here.
#
# Packages that don't declare a toolchain section get the container defaults.
# If a required toolchain is not available in the container image, the pipeline
# fails with exit code 1 and a clear message naming the missing tool.
toolchain:
  node: "20"                             # Node.js version for build-ui, npm vendor, and run: steps
  go: "1.23"                             # Go version for go vendor and run: steps
  rust: "1.80"                           # Rust version for cargo vendor and run: steps
  python: "3.12"                         # Python version for run: steps

# Integrity verification (optional)
verify:
  gpg:
    signature-source: 1                  # spec Source index containing the .asc/.sig
    keyring: "curl.gpg"                  # key name in the GPG keys directory
  checksums:
    url: "https://example.com/SHA256SUMS"
    algorithm: sha256

  # Override for upstream re-publications. Required when upstream re-publishes
  # a release artifact with different content under the same version. The
  # pipeline refuses to accept changed content automatically — a human must
  # add the new checksum here after investigation.
  accepted-checksums:
    - file: "example-1.2.3.tar.gz"
      sha512: "abc123..."
      reason: "Upstream re-published with corrected LICENSE file (verified via upstream issue #456)"

# Policy enforcement (optional)
# Validates the final artifacts. vendor-constraints acts as a safety net:
# if vendor-pin (above) is used, policy confirms the pin took effect; if
# vendor-pin is not used, policy catches violations that need manual action.
# Uses the same minimum version semantics as vendor-pin.
policy:
  vendor-constraints:
    - package: sanitize-html
      ecosystem: npm
      version: "2.17.5"
      reason: "CVE-2024-XXXXX"

    - package: golang.org/x/crypto
      ecosystem: go
      version: "0.31.0"
      reason: "CVE-2024-45337"

# Patch verification (optional)
patches:
  verify: true                           # diff-detect new/changed patches
  classify: true                         # attempt to classify by origin
  upstream-repo: "https://github.com/curl/curl"
  fail-on-unverified: false              # warn-only by default

  # Patch lifecycle rules (optional)
  # Declares version-scoped applicability for patches. The pipeline tool reads
  # these rules from patch headers (preferred) or from this YAML, and enforces
  # them during updates: patches outside their valid range are flagged or dropped.
  lifecycle:
    - file: "fix-memory-leak.patch"
      applies-to: "< 1.5.0"             # drop this patch at version 1.5.0+
      reason: "Fixed upstream in 1.5.0 (commit abc123)"
      action: drop                       # drop | warn (default: warn)

    - file: "cve-2024-45337.patch"
      applies-to: "< 0.31.0"
      reason: "CVE-2024-45337 — fixed upstream in golang.org/x/crypto 0.31.0"
      action: drop

    - file: "distro-branding.patch"
      applies-to: "*"                    # carry forward unconditionally
      reason: "Distribution-specific branding, always required"

# Post-update spec modifications (optional)
# Runs after fetch/transform — extracts metadata from downloaded sources and
# patches it into the spec.
post:
  # Extract bundled dependency versions and splice into spec between markers
  - bundled-provides:
      modules-txt: "vendor/modules.txt"  # Go modules.txt path inside extracted source
      start-marker: "# --- bundled-deps.sh ---"
      end-marker: "# --- end bundled-deps.sh ---"

  # Run a custom command (escape hatch for complex metadata extraction)
  - run: "./packaging/fill-versions.sh ${SPEC_FILE} source-v${VERSION}-stripped.tar.gz"

Variable substitution

The following variables are available in all string values:

Variable Value
${VERSION} New upstream version (e.g., 1.25.3)
${VERSION_MAJOR} Major version component (e.g., 1)
${VERSION_MINOR} Minor version component (e.g., 25)
${VERSION_PATCH} Patch version component (e.g., 3)
${OLD_VERSION} Previous version
${PACKAGE} Package name (directory name)
${SPEC_FILE} Path to the spec file

Version constraint semantics

All version: fields in vendor-pin and policy.vendor-constraints use minimum-version semantics: the value means “at least this version.” The tool translates this into ecosystem-native operations:

  • Go: go get <package>@v<version> (minimum version selection via MVS)
  • npm: npm install <package>@">= <version>"
  • Cargo: sets the dependency requirement to >= <version> in Cargo.toml

Default behavior (no pipeline YAML)

When no pipeline YAML is provided, the tool applies a built-in default:

  1. Parse all Source: URLs from the spec
  2. Download each from the upstream URL
  3. Generate sources file with SHA512 checksums
  4. No transforms, no verification, no policy enforcement

This covers the common case where the upstream distribution’s tarball is identical to upstream’s release artifact.

Patch Verification

When packages are updated from an upstream distribution, patch files (.patch) may arrive without inspection. The pipeline tool can optionally verify and manage patches.

Verification approach

The pipeline tool can optionally verify patches when invoked with --verify-patches:

  1. Diff detection. Compare the set of .patch files in the updated package directory against the previous version. Identify new patches, removed patches, and modified patches.

  2. Header parsing. Extract metadata from patch headers:

    • From: — author identity
    • Subject: — description
    • Commit hash references (e.g., From <hash>, cherry picked from commit <hash>)
    • Bug: / CVE: references
  3. Classification. Attempt to classify each patch:

    • Upstream backport — references a commit hash that exists in the upstream repo
    • CVE fix — references a CVE identifier
    • Build/packaging fix — modifies build system files (Makefile, configure, CMakeLists)
    • Distribution-specific — modifies paths, branding, or distribution-specific integration
    • Unclassified — cannot be automatically categorized
  4. Upstream verification. For patches claiming to be backports, check whether the referenced commit exists in the upstream repo (via git ls-remote or the upstream API).

  5. Reporting. Output classification and verification results in report.json. Optionally fail on unverified patches (controlled by patches.fail-on-unverified in the pipeline YAML).

Patch lifecycle enforcement

Patches have version-scoped lifetimes. A CVE backport is only valid until the upstream version that includes the fix. A build system workaround may only apply to a specific major version. Without enforcement, a patch that should have been dropped at version 2.0 silently persists, and a patch that must be carried forward can be accidentally removed during an update.

The pipeline tool enforces patch lifecycle rules declared either in patch headers or in the pipeline YAML’s patches.lifecycle section.

Header-based declaration (preferred). Patch authors add structured keywords to the patch header:

From: maintainer@example.com
Subject: Backport fix for CVE-2024-45337
Applies-To: < 0.31.0
Lifecycle-Action: drop
Lifecycle-Reason: Fixed upstream in golang.org/x/crypto 0.31.0
---

YAML-based declaration (fallback). For patches from upstream distributions that cannot have headers modified, rules are declared in patches.lifecycle in the pipeline YAML (see schema above). Header-based rules take precedence when both exist for the same patch.

Enforcement behavior:

  1. During an update to version ${VERSION}, the tool evaluates each patch’s applies-to range against the new version.
  2. If a patch is outside its valid range:
    • action: drop — the patch file is deleted from the package directory and the corresponding Patch: declaration and %patch / %autopatch application directives are removed from the spec. If the patch is applied inside a conditional block (%if), the tool flags it for manual intervention instead of attempting removal. The tool reports all actions in report.json.
    • action: warn (default) — the patch is flagged in report.json but not removed. The update proceeds.
  3. If a patch has applies-to: *, it is always carried forward.
  4. Patches without any lifecycle declaration are treated as having no version constraint (equivalent to applies-to: *, action: warn).

Carry-forward enforcement. The inverse case is also important: some patches (branding, distribution-specific integration) must never be dropped. If a patch marked applies-to: * is missing after an update (e.g., removed by an upstream distribution sync), the tool flags it as an error.

Example report output

{
  "patches": {
    "new": [
      {
        "file": "fix-memory-leak.patch",
        "classification": "upstream-backport",
        "upstream_commit": "abc123def456",
        "verified": true
      }
    ],
    "removed": ["old-workaround.patch"],
    "unchanged": ["distro-paths.patch"],
    "lifecycle": [
      {
        "file": "cve-2024-45337.patch",
        "applies_to": "< 0.31.0",
        "current_version": "0.31.0",
        "action": "drop",
        "result": "removed — version 0.31.0 is outside applies-to range"
      },
      {
        "file": "cve-2024-99999.patch",
        "applies_to": "< 2.0.0",
        "current_version": "2.0.0",
        "action": "drop",
        "result": "flagged — patch is applied inside a conditional %if block; manual removal required"
      },
      {
        "file": "distro-branding.patch",
        "applies_to": "*",
        "action": "carry-forward",
        "result": "present"
      }
    ]
  }
}

Examples

Trivial package (curl)

No pipeline YAML needed. The default behavior fetches Source0 from the URL in the spec. If you want GPG verification:

fetch:
  sources:
    - spec-source: 0
    - spec-source: 1

verify:
  gpg:
    signature-source: 1
    keyring: "curl.gpg"

Transformed package (Node.js)

Strip bundled OpenSSL, verify upstream checksums, extract component versions into spec:

spec-update:
  macros:
    - name: nodejs_define_version node
      value: "${VERSION}"
  reset-release: true

toolchain:
  node: "22"

fetch:
  sources:
    - url: "https://nodejs.org/dist/v${VERSION}/node-v${VERSION}.tar.gz"

transform:
  - strip-tarball:
      source: "node-v${VERSION}.tar.gz"
      remove:
        - "deps/openssl/"
      output: "node-v${VERSION}-stripped.tar.gz"

verify:
  checksums:
    url: "https://nodejs.org/dist/v${VERSION}/SHASUMS256.txt"
    algorithm: sha256

post:
  - run: "./packaging/fill-versions.sh ${SPEC_FILE} node-v${VERSION}-stripped.tar.gz"

Simple Go vendor (caddy)

A pattern shared by nats-server, oauth2-proxy, and similar Go projects:

fetch:
  sources:
    - git:
        repo: "https://github.com/caddyserver/caddy"
        ref: "v${VERSION}"

  vendor:
    ecosystem: go

Multi-submodule Go vendor (etcd)

fetch:
  sources:
    - url: "https://github.com/etcd-io/etcd/archive/v${VERSION}/etcd-${VERSION}.tar.gz"

  vendor:
    ecosystem: go
    config: go-vendor-tools.toml
    submodules:
      - server
      - etcdctl
      - etcdutl

Code generation + Go vendor (opentelemetry-collector-contrib)

Built-in primitives handle fetch and vendor; the code generation step requires run::

fetch:
  sources:
    - url: "https://github.com/open-telemetry/opentelemetry-collector-releases/archive/v${VERSION}/opentelemetry-collector-releases-${VERSION}.tar.gz"

  vendor:
    ecosystem: go
    config: go-vendor-tools.toml
    source-dir: _build

transform:
  - run: |
      curl -fSL -o ocb "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/cmd%2Fbuilder%2Fv${VERSION}/ocb_${VERSION}_linux_amd64"
      curl -fSL -o ocb_checksums.txt "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/cmd%2Fbuilder%2Fv${VERSION}/ocb_${VERSION}_checksums.txt"
      sha256sum -c --ignore-missing ocb_checksums.txt
      chmod +x ocb
      tar -xzf "opentelemetry-collector-releases-${VERSION}.tar.gz"
      cd "opentelemetry-collector-releases-${VERSION}"
      if [ -f scripts/prepare-obi.sh ]; then
        bash scripts/prepare-obi.sh otelcol-contrib
      fi
      ../ocb --skip-compilation --config distributions/otelcol-contrib/manifest.yaml
    outputs:
      - "opentelemetry-collector-contrib-${VERSION}-generated.tar.bz2"

Git snapshot (libXtst)

A pattern shared by libX11, libXext, libXi, libXrender:

fetch:
  sources:
    - git:
        repo: "https://gitlab.freedesktop.org/xorg/lib/libXtst"
        ref: "libXtst-${VERSION}"
        include-history: true

Built-in Primitives Coverage

An audit of 48 existing tarball/vendor scripts across 25+ packages identified the following coverage:

Fully declarative (no run: needed) — ~30 script files (15 unique patterns):

Built-in primitive Packages covered
vendor: {ecosystem: go} caddy, nats-server×2, oauth2-proxy
vendor: {ecosystem: go, submodules: [...]} etcd (3 submodules)
strip-tarball: {remove: [...]} nodejs×5, cyrus-sasl, perl-libnet, java in-tree libs×2
fetch: {git: {repo, ref}} oniguruma, php-jsqueeze, libX11/Xext/Xi/Xrender/Xtst
fetch + verify: {checksums} nodejs download+verify
vendor: {ecosystem: composer} composer

Partially declarative (generic primitives + 1–2 run: steps):

Package Generic part Custom run: step
otel-collector, otel-collector-contrib vendor: {ecosystem: go} OCB binary code generation
java-openjdk×2 strip-tarball, fetch: {git} ./configure + make store-source-revision (needs boot JDK)
selinux-policy fetch: {git} (×3 repos) Multi-repo selective archiving

Genuinely custom (run: required) — 7 packages:

Package Why it can’t be declarative
nss-fips Container-based RPM download with subscription-manager credentials + QEMU cross-arch
openssl-fips-provider Container-based SRPM download + nested RPM extraction
ca-certificates Interactive multi-source crypto trust data merging
gcc GCC-specific changelog/PR extraction from git history
gdb Interactive patch management tooling (stgit)
python3.14 Koji task-specific JIT stencil artifact extraction
erlang27 Complex patch reformatting + spec rewriting

The run: escape hatch exists for these ~7 packages. All other packages should use built-in primitives to maintain the declarative contract.

Open Questions

  1. Implementation approach. The pipeline YAML schema (ordered transform stages, run: escape hatches, variable substitution) resembles a bespoke Ansible without the ecosystem. The implementation choice also determines the language. Alternatives to consider:
    • Custom tool (Python or Go) — Python is consistent with existing tooling; Go produces a single static binary. Either way we own the full stack.
    • Tekton StepActions — already in some build ecosystems. Source generation could be a parameterized Tekton pipeline rather than a custom tool. Downside: harder to run locally.
    • Shared shell function library — the majority of scripts decompose into 3–4 operations (vendor_go, strip_tarball, fetch_git). A thin shell library called from per-package Makefiles may be more honest than YAML that serializes shell commands.
    • Minimal declarative YAML + driver — keep the YAML as a pure declaration of intent (what to fetch, what to strip, what to vendor) with no run: blocks, no ordering, no conditionals. A thin driver interprets it by calling shell functions. Packages that can’t be expressed this way keep their shell scripts. This preserves the attestation value of the YAML without building a workflow engine.

Hummingbird Integration

This section describes how the source pipeline tool integrates with Hummingbird’s existing infrastructure. The tool itself is distribution-agnostic; this section covers the Hummingbird-specific wiring.

Current Architecture

Two update systems

System Packages Source of tarballs Hook system
dist_git.py update 450 (clean + modified) Fedora lookaside cache (via sources file copied from Fedora dist-git) None
check_upstream_versions.py 22 (independent) Upstream URLs (via download_sources hooks or default spec URL download) Yes: update_spec, download_sources, post_update

dist_git.py update flow:

  1. Clones Fedora dist-git for the package
  2. Checks version, Koji build status, pre-release filtering
  3. Copies entire Fedora dist-git checkout into rpms/<package>/ via shutil.copytree — this includes the spec, patches, .gitignore, and sources file
  4. For modified packages, performs a 3-way git merge to preserve local changes
  5. Commits everything (spec, patches, sources file, metadata)

The sources file is committed to git as a text manifest (format: SHA512 (filename) = hash). Actual tarballs are excluded by .gitignore and stored in a lookaside cache.

check_upstream_versions.py flow:

  1. Queries release-monitoring.org for new upstream versions
  2. Runs three hook phases per package:
    • update_spec — updates the spec’s Version/Release (default: specfile.update_version())
    • download_sources — downloads source tarballs (default: fetches from spec Source URLs)
    • post_update — additional steps (default: no-op)
  3. Uploads downloaded tarballs to Hummingbird’s lookaside cache
  4. Updates the sources file with new checksums
  5. Commits

Hooks are defined in metadata/<package>.update-hooks.yaml. 12 packages currently have hooks.

Three lookaside cache backends

Configured in mock/dist-git-client.ini:

Backend URL pattern Used by
Fedora src.fedoraproject.org/repo/pkgs/rpms/{name}/{filename}/{hashtype}/{hash}/{filename} ~410 packages (default)
CentOS Stream sources.stream.centos.org/sources/rpms/{name}/... 1 package (rust-rpm-sequoia)
Hummingbird d1766whheab9hg.cloudfront.net/rpms/{name}/... (S3: arr-hummingbird-prod-dist-git-cache) ~40 packages (independent + forked)

The forked_from field in ci/package-overrides.yaml determines which cache a package uses at build time. Packages without forked_from default to Fedora’s cache.

How Konflux builds consume sources

Tekton PipelineRuns (generated by ci/generate_resources.py from Jinja2 templates) pass parameters to the build-rpm-package pipeline bundle (quay.io/hummingbird-ci/rpmbuild-pipeline):

  • monorepo-subdir: rpms/<package> — locates the spec, patches, and sources file
  • package-name: <name> — upstream name for lookaside URL construction
  • forked-from: <url> — (optional) selects the lookaside backend
  • dist-git-client-configdir: mock/ — (optional) points to the custom dist-git-client.ini

The pipeline uses dist-git-client to read the sources file and download tarballs from the appropriate cache, then runs mock to build RPMs.

Existing source generation patterns

Some independent packages already have tarball generation scripts:

  • rpms/opentelemetry-collector-contrib/create-vendor-tarball.sh — downloads upstream release, runs OCB to generate source code, vendors Go dependencies
  • rpms/caddy/create-vendor-tarball.sh — downloads upstream tarball, generates Go vendor archive
  • rpms/nodejs25/packaging/make-nodejs-tarball.sh — downloads upstream, strips bundled content

These scripts are wired into the update path via download_sources hooks in metadata/<package>.update-hooks.yaml. The pattern is: script runs, prints output filenames to stdout, automation uploads them to the Hummingbird lookaside cache.

Integration with dist_git.py update

The current flow copies everything from Fedora dist-git (including the sources file) via shutil.copytree. The pipeline tool inserts after this step:

Current:
  1. Clone Fedora dist-git
  2. copytree into rpms/<package>/        ← sources file points to Fedora lookaside
  3. Commit

Proposed:
  1. Clone Fedora dist-git
  2. copytree into rpms/<package>/        ← sources file points to Fedora lookaside
  3. Run source-pipeline tool             ← fetches from upstream, replaces sources file
  4. Upload tarballs to Hummingbird lookaside
  5. Commit                               ← sources file now points to Hummingbird lookaside

Implementation:

  • After the shutil.copytree (or merge for modified packages), check if metadata/<package>.source-pipeline.yaml exists
  • If it exists: invoke the pipeline tool via podman, collect outputs, upload to lookaside, replace the sources file
  • If it does not exist: run the built-in default (fetch from spec URLs, upload, replace sources)
  • A --skip-pipeline flag allows falling back to the current behavior during migration
  • Update ci/package-overrides.yaml to set forked_from to hummingbird for each migrated package (so Konflux builds fetch from the Hummingbird cache)

Integration with check_upstream_versions.py

The pipeline tool replaces all three hook phases from *.update-hooks.yaml, consolidating per-package update behavior into a single *.source-pipeline.yaml file:

Current:
  1. update_spec hook/default             ← *.update-hooks.yaml
  2. download_sources hook/default        ← *.update-hooks.yaml
  3. post_update hook/default             ← *.update-hooks.yaml

Proposed:
  1. spec-update (pipeline YAML)          ← replaces update_spec hooks
  2. fetch + transform (pipeline YAML)    ← replaces download_sources hooks
  3. post (pipeline YAML)                 ← replaces post_update hooks

This reduces per-package metadata from three files (metadata/<package>.json, *.update-hooks.yaml, *.source-pipeline.yaml) to two (metadata/<package>.json for identity/tracking, *.source-pipeline.yaml for all update behavior).

Existing *.update-hooks.yaml files are migrated to *.source-pipeline.yaml definitions. During migration, the hook system remains as a legacy fallback: if a package has a *.update-hooks.yaml but no pipeline YAML, the hooks run as before. Once all 12 hook files are migrated, the hook system is removed.

Integration with Konflux

Initially, the pipeline tool runs pre-build (during the update automation in GitLab CI). The tarballs it produces are uploaded to the Hummingbird lookaside, and the existing Tekton build pipeline consumes them via dist-git-client as it does today.

Future: the pipeline tool could run as a Tekton task within the Konflux build pipeline itself. This would move source generation into the SLSA-attested build environment, strengthening the provenance chain. The container image is already compatible — it just needs a Tekton Task definition.

Hummingbird-specific configuration

Pipeline YAML files live at metadata/<package>.source-pipeline.yaml.

GPG keys live at metadata/gpg-keys/<upstream-project>.gpg.

The container image is published as quay.io/hummingbird-ci/source-pipeline:latest.

Local development

Developers can run the tool directly:

podman run --rm \
  -v ./rpms/curl:/package:ro \
  -v ./metadata/curl.source-pipeline.yaml:/pipeline.yaml:ro \
  -v ./metadata/gpg-keys:/gpg-keys:ro \
  -v /tmp/output:/output \
  quay.io/hummingbird-ci/source-pipeline:latest \
  --version 8.21.0

# Outputs in /tmp/output/:
#   curl-8.21.0.tar.xz
#   curl-8.21.0.tar.xz.asc
#   sources
#   report.json

To preview without producing artifacts:

podman run --rm \
  -v ./rpms/curl:/package:ro \
  -v ./metadata/curl.source-pipeline.yaml:/pipeline.yaml:ro \
  -v ./metadata/gpg-keys:/gpg-keys:ro \
  quay.io/hummingbird-ci/source-pipeline:latest \
  --version 8.21.0 \
  --dry-run

# Runs Fetch → Transform → Verify → Policy but skips Emit.
# Exit code and report.json reflect what would happen.
# No /output mount needed.

Migration Path

Phase 1: Independent Go packages

Migrate the 22 independent packages that already fetch from upstream. Convert their existing create-vendor-tarball.sh scripts and download_sources hooks into pipeline YAML definitions. Validates the tool against known-good packages.

Scope: 7 packages with existing scripts + 15 without.

Of the 7 scripted packages, 4 (caddy, nats-server×2, oauth2-proxy) use an identical clone + go mod vendor + tar pattern that maps directly to vendor: {ecosystem: go}. etcd requires multi-submodule vendor support. The 2 otel-collector packages need a run: block for OCB code generation, but their vendor step is generic.

Phase 2: Trivial clean packages

Enable the default pipeline behavior (fetch from spec URLs) for the ~351 clean packages where Fedora’s tarball is identical to upstream. This is the highest-impact, lowest-effort phase.

Prerequisites:

  • Audit to classify packages as trivial vs. transformed (HUM-4620)
  • dist_git.py update integration complete (HUM-4621)

Per-package steps:

  1. Run the pipeline tool, compare output against existing Fedora-sourced tarball
  2. If identical: add forked_from: hummingbird to package-overrides.yaml
  3. If different: flag for Phase 3

Phase 3: Transformed packages

Write pipeline YAML definitions for packages where Fedora modifies the tarball. Based on the script audit, most transforms decompose into built-in primitives:

  • Strip + repack (nodejs×5, cyrus-sasl, perl-libnet, java in-tree libs): strip-tarball
  • Git snapshots (libX11 family×5, oniguruma, php-jsqueeze): fetch: {git: ...}
  • Composer vendor (composer): vendor: {ecosystem: composer}

Only ~7 packages (nss-fips, openssl-fips-provider, ca-certificates, gcc, gdb, python3.14, erlang27) require run: blocks for genuinely custom logic.

Scope: determined by Phase 2 audit.

Phase 4: Policy enforcement

Add policy sections to pipeline YAMLs for packages with known vendor dependency constraints. Initially driven by CVE fixes that need to survive upstream updates.

Phase 5: Patch verification

Enable patches.verify and patches.classify for packages. Start with warn-only (fail-on-unverified: false), gather data on classification accuracy, then selectively enable fail-on-unverified for high-risk packages.

Operational Health

Escape hatch ratio

The primary health metric for the source pipeline is the escape hatch ratio: the percentage of pipeline YAML definitions that contain run: blocks (in transform or post sections) relative to the total number of pipeline YAML definitions.

A run: block means the package needs custom shell commands that the built-in primitives cannot express. Every concern that accumulates over time – ecosystem coverage gaps, upstream behavioral drift, lock file format churn, new language ecosystems – ultimately manifests the same way: a package that cannot be expressed declaratively gets a run: block. This makes the ratio a single number that aggregates all pressures on the declarative model.

Interpretation:

Ratio Signal
< 15% Healthy. The primitive set covers real-world needs.
15-25% Watch. Look for repeated patterns in run: blocks that should become primitives.
> 25% Action needed. The YAML is becoming a workflow engine. Either expand the primitive set or reconsider the abstraction.

When the ratio climbs, examine the run: blocks for repeated patterns. If three or more packages use run: to do the same thing (e.g. a new vendoring ecosystem, a common tarball repack operation), that pattern should become a built-in primitive. The ratio climbing is not inherently bad – it is bad only if the response is to keep adding one-off scripts instead of investing in the primitive set.

CI enforcement

The ci/validate_pipeline_health.py script scans all metadata/*.source-pipeline.yaml files, calculates the escape hatch ratio, and reports the result. It runs as part of make check.

  • During migration (Phases 1-3): warn mode. The script prints the ratio and flags if it exceeds 25%, but does not fail CI. This avoids blocking legitimate migration work where some packages temporarily need run: blocks before new primitives are added.
  • After Phase 3: enable --fail mode. Once the primitive set is established and migration is complete, exceeding 25% is a hard CI failure that forces a conversation before a run: block is added.

2 - Agent-Friendly Documentation

How AI agents and LLMs can discover and consume the Project Hummingbird documentation site.

The documentation site provides several features that make it easier for AI agents and LLMs to discover, index, and consume its content.

Discovery and access methods

llms.txt

The site publishes an llms.txt file at the root, following the llmstxt.org specification. This file is a Markdown-formatted index of all documentation pages, grouped by section, with links to each page’s markdown URL. It serves as the primary entry point for agents discovering the site.

llms-full.txt

The site also publishes llms-full.txt – a full content dump of all documentation pages in a single file. This is intended for LLMs with large context windows that want to ingest the entire site in a single request without crawling individual pages.

Pages whose rendered content exceeds a size threshold (currently 50,000 characters) are replaced with a placeholder that includes the page title, description, and a link to the individual page’s markdown URL for on-demand fetching. This keeps the file at a manageable size while ensuring no content is completely inaccessible.

Per-page markdown output

Every page on the site is available in clean markdown format by appending /index.md to its URL. For example:

  • HTML: https://hummingbird-project.io/docs/contributing/quickstart/
  • Markdown: https://hummingbird-project.io/docs/contributing/quickstart/index.md

The markdown output resolves all shortcodes (such as file includes from other repositories) while preserving the content as markdown rather than converting to HTML. Each markdown page includes links to both llms.txt and llms-full.txt so agents can discover the site-wide indexes from any page.

Content negotiation

A CloudFront distribution sits between DNS and the site’s page host. A viewer-request CloudFront Function inspects the Accept header on every request: when the header includes text/markdown, the function rewrites the URI to serve the pre-built .md equivalent. Only HTML-like patterns (trailing slash, .html, bare paths) are rewritten; all other URIs pass through unchanged.

This means agents can use standard HTTP content negotiation on any page URL without needing to know the /index.md convention:

GET /docs/contributing/quickstart/ HTTP/1.1
Accept: text/markdown

Pages referenced from external sources have stable short URLs under the /l/ prefix (e.g., /l/adding-images). These return HTTP 301 to the canonical page URL, so pages can move without breaking incoming links. The Writing Documentation guide describes how to add aliases.

The CloudFront viewer-request function recognizes /l/ paths and normalizes them before they reach the origin. Content negotiation (Accept header) is skipped for /l/ paths — instead, the URL form determines the response: /l/foo and /l/foo/ redirect to the HTML page, while /l/foo.md and /l/foo/index.md redirect to the markdown file. If no matching alias exists, the origin returns a 404.

Specifications and tools

The site aims to follow the emerging standards for agent-friendly documentation:

  • llmstxt.org – the llms.txt specification
  • Agent-Friendly Documentation Spec – broader specification for documentation sites that serve AI agents
  • AFDocs – scoring tool that evaluates a site’s compliance with the Agent-Friendly Documentation Spec

Planned features

HTML llms-directive (HUM-6571)

A Docsy upgrade to v0.17.0 will embed a pointer to llms.txt inside the HTML <body> of every page, so agents landing on any HTML page can discover the LLMS index without prior knowledge of the site structure.

Documentation quality evaluation (HUM-6575)

A promptfoo-based evaluation harness will measure how accurately LLMs can answer questions about Hummingbird using the published documentation. This provides a feedback loop for identifying documentation pages that need improvement for agent consumption.

3 - Containers repository

Background documentation sourced from the containers repository.

3.1 - Image Pipeline

How the container image pipeline works from source to release

Explanation of the complete container image pipeline, from RPM dependency updates through source templates, generation, build, testing, release, and advisory publication.

Overview

The container image pipeline consists of six main stages:

  1. Source Templates - Jinja2 templates in images/*/ define container images
  2. Generation - Templates are rendered into Containerfiles and Konflux resources
  3. Build - Konflux builds multi-architecture container images
  4. Testing - Testing Farm runs integration tests via Konflux
  5. Enterprise Contract Validation - Conforma validates policy compliance before release
  6. Release - Images are published to registries and advisories are created

RPM packages are built and published separately via the RPM Pipeline. This document covers what happens after RPMs are available in the Hummingbird package repository.

RPM Dependency Updates

Container images pin exact RPM versions via lockfiles. When new RPMs are published to the Hummingbird Pulp repository, lockfiles must be regenerated for images to pick up the updates.

Lockfile structure

Each image variant has two RPM-related files:

File Purpose
generated/images/<image>/<distro>/<variant>/rpms/rpms.in.yaml Declares required packages
generated/images/<image>/<distro>/<variant>/rpms/rpms.lock.yaml Pins exact versions, URLs, and checksums

The lockfile contains every RPM (direct and transitive dependencies) with its exact version, architecture, URL pointing to the Pulp repository, and checksum. During build, the locked RPMs are downloaded in hermetic mode — no network access to external repositories.

Automatic lockfile updates

MintMaker / Renovate — A custom Renovate instance (forked rpm-lockfile manager from redhat-exd-rebuilds/renovate) runs as a Kubernetes CronJob. It scans each rpms.in.yaml file individually, resolves dependencies against Pulp repositories, and opens auto-merge MRs — one per image group and distro (e.g., “Refresh RPM lockfiles for go-1-25/hummingbird”).

The underlying tool is rpm-lockfile-prototype, which wraps the DNF dependency solver. For a given rpms.in.yaml, it resolves all direct and transitive dependencies against the yum repositories defined in yum-repos/*.repo and outputs a complete rpms.lock.yaml with pinned versions, download URLs, and checksums. Locally, generate_rpms_lock.py adds hash-based optimization: it hashes the input file and skips regeneration when the hash matches the existing lockfile, avoiding expensive solver runs when nothing has changed.

Manual lockfile updates

To refresh the lockfile for a single image variant:

make generated/images/<image>/<distro>/<variant>/rpms/rpms.lock.yaml FORCE_REFRESH=true

For example:

make generated/images/caddy/hummingbird/default/rpms/rpms.lock.yaml FORCE_REFRESH=true

To regenerate all lockfiles and open MRs for the changes:

# Regenerate all rpms.lock.yaml files
make all-host FORCE_REFRESH=true
# Open MRs for any changed lockfiles
ci/create_lockfile_update_mrs.sh

From lockfile MR to container build

When a lockfile MR merges to main, PipelinesAsCode triggers a container build (Stage 3). Each image variant has CEL expressions in its PipelineRun definition that match on file paths, so only images whose lockfiles changed get rebuilt.

Stage 1: Source Templates

Each container image is defined by templates in images/<image-name>/:

  • properties.yml - Image configuration (packages, variants, tags, etc.)
  • Containerfile.j2 - Jinja2 template for the container build
  • README.md.j2 - Documentation template
  • tests-container.yml - Integration test definitions

Templates use reusable macros from macros/*.yml.j2:

  • setup_newroot() - Configures DNF and filesystem
  • install_newroot() - Installs packages
  • cleanup_newroot() - Cleans up files
  • final_stage() - Creates scratch-based final image

Shared configuration is defined in images/variables.yml. See Global Variables Reference for details.

Stage 2: Generation

Templates are rendered into concrete artifacts that drive the pipeline:

  • Containerfiles - Build instructions for each image variant
  • README documentation - Image documentation for Quay.io
  • Konflux resources - CI/CD pipeline definitions

Containerfile Generation

Containerfiles are generated from templates for each image variant using Make’s incremental build system:

make

This combines:

  • Reusable macros from macros/
  • Service-specific templates from images/*/Containerfile.j2
  • Configuration from properties.yml (see Image Configuration Reference)
  • Variables from images/variables.yml (see Global Variables Reference)
  • RPM versions from rpms.lock.yaml files
  • Git submodule information from .gitmodules

Output: generated/images/<image-name>/<distro>/<variant>/Containerfile, along with VERSION and TAGS files

The build system uses timestamp-based dependency tracking, so only changed files are regenerated.

README Generation

After Containerfiles are generated, README documentation is generated from README.md.j2 templates using macros from macros/readme.yml.j2. Each image template produces one README per target defined in images/variables.yml (Hummingbird, Rawhide, Red Hat).

Output: generated/images/<image-name>/hummingbird/README.md, generated/images/<image-name>/rawhide/README.md, generated/images/<image-name>/README.md

Konflux Resource Generation

Konflux CI/CD resources are generated from templates in konflux-templates/:

make

This generates:

  • Components - Define what to build (one per image variant)
  • ImageRepositories - Define where to push images
  • ReleasePlanAdmissions - Define how to release images

Output: konflux-templates/rendered.yml

These resources must be deployed to Konflux before builds can run. See the Konflux Resource Deployment guide for how and when resources are deployed.

Stage 3: Build

Konflux builds container images automatically when changes are pushed to GitLab.

Build Triggers

  • Merge Requests: Builds all changed images and triggers tests
  • Main Branch: Builds all changed images (tests do not run on main)

Build Process

For each image variant:

  1. Konflux Component watches the GitLab repository
  2. On changes, Konflux triggers a build PipelineRun
  3. The build uses the generated Containerfile from generated/images/<name>/<distro>/<variant>/Containerfile
  4. Images are built for multiple architectures (x86_64 and aarch64)
  5. Built images are pushed to the development registry

Build Output

Development images are pushed to the Red Hat User Workloads registry:

quay.io/redhat-user-workloads/hummingbird-tenant/<group>--<variant>--main

Merge request builds are tagged with:

quay.io/redhat-user-workloads/hummingbird-tenant/<group>--<variant>--main:on-mr-<MR_ID>-<COMMIT_SHA>

Examples:

  • quay.io/redhat-user-workloads/hummingbird-tenant/curl--default--main
  • quay.io/redhat-user-workloads/hummingbird-tenant/nginx--builder--main:on-mr-123-abc1234

SBOM Generation

Each per-architecture build also produces an SPDX 2.3 Software Bill of Materials (SBOM), attached as an OCI artifact to the image. The SBOM is assembled from two independent scans, merged into a single document:

flowchart LR
    syft["Syft\n(buildah-remote-oci-ta)"] --> mobster["Mobster\n(buildah-remote-oci-ta)"]
    hermeto["Hermeto\n(prefetch-dependencies-oci-ta)"] --> mobster
    mobster --> sbom["Per-arch SPDX SBOM"]
    sbom --> index["Mobster\n(build-image-index)"]
    index --> oci["Index SBOM\n(.sbom OCI artifact)"]
  • Syft runs as the sbom-syft-generate step inside the buildah-remote-oci-ta Tekton task. It scans the RPM database of the built per-arch image and finds installed binary RPMs plus non-RPM packages (Go modules, pip packages, etc.). One SBOM is produced per architecture.
  • Hermeto runs inside the prefetch-dependencies-oci-ta Tekton task. It records all build-time dependencies from lockfiles. A single Hermeto SBOM covers all architectures and source RPMs.
  • Mobster runs as the prepare-sboms step inside buildah-remote-oci-ta, merging the Syft and Hermeto SBOMs into one SPDX document per architecture. A second Mobster invocation in the build-image-index task generates an index-level SBOM and attaches it as an OCI artifact.

See Security Labels and Metadata for the SBOM entry structure and how to access SBOMs from the registry.

Stage 4: Testing

Images are validated through two types of integration tests:

  • Container tests (tests-container.yml) - Run with Podman and Docker via Testing Farm on RHEL-9-Nightly systems
  • K8s tests (tests-k8s.yml) - Run in Konflux ephemeral Kubernetes namespaces

Test results appear as external jobs in GitLab CI pipelines, providing pass/fail status and links to the Konflux PipelineRun.

Test Triggering

Tests are only triggered on merge requests, not on main branch builds.

For an image group at images/<group-name>/, tests are triggered for all changes below that directory, excluding documentation-only changes.

Any changes below .tekton/ or ci/ will trigger tests for the caddy image to ensure infrastructure changes work before merging.

Test Execution

For each image group, pipelines are created per variant. If there is more than one variant in an image group, additional group pipelines are created. See Konflux group snapshot documentation for details.

Tests are selected based on the variants field in test files. Additionally, global tests from ci/{variant}-tests/ are included. Group pipelines run tests that specify variants: [group], allowing validation across multiple variants.

Reverse Dependency Testing

When a base image like core-runtime changes, dependent images (rust, xcaddy, etc.) need to be tested to ensure compatibility. Reverse dependency testing is enabled by default. Images can opt-out by setting reverse_dependency_tests: false in their properties.yml. This is recommended for images like “curl” which are widely used and only have a small API which their own tests cover well enough.

For container tests, dependent images are rebuilt locally in the testing environment to prevent version skew and ensure dependent images are in sync with the repository status in the merge request under test.

Integration Test Scenarios

Integration tests are triggered via IntegrationTestScenario resources defined in the infrastructure repository:

Application Purpose
containers-hummingbird Red Hat supported Hummingbird images
containers-community-hummingbird Community images (support_level: community)
containers-ci-hummingbird Experimental/infrastructure images (support_level: experimental)
containers-rawhide All Rawhide-based images

Container Testing

Container tests run via Testing Farm on RHEL-9-Nightly systems for both x86_64 and aarch64 architectures.

Container Testing Flow

  1. Developer opens MR modifying images/nginx/
  2. GitLab CI pipeline and Konflux pipelines start in parallel
  3. Konflux builds nginx image variants
  4. IntegrationTestScenario triggers Testing Farm job
  5. tmt discovers enabled child plans in ci/run_tests_container.fmf
  6. Testing Farm provisions one RHEL-9-Nightly machine per enabled plan (per architecture)
  7. tmt sets up the testing environment on each machine
  8. tmt runs tests via ci/run_tests_container.sh

Container Test ITS Configuration

The container tests use the upstream Testing Farm pipeline for Konflux CI:

kind: IntegrationTestScenario
spec:
  contexts:
    - {name: pull_request}
  params:
    - {name: COMPOSE, value: RHEL-9-Nightly}
    - {name: ARCH, value: x86_64|aarch64}  # one for each
    - {name: CONTEXT, value: fips=enabled}  # containers-hummingbird only
    - {name: IMAGE_TAG, value: v3.2}
  resolverRef:
    resolver: bundles
    params:
      - {name: bundle, value: quay.io/testing-farm/tmt-via-testing-farm:$(params.IMAGE_TAG)}
      - {name: name, value: tmt-via-testing-farm}
      - {name: kind, value: pipeline}

FMF Test Plan

The root folder of the containers repository is marked with a .fmf directory to enable Testing Farm support. The test plan in ci/run_tests_container.fmf uses FMF virtual child plans to define two execution variants:

  • /standard — inherits all parent configuration, always enabled
  • /fips — inherits all parent configuration plus a FIPS prepare step; disabled by default, enabled via adjust when the fips=enabled context is set

The parent node defines shared configuration (prepare, discover, execute) inherited by both children. Because it has children, FMF treats it as a branch node and does not select it as a runnable plan. The containers-hummingbird ITS passes CONTEXT=fips=enabled, so both /standard and /fips run in parallel. Other applications (rawhide, ci-hummingbird, community-hummingbird) pass no CONTEXT, so only /standard runs.

Both child plans run the following steps:

  1. Install podman for container testing
  2. Install Docker and start the Docker daemon on the host for Docker integration tests
  3. Fix git submodules until TFT-3991 is resolved
  4. For regular pipelines:
    • Verify the image built by Konflux is reproducible using ci/test_rebuild.sh
    • If needed, build reverse dependency images locally via Buildah
    • Run Podman and Docker tests via ci/run_tests_container.sh --component-name
  5. For group pipelines:
    • Run Podman and Docker group tests via ci/run_tests_container.sh --group-component-name

The /fips child additionally enables FIPS mode on the provisioned RHEL machine before all other prepare steps (via order: 0).

Both plans configure Podman, Buildah, and Docker to use public DNS servers 1.1.1.1 and 8.8.8.8 inside test containers. This selects the public Pulp endpoint to match customer access. Containers use their original CA trust stores. Podman and Buildah read /etc/containers/containers.conf.d/90-hummingbird-dns.conf. Docker uses the dns setting in /etc/docker/daemon.json. The host resolver and image CA bundles remain unchanged.

Container Test Environment

Testing Farm provides these environment variables to the test plan:

Variable Description
IMAGE_NAME Single component (e.g., curl--default--main)
IMAGE_NAMES Multiple components for group pipelines
IMAGE_URL Image URL from Konflux
IMAGE_URL_... Image URLs from Konflux for group pipelines
SNAPSHOT_b64 Snapshot metadata (base64 encoded)

K8s Testing

K8s tests run in Konflux ephemeral namespaces provisioned via EaaS (Environment as a Service).

K8s Testing Flow

  1. Developer opens MR modifying an image in the containers repository (e.g., nginx)
  2. GitLab CI pipeline and Konflux pipelines start in parallel
  3. Konflux builds image variants
  4. IntegrationTestScenario triggers K8s test pipeline
  5. Pipeline checks for tests-k8s.yml; skips with SUCCESS if not found
  6. Pipeline provisions ephemeral namespace via Konflux EaaS (tied to PipelineRun lifecycle)
  7. Pipeline fetches source via Trusted Artifacts
  8. Tests run via ci/run_tests_k8s.sh with kubeconfig for ephemeral namespace
  9. Pipeline fails if any test reports non-SUCCESS, ensuring GitLab sees correct status

K8s Test ITS Configuration

The K8s tests use the k8s-test-pipeline:

kind: IntegrationTestScenario
spec:
  contexts:
    - {name: pull_request}
  resolverRef:
    resolver: bundles
    params:
      - {name: bundle, value: quay.io/hummingbird-ci/k8s-test-pipeline:latest}
      - {name: name, value: k8s-test}
      - {name: kind, value: pipeline}

Stage 5: Enterprise Contract Validation

Before images can be released, they must pass Enterprise Contract (also known as Conforma) policy validation. This ensures images meet security, compliance, and build quality standards. These checks can also be run locally against Konflux-built images.

Policy Validation

Enterprise Contract validates that:

  • Images are built using trusted, verified Tekton tasks
  • Builds are hermetic (network-isolated with pre-fetched dependencies)
  • Required security tests have passed
  • Images have proper metadata and labels
  • Build artifacts meet supply chain security requirements

Policy Configuration

Policies are defined as EnterpriseContractPolicy resources in konflux-templates/macros/policy.yml.j2:

  • containers-hummingbird / containers-rawhide - Strict policies for production images
  • containers-community-hummingbird - Policy for community-supported images in the containers repository (same base exclusions as production policies)

These policies use the @redhat rule collection from the ec-release-policy.

Policy Exclusions

The following checks are excluded from the default @redhat policy set. When modifying exclusions, update the policy macro and this documentation.

Test Package

The test package verifies that each build was subjected to a set of tests and that those tests all passed.

Snyk SAST checks (test.required_tests_passed:sast-snyk-check, test.no_skipped_tests:sast-snyk-check, test.required_tests_passed:sast-snyk-check-oci-ta, test.no_skipped_tests:sast-snyk-check-oci-ta) are excluded because Hummingbird images are currently not supported by Snyk.

Red Hat certification preflight checks (test.no_failed_tests:ecosystem-cert-preflight-checks, test.no_erred_tests:ecosystem-cert-preflight-checks) are excluded because Hummingbird images are not yet published to the Red Hat certified container registry.

Informative test failures (test.no_failed_informative_tests) are excluded because these produce warnings for advisory purposes only and are explicitly non-blocking.

Deprecated image warnings (test.no_test_warnings:deprecated-image-check) are excluded because final images are built FROM scratch, and the builder image is updated via Renovate like all other images.

Trusted Task Package

The trusted_task package verifies that all Tekton Tasks involved in building the image are trusted by comparing Task references with a pre-defined list of trusted Tasks.

The trusted_task.current check warns when newer versions of tasks are available. This is excluded because we use stable pinned task versions and control upgrade timing via Renovate rather than requiring the latest version at all times.

RPM Repos Package

The rpm_repos package confirms that all RPM packages listed in SBOMs specify a known and permitted repository ID.

The rpm_repos.ids_known check is excluded because images use the internal hummingbird repository and Fedora repositories, which are not in the upstream known_rpm_repositories.yml list (that file only contains Red Hat official repositories).

RPM Signature Package

The rpm_signature package verifies that all RPMs in the image are signed by an allowed signing key.

The upstream default only allows the Red Hat release key 2 (199e2f91fd431d51). Rawhide, community, and experimental images contain Fedora-signed RPMs, so the containers-rawhide, containers-community-hummingbird, and containers-ci-hummingbird policies add Fedora signing keys via the additional_rpm_signature_keys macro parameter. The containers-hummingbird policy uses the default (Red Hat key only) because Hummingbird images install RPMs exclusively from the Hummingbird repository.

The Fedora key list in konflux-resources.yml.j2 should be kept in sync with the keys published at https://fedoraproject.org/security.

Labels Package

The labels package checks if the image has the expected labels set, including required and optional labels for Red Hat container certification.

Both labels.required_labels and labels.optional_labels are excluded because images currently only include basic labels (maintainer, license_terms, name, cpe) and version labels, not the full set of Red Hat certification labels (vendor, version, release, summary, description, url, etc.) required for Red Hat Ecosystem Catalog publishing.

Buildah Build Task Package

The buildah_build_task package verifies buildah build task parameters.

The buildah_build_task.privileged_nested_param check verifies that PRIVILEGED_NESTED is not set to true. This is excluded because images use the dnf-installroot helper from the builder image to build all containers, including the builder image itself. This script requires privileged operations (unshare, mount -t tmpfs, mount --bind for /proc and /dev/*) to set up the install root environment (see commit 3668af16).

Schedule Package

The schedule package verifies that releases conform to a given schedule, including weekday restrictions.

The schedule.weekday_restriction check is excluded to allow releases any day including weekends. The @redhat policy restricts weekend releases, but this project needs the ability to ship urgent CVE fixes immediately regardless of the day of week.

CVE Package

The cve package checks for blocking and non-blocking CVEs in container images.

The cve.cve_blockers check is excluded because blocking on known CVEs would prevent releasing images that fix other CVEs. A VEX feed could suppress false positives for RPM-level CVEs, but CVEs can also originate from other artifact types where VEX does not apply (see MR !2227).

Hermetic Task Package (CI-Only)

The hermetic_task package verifies that tasks were invoked with the proper parameters to perform a hermetic (network-isolated) execution.

All Konflux applications in the containers repository enforce the hermetic task check. All applications allow quay.io/hummingbird-ci/ as a base image source because images build FROM quay.io/hummingbird-ci/hummingbird-builder via setup_newroot.yml.j2.

Stage 6: Release

After images pass testing in merge requests and are merged to main, they are released to public registries.

Registry Organization

Images are published to different registries based on distro and purpose:

Registry Purpose
registry.access.redhat.com/hi/ Red Hat supported Hummingbird images (production)
quay.io/hummingbird/ Red Hat supported Hummingbird images (mirror)
quay.io/hummingbird-community/ Community-supported Hummingbird images
quay.io/hummingbird-rawhide/ All Rawhide-based images
quay.io/hummingbird-ci/ CI and tools images

Production Registry: Red Hat supported Hummingbird images are published to registry.access.redhat.com/hi/ via the rh-advisories release pipeline and mirrored to quay.io/hummingbird/.

Community Registry (hummingbird-community): Publishes community-supported images (those with support_level: community in properties.yml). Examples include minio, minio-client, and os-minimal.

CI/tools Registry (hummingbird-ci): Publishes tooling and infrastructure images. This includes tools-repo images (built outside the containers repository) and experimental images from the containers repository (support_level: experimental), such as hummingbird-builder.

Release Process

  1. Merge request passes all tests
  2. Merge request is merged to main branch
  3. Konflux builds images from main
  4. ReleasePlanAdmission resources trigger the release pipeline
  5. Images are signed via Cosign for supply chain attestation
  6. Images are copied from the Konflux registry to target registries
  7. Tags are applied based on properties.yml configuration
  8. For production releases: images are registered in the Red Hat container catalog via Pyxis

Release Mechanism

Releases are configured via ReleasePlanAdmission (RPA) resources in the containers repo and ReleasePlan resources in the infrastructure repo:

  • ReleasePlanAdmission - Defines per-registry release configuration (target registry, tags, visibility settings)
  • ReleasePlan - Triggers the release pipeline for a specific application and registry

Each distro/registry combination has its own RPA.

Production release pipeline

The production release uses the rh-advisories pipeline from the release-service-catalog. The push-snapshot task pushes images from the development registry to:

  • Production: registry.access.redhat.com/hi/<image>:<tags>

Configuration is defined in releng/hummingbird-containers-prod.yaml.

Image signing

Images are signed using Cosign as part of the release pipeline. Signing provides supply chain attestation, allowing consumers to verify image provenance.

Pyxis catalog registration

The release pipeline registers images in the Red Hat container catalog via the Pyxis API:

  • Product: Red Hat Hardened Images (Product ID 1071)
  • Metadata: version, categories, layer information, SBOM references
  • Configuration: releng/pyxis-hummingbird.yaml

Pyxis registration makes images discoverable in the Red Hat Ecosystem Catalog and links them to security advisories.

Release Output

Released images are published to registries based on distro and support level:

registry.access.redhat.com/hi/<image-repository>:<image-tag>
quay.io/hummingbird/<image-repository>:<image-tag>
quay.io/hummingbird-community/<image-repository>:<image-tag>
quay.io/hummingbird-rawhide/<image-repository>:<image-tag>
quay.io/hummingbird-ci/<image-repository>:<image-tag>

Examples:

  • registry.access.redhat.com/hi/curl:8 - Production Red Hat supported image
  • quay.io/hummingbird/nodejs:20 - Red Hat supported Hummingbird image (mirror)
  • quay.io/hummingbird-community/minio:latest - Community-supported image
  • quay.io/hummingbird-ci/hummingbird-builder:latest - Hummingbird Builder image
  • quay.io/hummingbird-rawhide/curl:latest - Rawhide image

Release Tags

Tags are extracted from Containerfile labels as defined in properties.yml:

  • latest - Latest version of the image
  • <major> - Major version (e.g., 20 for Node.js 20.x)
  • <major>.<minor> - Major.minor version (e.g., 20.11)
  • <full-version> - Complete version with release (e.g., 20.11.1-1.fc42)
  • <YYYYMMDD> - UTC date when RPM lockfile content last changed, available via rpm_lockfile.content_date for any image that includes it in its tags. Only advances when the resolved RPM set changes.
  • <timestamp> - Build timestamp (production releases only)

Non-default variants receive a -<variant> suffix (e.g., latest-builder).

Advisory Creation

When the production release pipeline runs, advisories are created:

  1. The Release Service generates an Advisory YAML from ReleasePlan and ReleasePlanAdmission metadata
  2. The advisory is pushed to the advisories repo on CEE GitLab
  3. GitLab CI validates the advisory against the schema and enforces field-level permissions
  4. On merge to main, the advisory is published via UMB and Kafka

Advisory types:

Type Meaning
RHSA Security Advisory (contains CVE fixes)
RHBA Bug Fix Advisory
RHEA Enhancement Advisory

After publication, the Pyxis API links each image record to its advisory via image_advisory_id.

VEX Feed Update

After advisory publication, Red Hat SDEngine generates public vulnerability data:

  • CSAF VEX documents (per-CVE) published at https://security.access.redhat.com/data/csaf/v2/vex-feed/
  • CSAF Advisory documents (per-advisory) published at https://security.access.redhat.com/data/csaf/v2/advisories/

The hummingbird-vex-feed repository maintains CPE mappings that associate Hummingbird package repositories with cpe:/a:redhat:hummingbird:1.

Image Documentation

When a commit to main changes a README.md file, the content is automatically pushed to quay.io as the image description via the update_quay_description job.

References

3.2 - Global Variables Reference

Complete reference for global configuration in images/variables.yml

Complete reference for global configuration settings that apply to all container images.

Overview

The images/variables.yml file contains global configuration that applies to all container images in the repository. These settings define project-wide defaults, security parameters, and variant-specific behavior.

Location: images/variables.yml (in the containers repository root)

Scope: All images inherit these settings unless overridden in image-specific properties.yml

Configuration Reference

cpe

  • Type: String (CPE 2.2 formatted string)
  • Default: "cpe:/a:redhat:hummingbird:1"
  • Description: Common Platform Enumeration (CPE) identifier for all Hummingbird containers.
  • Usage: Automatically added as a label to all generated container images and passed to inject-source-info during the build process.
  • Format: CPE 2.2 URI format: cpe:/part:vendor:product:version

default_user

  • Type: String (numeric UID)
  • Default: "65532"
  • Description: The default unprivileged user ID that all containers run as.
  • Usage: Referenced by the {{ set_user() }} macro when user: default is specified (or when user: is omitted, as default is the default value).
  • Value: 65532 is chosen as a high, non-conflicting UID that:
    • Avoids conflicts with system users (typically < 1000)
    • Avoids conflicts with regular users (typically 1000-60000)

default_distros

  • Type: Array of strings
  • Default: ["rawhide", "hummingbird"]
  • Description: Defines the distribution variants (distros) for which images are built. Each distro represents a different base package source.
  • Directory Structure: Images are organized as images/<name>/<distro>/<variant>/
  • Common Values:
    • rawhide - Uses Fedora Rawhide repositories only
    • hummingbird - Uses both Fedora Rawhide and Hummingbird repositories
  • Usage: Scripts iterate over distros and variants to find all image variants to build. Each distro/variant combination produces a separate container image.
  • See Also: default_variant_repos for repository configuration per distro

default_variants

  • Type: Array of strings
  • Default: ["default", "builder"]
  • Description: Defines the default set of variants to generate for each image within each distro when not explicitly specified in the image’s properties.yml.
  • Directory Structure: Variants are subdirectories within each distro: images/<name>/<distro>/<variant>/
  • Common Values:
    • default - The minimal runtime variant
    • builder - Extended variant with build tools and package managers
  • Extending: Images can use additional_variants in properties.yml to add extra variants while keeping the defaults (preferred approach)
  • Override: Images can specify variants in properties.yml to replace these defaults entirely (use when you need to exclude default variants)
  • See Also: Image Configuration Reference - variants

default_rpm_packages

  • Type: Object with variant names as keys, arrays of package names as values

  • Default:

    default_rpm_packages:
      builder:
        - bash
        - dnf5
        - shadow-utils
    
  • Description: Defines packages that are automatically included in specific variants across all images. These are added in addition to packages defined in image-specific properties.yml.

  • Variants: Currently only builder is defined, but other variants can be added

  • Usage: Use this to provide variant-specific packages that should be available in all images using that variant. For example, builder variants include package management tools for development workflows.

default_variant_repos

  • Type: Object with distro names as keys, arrays of repository filenames as values

  • Default:

    default_variant_repos:
      rawhide:
        - fedora-44.repo
      hummingbird:
        - fedora-44.repo
        - hummingbird.repo
    
  • Description: Defines which yum repositories each distro uses by default for package installation and lockfile generation. The Rawhide distro uses the latest branched Fedora development repo, while Hummingbird uses a stable Fedora release plus the Hummingbird package repository.

  • Repository Files: References files in the yum-repos/ directory

  • Usage: This allows different distros to use different package sources. The hummingbird distro includes additional repositories that provide Hummingbird-specific packages.

  • Override: Image-specific additional_repos in properties.yml are appended to the distro-specific repos

  • See Also: Image Configuration Reference - additional_repos

oscap

  • Type: Object

  • Default:

    oscap:
      enabled: true
      profiles:
        cis: true
        stig:
          variants:
            - "*fips*"
      exclude_rules:
        - id: xccdf_org.ssgproject.content_rule_root_path_no_dot
          reason: "False positive: environmentvariable58 probe requires /proc
            which is unavailable in offline chroot scanning"
    
  • Description: Global defaults for OpenSCAP compliance scanning. Scanning is enabled by default (enabled: true). Images can opt out by setting enabled: false in their properties.yml.

  • Fields:

    • enabled - Whether compliance scanning is active (overridden per image)
    • profiles - Which compliance profiles run per variant. Each profile can be true (all variants), false (disabled), or {variants: [...]} with glob patterns. Default: CIS for all variants, STIG for FIPS variants.
    • exclude_rules - Global rule exclusions applied to all oscap-enabled images. Image-specific exclusions in properties.yml are concatenated with these (not replaced).
  • Merge Behavior: When an image overrides oscap fields:

    • enabled (scalar) is replaced by the image value
    • profiles (dict) is recursively merged, so an image can override individual profiles without affecting others
    • exclude_rules (list) is concatenated, so image rules are added after global rules
  • See Also: Image Configuration Reference - Compliance Scanning

readme_targets

  • Type: Object with target names as keys, configuration objects as values

  • Default: See the actual file

  • Description: Defines target-specific configuration for generating multiple README files from a single template. Each target represents a different build/distribution channel (e.g., Hummingbird project vs Red Hat product).

  • Target Configuration Fields:

    • filename - Output filename for this target’s README
    • registry - Container registry URL for image references
    • product_name - Full product name for documentation
    • product_name_short - Short product name for headings and titles
    • doc_base_url - Base URL for documentation links
  • Usage: Templates access these values via {{ readme_targets[target].registry }} and similar expressions. The build system generates one README file per target from the same README.md.j2 template.

  • See Also: Image Pipeline - README Generation

Next Steps

3.3 - Konflux Resource Deployment

How Konflux resources are defined and deployed across repositories

Konflux resources for container images are split between two repositories:

  • containers: Per-image resources generated from properties.yml
  • infrastructure: Application-level resources and deployment to the Konflux cluster

This separation allows independent iteration on each concern.

Deployment Matrix

Resource Type Source Containers MR Containers Push Infra MR Infra Push
Component containers manual manual - -
ImageRepository containers manual manual - -
ReleasePlanAdmission containers - automatic manual automatic
EnterpriseContractPolicy containers - automatic manual automatic
Application infrastructure - - manual automatic
ReleasePlan infrastructure - - manual automatic
IntegrationTestScenario infrastructure - - manual automatic
ServiceAccount, Secret infrastructure - - manual automatic

Legend:

  • Containers MR: Downstream pipeline triggered from containers MR (deploys from MR commit)
  • Containers Push: Downstream pipeline triggered from containers push to main
  • Infra MR: Infrastructure MR pipeline (manual trigger)
  • Infra Push: Infrastructure push to main or web pipeline

Resource Locations and Rationale

Containers Repo

Resources are defined in konflux-templates/ and rendered to konflux-templates/rendered.yml.

Component and ImageRepository:

  1. Ownership: Components are managed by the containers repo. The infrastructure pipeline uses ONLY_DOWNSTREAM so only explicit downstream triggers from containers deploy changes, giving the containers repo full control over the component lifecycle.
  2. Dynamic generation: Generated from images/*/properties.yml via ci/internal/generate_konflux_resources.sh, depending on per-image configuration (variants, tags, repository names).
  3. Timing: Must be deployed early during MR review so Konflux can build and test new images.

ReleasePlanAdmission:

  1. Dynamic generation: Includes per-image tag mappings extracted from properties.yml.
  2. Main branch only: Prevents race conditions—deploying from a feature branch risks undoing other images’ onboarding. If branch A adds image-foo and branch B (created earlier) adds image-bar, deploying B’s RPA would remove image-foo from the release config.

EnterpriseContractPolicy:

  1. Documentation co-location: Policy exclusions are documented in Image Pipeline. Keeping policy and documentation together eases maintenance.

Infrastructure Repo

Application, ReleasePlan, and IntegrationTestScenario are defined in kubernetes/containers-*/ directories (one per Konflux application):

  1. Independent iteration: Changes are decoupled from containers repo activity—they can be modified, test-deployed via manual trigger in an infrastructure MR, verified, and merged without touching the containers repo.
  2. Testable before merge: If defined in the containers repo, these would only deploy after merging to main (like RPAs), making iteration difficult.
  3. Static configuration: These resources don’t depend on per-image data from properties.yml.

ServiceAccount and Secret for releases are defined in kubernetes/release-quay-hummingbird*/ directories (one per Quay organization):

  1. Security: Secret specifications (names, structure, credential references) should not be exposed in the containers repo.
  2. Independent iteration: Like other infrastructure resources, these can be modified and test-deployed without touching the containers repo.

The service accounts are referenced by ReleasePlanAdmissions to authorize pushing images to each quay.io/hummingbird* organization.

See Also

3.4 - Security Labels and Metadata

Container labels, embedded metadata, and SBOMs for vulnerability scanning

Hummingbird container images include labels, embedded metadata, and SBOMs that enable security scanners to perform container-first vulnerability reporting.

Overview

Security scanners need a way to determine which vulnerabilities are applicable to a container image. Three mechanisms provide this information:

  1. Container Labels - OCI image labels (name, cpe) set in the Containerfile
  2. Embedded Metadata - A labels.json file written to the container filesystem during build
  3. Software Bill of Materials (SBOM) - An SPDX document listing all packages, attached as an OCI artifact alongside the image

Labels and embedded metadata identify the product; the SBOM identifies the packages within it. Labels and embedded metadata provide the same core information but serve different access paths: labels are accessible via container inspection tools, while embedded metadata is accessible to scanners with only filesystem access.

Applicability

The cpe label is only added to released Hummingbird distro images (no CPE identifier for non-product images). All other labels are added to all images.

The labels.json embedded metadata file contains all Containerfile LABEL values plus auto-computed fields.

SBOMs are attached to all released images (both Rawhide and Hummingbird).

Container Labels

name

  • Type: String
  • Format: <org>/<image> or <org>/<image>-<variant>
  • Examples: hummingbird/nodejs-24, hummingbird/nodejs-24-builder, hummingbird/caddy
  • Description: Canonical name for the container image. This is the name that appears in VEX (Vulnerability Exploitability eXchange) statements and is used by scanners to map vulnerabilities to specific images.

The canonical name is derived from the image directory name (which includes the version, e.g. nodejs-24) and the variant. For default variants, the name is hummingbird/<image> (e.g. hummingbird/nodejs-24). For non-default variants, the variant is appended with a hyphen (e.g. hummingbird/nodejs-24-builder). Note that the canonical name may differ from the registry repository path. For example, nodejs-24 and nodejs-20 are both pushed to the hummingbird/nodejs registry repository with different tags, but their canonical names are hummingbird/nodejs-24 and hummingbird/nodejs-20 respectively.

See container-image-labels.md for the full name label format across all image types.

cpe

  • Type: String (CPE 2.2 formatted)
  • Format: cpe:/a:redhat:hummingbird:1
  • Description: Common Platform Enumeration (CPE) identifier for the container. CPE is a standardized naming scheme for software products that enables correlation with vulnerability databases.

The CPE value is defined globally in images/variables.yml and applied to all Hummingbird images. Containers with the same CPE are considered the same software product for vulnerability reporting purposes.

org.opencontainers.image.created

  • Type: String (RFC3339 timestamp)
  • Format: 2025-01-15T12:00:00Z
  • Description: Creation timestamp of the container image. Used by scanners to determine if an image predates or postdates a vulnerability fix.

If SOURCE_DATE_EPOCH is set during the build, that timestamp is used instead of the actual build time, supporting reproducible builds.

Embedded Metadata (labels.json)

The labels.json file is written to /usr/share/buildinfo/labels.json inside the container filesystem by the inject-source-info script during build. It contains all Containerfile LABEL values plus auto-computed fields like architecture and org.opencontainers.image.created.

Schema

The file follows the embedded_metadata.v1 schema published by Red Hat Product Security.

Fields

The embedded_metadata.v1 schema defines the minimum fields required by security scanners:

Field Type Description
name string Canonical container name (e.g., hummingbird/nodejs-24)
cpe string CPE identifier (e.g., cpe:/a:redhat:hummingbird:1)
architecture string Target architecture (e.g., amd64, arm64)
org.opencontainers.image.created string RFC3339 creation timestamp

The file also includes all other Containerfile LABEL values (e.g., description, summary, vendor, version). See container-image-labels.md for the complete label reference.

Example

{
  "name": "hummingbird/caddy",
  "cpe": "cpe:/a:redhat:hummingbird:1",
  "architecture": "amd64",
  "org.opencontainers.image.created": "2025-01-15T12:00:00Z"
}

Software Bill of Materials (SBOM)

Each released image has a per-architecture SPDX 2.3 SBOM attached as an OCI artifact. See SBOM Generation for how the production SBOMs are built from Syft, Hermeto, and Mobster.

Accessing SBOMs

SBOMs are stored as OCI artifacts alongside each per-architecture image. To download one, resolve the per-arch digest and use cosign:

IMAGE="registry.access.redhat.com/hi/caddy"
TAG="latest"
ARCH="amd64"

# Get per-architecture digests from the image index
skopeo inspect --raw "docker://${IMAGE}:${TAG}" \
  | jq '.manifests[] | {digest, platform}'

# Download the SBOM for the chosen architecture
ARCH_DIGEST="$(skopeo inspect --raw "docker://${IMAGE}:${TAG}" \
  | jq -r --arg arch "${ARCH}" \
    '.manifests[] | select(.platform.architecture == $arch) | .digest')"
cosign download sbom "${IMAGE}@${ARCH_DIGEST}" > sbom.json

Entry Sources

The merged SBOM contains entries from two tools:

Source Content Scope
Syft Installed binary RPMs + non-RPM packages Image architecture only
Hermeto Build-time dependencies from lockfiles All architectures + source RPMs

A package installed in the final image may have entries from both Syft and Hermeto (with different metadata), since it was both a build dependency and is present at runtime.

Distinguishing Entry Sources

After the Mobster merge, Syft and Hermeto entries carry mutually exclusive markers:

Marker Syft Hermeto
sourceInfo field present ✓
licenseDeclared set ✓
CPE references ✓
upstream= in PURL ✓
Annotation containing “hermeto” ✓
repository_id= in PURL ✓

As a general rule, any entry with an annotation containing the word “hermeto” (case-insensitive) originates from Hermeto (build-time provenance). All other entries originate from Syft (runtime image scan).

PURL Format

Syft and Hermeto use different PURL qualifier sets for the same package. Examples from caddy:latest (Hummingbird, amd64):

Syft (runtime):

pkg:rpm/hummingbird/caddy@2.10.2-1.hum1?arch=x86_64&distro=hummingbird-20251124&upstream=caddy-2.10.2-1.hum1.src.rpm

Hermeto (build, binary):

pkg:rpm/caddy@2.10.2-1.hum1?arch=x86_64&checksum=sha256:ca02a0...&repository_id=public-hummingbird-x86_64-rpms

Hermeto (build, source):

pkg:rpm/caddy@2.10.2-1.hum1?arch=src&checksum=sha256:42912d...&repository_id=public-hummingbird-source-rpms

Key differences:

  • Distro namespace: Syft includes the distro in the PURL path (pkg:rpm/hummingbird/...). Hermeto omits it (pkg:rpm/...).
  • EVR: Syft populates versionInfo with the full epoch:version-release. Hermeto sets versionInfo to the bare upstream version; the full EVR is only in the PURL @version.
  • Architecture: Syft entries match the image architecture. Hermeto entries carry an arch= PURL qualifier that may be the image arch, a different arch (cross-arch), noarch, or src (source RPMs).
  • Source RPM: Syft entries carry upstream=<srpm> in the PURL. Hermeto entries have separate arch=src entries instead.

Hermeto Annotation Format

Hermeto entries carry annotations with JSON-encoded metadata:

{
  "annotationDate": "2026-03-02T11:58:12Z",
  "annotationType": "OTHER",
  "annotator": "Tool: hermeto:jsonencoded",
  "comment": "{\"name\": \"hermeto:found_by\", \"value\": \"hermeto\"}"
}

Entry Breakdown Example

Typical counts for caddy:latest (Hummingbird, amd64); exact counts vary as image dependencies change:

Source Typical count Content
Syft ~53 Binary RPMs (x86_64 + noarch)
Syft 1 Go module (stdlib)
Syft 2 OCI image metadata
Hermeto ~41 Binary RPMs (x86_64)
Hermeto ~41 Binary RPMs (aarch64, cross-arch)
Hermeto ~11 Binary RPMs (noarch)
Hermeto ~36 Source RPMs (arch=src)

How Scanners Use This Metadata

  1. CPE Matching: Scanners use the cpe value to look up applicable VEX statements for the product
  2. Name Matching: The name identifies which specific container the VEX statements apply to
  3. Version Comparison: The creation timestamp enables comparison between the scanned image and fixed versions reported in VEX statements
  4. Package Enumeration: Scanners use the SBOM to enumerate all packages in the image and correlate them with vulnerability databases via PURLs and CPEs
File Purpose
documentation/background/container-image-labels.md Complete reference for all image labels
documentation/background/image-pipeline.md SBOM generation pipeline (Stage 3)
images/variables.yml Defines the global cpe value
images/hummingbird-builder/inject-source-info.sh Script that creates labels.json
macros/inject_source_info_labels.yml.j2 Macro that adds LABEL to Containerfile
macros/install_newroot.yml.j2 Macro that invokes inject-source-info

See Also

References

3.5 - Container Image Labels

Complete reference for all container image labels

Hummingbird container images carry labels from multiple standards and namespaces. This page is the single reference for every label.

Standards: C = Conforma (rule dataset), O = OCI Image Spec, H = Hummingbird project, S = Security schema

Labels

Label Aliases Value C O H S
architecture Host architecture (e.g., x86_64) ✓
com.redhat.component hummingbird ✓
com.redhat.license_terms UBI EULA ¹
cpe CPE identifier (Hummingbird only) ² ✓ ✓
distribution-scope public ✓
io.hummingbird-project.containerfile Containerfile path relative to repo root ⁹ ✓
io.hummingbird-project.deprecated true for deprecated image streams ✓
io.hummingbird-project.major-minor-version Major.minor from tags (e.g., 2.10) ³ ✓
io.hummingbird-project.major-version Major from tags (e.g., 2) ³ ✓
io.hummingbird-project.repository Publishing name (e.g., caddy) ⁴ ✓
io.hummingbird-project.stream Version stream (e.g., 2) ⁴ ✓
io.hummingbird-project.variant Variant name (e.g., fpm-builder) ⁸ ✓
io.hummingbird-project.variant.base Base specialization (e.g., fpm) ⁸ ✓
io.hummingbird-project.variant.builder true when builder (absent otherwise) ⁸ ✓
io.hummingbird-project.variant.description Base variant description (no modifiers) ⁸ ✓
io.hummingbird-project.variant.fips true when FIPS (absent otherwise) ⁸ ✓
io.k8s.description Long description ⁵ ✓
maintainer Project Hummingbird / Red Hat ✓
name Canonical publishing repository ² ✓ ✓
org.opencontainers.image.created build-date RFC3339 build timestamp ✓ ✓ ✓
org.opencontainers.image.description description Long description ⁵ ✓ ✓
org.opencontainers.image.revision vcs-ref Full git commit SHA ✓ ✓
org.opencontainers.image.source GitLab repository URL ✓
org.opencontainers.image.title Image name (e.g., caddy) ✓
org.opencontainers.image.url url Upstream project URL ⁵ ✓ ✓
org.opencontainers.image.vendor vendor Red Hat, Inc. ⁶ ✓ ✓
org.opencontainers.image.version version Full version from tags (e.g., 2.10.2) ³ ✓ ✓
release SOURCE_DATE_EPOCH (commit timestamp) ⁷ ✓
summary Short one-liner ⁵ ✓
vcs-type git ✓

Notes

¹ Licenses

The com.redhat.license_terms label is not required by Conforma, OCI, or the security schema. It is a Red Hat convention present on all Red Hat container images (UBI, language runtimes, etc.), pointing to the UBI EULA.

The org.opencontainers.image.licenses label is intentionally not set. Per-package license data is derived from SBOMs stored as OCI artifacts alongside each image. A manually-set SPDX expression would be incomplete compared to the SBOM-derived data available in the image catalog.

² Name and identity

The name label identifies the canonical publishing repository. It uses the repository value from properties.yml, prefixed by the registry organization:

  • Red Hat supported Hummingbird images: hi/<repository>
  • Community images: hummingbird-community/<repository>
  • Experimental images: hummingbird-ci/<repository>
  • Rawhide images: hummingbird-rawhide/<repository>

All variants published to the same repository have the same name label. For example, the default and builder variants of Caddy both use hi/caddy.

The cpe label is only set for Hummingbird distro images (not Rawhide). See Security Labels and Metadata for details on the scanning workflow and labels.json.

³ Version labels

Three version granularities are derived from the image’s tags: version (full, e.g., 2.10.2), major-minor-version (e.g., 2.10), and major-version (e.g., 2). The full version is the OCI/Conforma org.opencontainers.image.version; the two coarser granularities are Hummingbird project labels used for tag aliasing. There is no io.hummingbird-project.version label — it would duplicate the OCI label.

⁴ Repository and stream

The io.hummingbird-project.repository and io.hummingbird-project.stream fields together form the release identity pair. Both are defined in properties.yml. See image-configuration-reference for field definitions.

The io.hummingbird-project.deprecated label is present with the value true on the final release of a deprecated stream. It is omitted for non-deprecated streams. The catalog uses the label to preserve the current deprecation state without changing older image digests.

⁵ Description, summary, and URL

The description, summary, and url fields are defined in properties.yml. Description and summary target different display contexts:

  • summary: One-liner (~40-80 chars) for table/list views
  • description: Short paragraph (~100-250 chars, 1-2 sentences) for card views and podman inspect

Style rules:

  • Do not start summary or description with the image name
  • Use >- YAML scalar for multi-line readability in properties.yml
  • Avoid embedded double quotes and backslashes (no escaping in templates)

⁶ Vendor

All images use Red Hat, Inc. as vendor (the distributing entity). The OCI org.opencontainers.image.vendor uses Red Hat (without “, Inc.”) per OCI convention. Both values are set on all distros.

⁷ Release

The release label uses the commit timestamp as a Unix epoch (matching Red Hat convention). In Konflux this is set via the generate-labels pipeline task.

⁸ Variant labels

Each variant name is decomposed into a base specialization and cross-cutting modifiers (builder, fips). The naming convention is <base>[-fips][-builder], where modifier order does not matter. A bare modifier like builder has base default.

Examples: fpm-builder → base=fpm, builder=yes; fips-builder → base=default, builder=yes, fips=yes.

The .description label stores only the base description (e.g., “PHP FastCGI process manager”). Modifier display is handled by consumers using the .builder and .fips boolean labels. Base descriptions come from variant_descriptions in images/variables.yml (for default) and image-specific properties.yml (for bases like fpm, runtime).

⁹ Containerfile path

The io.hummingbird-project.containerfile label contains the path to the Containerfile source relative to the repository root (e.g., generated/images/caddy/hummingbird/default/Containerfile). A full URL to the source can be constructed from org.opencontainers.image.source + org.opencontainers.image.revision + this path.

Embedded Metadata (labels.json)

All labels are written to /usr/share/buildinfo/labels.json inside the container filesystem, providing filesystem-level access for security scanners. See Security Labels and Metadata for schema details.

File Purpose
images/<image>/properties.yml Per-image metadata fields
ci/internal/generate_jinja2.py Builds inject_labels dict from properties
macros/image_metadata_labels.yml.j2 Emits LABEL instructions from inject_labels
macros/inject_source_info_labels.yml.j2 Emits name and cpe LABEL instructions
macros/install_newroot.yml.j2 Invokes inject-source-info.sh with labels
images/hummingbird-builder/inject-source-info.sh Writes labels.json to container filesystem
ci/build_images.sh Adds build-time labels
ci/check_release_fields.py Validates label field values in properties.yml

3.6 - CI Scripts

This section documents the CI scripts used in the Project Hummingbird container build and test pipeline.

These scripts can be run locally for development and testing, and are also used by the automated CI/CD pipeline.

3.6.1 - build_images.sh

Build container images using buildah with support for multiple architectures and container engines

Purpose

Build container images using buildah with support for multiple architectures and container engines.

Usage

Usage: ci/build_images.sh [OPTIONS] [GROUP_NAMES...] [-- BUILDAH_ARGS...]

OPTIONS:
    --verbose, -v        Enable verbose output during build
    --arch ARCHITECTURE  Specify target architecture (e.g., amd64, arm64, s390x)
    --engine ENGINE      Specify runtime engine for testing/export (podman or docker)
    --setup              Set up Docker-in-Docker environment before exporting
    --component-name NAME
                         Parse component name (format: group--distro--variant)
                         Example: curl--rawhide--default → curl/rawhide/default
    --local-rpms-dir DIR Directory containing custom RPMs to use during build.
                         These have a higher priority and thus override the
                         standard repositories.
    --build-deps         Build all dependencies of specified groups, but NOT the
                         specified groups themselves. Collects forward, reverse,
                         and transitive dependencies automatically.
    --affected-tests     With --build-deps, select dependencies needed by affected tests
    --pull               Pull images from registry instead of building locally.
                         Fast alternative for local development. Uses published
                         images from quay.io.
    --dryrun             Show what would be built (or pulled with --pull) without
                         actually building/pulling.
    --help, -h           Show this help message

Examples

# Build single image group (all distro/variants)
ci/build_images.sh nginx
ci/build_images.sh nodejs-20

# Build specific distro/variant only
ci/build_images.sh nginx/rawhide/builder
ci/build_images.sh nodejs-20/hummingbird/default

# Build multiple image groups at once
ci/build_images.sh nginx curl git
ci/build_images.sh nginx/rawhide/default curl/hummingbird/builder

# Build with options
ci/build_images.sh --verbose curl
ci/build_images.sh --arch arm64 nginx
ci/build_images.sh --engine docker nginx
ci/build_images.sh --engine podman --verbose nginx

# Build multiple image groups with options
ci/build_images.sh --verbose nginx curl git
ci/build_images.sh --arch arm64 nginx postgresql

# Build all dependencies of dotnet-runtime-10-0 (forward + reverse + transitive)
ci/build_images.sh --build-deps dotnet-runtime-10-0

# Pull dependencies from registry instead of building (faster for local dev)
ci/build_images.sh --build-deps --pull dotnet-runtime-10-0

# Pull a specific image from registry
ci/build_images.sh --pull nginx/rawhide/default

# Build from CI component name format (used in CI environments)
ci/build_images.sh --component-name curl--rawhide--default

# Build with custom RPMs (for testing modified packages)
ci/build_images.sh --local-rpms-dir ../rpms/builds/hostname/RPMS git/rawhide/builder

Note: Some images require git submodules initialized (use git init --recurse-submodules when cloning or git submodule update --init if already cloned). When building for foreign architectures, make sure qemu-user-static is available.

Dependency Building

The --build-deps flag builds all dependencies needed for testing an image, but not the image itself. This is used in CI to ensure all required images are available before running tests.

Pulling vs Building Dependencies

For local test development, use --pull with --build-deps to pull pre-built images from the registry instead of building them locally. This is much faster and uses the same dependency collection logic:

# Slow: Build all dependencies locally
ci/build_images.sh --build-deps nginx

# Fast: Pull all dependencies from registry
ci/build_images.sh --build-deps --pull nginx

The --pull flag works without --build-deps too:

# Pull a specific image instead of building it
ci/build_images.sh --pull caddy/rawhide/default

How Dependency Collection Works

When you run ci/build_images.sh --build-deps <image>, it performs a 2-level expansion:

  1. Level 1: Collects forward and reverse dependencies of the specified image

    • Forward dependencies: Images that the specified image’s tests depend on (detected by TEST_IMAGES[...] references in test files)
    • Reverse dependencies: Images that depend on the specified image (filtered by reverse_dependency_tests: true in properties.yml)
  2. Level 2: Collects forward dependencies of the reverse dependencies

  3. Stops: No further expansion (avoids infinite graph traversal)

All dependencies are automatically deduplicated to ensure each image is built exactly once.

Relationship to Testing

The reverse_dependency_tests property in properties.yml affects both building and testing:

  • Build phase (--build-deps): Filters which reverse dependencies to build
  • Test phase (ci/run_tests_container.sh and ci/run_tests_k8s.sh with --include-reverse-deps): Filters which reverse dependencies to test

Set reverse_dependency_tests: false for images like curl that are used pervasively but don’t need reverse dependency workflows.

Dependency Building Examples

# Build dependencies for dotnet-runtime-10-0
# Builds: dotnet-sdk-10-0 (forward dependency)
ci/build_images.sh --build-deps dotnet-runtime-10-0

# Pull dependencies for dotnet-runtime-10-0 (faster alternative)
# Pulls: dotnet-sdk-10-0 from quay.io/hummingbird-rawhide
ci/build_images.sh --build-deps --pull dotnet-runtime-10-0

# Build dependencies for core-runtime
# Builds: xcaddy, go, rust (reverse deps with reverse_dependency_tests: true)
# Plus their forward dependencies
ci/build_images.sh --build-deps core-runtime

# Use --dryrun to see what would be built/pulled without actually doing it
ci/build_images.sh --dryrun --build-deps core-runtime
ci/build_images.sh --dryrun --build-deps --pull core-runtime

Building with custom RPMs

The --local-rpms-dir option enables testing container images with custom-built RPM packages, for iterating on package changes before committing to the RPM repository.

Workflow

  1. Build custom RPMs in the rpms repository (see its documentation for the complete workflow):

    cd ../rpms
    # modify a package
    ci/build_rpms.sh packagename
    # Built RPMs will be in builds/packagename/RPMS/
    
  2. Build container image using the custom RPMs:

    cd ../containers
    ci/build_images.sh --local-rpms-dir ../rpms/builds/packagename/RPMS imagename/builder
    
  3. Verify the custom package was installed:

    podman run --rm --entrypoint '' registry.access.redhat.com/hi/imagename:latest-builder rpm -qa
    

Builds for affected container tests

Use --build-deps --affected-tests to select dependencies needed by affected container checks across both engines. Testing Farm adds --pull to fetch published dependencies. Supplied Konflux image URL overrides take precedence. Omit --pull to validate unpublished dependency changes from the checkout.

ci/build_images.sh --build-deps --affected-tests --pull core-runtime/hummingbird/default

--affected-tests requires --build-deps. Without it, dependency building retains the full reverse suite for callers that need broader coverage.

3.6.2 - run_tests_container.sh

Run tests for image groups using containerized test environment with support for both Docker and Podman engines

Purpose

Run tests for image groups using containerized test environment with support for both Docker and Podman engines.

Usage

Run ci/run_tests_container.sh --help for full usage information.

Usage: ci/run_tests_container.sh [OPTIONS] [GROUP_NAMES...]

OPTIONS:
    --verbose, -v        Enable verbose output during testing
    --engine ENGINE      Specify container engine (podman or docker)
    --setup              Set up Docker-in-Docker environment before running tests
    --pause, -p          Pause failed tests before cleanup to allow debugging
                         Prints a message and waits for Enter before cleaning up containers
    --hermetic           Use hermetic builds (--pull=never, only use prefetched/built images)
                         Default for CI. Without this, missing images are pulled on demand.
    --component-name NAME
                         Parse component name (format: group--distro--variant)
                         Example: curl--rawhide--default → curl/rawhide/default
    --group-component-name NAME
                         Parse component name and add /group variant
                         Example: curl--rawhide--default → curl/rawhide/group
    --affected-tests     Run only affected dependent image checks, retaining globals
    --dryrun             Print selected tests without invoking container engines
    --include-reverse-deps
                         Also test groups that depend on specified groups
    --help, -h           Show this help message

Note: If no distro/variant is specified, all combinations will be tested. To test only a specific variant, use <group_name>/<distro>/<variant>. To test only a specific test, use <group_name>/<distro>/<variant>/<test>.

Engine Selection: Use --engine to specify the container engine (podman or docker). When using Docker, add --setup for automatic Docker-in-Docker environment setup.

Hermetic vs Non-Hermetic Testing:

  • Local development (default): Without --hermetic, the test runner allows pulling missing images from the registry. This is convenient for testing individual images without building all dependencies first.
  • CI environment: Use --hermetic to enforce that tests only use prefetched or locally-built images (via --pull=never). This ensures reproducible builds and prevents accidentally using images from the registry that differ from what Konflux built.

Building and Testing

For local development, you’ll typically want to build the images first, then test them:

# Build the images
ci/build_images.sh <group_name>[/distro/variant]
ci/build_images.sh <group_name1> <group_name2>  # Build multiple image groups

# Test the images
ci/run_tests_container.sh <group_name>[/distro/variant]
ci/run_tests_container.sh <group_name1> <group_name2>  # Test multiple image groups

The build script uses the same syntax as the test script, making it easy to build and test the same image/variant combination.

When working on base images (like core-runtime) that other images depend on, build the base image and its dependencies before running reverse-dependency tests:

# Build core-runtime, then its forward and reverse dependencies
ci/build_images.sh core-runtime
ci/build_images.sh --build-deps core-runtime

# Then test them all
ci/run_tests_container.sh --include-reverse-deps core-runtime

The -p/--pause option pauses the script on failed tests before cleaning up to allow interactive and efficient debugging.

Testing with Specific Image Builds

To reproduce CI failures, you can run the test with mapping the image under test to the Konflux build. Set IMAGE_URL_<GROUP>__<DISTRO>__<VARIANT> environment variables (uppercase, hyphens/dots → underscores, __ separates parts):

# Test with a specific CI build
IMAGE_URL_TOMCAT_10__HUMMINGBIRD__BUILDER='quay.io/redhat-user-workloads/.../tomcat-10--hummingbird--builder@sha256:...' \
  ci/run_tests_container.sh tomcat-10/hummingbird/builder

Automatic Retries for Transient Infrastructure Failures

The test runner automatically retries tests that fail with certain transient infrastructure errors. This helps avoid false test failures caused by temporary issues with external services like container registries or network problems.

Retry Behavior:

  • Failed tests are checked against a list of retriable error patterns
  • If a match is found, the test is automatically retried
  • If the test still fails after all attempts, it’s reported as a normal failure

Examples

# Test single group (all distro/variants)
ci/run_tests_container.sh curl
ci/run_tests_container.sh nginx

# Test single group (specific distro/variant)
ci/run_tests_container.sh curl/rawhide/default
ci/run_tests_container.sh nodejs-20/hummingbird/builder

# Test multiple groups (all distro/variants)
ci/run_tests_container.sh curl nginx mariadb

# Test multiple groups (mixed variants)
ci/run_tests_container.sh curl/rawhide/default nginx mariadb/hummingbird/builder

# Test with different engines
ci/run_tests_container.sh --engine docker --setup git
ci/run_tests_container.sh --engine podman --verbose nginx

# Test with verbose output for debugging
ci/run_tests_container.sh --verbose nginx mariadb dotnet-runtime-8-0

# Test specific tests
ci/run_tests_container.sh curl/rawhide/default/version
ci/run_tests_container.sh nginx/rawhide/default/port
ci/run_tests_container.sh curl/rawhide/default/version nginx/hummingbird/default/port

# Also test groups with reverse dependencies
ci/run_tests_container.sh --include-reverse-deps core-runtime

# Test from CI component name format (used in CI environments)
ci/run_tests_container.sh --component-name curl--rawhide--default

# Test across variants from CI component name format
ci/run_tests_container.sh --group-component-name curl--rawhide--default

Reverse dependency testing is enabled by default for all images. When enabled, changes to an image will automatically:

  • Find images that depend on it by scanning for TEST_IMAGES[group/distro/variant] references
  • Run tests for both the changed image and all its dependents

This catches breaking changes in base images early. Images can opt-out by setting reverse_dependency_tests: false in properties.yml (e.g., curl, which is widely used but typically doesn’t need reverse dependency testing).

Affected dependency tests

Testing Farm uses --affected-tests for single-component jobs. Dependency pulls use ci/build_images.sh --build-deps --affected-tests --pull with the same selector. The requested component runs its complete suite. Dependent variants run only image checks that reference the changed component variant, plus all applicable global checks.

Use --dryrun to list runnable checks without invoking container engines. Use --include-reverse-deps without --affected-tests to run full dependent suites.

Run each suite with an isolated container engine. Cleanup preserves resources present at startup and removes resources created during the suite, including resources created concurrently by other processes.

3.6.3 - run_tests_k8s.sh

Run K8s tests for image groups using kubectl with support for local development and CI environments

Purpose

Run K8s tests for image groups using kubectl. Tests execute in a real Kubernetes environment, validating that images work correctly in Kubernetes.

Usage

Run ci/run_tests_k8s.sh --help for full usage information.

Usage: ci/run_tests_k8s.sh [OPTIONS] [GROUP_NAMES...]

OPTIONS:
    --context CONTEXT    K8s context to use (REQUIRED unless --kubeconfig set)
    --kubeconfig FILE    Path to kubeconfig file (REQUIRED unless --context set)
    --push-image IMAGE   Push local image to OpenShift internal registry before testing
                         Sets TEST_IMAGE to the internal registry reference
    --verbose, -v        Show full output for passing tests
    --pause, -p          Pause on test failure before cleanup to allow debugging
    --component-name NAME
                         Parse component name (format: group--distro--variant)
                         Example: curl--rawhide--default → curl/rawhide/default
    --group-component-name NAME
                         Parse component name and add /group variant
                         Example: curl--rawhide--default → curl/rawhide/group
    --include-reverse-deps
                         Also test groups that depend on specified groups
    --output FILE        Write JSON test results to FILE (for CI integration)
                         When set, exits 0 after writing results regardless of test outcome
    --help, -h           Show this help message

Safety: The script requires explicit --context or --kubeconfig to prevent accidental operations on production clusters.

Note: If no distro/variant is specified, all combinations will be tested. To test only a specific variant, use <group_name>/<distro>/<variant>. To test only a specific test, use <group_name>/<distro>/<variant>/<test>.

Local Development Workflow

For local development, build images and push them to the internal registry:

# Build the image locally
ci/build_images.sh nginx/hummingbird/default
podman tag registry.access.redhat.com/hi/nginx:latest my-nginx:dev

# Push to internal registry and test
ci/run_tests_k8s.sh --context mpp-preprod --push-image my-nginx:dev nginx/hummingbird/default

The --push-image flag:

  • Compares local image digest with remote
  • Skips push if image already exists with same digest
  • Uses port-forward to registry-proxy for pushing
  • Sets TEST_IMAGE to the internal registry reference

Prerequisites:

  • oc login to the target cluster
  • oc project <namespace> to set the target namespace
  • Port-forward access to registry-proxy in hummingbird--internal

Testing with Published Images

Test published images without building locally:

# Test using published images (resolved via IMAGE_URL/IMAGE_NAME)
IMAGE_URL=registry.access.redhat.com/hi/nginx:latest \
IMAGE_NAME=nginx--hummingbird--default \
    ci/run_tests_k8s.sh --context mpp-preprod nginx/hummingbird/default

Environment Variables

Tests have access to these environment variables:

Variable Description
TEST_IMAGE Container image under test (with digest)
TEST_IMAGES Associative array with group/variant image URLs (uses TEST_DISTRO context)
TEST_IMAGES_PATH Path to file containing serialized TEST_IMAGES array
TEST_GROUP The image group being tested
TEST_DISTRO The distro being tested (e.g., rawhide)
TEST_VARIANT The variant being tested (e.g., default)
TEST_VERBOSE Show test command output (true or false)
TEST_RUN_ID Unique ID for this test run (for resource naming)
TEST_RUN_LABEL Label selector for cleanup (hum-k8s-test=<id>)

Cluster Access

The kubectl command is pre-configured with the context/kubeconfig from CLI args, so tests can use it directly without additional configuration.

Helper Function

Function Description
test_fail Fail the test with a custom error message

Examples

# Test single group with explicit context (all distro/variants)
ci/run_tests_k8s.sh --context mpp-preprod nginx

# Test specific distro/variant
ci/run_tests_k8s.sh --context mpp-preprod nginx/hummingbird/default

# Test specific test
ci/run_tests_k8s.sh --context mpp-preprod nginx/hummingbird/default/readiness-probe

# Test with verbose output for debugging
ci/run_tests_k8s.sh --verbose --context mpp-preprod nginx

# Test with kubeconfig file (CI environments)
ci/run_tests_k8s.sh --kubeconfig /workspace/kubeconfig nginx/hummingbird/default

# Build locally and push to internal registry
ci/build_images.sh nginx/hummingbird/default
podman tag registry.access.redhat.com/hi/nginx:latest my-nginx:dev
ci/run_tests_k8s.sh --context mpp-preprod --push-image my-nginx:dev nginx/hummingbird/default

# Test from CI component name format
ci/run_tests_k8s.sh --kubeconfig /workspace/kubeconfig --component-name nginx--hummingbird--default

# Write JSON results for CI integration
ci/run_tests_k8s.sh --output /tmp/results.json --kubeconfig /workspace/kubeconfig nginx/hummingbird/default

Resource Cleanup

Tests should label resources with TEST_RUN_LABEL for automatic cleanup:

kubectl create configmap my-config --from-literal=key=value
kubectl label configmap my-config "${TEST_RUN_LABEL}"

The test runner automatically cleans up all resources with the test run label after each test and at script exit.

CI Integration

In CI environments (Konflux), the script receives:

  • --kubeconfig pointing to the ephemeral namespace kubeconfig
  • --component-name or --group-component-name for component identification
  • --output for structured JSON results

The --output flag writes results in Konflux-compatible format and exits 0, allowing the pipeline to read results without relying on exit codes.

3.6.4 - retrigger_failed_checks.py

Retrigger failed Konflux CI checks for a given GitLab merge request

Purpose

Retrigger failed Konflux CI checks by posting retest commands and waiting for the checks to start running.

Usage

Run ci/retrigger_failed_checks.py --help for full usage information.

Usage: ci/retrigger_failed_checks.py <MR_URL> [OPTIONS]

OPTIONS:
    -h, --help              show this help message and exit
    --dry-run               just print what comments would be posted
    --token-path, -t PATH   path to file containing GitLab API token

Authentication (in order of precedence):
  1. --token-path: Path to file containing GitLab API token (highest priority)
  2. GITLAB_TOKEN_PATH: Environment variable with path to token file
  3. GITLAB_TOKEN: Environment variable with GitLab API token (fallback)

Behavior

The script will:

  1. Extract failed pipeline runs from the commit statuses on the MR
  2. Generate /retest {pipeline-name} commands for each failed run
  3. Post the retest commands as MR comments (unless –dry-run)
  4. Wait for the pipeline runs to start (unless –dry-run)

Examples

# Using --token-path option (highest priority)
ci/retrigger_failed_checks.py --token-path /path/to/token https://gitlab.com/group/project/-/merge_requests/1234

# Using GITLAB_TOKEN_PATH environment variable
GITLAB_TOKEN_PATH=/path/to/token ci/retrigger_failed_checks.py https://gitlab.com/group/project/-/merge_requests/1234

# Using GITLAB_TOKEN environment variable (legacy)
GITLAB_TOKEN=your-token-here ci/retrigger_failed_checks.py https://gitlab.com/group/project/-/merge_requests/1234

# Dry-run mode (show what would be done, don't post or wait)
ci/retrigger_failed_checks.py --token-path /path/to/token --dry-run https://gitlab.com/group/project/-/merge_requests/1234

3.6.5 - gitlab_sync.py

Sync a generated file to an external GitLab repository via merge request, with automatic merge wait, pipeline retry, and preemption support

Purpose

Sync a file from a public source URL to a target GitLab repository via merge request. The script always waits for the MR to be merged and for the post-merge pipeline to complete before returning. It is invoked by infrastructure CI jobs to keep external repos (pyxis-repo-configs, konflux-release-data) in sync with generated files from the source repos.

Usage

Run gitlab_sync.py --help for full usage information.

usage: gitlab_sync.py [-h] --source-url SOURCE_URL
                      --target-project TARGET_PROJECT
                      --target-file TARGET_FILE --sync-branch SYNC_BRANCH
                      --mr-title MR_TITLE --gitlab-url GITLAB_URL
                      [--merge-timeout MERGE_TIMEOUT]
                      [--post-merge-timeout POST_MERGE_TIMEOUT]
                      [--mr-description MR_DESCRIPTION] [--squash-on-merge]
                      [--dry-run]

Authentication:
  GITLAB_TOKEN    Environment variable with GitLab API token (required)

Monitoring:
  SENTRY_DSN      Sentry DSN for alerting on failures (optional)

How It Works

  1. Fetch source from the public --source-url (with HTTP retries)
  2. Determine comparison ref: use the sync branch if it exists, otherwise the project’s default branch
  3. Compare source content against the comparison ref (stripped whitespace)
  4. Early exit if content matches and the comparison ref is the default branch (nothing to deploy)
  5. Commit the source to the sync branch using the GitLab Commits API with force: true (creates a single-commit branch on top of the default branch). Also auto-squashes if the branch has accumulated multiple commits.
  6. Create or update MR targeting the default branch
  7. Self-approve the MR (best-effort; continues if already approved)
  8. Post takeover comment with CI_JOB_ID for preemption tracking
  9. Wait for MR merge (polling every 30s, up to --merge-timeout)
  10. Wait for post-merge pipeline to succeed (up to --post-merge-timeout)

Exit Codes

Code Meaning
0 Success (MR merged) or no changes needed
1 Failure (timeout, pipeline failure, unrecoverable error)
42 Preempted by a newer CI job managing the same MR

Preemption

When multiple CI jobs target the same sync branch, the script uses MR comments to coordinate. Each job posts a comment containing its CI_JOB_ID (monotonically increasing within a GitLab instance). Before each merge attempt, the script checks for comments with a higher job ID. If found, it yields by exiting with code 42.

The caller should handle exit 42 to stop processing (a newer job will handle all remaining syncs). When running outside CI (CI_JOB_ID not set), preemption is disabled.

Error Recovery

  • Pipeline failure: Retries the MR pipeline with exponential backoff (60s, 300s, 900s). Sends a Sentry alert if all retries are exhausted.
  • Merge conflict: Rebases the MR and re-approves (rebase resets approvals in GitLab). Up to 3 attempts before failing with a Sentry alert.
  • Post-merge pipeline failure: Retries once, then returns exit code 1. This blocks downstream sync steps (e.g., Pyxis must succeed before RPA).
  • Draft MR: Keeps polling without attempting to merge (allows manual intervention).

Examples

# Dry run: show diff without modifying anything
GITLAB_TOKEN=$TOKEN gitlab_sync.py \
  --source-url "https://gitlab.com/redhat/hummingbird/containers/-/raw/main/releng/pyxis-hummingbird.yaml" \
  --target-project "releng/pyxis-repo-configs" \
  --target-file "products/hummingbird/hummingbird.yaml" \
  --sync-branch "hummingbird/sync-containers-pyxis" \
  --mr-title "chore: Update hummingbird Pyxis config" \
  --gitlab-url "https://gitlab.cee.redhat.com" \
  --dry-run

# Sync Pyxis config (requires squash merge)
gitlab_sync.py \
  --gitlab-url "https://gitlab.cee.redhat.com" \
  --source-url "https://gitlab.com/redhat/hummingbird/containers/-/raw/main/releng/pyxis-hummingbird.yaml" \
  --target-project "releng/pyxis-repo-configs" \
  --target-file "products/hummingbird/hummingbird.yaml" \
  --sync-branch "hummingbird/sync-containers-pyxis" \
  --mr-title "chore: Update hummingbird Pyxis config" \
  --squash-on-merge

# Sync RPM RPA to konflux-release-data
gitlab_sync.py \
  --gitlab-url "https://gitlab.cee.redhat.com" \
  --source-url "https://gitlab.com/redhat/hummingbird/rpms/-/raw/main/releng/hummingbird-rpms-staging.yaml" \
  --target-project "releng/konflux-release-data" \
  --target-file "config/kflux-prd-rh03.nnv1.p1/product/ReleasePlanAdmission/hummingbird/hummingbird-rpms-staging.yaml" \
  --sync-branch "hummingbird/sync-rpms-rpa" \
  --mr-title "Update hummingbird RPM ReleasePlanAdmission"

Preemption wrapper for sequential syncs in a CI job:

gitlab_sync.py --gitlab-url ... --source-url ... --squash-on-merge
rc=$?
if [ "$rc" -eq 42 ]; then
  echo "Preempted by newer job, stopping"
  exit 0
elif [ "$rc" -ne 0 ]; then
  exit "$rc"
fi
# Proceed to next sync only on success
gitlab_sync.py --gitlab-url ... --source-url ...

Environment Variables

Variable Required Description
GITLAB_TOKEN Yes API token with Developer+ access on the target project
CI_JOB_ID No Set automatically in CI; enables preemption detection
SENTRY_DSN No Sentry DSN for failure alerting

Development

The script and gitlab-ci tool image are maintained in the Hummingbird tools repository, not this repository. Make implementation and test changes there.

4 - K8s Test Pipeline

Background documentation sourced from the K8s test pipeline repository, covering pipeline design, test format, and EaaS debugging for Kubernetes integration tests.

4.1 - Pipeline Design

Design guidelines and architecture of the K8s test pipeline.

Task/Step Overview

Task Type Steps Results Retries Timeout
check-for-tests Inline taskSpec write-snapshot, resolve-component, fetch-source, check-for-tests, write-results HAS_TESTS, TEST_OUTPUT 2 10m
provision-namespace External bundle (task-eaas-provision-space) (managed by task) secretRef 2 10m
run-tests Inline taskSpec write-snapshot, resolve-component, fetch-source, run-tests, write-results TEST_OUTPUT 2 24h
fail-pipeline-on-test-failure Inline taskSpec (finally) check-results — 0 10m

Both check-for-tests and run-tests independently fetch source — each task gets its own emptyDir volume, so no state carries across tasks.

Design Guidelines

1. Filesystem over results

Pass data between steps via files on a shared emptyDir, not Tekton results. Tekton results have a 4KB termination message limit (tektoncd/pipeline#4060) and large $(params.*) substitutions hit ARG_MAX.

Each task mounts an emptyDir at /workdir. Steps communicate via files: snapshot.json, component, image_names, src/, kubeconfig, result, error. Zero $(params.*) references remain inside script: blocks — all param values are passed via env vars or written to /workdir.

2. Distinguish retryable from non-retryable errors

Use exit 1 for transient failures (network, registry) that benefit from retry. Use exit 0 + a sentinel file for permanent failures (bad data) to prevent wasted retries.

Non-retryable errors write Konflux-format JSON to /workdir/error and exit 0. Subsequent steps check for this sentinel and skip. The write-results step copies it to TEST_OUTPUT. Tasks default to retries: 2, but fail-pipeline-on-test-failure has retries: 0 — test failures should not be retried.

3. Fail fast

Every shell script enables set -euo pipefail so unexpected command failures surface immediately rather than cascading silently.

4. Separate data from status

Report test results as structured data for the CI system to consume, and separately translate results into pipeline pass/fail for source control integration.

Konflux reads TEST_OUTPUT results as data but does not fail the pipeline based on them. The fail-pipeline-on-test-failure finally-task reads both TEST_OUTPUT values and exit 1 on non-SUCCESS. This is needed because GitLab PAC integration requires the pipeline itself to fail for MR status reporting.

5. Gate expensive work behind cheap checks

Run a fast, cheap check before provisioning resources. Skip the expensive path when there is nothing to do.

provision-namespace and run-tests are gated on check-for-tests.results.HAS_TESTS == "true" via Tekton when expressions. If no tests-k8s.yml exists, the pipeline completes with just check-for-tests, skipping EaaS provisioning. This is the common case for components without K8s tests.

6. Make failures reproducible

Preserve all diagnostic output (do not swallow stderr). Log exact commands so developers can copy-paste to reproduce locally.

Stderr from cosign/jq/oras is not swallowed. The full run_tests_k8s.sh command line is logged.

7. Set task timeouts deliberately

Tekton applies a global default TaskRun timeout (typically 1h from default-timeout-minutes in config-defaults) to every task that has no explicit timeout, regardless of pipeline-level timeouts. This means omitting a task timeout does NOT let the pipeline timeout control the bound — the task gets killed at 1h.

Every task in this pipeline has an explicit timeout. Bounded operations (check-for-tests, provision-namespace, fail-pipeline-on-test-failure) use 10m. The variable-duration run-tests task uses 24h so the pipeline-level timeout (controlled per-application via ITS annotations like test.appstudio.openshift.io/pipeline_timeout) is the effective bound, following the Testing Farm pipeline pattern.

8. Define shared logic once

When multiple tasks need identical steps, use YAML anchors to define them once and reuse via aliases. Tekton pipelines have no function/import mechanism, so anchors are the only DRY tool available within a single pipeline file. Without this, step logic drifts between tasks when one copy is updated but not the other.

write-snapshot, resolve-component, and fetch-source are defined as anchors (&write-snapshot-step, etc.) in check-for-tests and reused via *write-snapshot-step in run-tests. Both tasks independently execute the same steps to fetch and process source, staying in sync.

Trusted Artifacts Source Fetch

Source code is fetched via the Konflux Trusted Artifacts chain:

  1. cosign download attestation — retrieves build attestations (handles multiple JSONL attestations)
  2. Extract SOURCE_ARTIFACT from .predicate.buildConfig.tasks[].results[]
  3. oras blob fetch — downloads the source tarball
  4. tar -xzf — extracts to /workdir/src

The oci: prefix is stripped from the artifact URI. --no-same-owner avoids permission issues during extraction.

4.2 - Test Format

Pipeline-level test discovery, environment variables, parameters, and group snapshot handling.

Test Discovery

The pipeline delegates test discovery to each repository’s own test runner. It calls ci/run_tests_k8s.sh --has-tests with the component names from the snapshot and reads true/false from stdout to determine whether any component has K8s tests defined. This keeps test file location knowledge in the repository that owns the tests, not in the pipeline.

The actual tests-k8s.yml format is opaque to the pipeline — it is passed to the repository’s test runner script, which defines the structure and semantics. See the respective repository documentation for the YAML format.

Environment Variables

The test runner receives these variables from the pipeline:

Variable Description
TEST_IMAGE Container image under test (with digest)
TEST_GROUP Image group name (e.g., git, postgresql)
TEST_VARIANT Variant being tested (e.g., default, builder)
KUBECONFIG Path to kubeconfig for the ephemeral namespace
TEST_IMAGES Bash associative array of all images in the snapshot
TEST_IMAGES_PATH Path to source TEST_IMAGES in external scripts
TEST_RUN_ID Unique ID for this test run (for resource naming)
TEST_RUN_LABEL Label selector for cleanup (e.g., hum-k8s-test=<id>)
test_fail Shell function — call test_fail "message" to fail a test

Pipeline Parameters

Parameter Description Default
SNAPSHOT Konflux snapshot JSON or snapshot name (with snapshot-param-as-name annotation) —
EXTRA_SINGLE_COMPONENT_ARGS Additional arguments appended to the test runner for single-component (PR) snapshots --include-reverse-deps

Group Snapshot Handling

For group snapshots (post-merge), the pipeline exports:

  • IMAGE_NAMES — space-separated list of all component names
  • IMAGE_URL_<COMPONENT> — image URL for each component (name uppercased, dashes replaced with underscores)

The test runner script is called once with multiple --group-component-name arguments. Test discovery checks all components for tests-k8s.yml. Source is fetched from the first component’s build attestation (shared source tree).

4.3 - EaaS and Debugging

How the pipeline uses Konflux Environment as a Service, and how to debug test failures.

Environment as a Service (EaaS)

EaaS is a Konflux-managed service that provisions ephemeral OpenShift namespaces on member clusters for integration testing.

Provisioning Flow

  1. The provision-namespace task creates an eaas.konflux-ci.dev/v1alpha1 Namespace claim, owned by the PipelineRun.
  2. The EaaS controller allocates a namespace on a member cluster (currently kflux-prd-es01) and writes a scoped kubeconfig into a Kubernetes Secret.
  3. The run-tests step reads the kubeconfig from the Secret to get cluster access.
  4. When the PipelineRun is deleted, the owner reference triggers garbage collection of the claim, namespace, and Secret.

The member cluster may change — it is dynamically assigned by the EaaS controller.

Debugging Test Failures

Using Kubearchive for Historical PLRs

Kubearchive stores historical PipelineRun, TaskRun, and Pod data. The containers repo includes a helper script at ci/internal/k8s_helper.py.

Example: find PLRs for a specific PR:

cd /path/to/rprm-containers
python3 -c "
from ci.internal import k8s_helper
h = k8s_helper.K8sHelper('https://konflux-ui.apps.kflux-prd-rh03.nnv1.p1.openshiftapps.com/ns/hummingbird-tenant/')
endpoint = h._tekton_endpoint('pipelineruns')
items = h._fetch_from_both_sources(endpoint, 'pac.test.appstudio.openshift.io/pull-request=421,test.appstudio.openshift.io/scenario=tools-k8s-test')
for plr in items:
    print(plr['metadata']['name'], plr['status']['conditions'][0]['reason'])
"

Useful label selectors:

  • test.appstudio.openshift.io/scenario=<scenario-name> — filter by ITS name
  • pac.test.appstudio.openshift.io/pull-request=<number> — filter by MR
  • appstudio.openshift.io/component=<component> — filter by component

Accessing the EaaS Namespace During a Live PLR

While a PipelineRun is running, you can access the ephemeral namespace:

  1. Find the provision secret name from the provision-namespace task results (visible in the PLR status or Konflux UI).

  2. Extract the kubeconfig:

    kubectl get secret <secret-name> -n hummingbird-tenant \
      -o jsonpath='{.data.kubeconfig}' | base64 -d > /tmp/eaas-kubeconfig
    
  3. Use it to inspect the namespace:

    export KUBECONFIG=/tmp/eaas-kubeconfig
    kubectl get pods
    kubectl get events --sort-by='.lastTimestamp'
    kubectl logs <pod-name>
    

Finding Which Cluster EaaS Uses

To determine the current EaaS member cluster, extract the server URL from a kubeconfig provisioned by EaaS:

kubectl get secret <secret-name> -n hummingbird-tenant \
  -o jsonpath='{.data.kubeconfig}' | base64 -d | grep server

As of this writing, the EaaS member cluster is kflux-prd-es01.1ion.p1.openshiftapps.com. This may change as the EaaS controller dynamically assigns clusters.

5 - Tools repository

Background documentation sourced from the tools repository, covering infrastructure components like event forwarders, the message bus, and monitoring tools.

5.1 - Message Bus Architecture

The Hummingbird message bus is an event-driven architecture built on AWS SNS. Events from multiple sources flow through a central topic, enabling subscribers to filter and process only the events they need.

Architecture

flowchart TD
    subgraph Publishers
        GL[GitLab Webhooks]
        JIRA[Jira Automation]
        K8S[Kubernetes Clusters]
    end

    subgraph MessageBus [Message Bus]
        SNS[(SNS Topic)]
    end

    subgraph Archiver
        ARCH[SNS S3 Archiver]
        S3[(S3 Bucket)]
    end

    subgraph StatusDB [Status Database]
        SQS1[SQS Queue]
        WORKER[hummingbird-status]
        PG[(PostgreSQL)]
    end

    subgraph ConsumerQueue [Consumer Queue]
        SQS2[SQS Queue]
    end

    subgraph Catalog [Container Catalog]
        SYNC[SyncFunction]
        DDB[(DynamoDB)]
    end

    subgraph Consumers
        CONSUMER[Event Consumer]
    end

    GL -->|gitlab-event-forwarder| SNS
    JIRA -->|jira-event-forwarder| SNS
    K8S -->|kubernetes-event-forwarder| SNS
    SNS --> ARCH
    ARCH --> S3
    SNS --> SQS1
    SQS1 --> WORKER
    WORKER --> PG
    SNS -->|"FilterPolicy\nkind=Release"| SYNC
    SYNC -->|fetch manifests| Registry[(OCI Registry)]
    SYNC -->|read/write| DDB
    SNS --> SQS2
    SQS2 -->|new events| CONSUMER
    S3 -.->|historic events| CONSUMER
    PG -.->|aggregated status| CONSUMER

Components

Component Role Description Staging
hummingbird-events-topic Infrastructure Central SNS topic for all events Yes
gitlab-event-forwarder Publisher Receives GitLab webhooks, publishes to SNS Yes
jira-event-forwarder Publisher Receives Jira Automation webhooks, publishes to SNS Yes
kubernetes-event-forwarder Publisher Watches K8s resources, publishes changes to SNS Yes
sns-s3-archiver Subscriber Archives all events to S3 for querying/replay Yes
hummingbird-status Subscriber Ingests events to PostgreSQL for structured queries Yes
pipeline-timing Subscriber Stores pipeline timing in its own schema and queue No
hummingbird-agent Subscriber Processes events to drive automated workflows Yes
workqueue-service Subscriber Queues and dispatches work items from events Yes
container-catalog Subscriber Incrementally syncs image metadata to DynamoDB on Release events No

For subscriber rows, the Staging column describes running consumers. Pipeline Timing queues and subscriptions can be provisioned in both environments before the workers run; their presence does not mark the service as deployed.

Message Format

All messages include standard attributes for filtering:

Attribute Description Examples
source Event origin gitlab, jira, kubernetes
event_type Type of event push, merge_request, pipeline, build, MODIFIED

Additional attributes vary by source - see individual publisher docs for details.

GitLab Events

Published by gitlab-event-forwarder:

Attribute Description Example
project_path Full project path redhat/hummingbird/containers
group_path Full group path redhat/hummingbird

When configured, Pipeline Hooks use event_type: pipeline; Job Hooks use event_type: build.

Jira Events

Published by jira-event-forwarder:

Attribute Description Example
project_key Jira project key HUM
issue_type Issue type name Story, Bug

The event_type attribute is set from the X-Jira-Event-Type request header, which each Jira Automation rule hardcodes for its trigger type (e.g., issue_created, issue_updated).

Kubernetes Events

Published by kubernetes-event-forwarder:

Attribute Description Example
cluster Logical kubeconfig cluster entry name konflux-rh03
namespace Object namespace production
kind Resource kind PipelineRun, TaskRun, Snapshot, Release
api_version API version tekton.dev/v1, appstudio.redhat.com/v1alpha1
object_name Resource name nginx-7d8c4c9d6f

Published kinds depend on the deployed forwarder configuration. TaskRun source activation is separate rollout work. The forwarder obtains cluster from the selected kubeconfig context’s context.cluster value, not the API server URL.

Subscription Filtering

SNS filter policies enable subscribers to receive only relevant events:

{
  "source": ["gitlab"],
  "event_type": ["push", "merge_request"]
}
{
  "source": ["jira"],
  "event_type": ["issue_created", "issue_updated"]
}
{
  "source": ["kubernetes"],
  "kind": ["Deployment"],
  "event_type": ["MODIFIED"]
}

Kubernetes Release events (used by container-catalog sync Lambda):

{
  "kind": ["Release"]
}

See hummingbird-events-topic for subscription setup instructions.

Event Flow Example

  1. Developer pushes to GitLab repository
  2. GitLab sends webhook to gitlab-event-forwarder Lambda
  3. Lambda validates token, extracts metadata, publishes to SNS
  4. SNS delivers to all matching subscribers:
    • sns-s3-archiver stores event in S3
    • Event consumers process new events in real-time

Archive keys

The S3 archiver writes one deterministic key for each SNS message. Duplicate delivery rewrites the same object. GitLab pipeline and job identities include the project and their numeric IDs; all Kubernetes identities include logical cluster, namespace, and name. URL-valued cluster attributes from older forwarders retain their name-only archive identities.

See SNS S3 Archiver for the exact key format and the machine-readable archive-key contract used by the archiver and GitLab forwarder tests.

Consuming Events

Consumers can receive events from multiple sources:

  • New events: Subscribe to SNS topic for real-time processing
  • Historic events: Download from S3 archive for replay or catch-up
  • Structured queries: Query PostgreSQL via hummingbird-status for pipeline status, component state, and cross-referenced data

The S3 archive enables consumers to bootstrap state, then switch to live SNS events. Hummingbird Status provides a queryable view of its current pipeline status data. Timeline ingestion for GitLab pipelines/jobs and TaskRuns is separate follow-up work.

Replaying Events

The sns-s3-archiver stores complete SNS records with decoded payloads, enabling easy replay:

# Download archived events
aws s3 sync s3://bucket-name/2025/12/13/ ./local-events/

# Browse events
zcat ./local-events/2025/12/13/12/34/*.json.gz | jq
import gzip
import json
from pathlib import Path

# Replay archived events to handler
for path in sorted(Path("./local-events").rglob("*.json.gz")):
    with gzip.open(path, "rt") as f:
        sns_record = json.load(f)
    event = {"Records": [{"Sns": sns_record}]}
    my_handler.lambda_handler(event, None)

See sns-s3-archiver documentation for details on storage format and the _decode_message pattern for handlers.

Staging Environment

Production and staging use independent SNS topics. Each environment has its own publishers, archiver, and consumer queues so that changes can be tested in staging before production deployment.

flowchart TD
    GL["GitLab Projects\n(dual webhook delivery)"]
    JIRA["Jira Automation\n(dual webhook delivery)"]
    K8S["Konflux Cluster\n(K8s resources)"]

    subgraph production ["Production"]
        GEF_P["gitlab-event-forwarder\n(Lambda)"]
        JEF_P["jira-event-forwarder\n(Lambda)"]
        KEF_P["kubernetes-event-forwarder\n(K8s pod)"]
        SNS_P["SNS: arr-hummingbird-prod-events"]
        prodConsumers["consumers\n(status, agent, workqueue,\narchiver, etc.)"]

        GEF_P --> SNS_P
        JEF_P --> SNS_P
        KEF_P --> SNS_P
        SNS_P --> prodConsumers
    end

    subgraph staging ["Staging"]
        GEF_S["gitlab-event-forwarder\n(Lambda)"]
        JEF_S["jira-event-forwarder\n(Lambda)"]
        KEF_S["kubernetes-event-forwarder\n(K8s pod)"]
        SNS_S["SNS: arr-hummingbird-staging-events"]
        stagConsumers["consumers\n(status, agent, workqueue,\narchiver)"]

        GEF_S --> SNS_S
        JEF_S --> SNS_S
        KEF_S --> SNS_S
        SNS_S --> stagConsumers
    end

    GL --> GEF_P
    GL --> GEF_S
    JIRA --> JEF_P
    JIRA --> JEF_S
    K8S --> KEF_P
    K8S --> KEF_S

How it works

  • Duplicate webhook delivery: GitLab sends every event to both gitlab-event-forwarder.hummingbird-project.io (prod) and gitlab-event-forwarder.staging.hummingbird-project.io (staging). Each forwarder publishes to its own SNS topic.
  • Duplicate webhook delivery: Jira Automation sends every event to both jira-event-forwarder.hummingbird-project.io (prod) and jira-event-forwarder.staging.hummingbird-project.io (staging). Each forwarder publishes to its own SNS topic.
  • Dual Kubernetes watches: Both prod and staging kubernetes-event-forwarder pods watch the same Konflux cluster resources and publish to their respective topics.
  • Consumer isolation: Staging consumers (hummingbird-status, hummingbird-agent, workqueue-service) have separate SQS queues subscribed to the staging topic, with dedicated Postgres databases.
  • Staging archiver: A separate sns-s3-archiver subscribes to the staging topic and archives to its own S3 bucket with a shorter retention period than production.

5.2 - Alloy CloudWatch

An AWS CloudFormation/SAM stack that provisions a read-only IAM user for Grafana Alloy’s prometheus.exporter.cloudwatch component. Grants access to CloudWatch Metrics APIs so selected AWS service metrics (Lambda, SQS, SNS, DynamoDB) can be ingested into Mimir for alerting.

Features

  • Read-Only IAM User: Dedicated user for Alloy CloudWatch metrics exporter
  • CloudWatch Metrics: GetMetricData, GetMetricStatistics, ListMetrics
  • Resource Discovery: tag:GetResources for filtering by app-code=RPRM-001
  • Account Discovery: sts:GetCallerIdentity and ec2:DescribeRegions for YACE auto-discovery

Architecture

Single SAM template that creates:

  1. IAM User - Read-only service account (${ResourcePrefix}-user)
  2. IAM Policy - CloudWatch Metrics read, tag and region discovery, STS caller identity

The user’s access keys are created manually after deployment and stored in Vault. The companion infrastructure repo injects the credentials into the Alloy Hub pod on MPP via a Kubernetes Secret.

flowchart LR
  subgraph aws [AWS]
    iam["IAM User\n(read-only)"]
    cwMetrics["CloudWatch Metrics"]
  end
  subgraph mpp [MPP Hub]
    alloy["Alloy Hub"]
    mimir["Mimir"]
  end
  alloy -->|"GetMetricData"| cwMetrics
  alloy -->|"remote_write"| mimir
  alloy -.->|"credentials from Vault"| iam

IAM Permissions

All permissions are read-only with Resource: "*" (CloudWatch does not support resource-level restrictions for most read operations).

Category Actions
Metrics GetMetricData, GetMetricStatistics, ListMetrics
Discovery ec2:DescribeRegions, tag:GetResources, sts:GetCallerIdentity

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for CloudFormation)
  • Podman or Docker (for containerized SAM build/deploy)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd alloy-cloudwatch
sam deploy --guided   # First deployment
sam deploy            # Subsequent deployments

After deployment, create an access key for the IAM user and store it in Vault using cki_tools.credentials.manager.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.3 - Deployment Bot

An AWS CloudFormation/SAM stack that provisions IAM resources for automated AWS deployments from GitLab CI. Creates a service account with permissions to deploy SAM applications including Lambda functions, API Gateway, SNS topics, and related resources.

Features

  • IAM Service Account: Dedicated user for CI/CD pipelines
  • Scoped Permissions: Least-privilege access for SAM deployments
  • Resource Naming: All managed resources use consistent prefix

Architecture

Single SAM template that creates:

  1. IAM User - Service account for deployments (${ResourcePrefix}-deployment-bot)
  2. IAM Policy - Permissions for SAM deployment operations

Prerequisites

  • AWS CLI configured with admin credentials
  • Podman or Docker (for containerized SAM build/deploy)

Deployment

Deploy using containerized AWS SAM CLI:

cd deployment-bot
sam build
sam deploy --guided  # First deployment (interactive)
sam deploy           # Subsequent deployments

After deployment, create access keys for the IAM user and store them securely in HashiCorp Vault.

SAM Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
AdditionalResourcePrefix Additional prefix this bot can manage (e.g. staging) ""

Resource naming: IAM resources follow {ResourcePrefix}-deployment-bot pattern (e.g., myapp-prod-deployment-bot). Use AdditionalResourcePrefix to allow the same bot to manage resources in another environment.

Permissions

The deployment bot has permissions to manage:

Service Scope
CloudFormation Stacks matching ${ResourcePrefix}-*, SAM bootstrap stack
S3 SAM CLI managed buckets
Lambda Functions matching ${ResourcePrefix}-*
API Gateway All REST APIs and domains
SNS Topics matching ${ResourcePrefix}-*
IAM Users/policies/roles matching ${ResourcePrefix}-*
CloudWatch Log groups for managed Lambda functions
Route53 Record changes (for custom domains)
ACM Certificate management

Usage

Bootstrap (One-Time)

The deployment bot must be deployed first using admin credentials:

# With admin AWS credentials
cd deployment-bot
sam build
sam deploy --guided

Create access keys for the new IAM user and store in HashiCorp Vault.

Automated Deployments

Once bootstrapped, the deployment bot credentials are used by aws_deploy.sh in the infrastructure repository for all subsequent SAM deployments:

export PROJECT_NAME=gitlab-event-forwarder
./aws_deploy.sh

GitLab CI runs this automatically via the aws matrix job.

Development

This project contains only a SAM template with no application code.

Security

  • Access keys should be stored in HashiCorp Vault
  • Permissions are scoped to resources with the configured prefix
  • Route53 and ACM have broader permissions due to API limitations

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.4 - Grafana CloudWatch

An AWS CloudFormation/SAM stack that provisions a read-only IAM user for Grafana’s CloudWatch datasource plugin. Grants access to CloudWatch Logs and Metrics APIs so Lambda execution logs and AWS service metrics are browsable directly in Grafana.

Features

  • Read-Only IAM User: Dedicated user for Grafana CloudWatch datasource
  • CloudWatch Logs: Browse Lambda and AWS service log groups via Logs Insights
  • CloudWatch Metrics: Browse metrics across all AWS namespaces (Lambda, SQS, SNS, DynamoDB, etc.)
  • Resource Discovery: tag:GetResources and ec2:DescribeRegions for Grafana plugin autodiscovery

Architecture

Single SAM template that creates:

  1. IAM User - Read-only service account (${ResourcePrefix}-user)
  2. IAM Policy - CloudWatch Logs read, Metrics read, tag and region discovery

The user’s access keys are created manually after deployment and stored in Vault. The companion infrastructure repo injects the credentials into the Grafana pod on MPP via a Kubernetes Secret.

flowchart LR
  subgraph aws [AWS]
    iam["IAM User\n(read-only)"]
    cwLogs["CloudWatch Logs"]
    cwMetrics["CloudWatch Metrics"]
  end
  subgraph mpp [MPP Hub]
    grafana["Grafana\n(CloudWatch datasource)"]
  end
  grafana -->|"Logs Insights API"| cwLogs
  grafana -->|"Metrics API"| cwMetrics
  grafana -.->|"credentials from Vault"| iam

IAM Permissions

All permissions are read-only with Resource: "*" (CloudWatch does not support resource-level restrictions for most read operations).

Category Actions
Logs DescribeLogGroups, DescribeLogStreams, GetLogEvents, FilterLogEvents, StartQuery, StopQuery, GetQueryResults, DescribeQueries
Metrics GetMetricData, GetMetricStatistics, ListMetrics, DescribeAlarms, DescribeAlarmsForMetric
Discovery ec2:DescribeRegions, tag:GetResources

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for CloudFormation)
  • Podman or Docker (for containerized SAM build/deploy)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd grafana-cloudwatch
sam deploy --guided   # First deployment
sam deploy            # Subsequent deployments

After deployment, create an access key for the IAM user and store it in Vault using cki_tools.credentials.manager.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.5 - Hummingbird AI

hummingbird-ai is a synchronous Python library used in-process by Hummingbird agents to call Gemini and Anthropic models through normalized interfaces.

Features

  • Shared Gemini API/Vertex AI and Anthropic Vertex AI adapters.
  • Model construction, regional selection, token pricing, and usage accounting.
  • Retryable model-call handling and Prometheus request/token/cost metrics.
  • HTTP sessions and Google Application Default Credentials authentication.

The library is not a service and has no deployment of its own. Model adapter instances are scoped to individual conversations. Callers provide bounded concurrency for independent work.

Model interface

hummingbird_ai.models defines the ModelAdapter protocol and normalized ToolDef, ToolCall, Usage, ModelResponse, and ModelError types. Adapters accept provider-neutral tool definitions and return normalized responses. Their make_user_content() and make_tool_responses() methods create provider-specific conversation content; callers keep contents opaque.

Adapters also implement to_canonical() and from_canonical() for persisted conversation history. The canonical message format lets an agent resume a session with a different provider without storing either provider’s native wire format.

Provider adapters

Gemini

GeminiModel supports the direct Gemini API with an API key and Vertex AI with Google credentials. It translates the normalized tool and response types to Gemini REST JSON. Gemini’s implicit prompt cache is reflected in Usage.cache_read_tokens from cachedContentTokenCount.

Anthropic on Vertex AI

AnthropicVertexModel uses Vertex AI rawPredict. It handles Anthropic’s assistant/user turn alternation and associates tool results with the conversation-local tool-use IDs. For prompt caching it adds sliding cache_control breakpoints to shallow copies of messages; the original conversation history is not mutated. Transient messages can use ephemeral so warning text is excluded from the cache write prefix.

When Anthropic web search is enabled, the adapter adds the server-side search tool. Search blocks remain opaque to the agent tool loop and survive canonical session serialization. The adapter also follows pause_turn continuations and merges content and usage from those provider-side calls into the returned response.

Provider wire formats

The agent uses normalized types; adapters translate them to provider payloads:

Normalized data Gemini Anthropic on Vertex AI
System instruction systemInstruction top-level system string
Tool definitions functionDeclarations with JSON Schema tools with input_schema
Tool calls functionCall parts tool_use content blocks
Tool results functionResponse parts tool_result content blocks in a user turn

Gemini API-key requests use the direct Gemini endpoint; Vertex requests use Google authentication and a configured region. Anthropic requests use Vertex AI rawPredict. The adapters keep provider-specific message normalization inside generate(); the agent appends the returned raw_content without interpreting it.

Canonical sessions

Adapters convert their native conversation content to and from the shared canonical message format at persistence boundaries. Anthropic thinking and server-tool blocks are preserved as opaque pass-through fields and restored in their original order. This allows sessions saved by one provider to resume on another while keeping the agent loop independent of provider payload shapes. Extended thinking blocks in assistant responses are part of the cached prefix. They do not break cache hits when the following user message contains only tool_result blocks.

Prompt caching

Gemini Vertex AI prompt caching is implicit. The adapter reads cachedContentTokenCount from usage metadata and reports it as cache_read_tokens; it adds no cache annotations to requests.

Anthropic Vertex caching uses explicit cache_control: {"type": "ephemeral"} annotations. The adapter uses two of the four available breakpoints on messages: B2 writes the latest prefix and B1 reads the previous prefix. The prefix hash includes prior tools and system instructions, so extra breakpoints on those components are redundant. Breakpoints are added to shallow copies so the agent-owned history is unchanged. Ephemeral turns keep warning text out of the cache: B1 still reads the last stable prefix, while B2 and the tracked write position advance only to the last stable message before the transient tail. If there is no stable message to annotate, no breakpoint is moved.

Vertex AI Anthropic prompt caches have a five-minute TTL refreshed on a hit. Cache writes use 1.25 times the base input-token rate and reads use 0.10 times that rate. Minimum cacheable prefixes are 1,024 tokens for Sonnet/Opus and 4,096 for Haiku. A newly constructed adapter starts without a prior breakpoint position, so the first resumed call writes a fresh prefix before later calls can read it.

T1:     U1·B2                                       prev=0
T2:     U1·B1  M1  U2·B2                            prev=2
T3(E):  U1  M1  U2·B1  M2·B2  U3+E                  prev=3
T4(E):  U1  M1  U2  M2·B1  U3  M3·B2  U4+E          prev=5
T5:     U1  M1  U2  M2  U3  M3·B1  U4  M4  U5·B2    prev=8

Calls, retries, and accounting

hummingbird_ai.calls.generate_with_retry() owns model-call retry and usage accounting. A caller selects either an elapsed-time retry budget or a maximum attempt count. It can also pass response validation into the same attempt loop, so validation failures do not multiply a caller’s transport retry limit. Provider-specific HTTP status handling remains in each adapter.

Each returned ModelResponse contributes its input, output, cache-read, and cache-creation tokens and estimated cost exactly once, even when caller-side validation rejects that response. The library emits shared Vertex request, token, and cost counters while callers retain workflow-specific error handling and summaries.

Pricing is kept in models.MODEL_PRICING as per-million-token rates for input, output, cache reads, and cache creation. models.estimate_cost() returns an estimated USD cost or None for an unpriced model.

HTTP and authentication

hummingbird_ai.http.new_session() creates requests sessions with configurable transport retries. Model adapters disable transport-level retries for 429 so that rate-limit failures use the model-call retry policy. VertexAuth uses Google Application Default Credentials and resolves the project from an explicit argument, GOOGLE_CLOUD_PROJECT, then ADC.

requests.Timeout is caught before requests.ConnectionError in adapters because ConnectTimeout is a subclass of both. The urllib3 retry adapter does not retry POST requests (non-idempotent), so all transport errors from model calls propagate to the generate_with_retry retry layer.

Prerequisites

  • Python 3.11 or later.
  • Google credentials and a project for Vertex AI models; Gemini API key mode is also supported.

Installation

The library is installed as an in-repository dependency of both agents:

make hummingbird-agent/setup
make hummingbird-cve-agent/setup

Usage

from hummingbird_ai import models

model = models.build_model(
    "gemini-2.5-flash",
    google_api_key="",
    google_cloud_project="my-project",
    model_regions={"gemini-2.5-": "us-central1"},
)

Shared calls and usage accounting are provided by hummingbird_ai.calls. Agent-specific prompts, tools, response parsing, and decisions remain in the consuming agent.

Configuration

Setting Use
GOOGLE_API_KEY Passed by the caller for direct Gemini API access
GOOGLE_CLOUD_PROJECT Vertex AI project fallback when credentials do not specify a project

Model regions and Vertex labels are passed by the consuming agent when it constructs an adapter.

Development

See the main README for development workflows.

make check                         # lint
make test                          # run tests

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.6 - Hummingbird Events Topic

An SNS topic for all Hummingbird project events. This topic serves as a central hub for distributing events from multiple sources (GitLab webhooks, Kubernetes, etc.) to multiple subscribers, enabling event-driven architectures and integrations.

Features

  • Central Event Hub: Dedicated SNS topic per environment (production and staging) for all project events from multiple sources
  • Multiple Subscribers: Supports Lambda, SQS, HTTP endpoints, email, SMS, and more
  • Event Filtering: Subscribers can filter events using SNS subscription filter policies

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for SNS, CloudFormation)
  • Podman or Docker (for containerized SAM build/deploy)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd hummingbird-events-topic
make build     # Build SAM application
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Deployment outputs:

  • TopicArn - SNS topic ARN (for event publishers)
  • TopicName - SNS topic name

Parameters

Parameter Description Default
TopicName SNS topic name myapp-prod-events

Resource naming: The SNS topic uses the provided TopicName parameter.

Usage

Publishing Events

Event publishers need the topic ARN to publish messages:

# Get topic ARN from CloudFormation stack
aws cloudformation describe-stacks \
  --stack-name <stack-name> \
  --query 'Stacks[0].Outputs[?OutputKey==`TopicArn`].OutputValue' \
  --output text

Event publishers:

Subscribing to Events

Subscribe services to receive events:

Via AWS Console:

  1. Open SNS console → Topics
  2. Select the topic
  3. Create subscription (choose protocol: Lambda, SQS, HTTP, Email, etc.)
  4. Add subscription filter policy (optional)

Via AWS CLI:

aws sns subscribe \
  --topic-arn <topic-arn> \
  --protocol lambda \
  --notification-endpoint <lambda-arn>

Add filter policy:

aws sns set-subscription-attributes \
  --subscription-arn <subscription-arn> \
  --attribute-name FilterPolicy \
  --attribute-value '{"source": ["gitlab"], "event_type": ["push"]}'

Subscription Filter Examples

GitLab push events from specific project:

{
  "source": ["gitlab"],
  "event_type": ["push"],
  "project_path": ["redhat/hummingbird/containers"]
}

All merge request events:

{
  "source": ["gitlab"],
  "event_type": ["merge_request"]
}

All events from GitLab:

{
  "source": ["gitlab"]
}

Event metadata: See publisher documentation for available metadata:

Pipeline Timing queue and recovery

Pipeline Timing uses a separate standard SQS queue and dead-letter queue in each environment. The source queue subscribes to its environment’s SNS topic through pipeline-timing/template.yaml; the DLQ receives messages from the source queue after the receive limit. The Status queue and worker remain separate and unchanged. Provision queues and subscriptions in both environments before deploying the workers. Messages received before worker deployment may expire after four days; this pre-launch loss is accepted. Without a consumer, messages are not received and do not automatically move to the DLQ. After launch, monitor queue age during worker pauses to avoid losing events. Validate the staging worker before deploying the production worker or enabling new production event sources.

Setting Value
Queue arr-hummingbird-<env>-pipeline-timing-sqs
Dead-letter queue arr-hummingbird-<env>-pipeline-timing-sqs-dlq
Message retention 4 days on the source queue; 14 days on the DLQ
Redrive limit 3 receives
Visibility timeout 300 seconds
SNS raw message delivery false, preserving the SNS envelope, message ID, and timestamp
Subscription filtering None, the worker receives each topic event

When SQS automatically moves a standard-queue message to its DLQ, it preserves the original enqueue timestamp. The DLQ’s 14-day retention is measured from that timestamp, not added after the source queue’s four days. An operator redrive from the DLQ back to the source queue resets the enqueue timestamp.

The worker follows Status’s 20-second long polling and receives up to 10 messages per batch. Timing commits accepted messages individually, then acknowledges eligible receipts in one batch-delete request per receive. Partial or uncertain delete failures leave committed messages available for idempotent redelivery. The 300-second visibility timeout controls SQS redelivery; it does not interrupt message processing.

The queue names include sqs-dlq so the existing CloudWatch rules classify the DLQ separately. Grafana Alloy is Hummingbird’s monitoring collector. SAM stack tags let its CloudWatch exporter find the source queue and DLQ and forward their queue-depth and oldest-message-age metrics to Mimir. Alloy does not read or consume SQS messages.

The dedicated worker IAM user can call sqs:ReceiveMessage and sqs:DeleteMessage on the timing queue only. It has no access to the Status queue or the DLQ. The queue policy grants SNS delivery only from the matching environment topic and denies non-HTTPS requests. The DLQ redrive policy accepts only its matching source queue. The template uses the Status stack’s UserName and UserArn output names. Operator redrive uses separate AWS credentials.

Fix malformed events before redriving them. An operator can move messages from the DLQ back to the timing queue and check the move task. Set ENV_PREFIX to staging or prod, then read the queue ARNs from the stack outputs:

STACK_NAME="arr-hummingbird-${ENV_PREFIX}-pipeline-timing"
TIMING_QUEUE_ARN=$(aws cloudformation describe-stacks \
  --stack-name "$STACK_NAME" \
  --query "Stacks[0].Outputs[?OutputKey=='QueueArn'].OutputValue" \
  --output text)
TIMING_DLQ_ARN=$(aws cloudformation describe-stacks \
  --stack-name "$STACK_NAME" \
  --query "Stacks[0].Outputs[?OutputKey=='DeadLetterQueueArn'].OutputValue" \
  --output text)

aws sqs start-message-move-task \
  --source-arn "$TIMING_DLQ_ARN" \
  --destination-arn "$TIMING_QUEUE_ARN"

aws sqs list-message-move-tasks --source-arn "$TIMING_DLQ_ARN"

Verify that the message returns to the timing queue and that the worker processes it. During the initial infrastructure test, if the worker is not deployed yet, remove only the identifiable test message after confirming the move. Do not purge the queue. SNS message IDs make worker redelivery idempotent.

Development

This is a pure infrastructure project (no application code). See the main README for SAM build/deploy commands.

Security & Limitations

Security:

  • SNS topic follows least privilege principle
  • Access controlled via IAM policies
  • Supports server-side encryption (optional)
  • Publishers require sns:Publish permission
  • Subscribers require appropriate protocol permissions

Limitations:

  • Message size: Up to 256 KB
  • Maximum subscriptions: 12,500,000 per topic
  • Message retention: Not supported (use SQS for durable queuing)
  • Delivery retries: Protocol-dependent

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.7 - Vertex AI Cost Metrics

This document defines the shared cost-observability contract for all Hummingbird services that call Vertex AI. It covers two obligations:

  1. Prometheus metrics — estimated cost counters scraped into Mimir.
  2. Vertex request labels — billing metadata attached to every API call.

All services that make Vertex AI calls MUST implement both.

hummingbird-ai emits shared model request, token, and cost metrics for its consumers. The dashboard continues to expose independent database-backed gauges with the same names and labels.

1. Prometheus metrics (estimated cost)

Every Vertex AI caller MUST emit the following counters on its scraped /metrics endpoint. Using identical names and label sets across services enables a single Grafana dashboard without per-app query logic.

Counters

Name Unit Labels Description
hummingbird_vertex_cost_dollars_total USD model, workflow Estimated cost based on MODEL_PRICING x tokens
hummingbird_vertex_tokens_total tokens model, workflow, direction Token counts per API call
hummingbird_vertex_requests_total requests model, workflow, status API call attempts

Labels

Applications MUST set these labels. Additional labels are permitted if cardinality remains bounded.

Label Values
model Model name as sent to Vertex (e.g. gemini-2.5-flash, claude-sonnet-4-6)
workflow Workflow or operation name (e.g. code-review, renovate-babysit)
direction input, output, cache_read, cache_creation
status success, error, retry, parse_error

Request statuses are mutually exclusive per model attempt: success means the response was accepted, error is a terminal model-call failure, retry marks an attempt that will be retried, and parse_error marks a terminal response-validation failure. Token and cost metrics still include every model response, including responses rejected by validation.

Applications MUST NOT set cluster, namespace, app, service, or pod — Alloy adds these automatically at scrape time via relabel rules.

Cardinality

  • model: bounded by deployed model set (currently 2-4)
  • workflow: bounded by workflow config entries (~10 for agent, 1 for dashboard)
  • direction: fixed set of 4
  • status: fixed set of 4

Total series per app: model x workflow x max(direction, status) = 4 x 10 x 4 = 160. Well within Mimir per-metric limits.

2. Vertex request labels (billed cost)

Every Vertex API call MUST include billing labels so that GCP Cloud Billing rows can be attributed to a specific application and workflow once BigQuery export is enabled.

Required labels

Key Value Example
app Application name (matches Kubernetes app) agent, dashboard
workflow Workflow or operation name within the app code-review, analyze-failures

Callers MUST set both keys on every generateContent, streamGenerateContent, or rawPredict request.

How to attach

Endpoint Mechanism
generateContent (Gemini) Top-level "labels" key in JSON body
rawPredict / streamRawPredict X-Vertex-AI-Labels HTTP header

For generateContent, add the labels object to the request body:

{
  "contents": [...],
  "labels": {"app": "agent", "workflow": "code-review"}
}

For rawPredict, base64-encode the labels JSON and pass as a header (shown decoded for clarity):

import base64
import json

labels = {"app": "dashboard", "workflow": "analyze-failures"}
headers["X-Vertex-AI-Labels"] = base64.b64encode(
    json.dumps(labels).encode(),
).decode()

Constraints (GCP requirements)

  • Keys and values: lowercase letters, numbers, underscores, dashes only
  • Max 63 characters each
  • Keys MUST start with a letter
  • Labels are only forwarded for PayGo consumption (Provisioned Throughput silently ignores them)

Reference

Example PromQL

Total estimated spend yesterday (all apps):

sum(increase(hummingbird_vertex_cost_dollars_total[1d]))

Per-app daily cost:

sum by (app)(increase(hummingbird_vertex_cost_dollars_total[1d]))

Per-workflow breakdown for the agent:

sum by (workflow, model)(
  increase(hummingbird_vertex_cost_dollars_total{app="hummingbird-agent"}[1d])
)

Token consumption by direction:

sum by (app, direction)(increase(hummingbird_vertex_tokens_total[1d]))

Implementing apps

Application Port Workflow values
hummingbird-agent 9090 from wf_cfg.name per workflow
hummingbird-dashboard 8080 analyze-failures

5.8 - SNS S3 Archiver

A Lambda function that archives all messages from the Hummingbird Events SNS topic to S3. Events are stored as clean JSON with decoded payloads, enabling easy browsing with zcat | jq and replay to handlers.

Features

  • S3 Storage: Events stored with time-hierarchical key structure for efficient time-range queries
  • Clean JSON: Decodes gzip+base64 Message payload for human-readable storage
  • Replay Support: Preserves all MessageAttributes with updated content_encoding for replay fidelity
  • Easy Access: Download via aws s3 sync, browse with zcat | jq

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, S3, SNS, CloudFormation)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd sns-s3-archiver
make build     # Build SAM application
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Deployment outputs:

  • BucketName - S3 bucket name for archived events
  • BucketArn - S3 bucket ARN
  • FunctionArn - Lambda function ARN

Parameters

Parameter Description Default
ResourcePrefix Prefix for all resource names myapp-prod
SnsTopicArn ARN of the SNS topic to subscribe to (required)
SentryDsn Optional Sentry DSN for error tracking ""
RetentionDays Days to retain events (0=indefinite) 0

Resource naming: Bucket and Lambda names are derived from ResourcePrefix:

  • S3 bucket: {ResourcePrefix}-s3-events
  • Lambda function: {ResourcePrefix}-lambda-s3-archiver

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first to create the topic, then use its ARN for the SnsTopicArn parameter.

S3 Key Structure

Events are stored with a time-hierarchical key structure optimized for time-range queries:

sns/YYYY/MM/DD/HH/MM/TIMESTAMP#MSGID::source::kind::name.json.gz

Examples:

sns/2025/12/13/12/34/2025-12-13T12:34:56.789Z#abc12345::kubernetes::Snapshot::konflux-rh03--tenant--my-snapshot.json.gz
sns/2025/12/13/12/34/2025-12-13T12:34:56.789Z#def67890::gitlab::push::abc123def456.json.gz
sns/2025/12/13/12/34/2025-12-13T12:34:56.789Z#0123abcd::gitlab::pipeline::org-project-42.json.gz
sns/2025/12/13/12/34/2025-12-13T12:34:56.789Z#4567efab::gitlab::build::org-project-99.json.gz
sns/2025/12/13/12/34/2025-12-13T12:34:56.789Z#89abcdef::kubernetes::TaskRun::konflux-rh03--tenant--task-1.json.gz

The sns/ prefix separates the new format from legacy data, enabling incremental migration. The :: delimiter separates metadata fields (avoiding conflicts with K8s names that contain --). The resource name in the key enables efficient “latest state per resource” queries without downloading all files.

The minute-level subdirectory (/MM/) provides write distribution for high-throughput scenarios.

Resource identities

The timestamp and first eight characters of the SNS MessageId make each key deterministic for one SNS delivery. The archiver never uses an SQS message ID. Repeated archiver delivery of the same SNS record writes the same S3 object key.

Source Kind Resource identity
GitLab push First 12 characters of checkout_sha, falling back to after
GitLab pipeline Project path with / replaced by -, followed by object_attributes.id
GitLab build Project path with / replaced by -, followed by build_id
GitLab Other events Project path with / replaced by -
Kubernetes All kinds Logical cluster name, namespace, and name joined by --

The logical cluster name comes from the Kubernetes event forwarder’s selected kubeconfig context, not from the API server URL. URL-valued cluster attributes from an older forwarder retain the name-only identity used by existing archives.

When a Pipeline or Job Hook omits its numeric ID, the archiver retains the project-only GitLab identity.

sns-s3-archiver/tests/fixtures/archive-key-contract.json is the versioned, machine-readable contract for these identities. Archiver and GitLab forwarder tests load it directly. Hummingbird Status tests will load the same repository file as part of HUM-7529. It is test data, not a runtime dependency.

Storage Format

Each S3 object contains a gzip-compressed JSON record:

{
  "MessageId": "abc12345-1234-5678-9abc-def012345678",
  "Timestamp": "2025-12-13T12:34:56.789Z",
  "Message": {
    "apiVersion": "appstudio.redhat.com/v1alpha1",
    "kind": "Snapshot",
    "metadata": { ... },
    "spec": { ... }
  },
  "MessageAttributes": {
    "source": { "Type": "String", "Value": "kubernetes" },
    "kind": { "Type": "String", "Value": "Snapshot" },
    "content_encoding": { "Type": "String", "Value": "json" }
  }
}

Key points:

  • Message is a decoded JSON object (not the original base64+gzip string from SNS)
  • content_encoding is set to "json" to indicate the decoded format

Usage

Download Events Locally

# Sync entire bucket
aws s3 sync s3://bucket-name/ ./local-events/

# Sync specific time range
aws s3 sync s3://bucket-name/2025/12/13/ ./local-events/2025/12/13/

# Sync with filtering
aws s3 sync s3://bucket-name/ ./local-events/ --exclude "*" --include "*kubernetes*Snapshot*"

Browse Events

# List events for a specific hour
aws s3 ls s3://bucket-name/2025/12/13/12/ --recursive

# View a single event
aws s3 cp s3://bucket-name/2025/12/13/12/34/event.json.gz - | zcat | jq

# Local browsing
zcat ./local-events/2025/12/13/12/34/event.json.gz | jq

Replay Events

Consumer handlers should support both live SNS format and archived format:

def _decode_message(sns_record: dict) -> dict:
    """Decode Message - handles live SNS and archived formats."""
    message = sns_record.get("Message", "")
    encoding = sns_record.get("MessageAttributes", {}).get("content_encoding", {}).get("Value")

    # Archived format: Message is already decoded
    if encoding == "json" or isinstance(message, dict):
        return message if isinstance(message, dict) else json.loads(message)

    # Live SNS format: Message is gzip+base64
    if encoding == "gzip+base64":
        return json.loads(gzip.decompress(base64.b64decode(message)))

    # Fallback: plain JSON string
    return json.loads(message)

Compression Flow

Forwarder → SNS → Archiver → S3

1. Forwarder: gzip(payload) → base64 → SNS Message
2. SNS: Passes through with content_encoding="gzip+base64"
3. Archiver: Decodes Message, sets content_encoding="json", gzip(record) → S3
4. S3: Stores clean JSON record, gzip-compressed at object level

Development

See the main README for development workflows.

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

S3 Lifecycle Policies

The bucket has two lifecycle rules:

  • AbortIncompleteUploads: Automatically cleans up incomplete multipart uploads after 1 day (prevents storage cost from failed uploads)
  • ExpireOldEvents: When RetentionDays > 0, automatically deletes objects older than the specified retention period. Disabled when RetentionDays = 0 (indefinite retention, the default).

Security & Limitations

Security:

  • S3 bucket blocks all public access by default
  • S3 objects encrypted at rest (AES256)
  • Lambda follows least privilege principle (PutObject only)
  • CloudWatch logs capture all archive operations (7-day retention)
  • Sentry integration for error tracking

Limitations:

  • Lambda timeout: 30 seconds
  • Lambda memory: 256 MB
  • S3 object key length: max 1024 bytes
  • Single-region deployment (follows SNS topic region)

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.9 - Hummingbird Tools

Database backup and restore tools for the Hummingbird status database. Creates compressed PostgreSQL dumps, uploads to S3 with rotation, and supports restore from the latest backup.

Features

  • Streaming Backup: Streams pg_dump through gzip to temp file, then S3 multipart upload - handles arbitrarily large databases
  • Streaming Restore: Downloads backup to temp file, streams through psql - no memory constraints
  • Automatic Rotation: Keeps configurable number of backups per group (daily/weekly/monthly)
  • Separate IAM Users: Write user for production backups, read-only user for staging restore

Prerequisites

  • AWS CLI configured with appropriate credentials
  • PostgreSQL client tools (pg_dump, psql)
  • Python 3.11 or later
  • Access to the hummingbird-status PostgreSQL database

Deployment

The SAM template creates:

  • S3 bucket for database backups
  • IAM user with write permissions (for production backup CronJobs)
  • IAM user with read-only permissions (for staging restore CronJob)

Build and deploy using containerized AWS SAM CLI:

cd hummingbird-tools
make build     # Build SAM application
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Note: Only deployed in production - staging restore reads from the production backup bucket.

Parameters

Parameter Description Default
ResourcePrefix Prefix for all resource names myapp-prod

Resource naming:

  • S3 bucket: {ResourcePrefix}
  • Write IAM user: {ResourcePrefix}-write-user
  • Read IAM user: {ResourcePrefix}-read-user

Usage

Backup

Run a backup with rotation:

python3 -m hummingbird_tools.backup <group> <rotation>

Arguments:

  • group: Backup group name (e.g., daily, weekly, monthly)
  • rotation: Number of backups to keep for this group

Environment variables:

Variable Description
S3_BUCKET S3 bucket name
S3_PREFIX Optional prefix for S3 keys
POSTGRESQL_HOST Database hostname
POSTGRESQL_USER Database username
POSTGRESQL_PASSWORD Database password
POSTGRESQL_DATABASE Database name

Example:

export S3_BUCKET=myapp-prod-db-backups
export S3_PREFIX=mydb/
export POSTGRESQL_HOST=myapp-postgres
export POSTGRESQL_USER=postgres
export POSTGRESQL_PASSWORD=secret
export POSTGRESQL_DATABASE=mydb

python3 -m hummingbird_tools.backup daily 7

This creates a backup like mydb/2026-01-16-02-30.daily.sql.gz and removes any daily backups beyond 7.

Restore

Restore from the latest backup:

python3 -m hummingbird_tools.restore

Environment variables are the same as for backup.

The restore process:

  1. Lists all backups matching *.{daily,weekly,monthly}.sql.gz
  2. Selects the latest by filename sort (most recent timestamp)
  3. Downloads to temp file
  4. Drops all existing tables in the public schema
  5. Streams the backup through psql

CronJob Schedule

Deployed via Kubernetes CronJobs in kubernetes/hummingbird-status/:

CronJob Schedule Environment Command
daily 30 2 * * * production python3 -m hummingbird_tools.backup daily 7
weekly 30 6 * * 0 production python3 -m hummingbird_tools.backup weekly 4
monthly 30 10 1 * * production python3 -m hummingbird_tools.backup monthly 12
restore (suspended) staging python3 -m hummingbird_tools.restore

To manually trigger a staging restore:

kubectl create job --from=cronjob/hummingbird-status-restore manual-restore-$(date +%s)

S3 Key Structure

Backups are stored with a flat key structure per database:

{database}/{timestamp}.{group}.sql.gz

Examples:

mydb/2026-01-16-02-30.daily.sql.gz
mydb/2026-01-12-06-30.weekly.sql.gz
mydb/2026-01-01-10-30.monthly.sql.gz

Multiple databases can share the same bucket using different prefixes.

Development

See the main README for development workflows.

make hummingbird-tools/setup  # Install dependencies
make check                     # Lint code (ruff)
make fmt                       # Format code
make test                      # Run unit tests
make coverage                  # Run tests with coverage

Security

  • S3 bucket blocks all public access
  • S3 objects encrypted at rest (AES256)
  • Separate IAM users with least privilege:
    • Write user: s3:PutObject, s3:DeleteObject, s3:ListBucket
    • Read user: s3:GetObject, s3:ListBucket
  • Database passwords passed via environment variables (Kubernetes secrets)

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.10 - ProdSec RPM Catalog

Builds an RPM catalog from the Hummingbird Pulp repos and publishes it to the metadata distribution for ProdSec. Also includes a monitoring companion that prints the last publication timestamp.

Features

  • DNF-Based Repo Query: Reads package metadata from all 5 Hummingbird repos (source, x86_64, aarch64, ppc64le, s390x) using the DNF API
  • Pulp REST API Upload: Creates artifacts, file content, publications, and updates the distribution in a single automated workflow
  • Artifact Deduplication: Skips upload when content SHA-256 matches an existing artifact
  • Async Task Polling: Handles Pulp’s asynchronous tasks with configurable timeout (10 minutes)
  • Sentry Error Reporting: Errors are reported to Sentry when SENTRY_DSN is set
  • Monitoring Check: Companion module prints the publication timestamp for automated freshness monitoring

Prerequisites

  • Python 3.11 or later
  • dnf system library (available on Fedora/RHEL)
  • Network access to packages.redhat.com (repo metadata)
  • Pulp API access with client certificate or username/password auth
  • A file repository and distribution named metadata in the public-hummingbird Pulp domain

Usage

Build and Publish Catalog

python3 -m hummingbird_tools.prodsec_catalog

No arguments. All configuration is via environment variables.

Check Last Publication Time

python3 -m hummingbird_tools.prodsec_catalog_check

Prints the timestamp of the most recent catalog publication in local timezone. Uses the same authentication as the catalog builder.

Configuration

Authentication

Client certificate auth (preferred for CronJob):

Variable Description
HUMMINGBIRD_PULP_BOT_CERTIFICATE Path to client certificate PEM file
HUMMINGBIRD_PULP_BOT_KEY Path to private key PEM file (optional)
HUMMINGBIRD_PULP_BOT_PASSWORD Optional passphrase for the key

Basic auth (alternative for local use):

Variable Description
PULP_USERNAME Pulp API username
PULP_PASSWORD Pulp API password

Credentials can also be configured in ~/.config/pulp/cli.toml (same format as the pulp CLI). Set PULP_CONFIG to override the config file path (useful in containers where the home directory may vary).

Pulp API

Variable Description
PULP_BASE_URL Pulp API base URL (or read from cli.toml)
PULP_CONFIG Path to pulp CLI config file (default ~/.config/pulp/cli.toml)

Error Reporting

Variable Description
SENTRY_DSN Optional Sentry DSN for error tracking

CronJob Schedule

Deployed via Kubernetes CronJob in kubernetes/hummingbird-status/:

CronJob Schedule Environment Command
prodsec-catalog 0 4 * * * production python3 -m hummingbird_tools.prodsec_catalog

Catalog Format

The catalog is a tab-separated file with one RPM per line:

{name}-{version}-{release}.{arch}.rpm\t{repo}\t{build_timestamp}

Example:

nginx-1.28.0-1.hum1.x86_64.rpm  x86_64  2026-01-15 14:30:00
kernel-6.12.5-1.hum1.src.rpm    source  2026-01-10 08:00:00

Published at: https://packages.redhat.com/api/pulp-content/public-hummingbird/metadata/hummingbird-rpm-catalog.txt

Development

See the main README for development workflows.

make hummingbird-tools/setup  # Install dependencies
make check                     # Lint code (ruff)
make test                      # Run unit tests

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.11 - Chainguard Comparison

Collects CVE posture data comparing Hummingbird and Chainguard container images and surfaces the results in the Hummingbird dashboard under the Competitive tab.

Features

  • Fetches Hummingbird CVE data from the catalog API — the same source used by images.redhat.com, updated hourly by the scan pipeline
  • Scans Chainguard images with Grype (the only available source for Chainguard CVE data)
  • Stores results in PostgreSQL for trend tracking and historical comparison
  • Dashboard shows per-image CVE counts, severity breakdowns (C/H/M/L), delta (HB minus CG), 30-day trend charts, and aggregate summary stats
  • Runs every 6 hours as a Kubernetes CronJob

Prerequisites

  • Access to the Hummingbird cluster (PostgreSQL, image pull credentials)
  • The chainguard_comparison package from competitive-tools installed in the container image

Deployment

The tool is deployed as a Kubernetes CronJob. The container image is built by Konflux from chainguard-comparison/Containerfile.

Environment variables

Variable Required Description
DATABASE_URL Yes PostgreSQL connection string, e.g. postgresql://user:pass@host/db
GRYPE_TIMEOUT No Per-image Grype scan timeout in seconds (default: 300)
SENTRY_DSN No Sentry DSN for error reporting

How it works

  1. Discovery — lists Hummingbird images from the catalog API and Chainguard images via the OCI distribution API
  2. Matching — pairs images by name using the alias map in competitive-tools (e.g. nodejs ↔ node, postgresql ↔ postgres)
  3. HB collection — calls GET /v1/images/{name}/vulnerabilities/{tag} on the catalog API; no image pull or local scan required
  4. CG collection — pulls each Chainguard image and runs grype locally; Chainguard has no publicly accessible pre-scanned CVE feed
  5. Storage — writes both sides to the chainguard_scans table in PostgreSQL

Dashboard

The /competitive page in hummingbird-dashboard shows:

  • Summary cards: total images compared, count worse/better/same
  • Sortable table with per-image CVE counts and delta
  • Inline severity breakdown: Critical / High / Medium / Low
  • Per-image 30-day trend chart (click trend on any row)
  • Filter box for quick image lookup

Database schema

CREATE TABLE chainguard_scans (
    id          SERIAL PRIMARY KEY,
    collected_at TIMESTAMPTZ NOT NULL,
    source      TEXT NOT NULL,        -- 'hummingbird' or 'chainguard'
    image_name  TEXT NOT NULL,
    tag         TEXT NOT NULL DEFAULT 'latest',
    image_ref   TEXT,
    total_cves  INTEGER NOT NULL DEFAULT 0,
    critical    INTEGER NOT NULL DEFAULT 0,
    high        INTEGER NOT NULL DEFAULT 0,
    medium      INTEGER NOT NULL DEFAULT 0,
    low         INTEGER NOT NULL DEFAULT 0,
    negligible  INTEGER NOT NULL DEFAULT 0,
    unknown     INTEGER NOT NULL DEFAULT 0
);

The table is created by the Alembic migration b4e7f8a1c3d5_add_chainguard_scans.py.

Development

cd chainguard-comparison
pip install -e ".[dev]"
python -m pytest tests/

To run the collector locally against a development database:

export DATABASE_URL=postgresql://postgres:dev@localhost:5432/events
python -m cg_comparison

License

GPL-3.0-or-later

5.12 - CVE Analysis

Automated analysis of HUM Jira Security (CVE) tickets. For each ticket, the tool fetches vulnerability data from MITRE and NVD, compares it against the versions shipped in the Hummingbird package repository, searches for upstream and Fedora fixes, and computes whether the ticket should be closed, moved to In Progress, or flagged for manual investigation.

CVE Lifecycle & State Vocabulary

A CVE tracker moves through a shared lifecycle that both this tool and the CVE agent reason about. The deterministic analysis decides a target resolution for each ticket; the residual it cannot resolve (cve-needs-attention) is handed to the agent; closed tickets are later reconciled against the VEX feed.

stateDiagram-v2
    direction TB

    [*] --> New

    New --> InProgress: affected / needs investigation
    New --> DoneErrata: fixed by analysis
    New --> NotABug: component not present
    New --> WontDo: package EOL

    InProgress --> Agent: upstream-fix-available / cve-needs-attention

    Agent: CVE Agent
    Agent --> Review: fix MR opened
    Agent --> DoneErrata: already_fixed
    Agent --> NotABug: not_affected
    Agent --> InProgress: no_upstream_fix / needs_human / error

    Review --> DoneErrata: MR merged
    Review --> Review: MR conflicts / needs human

    state "Closed (JiraStatus)" as Closed {
        DoneErrata: Done-Errata
        NotABug: Not a Bug
        WontDo: Won't Do
    }

    DoneErrata --> AwaitingVEX: enqueue awaiting-vex
    NotABug --> AwaitingVEX: enqueue awaiting-vex
    WontDo --> [*]: no VEX reconciliation
    AwaitingVEX --> [*]: VEX matches resolution
    AwaitingVEX --> AwaitingVEX: pending / mismatch

    classDef jiraStatus fill:#e5e7e9,stroke:#27ae60,color:#1f2937
    classDef closedStatus fill:#e5e7e9,stroke:#1f5f8b,color:#fff
    classDef resolution fill:#d5f5e3,stroke:#27ae60,color:#1f2937
    classDef automation fill:#e5e7e9,stroke:#7f8c8d,color:#1f2937
    class New,InProgress,Review jiraStatus
    class Closed closedStatus
    class DoneErrata,NotABug,WontDo resolution
    class Agent,AwaitingVEX automation

Notes on the diagram:

  • Agent is the CVE agent. See that tool’s doc for the full internals (currently not deployed, work is done by humans).
  • Blue states are Jira statuses, green states are resolutions, and gray states represent supporting automation.
  • Won’t Do (package EOL) closes without VEX reconciliation; Done-Errata and Not a Bug enqueue awaiting-vex and leave AwaitingVEX only once the VEX feed matches the resolution.

These state names live in exactly one place, hummingbird_cve_analysis/lib/states.py, imported by both tools:

  • JiraStatus — New, In Progress, Review, Closed.
  • Resolution — Done-Errata, Not a Bug, Won't Do (set when closing).
  • Disposition — the second field of a computed_resolution line: the three resolutions above plus the open-ticket pseudo-dispositions affected and needs investigation.
  • Verdict — the CVE agent’s per-ticket outcome (needs_change, not_affected, already_fixed, no_upstream_fix, needs_human, error), with VERDICT_OUTCOME mapping each verdict to the Jira status/resolution it implies.

How It Works

1. Ticket Selection

The tool queries Jira for HUM project tickets with component Security. Tickets can be filtered by creation period (--show-since "2 weeks"), limited to specific keys (HUM-796 HUM-518), or scanned in bulk. Closed tickets are skipped by default unless --include-closed is specified or specific ticket keys are given on the command line.

Embargoed tickets (security level “Embargoed Security Issue” or embargo status field set to true) are always skipped.

Tickets with no valid CVE-YYYY-NNNN in the CVE ID field or Summary (for example GHSA/OSV-only trackers) are postponed: analysis is skipped, an INFO message is logged, and with --resolve a one-time tracker comment is posted and the cve-needs-attention label is applied (open tickets only). Use --skip-no-cve to omit those tickets without posting the postpone notice or adding the label.

2. CVE Data Collection

For each ticket the tool:

  1. Extracts the CVE ID from the Jira CVE ID field (customfield_10667) when populated, otherwise from the Summary via a regex match for CVE-YYYY-NNNN patterns. If neither source has a CVE ID, analysis is postponed (see above).

  2. Loads the CVE record from a local clone of the cvelistV5 repository and parses the CVE 5.0 affected block, following the CVE 5.0 Product and Version Encodings specification. This includes:

    • Parsing lessThan and lessThanOrEqual version ranges
    • Handling lessThan: "*" (no upper bound) and lessThan: "4.*" (end-of-series, all versions in the 4.x series) wildcards per the spec
    • Processing changes lists that subdivide ranges into affected and unaffected segments
    • Respecting defaultStatus at the product level
    • Filtering out versionType: "git" entries that use commit hashes instead of numeric versions (these are preserved as informational data but not used for version comparison)
    • Detecting CNA data errors where git commit hashes are used in version fields without versionType: "git", including hashes wrapped in operator syntax (e.g., < 6374ae0bcdfe...)
    • Parsing inline comparison operators (< 12.3.0, >= 4.0, <= 2.5) that are not part of the CVE 5.0 schema but are widely used by CNAs in practice
    • Parsing compound range bands (>= 2.0, < 2.2.26 and > 1.32.3, < 1.34.6) used by some CNAs to express a closed-open range in a single version string
  3. Looks up version ranges from the NVD data feeds and merges them with the MITRE data. NVD data is obtained from the JSON 2.0 data feeds at nvd.nist.gov/feeds/json/cve/2.0/ (per-year .json.gz tarballs updated daily, plus a CVE-Modified overlay updated every 2 hours). Feeds are cached locally (--nvd-cache-dir) and only re-downloaded when the NVD .meta file SHA256 indicates newer data is available. When MITRE has no vendor-specific data (all vendors are “n/a”), NVD ranges replace the MITRE data entirely. NVD references tagged “Patch” or matching commit URL patterns are also extracted and used for upstream fix detection (see below).

  4. Looks up the Hummingbird package version by scraping the Pulp repository index at packages.redhat.com for the latest RPM matching the package name extracted from the ticket summary.

3. Resolution Computation

The tool compares the shipped Hummingbird version against the affected version ranges to compute a recommended resolution:

  • Closed / Done-Errata: The repo version is explicitly unaffected (an exact version, an unaffected range, or product defaultStatus: unaffected), or NVD-only data establishes that it is past the fix/outside affected ranges. An exact unaffected version does not cover later versions. For CVE 5 data, when no entry matches, omitted defaultStatus means unknown, not safe. Overlapping affected ranges from multiple streams prevent a version-based close unless the version is explicitly unaffected.
  • In Progress / affected: The repo version falls within an affected range.
  • In Progress / affected (no version data): Neither MITRE nor NVD has affected version information. The ticket is moved to In Progress for manual review but does not receive the cve-needs-attention label.
  • In Progress / needs investigation: Version data was available but could not be compared (e.g., CVE only provides git commit hashes), or multiple distinct products with different versioning schemes are listed and no cve_product override is configured. These cases receive the cve-needs-attention label.

4. Product Mismatch Detection

The tool detects when a CVE was filed against the wrong Hummingbird package. It compares the CVE vendor/product names against the Hummingbird package name and upstream repo URL from the package map. For example, a CVE for isaacs/node-tar (an npm package) filed against the tar RPM (GNU tar) is flagged as a mismatch. This detection works even without a repo URL by comparing normalized product names.

Mismatch triage uses a two-phase evidence gate:

  1. Identity mismatch — CVE product/vendor does not match the package (cve_product override or heuristic name/repo matching)
  2. SBOM (+ binary) evidence — only then decide misfiled vs vendored vs needs investigation

Phase 2 fetches the latest package SBOM from the Hummingbird Pulp repository and records the artifact used (NVR/URL) plus match evidence. Go module matching accepts the module root and major-version paths (github.com/vendor/product or .../v5). Deeper import paths such as .../api or .../daemon only count as a hit when a CVE path hint (from malformed/version-path fields) aligns with that subpackage, so a client/API module does not satisfy a daemon-only CVE. If the CVE product is found as a vendored dependency, the tool runs binary confirmation in this order:

  1. SPDX SBOM evidence — sourceInfo pointing at shipped paths (e.g. Go buildinfo under /usr/bin/...) or CONTAINS from binary RPM roots (*.x86_64, *.aarch64, …) marks present_in_binary without Syft. Evidence only under .src / go.mod is treated as source-only for the next step.
  2. RPM Provides: bundled(...) — downloaded non-debug binary RPMs are queried with rpm -qp --provides. A matching Fedora bundled Provide (npm bundled(npm(name)), Go bundled(golang(IMPORT_PATH)), Node core bundled(nodejs-undici), etc.) marks present_in_binary with evidence_method=rpm_bundled_provides. Ecosystem wrappers such as npm(...) are unwrapped so a CVE/SBOM search for nanoid matches bundled(npm(nanoid)). This catches dependencies embedded into binaries (including minified frontend JS) that Syft cannot inventory as on-disk packages.
  3. Syft on binary RPMs — Syft scans the same RPMs, even after a Provides or SPDX hit, to find additional shipped copies. When OSIDB reports subpackage names that match RPMs for the NVR, only those RPMs are scanned for absence checks; vendored-version checks scan the full NVR. If OSIDB data is missing or the names do not match this package’s RPMs (for example a Redis CVE filed against boost), the full NVR set is used. Pulp download URLs use the arch from the RPM filename (aarch64 files under aarch64/, x86_64 under x86_64/; noarch stays on the x86_64 listing).

Syft absence only becomes absent_in_binary_confirmed when SBOM evidence is source-only (or no SBOM document was available) and Syft actually ran. A miss with ambiguous SBOM evidence stays unknown so embedded Go/Rust modules are not closed as source-only when SPDX already ties them to a binary. A Provides-only scan (Syft unavailable) never confirms absence. Per-RPM download or scan failures are skipped so sibling RPMs can still confirm presence; a failed scan never proves absence or that all vendored copies are fixed. A Provides query failure falls through to Syft for that same RPM but leaves the version evidence incomplete for auto-closure.

  • present_in_binary (mismatch_vendored): legitimate vendored ticket; compare the confirmed binary/Provide version (falling back to the SBOM vendored dep version) against the CVE ranges (parent latest_repo_version / Hummingbird repo version stay the package), and apply cve-needs-attention so operators can review the binary hit. Matching versions across binary RPMs, Syft artifacts, SPDX binary packages, and bundled Provides are all collected. A binary scan must complete before the versions can justify auto-closure; a confirmed presence with an incomplete scan remains open for investigation. Resolution is worst-of / all-must-be-fixed: the ticket stays affected if any copy is still affected; Done-Errata only when every copy is not affected. The tool does not invent multi-major fix mappings from a single CVE fixed line — Fixed in Build remains the override when CVE version data only describes one release line. Placeholder dep versions such as cargo std@0.0.0 are not comparable: fall back to the parent package version when available; otherwise set non_comparable_vendored_version, recommend New / needs investigation, and apply cve-needs-attention — never Done-Errata from “0.0.0 is not in any affected range”
  • absent_in_binary_confirmed (mismatch_source_only): source-only; eligible to close as Not a Bug / Component not Present. Must never set Fixed in Build or take the advisory / Done-Errata path.
  • unknown (mismatch_binary_unknown): leave for investigation; do not auto-close or call the ticket misfiled

Stable reason codes are emitted in analysis output (Reason codes:) and on each CVE entry (reason_code / mismatch_gate) for automation:

Reason code Meaning
mismatch_sbom_miss Identity mismatch + SBOM miss → misfiled candidate
mismatch_sbom_unavailable Identity mismatch but SBOM could not be checked
mismatch_binary_unknown SBOM hit but binary presence unknown
mismatch_source_only SBOM hit confirmed absent from binaries
mismatch_vendored SBOM hit confirmed present in binaries → label for review
non_comparable_vendored_version Vendored version is a placeholder (e.g. 0.0.0) with no parent fallback → needs attention

A misfiled recommendation requires identity mismatch + SBOM miss (no vendored hit). Ambiguous checks and confirmed binary RPM matches keep cve-needs-attention with the reason code above. When present in binaries, normal resolution flow still proceeds (In Progress / Close) in addition to the attention label. The upstream fix search targets the parent package’s repo. Each analysis cycle re-fetches the SBOM and re-runs binary confirmation.

5. Upstream Fix Detection

Fix search uses a precedence model so tickets are not dual-labeled from both Fedora and upstream when a definitive path exists:

  1. CVE/NVD fix links (preferred): NVD references tagged “Patch” or matching commit URL patterns, plus CVE/NVD PR/MR references that match the package’s upstream repo. When these yield a fix label, Bodhi and forge search are skipped.
  2. Otherwise, repo-aware search from metadata/*.json upstream_repo:
    • Fedora-based (src.fedoraproject.org, pagure.io, or missing repo URL): Bodhi + DistGit only (see §6)
    • Non-Fedora forge (GitHub/GitLab/cgit): forge search only

Forge Search (non-Fedora fallback):

  • GitHub: Searches PRs via the GitHub API (/search/issues) for PRs mentioning the CVE ID. Fetches commit counts from the pulls API.
  • GitLab: Searches merge requests via the GitLab API on supported instances (gitlab.com, gitlab.gnome.org, gitlab.freedesktop.org, etc.). The --gitlab-token is only sent to gitlab.com to avoid 401 errors on other instances.
  • cgit: Scrapes commit log pages on cgit hosts (Savannah, Sourceware, kernel.org, busybox.net, etc.) for commits mentioning the CVE ID. Handles URL rewrites for Savannah hosts (e.g., git.savannah.gnu.org/git/ to cgit.git.savannah.gnu.org/cgit/).

Fix status is classified as:

  • upstream-fix-available: At least one merged/closed PR, committed fix, or NVD patch reference
  • upstream-fix-in-progress: At least one open PR with no merged fixes

6. Fedora Fix Detection

Used only for Fedora-based packages (or when no upstream repo is configured) and only when CVE/NVD fix links did not already produce a label. Hits are classified with the same upstream-fix-* labels as forge/NVD evidence (Bodhi update links are still shown in analysis output):

  • Bodhi: Queries the Fedora Bodhi API for updates matching the package name that reference the CVE ID (in the cves list or notes field). Supports multi-page results. Stable → upstream-fix-available; testing/pending → upstream-fix-in-progress.
  • DistGit Spec Scan: When Bodhi has no matches, fetches the Fedora DistGit spec file and scans for CVE references in patch filenames, changelog entries, and comments to detect backported fixes (upstream-fix-available).

7. Fixed Build Detection

The tool automatically detects whether the current Hummingbird build fixed a CVE by checking if all CVE IDs are mentioned anywhere in the package directory (patch names, changelogs, comments in the .spec file and so on). If found, it returns the current source RPM name. Assuming that we run the analysis frequently enough, this is precise enough, otherwise it errs on the side of caution (i.e. it possibly marks a higher version as fixed even if an earlier one already carried the fix).

The detected build is shown in the output as Detected Fix: package-version-release.hum1.src.rpm.

When --resolve is specified and a fixed build is detected, the tool automatically populates the “Fixed in Build” Jira field with the detected SRPM name, but only if:

  • The field is not already set (manual values take precedence)
  • The issue is not in Closed status (respects human closure decisions)
  • The assessment is not mismatch_source_only / source_only_confirmed (Component not Present closes as Not a Bug and clears any Fixed in Build)
  • The computed resolution is already Closed / Done-Errata or every ticket CVE ID appears on an uncommented PatchN: line (HUM-6182). A spec comment or leftover unapplied file that merely mentions the CVE is not enough. Product-mismatch and EOL tickets never take this PatchN override.

Version-range analysis can still say “affected” after a backport that does not bump Version (for example an unbounded range such as 1.1.1+ on popt 1.19). In that case an applied CVE-YYYY-NNNN.patch listed as PatchN: is allowed to set Fixed in Build. The SRPM must already be in Pulp (the existing fix_committed_not_built gate is unchanged). The ticket is not closed here; advisory review remains the backstop.

The RPMs repository can be provided via --rpms-repo, pointing to a local clone. This is preferable with multiple runs to avoid the git clone operation. When not given, the repository will be (shallow) cloned into a temporary directory.

Similarly, the cvelistV5 repository (used for CVE record lookups) can be provided via --cve-repo. When not given, a shallow clone is performed into a temporary directory. For production use, pre-clone and update the repo externally (e.g. via a CronJob) to avoid the clone overhead on each run. The tool does not attempt to update the provided repo itself.

8. Advisory Integration (with --resolve)

When a CVE is resolved as “Closed / Done-Errata”, the tool integrates with the CEE GitLab advisories repo to document the fix:

  1. Clone: The advisories repo (default: releng/advisories) is shallow-cloned via a bot fork using netrc-based authentication (no tokens in process arguments or logs).
  2. Modify: For each resolved ticket, the advisory YAML is updated: cves.fixed entries are added, the type is changed from RHBA to RHSA, and CVE references are appended.
  3. Batch MR: All advisory changes are accumulated as individual commits on a single branch (cve-analysis/batch). One merge request is created at the end of the run covering all tickets.
  4. Review state: Tickets are transitioned to “Review” (not directly closed) and the batch MR URL is posted to each affected ticket.
  5. Auto-close: On subsequent runs, tickets in “Review” are checked: if the advisory MR has been merged, the ticket is closed as “Done-Errata”. If the MR has unresolvable rebase conflicts, a comment with manual resolution steps is posted and the advisory-mr-failed label is added.
  6. Slack notifications: When --slack-webhook-url (or the SLACK_WEBHOOK_URL env var) is set, the tool sends a Slack message when the batch MR fails to merge or the post-merge pipeline fails. Merge-failure messages @-mention prarit and jstibran.

The advisory flow is skipped when --advisories-project points to a non-production URL, allowing safe testing without modifying Jira.

The clone lives in a temporary directory and is removed at the end of the run. It is intentionally left on disk when the batch MR push fails or --keep-advisory-repo is passed, so an engineer can inspect it; a leftover clone from a prior run is automatically removed once it is a few hours old to prevent repeated failures from exhausting disk/inode space.

8.1 Recovery for failed advisory MRs

In some failure cases (for example, advisory MR conflicts followed by manual ticket closure), Jira status can be corrected to Done-Errata while dashboard lifecycle metadata remains incomplete. This leaves R-Time rows PENDING (elapsed-to-now) until image, VEX, and close all exist.

Use the recovery script to backfill delivery events:

  • rpm_fix_published_to_pulp (hb_rpm_fix) from packages.redhat.com source RPM timestamps
  • image_rebuilt_on_quay (hb_image_fix) from catalog/quay image history
  • hum_ticket_closed from Jira resolutiondate

Only tickets that are Closed / Done-Errata are used for event derivation and dashboard import. Tickets that remain in Review (or any non-closed state) are reported and skipped for event backfill.

For tickets still in Review, the script also attempts advisory reconciliation before deriving events:

  • Read advisory MR URL from Jira comments (Advisory MR: https://...)
  • If no MR URL is present in comments, search merged advisories MRs for the HUM ticket key in MR description/title
  • When a merged MR is found and --apply is used, transition the ticket to Closed / Done-Errata and then derive hum_ticket_closed

Script location:

  • hummingbird-cve-analysis/scripts/cleanup_advisory_mr_failure_events.py

Run in dry-run mode first (default):

cd /path/to/tools
export JIRA_TOKEN=your_jira_token
export CEE_GITLAB_TOKEN=your_cee_gitlab_token
export CVE_REPORT_TOKEN=your_dashboard_token

python3 hummingbird-cve-analysis/scripts/cleanup_advisory_mr_failure_events.py \
  --jira-user your-jira-user@example.com \
  --cee-gitlab-token your_cee_gitlab_token \
  --output-json /tmp/cleanup-advisory-derived.json

Apply the backfill to dashboard event storage:

python3 hummingbird-cve-analysis/scripts/cleanup_advisory_mr_failure_events.py \
  --jira-user your-jira-user@example.com \
  --cee-gitlab-token your_cee_gitlab_token \
  --output-json /tmp/cleanup-advisory-derived.json \
  --apply

Target specific tickets (instead of default JQL):

python3 hummingbird-cve-analysis/scripts/cleanup_advisory_mr_failure_events.py \
  --jira-user your-jira-user@example.com \
  --cee-gitlab-token your_cee_gitlab_token \
  --apply \
  HUM-4884 HUM-4871 HUM-4844

Verification example:

# Replace with the actual dashboard URL (same as --dashboard-url default)
DASHBOARD_URL=https://hummingbird-dashboard-hummingbird--internal.apps.int.spoke.prod.us-east-1.aws.paas.redhat.com
curl -s "${DASHBOARD_URL}/api/cve/r-time?days=30" | jq -r '
  .entries[]
  | select(.key=="HUM-4884")
  | [.key, (.fix_delivered_at // "-"), (.stages.hb_rpm_fix // "-"), (.stages.hb_image_fix // "-")]
  | @tsv
'

If hb_rpm_fix and hb_image_fix remain empty in the derived JSON output, the issue is source-data availability (no resolvable RPM/image publish signal), not dashboard ingestion.

If a ticket is in Review and no advisory MR can be found in Jira comments or merged advisories MRs, it is skipped and reported for manual follow-up. By default, the script targets advisory-mr-failed tickets (for both Closed / Done-Errata and Review states); override with --jql when needed.

8.2 Dashboard collection ticket selection

Dashboard lifecycle collection (HUM-5603) uses a separate ticket-selection path from cve_analysis mutations. Helpers live in hummingbird_cve_analysis/dashboard_selection.py.

Watermark (start of previous run): read the newest entry from the dashboard Run Log (GET /api/cve/run-log?limit=1). Approximate previous-run start as run_at - duration_seconds (Run Log run_at is ingest time), then subtract a small buffer (default 2 minutes) for clock skew. Using start — not end — avoids missing tickets updated while the previous collector run was in progress. Overlap is intentional; /api/cve-report event upserts are idempotent.

JQL selection:

  • All still-open HUM Security tickets (rescan for updates)
  • Closed tickets with Jira updated >= watermark
  • Bootstrap (empty Run Log): open tickets only
  • Explicit ticket keys: key in (...) (any status)

Use --start-time (ISO-8601, e.g. 2026-08-01T00:00:00Z) to override the Run Log watermark and recollect Closed tickets with updated >= that start, so dashboard data can be overwritten/backfilled.

Inspect watermark and JQL without modifying Jira or the dashboard. From the tools repo root, either install the package (make hummingbird-cve-analysis/setup) or set PYTHONPATH:

cd /path/to/tools
export PYTHONPATH=hummingbird-cve-analysis
export CVE_REPORT_TOKEN=your_dashboard_token

python3 -m hummingbird_cve_analysis.dashboard_selection --prod

# Optional: also search Jira and list matching keys
export JIRA_TOKEN=your_jira_token
python3 -m hummingbird_cve_analysis.dashboard_selection \
  --prod \
  --jira-user user@example.com \
  --fetch-issues -o json-pretty
Option Environment Variable Description
--prod / --preprod Required. Hardcoded production or preprod dashboard URL
--cve-report-token CVE_REPORT_TOKEN Bearer token for /api/cve/run-log
--start-time Explicit UTC collection start (ISO-8601); skips Run Log fetch
--watermark Alias for --start-time
--watermark-buffer-minutes Minutes subtracted from previous-run start estimate (default: 2)
--fetch-issues Also search Jira with the constructed JQL
--jira-token / --jira-user JIRA_TOKEN Jira credentials (only with --fetch-issues)
--jira-url JIRA_URL Jira base URL (only with --fetch-issues)
--max-results Max issues to fetch with --fetch-issues (default: 2000)
--output, -o human, json, or json-pretty

8.3 Analysis → collector handoff file

cve_analysis writes a small JSON handoff file for collect_cve_dashboard (HUM-5799 / HUM-5800). This carries Run Log counters, captured log_output, and per-ticket fields that are expensive to recompute (upstream_fix, fedora_fix) plus human_text for Run Log details. Delivery timestamps (hb_rpm_fix / hb_image_fix) and hum_ticket_closed are gathered by the collector, not this file. Analysis stdout is human text only; it does not emit /api/cve-report JSON.

PYTHONPATH=hummingbird-cve-analysis python -u -m hummingbird_cve_analysis.cve_analysis \
  --jira-user "$BOT_USER" --jira-token "$BOT_JIRA_TOKEN" \
  --handoff-file /tmp/cve_analysis_handoff.json \
  ...

Handoff shape (schema_version: 1):

  • Top level: run_started_at, duration_seconds, mutation counters (labels_changed, advisories_created / advisories_failed, tickets_closed, comments_posted, attachments_uploaded, fatal_errors), log_output
  • tickets[]: key, upstream_fix, fedora_fix, human_text
  • vex_updates[] (HUM-5843): key, vex_status, vex_match_state, vex_resolved, labels from the awaiting-vex reconcile pass

8.4 collect_cve_dashboard

collect_cve_dashboard (HUM-5798 / HUM-5800) owns dashboard JSON emission. cve_analysis no longer prints /api/cve-report JSON on stdout; it prints human analysis text and optionally writes --handoff-file for the collector.

  1. Resolve Run Log watermark + open-ticket rescan (section 8.2). Selection always includes Closed tickets labeled awaiting-vex (HUM-5843), awaiting-cve-published, or awaiting-image-rebuild (HUM-9975), then any vex_updates[] keys missing from that JQL
  2. For each ticket, gather slim lifecycle fields from Jira / cvelistV5 / Fixed-in-Build → Pulp (cve_published, hum_ticket_created, hum_ticket_closed, hb_rpm_fix) and catalog image history (hb_image_fix), plus OSIDB osidb_flaw_created / osidb_affect_created from the public OSIDB API (omitted when OSIDB has no data), cve_embargo_lifted – the OSIDB flaw unembargo_dt, included only when it postdates the flaw’s created_dt (i.e. the CVE was genuinely embargoed; omitted otherwise) – and osidb_flaw_public – the same unembargo_dt, but included unconditionally whenever OSIDB reports one, regardless of whether the flaw was ever embargoed – computed_resolution from Jira status/resolution (R-Time gates on Done-Errata in that string) and catalog_image_source from the catalog source map (R-Time delivery is image publish when true, RPM publish when false). Timestamp helpers live in lib/lifecycle.py and are shared with analyze_issue
  3. Merge --handoff-file counters, log_output, per-ticket upstream_fix / fedora_fix / human_text, and vex_updates
  4. Dry-run prints /api/cve-report JSON; --apply POSTs it

The collector builds the catalog source map once per run for hb_image_fix and catalog_image_source. When a delivery timestamp is omitted, the dashboard keeps any existing rpm_fix_published_to_pulp / image_rebuilt_on_quay value rather than clearing it. An empty or failed catalog map omits catalog_image_source so the dashboard keeps requiring an image.

PYTHONPATH=hummingbird-cve-analysis python -u -m hummingbird_cve_analysis.collect_cve_dashboard \
  --preprod \
  --cve-report-token "$CVE_REPORT_TOKEN" \
  --jira-user "$BOT_USER" --jira-token "$BOT_JIRA_TOKEN" \
  --cve-repo /tmp/cvelistV5 \
  --handoff-file /tmp/cve_analysis_handoff.json

# POST to preprod dashboard
PYTHONPATH=hummingbird-cve-analysis python -u -m hummingbird_cve_analysis.collect_cve_dashboard \
  --preprod \
  --cve-report-token "$CVE_REPORT_TOKEN" \
  --jira-user "$BOT_USER" --jira-token "$BOT_JIRA_TOKEN" \
  --cve-repo /tmp/cvelistV5 \
  --handoff-file /tmp/cve_analysis_handoff.json \
  --apply
Option Environment Variable Description
--prod / --preprod Required. Hardcoded production or preprod dashboard URL
--cve-report-token CVE_REPORT_TOKEN Bearer token for Run Log + /api/cve-report
--handoff-file CVE_ANALYSIS_HANDOFF_FILE Analysis handoff JSON from cve_analysis --handoff-file
--apply POST report to /api/cve-report (default: dry-run JSON on stdout)
--start-time Explicit UTC collection start (ISO-8601); skips Run Log fetch
--watermark Alias for --start-time
--cve-repo Local cvelistV5 repo for cve_published
--jira-token / --jira-user JIRA_TOKEN Jira credentials
--output, -o human, json, or json-pretty

8.5 verify_image_publish

verify_image_publish (HUM-721) catches CVE tickets that claim a fix via image rebuild (hb_image_fix in the whiteboard cve_cycle) but whose image was never actually published to the registry — hb_image_fix is set from catalog release-history metadata (oldest_created), which can lag or be wrong; this re-verifies with a live check.

  1. Search Jira for Closed HUM Security tickets updated within the last --window-minutes (default 60)
  2. For each, read hb_rpm_fix / hb_image_fix from the whiteboard; skip tickets where hb_image_fix falls outside the window
  3. Re-derive the source package name from the summary/labels (no SBOM / network calls) and re-run the same catalog-history lookup (find_image_publish_release) that originally produced hb_image_fix, recovering the specific image/stream/variant/digest
  4. HEAD the image manifest by digest directly against the registry (image_manifest_exists) — this is the part hb_image_fix itself never did
  5. Post a Slack alert for each unverified claim; log a summary either way

Read-only: never modifies Jira, the dashboard, or the CVE analysis pipeline.

PYTHONPATH=hummingbird-cve-analysis python3 -m hummingbird_cve_analysis.verify_image_publish \
  --jira-user "$BOT_USER" --jira-token "$BOT_JIRA_TOKEN" \
  --window-minutes 60
Option Environment Variable Description
--window-minutes Look back this many minutes for hb_image_fix (default: 60)
--slack-webhook-url SLACK_WEBHOOK_URL Webhook for unverified-claim alerts
--dry-run Check and log, but do not post to Slack
--catalog-api-base Catalog API base URL
--jira-token / --jira-user JIRA_TOKEN Jira credentials
--jira-url JIRA_URL Jira base URL

9. Jira Actions (with --resolve)

When --resolve is specified, the tool modifies Jira tickets:

  • Fixed in Build: When a fixed build is detected and the field is not already set, the tool populates it with the detected SRPM name (only for non-closed issues). Not-affected Closed / Done-Errata closes also set Fixed in Build from detected_fixed_build or latest_srpm when empty, so R-Time image timestamps can be resolved.
  • Closed / Done-Errata: Tickets where the shipped version is not affected are transitioned to Review with an advisory MR (see above). After the MR merges, they are closed as Done-Errata on the next run.
  • Move to In Progress: Tickets where the shipped version is affected are transitioned to In Progress with a comment including affected version ranges, upstream fix status, and Fedora update links.
  • Needs Investigation: Tickets with incomplete version data get a comment explaining why automatic resolution was not possible.
  • Product Mismatch: A comment is posted explaining the mismatch (with SBOM artifact evidence) and the cve-needs-attention label is applied. No transition is performed. Auto-detected Fixed-build / delivery timestamps (hb_rpm_fix, hb_image_fix) are cleared so R-Time does not treat misfiled tickets as done. Human Fixed in Build overrides are kept. When --resolve runs against the production advisories project, the analyzed package SBOM JSON is also attached to the ticket as {nvr}.sbom.json (skipped if that filename is already present). The same attachment is applied for other SBOM-backed review paths (vendored binary hits, source-only closes, binary-unknown).
  • Package Not Present: If the package is missing from the rpms repo, the tool still runs an SBOM-first check against the CVE product(s). An SBOM hit triggers Syft binary confirmation: absent_in_binary_confirmed closes as Not a Bug / Component not Present; presence or unknown leaves the ticket for investigation. An SBOM miss/unavailable closes as Not a Bug with VEX Component not Present. The close timestamp is persisted as hum_ticket_closed for dashboard ingestion.
  • Package EOL (fix_status: 0): Tickets for packages marked End-Of-Life in rpms metadata are closed as Won’t Do with no VEX Justification. This takes precedence over Done-Errata and Fixed-in-Build / advisory paths. Automation labels (upstream-fix-*, legacy fedora-fix-*, cve-needs-attention, cve-next-release, advisory-mr-failed) are removed; fedora-bz-filed is kept as an audit record. The close timestamp is persisted as hum_ticket_closed for dashboard ingestion.
  • Label Management: Labels are applied and updated:
    • upstream-fix-available / upstream-fix-in-progress (including Bodhi/DistGit evidence for Fedora-based packages)
    • cve-needs-attention (applied when human review is needed; removed when resolved)
    • Legacy fedora-fix-* labels are stripped on subsequent runs
    • Labels are upgraded (in-progress to available) and stale labels are cleaned up on ticket closure.

Every Jira action (label add/remove, status transition, product mismatch) includes a single comment with a bold action summary heading followed by the full analysis output in a preformatted code block, giving the reader the same detail they would see on the CLI.

10. Assignee-Based Automation Control

When --resolve is active, the tool checks each ticket’s assignee. If the assignee is not the bot account (--jira-user), the tool skips all automated actions (comments, labels, transitions) for that ticket. A one-time comment is posted explaining that automation is skipped. This allows humans to take ownership of a ticket by assigning it to themselves, preventing the bot from interfering with manual work. Reassigning back to the bot account re-enables automation.

For testing purposes, use --skip-assignee-check to process all tickets regardless of assignee.

11. Human Closure Protection

When the tool encounters a Closed ticket whose analysis recommends a non-Closed resolution (e.g., In Progress), it checks the Jira changelog to determine who performed the last status transition. If a human (not the bot) closed the ticket, the tool skips the transition to respect the human’s decision. This prevents the tool from reopening tickets where a fix was backported without a version bump or where a human determined the CVE does not apply.

12. Stale Closure Detection (with --include-closed)

When --include-closed is used, the tool also analyzes Closed tickets. If the current analysis recommends a non-Closed resolution (e.g., the package version changed and is now affected), a warning is emitted flagging the ticket for review.

13. Sprint and Epic Assignment

When --resolve is active, the tool automatically adds CVE tickets to the current Hummingbird sprint and links them to a per-sprint CVE tracking epic when human interaction is detected. This ensures sprint metrics capture human work on CVE tickets.

The tool queries the Jira Agile API for the active sprint on the Hummingbird board (--board-id, default 1489) and filters by name prefix (--sprint-prefix, default “Hum S”) to identify the correct sprint among multiple teams sharing the HUM project.

Sprint and epic assignment is triggered in two cases:

  • Self-assigned tickets: When a ticket is assigned to someone other than the bot, the ticket is added to the current sprint and linked to the sprint’s CVE epic.
  • Human-set Fixed in Build: When the Fixed in Build field was set by a human (not the bot), as determined by the issue changelog, the ticket is added to the current sprint.

If no matching epic exists for the current sprint, one is created automatically with the summary “CVE tickets for sprint <name>”. The epic key is cached for the duration of the run. A Jira comment is posted on the ticket recording the sprint and epic assignment.

Tickets that already have a sprint assigned are skipped. Tickets closed manually without going through either automated path should be added to the sprint by hand (see the manual process guide).

14. Continuous Operation

When --time-between-runs N is set (N > 0), the tool runs in a loop, re-executing the full analysis every N minutes. The default is 0 (single run). Shutdown is graceful: SIGINT/SIGTERM finishes the current cycle before exiting.

15. Feature Flag

When --feature-flag-name is set (default: cve_analysis_enabled), the tool checks a GitLab feature flag on the project specified by --feature-flag-project-id (default: 73447720, i.e. redhat/hummingbird/rpms) at the start of each cycle. If the flag is inactive, the cycle is skipped and the tool sleeps for 60 seconds before checking again.

The check uses the --gitlab-token / GITLAB_TOKEN credential, which must have Developer role or higher on the target project. The check is fail-open: if GitLab is unreachable or the token lacks permissions, the tool assumes the flag is enabled and proceeds.

Set --feature-flag-name "" to disable the check entirely.

See Also

  • CVE Analysis Reference — Package map, vendored dependency detection, prerequisites, usage, configuration, managed labels, output, logs, library layout, development, license

5.13 - Dashboard MR Linker

An AWS Lambda function that posts internal notes on newly opened GitLab merge requests linking to the Hummingbird dashboard status page. This provides an easy way for developers to check Konflux build status directly from their MRs.

Features

  • Internal Notes: Posts an internal note with dashboard link when MRs are opened (visible to project members only)
  • Configurable Projects: Only monitors specified projects via space-delimited list
  • SNS Integration: Subscribes to GitLab events via SNS with filter policy

Architecture

The Lambda subscribes to the existing SNS topic (from gitlab-event-forwarder) with a filter for merge_request events:

GitLab Webhook → gitlab-event-forwarder → SNS Topic → dashboard-mr-linker → GitLab API

When a new MR is opened on a configured repository, the Lambda posts an internal note:

:robot: **Hummingbird Status**

View Konflux build status for this MR: [org/group/repo!123](https://dashboard.example.com/mr/org/group/repo/123)

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, SNS, CloudFormation, CloudWatch Logs)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)
  • GitLab API token with api scope (Reporter role or higher on monitored projects)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd dashboard-mr-linker
make build     # Build Lambda package
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
SnsTopicArn SNS topic ARN (required)
GitLabToken GitLab API token (api scope, Reporter+) (required)
GitLabUrl GitLab instance URL https://gitlab.com
DashboardBaseUrl Base URL of the dashboard (required)
GitLabProjects Space-delimited list of GitLab project paths (required)
SentryDsn Optional Sentry DSN ``

Resource naming: Lambda follows {ResourcePrefix}-lambda-linker pattern.

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first to create the topic, then use its ARN for the SnsTopicArn parameter.

Usage

Configure the GITLAB_PROJECTS parameter with space-delimited project paths:

org/group/containers org/group/rpms org/other/project

Only MRs opened on these projects will receive dashboard link notes.

Development

See the main README for development workflows.

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

Configuration

Lambda function receives configuration via environment variables (automatically set by CloudFormation):

Variable Description
GITLAB_TOKEN GitLab API token (api scope, Reporter+)
GITLAB_URL GitLab instance URL
DASHBOARD_BASE_URL Base URL of the dashboard
GITLAB_PROJECTS Space-delimited list of GitLab project paths
SENTRY_DSN Optional Sentry DSN
AWS_REGION AWS region (auto-set)

Security & Limitations

Security:

  • GitLab API token should have minimal required scopes (api)
  • Token stored as CloudFormation parameter with NoEcho: true
  • SNS subscription uses filter policy to only receive relevant events
  • Notes are posted as internal (visible to project members only)
  • CloudWatch logs capture all note posts (7-day retention)
  • Sentry integration for error tracking

Limitations:

  • Lambda timeout: 30 seconds
  • Lambda memory: 256 MB
  • Only processes action: open events (not updates or other actions)
  • Skips MRs with the managed-by::workqueue label (created by workqueue-service). The workqueue-service manages its own dashboard integration for these MRs, so the Lambda backs off to avoid duplicate notes

Integration

The workqueue-service creates the dashboard note itself for MRs it manages. The managed-by::workqueue scoped label, applied by the create_mr executor, causes this Lambda to skip those MRs. The workqueue-service’s ensure_dashboard_note executor then posts the note and stores the dashboard_link dimension with the note ID.

For non-managed MRs (those not created by the workqueue-service), the Lambda remains the sole creator of the dashboard note.

Both systems use the same marker in the note body: :robot: **Hummingbird Status**. The Lambda’s COMMENT_MARKER and the workqueue-service’s DASHBOARD_LINK_MARKER constant must stay in sync — changing one without the other will break identification and may cause issues with future reconciliation or status rendering.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.14 - GitLab Event Forwarder

An AWS Lambda function that receives GitLab webhook events and forwards them to an SNS topic with structured metadata for filtering. Validates webhook signatures and extracts minimal metadata (source, event type, project/group paths) as SNS message attributes, enabling downstream subscribers to filter events precisely.

The original GitLab JSON payload is forwarded unchanged as the SNS message body.

Features

  • Webhook Signature Validation: Verifies authenticity using HMAC-SHA256 signing tokens (Standard Webhooks), with legacy X-Gitlab-Token fallback
  • Structured Metadata: Extracts event type, project/group paths as SNS message attributes
  • SNS Subscription Filtering: Enables precise event routing to subscribers
  • Custom Domain: Optional custom domain with automatic TLS certificate management via ACM and Route53

Architecture

Single Lambda function handles the webhook processing:

  1. Handler - API Gateway endpoint (/webhook) that validates GitLab webhook signatures, extracts metadata, and publishes to SNS

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, API Gateway, SNS, CloudFormation, CloudWatch Logs, and optionally Route53/ACM for custom domain)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd gitlab-event-forwarder
make build     # Build Lambda package
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Deployment outputs: ApiEndpoint - API Gateway endpoint URL (<api-endpoint>)

Custom Domain

Optional custom domain with automatic TLS certificate management (ACM + Route53). Requires a Route53 hosted zone. Deploy with CustomDomainName and HostedZoneId parameters - CloudFormation handles certificate creation, DNS validation, and configuration. Certificate validation takes 5-30 minutes; allow up to 1 hour for DNS propagation.

Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
SnsTopicArn SNS topic ARN (required)
GitLabWebhookTokens GitLab webhook tokens (JSON) (required)
GitLabSigningTokens Signing tokens (JSON array) []
SentryDsn Optional Sentry DSN ``
CustomDomainName Custom domain name ``
HostedZoneId Route53 hosted zone ``

Resource naming: Lambda and API resources follow {ResourcePrefix}-{type}-{name} pattern (e.g., myapp-prod-lambda-handler, myapp-prod-api).

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first to create the topic, then use its ARN for the SnsTopicArn parameter.

Usage

Configure GitLab projects or groups to send webhooks to <api-endpoint>:

webhooks:
  <api-endpoint>:
    token: <secret-token>
    signing_token: <whsec-signing-token>
    push_events: true
    merge_requests_events: true
    pipeline_events: true
    job_events: true

signing_token (recommended) uses HMAC-SHA256 verification with replay protection (±5 min tolerance) per the Standard Webhooks spec. token uses a static X-Gitlab-Token header. When signing tokens are configured on the forwarder, they take priority and webhook-signature is required on all requests.

SNS Subscription Filter Examples:

Push events from a specific project:

{
  "source": ["gitlab"],
  "event_type": ["push"],
  "project_path": ["redhat/hummingbird/containers"]
}

All merge request events:

{
  "source": ["gitlab"],
  "event_type": ["merge_request"]
}

Member events from a group:

{
  "source": ["gitlab"],
  "event_type": ["member"],
  "group_path": ["redhat/hummingbird"]
}

Development

See the main README for development workflows.

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

Configuration

Lambda function receives configuration via environment variables (automatically set by CloudFormation):

Variable Description
SNS_TOPIC_ARN SNS topic ARN
GITLAB_WEBHOOK_TOKENS GitLab webhook tokens (JSON array)
GITLAB_SIGNING_TOKENS Signing tokens (JSON array)
SENTRY_DSN Optional Sentry DSN
AWS_REGION AWS region (auto-set)

Event Metadata

The Lambda function extracts minimal metadata from GitLab webhook events and adds them as SNS message attributes:

Attribute Description Example
source Always "gitlab" gitlab
event_type From object_kind push, merge_request, pipeline, build
project_path Full project path redhat/hummingbird/containers
group_path Full group path redhat/hummingbird

Security & Limitations

Security:

  • HMAC-SHA256 signing token verification via webhook-signature header (Standard Webhooks spec) with ±5 min replay protection
  • When signing tokens are configured, static X-Gitlab-Token is not accepted
  • Legacy X-Gitlab-Token validation when no signing tokens are configured
  • Supports multiple active tokens for zero-downtime rotation
  • Invalid/missing tokens return HTTP 401
  • SNS topic follows least privilege principle
  • CloudWatch logs capture all webhook deliveries (7-day retention)
  • Sentry integration for error tracking

Limitations:

  • Lambda timeout: 30 seconds
  • Lambda memory: 256 MB
  • Webhook payload size: Up to 6 MB (API Gateway limit)
  • SNS limits the compressed and base64-encoded message plus attributes to 256 KiB

Pipeline Hooks use object_kind: pipeline; Job Hooks use object_kind: build. The forwarder compresses the original JSON for SNS transport. Consumers that decode gzip+base64 receive the unchanged webhook payload.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.15 - Hummingbird MR Human Tracker

A CLI tool that detects human intervention on automation-opened GitLab merge requests (Renovate, Dependabot, etc.) and links them to a per-sprint Jira epic for tracking.

Features

  • Bot MR Detection: Identifies MRs authored by bot accounts across multiple GitLab projects using configurable username patterns
  • Human Activity Detection: Finds MRs where humans have commented or pushed commits (via GitLab system notes)
  • Per-Sprint Jira Epic: Automatically finds or creates an epic per sprint to track automation fix work (e.g. “Automation MR fixes for sprint Hum S16”)
  • Remote Link Tracking: Adds affected MR URLs as remote links on the Jira epic, with deduplication to avoid duplicates on repeated runs
  • Merged/Closed MR Scanning: --since flag catches MRs that were merged or closed before the next scheduled run
  • Dry Run: Preview what would be linked without making any changes

Prerequisites

  • Python 3.11 or later
  • GitLab API token with read access to target projects (including MR notes)
  • Jira API token with permission to create epics and remote links in the HUM project

Installation

pip install -e hummingbird-mr-human-tracker

Usage

mr-human-tracker --jira-user user@redhat.com --dry-run -v

Common invocations

# Preview what would be linked (no changes made)
mr-human-tracker --jira-user user@redhat.com --dry-run -v

# Run for real, including MRs merged in the last 2 days
mr-human-tracker --jira-user user@redhat.com --since 2d

# Scan only specific projects
mr-human-tracker --jira-user user@redhat.com --projects redhat/hummingbird/tools

# Verbose output for debugging
mr-human-tracker --jira-user user@redhat.com --since 1w -v

Output

On completion, the tool prints a summary of linked MRs:

INFO hummingbird_mr_human_tracker.tracker: Active sprint: Hum S16 [June 11 - June 25] (id=68851)
INFO hummingbird_mr_human_tracker.tracker: Using epic HUM-2602
INFO hummingbird_mr_human_tracker.tracker: Linked 2 MR(s) to HUM-2602:
INFO hummingbird_mr_human_tracker.tracker:   https://gitlab.com/redhat/hummingbird/rpms/-/merge_requests/2395
INFO hummingbird_mr_human_tracker.tracker:   https://gitlab.com/redhat/hummingbird/containers/-/merge_requests/8476

Configuration

All configuration is via CLI arguments and environment variables.

CLI arguments

Argument Description Default
--gitlab-url GitLab instance URL GITLAB_URL env
--gitlab-token GitLab private token GITLAB_TOKEN env
--jira-url Jira base URL JIRA_URL env
--jira-token Jira API token JIRA_TOKEN env
--jira-user Jira email for Basic auth (omit for Bearer) None
--projects GitLab project paths to scan tools, rpms, containers
--board-id Jira board ID for sprint lookup 1489
--sprint-prefix Sprint name prefix to match Hum S
--since Also scan merged/closed MRs updated within window None (open MRs only)
--dry-run Report without making changes False
-v, --verbose Enable debug logging False

Environment variables

Variable Description
GITLAB_URL GitLab instance URL
GITLAB_TOKEN GitLab private token
JIRA_URL Jira base URL
JIRA_TOKEN Jira API token

Duration format for --since

The --since flag accepts a number followed by a unit:

Unit Meaning
h Hours
d Days
w Weeks

Examples: 12h, 2d, 1w

How it works

  1. Fetch active sprint from the Jira Agile API (board 1489, prefix “Hum S”)
  2. Find or create a Jira epic named “Automation MR fixes for sprint {sprint_name}” in the HUM project, assigned to the active sprint
  3. Scan GitLab projects for open MRs authored by bot accounts; if --since is set, also scan recently merged/closed MRs
  4. Detect human activity on each bot MR by checking:
    • Non-bot, non-system comments
    • System notes indicating a human pushed commits (“added N commit”)
    • Reopen events by non-bot users
  5. Add remote links on the Jira epic for each human-touched MR (skipping URLs already linked)

Bot detection

A username is considered a bot if it matches any of:

  • Contains bot_ or bot- (e.g. renovate_bot)
  • Ends with _bot (e.g. some_bot)
  • Ends with [bot] (e.g. renovate[bot], dependabot[bot])
  • Starts with project_<digits>_bot or group_<digits>_bot (GitLab service accounts)

Development

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.16 - MR Auto-Approver

An AWS Lambda function that auto-approves GitLab merge requests based on configurable per-project rules. Replaces CI/CD-based approval jobs and moves approval tokens out of GitLab CI/CD variables.

Features

  • Rules-Based Config: YAML config with per-project ordered rules matching on user IDs, username patterns, branch patterns, and Konflux pipeline status
  • First-Match-Wins: Rules evaluated in order; first match determines action (approve or deny)
  • Konflux Integration: Optional check that Konflux pipelines have posted commit statuses before approving
  • Fork Rejection: Unconditionally rejects MRs from forked projects
  • User Verification: Matches against the authenticated webhook event user, not forgeable Git commit metadata
  • Stateless & Idempotent: No queues or stored state; re-approving is a no-op

Architecture

The Lambda subscribes to the existing SNS topic (from gitlab-event-forwarder) with a filter for merge_request and pipeline events:

GitLab Webhook → gitlab-event-forwarder → SNS Topic → mr-auto-approver → GitLab API

Merge request events (open, update): evaluate rules using the authenticated pusher from the webhook payload. If matched and no Konflux check needed, approve immediately. If Konflux check needed, check statuses now and approve only if present and not failed.

Pipeline events (success, failed): look up the MR from the pipeline event via the GitLab API, evaluate rules, and approve if Konflux statuses are ready. This handles the case where Konflux posts commit statuses after the initial MR event.

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, SNS, CloudFormation, CloudWatch Logs)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)
  • GitLab API token(s) with api scope and permission to approve MRs on target projects
  • Target project webhooks must send merge_requests_events and pipeline_events to the gitlab-event-forwarder endpoint

Deployment

Build and deploy using containerized AWS SAM CLI:

cd mr-auto-approver
make build     # Build Lambda package
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
SnsTopicArn SNS topic ARN (required)
GitLabUrl GitLab instance URL https://gitlab.com
ConfigPath Path to YAML config (bundled with Lambda) config.yaml
ApprovalTokens JSON map of env var names to tokens {}
SentryDsn Optional Sentry DSN ``

Resource naming: Lambda follows {ResourcePrefix}-lambda-approver pattern.

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first to create the topic, then use its ARN for the SnsTopicArn parameter.

Configuration

The Lambda uses a YAML config file bundled at deploy time. The config defines per-project rules:

settings:
  gitlab_url: https://gitlab.com

projects:
  org/group/repo:
    token_env: APPROVAL_GITLAB_TOKEN_REPO
    rules:
      - branch_regexes: ["renovate/skip/.*"]
        action: deny
      - user_ids: [12345678]
        branch_regexes: ["renovate/.*"]
      - user_ids: [87654321]
        check_konflux: true

Rule fields

Field Type Default Description
user_ids list[int] [] GitLab user IDs (immutable, preferred)
user_regexes list[str] [] Fullmatch regexes for username
branch_regexes list[str] [] Fullmatch regexes for source branch
check_konflux bool false Require Konflux statuses before approval
action str approve approve or deny

Match logic: AND across field types, OR within a field. Empty fields match anything. First matching rule wins. user_ids and user_regexes are both user identity constraints – if either is set, the user must match at least one entry from across both lists. Prefer user_ids over user_regexes to prevent username confusion attacks.

Adding a new project

  1. Create a project-scoped GitLab token (Developer, api scope) in Vault
  2. Add the token to vars.sh and the jq command that builds APPROVAL_TOKENS
  3. Add a project entry to config.yaml in the infrastructure repo with token_env and rules
  4. Deploy the Lambda
  5. Ensure the project’s GitLab webhook sends events to the gitlab-event-forwarder endpoint

Environment variables

Variable Description
CONFIG_PATH Path to YAML config file
GITLAB_URL GitLab instance URL
APPROVAL_TOKENS JSON object mapping env var names to GitLab tokens
SENTRY_DSN Optional Sentry DSN

Security

  • Fork MRs are unconditionally rejected before any rule evaluation (source_project_id != target_project_id)
  • User matching supports immutable user.id (preferred) in addition to user.username from the webhook payload, preventing username confusion attacks. Never uses Git commit author/committer metadata
  • For pipeline events, the Lambda looks up head_pipeline.user (both id and username) via the GitLab API to verify the last pusher
  • Approval tokens stored as CloudFormation parameters with NoEcho: true
  • SNS subscription filter policy limits events to merge_request and pipeline
  • CloudWatch logs capture all approval decisions (7-day retention)

Development

See the main README for development workflows.

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.17 - CVE Analysis Reference

Reference and operational documentation for the CVE Analysis tool. This page covers package map, vendored dependency detection, prerequisites, usage, configuration, managed labels, output format, logs, library layout, development, and license. For conceptual documentation (CVE lifecycle, how the analysis pipeline works), see CVE Analysis.

Package Map

The package map is loaded from the rpms repo’s per-package metadata files (metadata/<package>.json). Each file contains an upstream_repo field pointing to the canonical upstream git repository, plus optional fields:

  • upstream_branch – upstream branch (for versioned packages sharing a repo)
  • cve_product – CVE vendor/product override
  • version_transform – version transform rule
  • fix_status – package fix policy (0 = End-Of-Life / will not fix CVEs; close as Won’t Do)

If fix_status is 0, analysis always resolves to Closed / Won’t Do (including when the shipped version is outside the CVE affected range or a product mismatch would otherwise need investigation). No VEX Justification is set. If fix_status is missing or set to 1, behavior is unchanged from the default analysis flow.

When an upstream fix is available, Jira backport guidance keeps the NEXT STEPS: Backport upstream patches to Hummingbird marker and reminds maintainers to keep independent packages’ modification_status as independent and omit modification_reason, mark Fedora-imported packages modified with a modification_reason, and leave metadata release unchanged. This guidance does not affect the CVE assessment or fix decision.

The cve_product field is an optional human-curated value that specifies which CVE vendor/product entry maps to this package. It supports these formats:

  • Vendor / Product (exact match): matches a specific vendor and product pair. Example: "F5 / NGINX Open Source" for the nginx package, which excludes NGINX Plus entries that use incompatible R-versioning.
  • Vendor (vendor prefix match): matches any product whose vendor starts with the given string. Example: "Go " for golang packages, where the vendor varies (Go standard library, Go toolchain, etc.) and the product varies by module (net/url, os, crypto/x509).
  • Multiple selectors: provide more than one acceptable value either as a JSON list in metadata (preferred) or as a semicolon-delimited string. Example: ["vda-linux / busybox_mirror", "BusyBox / BusyBox"] or "vda-linux / busybox_mirror; BusyBox / BusyBox". The tool treats this as “match any selector”.

When cve_product is set, the tool uses it for exact product matching in both resolution computation (filtering to the correct product in multi-product CVEs) and mismatch detection. When empty, the tool falls back to heuristic name matching.

The rpms repo is cloned automatically at startup (or provided via --rpms-repo). A CSV override can be passed via --package-map for backward compatibility.

Vendored Dependency Detection

Vendored dependency detection uses live SBOM lookups from the Hummingbird Pulp repository. When a CVE product mismatch is detected, the tool fetches the SPDX SBOM for the ticket’s package from packages.redhat.com/.../metadata/sboms/{package}-main/ (same public index used by the rpms CVE skill), selects the newest dated sha256-….sbom entry from that listing, and searches that document for the CVE product(s) only (via purl references), stopping on the first product hit. Binary evidence then collects all matching copies of that product. The analysis output always records the SBOM artifact used and whether the lookup hit, missed, or was unavailable.

The same SBOM-first check also gates Component not Present closures for packages missing from the rpms repo. An SBOM hit alone no longer blocks that closure: binary confirmation must reach absent_in_binary_confirmed (no bundled Provide match and Syft miss on source-only SPDX evidence) before Not a Bug / Component not Present is recommended. A binary hit or unknown result keeps the ticket open for investigation.

Go subpackages are matched by progressively stripping path components (e.g., github.com/jackc/pgx/v5/pgproto3 matches github.com/jackc/pgx/v5). When a match is found and confirmed in binaries, the confirmed binary/Provide version is preferred for resolution over the SBOM lockfile version. The SBOM and binary confirmation are re-run each analysis cycle.

Requires the rpm and/or syft CLI on PATH (both are available in the analysis container image via rpm-build and the Syft install). Without either tool, binary confirmation returns unknown. With only rpm, a bundled Provide can still confirm present_in_binary, but absence is never auto-confirmed without Syft.

The generate_vendored_map_sbom.py script in package_maps/ can still be used for auditing vendored dependencies across all packages, but is no longer required at runtime.

Prerequisites

  • Python 3.11 or later
  • Jira API token (Bearer or Basic auth)
  • Network access to redhat.atlassian.net, github.com, nvd.nist.gov, bodhi.fedoraproject.org, src.fedoraproject.org, and packages.redhat.com (note: github.com is only needed when --cve-repo is not provided and the tool must clone cvelistV5 itself)
  • rpm and syft CLIs on PATH (for binary RPM confirmation after SBOM hits: bundled Provides via rpm, component inventory via Syft)
  • GitHub API token (optional, for upstream PR search)
  • GitLab API token (optional, for gitlab.com MR search)
  • CEE GitLab API token (required with --resolve, for advisory repo)

Usage

# Basic usage with Jira token from environment
export JIRA_TOKEN=your_token
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com

# Analyze specific tickets
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com HUM-796 HUM-518

# Show only tickets from the last 2 weeks
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com --show-since "2 weeks"

# Analyze with upstream fix detection (report findings without modifying Jira)
export GITHUB_TOKEN=your_github_token
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com

# Resolve tickets and apply labels
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com --resolve

# Include closed tickets and check for stale closures
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com --include-closed

# Include closed tickets but exclude specific ones
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com \
  --include-closed --exclude "HUM-555,HUM-552"

# Skip issues without CVE links; write collector handoff
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com \
  --skip-no-cve --handoff-file /tmp/cve_analysis_handoff.json

# Continuous mode: resolve tickets every 30 minutes
python3 -m hummingbird_cve_analysis.cve_analysis --jira-user user@example.com \
  --resolve --time-between-runs 30

Dashboard /api/cve-report JSON is produced by collect_cve_dashboard (section 8.4), not by cve_analysis stdout (HUM-5800).

Inspecting lifecycle timing with dump_lifecycle

scripts/dump_lifecycle reads lifecycle milestones and package data from the dashboard HTTP API and prints them for one or more HUM tickets. CVE_REPORT_TOKEN is needed.

# Text output for one ticket against prod
scripts/dump_lifecycle --prod HUM-1234

# JSON output for multiple tickets against preprod
scripts/dump_lifecycle --preprod --json HUM-1234 HUM-5678

# Custom dashboard URL
scripts/dump_lifecycle --dashboard-url https://... HUM-1234

Output includes all milestone timestamps (cve_published, osidb_flaw_created, osidb_affect_created, cve_embargo_lifted, hum_ticket_created, …), computed duration legs, and rpm_first_published (package) sourced from package_lifecycle. The --json flag emits the output as a JSON array instead of human-readable text.

Backfilling OSIDB timestamps with backfill_osidb_timestamps.py

scripts/backfill_osidb_timestamps.py is a one-time helper that fills osidb_flaw_created and osidb_affect_created for tickets already stored in cve_ticket_events. It reads existing rows from /api/cve-export, looks up timestamps from the public OSIDB API, and POSTs new rows to /api/cve-import with reset=false (write-once; existing timestamps are kept). Tickets that already have both OSIDB events are skipped unless --force is set. CVE_REPORT_TOKEN is needed.

# Backfill prod
scripts/backfill_osidb_timestamps.py --prod

# Dry run against preprod
scripts/backfill_osidb_timestamps.py --preprod --dry-run

# Limit to specific tickets
scripts/backfill_osidb_timestamps.py --prod HUM-1234 HUM-5678

Ongoing collection is handled by collect_cve_dashboard. After a prod backfill, copy_prod_to_preprod_db copies the new event types with the rest of cve_ticket_events (they are on the dashboard import allowlist).

Syncing prod CVE data to preprod with copy_prod_to_preprod_db

scripts/copy_prod_to_preprod_db copies cve_analysis_log, cve_ticket_events, package_lifecycle, and cve_ticket_blame from prod to preprod via a direct pg_dump/pg_restore over two kubectl port-forward tunnels, instead of paginating them through /api/cve-export -> /api/cve-import. cve_analysis_log rows can carry up to 512 KiB of log_output, so the HTTP path needs hundreds of small paginated requests and aborts entirely on the first 5xx; the direct-DB path does the whole sync in one dump/restore pair, typically well under a minute end to end.

The script manages its own port-forwards — killing anything already bound to the local ports first — and retries pg_dump/pg_restore if a tunnel goes stale mid-transfer (a kubectl port-forward can keep its local listener up while silently dropping all traffic). It also checks kubectl auth whoami on both contexts before doing anything, printing the OpenShift console login URL if a token has expired.

Prerequisites: kubectl, pg_dump, pg_restore, and pg_isready on PATH, and an active oc login to both the prod and preprod OpenShift clusters.

# Check auth, start tunnels, and preview the planned pg_dump/pg_restore
# commands; no data is transferred
scripts/copy_prod_to_preprod_db --dry-run

# Run the sync (destructive on preprod; --yes is required)
scripts/copy_prod_to_preprod_db --yes

# Sync a subset of tables only
scripts/copy_prod_to_preprod_db --yes --table cve_analysis_log

A stalled tunnel is detected by watching for no progress (growing dump file size on export, pg_isready on restore) for --stall-timeout seconds (default 300s — preprod’s Postgres pod runs under a 500m CPU limit and has been observed taking 150s+ to respond during a checkpoint, so the default gives 2x margin above that; a shorter timeout causes false-positive “dead tunnel” restarts). On a stall, the affected tunnel is restarted and the step retried up to --max-attempts times (default 3); pg_restore --clean --if-exists makes every retry idempotent. A timing summary (per phase and total) prints at the end.

Collecting rpm_first_published with collect_rpm_first_published

scripts/collect_rpm_first_published sweeps the Hummingbird Pulp repo for all packages, finds the earliest SRPM upload timestamp for each, and writes the results to the package_lifecycle table via POST /api/cve-export - no direct DB access.

# Sweep all packages from the rpms repo and write to prod
scripts/collect_rpm_first_published --prod --rpms-repo /path/to/rpms

# Only process specific packages
scripts/collect_rpm_first_published --prod --rpms-repo /path/to/rpms --package curl wget

# Dry run — log what would be written without posting
scripts/collect_rpm_first_published --prod --rpms-repo /path/to/rpms --dry-run

Re-runs are safe: the dashboard upsert only updates occurred_at if the incoming timestamp is earlier than the stored one.

Analyzing CVE Response Delays with analyze_rpm_first_published

scripts/analyze_rpm_first_published inspects the relation between CVE publication, package onboarding (rpm_first_published), and HUM ticket creation:

# Analyze all tickets on prod
scripts/analyze_rpm_first_published --prod --token $CVE_REPORT_TOKEN

# Filter to new packages onboarded after CVE publication
scripts/analyze_rpm_first_published --prod --filter new-pkg

# Filter to pre-existing packages with at least 30 days actionable delay
scripts/analyze_rpm_first_published --prod --filter pre-existing --min-days 30 --sort actionable

CLI options:

  • --filter {all,new-pkg,pre-existing}: filter by onboarding timing relative to CVE.
  • --min-days N: minimum delay threshold in days (default: 0).
  • --sort {cve_to_hum,actionable,cve_to_rpm}: sort column (default: actionable).
  • --limit N: max rows to display (default: 50).

Configuration

Option Environment Variable Description
--jira-token JIRA_TOKEN Jira API or Bearer token
--jira-url JIRA_URL Jira base URL (default: https://redhat.atlassian.net)
--jira-user Username for Basic auth
--output, -o Stdout format: human only (json / json-pretty removed in HUM-5800)
--show-since Filter by creation period (e.g. 2 weeks, 3 hours)
--resolve Transition Jira tickets and apply labels
--skip-assignee-check Skip assignee validation (for testing)
--include-closed Include Closed tickets in analysis; warn on stale closures
--exclude Comma-separated ticket keys to skip (e.g. HUM-555,HUM-552)
--skip-no-cve Omit issues with no CVE ID (CVE ID field or Summary); no postpone comment/label
--max-results Max number of issues to fetch (default: 2000)
--github-token GITHUB_TOKEN GitHub API token for upstream PR search
--gitlab-token GITLAB_TOKEN GitLab API token for gitlab.com MR search
--nvd-cache-dir NVD_CACHE_DIR Directory for caching NVD data feed files; avoids re-downloading unchanged feeds
--cee-gitlab-token CEE_GITLAB_TOKEN CEE GitLab token for advisory repo operations
--advisories-project ADVISORIES_REPO Advisories repo URL (default: releng/advisories)
--advisories-fork ADVISORIES_FORK Bot’s fork URL for advisory MR creation
--slack-webhook-url SLACK_WEBHOOK_URL Slack webhook URL for failure notifications; empty or unset disables Slack
--no-merge-request Skip advisory MR creation during --resolve
--keep-advisory-repo Do not delete the cloned advisory repo after the run (useful for debugging)
--test-advisory Create advisory MR then immediately close it (for testing)
--package-map Path to CSV override; by default uses rpms repo metadata
--rpms-repo Path to RPMs git repo for fixed build detection
--cve-repo Path to local cvelistV5 git repo for CVE record lookups
--time-between-runs Re-run every N minutes; 0 = single run (default)
--feature-flag-project-id GitLab project ID for feature flag lookup (default: 73447720, rpms project)
--feature-flag-name Feature flag name to check each cycle (default: cve_analysis_enabled; empty = disabled)
--board-id Jira Agile board ID for sprint lookup (default: 1489)
--sprint-prefix Sprint name prefix to identify Hummingbird sprints (default: Hum S)
--handoff-file CVE_ANALYSIS_HANDOFF_FILE Write analysis→collector handoff JSON (counters, log, fix times, human_text)
SENTRY_DSN Optional Sentry DSN for error tracking

Managed Labels

The tool manages the following labels on Jira tickets. These are automatically applied, upgraded, and cleaned up:

Label Meaning
upstream-fix-available A fix exists (forge PR/commit, NVD/CVE link, or Fedora Bodhi/DistGit)
upstream-fix-in-progress A fix is in progress (open forge PR, or testing/pending Bodhi update)
fedora-bz-filed A Fedora Bugzilla has been filed for this CVE
cve-needs-attention Analysis needs human attention (see below)
advisory-mr-failed Advisory MR has unresolvable rebase conflicts
cve-next-release Fix will arrive via the next upstream release (set manually; see below)
awaiting-vex Closed ticket awaiting VEX agreement with Jira resolution (HUM-5843 work queue)
awaiting-cve-published Ticket (open or closed) awaiting a cve_published date from cvelistV5
awaiting-image-rebuild Closed Done-Errata ticket awaiting its image rebuild on Quay (HUM-9975)

Legacy fedora-fix-available / fedora-fix-in-progress labels are no longer applied; the bot removes them on subsequent runs (Bodhi/DistGit evidence now uses upstream-fix-*).

Labels are upgraded automatically (e.g., upstream-fix-in-progress is replaced by upstream-fix-available when a fix is merged). Stale labels are removed when tickets are closed, except fedora-bz-filed which is preserved as an audit record and awaiting-vex which is managed by the VEX reconcile pass. The cve-needs-attention label is removed automatically when the warning condition no longer applies.

awaiting-vex (HUM-5843)

When a ticket is closed as Done-Errata or Not a Bug in production, analysis adds awaiting-vex. Each --resolve cycle then:

  1. Queries Closed tickets labeled awaiting-vex, plus Closed Done-Errata / Not a Bug tickets resolved in the last 7 days (so human/agent closes that skipped the close-time enqueue still enter the queue)
  2. Fetches Hummingbird status from the Red Hat CSAF VEX feed for every CVE ID in the ticket summary, scoped to that ticket’s package (not a CVE-wide worst-case across other Hummingbird products)
  3. Compares to the Jira resolution for the awaiting-vex work queue (Done-Errata → fixed, Not a Bug → known_not_affected, package_not_listed when CSAF omits the package, or fixed – CSAF’s known_not_affected/fixed split tracks dependency-version presence, not code-path reachability, so a Not a Bug verdict is compatible with either, e.g. a downstream package swept into an unrelated dependency’s rebuild-and-fix); match requires all CVE IDs to agree
  4. On match: records vex_status + vex_resolved (scan time) in the collector handoff and removes the label if present
  5. On pending/mismatch: adds awaiting-vex if missing, otherwise keeps it, and still emits current vex_status. Catch-up closes with no CVE ID in the summary are not labeled: MATCH is impossible, and adding the label would re-select the ticket forever.

The Closed tab computes MATCH from stored vex_status plus Jira resolution; it does not use stored vex_match_state as source of truth. Dashboard reads merged event metadata (later rows overlay earlier ones) so a ticket closed after cve_published does not keep leftover New / In Progress analysis text as Resolution (HUM-6091).

The collector also fetches any vex_updates[] keys that the watermark JQL missed, so a same-cycle catch-up match still lands on the Closed tab.

Won’t Do closures do not enqueue awaiting-vex. Open-ticket VEX mismatches remain a vex-checker reconcile concern.

awaiting-image-rebuild (HUM-9975)

A ticket closes Done-Errata the moment the RPM-level fix ships – resolution is a version comparison with no dependency on whether the Hummingbird image has rebuilt with that fix yet, and image rebuilds routinely lag RPM publish by hours. collect_cve_dashboard’s watermark JQL only re-examines a Closed ticket when it was updated within the current watermark window or it carries an awaiting-* workflow label, so without a dedicated label a ticket that closes before its image finishes rebuilding falls out of the watermark window on the very next run and image_rebuilt_on_quay is never captured.

_sync_awaiting_image_rebuild_labels (collect_cve_dashboard.py) adds awaiting-image-rebuild to a Closed Done-Errata ticket once it has an hb_rpm_fix but no hb_image_fix yet, so the JQL keeps re-selecting it every run; the label is removed once hb_image_fix resolves or the ticket is no longer Done-Errata. This mirrors the awaiting-vex / awaiting-cve-published pattern.

cve-next-release

The cve-next-release label is used for CVEs where no immediate action is possible and the fix will arrive via the next upstream release. It is set manually. Common scenarios include:

  • Vendored dependencies: A fix exists in a vendored package (e.g. ws inside dotnet) but cannot be consumed until the parent package updates its vendored copy.
  • No backport path: The fix cannot be backported to the current release and must wait for a future upstream version.
  • Upstream fix pending: A fix is expected upstream but has not landed yet, and no interim mitigation is available.

Package metadata fix_status: 0 (EOL) does not use this label; those tickets are closed as Won’t Do instead.

When this label is present on a ticket:

  • The automation skips applying upstream-fix-available and upstream-fix-in-progress labels, since they would be misleading, and removes them if already present
  • The SBOM version check continues each analysis cycle, so when a new upstream release containing the fix is consumed, the ticket is closed normally
  • Fedora labels and cve-needs-attention are still managed normally
  • The label is not removed automatically; it must be removed manually when no longer applicable

cve-next-release tickets are not closed just because Hummingbird shipped a new build. They close when analysis concludes the CVE is no longer affected; for vendored dependencies, that means the SBOM shows the fixed upstream version, not merely a new parent NVR.

cve-needs-attention conditions

The cve-needs-attention label is applied when any of these conditions are detected:

  • Product mismatch: CVE vendor/product does not match the Hummingbird package (e.g. node-tar CVE filed against GNU tar) after an SBOM-first check finds no vendored dependency hit
  • Package missing from rpms but present in SBOM/binaries: package directory is absent from the rpms repo, yet the CVE product appears in the package SBOM and binary confirmation is present or unknown
  • Multiple products: CVE lists multiple distinct products with different versioning schemes and no cve_product override is configured (e.g. NGINX Open Source + NGINX Plus)
  • Git-only version data: CVE version data uses commit hashes instead of numeric versions (e.g. libsodium with lessThan: ad3004ec...)
  • CNA data error: A git commit hash is used where a version number is expected without setting versionType: "git", including hashes embedded in operator syntax (e.g., "version": "< 6374ae0bcdfe..."). The warning includes a link to the CVE 5.0 source control versions spec
  • Malformed version field: The version field contains syntax that could not be parsed (e.g., compound operator+hash ranges like >= hash1, < hash2)
  • CVE record not found: the CVE record file was not found in the cvelistV5 repo
  • No repo version: the Hummingbird package was not found in the Pulp repository (package name mismatch or missing SRPM)
  • Stale closure: ticket is Closed in Jira but current analysis recommends a different resolution

Human override workflow

When a human wants to take over a ticket from the bot:

  1. Assign the ticket to yourself – the bot skips all automation on tickets not assigned to the bot account. Reassign to the bot to re-enable automation.

  2. Set “Fixed in Build” – if you know the fix is in a specific SRPM, set the “Fixed in Build” field to the SRPM name (e.g. libarchive-3.8.7-1.hum1.src.rpm). The bot will use this to create the advisory MR and close the ticket, bypassing its own analysis.

Migration note: The cve-analysis-okay label is deprecated and no longer suppresses automation. Existing tickets with this label will be re-processed by the bot on its next run. To keep the bot from touching a specific ticket, reassign it to yourself before the next run. The cve-analysis-okay label can then be removed manually.

Output

Human-Readable

Project: HUM  Component: Security

  HUM-796  CVE-2026-2673 openssl: buffer overflow [hummingbird-1]
    Open since:       7 days (Mar 24 2026)
    Labels resolution: upstream-fix-available
    OpenSSL / OpenSSL:
      Affected versions: 3.5.0 < 3.5.6
      Fixed in:          3.5.6
      Hummingbird repo (latest):  3.5.5 / openssl-3.5.5-1.hum1.src.rpm
    Repo:             https://github.com/openssl/openssl
    CVE-2026-2673: upstream-fix-available (3 PRs)
      [CLOSED, 3 commits] Fix group tuple handling in DEFAULT expansion (3.5)
              https://github.com/openssl/openssl/pull/30110
    Fedora update: FEDORA-2026-abc123 openssl-3.5.6-1.fc44 (stable, security)
              https://bodhi.fedoraproject.org/updates/FEDORA-2026-abc123
    Jira current:     In Progress / (none)
    Jira resolution:  In Progress / affected (repo 3.5.5 is in affected range 3.5.0 < 3.5.6)

JSON

Each issue includes: key, summary, labels, open_since, cve_ids, cves (with version and resolution data), jira_current (status/resolution), computed_resolution, and upstream (with PR/MR search results, Bodhi updates, and Fedora version data).

Viewing Logs

The CVE analysis tool runs as a pod in the hummingbird--internal namespace on mpp-prod. See the OpenShift MP+ internal docs page for general log access instructions.

For CVE analysis specifically:

  • Live: Open the mpp-prod pods list in the OpenShift console, filter for cve-analysis, and open the Logs tab.
  • Grafana/Loki: Open the Hummingbird Grafana logs dashboard, select cluster mpp-prod and namespace hummingbird--internal, then filter for hummingbird-cve-analysis pods.

Library layout

Reusable library code lives under hummingbird_cve_analysis/lib/. The CLI entry point remains cve_analysis.py at the package root.

Module Responsibility
lib/github_client.py GitHub PR and release API reads
lib/gitlab_client.py gitlab.com merge request, release, and feature-flag reads
lib/fedora.py Fedora Bodhi, DistGit spec parsing, and Bugzilla helpers
lib/upstream.py Upstream forge search, cgit commits, and fix-status analysis
lib/net.py Shared network error tuple used by library HTTP callers
lib/nvd.py NVD JSON 2.0 feed cache, download, and reference parsing
lib/cvelist.py Local cvelistV5 repository access and CVE 5.0 affected parsing
lib/jira_client.py Jira REST read/write primitives and ADF comment helpers
lib/versions.py Pure version comparison and range helpers
lib/analysis.py CVE decision pipeline, product matching, and lifecycle analysis
lib/formatting.py Human and Jira comment/output formatting
lib/resolve.py Jira/advisory mutation helpers (labels, close, attach SBOM)
lib/ticket.py Shared one-ticket analyze/resolve API
lib/advisory_handler.py CEE advisories repo clone/edit/MR helpers
lib/osidb_client.py OSIDB subpackage PURL lookups and flaw/affect created timestamps
lib/pulp.py Hummingbird Pulp repo RPM/SRPM listings and repodata lookups
lib/catalog.py Container catalog API for image publish times
lib/rpms_repo.py Local rpms git repo, package map loading, fixed-build detection
lib/sbom.py SBOM fetch, vendored dependency lookup, binary RPM confirmation
lib/slack.py Slack webhook helper
lib/version_transforms.py Named version-transform helpers

cve_analysis.py is the CLI entry point only (argument parsing, JQL building, signal handling, and the main orchestration loop). Library code lives under lib/; import and patch those modules directly. New cron jobs, webhooks, and other integrations should prefer ticket.process_ticket for analyzing (and optionally resolving) a single Jira issue:

from hummingbird_cve_analysis.lib import ticket

result = ticket.process_ticket(
    issue,
    pkg_map,
    catalog_source_map,
    github_token,
    gitlab_token,
    base_url=base_url,
    token=token,
    resolve=False,
)

Development

See the main README for development workflows.

make hummingbird-cve-analysis/setup  # Install dependencies
make check                            # Lint code (ruff)
make test                             # Run unit tests

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.18 - Jira Event Forwarder

An AWS Lambda function that receives Jira Automation webhook events and forwards them to an SNS topic with structured metadata for filtering. Validates Bearer token authentication and restricts access to Atlassian egress IP ranges via an API Gateway resource policy.

The original Jira JSON payload is forwarded unchanged as the SNS message body.

Features

  • Bearer Token Authentication: Validates Authorization: Bearer <token> headers with constant-time comparison, supporting multiple tokens for zero-downtime rotation
  • Atlassian IP Restriction: API Gateway resource policy restricts access to Atlassian egress IP ranges (IPv4 and IPv6), fetched dynamically at deploy time
  • Structured Metadata: Extracts event type, project key, and issue type as SNS message attributes
  • SNS Subscription Filtering: Enables precise event routing to subscribers
  • Custom Domain: Optional custom domain with automatic TLS certificate management via ACM and Route53

Architecture

Single Lambda function handles the webhook processing:

  1. Handler - API Gateway endpoint (/webhook) that validates the Bearer token, extracts metadata from the Jira payload and request headers, and publishes to SNS

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, API Gateway, SNS, CloudFormation, CloudWatch Logs, and optionally Route53/ACM for custom domain)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)

Deployment

Build and deploy using containerized AWS SAM CLI:

cd jira-event-forwarder
make build     # Build Lambda package
make deploy    # First deployment (interactive/guided)
make redeploy  # Subsequent deployments (non-interactive)

Deployment outputs: ApiEndpoint - API Gateway endpoint URL (<api-endpoint>)

Custom Domain

Optional custom domain with automatic TLS certificate management (ACM + Route53). Requires a Route53 hosted zone. Deploy with CustomDomainName and HostedZoneId parameters - CloudFormation handles certificate creation, DNS validation, and configuration. Certificate validation takes 5-30 minutes; allow up to 1 hour for DNS propagation.

Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
SnsTopicArn SNS topic ARN (required)
JiraWebhookTokens Jira webhook tokens (JSON array) []
SentryDsn Optional Sentry DSN ``
AtlassianIpv4Cidrs Atlassian egress IPv4 CIDRs (comma-list) ``
AtlassianIpv6Cidrs Atlassian egress IPv6 CIDRs (comma-list) ``
CustomDomainName Custom domain name ``
HostedZoneId Route53 hosted zone ``

Resource naming: Lambda and API resources follow {ResourcePrefix}-{type}-{name} pattern (e.g., myapp-prod-lambda-handler, myapp-prod-api).

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first to create the topic, then use its ARN for the SnsTopicArn parameter.

Usage

Jira Automation allows only one trigger per rule, and the “Multiple issue events” trigger does not expose which event fired. Since each event needs a distinct X-Jira-Event-Type header, configure 6 separate automation rules — one per event type:

Trigger X-Jira-Event-Type value
Created issue_created
Updated issue_updated
Deleted issue_deleted
Assigned issue_assigned
Transitioned issue_transitioned
Moved issue_moved

Each rule’s “Send web request” action:

  • URL: <api-endpoint>
  • Method: POST
  • Headers:
    • Authorization: Bearer <token> — enter the token value and click “Hidden” to mask it in the Jira Automation UI
    • X-Jira-Event-Type: <value from table above>
  • Body: “Issue data (Jira format)”

SNS Subscription Filter Examples:

All Jira issue events:

{
  "source": ["jira"]
}

Specific Jira event types:

{
  "source": ["jira"],
  "event_type": ["issue_created", "issue_updated"]
}

Events from a specific project:

{
  "source": ["jira"],
  "project_key": ["HUM"]
}

Development

See the main README for development workflows.

make setup     # Install dependencies
make check     # Lint code (ruff)
make fmt       # Format code
make test      # Run unit tests
make coverage  # Run tests with coverage

Configuration

Lambda function receives configuration via environment variables (automatically set by CloudFormation):

Variable Description
SNS_TOPIC_ARN SNS topic ARN
JIRA_WEBHOOK_TOKENS Jira webhook tokens (JSON array)
SENTRY_DSN Optional Sentry DSN
AWS_REGION AWS region (auto-set)

Event Metadata

The Lambda function extracts metadata from Jira webhook events and request headers, adding them as SNS message attributes:

Attribute Description Example
source Always "jira" jira
event_type From X-Jira-Event-Type header issue_created
project_key Jira project key HUM
issue_type Issue type name Story, Bug
content_encoding Compression format gzip+base64

The event_type attribute is set from the X-Jira-Event-Type request header. Each Jira Automation rule hardcodes the appropriate value for its trigger type in this header (see Usage). If the header is absent, the event_type attribute is omitted from the SNS message.

Security & Limitations

Security:

  • Bearer token validation via Authorization header with constant-time comparison (hmac.compare_digest)
  • API Gateway resource policy restricting source IPs to Atlassian egress ranges (IPv4 and IPv6 CIDRs both via aws:SourceIp), fetched dynamically at deploy time from ip-ranges.atlassian.com
  • Supports multiple active tokens for zero-downtime rotation
  • Missing or invalid tokens return HTTP 401; no configured tokens returns HTTP 500
  • SNS topic follows least privilege principle
  • CloudWatch logs capture all webhook deliveries (7-day retention)
  • Sentry integration for error tracking

Limitations:

  • Lambda timeout: 30 seconds
  • Lambda memory: 256 MB
  • Webhook payload size: Up to 6 MB (API Gateway limit)
  • SNS limits the compressed and base64-encoded message plus attributes to 256 KiB

The forwarder compresses the original JSON for SNS transport. Consumers that decode gzip+base64 receive the unchanged webhook payload.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.19 - Container Catalog

Per-distro serverless catalog API for Hummingbird container images.

Features

  • Image Directory - Browse all container images with metadata
  • Tag Browser - View tags, digests, architectures per image
  • Specifications - Per-architecture OCI config details (env, cmd, user, labels)
  • SBOM - Per-architecture package lists from SPDX attestations
  • Vulnerabilities - CVE scanning results via Grype
  • CVE Metrics - Vulnerability aggregate and structured exposure logs
  • Provenance - Source traceability from SLSA attestations
  • Release History - Timeline of past builds with drill-down
  • Deprecation status - Stream and release status from OCI labels
  • OpenAPI Spec - Machine-readable API documentation at /v1/openapi.json
  • Swagger UI - Interactive API explorer at /v1/docs/

Architecture

Rust serverless stack deployed as two isolated per-distro stacks:

  • API Lambda - DynamoDB pass-through (~10ms response)
  • Sync Lambda - Incremental DynamoDB sync from SNS Release events (~22 registry calls per release)
  • Index Lambda (index-lambda) - SNS-triggered Syft JSON generation, uploads native Syft JSON to S3 for scanner consumption
  • Scan Lambda (scan-lambda) - SQS-triggered CVE scanning via Grype from native Syft JSON in S3, per-digest processing with first_seen tracking and partial batch failure reporting
  • Enqueue Lambda (enqueue-lambda) - Hourly EventBridge-triggered fan-out that checks Grype DB for updates and enqueues non-superseded digests to SQS
  • Metrics Lambda (metrics-lambda) - DynamoDB Stream-triggered vulnerability aggregate and structured CVE exposure logs
  • DynamoDB - Pre-computed JSON items (single-table PK/SK design, Streams: KEYS_ONLY)
  • S3 ScanDataBucket - Native Syft JSON storage for scanner data (gzipped, keyed by grype/{image}/{digest_hex}.json.gz)
  • SQS ScanQueue - Central queue for scan tasks (fed by S3 events and Enqueue Lambda)
  • CloudFront - CDN with per-endpoint cache TTLs
  • CloudWatch - Structured CVE exposure logs
  • catalog sync - Full DynamoDB population from GitLab + Quay.io OCI v2 registry
  • catalog scan - CLI CVE scanning via Grype; reads native Syft JSON from S3 (with --bucket) or builds synthetic JSON from DynamoDB SBOMs (fallback)
  • catalog index - CLI Syft JSON generation for backfilling S3
  • Catalog SPA - Lit 3 web app served from S3 via CloudFront

Scanner Architecture

CVE vulnerability data must match direct grype <image> scans exactly for all images. Synthetic Syft JSON (built from stored SBOM packages) cannot reliably reproduce the native output because Grype relies on metadata fields, artifact relationships, and deduplication logic that are lost in the SPDX-to-API roundtrip. Additionally, raw SPDX from the build system contains package bloat (e.g. Go sub-modules, empty-version entries) that native Syft filters out when scanning a compiled binary directly.

To guarantee exact-match results, the scanner chain stores native Syft JSON in S3 rather than DynamoDB: a single image’s Syft JSON is typically 1-10 MB (gzipped to 100 KB - 1 MB), which exceeds DynamoDB’s 400 KB item limit. S3 has no per-object size constraint, avoids provisioned throughput costs for large blobs, and supports event notifications for triggering downstream scanners. An independent Index Lambda runs syft <image> on each new release and uploads the full output to s3://{bucket}/grype/{image}/{hex}.json.gz. The S3 upload fires an event notification to SQS, which triggers the Scan Lambda to read the native JSON, run Grype, and write per-canonical vulnerability data to DynamoDB.

The scanner chain is intentionally independent of the catalog data chain: the same SNS Release event triggers both the Sync Lambda (catalog data to DynamoDB) and the Index Lambda (Syft JSON to S3), with no ordering dependency between them.

Vulnerability components include the source RPM filename from the input SBOM when available. Consumers can group affected binary RPMs by this value.

CVE Data Flow

graph TD
    Konflux([Konflux Release]) -->|SNS| Sync[SyncFunction]
    Konflux -->|SNS| Index[IndexFunction]
    Sync -->|update| Tags[(Tags)]
    Tags -->|"stream via ESM"| Metrics
    Tags -->|read| Scan[ScanFunction]
    Index -->|upload| S3Syft[(S3 Syft JSON)]
    S3Syft -->|"S3 event via SQS"| Scan
    Hourly([Schedule]) -->|hourly| Enqueue[EnqueueFunction]
    Enqueue --> Gate[check Grype DB]
    Gate -->|"fan out via SQS"| Scan
    Scan -->|update| Vulns[(Image Vulnerabilities)]
    Vulns -->|"stream via ESM"| Metrics[MetricsFunction]
    Metrics -->|"structured logs"| CloudWatch[CloudWatch Logs]
    Metrics -->|"write aggregate"| CatalogVulns[(Catalog Vulnerabilities)]

    classDef lambda fill:#d4e6f1,stroke:#2980b9
    classDef dynamo fill:#fdebd0,stroke:#e67e22
    classDef trigger fill:#d5f5e3,stroke:#27ae60
    classDef aws fill:#e8daf1,stroke:#8e44ad
    classDef gate fill:#e5e7e9,stroke:#7f8c8d
    class Sync,Scan,Enqueue,Metrics,Index lambda
    class Vulns,Tags,CatalogVulns,S3Syft dynamo
    class Konflux,Hourly trigger
    class CloudWatch aws
    class Gate gate

Prerequisites

  • Rust 1.75+ (for building backend)
  • Node.js 22+ (for building frontend)
  • AWS credentials (for DynamoDB access and deployment)
  • SAM CLI (for deployment)

API Endpoints

Endpoint Description
GET /v1/images Image directory
GET /v1/images/{name} Image overview (README)
GET /v1/images/{name}/tags Tags for an image
GET /v1/images/{name}/details/{canonical} Per-canonical details
GET /v1/images/{name}/sbom/{canonical} Package list
GET /v1/images/{name}/vulnerabilities/{canonical} Vulnerability scan
GET /v1/images/{name}/history/{stream}/{variant} Release timeline
GET /v1/images/{name}/releases/details/{digest} Release details (immutable)
GET /v1/images/{name}/releases/sbom/{digest} Release SBOM (immutable)
GET /v1/images/{name}/releases/vulnerabilities/{digest} Release vulnerabilities
GET /v1/vulnerabilities Catalog-wide CVE aggregate
GET /v1/openapi.json OpenAPI 3.1 specification
GET /v1/docs/ Interactive Swagger UI

Timestamp Fields

The oldest_created field on ImageSummary, Tag, and HistorySummary is the earliest OCI created timestamp across all architectures in the release. All architectures were built at or after this date, making it useful for conservative staleness detection.

The specifications endpoint returns per-architecture data keyed by architecture name. Each architecture’s created field is the direct OCI config root timestamp for that specific architecture.

Deprecation status

The containers repository can mark an image stream with the io.hummingbird-project.deprecated=true OCI label. The catalog exposes this status in two places:

  • Tag.deprecated identifies a release carrying the label.
  • ImageSummary.deprecated_streams lists streams whose current release is deprecated.

Both fields are additive API fields. Older DynamoDB items without these fields are read as active releases with no deprecated streams.

Usage

All tools are built as a single catalog binary with subcommands (api, sync, sync-lambda, scan, scan-lambda, index, index-lambda, enqueue-lambda, metrics, metrics-lambda). The binary is built in the Rust container and CLI subcommands are run in the gitlab-ci container (which provides grype, syft, and other tools). Only make and podman are required.

Sync

# Dry run (print items to stdout)
make container-catalog/sync ARGS="--distro rawhide --dry-run"

# Populate DynamoDB
make container-catalog/sync ARGS="--distro rawhide --table-name <table>"

Sync Lambda

The sync-lambda subcommand runs as an AWS Lambda function triggered by SNS Release events from kubernetes-event-forwarder. It incrementally syncs a single image release to DynamoDB (~22 registry API calls per release vs ~104 for a full sync).

The Lambda:

  1. Decodes gzip+base64 SNS messages
  2. Filters for Succeeded releases targeting the configured Quay.io namespace. Ignores DELETED events and releases with a non-null deletionTimestamp.
  3. Fetches OCI manifest data for the new digest
  4. Writes per-digest items (DETAILS, SBOM, RELEASE_DETAILS, RELEASE_SBOM)
  5. Merges into aggregate items (TAGS, HISTORY, OVERVIEW, DIRECTORY)
  6. Fetches README from GitLab for OVERVIEW content (uses README.md for Hummingbird, rawhide/README.md for Rawhide)

Registry fetch errors (manifest, SBOM, attestation) propagate as hard failures so the Lambda retries automatically (up to 2 retries with backoff) before sending to the DLQ. GitLab README failures are non-fatal – the existing README is preserved if the fetch fails.

Environment Variable Description
TABLE_NAME DynamoDB table name
DISTRO rawhide or hummingbird
SENTRY_DSN Optional Sentry DSN for error tracking

Index

The index subcommand generates native Syft JSON for all non-superseded image digests and uploads them to S3 for scanner consumption. It reads the image directory and TAGS from DynamoDB, checks S3 for existing objects (dedup via HeadObject), runs syft <image> --platform linux/amd64, gzips the output, and uploads to s3://{bucket}/grype/{image}/{hex}.json.gz.

# Dry run (show what would be uploaded)
make container-catalog/index ARGS="--distro hummingbird --table-name <table> --bucket <bucket> --dry-run"

# Backfill S3 for all images
make container-catalog/index ARGS="--distro hummingbird --table-name <table> --bucket <bucket>"

# Backfill a single image
make container-catalog/index ARGS="--distro hummingbird --table-name <table> --bucket <bucket> --image caddy"

Index Lambda

The index-lambda subcommand runs as an AWS Lambda function triggered by the same SNS Release events as the Sync Lambda. It operates independently of the catalog chain (no DynamoDB access) and generates native Syft JSON for scanner consumption.

The Lambda:

  1. Decodes gzip+base64 SNS messages (shared with Sync Lambda via release_event module)
  2. Filters for Succeeded releases targeting the configured Quay.io namespace. Ignores DELETED events and releases with a non-null deletionTimestamp.
  3. Checks S3 for existing objects (HeadObject dedup by digest)
  4. Runs syft <image>@<digest> --platform linux/amd64 --output syft-json
  5. Gzips and uploads to s3://{bucket}/grype/{image}/{hex}.json.gz

Failures propagate for Lambda retry (up to 2 retries) before sending to the IndexDLQ. The catalog index CLI backfills any gaps.

Environment Variable Description
SCAN_DATA_BUCKET S3 bucket for scanner data
DISTRO rawhide or hummingbird
SENTRY_DSN Optional Sentry DSN for error tracking

Scan

The scan subcommand reads image listings, tags, and SBOMs from DynamoDB (no registry access needed) and runs Grype against each image’s stored SBOM packages. Results include a first_seen timestamp per CVE, tracked at the group+variant+stream level and carried across releases for SLI computation.

# Dry run (print items to stdout)
make container-catalog/scan ARGS="--distro hummingbird --table-name <table> --dry-run"

# Scan and write to DynamoDB (purge stale vuln data first, implies --scope=all)
make container-catalog/scan ARGS="--distro hummingbird --table-name <table> --purge"

# Scan only non-superseded (current) tags
make container-catalog/scan ARGS="--distro hummingbird --table-name <table> --scope non-superseded"

# Scan all releases including historic (tagless) releases
make container-catalog/scan ARGS="--distro hummingbird --table-name <table> --scope all"

# Scan a single image
make container-catalog/scan ARGS="--distro hummingbird --table-name <table> --image caddy --dry-run"

Scan Lambda

The scan-lambda subcommand runs as an AWS Lambda function triggered by SQS messages. Two paths feed the ScanQueue:

  1. Real-time (S3 event notification): When the Index Lambda uploads a new Syft JSON to S3, an s3:ObjectCreated event (filtered on grype/ prefix) is sent directly to SQS
  2. Hourly (Enqueue Lambda): Fans out all non-superseded digests when the Grype vulnerability database has been updated, sending {"bucket": "...", "key": "grype/{image}/{hex}.json.gz"} messages

The Lambda accepts both S3 event notification JSON and direct {"bucket", "key"} messages. For each message it:

  1. Downloads and decompresses the native Syft JSON from S3
  2. Runs Grype on the raw bytes
  3. Looks up all canonical tags matching the digest from the TAGS item
  4. Writes VULNERABILITIES#{canonical} for each matching tag (with first_seen tracking) and RELEASE_VULNERABILITIES#{hex} once per digest

Processing is per-digest: a single Syft JSON serves all canonicals sharing that digest, avoiding redundant Grype invocations.

The Lambda downloads and hydrates the Grype DB on the first handler invocation of each cold start (cached in /tmp for warm invocations), processes one message per batch, and reports partial batch failures so failed records return to the queue.

Environment Variable Description
TABLE_NAME DynamoDB table name
DISTRO rawhide or hummingbird
SENTRY_DSN Optional Sentry DSN for error tracking

Enqueue Lambda

The enqueue-lambda subcommand runs hourly via EventBridge Schedule. On each invocation it performs a lightweight HTTP GET of the public Grype DB listing (latest.json, ~200 bytes) and compares the built timestamp against the stored CATALOG/LAST_FULL_SCAN_DB item in DynamoDB. If the DB hasn’t changed, it returns early (~23 of 24 hourly invocations short-circuit). When an update is detected, it reads the image directory and all TAGS items, deduplicates by digest, and sends one SQS message per unique digest using SendMessageBatch. Messages use the format {"bucket": "...", "key": "grype/{image}/{hex}.json.gz"}.

Environment Variable Description
TABLE_NAME DynamoDB table name
DISTRO rawhide or hummingbird
SCAN_QUEUE_URL SQS queue URL for scan messages
SCAN_DATA_BUCKET S3 bucket for scanner data
GRYPE_DB_LATEST_URL Grype DB listing URL (has sensible default)
SENTRY_DSN Optional Sentry DSN for error tracking

Metrics

The metrics subcommand performs a one-shot read of all non-superseded vulnerability data from DynamoDB, outputs structured CVE exposure logs, and writes the catalog-wide vulnerability aggregate to DynamoDB (PK=CATALOG, SK=VULNERABILITIES). With --dry-run, it skips the DynamoDB aggregate write (useful for local inspection).

# Dry run (print structured logs to stdout)
make container-catalog/metrics ARGS="--distro hummingbird --table-name <table> --dry-run"

# Write aggregate to DynamoDB and print structured logs
make container-catalog/metrics ARGS="--distro hummingbird --table-name <table>"

Metrics Lambda

The metrics-lambda subcommand runs as a DynamoDB Stream-triggered Lambda that writes a catalog-wide vulnerability aggregate to DynamoDB (served by GET /v1/vulnerabilities) and emits structured CVE exposure logs to CloudWatch Logs.

How It Works

The Lambda is triggered by DynamoDB Stream events filtered on VULNERABILITIES# and TAGS changes, with a 60-second batching window and reserved concurrency of 1 (single instance). It maintains an in-memory active CVE table across warm invocations:

  • Cold start: Reads CATALOG/DIRECTORY, all TAGS, and all non-superseded VULNERABILITIES# items from DynamoDB to build the full table (~2-3s at 10000 tags)
  • Warm invocations: Incrementally updates the table from stream event keys (~50-100 GetItem calls per batch)
  • After each invocation: Recomputes aggregate and emits structured logs

Structured Logs

Each invocation emits one JSON log line per active CVE to stdout (captured by CloudWatch Logs). Example query for all active CVEs:

filter message = "active_cve"
| fields cve, severity, exposure_hours, repository, stream, variant, component
| sort exposure_hours desc
Environment Variable Description
TABLE_NAME DynamoDB table name
DISTRO rawhide or hummingbird
SENTRY_DSN Optional Sentry DSN

Deployment

make container-catalog/build
make container-catalog/deploy

Configuration

catalog sync

Argument Description
--distro rawhide or hummingbird
--table-name DynamoDB table name
--purge Delete all items before writing
--cache-dir Cache directory (auto-detected)
--image Sync only a specific repo
--legacy-discovery Use GitLab-based repo discovery

catalog index

Argument Description
--distro rawhide or hummingbird
--table-name DynamoDB table name (for image/tag discovery)
--bucket S3 bucket for scanner data
--dry-run Print what would be uploaded without uploading
--image Index only a specific image
--parallel Number of concurrent operations (default: 2)

catalog scan

Argument Description
--distro rawhide or hummingbird
--table-name DynamoDB table name (required)
--bucket S3 bucket with native Syft JSON (uses S3 scan path)
--scope non-superseded, tags (default), or all
--dry-run Print items without writing
--purge Purge vuln data before writing (implies --scope all)
--cache-dir Cache directory (auto-detected)
--parallel Number of concurrent scans (default: 4)
--image Scan only a specific image
--tag Scan only a specific tag (requires --image)

SAM Parameters

Parameter Description
Distro rawhide or hummingbird
CacheEnabled Enable CloudFront caching
CatalogDomainName Catalog web UI domain
ApiDomainName API domain
HostedZoneId Route53 hosted zone
CorsOrigins Comma-separated CORS origins (default *)
SnsTopicArn SNS topic ARN for Release events (enables sync Lambda)

Frontend

The catalog web UI is a Lit 3 SPA (Web Components) with Tailwind CSS, built per-distro with Vite. Source is in container-catalog/frontend/.

Only make and podman are required (no local Node.js needed). Defaults from .envrc.defaults are applied automatically.

# Install dependencies
make container-catalog/frontend/setup

# Development server at http://localhost:5173
make container-catalog/frontend/dev

# Production build
make container-catalog/frontend/build

Host variants (*-host) run without podman (for CI or local Node.js).

Frontend Build Variables

Variable Description
VITE_API_URL API base URL for the distro
VITE_DISTRO rawhide or hummingbird
VITE_DISTRO_LABEL Display label for current distro
VITE_OTHER_CATALOG_URL URL of the other distro’s catalog (optional, hides link if unset)
VITE_OTHER_DISTRO_LABEL Display label for other distro (optional)
VITE_VULNERABILITIES_ENABLED Show vulnerabilities tab

Development

# Backend
cargo test                    # Run tests
cargo clippy --all-targets   # Lint
cargo fmt                     # Format

# Frontend (host variants, requires local Node.js)
cd container-catalog/frontend
npm run typecheck             # Type check
npm run build                 # Production build

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.20 - Hummingbird Agent

An event-driven LLM agent that investigates CI/CD failures and posts findings as GitLab merge request notes. The agent executes markdown-defined workflows using tool calling, with all data processing running inside an isolated sandbox container.

Model adapters, provider calls, and shared AI usage metrics come from the in-process hummingbird-ai library.

For the architectural design rationale, module boundaries, security invariants, and design decision registry, see Agent Design. For agent turn flow and tool handling, see Agent Model Loop. Provider adapters and wire formats are documented in Hummingbird AI.

flowchart TD
    Pipeline["Pipeline Fails"]
    MREvent["MR Created / Updated"]
    Slash["/hummingbird command"]

    Pipeline -->|"event"| Agent
    MREvent -->|"event"| Agent
    Slash -->|"command"| Agent

    subgraph Agent ["Hummingbird Agent"]
        APIs["GitLab + Konflux +<br/>Testing Farm"]
        Model["LLM<br/>(Gemini / Claude)"]
        subgraph Sandbox ["Isolated Sandbox (no network)"]
            Tools["jq / python3 / yq<br/>data processing"]
        end
        APIs <-->|"data"| Model
        Model <-->|"commands"| Tools
    end

    Note["MR Note<br/>(analysis or review)"]

    Agent -->|"posts"| Note
    Note -->|"reply to continue"| Agent

Features

  • Workflow-driven analysis - Investigation logic lives in .md files, not in code; easy to iterate without redeployment
  • Centralized YAML config - A single config file defines operational settings, workflows, enabled data sources with token env var names, project allowlists, and per-project limits
  • Sandboxed execution - All untrusted commands (jq, python3, shell) run in an isolated container, never on the host
  • Five sandbox backends - Podman for local development (network-isolated), direct K8s pod creation, Deployment-backed pod pool for low-latency production use (restricted-v2 SCC compliant), KubeVirt VM (direct), or VMIRS-backed VM pool for heavier workloads requiring full OS isolation
  • Data source abstraction - GitLab, Konflux, and Testing Farm are registered as tool-calling functions the model invokes directly
  • Auto-spill for large outputs - Stdout/stderr and data source responses exceeding 4 KB are automatically saved to sandbox files with a compact preview returned to the model, keeping context usage bounded
  • Prompt caching - Gemini uses implicit server-side caching automatically; Claude uses explicit sliding-window cache breakpoints that reduce input token costs by ~80% on multi-turn agent runs
  • Token budget management - Per-call context ceiling and iteration-based soft/hard limits prevent runaway sessions
  • Session persistence - Conversation history, transcript, and sandbox files saved to S3 (production) or local directory (development) for debugging and future session resumption. Sessions are stored in a provider-neutral format, enabling model switching between conversations.

Architecture

flowchart LR
    subgraph input [Input]
        SQS["SQS Queue"]
        CLI["CLI --event"]
    end

    subgraph agentLoop [Agent Loop]
        WF["Workflow .md<br/>(system prompt)"]
        LLM["LLM&lt;br/&gt;(Gemini / Claude)"]
        TR["Tool Registry"]
    end

    subgraph tools [Tools]
        SE["sandbox_exec"]
        FTS["fetch_to_sandbox"]
        DS["Data Sources"]
    end

    subgraph sandbox [Sandbox Container]
        JQ["jq / python3 / yq"]
        Files["Spilled files"]
    end

    subgraph external [External APIs]
        GL["GitLab API"]
        KX["Konflux K8s"]
        TF["Testing Farm"]
    end

    subgraph output [Output]
        Note["GitLab MR Note"]
        Session["Session State"]
    end

    SQS --> WF
    CLI --> WF
    WF --> LLM
    LLM -->|"tool calls"| TR
    TR --> SE --> sandbox
    TR --> FTS --> sandbox
    TR --> DS
    DS --> GL
    DS --> KX
    DS --> TF
    DS -->|"auto-spill"| sandbox
    LLM -->|"final text"| Note
    LLM --> Session

An event (CLI --event JSON or SQS message) identifies a GitLab project and MR IID. The config file maps project paths to workflows and provides action, max_iterations, enabled data sources, and token env var names. Both run and serve use this config. The markdown body of the prompt file becomes the LLM system prompt. The agent loop iterates until the model produces a final text response or hits the iteration/context limit. Tool calls are dispatched through the ToolRegistry: sandbox_exec runs shell commands, fetch_to_sandbox pipes data source output into the sandbox, and direct data source calls return results inline (or auto-spill large responses to files).

For a detailed walkthrough of the model loop – what gets sent to the model each iteration, how tool calls flow, the exact wire format, and how user replies integrate for session resumption – see Agent Model Loop.

Event-Driven Triggers

In production, the agent consumes events from an SQS queue subscribed to the central SNS topic. The SNS filter policy delivers three event types:

  • gitlab::pipeline – Fires when a GitLab CI pipeline completes. The agent triggers on status=failed pipelines from merge_request_event sources where the triggering user has at least Developer access on the project. Only workflows with trigger: pipeline are executed. Since the pipeline stays open until all Konflux external stages resolve, this naturally waits for all builds and tests to finish before triggering.

  • gitlab::merge_request – Fires on MR open and update events. Only workflows with trigger: merge_request are executed. The agent triggers in three cases: a new MR is opened (action=open), code is pushed to an existing MR (action=update with oldrev), or a draft MR is marked as ready (action=update with a changes.draft transition from true to false). All other update events (labels, assignees, description changes) are skipped – they carry no new code. Draft MRs are skipped; SHA-based deduplication prevents reviewing the same code revision twice. Access checks and trigger_rules filtering evaluate the push author: for open and push events the webhook user is the pusher; for undraft transitions the agent resolves the actual pusher from MR system notes so that the person undrafting cannot bypass the access check on code pushed by an untrusted user.

  • gitlab::note – MR comment events. Two sub-flows:

    • Slash command (/hummingbird <workflow-name>): triggers a specific workflow. Prefix matching is supported (e.g. /hummingbird analyze matches analyze-failures). /hummingbird or /hummingbird help lists available workflows. The note author must have Developer+ access on the project. Optional runtime overrides can be appended: /hummingbird code-review model=claude-sonnet-4-6 max_iterations=25.
    • Reply to agent note: when a user replies to an existing agent note (which contains a session marker), the agent loads the previous session from S3 (conversation history + sandbox files) and continues the conversation with the user’s reply as input. The system prompt includes a CONTINUATION_PROMPT that prevents the model from re-running the full workflow. If the session is not found (expired/deleted), the agent falls back to a cold start. Replies may include overrides on a separate line (e.g. /hummingbird model=claude-opus-4-6); override lines are stripped from the user message. Overrides persist in the session until explicitly changed.

    Notes generated by the agent itself (containing session markers) are skipped to prevent infinite loops. Reply threading respects the internal_notes config: if the project requires internal notes but the original thread was public, the reply is posted as a new top-level internal note instead.

Both pipeline and merge_request triggers apply per-workflow trigger rules filtering. Each workflow may define an ordered trigger_rules array; the first matching rule decides whether the event is allowed or denied (implicit deny on fallthrough). Rules can match on pipeline status, user_regex/user_regexes, branch_regex/branch_regexes, and title_regex/title_regexes. All conditions within a rule are ANDed; multiple patterns within a regex field are ORed. The ! prefix negates a pattern. If no trigger_rules are specified, merge_request triggers allow all events and pipeline triggers default to allowing only failed status (preserving backward compatibility).

The legacy ignore_users and ignore_branches fields are still accepted and automatically desugared into equivalent trigger_rules (deny + catch-all allow). They cannot be mixed with explicit trigger_rules.

Rate limiting is per-workflow: each workflow’s thread count is tracked independently via JSON session markers that embed the workflow name and commit SHA. The max_runs_per_mr limit applies separately to each workflow on a given MR.

Events flow through a two-stage SQS pipeline. A slim ingress router forwards webhooks from the standard queue to an SQS FIFO queue, grouped by discussion_id for note events (ensuring same-discussion ordering) and by SQS MessageId for pipeline/MR events (no serialization needed). Phase 1 handlers validate the event and resolve the session, then post a continuation message back to the FIFO grouped by session_id. Phase 2 picks up the continuation and runs the workflow. This ensures all work for a given session is serialized even across multiple discussion threads.

The SQS infrastructure is defined in template.yaml (SAM/CloudFormation): a standard ingress queue (60s visibility timeout for the router hop) and a FIFO work queue (30-minute visibility timeout for workflow execution).

Model Configuration

The agent supports multiple LLM providers via a unified adapter interface.

Gemini

  • API key (local dev) – Set GOOGLE_API_KEY. Calls generativelanguage.googleapis.com directly. No region configuration needed.
  • Vertex AI (production) – Set GOOGLE_CLOUD_PROJECT and configure model_regions in the YAML settings. Uses Application Default Credentials (ADC) via google-auth. For OpenShift, mount a service account key JSON file and set GOOGLE_APPLICATION_CREDENTIALS, or use workload identity.

Gemini uses implicit server-side caching – repeated prefixes are automatically cached by the Vertex AI backend with no opt-in required. Cached input tokens are billed at 25% of the base input rate. The agent tracks cached token counts from API responses for cost estimation.

Anthropic Claude

Claude models are accessed via Vertex AI using the same GCP project (GOOGLE_CLOUD_PROJECT) with the region resolved from model_regions. Enable the desired model in the GCP Model Garden. Use model: claude-sonnet-4-20250514 in workflow config.

Prompt caching is enabled automatically for Claude models. The agent places explicit cache breakpoints on messages so that the full conversation prefix is served from cache on every turn after the first. Cache writes cost 1.25x the base input rate; cache reads cost 0.10x (90% discount). For a typical 20-iteration agent run, this reduces input token costs by approximately 80%. Ephemeral messages (iteration warnings, nudges) are excluded from cache writes to avoid polluting the cache with transient content.

Region configuration

GCP regions are configured via settings.model_regions in the config file – a map of model name prefixes to GCP regions. The agent resolves the region by longest-prefix match on the effective model name (same algorithm as cost estimation). Example:

settings:
  model_regions:
    gemini-2.5-pro: us-east5
    gemini-3.1-pro: global
    claude: global

At least one of GOOGLE_API_KEY or GOOGLE_CLOUD_PROJECT must be set. If both are set, the API key takes precedence for Gemini models. Claude models always require Vertex AI mode (GOOGLE_CLOUD_PROJECT + model_regions); GOOGLE_API_KEY direct mode is for Gemini only.

Prerequisites

  • Python 3.11+
  • Podman (for local sandbox) or kubectl (for K8s sandbox)
  • Authentication: GOOGLE_API_KEY or GOOGLE_CLOUD_PROJECT (see above)
  • For Claude models: enable the desired model in GCP Model Garden
  • GitLab tokens referenced in the config file (model tool tokens, orchestrator tokens)
  • Kubeconfig with access to the Konflux cluster (if using Konflux data sources)

Installation

cd hummingbird-agent
pip install -e .

Usage

Local development (run)

The run command uses the config file for workflow lookup, data source registration, and token resolution – the same code path as serve. By default, results are printed to stdout (dry-run). Use --execute to post the result as a GitLab MR note.

There are two ways to select what to run:

Direct workflow selection (--workflow + --project + --event):

# Run a specific workflow on an MR, Podman sandbox, print to stdout
hummingbird-agent run \
    --workflow analyze-failures \
    --project org/group/project \
    --event '{"iid": 123, "sha": "abc123"}'

# Same but with K8s sandbox
hummingbird-agent run \
    --workflow analyze-failures \
    --project org/group/project \
    --event '{"iid": 123, "sha": "abc123"}' \
    --context my-cluster/my-namespace

# Post the result as a GitLab MR note (also saves session to S3 if configured)
hummingbird-agent run \
    --workflow analyze-failures \
    --project org/group/project \
    --event '{"iid": 123, "sha": "abc123"}' \
    --execute

# Save session locally for debugging (context.json, transcript.md, sandbox.tar.gz)
hummingbird-agent run \
    --workflow analyze-failures \
    --project org/group/project \
    --event '{"iid": 123, "sha": "abc123"}' \
    --save-session /tmp/my-session

# Resume from a saved session with a follow-up question
hummingbird-agent run \
    --workflow analyze-failures \
    --project org/group/project \
    --event '{"iid": 123, "sha": "abc123"}' \
    --resume-session /tmp/my-session \
    --message "Can you look at the clair-scan timeout more closely?"

# Chain: resume and save the new session for another round
hummingbird-agent run \
    --event-file event.json \
    --resume-session /tmp/my-session \
    --message "What layer hash failed?" \
    --save-session /tmp/my-session-2

Event-file replay (--event-file): routes the event through the same project-index lookup as serve, but skips rate limiting and status filtering:

# Replay a real webhook event, dry-run with Podman
hummingbird-agent run \
    --event-file event.json

# Replay with K8s sandbox and post notes
hummingbird-agent run \
    --event-file event.json \
    --context my-cluster/my-namespace \
    --execute

Production (serve)

# Poll SQS queue for events (pool sandbox by default, posts results as MR notes)
CONFIG_PATH=config.yml hummingbird-agent serve

Requires CONFIG_PATH pointing to a config file with settings.sqs_queue_url and settings.sqs_fifo_queue_url set. The config file defines which workflows run on which projects, with per-project limits and data source token mappings. Handles SIGTERM/SIGINT for graceful shutdown. A background router thread forwards events from the standard queue to the FIFO; the main thread consumes the FIFO with a semaphore-gated thread pool (controlled by settings.max_concurrent_agents) so excess messages stay in SQS for other instances.

Config hot-reload: In serve mode a background thread polls the config file for changes (every 5 seconds by default). When the file changes, the new config is validated and atomically swapped in – subsequent event dispatches use the updated config. If the new config is invalid, the previous config is kept and a warning is logged. No restart required for config changes.

The serve command also accepts --sandbox, --context, and --namespace for local development with a different sandbox backend (e.g. Podman).

CLI Options

run subcommand:

Option Description Default
--event Inline event JSON string (mutually exclusive with --event-file) -
--event-file Read event from a JSON file (mutually exclusive with --event) -
--workflow Workflow name from config file (requires --project) -
--project GitLab project path (requires --workflow) -
--execute Post result as GitLab MR note and save session to S3 -
--save-session Save session artifacts to this directory -
--resume-session Resume from a saved session directory (requires --message) -
--message Follow-up message for session resumption (requires --resume-session) -
--sandbox Sandbox backend (podman, k8s, k8spool, kubevirt, or kubevirtpool) podman
--context K8s context (implies K8s backend) -
--namespace K8s namespace -
-v, --verbose Enable debug logging -

serve subcommand:

Option Description Default
--sandbox Sandbox backend (podman, k8s, k8spool, kubevirt, or kubevirtpool) k8spool
--context K8s context (implies K8s backend) -
--namespace K8s namespace -
-v, --verbose Enable debug logging -

Configuration

Config file

Both run and serve use a single YAML config file. Set the path via CONFIG_PATH (default: config.example.yaml). The config has two sections: settings for operational parameters, and workflows for workflow definitions. The settings section provides defaults that can be omitted for local development (sensible defaults are used).

settings:
  gitlab_url: https://gitlab.com # GitLab instance URL
  sandbox: # sandbox pod configuration
    image: quay.io/.../gitlab-ci:latest #   container image (k8s mode only)
    namespace: default #   K8s namespace (required for K8s/pool/kubevirt)
    active_deadline_seconds: 1800 #   pod hard timeout / reap interval
    linger_seconds: 300 #   keep pod alive after success (pool mode, 0=disable)
    max_lingering_pods: 2 #   max idle lingering pods before eviction (pool mode)
    vm_image: quay.io/.../vm-disk:latest #   containerDisk image (kubevirt mode only)
    vm_memory: 1Gi #   VM guest memory (kubevirt mode only)
    vm_ssh_private_key: /path/to/key #   SSH private key for pool VMs (kubevirtpool mode)
    metadata: #   pod/VMI metadata (k8s/kubevirt mode)
      labels:
        app.kubernetes.io/name: hummingbird-agent-sandbox
    resources: #   K8s resource requests/limits (k8s mode)
      requests:
        cpu: "100m"
        memory: "256Mi"
        ephemeral-storage: "256Mi"
      limits:
        cpu: "1"
        memory: "1Gi"
        ephemeral-storage: "2Gi"
  max_concurrent_agents: 4 # max concurrent workflows (serve)
  sqs_queue_url: "" # standard SQS ingress queue URL (serve)
  sqs_fifo_queue_url: "" # FIFO work queue URL (serve)
  s3_session_bucket: "" # S3 bucket for session persistence
  model: gemini-3.1-pro-preview # or claude-sonnet-4-20250514
  model_regions: # model prefix -> GCP region
    gemini-2.5-pro: us-east5
    gemini-3.1-pro: global
    claude: global
  max_iterations: 30 # default iteration limit
  max_runs_per_mr: 5 # default per-MR rate limit
  internal_notes: true # default note visibility
  docs_url: https://gitlab.com/org/group/project/-/blob/main/docs/agent.md
  source_url: https://gitlab.com/org/group/project
  slack_url: https://slack.example.com/archives/C0123456789
  slack_label: "#my-channel"

workflows:
  code-review:
    trigger: merge_request # auto-trigger on MR events
    description: Performs AI-powered code review
    sandbox: k8spool # per-workflow sandbox override (optional)
    workflow_url: https://gitlab.com/org/group/project/-/blob/main/workflows/code-review.md
    trigger_rules: # ordered rule chain, first match wins
      - user_regex: "renovate\\[bot\\]" # deny bot MRs (regex fullmatch)
        action: deny
      - branch_regex: "chore/.*" # deny maintenance branches
        action: deny
      - action: allow # allow everything else
    prompt: workflows/code-review.md
    action: post_gitlab_note
    model: gemini-3.1-pro-preview # or claude-sonnet-4-20250514
    max_iterations: 15
    max_inline_size: 200000 # keep full diffs in context
    context_limit: 500000 # Gemini 3.1 Pro / Claude Sonnet 4 have large context windows
    data_sources:
      gitlab:
        token_env: GITLAB_TOKEN_RO
    projects:
      org/group/project: {}

  analyze-failures:
    trigger: pipeline # auto-trigger on failed pipelines
    description: Investigates CI/CD pipeline failures
    sandbox: kubevirtpool # use VM sandbox for heavier workloads
    auto_resolve_on_push: true # resolve threads when a new SHA is pushed
    auto_resolve_on_success: true # resolve threads when pipeline succeeds
    workflow_url: https://gitlab.com/org/group/project/-/blob/main/workflows/analyze-failures.md
    prompt: workflows/analyze-failures.md # relative to config file dir
    action: post_gitlab_note
    model: gemini-3.1-pro-preview # or claude-sonnet-4-20250514
    max_iterations: 50 # per-workflow iteration override

    data_sources: # model tool tokens (read-only)
      gitlab:
        token_env: GITLAB_TOKEN_RO # env var name, not the token
      konflux:
        cluster_url: https://example.com:6443/ns/my-tenant
        kubeconfig_env: KUBECONFIG
        kubearchive_url: https://kubearchive-api-server-product-kubearchive.apps.example.com
      testing_farm: {}

    projects:
      redhat/hummingbird/containers:
        tokens: # per-project model token overrides
          gitlab: GITLAB_TOKEN_CONTAINERS_RO
        action_tokens: # per-workflow write tokens
          gitlab: HUMMINGBIRD_AGENT_ACTION_ANALYZE_FAILURES_GITLAB_TOKEN_CONTAINERS

With --workflow/--project, the workflow and project are looked up directly in the config. With --event-file, the project is extracted from the event body and matched against the project index to find applicable workflows.

Discussion threads

All workflow results are posted as discussion threads: a placeholder note starts the discussion and the full result is posted as a reply. The placeholder is never edited, so email notifications include the actual result text. For slash commands, the result is posted as a reply in the triggering discussion.

Auto-resolve

Workflows can opt in to automatic resolution of their discussion threads:

  • auto_resolve_on_push (default false): When a new commit is pushed to the MR (i.e. a merge_request event with action: update), all agent discussion threads for the workflow whose SHA differs from the new HEAD are resolved. This clears stale failure analyses when the developer pushes a fix.
  • auto_resolve_on_success (default false): When the head pipeline succeeds, all agent discussion threads for the workflow on that MR are resolved (regardless of SHA). This handles pipeline reruns on the same SHA where a transient failure is now green.

Both flags are independent and can be combined. Resolution runs before rate limit checks, so threads are resolved even if the workflow’s per-MR run limit has been reached.

Workflows can enable Anthropic’s built-in web search server tool by listing web_search as a data source:

workflows:
  renovate-babysit:
    model: claude-sonnet-4-20250514
    data_sources:
      gitlab: {}
      web_search: {}
    # ...

When web_search is present in data_sources, the web_search_20250305 server tool is included in API requests to Claude. The model decides autonomously when to search. Search execution happens server-side (no client-side tool dispatch), and results appear as server_tool_use / web_search_tool_result content blocks in the response. These blocks are preserved through session save/resume.

Unlike other data sources, web_search has no configuration options and does not register any orchestrator-side tools – it is handled entirely by the model provider.

If the API returns a pause_turn stop reason (server-side search loop hit its iteration limit), the adapter automatically re-sends the conversation to continue, up to 5 continuations per generate() call.

Web search is only supported with Claude models. The setting is ignored for Gemini.

The first agent-authored note (placeholder) includes a footer with links to documentation, source code, the Slack channel, the workflow prompt, and a continuation prompt. These links are configured via global settings:

Setting Description
settings.docs_url Link to agent documentation
settings.source_url Link to agent source repository
settings.slack_url Link to support Slack channel
settings.slack_label Display text for Slack link (default: “Slack”)

Per-workflow, set workflow_url to link to the workflow’s prompt file. The footer stays on the placeholder and the result is a separate reply.

Token separation

Tokens are split into three tiers that never mix:

  • Model tool tokens (in YAML data_sources / tokens): read-only tokens passed to the LLM’s tool calls. Declared in the config file as env var names. These are user-defined and resolved at runtime from the referenced env vars. Create as project access tokens with Reporter role and read_api scope. Reporter is the minimum role required to see internal (confidential) notes in the discussions tool.
  • Workflow action tokens (in YAML action_tokens, per-project): write tokens used by the orchestrator for per-workflow GitLab writes (notes, thread resolution). Each workflow gets a dedicated bot user per project, providing clear audit trails for which agent produced each note. Declared in the project config as env var names. Create as project access tokens with Developer role and api scope. These tokens are never exposed to the LLM and never enter the ToolRegistry. Naming convention: HUMMINGBIRD_AGENT_ACTION_<WORKFLOW>_GITLAB_TOKEN_<PROJECT>.
  • Orchestrator tokens (ORCHESTRATOR_* env vars, NOT in YAML): tokens used by the runner for operational reads (member access checks, push author lookup, head pipeline queries) and infrastructure notes (access-denied replies, rate-limit notices). Create as project access tokens with Developer role and read_api scope (or api for infrastructure notes). Developer role is required because the discussions tool trust-filters notes by author access level (>= Developer); if the orchestrator bot has only Reporter access, its own notes are redacted. Resolved by convention: ORCHESTRATOR_GITLAB_TOKEN_<MANGLED_PROJECT> (per-project) or ORCHESTRATOR_GITLAB_TOKEN (fallback). The ORCHESTRATOR_ prefix makes these impossible to confuse with model tokens.

Environment Variables

The agent reads only secrets and authentication from environment variables. All operational settings come from the config file’s settings section.

Variable Required Default Description
CONFIG_PATH no config.example.yaml Path to config YAML
GOOGLE_API_KEY yes* - Gemini API key; Gemini direct mode only (not for Claude)
GOOGLE_CLOUD_PROJECT yes* - GCP project ID (Vertex AI mode); required for Claude models
ORCHESTRATOR_GITLAB_TOKEN serve - Orchestrator GitLab token (global fallback)
ORCHESTRATOR_GITLAB_TOKEN_<PROJECT> no - Per-project orchestrator token
SENTRY_DSN no - Sentry DSN for error tracking

*One of GOOGLE_API_KEY or GOOGLE_CLOUD_PROJECT is required. Claude models require Vertex AI (GOOGLE_CLOUD_PROJECT); GOOGLE_API_KEY is for Gemini direct mode only.

Model tool tokens (e.g. GITLAB_TOKEN_RO, GITLAB_TOKEN_CONTAINERS_RO) and data source credentials (e.g. KONFLUX_CLUSTER_URL, KUBECONFIG) are referenced by name in the config file’s data_sources and tokens sections. They are not listed in the table above because their names are user-defined.

Security and Design Constraints

The agent is designed to run in a shared OpenShift cluster without cluster-admin access, processing potentially untrusted merge requests. These constraints shaped the architecture:

Sandbox isolation. All arbitrary commands executed by the LLM run inside an ephemeral container, never on the host:

  • Podman (local): --network=none, --user 65532, no host mounts. Complete network isolation.
  • Kubernetes (production): Pods comply with OpenShift’s restricted-v2 Security Context Constraint: runAsNonRoot, seccompProfile: RuntimeDefault, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], automountServiceAccountToken: false (no K8s API access from sandbox), activeDeadlineSeconds (configurable, default 1800). Security context fields are hardcoded in the pod manifest for portability to vanilla Kubernetes with Pod Security Admission (restricted level). Resource requests/limits, metadata, and activeDeadlineSeconds are configurable via settings.sandbox in the config file. Network access is denied by a NetworkPolicy on the sandbox namespace that blocks all egress from all pods (podSelector: {}).

No cluster-admin required. The agent operates with namespace-scoped permissions only. The orchestrator’s ServiceAccount needs only:

  • pods: create, get, list, delete, patch – sandbox pod lifecycle and pool claims
  • pods/exec: create – command execution via kubectl exec
  • virtualmachineinstances.kubevirt.io: create, get, list, delete, patch – KubeVirt VMI lifecycle (kubevirt/kubevirtpool modes only)
  • secrets: create, get, delete – SSH key Secrets for VMI provisioning (kubevirt/kubevirtpool modes only)

These permissions are granted via a Role in the sandbox namespace, not the orchestrator’s own namespace. No CRDs, no custom runtimes, no cluster-scoped resources. Konflux data is fetched via bearer token from kubeconfig, not from inside the cluster.

Namespace separation. Sandbox pods are created in a dedicated namespace, separate from the orchestrator. This limits blast radius: even if a sandbox pod is compromised, it has no visibility into the orchestrator’s Secrets, Pods, or ServiceAccount tokens. The sandbox namespace is locked down with standard K8s resources:

  • RBAC: Role + RoleBinding scoped to the namespace, granting only the permissions above to the orchestrator’s ServiceAccount
  • NetworkPolicy: uses podSelector: {} to select all pods in the dedicated sandbox namespace, denying all egress (egress: []). The sandbox cannot reach the internet, the K8s API, or other pods.
  • activeDeadlineSeconds: sandbox pods self-terminate after the configured timeout (default 1800s / 30 minutes) even if the orchestrator crashes or is killed, preventing orphaned pods

Credential separation. Data source credentials (GitLab tokens, kubeconfig) live in the orchestrator process only, injected via K8s Secrets. The sandbox container has no credentials, no SA token (automountServiceAccountToken: false), and no network access. Data flows into the sandbox via stdin piping through write_file.

Command execution via kubectl exec. The K8s sandbox uses a hybrid approach: the Kubernetes Python client manages pod lifecycle (create, wait, delete), while kubectl exec handles command execution. This avoids the complexity and reliability issues of the websocket-based exec API.

KubeVirt VM isolation. KubeVirt sandbox VMs run as root inside the guest OS, but the VM itself is contained by the KubeVirt hypervisor (QEMU/KVM). The VM has no access to the Kubernetes API, no ServiceAccount token, and no credentials. SSH keys are ephemeral (generated per sandbox start for direct mode, or shared per pool for pool mode) and cleaned up with the VMI.

Sandbox Backends

Podman (local) K8s (direct) K8sPool (production) KubeVirt (direct) KubeVirtPool
Start podman run -d --network=none create_namespaced_pod Claim standby pod from Deployment Create VMI + SSH Secret Claim standby VMI from VMIRS
Exec podman exec kubectl exec kubectl exec ssh ssh
Auth Local Podman socket In-cluster SA or kubeconfig In-cluster SA or kubeconfig In-cluster SA or kubeconfig In-cluster SA or kubeconfig
Network None (--network=none) None (deny-all NetworkPolicy) None (deny-all NetworkPolicy) Cluster pod network (SSH) Cluster pod network (SSH)
User 65532 (fixed) Namespace UID range (SCC) Namespace UID range (SCC) root (inside VM) root (inside VM)
Cleanup podman rm -f delete_namespaced_pod delete_namespaced_pod Delete VMI + Secret + temp keys Delete VMI

All five implement the Sandbox protocol: start(), exec(), write_file(), read_file(), cleanup(), linger().

The pod pool backend (k8spool) eliminates pod startup latency by claiming pre-warmed pods from a Kubernetes Deployment. Claimed pods are detached from the ReplicaSet and the Deployment automatically creates replacements. After a successful workflow, pool pods linger for linger_seconds (default 300, configurable, 0 to disable) so that user replies can reuse the same pod without re-creating it or restoring from S3. The reaper runs once per workflow execution and deletes expired lingering pods. It also evicts excess lingering pods beyond max_lingering_pods (default 2), starting with those closest to their deadline. See the design doc (section 8.8) for details.

The KubeVirt backends (kubevirt, kubevirtpool) provide full VM isolation using KubeVirt VirtualMachineInstances. The VM boots from a containerDisk image and SSH keys are injected via a Kubernetes Secret volume (the VM image’s inject-ssh-keys.service reads from /dev/disk/by-id/virtio-ssh-pubkeys). Command execution uses SSH instead of kubectl exec. The kubevirtpool backend claims pre-warmed VMIs from a VirtualMachineInstanceReplicaSet, with the same claim/reap/linger semantics as the pod pool. The sandbox backend can be set per-workflow via the sandbox: field in the workflow config, with resolution order: workflow config > CLI --sandbox > default.

Data Sources

Data sources are registered as tool-calling functions. The model invokes them by name; the orchestrator executes them and returns results (or auto-spills large responses to the sandbox).

GitLab

Tool Description
gitlab_get_mr_details MR metadata (title, author, state, SHA, labels)
gitlab_get_mr_unified_diff Complete unified diff in patch format
gitlab_get_mr_diff Per-file structured change data
gitlab_get_mr_commits List of commits in a merge request
gitlab_get_mr_discussions Discussion threads with redacted agent transcripts and trust-filtered comments
gitlab_get_commit_statuses CI/CD pipeline statuses for a commit
gitlab_get_file_at_ref Raw file content at a git ref
gitlab_get_repo_archive Repository tar.gz (binary, auto-spilled)
gitlab_get_job_log CI job trace output (ANSI codes stripped)

Konflux

Fetches Tekton PipelineRuns and TaskRuns from both the live K8s API and Kubearchive (for completed resources), with deduplication by UID.

Tool Description
konflux_list_pipelineruns PipelineRuns for a commit SHA; optional status allowlist filter
konflux_list_taskruns TaskRuns by commit SHA or parent PipelineRun name; optional status filter
konflux_list_pods Pods for a commit SHA; optional status allowlist filter
konflux_get_pod Full pod resource (spec, status, conditions, container statuses)
konflux_get_pod_log Pod logs; optional container param, fetches all containers when omitted

Status filtering: All three list tools accept an optional status parameter — an allowlist of status.conditions[0].status values ("True" = success, "False" = failure, "Unknown" = in-progress). Only items matching the allowlist are written to the sandbox file. Response metadata always includes counts (all items seen, by reason), included, and excluded — the agent knows the full reason breakdown even when fetching only failures. Items without conditions always pass.

Per-PLR TaskRun fetching: konflux_list_taskruns accepts a pipelinerun parameter. When set, it uses tekton.dev/pipelineRun=<name> as a server-side label selector, returning only the ~15-21 TaskRuns for that PipelineRun (~600 KB) instead of all TaskRuns for the commit (~530 MB for 340 components).

Recommended pattern for analyze-failures: Fetch failing PLRs and TaskRuns together in one batch with status=["False"]. This captures every failure reason (Failed, Cancelled, PipelineRunTimeout, StepFailed, etc.) without enumerating them. Use response metadata counts for the summary. Use per-PLR fetch only when deeper investigation is needed.

Response metadata includes konflux_ui base URL for building reviewer-facing links.

Testing Farm

Tool Description
tf_get_results JUnit XML results for a request ID
tf_get_test_log Individual test log by URL (restricted to Testing Farm artifact URLs)
tf_get_request_status Request state, queue/run times

Response metadata includes artifacts_base URL for building artifact links.

Workflow System

Workflows are .md files whose content becomes the LLM system prompt verbatim. Workflow metadata (action, model, max_iterations, enabled data sources, project allowlists) is defined in the config file. The .md file is pure system prompt text.

Available workflows:

  • analyze-failures.md - Investigates CI/CD pipeline failures by fetching MR details, identifying failed pipelines via commit statuses, retrieving PipelineRuns/TaskRuns from Konflux, analyzing test results from Testing Farm, and producing a grouped root-cause report with reviewer-facing URLs.
  • code-review.md - Performs AI-powered code review by fetching MR details, the unified diff, and prior discussion threads in parallel, then attempting to load per-project rules from workflows/repo-rules/ (a mandatory step – rules take precedence over generic standards when present) and producing structured feedback with severity ratings, code examples, and actionable suggestions. On follow-up reviews (after SHA updates), the agent sees its own previous findings, developer responses, and resolved threads – avoiding duplicate findings and respecting developer explanations. Uses elevated max_inline_size (200 KB) and context_limit (500K tokens) to keep the full diff in context.
  • renovate-babysit.md - Triages a single Renovate-authored MR triggered by a successful pipeline. Classifies the MR as safe or risky based on diff scope, upstream dependency changes (from MR description, web search), and local codebase impact (via gitlab_get_repo_archive + grep). Posts a structured note with verdict, upstream change summary, and suggested actions for risky MRs. Requires a Claude model with web_search data source.

Token Budget Management

Prompt caching, cached token accounting, and provider-specific cost estimates are implemented in hummingbird-ai.

The agent uses a dual-limit approach instead of a cumulative token budget:

  • Iteration limit (settings.max_iterations, default 30, overridable per-workflow) - Hard cap on tool-calling rounds. A wrap-up prompt is injected at 80% (SOFT_ITERATION_RATIO).
  • Context limit (CONTEXT_LIMIT, default 60,000 tokens, overridable per-workflow via context_limit) - Per-call input token ceiling. When exceeded, a wrap-up prompt forces the model to finalize.

Large outputs are automatically redirected to sandbox files to keep the LLM context small. The spill threshold defaults to 4 KB (MAX_INLINE_SIZE) but can be overridden per-workflow via max_inline_size in the config:

  • sandbox_exec - stdout/stderr exceeding the threshold saved to /tmp/_out/{N}.txt; model receives a preview (head + tail) with file path
  • Data sources - text exceeding the threshold saved to /tmp/_out/{name}_{N}.txt with preview; binary data saved to .bin
  • fetch_to_sandbox - always writes to the caller-specified path; returns metadata only

All tuning constants are centralized in config.py:

Constant Default Purpose
DEFAULT_MAX_ITERATIONS 30 Hard iteration cap
SOFT_ITERATION_RATIO 0.8 Inject wrap-up at this fraction
CONTEXT_LIMIT 60,000 Per-call input token ceiling
OUTPUT_PREVIEW_BYTES 4,096 Preview size for spilled outputs
OUTPUT_TAIL_BYTES 512 Extra tail appended to previews
MAX_INLINE_SIZE 4,096 Max inline size for data source responses

Evaluation Framework

The agent includes an evaluation framework for scientific measurement of prompt and model quality. See Hummingbird Agent Evals for the full guide covering the shared hummingbird_agent.eval library, existing evaluations, and how to write new ones.

Development

See the main README for development workflows.

make hummingbird-agent/setup  # Install dependencies
make check                    # Lint code (ruff)
make fmt                      # Format code
make test                     # Run unit tests
make coverage                 # Run tests with coverage

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.21 - Hummingbird Agent Model Loop

How the Hummingbird agent runs conversation turns, calls tools, and integrates user replies.

For the architectural design rationale behind the loop (why full history replay, why iteration-based budgets, why nudge via system prompt), see Agent Design – Section 6. For operational usage documentation, see Hummingbird Agent. Provider adapters, wire formats, canonical message conversion, and caching are documented in Hummingbird AI.

What gets sent to the model

Every call to the model API sends three things:

1. System instruction

A single text string, rebuilt every iteration. The adapter maps it to the active provider’s request format. It is composed of:

BASE_SYSTEM_PROMPT            # agent.py: sandbox rules, tool usage tips
+ tool_notes                  # per-data-source notes from ToolRegistry
+ workflow_prompt             # full content of e.g. workflows/analyze-failures.md

Near the iteration or context limit, a warning suffix is appended to the system prompt for that call. The suffix escalates through three levels:

  • ITERATION_WARNING / CONTEXT_WARNING – soft “start wrapping up” at 80% of the iteration or context limit. Tools remain available.
  • FINAL_TURN_WARNING – hard stop on the absolute last iteration or after the context limit is exceeded. Combined with tool-calling disabled for that request to mechanically prevent further tool calls.
  • EMPTY_RESPONSE_NUDGE – appended when the model returns an empty response (no text, no tool calls). Tools remain available.

2. contents

A list of messages representing the full conversation history. It grows every iteration. Provider-specific content conversion is handled by the adapter; see Hummingbird AI.

3. tools

Tool definitions, static across all iterations. Adapters translate the shared tool definition into the provider’s schema.

4. Tool availability (conditional)

On the final turn (last iteration or after context limit exceeded), the request disables tools to mechanically prevent function calls. The active adapter applies the provider-specific setting.

This is only sent when FINAL_TURN_WARNING is active. All other iterations omit this constraint, allowing the model to choose freely between text and tools.

The agent loop, turn by turn

The loop in run_agent_loop runs up to max_iterations times. Each iteration is one round-trip to the model.

Initialization

system_prompt = BASE_SYSTEM_PROMPT + tool_notes + workflow_prompt
tool_defs     = [sandbox_exec, fetch_to_sandbox, fetch_batch_to_sandbox, <data sources...>]
contents      = [user: {"project": "org/repo", "iid": 42, "sha": "abc...", "session_id": "uuid"}]

Iteration 1

-> Send: system_instruction + contents (1 item) + tool_defs
<- Response: tool_call(gitlab_get_mr_details, {project: "org/repo", iid: 42})

contents.append(model: response.raw_content)  # opaque provider content
  execute tool -> result = {"result": "{\"title\": \"Fix auth\",...}"}
contents.append(tool: model.make_tool_responses([(name, result)]))

Iteration 2

-> Send: system_instruction + contents (3 items) + tool_defs
<- Response: tool_call(fetch_batch_to_sandbox, {requests: [...]})

contents.append(model: ...)
  execute tool -> result = {"results": [{"saved_to": "/tmp/data/pipelineruns.json", "bytes": 85432}]}
contents.append(tool: ...)

Iteration 3

-> Send: system_instruction + contents (5 items) + tool_defs
<- Response: tool_call(sandbox_exec, {command: "jq '[...]' /tmp/data/pipelineruns.json"})

contents.append(model: ...)
  execute tool -> result = {"exit_code": 0, "stdout": "...(preview)...", "stdout_file": "/tmp/_out/0.txt"}
contents.append(tool: ...)

Iteration N (final)

-> Send: system_instruction + contents (2N-1 items) + tool_defs
<- Response: text("## Konflux Failure Analysis\n\n...")    // text + no tool calls = DONE

contents.append(model: {text: "## Konflux Failure Analysis..."})
BREAK -- return (text, usage, transcript, contents)

Response handling and termination

Each iteration, the model’s response can contain text, tool calls, both, or neither. The agent handles each case:

Response Action
Text only Final response. Save text, break. (Happy path.)
Text + tool calls Save text as last_text fallback, execute the tool calls, continue loop. The model is thinking aloud while also acting.
Tool calls only Execute the tool calls, continue loop.
Neither Empty response – retry up to MAX_EMPTY_RETRIES (2) times. Each retry nudges the model via EMPTY_RESPONSE_NUDGE appended to the system prompt (not injected as a user message, to keep the contents list clean). Tools remain available during nudge retries.

The text + tool calls case is worth noting: the model’s text is not returned to the user immediately. It is stored as last_text (a fallback in case the loop terminates later without a clean final text, e.g. by hitting max iterations). The provider response is appended to contents unchanged; its wire format is documented in Hummingbird AI.

The loop terminates on:

  1. Text only – the model produced a final response.
  2. Max iterations reached – the final iteration uses FINAL_TURN_WARNING and disables tools through the adapter to force text output. Falls back to whatever last_text was seen, or "[Agent did not produce a final response]".
  3. Context limit exceeded – input_tokens >= CONTEXT_LIMIT sets context_exceeded, making the next iteration final (same as #2). A soft warning (CONTEXT_WARNING) fires earlier at 80% of the limit.
  4. Fatal model error – a provider error after shared retry handling is exhausted. Error classification and retries are described in Hummingbird AI.
  5. Empty responses exhausted – MAX_EMPTY_RETRIES (2) nudge retries failed.
  6. Unexpected error – any exception not handled by the retry mechanism (e.g. malformed API response) breaks the loop and returns partial results. Accumulated contents, transcript, and sandbox are preserved and the session is saved normally.

Budget escalation

Both iterations and context size use the same two-tier pattern:

Soft warning (80%) Hard stop (100%)
Iterations ITERATION_WARNING at 80% of max_iterations FINAL_TURN_WARNING on last iteration
Context CONTEXT_WARNING at 80% of CONTEXT_LIMIT FINAL_TURN_WARNING on next iteration after exceeding CONTEXT_LIMIT

Soft warnings are advisory (“start wrapping up”) and keep tools available. The hard stop disables tools through the active adapter to mechanically prevent further tool calls, ensuring the model produces text.

The contents list in detail

The agent treats each entry in contents as opaque adapter content during execution. Provider representations and canonical conversions are documented in Hummingbird AI.

model.make_user_content() constructs the initial event turn and model.make_tool_responses() appends results for dispatched calls. The agent stores the provider response as raw_content and appends it unchanged.

Typical 8-iteration conversation shape

contents[0]  = user:  {"project":"org/repo","iid":42,...}          # initial event
contents[1]  = model: tool_call(gitlab_get_mr_details)             # iter 1
contents[2]  = tool:  result(gitlab_get_mr_details)                # iter 1
contents[3]  = model: tool_call(fetch_batch_to_sandbox)            # iter 2
contents[4]  = tool:  result(fetch_batch_to_sandbox)               # iter 2
contents[5]  = model: tool_call(sandbox_exec)                      # iter 3
contents[6]  = tool:  result(sandbox_exec)                         # iter 3
...
contents[13] = model: tool_call(sandbox_exec)                      # iter 7
contents[14] = tool:  result(sandbox_exec)                         # iter 7
contents[15] = model: text("## Konflux Failure Analysis...")       # iter 8 (final)

This entire list is persisted as canonical messages in context.json; the adapter converts to and from its provider format at the persistence boundary. See Hummingbird AI for details.

How tools are called

When the model’s response contains tool calls, _execute_tool_calls iterates them sequentially:

tool_registry.execute(ToolCall(name, args))
  match name:
     "sandbox_exec"           -> runs sh -c in container -> {exit_code, stdout, stderr}
     "fetch_to_sandbox"       -> calls data source func -> writes to sandbox file -> {saved_to, bytes}
     "fetch_batch_to_sandbox" -> multiple fetch_to_sandbox in one call
     any data source name     -> calls func directly -> returns inline or auto-spills large output

Large output handling

When stdout or a data source response exceeds 4KB, it is automatically saved to a sandbox file (/tmp/_out/N.txt) and only a preview (head + tail) is returned to the model. The model gets the file path and can use sandbox_exec with jq/grep/head to process it. This keeps the context window manageable.

How a user reply integrates (session resumption)

When a user replies to an agent note on GitLab, the orchestrator resumes the conversation by restoring the previous state and appending the reply.

What gets restored from S3

  • context.json – the persisted conversation in canonical form; the active adapter converts it to the in-memory representation before the loop runs
  • sandbox.tar.gz – /tmp/_out/ files (PipelineRun JSONs, test logs, jq output, etc.) restored into the new sandbox

Canonical storage allows sessions to resume with another provider; conversion details are in Hummingbird AI.

The resumed contents list

// Restored from context.json (previous run)
contents[0]  = user:  {"project":"org/repo","iid":42,...}           # original event
contents[1]  = model: tool_call(gitlab_get_mr_details)              # iter 1
contents[2]  = tool:  result(gitlab_get_mr_details)                 # iter 1
...                                                                  # all prior turns
contents[15] = model: text("## Konflux Failure Analysis...")        # previous final text

// NEW: user reply appended
contents[16] = user:  "Can you look at the clair-scan timeout more closely?"

The agent loop continues

ITERATION 1 (resumed):
  -> Send: system_instruction (+ CONTINUATION_PROMPT) + contents[0..16] + tool_defs
  <- Response: tool_call(sandbox_exec, {command: "grep -i timeout /tmp/data/..."})
                                                       ^ using restored sandbox files
  contents[17] = model: tool_call(sandbox_exec)
  contents[18] = tool:  result(sandbox_exec)

ITERATION 2 (resumed):
  <- Response: text("The clair-scan timeout is caused by...")
  contents[19] = model: text("The clair-scan timeout is caused by...")
  BREAK

Cold start vs resumed – key differences

Aspect Cold start Resumed
System prompt BASE + tool_notes + workflow BASE + tool_notes + workflow + CONTINUATION_PROMPT
First user message {"project":..., "iid":...} {"project":..., "iid":...} (restored)
Conversation history Empty (just the event) Full prior conversation
Latest user message – "Can you look at the clair-scan timeout?"
Sandbox files Empty Restored from archive
Tool definitions Same Same
Session format Adapter content in memory Canonical on disk; adapter content after load

The CONTINUATION_PROMPT

Without this, the workflow prompt (e.g. analyze-failures.md) tells the model to follow a rigid workflow: Data.1, Data.2, Data.3, Analysis.1… The model might try to re-run the entire analysis. The continuation prompt overrides this:

## Continuation

This is a follow-up to a previous conversation. The conversation history
contains your prior analysis and tool calls. The user is replying with a
question or request about your previous work.

**Do NOT re-run the full workflow from scratch.** Instead:
- Respond directly to the user's question
- Use your tools to investigate further if needed (logs, data are still
  available in the sandbox)
- Reference your previous findings where relevant
- Keep your response focused on what the user asked

The model now understands: “I already did the analysis (it’s all in the conversation history). The user has a specific question. Let me answer it.”

Iteration and token budget for resumed sessions

The resumed session gets a full fresh iteration budget. run_workflow sets max_iterations from the workflow config (e.g. 50 for analyze-failures), and the loop counter starts at 0 regardless of how many iterations the previous session used. The model can make as many tool calls as it needs to answer the follow-up.

The practical constraint is the context window, not iterations. The restored contents can be large – a typical 8-iteration cold start uses ~50-100K input tokens. Since the model re-counts the full history every iteration, the resumed session starts at roughly the token count where the previous session ended, plus the new user message. Each new tool call adds more. Token counts logged by the agent are billed tokens (cumulative across API calls; each call re-sends the full history, so these overlap).

The existing CONTEXT_LIMIT check still applies: at 80% of the limit, a soft CONTEXT_WARNING tells the model to start wrapping up. If the limit is exceeded, the next iteration becomes final (FINAL_TURN_WARNING with provider tools disabled by the adapter).

For the typical case (user asks one focused follow-up, agent does 1-3 tool calls), this is fine. Multi-turn deep dives will eventually hit the context limit, at which point the agent wraps up – and the user can start a fresh conversation if needed.

Provider behavior

Provider wire formats, canonical message conversion, prompt caching, server-side tools, and adapter continuations are owned by the hummingbird-ai library. The Hummingbird agent loop remains responsible for tool dispatch, iteration/context budgets, and session resumption.

5.22 - Red Hat Catalog

Web UI for browsing Hummingbird container images, security advisories, and API documentation.

Features

  • Image Catalog - Browse container images with tags, architectures, and specifications
  • Security Feed - Security advisory feed with CVE details
  • API Documentation - Interactive API documentation browser
  • Image Details - SBOM, provenance, and release history per image

Architecture

React 18 + PatternFly v6 SPA. Data fetching via TanStack Query against the container-catalog API (proxied through webpack-dev-server in development, direct in production). Webpack 5 build. Hosted on CloudFront + S3 (production) with MR previews on GitLab Pages.

Prerequisites

  • Node.js 22+ (for host targets)
  • make + podman (for container targets)

Usage

Only make and podman are required for container targets. Host variants (*-host) run without podman.

Target Description
redhat-catalog/setup Install dependencies (container)
redhat-catalog/check Type-check, lint, and test (container)
redhat-catalog/build Production build (container)
redhat-catalog/dev Dev server on port 9000 (container)
redhat-catalog/setup-host Install dependencies (host)
redhat-catalog/check-host Type-check, lint, and test (host)
redhat-catalog/build-host Production build (host)
redhat-catalog/dev-host Dev server (host)

Set ASSET_PATH to control the base path for non-root deployments (used by webpack output.publicPath and React Router basename).

Configuration

Build Variables

Variable Description
ASSET_PATH Webpack public path / React Router basename

API base URLs are configured in app-config.ts.

SAM Parameters

Parameter Description
ResourcePrefix Prefix for all resource names
CatalogDomainName Custom domain (optional, leave empty for CloudFront default)

Deployment

Production deployment uses a CloudFront + S3 stack defined in template.yaml. The infrastructure post-deploy script builds the SPA in a Node.js container, syncs it to S3, and creates a CloudFront cache invalidation. Custom domain support is optional via the CatalogDomainName SAM parameter. When set, an ACM certificate is created in-template with DNS validation – the validation CNAME must already exist in the external DNS zone before deployment.

MR previews are deployed to GitLab Pages with path_prefix per MR, auto-cleaned on merge/close with a 1-week expiry.

Environments and CI promotion

Experimental, staging, and production URLs, MR vs main pipelines, deploy triggers, and UAT sign-off are documented in Red Hat Catalog Environment Promotion (monorepo documentation/) and Red Hat Catalog UAT Program.

Development

Detailed contributor documentation lives in-tree:

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.23 - Hummingbird Agent Design

Architectural design document for the hummingbird-agent. Covers the reasoning behind every major design choice so that future changes can be made safely, without accidentally violating invariants that hold the system together.

For operational usage (CLI, config reference, deployment), see Hummingbird Agent. For agent turn flow and tool handling, see Agent Model Loop. Provider adapters and wire formats are documented in Hummingbird AI.

1. Design Philosophy

Five principles shaped the agent’s architecture. Every component traces back to at least one of these.

Security by default. The agent processes untrusted merge requests. All LLM-driven commands run inside isolated sandbox containers with no network and no credentials. Orchestrator write tokens and model read tokens live in separate code paths that never cross. No cluster-admin is required.

Config over code. Investigation logic lives in markdown workflow files that become the LLM system prompt verbatim – changing the analysis strategy requires no code change and no redeployment. Operational settings (data sources, project allowlists, iteration limits) live in a YAML config file that is auditable and committable. Secrets are the only thing in environment variables.

Bounded cost. Every output path has a size cap. Large tool outputs are auto-spilled to sandbox files with only a preview returned to the model. Each session has both an iteration limit and a per-call context token limit with two-tier escalation (soft warning, then hard stop with tool disabling). The relationship between these constants is documented and centralized.

Partial over nothing. When some data is unavailable (expired pod logs, unreachable Konflux cluster, Testing Farm 404), the agent continues with whatever data it has and notes the gap in its output. A partial report is more valuable than a crash.

Model-agnostic agent loop. The agent loop (agent.py) does not inspect the internal structure of the contents list. It appends raw_content from model responses and make_tool_responses() output without looking inside. Each model backend owns its wire format. This makes it possible to add new model backends (Claude, GPT) without touching the agent loop.

2. System Architecture

2.1. Component overview

flowchart TB
    subgraph input [Event Sources]
        SQS["SQS Queue<br/>(gitlab::pipeline, gitlab::note)"]
        CLI["CLI<br/>(--event / --event-file)"]
    end

    subgraph orchestrator [Orchestrator Process]
        Events["events.py<br/>SQS consumer"]
        Runner["runner.py<br/>Event routing"]
        Agent["agent.py<br/>Model loop"]
        Tools["tools.py<br/>Tool registry"]
        Actions["actions.py<br/>GitLab notes"]
        Sessions["sessions.py<br/>Persistence"]
        WfConfig["workflow_config.py<br/>YAML config"]
    end

    subgraph ai [Shared AI Library]
        Models["hummingbird-ai<br/>Gemini / Anthropic adapters"]
    end

    subgraph sandbox [Sandbox Container]
        SB["PodmanSandbox / K8sSandbox / KubeVirtSandbox<br/>jq, python3, yq"]
        SpillFiles["/tmp/data/_out/ spill files"]
        DataFiles["/tmp/data/ working files"]
    end

    subgraph dataSources [Data Source Modules]
        GL["gitlab.py"]
        KX["konflux.py"]
        TF["testing_farm.py"]
    end

    subgraph external [External APIs]
        GitLabAPI["GitLab API"]
        KonfluxAPI["K8s + Kubearchive"]
        TFAPI["Testing Farm API"]
    end

    subgraph storage [Storage]
        S3["S3 Sessions"]
        MRNote["GitLab MR Notes"]
    end

    SQS --> Events --> Runner
    CLI --> Runner
    Runner --> Agent
    Agent --> Tools
    Tools --> SB
    Tools --> dataSources
    dataSources --> external
    Agent --> Models
    Runner --> Actions --> MRNote
    Runner --> Sessions --> S3
    Runner --> WfConfig

2.2. Request lifecycle

A complete run proceeds through these stages:

  1. Event arrival. An SQS message (production) or CLI invocation (dev) provides a GitLab project path and MR IID.

  2. Config lookup. workflow_config.py maps the project to one or more workflows via the project_index. Each match yields a WorkflowConfig and ProjectEntry with data source declarations and per-project settings.

  3. Rate-limit check and SHA dedup. actions.scan_agent_threads() scans MR discussions in a single pass, parsing JSON session markers to determine the per-workflow thread count and whether the current SHA has already been reviewed. If the SHA was already reviewed, the workflow is skipped. If the thread count meets or exceeds max_runs_per_mr, the workflow is skipped. Slash commands and replies bypass this check.

  4. Placeholder note. actions.create_placeholder_note() posts a placeholder so the reviewer knows analysis is in progress. The note contains a JSON session marker (<!-- hummingbird-session: {"id":"UUID","wf":"name","sha":"abc"} -->) for rate limiting, SHA dedup, and session resumption.

  5. Sandbox start. sandbox.create_sandbox() starts a Podman container or K8s pod. /tmp/data/ is pre-created for the model’s use.

  6. Data source registration. data_sources.register_selected() resolves tokens and URLs from the config and registers tool functions on the ToolRegistry. Only data sources declared in the workflow config are registered.

  7. Agent loop. agent.run_agent_loop() runs the model loop: the workflow markdown becomes the system prompt, tool definitions are provided, and the model iterates calling tools and producing text until it emits a final response or hits a budget limit.

  8. Note update. The final text, wrapped with a session marker and reply prompt, replaces the placeholder note.

  9. Session save. Conversation history (contents), transcript, and sandbox archive are saved to S3 (production) or a local directory (dev).

  10. Sandbox cleanup. The container/pod is deleted. K8s pods also have a configurable activeDeadlineSeconds backstop (default 1800s) in case the orchestrator dies.

2.3. Architectural boundaries

The codebase is organized around four strict boundaries:

Runner (runner.py) is the orchestration layer. It owns event routing, the placeholder/update note lifecycle, session save/load, and sandbox lifecycle. It calls agent.run_agent_loop() but never reaches into the agent’s internals.

Agent (agent.py) is the model loop. It knows about the model interface, tool definitions, and the contents list, but nothing about GitLab, SQS, sessions, or actions. It returns (text, usage, transcript, contents) and is completely unaware of what happens with those values.

Tools (tools.py) bridge the agent and the sandbox/data-sources. The agent calls tool_registry.execute(tool_call) and gets a dict back. It never calls sandbox methods directly. This indirection is what enables auto-spill: the tool registry can transparently save large outputs to sandbox files and return previews.

Model providers are supplied by the shared hummingbird-ai library, which owns adapter construction, provider formats, retries, and AI usage accounting. The agent loop uses the library’s normalized adapter interface and treats conversation content as opaque.

3. Module Architecture

3.1. Dependency graph

flowchart TD
    main["__main__.py"]
    config_mod["config.py"]
    wf_config["workflow_config.py"]
    events_mod["events.py"]
    runner_mod["runner.py"]
    agent_mod["agent.py"]
    tools_mod["tools.py"]
    sandbox_mod["sandbox.py"]
    actions_mod["actions.py"]
    sessions_mod["sessions.py"]
    transcript_mod["transcript.py"]
    ds_init["data_sources/__init__.py"]
    ds_gitlab["data_sources/gitlab.py"]
    ds_konflux["data_sources/konflux.py"]
    ds_tf["data_sources/testing_farm.py"]
    config_watch_mod["config_watch.py"]
    ai_pkg["hummingbird-ai library"]

    main --> config_mod
    main --> config_watch_mod
    main --> wf_config
    main --> events_mod
    main --> runner_mod
    main --> sessions_mod
    main --> transcript_mod
    main --> actions_mod

    config_watch_mod --> config_mod
    config_watch_mod --> wf_config

    runner_mod --> actions_mod
    runner_mod --> agent_mod
    runner_mod --> config_mod
    runner_mod --> ds_init
    runner_mod --> sandbox_mod
    runner_mod --> sessions_mod
    runner_mod --> tools_mod
    runner_mod --> ai_pkg
    runner_mod --> wf_config
    runner_mod --> transcript_mod

    agent_mod --> config_mod
    agent_mod --> ai_pkg
    agent_mod --> tools_mod

    tools_mod --> config_mod
    tools_mod --> ai_pkg
    tools_mod --> sandbox_mod

    ds_init --> tools_mod
    ds_init --> wf_config
    ds_init --> ds_gitlab
    ds_init --> ds_konflux
    ds_init --> ds_tf

    ds_gitlab --> ai_pkg
    ds_konflux --> ai_pkg
    ds_tf --> ai_pkg

    sessions_mod --> sandbox_mod

3.2. Module responsibilities

Each module has a single, well-defined responsibility. The boundary rules below are invariants – violating them breaks the security model or the separation of concerns.

config.py – Runtime configuration and constants

  • Owns: Config dataclass and agent tuning constants (DEFAULT_MAX_ITERATIONS, CONTEXT_LIMIT, OUTPUT_PREVIEW_BYTES, etc.)
  • Boundary: Pure data. No I/O except reading env vars in Config.load(). No imports from other Hummingbird agent modules; model setup and pricing are owned by hummingbird-ai.
  • Invariant: All interdependent budget/spill constants must be defined here with their relationship documented in the comment block.

workflow_config.py – YAML config loader and token resolution

  • Owns: AgentConfig, WorkflowConfig (with trigger, description, trigger_rules, ignore_users, ignore_branches fields), TriggerRule, ProjectEntry, DataSourceConfig dataclasses; load(), validate_env(), evaluate_trigger_rules(), resolve_tool_token(), resolve_cluster_url(), get_prompt().
  • Boundary: Reads YAML from disk and env vars. Never instantiates network clients or model objects.
  • Invariant: resolve_tool_token() resolves model tool tokens only. It must never return orchestrator tokens. The resolution chain is: project_entry.tokens[ds] -> workflow_cfg.data_sources[ds].token_env -> "" (empty).

events.py – SQS consumer

  • Owns: decode_sns_message() (SNS envelope decoding), poll_loop() (blocking SQS consumer with concurrency control).
  • Boundary: Knows about SQS/SNS wire formats. Calls a generic EventHandler callback. Does not know about GitLab, workflows, or the agent.
  • Invariant: Failed messages are not deleted from SQS (they go to the DLQ after visibility timeout expires). Successful messages are deleted after handler returns.

config_watch.py – Background config hot-reload

  • Owns: ConfigHolder (thread-safe config pair with atomic swap), start_watcher() (daemon thread that polls config file mtime), _watch_loop().
  • Boundary: Knows about config.Config.load() and workflow_config.load(). Does not know about events, the agent, or any runtime state.
  • Invariant: On reload failure (parse error, missing env var), the previous config is kept and a warning is logged. The watcher never crashes the serve loop.

runner.py – Event routing and workflow execution

  • Owns: handle_event(), handle_pipeline(), handle_merge_request(), handle_note(), _execute_workflow(), _handle_reply(), run_workflow(), _acquire_sandbox(), _resolve_slash_workflow(), _format_help(), _is_ignored_user(), WorkflowRequest, WorkflowResult, SandboxOpts.
  • Boundary: Orchestrates everything: config lookup, rate limiting, placeholder notes, sandbox lifecycle, agent invocation, session save. This is the only module that touches both actions and agent.
  • Invariant: run_workflow() either lingers the sandbox (success) or cleans it up (exception).

agent.py – Model-agnostic tool-calling loop

  • Owns: run_agent_loop(), system prompt constants (BASE_SYSTEM_PROMPT, CONTINUATION_PROMPT, warning/nudge strings), budget logic.
  • Boundary: Uses the ModelAdapter interface from hummingbird-ai and tools (for execute()). Does not know about GitLab, SQS, sessions, or actions, or which backend is in use.
  • Invariant: The contents list is treated as opaque. Items are appended via response.raw_content and model.make_tool_responses(). The agent never inspects, modifies, or deletes items in the list (except for empty response retries, which pop() the last appended item before the model has seen any tool results for it).

tools.py – Tool registry

  • Owns: ToolRegistry class with sandbox_exec, fetch_to_sandbox, fetch_batch_to_sandbox built-in tools; data source registration and dispatch; auto-spill logic.
  • Boundary: Owns the sandbox reference and all tool execution. The agent never touches the sandbox directly.
  • Invariant: _spill_counter is shared across all spill paths (sandbox exec stdout, sandbox exec stderr, data source auto-spill) to prevent filename collisions in /tmp/data/_out/.

sandbox.py – Sandbox backends

  • Owns: Sandbox protocol, PodmanSandbox, K8sSandbox, K8sPoolSandbox, SandboxPool, KubeVirtSandbox, VmiPool, KubeVirtPoolSandbox, create_sandbox(), ExecResult.
  • Boundary: Knows about container/pod/VM lifecycle and command execution. Does not know about tools, models, or the agent.
  • Invariant: All backends must implement the same 8-method protocol (start, exec, write_file, stream_to_file, stream_exec, read_file_iter, cleanup, linger). exec() accepts optional stdin_data for piping raw bytes. All must pre-create /tmp/data/ in start(). All must use stdin piping for write_file() (never host volume mounts).

actions.py – GitLab note lifecycle

  • Owns: Orchestrator token resolution (resolve_orchestrator_token()), workflow action token client creation (_make_client()), bot user ID collection (collect_workflow_bot_ids()), note CRUD, JSON marker parsing (marker_tag, parse_marker), scan_agent_threads (per-workflow rate limiting + SHA dedup), post_simple_reply, member access checks, session-for-reply lookup, AgentThreadInfo, SessionRef.
  • Boundary: Functions are split by token tier:
    • Orchestrator token (operational reads + infrastructure notes, use _get_client): check_member_access, get_latest_push_author, get_head_pipeline_id, is_first_note_in_discussion, reply_access_denied, notify_rate_limit, post_simple_reply.
    • Workflow action token (per-workflow-attributable writes, take explicit token: str): create_placeholder_discussion, post_discussion_note, resolve_agent_threads, scan_agent_threads. Never touches model tool tokens, the tool registry, or the agent.
  • Invariant: Orchestrator tokens are resolved from ORCHESTRATOR_* env vars only. Workflow action tokens are resolved from action_tokens in YAML and passed as explicit parameters. Neither tier is exposed to the model.

sessions.py – Session persistence

  • Owns: archive_sandbox(), save_local(), save_s3(), load_s3(), load_local(), restore_sandbox(), SessionData.
  • Boundary: Knows about sandbox (for archiving) and S3/filesystem (for storage). Does not know about the agent, tools, or models. Load/save uses canonical envelopes with format_version: 1; unversioned legacy sessions are not supported.
  • Invariant: The sandbox archive is never extracted on the orchestrator. It is created inside the sandbox (tar czf), streamed out via read_file_iter(), and restored inside a new sandbox via stream_exec("tar xzf -").

transcript.py – Transcript rendering

  • Owns: render_markdown(), per-tool rendering functions, truncation.
  • Boundary: Pure transformation from TranscriptEntry list to markdown. No I/O, no side effects.

data_sources/__init__.py – Data source registration

  • Owns: register_selected() – resolves tokens/URLs from config and calls each data source’s register() function.
  • Boundary: Bridges workflow_config (for token/URL resolution) and tools (for registration). Only registers data sources declared in the workflow config.

data_sources/gitlab.py, konflux.py, testing_farm.py

  • Own: register() function that creates tool definitions and closures over credentials, and registers them on the ToolRegistry.
  • Boundary: Each module talks to one external API. Credentials are captured at registration time via closure, never stored globally.

4. Security Model

The agent runs in a shared OpenShift cluster without cluster-admin access, processing merge requests from repositories where external contributors can submit code. The security model addresses two threat vectors: (1) the LLM executing arbitrary commands chosen by an attacker-controlled MR, and (2) credential leakage between the model, the sandbox, and orchestrator actions.

4.1. Sandbox isolation

All commands generated by the LLM run inside an ephemeral container, never on the orchestrator host. The sandbox has no credentials, no network, and no visibility into the orchestrator.

flowchart LR
    subgraph orchestrator [Orchestrator]
        Agent["Agent Loop"]
        Creds["Secrets<br/>(tokens, kubeconfig)"]
    end
    subgraph sandbox [Sandbox Container]
        Shell["sh -c commands"]
        Files["/tmp/data/<br/>(incl. _out/ spill dir)"]
    end
    Agent -->|"stdin pipe<br/>(write_file)"| sandbox
    Agent -->|"exec command"| sandbox
    sandbox -->|"stdout/stderr"| Agent
    sandbox -.-x|"NO network"| Internet["Internet / K8s API"]
    sandbox -.-x|"NO access"| Creds

Podman (local development):

  • --network=none – complete network isolation; curl, wget, pip install all fail
  • --user 65532 – fixed non-root UID; no privilege escalation
  • No host volume mounts – data enters only via stdin piping through write_file()

Kubernetes (production):

Every field in the pod manifest is set explicitly for portability to vanilla Kubernetes with Pod Security Admission (restricted level), not just reliance on OpenShift’s restricted-v2 SCC admission:

  • automountServiceAccountToken: false – no K8s API access from sandbox
  • runAsNonRoot: true – enforced at pod level
  • seccompProfile: RuntimeDefault – required by restricted level
  • allowPrivilegeEscalation: false – container level
  • capabilities.drop: ["ALL"] – container level
  • activeDeadlineSeconds: 1800 – pod self-terminates after 30 minutes even if the orchestrator crashes; prevents orphaned pods
  • restartPolicy: Never – pod does not restart on failure
  • NetworkPolicy on the sandbox namespace blocks all egress from all pods in the namespace (podSelector: {}, egress: [])

The pod does not set runAsUser explicitly. On OpenShift, the SCC assigns a UID from the namespace range. On vanilla K8s, the image’s USER directive is used.

4.2. Credential separation (four-tier token model)

Tokens are split into four tiers with strict code-path separation:

flowchart TB
    subgraph tier1 [Tier 1: Orchestrator Tokens]
        OT["ORCHESTRATOR_GITLAB_TOKEN_*<br/>Operational reads<br/>Env vars only, never in YAML"]
    end
    subgraph tier2 [Tier 2: Workflow Action Tokens]
        AT["action_tokens in YAML<br/>Per-workflow bot identity<br/>Write scope (api)"]
    end
    subgraph tier3 [Tier 3: Model Tool Tokens]
        MT["GITLAB_TOKEN_RO, etc.<br/>Read scope (read_api)<br/>Env var NAMES in YAML"]
    end
    subgraph tier4 [Tier 4: Sandbox]
        SB["Zero credentials<br/>Zero network<br/>Zero SA token"]
    end

    OT -->|"used by"| OpsReads["actions.py<br/>(member access, push author,<br/>rate-limit notices)"]
    AT -->|"used by"| WfWrites["actions.py<br/>(notes, thread resolution)"]
    MT -->|"used by"| DataSources["data_sources/<br/>(GitLab, Konflux, TF)"]
    SB -->|"used by"| SandboxExec["sandbox_exec<br/>(sh -c commands)"]

    OpsReads -.-x|"NEVER"| DataSources
    WfWrites -.-x|"NEVER"| DataSources
    WfWrites -.-x|"NEVER"| SandboxExec
    DataSources -.-x|"NEVER"| OpsReads
    DataSources -.-x|"NEVER"| WfWrites

Tier 1 – Orchestrator tokens. Tokens used by actions.py for operational reads (member access checks, push author lookup, head pipeline queries) and infrastructure notes (access-denied replies, rate-limit notices). Resolved by convention from env vars: ORCHESTRATOR_GITLAB_TOKEN_<MANGLED_PROJECT> (per-project) or ORCHESTRATOR_GITLAB_TOKEN (global fallback). The ORCHESTRATOR_ prefix is a structural safeguard – these env var names can never appear in the YAML config’s data_sources, tokens, or action_tokens sections. These tokens can be scoped to read_api since all per-workflow writes moved to Tier 2.

Tier 2 – Workflow action tokens. Per-workflow bot identity tokens (api scope) used by actions.py for all workflow-attributable GitLab writes: placeholder notes, discussion notes, and thread resolution. Each workflow gets a dedicated bot user per project, providing clear audit trails for which agent produced each note. Declared in the YAML config as env var names at the project level:

projects:
  redhat/hummingbird/containers:
    action_tokens:
      gitlab: HUMMINGBIRD_AGENT_ACTION_CODE_REVIEW_GITLAB_TOKEN_CONTAINERS

Resolved by resolve_action_token(project_entry, ds_name) which reads the env var name from action_tokens and resolves it from os.environ. These tokens are passed as explicit token: str parameters to write functions in actions.py. They NEVER enter the ToolRegistry or model tool calls.

At startup, collect_workflow_bot_ids() authenticates each unique workflow token to build a set of all workflow bot user IDs. This set is used by handle_note for self-filtering (skipping notes from any workflow bot) and by find_session_for_reply for session marker scanning.

The naming convention groups tokens alphabetically: HUMMINGBIRD_AGENT_ACTION_<WORKFLOW>_GITLAB_TOKEN_<PROJECT>.

Tier 3 – Model tool tokens. Read-only tokens (read_api scope for GitLab) used by data source modules during tool execution. Declared in the YAML config as env var names (not values):

data_sources:
  gitlab:
    token_env: GITLAB_TOKEN_RO # name of the env var

Per-project overrides are possible:

projects:
  redhat/hummingbird/containers:
    tokens:
      gitlab: GITLAB_TOKEN_CONTAINERS_RO # overrides token_env for this project

The resolution chain in resolve_tool_token() is: project_entry.tokens[ds] -> workflow_cfg.data_sources[ds].token_env -> ""

Storing env var names (not values) in YAML means the config file is safe to commit and audit. Actual secret values live in environment variables, injected via K8s Secrets at deployment time.

Tier 4 – Sandbox. The sandbox container has zero credentials, zero network access, and automountServiceAccountToken: false (no K8s API access). Data enters the sandbox only via write_file() (stdin piping). The model cannot instruct the sandbox to reach external APIs – it must use the orchestrator’s data source tools.

4.3. Namespace separation

Sandbox pods run in a dedicated namespace (hummingbird--agent-sandbox), separate from the orchestrator namespace (hummingbird--internal). This limits blast radius: even if a sandbox pod is compromised, it has no visibility into the orchestrator’s Secrets, Pods, or ServiceAccount tokens.

RBAC setup:

The orchestrator’s ServiceAccount gets a Role in the sandbox namespace (not its own namespace) granting only:

  • pods: create, get, list, delete, patch – sandbox pod lifecycle and pool claims
  • pods/exec: create – command execution via kubectl exec

No CRDs, no custom runtimes, no cluster-scoped resources. The orchestrator needs only namespace-scoped permissions, so it works with standard OpenShift RBAC without requesting cluster-admin.

Konflux data is fetched via bearer token from kubeconfig credentials, not from inside the cluster. The orchestrator’s ServiceAccount does not need access to Konflux namespaces.

NetworkPolicy:

A deny-all-egress NetworkPolicy in the sandbox namespace uses podSelector: {} to match all pods and sets egress: []. The sandbox cannot reach the internet, the K8s API, or other pods in the cluster.

4.4. Security invariants

These must hold for the security model to be effective. Any change that violates one of these is a security regression:

  1. Orchestrator tokens must NEVER flow into ToolRegistry, data_sources, or model contents. They are resolved in actions.py only.

  2. Workflow action tokens must NEVER flow into ToolRegistry, data_sources, or model contents. They are passed as explicit token: str parameters within actions.py and runner.py only.

  3. Model tool tokens must NEVER flow into actions.py. They are resolved in workflow_config.py and consumed in data_sources/.

  4. The sandbox must NEVER have network access or credentials. No host mounts, no SA token, no env vars with secrets.

  5. The sandbox archive is NEVER extracted on the orchestrator. It is created inside one sandbox and restored inside another. The orchestrator only transports the bytes.

  6. No cluster-admin required. Only namespace-scoped resources (Role, RoleBinding, Pod, NetworkPolicy) are used.

  7. Agent-generated notes are skipped on re-processing. Notes containing SESSION_MARKER_PREFIX are filtered out in handle_note() to prevent infinite loops. Self-filtering checks against all workflow bot user IDs (collected at startup) plus the orchestrator bot ID.

  8. All auto-triggers require Developer access. handle_pipeline(), handle_merge_request(), and handle_note() each check check_member_access() on the event’s user before executing a workflow. Events from non-developers are silently skipped (or replied to with an access-denied message for slash commands). This ensures that the target MR’s work products – its diff, description, CI logs, and commit messages – originate from a trusted author. Fork MRs from external contributors are blocked because the MR author lacks Developer+ on the target project.

    Scope limitation: this gate only covers the target MR. Once the agent is running, the model controls tool arguments and can direct tools at content beyond the target MR – other MRs, other refs, other job IDs, even other projects reachable by the read-only token. The current mitigations are: (a) workflow prompts instruct the model to use the event’s {project, iid, sha}, (b) the model would need to be manipulated via prompt injection from already-trusted content to deviate, (c) the sandbox prevents the model from acting on manipulated reasoning beyond producing text output, and (d) the token is read-only with minimal scope. However, if a data source tool is added that returns user-authored prose from arbitrary resources (e.g. issue bodies, wiki pages), it should apply per-author trust filtering (invariant #10).

  9. Data source tools that accept URLs must validate them against an allowlist. tf_get_test_log restricts URLs to the Testing Farm artifacts prefix to prevent the LLM from directing the orchestrator to fetch arbitrary URLs (SSRF).

  10. Third-party commentary entering the model prompt must be trust-filtered. Data sources that feed text from users other than the event trigger into contents (discussion comments, issue bodies) must gate on project membership at Developer+ level per author. Content from untrusted authors must be redacted to a fixed placeholder, never sanitized or escaped – there is no reliable way to escape adversarial text for an LLM prompt. Discussions where all notes are untrusted must be dropped entirely. See §9.1 for the reference implementation in gitlab_get_mr_discussions.

This invariant covers commentary (what other people said about the MR), not work products (the diff, CI logs, commit messages). Work products are inherently the input to the agent’s analysis and cannot be content-filtered without defeating the agent’s purpose. For the target MR, their trust comes from invariant #8 (the event trigger is Developer+). For content the model fetches beyond the target MR, trust depends on the tool: discussion tools must filter per-author (#10), while code/log/metadata tools rely on the mitigations described in #7.

5. Configuration System

5.1. Two sources, strict separation

Configuration comes from exactly two sources with no overlap:

  • YAML config file (CONFIG_PATH): operational settings, workflow definitions, project allowlists, data source declarations, and token env var names. This file is safe to commit, review, and audit.
  • Environment variables: secrets only (API keys, GitLab tokens, kubeconfig paths). These are injected at deployment time via K8s Secrets.

This split exists because YAML provides structure, validation, and audit trails, while secrets must stay out of version control.

5.2. Config loading

Two config objects are built at startup:

Config (from config.py): loaded by Config.load(settings), where settings is the settings: section from the YAML file. Auth/bootstrap fields come from env vars (GOOGLE_API_KEY, GOOGLE_CLOUD_PROJECT, etc.). Operational fields come from the settings dict with sensible defaults.

AgentConfig (from workflow_config.py): loaded by workflow_config.load(path). Contains all workflow definitions, project entries, and a pre-built project_index.

The two are kept separate because Config is needed everywhere (model construction, sandbox creation, session storage), while AgentConfig is only needed for event routing and data source registration.

5.3. YAML config structure

settings:
  gitlab_url: https://gitlab.com # operational, not a secret
  sandbox:
    image: quay.io/.../image:tag
    namespace: my-namespace # K8s only
  model: gemini-3.1-pro-preview
  # model: claude-sonnet-4-5@20250929   # Anthropic via Vertex AI (alternative)
  max_iterations: 30
  max_runs_per_mr: 5
  internal_notes: true
  max_concurrent_agents: 4
  sqs_queue_url: "" # empty = no SQS (local dev)
  s3_session_bucket: "" # empty = no S3 (local dev)
  trigger_prefix: /hummingbird # slash command prefix
  session_marker_prefix: hummingbird-session # HTML comment marker ID
  auto_trigger: true # auto-run on failed pipelines

workflows:
  analyze-failures:
    prompt: workflows/analyze-failures.md # relative to config file dir
    action: post_gitlab_note
    model: gemini-3.1-pro-preview # per-workflow override
    max_iterations: 50 # per-workflow override

    data_sources:
      gitlab:
        token_env: GITLAB_TOKEN_RO # env var name, not the value
      konflux:
        cluster_url: https://example.com:6443/ns/my-tenant
        kubeconfig_env: KUBECONFIG
        kubearchive_url: https://kubearchive-api-server-product-kubearchive.apps.example.com
      testing_farm: {}

    projects:
      redhat/hummingbird/containers:
        tokens: # per-project token overrides
          gitlab: GITLAB_TOKEN_CONTAINERS_RO

Design choices in this structure:

Workflow-first organization. Each workflow owns its project list, not the other way around. This scopes data source permissions per workflow-project pair. A future code-review workflow can have different GitLab tokens (with different scopes) than the analyze-failures workflow, with no ambiguity.

Token env var names in YAML (not values). The YAML file is committed to the repo. It contains token_env: GITLAB_TOKEN_RO (a name), not the actual token. The actual secret value is resolved at runtime via os.environ.get(env_var). This allows the config to be reviewed and audited without exposing secrets.

Inline cluster_url only. The Konflux cluster URL is operational configuration (it identifies which cluster to talk to), not a secret. Putting it inline in YAML makes it visible and auditable. The URL must include the namespace path (e.g. /ns/my-tenant).

5.4. Project index

At load time, _build_project_index() constructs a reverse lookup:

project_index: dict[str, list[tuple[str, WorkflowConfig, ProjectEntry]]]
# e.g. {"redhat/hummingbird/containers": [("analyze-failures", wf_cfg, proj_entry)]}

This provides O(1) lookup when a pipeline event arrives with a project path. A single project can appear in multiple workflows (e.g. both analyze-failures and a future code-review), and each matching workflow will be triggered independently.

5.5. Token resolution chains

Model tool tokens (for data source API calls):

resolve_tool_token(project_entry, ds_name, workflow_cfg):
  1. project_entry.tokens[ds_name]           -> per-project override
  2. workflow_cfg.data_sources[ds_name].token_env  -> workflow default
  3. "" (empty string)                        -> no token
  Each step resolves the env var NAME, then reads os.environ[name].

Workflow action tokens (for per-workflow GitLab writes):

resolve_action_token(project_entry, ds_name):
  1. project_entry.action_tokens[ds_name]  -> env var NAME
  2. os.environ[name]                      -> resolved token value
  3. "" (empty)                            -> not configured
  No fallback chain. Missing tokens cause validate_env to fail at startup.

Orchestrator tokens (for operational reads and infrastructure notes):

resolve_orchestrator_token(project_path):
  1. ORCHESTRATOR_GITLAB_TOKEN_<MANGLED_PROJECT>  -> per-project
  2. ORCHESTRATOR_GITLAB_TOKEN                     -> global fallback
  Mangling: "/" -> "_", "-" -> "_", uppercase.
  e.g. "redhat/hummingbird/containers" -> ORCHESTRATOR_GITLAB_TOKEN_REDHAT_HUMMINGBIRD_CONTAINERS

Cluster URL (for Konflux):

resolve_cluster_url(ds_cfg):
  -> ds_cfg.cluster_url (inline value in YAML)

5.6. Environment validation

validate_env(agent_cfg) checks at startup that all referenced env vars exist. It walks every workflow’s data sources, project token overrides, and project action token entries, collecting missing vars into a single error message. This catches configuration errors early instead of failing mid-run when a specific data source or workflow action is first used. Missing action_tokens env vars are treated the same as missing model tool tokens: startup fails unconditionally.

See Also

5.24 - Hummingbird Agent Design — Engine

Agent loop, tool system, and sandbox backends for the Hummingbird Agent. This page covers the execution engine: how the agent iterates, dispatches tools, and isolates execution. For foundations (design philosophy, system architecture, modules, security, config), see Hummingbird Agent Design.

6. Agent Loop Design

For the wire-format walkthrough (what bytes go to the model API, what comes back), see Agent Model Loop. This section covers the design rationale behind the loop.

6.1. Full history replay

The Gemini and Anthropic APIs are stateless. Every call sends the complete contents list from the beginning of the conversation. This means every large tool output sitting in history inflates every subsequent API call.

This property is fundamental to why auto-spill exists (section 7). Without auto-spill, a single cat of a 32KB file early in the conversation adds ~8K tokens to every remaining API call. Over a 15-iteration run, that is ~120K wasted tokens.

The alternative – conversation compaction (replacing old tool results with summaries) – was considered and deferred. Each provider has strict requirements about content structure, and modifying history risks confusing the model or violating API constraints. Provider-specific format requirements are documented in Hummingbird AI. Auto-spill solves 90% of the problem with none of the risk.

6.2. Budget model: iterations + context limit

The agent uses a dual-limit approach rather than a cumulative token budget:

Iteration limit (max_iterations, default 30 per workflow config). Hard cap on the number of model round-trips. This is the primary cost control lever. With auto-spill keeping per-call context bounded, iteration count is roughly proportional to cost.

Per-call context limit (CONTEXT_LIMIT, default 60,000 tokens). Checked after each API call using response.usage.total_input_tokens. This is a safety net for cases where auto-spill is not sufficient (e.g., many small tool results that individually stay under the spill threshold but cumulatively fill the context).

Why not a cumulative token budget? Because with full history replay, each API call re-sends everything. “Cumulative billed tokens” double-counts: call 1 sends 5K, call 2 sends 10K (including the 5K again), so billed total is 15K but actual new content is only 10K. Iteration count is a simpler and more predictable proxy for cost.

6.3. Two-tier budget escalation

Both limits use the same escalation pattern:

flowchart LR
    Normal["Normal<br/>tools available"]
    Soft["Soft Warning (80%)<br/>ITERATION_WARNING or<br/>CONTEXT_WARNING<br/>tools still available"]
    Hard["Hard Stop (100%)<br/>FINAL_TURN_WARNING<br/>toolConfig: NONE"]

    Normal -->|"80% reached"| Soft
    Soft -->|"100% reached"| Hard

Soft warning (80%). An ephemeral user message (ITERATION_WARNING) is injected into contents for that turn only, then popped before the response is persisted. Tools remain available so the model can finish in-progress work.

Hard stop (100%). FINAL_TURN_WARNING is injected as an ephemeral user message AND tool_defs is set to [] (empty list) to physically prevent further tool calls. The model must produce text. This is more reliable than relying on provider-side tool disabling, which models may ignore.

6.3.1 Ephemeral messages and provider normalization

All per-turn warnings use ephemeral user messages: they are appended to contents before the API call and popped immediately after. This keeps the system prompt stable across all iterations and prevents warnings from polluting the conversation history saved in sessions.

Provider adapters normalize provider-specific conversation requirements while preserving the agent’s saved history; details are in Hummingbird AI.

6.4. Empty response handling

Models occasionally return unusable responses (no text and no valid tool calls). The agent retries up to MAX_EMPTY_RETRIES (2) times:

  1. Pop the empty response from contents. It adds nothing and may confuse the model on the next call.
  2. Inject EMPTY_RESPONSE_NUDGE as an ephemeral user message on the next turn: “Your previous response was empty. Please continue…”
  3. Continue the loop (consuming an iteration).

The nudge is delivered as an ephemeral user message (injected before the API call and popped after). This avoids mutating the system prompt and keeps the conversation history clean for session persistence.

The empty_retries counter resets to 0 after any successful iteration (one where tool calls were executed). This means the model gets fresh retries if it produces empty responses at different points in the conversation.

MALFORMED_FUNCTION_CALL handling. Models sometimes return a finishReason of MALFORMED_FUNCTION_CALL (Gemini) with no usable tool calls. This is treated as a special case of empty response: the retry mechanism kicks in, but the nudge is replaced with MALFORMED_CALL_NUDGE which tells the model to retry with simpler arguments and avoid large text payloads in tool call arguments.

6.5. Error recovery

Model API and transport errors. The hummingbird-ai library classifies provider and transport failures, applies the workflow’s retry budget, and records AI usage and request metrics. See Hummingbird AI for provider-specific behavior. The agent loop retains partial results when a terminal error reaches it.

Unexpected loop errors. run_agent_loop wraps the _generate_with_retry call in a try/except Exception that logs and breaks instead of propagating. This ensures the function always returns partial results (accumulated contents, transcript, sandbox state) even when an unexpected exception occurs (e.g. JSONDecodeError from a truncated API response). The caller saves the session normally – conversation history and sandbox archive are preserved for resumption. This follows the “partial over nothing” principle: 32 iterations of work are more valuable than a crash.

No failure-path session save. _execute_workflow does not save a session when run_workflow raises. With the agent loop catching unexpected errors, the failure path only fires for infrastructure errors (sandbox start, config) where there is no useful state. Not saving avoids overwriting a previous good session when a reply attempt fails.

Tool execution errors. _execute_tool_calls() wraps each tool_registry.execute() in a try/except. Unhandled exceptions are caught and returned to the model as {"error": "Tool X failed: ..."}. This prevents a single broken tool from crashing the entire session – the model sees the error and can adapt.

Sandbox exec timeout. If a command exceeds EXEC_TIMEOUT (120s), the TimeoutExpired exception is caught in the tool registry and returned as a structured error dict. The model can retry with a different command or proceed without the result.

6.6. Session resumption

When resuming from a previous session:

  1. initial_contents (the full contents list from the previous run) is prepended, followed by the user’s reply as a new user turn.
  2. CONTINUATION_PROMPT is appended to the system prompt.
  3. The sandbox is restored from the archived tarball.
  4. A fresh iteration budget starts from 0.

Saved sessions use a versioned JSON envelope (format_version: 1) whose contents are in a canonical message format independent of the active model. Unversioned legacy sessions are not supported. On resume, run_workflow asks the active adapter to load canonical history; after a successful run, it converts native contents back to canonical form before persistence. The library documents the canonical format and provider conversions in Hummingbird AI.

CONTINUATION_PROMPT is critical. Without it, the workflow prompt (e.g., analyze-failures.md) tells the model to follow a rigid multi-phase workflow: Data.1, Data.2, Analysis.1… The model would try to re-run the entire analysis. The continuation prompt overrides this: “Do NOT re-run the full workflow. Respond directly to the user’s question.”

The practical constraint on resumed sessions is the context window, not iterations. The restored history from an 8-iteration cold start uses ~50-100K input tokens. Each new tool call adds more. The existing CONTEXT_LIMIT check still applies and will force wrap-up if the context grows too large.

Canonical session format (rationale)

Storing sessions in canonical form decouples persisted history from any one provider’s JSON shape. The agent loop treats native contents as opaque; the library owns provider conversion at load and save boundaries.

6.7. Provider caching and pricing

Provider-specific prompt caching and token-cost estimation are owned by hummingbird-ai.

7. Tool System and Auto-Spill

7.1. Tool registry design

ToolRegistry is the single point of dispatch for all tool calls. The agent calls registry.execute(tool_call) and gets a dict back. It never calls sandbox methods or data source functions directly.

Three built-in tools are always available:

  • sandbox_exec – runs sh -c <command> in the sandbox container. Returns {exit_code, stdout, stderr}, with auto-spill for large outputs.
  • fetch_to_sandbox – calls a data source function and writes the result to a specified path in the sandbox. Returns metadata only (path, byte count, line count). Used when the model wants explicit path control.
  • fetch_batch_to_sandbox – calls multiple fetch_to_sandbox in one tool call. Saves iterations vs. sequential calls (e.g., fetching both pipelineruns and taskruns in one round-trip).

Data source tools are registered dynamically per workflow config. Each data source module provides a register() function that creates ToolDef objects and closures over credentials, then calls registry.register_data_source(tool_def, func, response_metadata).

7.2. Auto-spill architecture

Auto-spill is the key mechanism for keeping the LLM context bounded. Without it, large outputs accumulate in the contents list and inflate every subsequent API call (because both APIs replay the full history).

flowchart TD
    Output["Tool produces output"]
    SizeCheck{"size > OUTPUT_PREVIEW_BYTES<br/>(4 KB)?"}
    Inline["Return full output inline"]
    Spill["Write full output to<br/>/tmp/data/_out/N.txt"]
    Preview["Return preview<br/>(head + tail) +<br/>file path metadata"]

    Output --> SizeCheck
    SizeCheck -->|"<= 4 KB"| Inline
    SizeCheck -->|"> 4 KB"| Spill --> Preview

Three spill paths share the same _spill_counter to prevent filename collisions:

sandbox_exec spill. When stdout or stderr exceeds OUTPUT_PREVIEW_BYTES (4096 bytes), the full output is written to /tmp/data/_out/{counter}.txt via _spill_field(). The model receives:

{
  "exit_code": 0,
  "stdout": "<first 4KB>",
  "stdout_truncated": true,
  "stdout_file": "/tmp/data/_out/0.txt",
  "stdout_bytes": 32768,
  "stdout_lines": 1024,
  "stdout_tail": "<last 512 bytes>"
}

The preview (head + tail) gives the model enough context to decide whether to process the full file with jq/grep/head.

Data source inline spill. When a direct data source call returns text larger than MAX_INLINE_SIZE (4096 bytes), _spill_data_source() writes it to /tmp/data/_out/{name}_{counter}.txt and returns:

{
  "saved_to": "/tmp/data/_out/konflux_list_pipelineruns_1.txt",
  "bytes": 85432,
  "lines": 2100,
  "preview": "<first 4KB>"
}

Streaming responses (e.g., gitlab_get_repo_archive which returns a StreamingResponse with suffix .tar.gz) are written via _spill_streaming(). Text streams (binary=False, the default) capture a UTF-8 preview from the head while piping chunks to the sandbox. Binary streams (binary=True) skip preview capture entirely and return only {saved_to, bytes}, avoiding meaningless decoded output for formats like tar.gz. The StreamingResponse.suffix field controls the file extension (.jsonl, .tar.gz, .log).

Non-streaming binary responses are written to .bin files with no preview via _spill_binary().

Sandbox-optional mode. ToolRegistry accepts sb=None for use cases that do not require a sandbox (e.g. evaluation harnesses that only use native model tools like Google Search grounding). When no sandbox is available:

  • Sandbox tools (sandbox_exec, fetch_to_sandbox, fetch_batch_to_sandbox) are omitted from get_tool_defs() so the model never sees them.
  • Text data sources (inline and streaming) degrade to truncated previews (OUTPUT_PREVIEW_BYTES). The model receives {preview, truncated: true} instead of {saved_to, preview}. Streaming text is consumed into a memory buffer up to the preview limit and the rest is discarded.
  • Binary data (streaming and non-streaming) returns an error with the byte count ({error, bytes}). Binary content cannot be meaningfully previewed as text.

fetch_to_sandbox spill. Always writes to the caller-specified path (not /tmp/data/_out/). Returns metadata only (no preview). The model uses this when it wants a specific filename for later processing.

7.3. Why auto-spill instead of rejecting large outputs

The earlier design rejected large data source responses with “use fetch_to_sandbox instead.” This caused two wasted iterations per rejection: the model makes the call, gets rejected, then has to repeat with fetch_to_sandbox. With auto-spill, the data flows to a sandbox file transparently. Validation showed ~10% fewer iterations with auto-spill.

7.4. Why fetch_to_sandbox still exists alongside auto-spill

Auto-spill handles the common case, but fetch_to_sandbox provides:

  • Explicit path control. The model can choose meaningful filenames (/tmp/data/pipelineruns.json) instead of getting auto-generated names (/tmp/data/_out/konflux_list_pipelineruns_1.txt).
  • Batch fetching. fetch_batch_to_sandbox combines multiple fetches in one tool call, saving iterations.
  • No preview overhead. fetch_to_sandbox returns metadata only, which is useful when the model knows it will process the file with sandbox_exec anyway.

7.5. ToolDef notes

ToolDef has an optional notes field for domain knowledge that belongs in the system prompt but not in the tool’s JSON schema. Examples:

  • Konflux notes explain dual K8s/Kubearchive fetch, UID deduplication, and the two label selectors (BUILD vs. TEST).
  • Testing Farm notes explain XML result structure and usage patterns.

ToolRegistry.get_tool_notes() collects all non-None notes into a ## Data Source Notes section that is prepended to the workflow body in the system prompt:

BASE_SYSTEM_PROMPT + tool_notes + workflow_body

This keeps domain knowledge close to the tool definitions (in the data source module) rather than duplicated in every workflow .md file.

7.6. Response metadata

register_data_source() accepts optional response_metadata – a dict that is merged into every response from that tool (inline, auto-spill, and fetch_to_sandbox). Used for:

  • Konflux: {"konflux_ui": "https://..."} so the model can build reviewer-facing PipelineRun links.
  • Testing Farm: {"artifacts_base": "https://..."} so the model can build artifact links.

This avoids having the model ask “what is the Konflux UI URL?” – the information arrives with every tool response.

8. Sandbox Architecture

8.1. The Sandbox protocol

All backends implement an 8-method typing.Protocol:

class Sandbox(Protocol):
    def start(self) -> None                                                  # create container/pod, pre-create /tmp/data/
    def exec(command, *, stdin_data: bytes | None) -> ExecResult             # sh -c in sandbox
    def write_file(path, data: bytes) -> None                               # stdin pipe: cat > path
    def stream_to_file(path, chunks: Iterable[bytes]) -> tuple[int, int]    # Popen stdin pipe, returns (bytes, lines)
    def stream_exec(command, chunks: Iterable[bytes]) -> ExecResult         # Popen with streaming stdin
    def read_file_iter(path) -> Iterator[bytes]                             # Popen stdout pipe in chunks
    def cleanup(self) -> None                                               # rm -f container / delete pod
    def linger(session_id) -> None                                          # keep alive for reuse, or fall back to cleanup

ExecResult is (exit_code: int, stdout: str, stderr: str).

stream_to_file() and stream_exec() use subprocess.Popen with a stdin pipe, writing chunks incrementally. This avoids buffering large payloads in orchestrator memory (e.g., streaming a tar.gz archive into the sandbox). read_file_iter() reads from a Popen stdout pipe in 64 KB chunks.

write_file() uses stdin piping (cat > path), never host volume mounts. This is critical: it means data flows through the orchestrator process, not through a shared filesystem. The sandbox has no host mounts.

8.2. Backend selection

Five sandbox backends are available:

Backend --sandbox When to use
PodmanSandbox podman Local development (default for run)
K8sSandbox k8s Direct pod creation on a K8s cluster
K8sPoolSandbox k8spool Pre-warmed pool via Deployment (default for serve)
KubeVirtSandbox kubevirt Direct VMI creation with SSH exec
KubeVirtPoolSandbox kubevirtpool Pre-warmed VMI pool via VMIRS

create_sandbox() factory handles podman, k8s, and kubevirt. Pool backends (k8spool, kubevirtpool) are handled in __main__.py, which creates a SandboxPool or VmiPool and passes it to run_workflow().

The sandbox backend can be set per-workflow via the sandbox: field in the workflow config YAML. Resolution order: workflow.sandbox > CLI --sandbox

default. This allows different workflows to use different backends (e.g., lightweight code review uses pod pool, heavier analysis uses VM pool).

K8s is never auto-detected from the environment. It requires explicit CLI flags. This prevents accidental use of a K8s sandbox when developing locally.

For K8s namespace resolution (k8s and k8spool):

  1. If --namespace is provided, use it.
  2. If --context is provided, extract the namespace from the kubeconfig context. If the context has no default namespace, raise an error.
  3. If neither is provided (in-cluster), use sandbox.namespace from the config file.

8.3. PodmanSandbox

podman run -d --name hb-sandbox-{uuid8} --network=none --user 65532 \
    --workdir /tmp {image} sleep infinity
  • Unique container name with UUID suffix prevents collisions
  • sleep infinity keeps the container alive for repeated exec calls
  • --network=none provides complete network isolation
  • --user 65532 is a fixed non-root UID (matches nonroot in distroless)
  • Cleanup: podman rm -f (force, in case exec is still running)

8.4. K8sSandbox: the hybrid approach

The K8s sandbox uses a hybrid of two tools:

  • kubernetes Python library for pod lifecycle: create_namespaced_pod, read_namespaced_pod (poll for Running), delete_namespaced_pod.
  • kubectl exec subprocess for command execution.

Why not use the kubernetes Python library for exec too? Three problems discovered during development:

  1. No stdin EOF signal in WebSocket v1-v4. The Kubernetes exec protocol uses WebSocket channels (stdin=0, stdout=1, stderr=2). Protocol versions 1-4 have no mechanism to signal “stdin is done.” Commands like cat > /file hang forever waiting for more input. Python client v5 support does not exist.

  2. BrokenPipeError on large stdin. Sending more than ~1MB through the WebSocket stream() API causes pipe errors, breaking write_file() for large data source responses.

  3. Unbounded memory from stream(). The stream() function accumulates all stdout/stderr data in memory with no streaming control. A command producing megabytes of output would consume unbounded memory.

kubectl exec as a subprocess avoids all three problems and provides the same interface as podman exec – stdin via subprocess.PIPE, stdout/stderr captured, exit code from return code. The implementation in exec() is nearly identical between PodmanSandbox and K8sSandbox.

8.5. Pod manifest design

The K8s pod manifest is built by _build_pod_manifest():

metadata:
  labels:
    app.kubernetes.io/name: hummingbird-agent-sandbox
spec:
  automountServiceAccountToken: false # no K8s API from sandbox
  activeDeadlineSeconds: 1800 # 30min hard timeout, backstop
  restartPolicy: Never
  securityContext:
    runAsNonRoot: true
    seccompProfile: RuntimeDefault
  containers:
    - name: sandbox
      command: ["sleep", "infinity"]
      workingDir: /tmp
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      resources:
        requests: { cpu: 100m, memory: 256Mi, ephemeral-storage: 256Mi }
        limits: { cpu: "1", memory: 1Gi, ephemeral-storage: 2Gi }

runAsUser is deliberately omitted. On OpenShift, the restricted-v2 SCC assigns a UID from the namespace UID range. On vanilla K8s, the image’s USER directive is used. Setting an explicit UID would conflict with OpenShift’s SCC admission.

8.6. Auth modes

  • Local development: --context flag passes the kubeconfig context to config.new_client_from_config(context=...), creating a per-instance ApiClient.
  • Production (in-cluster): context=None triggers config.load_incluster_config(), using the pod’s ServiceAccount token.

The kubectl exec commands include --context when running locally but omit it when in-cluster (kubectl uses the default in-cluster config).

8.7. Pre-created directories

Both backends run mkdir -p /tmp/data in start() after the container/pod is up. This provides a working directory for the model without consuming an iteration. /tmp/data/_out (the spill directory) is created on demand by mkdir -p $(dirname ...) in write_file().

8.8. K8sPoolSandbox: Deployment-backed pool

On-demand pod creation adds 5-30 seconds of latency per workflow (image pull, scheduling, container start). For interactive use (slash commands, reply-based resumption), this delay is user-facing. The pool eliminates it.

Mechanism. A Kubernetes Deployment maintains a set of pods labelled hummingbird/role: standby. When a sandbox is needed, SandboxPool.claim() finds a Running standby pod, patches it to role: active, clears its ownerReferences (detaching it from the ReplicaSet), and annotates it with hummingbird/reap-by (an absolute UTC deadline). The Deployment controller sees the ReplicaSet is under the desired replica count and creates a replacement.

Pod lifecycle.

  1. Deployment creates pod -> role: standby (managed by ReplicaSet)
  2. claim() patches -> role: active, ownerReferences: [], reap-by: now + max_active_seconds
  3. Agent uses the pod via inherited K8sSandbox.exec()
  4. On success: linger() patches -> reap-by: now + linger_seconds, session-id: <id> (pod stays alive)
  5. On reply within linger window: try_reclaim() patches -> reap-by: now + max_active_seconds, clears session-id
  6. On failure or linger expiry: pod is deleted (by cleanup() or the reaper)

Pod lingering. After a successful workflow, pool sandboxes are kept alive for linger_seconds (default 300) instead of being deleted immediately. The pod is annotated with hummingbird/session-id to link it back to the session. If a user reply arrives within the linger window, try_reclaim() finds the pod by session-id, clears the annotation (marking it as in-use), and resets reap-by. This skips pod creation and S3 archive restoration. If no reply arrives, the reaper deletes the pod when its reap-by deadline passes.

Concurrency safety. session-id is only present while lingering. Its absence during active execution prevents concurrent replies from adopting the same pod. try_reclaim() is serialized by a threading lock; the first caller wins, others fall back to S3 restore with a fresh pod.

Reaping. reap_expired() is called once per workflow execution (after sandbox acquisition, before the model loop). It performs a single sorted pass over all active pods:

  1. Non-lingering pods (no session-id) past their reap-by deadline are deleted immediately.
  2. Lingering pods (with session-id) are sorted by reap-by ascending. The loop deletes pods that are either expired (now > reap-by) or exceed max_lingering_pods (default 2, configurable), starting with those closest to their deadline. The loop stops when both conditions are satisfied (now <= reap-by and remaining count <= limit).

This replaces the previous design where _reap_expired() ran inside claim() on every poll iteration. Moving it to a once-per-workflow call reduces API load and centralizes cleanup. The max_lingering_pods cap prevents unbounded pod accumulation from many short-lived workflows.

Indefinite wait. claim() polls until a pod is available or the shutdown event fires. It logs at DEBUG level on each poll, escalating to WARNING after ~2 minutes. This is normal behavior when the pool (Deployment replicas) is smaller than max_concurrent_agents – the SQS semaphore caps concurrency, so demand will not permanently exceed supply.

Config simplification. In pool mode, the pod image, resources, metadata, and security context are defined solely in the Deployment template. The agent config only needs sandbox.namespace and sandbox.active_deadline_seconds. This eliminates duplication between the agent configmap and the Deployment.

Class design. K8sPoolSandbox inherits from K8sSandbox to reuse exec(), write_file(), read_file_iter(), and cleanup(). It overrides __init__() (does not call super().__init__() since that resolves kubeconfig), start() (claims from pool instead of creating a pod), and linger() (patches annotations instead of deleting). It adds start_from_existing() for the reclaim path. All sandbox backends implement linger(session_id): non-pool backends fall back to cleanup().

8.9. KubeVirtSandbox: SSH-based VM execution

KubeVirtSandbox provides full VM isolation using KubeVirt VirtualMachineInstances. It parallels K8sSandbox but uses CustomObjectsApi for VMI lifecycle and ssh for command execution instead of kubectl exec.

Why SSH? VMs have no kubectl exec equivalent. KubeVirt provides virtctl console (serial) and VNC, but these are not suitable for programmatic command execution. SSH provides a reliable, well-understood exec channel with stdin/stdout/stderr piping.

SSH key injection. The VM sandbox image includes a custom inject-ssh-keys.service that reads SSH public keys from a KubeVirt Secret volume attached as a virtio disk with serial ssh-pubkeys (visible at /dev/disk/by-id/virtio-ssh-pubkeys) and installs them to /root/.ssh/authorized_keys before sshd starts. This avoids the need for cloudInitNoCloud or qemu-guest-agent.

Single-VM lifecycle:

  1. Generate an ephemeral ed25519 key pair via ssh-keygen
  2. Create a K8s Secret with the public key
  3. Create the VMI referencing the containerDisk image and the SSH Secret
  4. Poll VMI status until Running with a pod network IP
  5. Poll SSH readiness (ssh sandbox@ip true)
  6. Execute workflow commands via SSH
  7. Cleanup: delete VMI, delete Secret, remove temp key files

Polling strategy. Two-phase wait: first poll status.phase via get_namespaced_custom_object until Running (same interval as pod polling), then poll SSH readiness with ConnectTimeout=5. Total timeout matches POD_START_TIMEOUT (300s) for VMI startup plus _SSH_READY_TIMEOUT (120s) for SSH.

8.10. KubeVirtPoolSandbox: VMIRS-backed pool

KubeVirtPoolSandbox + VmiPool parallel K8sPoolSandbox + SandboxPool, operating on VMI custom resources instead of Pods.

VirtualMachineInstanceReplicaSet (VMIRS). KubeVirt’s native controller maintains a desired number of identical VMIs. When a VMI is claimed (label flipped to active, ownerReferences cleared), the VMIRS automatically creates a replacement – the same pattern as a Deployment replenishing claimed pods.

Shared SSH key. Pool mode uses a single key pair for all standby VMIs. The public key is stored in a K8s Secret referenced by all VMIs in the VMIRS template. The private key path is provided via sandbox.vm_ssh_private_key in the config. On pool restart, a new key pair can be generated; existing VMIs drain naturally with the old key.

Claim/reap/linger semantics. Identical to the pod pool: claim() polls for Running VMIs with hummingbird/role=standby, patches to active, clears ownerReferences. reap_expired() deletes VMIs past their hummingbird/reap-by deadline. try_reclaim() finds lingering VMIs by hummingbird/session-id. The only difference is the API: all operations go through CustomObjectsApi with kubevirt.io/v1 virtualmachineinstances.

Class design. KubeVirtPoolSandbox inherits from KubeVirtSandbox to reuse exec(), write_file(), read_file_iter() (all SSH-based). It overrides __init__() (takes pool, skips key generation), start() (claims from pool), cleanup() (only deletes VMI, not Secret/keys which are pool-owned), and linger() (patches annotations).

5.25 - Hummingbird Agent Evals

Evaluation framework for scientific measurement of LLM output quality. For the agent itself, see Hummingbird Agent. For architecture details, see Hummingbird Agent Design.

What evals are

Evals measure the quality of LLM outputs under controlled conditions. They answer questions like “does prompt v5 produce better code reviews than v4?” or “can an LLM answer this question correctly using only public documentation?”

Evals are not unit tests. Unit tests verify deterministic behavior; evals quantify stochastic output quality through structured scoring.

General pattern

Evals generally follow a pattern of:

  1. Inputs – a set of test cases: golden dataset with scoring criteria, real MRs to review, or other structured inputs.
  2. Model execution – run one or more LLM configurations against the inputs, optionally with tools (web search, sandbox).
  3. Scoring – evaluate outputs via LLM-as-judge, comparison against reference answers, or human review.
  4. Iteration – adjust prompts or config, re-run, check for improvement.

Not all evals use every step. Baseline measurements may stop at step 3; comparison evals may skip the golden dataset entirely.

Shared infrastructure

hummingbird_agent.eval

The hummingbird_agent.eval module provides generic evaluation primitives used across all evals. Key components:

  • EvalJudge – LLM-as-judge wrapper over Vertex AI with explicit temperature and token limit control. Supports both strict judging (low temperature) and open-ended analysis (higher temperature).
  • Snapshot management – saves timestamped evaluation results with metadata, maintains a latest symlink.
  • Variant tracking – persists the best-performing prompt or config variant across iterations.
  • Convergence detection – checks whether scores have plateaued across consecutive iterations.
  • Holdout validation – compares holdout scores against dev scores using standard error of the mean.

See the module docstrings for the full API.

hummingbird_agent.evals.mr_helpers

MR-experiment-specific helpers built on top of hummingbird_agent.eval. Provides GitLab auth, MR data I/O, review generation orchestration, and CLI scaffolding. Used by the code-review and renovate-triage evals.

Running an eval

Prerequisites

  1. GCP Application Default Credentials:

    gcloud auth application-default login
    export GOOGLE_CLOUD_PROJECT=<your-gcp-project>
    
  2. The agent package installed in editable mode:

    cd hummingbird-agent
    pip install -e .
    

Execution

Each eval is a Python module under hummingbird_agent/evals/. Run from hummingbird-agent/:

python -m hummingbird_agent.evals.<name>.<script> [options]

Refer to each eval’s own README for specific options and configuration.

Existing evals

Writing a new eval

Create a new directory under hummingbird_agent/evals/ with:

  • README.md – what the eval measures and how to run it, linking back to this document for the general framework.
  • Golden dataset – inputs and scoring criteria. Can live outside the eval directory (e.g. in the documentation repo alongside the content being measured) and be passed via CLI.
  • run_*.py – orchestration script using hummingbird_agent.eval primitives (or hummingbird_agent.evals.mr_helpers for MR-based evals).

The eval’s run script should be importable as a module (python -m hummingbird_agent.evals.<name>.<script>). Add an __init__.py if needed. Do not hardcode GCP project IDs; VertexAuth resolves them at runtime from GOOGLE_CLOUD_PROJECT or ADC credentials.

Authentication

All evals use VertexAuth from hummingbird_ai.http, which resolves credentials and project in this order:

  1. Explicit project= parameter (if passed).
  2. GOOGLE_CLOUD_PROJECT environment variable.
  3. ADC default project (from gcloud auth application-default login).

Service account keys work automatically when GOOGLE_APPLICATION_CREDENTIALS is set.

5.26 - Documentation Quality Evaluation

Reference for the documentation quality evaluation, which measures whether public documentation is complete enough for LLMs with web search to answer questions correctly. For the general evaluation framework, see Hummingbird Agent Evals.

Config file

A single YAML file combines evaluation settings and the golden dataset. It is passed to the run script via -c:

python -m hummingbird_agent.evals.docs_eval.run_eval -c path/to/docs_eval.yml

The config file lives in the documentation repo alongside the content being measured.

Evaluation settings

Key Required Default Description
models yes – List of model configurations
models[].id yes – Vertex AI model identifier
models[].region yes – Vertex AI region
models[].google_search no false Enable Google Search grounding (Gemini)
models[].web_search no false Enable web search tool (Claude)
judge yes – Judge model configuration
judge.model yes – Judge model identifier
judge.region no global Judge model region
repetitions no 3 Runs per (question, model) pair
max_iterations no 5 Agent loop iteration cap per question

Golden dataset

The questions list defines what the evaluation measures:

Field Required Default Description
id yes – Unique identifier (used in output paths)
question yes – Question sent to each model
facts yes – List of fact checks
facts[].question yes – Yes/no question about the response
facts[].weight no 1 Relative importance
coherence no – Criterion for narrative quality scoring
coherence_weight no 1.5 Weight of coherence in the final score
threshold no 0.4 Minimum weighted score to pass

CLI options

Option Default Description
-c / --config required Path to config YAML
--phase all ask, judge, or all
--results-dir results/ next to config Output directory
--run-id UTC timestamp Subdirectory under results

Scoring

Each response is scored independently:

  • Fact scoring – the judge answers each fact question with yes or no. Passing facts contribute their weight to the numerator.
  • Coherence scoring – the judge rates narrative quality from 0.0 to 1.0. The coherence score is multiplied by its weight and added to the total.
  • Weighted score – sum(passed_fact_weights + coherence_contribution) / total_weight. A response passes if weighted_score >= threshold.

The summary aggregates per-model averages across all repetitions.

5.27 - Hummingbird Agent Design — Runtime

Data sources, event pipeline, sessions, workflows, and deployment for the Hummingbird Agent. This page covers the runtime: how the agent connects to external systems, processes events, manages sessions, and is deployed. For the execution engine (agent loop, tool system, sandbox), see Hummingbird Agent Design — Engine.

9. Data Sources

Data sources are external APIs wrapped as tool-calling functions. The model invokes them by name; the orchestrator executes them and returns results (inline or auto-spilled). Each data source module follows the same pattern:

  1. Define TOOL_DEFS – a list of ToolDef objects with names, descriptions, parameter schemas, and optional notes.
  2. Define implementation functions that take a pre-configured client as the first argument.
  3. Provide a register() function that creates the client, wraps implementations with functools.partial, and calls registry.register_data_source() for each tool.

Credentials are captured at registration time via functools.partial closures. They are never stored as global state and never leak into tool definitions or model contents.

9.1. GitLab

Uses python-gitlab library with retry_transient_errors=True for automatic retry on transient HTTP errors.

Tools:

  • gitlab_get_mr_details – MR metadata (title, author, SHA, labels, URLs)
  • gitlab_get_commit_statuses – all CI/CD statuses for a commit SHA (paginated automatically). Covers both Konflux external statuses and native GitLab CI job statuses. Each status includes target_url (job page link containing the job ID) and allow_failure.
  • gitlab_get_mr_diff – changed files in the MR
  • gitlab_get_file_at_ref – raw file content at a git ref
  • gitlab_get_repo_archive – repository tar.gz via repository_archive(iterator=True). Returns a StreamingResponse with chunked binary data (suffix .tar.gz), so the archive streams to the sandbox without buffering in orchestrator memory.
  • gitlab_get_job_log – job trace (log output) for a GitLab CI job. Uses lazy=True on the job object and trace(iterator=True) for streaming. Each chunk is decoded with errors='replace' and ANSI escape codes are stripped per-chunk before re-encoding. Returns a StreamingResponse (suffix .log). Per-chunk ANSI stripping is safe because escape sequences are <20 bytes and chunks are 1024+ bytes.

No response_metadata is set because GitLab commit statuses already contain target_url fields that the model uses for linking.

gitlab_get_mr_discussions – trust filtering and redaction.

MR discussions are the first data source that feeds user-generated free text into the model prompt. Unlike diffs and CI logs (which are code or machine output), discussion comments can contain arbitrary prose written by anyone who can post on the MR – including external contributors on public projects. This creates a prompt injection vector: an attacker posts a comment containing instructions that manipulate the model’s review output.

The discussions tool addresses this with a three-layer defence:

  1. Author trust gate. Each note’s author is checked for Developer+ access (level >= 30) on the project via members_all.get(author_id). Results are cached per author_id within a single call to avoid repeated API lookups. System notes (merge events, label changes) bypass the author check. Agent-authored notes are trusted via their author’s Developer+ access (the bot account holds Developer access on configured projects). Note: agent note detection for redaction purposes (stripping transcripts and footers) uses session marker presence, but trust is always based on the author’s access level, not the marker. This prevents marker injection from granting trust to non-member notes.

  2. Redaction, not sanitization. Untrusted notes in a discussion that also contains trusted notes are replaced with a fixed placeholder ([redacted: non-member comment]). The placeholder preserves the conversation structure (the model sees that someone replied, but not what they said). Discussions where all notes are untrusted are dropped entirely. No attempt is made to sanitize or escape untrusted content – there is no reliable escaping mechanism for LLM prompts, so the only safe option is to withhold the content entirely.

  3. Agent note stripping. Agent-authored notes contain large <details><summary>Agent transcript</summary>...</details> blocks (15K-90K chars of raw tool calls), metadata footers, and session markers. These are stripped before the note enters the model prompt, leaving only the review text. This serves dual purposes: token efficiency and avoiding feeding the model its own raw tool call history (which could cause degenerate self-referential loops).

Residual risks and scope limitations:

  • A compromised Developer+ account can inject adversarial content. This is accepted as equivalent to the existing risk of a compromised developer pushing malicious code (which the agent would also process).
  • The trust threshold is project-level (Developer role on the project), not MR-level. A developer on the project can influence any MR’s review.
  • Comments posted after the discussions are fetched but before the review is posted are not seen. This is a TOCTOU gap but has no security impact (the model simply misses late comments).

9.2. Konflux

Uses raw requests against K8s and Kubearchive APIs (not the kubernetes Python SDK). This avoids the heavy kubernetes client dependency for what is essentially bearer-token HTTP with label selectors.

Dual-fetch architecture:

flowchart LR
    subgraph fetch [Fetch Phase]
        KA["Kubearchive<br/>(historical)"]
        K8s["K8s API<br/>(live)"]
    end
    Combine["Combine items"]
    Dedup["Deduplicate<br/>by metadata.uid"]

    KA --> Combine
    K8s --> Combine
    Combine --> Dedup

For each resource type (PipelineRuns, TaskRuns), the client fetches from both Kubearchive (completed/historical resources) and the live K8s API, combines the results, and deduplicates by metadata.uid. This ensures no resources are missed regardless of whether they have been archived yet.

Two label selectors. Konflux uses different labels for BUILD and TEST PipelineRuns:

  • BUILD: pipelinesascode.tekton.dev/sha=<commit_sha>
  • TEST: pac.test.appstudio.openshift.io/sha=<commit_sha>

Each iter_pipelineruns()/iter_taskruns() call queries both selectors and combines the results. _iter_resources(endpoint, label_selectors) is the generic implementation: it creates one session, iterates each selector through _iter_both_sources(), and deduplicates across selectors by UID.

Per-PLR TaskRun fetching. iter_taskruns() accepts either commit_sha (two-selector path) or pipelinerun (single selector: tekton.dev/pipelineRun=<name>). When pipelinerun is set, only the ~15-21 TaskRuns for that PipelineRun are fetched (~600 KB) instead of all TaskRuns for the commit (~530 MB for large commits). This is server-side filtering — the K8s API and Kubearchive both support the tekton.dev/pipelineRun label selector.

Status filtering and streaming counts. _items_to_jsonl(items, status, stats) accepts an optional status: set[str] | None allowlist and a _StreamStats accumulator. For each item it increments stats.by_reason (keyed by reason) regardless of the filter (so counts reflect all items seen), then skips items whose status.conditions[0].status is not in the allowlist. The allowlist values are "True" (success), "False" (failure), or "Unknown" (in-progress) — only 3 possible values, covering every Tekton reason without enumeration. Items without conditions always pass. The _finalize() generator wrapper exhausts the chunks iterator, then writes counts, included, and excluded into the StreamingResponse.metadata dict — after the sandbox file is fully written. The filtering happens O(1) per item with no buffering.

StreamingResponse.metadata. StreamingResponse (in tools.py) has a mutable metadata: dict[str, Any] field (default empty dict). Both _exec_fetch_to_sandbox() and _spill_streaming() merge it into the response dict after the iterator is exhausted. The Konflux code populates it with counts, included, and excluded; other data sources can use it for their own post-streaming summaries without touching tools.py.

Pod log fetching. get_pod_log() accepts an optional container parameter. When specified, it fetches logs for that single container. When omitted, it discovers all containers from the pod spec and concatenates their logs with === container_name === headers. Each individual log fetch tries Kubearchive first, then falls back to the live K8s API. Logs may be unavailable from both sources if the pod has expired.

Streaming pagination. K8s list endpoints can return pages of 80+ MB when a commit touches many components (e.g. 500 PipelineRuns per page). iter_paginated() uses session.get(stream=True) and ijson.parse() to stream-parse each page: items are yielded one at a time via ObjectBuilder, and the metadata.continue pagination token is captured from the same parse pass. resp.raw.decode_content = True is set so urllib3 transparently decompresses gzip/deflate Content-Encoding inline (Kubearchive returns gzip-compressed responses). Only one item is in memory at a time – O(single_item) regardless of page size. Truncated or malformed streams raise IncompleteJSONError, which is caught and treated like a network error (log a warning, stop iterating that source).

Credential resolution. KonfluxClient.__init__() parses the kubeconfig file to extract the API server URL and bearer token for the cluster. The cluster domain is extracted from the cluster_url config value. This approach avoids depending on kubectl or the kubernetes Python library for API authentication.

Response metadata: Static metadata {"konflux_ui": "...", "konflux_pipelinerun_url_pattern": "..."} is merged into every response so the model can build reviewer-facing links. Dynamic per-call metadata (counts, included, excluded) is populated into StreamingResponse.metadata by _finalize() during streaming and merged by tools.py after the iterator is exhausted.

9.3. Testing Farm

Uses requests.Session with a module-level retry adapter (429, 5xx) for resilience against transient errors.

Tools:

  • tf_get_results – JUnit XML results for a request ID
  • tf_get_test_log – individual test log by URL (from results.xml); restricted to the ARTIFACTS_BASE prefix to prevent SSRF
  • tf_get_request_status – request state, queue/run times

ToolDef notes on tf_get_results document the XML structure: //testsuites/@overall-result, //testcase/@result, //testcase/logs/log with @name and @href. This domain knowledge goes into the system prompt so the model knows how to parse the XML with sandbox_exec using python3 xml.etree.ElementTree.

Response metadata: {"artifacts_base": "https://artifacts.osci.redhat.com/testing-farm"} is merged into every response so the model can build artifact links like [{request_id}]({artifacts_base}/{request_id}/).

9.4. Registration flow

data_sources.register_selected() is the entry point called by runner.py. It iterates the workflow config’s data_sources dict and registers only the declared sources:

for ds_name, ds_cfg in wf_cfg.data_sources.items():
    if ds_name == "gitlab":
        token = resolve_tool_token(proj_entry, "gitlab", wf_cfg)
        gitlab.register(registry, gitlab_url, token)
    elif ds_name == "konflux":
        cluster_url = resolve_cluster_url(ds_cfg)
        kubeconfig_path = os.environ.get(ds_cfg.kubeconfig_env, "")
        konflux.register(registry, cluster_url, kubeconfig_path, ds_cfg.kubearchive_url)
    elif ds_name == "testing_farm":
        testing_farm.register(registry)

This selective registration means a workflow with data_sources: {gitlab: {...}} only exposes GitLab tools to the model. Konflux and Testing Farm tools do not appear in the tool definitions, preventing the model from attempting to use unconfigured data sources.

9.5. Data flow: streaming vs buffered

Data moves from external APIs through the orchestrator to the model (inline or auto-spilled to sandbox). The memory profile of each tool depends on whether the HTTP response is consumed incrementally or buffered entirely.

Streaming (preferred for large/unbounded responses). The HTTP response is consumed incrementally – via ijson stream parsing, iterator=True in python-gitlab, or lazy pagination. Only one chunk or item is in memory at a time. Used by:

  • iter_paginated() (Konflux) – session.get(stream=True) + ijson
  • get_commit_statuses() (GitLab) – statuses.list(iterator=True) via _iter_commit_statuses(), yielding JSONL bytes one status at a time
  • get_repo_archive() (GitLab) – repository_archive(iterator=True), returns chunked tar.gz binary via StreamingResponse(suffix=".tar.gz", binary=True) – no text preview is generated
  • get_job_log() (GitLab) – trace(iterator=True) with per-chunk ANSI stripping, returns StreamingResponse(suffix=".log")

All produce StreamingResponse objects that flow through stream_to_file() into the sandbox without accumulating in orchestrator memory. StreamingResponse.suffix controls the auto-spill filename extension (.jsonl, .tar.gz, .log). StreamingResponse.binary controls whether a head preview is captured (False for text, True to skip for opaque binary formats).

Buffered (acceptable for small/bounded responses). The full response is loaded into memory as a string or dict. This is fine when the response size is bounded and small (typically < 1 MB). Used by:

  • get_mr_details() (GitLab) – single MR object, < 10 KB
  • get_file_at_ref() (GitLab) – single file, bounded by repo constraints
  • tf_get_results(), tf_get_test_log(), tf_get_request_status() (Testing Farm) – XML/text/JSON, typically < 1 MB

Buffered with risk (candidates for future streaming). Same buffered pattern but the response size is not bounded by design. Auto-spill mitigates the context growth problem (large outputs are written to sandbox files), but the orchestrator still spikes RSS during the fetch:

  • get_pod_log() (Konflux) – resp.text per container, concatenated. Multi-container pods accumulate all logs.
  • get_mr_diff() (GitLab) – full diff JSON, scales with MR size. GitLab truncates server-side but the result can still be large.

Invariant: paginated K8s list endpoints must always use streaming. Page sizes scale with the number of components in the commit – a single page can contain hundreds of PipelineRuns or TaskRuns (80+ MB JSON). Buffering these responses risks OOM under normal production workloads, especially with concurrent workflows.

10. Event Pipeline and Production Operations

10.1. Two-stage SQS pipeline (ingress router + FIFO worker)

Events flow through a two-stage pipeline to serialize per-session work:

flowchart LR
  STD["Standard SQS<br/>(ingress)"]
  R["Router"]
  FIFO["SQS FIFO<br/>(work queue)"]
  W["Worker<br/>(poll_loop)"]

  STD -->|ReceiveMessage| R
  R -->|"SendMessage<br/>MessageGroupId"| FIFO
  W -->|"continuation<br/>group=session_id"| FIFO
  FIFO -->|ReceiveMessage| W

Ingress router. events.router_loop() is a single-threaded loop that long-polls the standard (ingress) queue, peeks into each SNS envelope to assign a MessageGroupId, forwards the raw message body to the FIFO queue, then deletes from the standard queue.

  • Note events: MessageGroupId = discussion_id from the webhook body. This serializes replies to the same agent session – FIFO delivers at most one in-flight message per group.
  • Pipeline / MR events: MessageGroupId = SQS MessageId (unique per message). No serialization – each event is its own group.
  • MessageDeduplicationId: Always the SQS MessageId from the standard queue. Absorbs duplicate deliveries from standard SQS’s at-least-once semantics (5-minute dedup window).

If SendMessage to FIFO fails, the message is not deleted from the standard queue and retries via visibility timeout.

FIFO worker. events.poll_loop() long-polls the FIFO queue, decodes SNS envelopes, and dispatches to handle_event. The max_concurrent_agents semaphore caps parallel groups being processed. FIFO guarantees that within a group, only one message is in-flight.

Two-phase processing. When the FIFO worker picks up a webhook (Phase 1), the handler validates the event, resolves the session_id, then posts a continuation message back to the same FIFO with MessageGroupId = session_id. The Phase 1 message is deleted quickly (~1-5s). Phase 2 processes the continuation: loading session state, building WorkflowRequest, and calling _execute_workflow. Because Phase 2 continuations share a MessageGroupId per session, all work for a given session is serialized – even if it spans multiple GitLab discussion threads.

Uniform message format. Both webhook messages (from SNS) and continuation messages use the same SNS-style envelope with gzip+base64 compression. encode_envelope() is the inverse of decode_sns_message(). The consumer doesn’t need to distinguish between webhook and continuation messages at the transport layer.

Graceful shutdown. SIGTERM/SIGINT set a shutdown_event. Both loops exit. Messages in SQS become visible again after the visibility timeout for other consumers.

Why two queues. The standard queue receives events from SNS (via subscription). The FIFO queue serializes per-session work. The gitlab-event-forwarder and SNS subscription are unchanged – the routing logic lives entirely in the agent codebase.

10.2. SNS envelope decoding

Messages arrive as SNS notifications with:

  • MessageAttributes.source – event source (e.g., "gitlab")
  • MessageAttributes.event_type – event type (e.g., "pipeline", "merge_request", "note")
  • MessageAttributes.content_encoding – "gzip+base64" for gitlab-event-forwarder, absent for kubernetes-event-forwarder
  • Message – the actual event body (JSON string, or gzip+base64 encoded)

decode_sns_message() handles both encoding formats transparently.

10.3. Event routing

runner.handle_event() dispatches on (source, event_type):

flowchart TD
    Event["Event arrives"]
    Check{"source/type?"}
    Cont["handle_continuation()"]
    Pipeline["handle_pipeline()"]
    MRHandler["handle_merge_request()"]
    Note["handle_note()"]
    Ignore["Ignore"]

    Event --> Check
    Check -->|"agent/continuation"| Cont
    Check -->|"gitlab/pipeline"| Pipeline
    Check -->|"gitlab/merge_request"| MRHandler
    Check -->|"gitlab/note"| Note
    Check -->|"other"| Ignore

    Pipeline --> FilterSource{"source = merge_request_event?"}
    FilterSource -->|"yes"| AutoResolve["auto-resolve on success<br/>(unconditional)"]
    FilterSource -->|"no"| Skip1["Skip"]
    AutoResolve --> PipeRules["evaluate trigger_rules<br/>(status + user + branch + title)"]
    PipeRules -->|"allow"| Enqueue1["_enqueue_continuation()"]
    PipeRules -->|"deny"| Skip1a["Skip"]

    MRHandler --> FilterMRAction{"action in open/update?<br/>draft = false?"}
    FilterMRAction -->|"yes"| MRRules["evaluate trigger_rules<br/>(user + branch + title)"]
    FilterMRAction -->|"no"| Skip1b["Skip"]
    MRRules -->|"allow"| Enqueue2["_enqueue_continuation()"]
    MRRules -->|"deny"| Skip1c["Skip"]

    Note --> FilterMRNote{"MR note?<br/>action = create?"}
    FilterMRNote -->|"yes"| NoteType
    FilterMRNote -->|"no"| Skip3["Skip"]

    NoteType{"Note type?"}
    NoteType -->|"author_id == bot_id"| Skip4["Skip (own note)"]
    NoteType -->|"/hummingbird help"| Help["post_simple_reply (help)"]
    NoteType -->|"/hummingbird wf-name"| SlashCmd["_resolve_slash_workflow()<br/>→ _enqueue_continuation()"]
    NoteType -->|"DiscussionNote reply"| FindSession["find_session_for_reply()"]
    NoteType -->|"other"| Skip5["Skip"]

    FindSession --> Found{"session found?"}
    Found -->|"yes"| Enqueue3["_enqueue_continuation()<br/>(resume_session)"]
    Found -->|"no"| Skip6["Skip"]

    Cont --> ContType{"work_type?"}
    ContType -->|"new_session"| ExecNew["_execute_workflow()"]
    ContType -->|"resume_session"| LoadS3["load session from S3<br/>→ _execute_workflow()"]

Phase 1 handlers (handle_pipeline, handle_merge_request, handle_note) perform validation, auth checks, and session resolution, then post a WorkOrder continuation message via _enqueue_continuation(). They do not call _execute_workflow directly.

Phase 2 (handle_continuation) receives the WorkOrder, resolves the workflow config, builds a WorkflowRequest, and calls _execute_workflow. For resume_session orders, it loads the previous session from S3. _execute_workflow handles SHA dedup and per-workflow rate limiting via scan_agent_threads.

Slash commands dispatch to a single named workflow via _resolve_slash_workflow (exact match, then prefix match). Help and error replies are posted via post_simple_reply (no session marker, no rate limit impact).

10.4. Pipeline trigger design

Why gitlab::pipeline instead of kubernetes::PipelineRun: A GitLab pipeline event fires once when the pipeline completes. Since Konflux external stages are attached to the pipeline, the event naturally waits for all builds and tests to finish before triggering. This means the agent sees the full picture in one event, without needing to deduplicate or wait for stragglers.

Trigger filters:

  • status in {failed} – only failed pipelines trigger analysis
  • source == "merge_request_event" – only MR pipelines, not branch/tag

10.4a. Merge request trigger design

The handle_merge_request handler fires on MR open, reopen, and update events (action in {"open", "reopen", "update"}). Draft MRs are skipped (object_attributes.draft == True); marking a draft as ready triggers a review since the event arrives with draft: false.

Event classification is deliberately simple: the handler does not inspect oldrev or changes.draft fields. Instead, SHA-based deduplication in _execute_workflow (via JSON session markers) ensures each code revision is reviewed at most once. Metadata-only updates (title, label changes) on an already-reviewed SHA are silently skipped.

10.5. Note trigger design

Two sub-flows:

Slash command (/hummingbird <workflow-name>): Triggers a specific named workflow, bypassing rate limiting. _resolve_slash_workflow performs exact-match lookup first, then falls back to unique prefix matching. If the subcommand is missing or is “help”, _format_help returns a list of available workflows with descriptions. Ambiguous or unknown subcommands produce an error message via post_simple_reply. The note author must have Developer+ access (level >= 30) on the project, checked via check_member_access(). If denied, the agent replies in the same discussion thread with a short access-denied message.

Reply to agent note: When a user replies to an existing agent note (which contains a JSON session marker):

  1. find_session_for_reply() walks the discussion thread, filtering by bot author ID (get_bot_user_id()), and returns the first matching session marker. All bot markers in a thread share the same session ID.
  2. handle_note posts a resume_session continuation to the FIFO (grouped by session_id).
  3. handle_continuation loads the session from S3 (conversation history and sandbox archive), builds a WorkflowRequest, and calls _execute_workflow() to resume with the user’s reply.
  4. If the session is not found (expired/deleted), the agent replies with a message explaining the session has expired and suggesting to start a new run.

Reply authors are also subject to the Developer+ access check. If denied, the agent replies in the discussion thread with the same access-denied message.

Non-agent-directed notes: Regular comments that are neither slash commands nor replies to agent threads are silently ignored (debug-level log). No access check is performed for these.

Self-note filtering: Notes where author_id matches the bot’s own user ID (resolved via get_bot_user_id()) are skipped immediately. This prevents infinite loops where the agent’s own output triggers another agent run. The author-ID check replaces the previous substring check (marker_prefix_str in note_body), which was vulnerable to denial-of-service: a user including the marker prefix in their reply would cause the bot to silently ignore it.

Threading logic for replies: If the project requires internal notes (internal_notes: true) but the original discussion was public, the reply is posted as a new top-level internal note instead of replying in the public thread. This prevents leaking internal analysis into public threads.

10.6. Note lifecycle

1. create_placeholder_note()   -> "Running the <workflow> workflow..."
                                   + JSON session marker (id, wf, sha)
2. run_workflow()              -> agent loop
3. update_note()               -> replace placeholder with result
                                   + reply prompt + JSON session marker

Reply notes (from session resumption) omit the reply prompt since the
user is already engaged in the conversation.

On failure, the placeholder is updated to “Hummingbird analysis failed.” The JSON session marker is always present so the note can be identified as agent-generated (for rate limiting, SHA dedup, and self-note filtering). The marker embeds the workflow name and commit SHA, enabling per-workflow rate limiting and SHA-based deduplication.

10.7. Rate limiting and SHA deduplication

scan_agent_threads() performs a single pass over MR discussions, parsing JSON session markers (<!-- hummingbird-session: {"id":...,"wf":...,"sha":...} -->) to determine:

  1. Per-workflow thread count – how many threads the given workflow has created on this MR. If the count meets or exceeds max_runs_per_mr, the workflow is skipped.
  2. SHA dedup – whether the current commit SHA has already been reviewed by this workflow. If so, the workflow is skipped (prevents re-reviewing the same code on metadata-only MR updates).

Slash commands and replies bypass both checks entirely.

The rate limit is per-workflow per-MR: different workflows maintain independent thread counts on the same MR. JSON markers are backward compatible – old plain-UUID markers (<!-- hummingbird-session: UUID -->) are parsed as {"id": "UUID"} with no workflow or SHA information, so they are not counted toward any specific workflow’s limit.

11. Session System

Sessions enable conversation continuity: a user can reply to an agent note and the agent picks up where it left off, with full context and sandbox files restored.

11.1. What gets saved

Three artifacts are saved after each run:

  • context.json – the full contents list from the agent loop in the canonical provider-independent format. It is the minimum state needed to resume the conversation; format conversion is handled by the AI library.
  • transcript.md – a human-readable markdown rendering of the run for debugging and auditing. Not used for resumption.
  • sandbox.tar.gz – an archive of /tmp/data/ (the sandbox working directory, which includes the _out/ spill subdirectory). Contains PipelineRun JSONs, test logs, jq output, and any other files the model created during the run. Restored into the new sandbox on resumption so the model can reference its previous work.

11.2. Storage backends

S3 (production):

s3://{bucket}/sessions/{session_id}/context.json
s3://{bucket}/sessions/{session_id}/transcript.md
s3://{bucket}/sessions/{session_id}/sandbox.tar.gz

Local directory (development, --save-session):

{directory}/context.json
{directory}/transcript.md
{directory}/sandbox.tar.gz

Both backends have the same interface. S3 save is best-effort: wrapped in try/except so a transient S3 error does not prevent the MR note from being posted. The note always gets delivered first.

11.3. Sandbox archive transport

The sandbox archive requires special handling because the orchestrator cannot directly access the sandbox filesystem (no volume mounts):

Archive (inside sandbox):
  sb.exec("tar czf /tmp/_archive.tar.gz -C /tmp data")

Stream out (sandbox -> host temp file):
  for chunk in sb.read_file_iter("/tmp/_archive.tar.gz"):
      tmp.write(chunk)                     # 64 KB chunks, no base64

Restore (host temp file -> new sandbox):
  sb.stream_exec("tar xzf - -C /tmp", file_chunks())

Both directions use streaming binary I/O via Popen stdin/stdout pipes. No base64 encoding is needed: read_file_iter() yields raw bytes from the sandbox via a Popen stdout pipe, and stream_exec() feeds raw bytes into a Popen stdin pipe. The archive is never extracted on the orchestrator – it exists only as opaque bytes being transported between sandboxes. This is a security invariant: the orchestrator never parses or inspects the archive contents.

11.4. Resumption flow

flowchart TD
    Reply["User replies to agent note"]
    FindSession["find_session_for_reply()<br/>walks discussion thread"]
    LoadS3["load_s3(session_id)"]
    NotFound{"session found?"}
    Expired["Post session-expired reply"]
    NewSandbox["Start new sandbox"]
    Restore["restore_sandbox(archive)"]
    BuildContents["contents = old_contents + user_reply"]
    RunLoop["run_agent_loop(<br/>initial_contents, CONTINUATION_PROMPT)"]

    Reply --> FindSession --> LoadS3
    LoadS3 --> NotFound
    NotFound -->|"no"| Expired
    NotFound -->|"yes"| NewSandbox
    NewSandbox --> Restore --> BuildContents --> RunLoop

Key aspects:

  • Fresh iteration budget. The resumed session starts iteration 0 with the full max_iterations budget, regardless of how many iterations the previous session used.
  • Context window is the real limit. A typical 8-iteration cold start uses ~50-100K input tokens. The restored history is sent in full on every API call. The CONTEXT_LIMIT check applies and will force wrap-up if needed.
  • Session ID reuse. The resumed session keeps the original session ID. S3 state is overwritten in place with the updated conversation history. Since GitLab discussions are linear (not branching), there is no need for a tree of sessions – the conversation is a single sequential thread.
  • Graceful degradation. If the S3 session is not found (expired, deleted), the agent posts a reply explaining the session has expired and suggesting to start a new run. It does not fall back to a cold start.

11.5. Session markers

Every agent note contains a hidden HTML comment with a JSON payload:

<!-- hummingbird-session: {"id":"UUID","wf":"code-review","sha":"abc123"} -->

The JSON payload contains:

  • id – session UUID (always present)
  • wf – workflow name (present for new-format markers)
  • sha – commit SHA at the time of review (present for auto-triggered runs)

This marker serves four purposes:

  1. Resumption: find_session_for_reply() searches discussion threads for this marker to find the session ID. Only bot-authored notes are considered; the first matching marker wins.
  2. Per-workflow rate limiting: scan_agent_threads() counts discussion threads for a specific workflow using the wf field. Only bot-authored notes are scanned.
  3. SHA deduplication: scan_agent_threads() checks whether the current SHA has already been reviewed by the workflow using the sha field.
  4. Self-filtering: handle_note() compares the webhook event’s author_id against the bot’s own user ID (via get_bot_user_id()) to prevent infinite loops.

All marker-scanning functions resolve the bot user ID via gl.auth() (GET /user) on the orchestrator token. This ensures markers in non-bot notes (user replies, external contributors) are never parsed, preventing session hijacking via injected markers.

Old plain-UUID markers (<!-- hummingbird-session: UUID -->) are parsed as {"id": "UUID"} for backward compatibility. They are counted for resumption but not for per-workflow rate limiting or SHA dedup (no wf/sha information).

12. Workflow System

12.1. Separation of prompt and metadata

A workflow has two parts that live in different places:

  • Prompt (.md file): the LLM system prompt, verbatim. This is the investigation strategy, tool usage guidance, data patterns, and output format. Pure text, no code.
  • Metadata (YAML config): operational settings – action, model, max_iterations, data_sources, projects. This controls what the orchestrator does with the workflow, not what the model does.

This separation means changing the analysis strategy (e.g., adding a new investigation step) requires editing a markdown file. Changing operational parameters (e.g., which projects use this workflow, which model to use) requires editing the YAML config. Neither requires a code change.

12.2. System prompt layering

The system prompt sent to the model is built from three layers:

BASE_SYSTEM_PROMPT        # agent.py: sandbox rules, tool usage tips
+ tool_notes              # from ToolRegistry: per-data-source domain knowledge
+ workflow_body           # full content of e.g. workflows/analyze-failures.md

On session resumption, a fourth layer is appended:

+ CONTINUATION_PROMPT     # agent.py: "do not re-run the full workflow"

The system prompt is rebuilt every iteration (to allow warning suffixes to be appended), but the base content is stable. Warning suffixes are appended at the end so they override earlier instructions.

12.3. Design choice: strategy, not procedure

The workflow .md file describes strategy and guidance, not a rigid script. The model decides when and how to use each tool based on what it sees.

This matters because merge request failures are diverse. A fixed procedure would either miss edge cases (e.g., build failures before tests ran) or waste iterations on steps that don’t apply. By giving the model a strategy (“identify failing tests, fetch details, analyze root causes, group by similarity”), it can adapt to whatever it encounters.

The workflow does structure the investigation into phases (Data Collection, Analysis, Output) for clarity, but these are guidelines, not enforced checkpoints.

12.4. Workflow file anatomy (analyze-failures.md)

The primary workflow is structured as:

  1. Scope and approach – what this workflow analyzes and what it ignores
  2. Input – event JSON format (project, iid)
  3. Data Collection Phase:
    • Data.1: Fetch MR details (direct call, small response)
    • Data.2: Fetch commit statuses, identify failures
    • Data.3: Batch fetch PipelineRuns + TaskRuns (fetch_batch_to_sandbox)
    • Data.4: Process with jq, extract Testing Farm data in bulk
  4. Analysis Phase:
    • Analysis.1a: Investigate each failed PipelineRun individually
    • Analysis.1b: Summarize and group by root cause
  5. Output – markdown template with root causes, collapsible details, clickable links (PipelineRun, Testing Farm, test logs)
  6. Error Handling – partial report philosophy

Design considerations embedded in the workflow:

  • Efficient bulk fetching: PipelineRuns and TaskRuns are fetched in one fetch_batch_to_sandbox call, not individually.
  • Testing Farm data extracted in Data.4, analyzed in Analysis.1: All TF results.xml are fetched in bulk before analysis starts, enabling cross-failure pattern detection.
  • Log fetching is selective: Only 1-2 representative logs per failure pattern, not all logs. This keeps iteration count bounded.
  • Reviewer-facing URLs: The output template instructs the model to include clickable links using konflux_ui and artifacts_base from response metadata.

12.5. Prompt file resolution

get_prompt(workflow_cfg, config_dir) resolves the prompt: field relative to the config file’s directory:

prompt_path = config_dir / workflow_cfg.prompt
# e.g. /app/config.yaml with prompt: workflows/analyze-failures.md
# -> /app/workflows/analyze-failures.md

In the container image, workflows are baked in at /app/workflows/. A ConfigMap can override them at deploy time by mounting at the same path.

13. Deployment and Container

13.1. Container build strategy

The Containerfile uses an all-RPM builder+installroot+scratch pattern (no pip, no venv):

  1. Builder stage: Uses a Fedora-based builder image with dnf. Installs all dependencies as RPMs into a clean --installroot.
  2. Application code: Copies hummingbird_agent/ and workflows/ into the installroot at /app/.
  3. Final stage: FROM scratch, copies the entire installroot. No package manager, no shell beyond what RPMs provide.

RPM dependencies: python3-boto3, python3-google-auth+requests, python3-gitlab, python3-kubernetes, python3-pyyaml, python3-requests, python3-sentry-sdk, kubernetes1.35-client (for kubectl).

This approach was chosen over pip because:

  • All deps come from Fedora’s package repository – no PyPI supply chain risk
  • Smaller image (~22 MB saved vs. google-genai SDK alone)
  • Reproducible builds from known RPM versions
  • No compilation step (no gcc/python3-devel in the image)

13.2. Container runtime properties

CMD ["python3", "-m", "hummingbird_agent", "serve"]
WORKDIR /app
USER 65532

The default CMD runs serve mode for production. Local development uses run mode via explicit command override. USER 65532 matches the standard nonroot UID used by distroless images and the Podman sandbox.

13.3. K8s deployment manifests

Located in hummingbird-agent/kubernetes/:

deployment.yaml:

  • Single replica with rolling update (maxSurge: 1, maxUnavailable: 0)
  • terminationGracePeriodSeconds: 900 (15 minutes) to allow in-flight agent runs to complete on shutdown
  • ServiceAccount: hummingbird-agent
  • Secrets mounted from K8s Secret hummingbird-agent
  • Commented-out mounts for workflow ConfigMap and custom CA trust

rbac.yaml (applied in the sandbox namespace):

  • ServiceAccount hummingbird-agent
  • Role with pods: create, get, list, delete, patch and pods/exec: create
  • RoleBinding linking the SA to the Role
  • patch is required for pool mode (relabeling pods during claim)

sandbox-pool.yaml (applied in the sandbox namespace):

  • Deployment with replicas: 3 (tuned to balance latency vs cost)
  • Pods labelled hummingbird/role: standby for pool discovery
  • Same security context and resource limits as direct-creation pods
  • revisionHistoryLimit: 2, rolling update with maxSurge: 1, maxUnavailable: 0

networkpolicy.yaml:

  • podSelector: {} – applies to all pods in the namespace
  • egress: [] – deny all outbound traffic

secret.yaml:

  • Template with all required env vars (API keys, tokens, URLs)
  • Values must be populated per deployment

13.4. Workflow mounting

Workflows are baked into the image at /app/workflows/. To update workflows without rebuilding the image:

  1. Create a ConfigMap from the workflows directory
  2. Uncomment the volume mount in the deployment YAML
  3. The ConfigMap mount replaces the baked-in directory (all-or-nothing)

This is useful for rapid iteration in staging without waiting for a new image build.

13.5. Custom CA trust

For clusters with internal CA certificates (common in enterprise environments), the deployment supports OpenShift’s CA injection:

  1. Create a ConfigMap with the config.openshift.io/inject-trusted-cabundle label
  2. Mount it at /etc/pki/custom
  3. Set REQUESTS_CA_BUNDLE=/etc/pki/custom/ca-bundle.crt

OpenShift automatically injects the cluster’s CA bundle into the ConfigMap.

5.28 - Hummingbird Agent Design — Decisions

Design Decision Registry (DDR) for the Hummingbird Agent — every major architectural choice, the alternatives considered, why the chosen approach won, and what would break if reversed. For the full design context, see Hummingbird Agent Design. Provider adapter decisions are in Hummingbird AI Design.

Design Decisions

kubectl exec over kubernetes Python exec API

  • Chosen: kubectl exec as subprocess for sandbox command execution.
  • Alternative: kubernetes Python client stream() API.
  • Why: Three showstopper bugs in the Python client: (1) WebSocket v1-v4 has no stdin EOF signal, so cat > /file hangs forever; (2) BrokenPipeError on stdin larger than ~1MB; (3) stream() accumulates all stdout in memory with no control. kubectl avoids all three and provides the same subprocess interface as Podman.
  • If reversed: write_file() would hang or fail on large data. Pod log retrieval with large outputs would OOM. Sandbox reliability would drop significantly.

YAML config over env vars for operational settings

  • Chosen: Single YAML file for workflows, projects, limits, data source declarations.
  • Alternative: Everything in env vars (original design).
  • Why: Env vars cannot express structured data (workflow-project mappings, per-project token overrides, data source config with multiple fields). YAML provides structure, validation, and audit trails.
  • If reversed: Token scoping would be lost (no per-workflow-project token overrides). Project allowlists would be impossible. The config would be unauditable.

Token env var names in YAML, not values

  • Chosen: YAML contains token_env: GITLAB_TOKEN_RO (the env var name), not the actual token value.
  • Alternative: Inline secrets in YAML, or env vars for everything.
  • Why: The YAML file can be committed, reviewed, and audited. Actual secrets stay in env vars (injected via K8s Secrets). Inline secrets would make the config file a secret itself.
  • If reversed: The config file would become a secret, breaking audit trails and code review workflows.

Orchestrator token prefix convention (ORCHESTRATOR_*)

  • Chosen: Orchestrator tokens use ORCHESTRATOR_GITLAB_TOKEN_* env var names by convention.
  • Alternative: Same env var namespace as model tokens, distinguished by context.
  • Why: Structural separation makes it impossible to accidentally pass an orchestrator token to a model tool (or vice versa). The ORCHESTRATOR_ prefix is never used in YAML token_env fields.
  • If reversed: A misconfiguration could leak write-capable tokens to the model, which could then expose them via tool calls.

Auto-spill over conversation compaction

  • Chosen: Large outputs are saved to sandbox files with previews returned to the model.
  • Alternative: Replace old tool results in contents with compact summaries (conversation compaction).
  • Why: Compaction requires modifying the contents list, which risks violating provider conversation constraints. Auto-spill achieves ~90% of the token reduction without modifying model history. See Hummingbird AI for adapter format requirements.
  • If reversed: Token usage would increase ~15-30%. Per-call context would grow unbounded. Sessions would hit the context limit much sooner. Conversation compaction could be added on top of auto-spill in the future, but is not needed with current workloads.

Iteration count over cumulative token budget

  • Chosen: max_iterations as the primary cost control lever.
  • Alternative: Cumulative token budget (stop when total billed tokens exceed a threshold).
  • Why: With full history replay, each API call re-sends everything. Cumulative billed tokens double-count: call 1 = 5K, call 2 = 10K (including 5K again), total = 15K billed but only 10K new content. With auto-spill keeping per-call size bounded, iteration count is a much simpler and more predictable proxy for actual cost.
  • If reversed: The budget model would be confusing and inaccurate. Cost estimates would be wrong. The cumulative metric is still logged for observability, but it does not drive termination.

ToolDef notes in system prompt, not tool schema

  • Chosen: Domain knowledge (Konflux dual-fetch, TF XML structure) goes in ToolDef.notes, injected into the system prompt.
  • Alternative: Put everything in the tool schema description.
  • Why: Tool schemas have character limits and are sent in the tools field of every API call. Long descriptions waste tokens on tool defs. System prompt notes are sent once and can be arbitrarily detailed.
  • If reversed: Tool descriptions would be bloated. Domain knowledge would need to be duplicated in every workflow .md file.

Full history replay (no compaction)

  • Chosen: The contents list grows monotonically. Items are never removed or modified (except empty response retries).
  • Alternative: Compact old turns to reduce context size.
  • Why: Provider APIs impose strict conversation structure. Modifying or removing items risks invalid history. Auto-spill handles the growth problem at the source (preventing large items from entering history).
  • If reversed: Risk of provider API errors from malformed conversation structure and of confusing the model with summarized results.

fetch_to_sandbox kept alongside auto-spill

  • Chosen: Both fetch_to_sandbox and auto-spill coexist.
  • Alternative: Remove fetch_to_sandbox since auto-spill handles large outputs automatically.
  • Why: fetch_to_sandbox provides explicit path control (model can choose meaningful filenames), batch fetching (one tool call for multiple fetches), and no-preview responses (useful when the model will process with jq anyway).
  • If reversed: The model would lose path control and batch fetching would require multiple auto-spilled calls. The workflow would need more iterations to achieve the same result.

K8s library for lifecycle + kubectl for exec (hybrid)

  • Chosen: Use the kubernetes Python library for pod create/read/delete, kubectl subprocess for exec.
  • Alternative: All-kubectl (subprocess for everything) or all-library.
  • Why: The library provides typed pod status polling and clean error handling for lifecycle. kubectl provides reliable exec (see “kubectl exec over kubernetes Python exec API” above). Using the library for exec would require solving the three WebSocket bugs.
  • If reversed: Either lifecycle management would be fragile (parsing kubectl JSON output for pod status) or exec would be unreliable.

Single-file YAML config over multiple config files

  • Chosen: Everything in one config.yaml with settings and workflows sections.
  • Alternative: Separate files per workflow, or settings.yaml + workflows.yaml.
  • Why: Single source of truth. All project-workflow mappings visible in one place. Settings-level defaults flow down to all projects. No file discovery logic needed.
  • If reversed: Token resolution would need cross-file lookups. Project index would need multi-file aggregation. Config validation would be more complex.

Workflow .md as pure prompt, metadata in YAML

  • Chosen: The workflow .md file is pure system prompt text. Metadata (action, model, max_iterations, data_sources, projects) lives in the YAML config.
  • Alternative: YAML frontmatter in the .md file (original design).
  • Why: Separation of concerns. The .md file is the model’s instructions – it should be readable and editable by anyone writing prompts. The YAML config is the orchestrator’s instructions – it controls routing, limits, and credentials. Mixing them in one file conflates two audiences.
  • If reversed: Prompt authors would need to understand YAML config structure. Token/project config would be scattered across .md files instead of centralized.

Nudge via system prompt suffix, not user message

  • Chosen: Empty response nudge and budget warnings are appended to the system prompt as suffixes.
  • Alternative: Inject synthetic user messages into contents.
  • Why: User messages in contents must come from the actual user (the initial event, or a reply). Injecting synthetic messages pollutes the conversation history that is saved in sessions. System prompt suffixes are transient – they affect one API call without permanently modifying the conversation state.
  • If reversed: Saved sessions would contain synthetic user messages. Resumed conversations would be confusing. The model might respond to the synthetic messages instead of the user’s actual input.

Session ID reuse over new-ID-per-resume

  • Chosen: Resumed sessions keep the original session ID. S3 state is overwritten in place.
  • Alternative: Each resume generates a new UUID, saving alongside the original (append-only tree of sessions).
  • Why: GitLab discussions are linear, not branching. There is no scenario where two different sessions from the same thread are both valid. A new UUID per resume creates orphaned S3 snapshots that are never referenced again, since find_session_for_reply() always picks the latest marker. Reusing the ID is simpler, uses less storage, and matches the linear conversation model.
  • If reversed: S3 would accumulate orphaned session snapshots. Each resume would need a new note marker, but the old markers would still be in the thread, creating confusion about which session is current.

Reply to unauthorized agent-directed notes instead of silent skip

  • Chosen: When an unauthorized user sends a slash command or replies to an agent thread, the agent replies in the same discussion with a short access-denied message. Non-agent-directed notes are silently ignored (debug log, no access check).
  • Alternative: Log a warning and skip silently for all unauthorized notes (the original behavior).
  • Why: Silent skip gives no feedback to someone who intentionally tried to engage the agent, which is confusing. On the other hand, checking access and logging a warning for every random comment on a public project is noisy and pointless. Moving the access check after intent detection cleanly separates the two cases. The reply uses the discussions API so it automatically inherits confidentiality from the parent note/thread.
  • If reversed: Users without sufficient access who try /hummingbird would get no feedback. The agent log would be noisy with warnings for every comment on public MRs.

Deployment-backed pod pool over Python-thread pool

  • Chosen: A Kubernetes Deployment maintains pre-warmed standby pods. The agent claims a pod by relabeling it; the Deployment replaces it.
  • Alternative: A Python-side thread pool that pre-creates pods and queues them for use.
  • Why: The Deployment provides self-healing (restart crashed pods), native scaling (kubectl scale), rolling updates (image changes), and monitoring via standard K8s tooling. A Python pool would need to reimplement all of these.
  • If reversed: Pod replenishment, crash recovery, and image updates would all need custom code. Scaling would require agent redeployment.

Pod isolation: never reuse sandbox pods

  • Chosen: Each workflow run gets its own pod. After cleanup, the pod is deleted. Claimed pods are detached from the ReplicaSet.
  • Alternative: Return used pods to the pool and reset them.
  • Why: Residual state from a previous run (files, environment, running processes) could leak between MR investigations, creating security and correctness risks. Deletion is simple and foolproof.
  • If reversed: Would need a reliable pod-reset mechanism and auditing that no state survives between runs.

Absolute reap-by deadline over relative claimed-at age

  • Chosen: Active pods are annotated with hummingbird/reap-by (an absolute UTC timestamp). The reaper deletes pods where now > reap-by.
  • Alternative: Annotate with claimed-at and compute age relative to max_active_seconds; or use activeDeadlineSeconds on the pod spec.
  • Why: An absolute deadline simplifies the reaper to a single comparison. It also supports varying deadlines: claim sets reap-by = now + max_active_seconds, while linger sets reap-by = now + linger_seconds. With a relative timestamp, the reaper would need to know which mode the pod is in. activeDeadlineSeconds applies from pod creation, not from claim – standby pods would expire before being used.
  • If reversed: The reaper would need mode-aware age calculations. Lingering pods would require a separate annotation or reaper path.

Indefinite claim wait over timeout

  • Chosen: SandboxPool.claim() polls indefinitely (governed by shutdown event), logging at DEBUG then WARNING.
  • Alternative: Timeout after N seconds and raise an error.
  • Why: The pool is typically smaller than max_concurrent_agents for cost reasons. Waiting for a replacement pod is normal operational behavior, not an error. A timeout would cause spurious failures during burst traffic. The SQS semaphore already bounds concurrency.
  • If reversed: Burst traffic would cause avoidable failures instead of brief delays.

Pool config in Deployment only, not in agent configmap

  • Chosen: In pool mode (k8spool), the pod image, resources, labels, and security context are defined solely in the Deployment template. The agent config only needs namespace and active_deadline_seconds.
  • Alternative: Keep image/resources/metadata in the agent configmap too (as in k8s mode).
  • Why: Eliminates config duplication. The Deployment template is the single source of truth. Changes to pod resources or image only require updating one place and re-rolling the Deployment.
  • If reversed: Config drift between the Deployment and the agent configmap would be a constant risk.

Pod lingering over immediate cleanup for reply latency

  • Chosen: After a successful workflow, pool sandbox pods linger for linger_seconds (default 300) with a session-id annotation. Replies within the window reclaim the pod via try_reclaim(), skipping pod creation and S3 archive restoration.
  • Alternative: Always delete the pod immediately and restore from S3 on every reply.
  • Why: Reply latency drops from seconds (pod claim + S3 restore) to near-zero. The S3 archive is still saved as a fallback if the pod is gone. The reap-by annotation ensures lingering pods are cleaned up if no reply arrives. The session-id annotation doubles as a concurrency guard: it is only present while lingering, preventing active pods from being reclaimed.
  • If reversed: Every reply would pay full pod + restore latency, even for immediate follow-ups. User experience for conversational interactions would degrade noticeably.

linger() on Sandbox protocol over isinstance checks in runner

  • Chosen: All sandbox backends implement linger(session_id). Non-pool backends fall back to cleanup(). The runner calls sb.linger() without type checks.
  • Alternative: isinstance(sb, K8sPoolSandbox) in run_workflow().
  • Why: Keeps the runner backend-agnostic. Adding a new backend requires only implementing the protocol, not touching the runner. The fallback behavior is co-located with each backend.
  • If reversed: The runner would need to know about every backend type and their linger capabilities.

GCP SA key over Workload Identity Federation

  • Chosen: GCP service account key stored in Vault, rotated via cki-tools credential manager (prepare/switch/clean cycle).
  • Alternative: Workload Identity Federation (WIF) with projected SA token. Two sub-options: (a) automatic OIDC discovery if the cluster issuer is public, (b) manual JWKS upload for internal issuers.
  • Why: mpp-prod’s OIDC issuer is https://kubernetes.default.svc (internal, not publicly reachable), so GCP STS cannot discover it automatically. Manual JWKS upload works but requires re-upload after SRE-triggered signing key rotations. SA key integrates with the existing credential manager rotation infrastructure (same pattern as AWS keys and GitLab tokens), needs no OIDC reachability, and enables automated validate/update via google-auth in CI. The application code (hummingbird_ai.http.VertexAuth / google.auth.default()) works identically with both approaches – switching to WIF later requires only infrastructure changes.
  • If reversed: Replace SA key with WIF projected token + credential config ConfigMap. The gcp_service_account_key token type in cki-tools would no longer be needed for this use case.

Developer+ trust filtering over unfiltered or sanitized discussion comments

  • Chosen: gitlab_get_mr_discussions checks each note author’s project access level (Developer+ / >= 30). Untrusted notes are replaced with a fixed placeholder in mixed-trust discussions, or the entire discussion is dropped if all notes are untrusted. Agent notes are always trusted (detected by session marker) but have transcripts and metadata stripped.
  • Alternatives considered:
    • (a) Include all comments unfiltered. Simplest, but any external contributor can craft comments that manipulate the model’s review output (prompt injection).
    • (b) Sanitize/escape untrusted content (strip markdown, quote as code blocks, prefix with “[external]”). There is no reliable escaping mechanism for LLM prompts – the model interprets natural language regardless of formatting. Escaping gives a false sense of security.
    • (c) Only include agent-authored notes (skip all human comments). Safe, but defeats the purpose: the model would never see developer responses to its own findings.
    • (d) Include only discussions where the agent participated. Better, but still misses developer-initiated review threads that provide relevant context.
  • Why: Developer+ is the same threshold used for pipeline trigger authorization (invariant #8) and slash command access. It matches the trust boundary already established: people who can push code and approve MRs are trusted to provide review context. The placeholder approach preserves discussion structure (the model sees that someone replied) without exposing the content. Full-drop for all-untrusted discussions avoids noise from discussions that contain zero useful context.
  • If reversed: (a) opens a prompt injection vector on any project that accepts external MRs. (b) provides no actual protection. (c) makes follow-up reviews unable to see developer explanations, causing repeated false positives. (d) misses developer-initiated context.

Bot-author filtering for session marker parsing over unfiltered note scanning

  • Chosen: find_session_for_reply(), scan_agent_threads(), and the handle_note() self-filter all resolve the bot user ID via get_bot_user_id() (which calls gl.auth() on the orchestrator token) and only consider notes where author.id matches. In find_session_for_reply(), the first matching marker wins.
  • Alternative: Parse markers from any note and keep the last match; substring self-filter in handle_note (previous behavior).
  • Why: The previous unfiltered approach allowed session hijacking: a user reply or agent review prose containing an example marker (<!-- hummingbird-session: ... -->) was parsed as a real session, causing “session expired” errors in production. For handle_note, the substring check caused the reverse problem: a user embedding the marker prefix in a reply silently suppressed the event (denial of service). The gl.auth() call is one GET /user per invocation – negligible cost. _get_client_with_bot_id() returns both the authenticated client and the bot user ID, avoiding redundant client construction.
  • If reversed: Any MR participant can inject a marker to hijack the session ID or inflate rate-limit counts. The agent’s own review prose containing example markers causes spurious “session expired” messages.

ijson streaming over buffered JSON for K8s list responses

  • Chosen: session.get(stream=True) + ijson.parse(resp.raw) for iter_paginated(). Items are yielded one at a time via ObjectBuilder; the metadata.continue pagination token is captured in the same parse pass. resp.raw.decode_content = True is required because Kubearchive returns gzip Content-Encoding; without it ijson sees compressed bytes.
  • Alternative: session.get().json() loads the full page into memory (the original implementation).
  • Why: A single K8s list page can contain 500 PipelineRuns (80+ MB JSON). Parsing this with .json() creates a +247 MB RSS spike (raw bytes + decoded string + parsed dict coexist). With 4 concurrent workflows, this exceeds any reasonable pod memory limit. ijson stream-parsing yields items one at a time, reducing the peak to +12 MB for the same data – a 95% reduction. The continue token appears in the metadata object (before or after items depending on the server); the event-driven parser captures it regardless of order.
  • If reversed: Large MRs (100+ components) would OOM the agent pod. Concurrent workflows would multiply the problem. The pod memory limit would need to scale with the largest possible page size, which is unbounded.

Action token tier for per-workflow identity

  • Chosen: A dedicated Tier 2 of workflow action tokens, separate from both orchestrator tokens (Tier 1) and model tool tokens (Tier 3). Each workflow gets a dedicated GitLab bot user per project for all write operations (notes, thread resolution).
  • Alternative: Extend orchestrator tokens with a per-workflow fallback chain (ORCHESTRATOR_<WORKFLOW>_GITLAB_TOKEN_<PROJECT> → ORCHESTRATOR_GITLAB_TOKEN_<PROJECT> → ORCHESTRATOR_GITLAB_TOKEN).
  • Why: Orchestrator tokens serve a fundamentally different purpose (operational reads + infrastructure notes) than workflow output (analysis results, review comments). Mixing them in one fallback chain risks a single bot identity posting notes on behalf of multiple workflows, eliminating the audit trail. A separate tier with explicit token: str parameters makes the code path unambiguous: callers must choose which tier they use. The naming convention (HUMMINGBIRD_AGENT_ACTION_<WORKFLOW>_GITLAB_TOKEN_<PROJECT>) groups tokens alphabetically by workflow, making env var auditing straightforward.
  • If reversed: All workflow notes would appear from the same bot user, making it impossible to distinguish which agent (code-review vs analyze-failures vs renovate-babysit) produced a given note in the MR timeline. GitLab’s audit log would attribute all actions to one identity.

Per-project-only action token config (no workflow-level default)

  • Chosen: action_tokens is defined at the project level only in YAML config. No workflow-level action_tokens default is supported.
  • Alternative: Allow workflow-level action_tokens that projects inherit by default, with per-project overrides.
  • Why: GitLab project access tokens are project-scoped. A workflow-level default would be misleading – a single token cannot write to multiple projects. Forcing per-project declaration makes the mapping explicit and prevents configuration errors where a cross-project default silently fails.
  • If reversed: Operators could set a workflow-level token that only works for one project, causing silent 403 errors for other projects in the same workflow.

SSH over kubectl exec for KubeVirt VMs

  • Chosen: SSH (ssh -T sandbox@ip command) for VM command execution.
  • Alternative: virtctl console (serial console), KubeVirt websocket API, or qemu-guest-agent exec.
  • Why: VMs have no kubectl exec equivalent. virtctl console provides a serial terminal, not a programmable exec channel. The websocket API requires a VNC or serial connection. qemu-guest-agent is not installed in the VM image. SSH provides clean stdin/stdout/stderr separation, timeout control, and is already well-tested in subprocess pipelines.
  • If reversed: Would need qemu-guest-agent in the image and the exec endpoint, which has poorer error reporting and no streaming stdin.

Secret volume for SSH key injection

  • Chosen: Kubernetes Secret mounted as a virtio disk (serial ssh-pubkeys) read by the VM’s inject-ssh-keys.service at boot.
  • Alternative: cloudInitNoCloud or accessCredentials with qemu-guest-agent.
  • Why: The VM image already has inject-ssh-keys.service which reads from the virtio disk. cloudInitNoCloud requires cloud-init in the image. accessCredentials requires qemu-guest-agent. The Secret volume approach requires no additional software in the VM image.
  • If reversed: Would need cloud-init or guest agent in the bootc image, adding complexity and attack surface.

VMIRS for VM pool replenishment

  • Chosen: VirtualMachineInstanceReplicaSet (VMIRS) as the replenishment controller for the VM pool.
  • Alternative: Custom controller, or VirtualMachine with RunStrategy.
  • Why: VMIRS is KubeVirt’s native equivalent of a Deployment’s ReplicaSet – it maintains a desired count of identical VMIs. When a VMI is claimed (ownerReferences cleared), the VMIRS creates a replacement automatically. Same pattern as the pod pool with Deployment.
  • If reversed: Would need a custom controller to maintain the VMI pool, adding operational complexity.

Per-workflow sandbox backend

  • Chosen: sandbox: field in workflow config, resolved per-workflow with fallback to CLI --sandbox flag.
  • Alternative: Global-only sandbox selection via CLI.
  • Why: Different workflows have different isolation requirements. Code review needs only a lightweight container, while failure analysis may need a full VM with root access. Per-workflow selection avoids running all workflows in VMs (wasteful) or all in containers (insufficient isolation).
  • If reversed: All workflows would share the same sandbox backend, requiring either over-provisioning (VMs for everything) or accepting weaker isolation for some workflows.

5.29 - Hummingbird Data Flow

Overview of all data sources, what is stored where, and how data moves between systems.


Data sources

Source What it provides
Jira REST API CVE tracker ticket fields (status, timestamps, labels, custom fields)
Jira whiteboard (customfield_10841) Per-ticket timestamp cache written back by the analysis tool
Red Hat Pulp (packages.redhat.com) SRPM/RPM publish timestamps for Hummingbird repos
Hummingbird container catalog API Image rebuild history (when a package first appeared in a rebuilt image)
Red Hat CSAF VEX feed (security.access.redhat.com) Advisory fix/not-affected status per CVE
OSIDB (osidb.prodsec.redhat.com) Subpackage-level affectedness; flaw created_dt and hummingbird-1 affect created_dt
NVD / CVE list CVE publish dates, product/version data
GitHub / GitLab Upstream fix commit / PR / release dates
Fedora updates Fedora update availability timestamps
Konflux SNS/SQS events Pipeline run, snapshot, release, MR, push events

PostgreSQL databases

There are two Postgres databases.

1. hummingbird-status — Konflux pipeline events

Fed by SNS/SQS events via the hummingbird-status ingestor.

Table Primary key What is stored
gitlab_pushes (sha, repo, ref) Git push events — sha, branch, commit list
components name Konflux component definitions and git context
pipelineruns name Build/test PLR outcomes, status, start/completion times
snapshots name Multi-component snapshots, source PLR, sha
releases name Release outcomes, images, LLM analysis text
gitlab_merge_requests (project, iid) Current MR state, merge commit sha
gitlab_mr_versions (project, iid, sha) Per-commit history of each MR head

The legacy tables above remain in public. The independent pipeline-timing service consumes a second SQS copy of each SNS envelope and writes only to its Alembic-managed pipeline_timing schema in the same database. The pipeline-timing Pod’s init container creates that schema and runs migrations before its worker container starts. The single-replica Deployment uses Recreate for upgrades. Scaling it to zero leaves the queue untouched without affecting legacy ingestion.

Timeline table Primary key What is stored
events SNS message ID Source, kind, timestamp, exact S3 bucket and key
merge_requests, commits, merge_request_commits GitLab project ID and source identities MR lifecycle and head/merge commits
gitlab_pipelines, gitlab_jobs GitLab project ID and pipeline/job ID Execution status and timing per attempt
pipeline_runs, task_runs, snapshots, releases Cluster and Kubernetes UID Konflux execution, bridge lineage, and Release completion

These tables store selected fields and event references, not raw event JSON. The archive retains the raw SNS envelope. Canonical HTTPS repository URL plus SHA links MR commits, GitLab pipelines, and Konflux PipelineRuns; project and pipeline IDs link GitLab jobs. Kubernetes owner UIDs link TaskRuns to PipelineRuns, while the Snapshot label and source run name connect build to test, and a Release names its Snapshot and managed PipelineRun. Distinct run UIDs and job IDs preserve retries. Consumers calculate elapsed intervals from the stored timestamps. The Dashboard continues to read only the public tables.

2. hummingbird-dashboard — CVE lifecycle + dashboard overlays

Table Primary key / unique What is stored
cve_ticket_events (ticket_key, event_type) One row per lifecycle milestone per ticket. occurred_at = timestamp; metadata = JSONB (see below). The source of all R-Time computations.
cve_analysis_log id Each analysis tool run: timestamp, ticket count, log output
cve_ticket_claims ticket_key Claim/lock for deduplicating concurrent analysis runs
cve_ticket_blame (ticket_key, category) unique Human-assigned R-Time blame categories (hummingbird, prodsec, konflux, testing, pulp, other); a ticket may have multiple
dashboard_settings key Runtime flags: auto-rerun enabled, analysis enabled, etc.
auto_rerun_log id History of automatic retrigger attempts
blocked_error_patterns id Regex patterns that suppress auto-rerun
blocked_snapshots snapshot_name Manually blocked snapshots
analysis_log / analysis_costs id LLM failure analysis runs and token costs
blocked_push_builds / push_build_rerun_log id Push build blocking and retry history
package_lifecycle (package, event_type) Package-scoped lifecycle milestones (e.g. rpm_first_published). Distinct from cve_ticket_events — one row per package/milestone, not per ticket.

cve_ticket_events lifecycle milestone event_type values

These are the canonical names after the HUM-5918 migration:

event_type Meaning
cve_published CVE published date (NVD / CVE list)
osidb_flaw_created OSIDB flaw created_dt (when ProdSec ingested the CVE)
osidb_affect_created hummingbird-1 affect created_dt for this ticket’s pscomponent (when a HUM tracker could be filed)
cve_embargo_lifted OSIDB flaw unembargo_dt, only when it postdates created_dt (i.e. the flaw was genuinely embargoed; omitted otherwise)
osidb_flaw_public OSIDB flaw unembargo_dt, unconditionally – when OSIDB considers the flaw public, whether genuinely embargoed or not
hum_ticket_created HUM Jira ticket creation date
hum_ticket_closed HUM Jira ticket resolution date
upstream_fix_merged Upstream fix commit / release merged
fedora_update_available Fedora update containing the fix became available
rpm_fix_published_to_pulp Fix RPM published to Hummingbird Pulp repo
image_rebuilt_on_quay Hummingbird container image rebuilt with the fix
vex_resolved Red Hat CSAF VEX advisory confirmed the resolution (HUM-5843)

rpm_fix_published_to_pulp and image_rebuilt_on_quay are mutable — they are updated when a newer delivery event supersedes the previous one. All other event types are write-once: once set, occurred_at is never overwritten.

cve_ticket_events.metadata JSONB fields

Each row carries a metadata blob that reflects the ticket’s state at the time of the most recent analysis run:

Field Source Description
computed_resolution analysis tool Hummingbird’s computed fix status
jira_status Jira status field Current Jira issue status
labels Jira labels field All labels on the ticket
fixed_in_build Jira customfield_10578 SRPM set manually in the “Fixed in Build” field
detected_fixed_build Pulp / SRPM detection SRPM filename auto-detected from Pulp repodata
vex_status Red Hat VEX feed fixed / known_not_affected / …
vex_match_state reconciliation logic matched / pending / mismatch
vex_resolved VEX reconciliation Timestamp when VEX first agreed with Jira resolution
catalog_image_source collector catalog map true when the package is a catalog image SBOM source (R-Time delivery is image publish); false means RPM publish. Missing on old rows: dashboard still requires image

Jira whiteboard (customfield_10841)

The CVE analysis tool uses the Jira whiteboard field as a per-ticket timestamp cache. This is being phased out in favour of Postgres as the durable store (HUM-5917), but is still the source for a subset of fields.

The whiteboard holds compact JSON. The relevant sub-key is cve_cycle:

cve_cycle key Meaning Mutable?
cve_published CVE publish date No — write-once
jira_created HUM ticket creation date No — write-once
jira_closed HUM ticket close date No — disabled (255-char limit)
upstream_fix Upstream fix timestamp No — write-once
fedora_fix Fedora update timestamp No — write-once
hb_rpm_fix RPM published to Pulp Yes — re-evaluated each run
hb_image_fix Image rebuilt on Quay Yes — re-evaluated each run

The whiteboard is read and written only by the CVE analysis tool. The dashboard never reads it directly; its authoritative source is always cve_ticket_events.


Jira fields read by the CVE analysis tool

On each run the analysis tool fetches every open HUM CVE tracker ticket and reads the following fields from the Jira REST API:

Jira field / custom field Purpose
summary Ticket title / package detection
status, resolution, resolutiondate Ticket lifecycle state
created Jira ticket creation date → hum_ticket_created
description, comment CVE ID extraction, fix evidence
labels cve-next-release, fix-in-progress, etc.
security Embargo level
assignee Ticket owner
customfield_10578 (Fixed in Build) Manually set SRPM identifying the fix build
customfield_10841 (Whiteboard) Cached cve_cycle timestamps (read + write)
customfield_10667 (CVE ID) Structured CVE identifiers
customfield_10860 (Embargo Status) Embargo flag
customfield_10020 (Sprint) Sprint membership
customfield_10014 (Epic Link) Parent epic

Data flow

┌─────────────────────────────────────────────────────────────────┐
│  External data sources                                          │
│                                                                 │
│  Jira REST API ─────────────────────────────────────┐           │
│  NVD / CVE list ────────────────────────────────────┤           │
│  GitHub / GitLab (upstream fix commits) ────────────┤           │
│  Fedora updates ────────────────────────────────────┤           │
│  Red Hat Pulp (RPM publish times) ─────────────────►│           │
│  Hummingbird catalog API (image rebuild times) ─────┤           │
│  Red Hat CSAF VEX feed ─────────────────────────────┤           │
│  OSIDB (subpackage affectedness) ───────────────────┘           │
└──────────────────────────────┬──────────────────────────────────┘
                               │
                               ▼
┌──────────────────────────────────────────────────────────────────┐
│  CVE analysis tool (cron, hummingbird-cve-analysis)              │
│                                                                  │
│  • Reads Jira ticket fields + whiteboard cache                   │
│  • Resolves fix SRPM from Pulp repodata                          │
│  • Resolves image rebuild timestamp from catalog API             │
│  • Checks upstream PR/release dates (GitHub/GitLab)              │
│  • Fetches CVE publish date (NVD/CVE list)                       │
│  • Reconciles VEX status (CSAF feed)                             │
│  • Checks subpackage affectedness (OSIDB)                        │
│                                                                  │
│  Writes back to:                                                 │
│  ├── Jira whiteboard (cve_cycle timestamp cache)                 │
│  ├── Jira labels / Fixed-in-Build field                          │
│  ├── Jira comments                                               │
│  └── dashboard DB → cve_ticket_events (one row per milestone)    │
│                      cve_analysis_log (run record)               │
└──────────────────────────────┬───────────────────────────────────┘
                               │
           ┌───────────────────┴───────────────────┐
           │                                       │
           ▼                                       ▼
┌──────────────────────────┐      ┌───────────────────────────────┐
│  Jira whiteboard         │      │  dashboard DB                 │
│  (per-ticket JSON cache) │      │  cve_ticket_events table      │
│  cve_cycle timestamps    │      │  one row per (ticket,         │
│  ← being phased out      │      │    event_type) milestone      │
│    (HUM-5917)            │      └───────────────┬───────────────┘
└──────────────────────────┘                      │
                                                  ▼
                               ┌──────────────────────────────────┐
                               │  hummingbird-dashboard API       │
                               │                                  │
                               │  Pivots cve_ticket_events rows   │
                               │  into per-ticket dicts, computes:│
                               │  • entry-level R-Time fields     │
                               │    (HUM-5920)                    │
                               │  • duration legs inside stages   │
                               │    (HUM-5921)                    │
                               │                                  │
                               │  Serves JSON + Jinja templates   │
                               └──────────────────────────────────┘
flowchart LR
    EVENTS[GitLab pushes, pipelines, jobs, MRs<br/>Kubernetes PipelineRuns, TaskRuns, Snapshots, Releases]
    SNS[(SNS topic)]
    ARCHIVER[S3 archiver]
    S3[(Deterministic archive)]
    STATUS[hummingbird-status ingestor]
    STATUS_DB[(hummingbird-status DB)]
    DASHBOARD[Dashboard API]
    TIMELINE[Pipeline timing queue and worker]
    TIMELINE_DB[(pipeline_timing schema)]

    EVENTS --> SNS
    SNS --> ARCHIVER --> S3
    SNS --> STATUS --> STATUS_DB --> DASHBOARD
    SNS --> TIMELINE --> TIMELINE_DB

Kubernetes TaskRun transport

The Kubernetes event forwarder applies optional resource labelSelector and fieldSelector values to both its initial LIST and subsequent Watch requests. Field-selector support depends on the Kubernetes resource and API server; TaskRun collection should use a confirmed stable RPM application label instead of a status field selector.

The staging and production forwarders watch the configured resource kinds in both hummingbird-tenant and rhtap-releng-tenant, including TaskRuns in each namespace. Events go through each environment’s SNS topic and existing SQS copies; its Pipeline Timing worker consumes its dedicated queue. Because the resource list applies to each kubeconfig context, the managed context also watches the configured PipelineRun, Snapshot, Release, and Component kinds there. The TaskRun entry has no selector, so both forwarders list and watch all TaskRuns in both namespaces until a stable RPM application label is confirmed or namespace-wide volume is approved.

The forwarder publishes the logical kubeconfig context.cluster value, rather than the API server URL, with each Kubernetes event. For TaskRuns, it removes only metadata.managedFields before gzip/base64 encoding; identity, owner references, labels, annotations, task specification, conditions, results, pod name, and timing remain in the published and archived payload.


API field names

The dashboard API exposes these computed fields per R-Time entry:

Entry-level fields

API field Meaning
cve_published_at Start of R-Time: CVE published, falling back to HUM created when missing (Include CVE-HUM); the later of HUM created and CVE published (Exclude, so an embargoed ticket also starts at disclosure); either way, then floored at Upstream Fix Merged so the clock never starts before a fix exists to backport
fix_delivered_at Done timestamp: latest of delivery and Jira close (Exclude VEX), plus VEX when Include VEX is on; unset until the required gates exist. Delivery is image publish for catalog image sources, RPM publish otherwise
cve_to_delivery_hours Completed R-Time only (cve_published_at → fix_delivered_at); None while still accumulating
cve_to_hum_created_hours Filing lag after NVD: max(HUM created − NVD, 0); 0.0 when HUM is first
advisory_to_vex_hours Completed ADV-VEX only (hum_ticket_closed → vex_resolved); None until VEX exists
display_advisory_to_vex_hours ADV-VEX hours, or elapsed-to-now when the VEX feed has not updated yet
fix_before_cve_published True when the delivery timestamp predates notification (informational)
hum_ticket_open True until the selected done gates exist
deferred_to_next_release True for cve-next-release tickets
display_duration_hours R-Time hours, or elapsed-to-now when the ticket is not yet done

Duration legs (stages dict)

API field Interval
cve_published_to_osidb_flaw CVE publish → OSIDB flaw created
osidb_flaw_to_osidb_affect OSIDB flaw created → hummingbird-1 affect created
osidb_affect_to_hum_created hummingbird-1 affect created → HUM ticket opened
cve_published_to_hum_created CVE publish → HUM ticket opened
cve_published_to_upstream_fix CVE publish → upstream fix merged
hum_created_to_upstream_fix HUM ticket → upstream fix merged
upstream_fix_to_fedora_update Upstream fix → Fedora update available
fedora_update_to_rpm_fix_published Fedora update → fix RPM in Pulp
upstream_fix_to_rpm_fix_published Upstream fix → fix RPM in Pulp
hum_created_to_rpm_fix_published HUM ticket → fix RPM in Pulp
rpm_fix_published_to_image_rebuilt Fix RPM in Pulp → image rebuilt on Quay
image_rebuilt_to_hum_closed Image rebuild → HUM ticket closed
hum_closed_to_vex_resolved HUM ticket closed (advisory MR merge) → VEX feed update

Derived flags (stages dict)

osidb_flaw_unavailable is computed in _build_stages(), not persisted to cve_ticket_events: True only when osidb_affect_created is present but osidb_flaw_created is absent – OSIDB demonstrably knows the CVE (an affect was found) but has no public Flaw record for it. A ticket missing all three OSIDB fields is ambiguous (the collector may predate these fields, or OSIDB may have no data yet) and is not flagged. When true, _timeline_events() prepends a synthetic osidb_flaw_public entry with no real timestamp, displayed as “INTERNAL” and always first in the Stage Breakdown’s timestamp grid on the Open, Blame, and R-Time pages – a flaw must exist before any public OSIDB/CVE activity can reference it, so it precedes every other milestone even without a known date. It is intentionally excluded from the chronological bar graph (stages.timeline), which only plots real timestamps.

osidb_data_missing is the weaker, mutually exclusive counterpart: True when OSIDB has neither a Flaw nor an Affect record for the CVE – stating a fact (no OSIDB data found), not a cause, since this can simply mean OSIDB has not yet ingested a very recently published CVE. Rendered as a small “osidb-missing” badge next to the ticket key in the collapsed table row (the same spot and style as the R-Time page’s budget-excluded badge), not inside the Stage Breakdown drawer, on the Open, Blame, and R-Time pages.

Aggregate stats

API field Meaning
avg_hum_created_to_rpm_fix_hours Mean HUM ticket → RPM fix (skips missing legs)
avg_rpm_fix_to_image_rebuilt_hours Mean RPM publish → image rebuild
avg_advisory_to_vex_hours Mean Done-Errata close → VEX feed (completed ADV-VEX only)

Package lifecycle data

rpm_first_published is the earliest timestamp at which any SRPM for a given package appeared in the Hummingbird Pulp repo. It is package-scoped — one row per package — as opposed to cve_ticket_events which is ticket-scoped.

Collection

scripts/collect_rpm_first_published --prod --rpms-repo /path/to/rpms

Flow:

  1. Loads the package list from rpms_repo.load_package_map_from_metadata().
  2. For each package: calls pulp.fetch_earliest_srpm_time(), which browses packages.redhat.com/.../source/Packages/{letter}/, parses the HTML listing already used by the analysis tool, and returns the earliest upload timestamp across all SRPMs for that package.
  3. POSTs all results in one request to /api/cve-import (same endpoint used for cve_ticket_events sync). The dashboard upserts with COALESCE(LEAST(existing, incoming), existing, incoming) — re-runs only update occurred_at if the incoming timestamp is earlier, and NULLs are never stored over a real value.

Surfaced in dump_lifecycle

scripts/dump_lifecycle --prod HUM-1234

After fetching ticket milestones from cve_ticket_events, the script resolves the package name from those rows and makes a second call to /api/cve-export?section=package_lifecycle to fetch rpm_first_published for that package. It is shown at the bottom of the text output and under rpm_first_published in the JSON output.

5.30 - Kubernetes Event Forwarder

A Kubernetes deployment that watches resource changes (ADDED/MODIFIED/DELETED) across multiple clusters and forwards them to an SNS topic with structured metadata for filtering. Uses kubeconfig contexts as the source of truth for which clusters and namespaces to watch.

The full Kubernetes object JSON is forwarded as the SNS message body, compressed with gzip and base64-encoded.

The staging and production forwarders watch the configured resources in both hummingbird-tenant and rhtap-releng-tenant on RH03. TaskRun collection is enabled in both namespaces and environments.

For TaskRun events only, Kubernetes metadata.managedFields is removed before compression. All other object data, including identity, owner references, labels, annotations, task specification, conditions, results, pod name, and timing is retained.

Features

  • Multi-Cluster Support: Watch resources across multiple Kubernetes clusters using kubeconfig contexts
  • Dynamic Resource Watching: Configure any namespaced resource type using standard Kubernetes apiVersion and kind
  • SNS Integration: Publish events with structured message attributes for precise filtering
  • Compression: Events are compressed (gzip+base64) to reduce SNS message size
  • Selectors: Resource-specific Kubernetes label and field selectors limit LIST and Watch requests
  • Automatic Reconnection: Handles watch connection failures and reconnects automatically

Architecture

Threading Model

The forwarder uses a multi-threaded architecture:

  • Watcher threads (one per context + resource type): Each runs an independent LIST+Watch loop. Isolation ensures one slow/failing cluster doesn’t affect others.

  • Publisher thread (single, shared): Reads events from a queue and publishes to SNS. Decouples K8s API interaction from SNS latency (~150ms per publish), preventing watch loop stalls that could cause resourceVersion staleness.

Memory optimization: When SPOOL_DIR is set, messages are written to temporary files instead of being held in memory. This reduces memory pressure during event bursts (e.g., LIST operations or many resources created at once) and enables recovery of unsent messages after restarts (e.g., after OOM kills).

LIST + Watch Pattern

The forwarder uses explicit LIST followed by Watch rather than resource_version="0":

  1. LIST retrieves all current objects and a snapshot resourceVersion
  2. Watch starts from the LIST’s resourceVersion (not object RVs)
  3. On graceful watch timeout (300s), restart watch from last known RV (no re-list)
  4. On errors, fresh LIST to ensure current state

This is necessary because synthetic ADDED events from resource_version="0" have unsorted, potentially-stale resourceVersions (they reflect when objects were last modified). If the watch disconnects mid-stream, the tracked RV could be arbitrarily old and may already be compacted by etcd, causing a 410 error cascade.

Prerequisites

  • Target cluster: ServiceAccount with watch permissions on the resources you want to monitor
  • Deployment cluster: Kubernetes cluster to run the forwarder
  • AWS credentials: SNS publish permissions (optional - logs events if not configured)

Deployment

The container image is built via Konflux CI/CD and published to quay.io/hummingbird-ci/kubernetes-event-forwarder:latest.

Example Kubernetes manifests are provided in the kubernetes/ directory:

  • rbac.yaml - ServiceAccount, Role, RoleBinding for the target cluster
  • secret.yaml - Kubeconfig and AWS credentials
  • configmap.yaml - Resource watch configuration
  • deployment.yaml - Forwarder deployment

Quick Start

  1. On the target cluster (the one you want to watch), apply RBAC and create a token:

    kubectl apply -f kubernetes/rbac.yaml
    kubectl create token kubernetes-event-forwarder --duration=8760h
    
  2. Update the example manifests with your values:

    • secret.yaml: cluster URL, token, AWS credentials
    • configmap.yaml: resources to watch
    • deployment.yaml: SNS topic ARN, AWS region
  3. Apply the manifests to your deployment cluster:

    kubectl apply -f kubernetes/secret.yaml
    kubectl apply -f kubernetes/configmap.yaml
    kubectl apply -f kubernetes/deployment.yaml
    

Prerequisites: Deploy hummingbird-events-topic first to create the SNS topic, then deploy the AWS resources (see below).

AWS Resources

The SAM template (template.yaml) provisions IAM resources for SNS publishing:

  • IAM User (${ResourcePrefix}-user) - Service account for the forwarder
  • IAM Policy (${ResourcePrefix}-policy) - Grants sns:Publish to the SNS topic

Deploy using containerized AWS SAM CLI:

cd kubernetes-event-forwarder
sam build
sam deploy --guided  # First deployment (interactive)
sam deploy           # Subsequent deployments

After deployment, create access keys for the IAM user and store them securely.

SAM Parameters

Parameter Description Default
ResourcePrefix Prefix for resources myapp-prod
SnsTopicArn SNS topic ARN (required)

Resource naming: IAM resources follow {ResourcePrefix}-{type} pattern (e.g., myapp-prod-user, myapp-prod-policy).

Usage

Configure resources to watch in config.yaml:

resources:
  - apiVersion: v1
    kind: Pod
    labelSelector: app=example
    fieldSelector: status.phase=Running
  - apiVersion: apps/v1
    kind: Deployment
  - apiVersion: v1
    kind: ConfigMap

labelSelector and fieldSelector are optional per-resource settings and use standard Kubernetes selector syntax. Field-selector support is resource and API server dependent; do not use a TaskRun status field selector. Use a stable RPM application label for TaskRuns only after confirming its propagation in the target environment. The deployed resource list applies to every kubeconfig context, so both forwarders watch all configured kinds in both namespaces. The TaskRun entry has no selector and therefore watches all TaskRuns in both namespaces. Confirm a stable RPM application label and representative payload sizes; otherwise review and approve the namespace-wide event rate. HUM-7532 provides the managed namespace access, and HUM-7535 owns environment-specific infrastructure.

SNS Subscription Filter Examples:

Pod events in a specific namespace:

{
  "source": ["kubernetes"],
  "kind": ["Pod"],
  "namespace": ["production"]
}

All deployment changes:

{
  "source": ["kubernetes"],
  "kind": ["Deployment"]
}

Deleted resources across all clusters:

{
  "source": ["kubernetes"],
  "event_type": ["DELETED"]
}

Development

See the main README for development workflows.

make kubernetes-event-forwarder/setup  # Install dependencies
make check                             # Lint code (ruff)
make fmt                               # Format code
make test                              # Run unit tests
make coverage                          # Run tests with coverage

Configuration

The deployment is configured via environment variables:

Variable Description
CONFIG_PATH Path to config YAML file
CONFIG Inline config YAML (alternative)
SNS_TOPIC_ARN SNS topic ARN (optional - logs if unset)
SPOOL_DIR Optional spool directory for file-based message queue
AWS_ACCESS_KEY_ID AWS access key ID
AWS_SECRET_ACCESS_KEY AWS secret access key
AWS_DEFAULT_REGION AWS region
METRICS_PORT Prometheus metrics port (default: 9090)
SENTRY_DSN Optional Sentry DSN

Event Metadata

The forwarder extracts metadata from Kubernetes events and adds them as SNS message attributes:

Attribute Description Example
source Always "kubernetes" kubernetes
cluster Logical kubeconfig cluster entry name konflux-rh03
namespace Object namespace production
api_version Resource API version v1, apps/v1
kind Resource kind Pod, Deployment
event_type Event type ADDED, MODIFIED, DELETED
object_name Name of the object nginx-7d8c4c9d6f
content_encoding Message encoding gzip+base64

cluster is the context.cluster value for the kubeconfig context that the forwarder uses to watch the resource. It is not the API server URL.

Security & Limitations

Security:

  • AWS credentials stored in Kubernetes Secret
  • Kubeconfig credentials stored in Kubernetes Secret
  • SNS topic follows least privilege principle (publish-only)
  • Sentry integration for error tracking

Limitations:

  • Only namespaced resources supported
  • One thread per context + resource type combination
  • SNS message size limit: 256 KB (after compression)
  • SNS measures the UTF-8 gzip/base64 message plus attribute names, types, and values against its 262,144-byte limit. The forwarder logs each computed size and remaining headroom and exports hummingbird_event_forwarder_sns_publish_size_bytes. Review representative RPM build, test, and tenant-release TaskRun measurements before enabling the source; do not remove further fields without an approved projection.
  • The source-tree fixtures in kubernetes-event-forwarder/tests/fixtures/ are synthetic, redacted RPM build, test, and tenant-release TaskRun regression fixtures. Their unit test measures the complete publish body and attributes and requires at least 32 KiB of headroom. HUM-7533 owns source-activation measurement review.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.31 - Jira Image Requests

AWS Lambda proxy that serves Hummingbird Image Request issues from Jira (with DynamoDB caching) and creates Stories/Epics from the catalog request form.

Features

  • Public list: GET /image-requests returns Ready Image Request issues
  • Form submit: POST /image-requests creates a version Story. An Epic is created only when a second request uses the same image name; later requests reuse that Epic. A first-time name is Story-only.
  • Customer scoring: Writes the Customer criterion on the Epic from paid email domains (placeholder until subscriber login)
  • Total scoring: Maintained by Jira Automation from manual triage fields

Prerequisites

  • AWS CLI with permissions for Lambda, API Gateway, DynamoDB, SSM, CloudFormation
  • Podman or Docker for SAM build
  • Jira bot token in SSM (SecureString)
  • HUM project custom fields for scoring (see below)

Deployment

make jira-image-requests/build
make jira-image-requests/deploy

Configuration

Parameter / env Description
ResourcePrefix Prefix for AWS resource names
JiraTokenParameter SSM parameter name for the Jira API token
CacheTtlSeconds Cache TTL for the public list (default 300)
CorsAllowOrigin Allowed CORS origin
ScoringCustomerField Jira custom field id for Customer (customfield_…); empty skips write
PaidCustomerDomains Comma-separated email domains scored as paid (e.g. acme.com,contoso.com)
SentryDsn Optional Sentry DSN

Image request scoring

Triage scores live on the Image Request Epic, not on customer-facing Stories.

Custom fields (HUM Epics)

Create (or map) these fields, then record each customfield_XXXXX id:

Field Type / values Who sets it
IR Legal Select: Y / N Manual
IR Competitive Number (typically 0–1) Manual
IR Marketable Number Manual
IR Supportability Number Manual
IR Upstream Health Number Manual
IR Level of Effort Number: 3 easy, 2 medium, 1 hard Manual
IR Technical Feasibility Number: 0 blocking, 1 feasible Manual
IR Portfolio Conflicts Number: 0 conflict, 1 good Manual
IR Customer Number: 0 or 1 Lambda (ScoringCustomerField)
IR Total Number Jira Automation only

Gate: Legal must be Y to continue scoring. Competitive, Marketable, and Supportability / Maturity are also expected to be 1 before a meaningful Total (team convention from the triage spreadsheet).

Lambda: Customer criterion

When a POST creates or reuses an Epic (second or later request for a name), the Lambda:

  1. Loads Stories under the Epic
  2. Sets Customer to 1 if any submitter email domain is in PaidCustomerDomains, otherwise 0
  3. Writes the value to ScoringCustomerField when configured

A first request for a name does not create an Epic, so Customer is not written until a second request for that name arrives.

When subscriber login lands, replace the domain list with real subscription status.

Configure two rules in Jira (Project settings → Automation). Use the real field names/ids from your instance.

  • Trigger: Field value changed for any of: Competitive, Marketable, Supportability, Upstream Health, Level of Effort, Technical Feasibility, Portfolio Conflicts, Customer (and optionally Legal)
  • Condition: Issue type = Epic AND component = Image Request AND IR Legal = Y
  • Action: Edit issue → set IR Total to the sum of the numeric criteria (exclude Legal). Example smart value shape:
{{#=}}
{{issue.IR Competitive}} + {{issue.IR Marketable}} + {{issue.IR Supportability}} +
{{issue.IR Upstream Health}} + {{issue.IR Level of Effort}} +
{{issue.IR Technical Feasibility}} + {{issue.IR Portfolio Conflicts}} +
{{issue.IR Customer}}
{{/}}

Use your instance’s smart-value field keys (often customfield_XXXXX).

  • Optional action: Remove label ir-legal-blocked when Legal is Y
  • Trigger: IR Legal changed
  • Condition: Issue type = Epic AND component = Image Request AND IR Legal is empty OR IR Legal = N
  • Actions:
    1. Clear IR Total (or set to empty)
    2. Add label ir-legal-blocked

Jira Automation recalculates Total whenever triage fields change — no webhook or Lambda rescoring path is required.

Development

make jira-image-requests/setup
make test

License

GPL-3.0-or-later

5.32 - PAC Trigger

A CLI tool to manually trigger Konflux PAC (Pipelines-as-Code) pipelines for a specific component and branch. Useful for debugging pipeline issues or re-running builds without pushing a new commit.

Usage

# Trigger pipeline for a component (uses branch HEAD)
pac-trigger --gitlab-project-url https://gitlab.com/org/group/project \
            --component myimage--default--main \
            --cluster-url https://konflux-ui.apps.cluster.example.com

# Trigger with specific commit
pac-trigger --gitlab-project-url https://gitlab.com/org/group/project \
            --component myimage--default--main \
            --commit abc123def456 \
            --cluster-url https://konflux-ui.apps.cluster.example.com

# Dry run (preview without creating)
pac-trigger --gitlab-project-url https://gitlab.com/org/group/project \
            --component myimage--default--main \
            --cluster-url https://konflux-ui.apps.cluster.example.com \
            --dry-run

Options

Option Description Default
--gitlab-project-url GitLab project URL (required) -
--component Component name (required) -
--branch Branch name main
--commit Specific commit SHA HEAD
--cluster-url Konflux cluster URL (required) -
--dry-run Preview PipelineRun without creating false
-v, --verbose Enable debug logging false

Installation

cd pac-trigger
pip install -e .

Prerequisites

  • Kubeconfig: Context with access to the target Konflux cluster/namespace
  • Repository resource: PAC Repository must exist for the GitLab project
  • Push template: Component must have a push template in .tekton/*.yaml

How It Works

  1. Fetch commit: Gets HEAD commit SHA from GitLab (or uses provided commit)
  2. Fetch template: Downloads .tekton/*.yaml files via GitLab API, finds push template matching the component
  3. Get namespace: Extracts namespace from template metadata
  4. Find credentials: Looks up PAC Repository resource to find git secret
  5. Create git-auth secret: Creates ephemeral pac-trigger-gitauth-* secret with git credentials
  6. Create PipelineRun: Substitutes template variables and creates the PipelineRun
  7. Link secret: Sets ownerReference on secret for garbage collection
  8. Print URL: Outputs Konflux UI URL for the PipelineRun

Template Variables

The following PAC template variables are supported:

Variable Substituted with
{{revision}} Commit SHA
{{target_branch}} Branch name
{{repo_url}} GitLab project URL
{{git_auth_secret}} Created secret name

Unsupported template variables will cause an error.

Labels Added

Label Value Purpose
pac-trigger/manual true Identifies manual triggers

Features

  • Template-based: Fetches PipelineRun templates from .tekton/*.yaml files via anonymous GitLab API
  • Push-only: Only triggers push templates (filters by CEL expression)
  • Automatic credentials: Creates ephemeral git-auth secrets from existing PAC Repository secrets
  • Garbage collection: Secrets are linked to PipelineRun via ownerReference
  • Kubeconfig-based: Uses local kubeconfig for cluster authentication

Development

See the main README for development workflows.

make pac-trigger/setup  # Install dependencies
make check              # Lint code (ruff)
make fmt                # Format code
make test               # Run unit tests
make coverage           # Run tests with coverage

Comparison with PAC-triggered Runs

Manually triggered PipelineRuns differ from PAC-triggered ones:

Present in both:

  • appstudio.openshift.io/application
  • appstudio.openshift.io/component
  • pipelines.appstudio.openshift.io/type: build
  • build.appstudio.redhat.com/commit_sha
  • build.appstudio.redhat.com/target_branch

Unique to pac-trigger:

  • pac-trigger/manual: true label

Missing (PAC internal metadata):

  • pipelinesascode.tekton.dev/event-type
  • pipelinesascode.tekton.dev/sha
  • GitLab status reporting annotations

These differences don’t affect pipeline execution—they’re used for PAC’s internal tracking and GitLab commit status updates.

Limitations

  • Only push templates supported (not pull-request)
  • Only GitLab repositories supported
  • Requires existing PAC Repository resource
  • No GitLab commit status reporting

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.33 - Hummingbird Status

SQS worker and database management for ingesting Konflux pipeline events.

Features

  • SQS Worker - Real-time database updates from SNS pipeline events
  • GitLab Event Ingestion - Push events and merge request events with version tracking
  • Database Init - Populate from pg_dump, S3 archive, or local filesystem mirror
  • Prometheus Metrics - Built-in metrics endpoint for monitoring
  • MR Reconciliation - Fix stale MR state by checking GitLab API
  • Schema Management - Idempotent database schema creation

Pipeline timing and lineage are ingested by the separate Pipeline Timing service into a dedicated schema in the same events database. The Status worker ignores pipeline, job, and TaskRun events it does not store in public.

Prerequisites

  • Python 3.11+
  • PostgreSQL 16
  • AWS credentials (for SQS worker and S3 initialization)

Installation

cd hummingbird-status
pip install -e .

Usage

Local Development

cd hummingbird-status

# Database management
./dev.sh db-start              # Start PostgreSQL container
./dev.sh db-init               # Initialize from available source
./dev.sh db-shell              # PostgreSQL interactive shell
./dev.sh db-dump               # Create pg_dump file
./dev.sh db-stop               # Stop PostgreSQL
./dev.sh db-reset              # Stop and delete volume

# With data source
SNS_MIRROR=/path/to/mirror ./dev.sh db-init   # From filesystem
S3_BUCKET=bucket-name ./dev.sh db-init        # From S3

# SQS worker (requires credentials)
SQS_QUEUE_URL=https://... ./dev.sh worker

Container Deployment

The container runs the SQS worker by default:

podman build -f Containerfile -t hummingbird-status .
podman run -e DATABASE_URL=... -e SQS_QUEUE_URL=... hummingbird-status

For database initialization:

podman run -e DATABASE_URL=... -e S3_BUCKET=... \
    hummingbird-status python3 -m hummingbird_status.worker.init_db

MR Reconciliation

MR state is updated via GitLab webhooks. If a webhook is lost, an MR can appear as “opened” in the dashboard when it is actually merged or closed. The reconcile command checks all open MRs against the GitLab API and corrects stale entries:

# Using environment variables
GITLAB_TOKEN=glpat-... python -m hummingbird_status.reconcile

# Using CLI arguments
python -m hummingbird_status.reconcile \
    --database-url postgresql://... \
    --gitlab-token glpat-...

In a container:

podman run -e DATABASE_URL=... -e GITLAB_TOKEN=... \
    hummingbird-status python3 -m hummingbird_status.reconcile

Configuration

Environment Variables

Variable Default Description
DATABASE_URL - PostgreSQL connection URL
SQS_QUEUE_URL - SQS queue URL (worker)
S3_BUCKET - S3 bucket for init
S3_PREFIX sns/ S3 key prefix
LOCAL_MIRROR_PATH /data/sns-mirror Local S3 mirror path
INIT_DUMP_PATH /data/init/dump.sql pg_dump file path
GITLAB_URL https://gitlab.com GitLab instance URL (reconcile)
GITLAB_TOKEN - GitLab API token (reconcile)
METRICS_PORT 9090 Prometheus metrics port

Database Initialization Priority

  1. pg_dump file - $INIT_DUMP_PATH if exists
  2. Local S3 mirror - $LOCAL_MIRROR_PATH/sns/*.json.gz if exists
  3. S3 bucket - $S3_BUCKET/$S3_PREFIX with AWS credentials

Database Schema

The database stores Konflux pipeline events and GitLab notifications (pushes and MRs).

flowchart TD
    push[["<b>gitlab_pushes</b><br/>commit sha, changed files"]]
    mr[["<b>gitlab_merge_requests</b><br/>current MR state"]]
    mrv[["<b>gitlab_mr_versions</b><br/>head commit history"]]
    comp[["<b>components</b><br/>git_context → component mapping"]]
    build[["<b>pipelineruns</b> (type=build)<br/>one per affected component"]]
    snap[["<b>snapshots</b><br/>image digest, links via source_plr"]]
    test[["<b>pipelineruns</b> (type=test)<br/>integration tests per snapshot"]]
    rel[["<b>releases</b><br/>publish to registry, links to snapshot"]]
    relplr[["<b>pipelineruns</b> (type=release)<br/>executes release, linked via release_plr"]]

    push -- "+ affected" --> build
    mr -- "sha" --> mrv
    mrv -- "sha joins" --> build
    comp -- "components" --> build
    build -- "success<br/>creates" --> snap
    snap -- "triggers" --> test
    test -- "success<br/>creates" --> rel
    rel -- "managed<br/>by" --> relplr

Tables

Table Primary Key Description
gitlab_pushes sha,repo,ref GitLab push events to main branch
gitlab_merge_requests project,iid Current state of merge requests
gitlab_mr_versions project,iid,sha Head commit history for each MR
components name Konflux Component resources
pipelineruns name Build, test, and release pipelines
snapshots name Image snapshots after successful builds
releases name Published releases to target registry

Merge Request Tracking

The MR tables enable tracking build status across MR versions:

  • gitlab_merge_requests - Stores current MR metadata (title, state, branches, author, latest head SHA). Updated via ON CONFLICT ... WHERE updated_at < to keep the most recent state. author_name captures the webhook user.name alongside author (the username) so the dashboard can show a bot/access-token account’s real GitLab name (e.g. “chore-mr”) instead of its generated username – like author, it’s set once and preserved via COALESCE on conflict, so it isn’t backfilled for MRs that predate this column.

  • gitlab_mr_versions - Records each unique head commit SHA for an MR. When force-pushing the same SHA, created_at is updated to the latest event timestamp via GREATEST(), ensuring correct ordering even after force pushes.

Development

Running Tests

cd hummingbird-status
pip install -e ".[dev]"
pytest

Project Structure

hummingbird-status/
├── Containerfile
├── dev.sh
├── template.yaml         # SAM template for AWS resources
├── hummingbird_status/
│   ├── db.py             # Database schema and utilities
│   ├── ingest.py         # SNS event parsing and ingestion
│   ├── reconcile.py      # MR state reconciliation with GitLab
│   └── worker/
│       ├── sqs.py        # SQS consumer
│       └── init_db.py    # Database initializer
└── tests/

AWS Resources

Deploy the SQS queue using SAM:

cd hummingbird-status
make build     # Build SAM application
make deploy    # First deployment (guided)
make redeploy  # Subsequent deployments

Parameters

Parameter Description Default
ResourcePrefix Prefix for all resource names myapp-prod
SnsTopicArn ARN of the SNS topic to subscribe (required)

Prerequisites: Requires an existing SNS topic. Deploy hummingbird-events-topic first.

See the main README for development workflows.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.34 - Lambda S3 Cache

An AWS Lambda setup that caches files from public URLs in S3, supporting both HTTP file caching (RPMs, repository metadata) and OCI Distribution API v2 (container image manifests and blobs). When a URL is requested, the service returns a 302 redirect to either the cached S3 copy (via presigned URL) or the original source. Cache misses trigger asynchronous downloads to S3, ensuring future requests are served from the cache.

Caching uses the original URL’s host and path as the S3 key for HTTP content, and a flat content-addressed layout (cache/oci/<digest>) for OCI artifacts. Frequently accessed content automatically extends its cache lifetime on each access.

Note: Cached content behavior depends on file type:

  • Immutable content (matching immutable extension filter, e.g., .rpm): Changes at origin won’t be reflected until cache expires
  • Mutable content (non-matching extension, e.g., repomd.xml): Cache is revalidated on each request via HEAD check; stale behavior is configurable

Cache Revalidation: Mutable content uses HTTP ETags for efficient validation. The uploader stores the origin’s ETag in S3 metadata when caching content. On subsequent requests, the handler sends a HEAD request with If-None-Match header containing the stored ETag. If the origin responds with 304 Not Modified, the cache is fresh and served directly. If the ETag differs, the cache is stale and an async refresh is triggered; response behavior depends on StaleCacheBehavior.

Note: If the origin doesn’t provide ETags, the cache cannot validate freshness. In this case, mutable content always redirects to origin without triggering cache updates (caching would be ineffective since every request would redirect anyway).

Features

  • Streaming Upload: Handles files efficiently by streaming directly from source to S3 without loading into memory
  • Presigned S3 URLs: Returns short-lived signed URLs for cached content
  • Automatic Expiration: Cached content expires after a certain time, with lifetime extended on each access
  • URL Prefix Allowlist: Only caches content matching explicitly allowed host+path prefixes
  • Immutable Extension Filter: Identifies immutable content that doesn’t require revalidation
  • Cache Revalidation: Mutable content (non-matching extensions) is validated on each request; stale cache behavior is configurable (origin or cache)
  • OCI Image Caching: Caches OCI container image manifests and blobs via the OCI Distribution API v2, protecting against upstream digest garbage collection
  • Multi-Registry Support: OCI caching supports multiple upstream registries by encoding the registry hostname in the request path
  • Custom Domain: Optional custom domain with automatic TLS certificate management via ACM and Route53

Architecture

Three Lambda functions handle the caching workflow:

  1. Handler - API Gateway endpoint that checks cache, returns 302 redirects, and triggers async operations. Implements touch cooldown to prevent S3 throttling by only touching objects after a configurable time period has elapsed since last modification.
  2. Uploader - Downloads from origin and streams to S3 on cache misses (invoked asynchronously)
  3. Touch - Updates S3 object timestamps to extend cache lifetime on cache hits (invoked asynchronously only when cooldown period has elapsed)

Request Flow

flowchart TD
    Start([Request]) --> CheckURL{URL matches<br/>AllowedPrefixes?}

    CheckURL -->|No| RedirectOrigin
    CheckURL -->|Yes| HeadS3[HEAD S3]:::network
    HeadS3 --> CheckCache{Cache exists?}

    CheckCache -->|No: Cache Miss| InvokeUploaderMiss[Invoke Uploader async]:::async
    CheckCache -->|Yes: Cache Hit| CheckImmutable{Immutable extension?}

    CheckImmutable -->|No: Mutable| HeadOrigin[HEAD Origin<br/>If-None-Match: stored ETag]:::network
    HeadOrigin --> CheckChanged{Origin changed?}

    CheckChanged -->|No ETag from origin| RedirectOrigin
    CheckChanged -->|ETag differs| InvokeUploaderStale[Invoke Uploader async]:::async
    CheckChanged -->|ETag matches| CheckCooldown
    CheckChanged -->|304 Not Modified| CheckCooldown
    CheckChanged -->|"Error/timeout"| CheckCooldown

    CheckImmutable -->|Yes| CheckCooldown

    CheckCooldown{Touch cooldown<br/>elapsed?} -->|Yes| InvokeTouch[Invoke Touch async]:::async

    InvokeUploaderMiss --> RedirectOrigin
    InvokeUploaderStale -->|"STALE_CACHE_BEHAVIOR=origin"| RedirectOrigin

    InvokeUploaderStale -->|"STALE_CACHE_BEHAVIOR=cache"| RedirectCache
    CheckCooldown -->|No| RedirectCache
    InvokeTouch --> RedirectCache

    subgraph redirectGroup [ ]
        RedirectOrigin[302 to Origin URL]:::redirect
        RedirectCache[302 to S3 Presigned URL]:::redirect
    end
    style redirectGroup fill:none,stroke:none

    classDef network fill:#10b981,color:#000
    classDef async fill:#ff9900,color:#000
    classDef redirect fill:#3b82f6,color:#fff

Legend: Green = network call, Orange = async Lambda invocation, Blue = 302 redirect response

Prerequisites

  • AWS CLI configured with appropriate credentials (IAM permissions for Lambda, API Gateway, S3, CloudFormation, CloudWatch Logs, and optionally Route53/ACM for custom domain)
  • Podman or Docker (for containerized SAM build/deploy)
  • Python 3.11 or later (for development)

Deployment

Build and deploy using containerized AWS SAM CLI:

make lambda-s3-cache/build     # Build Lambda package
make lambda-s3-cache/deploy    # First deployment (interactive/guided)
make lambda-s3-cache/redeploy  # Subsequent deployments (non-interactive)

Deployment output: ApiEndpoint - the API Gateway URL to use for requests

Custom Domain

Optional custom domain with automatic TLS certificate management (ACM + Route53). Requires a Route53 hosted zone. Deploy with CustomDomainName and HostedZoneId parameters - CloudFormation handles certificate creation, DNS validation, and configuration. Certificate validation takes 5-30 minutes; allow up to 1 hour for DNS propagation.

Parameters

Parameter Description Default
ResourcePrefix Prefix for all resource names myapp-prod
PresignedUrlExpiration Presigned URL expiration (seconds) 3600
AllowedPrefixes Whitespace-separated list of allowed URL prefixes example.com/path/
ImmutableExtensions File extensions for immutable content (no revalidation) .rpm
CacheExpirationDays Days to keep cached content (minimum: 1) 14
TouchCooldownMinutes Minimum minutes between touch operations 60
StaleCacheBehavior When cache is stale: origin or cache origin
OciAllowedPrefixes Whitespace-separated OCI image prefixes (registry/name) ``
OciOriginHeadTimeout Timeout (seconds) for HEAD requests to OCI registries 5
CustomDomainName Optional custom domain name ``
HostedZoneId Route53 hosted zone ID (required if custom domain) ``
SentryDsn Optional Sentry DSN for error tracking ``

Resource naming: All AWS resources follow {ResourcePrefix}-{type}-{name} pattern (e.g., myapp-prod-bucket, myapp-prod-lambda-handler).

Usage

Make GET requests to the API endpoint (or custom domain if configured). The URL to cache is encoded in the path (without the https:// prefix):

curl -L "https://<api-endpoint>/example.com/path/to/file.rpm"

Behavior:

  • First request (cache miss): Redirects to original URL while triggering async S3 upload
  • Subsequent requests (cache hit):
    • Immutable content (matching immutable extension filter): Redirects to presigned S3 URL and resets cache expiration
    • Mutable content (non-matching extension): HEAD request validates cache freshness (10s timeout). If stale, behavior depends on StaleCacheBehavior; if fresh, serves from cache
  • Disallowed prefixes: URLs not matching allowed prefixes are transparently redirected to the original URL (302 pass-through)

Immutable Extension Filter Behavior

The ImmutableExtensions parameter determines caching behavior:

  • Matching extension (e.g., .rpm): Treated as immutable content. Cached without revalidation - changes at origin won’t be reflected until cache expires. Served directly from cache on all requests.

  • Non-matching extension (e.g., .xml, .gz): Treated as mutable content. Cached with revalidation - HEAD request on each cache hit validates freshness using ETags. If stale, behavior depends on StaleCacheBehavior:

    • origin (default): Redirects to origin for fresh content, async refresh
    • cache: Serves stale cache immediately (faster), async refresh in background

Important: All files matching AllowedPrefixes are cached, regardless of extension. The extension filter only determines whether revalidation is performed.

Usage as Repository Proxy

The cache can be used as a DNF/yum baseurl for RPM repositories. Configure ImmutableExtensions to include .rpm:

  • .rpm files (matching extension): Cached as immutable - fast, no revalidation
  • Metadata files (non-matching extension, e.g., repomd.xml, primary.xml.gz): Cached with revalidation - ensures fresh metadata while benefiting from cache
[myrepo]
name=My Repository
baseurl=https://koji-s3-cache.example.com/download.example.org/pub/repo/$basearch/
enabled=1

This provides caching benefits for RPM downloads while ensuring repository metadata stays fresh.

OCI Image Caching

The cache also supports the OCI Distribution API v2 for caching container image manifests and blobs. This prevents deployment failures caused by upstream registries garbage-collecting digest-pinned images when tags move.

Requests with a v2/ path prefix are handled as OCI requests. The upstream registry is extracted from the first path segment after v2/, enabling multi-registry support through a single cache endpoint.

OCI request format:

GET /v2/<registry>/<image-name>/manifests/<digest>
GET /v2/<registry>/<image-name>/blobs/<digest>

Example:

# Health check
curl https://cache.example.com/v2/

# Manifest request (quay.io/openshift/origin-oauth-proxy)
curl -L https://cache.example.com/v2/quay.io/openshift/origin-oauth-proxy/manifests/sha256:504c036d...

OCI cache strategy (origin-first):

The OCI cache uses an origin-first strategy, matching the proven CKI dependency-archive pattern. This minimizes S3 egress — the cache is only consulted when the upstream registry is unavailable:

  1. HEAD upstream registry — check if the digest is still available
  2. Origin available + not cached → redirect to upstream, trigger async S3 upload
  3. Origin available + cached → redirect to upstream (touch if cooldown elapsed)
  4. Origin unavailable + cached manifest → serve inline from S3 (200 with body, Content-Type from metadata, Docker-Content-Digest header)
  5. Origin unavailable + cached blob → 302 to presigned S3 URL
  6. Origin unavailable + not cached → redirect to upstream as last resort
  7. Any error → redirect to upstream (fail-open)

S3 key layout: cache/oci/sha256:<digest> — flat, content-addressed. The same blob shared by different images is stored only once.

OCI request flow:

flowchart TD
    Start([OCI Request]) --> CheckPath{Path starts<br/>with v2/?}
    CheckPath -->|No| RPMHandler[RPM cache handler]
    CheckPath -->|Yes| V2Health{v2 or v2/?}
    V2Health -->|Yes| Return200[200 OK<br/>registry/2.0]:::redirect
    V2Health -->|No| ParsePath[Parse path:<br/>registry/name/kind/digest]
    ParsePath --> ValidDigest{Valid digest?<br/>manifests or blobs?}
    ValidDigest -->|No| ForwardUpstream[302 to upstream]:::redirect
    ValidDigest -->|Yes| CheckAllow{In OCI<br/>AllowedPrefixes?}
    CheckAllow -->|No| ForwardUpstream
    CheckAllow -->|Yes| HeadOrigin[HEAD upstream registry]:::network
    HeadOrigin --> OriginUp{Origin available?}
    OriginUp -->|Yes| CheckOciCache{Cached in S3?}
    CheckOciCache -->|No| InvokeUploader[Invoke Uploader async]:::async
    CheckOciCache -->|Yes| TouchCheck{Touch cooldown?}
    TouchCheck -->|Elapsed| InvokeTouchOci[Invoke Touch async]:::async
    TouchCheck -->|Not elapsed| RedirectUpstream
    InvokeTouchOci --> RedirectUpstream[302 to upstream]:::redirect
    InvokeUploader --> RedirectUpstream
    OriginUp -->|No| CheckFallback{Cached in S3?}
    CheckFallback -->|Yes, manifest| ServeInline[200 inline<br/>from S3]:::redirect
    CheckFallback -->|Yes, blob| PresignedRedirect[302 to<br/>presigned S3]:::redirect
    CheckFallback -->|No| ForwardUpstream

    classDef network fill:#10b981,color:#000
    classDef async fill:#ff9900,color:#000
    classDef redirect fill:#3b82f6,color:#fff

Note: Tag-based pulls (/manifests/latest), tag listings (/tags/list), and images not in OciAllowedPrefixes are transparently forwarded to the upstream registry without caching.

Development

See the main README for development workflows.

make lambda-s3-cache/setup  # Install dependencies
make check                  # Lint code
make fmt                    # Format code
make test                   # Run unit tests
make coverage               # Run tests with coverage

Configuration

Lambda functions receive configuration via environment variables (automatically set by CloudFormation):

Variable Handler Uploader Touch Description
S3_BUCKET_NAME ✅ ✅ ✅ S3 bucket name
PRESIGNED_URL_EXPIRATION ✅ Presigned URL expiration (sec)
UPLOADER_LAMBDA_ARN ✅ Uploader Lambda ARN
TOUCH_LAMBDA_ARN ✅ Touch Lambda ARN
TOUCH_COOLDOWN_MINUTES ✅ Min minutes between touch ops
ALLOWED_PREFIXES ✅ Allowed URL prefixes
IMMUTABLE_EXTENSIONS ✅ Extensions for immutable content
STALE_CACHE_BEHAVIOR ✅ Stale behavior: origin or cache
OCI_ALLOWED_PREFIXES ✅ OCI image prefixes to cache
ORIGIN_HEAD_TIMEOUT ✅ OCI origin HEAD timeout (sec)
SENTRY_DSN ✅ ✅ ✅ Optional Sentry DSN

Security & Limitations

Security:

  • S3 bucket has public access blocked; all objects encrypted at rest (AES256)
  • Presigned URLs expire after a certain time
  • IAM policies follow least privilege principle
  • URL prefix allowlist prevents caching arbitrary URLs
  • OCI image allowlist (OCI_ALLOWED_PREFIXES) controls which images are cached
  • Immutable extension filter distinguishes immutable vs mutable content for revalidation
  • OCI caching only handles public images (no auth forwarding)

Limitations:

  • Lambda timeout: 15 min (uploader), 30 sec (handler, touch)
  • Lambda memory: 1024 MB (uploader), 256 MB (handler, touch)
  • S3 object size: Up to 5 TB (AWS limit)
  • API Gateway 6 MB response limit for inline manifest responses (manifests are typically under 100 KB)
  • OCI caching only supports digest-based pulls (sha256:...); tag-based pulls are forwarded to the upstream registry

Design Decisions Record

DDR-1: S3 Lambda OCI cache vs ECR pull-through cache

Decision: Build the OCI cache as a new code path in the existing lambda-s3-cache Lambda, rather than using AWS ECR pull-through cache.

Context: Third-party container images pinned by digest vanish when upstream registries garbage-collect old manifests after tag re-pushes (e.g., quay.io/openshift/origin-oauth-proxy:5.1 digests disappear within hours). This causes ImagePullBackOff failures on pod restarts.

Alternatives: (a) ECR pull-through cache — managed service, no custom code, retains cached digests when upstream is unavailable. (b) images.paas.redhat.com — Red Hat internal registry with existing pull-through proxy orgs for some registries. (c) Artifactory — supports any upstream registry as a remote repository.

Rationale: ECR pull-through cache requires private ECR registries, which need IAM authentication — tokens expire every 12 hours, requiring a CronJob for credential rotation on OCP pods. The S3 Lambda approach serves content via a public HTTPS URL with zero authentication overhead. images.paas.redhat.com was ruled out due to dependency on Red Hat IT (rug-pulling risk if they decommission the proxy orgs). Artifactory was not in use and would add a new service dependency. The S3 Lambda approach reuses existing infrastructure (same Lambda, API Gateway, S3 bucket, custom domain) and adds ~100 lines of OCI handler code ported from the proven CKI dependency-archive.

DDR-2: Origin-first (HEAD-first) strategy vs cache-first

Decision: Use an origin-first strategy: always check the upstream registry first via HEAD request, redirect to upstream when available, and serve from S3 only when upstream returns 404 or errors.

Context: The existing RPM cache uses a cache-first strategy (check S3 first, revalidate with origin). For OCI artifacts, the design choice affects S3 egress costs and cache freshness.

Alternatives: (a) Cache-first — check S3 first, serve from cache if present. (b) Origin-first — check upstream first, fall back to S3.

Rationale: OCI digests are content-addressed and immutable — there is no staleness concern (unlike RPM repo metadata). The origin-first approach minimizes S3 egress costs: when upstream is healthy (the common case), every pull redirects directly to the upstream registry with zero S3 data transfer. S3 is only read when upstream has garbage-collected the digest. This matches the CKI dependency-archive pattern, which is proven in production. The cache populates asynchronously on first access and serves as a safety net, not a performance layer.

DDR-3: Registry hostname in URL path vs per-registry subdomains

Decision: Encode the upstream registry hostname as the first path segment after v2/ (e.g., koji-s3-cache.example.com/quay.io/openshift/origin-oauth-proxy@sha256:...).

Context: The CKI dependency-archive assumes a single upstream registry (OCI_REGISTRY env var). Hummingbird pulls from 4+ registries (quay.io, docker.io, ghcr.io, registry.gitlab.com).

Alternatives: (a) Per-registry subdomains (quay-io.cache.example.com/...) — standard OCI URL format but requires wildcard TLS certificates and DNS records, potentially separate Lambda deployments. (b) Registry hostname in the URL path — single endpoint, handler extracts the registry from the first path segment.

Rationale: Encoding the registry in the path allows a single Lambda, single API Gateway, single custom domain, and single TLS certificate to serve all registries. The /{path+} catch-all route already captures everything. The v2/ prefix naturally separates OCI traffic from RPM cache traffic. The kubelet constructs the correct path automatically from the image reference. This approach requires zero new infrastructure beyond the existing lambda-s3-cache stack.

DDR-4: Jinja macro rewrite vs registries.conf

Decision: Rewrite image URLs at Jinja template render time using a cached_image() macro, rather than configuring registries.conf on OCP nodes.

Context: OCP pods need to pull from the cache instead of directly from upstream registries. The standard approach is registries.conf mirror configuration on the cluster nodes.

Alternatives: (a) Node-level registries.conf via MachineConfig. (b) Jinja macro rewriting image refs at deploy time.

Rationale: Hummingbird has namespace-admin access only on OCP clusters — no MachineConfig, no node-level changes. The Jinja macro approach works entirely at the deployment manifest level: vars.d/*.yml files continue to contain upstream refs (Renovate keeps working unchanged), and the macro rewrites them to cache URLs at render time. This is transparent to Renovate, requires no cluster-admin privileges, and can be disabled with a single variable change.

DDR-5: Scope limited to third-party images

Decision: Only cache third-party images. Hummingbird-owned images (quay.io/hummingbird-ci/* and registry.access.redhat.com/hi/*) are excluded.

Context: The cache exists to protect against upstream registries garbage-collecting digest-pinned images.

Alternatives: Cache all images uniformly.

Rationale: Hummingbird-owned images have additional sha-based tags (commit SHA, build ID) that persist independently of the floating :latest tag. Old digests are not garbage-collected because these immutable tags keep them reachable. Caching them would add S3 storage and Lambda invocations with no benefit. The cached_image() Jinja macro skips rewriting for quay.io/hummingbird-ci/ prefixes.

DDR-6: Separate OCI handler function vs extending RPM cache logic

Decision: Implement _handle_oci_cacheable() as a separate function from the existing RPM cache logic (lambda_handler → _handle_rpm).

Context: Both OCI and RPM caching share the same S3 bucket, uploader Lambda, and touch mechanism. The code could be unified.

Alternatives: Extend the existing RPM handler to handle OCI requests by adding OCI-specific branches.

Rationale: The RPM cache has ETag-based revalidation and configurable stale-cache behavior (origin vs cache) that do not apply to OCI — digests are immutable by definition. Keeping the handlers separate avoids accidentally changing RPM behavior and keeps each code path focused. The OCI handler was ported from the CKI dependency-archive, which is proven in production; staying close to the CKI code structure reduces the risk of introducing untested edge cases.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.35 - RPM CVE Count

A CLI tool to count the number of known CVEs for a given list of RPM packages by querying the Red Hat OSIDB database.

Features

  • Batch processing: Query multiple packages from a file
  • Impact filtering: Filter by CVE severity (CRITICAL, IMPORTANT, MODERATE, LOW)
  • Date filtering: Count only CVEs created after a specific date
  • CSV output: Easy to import into spreadsheets or process with other tools

Prerequisites

  • Red Hat VPN: Must be connected to access the OSIDB database
  • Go 1.21+: For building from source

Installation

Install the latest version:

go install gitlab.com/redhat/hummingbird/tools/rpm-cve-count@latest

Or build from source:

git clone https://gitlab.com/redhat/hummingbird/tools.git
cd tools/rpm-cve-count
go build

Usage

rpm-cve-count -file <package-file> [-after <date>] [-impact <level>]

Options

Option Description Required
-file Read packages from file (one per line) Yes
-after Count CVEs created after date (YYYY-MM-DD) No
-impact Filter by impact: CRITICAL, IMPORTANT, MODERATE, LOW No

Examples

Create a file with package names (one per line):

# packages.txt
kernel
systemd
openssl
glibc

Count all CVEs for the packages:

$ rpm-cve-count -file packages.txt
kernel,342
systemd,87
openssl,156
glibc,234

Count only CRITICAL CVEs:

$ rpm-cve-count -file packages.txt -impact CRITICAL
kernel,23
systemd,5
openssl,18
glibc,12

Count CVEs created after a specific date:

$ rpm-cve-count -file packages.txt -after 2024-01-01
kernel,45
systemd,12
openssl,28
glibc,31

Combine filters to count CRITICAL CVEs from the last year:

$ rpm-cve-count -file packages.txt -impact CRITICAL -after 2024-01-01
kernel,8
systemd,2
openssl,5
glibc,3

Save results to CSV:

rpm-cve-count -file packages.txt > results.csv

Output Format

CSV format with two columns:

  • Package name
  • CVE count

Development

# Build
go build

# Run tests
go test ./...

# Install locally
go install

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.36 - Hummingbird Dashboard

Web dashboard and CLI for monitoring Konflux build pipeline status.

Features

  • Web Dashboard - Real-time view of build, test, and release status
  • Commits View - Detailed pipeline status per commit with expandable releases
  • Components View - Latest status per component grouped by state
  • Merge Requests View - Track build status across MR versions
  • CLI Tool - Command-line access to pipeline data in YAML/JSON/table formats
  • Failed Releases - View and manage failed releases with LLM-powered analysis
  • Failed Push Builds - Detect and auto-retry failed on-push Konflux builds
  • Auto-Rerun - Automatically retry transient release and push build failures
  • Blocked Snapshots - Block/unblock snapshots from auto-rerun with error pattern matching
  • Smart Triggering - Application-aware rules for determining affected components
  • Error Budget Alerts - Slack notifications when CVE R-Time error budget hits 20%, 5%, or 0% remaining
  • Blame Page - /cve/blame view listing late unblamed tickets with per-category breakdown and per-ticket stage detail
  • ProdSec CVE-HUM SLA Auto-Blame - Auto-locks ProdSec blame when CVE-HUM time exceeds 6h
  • Image Pull Availability - Estimated SLI-UE1-B availability from manifest and sampled checks
  • RPM Tracker API - Read-only snapshot of tracker-owned tables in the shared database

Prerequisites

Installation

cd hummingbird-dashboard
pip install -e .

Usage

Local Development

cd hummingbird-dashboard

# Option 1: Port-forward to production database
./dev.sh port-forward  # In terminal 1
DATABASE_URL=postgresql://postgres@localhost:15432/events ./dev.sh start  # In terminal 2

# Option 2: Use local database (via hummingbird-status)
cd ../hummingbird-status && ./dev.sh db-start && ./dev.sh db-init
cd ../hummingbird-dashboard
DATABASE_URL=postgresql://postgres:dev@localhost:5432/events ./dev.sh start

The dashboard runs at http://localhost:8080 with live reload.

Web UI

Endpoint Description
/ Dashboard with all applications
/apps/{app}/commits Commits view for an application
/apps/{app}/components Components view for an application
/mrs Merge requests overview (filterable)
/mr/{project}/{iid} MR detail with build status per version
/releases/failed Failed releases with analysis
/releases/blocked Blocked snapshots management
/releases/analysis-log LLM analysis run history
/cve/status CVE ticket status dashboard
/cve/run-log CVE analysis run history
/cve/open Open CVE trackers with SLO status
/cve/closed Closed tickets with VEX reconciliation
/cve/r-time CVE R-Time (notified to done) metrics
/cve/blame Late unblamed tickets and per-category blame breakdown
/availability Estimated image pull availability (SLI-UE1-B)
/health Health check endpoint
/metrics Prometheus metrics

Each CVE tracker page (/cve/status, /cve/open, /cve/closed, /cve/r-time) has a “Download CSV” button at the bottom of the page that downloads the current view (same filters as the page) as a CSV file, via a matching /cve/{tab}/csv route (for example /cve/open/csv?slo=24). A “Show CVE ID” toggle in the header reveals the CVE ID next to each HUM ticket key (HUM-1234/CVE-2026-56789); the preference persists via localStorage.

/cve/r-time/csv appends one column per Stage Breakdown milestone (HUM Ticket Created, CVE Published, OSIDB Flaw Created, OSIDB Flaw Public, OSIDB Affect Created, Upstream Fix Merged, Fedora Update Available, RPM Published to Pulp, Image Rebuilt on Quay, HUM Ticket Closed, VEX Updated, CVE Embargo Lifted) after the summary columns, in the same chronological order used in the per-ticket Stage Breakdown panel. Each cell is an ISO 8601 timestamp, or blank when that milestone has no data for the ticket.

On the CVE Tracker Status, Open, and Closed pages, a compact outline icon beside a HUM ticket indicates the CVE Agent’s Jira verdict label. Hover or focus the ticket for the recommendation; the single “Agent verdicts” help control explains the icons. These are agent recommendations, not confirmed Jira resolutions. The dashboard keeps a small copy of the label-to-icon mapping from hummingbird_cve_agent/labels.py to avoid adding the CVE Agent as a dashboard build dependency.

On /competitive, click trend for an image to compare Hummingbird and Chainguard CVE counts over the past 30 days. Distributor series share a chronological time axis and value scale, with distinct colors and legend labels. Hover over a point to see its distributor, collection date and time, and CVE count. Missing observations are not filled with zero, and explicit null counts leave gaps in the line. Points with invalid collection timestamps are omitted. Opening another image clears the previous chart immediately, so a failed request cannot leave another image’s data displayed. Data collection and CVE calculations are unchanged.

CVE Search API

GET /api/cve-runs/search — search CVE analysis run history.

Parameter Type Default Description
package string — Filter by package name (substring match)
ticket string — Filter by ticket key (substring match)
q string — Free-text search across ticket details
log string — Search CronJob log output
days int 30 Time window in days (1–365)
limit int 500 Max results (1–2000)

At least one of package, ticket, q, or log is required.

The response includes two result sets:

  • results — per-ticket matches from the JSONB details column
  • log_matches — per-run matches from CronJob log output, each with a log_excerpt showing the matching lines in context

Example:

# Find runs whose logs mention "merge train" — show excerpts
curl -s '/api/cve-runs/search?log=merge+train' | jq '.log_matches[] | {run_at, log_excerpt}'

# Find tickets for a package, also searching logs
curl -s '/api/cve-runs/search?package=openssl&log=advisory+failed&days=7' \
  | jq '{tickets: [.results[].detail.key], log_hits: .log_matches | length}'

# List all ticket keys matching a free-text query
curl -s '/api/cve-runs/search?q=needs-attention&days=14' | jq '[.results[].detail.key] | unique'

CLI

# View latest commit status
hummingbird-dashboard --application myapp --format table commit

# View specific commit
hummingbird-dashboard --application myapp commit abc1234

# View multiple commits
hummingbird-dashboard --application myapp --format table commit --limit 10

# Component status overview
hummingbird-dashboard --application myapp component

# JSON output
hummingbird-dashboard --format json commit | jq .

# Auto-rerun failed releases
hummingbird-dashboard auto-rerun

# Analyze failed releases via LLM
hummingbird-dashboard analyze-failures

# Probe catalog image pull availability (HUM-721)
hummingbird-dashboard check-image-pulls
hummingbird-dashboard check-image-pulls --dry-run
hummingbird-dashboard check-image-pulls --force

REST API Reference

All endpoints return JSON. Read-only GET endpoints are unauthenticated. Interactive OpenAPI/Swagger documentation is available at /docs.

RPM Tracker

GET /api/rpm-upstream-state returns the collection generation and checkout SHA with upstream, package status, rollout reasons, and all open update MRs from the independently migrated rpm_tracker schema. It also exposes the MR reconciliation scope and freshness and one selected blocking_mr per package row. GET /api/rpm-update-mrs lists every open update MR independently of that selection, including multiple MRs for one package. Add ?stuck_only=true for MRs older than 24 hours and MRs with unknown creation-based ages; unknown ages remain null rather than disappearing. The dashboard does not create or write these tables. Run the tracker initializer before deploying this API; until a compatible tracker schema is present, this endpoint returns HTTP 503. See RPM Tracker for migration and storage details.

Tier 1 — Core Pipeline Status

GET /api/apps/{app_name}/commits

Pipeline status for commits in an application.

Parameter Type Default Description
sha string — Filter by commit SHA (prefix)
component string — Filter by component name
limit int 10 Max commits to return (max 100)
curl -s 'http://localhost:8080/api/apps/rpms/commits?limit=5' | jq .
curl -s 'http://localhost:8080/api/apps/rpms/commits?sha=abc123&component=openssl' | jq .

GET /api/apps/{app_name}/components

Latest build status per component, grouped by state.

Parameter Type Default Description
search string — Filter components by name (substring)
curl -s 'http://localhost:8080/api/apps/rpms/components' | jq .
curl -s 'http://localhost:8080/api/apps/rpms/components?search=openssl' | jq .

Tier 2 — Operational Visibility

GET /api/dashboard

Full overview across all applications: aggregate build counts, failed releases, component staleness, and recent activity.

curl -s 'http://localhost:8080/api/dashboard' | jq .

GET /api/releases/failed

List of currently failed releases with analysis results.

Parameter Type Default Description
application string — Filter by application name
curl -s 'http://localhost:8080/api/releases/failed' | jq .
curl -s 'http://localhost:8080/api/releases/failed?application=rpms' | jq .

GET /api/releases/{name}/analysis

LLM failure analysis for a specific release (root cause, classification, recommendation).

curl -s 'http://localhost:8080/api/releases/my-release-abc/analysis' | jq .

GET /api/releases/auto-rerun-log

History of automatic rerun attempts.

Parameter Type Default Description
limit int 50 Max entries
curl -s 'http://localhost:8080/api/releases/auto-rerun-log?limit=10' | jq .

GET /api/releases/analysis-log

History of LLM analysis runs.

Parameter Type Default Description
limit int 50 Max entries
curl -s 'http://localhost:8080/api/releases/analysis-log?limit=10' | jq .

GET /api/releases/analysis-costs

Cumulative token usage and estimated cost for LLM analysis runs.

curl -s 'http://localhost:8080/api/releases/analysis-costs' | jq .

GET /api/mrs

Merge requests across monitored repositories.

Parameter Type Default Description
state string opened MR state: opened, merged, closed, all
project string — Filter by project (repository) name
curl -s 'http://localhost:8080/api/mrs' | jq .
curl -s 'http://localhost:8080/api/mrs?state=merged&project=rpms' | jq .

GET /api/mrs/projects

List of all projects (repositories) with tracked merge requests.

curl -s 'http://localhost:8080/api/mrs/projects' | jq .

GET /api/mr/{project}/{iid}

Detail for a single merge request, including all versions and per-version build status.

curl -s 'http://localhost:8080/api/mr/rpms/42' | jq .

GET /api/cve/status

Current CVE ticket status across all tracked packages.

curl -s 'http://localhost:8080/api/cve/status' | jq .

GET /api/cve/open

Open HUM CVE tracker tickets with SLO status. Notified is the later of HUM ticket created and fix available (upstream or Fedora). SLO is green PASS unless a fix is available and elapsed time is within 8h of the selected SLO (yellow AT RISK) or past it (red FAIL).

Parameter Type Default Description
slo float 24 SLO threshold hours (24, 72, or 168)
include_next_release bool true Include cve-next-release tickets
curl -s 'http://localhost:8080/api/cve/open?slo=24' | jq .

GET /api/cve/closed

Closed HUM CVE tracker tickets with Red Hat CSAF VEX reconciliation (HUM-5843). Analysis stores package-scoped vex_status and Jira resolution; the dashboard computes MATCH from those facts (Done-Errata → fixed, Not a Bug → known_not_affected, package_not_listed, or fixed – a downstream package rebuilt by an unrelated dependency’s fix is not a contradiction of a Not a Bug verdict). Shows Hummingbird vex_status, match state (matched / pending / mismatch), and vex_resolved (first scan time where VEX agreed with the Jira resolution). Event metadata is merged across a ticket’s rows in occurred_at order (HUM-6091): later non-empty fields overlay earlier ones, so Close/VEX facts are not stuck on the first cve_published row. The Resolution column uses Jira Closed / {resolution} when stored analysis text is not already Closed. The Closed tab shows the VEX timestamp as a link to the CVE’s Red Hat CSAF VEX document when the package is named in CSAF. package_not_listed (Not a Bug, package absent from the document) shows N/A with no link: this ticket did not produce a VEX change.

Parameter Type Default Description
days int 30 Time window (0 = all time)
curl -s 'http://localhost:8080/api/cve/closed?days=30' | jq .

GET /api/cve/r-time

CVE R-Time (notified to done) for Done-Errata tickets measures time spent on a fixable CVE, so its start never predates the upstream fix in either mode. Include CVE-HUM starts at CVE publication (NVD datePublished / cve_published), falling back to HUM ticket creation only when CVE publication is missing – CVE publication wins even when the HUM ticket predates it, so an embargoed ticket’s R-Time starts at public disclosure rather than at ticket creation (which can predate disclosure by days while the CVE is still privately embargoed). Exclude CVE-HUM starts at the later of HUM created and CVE publication – same as HUM created for a normal ticket (Exclude’s purpose is to ignore filing lag after disclosure), but an embargoed ticket’s clock must never start before public disclosure in either mode, so it also starts at CVE publication there. Whichever of the two the mode picks, Upstream Fix Merged is then applied as a floor on top: the final notified timestamp is the later of that value and Upstream Fix Merged, so neither mode ever counts time before a fix existed to backport. Delivery is the catalog image publish when the package is a catalog image source, otherwise the RPM publish. Include VEX requires delivery, VEX updated, and Closed / Done-Errata; Exclude VEX requires delivery and close only. The done timestamp is the latest of the required gates, and only when all of them exist. Incomplete tickets stay on the table with elapsed time until now; the days window filters by Done-Errata close (fallback: notified), same as completed tickets use done. CVE-HUM is filing lag after NVD (max(HUM created − NVD, 0)); HUM-first tickets show 0.0h. ADV-VEX is Done-Errata close to VEX feed update (max(VEX − close, 0)); close is recorded when the advisory MR merges. Fedora is not start or end; Upstream Fix Merged only ever gates R-Time’s start, never its end. Pre-built (delivery before notification) is informational only. Rows without catalog_image_source in metadata still require an image until the collector restamps them. Each entry includes package onboarding timestamps and delay analysis (rpm_first_published_at, pkg_lag_hours, and our_delay_hours). Default excludes cve-next-release, CVE-HUM time, and VEX time.

Parameter Type Default Description
days int 30 Time window (0 = all time)
slo float 168 SLO threshold hours (24, 72, or 168)
include_next_release bool false Include cve-next-release tickets
include_hum_cve_time bool false Include NVD-to-HUM filing lag in R-Time
include_vex_time bool false Require VEX feed update before R-Time ends
curl -s 'http://localhost:8080/api/cve/r-time?days=10&slo=168' | jq .

GET /api/cve/run-log

History of CVE analysis CronJob runs.

Parameter Type Default Description
limit int 100 Max entries
curl -s 'http://localhost:8080/api/cve/run-log?limit=10' | jq .

GET /api/cve-export

Export CVE sync data as JSON for prod-to-preprod replication. Valid section values are cve_analysis_log, cve_ticket_events, package_lifecycle, and cve_ticket_blame.

Optional pagination parameters:

Parameter Type Default Description
section string — Section to export
limit int 2000 Rows per page
offset int 0 Pagination offset
curl -s -H "Authorization: Bearer $CVE_REPORT_TOKEN" \
  'http://localhost:8080/api/cve-export' | jq '.cve_ticket_events | length'

curl -s -H "Authorization: Bearer $CVE_REPORT_TOKEN" \
  'http://localhost:8080/api/cve-export?section=cve_ticket_events&limit=1000&offset=0' \
  | jq '.count,.has_more'

# Package-scoped lifecycle milestones (rpm_first_published, etc.)
curl -s -H "Authorization: Bearer $CVE_REPORT_TOKEN" \
  'http://localhost:8080/api/cve-export?section=package_lifecycle&limit=10000&offset=0' \
  | jq '.rows[] | select(.event_type == "rpm_first_published")'

Event type validation. The import path (POST /api/cve-import) validates every event_type value against a canonical allowlist defined in hummingbird_dashboard/sources.py:

  • CANONICAL_TICKET_EVENT_TYPES — values accepted for cve_ticket_events: cve_published, osidb_flaw_created, osidb_affect_created, cve_embargo_lifted, osidb_flaw_public, hum_ticket_created, hum_ticket_closed, upstream_fix_merged, fedora_update_available, rpm_fix_published_to_pulp, image_rebuilt_on_quay, vex_resolved.
  • CANONICAL_PACKAGE_EVENT_TYPES — values accepted for package_lifecycle: rpm_first_published.

Legacy names (e.g. jira_created, delivered_in_rpm, …) are mapped to their canonical equivalents via _LEGACY_EVENT_KEY_MAP. Unknown names are rejected with HTTP 400.

POST /api/cve-import

Replace local CVE sync data from a payload previously returned by /api/cve-export.

curl -s -X POST \
  -H "Authorization: Bearer $CVE_REPORT_TOKEN" \
  -H "Content-Type: application/json" \
  --data @cve-export.json \
  'http://localhost:8080/api/cve-import' | jq .

curl -s -X POST \
  -H "Authorization: Bearer $CVE_REPORT_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"section":"cve_ticket_events","rows":[],"reset":true}' \
  'http://localhost:8080/api/cve-import' | jq .

Ticket-event types such as osidb_flaw_created, osidb_affect_created, cve_embargo_lifted, and osidb_flaw_public import with the rest of cve_ticket_events because they are on CANONICAL_TICKET_EVENT_TYPES.

occurred_at conflict resolution on import matches the live collector path (HUM-6860): rpm_fix_published_to_pulp and image_rebuilt_on_quay are mutable delivery timestamps where a non-null incoming value wins, so a sync can update them, without letting a null incoming value erase an already-recorded delivery timestamp; every other event_type is write-once, keeping the existing timestamp once set so a sync or backfill re-run cannot overwrite an established immutable milestone.

Bulk prod-to-preprod replication of cve_analysis_log, cve_ticket_events, package_lifecycle, and cve_ticket_blame no longer goes through this API — cve_analysis_log rows can carry up to 512 KiB of log_output, so paginating that table meant hundreds of small requests that aborted entirely on the first 5xx. See copy_prod_to_preprod_db in CVE Analysis Reference for the direct pg_dump/pg_restore sync tool that replaced it. These endpoints remain in use for the smaller, targeted writes collect_rpm_first_published and backfill_osidb_timestamps.py make directly against prod or preprod.

Tier 3 — Analytics & SRE Metrics

GET /api/stats/build-throughput

Build counts over time (successful, failed, total).

Parameter Type Default Description
days int 30 Lookback window in days
curl -s 'http://localhost:8080/api/stats/build-throughput?days=7' | jq .

GET /api/stats/release-success-rate

Release success/failure ratios over time.

Parameter Type Default Description
days int 30 Lookback window in days
application string — Filter by application
curl -s 'http://localhost:8080/api/stats/release-success-rate?days=14&application=rpms' | jq .

GET /api/stats/auto-rerun-effectiveness

Success rate and time-to-resolution for automatic reruns.

Parameter Type Default Description
days int 30 Lookback window in days
curl -s 'http://localhost:8080/api/stats/auto-rerun-effectiveness?days=7' | jq .

GET /api/components/staleness

Components ranked by time since last successful build.

curl -s 'http://localhost:8080/api/components/staleness' | jq .

GET /api/releases/similar-failures

Find releases with error messages similar to a given string.

Parameter Type Default Description
error string — Error text to match against
curl -s 'http://localhost:8080/api/releases/similar-failures?error=timeout+connecting' | jq .

GET /api/apps/{app_name}/last-successful

Timestamp and SHA of the last fully successful pipeline per component.

curl -s 'http://localhost:8080/api/apps/rpms/last-successful' | jq .

Tier 4 — System

GET /api/service-status/json

Health of upstream services the dashboard depends on (database, Konflux API, KubeArchive, etc.).

curl -s 'http://localhost:8080/api/service-status/json' | jq .

GET /api/settings

Current runtime settings (auto-rerun enabled, analysis enabled, blocked patterns, etc.).

curl -s 'http://localhost:8080/api/settings' | jq .

GET /health

Enriched health check returning service version, uptime, database connectivity, and dependency status.

curl -s 'http://localhost:8080/health' | jq .

Configuration

Environment Variables

Variable Default Description
DATABASE_URL postgresql://...localhost:5432/ PostgreSQL connection URL
PORT 8080 Web server port

Authentication Variables

These control the retrigger functionality (requires OAuth proxy in production):

Variable Default Description
TRIGGER_AUTH_MODE oauth oauth (production) or local
TRIGGER_AUTH_GROUP konflux-hummingbird-admin-access OpenShift group required to retrigger
TRIGGER_LOCAL_USER local-dev Username when TRIGGER_AUTH_MODE=local
CVE_REPORT_TOKEN Bearer token for CVE API auth

For local development with retrigger enabled:

TRIGGER_AUTH_MODE=local DATABASE_URL=... ./dev.sh start

Auto-Rerun Variables

These control the auto-rerun cronjob and failure analysis:

Variable Default Description
AUTO_RERUN_MIN_AGE_MINUTES 30 Minimum failure age before retrying
AUTO_RERUN_MAX_RETRIES 3 Max rerun attempts per snapshot+plan
KONFLUX_KUBECONFIG_PATH Path to Konflux kubeconfig file
RELEASE_NAMESPACE Namespace where releases are created
MANAGED_NAMESPACE Namespace for fetching release PLRs
KUBEARCHIVE_URL KubeArchive API URL for archived resources
GOOGLE_APPLICATION_CREDENTIALS Path to GCP SA key for Vertex AI
GOOGLE_CLOUD_PROJECT GCP project ID for Vertex AI
ANALYSIS_MODEL_API_KEY Gemini API key (fallback if no GCP creds)
ANALYSIS_MODEL gemini-2.5-flash LLM model for failure analysis
ANALYSIS_MODEL_REGION global Vertex AI region
MAX_ANALYSES_PER_CYCLE 5 Max releases to analyze per cycle
SLACK_WEBHOOK_URL Slack webhook for rerun/analysis notifications
DASHBOARD_URL Dashboard base URL for Slack links
GITLAB_SLACK_MAP_PATH /etc/hummingbird/gitlab-slack-map.yaml GitLab→Slack map for author @-mentions (infra-mounted; missing omits mention)
PUSH_RERUN_MIN_AGE_MINUTES 10 Min push build failure age before retrying
PUSH_RERUN_MAX_RETRIES 3 Max retry attempts per component+sha

Error Budget Alert Variables

The dashboard pod periodically evaluates CVE R-Time error budget (168h SLO, 30-day window) and posts to Slack on worsening transitions only:

Remaining budget Slack alert
≤20% Warning
≤5% Critical
0% Exhausted (sprint-stop)

Incoming webhooks always post to the channel they were created for; the payload cannot select #team-hummingbird. Create the webhook for that channel and set ERROR_BUDGET_SLACK_WEBHOOK_URL (or point the shared SLACK_WEBHOOK_URL at that webhook).

State is stored in dashboard_settings (error_budget_rtime_alert_state) so the same threshold is not re-alerted. The stored state is claimed with a compare-and-set before Slack is called, and rolled back if the post fails, so a transient Slack error is retried on the next interval and concurrent evaluators cannot double-post the same transition. Recovery updates the stored state silently so a later re-worsening can alert again. No message is sent when the webhook URL is unset, or when alerts are disabled via the R-Time page toggle (error_budget_alerts_enabled in dashboard_settings; default enabled).

Variable Default Description
ERROR_BUDGET_SLACK_WEBHOOK_URL SLACK_WEBHOOK_URL Incoming webhook created for the alert channel (typically #team-hummingbird)
SLACK_WEBHOOK_URL Fallback webhook if the error-budget URL is unset
DASHBOARD_URL Base URL for the R-Time deep link in alerts
ERROR_BUDGET_ALERT_INTERVAL_SECONDS 900 Seconds between background checks

Failures

The failures section is accessible via the “Failures” nav item and provides two views selectable by tab: Releases and Push Builds.

Failed Releases

The /failures/releases page shows all failed releases with:

  • LLM Analysis — Each failure is analyzed by Gemini with root cause, classification, and recommendation
  • Failure Classification — Transient, Configuration, Code, External Service, or Unknown
  • Rerun History — Past rerun attempts and outcomes per snapshot
  • Error Pattern Matching — Auto-block snapshots matching known error patterns
  • Auto-Rerun — Automatically retry transient failures (configurable via dashboard toggle)
  • Analysis Toggle — Enable/disable LLM analysis from the dashboard

CLI Subcommands

The auto-rerun subcommand retries eligible failed releases:

hummingbird-dashboard auto-rerun --application myapp

The auto-rerun-push subcommand retries eligible failed push builds:

hummingbird-dashboard auto-rerun-push --application myapp

The analyze-failures subcommand runs LLM analysis on unanalyzed failures:

hummingbird-dashboard analyze-failures --managed-namespace rhtap-releng-tenant

All three are designed to run as Kubernetes CronJobs. Slack messages for auto-rerun, push-retry, analysis, and manual UI reruns include a GitLab MR link (and author @-mention when mapped) when the failure SHA resolves to an MR.

Image Pull Availability

The /availability page tracks HUM-721 (SLI-UE1-B): can a customer actually pull the images we publish. Continuously pulling every catalog image on a tight schedule is not feasible (terabytes of registry traffic), so the check is split into three tiers, run by check-image-pulls:

  • Manifest HEAD (exhaustive) — a cheap HEAD request, no image bytes transferred, against every latest* image:variant tag in the catalog on every run. This is the headline “Estimated Availability” number. A digest cache skips the live HEAD when the tag’s digest is unchanged, the previous check succeeded, and it was confirmed within the last 6 hours.
  • Blob sample (sampled) — a real HEAD against every blob (config + layers) of a small random sample of currently-available images each run, confirming referenced layers still exist in storage.
  • Full-pull sample (sampled) — a real GET of one image’s config blob (a few KB, following the registry’s CDN redirect) each run — genuine end-to-end retrieval, not just a HEAD.

Both sample tiers prioritize tags published in the last hour over older ones, since “did the thing we just shipped actually land” is the highest-value failure mode. The resulting availability % is estimated: the manifest tier is exhaustive, but the blob/full-pull tiers are sampled, not exhaustive — the page shows all three separately rather than blending them into one number.

  • SLO: 95% target, 28-day rolling window, based on the manifest tier
  • Error Budget: manifest-check misses allowed within the window
hummingbird-dashboard check-image-pulls
hummingbird-dashboard check-image-pulls --dry-run
hummingbird-dashboard check-image-pulls --force
Option Description
--dry-run Probe without writing results to the database
--force Force live manifest HEAD checks (ignore digest cache)
--catalog-api-base Catalog API base URL (default: CATALOG_API_BASE or built-in)

Designed to run as a Kubernetes CronJob (every 15 minutes). Checks can be disabled from the dashboard via the image_pull_checks_enabled setting.

Merge Requests

The /mrs page shows merge requests across all monitored repositories with:

  • State filter - Open, merged, closed, or all MRs
  • Project filter - Filter by specific repository
  • Build status - Aggregate status across all components

The /mr/{project}/{iid} detail page shows:

  • All MR versions - Each head commit SHA that was pushed to the MR
  • Build status per version - Full pipeline status (build, snapshot, test, release)
  • Links to Konflux UI - Direct links to PipelineRuns and Snapshots

Versions are ordered by latest event timestamp, so force-pushed commits appear in the correct position even if they reuse an earlier SHA.

Component Status

The dashboard tracks component build status with these states:

Status Icon Description
Success ✅ Build passed for expected commit
Superseded 🔄 Expected commit not built, but newer succeeded
Failed ❌ Build failed for expected commit
Stale ⚠️ Build not triggered for expected commit
Running ⏳ Build in progress
Missing ❓ No build found

Components are grouped by status: Failed/Stale → Running → OK → Missing.

Trigger Rules

The dashboard uses application-specific rules to determine which components are affected by a push:

  • containers: Changes in images/{component} trigger builds
  • rpms: Changes in rpms/{component} or mock/mock.cfg trigger builds
  • tools: Changes in {component} directory trigger builds

Certain files are excluded from triggering (README, templates, etc.).

Development

See the main README for development workflows.

Running Tests

cd hummingbird-dashboard
pip install -e ".[dev]"
pytest

The PostgreSQL migration regression test uses the CI PostgreSQL sidecar via DATABASE_URL. Locally, set DATABASE_URL to a disposable PostgreSQL database to run it; without one, the test is skipped.

Project Structure

hummingbird-dashboard/
├── Containerfile
├── alembic.ini
├── alembic/
│   ├── env.py          # Alembic environment (DB URL, include_object guard)
│   ├── script.py.mako  # Migration template
│   └── versions/       # Migration files
├── dev.sh
├── hummingbird_dashboard/
│   ├── analysis.py     # LLM failure analysis (Gemini)
│   ├── cli.py          # CLI entry point
│   ├── db/             # SQLAlchemy engine/session + ORM models
│   │   ├── __init__.py # get_db_url(), get_engine(), get_sessionmaker(), upgrade_head()
│   │   └── models.py
│   ├── konflux.py      # Konflux API client
│   ├── models.py       # Data models (Component, PushEvent)
│   ├── sources.py      # PostgreSQL queries
│   ├── table.py        # CLI table formatting
│   ├── triggers.py     # Application-specific trigger rules
│   ├── views.py        # Status computation and aggregation
│   └── web/
│       ├── app.py      # FastAPI application
│       └── templates/  # Jinja2 templates
└── tests/

Building Container Image

cd hummingbird-dashboard
podman build -f Containerfile -t hummingbird-dashboard .

Database Migrations

The dashboard uses Alembic to manage its schema. Migrations run automatically on every startup via alembic upgrade head.

On first startup against an existing environment, the dashboard detects that the schema is already present (via the blocked_snapshots sentinel table) and automatically stamps the baseline migration as applied before upgrading. No manual intervention is required.

Local development — migrations run automatically via ./dev.sh db-seed:

./dev.sh db-start
./dev.sh db-seed   # runs alembic upgrade head then seeds test data
./dev.sh start

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.37 - Pipeline Timing

Independent SQS worker for queryable RPM delivery timing and cross-system lineage. It shares the Status events database but owns only the pipeline_timing schema.

Features

  • Current state, timestamps, and separate attempts for GitLab jobs and Konflux PipelineRuns
  • Commit, merge request, PipelineRun, TaskRun, Snapshot, and Release lineage
  • Deterministic S3 event references without raw JSON in reporting tables
  • Alembic-managed schema, dedicated queue, and worker metrics

Prerequisites

  • Python 3.11+ and PostgreSQL 16
  • A dedicated timing SQS queue subscribed to the environment’s SNS topic with RawMessageDelivery: false
  • The S3 archive bucket for the same environment

The existing Status worker and event forwarders keep their own images and queues. Infrastructure provisions the timeline queues, IAM, and alerts separately from the Status worker. Each environment has a separate queue named arr-hummingbird-<env>-pipeline-timing-sqs and a matching -sqs-dlq queue. The source queue retains messages for 4 days; its DLQ retains them for 14 days from the original enqueue time when SQS automatically moves a message to the DLQ. Operator redrive from the DLQ resets the enqueue timestamp. The source queue moves a message to its DLQ after 3 receives and uses a 300-second visibility timeout, matching Status. The SNS subscription preserves the SNS envelope (RawMessageDelivery: false) and does not filter events. See Hummingbird Events Topic for queue policy and redrive details. Provision both environments’ queues and SNS subscriptions before deploying the workers. Messages collected before a worker starts may expire after four days; this pre-launch loss is accepted. A queue without a consumer does not move messages to its DLQ because no receives occur. Validate the staging worker before deploying the production worker or enabling new production event sources.

Installation

cd pipeline-timing
pip install -e ".[dev]"

The pipeline-timing Containerfile builds the separate quay.io/hummingbird-ci/pipeline-timing image. Both the initializer and worker run from this image.

Usage

Schema initialization

Run the initializer as the init container of the single-replica timing worker Deployment. Use a Recreate strategy so Kubernetes stops the old worker before starting a Pod whose init container may upgrade the schema:

DATABASE_URL="$DATABASE_URL" python3 -m pipeline_timing.worker.init_db

The initializer creates the pipeline_timing schema and runs alembic upgrade head each time a Pod starts. This is a no-op at the expected revision; a new image can apply a forward migration during rollout. If migration fails, the initializer exits nonzero and Kubernetes does not start the worker. The worker checks the revision again before polling. Fix migration errors with a forward revision; do not downgrade or drop the schema during rollback.

Timing worker

Deploy one worker replica with the environment’s dedicated queue and archive bucket. Kubernetes starts it only after the init container succeeds:

SQS_QUEUE_URL="$TIMING_QUEUE_URL" \
  S3_BUCKET="$TIMING_ARCHIVE_BUCKET" python3 -m pipeline_timing.worker

The worker polls when it starts. Accepted events write the event reference and reporting rows in one committed transaction before SQS acknowledgement. Malformed events remain for retry and eventual dead-letter-queue redrive; database or revision failures stop polling without affecting legacy Status. Scale the timing worker to zero replicas to pause ingestion. After launch, monitor queue age against the four-day source retention limit during a pause to avoid losing events. SQS preserves the original enqueue time for automatic DLQ transfer, and an operator redrive resets it. A new rollout uses Recreate: the old worker stops, Alembic upgrades, and the new worker starts. The S3 archiver receives its own SNS copy, so a database reference may exist briefly before its archive object; monitor archiver failures separately.

The worker follows Status’s polling pattern: a 20-second long poll receives up to 10 messages, processed sequentially with boto3’s default request retries. Each accepted event commits in its own transaction. At the end of each receive, one SQS batch-delete request acknowledges committed events and ignored messages. Short receives flush immediately; failed processing never adds a receipt to the delete request. Receive errors are logged and polling continues. Partial delete failures and uncertain request outcomes leave committed events available for idempotent redelivery, and polling continues. If a database failure interrupts a receive, the worker first attempts to acknowledge earlier confirmed commits. Database connection or schema failures stop the worker for Kubernetes to restart it. SQS visibility controls when an unacknowledged message becomes available again.

The worker caches up to 128 UPSERT statement shapes by table and ordered columns. Each execution binds fresh event values; the cache does not retain payloads, connections, or cursors. Event ordering and sparse-update rules are unchanged.

Queue age and DLQ depth are monitored independently from the Status queue. Fix malformed messages before using the documented redrive procedure. If the worker is paused or not yet deployed, remove only a known test message after confirming that redrive returned it to the timing queue.

Configuration

Variable Default Description
DATABASE_URL - Existing Status events database URL
SQS_QUEUE_URL - Environment-specific timing queue URL
S3_BUCKET - Environment-specific SNS archive bucket
SQS_WAIT_TIME 20 SQS long-poll interval in seconds
METRICS_PORT 9090 Prometheus port when the worker is enabled

Infrastructure supplies matching queue and archive-bucket values for each environment:

Environment Queue URL variable Archive bucket variable
Staging ARR_HUMMINGBIRD_STAGING_PIPELINE_TIMING_SQS_QUEUE_URL ARR_HUMMINGBIRD_STAGING_PIPELINE_TIMING_S3_BUCKET
Production ARR_HUMMINGBIRD_PROD_PIPELINE_TIMING_SQS_QUEUE_URL ARR_HUMMINGBIRD_PROD_PIPELINE_TIMING_S3_BUCKET

POSTGRES_URL is also accepted when DATABASE_URL is not set. For local development only, the database URL defaults to postgresql://postgres:dev@localhost:5432/events.

Schema and lineage

Table Identity Purpose
events SNS message ID Source, timestamp, S3 bucket and key
merge_requests GitLab project ID and MR IID Current state and lifecycle times
commits GitLab project ID and SHA Commit metadata
merge_request_commits Project ID, IID, SHA, relation Head and merge-commit links
gitlab_pipelines Project ID and pipeline ID Pipeline status and timing
gitlab_jobs Project ID and job ID Individual job attempts
pipeline_runs Cluster and Kubernetes UID Build, test, and Release attempts
task_runs Cluster and Kubernetes UID Task timing and parent UID
snapshots Cluster and Kubernetes UID Build-to-test bridge
releases Cluster and Kubernetes UID Snapshot-to-Release bridge

Source timestamps accept ISO 8601 and GitLab’s YYYY-MM-DD HH:MM:SS UTC format and are normalized to UTC. Timestamps without a timezone are interpreted as UTC.

Every domain row stores latest_event_id pointing to events. The schema stores selected reporting fields, not raw event JSON. Join MR head and merge commits to GitLab pipelines by canonical HTTPS repository URL and SHA; join jobs by project and pipeline ID. Konflux PipelineRuns use the same repository URL and SHA, then fan out by component and execution UID. TaskRuns link through PipelineRun owner UIDs; Snapshot source-run names, test-run Snapshot labels, and Release references connect the remaining phases. The Release completion time closes the current timeline. Distinct job IDs and PipelineRun UIDs retain each retry’s status and timing.

PipelineRun SHAs come from supported labels and annotations, including pac.test.appstudio.openshift.io/sha, before falling back to the revision parameter.

Metrics include hummingbird_pipeline_timing_records_total{kind,result}, hummingbird_pipeline_timing_failures_total{reason}, hummingbird_pipeline_timing_missing_lineage_total{kind}, and hummingbird_pipeline_timing_worker_stopped. success and ignored record results count only confirmed SQS acknowledgements, including duplicates. Each failed or uncertain batch entry increments the sqs failure counter. These counters do not measure unique event rows. Monitor queue age and dead-letter-queue depth separately from the legacy Status queue. Fix malformed events before redriving them; SNS message IDs make processing idempotent.

Development

Run tests against the PostgreSQL service provided in CI or a local database:

DATABASE_URL=postgresql://postgres:dev@localhost:5432/events \
  python3 -m unittest discover -s tests -t .

Tests also load the canonical archive-key fixture in sns-s3-archiver/tests/fixtures/archive-key-contract.json. CI provides DATABASE_URL for the PostgreSQL integration tests. The timing image has a Kubernetes import smoke test in tests-k8s.yml.

License

This project is licensed under the GNU General Public License v3.0 or later. See the LICENSE.

5.38 - RPM Tracker

RPM Tracker owns the rpm_tracker schema in the shared events PostgreSQL database. It stores upstream state and packaged version status for the dashboard to read. See the system proposal for the collection and UI design.

Features

  1. Independent Alembic migration chain with an initializer and revision gate.
  2. Transactional replacement of the current inventory with a generation check.
  3. Read-only dashboard access to tracker data in the shared database.
  4. Full RPM spec and upstream monitor collection with currency, staleness, packaged-stream lifecycle, and independent upstream scan health.
  5. Database-only update MR reconciliation with merge-time rollouts, independent open-MR listing, and creation-based ages with explicit uncertainty.
  6. A standalone Konflux image with native RPM support and independent collection and MR-reconciliation CronJob templates for staging and production.

Prerequisites

Python 3.11 or newer and access to the shared PostgreSQL events database. The migration user needs permission to create the rpm_tracker schema. Collection also requires Git, rpmspec, the system Python RPM bindings, and read access to the rpms repository and upstream monitor. On Fedora, install git-core, rpm-build, and python3-rpm. The Python dependencies needed to load the rpms version helper are included in the tracker package. Do not run collection in a virtual environment that hides the system RPM bindings. The helper also needs the packaged specfile dependency to include sudo’s upstream patchlevel from its extraver macro. MR reconciliation requires read access to public.gitlab_merge_requests and write access to tracker tables. It needs neither GitLab API credentials nor native RPM bindings.

Installation

cd rpm-tracker
pip install -e .
DATABASE_URL=postgresql://postgres@localhost:5432/events python -m rpm_tracker.init_db

The Alembic chain uses rpm_tracker.alembic_version, separate from other services’ migration histories and outside public. The migration files are bundled in the installed rpm_tracker package. The initializer creates the schema and applies pending migrations on each invocation; it is a no-op at the current revision. Run it before deploying the dashboard API, and run it as an init step before the collector starts. A failed migration must prevent collection. The dashboard read endpoint returns HTTP 503 until a migrated schema compatible with its response model is available. Later tracker revisions remain readable when they preserve the fields used by the dashboard.

Online initialization takes a PostgreSQL transaction advisory lock, so collection and reconciliation init containers can start together without racing migrations. The initializer delegates this database work to rpm_tracker.db.initialize(). That helper creates the schema, opens the transaction, and passes its connection to Alembic before applying runtime grants. Alembic’s migrations/env.py only configures offline or online migration execution and the tracker ORM metadata. When RPM_TRACKER_RUNTIME_PASSWORD is supplied, initialization also creates or updates the rpm_tracker login role. This requires the existing Status database administrator credential. The role receives tracker-table reads and writes, read-only access to the tracker revision and public.gitlab_merge_requests, and no schema-creation or administrator privileges. Role/password updates and migrations commit together. Omit this variable for migration-only initialization.

Standalone Alembic commands do not create the schema or configure runtime roles. Run the initializer first. Offline SQL likewise assumes that the tracker schema has already been provisioned.

Container image

The rpm-tracker/Containerfile installs Git, rpm-build, python3-rpm, CA certificates, and the tracker package from pyproject.toml. The installed package includes its migrations; the image runs as the base image’s non-root user. Konflux discovers the Containerfile through the existing tools renderer and builds both supported architectures. The release must be pinned by digest in the infrastructure repository’s vars.d/rpm-tracker.yml before deployment.

podman build -t localhost/rpm-tracker:dev rpm-tracker
podman run --rm localhost/rpm-tracker:dev rpm-tracker-collect --help

The infrastructure kubernetes/rpm-tracker/ templates initialize the database before each run, then use the restricted runtime role. Collection is scheduled at 0 */4 * * * UTC; reconciliation is scheduled independently every ten minutes. The staging and production variants use their own Status database and runtime password. The infrastructure operating runbook documents rollout and manual checks.

Usage

Collect an inventory

DATABASE_URL=postgresql://postgres@localhost:5432/events \
  python -m rpm_tracker.collect \
  --rpms-repository https://gitlab.com/org/group/rpms.git \
  --monitor-url https://upstream-monitor.example.org

The installed rpm-tracker-collect command accepts the same arguments. Each run checks the migrated schema and reads the expected generation before cloning the repository’s default branch into a temporary directory. It reads every package directory, queries spec versions through that checkout’s parse_spec_version helper, and records the checked commit SHA. Metadata version is not a fallback. Missing or malformed metadata and failed checkout enumeration stop collection; an unparseable spec does not omit a package. Clone failures log Git’s captured stderr before re-raising the original CalledProcessError, which retains the diagnostics in its stderr attribute.

The collector reads every monitor project-catalog and release-history page, following X-Next-Cursor even on an empty page. History pagination omits releases without dates, so collection also reads GET /v1/projects/{id} and merges its releases and observed_releases into the history by source and release ID. Snapshot records take precedence for duplicates, including corrected or newly unknown dates. Undated newer releases therefore affect currency and make staleness unavailable instead of disappearing from the comparison. Anitya’s latest-only date coverage is supported. A JSON null stream list is treated as empty; malformed non-list responses remain errors. A project without eligible releases produces unknown without preventing other packages from refreshing.

Requests have a 30-second timeout and are paced sequentially with a 0.2-second delay. All monitor redirects are rejected, including same-origin redirects, so bearer tokens are never forwarded to a redirect destination. Configure the canonical monitor API base URL; a redirect fails collection and retains the previous inventory. Definitions verify repository matches; shared projects are fetched only once. HTTP, response validation, or pagination failures, including a failed snapshot request, prevent any inventory write. Monitor APIs do not promise a single snapshot across requests; a later complete run reconciles changes.

Currency uses native RPM version comparison with no packaging or lexical fallback. A version equal to or ahead of the eligible upstream maximum is current; an eligible newer release makes it behind. No verified project mapping means unmonitored. A mapped package with an unusable spec, update target, or comparison is unknown; its reason is logged and monitor health is retained. Pinned lines use component-boundary matching. With no track_upstream, use upstream_stream or a uniquely matched packaged stream, never global latest. An explicit stream does not change a pinned or latest update target.

Monitor scheme names are trimmed and lowercased during definition validation, matching the monitor registry. Mixed-case or whitespace-padded names therefore use the same normalization and pinned-target filtering as their canonical names.

first_behind_at is recomputed as the earliest date of an eligible release newer than the packaged version. Later releases do not reset it. If any eligible newer release lacks a date, staleness is unavailable. Lifecycle always describes the packaged stream, independently of the update target. Non-numeric/custom stream names require an exact release match or an explicit upstream_stream override. Package suffix stripping and named transforms reuse the rpms helper; already normalized OpenJDK versions are not transformed twice. After explicit package transforms, semantic +build metadata is removed and prerelease hyphens are represented as RPM tildes. Build-only differences do not create upgrades or staleness, while OpenJDK’s build number is retained by its explicit transform. For monitor schemes pep440 and python, PEP 440 alpha, beta, and release-candidate versions are normalized to RPM tilde notation (3.15.0rc2 becomes 3.15.0~rc2), so equivalent packaged prereleases compare equal and final releases sort later. The standard packaging parser handles PEP 440 aliases and casing. Nonzero epochs, development releases, post-releases, local versions, and invalid PEP 440 values produce unknown rather than an unsupported comparison. CVE version_transform and source-artifact availability checks do not affect tracker currency.

Repository URLs are matched across HTTPS, SSH, and Git syntax with .git and trailing slashes removed. Multiple matching projects require the metadata field upstream_monitor_project_id. This is a monitor identifier, not an Anitya release_monitoring_project_id. Providers whose repository cannot be recovered from their definition, such as Anitya or numeric GitLab IDs, require that explicit verified mapping. A resolvable source repository must agree with upstream_repo.

A monitor status reporting a failed upstream scan is preserved even if readable history permits successful collection. Dashboard consumers can distinguish old collection data from failed scanning using sync timestamps and the separate monitor_state, monitor_last_error, and monitor_last_success_at fields. Write failures and generation conflicts propagate and leave the last successful inventory unchanged; they require a fresh collection rather than retrying an old payload. The collector does not migrate the database; the CronJob’s blocking init container runs the initializer first.

Reconcile update MRs

DATABASE_URL=postgresql://postgres@localhost:5432/events \
  python -m rpm_tracker.reconcile \
  --rpms-project org/group/rpms --target-branch main

The installed rpm-tracker-reconcile command accepts the same arguments. Run the initializer before reconciliation and schedule reconciliation periodically, independently of monitor collection. The first successful reconciliation stores the exact project and target branch in rpm_sync_state; subsequent runs reject a different scope to prevent historical rollout records being reassigned.

The reconciler reads the existing Status MR rows in the shared database. It recognizes chore(rpms): Update <package> to <version> and Update <package> to <version>, including Draft:, WIP:, and STALE PATCH: prefixes. Both chore/upstream-update-<package> and chore/upstream-update-failure-<package> corroborate package identity, including diagnostic MRs retitled to valid update titles, but never establish a version. Package names and versions are exact, not substring or prefix matches. Unsupported RPM version syntax and conflicting branch/title packages remain uncertain. Diagnostic failure MRs with title Draft: investigate failed upstream update for <package> remain visible as package-level candidates without a target version.

rpm_update_mrs preserves every qualifying MR by (project, iid), including multiple open MRs for one package and packages absent from the current inventory. Each successful run refreshes the candidate snapshot from the Status rows; closed and no-longer-qualifying MRs disappear from the open list. Previously observed merged identities survive unrecognized titles, changes of target branch, and temporarily missing Status rows. This retained evidence can reject a later version change, but only currently qualifying MRs can resolve a rollout. The tracker stores creation and merge timestamps from the raw webhook’s object_attributes.created_at and object_attributes.merged_at. Timestamps must include an offset, Z, or GitLab’s space-separated UTC suffix and are normalized to UTC. Neither mutable updated_at, observation time, nor MR-head-version dates are substitutes. Previously reliable timestamps survive later omitted fields. Conflicting timestamps remain unavailable, with their reason retained for investigation.

Rollouts are keyed by (package, version). Exactly one verified merged MR in the configured scope with an explicit reliable merge timestamp resolves a rollout. Missing timestamps, conflicting identities, and multiple matching merged MRs leave rolled_out_at null with an unavailable_reason. Merge times before creation or in the future are unavailable. Changing a verified version identity after merge cannot move its rollout to another version. The timestamp is a v1 merge-time proxy, not proof of successful publication.

Every run revisits current packaged versions, verified merged update targets, and historical rollout keys. Later MR evidence can therefore resolve an unavailable historical version after the inventory has advanced. New ambiguity invalidates an earlier resolution. A rollout’s resolved_at changes only when its resolution result changes. The MR snapshot, rollout changes, and last_mr_reconciled_at commit atomically under the existing sync-row lock; failure retains the previous snapshot and freshness timestamp. MR reconciliation does not change the inventory generation or collection freshness.

Read MR information

The dashboard exposes:

  • GET /api/rpm-upstream-state: inventory, rollout reasons, reconciliation scope/freshness, all open_update_mrs, and one blocking_mr per package row.
  • GET /api/rpm-update-mrs: all qualifying open MRs independently of package rows, plus last_mr_reconciled_at.
  • GET /api/rpm-update-mrs?stuck_only=true: MRs older than 24 hours and MRs with unknown ages. Exactly 24 hours is not older than the threshold.

MR ages are computed from creation time at read time. Missing, conflicting, or future creation times produce null age_seconds and null is_stuck, never zero or omission. created_at_issue explains invalid or conflicting evidence. The package-row MR prefers a verified match to the selected upstream version, then the newest known creation time, source update time, project, and IID for deterministic selection. A fallback MR link is a package-level candidate, not evidence of a target-version match. Selecting one package-row MR never removes other MRs from either listing. A null reconciliation timestamp means no MR snapshot has been collected yet.

Transactional storage

Create a SQLAlchemy engine with rpm_tracker.db.get_engine() for the storage module, and dispose of it when collection finishes. The tracker-owned ORM models mirror the Alembic tables; only the initializer migrates them. Call store.get_generation(engine) before collecting; both it and store.ingest(engine, request) require the bundled schema revision. Fetch the complete RPM checkout, every required monitor release page, and each mapped project’s snapshot before calling ingest() with one status per package and the checked checkout SHA. A failed fetch must not submit a partial inventory. ingest() locks the singleton sync row, checks the expected generation, and replaces upstream and status rows in one transaction. A failed write or conflicting generation leaves the previous inventory unchanged. Stored rollout times and MR information are separate from inventory replacement. The dashboard reads the sync, upstream, status, rollout, and MR tables in one read-only snapshot; it does not migrate or write tracker tables.

Configuration

Variable Description
DATABASE_URL Shared events PostgreSQL connection URL
POSTGRES_URL Connection URL fallback
RPMS_REPOSITORY_URL Repository URL, alternatively supplied by --rpms-repository
UPSTREAM_MONITOR_URL Monitor base URL, alternatively supplied by --monitor-url
UPSTREAM_MONITOR_TOKEN Optional bearer token for monitor API reads
RPM_TRACKER_RUNTIME_PASSWORD Initializer-only password for the restricted runtime role
PGPASSWORD PostgreSQL password supplied separately from the connection URL
SSL_CERT_FILE Trusted CA bundle for the monitor client’s stdlib HTTPS requests
GIT_SSL_CAINFO Trusted CA bundle for Git HTTPS requests
REQUESTS_CA_BUNDLE Trusted CA bundle for the rpms helper’s Requests dependency
Reconciliation option Description
--rpms-project Required exact GitLab project path, e.g. org/group/rpms
--target-branch Exact inventory target branch; defaults to main

Development

cd rpm-tracker
pip install -e '.[dev]'
pytest

To run the PostgreSQL migration, reconciliation, and atomic replacement tests, set DATABASE_URL to a disposable PostgreSQL database before running the tracker and dashboard tests. CI supplies test_events; the database-backed tests are skipped when DATABASE_URL is unset. The tracker tests refuse to replace an existing nonempty tracker inventory.

The initializer privilege test requires no existing rpm_tracker login role and uses a disposable database. It verifies concurrent initialization, quoted passwords, allowed tracker writes, and rejected public/revision/schema writes. Image smoke checks in rpm-tracker/tests-k8s.yml verify executable CLIs, native RPM ordering, spec parsing, specfile macro expansion, and bundled migrations through the existing Konflux K8s integration scenario.

Native RPM ordering and collector database integration checks require the system Python RPM bindings. Policy and collection-failure unit tests can run without them, but production collection fails immediately when the bindings are unavailable.

Check model and migration drift

After initializing a disposable database, compare its migrated schema with the tracker ORM models from the rpm-tracker/ directory:

DATABASE_URL=postgresql://postgres@localhost:5432/events \
  python -m alembic -c rpm_tracker/alembic.ini check

Alembic receives models.Base.metadata and reflects only the rpm_tracker schema. Supported schema differences, including unexpected tracker columns and tables, fail the check. Status tables in public and other service schemas are excluded. Checking does not run role provisioning or change the database.

License

This project is licensed under the GNU General Public License v3.0 or later. See the LICENSE.

5.39 - 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). Includes pending_until for 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. If mr_iid is not set, creates the branch, commit, and MR via the GitLab Commits API and applies the managed-by::workqueue scoped label. If mr_iid is 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. Sets needs_mr to "false" on success.
  • ensure_dashboard_note — posts an internal note with the Hummingbird dashboard link on the MR and stores the needs_dashboard dimension as "false" with detail {"note_id": int, "url": str}. Selected by the decider after create_mr or update_mr completes when needs_dashboard != "false".
  • check_empty_diff — checks whether an active MR’s diff became empty (target branch moved). Triggered by the needs_empty_check dimension via pending_until expiry (~every 30 minutes per item). If the diff is empty, closes the MR with an explanatory note, deletes the branch, and cancels the item. Sets needs_empty_check to "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 the needs_reconcile dimension, set by webhooks (approved, unapproved, update, pipeline success/failure) and pending_until expiry. Merges directly when detailed_merge_status is mergeable and auto_merge is enabled. Syncs title and description if drifted. Sets needs_reconcile to "false" on completion (with pending_until for 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 TRUE using SELECT ... 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_until during execution
  • Executors apply state updates directly (phase, MR fields) and must not call session.commit() or session.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_until dimensions (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

  1. Create a detail table and SQLAlchemy model (e.g. CveWorkItem)
  2. Register a decider function: decider.register("cve", _cve_decide)
  3. Register executor functions for each action
  4. 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)

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. Include phase and action when 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 as old_state. Omit fields that are None (filtered by to_dict()).
  • trigger: what caused this event. Always include source ("api", "worker", or "decider"). Add action for deliberate operations, reason for error/system transitions, error for failure details.
  • metadata: context orthogonal to the state transition — request info on auth events, type-specific data on submit. Not for duplicating state already in old_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

  1. GitLab sends a build (job) webhook when a CI job finishes
  2. The event handler appends the failed job to the needs_ci_retry dimension’s jobs list and sets status to "true"
  3. The decider selects the retry_ci_jobs executor
  4. The executor processes all jobs in the list, matching each against the config tree and retrying matching jobs via the GitLab Jobs API
  5. Jobs with non-zero delay set pending_until to the earliest per-job not_before timestamp — the sweep notifies when it expires
  6. 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.search on joined lines (slash-delimited)

Retry safety

Before retrying a job, the executor checks:

  1. No newer job with the same name exists (prevents double retries)
  2. 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:

  1. WORKQUEUE_SERVICE_TOKEN env var — used directly as a Bearer token. Recommended for CI (OIDC/projected SA tokens).
  2. Cached token — if a valid token exists in the per-server cache, it is reused automatically.
  3. 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. Requires requests-gssapi and 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.

  • issuer determines which JWKS keys validate the token signature. Use k8s as shorthand for the auto-detected cluster OIDC issuer.
  • claims matches JWT payload fields. All conditions must match (AND); list values match any (OR). Values are compared case-insensitively with string coercion (handles numeric project_id, boolean ref_protected).
  • permissions grants read, retry, and/or submit.
  • item_types optionally restricts the rule to specific work item types. Omit to apply to all types.
  • Every issuer rule must include aud in claims to 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:

  1. Exact match on the full project path
  2. Longest group-prefix match on a / boundary (e.g. redhat/rhel/src matches redhat/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.

5.40 - Work Queue Service Design

Architectural design document for workqueue-service. Covers the reasoning behind every major design choice so that future changes can be made safely. For operational usage, see Work Queue Service.

1. Design Philosophy

Four principles shaped the service’s architecture:

Security boundary. The service sits between untrusted callers (AI agents, CI jobs, automation scripts) and trusted write operations (GitLab pushes, MR creation). All changes are inspectable structured data, not opaque blobs. Path blocklists and action validation run before any GitLab write.

Type-agnostic queue, type-specific behaviour. The processing loop, claiming, heartbeat, and lease management are generic. Type-specific logic (MR creation, CVE remediation) lives in pluggable subpackages that register deciders and executors at import time. Adding a new work item type requires no changes to the generic layer.

Single pending action. Only one action per work item at a time. After each action, the decider re-evaluates with current state. This avoids queuing actions that assume a future state which may not hold.

Phase + dimensions, not a linear state machine. The coarse lifecycle (active/completed/failed/cancelled) is separate from independent readiness signals (CI, approval, Konflux build). Dimensions can regress (force-push resets CI), which a linear state machine cannot express.

2. Architecture

Callers submit work items via REST API. The ProcessingManager drains pending actions in per-item threads, dispatching to type-specific executors. Executors create and update GitLab MRs via the Commits API.

flowchart LR
    callers["Callers\n(CI, CLI, agents)"] --> API["REST API\n+ Auth"]
    API --> DB[("PostgreSQL")]
    Processing["ProcessingManager"] --> DB
    Processing --> GL["GitLab\nCommits API"]
    API -.-> Processing

3. Design Decisions Record

See Work Queue Service Design Decisions for all DDR entries covering data model, auth, processing, event pipeline, and MR lifecycle decisions.

4. Future Work

Event-driven dimension updates

The dimension mechanism is implemented with all MR dimensions following the uniform needs_* model (DDR-48). MR state events drive phase transitions (merge -> completed, close -> cancelled). Future dimensions: Konflux PipelineRun events updating needs_konflux_build/needs_konflux_release. Each follows the same EventResult.dimensions pattern.

Non-managed MR tracking

Design how the service handles MRs it did not create (MRs without the managed-by::workqueue label). Requires subscribing to note events so the service can discover dashboard notes posted by the Lambda and adopt them as needs_dashboard dimensions. Prerequisite for retiring the Lambda.

Status rendering

An executor that edits the dashboard note (using the note_id from the needs_dashboard dimension detail) with aggregated dimension status (CI, Konflux).

CVE work item type

Add cve/ subpackage with CveWorkItem, CVE-specific decider (analyze → label → create advisory MR → check VEX → close), and CVE-specific executors. No changes to the generic layer.

Content validation and size limits

Add content-level inspection to the pre-commit checks: maximum file size, binary detection, and content pattern matching. Currently only path blocklists and action validation are enforced.

Reconciliation sweep

Note: The reconcile_mr executor (DDR-46) now provides per-item reconciliation. A full sweep (querying GitLab for all active items in bulk) remains a future optimization for catching items that fall through individual reconciliation.

Service rename

The service was renamed from hummingbird-mr-service to workqueue-service to reflect its generalized purpose. A further rename to hummingbird-work-queue or similar is deferred until a second work item type is implemented.

5.41 - Work Queue Service Design Decisions

Design Decision Registry (DDR) for workqueue-service — every major design choice, the alternatives considered, and why the chosen approach won. For the design overview, see Work Queue Service Design. For operational usage, see Work Queue Service.

DDR-1: Phase + dimensions vs linear state machine

Decision: Replace the 12-state linear state machine with a phase + dimensions model.

Context: The initial design used a single state column: pending → in_progress → created → mr_ci → approved → merged → post_merge_ci → building → releasing → completed. This broke because post-creation concerns (CI, approval, Konflux build) are independent — CI can pass before approval, approval can be revoked, force-push resets CI but not approval.

Alternatives: (a) Composite states (ci_passed_approval_pending) — exponential explosion. (b) Multiple boolean columns — rigid, schema migration for each new signal.

Rationale: Phase tracks coarse lifecycle; independent dimensions in a separate table track readiness signals with variant support for multi-instance cases (per-component Konflux builds). Dimensions can regress naturally. New dimension types need no schema migration.

DDR-2: Four generic phases (active/completed/failed/cancelled)

Decision: Narrow from 6 MR-specific phases to 4 generic phases.

Context: The intermediate design had pending, active, merged, completed, failed, closed — mixing generic concepts with MR-specific milestones.

Alternatives: Keep MR-specific phases and add more for each type.

Rationale: Phase answers “is this item done, and how?” Type-specific milestones (MR merged, CI passed) are tracked via dimensions and detail-table columns. Items start as active — there is no pending phase because the decider immediately assigns an action at creation time.

DDR-3: Dimensions in a separate table with variant support

Decision: Store dimensions in work_item_statuses table, not as columns on work_items.

Context: Initially considered a column per dimension (ci_status, approval_status, etc.).

Alternatives: (a) Column per dimension — schema migration for each new one. (b) JSONB column — flexible but loses CHECK constraints and complicates queries. (c) Separate table with composite PK (work_item_id, dimension, variant).

Rationale: Option (c). New dimensions need no migration. Variants handle multi-instance signals (multiple Konflux components). Each work item only has rows for dimensions that apply to it. The “are all dimensions satisfied?” query is a natural GROUP BY/HAVING.

DDR-4: Decider as pure function, not state machine transitions

Decision: The decider is a pure function decide(item) → action | None registered per type. It only determines the next action to execute.

Context: The original design had a TRANSITIONS dict mapping state → frozenset[state] with validate_transition().

Alternatives: (a) Transition table — rigid, can’t express “depends on which dimensions passed”. (b) Decider function — flexible, testable.

Rationale: The decider is trivially testable (given state, assert action). State transitions come from executors (they apply phase changes directly) and events (they update dimensions). The decider just picks the next action based on current state.

DDR-5: Single pending action, not a queue

Decision: One pending action per work item at a time.

Context: Considered queuing multiple actions (“merge, then trigger Konflux build”).

Alternatives: Action queue table per work item.

Rationale: Queued actions assume future state that may not hold. If “merge” fails, the queued “trigger Konflux build” is nonsense. After each action, the decider re-evaluates with current state. The attempts counter tracks consecutive failures, not total actions.

DDR-6: Claiming via leased_until (reusable)

Decision: Time-based lease (leased_until column) for work item claiming, reusable across all phases.

Context: The original design used state = 'in_progress' as a one-time claiming mechanism.

Alternatives: (a) claimed_by + claimed_at columns — identifies the worker but adds complexity. (b) state = 'in_progress' — ties claiming to phase, not reusable. (c) leased_until timestamp — simple, reusable, self-recovering.

Rationale: Option (c). Items can be reclaimed repeatedly throughout their lifecycle. Expired leases are automatically reclaimable by other workers. The heartbeat context manager extends the lease during processing.

DDR-7: Generic + detail tables (joined table inheritance)

Decision: Split work_items into generic queue table + per-type detail tables (mr_details, future cve_details).

Context: The initial design had all MR-specific columns on the work_items table.

Alternatives: (a) Single table with nullable type-specific columns — sparse, doesn’t enforce type constraints. (b) JSONB payload column — flexible but loses type safety. (c) Joined table inheritance.

Rationale: Option (c). SQLAlchemy’s polymorphic_load="selectin" automatically loads the correct subclass. New types add their own detail table — no changes to the generic layer. Type-specific columns have proper types and constraints.

DDR-8: Type-dispatched decider/executor registries

Decision: Registry pattern for deciders and executors. Each type registers at import time via decider.register("mr", _mr_decide).

Context: Needed a way to dispatch to type-specific logic without the generic layer knowing about types.

Alternatives: (a) if/elif chains in the processing loop. (b) Class-based dispatch (strategy pattern). (c) Registry functions.

Rationale: Option (c). Simple, explicit, no class hierarchy needed. The mr/__init__.py import triggers registration. New types just add their own register() calls.

DDR-9: Structured file actions vs git patch_data

Decision: Replace opaque patch_data (BYTEA, git format-patch output) with structured file_actions (JSONB) and commit_message.

Context: The MR service sits on the security boundary — it writes to GitLab repos on behalf of untrusted callers. HUM-851 and the AI Investigation design rules require pre-commit inspection (path blocklists, content validation).

Alternatives: (a) Keep patch_data, clone repo, apply patch, then inspect diff — inspection happens after creating a working tree with credentials. (b) Parse patches with unidiff library — gives diffs not full content, still needs original files. (c) Structured file actions.

Rationale: Option (c). Inspection is a pure function over data the service already has — no clone, no git binary, no temp dirs. Path blocklists and action validation run before any API call. The file_actions JSONB column is also a queryable audit record.

DDR-10: GitLab Commits API vs git subprocess

Decision: Use the GitLab Commits API for branch creation and file commits. Delete git_ops.py.

Context: The original executor shelled out to git (clone, am, push), requiring: git binary in the container, subprocess timeout handling, GIT_ASKPASS credential management, temp directory lifecycle.

Alternatives: (a) Keep git subprocess with GIT_ASKPASS and timeout. (b) GitLab Commits API.

Rationale: Option (b). gitlab_sync.py in the same repo already proves the pattern. The Commits API’s start_branch parameter creates the branch automatically. No subprocess, no credentials in URLs, no temp dirs. Callers have full file contents (not patches), so git am semantics aren’t needed.

DDR-11: OIDC issuer from discovery, not hardcoded

Decision: Read the OIDC issuer from the /.well-known/openid-configuration discovery response. Rewrite the JWKS URI to use kubernetes.default.svc.

Context: The initial implementation hardcoded issuer = "https://kubernetes.default.svc". This failed on EaaS clusters where --service-account-issuer is https://oidc.op1.openshiftapps.com/.... The JWKS URI from discovery pointed to an IP:6443 that was unreachable from pods.

Alternatives: (a) Environment variable override (KUBE_OIDC_ISSUER). (b) Read from discovery.

Rationale: Option (b). No configuration needed — works on any cluster automatically. The JWKS fetch uses the path from discovery but rewrites the host to kubernetes.default.svc (always reachable on port 443). SA bearer token authenticates both requests (OpenShift blocks anonymous JWKS access).

DDR-12: item_type not type

Decision: Name the discriminator column item_type everywhere (DB, ORM, API, schemas).

Context: type is a Python builtin. Using it as a function parameter triggers ruff A002.

Alternatives: (a) Use type with alias in different contexts. (b) Rename to item_type everywhere.

Rationale: Option (b). Clean rename, no aliases, no special casing. Consistent at every layer.

DDR-13: source field removed

Decision: Remove the source column from mr_details. The caller_identity field (set automatically from authentication) serves the same purpose.

Context: source was a user-declared label (“rpms-ci”, “dependency-updater”) while caller_identity is the authenticated identity. Having both was redundant.

Alternatives: Keep both for cases where a shared SA submits on behalf of different logical sources.

Rationale: Removed until needed. caller_identity captures who submitted. If per-source filtering is needed later, it can be re-introduced as a field on MRWorkItemCreate.

DDR-14: attempts counts consecutive failures, not total actions

Decision: attempts increments only on executor failure and resets to 0 on success.

Context: The initial implementation incremented attempts before every action execution. A work item chaining 4 actions (ensure_mr → merge → trigger_build → complete) would exhaust MAX_ATTEMPTS on the third action.

Alternatives: (a) Separate total_actions counter. (b) Only count failures.

Rationale: Option (b). attempts answers “how many consecutive times has this action failed?” — the signal for escalation. Total actions executed is an audit concern, not a processing concern.

DDR-15: Executor applies state directly; decider only picks next action

Decision: Executors directly modify item.phase, item.mr_iid, etc. The decider only returns the next action to execute.

Context: Initially the processing loop set the phase based on ExecutorResult.success. This mixed two concerns.

Alternatives: (a) Processing loop sets phase. (b) Decider sets phase. (c) Executor sets phase.

Rationale: Option (c). The executor knows what happened (“I created an MR, so phase stays active”). Events that update dimensions also directly set state. The pattern is the same: something happens → state is updated → decider evaluates for the next action. Symmetry with event-driven dimension updates.

DDR-16: Items stay active after MR creation

Decision: After the ensure_mr executor succeeds, the item remains in active phase with no further actions pending.

Context: After MR creation, the decider returns None (no action). The item sits in active indefinitely.

Alternatives: (a) Set phase to completed after MR creation. (b) Keep active for future event-driven lifecycle tracking.

Rationale: Option (b). The active phase covers the entire lifecycle — from submission through CI, approval, merge, and post-merge tracking. The decider returns no actions until event-driven dimension updates (future SQS consumer) trigger the next step.

DDR-17: Audit log to S3, not database

Decision: Service-level audit (state transitions, decider decisions, actions taken) goes to S3 as immutable objects.

Context: Raw webhook events are already archived by sns-s3-archiver. The service needs its own audit trail for state transitions and decider evaluations.

Alternatives: (a) Database table (work_item_events). (b) S3 immutable objects. (c) Both.

Rationale: Option (b). S3 is immutable and cheap. The operational DB stays lean. Raw events are already in S3 via sns-s3-archiver; service-level audit captures the service’s reaction to those events.

DDR-18: Agent direction — dumber, not smarter

Decision: The hummingbird-agent should become a pure compute engine. The work queue service is the MR lifecycle orchestrator. The agent never calls the work queue service; the work queue service calls the agent.

Context: HUM-857 proposed adding create_merge_request as a new action in the agent’s actions.py. This was the wrong direction.

Alternatives: (a) Agent gains more actions (push, merge, MR management). (b) Agent becomes stateless compute; work queue service orchestrates.

Rationale: Option (b). The agent keeps LLM loop, tool registry, and sandbox — nothing else. Event routing, rate limiting, placeholder notes, result posting, session management, and all GitLab writes move to the work queue service. HUM-857 closed as Obsolete.

DDR-19: Shared database

Decision: The work queue service uses the same events PostgreSQL database as hummingbird-status and hummingbird-dashboard.

Context: Cross-service joins (e.g. work_items JOIN gitlab_merge_requests JOIN pipelineruns) are useful for the dashboard.

Alternatives: Separate database per service.

Rationale: Shared DB with service-specific Alembic migration chains (alembic_version_mr_service). Each service manages its own tables independently. The migration chain is isolated so one service can evolve its schema without affecting others.

DDR-20: Pydantic config over cki-lib/jsonschema

Decision: Use Pydantic for config validation. Load config from a YAML file at startup.

Context: Needed structured config for authorization rules and per-type operational settings. Alternatives: cki-lib config helpers, jsonschema, or plain dicts.

Alternatives: (a) cki-lib — adds a dependency, the service stays self-contained without it. (b) jsonschema — separate schema file, no type-safe access. (c) Pydantic — already a dependency (API schemas).

Rationale: Option (c). Pydantic provides type-safe attribute access, defaults, cross-field validators (e.g. mutual exclusivity of groups vs issuer+claims), and optional JSON Schema export. No new dependencies.

DDR-21: Claim-based rules (Vault bound_claims pattern)

Decision: Authorization rules match on arbitrary JWT claims, not just sub. All claim conditions AND; list values OR.

Context: Studied JWT trust/authz config from AWS API Gateway, HashiCorp Vault, Kubernetes Structured Auth, Istio, Traefik Hub, and oauth2-proxy. All support matching on arbitrary claims.

Alternatives: (a) Subject allowlist — rigid, requires exact sub values. (b) Claim-based matching — flexible, supports project_path, ref, groups, etc.

Rationale: Option (b). Vault’s bound_claims pattern is proven and simple. Values are coerced to lowercase strings before comparison to handle int/string mismatches (GitLab’s numeric project_id) and bool/string mismatches (ref_protected: true).

DDR-22: Issuer per rule, audience as claim

Decision: issuer is the only special field on a token rule — it determines which JWKS keys to use. All other JWT conditions (including aud) are standard claim matches in the claims dict.

Context: Considered a separate jwt.issuers section and/or treating aud as a first-class field alongside issuer.

Alternatives: (a) Global jwt section with trusted issuers + separate rules section. (b) aud as a dedicated field on each rule. (c) Issuer per rule, audience as a claim.

Rationale: Option (c). Keeps each rule self-contained and self-documenting. The Pydantic validator enforces that every issuer rule includes aud in claims to prevent cross-service token reuse.

DDR-23: Unified rule model for groups and tokens

Decision: A single rules list where every rule maps a remote identity (groups or issuer+claims) to local permissions.

Context: The original design had admin_groups/retry_groups (local permission → remote groups) alongside rules (remote identity → local permissions) — opposite mapping directions.

Alternatives: Keep separate group and token rule formats.

Rationale: Unified model. Every rule follows the same structure: remote identity → local permissions. Pydantic cross-field validators enforce mutual exclusivity.

DDR-24: Bearer-only auth with users/~ self-authentication

Decision: All API auth uses Authorization: Bearer tokens. Opaque OCP tokens (sha256~...) are validated by calling the OCP users/~ API with the user’s own token (self-authenticating).

Context: The oauth-proxy cannot simultaneously forward cookie-session tokens AND pass through non-OCP Bearer tokens on the same path. The TokenReview API would be cleaner but requires system:auth-delegator ClusterRoleBinding — a cluster-scoped permission not available on managed OCP clusters.

Alternatives: (a) Trust X-Forwarded-User from the proxy on all paths — injection risk on bypassed paths. (b) TokenReview API — requires cluster RBAC. (c) users/~ self-authentication.

Rationale: Option (c). No special RBAC needed. The users/~ endpoint returns username and groups. Results are cached with a thread-safe TTL cache (5 minutes). The /me endpoint bridges OAuth cookie sessions to Bearer tokens.

DDR-25: handled_types as explicit opt-in

Decision: handled_types declares which work item types this instance serves. Empty means handle nothing.

Context: Different deployments may serve different types from the same container image. Types carry per-type config (e.g. gitlab_url for MR).

Alternatives: (a) Handle all types by default. (b) Explicit opt-in.

Rationale: Option (b). Prevents accidental catch-all deployments. The sweep returns early when handled_types is empty — no items stolen from other instances in a shared-database deployment.

DDR-26: k8s shorthand skipped when not in-cluster

Decision: Rules with issuer: k8s are silently skipped when the in-cluster CA bundle is absent.

Context: The K8s OIDC issuer URL varies by cluster and is auto-detected at runtime. Local development doesn’t have a cluster.

Alternatives: (a) Error on startup if k8s issuer can’t be resolved. (b) Skip silently.

Rationale: Option (b). Allows using a production config file for local development and testing without startup errors.

Decision: The oauth-proxy sidecar handles only the OAuth login flow. API endpoints bypass the proxy. A proxy-protected /me endpoint bridges cookie sessions to Bearer tokens. The proxy must be configured with --cookie-refresh=1h to prevent stale-token failures.

Context: The OCP oauth-proxy cannot simultaneously forward cookie-session tokens AND pass through non-OCP Bearer tokens on the same path. Protected paths validate Bearer tokens via TokenReview (requires cluster RBAC we cannot obtain) or reject non-OCP tokens. Bypassed paths pass all Bearer tokens through but ignore cookies. Additionally, the OCP access token embedded in the proxy’s session cookie has its own expiry (default 24h), independent of the cookie lifetime. Without --cookie-refresh, the cookie outlives the token, causing the proxy to forward expired credentials while still reporting the user as authenticated — the “logged in but not authorized” failure observed in the hummingbird-dashboard deployment.

Alternatives: (a) All paths protected — blocks non-OCP Bearer tokens. (b) --openshift-delegate-urls — requires system:auth-delegator ClusterRoleBinding. (c) App reads proxy cookies directly — tight coupling to proxy internals. (d) Login-only proxy with /me bridge.

Rationale: Option (d). The /me endpoint is the only proxy-protected API path. It reads X-Forwarded-Access-Token (set by the proxy from the cookie session) and returns the OCP access token to the caller. Subsequent API calls use Authorization: Bearer sha256~... on bypassed paths, where the app validates via the OCP users/~ API. --cookie-refresh=1h keeps the embedded token fresh. TTL-bounded caching (5 minutes) on users/~ results ensures expired-token failures are never permanent (unlike @functools.cache which caches failures indefinitely).

DDR-28: Audit log with logging + optional S3 archival

Decision: Always log audit entries via Python logging at INFO level, and optionally archive to S3 as gzipped JSON objects when WORKQUEUE_SERVICE_AUDIT_S3_BUCKET is set.

Context: Needed an immutable record of state transitions, action outcomes, and auth decisions for compliance and debugging.

Alternatives: (a) Database table — bloats the operational DB with a different concern. (b) S3-only — no visibility without querying Athena; loses events if S3 is unreachable.

Why logging + S3: Logging to stdout is zero-config and integrates with existing container log collection for real-time visibility. S3 provides the durable, queryable archive (Athena) for compliance.

Key design rules:

  • Audit failures never propagate — write_audit_entry() catches all exceptions and logs them, but never raises.
  • S3 keys use YYYY/MM/DD/HH/MM/ prefix for Athena partitioning.
  • ContentEncoding: gzip set so S3 API consumers get transparent decompression.

Field conventions: See Audit Log Field Conventions in the operational documentation.

DDR-29: Events processed in work-item threads, not consumer threads

Decision: The SQS consumer thread stores events in the pending_events table. Existing per-work-item threads drain and process them.

Context: The ProcessingManager already runs one thread per work item with lease protection. Processing events in the same thread means events and actions are naturally serialized per work item.

Alternatives: (a) Consumer threads handle events directly — concurrency conflicts with action threads. (b) FIFO queue + in-memory dispatch — receipt handle management, visibility timeout extensions, message loss on crash.

Rationale: Option (c). Events join the existing thread structure. The decider runs after each event, determining follow-up actions. The single-thread-per-item invariant is preserved.

DDR-30: pending_events table as durable event store

Decision: Use a PostgreSQL table (pending_events) instead of FIFO queues or in-memory queues for event delivery to work-item threads.

Context: Since work-item threads do the processing, the SQS consumer just needs to deliver events to them durably.

Alternatives: (a) FIFO queue + in-memory dispatch — complex EventBroker, receipt handle management, message loss on crash. (b) FIFO queue + direct handling — concurrency conflicts.

Rationale: The DB provides durability (crash recovery), serialization (single thread per work item), and ordering (last_event_at guard). No FIFO queue, router thread, MessageGroupId computation, in-memory queues, or visibility timeout management needed.

DDR-31: SQS delete after INSERT, not after full processing

Decision: Delete the SQS message after INSERT+COMMIT into pending_events, not after full event processing.

Context: The hummingbird-agent deletes after handling (no DB intermediary). The workqueue-service can delete after INSERT+COMMIT because the event is crash-recoverable in PostgreSQL.

Alternatives: Delete after full processing — requires visibility timeout extensions, receipt handle passing, heartbeat management.

Rationale: After INSERT+COMMIT the event is durable in PostgreSQL. Duplicate SQS delivery may INSERT the same event twice; the last_event_at guard makes processing idempotent.

DDR-32: Resolver filters by phase, consumer stays read-only

Decision: The resolver filters by phase = 'active' on the MRWorkItem query. SQLAlchemy’s joined table inheritance handles the work_items table access implicitly. The consumer does SELECT + INSERT only, no UPDATE, no row lock contention.

Context: Only active items accept event-driven transitions (see DDR-37). Filtering at resolve time avoids unnecessary INSERTs into pending_events, thread spawns, and no-op processing for terminal items.

Alternatives: (a) Resolve without phase filter, guard only at processing time — wastes work for terminal items. (b) Consumer checks phase and applies transitions directly — contention with work-item threads.

Rationale: The phase filter adds negligible cost (PK lookup on work_items, typically one row). The consumer remains read-only. Business logic (phase transitions, decider, audit) is applied by the generic handle_event layer in the work-item thread.

DDR-33: Raw events stored, interpretation separated from application

Decision: The pending_events table stores the raw event JSON body and event type. Type-specific handlers interpret events (read-only on the work item), the generic layer applies the result.

Context: Event processing has three concerns: interpretation (what does this event mean?), application (phase transition, decider, audit), and routing (which work items?). Mixing them makes handlers hard to test and inconsistent across types.

Alternatives: (a) Handlers apply all changes directly — mixes interpretation with mutation, each handler reimplements decider/audit boilerplate. (b) Pre-process events in the consumer — consumer needs type-specific knowledge.

Rationale: Handlers return an EventResult dataclass (phase, event_ts, trigger metadata). The generic handle_event applies the phase transition via update_work_item_from_event, runs the decider, and emits audit entries. Handlers have read-only access to the work item and session (e.g. to check existing dimensions or detail fields) but EventResult is their only output — they never mutate the work item directly. This ensures decider/audit logic is consistent across all item types. See DDR-38 for the dimension delivery extension.

DDR-34: Per-work-item advisory lock for insert/drain coordination

Decision: Use pg_advisory_xact_lock(key) (transaction-scoped) keyed by work_item_id.int & 0x7FFFFFFFFFFFFFFF to coordinate the consumer’s INSERT with the work-item thread’s exit decision.

Context: Without coordination, the consumer can INSERT an event between the thread’s final drain check and its exit, orphaning the event until the next sweep (~5 minutes).

Alternatives: (a) Accept the race — events wait up to 5 minutes. (b) Global lock — unnecessary contention.

Rationale: Per-work-item advisory lock closes the gap. Either the INSERT commits before the thread’s re-drain (thread sees it and continues), or the INSERT commits after the thread exits (notify() spawns a new thread). The hash space is 2^63, making collisions negligible. The sweep is the safety net for the astronomically unlikely collision case.

DDR-35: Sweep for startup recovery

Decision: Extend the periodic sweep to also check for work items with unprocessed pending_events rows.

Context: Events that arrive while the service is stopped, or that survive a mid-processing crash, must be processed on restart.

Alternatives: Separate startup-only scan.

Rationale: Unifying startup recovery with the periodic sweep keeps a single code path. The sweep runs immediately on manager.start(), catching events from downtime. It also serves as a safety net behind the advisory lock.

DDR-36: Consumer lifecycle and shutdown ordering

Decision: The SQS consumer is a single daemon thread started from the FastAPI lifespan. Shutdown stops the consumer before work-item threads.

Context: If work-item threads stop while the consumer is still INSERTing, events could be orphaned. The consumer must stop first.

Alternatives: Non-daemon thread with explicit join.

Rationale: Single thread is sufficient for low event volume (MR state changes). Daemon thread ensures process exit even if the thread hangs. Shutdown ordering: (1) set shutdown_event, (2) join consumer thread (timeout=30s), (3) call manager.stop(). No consumer when WORKQUEUE_SERVICE_EVENTS_QUEUE_URL is unset.

DDR-37: Terminal phase semantics — only active items accept events

Decision: Only items in phase = 'active' accept event-driven phase transitions. completed, failed, and cancelled are terminal phases — recovery requires explicit action via the retry API.

Context: Initially only failed was guarded (phase != 'failed'). completed and cancelled items could be modified by events: a closed event could transition a completed item to cancelled, and an opened event could reactivate a cancelled item.

Alternatives: (a) Guard only failed — inconsistent, completed items are accidentally mutable. (b) Growing exclusion list (NOT IN ('failed', 'completed')) — fragile if new phases are added. (c) Positive match phase = 'active'.

Rationale: Option (c). A single positive match is clearer and future-proof. If a work item was cancelled (MR closed) or completed (MR merged), only deliberate human action (retry API) should reactivate it — not an automated event. The retry API accepts both failed and cancelled items. The resolver also filters by phase = 'active' at query time (DDR-32) to avoid unnecessary INSERT/processing for terminal items.

DDR-38: Dimension updates via EventResult

Decision: Event handlers return dimension upserts as part of EventResult. The generic handle_event layer applies them.

Context: DDR-33 established that handlers return structured results and the generic layer applies them. Dimensions (DDR-1, DDR-3) need the same pattern — handlers identify which dimensions changed, the generic layer upserts them and emits audit entries.

Alternatives: (a) Handlers call crud.upsert_dimension() directly — breaks the read-only handler contract from DDR-33. (b) Separate dimension-specific consumer — duplicates the resolver/handler infrastructure.

Rationale: EventResult.dimensions is a list of DimensionUpdate dataclasses. After phase transition logic, handle_event iterates over dimensions and calls crud.upsert_dimension() for each, emitting an AuditEntry with event_type="dimension_change" using the existing dimensions field. Dimension upserts run even when the phase transition is stale (the early return is refactored to skip only the phase/decider block). This ensures dimension-only events (e.g. note events with no phase change) are still applied.

DDR-39: Server-derived idempotency keys with phase-aware uniqueness

Decision: Replace the client-supplied idempotency_key field with a server-derived key mr:{project}:{source_branch}:{generation}. Enforce uniqueness only among non-terminal phases via a partial unique index.

Context: The original API required callers to supply a unique idempotency_key per submission. Recurring callers (dist-git sync, upstream sync) had to invent unique keys for each invocation and track them across runs. The global unique constraint also blocked new submissions for a branch that had a previous completed or cancelled item with the same key.

Alternatives: (a) Client-supplied key with a prefix convention — callers still need to generate and track keys. (b) Server-derived key from the natural deduplication identity (project, source_branch) with a generation counter for lifecycle restarts.

Rationale: Option (b). The idempotency key is a DB-level safety net, not a caller concern. The natural identity is (project, source_branch); the generation counter increments when a new item is created after the previous one reached a terminal phase. The partial unique index (WHERE phase NOT IN ('completed', 'cancelled')) ensures at most one active/failed item per idempotency key while allowing completed items to coexist with their successors.

DDR-40: Upsert semantics for recurring MR management

Decision: Replace one-shot insert_mr_work_item with upsert_mr_work_item that looks up an existing active/failed item by (project, source_branch) and determines the correct action from a content hash comparison.

Context: Recurring callers submit MRs for the same branch repeatedly. Under the old insert model, each submission created a new work item — even when the previous one was still active, leading to orphaned branches and duplicate MRs.

Alternatives: (a) Client manages deduplication — check before submit, handle conflicts. (b) Server-side upsert — callers always submit current desired state, the service deduplicates transparently.

Rationale: Option (b). The upsert does a (project, source_branch) lookup among non-terminal items and branches on the result:

  • No match → create a new item (actionable = true)
  • Active, same content → no-op (return existing, "unchanged")
  • Active, different content → update fields, set actionable = true, reset needs_mr = "true"
  • Failed → reset to active, update content, clear error ("retried"), set actionable = true
  • Terminal (completed/cancelled) → create a new generation (DDR-39)

Concurrent insert races are handled via DDR-39’s partial unique index — the loser catches the IntegrityError, rolls back, and retries.

DDR-41: ThreadPoolExecutor with bounded workers

Decision: Replace unbounded per-item threading.Thread spawning with a fixed-size ThreadPoolExecutor (10 workers).

Context: The periodic tick mechanism (DDR-42) can wake many idle items simultaneously. Unbounded thread creation would cause resource spikes (~109 threads in the worst case).

Alternatives: (a) Keep per-item threads with a semaphore. (b) Bounded thread pool.

Rationale: Option (b). ThreadPoolExecutor provides built-in queuing when all workers are busy, deduplication via _submitted set, and clean shutdown via pool.shutdown(wait=True). The 10-worker limit caps concurrent GitLab API calls.

DDR-42: Hash-bucketed tick events for periodic re-evaluation

Superseded by DDR-48. The tick_30m and tick_5m synthetic event mechanism has been replaced by the pending_until column on work_item_statuses. The sweep now finds items with expired pending_until and notifies them directly — no event insertion needed.

Decision: Insert synthetic tick_30m PendingEvent rows for idle active items using hash bucketing (6 buckets × 5-minute sweep = 30-minute full cycle).

Context: MRs can become stale when the target branch moves (another MR merges the same content). No reliable GitLab webhook exists for this case. Periodic re-evaluation via tick events enables detection.

Alternatives: (a) Reconciliation sweep as a separate job. (b) Tick events via the existing sweep mechanism.

Rationale: Option (b). Reuses the existing sweep and event processing infrastructure. Hash bucketing spreads the load deterministically. The NOT EXISTS guard prevents tick accumulation when the pool is backlogged. Named tick_30m to allow future ticks at different intervals.

DDR-43: Empty diff detection via repository_compare

Decision: Detect and handle empty MR diffs at two call sites: ensure_mr (prevention on create and force-push) and check_empty_diff (periodic via sweep).

Context: The detection-execution race causes empty MRs when the target branch moves between change detection and MR creation. Also, existing MRs can become empty after creation.

Alternatives: (a) Compare file content hashes before commit. (b) GitLab repository_compare API after commit/push.

Rationale: Option (b). The repository_compare endpoint returns the diff between two branches. Empty diffs list means the branches are identical. This is authoritative and handles edge cases that hash comparison might miss (e.g. whitespace normalization). Cost: one API call per check (~4 calls/minute across all items for periodic checks).

DDR-44: Per-item processing deadline

Decision: Exit the work item loop after 60 seconds of wall-clock time.

Context: The tick mechanism (DDR-42) and check_stale_mr action could cause extended processing loops for items that repeatedly generate new actions. A deadline prevents any single item from holding a pool worker indefinitely.

Alternatives: (a) Limit loop iterations. (b) Wall-clock deadline.

Rationale: Option (b). Wall-clock time is predictable regardless of action duration. The deadline is between loop iterations, not mid-executor, so executors have their own HTTP timeouts. The item is re-picked by the next sweep if work remains.

DDR-45: Dimension-driven decider with needs_* flags

Decision: The decider scans needs_* dimensions in priority order instead of encoding per-concern conditionals.

Context: The original decider checked item.mr_iid is None for create, dashboard_link dimension absence for dashboard notes, and would need more checks for auto-merge, stale detection, metadata sync. Each new concern added another conditional.

Alternatives: (a) Per-concern conditionals in decider. (b) Dimension-driven: events/executors set needs_* flags, decider scans them.

Rationale: Option (b). The decider becomes a simple priority scan. Executors own their trigger-to-next-step logic by setting the appropriate needs_* dimension. Events set needs_reconcile to request re-evaluation. Adding a new concern = new dimension + handler in reconciler, no decider changes needed.

Update (DDR-48): The original three-category model (has_*, needs_*, observed state) was unified into a single needs_* trigger model with "true"/"false" status. See DDR-48.

DDR-46: reconcile_mr pattern

Decision: Single reconcile_mr executor handles all MR state convergence, with cheap (always) and expensive (flagged) tiers. Uses direct mr.merge() instead of GitLab auto-merge.

Context: Separate executors per concern (check_stale_mr, enable_auto_merge, sync_metadata) each needed their own registration, decider rule, and dimension tracking. GitLab auto-merge (auto_merge=True) introduced state-management complexity: merge_when_pipeline_succeeds tracking, stale SHA detection after force-push, and recurring 422 noise for non-mergeable MRs.

Rationale: One mergerequests.get() call serves all cheap checks. The reconciler checks detailed_merge_status == "mergeable" and calls mr.merge() directly — no GitLab auto-merge state to track, no skip-lists to maintain, no stale SHA detection. Pipeline completion and approval webhooks trigger reconcile via the needs_reconcile dimension, keeping merge latency to seconds. Expensive operations (empty diff via repository_compare) only run when needs_empty_check is actionable (periodic via pending_until, DDR-48). The reconciler is idempotent — all corrections are conditional on actual vs desired state.

DDR-47: CI job retry herder

Updated by DDR-48. The needs_ci_retry="pending_tick_5m" status and tick_5m promotion mechanism have been replaced by pending_until on the dimension row.

Problem: Hummingbird CI pipelines frequently fail due to transient infrastructure errors (runner system failures, git clone timeouts, OOM kills). Manual retries waste developer time and delay merges.

Options considered:

  • A. Executor action in workqueue-service (selected): Shares existing GitLab tokens, project config, DB-persisted state, and audit trail.
  • B. Standalone herder service: Maximum CKI fidelity but requires a new deployment, duplicates token management, and has no MR lifecycle integration.
  • C. Reconciliation polling: No new webhooks, but 30-minute detection latency and wastes API calls polling healthy pipelines.

Decision: Option A. The matcher engine is ported from the CKI pipeline-herder with a reduced field set. Build events are received via the existing SNS→SQS pipeline and resolved to MR work items by matching pipeline_id against head_pipeline_id on mr_details.

DDR-48: Uniform needs_* dimension model with pending_until

Decision: Unify all dimensions under a single needs_* trigger model with "true"/"false" status and an optional pending_until column for deferred re-evaluation. Drop synthetic tick events.

Context: The dimension system had grown three distinct categories (DDR-45): has_* positive completion markers, needs_* action triggers, and observed state dimensions (approval, pipeline, merge_status, has_conflicts). The has_* and needs_* split created asymmetric decider logic. Observed state dimensions were write-only — the reconciler set them but no code read them. The tick_5m/tick_30m synthetic event mechanism (DDR-42) required hash bucketing, NOT EXISTS guards, and special "pending_tick_5m" status values.

Alternatives: (a) Keep the three-category model and add pending_until alongside tick events — increases complexity with two deferral mechanisms. (b) Unify to needs_* with pending_until.

Rationale: Option (b).

  • Uniform semantics: every dimension is a needs_* trigger. Status is "true" (action needed) or "false" (handled). The decider uses a single check pattern: != "false" means actionable.
  • Absent = actionable: missing dimension rows are treated as needing action, so new items don’t require explicit initialization of every dimension.
  • pending_until replaces ticks: executors set pending_until to schedule future re-evaluation. The sweep finds expired pending_until and notifies — no synthetic events, no hash bucketing, no "pending_tick_5m" status values.
  • Dropped dimensions: approval, merge_status, has_conflicts were write-only. The pipeline dimension carried a single integer and is better modeled as a typed column on mr_details.
  • Deferred dimensions: the decider pre-filters dimensions where pending_until is in the future, treating them as "false".

DDR-49: Replace pending_action TEXT with actionable BOOLEAN

Decision: Drop the pending_action TEXT column. Replace it with actionable BOOLEAN DEFAULT FALSE. The decider is called in exactly one place: the processing loop’s derive-and-dispatch step.

Context: pending_action served three roles: (1) sweep discoverability (WHERE pending_action IS NOT NULL), (2) processing loop continue/exit signal, (3) executor dispatch key. Roles 2 and 3 made the column authoritative for which action to run, creating a sync burden with the dimension-driven decider.

Alternatives:

  • Remove entirely — rejected: the decider treats absent dimensions as actionable, so a SQL query over dimension rows cannot find items whose next action derives from a missing row. A wake-up signal persisted to the DB is required.
  • next_action TEXT (renamed cache) — rejected: requires decider.decide() calls in the CRUD layer. The authoritative-looking name invites future dispatch reads.
  • actionable BOOLEAN — selected.

Rationale: A boolean is sufficient to bridge the absent-dimension discoverability gap. Stale True is harmless (false-positive sweep → loop starts, decider returns None, exits immediately). actionable is written in exactly two places: True when work is enqueued (creation, update, retry, event insertion), False only on the loop’s exit branch (committed atomically when both decider.decide() returns None and no pending events exist). This invariant ensures the item stays sweep-visible throughout processing — a crash at any point leaves actionable = True, preventing items from becoming permanently stuck.

5.42 - Service metrics

Long-running exporter for Hummingbird AWS Cost Explorer spend, Kubernetes Metrics API CPU/memory gauges, OpenShift cluster resource quota usage, PVC filesystem usage, and GitLab CI/CD schedule status.

Features

  • Explicit collectors - nothing runs unless listed in METRICS_CONFIG
  • Kubernetes Metrics API - per-container CPU and memory gauges
  • Cluster resource quotas - AppliedClusterResourceQuota usage and limits
  • AWS Cost Explorer - yesterday’s NetAmortizedCost by team and service
  • Volume filesystem usage - PVC disk usage via shutil.disk_usage()
  • GitLab CI/CD schedules - pipeline schedule health via GraphQL
  • Prometheus - HTTP metrics on port 9090

Configuration

Collectors are off unless listed in METRICS_CONFIG:

enabled: [kubernetes]

Unknown names fail startup.

Variable Purpose
METRICS_CONFIG YAML with enabled collector list (required)
KUBERNETES_CONFIG YAML with namespaces for the kubernetes collector
VOLUMES_CONFIG YAML with volumes list for the volume collector
GITLAB_URL GitLab instance URL (default https://gitlab.com)
GITLAB_TOKEN GitLab API token with read_api scope (required for gitlab collector)
GITLAB_CONFIG YAML with namespaces list for the gitlab collector
METRICS_PORT HTTP port (default 9090)
SENTRY_DSN Optional Sentry DSN
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY Cost Explorer IAM user (hub)
AWS_DEFAULT_REGION Must be us-east-1 for Cost Explorer

Kubernetes Metrics API

hummingbird_k8s_resource_usage{namespace,pod,container,resource,app} with resource=cpu|memory and app sourced from the pod’s app.kubernetes.io/name label (empty string if absent). Instant gauges from /apis/metrics.k8s.io/v1beta1/namespaces/<ns>/pods. This is not cAdvisor: managed OpenShift does not grant nodes/metrics or cluster-monitoring-view, so kubelet /metrics/cadvisor and platform Prometheus federation are not available.

If KUBERNETES_CONFIG.namespaces is unset, the collector uses the pod’s ServiceAccount namespace.

Cluster resource quotas

The kubernetes collector also reads namespace-scoped AppliedClusterResourceQuota objects from the quota.openshift.io/v1 API and exposes two gauges that mirror the openshift-state-metrics schema with a hummingbird_ prefix:

  • hummingbird_clusterresourcequota_usage{name,resource,type} – cluster-wide totals from status.total (type=hard|used)
  • hummingbird_clusterresourcequota_namespace_usage{name,namespace,resource,type} – per-namespace breakdown from status.namespaces (type=hard|used)

resource values match Kubernetes quantity names: cpu, memory, requests.cpu, limits.memory, pods, etc. Quantities are converted to base units (cores, bytes, count).

The same ACRQ may appear in multiple namespaces; the global metric is idempotent across duplicates. Stale series are removed when ACRQs disappear.

Requires list on appliedclusterresourcequotas in quota.openshift.io (namespace-scoped Role – no ClusterRole needed).

Volume filesystem usage

The volume collector reports disk usage for PVC-backed volumes. It is designed to run as a sidecar on pods that mount PVCs, since kubelet_volume_stats_* metrics are inaccessible on managed clusters (no ClusterRole for nodes/metrics) and RWO volumes cannot be mounted cross-namespace.

Configure via VOLUMES_CONFIG:

VOLUMES_CONFIG: |
  volumes:
    - {name: my-postgres, path: /data}

Metrics (collected every 5 minutes):

  • hummingbird_volume_usage_bytes{name} – used bytes
  • hummingbird_volume_total_bytes{name} – total capacity
  • hummingbird_volume_available_bytes{name} – available bytes

Deploy with enabled: [volume] and a non-default METRICS_PORT (e.g. 9091) to avoid port conflicts when running as a sidecar alongside other containers. See the hummingbird-status StatefulSet in the infrastructure repo for an example.

GitLab CI/CD schedules

The gitlab collector monitors the health of pipeline schedules across all projects in configured GitLab namespaces. It queries the GitLab GraphQL API once per hour, paginating through projects to fetch each schedule’s last pipeline status.

Configure via GITLAB_CONFIG:

GITLAB_CONFIG: |
  namespaces:
    - redhat/hummingbird

The GITLAB_TOKEN environment variable must contain a token with read_api scope that has access to the monitored namespaces. Keep it in an OCP Secret (not the ConfigMap).

Metric (collected every hour):

  • hummingbird_gitlab_schedule_status{instance,project,active,description,cron} – Enum reporting the last pipeline status of each CI/CD schedule

States: created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled, unknown. Schedules with no last pipeline report unknown.

Stale series are removed automatically when schedules disappear.

AWS costs

hummingbird_aws_cost{group,type,key} is yesterday’s NetAmortizedCost. Queries run at process start and every 24 hours.

group=all is the shared account by app-code and SERVICE. group=hummingbird filters app-code=RPRM-001 and groups by SERVICE, OPERATION, USAGE_TYPE, Name tag, and SERVICE+OPERATION.

Untagged Cost Explorer keys such as app-code$ become key=untagged. Well-known SERVICE names are shortened (S3, EC2). Dual grouping uses S3|PutObject.

The SAM template creates an IAM user with ce:GetCostAndUsage only. Access keys are created after deploy and stored in Vault. Deployment lives in the infrastructure repo.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.43 - VEX Checker

A CLI tool that works with the Red Hat CSAF VEX feed and the Hummingbird catalog API to check CVE statuses and track coverage across Jira, VEX advisories, and container image scan results.

Subcommands

Subcommand Purpose
check Look up a single CVE and show Hummingbird product statuses
reconcile Cross-reference Jira CVE tickets with the VEX feed
catalog Cross-reference catalog API CVEs with Jira tickets

Prerequisites

  • Python 3.11+
  • Internet access to security.access.redhat.com (no VPN required) for check/reconcile; to api-hummingbird.hummingbird-project.io (no VPN required) for catalog
  • JIRA_EMAIL Atlassian account email — required for reconcile and catalog
  • JIRA_TOKEN Jira API token — required for reconcile and catalog

check — Single CVE lookup

Fetches the CSAF VEX document for a given CVE and prints the status of all Hummingbird products, filtering out all other RHEL/OpenShift/etc. entries.

check: usage

./check_vex.py check CVE-YYYY-NNNNN [--json]

check: options

Option Description
CVE-ID CVE to look up
--json, -j Output results as JSON

check: examples

$ ./check_vex.py check CVE-2014-8090

CVE-2014-8090 — CVE-2014-8090 ruby: REXML billion laughs attack (Moderate)
  hummingbird-1:ruby.src      known_affected  none_available
  hummingbird-1:ruby3.3.src   known_affected  none_available
  hummingbird-1:ruby3.4.src   known_affected  none_available
  hummingbird-1:ruby4.0.src   known_affected  none_available
$ ./check_vex.py check CVE-2014-8090 --json
{
  "cve": "CVE-2014-8090",
  "title": "...",
  "severity": "Moderate",
  "hummingbird": [
    { "product_id": "hummingbird-1:ruby.src", "status": "known_affected", "remediation": "none_available" },
    ...
  ]
}

check: exit codes

Code Meaning
0 No VEX document found, no Hummingbird products listed, or no known_affected status
1 One or more Hummingbird products are known_affected
2 Invalid CVE ID format

reconcile — Jira/VEX sync check

Queries the Jira HUM project (Security component) for all CVE tickets, then checks each CVE in the VEX feed. Reports two types of mismatches:

  • Open in Jira but not known_affected in VEX — VEX says the issue is resolved but the Jira ticket is still open.
  • Closed in Jira but known_affected in VEX — the Jira ticket was closed but Red Hat’s advisory still marks Hummingbird as affected.

Continuous closed-ticket VEX reconciliation (Done-Errata → fixed, Not a Bug → known_not_affected or package_not_listed, scoped to the ticket’s package, with awaiting-vex work queue and dashboard persistence) lives in hummingbird-cve-analysis / the dashboard Closed tab (HUM-5843). reconcile remains the one-shot CLI audit.

Jira tickets are considered closed when their status is one of: Done, Closed, Won't Fix, Not a Bug.

VEX documents are fetched in parallel (--workers, default 20) so the command is fast even with hundreds of CVE tickets.

reconcile: usage

export JIRA_EMAIL=you@redhat.com
export JIRA_TOKEN=<your-api-token>
./check_vex.py reconcile [--workers N] [--json]

Credentials are read exclusively from environment variables to avoid exposing them in process listings.

reconcile: options

Option Description
--jira-url Jira base URL (default: https://redhat.atlassian.net)
--workers Parallel VEX fetch workers (default: 20)
--json, -j Output results as JSON

reconcile: example

$ ./check_vex.py reconcile

Fetching Jira tickets... 142 ticket(s)
Fetching VEX statuses for 89 unique CVE(s)...
  89/89
2 mismatch(es) found:

  HUM-1234  CVE-2025-1234  closed → known_affected  (VEX still open)
    Jira: https://redhat.atlassian.net/browse/HUM-1234
    VEX:  https://security.access.redhat.com/data/csaf/v2/vex-feed/2025/cve-2025-1234.json

  HUM-5678  CVE-2024-5678  open → fixed  (VEX not affected)
    Jira: https://redhat.atlassian.net/browse/HUM-5678
    VEX:  https://security.access.redhat.com/data/csaf/v2/vex-feed/2024/cve-2024-5678.json

reconcile: exit codes

Code Meaning
0 No mismatches found
1 One or more mismatches detected
2 Missing JIRA_EMAIL or JIRA_TOKEN

catalog — Catalog API / Jira coverage check

Fetches all CVEs currently detected in Hummingbird container images (via Grype scan results stored in the catalog API) and cross-references them with Jira HUM Security tickets. Reports CVEs that have no corresponding Jira ticket.

CVEs with a closed Jira ticket are reported separately as expected propagation delay — the typical flow is CVE fix → RPM → image rebuild → Grype DB update, and Jira tickets are closed early while the catalog API reflects the latest Grype scan (updated ~once daily).

Non-CVE vulnerability identifiers (e.g. GHSA-*) from Grype are filtered out since they are not tracked in Jira.

catalog: usage

export JIRA_EMAIL=you@redhat.com
export JIRA_TOKEN=<your-api-token>
./check_vex.py catalog [--api-url URL] [--workers N] [--json]

Each CVE line shows severity, age since first detection, affected images, and for tracked CVEs the Jira ticket key. Components with known fix versions are shown in brackets when available.

Closed Jira tickets are cross-referenced with the VEX feed to distinguish between different root causes:

  • Done-Errata, VEX still affected – fix shipped in RPM but not yet propagated to container images; needs rebuild or lockfile refresh.
  • Done-Errata, VEX resolved – fix fully propagated, Grype DB just needs its daily update to stop flagging.
  • Won’t Do / Not a Bug, VEX resolved – VEX already marks CVE as not affected; Grype DB delay, will self-resolve.
  • Won’t Do / Not a Bug, VEX still affected – genuine known issue that will persist in scans (accepted risk, upstream won’t fix, etc.).

catalog: options

Option Description
--api-url Catalog API base URL (default: https://api-hummingbird.hummingbird-project.io/v1)
--jira-url Jira base URL (default: https://redhat.atlassian.net)
--workers Parallel VEX fetch workers (default: 20)
--json, -j Output results as JSON

catalog: example

$ ./check_vex.py catalog

Catalog: 72 CVEs across 29 images (scanned 2026-04-28T10:45:38Z)
Jira: 748 tickets (437 unique CVEs)

11 untracked CVE(s) (no Jira ticket):

  CVE-2008-2662  High     8d  ruby (12 tags)  [ruby3.3@3.3.10, +36 more]
  CVE-2026-2950  Medium   8d  aspnet-runtime, ... (30 tags)  [lodash@4.17.21]
  ...

45 tracked CVE(s) open in Jira:

  CVE-2025-68114  High  8d  php (3 tags)  [capstone@5.0.6]  HUM-925
  ...

8 closed in Jira (Done-Errata) but VEX still affected -- verify fix propagated:

  CVE-2026-27143  Critical  8d  caddy, go-fdo-client, ... (10 tags)  HUM-969

4 closed in Jira (Done-Errata), VEX resolved -- Grype DB delay:

  CVE-2025-61732  High  8d  go, xcaddy (6 tags)  HUM-1119

3 closed in Jira (Won't Do / Not a Bug), VEX resolved -- Grype DB delay:

  CVE-2026-27140  High  8d  caddy, go, ... (10 tags)  HUM-967

1 closed in Jira (Won't Do / Not a Bug), VEX still affected -- genuine:

  CVE-2025-12781  Medium  8d  postgresql, python, ... (17 tags)  HUM-1387

catalog: exit codes

Code Meaning
0 All catalog CVEs are tracked (have a Jira ticket)
1 One or more untracked CVEs found
2 Missing JIRA_EMAIL or JIRA_TOKEN

Development

# Run tests
cd vex-checker && python3 -m unittest discover tests -v

# Run linter (from repo root)
ruff check vex-checker

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.44 - Hummingbird CVE agent

CLI that investigates open HUM Security tickets labeled cve-needs-attention or upstream-fix-available, using AI where deterministic analysis could not resolve the CVE. Fixes are handed to workqueue-service as mr work items.

System overview

flowchart TB

  TICKET["HUM ticket key"] --> RUN["run HUM-XXXX"]
  SWEEP["sweep [--limit N] [--workers N]"] --> SEARCH["Paginated Jira search<br/>open HUM Security tickets"]
  SEARCH --> FILTER{"Either scope label?<br/>Not embargoed?<br/>No agent analysis JSON?"}
  FILTER -->|eligible| POOL["Worker ticket pool"]
  FILTER -->|skipped| REPORT["Sweep report JSON<br/>counts + outcomes"]

  subgraph AGENT["CVE Agent"]
      direction TB

      RUN --> COL["Collect facts<br/>Jira · Pulp · SBOM"] --> CAND{"Backport<br/>candidate?"}
      POOL --> COL

      subgraph SANDBOX["Ephemeral rpms sandbox · one clone per ticket"]
          direction TB
          AIINV["AI investigation<br/>read-only SBOM · file tools"]
          BACKPORT["Secret-free sparse clone<br/>patches → file_actions"]
      end

      CAND -->|"affected + fix, current"| BACKPORT
      CAND -->|"no · or stale"| AIINV
      AIINV -->|"needs_change + fix"| BACKPORT
      AIINV -->|verdict| RESULT["InvestigationResult"]
      BACKPORT --> RESULT
      RESULT --> OUT["Result JSON<br/>stdout · --output"]
      RESULT -->|"--post-to-jira"| COMMENT["Jira comment<br/>+ result attachment"]
      RESULT --> REPORT
  end

  MR["workqueue-service<br/>mr work item · draft RPM MR"]
  KONFLUX["Konflux build"]

  RESULT -->|"--submit-mr"| MR --> KONFLUX

  classDef agent fill:#dbeafe,stroke:#2563eb,color:#111827
  classDef sandbox fill:#dcfce7,stroke:#16a34a,color:#111827
  classDef external fill:#f3f4f6,stroke:#6b7280,color:#111827
  class RUN,SWEEP,SEARCH,FILTER,POOL,COL,CAND,RESULT,OUT,COMMENT,AIINV,REPORT agent
  class BACKPORT sandbox
  class TICKET,MR,KONFLUX external

The agent runs as a CLI and is stateless: run handles one ticket, while sweep pages through all eligible tickets and investigates them concurrently. By default it only prints or persists results locally; Jira comments and MR submissions are opt-in (see Writes).

Critical evidence network, download, or parse failures abort the investigation; their local diagnostic report leaves the ticket eligible for a later retry. Unsupported or ambiguous evidence becomes needs_human. The ticket-level state machine lives in the CVE Lifecycle & State Vocabulary.

Tl;Dr

  • Scoped to CVE Security tickets - investigates open HUM Security tickets with either cve-needs-attention or upstream-fix-available; these are the deterministic-analysis residual and upstream-fix populations respectively. Sweeps skip embargoed tickets and tickets already carrying their <ticket>-cve-agent-analysis.json attachment.
  • Backporting - verifies upstream patches against pinned package source and prepares RPM changes; see Backporting.
  • AI in one place - every LLM call goes through ai.AgentAiClient, which uses the shared hummingbird_ai.models library directly, so its token/cost metrics feed the shared GCP AI-costs dashboard. AI judges the fuzzy residual the deterministic path does not own; see What the model sees for the exact context. The model can use bounded read-only tools to search the SBOM, inspect package files, inspect checksum-pinned package sources, and look up exact npm release metadata. Each ticket makes at most five tool-enabled model calls plus one final-answer call, whose request carries no tool definitions so the model must conclude. SBOM searches return at most 20 components with binary-presence evidence (present, source_only, or unknown) from the shared SBOM classifier; a name/version match or source-only hit does not prove the component ships. When a matching SBOM result is unknown, the agent can scan published RPMs for that component. Pulp listings and SBOM downloads are limited to 64 MiB each.
  • Structured results - every investigation produces a validated, versioned result contract. The result status is a Verdict from the shared lifecycle vocabulary (hummingbird_cve_analysis/lib/states.py), so the agent and cve_analysis speak the same state language. See the CVE Lifecycle & State Vocabulary for the full state machine. Every result also carries the ticket’s collection records: each evidence step’s outcome (success, empty, partial, failure, or limitation), its retryability, and its provenance. The caller fills them; the model neither sees nor writes them.

Prerequisites

  • Python 3.11+
  • GNU patch for automatic backports (gpatch on macOS); unsupported or unavailable patch tools leave the ticket for human review.
  • Git for %autosetup -S git_am backports.
  • GITHUB_TOKEN for GitHub backport lookup when unauthenticated API requests hit GitHub’s rate limit.
  • Jira API credentials (JIRA_TOKEN, and JIRA_USER where basic auth applies)
  • Vertex AI access for the AI path: GOOGLE_CLOUD_PROJECT, or GOOGLE_API_KEY for the direct Gemini API. The deterministic fast path needs neither.
  • For --submit-mr only: WORKQUEUE_SERVICE_URL and a WORKQUEUE_SERVICE_TOKEN bearing submit permission on the mr work type. submit comes from membership in the konflux-hummingbird-admin-access group. Check with curl -H "Authorization: Bearer $WORKQUEUE_SERVICE_TOKEN" "$WORKQUEUE_SERVICE_URL/api/whoami".

Installation

make hummingbird-cve-agent/setup

The target installs the in-repo dependencies (hummingbird-ai, hummingbird-cve-analysis) editable alongside this package. Equivalent by hand:

cd hummingbird-cve-agent
pip install -e ../hummingbird-ai -e ../hummingbird-cve-analysis -e ".[dev]"

For the environment, copy hummingbird-cve-agent/.env.example to .env and source it for a quick way to set up all necessary variables. If the workqueue endpoint uses an internal CA, export REQUESTS_CA_BUNDLE in the same shell as --submit-mr. Its PEM file must contain both the public roots (for Google OAuth) and the internal root (for the workqueue endpoint).

Usage

hummingbird-cve-agent run HUM-1234
hummingbird-cve-agent run HUM-1234 --output results/HUM-1234.json
hummingbird-cve-agent run HUM-1234 --post-to-jira
hummingbird-cve-agent run HUM-1234 --submit-mr
hummingbird-cve-agent sweep
hummingbird-cve-agent sweep --limit 25 --workers 6 --output sweep.json
hummingbird-cve-agent sweep --post-to-jira --submit-mr

This gathers facts for the ticket, runs the investigation, and prints the result as JSON or persists it to --output. See Backporting for how upstream fixes become package changes and what happens when preparation fails.

CLI logs include ticket and package context, e.g. - INFO (HUM-1234 openssl): collected facts for HUM-1234; the package is - until known, and unscoped logs show - -.

sweep defaults to all eligible tickets and four concurrent workers. --limit caps attempted investigations, including failed attempts; analyzed, embargoed, and out-of-scope search results do not consume the limit. Standard output shows counts, verdicts, token usage, estimated cost, and compact per-ticket summaries. --output FILE also saves the complete JSON report. The command exits nonzero if any ticket fails, while continuing other investigations. Without --post-to-jira, the sweep does not add the attachment used to mark a ticket as investigated, so a later sweep can retry it. Critical collection failures never post a recommendation or analysis attachment, even when the write flags are enabled. The CLI and sweep retain bounded failure records locally; transient failures are marked retryable and are not cached.

Backporting

The agent separates deciding that a package needs a fix from preparing the RPM changes. AI may judge affectedness or identify an upstream commit; the backporting code fetches and verifies the upstream patch, then prepares RPM changes. It does not rewrite or generate patch content (yet, see Planned work).

flowchart TD
    C{"Is fix in analysis?"} -->|Yes| U["Resolve fix and select release patches"]
    C -->|No| I["AI investigates"]
    I -->|Needs change + fix| U
    I -->|Otherwise| R["Return investigation result"]
    U --> V["Verify pinned source<br/>with existing patches first"]
    V -->|Pass| W["Create patch, spec,<br/>and metadata changes"]
    V -->|Fail| H["No file actions"]
    W --> M{"Submit MR?"}
    M -->|No| J["Return JSON"]
    M -->|Yes| D["Draft MR → Konflux<br/>and human review"]

1. Identify and select the upstream fix

The fast path skips AI only when the existing CVE analysis says the package is affected, names a backport as the next step, the ticket has the upstream-fix-available label, and the analysis still matches the package version and NVR collected now. If these checks do not pass, AI investigates. A needs_change result with a usable fix reference enters the same preparation path; otherwise, the agent returns the investigation result only.

Commit URLs are fetched directly. A merged PR URL is first resolved to its commits in application order. Branch-tagged patches must target the release line of the shipped package; a mismatch requires human review. After selecting the line, identical diffs are removed so mirrored or duplicate backports are not applied twice.

2. Prepare and verify against package source

The source preflight reads the package spec and checksum-pinned source archives from the rpms or original dist-git lookaside. For supported %prep forms, it extracts only the files touched by the patch stack, applies the existing downstream patches first, then checks the proposed upstream patches. This verifies patch application—not compilation, runtime behavior, or that the fix resolves the CVE.

Check Current behavior
Patch engine GNU patch permits fuzz up to 2 lines; %autosetup -S git and git_am use Git’s patch engine.
Upstream path prefix A shared prefix is stripped only when the archive proves the existing-file mapping. New-file paths use that mapping only when an existing hunk anchors it and the destination is absent.

3. Review the generated changes

Only verified patch text is staged. One file is created for each selected upstream patch; the spec and package metadata are updated as follows:

Output Change
rpms/<package>/<NNNNN>-<cve>-<slug>.patch New numbered patch file; the slug uses up to five words from the ticket summary, or the short commit ID if no usable summary exists.
Package .spec Adds patch declarations and arranges their application. Bumps Release by a trailing micro version (4.1 → 4.2); date-shaped releases require human review.
metadata/<package>.json Marks imported packages modified and adds the ticket/CVE to modification_reason. Independent packages remain independent; unrelated metadata is preserved.
Why preparation stopped Result
Supported %prep, path, or patch checks reject the fast-path fix needs_human, no file_actions.
AI investigation says needs_change, but automatic preparation fails Keep the finding and confidence; add the preparation failure to the summary; no file_actions.
Critical source fetch, checksum, or other evidence collection fails Abort with a local diagnostic report; retry behavior follows the failure classification.

4. Hand off the prepared changes

Use --submit-mr to hand off prepared actions; see Writes for submission and review details.

Planned work

HUM-8512 tracks one bounded AI attempt to adapt a patch after a hunk conflict. The planned flow replays the existing package patch stack, then verifies the adapted candidate from a clean tree with zero fuzz. It also proposes regenerating the candidate as a clean downstream diff and verifying that diff before staging. Neither patch adaptation nor patch regeneration is implemented today.

Writes: Jira comment and MR submission

Both writes are off by default; each is enabled solely by its own flag, so a bare run never touches Jira or GitLab. The two flags are independent — a comment can be posted without submitting an MR and vice versa — and a failed write exits non-zero. When both are enabled, the MR is submitted before the Jira comment and analysis attachment, so a failed MR submission leaves the ticket eligible for a later sweep.

  • --post-to-jira posts a deterministic RECOMMENDATION: ... comment rendered from the validated InvestigationResult — no AI call at comment time — through cve_analysis’s jira_client, which handles ADF formatting and duplicate suppression, so re-running a command is idempotent. It also synchronizes one cve-agent-* Jira label to the current verdict, replacing any prior CVE Agent verdict label and preserving unrelated labels. A label update failure stops the write before the analysis attachment is uploaded. The comment carries the verdict, summary, cited evidence, and the next review step when human review is needed, including when a necessary package fix has no automated backport. The full result JSON is attached as <TICKET>-cve-agent-analysis.json with the supporting evidence, file actions, and commit message. A duplicate comment does not prevent the agent from synchronizing the label or uploading a missing analysis attachment. Existing tickets are not backfilled; a later --post-to-jira run synchronizes their verdict label.

    Verdict Jira label
    needs_change cve-agent-needs-change
    not_affected cve-agent-not-affected
    already_fixed cve-agent-already-fixed
    no_upstream_fix cve-agent-no-upstream-fix
    needs_human cve-agent-needs-human
    error cve-agent-error
  • --submit-mr submits a draft MR work item to a running workqueue-service instance (work items default to auto_merge=false). It is a no-op unless the result carries a prepared backport (file_actions + commit_message). Submission POSTs to WORKQUEUE_SERVICE_URL with the WORKQUEUE_SERVICE_TOKEN service-account token; the agent is headless, so there is no interactive login fallback. The submitted work item’s description includes the package, CVE, built NVR when available, selected upstream commits, investigation reasoning, and pending Konflux and human checks. Currently, workqueue-service only applies this description when updating an existing MR.

Eval harness

eval scores investigations against completed human /cve sessions. The eval set, eval-tickets.txt, holds Jira ticket keys and nothing else — ticket content is internal data and is never committed — so facts are fetched live and running the eval needs Jira access.

hummingbird-cve-agent eval eval-tickets.txt --output eval-report.json

Read-only: every Jira request is a GET (issue, comments, changelog); test_evaluate asserts it.

A resolved ticket gives the answer away — fixed_in_build set, labels moved to awaiting-vex, status Closed, newest analysis comment post-fix — so each ticket is rewound through its changelog, dropping later comments.

The rewind cutoff is the handoff, not the close: cve_analysis records Fixed in Build and posts its assessment before it transitions the ticket, so the cutoff is the earlier of the resolution and the first recorded fix build. A reopened ticket rewinds to its first build, which is deliberately conservative — rewinding too far only costs context, rewinding too little hands the agent the conclusion. The fix build is additionally blanked outright, so a build recorded outside the changelog cannot leak either.

What is scored

Two axes, because one is not enough:

  • Disposition — where each side says the ticket ends up. The verdict is mapped to its eventual disposition, not through states.VERDICT_OUTCOME: that shared table answers “what does the agent do next”, so needs_change is In Progress — but a ticket the human backported still ends Done-Errata, and scoring against the immediate state would mark a correct needs_change as a miss and reward a wrong already_fixed.
  • Route — which branch the orchestrator would take, since already_fixed (Fixed in Build) and needs_change (MR creation) share the Done-Errata ending and are indistinguishable on the first axis. The human’s branch is derived from Pulp upload times: a fix build published after the handoff means the package had to change (needs_change), an earlier one means the shipped package already carried the fix (already_fixed); a Not a Bug closure is not_affected. A build Pulp no longer lists is reported as n/a and excluded from the route denominator rather than guessed.

Verdicts that reach no conclusion (needs_human, error) and conclusions no verdict can produce (unknown resolutions) never match. Package EOL Won't Do closures are deterministic cve_analysis outcomes and excluded before the AI eval runs. A ticket that cannot be rewound or investigated scores error without aborting the run.

HUM-7554     agree     verdict=already_fixed  expected=Closed / Done-Errata  actual=Closed / Done-Errata  route=!=needs_change
HUM-7537     DISAGREE  verdict=needs_human    expected=Closed / Won't Do     actual=                      route=n/a
disposition agreement: 1/2 (50%)
route agreement: 0/1 (0%), 1 undetermined
note: package facts (SRPM, SBOM, rpms repo) are current state, not the state at decision time, so agreement is retrospective

Retrospective, not historical replay

Only the Jira side is rewound. Package facts — the latest SRPM, the latest SBOM, and the rpms repo the sandbox clones — are read live, so an old ticket is re-investigated against today’s package state rather than the state that existed when the human decided. Where the fix has since shipped, already_fixed is partly handed to the agent, so the score is retrospective current-state agreement, not a historical replay; every report prints that caveat. True replay needs as-of-cutoff SRPM and SBOM selection plus a date-pinned rpms checkout, and is deliberately left to a follow-up.

Configuration

Operational settings come from the YAML config file; secrets and model credentials come from the environment.

Variable Purpose
HUMMINGBIRD_CVE_AGENT_CONFIG Path to a YAML config file (optional)
JIRA_TOKEN Jira API token
JIRA_USER Jira basic-auth user (optional)
GITHUB_TOKEN Read-only GitHub token for PR commit lookups (optional)
GITLAB_TOKEN Read-only GitLab.com token for MR commit lookups (optional)
GOOGLE_API_KEY Gemini API key (direct API mode)
GOOGLE_CLOUD_PROJECT GCP project for Vertex AI mode
WORKQUEUE_SERVICE_URL Base URL of a running workqueue-service instance (for --submit-mr)
WORKQUEUE_SERVICE_TOKEN Bearer token for workqueue-service (required for --submit-mr)

YAML settings:

Key Default Purpose
jira_url Red Hat Jira Jira base URL
model claude-sonnet-4-6 Model name passed to build_model
model_regions Gemini and Claude region prefixes Model-name prefix to Vertex region

Neither forge token grants write access: they only authenticate the read-only API calls that resolve a pull/merge request to its commits, and are needed only for private or rate-limited repositories. The sandbox never holds a push token.

What the model sees

Only non-candidates reach the model.

The prompt is a static system instruction encoding the /cve resolution process, plus the validated TicketFacts as JSON: ticket key, package, CVEs, Jira summary/description/labels, status/resolution/fixed_in_build, cve_analysis_block, discussion, and the built NVR and SBOM identity.

  • The SBOM document is excluded. Only its identity (sbom_nvr, sbom_url) is in the prompt; the multi-megabyte SPDX body is reachable only through the bounded search_sbom tool. Binary-presence evidence comes from SPDX sourceInfo and CONTAINS relationships to RPM roots; unknown requires further investigation. RPM repo reads inspect packaging files and patches, not necessarily the built upstream source or installed binary payload.
  • Package-directory search. A bounded literal search_package_files grep locates evidence spread across packaging files — whether a build config enables the vulnerable applet, which patch names a CVE — without spending the per-file read budget. An empty result is not proof of absence.
  • Other-package evidence. lookup_shipped_package resolves a candidate name to its latest source-package SBOM and reports the NVR, component version, and binary presence (present, source_only, or unknown). Only present confirms the named component ships; source-only or missing SBOM data remains inconclusive. Lookups are capped per ticket.
  • Package source and npm evidence. Source reads/searches use the checksum-pinned archives declared in the current package spec, replaying supported downstream patches without running spec scriptlets. Results report the spec version/release, rpms-repo revision, archive filename/checksum, and ticket NVR, with built_nvr_match: unverified. This is evidence about the inspected packaging revision; it does not prove that revision built the ticket NVR unless the match is established. Source inspection refuses source/patch conditions that depend on optional RPM macros not defined in the spec or on conditions affected by %undefine; it does not infer the build environment’s macros.dist. It also refuses conditional patch declarations and patch_direct preparation rather than guessing which patches RPM applies. Header conditions containing only dependencies or simple macro definitions unused by the header and %prep are ignored. Build-only switches such as Qt options therefore do not block source inspection or backport preparation. read_package_source and read_package_file accept optional start_line (1-based) and max_lines arguments and report start_line, end_line, and total_lines. An empty window has end_line: null. The existing per-read and shared character budgets still apply; truncated indicates character clipping within the requested window. SBOM search includes SPDX package URLs, including scoped npm identifiers. Matches with multiple versions of the same package name report binary presence as unknown, including PURL matches, to avoid mixing evidence across versions. PURL-based classification requires its embedded version to match the package’s versionInfo; missing or mismatched PURL versions use the name-based path. Positive PURL evidence reporting a different version is treated as unknown. Full npm registry metadata can provide exact-version publication time, gitHead, repository, and integrity, but neither a version date nor metadata alone proves that the dependency ships or contains a fix.
  • Known fix against a release archive. check_upstream_fix fetches a known commit patch from a supported HTTPS forge (up to three checks per ticket, 2 MiB maximum per patch, redirects disabled) and checks it against the prepared, spec-pinned source after downstream patches. Top-level upstream changelog diffs (CHANGES, CHANGELOG, ChangeLog, NEWS, HISTORY, with an optional .md/.rst/.txt suffix) are omitted because RPM changelog entries belong in the spec; the omitted paths are returned in omitted_paths. The same omission applies to the .patch files a deterministic backport stages. The result records source provenance and a patch_diff excerpt, capped at 12,000 characters and charged against the shared 72,000-character source/file read budget, which carries enough headroom for three fix checks and source reading. patch_truncated identifies an incomplete excerpt. An exhausted budget returns an error before source preparation or patch fetching and does not consume the fix-check attempt. Tool logs report patch applicability and affected-path counts without logging the diff content. A patch that applies shows that exact diff is absent from the inspected source snapshot, not that the vulnerable code is reachable or that the snapshot built the ticket NVR. A patch that does not apply is inconclusive. Failure responses include bounded rejected hunks and original downstream source context when available; these excerpts are diagnostic evidence, not a patch-adaptation attempt. Failed source checks, an absent CVE-named patch, %files entries, and missing disable flags alone do not establish affectedness, shipped presence, or fix absence; unresolved cases require needs_human.
  • Upstream source probe. Once per ticket, the model may inspect an official upstream HTTPS Git repository. It searches shallow, blobless, tagless history and advisory/security metadata for the CVE or a supplied fix commit. When an exact base commit is known, it also checks fix ancestry and runs git apply --check; without one it reports candidates and changed paths only. A candidate with resolved: false could not be fetched and is not evidence. Other candidate results are retained as partial evidence; network failures abort the run while preserving that partial result locally. If base_commit_resolved is false, ancestry and applicability are unknown. Short SHAs that Git cannot fetch are reported unresolved rather than expanded through a forge API. GitHub and GitLab web repository URLs are normalized to .git clone endpoints; HTTP redirects remain disabled. An applicable or non-applicable patch alone does not establish affectedness or fix inclusion. No candidate in this bounded search requires needs_human, not no_upstream_fix. Collection failures abort the run; unresolved refs remain partial evidence. The Job running the agent must enforce CPU, memory, and ephemeral-storage limits for this external fetch.
  • Non-retryable tool results. A result with retryable: false indicates a cached capability failure, consumed upstream probe/check allowance, or exhausted per-ticket budget. Repeating the call will not change it; the model records the evidence gap and moves on.
  • Caller-owned result fields. The response schema is InvestigationResult minus CALLER_FILLED, so the model returns only status, confidence, summary, evidence, and vex_justification. The caller supplies file_actions, commit_message, ticket, package, and cves.

Security

The agent processes untrusted input — Jira ticket content, upstream commits, and package spec/patch files — so the sandbox is built so that no untrusted code is ever executed and untrusted input cannot reach secrets or a shared host.

  • No untrusted code execution. The sandbox clones the rpms repo, reads files, resolves upstream PRs, downloads patches, and checks them with GNU patch or Git’s patch engine before staging. It never runs rpmbuild, a spec’s %prep scriptlet, or any code supplied by a spec or patch. Verifying the fix by building the package — the step that would execute spec-controlled code — is deliberately delegated to Konflux, which builds the resulting MR in its own hardened pipeline. This keeps a whole class of remote-code-execution surface (and slow, resource-heavy builds of large packages) out of the agent entirely.
  • Deployment isolation. The agent ships as a container image and is meant to run as a Kubernetes Job or inside workqueue-service; it is not deployed yet. The rpms fork lives in a private temp dir inside that boundary and is removed on exit, so git operations run inside the deployment sandbox rather than on a shared host — no separate nested container is needed, because nothing here executes untrusted code.
  • Secret-free subprocesses. git runs with a minimal, allowlisted environment. The service process env (Jira and model tokens) is never inherited by child processes, so a subprocess cannot read or exfiltrate them.
  • Uncredentialed, hardened git. The fork is a public, read-only clone with no push token. Credential helpers and interactive prompts are disabled, system/global git config is neutralized (an attacker-controlled GIT_CONFIG_GLOBAL cannot change behaviour), and submodule recursion is off.
  • Input validation. Package names are restricted to a safe charset (no path traversal or absolute paths), file reads are confined to the package directory, saved patch names must be *.patch and cannot overwrite existing files, and patch URLs must use HTTPS on a recognized upstream forge.

Development

See the main README for development workflows.

make check                         # lint
make test                          # run tests

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

5.45 - Red Hat Catalog Environment Promotion

Environments

Environment URL Source Deploy trigger
MR Preview GitLab Pages (/mr-{IID}) MR branch Auto on MR pipeline
Experimental images.experimental.hummingbird-project.io/<branch-name> experiment/* branch Auto on push to experiment branch
Staging images.staging.hummingbird-project.io main Auto on merge to main
Production images.redhat.com main Auto on merge to main

Disambiguation: The infra experimental host (images.experimental.hummingbird-project.io, experiment/* branches) is an off-main deploy sandbox. The former in-app /experimental product route was removed in HUM-2171 — use /api instead. See Archived surfaces.

Promotion Paths

Routine: staging to production

  1. MR merged to main
  2. deploy_redhat_catalog_staging and deploy_redhat_catalog run automatically in parallel (both trigger on push to main when catalog files change)
  3. UAT sign-off on staging — product, design, and engineering release approvers complete the UAT checklist and file a record (UAT program runbook)

Exploration: experimental to staging to production

  1. Create branch experiment/<name> from main (must use exactly experiment/, not experimental/ or other prefixes — without it, no deploy triggers)
  2. Push triggers deploy_redhat_catalog_experimental automatically
  3. Branch deploys to images.experimental.hummingbird-project.io/<name>/
  4. Iterate on experimental host for days or weeks
  5. Light UAT (optional spot-check on experimental URL — see UAT program — experimental path)
  6. When ready: open MR from experiment/<name> to main
  7. Normal MR review, merge, then routine promotion path (full UAT on staging)

Retiring an experiment

  1. Delete the experiment/<name> branch

  2. Remove the branch prefix from the experimental S3 bucket:

    aws s3 rm s3://redhat-catalog-experimental-spa/<name>/ --recursive
    
  3. Invalidate the CloudFront cache:

    aws cloudfront create-invalidation \
      --distribution-id <EXPERIMENTAL_DISTRIBUTION_ID> \
      --paths "/<name>/*"
    

Build Configuration Per Environment

The infrastructure pipeline sets these environment variables at build time:

Variable Experimental Staging Production
ASSET_PATH /<branch-name>/ / /
DPAL_USE_STAGING true true (unset, uses prod)
TRUSTARC_DATA_DOMAIN images.experimental.hummingbird-project.io images.staging.hummingbird-project.io images.redhat.com
CATALOG_API_BASE_URL (unset, uses app default) (unset, uses app default) (unset, uses prod)
CATALOG_BASE_URL (unset, uses app default) (unset, uses app default) (unset, uses prod)
HUMMINGBIRD_API_URL (unset, uses app default) (unset, uses app default) (unset, uses prod)
JIRA_API_URL Jira API URL Jira API URL Jira API URL

Infrastructure

SAM Stacks

Stack Template ResourcePrefix CatalogDomainName
redhat-catalog-prod template.yaml redhat-catalog images.redhat.com
redhat-catalog-staging template.yaml redhat-catalog-staging images.staging.hummingbird-project.io
redhat-catalog-experimental template-experimental.yaml redhat-catalog-experimental images.experimental.hummingbird-project.io

Staging uses the same template.yaml as production (single-site CloudFront distribution). Experimental uses template-experimental.yaml which adds a CloudFront Function for path-prefix SPA routing across multiple branches.

DNS

ALIAS records (images.staging/experimental.hummingbird-project.io → CloudFront) are created automatically by post_deploy.sh after each deploy.

First-time bootstrap: The SAM template creates an ACM certificate with DNS validation. The ACM validation CNAME must exist in the hummingbird-project.io Route 53 zone before the first sam deploy, or CloudFormation will wait 30 minutes and roll back. To bootstrap:

  1. Deploy with CatalogDomainName="" (uses CloudFront default domain, no cert)
  2. Create the ACM validation CNAME in Route 53 (get the value from the ACM console or aws acm describe-certificate)
  3. Redeploy with CatalogDomainName set to the real domain

After the first successful deploy, no manual DNS steps are needed.

CI/CD Variables

Set in GitLab project settings:

Variable Value
REDHAT_CATALOG_STAGING_URL https://images.staging.hummingbird-project.io

Pipeline Flow

experiment/* push:
  redhat-catalog-experimental-check
    -> deploy_redhat_catalog_experimental

MR (target main):
  redhat-catalog (build + test)
    -> redhat-catalog-browser-tests
    -> redhat-catalog-visual-mock (Playwright mock API; MR vs merge-base diff)
    -> pages (preview)

main push:
  deploy_redhat_catalog_staging (auto)
  deploy_redhat_catalog (auto)

Visual regression details: redhat-catalog testing guide — Visual regression and e2e/visual/README.md.

5.46 - S3 Lookaside Cache

Infrastructure for caching dist-git lookaside content, providing access to source artifacts for the Hummingbird build pipeline.

Overview

This stack provides S3-based infrastructure for dist-git artifacts with CloudFront CDN. It caches content from upstream sources like Git tarballs.

A companion stack (s3-lookaside-cache-upload-role) creates a GitLab OIDC identity provider and an IAM role that GitLab CI jobs can assume using short-lived JWT tokens, eliminating the need for long-lived access keys.

Architecture

Cache Infrastructure

flowchart LR
    clients["Build Clients"]
    cf["CloudFront\nDistribution"]
    s3["S3 Bucket\n(dist-git-cache)"]
    logs["S3 Logs\nBucket"]
    backup["AWS Backup\nVault"]
    headers["Security\nHeaders"]

    clients --> cf --> s3
    cf --> headers
    s3 --> logs --> backup

GitLab CI Upload Role

flowchart LR
    gitlab["GitLab CI\n(.gitlab-ci.yml)"]
    sts["AWS STS\n(validates JWT\nvia OIDC/JWKS)"]
    role["IAM Role\n(scoped to\nS3 PutObject)"]
    s3["S3 Bucket\n(dist-git cache)"]

    gitlab -- "id_token" --> sts
    sts -- "AssumeRoleWithWebIdentity" --> role
    role -- "temporary credentials" --> sts
    sts -- "credentials" --> gitlab
    gitlab -- "s3:PutObject" --> s3

Components

Cache Stack (s3-lookaside-cache)

Resource Type Description
DistGitCacheBucket S3 Bucket Main cache storage with versioning and object lock
DistGitLogBucket S3 Bucket Access logs with tiered storage lifecycle
DistGitCacheDistribution CloudFront CDN with HTTP/2+3, IPv6, TLS 1.2+
DistGitCacheOAC Origin Access Ctrl Secure S3 access from CloudFront
DistGitCacheCachePolicy Cache Policy 1-day default TTL, 1-year max, Gzip/Brotli
DistGitCacheResponseHeadersPolicy Response Headers HSTS, X-Frame-Options, XSS protection
DistGitBackupVault Backup Vault AWS Backup vault for data protection
DistGitBackupPlan Backup Plan Daily (35-day) and weekly (365-day) backups
DistGitUploadPolicy IAM Managed Policy Grants s3:PutObject to the cache bucket

Upload Role Stack (s3-lookaside-cache-upload-role)

Resource Type Description
GitLabOIDCProvider IAM OIDC Provider Registers gitlab.com as a trusted identity provider
DistGitUploadRole IAM Role Web identity role assumable by the configured GitLab project

Parameters

Cache Stack Parameters

Parameter Description
ResourcePrefix Prefix for resource names (e.g., arr-hummingbird-prod-dist-git-cache)
BucketName Globally unique name for the cache bucket (logs bucket appends -logs)

Upload Role Parameters

Parameter Description
ResourcePrefix Prefix for resource names (e.g., arr-hummingbird-prod-dist-git-upload)
CacheStackName Name of the deployed s3-lookaside-cache CloudFormation stack
AdditionalCacheStackName Optional second cache stack whose upload policy is attached to the role
GitLabProjectPath GitLab project path allowed to assume the role (e.g., redhat/hummingbird/rpms)

S3 Key Structure

Files are stored using the dist-git lookaside path convention:

{namespace}/{package}/{filename}/{hashType}/{hash}/{filename}

Example:

rpms/tar/tar-1.35.tar.xz/sha512/abc123.../tar-1.35.tar.xz

Trust Policy

The upload role’s trust policy restricts access using three conditions (all StringEquals):

  • Audience (gitlab.com:aud): Must be https://gitlab.com
  • Subject (gitlab.com:sub): Must match project_path:<GitLabProjectPath>:ref_type:branch:ref:main
  • Protected ref (gitlab.com:ref_protected): Must be "true"

Only the main branch of the configured project can assume the role.

GitLab CI Usage

Configure your .gitlab-ci.yml to assume the role using id_tokens. The AWS CLI automatically calls AssumeRoleWithWebIdentity when AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN are set:

upload to cache:
  image:
    name: amazon/aws-cli:latest
    entrypoint: [""]
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  script:
    - set +x
    - printenv GITLAB_OIDC_TOKEN > /tmp/oidc-token
    - export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/oidc-token
    - export AWS_ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${ROLE_NAME}"
    - export AWS_ROLE_SESSION_NAME="gitlab-ci-${CI_JOB_ID}"
    - aws s3 cp "$FILE" "s3://${BUCKET}/${S3_KEY}"
    - rm -f /tmp/oidc-token

The aud value in id_tokens must match the audience configured in the OIDC identity provider (https://gitlab.com). The token is written to a file because the AWS SDK reads it from AWS_WEB_IDENTITY_TOKEN_FILE rather than accepting it inline.

Security Features

  • Encryption: AES256 server-side encryption with S3 bucket keys
  • Public Access: All public access blocked on both buckets
  • Transport Security: HTTPS enforced via bucket policy
  • Object Lock: GOVERNANCE mode with 1-day default retention (will be increased in the future)
  • CloudFront OAC: Modern Origin Access Control (not legacy OAI)
  • Security Headers: HSTS, X-Frame-Options (DENY), X-Content-Type-Options, X-XSS-Protection, strict referrer policy
  • TLS 1.2 Minimum: For all CloudFront connections
  • OIDC Federation: Short-lived tokens instead of long-lived access keys

Backup Strategy

Schedule Retention Cold Storage
Daily (5:00 AM UTC) 35 days -
Weekly (Sunday 5:00 AM UTC) 365 days After 30 days

Continuous backup is enabled for point-in-time recovery.

Log Lifecycle

Access logs transition through storage tiers:

Age Storage Class
0-30 days Standard
30-60 days Standard-IA
60-90 days Glacier IR
90+ days Expired

Outputs

Cache Stack Outputs

Output Description
BucketName Name of the cache S3 bucket
BucketArn ARN of the cache S3 bucket
LogBucketName Name of the logs S3 bucket
LogBucketArn ARN of the logs S3 bucket
DistributionDomainName CloudFront domain name
DistributionId CloudFront distribution ID
DistributionArn CloudFront distribution ARN
BackupVaultName AWS Backup vault name
BackupVaultArn AWS Backup vault ARN
BackupPlanId AWS Backup plan ID
UploadPolicyArn ARN of the managed policy granting upload access

Upload Role Outputs

Output Description
RoleArn ARN of the upload role (pass to GitLab CI as ROLE_ARN)
RoleName Name of the upload role
OIDCProviderArn ARN of the GitLab OIDC identity provider

References

5.47 - Docs Content Negotiation

CloudFront distribution with HTTP content negotiation for the Hummingbird public documentation site (hummingbird-project.io). When a client sends Accept: text/markdown, a CloudFront Function rewrites the request URI to the pre-built .md equivalent so GitLab Pages serves Hugo’s markdown output instead of HTML.

Overview

Hugo generates .md files alongside .html for every page. GitLab Pages is a static file server — it cannot inspect Accept headers and select a response type. This stack interposes a CloudFront distribution between DNS and GitLab Pages, adding the header-based routing that GitLab Pages cannot provide.

The stack is deployed by the infrastructure repository. After each deploy, Route53 A and AAAA alias records for hummingbird-project.io are updated to point at the CloudFront distribution.

Architecture

flowchart LR
    client["Client\n(browser / agent)"]
    dns["Route53\nhummingbird-project.io"]
    cf["CloudFront\nDistribution"]
    fn_req["CloudFront Function\n(viewer-request)"]
    fn_resp["CloudFront Function\n(viewer-response)"]
    pages["GitLab Pages\nredhat.gitlab.io"]

    client -->|"HTTPS"| dns
    dns -->|"A/AAAA alias"| cf
    cf --> fn_req
    fn_req -->|"rewrite URI if\nAccept: text/markdown"| cf
    cf -->|"forwards viewer Host header"| pages
    pages --> fn_resp
    fn_resp -->|"auth redirects → 404"| client

Content Negotiation Logic

The cloudfront-js-2.0 viewer-request function runs at every edge location before the cache is consulted:

  1. If the URI starts with /l/ → normalize and pass through (regardless of headers). These are stable alias paths that GitLab Pages handles via a single _redirects splat rule per alias (/l/foo/*). The function normalizes variant request forms so the splat rule matches: /l/foo gets a trailing slash, /l/foo.md becomes /l/foo/index.md. Requests already in /l/foo/ or /l/foo/index.md form pass through unchanged.
  2. If Accept does not include text/markdown → pass through unchanged (HTML response served as before).
  3. Rewrite only known HTML-like patterns:
    • /docs/ (trailing slash) → /docs/index.md
    • /docs/page.html → /docs/page.md
    • /docs/page (no extension) → /docs/page/index.md
  4. Everything else (.css, .js, .png, .md, .txt, etc.) → pass through unchanged.

Because the rewrite happens before the cache key is computed (viewer-request event), HTML and markdown responses land in separate cache entries. No custom cache policy is needed — the standard CachingOptimized managed policy is used.

Auth-Redirect Guard

A second CloudFront Function (AuthRedirectGuardFunction) runs on viewer-response. It detects 302 responses whose Location header points to projects.gitlab.io/auth — the GitLab Pages access-control gate — and converts them into a 404 Not Found before they reach the viewer.

GitLab Pages access control causes the origin to return a 302 auth redirect for nonexistent paths instead of a proper 404. CloudFront caches origin responses per Accept-Encoding variant (gzip vs brotli), so these redirects can persist for up to 24 hours. Without the guard, clients follow a redirect chain to gitlab.com/users/sign_in and land on a Cloudflare challenge page.

Origin

The origin is redhat.gitlab.io (the GitLab Pages default domain). GitLab Pages routes requests based on the Host header. The AllViewer origin request policy forwards the viewer’s Host: hummingbird-project.io header to the origin, so the correct project is served regardless of what DNS hummingbird-project.io resolves to.

Components

Resource Type Description
ContentNegotiationFunction CloudFront Function viewer-request; rewrites URI for Accept: text/markdown
AuthRedirectGuardFunction CloudFront Function viewer-response; converts auth redirects to 404
DocsCertificate ACM Certificate TLS for the custom domain; DNS-validated via Route53
DocsDistribution CloudFront Distribution CDN proxy in front of GitLab Pages

Parameters

Parameter Description
ResourcePrefix Prefix for resource names (e.g., arr-hummingbird-prod-docs-cdn)
DocsDomainName Custom domain for the documentation site (e.g., hummingbird-project.io)
HostedZoneId Route53 hosted zone ID — used for ACM DNS validation CNAME auto-creation

Outputs

Output Description
DistributionDomainName CloudFront domain name
DistributionId CloudFront distribution ID

Deployment

Deployed by the infrastructure repository. Stack name and region are configured in samconfig.toml.j2 in the infrastructure repo.

On the first deploy, CloudFormation requests an ACM certificate and auto-creates the DNS validation CNAME in Route53 (via HostedZoneId). Once validated (~2–5 minutes), the distribution is created and Route53 alias records are updated to point at it.

Verification

# Content negotiation: should return text/markdown
curl -H "Accept: text/markdown" https://hummingbird-project.io/docs/ -sI

# Normal browsing: should return text/html (unchanged)
curl https://hummingbird-project.io/docs/ -sI

# /l/ alias paths are not rewritten — GitLab Pages issues HTTP 301 instead
curl -H "Accept: text/markdown" https://hummingbird-project.io/l/adding-images -sI

# Nonexistent paths return 404 (auth-redirect guard converts the origin's 302)
curl https://hummingbird-project.io/this-does-not-exist -sI

# Static assets are excluded from rewriting
curl -H "Accept: text/markdown" https://hummingbird-project.io/favicon.ico -sI

# Alternate domains still redirect to primary (unchanged)
curl https://project-hummingbird.io/ -sI   # expect 308
curl https://hummingbird-project.org/ -sI  # expect 308

# DNS resolves to CloudFront, not the GitLab Pages IP
dig hummingbird-project.io A +short

Scope

Only the primary domain (hummingbird-project.io) is proxied through CloudFront. The alternate domains (project-hummingbird.io, hummingbird-project.org) continue pointing directly to GitLab Pages, which issues 308 redirects to the primary domain.

5.48 - Red Hat Catalog UAT Program

User Acceptance Testing (UAT) for the Red Hat Catalog SPA — human sign-off on staging before production promotion.

Epic: HUM-2179 · Story: HUM-2183 · Depends on: HUM-2060 (staging host + promotion gates)

Purpose

UAT complements automated validation (smoke, E2E, visual regression, security scans). It catches product, content, and UX issues that automation does not cover. UAT does not replace automated gates.

Layer Owner Blocks prod?
Unit / lint / MR security CI MR merge
UAT sign-off Product + design + eng Human review before prod is live
Post-prod smoke CI / on-call Rollback decision

Environments (disambiguation)

Term Meaning URL pattern
Staging Pre-prod validation target for UAT https://images.staging.hummingbird-project.io
Experimental host Infra sandbox for experiment/* branches https://images.experimental.hummingbird-project.io/<branch>/
Former /experimental route Removed in-app product route (HUM-2171) Returns 404 — use /api instead. See archived-surfaces.md

See Red Hat Catalog Environment Promotion for full promotion paths.

Promotion gates

Staging → production (full UAT)

Both deploy_redhat_catalog_staging and deploy_redhat_catalog trigger automatically on merge to main (when catalog files change). UAT is a human review layer that runs concurrently with the automated deploys — if UAT reveals issues, the release owner must roll back production.

Required before a release is considered complete:

  1. deploy_redhat_catalog_staging succeeded on main
  2. MR-blocking security jobs clean (or approved exceptions) — see security-scanning.md
  3. UAT sign-off recorded (checklist + Jira comment; git record in redhat-catalog/docs/uat-records/)

Light UAT on experimental host

Experimental hosts are for iteration, not customer release. When opening an MR from experiment/<name> to main, use a light UAT pass on the experimental URL — not the full checklist:

Check Required?
Feature under test works on experimental host Yes
No console errors on primary journey Yes
Masthead/footer not broken Yes
Full staging UAT checklist No — runs on staging after merge
Named approver sign-off Eng owner only (no prod gate yet)

After merge, staging receives the full UAT before prod.

Named approvers (staging → prod)

Confirm names with the product owner during checklist review (HUM-2060 open question on approval owners). Until confirmed, use these roles:

Role Primary Backup Responsibility
Product TBD — Catalog journeys, copy, feature completeness
Design TBD — Visual polish, layout, theme, responsive UX
Engineering release TBD TBD Pipeline green, staging commit matches intent, prod deploy authorization

Any Block from product or design stops prod promotion until defects are fixed on staging and UAT re-run.

Runbook — request and run UAT

1. Request UAT (release owner)

When main pipeline completes staging deploy + automated gates:

  1. Create or reuse a Release ticket in Jira (label redhat-catalog, link HUM-2179 epic).
  2. Comment with pipeline URL, commit SHA, and summary of user-facing changes.
  3. Label ticket uat-requested.
  4. Notify approvers (#hummingbird or team channel — use team default).

2. Execute UAT (approvers / delegates)

  1. Open uat-checklist.md against staging.
  2. File results in redhat-catalog/docs/uat-records/YYYY-MM-DD-<topic>.md.
  3. Log defects as Jira bugs linked to the release ticket.

Expected turnaround: 2 business days from uat-requested for routine releases; same-day for hotfixes when approvers are available.

3. Sign off or block

  • Pass: All checklist items pass or have accepted exceptions; all three approver rows signed; Jira comment posted (template below); label uat-pass.
  • Fail: Label uat-fail; roll back production if already deployed, fix defects on staging, and re-run UAT.

4. Record production promotion (engineering release)

  1. Verify Jira release ticket has uat-pass and linked sign-off record.
  2. Production deploys automatically on merge to main — confirm deploy_redhat_catalog succeeded in the GitLab main pipeline.
  3. Comment on release ticket with prod pipeline URL.

What blocks release

Blocker Who resolves
Staging smoke or visual CI failed Engineering
Open Critical/High security finding without exception Engineering + security
UAT checklist failure (product/design) Engineering fixes → re-UAT
Missing approver sign-off Approver
Staging commit ≠ intended release SHA Release owner

Jira sign-off template

Post on the release ticket when UAT completes:

h3. UAT sign-off — staging → prod

*Staging URL:* https://images.staging.hummingbird-project.io
*Commit:* {full SHA}
*Pipeline:* {GitLab pipeline URL}
*Checklist record:* redhat-catalog/docs/uat-records/{filename}.md

||Role||Name||Decision||
|Product|{name}|Approve / Block|
|Design|{name}|Approve / Block|
|Engineering release|{name}|Approve / Block|

*Result:* Approved for prod / Blocked
*Defects:* {HUM-xxx links or "none"}
*Tester:* {name}, {date}

Suggested Jira labels

Label Meaning
uat-requested Staging ready; waiting for human validation
uat-pass Full sign-off complete
uat-fail Blocked; defects logged
uat-light Experimental → staging spot-check only

Optional custom field (if added in Jira project settings): UAT status (Not started / In progress / Passed / Failed).

Release ticket template

Create a Jira Task or Release issue per prod promotion:

## Release summary

- **Target:** staging → production
- **Commit:**
- **User-facing changes:**

## Automated gates

- [ ] Staging deploy green
- [ ] Production deploy green (auto-triggered on merge)
- [ ] Security scans clean (or exceptions documented)

## UAT

- [ ] Checklist completed ([uat-checklist.md](link))
- [ ] Sign-off record filed ([uat-records/](link))
- [ ] Product approve
- [ ] Design approve
- [ ] Engineering release approve

## Prod verify

- [ ] deploy_redhat_catalog succeeded in main pipeline
- [ ] Post-deploy verification

Out of scope

  • Automating UAT (Playwright covers regression; UAT stays human)
  • Legal/compliance sign-off (separate security program)

5.49 - Hummingbird MR Collaboration

How to open merge requests and help on someone else’s MR in the redhat/hummingbird/tools monorepo. Applies to all components (including redhat-catalog/).

Features

  • Same-repo branches — contributors work in the shared GitLab project, not personal forks for day-to-day changes
  • One MR per change — seniors push fixes to the author’s source branch instead of opening a nested MR
  • Automatic CI — each push to the MR branch re-runs the pipeline, GitLab Pages preview, and dashboard status links

Prerequisites

  • GitLab account with Developer (or higher) access on redhat/hummingbird/tools
  • Git clone of the monorepo
  • Optional: glab CLI authenticated to GitLab

Git remotes

Many developers use two remotes:

Remote Typical URL Use for
upstream git@gitlab.com:redhat/hummingbird/tools.git Fetch MRs, push to shared branches
origin Personal fork (if configured) Optional; not used for team MRs

Add upstream if missing:

git remote add upstream git@gitlab.com:redhat/hummingbird/tools.git
git fetch upstream

Do not open fork MRs for routine team work. mr-auto-approver unconditionally rejects MRs where source_project_id != target_project_id.

Open an MR (author)

git clone git@gitlab.com:redhat/hummingbird/tools.git
cd tools
git checkout -b hum-XXXX-short-description
# edit, commit
git push -u upstream hum-XXXX-short-description

Open an MR targeting main in the GitLab UI.

Branch naming: prefer hum-XXXX-* or a descriptive feat/*, fix/*, or experiment/* prefix (see Environment Promotion for experiment/* deploy behavior).

Help on someone else’s MR (reviewer)

Use the MR source branch name from the GitLab MR page (not necessarily your local checkout name).

1. Check out the MR locally

Option A — fetch by MR number (works without glab):

git fetch upstream merge-requests/<IID>/head:mr-<IID>
git checkout mr-<IID>

Option B — checkout the source branch:

git fetch upstream <source-branch>
git checkout -B <source-branch> upstream/<source-branch>

Option C — glab:

glab mr checkout <IID> --repo redhat/hummingbird/tools

2. Edit and verify locally

Component-specific checks (example for Red Hat Catalog):

cd redhat-catalog
npm run ci-checks
npm run start:dev   # optional UI smoke test

3. Push to the author’s branch

If your local branch name matches the MR source branch:

git push upstream <source-branch>

If you checked out via mr-<IID> (local name differs from remote), push explicitly:

git push upstream HEAD:<source-branch>

Example: local branch mr-748, remote source branch feat/image-request-form:

git push upstream HEAD:feat/image-request-form

Optional — rename locally so future pushes are simpler:

git branch -m mr-<IID> <source-branch>
git push -u upstream <source-branch>

The existing MR updates; CI re-runs; no second MR is needed.

What runs on each MR push

For redhat-catalog/** changes, see Red Hat Catalog Environment Promotion — Pipeline Flow. Summary:

Stage Result
Test Build, lint, type-check, unit tests, audit, SAST
Visual Mock visual diff (MR head vs merge-base)
Preview GitLab Pages at /mr-{IID}
Status Internal MR note with Hummingbird dashboard link
Agent Code review on open/update; /hummingbird analyze-failures on CI fail

After merge to main, staging deploys automatically; production requires manual gate and UAT sign-off.

Troubleshooting

error: src refspec <branch> does not match any

Git has no local branch with that name. You are probably on mr-<IID> while pushing git push upstream feat/....

Fix: push the current branch to the remote source branch:

git push upstream HEAD:<source-branch>

Push rejected / permission denied

  • Confirm Developer+ access on redhat/hummingbird/tools
  • Confirm you are pushing to upstream, not a personal fork
  • Check whether the MR author enabled “Prevent pushing to source branch” (uncommon)

fatal: couldn't find remote ref merge-requests/.../head

Fetch from upstream, not origin:

git fetch upstream merge-requests/<IID>/head:mr-<IID>

Team tips

Tip Why
Assign yourself when taking over pushes Clear ownership on the MR
Use MR Pages preview Review UI without local dev
Use dashboard link in MR internal note Per-commit Konflux/build status
Use GitLab suggestions Small fixes without a local checkout
Use experiment/<name> for long exploration Live host before opening an MR

5.50 - K8s Integration Tests

Smoke tests that verify tools container images work in a real Kubernetes cluster. Each test creates short-lived pods, checks expected behavior, and cleans up. Tests run automatically on every MR via the Konflux K8s test pipeline.

Test Runner

Tests are executed by ci/run_tests_k8s.sh. For each component it:

  1. Reads {component}/tests-k8s.yml
  2. Parses each test entry (YAML -> JSON via Python)
  3. Runs the command block with kubectl wired to the target cluster
  4. Cleans up labeled resources (hum-k8s-test=<run-id>) after each test

Running Locally

Test against a local cluster (kind, minikube, or remote):

# Single component with a published image
IMAGE_URL=quay.io/hummingbird-ci/gitlab-ci:latest \
IMAGE_NAME=gitlab-ci--tools \
  ci/run_tests_k8s.sh --context kind-kind gitlab-ci--tools

# Single component with a locally-built image
podman build -t localhost/hummingbird-dashboard:dev hummingbird-dashboard/
kind load docker-image localhost/hummingbird-dashboard:dev
IMAGE_URL=localhost/hummingbird-dashboard:dev \
IMAGE_NAME=hummingbird-dashboard--tools \
  ci/run_tests_k8s.sh --context kind-kind hummingbird-dashboard--tools

CI Integration

In Konflux, the tools-k8s-test IntegrationTestScenario triggers on every MR. The pipeline:

  1. Checks whether tests-k8s.yml exists for the changed component
  2. Provisions an ephemeral EaaS namespace
  3. Runs ci/run_tests_k8s.sh with the built image

Components without tests-k8s.yml skip the test (the pipeline exits early after the check step).

When an MR touches multiple components in one PR-group snapshot, Konflux runs a single group PipelineRun instead of one per component, invoking the script once with --group-component-name (repeated) and per-component IMAGE_URL_<COMPONENT> env vars instead of the single-component IMAGE_URL/IMAGE_NAME pair. --group-component-name is handled identically to --component-name.

Adding Tests for a New Component

  1. Create {component}/tests-k8s.yml with one or more named tests:

    ---
    smoke-test:
      command: |
        name="${TEST_GROUP}-smoke-${TEST_RUN_ID}"
        kubectl run "${name}" --image="${TEST_IMAGE:?}" --restart=Never \
          --labels="${TEST_RUN_LABEL}" \
          --command -- echo "hello"
        kubectl wait --for=jsonpath='{.status.phase}'=Succeeded \
          "pod/${name}" --timeout=120s || test_fail "Pod did not succeed"
        kubectl logs "${name}"
    
  2. Use ${TEST_IMAGE} for the image under test, ${TEST_RUN_ID} and ${TEST_RUN_LABEL} for unique naming and cleanup.

  3. Call test_fail "message" to fail a test with a clear message.

  4. Keep tests fast (under 2 minutes) — these are smoke tests, not integration suites.

Common pitfalls

  • All lines in command: | block scalars must be indented relative to the command key. Unindented lines break YAML parsing.
  • Use command -v instead of which to check for commands — which is not available in all container images.
  • For multi-statement Python one-liners, use semicolons on a single line: python3 -c 'import foo; import bar; print("ok")'

Debugging Failures

See the EaaS and Debugging guide for accessing ephemeral namespaces and using Kubearchive for historical PipelineRun data.