Add alertmanager as supported sender and update deps

This commit is contained in:
Abel Luck 2023-11-07 15:14:56 +01:00
parent 05ffc640ed
commit 973e1fd789
18 changed files with 1682 additions and 1155 deletions

57
ops_bot/alertmanager.py Normal file
View file

@ -0,0 +1,57 @@
import logging
from typing import Any, Dict, List, Tuple
from fastapi import Request
from ops_bot.common import COLOR_ALARM, COLOR_OK, COLOR_UNKNOWN
from ops_bot.config import RoutingKey
def prometheus_alert_to_markdown(
alert_data: Dict, # type: ignore[type-arg]
) -> List[Tuple[str, str]]:
"""
Converts a prometheus alert json to markdown
"""
messages = []
known_labels = ["alertname", "instance", "job"]
for alert in alert_data["alerts"]:
title = (
alert["annotations"]["description"]
if hasattr(alert["annotations"], "description")
else alert["annotations"]["summary"]
)
severity = alert.get("severity", "critical")
if alert["status"] == "resolved":
color = COLOR_OK
elif severity == "critical":
color = COLOR_ALARM
else:
color = COLOR_UNKNOWN
status = alert["status"]
generatorURL = alert.get("generatorURL")
plain = f"{status}: {title}"
header_str = f"{status.upper()}[{status}]"
formatted = f"<strong><font color={color}>{header_str}</font></strong>: [{title}]({generatorURL})"
for label_name in known_labels:
try:
plain += "\n* **{0}**: {1}".format(
label_name.capitalize(), alert["labels"][label_name]
)
formatted += "\n* **{0}**: {1}".format(
label_name.capitalize(), alert["labels"][label_name]
)
except Exception as e:
logging.error("error parsing labels", exc_info=e)
pass
messages.append((plain, formatted))
return messages
async def parse_alertmanager_event(
route: RoutingKey,
payload: Any,
request: Request,
) -> List[Tuple[str, str]]:
return prometheus_alert_to_markdown(payload)

View file

@ -4,7 +4,7 @@ from typing import Any
import click
from ops_bot.config import RoutingKey, load_config, save_config
from ops_bot.config import RoutingKey, hook_types, load_config, save_config
@click.group()
@ -28,20 +28,23 @@ def cli(ctx: Any, config_file: str) -> None:
)
@click.option("--room-id", help="The room ID to send the messages to")
@click.pass_obj
def add_hook(config_file: str, name: str, hook_type: str, room_id: str) -> None:
def add_hook(config_file: str, name: str, maybe_hook_type: str, room_id: str) -> None:
settings = load_config(config_file)
path_key = secrets.token_urlsafe(30)
secret_token = secrets.token_urlsafe(30)
if name in set([key.name for key in settings.routing_keys]):
print("Error: A hook with that name already exists")
sys.exit(1)
if maybe_hook_type not in hook_types:
print(f"Invalid hook type {maybe_hook_type}")
print("Must be one of ", ", ".join(hook_types))
settings.routing_keys.append(
RoutingKey(
name=name,
path_key=path_key,
secret_token=secret_token,
room_id=room_id,
hook_type=hook_type,
hook_type=maybe_hook_type, # type: ignore[arg-type]
)
)
save_config(settings)

View file

@ -1,30 +1,37 @@
import json
import logging
import os
import typing
from pathlib import Path
from typing import List, Literal
from pydantic import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from ops_bot.matrix import MatrixClientSettings
env_path = os.getenv("BOT_ENV_FILE")
HookType = Literal["gitlab", "pagerduty", "aws-sns", "alertmanager"]
hook_types = typing.get_args(HookType)
class RoutingKey(BaseSettings):
name: str
path_key: str
secret_token: str
room_id: str
hook_type: Literal["gitlab", "pagerduty", "aws-sns"]
hook_type: HookType
class BotSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BOT_", env_file=env_path, env_file_encoding="utf-8"
)
routing_keys: List[RoutingKey]
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
matrix: MatrixClientSettings
class Config:
env_prefix = "BOT_"
case_sensitive = False
def get_rooms(self) -> List[str]:
return list(set([key.room_id for key in self.routing_keys]))
@ -35,9 +42,10 @@ def config_file_exists(filename: str) -> bool:
def load_config(filename: str = "config.json") -> BotSettings:
if config_file_exists(filename):
bot_settings = BotSettings.parse_file(filename)
with open(filename, "r") as f:
bot_settings = BotSettings.model_validate_json(f.read())
else:
bot_settings = BotSettings(_env_file=".env", _env_file_encoding="utf-8")
bot_settings = BotSettings() # type: ignore[call-arg]
logging.getLogger().setLevel(bot_settings.log_level)
return bot_settings

