Skip to content

Commit

Permalink
finish up the api
Browse files Browse the repository at this point in the history
  • Loading branch information
wedamija committed Sep 21, 2024
1 parent 561246c commit 6aa2a89
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 49 deletions.
25 changes: 14 additions & 11 deletions src/sentry/uptime/endpoints/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
create_project_uptime_subscription,
create_uptime_subscription,
)
from sentry.utils import json
from sentry.utils.audit import create_audit_entry

MAX_MONITORS_PER_DOMAIN = 100
Expand Down Expand Up @@ -55,26 +54,30 @@ class UptimeMonitorValidator(CamelSnakeSerializer):
body = serializers.CharField(required=False)

def validate(self, attrs):
request_line_size = len(f"{attrs.get('method', 'GET')} {attrs['url']} HTTP/1.1\r\n")
headers_dict = {}
method = "GET"
body = ""
url = ""
if self.instance:
headers_dict = self.instance.uptime_subscription.headers
method = self.instance.uptime_subscription.method
body = self.instance.uptime_subscription.body or ""
url = self.instance.uptime_subscription.url
request_line_size = len(
f"{attrs.get('method', method)} {attrs.get('url', url)} HTTP/1.1\r\n"
)

headers_dict = json.loads(attrs.get("headers", "{}"))
headers_dict = attrs.get("headers", headers_dict)
headers_size = sum(len(f"{key}: {value}\r\n") for key, value in headers_dict.items())

body_size = len(attrs.get("body", ""))
body_size = len(attrs.get("body", body))

request_size = request_line_size + headers_size + len("\r\n") + body_size

if request_size > MAX_REQUEST_SIZE:
raise serializers.ValidationError("Request is too large")
return attrs

def validate_headers(self, headers):
try:
json.loads(headers)
return headers
except ValueError:
raise serializers.ValidationError("Headers must be valid JSON")

def validate_method(self, method):
if method not in SUPPORTED_HTTP_METHODS:
raise serializers.ValidationError("Specify a valid and supported HTTP method")
Expand Down
15 changes: 7 additions & 8 deletions src/sentry/uptime/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
from sentry.utils.function_cache import cache_func_for_models
from sentry.utils.json import JSONEncoder

headers_json_encoder = JSONEncoder(
separators=(",", ":"),
# We sort the keys here so that we can deterministically compare headers
sort_keys=True,
).encode


@region_silo_model
class UptimeSubscription(BaseRemoteSubscription, DefaultFieldsModelExisting):
Expand All @@ -48,14 +54,7 @@ class UptimeSubscription(BaseRemoteSubscription, DefaultFieldsModelExisting):
# HTTP method to perform the check with
method = models.CharField(max_length=20, db_default="GET")
# HTTP headers to send when performing the check
headers = JSONField(
json_dumps=JSONEncoder(
separators=(",", ":"),
# We sort the keys here so that we can deterministically compare headers
sort_keys=True,
).encode,
db_default={},
)
headers = JSONField(json_dumps=headers_json_encoder, db_default={})
# HTTP body to send when performing the check
body = models.TextField(null=True)

Expand Down
57 changes: 35 additions & 22 deletions src/sentry/uptime/subscriptions/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
from typing import Any

from django.db import IntegrityError
from django.db.models import TextField
from django.db.models.expressions import Value
from django.db.models.functions import MD5, Coalesce

