diff --git a/ymir/agents/golang_rebuild/README.md b/ymir/agents/golang_rebuild/README.md new file mode 100644 index 00000000..e2ccc437 --- /dev/null +++ b/ymir/agents/golang_rebuild/README.md @@ -0,0 +1,292 @@ +# Golang CVE Rebuild Agent + +Automates rebuilding RHEL 9.x and 10.x z-stream components affected by Golang CVE fixes. Integrates with ai-workflows infrastructure using GitLab MR workflow for all submissions. + +## How It Works + +``` +Engineer adds comment to Jira ticket with build instructions (optional) + | +Engineer applies "golang-rebuild-queue" label to trigger the agent + | +Agent reads comment --> parses side-tag, commit hash, extra jiras, custom message + | +Agent forks dist-git repo on GitLab (via MCP gateway) + | +Agent bumps spec file: release version + changelog entry + | +If commit hash provided: updates %global commit0, spectool -g, rhpkg new-sources + | +Agent triggers scratch build (rhpkg scratch-build --srpm) + | +Agent posts scratch build result to Jira --> STOPS + | +Engineer reviews scratch build, adds "golang-rebuild-approved" label + | +Agent commits, pushes to fork, opens GitLab MR for review + | +Official build happens when MR is merged (via GitLab pipeline) +``` + +## Prerequisites + +### System Tools + +```bash +# Verify these are installed +rhpkg --version +brew --version +spectool --version +kinit -V +git --version +``` + +### Authentication + +**Kerberos** (for rhpkg/brew): +```bash +kinit @REDHAT.COM +klist # verify ticket +``` + +**Jira API** (for reading tickets and posting comments): +```bash +# Create ~/.rh-jira-mcp.env with: +JIRA_URL=https://redhat.atlassian.net +JIRA_EMAIL=your.email@redhat.com +JIRA_API_TOKEN= + +# Load before running: +source ~/.rh-jira-mcp.env +export JIRA_USERNAME="$JIRA_EMAIL" +export JIRA_PASSWORD="$JIRA_API_TOKEN" +``` + +To get a Jira API token: https://id.atlassian.com/manage-profile/security/api-tokens + +### MCP Gateway + +The agent uses ai-workflows's MCP gateway for GitLab operations (fork, push, open MR). Set: +```bash +export MCP_GATEWAY_URL=http://mcp-gateway:8000/sse +``` + +### Python Dependencies + +```bash +pip install jira pyyaml pydantic aiofiles pytest pytest-asyncio +``` + +## Setup + +1. Clone ai-workflows and ensure the `ymir/agents/golang_rebuild/` directory is present +2. Copy and customize config: + ```bash + # Edit config.yaml to update allowed components and RHEL versions + vi ymir/agents/golang_rebuild/config.yaml + ``` +3. Set environment variables: + ```bash + source ~/.rh-jira-mcp.env + export JIRA_USERNAME="$JIRA_EMAIL" + export JIRA_PASSWORD="$JIRA_API_TOKEN" + export MCP_GATEWAY_URL=http://mcp-gateway:8000/sse + export GOLANG_REBUILD_CONFIG=/path/to/config.yaml # optional, auto-detected + ``` + +## Usage + +### For Engineers (Jira-based workflow) + +#### Simple rebuild (no special instructions) + +1. Find the component Jira ticket (e.g., RHEL-149580 for buildah) +2. Verify the parent Golang CVE ticket status is "Integration", "Release Pending", or "Done" +3. Apply label: **`golang-rebuild-queue`** +4. Agent will: + - Auto-detect CVE IDs and RHEL version from ticket + - Bump spec release and add changelog + - Trigger scratch build + - Post result to Jira +5. Review the scratch build result +6. If OK, apply label: **`golang-rebuild-approved`** +7. Agent opens a GitLab MR for final review and merge + +#### Rebuild with side-tag (custom golang version) + +When the buildroot has an older golang and you need a newer version: + +1. Add a comment to the component ticket **before** applying the label: + ``` + side-tag: rhel-9.4.0-z-gotoolset-stack-gate + release: rhel-9.4.0 + ``` +2. Apply label: **`golang-rebuild-queue`** +3. Agent uses the side-tag for scratch build + +#### Rebuild with new commit hash + +When sources need updating (new upstream commit): + +1. Add a comment: + ``` + commit: abc123def456789 + ``` +2. Apply label: **`golang-rebuild-queue`** +3. Agent updates `%global commit0`, runs `spectool -g`, `rhpkg new-sources` + +#### Full example comment (all options) + +``` +side-tag: rhel-9.4.0-z-gotoolset-stack-gate +release: rhel-9.4.0 +commit: abc123def456789 +jiras: RHEL-158645 RHEL-147034 RHEL-146820 +message: Rebuilding with golang 1.25.8 for critical security fix +``` + +All fields are optional. If no comment is found, agent uses defaults. + +### Comment Fields Reference + +| Field | Description | Example | +|-------|-------------|---------| +| `side-tag` | Brew side-tag target (overrides default) | `rhel-9.4.0-z-gotoolset-stack-gate` | +| `release` | `--release` flag for rhpkg (required with side-tag) | `rhel-9.4.0` | +| `commit` | New commit hash for `%global commit0` | `abc123def456789` | +| `jiras` | Additional Jira IDs for changelog/commit | `RHEL-158645 RHEL-147034` | +| `message` | Custom changelog/commit message | `Rebuilding with golang 1.25.8 for security fix` | + +### Jira Labels Reference + +| Label | Purpose | Applied by | +|-------|---------|-----------| +| `golang-rebuild-queue` | Triggers the agent to process this ticket | Engineer | +| `golang-rebuild-approved` | Approves official build after scratch succeeds | Engineer | +| `jotnar_golang_rebuild_in_progress` | Agent is currently processing | Agent | +| `jotnar_golang_rebuild_completed` | Rebuild completed successfully | Agent | +| `jotnar_golang_rebuild_failed` | Rebuild failed | Agent | +| `jotnar_golang_rebuild_errored` | Unexpected error occurred | Agent | + +### Direct Mode (environment variables) + +```bash +export GOLANG_TICKET=RHEL-158645 +export DRY_RUN=true +export MCP_GATEWAY_URL=http://mcp-gateway:8000/sse +python -m ymir.agents.golang_rebuild +``` + +### Queue Mode (Redis, for deployment) + +```bash +export REDIS_URL=redis://valkey:6379/0 +export MCP_GATEWAY_URL=http://mcp-gateway:8000/sse +export CONTAINER_VERSION=c9s +python ymir/agents/golang_rebuild/workflow.py +``` + +## What the Agent Produces + +### Changelog Entry + +``` +* Mon May 05 2026 Golang Rebuild Agent - 2:1.33.13-3.1 +- Rebuilding with golang 1.25.8 to fix net/http vulnerability +- Fixes: CVE-2025-12345 CVE-2025-67890 +- Resolves: RHEL-149580 RHEL-158645 RHEL-147034 +``` + +### Commit Message + +``` +Rebuilding with golang 1.25.8 to fix net/http vulnerability +Fixes: CVE-2025-12345 CVE-2025-67890 +Resolves: RHEL-149580 RHEL-158645 RHEL-147034 + +Signed-off-by: Golang Rebuild Agent +``` + +### GitLab MR + +Title: `Rebuild buildah for golang CVE fix` + +Description includes scratch build NVR, Brew link, CVE list, and resolved Jira tickets. + +## Configuration + +Edit `config.yaml` to customize: + +- **RHEL versions**: Add/remove z-stream versions with branch and build target +- **Component filter**: Control which components are processed +- **Brew settings**: Adjust polling interval and timeout for scratch builds + +See `config.yaml` for inline documentation. + +## File Structure + +``` +ymir/agents/golang_rebuild/ + __init__.py # Package init + __main__.py # Entry point (python -m ymir.agents.golang_rebuild) + workflow.py # Main orchestrator (async, queue + direct mode) + comment_parser.py # Parses Jira comments for build instructions + jira_queries.py # Read-only Jira queries (CVE discovery) + brew_client.py # Async Brew/rhpkg scratch builds + git_client.py # Async git/rhpkg operations + specfile.py # RPM spec file parsing and modification + models.py # Pydantic data models + constants.py # Agent identity, component list, templates + utils.py # Helpers (CVE extraction, config loading) + config.yaml # Configuration file + README.md # This file + tests/ # Unit tests (63 tests) +``` + +## Supported RHEL Versions + +| Version | Branch | Build Target | Status | +|---------|--------|-------------|--------| +| RHEL 9.4.z | rhel-9.4.0 | rhel-9.4.0-candidate | Supported | +| RHEL 9.6.z | rhel-9.6.0 | rhel-9.6.0-candidate | Supported | +| RHEL 9.7.z | rhel-9.7.0 | rhel-9.7.0-candidate | Supported | +| RHEL 10.1.z | c10s | c10s-candidate | Supported | +| RHEL 8.x | - | - | Not supported | + +## Running Tests + +```bash +cd ai-workflows +PYTHONPATH=$(pwd) python -m pytest ymir/agents/golang_rebuild/tests/ -v +``` + +## Troubleshooting + +### "Jira credentials not found" +```bash +source ~/.rh-jira-mcp.env +export JIRA_USERNAME="$JIRA_EMAIL" +export JIRA_PASSWORD="$JIRA_API_TOKEN" +``` + +### "No module named 'tasks'" +The `tasks` module is part of ai-workflows agents. Ensure PYTHONPATH includes both the repo root and agents directory: +```bash +export PYTHONPATH=/path/to/ai-workflows:/path/to/ai-workflows/agents +``` + +### Scratch build times out +Increase `max_wait_time` in `config.yaml` under `brew` section (default: 7200 seconds / 2 hours). + +### "Branch not found" +The RHEL version in the ticket may not have a corresponding branch yet. Check that the branch exists in the dist-git repo. + +### Kerberos expired +```bash +kinit @REDHAT.COM +``` + +## Contact + +- Jotnar team: jotnar@redhat.com +- Slack: #forum-jotnar-package-automation diff --git a/ymir/agents/golang_rebuild/__init__.py b/ymir/agents/golang_rebuild/__init__.py new file mode 100644 index 00000000..c7e60610 --- /dev/null +++ b/ymir/agents/golang_rebuild/__init__.py @@ -0,0 +1,11 @@ +""" +Golang CVE Rebuild Agent + +Deterministic orchestrator for rebuilding RHEL 9.x/10.x z-stream components +affected by Golang CVE fixes. Uses GitLab MR workflow for all submissions. + +This agent does NOT use BeeAI framework — it is a pure Python orchestrator +since golang rebuilds are deterministic (no LLM reasoning needed). +""" + +__version__ = "0.1.0" diff --git a/ymir/agents/golang_rebuild/__main__.py b/ymir/agents/golang_rebuild/__main__.py new file mode 100644 index 00000000..91d3abeb --- /dev/null +++ b/ymir/agents/golang_rebuild/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for running as: python -m ymir.agents.golang_rebuild""" + +import asyncio + +from ymir.agents.golang_rebuild.workflow import main + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ymir/agents/golang_rebuild/brew_client.py b/ymir/agents/golang_rebuild/brew_client.py new file mode 100644 index 00000000..5a02d37a --- /dev/null +++ b/ymir/agents/golang_rebuild/brew_client.py @@ -0,0 +1,237 @@ +""" +Brew Build Client (async) + +Handles Brew build operations: +- Trigger scratch builds +- Trigger final builds +- Monitor build status +- Get build information + +Uses brew command-line tool (requires Kerberos authentication). +""" + +import asyncio +import logging +import re +from collections.abc import Callable +from pathlib import Path + +from ymir.agents.golang_rebuild.constants import BREW_URL +from ymir.agents.golang_rebuild.models import BuildResult + +logger = logging.getLogger(__name__) + + +async def _run_command( + command: list[str], + cwd: Path | None = None, + check: bool = True, +) -> tuple[int, str, str]: + """Run async subprocess command.""" + logger.debug(f"Running: {' '.join(command)}") + proc = await asyncio.create_subprocess_exec( + *command, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + stdout_str = stdout.decode() if stdout else "" + stderr_str = stderr.decode() if stderr else "" + + if check and proc.returncode != 0: + raise RuntimeError(f"Command failed ({proc.returncode}): {' '.join(command)}\n{stderr_str}") + + return proc.returncode, stdout_str, stderr_str + + +class BrewClient: + """Client for Brew build system operations (async).""" + + def __init__(self, config: dict | None = None): + self.config = config or {} + self.brew_url = self.config.get("brew", {}).get("url", BREW_URL) + self.poll_interval = self.config.get("brew", {}).get("poll_interval", 30) + self.max_wait_time = self.config.get("brew", {}).get("max_wait_time", 7200) + + async def _run_brew_command(self, command: list[str], check: bool = True) -> tuple[int, str, str]: + """Run brew command.""" + return await _run_command(["brew", *command], check=check) + + async def scratch_build( + self, + repo_path: Path, + target: str, + release: str | None = None, + ) -> str: + """ + Trigger scratch build using rhpkg. Returns task ID. + + Args: + release: Optional --release flag for side-tag builds + (e.g., "rhel-9.4.0" for side-tag target) + """ + logger.info(f"Triggering scratch build for {repo_path} (target: {target}, release: {release})") + cmd = ["rhpkg"] + if release: + cmd.append(f"--release={release}") + cmd.extend(["scratch-build", "--srpm", f"--target={target}"]) + _, stdout, _ = await _run_command(cmd, cwd=repo_path) + task_id = self._extract_task_id(stdout) + if not task_id: + raise ValueError(f"Failed to extract task ID from scratch build output:\n{stdout}") + logger.info(f"Scratch build task created: {task_id}") + return task_id + + async def final_build( + self, + repo_path: Path, + target: str, + release: str | None = None, + ) -> str: + """ + Trigger final build using rhpkg. Returns task ID. + + Args: + release: Optional --release flag for side-tag builds + """ + logger.info(f"Triggering final build for {repo_path} (target: {target}, release: {release})") + cmd = ["rhpkg"] + if release: + cmd.append(f"--release={release}") + cmd.extend(["build", f"--target={target}"]) + _, stdout, _ = await _run_command( + cmd, + cwd=repo_path, + ) + task_id = self._extract_task_id(stdout) + if not task_id: + raise ValueError(f"Failed to extract task ID from build output:\n{stdout}") + logger.info(f"Final build task created: {task_id}") + return task_id + + def _extract_task_id(self, output: str) -> str | None: + """Extract task ID from rhpkg/brew output.""" + for pattern in [r"Created task:\s*(\d+)", r"Task ID:\s*(\d+)", r"taskID=(\d+)"]: + match = re.search(pattern, output, re.IGNORECASE) + if match: + return match.group(1) + return None + + async def get_task_info(self, task_id: str) -> dict: + """Get task information.""" + _, stdout, _ = await self._run_brew_command(["taskinfo", task_id]) + info = {} + for line in stdout.splitlines(): + if ":" in line: + key, value = line.split(":", 1) + info[key.strip()] = value.strip() + return info + + async def get_task_state(self, task_id: str) -> tuple[str | None, str | None]: + """Get task state and result.""" + info = await self.get_task_info(task_id) + state = info.get("State", "").lower() + result = None + if state == "closed": + result_text = info.get("Result", "").lower() + result = "success" if ("success" in result_text or result_text == "0") else "fail" + return state, result + + async def is_task_finished(self, task_id: str) -> tuple[bool, str | None]: + """Check if task is finished.""" + state, result = await self.get_task_state(task_id) + if state == "closed": + return True, result + if state in ["canceled", "failed"]: + return True, "fail" + return False, None + + async def wait_for_task( + self, + task_id: str, + poll_interval: int | None = None, + max_wait: int | None = None, + callback: Callable | None = None, + ) -> BuildResult: + """Wait for task to complete, polling periodically.""" + poll_interval = poll_interval or self.poll_interval + max_wait = max_wait or self.max_wait_time + + logger.info(f"Waiting for task {task_id} (poll every {poll_interval}s, max {max_wait}s)") + elapsed = 0 + + while True: + is_finished, result = await self.is_task_finished(task_id) + + if callback: + state, _ = await self.get_task_state(task_id) + callback(task_id, state, elapsed) + + if is_finished: + logger.info(f"Task {task_id} finished with result: {result}") + build_info = await self.get_build_info_from_task(task_id) + return BuildResult( + task_id=task_id, + nvr=build_info.get("nvr"), + state=result, + success=(result == "success"), + build_url=f"{self.brew_url}/taskinfo?taskID={task_id}", + ) + + if elapsed >= max_wait: + logger.error(f"Task {task_id} timed out after {elapsed}s") + return BuildResult( + task_id=task_id, + state="timeout", + success=False, + error_message=f"Build timed out after {elapsed}s", + build_url=f"{self.brew_url}/taskinfo?taskID={task_id}", + ) + + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + async def get_build_info_from_task(self, task_id: str) -> dict: + """Get build information from task.""" + info = await self.get_task_info(task_id) + nvr = None + if "Build" in info: + match = re.match(r"([^\s]+)\s*\((\d+)\)", info["Build"]) + if match: + nvr = match.group(1) + return {"task_id": task_id, "nvr": nvr, "info": info} + + async def get_latest_build(self, tag: str, package: str) -> str | None: + """Get latest build NVR for a package in a tag.""" + returncode, stdout, _ = await self._run_brew_command(["latest-build", tag, package], check=False) + if returncode != 0: + return None + lines = stdout.strip().splitlines() + if len(lines) >= 3: + return lines[2].split()[0] + return None + + async def build_and_wait( + self, + repo_path: Path, + target: str, + scratch: bool = False, + release: str | None = None, + callback: Callable | None = None, + ) -> BuildResult: + """Trigger build and wait for completion.""" + if scratch: + task_id = await self.scratch_build(repo_path, target, release=release) + else: + task_id = await self.final_build(repo_path, target, release=release) + return await self.wait_for_task(task_id, callback=callback) + + async def verify_kerberos_auth(self) -> bool: + """Verify Kerberos authentication is valid.""" + try: + returncode, _, _ = await _run_command(["klist", "-s"], check=False) + return returncode == 0 + except FileNotFoundError: + logger.error("klist command not found - Kerberos tools not installed") + return False diff --git a/ymir/agents/golang_rebuild/comment_parser.py b/ymir/agents/golang_rebuild/comment_parser.py new file mode 100644 index 00000000..3cf5959e --- /dev/null +++ b/ymir/agents/golang_rebuild/comment_parser.py @@ -0,0 +1,145 @@ +""" +Parse build instructions from Jira comments. + +Engineers add a comment to the component ticket before applying the +golang-rebuild-queue label. The agent reads the 3 most recent comments +and extracts build instructions. + +Supported fields (all optional, one per line, case-insensitive keys): + side-tag: rhel-9.4.0-z-gotoolset-stack-gate + release: rhel-9.4.0 + commit: + jiras: RHEL-158645 RHEL-147034 RHEL-146820 + message: Rebuilding with golang 1.25.8 for critical security fix + +If no structured comment is found, the agent falls back to default behavior. +""" + +import logging +import re + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# Fields we look for in comments (case-insensitive) +KNOWN_FIELDS = {"side-tag", "release", "commit", "jiras", "message"} + + +class BuildInstructions(BaseModel): + """Parsed build instructions from a Jira comment.""" + + side_tag: str | None = Field( + default=None, description="Side-tag target (e.g., rhel-9.4.0-z-gotoolset-stack-gate)" + ) + release: str | None = Field(default=None, description="--release flag for rhpkg (e.g., rhel-9.4.0)") + commit: str | None = Field(default=None, description="Commit hash for %global commit0 update") + additional_jiras: list[str] = Field( + default_factory=list, description="Additional Jira IDs for changelog/commit" + ) + custom_message: str | None = Field(default=None, description="Custom changelog/commit message") + source_comment_id: str | None = Field( + default=None, description="ID of the comment these instructions came from" + ) + + @property + def has_side_tag(self) -> bool: + return self.side_tag is not None + + @property + def has_commit(self) -> bool: + return self.commit is not None + + @property + def build_target(self) -> str | None: + """Return side-tag as build target if set, else None (use default).""" + return self.side_tag + + def get_rhpkg_args(self) -> list[str]: + """Get extra rhpkg arguments for --release flag.""" + if self.release: + return [f"--release={self.release}"] + return [] + + +def parse_comment_text(text: str) -> BuildInstructions | None: + """ + Parse a single comment for build instructions. + + Looks for key: value lines. A comment is considered to have build + instructions if it contains at least one recognized field. + + Args: + text: Comment body text + + Returns: + BuildInstructions if any fields found, None otherwise + """ + if not text or not text.strip(): + return None + + instructions = {} + lines = text.strip().splitlines() + + for line in lines: + line = line.strip() + if not line or ":" not in line: + continue + + # Split on first colon only + key, _, value = line.partition(":") + key = key.strip().lower() + value = value.strip() + + if not value: + continue + + if key == "side-tag": + instructions["side_tag"] = value + elif key == "release": + instructions["release"] = value + elif key == "commit": + instructions["commit"] = value + elif key == "jiras": + # Parse space or comma separated Jira keys + jira_keys = re.findall(r"[A-Z]+-\d+", value.upper()) + if jira_keys: + instructions["additional_jiras"] = jira_keys + elif key == "message": + instructions["custom_message"] = value + + if not instructions: + return None + + logger.info(f"Parsed build instructions: {list(instructions.keys())}") + return BuildInstructions(**instructions) + + +def parse_recent_comments(comments: list[dict]) -> BuildInstructions | None: + """ + Parse the 3 most recent comments for build instructions. + + Checks comments in reverse chronological order (newest first). + Returns the first comment that contains recognized fields. + + Args: + comments: List of comment dicts with "body" and optionally "id" keys. + Should be ordered oldest-first (Jira default). + + Returns: + BuildInstructions from the most recent matching comment, or None + """ + # Take last 3 comments (most recent), check newest first + recent = comments[-3:] if len(comments) > 3 else comments + recent = list(reversed(recent)) + + for comment in recent: + body = comment.get("body", "") + result = parse_comment_text(body) + if result: + result.source_comment_id = comment.get("id") + logger.info(f"Found build instructions in comment {result.source_comment_id}") + return result + + logger.info("No build instructions found in recent comments") + return None diff --git a/ymir/agents/golang_rebuild/config.yaml b/ymir/agents/golang_rebuild/config.yaml new file mode 100644 index 00000000..8b9eb798 --- /dev/null +++ b/ymir/agents/golang_rebuild/config.yaml @@ -0,0 +1,87 @@ +# ============================================================================= +# Golang CVE Rebuild Agent - Configuration +# ============================================================================= +# +# This agent automates rebuilding RHEL 9.x and 10.x z-stream components +# affected by Golang CVE fixes. It integrates with jotnar-se infrastructure. +# +# Prerequisites: +# - Jira API credentials (JIRA_EMAIL + JIRA_API_TOKEN) +# - Kerberos authentication (kinit @REDHAT.COM) +# - MCP gateway running (for GitLab fork/MR operations) +# - rhpkg and spectool installed +# +# See README.md for full setup instructions. +# ============================================================================= + +# ============================================================================= +# RHEL Versions +# ============================================================================= +# Only RHEL 9.x and 10.x z-streams are supported. +# Update these when new z-streams are released. + +current_z_streams: + "9": "rhel-9.7.z" + "10": "rhel-10.1.z" + +rhel_versions: + "9.0.z": + branch: "rhel-9.0.0" + build_target: "rhel-9.0.0-candidate" + + "9.2.z": + branch: "rhel-9.2.0" + build_target: "rhel-9.2.0-candidate" + + "9.4.z": + branch: "rhel-9.4.0" + build_target: "rhel-9.4.0-candidate" + + "9.6.z": + branch: "rhel-9.6.0" + build_target: "rhel-9.6.0-candidate" + + "9.7.z": + branch: "rhel-9.7.0" + build_target: "rhel-9.7.0-candidate" + + "10.1.z": + branch: "c10s" + build_target: "c10s-candidate" + + # Add new z-streams here as they are released: + # "9.8.z": + # branch: "rhel-9.8.0" + # build_target: "rhel-9.8.0-candidate" + +# ============================================================================= +# Component Filter +# ============================================================================= +# Only these components will be rebuilt. Others are skipped. +# Set enabled: false to process ALL golang-dependent components. + +component_filter: + enabled: true + allowed_components: + - "podman" + - "buildah" + - "skopeo" + - "containernetworking-plugins" + - "runc" + - "gvisor-tap-vsock" + - "grafana" + - "grafana-pcp" + +# ============================================================================= +# Brew Build Settings +# ============================================================================= +brew: + url: "https://brewweb.engineering.redhat.com/brew" + poll_interval: 30 # seconds between scratch build status checks + max_wait_time: 7200 # max wait for scratch build (2 hours) + +# ============================================================================= +# Git Settings +# ============================================================================= +git: + default_tool: "rhpkg" diff --git a/ymir/agents/golang_rebuild/constants.py b/ymir/agents/golang_rebuild/constants.py new file mode 100644 index 00000000..4d835202 --- /dev/null +++ b/ymir/agents/golang_rebuild/constants.py @@ -0,0 +1,77 @@ +""" +Golang-specific constants for the rebuild agent. + +Infrastructure constants (Redis queues, Jira labels) are in common/constants.py. +This module contains only domain-specific constants for golang rebuilds. +""" + +from enum import Enum + +# Jira statuses indicating Golang CVE is fixed and ready for rebuild +GOLANG_CVE_FIXED_STATUSES = ["Integration", "Release Pending", "Done"] + +# Component ticket statuses valid for processing +COMPONENT_VALID_STATUSES = ["New", "Assigned", "In Progress", "Triaging"] + +# Common golang-dependent components +GOLANG_COMPONENTS = [ + "podman", + "buildah", + "skopeo", + "cri-o", + "runc", + "grafana", + "prometheus", + "containers-common", + "conmon-rs", + "containernetworking-plugins", + "gvisor-tap-vsock", + "grafana-pcp", +] + + +class RebuildStatus(Enum): + """Status of a rebuild task""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + SCRATCH_BUILD = "scratch_build" + SCRATCH_COMPLETE = "scratch_complete" + FINAL_BUILD = "final_build" + COMPLETED = "completed" + FAILED = "failed" + ERRORED = "errored" + + +class BrewBuildState(Enum): + """Brew build task states""" + + FREE = 0 + OPEN = 1 + CLOSED = 2 + CANCELED = 3 + ASSIGNED = 4 + FAILED = 5 + + +# Brew URLs +BREW_URL = "https://brewweb.engineering.redhat.com/brew" +BREW_HUB_URL = "https://brewhub.engineering.redhat.com/brewhub" + +# Agent identity (matching jotnar-se pattern) +AGENT_NAME = "Golang Rebuild Agent" +AGENT_EMAIL = "jotnar@redhat.com" + +# Changelog entry template +CHANGELOG_TEMPLATE = """* {date} {name} <{email}> - {nvr} +- Rebuilding with new golang {golang_version} +- Fixes: {cves} +- Resolves: {jiras} +""" + +# Commit message template +COMMIT_MESSAGE_TEMPLATE = """Rebuilding with new golang {golang_version} +Fixes: {cves} +Resolves: {jiras} + +Signed-off-by: {name} <{email}>""" diff --git a/ymir/agents/golang_rebuild/git_client.py b/ymir/agents/golang_rebuild/git_client.py new file mode 100644 index 00000000..9a19f4fc --- /dev/null +++ b/ymir/agents/golang_rebuild/git_client.py @@ -0,0 +1,252 @@ +""" +Git Client for RHEL 8/9 Brew workflow (async). + +Uses rhpkg for dist-git repositories. For RHEL 10+ GitLab workflow, +the agent uses agents/tasks.py:fork_and_prepare_dist_git() instead. +""" + +import asyncio +import logging +from pathlib import Path + +from ymir.agents.golang_rebuild.constants import COMMIT_MESSAGE_TEMPLATE +from ymir.agents.golang_rebuild.utils import format_cve_list, format_jira_list + +logger = logging.getLogger(__name__) + + +async def _run_command( + command: list[str], + cwd: Path | None = None, + check: bool = True, +) -> tuple[int, str, str]: + """Run async subprocess command.""" + logger.debug(f"Running: {' '.join(command)}") + proc = await asyncio.create_subprocess_exec( + *command, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + stdout_str = stdout.decode() if stdout else "" + stderr_str = stderr.decode() if stderr else "" + + if check and proc.returncode != 0: + raise RuntimeError(f"Command failed ({proc.returncode}): {' '.join(command)}\n{stderr_str}") + + return proc.returncode, stdout_str, stderr_str + + +class GitClient: + """Client for Git/rhpkg operations on RHEL dist-git repositories (async).""" + + def __init__(self, config: dict | None = None): + self.config = config or {} + self.git_tool = self.config.get("git", {}).get("default_tool", "rhpkg") + + async def clone_repository(self, component: str, target_dir: Path, branch: str | None = None) -> Path: + """Clone dist-git repository using rhpkg.""" + target_dir.mkdir(parents=True, exist_ok=True) + repo_path = target_dir / component + + if repo_path.exists(): + logger.info(f"Repository already exists at {repo_path}, pulling updates") + await self.pull(repo_path) + if branch: + await self.checkout_branch(repo_path, branch) + return repo_path + + logger.info(f"Cloning {component} into {target_dir}") + + if self.git_tool == "rhpkg": + await _run_command(["rhpkg", "clone", component], cwd=target_dir) + elif self.git_tool == "fedpkg": + await _run_command(["fedpkg", "clone", component], cwd=target_dir) + else: + repo_url = self.config.get("components", {}).get(component, {}).get("repo_url") + if not repo_url: + raise ValueError(f"No repository URL configured for {component}") + await _run_command(["git", "clone", repo_url], cwd=target_dir) + + if branch: + await self.checkout_branch(repo_path, branch) + + logger.info(f"Successfully cloned {component} to {repo_path}") + return repo_path + + async def checkout_branch(self, repo_path: Path, branch: str): + """Checkout a specific branch.""" + logger.info(f"Checking out branch: {branch}") + returncode, _, _ = await _run_command(["git", "checkout", branch], cwd=repo_path, check=False) + if returncode == 0: + return + + await _run_command(["git", "fetch", "--all"], cwd=repo_path) + for remote in ["origin", "rhel-gitlab", "centos-gitlab"]: + returncode, _, _ = await _run_command( + ["git", "checkout", "-b", branch, f"{remote}/{branch}"], + cwd=repo_path, + check=False, + ) + if returncode == 0: + return + + raise ValueError(f"Branch {branch} not found in any remote (origin, rhel-gitlab, centos-gitlab)") + + async def pull(self, repo_path: Path, branch: str | None = None): + """Pull latest changes from remote.""" + if branch: + await _run_command(["git", "pull", "origin", branch], cwd=repo_path) + else: + await _run_command(["git", "pull"], cwd=repo_path) + + async def get_current_branch(self, repo_path: Path) -> str: + """Get current branch name.""" + _, stdout, _ = await _run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path) + return stdout.strip() + + async def has_staged_changes(self, repo_path: Path) -> bool: + """Check if there are staged changes.""" + returncode, _, _ = await _run_command( + ["git", "diff", "--cached", "--quiet"], cwd=repo_path, check=False + ) + return returncode == 1 + + async def has_uncommitted_changes(self, repo_path: Path) -> bool: + """Check if repository has uncommitted changes.""" + _, stdout, _ = await _run_command(["git", "status", "--short"], cwd=repo_path) + return bool(stdout.strip()) + + async def stage_file(self, repo_path: Path, file_path: str | Path): + """Stage a file for commit.""" + await _run_command(["git", "add", str(file_path)], cwd=repo_path) + + async def commit( + self, repo_path: Path, message: str, author_name: str | None = None, author_email: str | None = None + ): + """Create a commit.""" + if not await self.has_staged_changes(repo_path): + logger.warning("No staged changes to commit") + return + cmd = ["git", "commit", "-m", message] + if author_name and author_email: + cmd.extend(["--author", f"{author_name} <{author_email}>"]) + await _run_command(cmd, cwd=repo_path) + + async def commit_golang_rebuild( + self, + repo_path: Path, + golang_version: str, + cves: list[str], + jiras: list[str], + author_name: str, + author_email: str, + ): + """Create commit for golang rebuild with standard message format.""" + message = COMMIT_MESSAGE_TEMPLATE.format( + golang_version=golang_version, + cves=format_cve_list(cves), + jiras=format_jira_list(jiras), + name=author_name, + email=author_email, + ) + await self.commit(repo_path, message, author_name, author_email) + + async def push(self, repo_path: Path, branch: str | None = None, force: bool = False): + """Push commits to remote.""" + cmd = ["git", "push"] + if force: + cmd.append("--force") + if branch: + cmd.extend(["origin", branch]) + logger.info(f"Pushing to remote: {branch or 'current branch'}") + await _run_command(cmd, cwd=repo_path) + + async def prepare_rebuild_commit( + self, + repo_path: Path, + spec_file: str | Path, + golang_version: str, + cves: list[str], + jiras: list[str], + author_name: str, + author_email: str, + ) -> bool: + """Stage spec file, commit with standard message. Returns True if committed.""" + await self.stage_file(repo_path, spec_file) + if not await self.has_staged_changes(repo_path): + logger.warning("No changes to commit in spec file") + return False + await self.commit_golang_rebuild(repo_path, golang_version, cves, jiras, author_name, author_email) + return True + + async def verify_clean_state(self, repo_path: Path) -> tuple[bool, str]: + """Verify repository is in clean state.""" + if await self.has_uncommitted_changes(repo_path): + _, stdout, _ = await _run_command(["git", "status", "--short"], cwd=repo_path) + return False, f"Repository has uncommitted changes:\n{stdout}" + return True, "Repository is clean" + + async def verify_branch(self, repo_path: Path, expected_branch: str) -> tuple[bool, str]: + """Verify repository is on expected branch.""" + current = await self.get_current_branch(repo_path) + if current != expected_branch: + return False, f"On branch '{current}', expected '{expected_branch}'" + return True, f"On correct branch: {expected_branch}" + + async def download_sources(self, repo_path: Path, spec_file: str) -> str: + """ + Download upstream sources using spectool. + + Args: + repo_path: Path to repository + spec_file: Spec file name (e.g., "buildah.spec") + + Returns: + stdout from spectool + """ + logger.info(f"Downloading sources: spectool -g {spec_file}") + _, stdout, _ = await _run_command( + ["spectool", "-g", spec_file], + cwd=repo_path, + ) + return stdout + + async def upload_new_sources(self, repo_path: Path) -> str: + """ + Upload new sources to lookaside cache using rhpkg new-sources. + + Finds downloaded tarballs in the repo directory and uploads them. + + Returns: + stdout from rhpkg new-sources + """ + # Find tarballs (common source archive patterns) + tarball_patterns = ["*.tar.gz", "*.tar.bz2", "*.tar.xz", "*.tgz", "*.zip"] + tarballs = [] + for pattern in tarball_patterns: + tarballs.extend(repo_path.glob(pattern)) + + if not tarballs: + raise FileNotFoundError(f"No source tarballs found in {repo_path}") + + tarball_names = [t.name for t in tarballs] + logger.info(f"Uploading new sources: rhpkg new-sources {' '.join(tarball_names)}") + + _, stdout, _ = await _run_command( + ["rhpkg", "new-sources", *tarball_names], + cwd=repo_path, + ) + return stdout + + async def update_sources_for_commit(self, repo_path: Path, spec_file: str) -> None: + """ + Full source update flow: spectool -g -> rhpkg new-sources. + + Used when a new commit hash is set in the spec file and sources + need to be re-downloaded and uploaded to lookaside cache. + """ + logger.info("Updating sources for new commit hash") + await self.download_sources(repo_path, spec_file) + await self.upload_new_sources(repo_path) diff --git a/ymir/agents/golang_rebuild/jira_queries.py b/ymir/agents/golang_rebuild/jira_queries.py new file mode 100644 index 00000000..d6f5910e --- /dev/null +++ b/ymir/agents/golang_rebuild/jira_queries.py @@ -0,0 +1,231 @@ +""" +Jira read-only queries for Golang rebuild agent. + +Write operations (comments, labels, transitions) use agents/tasks.py MCP helpers. +This module handles only domain-specific query/extraction logic. +""" + +import logging +import os +import re +from typing import Any + +from jira import JIRA +from jira.exceptions import JIRAError + +from ymir.agents.golang_rebuild.constants import COMPONENT_VALID_STATUSES, GOLANG_CVE_FIXED_STATUSES +from ymir.agents.golang_rebuild.models import GolangCVEInfo +from ymir.agents.golang_rebuild.utils import extract_cves_from_text, extract_rhel_version_from_text +from ymir.common.constants import GOLANG_REBUILD_QUEUE_LABEL +from ymir.common.version_utils import get_short_version, parse_rhel_version + +logger = logging.getLogger(__name__) + + +class GolangJiraQueries: + """ + Read-only Jira queries for golang CVE rebuild discovery. + + Uses the `jira` Python library for direct API access. + """ + + MAX_RESULTS_PER_PAGE = 500 + API_TIMEOUT = 90 + + def __init__(self, jira_url: str | None = None, config: dict | None = None): + self.jira_url = jira_url or os.getenv("JIRA_URL", "https://redhat.atlassian.net") + self.config = config or {} + + jira_email = os.getenv("JIRA_EMAIL") or os.getenv("JIRA_USERNAME") + jira_token = os.getenv("JIRA_API_TOKEN") or os.getenv("JIRA_PASSWORD") + + if not jira_email or not jira_token: + raise ValueError( + "Jira credentials not found. Set JIRA_EMAIL and JIRA_API_TOKEN " + "environment variables (or source ~/.rh-jira-mcp.env)" + ) + + options = { + "server": self.jira_url, + "rest_api_version": "3", + "agile_rest_api_version": "latest", + } + self.jira = JIRA( + options=options, + basic_auth=(jira_email, jira_token), + max_retries=3, + timeout=self.API_TIMEOUT, + ) + logger.info(f"Connected to Jira at {self.jira_url}") + + def _issue_to_dict(self, issue) -> dict[str, Any]: + """Convert JIRA issue object to dictionary.""" + return { + "key": issue.key, + "id": issue.id, + "fields": { + "summary": str(getattr(issue.fields, "summary", "")), + "description": str(getattr(issue.fields, "description", "") or ""), + "status": {"name": str(getattr(issue.fields.status, "name", ""))} + if hasattr(issue.fields, "status") + else {}, + "labels": list(getattr(issue.fields, "labels", [])), + "components": [{"name": str(c.name)} for c in getattr(issue.fields, "components", [])], + }, + } + + def search_issues( + self, jql: str, fields: list[str] | None = None, max_results: int | None = None + ) -> list[dict[str, Any]]: + """Search for issues using JQL query.""" + if fields is None: + fields_list = ["key", "summary", "labels", "status", "description", "components"] + elif isinstance(fields, list): + fields_list = fields + else: + fields_list = fields.split(",") + + logger.debug(f"Searching Jira: {jql}") + + try: + import requests + + url = f"{self.jira_url}/rest/api/3/search/jql" + auth = self.jira._session.auth + payload = { + "jql": jql, + "maxResults": max_results or self.MAX_RESULTS_PER_PAGE, + "fields": fields_list, + } + response = requests.post(url, json=payload, auth=auth, timeout=self.API_TIMEOUT) + response.raise_for_status() + data = response.json() + + result = [] + for issue_data in data.get("issues", []): + issue = self.jira.issue(issue_data["key"], fields=",".join(fields_list)) + result.append(self._issue_to_dict(issue)) + + logger.info(f"Found {len(result)} issues") + return result + + except (JIRAError, requests.HTTPError) as e: + logger.error(f"Jira search failed: {e}") + raise + + def get_issue(self, issue_key: str) -> dict[str, Any]: + """Get a single issue by key.""" + try: + issue = self.jira.issue(issue_key, fields="key,summary,labels,status,description,components") + return self._issue_to_dict(issue) + except JIRAError as e: + logger.error(f"Failed to get issue {issue_key}: {e}") + raise + + def find_golang_cve_tickets(self) -> list[dict[str, Any]]: + """Find Golang CVE tickets in the queue (with golang-rebuild-queue label).""" + query_template = self.config.get("jira", {}).get("queries", {}).get("golang_queue") + if not query_template: + status_list = ", ".join(f'"{s}"' for s in GOLANG_CVE_FIXED_STATUSES) + query_template = ( + f"project = RHEL AND labels = {GOLANG_REBUILD_QUEUE_LABEL} " + f"AND component = golang AND status IN ({status_list}) " + f"AND labels = CVE ORDER BY created DESC" + ) + jql = query_template.format(queue_label=GOLANG_REBUILD_QUEUE_LABEL) + logger.info(f"Searching for Golang CVE tickets: {jql}") + return self.search_issues(jql) + + def find_dependent_tickets(self, cve_id: str, rhel_version: str) -> list[dict[str, Any]]: + """Find component tickets affected by a specific CVE.""" + short_version = get_short_version(rhel_version) or rhel_version + + query_template = self.config.get("jira", {}).get("queries", {}).get("dependent_tickets") + if not query_template: + status_list = ", ".join(f'"{s}"' for s in COMPONENT_VALID_STATUSES) + query_template = ( + f'project = RHEL AND summary ~ "{{cve}}" ' + f'AND (summary ~ "{{rhel_version}}" OR summary ~ "{{short_version}}") ' + f"AND status IN ({status_list}) " + f"ORDER BY component ASC" + ) + + jql = query_template.format(cve=cve_id, rhel_version=rhel_version, short_version=short_version) + logger.info(f"Searching for dependent tickets: {jql}") + return self.search_issues(jql) + + def extract_golang_cve_info(self, issue: dict[str, Any]) -> GolangCVEInfo | None: + """Extract Golang CVE information from a Jira ticket.""" + fields = issue.get("fields", {}) + summary = fields.get("summary", "") + description = fields.get("description", "") + status = fields.get("status", {}).get("name", "") + + cve_ids = extract_cves_from_text(summary + " " + description) + if not cve_ids: + logger.warning(f"No CVE IDs found in ticket {issue.get('key')}") + return None + + rhel_version = extract_rhel_version_from_text(summary + " " + description) + if not rhel_version: + logger.warning(f"No RHEL version found in ticket {issue.get('key')}") + return None + + parsed = parse_rhel_version(rhel_version) + if parsed is None: + logger.warning(f"Invalid RHEL version format: {rhel_version}") + return None + + _major, _minor, is_zstream = parsed + if not is_zstream: + logger.info(f"Skipping y-stream ticket {issue.get('key')}: {rhel_version}") + return None + + # Extract golang version + golang_match = re.search( + r"(?:golang-?|go)(\d+\.\d+\.\d+)", summary + " " + description, re.IGNORECASE + ) + golang_version = golang_match.group(1) if golang_match else "unknown" + + return GolangCVEInfo( + ticket_key=issue.get("key"), + cve_ids=cve_ids, + rhel_version=rhel_version, + golang_version=golang_version, + status=status, + is_zstream=is_zstream, + summary=summary, + description=description, + ) + + def get_issue_comments(self, issue_key: str) -> list[dict]: + """ + Get comments from a Jira ticket. + + Returns list of dicts with "id", "body", "author", "created" keys. + Ordered oldest-first (Jira default). + """ + try: + comments = self.jira.comments(issue_key) + return [ + { + "id": str(c.id), + "body": str(getattr(c, "body", "")), + "author": str(getattr(c.author, "displayName", "")) if hasattr(c, "author") else "", + "created": str(getattr(c, "created", "")), + } + for c in comments + ] + except JIRAError as e: + logger.warning(f"Failed to get comments for {issue_key}: {e}") + return [] + + def check_label_exists(self, issue_key: str, label: str) -> bool: + """Check if a specific label exists on a ticket.""" + try: + issue = self.jira.issue(issue_key, fields="labels") + labels = list(getattr(issue.fields, "labels", [])) + return label in labels + except JIRAError as e: + logger.warning(f"Failed to check labels for {issue_key}: {e}") + return False diff --git a/ymir/agents/golang_rebuild/models.py b/ymir/agents/golang_rebuild/models.py new file mode 100644 index 00000000..e787c0ee --- /dev/null +++ b/ymir/agents/golang_rebuild/models.py @@ -0,0 +1,115 @@ +""" +Pydantic models for Golang Rebuild Agent. + +Uses common.models.Task for queue integration. +""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ymir.agents.golang_rebuild.constants import RebuildStatus + + +class GolangRebuildData(BaseModel): + """Triage routing metadata for golang rebuild tasks (stored in Task.metadata).""" + + golang_ticket: str = Field(description="Golang CVE ticket key (e.g., RHEL-158645)") + component_ticket: str = Field(description="Component ticket key (e.g., RHEL-149580)") + component: str = Field(description="Component name (e.g., buildah)") + rhel_version: str = Field(description="RHEL version (e.g., rhel-9.7.z)") + golang_version: str = Field(description="Golang version (e.g., 1.25.8)") + cves: list[str] = Field(default_factory=list, description="CVE IDs") + workflow: str = Field(default="brew_build", description="Workflow type (brew_build or gitlab_mr)") + additional_jiras: list[str] = Field(default_factory=list, description="Additional Jira tickets") + branch: str | None = Field(default=None, description="Dist-git branch") + build_target: str | None = Field(default=None, description="Brew build target") + + +class GolangCVEInfo(BaseModel): + """Information extracted from a Golang CVE Jira ticket.""" + + ticket_key: str = Field(description="Jira ticket key") + cve_ids: list[str] = Field(description="CVE IDs") + rhel_version: str = Field(description="RHEL version (e.g., rhel-9.7.z)") + golang_version: str = Field(default="unknown", description="Golang version") + status: str = Field(description="Jira ticket status") + is_zstream: bool = Field(description="Whether this is a z-stream version") + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + + +class ComponentRebuildInfo(BaseModel): + """Information about a component rebuild.""" + + component: str + ticket_key: str + rhel_version: str + cve_ids: list[str] + golang_version: str + + # Repository information + repo_url: str | None = None + repo_path: str | None = None + branch: str | None = None + + # Build information + build_target: str | None = None + scratch_task_id: str | None = None + scratch_nvr: str | None = None + final_task_id: str | None = None + final_nvr: str | None = None + + # GitLab MR information (for RHEL 10+) + fork_url: str | None = None + mr_url: str | None = None + + # Status + status: RebuildStatus = RebuildStatus.PENDING + error_message: str | None = None + + +class BuildResult(BaseModel): + """Result of a Brew build.""" + + task_id: str + nvr: str | None = None + state: str | None = None + success: bool = False + error_message: str | None = None + build_url: str | None = None + + +class RebuildSummary(BaseModel): + """Summary of a golang rebuild operation.""" + + golang_ticket: str + components_processed: int = 0 + components_succeeded: int = 0 + components_failed: int = 0 + components_skipped: int = 0 + component_results: list[dict] = Field(default_factory=list) + started_at: datetime | None = None + completed_at: datetime | None = None + + def add_result( + self, + component: str, + success: bool, + message: str = "", + scratch_task_id: str | None = None, + scratch_nvr: str | None = None, + ): + """Add a component rebuild result.""" + result = {"component": component, "success": success, "message": message} + if scratch_task_id: + result["scratch_task_id"] = scratch_task_id + result["scratch_nvr"] = scratch_nvr + result["brew_url"] = ( + f"https://brewweb.engineering.redhat.com/brew/taskinfo?taskID={scratch_task_id}" + ) + self.component_results.append(result) + if success: + self.components_succeeded += 1 + else: + self.components_failed += 1 diff --git a/ymir/agents/golang_rebuild/specfile.py b/ymir/agents/golang_rebuild/specfile.py new file mode 100644 index 00000000..386d1cb1 --- /dev/null +++ b/ymir/agents/golang_rebuild/specfile.py @@ -0,0 +1,292 @@ +""" +RPM Spec File Parser and Modifier + +Handles parsing RPM spec files, bumping release versions, +and adding changelog entries for golang rebuilds. +""" + +import logging +import re +from pathlib import Path + +from ymir.agents.golang_rebuild.utils import format_cve_list, format_date_for_changelog, format_jira_list + +logger = logging.getLogger(__name__) + + +class SpecFile: + """ + RPM Spec File parser and modifier. + + Handles operations specific to golang rebuild workflow: + - Bump release version (e.g., 3%{?dist} -> 3%{?dist}.1) + - Add changelog entry + - Extract metadata (name, version, release, epoch, NVR) + """ + + def __init__(self, spec_path: str | Path): + self.spec_path = Path(spec_path) + if not self.spec_path.exists(): + raise FileNotFoundError(f"Spec file not found: {spec_path}") + self.content = self.spec_path.read_text() + self.lines = self.content.splitlines() + + def save(self, output_path: str | Path | None = None): + """Save spec file.""" + output = Path(output_path) if output_path else self.spec_path + output.write_text("\n".join(self.lines) + "\n") + logger.info(f"Saved spec file: {output}") + + def get_name(self) -> str | None: + """Get package name from spec file.""" + for line in self.lines: + match = re.match(r"^Name:\s+(.+)$", line, re.IGNORECASE) + if match: + return re.sub(r"%\{[^}]+\}", "", match.group(1).strip()).strip() + return None + + def get_version(self) -> str | None: + """Get version from spec file.""" + for line in self.lines: + match = re.match(r"^Version:\s+(.+)$", line, re.IGNORECASE) + if match: + return match.group(1).strip() + return None + + def get_epoch(self) -> str | None: + """Get epoch from spec file.""" + for line in self.lines: + match = re.match(r"^Epoch:\s+(.+)$", line, re.IGNORECASE) + if match: + return match.group(1).strip() + return None + + def get_release(self) -> str | None: + """Get current release from spec file.""" + for line in self.lines: + match = re.match(r"^Release:\s+(.+)$", line, re.IGNORECASE) + if match: + return match.group(1).strip() + return None + + def get_nvr(self) -> tuple[str | None, str | None, str | None]: + """Get Name-Version-Release.""" + return self.get_name(), self.get_version(), self.get_release() + + def get_full_nvr(self) -> str | None: + """Get full NVR string including epoch if present.""" + name, version, release = self.get_nvr() + if not all([name, version, release]): + return None + epoch = self.get_epoch() + if epoch: + return f"{epoch}:{name}-{version}-{release}" + return f"{name}-{version}-{release}" + + def bump_release(self) -> tuple[str, str]: + """ + Bump release version for golang rebuild. + + Patterns: + - 3%{?dist} -> 3%{?dist}.1 + - 3%{?dist}.1 -> 3%{?dist}.2 + - 5 -> 5.1 + + Returns: + Tuple of (old_release, new_release) + """ + release_line_idx = None + current_release = None + + for idx, line in enumerate(self.lines): + match = re.match(r"^Release:\s+(.+)$", line, re.IGNORECASE) + if match: + release_line_idx = idx + current_release = match.group(1).strip() + break + + if release_line_idx is None or current_release is None: + raise ValueError("Release: line not found in spec file") + + # Try with dist macro + match = re.match(r"^(\d+)(%\{?\??dist}?)(?:\.(\d+))?$", current_release) + if match: + base_number = match.group(1) + dist_macro = match.group(2) + minor = match.group(3) + if minor: + new_release = f"{base_number}{dist_macro}.{int(minor) + 1}" + else: + new_release = f"{base_number}{dist_macro}.1" + else: + # Try without dist macro + match = re.match(r"^(\d+)(?:\.(\d+))?$", current_release) + if match: + base_number = match.group(1) + minor = match.group(2) + new_release = f"{base_number}.{int(minor) + 1}" if minor else f"{base_number}.1" + else: + raise ValueError(f"Unsupported release format: {current_release}") + + self.lines[release_line_idx] = f"Release: {new_release}" + logger.info(f"Bumped release: {current_release} -> {new_release}") + return current_release, new_release + + def find_changelog_line(self) -> int: + """Find %changelog section line number.""" + for idx, line in enumerate(self.lines): + if re.match(r"^%changelog\s*$", line, re.IGNORECASE): + return idx + raise ValueError("%changelog section not found in spec file") + + def add_changelog_entry( + self, + golang_version: str, + cves: list[str], + jiras: list[str], + author_name: str, + author_email: str, + custom_message: str | None = None, + ): + """ + Add changelog entry for golang rebuild. + + Args: + custom_message: If provided, replaces the default "Rebuilding with new golang X.Y.Z" line. + """ + nvr = self.get_full_nvr() + if not nvr: + raise ValueError("Cannot generate NVR for changelog entry") + + date_str = format_date_for_changelog() + description = custom_message or f"Rebuilding with new golang {golang_version}" + entry_lines = [ + f"* {date_str} {author_name} <{author_email}> - {nvr}", + f"- {description}", + f"- Fixes: {format_cve_list(cves)}", + f"- Resolves: {format_jira_list(jiras)}", + ] + + changelog_idx = self.find_changelog_line() + insert_idx = changelog_idx + 1 + + if insert_idx < len(self.lines) and self.lines[insert_idx].strip(): + entry_lines.append("") + + for i, entry_line in enumerate(entry_lines): + self.lines.insert(insert_idx + i, entry_line) + + logger.info(f"Added changelog entry for golang {golang_version}") + + def get_latest_changelog_entry(self) -> str | None: + """Get the latest changelog entry.""" + try: + changelog_idx = self.find_changelog_line() + except ValueError: + return None + + entry_lines = [] + for idx in range(changelog_idx + 1, len(self.lines)): + line = self.lines[idx] + if line.startswith("%") and idx > changelog_idx + 1: + break + if line.startswith("*") and entry_lines: + break + entry_lines.append(line) + + return "\n".join(entry_lines).strip() + + def update_commit0(self, new_commit: str) -> str | None: + """ + Update %global commit0 (or %global commit) in spec file. + + Searches for lines like: + %global commit0 abc123... + %global commit abc123... + + Args: + new_commit: New commit hash + + Returns: + Old commit hash, or None if not found + """ + patterns = [ + (r"^(%global\s+commit0\s+)(\S+)$", "commit0"), + (r"^(%global\s+commit\s+)(\S+)$", "commit"), + ] + + for idx, line in enumerate(self.lines): + for pattern, name in patterns: + match = re.match(pattern, line, re.IGNORECASE) + if match: + old_commit = match.group(2) + self.lines[idx] = f"{match.group(1)}{new_commit}" + logger.info(f"Updated %global {name}: {old_commit[:12]}... -> {new_commit[:12]}...") + return old_commit + + logger.warning("No %global commit0 or %global commit found in spec file") + return None + + def validate_spec(self) -> list[str]: + """Validate spec file format. Returns list of errors.""" + errors = [] + if not self.get_name(): + errors.append("Name: field not found") + if not self.get_version(): + errors.append("Version: field not found") + if not self.get_release(): + errors.append("Release: field not found") + try: + self.find_changelog_line() + except ValueError: + errors.append("%changelog section not found") + return errors + + @staticmethod + def find_spec_file(directory: Path) -> Path | None: + """Find .spec file in directory.""" + spec_files = list(directory.glob("*.spec")) + if not spec_files: + return None + if len(spec_files) > 1: + raise ValueError(f"Multiple .spec files found in {directory}: {spec_files}") + return spec_files[0] + + +def bump_spec_for_golang_rebuild( + spec_path: str | Path, + golang_version: str, + cves: list[str], + jiras: list[str], + author_name: str, + author_email: str, + commit_hash: str | None = None, + custom_message: str | None = None, +) -> tuple[str, str]: + """ + Bump spec file for golang rebuild (convenience function). + + Args: + commit_hash: If provided, updates %global commit0 in spec. + custom_message: If provided, replaces default changelog description. + + Returns: + Tuple of (old_release, new_release) + """ + spec = SpecFile(spec_path) + + # Update commit hash if provided + if commit_hash: + spec.update_commit0(commit_hash) + + old_release, new_release = spec.bump_release() + spec.add_changelog_entry( + golang_version=golang_version, + cves=cves, + jiras=jiras, + author_name=author_name, + author_email=author_email, + custom_message=custom_message, + ) + spec.save() + return old_release, new_release diff --git a/ymir/agents/golang_rebuild/tests/__init__.py b/ymir/agents/golang_rebuild/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ymir/agents/golang_rebuild/tests/test_brew_client.py b/ymir/agents/golang_rebuild/tests/test_brew_client.py new file mode 100644 index 00000000..7c75ef88 --- /dev/null +++ b/ymir/agents/golang_rebuild/tests/test_brew_client.py @@ -0,0 +1,148 @@ +""" +Unit tests for Brew Client (async) +""" + +from unittest.mock import patch + +import pytest + +from ymir.agents.golang_rebuild.brew_client import BrewClient +from ymir.agents.golang_rebuild.models import BuildResult + + +@pytest.fixture +def brew_client(): + return BrewClient(config={"brew": {"poll_interval": 1, "max_wait_time": 10}}) + + +@pytest.fixture +def mock_repo_path(tmp_path): + repo = tmp_path / "buildah" + repo.mkdir() + return repo + + +class TestBrewClient: + def test_extract_task_id_created_task(self, brew_client): + output = "Created task: 123456789\nTask info: https://brewweb..." + assert brew_client._extract_task_id(output) == "123456789" + + def test_extract_task_id_task_url(self, brew_client): + output = "Task info: https://brewweb.engineering.redhat.com/brew/taskinfo?taskID=987654321" + assert brew_client._extract_task_id(output) == "987654321" + + def test_extract_task_id_not_found(self, brew_client): + assert brew_client._extract_task_id("Some random output") is None + + @pytest.mark.asyncio + @patch("ymir.agents.golang_rebuild.brew_client._run_command") + async def test_scratch_build(self, mock_run, brew_client, mock_repo_path): + mock_run.return_value = (0, "Created task: 123456789\n", "") + task_id = await brew_client.scratch_build(mock_repo_path, "rhel-9.7.0-candidate") + assert task_id == "123456789" + args = mock_run.call_args[0][0] + assert "rhpkg" in args + assert "scratch-build" in args + + @pytest.mark.asyncio + @patch("ymir.agents.golang_rebuild.brew_client._run_command") + async def test_final_build(self, mock_run, brew_client, mock_repo_path): + mock_run.return_value = (0, "Created task: 987654321\n", "") + task_id = await brew_client.final_build(mock_repo_path, "rhel-9.7.0-candidate") + assert task_id == "987654321" + args = mock_run.call_args[0][0] + assert "rhpkg" in args + assert "build" in args + + @pytest.mark.asyncio + async def test_get_task_info(self, brew_client): + with patch.object(brew_client, "_run_brew_command") as mock_brew: + mock_brew.return_value = ( + 0, + "Task: 123456789\nState: closed\nResult: success\nBuild: buildah-1.33.13-3.2.el9_7 (12345)\n", + "", + ) + info = await brew_client.get_task_info("123456789") + assert info["Task"] == "123456789" + assert info["State"] == "closed" + assert info["Result"] == "success" + + @pytest.mark.asyncio + async def test_get_task_state_success(self, brew_client): + with patch.object(brew_client, "get_task_info") as mock_info: + mock_info.return_value = {"State": "closed", "Result": "success"} + state, result = await brew_client.get_task_state("123456789") + assert state == "closed" + assert result == "success" + + @pytest.mark.asyncio + async def test_get_task_state_fail(self, brew_client): + with patch.object(brew_client, "get_task_info") as mock_info: + mock_info.return_value = {"State": "closed", "Result": "failed"} + state, result = await brew_client.get_task_state("123456789") + assert state == "closed" + assert result == "fail" + + @pytest.mark.asyncio + async def test_get_task_state_running(self, brew_client): + with patch.object(brew_client, "get_task_info") as mock_info: + mock_info.return_value = {"State": "open"} + state, result = await brew_client.get_task_state("123456789") + assert state == "open" + assert result is None + + @pytest.mark.asyncio + async def test_is_task_finished_success(self, brew_client): + with patch.object(brew_client, "get_task_state") as mock_state: + mock_state.return_value = ("closed", "success") + is_finished, result = await brew_client.is_task_finished("123456789") + assert is_finished is True + assert result == "success" + + @pytest.mark.asyncio + async def test_is_task_finished_running(self, brew_client): + with patch.object(brew_client, "get_task_state") as mock_state: + mock_state.return_value = ("open", None) + is_finished, result = await brew_client.is_task_finished("123456789") + assert is_finished is False + assert result is None + + @pytest.mark.asyncio + async def test_build_and_wait_scratch(self, brew_client, mock_repo_path): + with ( + patch.object(brew_client, "scratch_build") as mock_scratch, + patch.object(brew_client, "wait_for_task") as mock_wait, + ): + mock_scratch.return_value = "123456789" + mock_wait.return_value = BuildResult( + task_id="123456789", nvr="buildah-1.33.13-3.2.el9_7", success=True, state="success" + ) + result = await brew_client.build_and_wait(mock_repo_path, "rhel-9.7.0-candidate", scratch=True) + assert result.success is True + mock_scratch.assert_called_once() + + @pytest.mark.asyncio + async def test_build_and_wait_final(self, brew_client, mock_repo_path): + with ( + patch.object(brew_client, "final_build") as mock_final, + patch.object(brew_client, "wait_for_task") as mock_wait, + ): + mock_final.return_value = "987654321" + mock_wait.return_value = BuildResult( + task_id="987654321", nvr="buildah-1.33.13-3.2.el9_7", success=True, state="success" + ) + result = await brew_client.build_and_wait(mock_repo_path, "rhel-9.7.0-candidate", scratch=False) + assert result.success is True + mock_final.assert_called_once() + + @pytest.mark.asyncio + @patch("ymir.agents.golang_rebuild.brew_client._run_command") + async def test_verify_kerberos_auth_valid(self, mock_run, brew_client): + mock_run.return_value = (0, "", "") + assert await brew_client.verify_kerberos_auth() is True + + @pytest.mark.asyncio + @patch("ymir.agents.golang_rebuild.brew_client._run_command") + async def test_verify_kerberos_auth_invalid(self, mock_run, brew_client): + mock_run.return_value = (1, "", "") + assert await brew_client.verify_kerberos_auth() is False diff --git a/ymir/agents/golang_rebuild/tests/test_comment_parser.py b/ymir/agents/golang_rebuild/tests/test_comment_parser.py new file mode 100644 index 00000000..a9d1a755 --- /dev/null +++ b/ymir/agents/golang_rebuild/tests/test_comment_parser.py @@ -0,0 +1,122 @@ +""" +Unit tests for Jira comment parser +""" + +from ymir.agents.golang_rebuild.comment_parser import parse_comment_text, parse_recent_comments + + +class TestParseCommentText: + def test_full_comment(self): + text = """side-tag: rhel-9.4.0-z-gotoolset-stack-gate +release: rhel-9.4.0 +commit: test0commit0hash +jiras: RHEL-158645 RHEL-147034 RHEL-146820 +message: Rebuilding with golang 1.25.8 for critical security fix""" + + result = parse_comment_text(text) + assert result is not None + assert result.side_tag == "rhel-9.4.0-z-gotoolset-stack-gate" + assert result.release == "rhel-9.4.0" + assert result.commit == "test0commit0hash" + assert result.additional_jiras == ["RHEL-158645", "RHEL-147034", "RHEL-146820"] + assert result.custom_message == "Rebuilding with golang 1.25.8 for critical security fix" + + def test_side_tag_only(self): + text = "side-tag: rhel-9.4.0-z-gotoolset-stack-gate\nrelease: rhel-9.4.0" + result = parse_comment_text(text) + assert result is not None + assert result.has_side_tag is True + assert result.side_tag == "rhel-9.4.0-z-gotoolset-stack-gate" + assert result.release == "rhel-9.4.0" + assert result.commit is None + assert result.additional_jiras == [] + + def test_commit_only(self): + text = "commit: test0commit0hash" + result = parse_comment_text(text) + assert result is not None + assert result.has_commit is True + assert result.commit == "test0commit0hash" + assert result.has_side_tag is False + + def test_jiras_comma_separated(self): + text = "jiras: RHEL-111, RHEL-222, RHEL-333" + result = parse_comment_text(text) + assert result is not None + assert set(result.additional_jiras) == {"RHEL-111", "RHEL-222", "RHEL-333"} + + def test_message_only(self): + text = "message: Custom rebuild reason for security compliance" + result = parse_comment_text(text) + assert result is not None + assert result.custom_message == "Custom rebuild reason for security compliance" + + def test_no_recognized_fields(self): + text = "This is just a regular comment about the ticket" + result = parse_comment_text(text) + assert result is None + + def test_empty_comment(self): + assert parse_comment_text("") is None + assert parse_comment_text(" ") is None + + def test_case_insensitive_keys(self): + text = "Side-Tag: rhel-9.4.0-z-gotoolset-stack-gate\nRelease: rhel-9.4.0" + result = parse_comment_text(text) + assert result is not None + assert result.side_tag == "rhel-9.4.0-z-gotoolset-stack-gate" + + def test_get_rhpkg_args(self): + text = "side-tag: rhel-9.4.0-z-gotoolset-stack-gate\nrelease: rhel-9.4.0" + result = parse_comment_text(text) + assert result.get_rhpkg_args() == ["--release=rhel-9.4.0"] + assert result.build_target == "rhel-9.4.0-z-gotoolset-stack-gate" + + def test_get_rhpkg_args_no_release(self): + text = "commit: abc123" + result = parse_comment_text(text) + assert result.get_rhpkg_args() == [] + + +class TestParseRecentComments: + def test_finds_in_most_recent(self): + comments = [ + {"id": "1", "body": "Regular comment"}, + {"id": "2", "body": "Another regular comment"}, + {"id": "3", "body": "side-tag: rhel-9.4.0-z-gotoolset-stack-gate\nrelease: rhel-9.4.0"}, + ] + result = parse_recent_comments(comments) + assert result is not None + assert result.source_comment_id == "3" + assert result.side_tag == "rhel-9.4.0-z-gotoolset-stack-gate" + + def test_prefers_newest(self): + comments = [ + {"id": "1", "body": "commit: old_hash"}, + {"id": "2", "body": "commit: new_hash"}, + ] + result = parse_recent_comments(comments) + assert result is not None + assert result.commit == "new_hash" + assert result.source_comment_id == "2" + + def test_only_checks_last_three(self): + comments = [ + {"id": "1", "body": "commit: should_be_ignored"}, + {"id": "2", "body": "Regular comment"}, + {"id": "3", "body": "Regular comment"}, + {"id": "4", "body": "Regular comment"}, + {"id": "5", "body": "Regular comment"}, + ] + result = parse_recent_comments(comments) + assert result is None # comment 1 is too old (>3 from end) + + def test_no_comments(self): + assert parse_recent_comments([]) is None + + def test_no_matching_comments(self): + comments = [ + {"id": "1", "body": "Just discussing the ticket"}, + {"id": "2", "body": "Looks good to me"}, + ] + assert parse_recent_comments(comments) is None diff --git a/ymir/agents/golang_rebuild/tests/test_jira_queries.py b/ymir/agents/golang_rebuild/tests/test_jira_queries.py new file mode 100644 index 00000000..123e939b --- /dev/null +++ b/ymir/agents/golang_rebuild/tests/test_jira_queries.py @@ -0,0 +1,109 @@ +""" +Unit tests for Jira Queries (read-only operations) +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ymir.agents.golang_rebuild.jira_queries import GolangJiraQueries + + +@pytest.fixture +def jira_queries(): + """Create GolangJiraQueries with mocked JIRA client (no real credentials needed).""" + with ( + patch.dict("os.environ", {"JIRA_EMAIL": "test@test.com", "JIRA_API_TOKEN": "fake-token"}), + patch("ymir.agents.golang_rebuild.jira_queries.JIRA") as mock_jira_cls, + ): + mock_jira_cls.return_value = MagicMock() + return GolangJiraQueries(jira_url="https://test.jira.com") + + +@pytest.fixture +def sample_golang_ticket(): + return { + "key": "RHEL-158645", + "fields": { + "summary": "CVE-2025-12345 CVE-2025-67890 for RHEL 9.7.z - golang security update", + "description": "Security update for golang-1.25.8 to fix multiple CVEs", + "status": {"name": "Release Pending"}, + "labels": ["CVE", "golang-rebuild-queue"], + "components": [{"name": "golang"}], + }, + } + + +class TestGolangJiraQueries: + def test_extract_golang_cve_info_success(self, jira_queries, sample_golang_ticket): + info = jira_queries.extract_golang_cve_info(sample_golang_ticket) + assert info is not None + assert info.ticket_key == "RHEL-158645" + assert "CVE-2025-12345" in info.cve_ids + assert "CVE-2025-67890" in info.cve_ids + assert info.rhel_version == "rhel-9.7.z" + assert info.is_zstream is True + assert info.golang_version == "1.25.8" + + def test_extract_golang_cve_info_ystream_skipped(self, jira_queries): + ystream_ticket = { + "key": "RHEL-200000", + "fields": { + "summary": "CVE-2025-11111 for RHEL 9.8 - golang update", + "description": "Feature release update", + "status": {"name": "Done"}, + "labels": ["CVE"], + }, + } + info = jira_queries.extract_golang_cve_info(ystream_ticket) + assert info is None + + def test_extract_golang_cve_info_no_cve(self, jira_queries): + ticket = { + "key": "RHEL-111111", + "fields": { + "summary": "Regular golang update for RHEL 9.7.z", + "description": "No CVE mentioned", + "status": {"name": "New"}, + }, + } + info = jira_queries.extract_golang_cve_info(ticket) + assert info is None + + @patch.object(GolangJiraQueries, "search_issues") + def test_find_golang_cve_tickets(self, mock_search, jira_queries, sample_golang_ticket): + mock_search.return_value = [sample_golang_ticket] + tickets = jira_queries.find_golang_cve_tickets() + assert len(tickets) == 1 + assert tickets[0]["key"] == "RHEL-158645" + mock_search.assert_called_once() + + @patch.object(GolangJiraQueries, "search_issues") + def test_find_dependent_tickets(self, mock_search, jira_queries): + component_ticket = { + "key": "RHEL-149580", + "fields": { + "summary": "CVE-2025-12345 for RHEL 9.7.z - buildah rebuild", + "components": [{"name": "buildah"}], + }, + } + mock_search.return_value = [component_ticket] + tickets = jira_queries.find_dependent_tickets("CVE-2025-12345", "rhel-9.7.z") + assert len(tickets) == 1 + assert tickets[0]["key"] == "RHEL-149580" + + def test_get_issue(self, jira_queries): + mock_issue = MagicMock() + mock_issue.key = "RHEL-158645" + mock_issue.id = "12345" + mock_issue.fields.summary = "Test summary" + mock_issue.fields.description = "Test description" + mock_issue.fields.status.name = "New" + mock_issue.fields.labels = ["CVE"] + mock_issue.fields.components = [] + + jira_queries.jira.issue.return_value = mock_issue + + result = jira_queries.get_issue("RHEL-158645") + assert result["key"] == "RHEL-158645" + assert result["fields"]["summary"] == "Test summary" diff --git a/ymir/agents/golang_rebuild/tests/test_specfile.py b/ymir/agents/golang_rebuild/tests/test_specfile.py new file mode 100644 index 00000000..e7dbd625 --- /dev/null +++ b/ymir/agents/golang_rebuild/tests/test_specfile.py @@ -0,0 +1,188 @@ +""" +Unit tests for Spec File Parser +""" + +import pytest + +from ymir.agents.golang_rebuild.specfile import SpecFile, bump_spec_for_golang_rebuild + + +@pytest.fixture +def sample_spec_content(): + return """Name: buildah +Version: 1.33.13 +Release: 3%{?dist} +Epoch: 2 +Summary: Container image builder + +%description +Buildah is a tool for building OCI container images. + +%changelog +* Mon Apr 15 2026 Previous Author - 2:1.33.13-3 +- Previous change +- Resolves: RHEL-100000 +""" + + +@pytest.fixture +def spec_file(tmp_path, sample_spec_content): + spec_path = tmp_path / "buildah.spec" + spec_path.write_text(sample_spec_content) + return SpecFile(spec_path) + + +class TestSpecFile: + def test_get_name(self, spec_file): + assert spec_file.get_name() == "buildah" + + def test_get_version(self, spec_file): + assert spec_file.get_version() == "1.33.13" + + def test_get_epoch(self, spec_file): + assert spec_file.get_epoch() == "2" + + def test_get_release(self, spec_file): + assert spec_file.get_release() == "3%{?dist}" + + def test_get_nvr(self, spec_file): + name, version, release = spec_file.get_nvr() + assert name == "buildah" + assert version == "1.33.13" + assert release == "3%{?dist}" + + def test_get_full_nvr(self, spec_file): + nvr = spec_file.get_full_nvr() + assert nvr == "2:buildah-1.33.13-3%{?dist}" + + def test_bump_release_first_time(self, spec_file): + old, new = spec_file.bump_release() + assert old == "3%{?dist}" + assert new == "3%{?dist}.1" + assert spec_file.get_release() == "3%{?dist}.1" + + def test_bump_release_increment_minor(self, tmp_path): + content = "Name: buildah\nVersion: 1.33.13\nRelease: 3%{?dist}.1\n\n%changelog\n* Test entry\n" + spec_path = tmp_path / "buildah.spec" + spec_path.write_text(content) + spec = SpecFile(spec_path) + old, new = spec.bump_release() + assert old == "3%{?dist}.1" + assert new == "3%{?dist}.2" + + def test_bump_release_no_dist_macro(self, tmp_path): + content = "Name: test\nVersion: 1.0\nRelease: 5\n\n%changelog\n" + spec_path = tmp_path / "test.spec" + spec_path.write_text(content) + spec = SpecFile(spec_path) + old, new = spec.bump_release() + assert old == "5" + assert new == "5.1" + + def test_find_changelog_line(self, spec_file): + idx = spec_file.find_changelog_line() + assert spec_file.lines[idx] == "%changelog" + + def test_add_changelog_entry(self, spec_file): + spec_file.add_changelog_entry( + golang_version="1.25.8", + cves=["CVE-2025-12345", "CVE-2025-67890"], + jiras=["RHEL-158645", "RHEL-149580"], + author_name="Test User", + author_email="test@redhat.com", + ) + latest = spec_file.get_latest_changelog_entry() + assert "Test User " in latest + assert "2:buildah-1.33.13-3%{?dist}" in latest + assert "Rebuilding with new golang 1.25.8" in latest + assert "CVE-2025-12345" in latest + assert "RHEL-149580" in latest + + def test_validate_spec_valid(self, spec_file): + errors = spec_file.validate_spec() + assert len(errors) == 0 + + def test_validate_spec_missing_fields(self, tmp_path): + content = "Summary: Invalid spec\n%description\nMissing required fields" + spec_path = tmp_path / "invalid.spec" + spec_path.write_text(content) + spec = SpecFile(spec_path) + errors = spec.validate_spec() + assert "Name: field not found" in errors + assert "Version: field not found" in errors + assert "Release: field not found" in errors + assert "%changelog section not found" in errors + + def test_find_spec_file_single(self, tmp_path): + spec_path = tmp_path / "test.spec" + spec_path.write_text("Name: test") + found = SpecFile.find_spec_file(tmp_path) + assert found == spec_path + + def test_find_spec_file_multiple_error(self, tmp_path): + (tmp_path / "test1.spec").write_text("Name: test1") + (tmp_path / "test2.spec").write_text("Name: test2") + with pytest.raises(ValueError, match=r"Multiple \.spec files"): + SpecFile.find_spec_file(tmp_path) + + def test_find_spec_file_none(self, tmp_path): + found = SpecFile.find_spec_file(tmp_path) + assert found is None + + def test_update_commit0(self, tmp_path): + content = """%global commit0 aabbccdd11223344 +Name: buildah +Version: 1.33.13 +Release: 3%{?dist} + +%changelog +""" + spec_path = tmp_path / "buildah.spec" + spec_path.write_text(content) + spec = SpecFile(spec_path) + + old = spec.update_commit0("ff00ff00ff00ff00") + assert old == "aabbccdd11223344" + # Verify the line was updated + assert "%global commit0 ff00ff00ff00ff00" in "\n".join(spec.lines) + + def test_update_commit0_not_found(self, tmp_path): + content = "Name: test\nVersion: 1.0\nRelease: 1\n\n%changelog\n" + spec_path = tmp_path / "test.spec" + spec_path.write_text(content) + spec = SpecFile(spec_path) + assert spec.update_commit0("abc123") is None + + def test_custom_message_in_changelog(self, tmp_path, sample_spec_content): + spec_path = tmp_path / "buildah.spec" + spec_path.write_text(sample_spec_content) + spec = SpecFile(spec_path) + spec.add_changelog_entry( + golang_version="1.25.8", + cves=["CVE-2025-12345"], + jiras=["RHEL-149580"], + author_name="Test User", + author_email="test@redhat.com", + custom_message="Custom rebuild reason for net/http vulnerability", + ) + latest = spec.get_latest_changelog_entry() + assert "Custom rebuild reason for net/http vulnerability" in latest + assert "Rebuilding with new golang" not in latest + + def test_bump_spec_for_golang_rebuild(self, tmp_path, sample_spec_content): + spec_path = tmp_path / "buildah.spec" + spec_path.write_text(sample_spec_content) + old, new = bump_spec_for_golang_rebuild( + spec_path=spec_path, + golang_version="1.25.8", + cves=["CVE-2025-12345"], + jiras=["RHEL-149580"], + author_name="Test User", + author_email="test@redhat.com", + ) + assert old == "3%{?dist}" + assert new == "3%{?dist}.1" + spec = SpecFile(spec_path) + assert spec.get_release() == "3%{?dist}.1" + latest = spec.get_latest_changelog_entry() + assert "Rebuilding with new golang 1.25.8" in latest diff --git a/ymir/agents/golang_rebuild/tests/test_workflow.py b/ymir/agents/golang_rebuild/tests/test_workflow.py new file mode 100644 index 00000000..ec706a02 --- /dev/null +++ b/ymir/agents/golang_rebuild/tests/test_workflow.py @@ -0,0 +1,175 @@ +""" +Unit tests for Workflow Orchestrator (async) +""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from ymir.agents.golang_rebuild.models import ( + ComponentRebuildInfo, + GolangCVEInfo, + RebuildStatus, +) +from ymir.agents.golang_rebuild.workflow import GolangRebuildWorkflow + + +@pytest.fixture +def workflow(): + """Create workflow with mocked dependencies (no real credentials needed).""" + with ( + patch("ymir.agents.golang_rebuild.workflow.load_golang_config") as mock_config, + patch("ymir.agents.golang_rebuild.workflow.GolangJiraQueries"), + patch("ymir.agents.golang_rebuild.workflow.GitClient"), + patch("ymir.agents.golang_rebuild.workflow.BrewClient"), + ): + mock_config.return_value = { + "user": {"name": "Test User", "email": "test@redhat.com"}, + "workspace": {"base_path": "/tmp/RHEL"}, + "rhel_versions": { + "9.7.z": { + "branch": "rhel-9.7.0", + "build_target": "rhel-9.7.0-candidate", + } + }, + "component_filter": {"enabled": False}, + } + return GolangRebuildWorkflow(dry_run=True) + + +@pytest.fixture +def sample_cve_info(): + return GolangCVEInfo( + ticket_key="RHEL-158645", + cve_ids=["CVE-2025-12345", "CVE-2025-67890"], + rhel_version="rhel-9.7.z", + golang_version="1.25.8", + status="Release Pending", + is_zstream=True, + ) + + +@pytest.fixture +def sample_component_info(): + return ComponentRebuildInfo( + component="buildah", + ticket_key="RHEL-149580", + rhel_version="rhel-9.7.z", + cve_ids=["CVE-2025-12345"], + golang_version="1.25.8", + branch="rhel-9.7.0", + build_target="rhel-9.7.0-candidate", + ) + + +class TestGolangRebuildWorkflow: + @pytest.mark.asyncio + async def test_process_golang_cve_ticket(self, workflow, sample_cve_info): + workflow.jira_queries.get_issue.return_value = { + "key": "RHEL-158645", + "fields": {"summary": "CVE-2025-12345 for RHEL 9.7.z"}, + } + workflow.jira_queries.extract_golang_cve_info.return_value = sample_cve_info + + with ( + patch.object(workflow, "_find_all_dependent_tickets") as mock_find, + patch.object(workflow, "process_component_rebuild") as mock_process, + ): + mock_find.return_value = [ + { + "key": "RHEL-149580", + "fields": { + "components": [{"name": "buildah"}], + "summary": "CVE-2025-12345 - buildah", + }, + }, + ] + mock_process.return_value = True + + summary = await workflow.process_golang_cve_ticket("RHEL-158645") + + assert summary.golang_ticket == "RHEL-158645" + assert summary.components_processed == 1 + assert summary.components_succeeded == 1 + + @pytest.mark.asyncio + async def test_process_component_rebuild(self, workflow, sample_component_info): + with ( + patch.object(workflow, "_process_rebuild") as mock_rebuild, + patch("ymir.agents.golang_rebuild.workflow.get_rhel_version_config") as mock_vc, + patch("ymir.agents.golang_rebuild.workflow._import_tasks") as mock_tasks, + ): + mock_vc.return_value = { + "branch": "rhel-9.7.0", + "build_target": "rhel-9.7.0-candidate", + } + mock_rebuild.return_value = True + mock_tasks.return_value = MagicMock() + + success = await workflow.process_component_rebuild(sample_component_info) + assert success is True + mock_rebuild.assert_called_once() + + @pytest.mark.asyncio + async def test_process_rebuild_spec_error(self, workflow, sample_component_info, tmp_path): + with ( + patch("ymir.agents.golang_rebuild.workflow._import_tasks") as mock_tasks_fn, + patch("ymir.agents.golang_rebuild.workflow.SpecFile") as mock_spec_cls, + ): + mock_tasks = MagicMock() + mock_tasks.fork_and_prepare_dist_git = AsyncMock( + return_value=(tmp_path, "branch", "fork_url", None) + ) + mock_tasks_fn.return_value = mock_tasks + mock_spec_cls.find_spec_file.return_value = None + + workflow.gateway_tools = [MagicMock()] + workflow.jira_queries.get_issue_comments = Mock(return_value=[]) + + success = await workflow._process_rebuild(sample_component_info) + assert success is False + assert sample_component_info.status == RebuildStatus.FAILED + assert "No .spec file found" in sample_component_info.error_message + + def test_extract_component_name_from_components(self, workflow): + issue = { + "key": "RHEL-149580", + "fields": {"components": [{"name": "buildah"}], "summary": "Some summary"}, + } + assert workflow._extract_component_name(issue) == "buildah" + + def test_extract_component_name_from_summary(self, workflow): + issue = { + "key": "RHEL-149580", + "fields": {"components": [], "summary": "CVE-2025-12345 - podman rebuild"}, + } + assert workflow._extract_component_name(issue) == "podman" + + def test_find_all_dependent_tickets_dedupe(self, workflow, sample_cve_info): + workflow.jira_queries.find_dependent_tickets = Mock( + side_effect=[ + [{"key": "RHEL-149580"}, {"key": "RHEL-147034"}], + [{"key": "RHEL-149580"}, {"key": "RHEL-150000"}], + ] + ) + tickets = workflow._find_all_dependent_tickets(sample_cve_info) + assert len(tickets) == 3 + keys = [t["key"] for t in tickets] + assert "RHEL-149580" in keys + assert "RHEL-147034" in keys + assert "RHEL-150000" in keys + + @pytest.mark.asyncio + async def test_process_queue(self, workflow): + workflow.jira_queries.find_golang_cve_tickets = Mock( + return_value=[{"key": "RHEL-158645"}, {"key": "RHEL-158646"}] + ) + with patch.object(workflow, "process_golang_cve_ticket") as mock_process: + mock_process.return_value = Mock( + golang_ticket="RHEL-158645", + components_succeeded=1, + components_failed=0, + ) + summaries = await workflow.process_queue(max_tickets=2) + assert len(summaries) == 2 + assert mock_process.call_count == 2 diff --git a/ymir/agents/golang_rebuild/utils.py b/ymir/agents/golang_rebuild/utils.py new file mode 100644 index 00000000..1466ab9c --- /dev/null +++ b/ymir/agents/golang_rebuild/utils.py @@ -0,0 +1,98 @@ +""" +Golang rebuild utility functions. + +Infrastructure utilities (auth, Redis, config) are in common/utils.py. +This module contains only domain-specific helpers for golang rebuilds. +""" + +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +import yaml + + +def extract_cves_from_text(text: str) -> list[str]: + """Extract CVE IDs from text (e.g., ["CVE-2025-12345"]).""" + cves = re.findall(r"CVE-\d{4}-\d{4,7}", text, re.IGNORECASE) + return list({cve.upper() for cve in cves}) + + +def extract_rhel_version_from_text(text: str) -> str | None: + """Extract RHEL version from text (e.g., "rhel-9.7.z").""" + patterns = [ + r"rhel-(\d+)\.(\d+)(?:\.0)?(\.z)", + r"(?:^|\s)(\d+)\.(\d+)(\.z)", + ] + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match and len(match.groups()) == 3: + major, minor, zstream = match.groups() + return f"rhel-{major}.{minor}{zstream}" + return None + + +def format_cve_list(cves: list[str]) -> str: + """Format list of CVEs as space-separated string.""" + return " ".join(sorted(cves)) + + +def format_jira_list(jiras: list[str]) -> str: + """Format list of Jira keys as space-separated string.""" + return " ".join(sorted(jiras)) + + +def format_date_for_changelog() -> str: + """Format current date for RPM changelog (e.g., 'Mon Apr 29 2026').""" + return datetime.now().strftime("%a %b %d %Y") + + +def load_golang_config(config_path: str | None = None) -> dict[str, Any]: + """ + Load golang rebuild configuration from YAML file. + + Args: + config_path: Path to config.yaml. If None, tries default locations. + + Returns: + Configuration dictionary + """ + if config_path is None: + possible_paths = [ + Path("config.yaml"), + Path(__file__).parent / "config.yaml", + Path.home() / ".config" / "golang-rebuild" / "config.yaml", + ] + for path in possible_paths: + if path.exists(): + config_path = str(path) + break + else: + raise FileNotFoundError( + f"Golang rebuild config not found. Searched: {[str(p) for p in possible_paths]}" + ) + + with open(config_path) as f: + return yaml.safe_load(f) + + +def get_rhel_version_config(config: dict, rhel_version: str) -> dict[str, Any]: + """Get configuration for a specific RHEL version.""" + rhel_versions = config.get("rhel_versions", {}) + if rhel_version in rhel_versions: + return rhel_versions[rhel_version] + + normalized = rhel_version.lower().replace("rhel-", "") + for key, value in rhel_versions.items(): + if key.lower().replace("rhel-", "") == normalized: + return value + + raise KeyError(f"RHEL version '{rhel_version}' not found in configuration") + + +def get_workspace_path(config: dict, component: str, rhel_version: str) -> Path: + """Get workspace path for a component repository.""" + base_path = config.get("workspace", {}).get("base_path", "/tmp/golang-rebuilds") + version_str = rhel_version.lower().replace("rhel-", "").replace(".z", "") + return Path(base_path) / component / version_str / component diff --git a/ymir/agents/golang_rebuild/workflow.py b/ymir/agents/golang_rebuild/workflow.py new file mode 100644 index 00000000..27755cef --- /dev/null +++ b/ymir/agents/golang_rebuild/workflow.py @@ -0,0 +1,714 @@ +""" +Workflow Orchestrator for Golang CVE Rebuilds (async) + +Coordinates Jira ticket processing, repository operations, spec file +modifications, build execution, and status updates. + +Supports Brew build workflow (RHEL 8/9) and GitLab MR workflow (RHEL 10+). + +For Jira write operations, uses agents/tasks.py MCP gateway helpers. +For Jira read operations, uses jira_queries.py (direct JIRA library). +""" + +import asyncio +import logging +import os +import traceback +from datetime import datetime +from pathlib import Path + +from ymir.agents.golang_rebuild.brew_client import BrewClient +from ymir.agents.golang_rebuild.constants import ( + AGENT_EMAIL, + AGENT_NAME, + GOLANG_COMPONENTS, + GOLANG_CVE_FIXED_STATUSES, + RebuildStatus, +) +from ymir.agents.golang_rebuild.git_client import GitClient +from ymir.agents.golang_rebuild.jira_queries import GolangJiraQueries +from ymir.agents.golang_rebuild.models import ( + ComponentRebuildInfo, + GolangCVEInfo, + GolangRebuildData, + RebuildSummary, +) +from ymir.agents.golang_rebuild.specfile import SpecFile, bump_spec_for_golang_rebuild +from ymir.agents.golang_rebuild.utils import ( + get_rhel_version_config, + get_workspace_path, + load_golang_config, +) +from ymir.common.constants import ( + GOLANG_REBUILD_QUEUE_LABEL, + JiraLabels, + RedisQueues, +) +from ymir.common.version_utils import parse_rhel_version + + +def _import_tasks(): + """Lazy import of ymir.agents.tasks to avoid pulling in beeai_framework at module level.""" + import ymir.agents.tasks as tasks + + return tasks + + +def _import_queue_deps(): + """Lazy import of queue/Redis dependencies.""" + from ymir.common.base_utils import fix_await, redis_client + from ymir.common.models import ErrorData, Task + + return Task, ErrorData, redis_client, fix_await + + +logger = logging.getLogger(__name__) + + +class GolangRebuildWorkflow: + """ + Main workflow orchestrator for golang CVE rebuilds (async). + + Uses MCP gateway for Jira writes, direct JIRA library for reads. + """ + + def __init__( + self, + config_path: str | None = None, + dry_run: bool = False, + gateway_tools: list | None = None, + ): + self.config = load_golang_config(config_path) + self.dry_run = dry_run + self.gateway_tools = gateway_tools + + # Initialize clients + self.jira_queries = GolangJiraQueries(config=self.config) + self.git_client = GitClient(config=self.config) + self.brew_client = BrewClient(config=self.config) + + # User info + # Agent identity for changelog/commit (not user-specific) + self.agent_name = AGENT_NAME + self.agent_email = AGENT_EMAIL + + logger.info(f"Initialized workflow (dry_run={dry_run})") + + async def process_golang_cve_ticket(self, ticket_key: str) -> RebuildSummary: + """Process a single Golang CVE ticket and rebuild all dependent components.""" + logger.info(f"Processing Golang CVE ticket: {ticket_key}") + + summary = RebuildSummary( + golang_ticket=ticket_key, + started_at=datetime.utcnow(), + ) + + try: + issue = self.jira_queries.get_issue(ticket_key) + cve_info = self.jira_queries.extract_golang_cve_info(issue) + if not cve_info: + logger.error(f"Failed to extract CVE info from {ticket_key}") + summary.components_skipped += 1 + return summary + + # Check if Golang CVE is fixed + if cve_info.status not in GOLANG_CVE_FIXED_STATUSES: + if not self.dry_run: + logger.warning(f"Golang CVE {ticket_key} not fixed yet (status: {cve_info.status})") + summary.components_skipped += 1 + return summary + logger.warning( + f"DRY RUN: Golang CVE {ticket_key} status is '{cve_info.status}', proceeding for testing" + ) + else: + logger.info(f"Golang CVE {ticket_key} is fixed (status: {cve_info.status})") + + # Validate z-stream + parsed = parse_rhel_version(cve_info.rhel_version) + if not parsed or not parsed[2]: + logger.warning(f"Skipping non-z-stream ticket: {ticket_key} ({cve_info.rhel_version})") + summary.components_skipped += 1 + return summary + + logger.info(f"Processing {ticket_key}: {len(cve_info.cve_ids)} CVEs for {cve_info.rhel_version}") + + # Find dependent component tickets + component_tickets = self._find_all_dependent_tickets(cve_info) + logger.info(f"Found {len(component_tickets)} dependent component tickets") + + for component_issue in component_tickets: + component_key = component_issue.get("key") + component_name = self._extract_component_name(component_issue) + + if not component_name: + logger.warning(f"Could not determine component name for {component_key}") + summary.components_skipped += 1 + continue + + if not self._is_component_allowed(component_name): + logger.info(f"Skipping {component_name} ({component_key}) - not in allowed list") + summary.components_skipped += 1 + continue + + logger.info(f"Processing component: {component_name} ({component_key})") + + rebuild_info = ComponentRebuildInfo( + component=component_name, + ticket_key=component_key, + rhel_version=cve_info.rhel_version, + cve_ids=cve_info.cve_ids, + golang_version=cve_info.golang_version, + ) + + success = await self.process_component_rebuild(rebuild_info, cve_info.ticket_key) + + summary.components_processed += 1 + summary.add_result( + component=component_name, + success=success, + message=rebuild_info.error_message or "Success", + scratch_task_id=rebuild_info.scratch_task_id, + scratch_nvr=rebuild_info.scratch_nvr, + ) + + except Exception as e: + logger.exception(f"Error processing {ticket_key}") + summary.add_result(component="workflow", success=False, message=str(e)) + + summary.completed_at = datetime.utcnow() + return summary + + async def process_component_rebuild( + self, rebuild_info: ComponentRebuildInfo, golang_ticket: str | None = None + ) -> bool: + """Process rebuild for a single component.""" + logger.info(f"Processing rebuild for {rebuild_info.component}") + + try: + version_config = get_rhel_version_config(self.config, rebuild_info.rhel_version) + rebuild_info.branch = version_config.get("branch") + rebuild_info.build_target = version_config.get("build_target") + + # Only RHEL 9.x and 10.x z-streams supported (RHEL 8 dropped) + parsed = parse_rhel_version(rebuild_info.rhel_version) + if parsed and int(parsed[0]) < 9: + logger.warning(f"RHEL {parsed[0]}.x not supported, skipping {rebuild_info.ticket_key}") + rebuild_info.error_message = f"RHEL {parsed[0]}.x not supported (only 9.x and 10.x)" + return False + + tasks = _import_tasks() + + # Mark ticket as in progress via MCP + if not self.dry_run: + await tasks.set_jira_labels( + jira_issue=rebuild_info.ticket_key, + labels_to_add=[JiraLabels.GOLANG_REBUILD_IN_PROGRESS.value], + labels_to_remove=[JiraLabels.GOLANG_REBUILD_TRIAGED.value], + ) + + success = await self._process_rebuild(rebuild_info) + + # Update labels based on result + if not self.dry_run: + if success: + await tasks.set_jira_labels( + jira_issue=rebuild_info.ticket_key, + labels_to_add=[JiraLabels.GOLANG_REBUILD_COMPLETED.value], + labels_to_remove=[ + JiraLabels.GOLANG_REBUILD_IN_PROGRESS.value, + GOLANG_REBUILD_QUEUE_LABEL, + ], + ) + else: + await tasks.set_jira_labels( + jira_issue=rebuild_info.ticket_key, + labels_to_add=[JiraLabels.GOLANG_REBUILD_FAILED.value], + labels_to_remove=[JiraLabels.GOLANG_REBUILD_IN_PROGRESS.value], + ) + + return success + + except Exception as e: + logger.exception(f"Error processing component {rebuild_info.component}") + rebuild_info.status = RebuildStatus.ERRORED + rebuild_info.error_message = str(e) + if not self.dry_run: + await tasks.set_jira_labels( + jira_issue=rebuild_info.ticket_key, + labels_to_add=[JiraLabels.GOLANG_REBUILD_ERRORED.value], + labels_to_remove=[JiraLabels.GOLANG_REBUILD_IN_PROGRESS.value], + ) + return False + + async def _process_rebuild(self, rebuild_info: ComponentRebuildInfo) -> bool: + """ + Unified rebuild workflow for RHEL 9.x and 10.x z-streams. + + Steps: + 1. Read Jira comment for build instructions (side-tag, commit, extra jiras, message) + 2. Fork dist-git repo via MCP (GitLab fork, same as jotnar-se) + 3. Bump spec file (release + changelog with all jiras and custom message) + 4. If commit hash: update %global commit0, spectool -g, rhpkg new-sources + 5. Scratch build (rhpkg scratch-build --srpm, with side-tag if provided) + 6. STOP — post scratch result to Jira, wait for golang-rebuild-approved label + 7. On approval: commit, push to fork, open GitLab MR for review + 8. Official build happens when MR is merged (via GitLab pipeline) + """ + logger.info(f"Starting rebuild workflow for {rebuild_info.component} ({rebuild_info.rhel_version})") + tasks = _import_tasks() + from ymir.agents.golang_rebuild.comment_parser import parse_recent_comments + from ymir.agents.golang_rebuild.utils import format_cve_list, format_jira_list + + try: + if not self.gateway_tools: + raise ValueError( + "Rebuild workflow requires MCP gateway tools. Set MCP_GATEWAY_URL environment variable." + ) + + # Step 1: Read build instructions from Jira comments + comments = self.jira_queries.get_issue_comments(rebuild_info.ticket_key) + instructions = parse_recent_comments(comments) + + # Determine build target and release flag (for side-tag) + build_target = rebuild_info.build_target + release_flag = None + if instructions and instructions.has_side_tag: + build_target = instructions.side_tag + release_flag = instructions.release + logger.info(f"Using side-tag: {build_target} (release: {release_flag})") + + # Collect all Jira IDs for changelog/commit + all_jiras = [rebuild_info.ticket_key] + if instructions and instructions.additional_jiras: + for jira_id in instructions.additional_jiras: + if jira_id not in all_jiras: + all_jiras.append(jira_id) + logger.info(f"Jira IDs for changelog: {all_jiras}") + + custom_message = instructions.custom_message if instructions else None + commit_hash = instructions.commit if instructions else None + + # Step 2: Fork and prepare dist-git via MCP (GitLab fork) + local_clone, update_branch, fork_url, _ = await tasks.fork_and_prepare_dist_git( + jira_issue=rebuild_info.ticket_key, + package=rebuild_info.component, + dist_git_branch=rebuild_info.branch, + available_tools=self.gateway_tools, + ) + rebuild_info.repo_path = str(local_clone) + rebuild_info.fork_url = fork_url + + # Step 3: Find and bump spec file + spec_file = SpecFile.find_spec_file(local_clone) + if not spec_file: + raise FileNotFoundError(f"No .spec file found in {local_clone}") + + old_release, new_release = bump_spec_for_golang_rebuild( + spec_path=spec_file, + golang_version=rebuild_info.golang_version, + cves=rebuild_info.cve_ids, + jiras=all_jiras, + author_name=self.agent_name, + author_email=self.agent_email, + commit_hash=commit_hash, + custom_message=custom_message, + ) + logger.info(f"Bumped release: {old_release} -> {new_release}") + + # Step 4: If commit hash provided, download and upload new sources + if commit_hash: + logger.info(f"Updating sources for commit {commit_hash[:12]}...") + await self.git_client.update_sources_for_commit(local_clone, spec_file.name) + + # Step 5: Scratch build from local changes + rebuild_info.status = RebuildStatus.SCRATCH_BUILD + scratch_result = await self.brew_client.build_and_wait( + repo_path=local_clone, + target=build_target, + scratch=True, + release=release_flag, + ) + rebuild_info.scratch_task_id = scratch_result.task_id + rebuild_info.scratch_nvr = scratch_result.nvr + + if not scratch_result.success: + raise ValueError(f"Scratch build failed: {scratch_result.error_message}") + + logger.info(f"Scratch build succeeded: {scratch_result.nvr}") + + # Step 6: Post scratch result and STOP — wait for approval + rebuild_info.status = RebuildStatus.SCRATCH_COMPLETE + await self._post_scratch_result_and_wait_approval(rebuild_info, build_target, release_flag) + + # Step 7: Approved — stage, commit, push to fork, open GitLab MR + files_to_stage = [spec_file.name] + if commit_hash: + files_to_stage.append("sources") + await tasks.stage_changes(local_clone, files_to_stage) + + msg_description = custom_message or f"Rebuilding with new golang {rebuild_info.golang_version}" + commit_msg = ( + f"{msg_description}\n" + f"Fixes: {format_cve_list(rebuild_info.cve_ids)}\n" + f"Resolves: {format_jira_list(all_jiras)}\n\n" + f"Signed-off-by: {self.agent_name} <{self.agent_email}>" + ) + mr_title = f"Rebuild {rebuild_info.component} for golang CVE fix" + brew_url = ( + f"https://brewweb.engineering.redhat.com/brew/taskinfo?taskID={rebuild_info.scratch_task_id}" + ) + mr_description = ( + f"{msg_description}\n\n" + f"Scratch build: {rebuild_info.scratch_nvr} ([Brew]({brew_url}))\n\n" + f"CVEs: {format_cve_list(rebuild_info.cve_ids)}\n" + f"Resolves: {format_jira_list(all_jiras)}\n" + ) + + mr_url, _newly_created = await tasks.commit_push_and_open_mr( + local_clone=local_clone, + commit_message=commit_msg, + fork_url=fork_url, + dist_git_branch=rebuild_info.branch, + update_branch=update_branch, + mr_title=mr_title, + mr_description=mr_description, + available_tools=self.gateway_tools, + commit_only=self.dry_run, + ) + + rebuild_info.mr_url = mr_url + rebuild_info.status = RebuildStatus.COMPLETED + + # Comment in Jira with MR link + if mr_url and not self.dry_run: + await tasks.comment_in_jira( + jira_issue=rebuild_info.ticket_key, + agent_type="Golang Rebuild", + comment_text=( + f"Merge request created for review:\n\n" + f"MR: {mr_url}\n" + f"Scratch Build: {rebuild_info.scratch_nvr}\n\n" + f"Official build will be triggered when MR is merged." + ), + available_tools=self.gateway_tools, + ) + + logger.info(f"MR created: {mr_url}") + return True + + except Exception as e: + logger.exception(f"Rebuild workflow failed for {rebuild_info.component}") + rebuild_info.status = RebuildStatus.FAILED + rebuild_info.error_message = str(e) + return False + + # ========================================== + # Helper Methods + # ========================================== + + def _is_component_allowed(self, component_name: str) -> bool: + """Check if a component is allowed based on config filter.""" + component_filter = self.config.get("component_filter", {}) + if not component_filter.get("enabled", False): + return True + allowed = component_filter.get("allowed_components", []) + if not allowed: + return True + return component_name.lower() in [c.lower() for c in allowed] + + async def _prepare_repository(self, rebuild_info: ComponentRebuildInfo) -> Path: + """Clone or update repository for component (RHEL 8/9 Brew workflow).""" + workspace = get_workspace_path(self.config, rebuild_info.component, rebuild_info.rhel_version) + repo_path = await self.git_client.clone_repository( + component=rebuild_info.component, + target_dir=workspace.parent, + branch=rebuild_info.branch, + ) + is_clean, message = await self.git_client.verify_clean_state(repo_path) + if not is_clean: + logger.warning(f"Repository not clean: {message}") + is_correct, message = await self.git_client.verify_branch(repo_path, rebuild_info.branch) + if not is_correct: + logger.warning(f"Branch mismatch: {message}") + await self.git_client.checkout_branch(repo_path, rebuild_info.branch) + return repo_path + + async def _post_scratch_result_and_wait_approval( + self, + rebuild_info: ComponentRebuildInfo, + build_target: str, + release_flag: str | None, + ): + """ + Post scratch build result to Jira and wait for golang-rebuild-approved label. + + Polls the ticket every 60 seconds for the approval label. + """ + tasks = _import_tasks() + + brew_url = ( + f"https://brewweb.engineering.redhat.com/brew/taskinfo?taskID={rebuild_info.scratch_task_id}" + ) + target_info = f"Target: {build_target}" + if release_flag: + target_info += f" (release: {release_flag})" + + approval_comment = ( + f"Scratch build completed successfully.\n\n" + f"Task ID: {rebuild_info.scratch_task_id}\n" + f"NVR: {rebuild_info.scratch_nvr}\n" + f"{target_info}\n" + f"Brew URL: {brew_url}\n\n" + f"Changes are ready locally but NOT pushed.\n" + f"To proceed with official build, add label: golang-rebuild-approved" + ) + + if self.gateway_tools: + try: + await tasks.comment_in_jira( + jira_issue=rebuild_info.ticket_key, + agent_type="Golang Rebuild", + comment_text=approval_comment, + available_tools=self.gateway_tools, + ) + except Exception as e: + logger.warning(f"Failed to post scratch result comment: {e}") + + if self.dry_run: + logger.info("DRY RUN: Skipping approval wait, would stop here.") + return + + # Poll for approval label + logger.info(f"Waiting for golang-rebuild-approved label on {rebuild_info.ticket_key}...") + approval_label = JiraLabels.GOLANG_REBUILD_APPROVED.value + poll_interval = 60 # seconds + + while True: + if self.jira_queries.check_label_exists(rebuild_info.ticket_key, approval_label): + logger.info(f"Approval label found on {rebuild_info.ticket_key}. Proceeding.") + return + logger.debug(f"No approval yet for {rebuild_info.ticket_key}, checking again in {poll_interval}s") + await asyncio.sleep(poll_interval) + + def _find_all_dependent_tickets(self, cve_info: GolangCVEInfo) -> list[dict]: + """Find all dependent component tickets for a Golang CVE.""" + all_tickets = [] + for cve_id in cve_info.cve_ids: + tickets = self.jira_queries.find_dependent_tickets( + cve_id=cve_id, rhel_version=cve_info.rhel_version + ) + all_tickets.extend(tickets) + + seen_keys = set() + unique_tickets = [] + for ticket in all_tickets: + key = ticket.get("key") + if key and key not in seen_keys: + seen_keys.add(key) + unique_tickets.append(ticket) + return unique_tickets + + def _extract_component_name(self, issue: dict) -> str | None: + """Extract component name from Jira issue.""" + fields = issue.get("fields", {}) + components = fields.get("components", []) + if components: + return components[0].get("name") + + summary = fields.get("summary", "") + for component in GOLANG_COMPONENTS: + if component.lower() in summary.lower(): + return component + return None + + async def _add_build_comment(self, rebuild_info: ComponentRebuildInfo, scratch_only: bool): + """Add build info comment to Jira via MCP.""" + tasks = _import_tasks() + if not self.gateway_tools: + return + + from ymir.agents.golang_rebuild.utils import format_cve_list + + parts = [ + "Rebuild completed for golang CVE fix.", + "", + f"Component: {rebuild_info.component}", + f"RHEL Version: {rebuild_info.rhel_version}", + f"Golang Version: {rebuild_info.golang_version}", + f"CVE(s): {format_cve_list(rebuild_info.cve_ids)}", + "", + ] + if rebuild_info.scratch_task_id: + parts.append(f"Scratch Build: {rebuild_info.scratch_task_id} ({rebuild_info.scratch_nvr})") + if not scratch_only and rebuild_info.final_task_id: + parts.append(f"Final Build: {rebuild_info.final_task_id} ({rebuild_info.final_nvr})") + parts.append("") + parts.append( + f"Brew URL: https://brewweb.engineering.redhat.com/brew/taskinfo?taskID={rebuild_info.final_task_id}" + ) + + try: + await tasks.comment_in_jira( + jira_issue=rebuild_info.ticket_key, + agent_type="Golang Rebuild", + comment_text="\n".join(parts), + available_tools=self.gateway_tools, + ) + except Exception as e: + logger.warning(f"Failed to add build comment to {rebuild_info.ticket_key}: {e}") + + async def process_queue(self, max_tickets: int | None = None) -> list[RebuildSummary]: + """Process all Golang CVE tickets in the Jira queue.""" + logger.info("Processing Golang CVE queue") + tickets = self.jira_queries.find_golang_cve_tickets() + if max_tickets: + tickets = tickets[:max_tickets] + + logger.info(f"Found {len(tickets)} tickets to process") + summaries = [] + for issue in tickets: + ticket_key = issue.get("key") + try: + summary = await self.process_golang_cve_ticket(ticket_key) + summaries.append(summary) + except Exception: + logger.exception(f"Failed to process {ticket_key}") + summaries.append(RebuildSummary(golang_ticket=ticket_key, components_failed=1)) + + return summaries + + +# ========================================== +# Entry Point (queue mode + direct mode) +# ========================================== + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + + dry_run = os.getenv("DRY_RUN", "False").lower() == "true" + config_path = os.getenv("GOLANG_REBUILD_CONFIG") + + # Direct mode: process a single ticket + if golang_ticket := os.getenv("GOLANG_TICKET"): + logger.info(f"Running in direct mode for ticket: {golang_ticket}") + + gateway_tools = None + mcp_url = os.getenv("MCP_GATEWAY_URL") + if mcp_url: + from ymir.agents.utils import mcp_tools + + async with mcp_tools(mcp_url) as tools: + workflow = GolangRebuildWorkflow( + config_path=config_path, dry_run=dry_run, gateway_tools=tools + ) + summary = await workflow.process_golang_cve_ticket(golang_ticket) + logger.info(f"Direct run completed: {summary.model_dump_json(indent=4)}") + return + + # No MCP gateway - run without Jira write support + workflow = GolangRebuildWorkflow(config_path=config_path, dry_run=dry_run) + summary = await workflow.process_golang_cve_ticket(golang_ticket) + logger.info(f"Direct run completed: {summary.model_dump_json(indent=4)}") + return + + # Queue mode: listen on Redis + logger.info("Starting golang rebuild agent in queue mode") + Task, ErrorData, redis_client, fix_await = _import_queue_deps() + tasks = _import_tasks() + async with redis_client(os.environ["REDIS_URL"]) as redis: + max_retries = int(os.getenv("MAX_RETRIES", 3)) + container_version = os.getenv("CONTAINER_VERSION", "c9s") + queue = ( + RedisQueues.GOLANG_REBUILD_QUEUE_C9S.value + if container_version == "c9s" + else RedisQueues.GOLANG_REBUILD_QUEUE_C10S.value + ) + logger.info(f"Connected to Redis, listening to queue: {queue}") + + mcp_url = os.environ.get("MCP_GATEWAY_URL") + + while True: + logger.info(f"Waiting for tasks from {queue} (timeout: 30s)...") + element = await fix_await(redis.brpop([queue], timeout=30)) + if element is None: + continue + + _, payload = element + logger.info("Received task from queue.") + + try: + task = Task.model_validate_json(payload) + rebuild_data = GolangRebuildData.model_validate( + task.metadata.get("rebuild_data", task.metadata) + ) + except Exception as e: + logger.error(f"Failed to parse task from queue (malformed data): {e}") + await fix_await( + redis.lpush( + RedisQueues.ERROR_LIST.value, + ErrorData(details=f"Malformed task: {e}", jira_issue="unknown").model_dump_json(), + ) + ) + continue + + async def retry(task, error, rd=rebuild_data): + task.attempts += 1 + if task.attempts < max_retries: + logger.warning(f"Task failed (attempt {task.attempts}/{max_retries}), re-queuing") + await fix_await(redis.lpush(queue, task.model_dump_json())) + else: + logger.error(f"Task failed after {max_retries} attempts, moving to error list") + await tasks.set_jira_labels( + jira_issue=rd.golang_ticket, + labels_to_add=[JiraLabels.GOLANG_REBUILD_ERRORED.value], + labels_to_remove=[ + JiraLabels.GOLANG_REBUILD_TRIAGED.value, + GOLANG_REBUILD_QUEUE_LABEL, + ], + dry_run=dry_run, + ) + await fix_await( + redis.lpush( + RedisQueues.ERROR_LIST.value, + ErrorData(details=error, jira_issue=rd.golang_ticket).model_dump_json(), + ) + ) + + try: + gateway_tools = None + if mcp_url: + from ymir.agents.utils import mcp_tools + + async with mcp_tools(mcp_url) as tools: + gateway_tools = tools + workflow = GolangRebuildWorkflow( + config_path=config_path, + dry_run=dry_run, + gateway_tools=gateway_tools, + ) + summary = await workflow.process_golang_cve_ticket(rebuild_data.golang_ticket) + else: + workflow = GolangRebuildWorkflow(config_path=config_path, dry_run=dry_run) + summary = await workflow.process_golang_cve_ticket(rebuild_data.golang_ticket) + + if summary.components_failed == 0 and summary.components_succeeded > 0: + logger.info(f"Success for {rebuild_data.golang_ticket}") + await fix_await( + redis.lpush( + RedisQueues.GOLANG_REBUILD_COMPLETED.value, + summary.model_dump_json(), + ) + ) + else: + error_msg = f"Some components failed: {summary.components_failed}" + await retry(task, error_msg) + + except Exception as e: + error = "".join(traceback.format_exception(e)) + logger.error(f"Exception during processing: {error}") + await retry(task, error) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ymir/common/constants.py b/ymir/common/constants.py index bfdd8d7e..8028b8c5 100644 --- a/ymir/common/constants.py +++ b/ymir/common/constants.py @@ -22,6 +22,9 @@ class RedisQueues(Enum): REBASE_QUEUE = "rebase_queue" BACKPORT_QUEUE = "backport_queue" POSTPONED_LIST = "postponed_list" + GOLANG_REBUILD_QUEUE_C9S = "golang_rebuild_queue_c9s" + GOLANG_REBUILD_QUEUE_C10S = "golang_rebuild_queue_c10s" + GOLANG_REBUILD_COMPLETED = "completed_golang_rebuild_list" @classmethod def all_queues(cls) -> set[str]: @@ -39,6 +42,8 @@ def input_queues(cls) -> set[str]: cls.BACKPORT_QUEUE_C10S.value, cls.REBUILD_QUEUE_C9S.value, cls.REBUILD_QUEUE_C10S.value, + cls.GOLANG_REBUILD_QUEUE_C9S.value, + cls.GOLANG_REBUILD_QUEUE_C10S.value, cls.CLARIFICATION_NEEDED_QUEUE.value, cls.REBASE_QUEUE.value, cls.BACKPORT_QUEUE.value, @@ -53,6 +58,7 @@ def data_queues(cls) -> set[str]: cls.COMPLETED_REBASE_LIST.value, cls.COMPLETED_BACKPORT_LIST.value, cls.COMPLETED_REBUILD_LIST.value, + cls.GOLANG_REBUILD_COMPLETED.value, cls.POSTPONED_LIST.value, } @@ -77,6 +83,13 @@ def get_rebuild_queue_for_branch(cls, target_branch: str | None) -> str: return cls.REBUILD_QUEUE_C9S.value return cls.REBUILD_QUEUE_C10S.value + @classmethod + def get_golang_rebuild_queue_for_branch(cls, target_branch: str | None) -> str: + """Return appropriate golang rebuild queue based on target branch""" + if target_branch and cls._use_c9s_branch(target_branch): + return cls.GOLANG_REBUILD_QUEUE_C9S.value + return cls.GOLANG_REBUILD_QUEUE_C10S.value + @classmethod def _use_c9s_branch(cls, branch: str) -> bool: """Check if branch should use c9s container""" @@ -116,7 +129,18 @@ class JiraLabels(Enum): RETRY_NEEDED = "ymir_retry_needed" FUSA = "ymir_fusa" + # Golang rebuild labels + GOLANG_REBUILD_TRIAGED = "ymir_golang_rebuild_triaged" + GOLANG_REBUILD_IN_PROGRESS = "ymir_golang_rebuild_in_progress" + GOLANG_REBUILD_COMPLETED = "ymir_golang_rebuild_completed" + GOLANG_REBUILD_ERRORED = "ymir_golang_rebuild_errored" + GOLANG_REBUILD_FAILED = "ymir_golang_rebuild_failed" + GOLANG_REBUILD_APPROVED = "golang-rebuild-approved" + @classmethod def all_labels(cls) -> set[str]: """Return all Ymir labels for cleanup operations""" return {label.value for label in cls} + + +GOLANG_REBUILD_QUEUE_LABEL = "golang-rebuild-queue" diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index fcd86433..d3d2577d 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -162,3 +162,47 @@ async def is_older_zstream( current_minor = int(current_parsed[1]) target_minor = int(minor_str) return target_minor < current_minor + + +def get_branch_from_version(version: str) -> str | None: + """ + Get dist-git branch name from RHEL version. + + Examples: + - rhel-9.7.z -> rhel-9.7.0 + - rhel-10.1.z -> rhel-10.1.0 + """ + parsed = parse_rhel_version(version) + if parsed is None: + return None + major, minor, _ = parsed + return f"rhel-{major}.{minor}.0" + + +def get_brew_target_from_version(version: str) -> str | None: + """ + Get Brew build target from RHEL version. + + Examples: + - rhel-9.7.z -> rhel-9.7.0-candidate + - rhel-10.1.z -> rhel-10.1.0-candidate + """ + branch = get_branch_from_version(version) + if branch is None: + return None + return f"{branch}-candidate" + + +def get_short_version(version: str) -> str | None: + """ + Get short version string (e.g., for Jira queries). + + Examples: + - rhel-9.7.z -> 9.7 + - rhel-10.1.z -> 10.1 + """ + parsed = parse_rhel_version(version) + if parsed is None: + return None + major, minor, _ = parsed + return f"{major}.{minor}"