osbuild: add result error reporting for sources

This commit adds error reporting from source download errors
to the monitor. It reuses the `BuildResult` for symmetry but
we probably want to refactor this a bit to make source handling
a bit more similar to stages.
This commit is contained in:
Michael Vogt 2024-11-14 17:03:29 +01:00 committed by Achilleas Koutsou
parent 5ba7cadd8b
commit c27c32be0e
3 changed files with 122 additions and 8 deletions

View file

@ -16,9 +16,10 @@ import json
import os
import sys
import time
from typing import Dict, Optional, Set
from typing import Dict, Optional, Set, Union
import osbuild
from osbuild.pipeline import BuildResult, DownloadResult
from osbuild.util.term import fmt as vt
@ -166,7 +167,8 @@ class Progress:
def log_entry(message: Optional[str] = None,
context: Optional[Context] = None,
progress: Optional[Progress] = None,
build_result: Optional[osbuild.pipeline.BuildResult] = None) -> dict:
result: Union[BuildResult, DownloadResult, None] = None,
) -> dict:
"""
Create a single log entry dict with a given message, context, and progress objects.
All arguments are optional. A timestamp is added to the message.
@ -175,7 +177,7 @@ def log_entry(message: Optional[str] = None,
# monitors support that
return omitempty({
"message": message,
"build_result": build_result.as_dict() if build_result else None,
"result": result.as_dict() if result else None,
"context": context.as_dict() if context else None,
"progress": progress.as_dict() if progress else None,
"timestamp": time.time(),
@ -229,7 +231,7 @@ class BaseMonitor(abc.ABC):
def assembler(self, assembler: osbuild.Stage):
"""Called when an assembler is being built"""
def result(self, result: osbuild.pipeline.BuildResult):
def result(self, result: Union[BuildResult, DownloadResult]) -> None:
"""Called when a module (stage/assembler) is done with its result"""
def log(self, message: str, origin: Optional[str] = None):
@ -256,7 +258,7 @@ class LogMonitor(BaseMonitor):
super().__init__(fd, total_steps)
self.timer_start = 0
def result(self, result: osbuild.pipeline.BuildResult):
def result(self, result: Union[BuildResult, DownloadResult]) -> None:
duration = int(time.time() - self.timer_start)
self.out.write(f"\n⏱ Duration: {duration}s\n")
@ -337,7 +339,7 @@ class JSONSeqMonitor(BaseMonitor):
self.log(f"Starting module {module.name}", origin="osbuild.monitor")
# result is for modules
def result(self, result: osbuild.pipeline.BuildResult):
def result(self, result: Union[BuildResult, DownloadResult]) -> None:
# we may need to check pipeline ids here in the future
if self._progress.sub_progress:
self._progress.sub_progress.incr()
@ -349,7 +351,7 @@ class JSONSeqMonitor(BaseMonitor):
# We should probably remove the "output" key from the result
# as it is redundant, each output already generates a "log()"
# message that is streamed to the client.
build_result=result,
result=result,
))
def log(self, message, origin: Optional[str] = None):

View file

@ -55,6 +55,17 @@ class BuildResult:
return vars(self)
class DownloadResult:
def __init__(self, name: str, source_id: str, success: bool) -> None:
self.name = name
self.id = source_id
self.success = success
self.output = ""
def as_dict(self) -> Dict[str, Any]:
return vars(self)
class Stage:
def __init__(self, info, source_options, build, base, options, source_epoch):
self.info = info
@ -417,8 +428,19 @@ class Manifest:
for source in self.sources:
# Workaround for lack of progress from sources, this
# will need to be reworked later.
dr = DownloadResult(source.name, source.id, success=True)
monitor.begin(source)
source.download(mgr, store)
try:
source.download(mgr, store)
except host.RemoteError as e:
dr.success = False
dr.output = str(e)
monitor.result(dr)
raise e
monitor.result(dr)
# ideally we would make the whole of download more symmetric
# to "build_stages" and return a "results" here in "finish"
# as well
monitor.finish({"name": source.info.name})
def depsolve(self, store: ObjectStore, targets: Iterable[str]) -> List[str]:

View file

@ -10,6 +10,9 @@ import tempfile
import time
import unittest
from collections import defaultdict
from unittest.mock import Mock, patch
import pytest
import osbuild
import osbuild.meta
@ -326,3 +329,90 @@ def test_context_id():
assert ctx.id == "20bf38c0723b15c2c9a52733c99814c298628526d8b8eabf7c378101cc9a9cf3"
ctx._origin = "foo" # pylint: disable=protected-access
assert ctx.id != "00d202e4fc9d917def414d1c9f284b137287144087ec275f2d146d9d47b3c8bb"
def test_monitor_download_happy(tmp_path):
store = ObjectStore(tmp_path)
tape = TapeMonitor()
happy_source = Mock()
manifest = osbuild.Manifest()
manifest.sources = [happy_source]
manifest.download(store, tape)
assert tape.counter["begin"] == 1
assert tape.counter["finish"] == 1
assert tape.counter["result"] == 1
# no stage was run as part of the download so this is nil
assert tape.counter["stages"] == 0
def test_monitor_download_error(tmp_path):
store = ObjectStore(tmp_path)
tape = TapeMonitor()
failing_source = Mock()
failing_source.download.side_effect = osbuild.host.RemoteError("name", "value", "stack")
manifest = osbuild.Manifest()
manifest.sources = [failing_source]
# this is different from stage failures, those do not raise exceptions
with pytest.raises(osbuild.host.RemoteError):
manifest.download(store, tape)
assert tape.counter["begin"] == 1
assert tape.counter["result"] == 1
# this is different from stage failures that emit a "finish" on failure
# here
assert tape.counter["finish"] == 0
@patch.object(osbuild.sources.Source, "download")
def test_jsonseq_download_happy(_, tmp_path):
store = ObjectStore(tmp_path)
index = osbuild.meta.Index(os.curdir)
info = index.get_module_info("Source", "org.osbuild.curl")
happy_source = osbuild.sources.Source(info, {}, None)
manifest = osbuild.Manifest()
manifest.sources = [happy_source]
with tempfile.TemporaryFile() as tf:
mon = JSONSeqMonitor(tf.fileno(), 1)
manifest.download(store, mon)
tf.flush()
tf.seek(0)
log = []
for line in tf.read().decode().strip().split("\x1e"):
log.append(json.loads(line))
assert len(log) == 3
assert log[0]["message"] == "Starting pipeline source org.osbuild.curl"
assert log[1]["message"] == "Finished module source org.osbuild.curl"
assert log[1]["result"]["name"] == "source org.osbuild.curl"
assert log[1]["result"]["success"]
assert log[2]["message"] == "Finished pipeline org.osbuild.curl"
@patch.object(osbuild.sources.Source, "download")
def test_jsonseq_download_unhappy(mocked_download, tmp_path):
store = ObjectStore(tmp_path)
index = osbuild.meta.Index(os.curdir)
info = index.get_module_info("Source", "org.osbuild.curl")
failing_source = osbuild.sources.Source(info, {}, None)
mocked_download.side_effect = osbuild.host.RemoteError("RuntimeError", "curl: error download ...", "error stack")
manifest = osbuild.Manifest()
manifest.sources = [failing_source]
with tempfile.TemporaryFile() as tf:
mon = JSONSeqMonitor(tf.fileno(), 1)
with pytest.raises(osbuild.host.RemoteError):
manifest.download(store, mon)
tf.flush()
tf.seek(0)
log = []
for line in tf.read().decode().strip().split("\x1e"):
log.append(json.loads(line))
assert len(log) == 2
assert log[0]["message"] == "Starting pipeline source org.osbuild.curl"
assert log[1]["message"] == "Finished module source org.osbuild.curl"
assert log[1]["result"]["name"] == "source org.osbuild.curl"
assert log[1]["result"]["success"] is False
assert log[1]["result"]["output"] == "RuntimeError: curl: error download ...\n error stack"