Skip to content

Commit

Permalink
Mail (SMTP) sink (#1128)
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertSzefler authored Oct 27, 2023
1 parent bc663e5 commit 7c2b95f
Show file tree
Hide file tree
Showing 20 changed files with 1,560 additions and 1,120 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ generated_values*.yaml
.isort.cfg
skaffold.dev.yaml
tests/last_used_tag.txt
pytest.ini
4 changes: 4 additions & 0 deletions docs/configuration/sinks/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ Click a sink for setup instructions.
:link: kafka
:link-type: doc

.. grid-item-card:: :octicon:`cpu;1em;` Mail
:class-card: sd-bg-light sd-bg-text-light
:link: mail
:link-type: doc


**Need support for a new sink?** `Tell us and we'll add it. <https://github.com/robusta-dev/robusta/issues/new?assignees=&labels=&template=feature_request.md&title=New%20Sink:>`_
Expand Down
33 changes: 33 additions & 0 deletions docs/configuration/sinks/mail.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Mail
#################

Robusta can report issues and events in your Kubernetes cluster by sending
emails.

Connecting the mail sink
------------------------------------------------

To set up the mail sink, you need access to an SMTP server. You should also
set the sender and receiver(s) addresses.

As Robusta uses the `Apprise library <https://github.com/caronc/apprise>`_ under the hood for running mail
notifications, you can configure the "mailto" field described below using
the convenient and sophisticated syntax provided by Apprise. For more details
`see here <https://github.com/caronc/apprise/wiki/Notify_email>`_.

Configuring the mail sink
------------------------------------------------

.. admonition:: Add this to your generated_values.yaml

.. code-block:: yaml
sinksConfig:
- mail_sink:
name: mail_sink
mailto: "mailtos://user:password@server&from=a@x&to=b@y,c@z"
(Note the quotes around the value in mailto. It's highly recommended to add
them as this ensures that characters like `:` are handled correctly)

Then do a :ref:`Helm Upgrade <Simple Upgrade>`.
2 changes: 1 addition & 1 deletion playbooks/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ include_trailing_comma = true
python = "^3.7.1"
CairoSVG = "^2.5.2"
Flask = "^2.0.2"
prometheus-api-client = "^0.4.2"
prometheus-api-client = "^0.5.4"
pygal = "^3.0.0"
tinycss = "^0.4"
cssselect = "^1.1.0"
Expand Down
2,072 changes: 990 additions & 1,082 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ robusta = "robusta.cli.main:app"


[tool.poetry.dependencies]
python = "^3.8"
# Robusta CLI should work on 3.8, but the core code requires 3.9+. Both
# are known not to work on 3.11. Unfortunately this spec is shared between
# the CLI and core, so we have to specify 3.8+.
python = "^3.8, <3.11"
typer = "^0.4.1"
colorlog = "^5.0.1"
pydantic = "^1.8.1"
Expand Down Expand Up @@ -64,6 +67,8 @@ fpdf2 = "^2.7.1"
attrs = "^23.1.0"
prometrix = "0.1.10"
hikaru-model-26 = "^1.1.1"
apprise = "^1.5.0"

[tool.poetry.dev-dependencies]
pre-commit = "^2.13.0"
pytest = "^6.2.4"
Expand Down
4 changes: 2 additions & 2 deletions run_runner_locally.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ fi

echo "Setting up local runner environment"
mkdir -p deployment/playbooks/defaults
ln -fsw $(pwd)/playbooks/robusta_playbooks/ ./deployment/playbooks/defaults
ln -fsw $(pwd)/playbooks/pyproject.toml ./deployment/playbooks/defaults
ln -fs $(pwd)/playbooks/robusta_playbooks/ ./deployment/playbooks/defaults
ln -fs $(pwd)/playbooks/pyproject.toml ./deployment/playbooks/defaults

echo "Checking if runner can listen on port ${PORT}"
if lsof -Pi :${PORT} -sTCP:LISTEN -t >/dev/null ; then
Expand Down
3 changes: 2 additions & 1 deletion src/robusta/core/model/runner_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from robusta.core.sinks.pagerduty.pagerduty_sink_params import PagerdutyConfigWrapper
from robusta.core.sinks.robusta.robusta_sink_params import RobustaSinkConfigWrapper
from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper
from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper
from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper
from robusta.core.sinks.victorops.victorops_sink_params import VictoropsConfigWrapper
from robusta.core.sinks.webex.webex_sink_params import WebexSinkConfigWrapper
Expand Down Expand Up @@ -53,6 +54,7 @@ class RunnerConfig(BaseModel):
YaMessengerSinkConfigWrapper,
JiraSinkConfigWrapper,
FileSinkConfigWrapper,
MailSinkConfigWrapper,
]
]
]
Expand All @@ -67,7 +69,6 @@ def env_var_repo_keys(cls, playbook_repos: Dict[str, PlaybookRepo]):

