Skip to content

Commit 37e77fc

Browse files
committed
alerting: make rules super duper configurable
1 parent 7f5cb7e commit 37e77fc

8 files changed

Lines changed: 1392 additions & 57 deletions

File tree

cabotage/client/templates/user/organization_settings.html

Lines changed: 615 additions & 52 deletions
Large diffs are not rendered by default.

cabotage/server/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,14 @@ def _hashed_url_for(endpoint, **values):
240240
from cabotage.server.user.github_oauth import init_github_oauth
241241
from cabotage.server.integrations.slack_oauth import init_slack_oauth
242242
from cabotage.server.integrations.discord_oauth import init_discord_oauth
243+
from cabotage.server.integrations.notification_routing import (
244+
init_notification_routing,
245+
)
243246

244247
init_github_oauth(app)
245248
init_slack_oauth(app)
246249
init_discord_oauth(app)
250+
init_notification_routing(app)
247251
vault_db_creds.init_app(app)
248252
db.init_app(app)
249253
principal.init_app(app)
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import logging
2+
3+
from flask import Blueprint, abort, jsonify, request
4+
from flask_security import login_required
5+
from sqlalchemy import cast
6+
from sqlalchemy.dialects.postgresql import ARRAY, TEXT
7+
8+
from cabotage.server import db
9+
from cabotage.server.acl import AdministerOrganizationPermission
10+
from cabotage.server.models.auth import Organization
11+
from cabotage.server.models.notifications import (
12+
NOTIFICATION_CATEGORIES,
13+
NotificationRoute,
14+
)
15+
16+
log = logging.getLogger(__name__)
17+
18+
notification_routing_bp = Blueprint(
19+
"notification_routing", __name__, url_prefix="/integrations/notifications"
20+
)
21+
22+
23+
def _get_org_or_403(org_slug):
24+
organization = Organization.query.filter_by(slug=org_slug).first_or_404()
25+
if not AdministerOrganizationPermission(organization.id).can():
26+
abort(403)
27+
return organization
28+
29+
30+
@notification_routing_bp.route("/<org_slug>/categories")
31+
@login_required
32+
def list_categories(org_slug):
33+
_get_org_or_403(org_slug)
34+
return jsonify({"categories": NOTIFICATION_CATEGORIES})
35+
36+
37+
@notification_routing_bp.route("/<org_slug>/routes")
38+
@login_required
39+
def list_routes(org_slug):
40+
"""Get all routes, optionally filtered by category.
41+
42+
Query params: category (optional)
43+
"""
44+
organization = _get_org_or_403(org_slug)
45+
46+
query = NotificationRoute.query.filter_by(organization_id=organization.id)
47+
48+
category = request.args.get("category")
49+
if category:
50+
if category not in NOTIFICATION_CATEGORIES:
51+
return jsonify({"error": "Invalid category"}), 400
52+
type_keys = [
53+
f"{category}.{t}" for t in NOTIFICATION_CATEGORIES[category]["types"]
54+
]
55+
query = query.filter(
56+
NotificationRoute.notification_types.op("?|")(cast(type_keys, ARRAY(TEXT)))
57+
)
58+
59+
routes = query.all()
60+
return jsonify(
61+
{
62+
"routes": [_route_to_dict(r) for r in routes],
63+
}
64+
)
65+
66+
67+
@notification_routing_bp.route("/<org_slug>/routes", methods=["POST"])
68+
@login_required
69+
def save_route(org_slug):
70+
"""Create or update a single route.
71+
72+
JSON body: {
73+
notification_types: ["pipeline.deploy", "pipeline.release"],
74+
project_ids: ["uuid", ...], // empty = any
75+
environment_ids: ["uuid", ...],
76+
application_ids: ["uuid", ...],
77+
integration: "slack",
78+
channel_id: "C001",
79+
channel_name: "#deploys",
80+
enabled: true
81+
}
82+
"""
83+
organization = _get_org_or_403(org_slug)
84+
data = request.get_json()
85+
if not data:
86+
abort(400)
87+
88+
ntypes = data.get("notification_types") or []
89+
if not ntypes:
90+
return jsonify({"error": "notification_types is required"}), 400
91+
92+
# Validate all types
93+
all_valid = set()
94+
for cat_key, cat_info in NOTIFICATION_CATEGORIES.items():
95+
for t in cat_info["types"]:
96+
all_valid.add(f"{cat_key}.{t}")
97+
for ntype in ntypes:
98+
if ntype not in all_valid:
99+
return jsonify({"error": f"Invalid notification type: {ntype}"}), 400
100+
101+
integration = data.get("integration")
102+
if not integration or integration not in ("slack", "discord"):
103+
return jsonify({"error": "integration must be 'slack' or 'discord'"}), 400
104+
105+
channel_id = data.get("channel_id")
106+
if not channel_id:
107+
return jsonify({"error": "channel_id is required"}), 400
108+
109+
project_ids = data.get("project_ids") or []
110+
environment_ids = data.get("environment_ids") or []
111+
application_ids = data.get("application_ids") or []
112+
113+
route_id = data.get("id")
114+
if route_id:
115+
route = NotificationRoute.query.filter_by(
116+
id=route_id, organization_id=organization.id
117+
).first_or_404()
118+
route.notification_types = ntypes
119+
route.project_ids = project_ids
120+
route.environment_ids = environment_ids
121+
route.application_ids = application_ids
122+
route.integration = integration
123+
route.channel_id = channel_id
124+
route.channel_name = data.get("channel_name")
125+
route.enabled = data.get("enabled", True)
126+
else:
127+
route = NotificationRoute(
128+
organization_id=organization.id,
129+
notification_types=ntypes,
130+
project_ids=project_ids,
131+
environment_ids=environment_ids,
132+
application_ids=application_ids,
133+
integration=integration,
134+
channel_id=channel_id,
135+
channel_name=data.get("channel_name"),
136+
enabled=data.get("enabled", True),
137+
)
138+
db.session.add(route)
139+
140+
db.session.commit()
141+
return jsonify(_route_to_dict(route)), 201
142+
143+
144+
@notification_routing_bp.route("/<org_slug>/routes/<route_id>", methods=["DELETE"])
145+
@login_required
146+
def delete_route(org_slug, route_id):
147+
organization = _get_org_or_403(org_slug)
148+
route = NotificationRoute.query.filter_by(
149+
id=route_id, organization_id=organization.id
150+
).first_or_404()
151+
db.session.delete(route)
152+
db.session.commit()
153+
return jsonify({"status": "ok"})
154+
155+
156+
@notification_routing_bp.route("/<org_slug>/scopes")
157+
@login_required
158+
def list_scopes(org_slug):
159+
organization = _get_org_or_403(org_slug)
160+
161+
from cabotage.server.models.projects import Project
162+
163+
projects = (
164+
Project.query.filter_by(organization_id=organization.id)
165+
.filter(Project.deleted_at.is_(None))
166+
.all()
167+
)
168+
169+
result = []
170+
for project in projects:
171+
result.append(
172+
{
173+
"id": str(project.id),
174+
"name": project.name,
175+
"slug": project.slug,
176+
"environments": [
177+
{"id": str(e.id), "name": e.name, "slug": e.slug}
178+
for e in project.active_environments
179+
],
180+
"applications": [
181+
{"id": str(a.id), "name": a.name, "slug": a.slug}
182+
for a in project.active_applications
183+
],
184+
}
185+
)
186+
187+
return jsonify({"projects": result})
188+
189+
190+
def _route_to_dict(route):
191+
return {
192+
"id": str(route.id),
193+
"notification_types": route.notification_types or [],
194+
"project_ids": route.project_ids or [],
195+
"environment_ids": route.environment_ids or [],
196+
"application_ids": route.application_ids or [],
197+
"integration": route.integration,
198+
"channel_id": route.channel_id,
199+
"channel_name": route.channel_name,
200+
"enabled": route.enabled,
201+
}
202+
203+
204+
def init_notification_routing(app):
205+
app.register_blueprint(notification_routing_bp)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
import uuid
5+
from typing import Any, TypedDict
6+
7+
from sqlalchemy import Boolean, DateTime, ForeignKey, String, text
8+
from sqlalchemy.dialects import postgresql
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship, backref
10+
11+
from cabotage.server import Model
12+
13+
14+
class NotificationCategory(TypedDict):
15+
label: str
16+
types: dict[str, str]
17+
scoped: bool
18+
19+
20+
NOTIFICATION_CATEGORIES: dict[str, NotificationCategory] = {
21+
"organization": {
22+
"label": "Organization",
23+
"types": {
24+
"usage": "Usage",
25+
},
26+
"scoped": False,
27+
},
28+
"usage": {
29+
"label": "Usage",
30+
"types": {
31+
"cpu": "CPU",
32+
"memory": "RAM",
33+
"bandwidth": "Bandwidth",
34+
},
35+
"scoped": True,
36+
},
37+
"pipeline": {
38+
"label": "Pipeline",
39+
"types": {
40+
"image_build": "Image Build",
41+
"release": "Release",
42+
"deploy": "Deploy",
43+
},
44+
"scoped": True,
45+
},
46+
"health": {
47+
"label": "Service Health",
48+
"types": {
49+
"oom": "OOM Killed",
50+
"crash_restart": "Crashes & Restarts",
51+
},
52+
"scoped": True,
53+
},
54+
"http": {
55+
"label": "HTTP",
56+
"types": {
57+
"5xx": "5xx Errors",
58+
"latency": "Latency",
59+
},
60+
"scoped": True,
61+
},
62+
}
63+
64+
65+
class NotificationRoute(Model):
66+
__tablename__ = "notification_routes"
67+
68+
id: Mapped[uuid.UUID] = mapped_column(
69+
postgresql.UUID(as_uuid=True),
70+
server_default=text("gen_random_uuid()"),
71+
primary_key=True,
72+
)
73+
organization_id: Mapped[uuid.UUID] = mapped_column(
74+
postgresql.UUID(as_uuid=True),
75+
ForeignKey("organizations.id"),
76+
index=True,
77+
)
78+
notification_types: Mapped[Any] = mapped_column(
79+
postgresql.JSONB(), server_default=text("'[]'::jsonb")
80+
)
81+
project_ids: Mapped[Any] = mapped_column(
82+
postgresql.JSONB(), server_default=text("'[]'::jsonb")
83+
)
84+
environment_ids: Mapped[Any] = mapped_column(
85+
postgresql.JSONB(), server_default=text("'[]'::jsonb")
86+
)
87+
application_ids: Mapped[Any] = mapped_column(
88+
postgresql.JSONB(), server_default=text("'[]'::jsonb")
89+
)
90+
integration: Mapped[str] = mapped_column(String(32))
91+
channel_id: Mapped[str] = mapped_column(String(64))
92+
channel_name: Mapped[str | None] = mapped_column(String(255))
93+
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
94+
created_at: Mapped[datetime.datetime] = mapped_column(
95+
DateTime, default=datetime.datetime.now
96+
)
97+
updated_at: Mapped[datetime.datetime] = mapped_column(
98+
DateTime,
99+
default=datetime.datetime.now,
100+
onupdate=datetime.datetime.now,
101+
)
102+
103+
organization = relationship(
104+
"Organization",
105+
backref=backref("notification_routes", lazy="dynamic"),
106+
)
107+
108+
def __repr__(self):
109+
return f"<NotificationRoute {self.id} types={self.notification_types} integration={self.integration}>"

cabotage/server/user/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,8 @@ def organization_settings(org_slug):
826826
],
827827
)
828828

829+
from cabotage.server.models.notifications import NOTIFICATION_CATEGORIES
830+
829831
return render_template(
830832
"user/organization_settings.html",
831833
organization=organization,
@@ -837,6 +839,7 @@ def organization_settings(org_slug):
837839
oidc_issuer_url=oidc.issuer_url(),
838840
slack_integration=organization.slack_integration,
839841
discord_integration=organization.discord_integration,
842+
notification_categories=NOTIFICATION_CATEGORIES,
840843
)
841844

842845

0 commit comments

Comments
 (0)