-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
connectors-ci: improve pytest result evaluation #28767
Changes from 5 commits
ddb7345
9ae813c
10a144f
650793f
e3eae0d
27a9b69
01acbd8
b188ded
7f7f5b0
e24454d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
name: Airbyte CI pipeline tests | ||
|
||
on: | ||
push: | ||
branches: | ||
- !master | ||
paths: | ||
- "airbyte-ci/" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,11 +18,10 @@ | |
import anyio | ||
import asyncer | ||
from anyio import Path | ||
from pipelines import sentry_utils | ||
|
||
from connector_ops.utils import console | ||
from dagger import Container, DaggerError, QueryError | ||
from jinja2 import Environment, PackageLoader, select_autoescape | ||
from pipelines import sentry_utils | ||
from pipelines.actions import remote_storage | ||
from pipelines.consts import GCS_PUBLIC_DOMAIN, LOCAL_REPORTS_PATH_ROOT, PYPROJECT_TOML_FILE_PATH | ||
from pipelines.utils import check_path_in_workdir, format_duration, get_exec_result, slugify | ||
|
@@ -56,26 +55,6 @@ class StepStatus(Enum): | |
FAILURE = "Failed" | ||
SKIPPED = "Skipped" | ||
|
||
def from_exit_code(exit_code: int) -> StepStatus: | ||
"""Map an exit code to a step status. | ||
|
||
Args: | ||
exit_code (int): A process exit code. | ||
|
||
Raises: | ||
ValueError: Raised if the exit code is not mapped to a step status. | ||
|
||
Returns: | ||
StepStatus: The step status inferred from the exit code. | ||
""" | ||
if exit_code == 0: | ||
return StepStatus.SUCCESS | ||
# pytest returns a 5 exit code when no test is found. | ||
elif exit_code == 5: | ||
return StepStatus.SKIPPED | ||
else: | ||
return StepStatus.FAILURE | ||
|
||
def get_rich_style(self) -> Style: | ||
"""Match color used in the console output to the step status.""" | ||
if self is StepStatus.SUCCESS: | ||
|
@@ -104,6 +83,9 @@ class Step(ABC): | |
title: ClassVar[str] | ||
max_retries: ClassVar[int] = 0 | ||
should_log: ClassVar[bool] = True | ||
should_persist_stdout_stderr_logs: ClassVar[bool] = True | ||
success_exit_code: ClassVar[int] = 0 | ||
skipped_exit_code: ClassVar[int] = None | ||
# The max duration of a step run. If the step run for more than this duration it will be considered as timed out. | ||
# The default of 5 hours is arbitrary and can be changed if needed. | ||
max_duration: ClassVar[timedelta] = timedelta(hours=5) | ||
|
@@ -124,19 +106,23 @@ def run_duration(self) -> timedelta: | |
@property | ||
def logger(self) -> logging.Logger: | ||
if self.should_log: | ||
return self.context.logger | ||
return logging.getLogger(f"{self.context.pipeline_name} - {self.title}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
else: | ||
disabled_logger = logging.getLogger() | ||
disabled_logger.disabled = True | ||
return disabled_logger | ||
|
||
@property | ||
def dagger_client(self) -> Container: | ||
return self.context.dagger_client.pipeline(self.title) | ||
|
||
async def log_progress(self, completion_event: anyio.Event) -> None: | ||
"""Log the step progress every 30 seconds until the step is done.""" | ||
while not completion_event.is_set(): | ||
duration = datetime.utcnow() - self.started_at | ||
elapsed_seconds = duration.total_seconds() | ||
if elapsed_seconds > 30 and round(elapsed_seconds) % 30 == 0: | ||
self.logger.info(f"⏳ Still running {self.title}... (duration: {format_duration(duration)})") | ||
self.logger.info(f"⏳ Still running... (duration: {format_duration(duration)})") | ||
await anyio.sleep(1) | ||
|
||
async def run_with_completion(self, completion_event: anyio.Event, *args, **kwargs) -> StepResult: | ||
|
@@ -174,7 +160,7 @@ async def run(self, *args, **kwargs) -> StepResult: | |
if result.status is StepStatus.FAILURE and self.retry_count <= self.max_retries and self.max_retries > 0: | ||
self.retry_count += 1 | ||
await anyio.sleep(10) | ||
self.logger.warn(f"Retry #{self.retry_count} for {self.title} step on connector {self.context.connector.technical_name}.") | ||
self.logger.warn(f"Retry #{self.retry_count}.") | ||
return await self.run(*args, **kwargs) | ||
self.stopped_at = datetime.utcnow() | ||
self.log_step_result(result) | ||
|
@@ -192,11 +178,11 @@ def log_step_result(self, result: StepResult) -> None: | |
""" | ||
duration = format_duration(self.run_duration) | ||
if result.status is StepStatus.FAILURE: | ||
self.logger.error(f"{result.status.get_emoji()} {self.title} failed (duration: {duration})") | ||
self.logger.error(f"{result.status.get_emoji()} failed (duration: {duration})") | ||
if result.status is StepStatus.SKIPPED: | ||
self.logger.info(f"{result.status.get_emoji()} {self.title} was skipped (duration: {duration})") | ||
self.logger.info(f"{result.status.get_emoji()} was skipped (duration: {duration})") | ||
if result.status is StepStatus.SUCCESS: | ||
self.logger.info(f"{result.status.get_emoji()} {self.title} was successful (duration: {duration})") | ||
self.logger.info(f"{result.status.get_emoji()} was successful (duration: {duration})") | ||
|
||
@abstractmethod | ||
async def _run(self, *args, **kwargs) -> StepResult: | ||
|
@@ -218,6 +204,58 @@ def skip(self, reason: str = None) -> StepResult: | |
""" | ||
return StepResult(self, StepStatus.SKIPPED, stdout=reason) | ||
|
||
async def write_log_files(self, stdout: Optional[str] = None, stderr: Optional[str] = None) -> List[Path]: | ||
"""Write stdout and stderr logs to a file in the connector code directory. | ||
|
||
Args: | ||
stdout (Optional[str], optional): The step final container stdout. Defaults to None. | ||
stderr (Optional[str], optional): The step final container stderr. Defaults to None. | ||
|
||
Returns: | ||
List[Path]: The list of written log files. | ||
""" | ||
if not stdout and not stderr: | ||
return [] | ||
|
||
written_log_files = [] | ||
log_directory = Path(f"{self.context.connector.code_directory}/airbyte_ci_logs/{slugify(self.context.pipeline_name)}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use connector here if this is our generic |
||
await log_directory.mkdir(exist_ok=True, parents=True) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doing all this manual stderr/out handling and log writing makes me nervous. Particularly now that they write to not just our ci folder but also the connector folder My worries generally come down to If we keep writing log handling at a low level
This leads me to ask What is stopping us from moving all our log writing/handling to the dagger client boundary here: For example a possible solution could be
If this is possible and is a good idea. can we make a small step towards this now by
e.g. https://stackoverflow.com/questions/6386698/how-to-write-to-a-file-using-the-logging-python-module There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bnchrch let's keep the logging change for a different PR then.
This is not low level IMO because it's a change made at the Step class level, so we centralize all the logging logic on the base class.
This is what this change did: the step have a specific logger with pipeline and step name.
In this context you mean parsing the dagger client logs. Splitting them into different per step/pipeline logs file will be very brittle as we have no control on the shape of the dagger logs, I'd rather ask the dagger team to provide such a feature and IMO it directly overlaps with the log access in the Dagger Web UI.
Yep you're right that using a combo of logging to stdout + write logic can definitely be handled at the logger level. Let's do it later and groom #28423 a bit more. |
||
if stdout: | ||
# TODO alafanechere we could also log the stdout and stderr of the container in the pipeline context. | ||
# It could be a nice alternative to the --show-dagger-logs flag. | ||
stdout_log_path = await (log_directory / f"{slugify(self.title).replace('-', '_')}_stdout.log").resolve() | ||
await stdout_log_path.write_text(stdout) | ||
self.logger.info(f"stdout logs written to {stdout_log_path}") | ||
written_log_files.append(stdout_log_path) | ||
if stderr: | ||
stderr_log_path = await (log_directory / f"{slugify(self.title).replace('-', '_')}_stderr.log").resolve() | ||
await stderr_log_path.write_text(stderr) | ||
self.logger.info(f"stderr logs written to {stderr_log_path}") | ||
written_log_files.append(stderr_log_path) | ||
return written_log_files | ||
|
||
def get_step_status_from_exit_code( | ||
self, | ||
exit_code: int, | ||
) -> StepStatus: | ||
"""Map an exit code to a step status. | ||
|
||
Args: | ||
exit_code (int): A process exit code. | ||
|
||
Raises: | ||
ValueError: Raised if the exit code is not mapped to a step status. | ||
|
||
Returns: | ||
StepStatus: The step status inferred from the exit code. | ||
""" | ||
if exit_code == self.success_exit_code: | ||
return StepStatus.SUCCESS | ||
elif self.skipped_exit_code is not None and exit_code == self.skipped_exit_code: | ||
return StepStatus.SKIPPED | ||
else: | ||
return StepStatus.FAILURE | ||
|
||
async def get_step_result(self, container: Container) -> StepResult: | ||
"""Concurrent retrieval of exit code, stdout and stdout of a container. | ||
|
||
|
@@ -230,9 +268,11 @@ async def get_step_result(self, container: Container) -> StepResult: | |
StepResult: Failure or success with stdout and stderr. | ||
""" | ||
exit_code, stdout, stderr = await get_exec_result(container) | ||
if self.context.is_local and self.should_persist_stdout_stderr_logs: | ||
await self.write_log_files(stdout, stderr) | ||
return StepResult( | ||
self, | ||
StepStatus.from_exit_code(exit_code), | ||
self.get_step_status_from_exit_code(exit_code), | ||
stderr=stderr, | ||
stdout=stdout, | ||
output_artifact=container, | ||
|
@@ -249,31 +289,7 @@ def _get_timed_out_step_result(self) -> StepResult: | |
class PytestStep(Step, ABC): | ||
"""An abstract class to run pytest tests and evaluate success or failure according to pytest logs.""" | ||
|
||
async def write_log_file(self, logs) -> str: | ||
"""Return the path to the pytest log file.""" | ||
log_directory = Path(f"{self.context.connector.code_directory}/airbyte_ci_logs") | ||
await log_directory.mkdir(exist_ok=True) | ||
log_path = await (log_directory / f"{slugify(self.title).replace('-', '_')}.log").resolve() | ||
await log_path.write_text(logs) | ||
self.logger.info(f"Pytest logs written to {log_path}") | ||
|
||
# TODO this is not very robust if pytest crashes and does not outputs its expected last log line. | ||
def pytest_logs_to_step_result(self, logs: str) -> StepResult: | ||
"""Parse pytest log and infer failure, success or skipping. | ||
|
||
Args: | ||
logs (str): The pytest logs. | ||
|
||
Returns: | ||
StepResult: The inferred step result according to the log. | ||
""" | ||
last_log_line = logs.split("\n")[-2] | ||
if "failed" in last_log_line or "errors" in last_log_line: | ||
return StepResult(self, StepStatus.FAILURE, stderr=logs) | ||
elif "no tests ran" in last_log_line: | ||
return StepResult(self, StepStatus.SKIPPED, stdout=logs) | ||
else: | ||
return StepResult(self, StepStatus.SUCCESS, stdout=logs) | ||
skipped_exit_code = 5 | ||
|
||
async def _run_tests_in_directory(self, connector_under_test: Container, test_directory: str) -> StepResult: | ||
"""Run the pytest tests in the test_directory that was passed. | ||
|
@@ -294,18 +310,13 @@ async def _run_tests_in_directory(self, connector_under_test: Container, test_di | |
"python", | ||
"-m", | ||
"pytest", | ||
"--suppress-tests-failed-exit-code", | ||
"--suppress-no-test-exit-code", | ||
"-s", | ||
test_directory, | ||
"-c", | ||
test_config, | ||
] | ||
) | ||
logs = await tester.stdout() | ||
if self.context.is_local: | ||
await self.write_log_file(logs) | ||
return self.pytest_logs_to_step_result(logs) | ||
return await self.get_step_result(tester) | ||
|
||
else: | ||
return StepResult(self, StepStatus.SKIPPED) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are our tests now passing for pipelines? Did I miss that PR?!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, it was a WIP push from a different branch: #28857