@staticmethod
def _replace_env_var_in_playbook_repo(playbook_repo: PlaybookRepo):

url_env_var_replacement = get_env_replacement(playbook_repo.url)
if url_env_var_replacement:
playbook_repo.url = url_env_var_replacement
Expand Down
3 changes: 2 additions & 1 deletion src/robusta/core/reporting/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

class BaseBlock(BaseModel):
hidden: bool = False
html_class: str = None


class Emojis(Enum):
Expand Down Expand Up @@ -51,7 +52,7 @@ def to_emoji(self) -> str:
if self == FindingSeverity.DEBUG:
return "🔵"
elif self == FindingSeverity.INFO:
return "🟢"
return "⚪️"
elif self == FindingSeverity.LOW:
return "🟡"
elif self == FindingSeverity.MEDIUM:
Expand Down
Empty file.
18 changes: 18 additions & 0 deletions src/robusta/core/sinks/mail/mail_sink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from robusta.core.reporting.base import Finding
from robusta.core.sinks.sink_base import SinkBase
from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper
from robusta.integrations.mail.sender import MailSender


class MailSink(SinkBase):
def __init__(self, sink_config: MailSinkConfigWrapper, registry):
super().__init__(sink_config.mail_sink, registry)
self.sender = MailSender(
sink_config.mail_sink.mailto,
self.signing_key,
self.account_id,
self.cluster_name,
)

def write_finding(self, finding: Finding, platform_enabled: bool):
self.sender.send_finding_via_email(finding, platform_enabled)
23 changes: 23 additions & 0 deletions src/robusta/core/sinks/mail/mail_sink_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from pydantic import validator

from robusta.core.sinks.sink_base_params import SinkBaseParams
from robusta.core.sinks.sink_config import SinkConfigBase


class MailSinkParams(SinkBaseParams):
mailto: str

@validator("mailto")
def validate_mailto(cls, mailto):
# Make sure we only handle emails and exclude other schemes provided by apprise
# (there is a lot of them).
if not (mailto.startswith("mailto://") or mailto.startswith("mailtos://")):
raise AttributeError(f"{mailto} is not a mailto(s) address")
return mailto


class MailSinkConfigWrapper(SinkConfigBase):
mail_sink: MailSinkParams

def get_params(self) -> SinkBaseParams:
return self.mail_sink
3 changes: 2 additions & 1 deletion src/robusta/core/sinks/opsgenie/opsgenie_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,6 @@ def __to_description(self, finding: Finding, platform_enabled: bool) -> str:

@classmethod
def __enrichments_as_text(cls, enrichments: List[Enrichment]) -> str:
text_arr = [Transformer.to_html(enrichment.blocks) for enrichment in enrichments]
transformer = Transformer()
text_arr = [transformer.to_html(enrichment.blocks) for enrichment in enrichments]
return "---\n".join(text_arr)
4 changes: 4 additions & 0 deletions src/robusta/core/sinks/sink_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from robusta.core.sinks.file.file_sink_params import FileSinkConfigWrapper
from robusta.core.sinks.jira import JiraSink, JiraSinkConfigWrapper
from robusta.core.sinks.kafka import KafkaSink, KafkaSinkConfigWrapper
from robusta.core.sinks.mail.mail_sink import MailSink
from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper
from robusta.core.sinks.mattermost import MattermostSink, MattermostSinkConfigWrapper
from robusta.core.sinks.msteams import MsTeamsSink, MsTeamsSinkConfigWrapper
from robusta.core.sinks.opsgenie import OpsGenieSink, OpsGenieSinkConfigWrapper
Expand All @@ -20,6 +22,7 @@
from robusta.core.sinks.webhook import WebhookSink, WebhookSinkConfigWrapper
from robusta.core.sinks.yamessenger import YaMessengerSink, YaMessengerSinkConfigWrapper


class SinkFactory:
__sink_config_mapping: Dict[Type[SinkConfigBase], Type[SinkBase]] = {
SlackSinkConfigWrapper: SlackSink,
Expand All @@ -38,6 +41,7 @@ class SinkFactory:
YaMessengerSinkConfigWrapper: YaMessengerSink,
JiraSinkConfigWrapper: JiraSink,
FileSinkConfigWrapper: FileSink,
MailSinkConfigWrapper: MailSink,
}