View file

@ -59,9 +59,9 @@ async def handle_event(x_gitlab_event: str, payload: Any) -> List[Tuple[str, str
**attr.asdict(subevt, recurse=False),
**{key: getattr(subevt, key) for key in subevt.event_properties},
"abort": abort,
**base_args, # type: ignore
**base_args,
}
args["templates"] = templates.proxy(args) # type: ignore
args["templates"] = templates.proxy(args)
html = tpl.render(**args)
if not html or aborted:
@ -87,4 +87,6 @@ async def parse_event(
route: RoutingKey, payload: Any, request: Request
) -> List[Tuple[str, str]]:
x_gitlab_event = request.headers.get("x-gitlab-event")
return await handle_event(x_gitlab_event, payload)
if x_gitlab_event:
return await handle_event(x_gitlab_event, payload)
return []

View file

@ -1,10 +1,12 @@
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
import sys
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, Tuple, cast
import json_logging
import uvicorn
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.security import (
HTTPAuthorizationCredentials,
@ -13,18 +15,11 @@ from fastapi.security import (
HTTPBearer,
)
from ops_bot import aws, pagerduty
from ops_bot import alertmanager, aws, pagerduty
from ops_bot.config import BotSettings, RoutingKey, load_config
from ops_bot.gitlab import hook as gitlab_hook
from ops_bot.matrix import MatrixClient
load_dotenv()
app = FastAPI()
bearer_security = HTTPBearer(auto_error=False)
basic_security = HTTPBasic(auto_error=False)
async def get_matrix_service(request: Request) -> MatrixClient:
"""A helper to fetch the matrix client from the app state"""
@ -37,21 +32,29 @@ async def matrix_main(matrix_client: MatrixClient) -> None:
await asyncio.gather(*workers)
@app.on_event("startup")
async def startup_event() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
config_fname = os.environ.get("BOT_CONFIG_FILE", "config.yaml")
bot_settings = load_config(config_fname)
c = MatrixClient(settings=bot_settings.matrix, join_rooms=bot_settings.get_rooms())
app.state.matrix_client = c
app.state.bot_settings = bot_settings
asyncio.create_task(matrix_main(c))
@app.on_event("shutdown")
async def shutdown_event() -> None:
yield
await app.state.matrix_client.shutdown()
app = FastAPI(lifespan=lifespan)
bearer_security = HTTPBearer(auto_error=False)
basic_security = HTTPBasic(auto_error=False)
log = logging.getLogger("ops_bot")
log.addHandler(logging.StreamHandler(sys.stdout))
json_logging.init_fastapi(enable_json=True)
json_logging.init_request_instrument(app)
@app.get("/")
async def root() -> Dict[str, str]:
return {"message": "Hello World"}
@ -113,6 +116,7 @@ handlers: Dict[str, Tuple[Authorizer, ParseHandler]] = {
"gitlab": (gitlab_hook.authorize, gitlab_hook.parse_event),
"pagerduty": (bearer_token_authorizer, pagerduty.parse_pagerduty_event),
"aws-sns": (nop_authorizer, aws.parse_sns_event),
"alertmanager": (nop_authorizer, alertmanager.parse_alertmanager_event),
}

View file

@ -1,12 +1,14 @@
import json
import logging
import os
import pathlib
import sys
from typing import List, Optional, Protocol
from markdown import markdown
from nio import AsyncClient, AsyncClientConfig, LoginResponse
from pydantic import BaseModel, BaseSettings
from pydantic import BaseModel
from pydantic_settings import BaseSettings
class ClientCredentials(BaseModel):
@ -72,6 +74,9 @@ class MatrixClient:
self.greeting_sent = False
if self.store_path and not os.path.isdir(self.store_path):
os.mkdir(self.store_path)
async def start(self) -> None:
await self.login()
@ -95,9 +100,9 @@ class MatrixClient:
async def login_fresh(self) -> None:
self.client = AsyncClient(
self.settings.homeserver,
self.settings.user_id,
self.settings.store_path,
homeserver=self.settings.homeserver,
user=self.settings.user_id,
store_path=str(self.settings.store_path),
config=self.client_config,
ssl=self.settings.verify_ssl,
)
@ -117,11 +122,12 @@ class MatrixClient:
async def login_with_credentials(self) -> None:
credentials = self.credential_store.read()
self.client = AsyncClient(
credentials.homeserver,
credentials.user_id,
homeserver=credentials.homeserver,
user=credentials.user_id,
device_id=credentials.device_id,
store_path=self.store_path,
store_path=str(self.store_path),
config=self.client_config,
ssl=True,
)