Skip to content
This repository was archived by the owner on May 31, 2023. It is now read-only.

Commit

Permalink
feat: Enrich reporting with error codes for different failure cases
Browse files Browse the repository at this point in the history
  • Loading branch information
tumido committed Jan 7, 2021
1 parent d36c949 commit 1ef3997
Show file tree
Hide file tree
Showing 16 changed files with 164 additions and 60 deletions.
34 changes: 24 additions & 10 deletions solgate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import click

from solgate import list_source, send, send_report, __version__ as version
from .utils import serialize, logger, deserialize, read_general_config
from .utils import serialize, logger, deserialize, read_general_config, NoFilesToSyncError, FilesFailedToSyncError
from .report import DEFAULT_RECIPIENT, DEFAULT_SENDER, DEFAULT_SMTP_SERVER


Expand Down Expand Up @@ -54,18 +54,23 @@ def _send(ctx, key: str = None, listing_file: str = None):
elif key:
files_to_transfer = [dict(relpath=key)]
else:
logger.error("Nothing to send through solgate.")
exit(1)
files_to_transfer = []

try:
success = send(files_to_transfer, ctx.obj["config"])
send(files_to_transfer, ctx.obj["config"])
except FileNotFoundError as e:
logger.error(e, exc_info=True)
raise NoFilesToSyncError(*e.args)
except ValueError as e:
logger.error(e, exc_info=True)
raise click.BadParameter("Environment not configured properly")
except IOError as e:
logger.error(e, exc_info=True)
raise FilesFailedToSyncError(*e.args)
except: # noqa: E722
logger.error("Unexpected error during transfer", exc_info=True)
exit(1)
raise click.ClickException("Unexpected error")

if not success:
logger.error("Failed to perform a full sync")
exit(1)
logger.info("Successfully synced all files to all destinations")


Expand All @@ -83,9 +88,15 @@ def _list(ctx, output: str = None):
serialize(files, output)
else:
click.echo(files)
except ValueError as e:
logger.error(e, exc_info=True)
raise click.BadParameter("Environment not configured properly")
except FileNotFoundError as e:
logger.error(e, exc_info=True)
raise NoFilesToSyncError(*e.args)
except: # noqa: F401
logger.error("Unexpected error", exc_info=True)
exit(1)
raise click.ClickException("Unexpected error")