from sentry.db.models.fields.text import CharField
from sentry.models.project import Project
from sentry.types.actor import Actor
from sentry.uptime.detectors.url_extraction import extract_domain_parts
from sentry.uptime.models import (
ProjectUptimeSubscription,
ProjectUptimeSubscriptionMode,
UptimeSubscription,
headers_json_encoder,
)
from sentry.uptime.rdap.tasks import fetch_subscription_rdap_info
from sentry.uptime.subscriptions.tasks import (
Expand All @@ -30,39 +31,47 @@


def retrieve_uptime_subscription(
url: str, interval_seconds: int, method: str = "GET", headers: str = "{}", body: str = ""
url: str, interval_seconds: int, method: str, headers: dict[str, str], body: str | None
) -> UptimeSubscription | None:
headers_md5 = hashlib.md5(headers.encode("utf-8")).hexdigest()
body_md5 = hashlib.md5(body.encode("utf-8")).hexdigest()

filtered_qs = UptimeSubscription.objects.filter(
url=url, interval_seconds=interval_seconds, method=method
)
return (
filtered_qs.annotate(
headers_md5=MD5("headers"),
body_md5=Coalesce(MD5("body"), Value(""), output_field=CharField()),
)
.filter(
headers_md5=headers_md5,
body_md5=body_md5,
try:
subscription = (
UptimeSubscription.objects.filter(
url=url, interval_seconds=interval_seconds, method=method
)
.annotate(
headers_md5=MD5("headers", output_field=TextField()),
body_md5=Coalesce(MD5("body"), Value(""), output_field=TextField()),
)
.filter(
headers_md5=hashlib.md5(headers_json_encoder(headers).encode("utf-8")).hexdigest(),
body_md5=hashlib.md5(body.encode("utf-8")).hexdigest() if body else "",
)
.get()
)
.first()
)
except UptimeSubscription.DoesNotExist:
subscription = None
return subscription


def create_uptime_subscription(
url: str, interval_seconds: int, timeout_ms: int = DEFAULT_SUBSCRIPTION_TIMEOUT_MS, **kwargs
url: str,
interval_seconds: int,
timeout_ms: int = DEFAULT_SUBSCRIPTION_TIMEOUT_MS,
method: str = "GET",
headers: dict[str, str] | None = None,
body: str | None = None,
) -> UptimeSubscription:
"""
Creates a new uptime subscription. This creates the row in postgres, and fires a task that will send the config
to the uptime check system.
"""
if headers is None:
headers = {}
# We extract the domain and suffix of the url here. This is used to prevent there being too many checks to a single
# domain.
result = extract_domain_parts(url)

subscription = retrieve_uptime_subscription(url, interval_seconds, **kwargs)
subscription = retrieve_uptime_subscription(url, interval_seconds, method, headers, body)
created = False

if subscription is None:
Expand All @@ -75,12 +84,16 @@ def create_uptime_subscription(
timeout_ms=timeout_ms,
status=UptimeSubscription.Status.CREATING.value,
type=UPTIME_SUBSCRIPTION_TYPE,
**kwargs,
method=method,
headers=headers,
body=body,
)
created = True
except IntegrityError:
# Handle race condition where we tried to retrieve an existing subscription while it was being created
subscription = retrieve_uptime_subscription(url, interval_seconds, **kwargs)
subscription = retrieve_uptime_subscription(
url, interval_seconds, method, headers, body
)

if subscription.status == UptimeSubscription.Status.DELETING.value:
# This is pretty unlikely to happen, but we should avoid deleting the subscription here and just confirm it
Expand Down
44 changes: 36 additions & 8 deletions tests/sentry/uptime/endpoints/test_project_uptime_alert_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def test_headers_body_method(self):
interval_seconds=60,
method="POST",
body='{"key": "value"}',
headers='{"header": "value"}',
headers={"header": "value"},
)
uptime_monitor = ProjectUptimeSubscription.objects.get(id=resp.data["id"])
uptime_subscription = uptime_monitor.uptime_subscription
Expand All @@ -128,8 +128,8 @@ def test_headers_body_method(self):
assert uptime_subscription.headers == {"header": "value"}

@with_feature("organizations:uptime-api-create-update")
def test_bad_headers(self):
resp = self.get_error_response(
def test_headers_body_method_already_exists(self):
resp = self.get_success_response(
self.organization.slug,
self.project.slug,
name="test",
Expand All @@ -138,11 +138,39 @@ def test_bad_headers(self):
interval_seconds=60,
method="POST",
body='{"key": "value"}',
headers='{"header: "value"}',
headers={"header": "value"},
)
uptime_monitor = ProjectUptimeSubscription.objects.get(id=resp.data["id"])
new_proj = self.create_project()
resp = self.get_success_response(
self.organization.slug,
new_proj.slug,
name="test",
owner=f"user:{self.user.id}",
url="http://sentry.io",
interval_seconds=60,
method="POST",
body='{"key": "value"}',
headers={"header": "value"},
)
new_uptime_monitor = ProjectUptimeSubscription.objects.get(id=resp.data["id"])
assert uptime_monitor.uptime_subscription_id == new_uptime_monitor.uptime_subscription_id
assert new_uptime_monitor.project_id != uptime_monitor.project_id
resp = self.get_success_response(
self.organization.slug,
new_proj.slug,
name="test",
owner=f"user:{self.user.id}",
url="http://sentry.io",
interval_seconds=60,
method="POST",
body='{"key": "value"}',
headers={"header": "valu"},
)
newer_uptime_monitor = ProjectUptimeSubscription.objects.get(id=resp.data["id"])
assert (
newer_uptime_monitor.uptime_subscription_id != new_uptime_monitor.uptime_subscription_id
)
assert resp.data == {
"headers": [ErrorDetail(string="Headers must be valid JSON", code="invalid")]
}

@with_feature("organizations:uptime-api-create-update")
def test_size_too_big(self):
Expand All @@ -155,7 +183,7 @@ def test_size_too_big(self):
interval_seconds=60,
method="POST",
body="body" * 250,
headers='{"header": "value"}',
headers={"header": "value"},
)
assert resp.data == {
"nonFieldErrors": [ErrorDetail(string="Request is too large", code="invalid")]
Expand Down

0 comments on commit 6aa2a89

Please sign in to comment.