Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions ymir/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,49 @@


@contextmanager
def tool_error_context(error_message: str, **additional_context):
def tool_error_context(
error_message: str,
include_exception_message_for: tuple[type[Exception], ...] = (),
**additional_context,
):
"""Context manager for unified tool error handling with observability.

Catches exceptions and wraps them as ToolErrorWithContext with a clean
LLM-facing message while preserving more specific details (exception type,
exception message and additional context) for logs and traces.

Args:
error_message: Clean error message shown to the LLM.
include_exception_message_for: Exception types whose exception messages
should be appended to the LLM-facing error_message.
**additional_context: Key-value pairs for observability, automatically
redacted for credentials.

Raises:
ToolErrorWithContext: Wraps any caught exception (except ToolErrorWithContext
which passes through unchanged) and adds provided additional context
for observability.
"""
try:
yield
except ToolErrorWithContext:
raise
except Exception as e:
if isinstance(e, include_exception_message_for):
error_message = f"{error_message}: {redact_credentials(str(e))}"

additional_context["exception"] = f"{type(e).__name__}: {e}"
redacted_additional_context = {k: redact_credentials(str(v)) for k, v in additional_context.items()}
raise ToolErrorWithContext(
error_message, cause=e, additional_context=redacted_additional_context
error_message,
cause=e,
additional_context=make_additional_context(**additional_context),
) from e


def make_additional_context(**additional_context) -> dict[str, str]:
return {k: redact_credentials(str(v)) for k, v in additional_context.items()}


class CloneableTool(Tool[TInput, TRunOptions, TOutput]):
"""Tool with clone method and built-in timeout handling"""

Expand Down
102 changes: 70 additions & 32 deletions ymir/tools/privileged/copr.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@
from beeai_framework.emitter import Emitter
from beeai_framework.tools import (
JSONToolOutput,
ToolError,
ToolRunOptions,
)
from copr.v3 import BuildProxy, ProjectChrootProxy, ProjectProxy
from copr.v3.exceptions import CoprException
from pydantic import BaseModel, Field

from ymir.common import load_rhel_config
from ymir.common.base_utils import KerberosError, init_kerberos_ticket
from ymir.common.base_utils import init_kerberos_ticket
from ymir.common.validators import AbsolutePath
from ymir.common.version_utils import parse_branch_name
from ymir.tools.base import CloneableTool as Tool
from ymir.tools.base import make_additional_context, tool_error_context
from ymir.tools.constants import AIOHTTP_TIMEOUT, YMIR_USER_AGENT
from ymir.tools.errors import ToolErrorWithContext
from ymir.tools.http import aiohttp_get_with_retries