@classmethod
Expand Down
75 changes: 44 additions & 31 deletions src/robusta/core/sinks/transformer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import re
import urllib.parse
from collections import defaultdict
Expand Down Expand Up @@ -96,7 +97,7 @@ def to_github_markdown(markdown_data: str, add_angular_brackets: bool = True) ->
return re.sub(r"\*([^\*]*)\*", r"**\1**", markdown_data)

@classmethod
def __markdown_to_html(cls, mrkdwn_text: str) -> str:
def __markdown_to_html(cls, mrkdwn_text: str, html_class: str = None) -> str:
# replace links: from <http://url|name> to <a href="url">name</a>
mrkdwn_links = re.findall(r"<[^\\|]*\|[^\>]*>", mrkdwn_text)
for link in mrkdwn_links:
Expand All @@ -109,34 +110,48 @@ def __markdown_to_html(cls, mrkdwn_text: str) -> str:

# Note - markdown2 should be used after slack links already converted, otherwise it's getting corrupted!
# Convert other markdown content
return markdown2.markdown(mrkdwn_text)

@classmethod
def to_html(cls, blocks: List[BaseBlock]) -> str:
lines = []
for block in blocks:
if isinstance(block, MarkdownBlock):
if not block.text:
continue
lines.append(f"{cls.__markdown_to_html(block.text)}")
elif isinstance(block, DividerBlock):
lines.append("-------------------")
elif isinstance(block, JsonBlock):
lines.append(block.json_str)
elif isinstance(block, KubernetesDiffBlock):
for diff in block.diffs:
lines.append(
cls.__markdown_to_html(f"*{'.'.join(diff.path)}*: {diff.other_value} ==> {diff.value}")
)
elif isinstance(block, HeaderBlock):
lines.append(f"<strong>{block.text}</strong>")
elif isinstance(block, ListBlock):
lines.extend(cls.__markdown_to_html(block.to_markdown().text))
elif isinstance(block, TableBlock):
if block.table_name:
lines.append(cls.__markdown_to_html(block.table_name))
lines.append(tabulate(block.render_rows(), headers=block.headers, tablefmt="html").replace("\n", ""))
return "\n".join(lines)
if html_class:
# TODO this will most probably apply to *all* <p> elements, while we're
# really only interested with the topmost one.
extras = {"html-classes": {"p": html_class}}
else:
extras = {}
return markdown2.markdown(mrkdwn_text, extras=extras)

def to_html(self, blocks: List[BaseBlock]) -> str:
return "\n".join(self.block_to_html(block) for block in blocks)

def block_to_html(self, block: BaseBlock) -> str:
if isinstance(block, MarkdownBlock):
if block.text:
return self.__markdown_to_html(block.text, getattr(block, "html_class"))
else:
return ""
elif isinstance(block, DividerBlock):
return "-------------------"
elif isinstance(block, JsonBlock):
return block.json_str
elif isinstance(block, KubernetesDiffBlock):
return "\n".join(
self.__markdown_to_html(f"*{'.'.join(diff.path)}*: {diff.other_value} ==> {diff.value}")
for diff in block.diffs
)
elif isinstance(block, HeaderBlock):
return f"<strong>{block.text}</strong>"
elif isinstance(block, ListBlock):
return self.__markdown_to_html(block.to_markdown().text)
elif isinstance(block, TableBlock):
if block.table_name:
name_part = self.__markdown_to_html(block.table_name)
else:
name_part = ""
return name_part + tabulate(block.render_rows(), headers=block.headers, tablefmt="html").replace("\n", "")
elif isinstance(block, ScanReportBlock):
logging.warning("block_to_html should never be called with a ScanReportBlock instance")
return ""
else:
logging.warning(f"Unsupported block type ({type(block)}) found when rendering HTML")
return ""

@classmethod
def to_standard_markdown(cls, blocks: List[BaseBlock]) -> str:
Expand Down Expand Up @@ -178,7 +193,6 @@ def tableblock_to_fileblocks(blocks: List[BaseBlock], column_limit: int) -> List

@staticmethod
def scanReportBlock_to_fileblock(block: BaseBlock) -> BaseBlock:

if not isinstance(block, ScanReportBlock):
return block

Expand Down Expand Up @@ -243,7 +257,6 @@ def write_table(pdf: FPDF, rows: list[list[str]]):
sections[item.kind][f"{item.name}/{item.namespace}"].append(item)

for kind, grouped_issues in sections.items():

rows = [["Priority", "Name", "Namespace", "Issues"]]
for group, scanRes in grouped_issues.items():
n, ns = group.split("/", 1)
Expand Down
Empty file.
Loading

0 comments on commit 7c2b95f

Please sign in to comment.