@cli.command("report")
Expand Down Expand Up @@ -169,7 +180,10 @@ def _report(

context = dict(name=name, namespace=namespace, status=status, timestamp=timestamp, host=host)

send_report(context, failures, config)
try:
send_report(context, failures, config)
except ValueError as e:
raise click.BadParameter(*e.args)


@cli.command("version")
Expand Down
15 changes: 6 additions & 9 deletions solgate/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def parse_timedelta(timestr: str) -> timedelta:
time_params = {k: int(v) for k, v in parts.groupdict().items() if v} # type: ignore

if not time_params:
raise EnvironmentError("Timedelta format is not valid")
raise ValueError("Timedelta format is not valid")

return timedelta(**time_params)

Expand All @@ -71,18 +71,15 @@ def list_source(config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
general_config = read_general_config(**config)
try:
oldest_date = datetime.now(timezone.utc) - parse_timedelta(general_config.get("timedelta", DEFAULT_TIMEDELTA))
s3 = S3FileSystem.from_config_file(config)[0]
except EnvironmentError:
logger.error("Environment not set properly, exiting", exc_info=True)
exit(1)

oldest_date = datetime.now(timezone.utc) - parse_timedelta(general_config.get("timedelta", DEFAULT_TIMEDELTA))
s3 = S3FileSystem.from_config_file(config)[0]

constraint = lambda meta: meta["LastModified"] >= oldest_date # noqa: E731
located_files = s3.find(constraint=constraint)

if not located_files:
logger.error("No files found in given TIMEDELTA", dict(files=[]))
exit(1)
raise FileNotFoundError("No files found in given TIMEDELTA")
logger.info("Files found", dict(files=list(located_files.keys())))

# Select a metadata subset, so we don't clutter the workflow
Expand Down
25 changes: 23 additions & 2 deletions solgate/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from json import loads
from typing import Dict, Union, Any
from pathlib import Path
import re

from jinja2 import Template

from .utils import logger
from .utils import logger, EXIT_CODES

TEMPLATE_HTML = Path(__file__).absolute().parent / "utils" / "email_alert_template.html"
TEMPLATE_PLAINTEXT = Path(__file__).absolute().parent / "utils" / "email_alert_template.txt"
Expand Down Expand Up @@ -68,6 +69,24 @@ def decode_failures(failures: str) -> list:
raise ValueError(e)


def parse_error_reason(failures: list) -> list:
"""Parse error reason message from the failed nodes messages.
Args:
failures (list): List of failed nodes
Returns:
list: List of error reason messages.
"""
regex = r"failed with exit code (?P<exit_code>\d+)"

messages = [re.match(regex, f.get("message", "")) for f in failures]
exit_codes = map(lambda m: int(m.groupdict().get("exit_code", 1)), filter(None, messages))

return [EXIT_CODES[e].msg or f"Unknown reason for exit code '{e}'" for e in exit_codes]


def send_report(context: Dict[str, Any], failures: str, config: Dict[str, str]) -> None:
"""Send an email notification.
Expand All @@ -86,14 +105,16 @@ def send_report(context: Dict[str, Any], failures: str, config: Dict[str, str])
context = context.copy()
if not context.keys() == set(["name", "namespace", "status", "timestamp", "host"]) or not all(context.values()):
logger.error("Alert content is not passed properly")
exit(1)
raise ValueError("Alert content is not passed properly")

try:
context["failures"] = decode_failures(failures)
except ValueError:
logger.error("Unable to parse workflow failures", exc_info=True)
context["failures"] = []

context["reason"] = parse_error_reason(context["failures"])

logger.info("Sending email alert")

msg = EmailMessage()
Expand Down
16 changes: 9 additions & 7 deletions solgate/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,19 @@ def send(files_to_transfer: List[Dict[str, Any]], config: Dict[str, Any]) -> boo
Returns:
bool: True if success
Raises:
ValueError: Raised when local environment is not set properly
FileNotFoundError: Raised when there are no files to be transfered (noop)
IOError: Raised when some files failed to transfer
"""
try:
clients = S3FileSystem.from_config_file(config)
except EnvironmentError:
logger.error("Environment not set properly, exiting", exc_info=True)
return False
except ValueError:
raise

if not files_to_transfer:
logger.error("No files to transfer")
return False
raise FileNotFoundError("No files to transfer")

failed = []
for source_file in files_to_transfer:
Expand All @@ -148,6 +151,5 @@ def send(files_to_transfer: List[Dict[str, Any]], config: Dict[str, Any]) -> boo
failed.append(source_file)

if failed:
logger.error("Some files failed to be transferred", dict(failed_files=failed))
return False
raise IOError("Some files failed to be transferred", dict(failed_files=failed))
return True
1 change: 1 addition & 0 deletions solgate/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from .io import deserialize, key_formatter, read_general_config, serialize
from .logging import logger
from .s3 import S3File, S3FileSystem
from .exceptions import EXIT_CODES, NoFilesToSyncError, FilesFailedToSyncError
1 change: 1 addition & 0 deletions solgate/utils/email_alert_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<tr><td>Namespace</td><td>{{ namespace }}</td></tr>
<tr><td>Status</td><td>{{ status }}</td></tr>
<tr><td>Created</td><td>{{ timestamp }}</td></tr>
<tr><td>Failure reason</td><td>{{ reason|join(', ') }}</td></tr>
</table>
<p><b>Failures:</b></p>
{%- if not failures %}
Expand Down
9 changes: 5 additions & 4 deletions solgate/utils/email_alert_template.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
Argo workflow has failed!

Name: {{ name }}
Namespace: {{ namespace }}
Status: {{ status }}
Created: {{ timestamp }}
Name: {{ name }}
Namespace: {{ namespace }}
Status: {{ status }}
Created: {{ timestamp }}
Failure reason: {{ reason|join(', ') }}

Failures:
{%- if not failures %}
Expand Down
26 changes: 26 additions & 0 deletions solgate/utils/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Solgate Exceptions registry module."""

from collections import namedtuple
from click import ClickException, BadParameter


class NoFilesToSyncError(ClickException):
"""An exception that Click raises when there's no files to be synced."""

exit_code = 3


class FilesFailedToSyncError(ClickException):
"""An exception that Click raises when the sync was not fully successfull but no other exception was raised."""

exit_code = 4


ExceptionWithErrorCode = namedtuple("ExceptionWithErrorCode", ["msg", "type"])

EXIT_CODES = {
1: ExceptionWithErrorCode("Unexpected runtime error", ClickException),
2: ExceptionWithErrorCode("Misconfiguration", BadParameter),
3: ExceptionWithErrorCode("No new payloads found", NoFilesToSyncError),
4: ExceptionWithErrorCode("Some files failed to sync", FilesFailedToSyncError),
}
6 changes: 3 additions & 3 deletions solgate/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from datetime import datetime
from functools import lru_cache, partial
from string import Formatter
from typing import Any, Dict, Iterator
from typing import Any, Dict, Iterator, Union

load = partial(yaml_load, Loader=Loader)

Expand Down Expand Up @@ -67,11 +67,11 @@ def deserialize(filename: str) -> Any:
return json.load(f)


def _read_yaml_file(filename) -> Dict[str, Any]:
def _read_yaml_file(filename: Union[str, Path]) -> Dict[str, Any]:
"""Read a file.
Args:
filename (str, optional): Configuration file location. Defaults to None.
filename (str): Configuration file location. Defaults to None.
Returns:
Dict[str, Any]: Pythonic representation of the config file.
Expand Down
2 changes: 1 addition & 1 deletion solgate/utils/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def from_config_file(cls, config: Dict[str, Any]) -> List["S3FileSystem"]:
config_list = read_s3_config(**config)
return [cls(**config) for config in config_list]
except TypeError:
raise EnvironmentError("Config file not parseable.")
raise ValueError("Config file not parseable.")

def find(
self,
Expand Down
33 changes: 23 additions & 10 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@ def test_list(run, mocker, cli_args, func_args, file_output):
assert "['list', 'of', 'files']\n" in result.output


def test_list_negative(run, mocker):
@pytest.mark.parametrize(
"side_effect,errno", [(RuntimeError, 1), (ValueError("msg"), 2), (FileNotFoundError("msg"), 3),],
)
def test_list_negative(run, mocker, side_effect, errno):
"""Should fail on list command."""
mocker.patch("solgate.cli.list_source", side_effect=EnvironmentError)
mocker.patch("solgate.cli.list_source", side_effect=side_effect)

result = run("list")

assert result.exit_code == 1
assert result.exit_code == errno


@pytest.mark.parametrize(
Expand All @@ -80,20 +83,21 @@ def test_send(run, mocker, cli_args, func_args):


@pytest.mark.parametrize(
"kwargs,cli_args",
"side_effect,cli_args,errno",
[
(dict(side_effect=RuntimeError), ["send", "key"]),
(dict(return_value=False), ["send", "key"]),
(dict(), ["send"]),
(RuntimeError, ["send", "key"], 1),
(ValueError("msg"), ["send"], 2),
(FileNotFoundError("msg"), ["send", "key"], 3),
(IOError("msg"), ["send", "key"], 4),
],
)
def test_send_negative(run, mocker, kwargs, cli_args):
def test_send_negative(run, mocker, side_effect, cli_args, errno):
"""Should fail on sync failure."""
mocker.patch("solgate.cli.send", **kwargs)
mocker.patch("solgate.cli.send", side_effect=side_effect)

result = run(cli_args)

assert result.exit_code == 1
assert result.exit_code == errno


context_keys = ["name", "namespace", "status", "host", "timestamp"]
Expand Down Expand Up @@ -178,3 +182,12 @@ def test_report(run, mocker, cli_args, func_args, fixture_dir, env):
logger_spy.assert_called_once_with(
"Config file is not present or not valid, alerting to/from default email address."
)


def test_report_negative(run, mocker):
"""Should fail on list command."""
mocker.patch("solgate.cli.list_source", side_effects=ValueError("msg"))

result = run("report")

assert result.exit_code == 2
8 changes: 4 additions & 4 deletions tests/lookup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_parse_timedelta(input, output):

def test_parse_timedelta_error():
"""Should raise when unable to parse."""
with pytest.raises(EnvironmentError):
with pytest.raises(ValueError):
lookup.parse_timedelta("1y")


Expand Down Expand Up @@ -68,9 +68,9 @@ def test_list_source(mocked_s3, mocker, config, old_object_modified_date, found_
def test_list_source_invalid_config(mocker):
"""Should raise when client can't be instantiated."""
mocker.patch("solgate.lookup.read_general_config", return_value=dict())
mocker.patch("solgate.lookup.S3FileSystem.from_config_file", side_effect=EnvironmentError)
mocker.patch("solgate.lookup.S3FileSystem.from_config_file", side_effect=ValueError)

with pytest.raises(SystemExit):
with pytest.raises(ValueError):
lookup.list_source({})


Expand All @@ -84,5 +84,5 @@ def test_list_source_no_objects(mocked_s3, mocker):
fs.s3fs.touch("BUCKET/old.csv")
s3_backend.buckets["BUCKET"].keys["old.csv"].last_modified = datetime(2020, 1, 1)

with pytest.raises(SystemExit):
with pytest.raises(FileNotFoundError):
lookup.list_source({})
Loading

0 comments on commit 1ef3997

Please sign in to comment.