COPR_CONFIG = {
Expand Down Expand Up @@ -124,15 +125,11 @@ async def _run(
srpm_path = tool_input.srpm_path
dist_git_branch = tool_input.dist_git_branch
jira_issue = tool_input.jira_issue
try:
with tool_error_context("Failed to initialize Kerberos ticket"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't KerberosError messages be included as well?

principal = await init_kerberos_ticket()
except KerberosError as e:
raise ToolError(f"Failed to initialize Kerberos ticket: {e}") from e
copr_user = principal.split("@", maxsplit=1)[0]
try:
with tool_error_context("Failed to read SRPM header", srpm_path=str(srpm_path)):
exclusive_arches = await self.get_exclusive_arches(srpm_path)
except Exception as e:
raise ToolError(f"Failed to read SRPM header: {e}") from e
# build for the fastest supported arch (see COPR_ARCH_PREFERENCE);
# default to x86_64 when the package has no ExclusiveArch
build_arch = next(
Expand All @@ -141,11 +138,13 @@ async def _run(
)
rhel_config = await load_rhel_config()
upcoming_z_streams = rhel_config.get("upcoming_z_streams", {})
try:
with tool_error_context(
"Failed to deduce Copr chroot",
dist_git_branch=dist_git_branch,
build_arch=build_arch,
):
chroot_base, majorver = await self.branch_to_chroot(dist_git_branch, upcoming_z_streams)
chroot = f"{chroot_base}-{build_arch}"
except ValueError as e:
raise ToolError(f"Failed to deduce Copr chroot: {e}") from e
logger.info(f"Connecting to Copr API at {COPR_CONFIG['copr_url']} for project creation/update")
project_proxy = ProjectProxy({"username": copr_user, **COPR_CONFIG})
kwargs = {
Expand All @@ -164,14 +163,30 @@ async def _run(
kwargs["chroots"] = sorted(set(project.chroot_repos.keys()) | {chroot})
await _copr_api_call(project_proxy.edit, **kwargs)
except Exception as e:
raise ToolError(f"Failed to create or update Copr project: {_copr_error_detail(e)}") from e
raise ToolErrorWithContext(
"Failed to create or update Copr project",
cause=e,
additional_context=make_additional_context(
copr_user=copr_user,
project=jira_issue,
chroot=chroot,
exception=_copr_error_detail(e),
),
) from e
if chroot.startswith("custom-"):
# make sure the chroot has access to corresponding buildroot repository
logger.info(f"Connecting to Copr API to update chroot configuration for {chroot}")
chroot_proxy = ProjectChrootProxy({"username": copr_user, **COPR_CONFIG})
bootstrap_image = f"registry.access.redhat.com/ubi{majorver}/ubi"
if not (internal_repos_host := rhel_config.get("internal_repos_host")):
raise ToolError("Internal repos host not configured")
raise ToolErrorWithContext(
"Internal repos host not configured",
additional_context=make_additional_context(
copr_user=copr_user,
project=jira_issue,
chroot=chroot,
),
)
buildroot_repo_url = urljoin(
internal_repos_host,
f"brewroot/repos/{dist_git_branch}-z-build/latest/{build_arch}",
Expand Down Expand Up @@ -207,7 +222,16 @@ async def _run(
**kwargs,
)
except Exception as e:
raise ToolError(f"Failed to update Copr chroot: {_copr_error_detail(e)}") from e
raise ToolErrorWithContext(
"Failed to update Copr chroot",
cause=e,
additional_context=make_additional_context(
copr_user=copr_user,
project=jira_issue,
chroot=chroot,
exception=_copr_error_detail(e),
),
) from e
logger.info(f"Connecting to Copr API to submit build for {srpm_path}")
build_proxy = BuildProxy({"username": copr_user, **COPR_CONFIG})
try:
Expand All @@ -218,10 +242,19 @@ async def _run(
path=str(srpm_path),
buildopts={"chroots": [chroot], "timeout": COPR_BUILD_TIMEOUT},
)
except Exception as e:
raise ToolError(f"Failed to submit Copr build: {_copr_error_detail(e)}") from e
else:
logger.info(f"{jira_issue}: build of {srpm_path} in {chroot} started: {build.id:08d}")
except Exception as e:
raise ToolErrorWithContext(
"Failed to submit Copr build",
cause=e,
additional_context=make_additional_context(
copr_user=copr_user,
project=jira_issue,
chroot=chroot,
srpm_path=str(srpm_path),
exception=_copr_error_detail(e),
),
) from e

async def get_artifacts_urls(build):
if build.source_package and (package := build.source_package.get("name")):
Expand Down Expand Up @@ -370,21 +403,26 @@ async def _run(
for url in artifacts_urls:
logger.info(f"Downloading build artifact from: {url}")
try:
async with aiohttp_get_with_retries(session, url) as response:
if response.status < 400:
target = Path(Path(urlparse(url).path).name)
content = await response.read()
if ".log" in target.suffixes:
if content.startswith(b"\x1f\x8b"):
# decompress logs on-the-fly
content = gzip.decompress(content)
if target.suffix == ".gz":
target = target.with_suffix("")
(target_path / target).write_bytes(content)
else:
raise ValueError(f"{response.status} {response.reason}")
except Exception as e:
with tool_error_context(
"Failed to download build artifact",
include_exception_message_for=(ValueError,),
artifacts_url=url,
):
async with aiohttp_get_with_retries(session, url) as response:
if response.status < 400:
target = Path(Path(urlparse(url).path).name)
content = await response.read()
if ".log" in target.suffixes:
if content.startswith(b"\x1f\x8b"):
# decompress logs on-the-fly
content = gzip.decompress(content)
if target.suffix == ".gz":
target = target.with_suffix("")
(target_path / target).write_bytes(content)
else:
raise ValueError(f"{response.status} {response.reason}")
except Exception:
# Cleanup temporary dir
rmtree(target_path)
raise ToolError(f"Failed to download {url}: {e}") from e
raise
return DownloadArtifactsToolOutput(result=DownloadArtifactsResult(target_path=target_path))
36 changes: 17 additions & 19 deletions ymir/tools/privileged/distgit.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
from pydantic import BaseModel, Field
from specfile import Specfile

from ymir.common.base_utils import KerberosError, init_kerberos_ticket
from ymir.common.base_utils import init_kerberos_ticket
from ymir.common.utils import get_latest_candidate_build, get_latest_z_pending_build
from ymir.common.version_utils import is_older_zstream, parse_zstream_branch_name
from ymir.tools.base import CloneableTool as Tool
from ymir.tools.base import tool_error_context
from ymir.tools.base import make_additional_context, tool_error_context
from ymir.tools.errors import ToolErrorWithContext
from ymir.tools.privileged.utils import sanitize_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -183,11 +184,8 @@ async def _run(
) -> StringToolOutput:
package = tool_input.package
branch = tool_input.branch
try:
with tool_error_context("Failed to initialize Kerberos ticket"):
principal = await init_kerberos_ticket()
except KerberosError as e:
logger.error("Kerberos initialization failed: %s", e)
raise ToolError("Failed to initialize Kerberos ticket") from e
username = principal.split("@", maxsplit=1)[0]
token = os.environ["GITLAB_TOKEN"]
gitlab_repo_url = f"https://oauth2:{token}@gitlab.com/redhat/rhel/rpms/{package}"
Expand All @@ -200,7 +198,12 @@ async def _run(
result=f"Z-Stream branch {branch} already exists, no need to create it"
)
with (
tool_error_context("Failed to create Z-Stream branch", package=package, branch=branch),
tool_error_context(
"Failed to create Z-Stream branch",
include_exception_message_for=(ToolError,),
package=package,
branch=branch,
),
tempfile.TemporaryDirectory() as path,
):
# Username is taken from the Kerberos principal and embedded in
Expand Down Expand Up @@ -257,23 +260,18 @@ async def _clone():
return StringToolOutput(result=f"Successfully created Z-Stream branch {branch}")
except git.exc.GitCommandError as e:
if not _is_transient_git_error(e):
logger.error(
"Failed to poll GitLab mirror for %s/%s: %s",
package,
branch,
sanitize_url(str(e)),
)
raise ToolError("Failed to poll GitLab mirror") from e
logger.warning(f"Transient error polling GitLab mirror sync: {sanitize_url(str(e))}")
elapsed = int(time.monotonic() - start_time)
logger.info(
f"Waiting for GitLab mirror sync of {package} branch {branch} ({elapsed}s elapsed)"
)
await asyncio.sleep(30)
logger.error(
"GitLab mirror sync timed out for %s branch %s after %ds",
package,
branch,
SYNC_TIMEOUT,
raise ToolErrorWithContext(
"GitLab mirror sync timed out",
additional_context=make_additional_context(
package=package,
branch=branch,
timeout=SYNC_TIMEOUT,
),
)
raise ToolError("GitLab mirror sync timed out")
Loading
Loading