Skip to content

feat(remote-config): Add proxy endpoint for configurations #71773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@
from sentry.monitors.endpoints.project_processing_errors_details import (
ProjectProcessingErrorsDetailsEndpoint,
)
from sentry.remote_config.endpoints import ProjectConfigurationEndpoint
from sentry.remote_config.endpoints import (
ProjectConfigurationEndpoint,
ProjectConfigurationProxyEndpoint,
)
from sentry.replays.endpoints.organization_replay_count import OrganizationReplayCountEndpoint
from sentry.replays.endpoints.organization_replay_details import OrganizationReplayDetailsEndpoint
from sentry.replays.endpoints.organization_replay_events_meta import (
Expand Down Expand Up @@ -3256,6 +3259,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
SetupWizard.as_view(),
name="sentry-api-0-project-wizard",
),
re_path(
r"^remote-config/projects/(?P<project_id>[^\/]+)$",
ProjectConfigurationProxyEndpoint.as_view(),
name="sentry-api-0-project-remote-configuration",
),
# Internal
re_path(
r"^internal/",
Expand Down
44 changes: 41 additions & 3 deletions src/sentry/remote_config/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Host: https://sentry.io/api/0

### Get Configuration [GET]

Retrieve the DSN's configuration.
Retrieve the project's configuration.

**Attributes**

Expand Down Expand Up @@ -64,7 +64,7 @@ Retrieve the DSN's configuration.

### Set Configuration [POST]

Set the DSN's configuration.
Set the project's configuration.

- Request

Expand Down Expand Up @@ -114,6 +114,44 @@ Set the DSN's configuration.

### Delete Configuration [DELETE]

Delete the DSN's configuration.
Delete the project's configuration.

- Response 204

## Configuration Proxy [/remote-config/projects/<project_id>/]

Temporary configuration proxy resource.

### Get Configuration [GET]

Fetch a project's configuration. Responses should be proxied exactly to the SDK.

- Response 200

- Headers

Cache-Control: public, max-age=3600
Content-Type: application/json
ETag: a7966bf58e23583c9a5a4059383ff850

- Body

```json
{
"features": [
{
"key": "hello",
"value": "world"
},
{
"key": "has_access",
"value": true
}
],
"options": {
"sample_rate": 1.0,
"traces_sample_rate": 0.5
},
"version": 1
}
```
73 changes: 68 additions & 5 deletions src/sentry/remote_config/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import hashlib

from django.contrib.auth.models import AnonymousUser
from rest_framework import serializers
from rest_framework.authentication import BasicAuthentication
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.authentication import AuthenticationSiloLimit
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission
from sentry.api.permissions import RelayPermission
from sentry.models.project import Project
from sentry.remote_config.storage import make_storage
from sentry.remote_config.storage import BlobDriver, make_storage
from sentry.silo.base import SiloMode
from sentry.utils import json, metrics


class OptionsValidator(Serializer):
Expand Down Expand Up @@ -49,7 +57,7 @@ def get(self, request: Request, project: Project) -> Response:
if not features.has(
"organizations:remote-config", project.organization, actor=request.user
):
return Response(status=404)
return Response("Disabled", status=404)

remote_config = make_storage(project).get()
if remote_config is None:
Expand All @@ -62,7 +70,7 @@ def post(self, request: Request, project: Project) -> Response:
if not features.has(
"organizations:remote-config", project.organization, actor=request.user
):
return Response(status=404)
return Response("Disabled", status=404)

validator = ConfigurationContainerValidator(data=request.data)
if not validator.is_valid():
Expand All @@ -71,14 +79,69 @@ def post(self, request: Request, project: Project) -> Response:
result = validator.validated_data["data"]

make_storage(project).set(result)
metrics.incr("remote_config.configuration.write")
return Response({"data": result}, status=201)

def delete(self, request: Request, project: Project) -> Response:
"""Delete remote configuration from project options."""
if not features.has(
"organizations:remote-config", project.organization, actor=request.user
):
return Response(status=404)
return Response("Disabled", status=404)

make_storage(project).pop()
metrics.incr("remote_config.configuration.delete")
return Response("", status=204)


@AuthenticationSiloLimit(SiloMode.REGION)
class RelayAuthentication(BasicAuthentication):
"""Same as default Relay authentication except without body signing."""

def authenticate(self, request: Request):
return (AnonymousUser(), None)


class RemoteConfigRelayPermission(RelayPermission):
def has_permission(self, request: Request, view: object) -> bool:
# Relay has permission to do everything! Except the only thing we expose is a simple
# read endpoint full of public data...
return True


@region_silo_endpoint
class ProjectConfigurationProxyEndpoint(Endpoint):
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}
owner = ApiOwner.REMOTE_CONFIG
authentication_classes = (RelayAuthentication,)
permission_classes = (RemoteConfigRelayPermission,)
enforce_rate_limit = False

def get(self, request: Request, project_id: int) -> Response:
metrics.incr("remote_config.configuration.requested")

project = Project.objects.select_related("organization").get(pk=project_id)
if not features.has("organizations:remote-config", project.organization, actor=None):
metrics.incr("remote_config.configuration.flag_disabled")
return Response("Disabled", status=404)

result = BlobDriver(project).get()
if result is None:
metrics.incr("remote_config.configuration.not_found")
return Response("Not found", status=404)

result_str = json.dumps(result)
metrics.incr("remote_config.configuration.returned")
metrics.distribution("remote_config.configuration.size", value=len(result_str))

# Emulating cache headers just because.
return Response(
result,
status=200,
headers={
"Cache-Control": "public, max-age=3600",
"ETag": hashlib.sha1(result_str.encode()).hexdigest(),
},
)
69 changes: 68 additions & 1 deletion tests/sentry/remote_config/endpoints/test_configuration.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from typing import Any
from uuid import uuid4

from django.urls import reverse
from sentry_relay.auth import generate_key_pair

from sentry.models.relay import Relay
from sentry.remote_config.storage import StorageBackend
from sentry.testutils.cases import APITestCase

REMOTE_CONFIG_FEATURES = {"organizations:remote-config": True}


class ConfiguratioAPITestCase(APITestCase):
class ConfigurationAPITestCase(APITestCase):
endpoint = "sentry-api-0-project-key-configuration"

def setUp(self):
Expand Down Expand Up @@ -174,3 +177,67 @@ def test_delete_configuration_not_found(self):
def test_delete_configuration_not_enabled(self):
response = self.client.delete(self.url)
assert response.status_code == 404


class ConfigurationProxyAPITestCase(APITestCase):
endpoint = "sentry-api-0-project-remote-configuration"

def setUp(self):
super().setUp()
self.url = reverse(self.endpoint, args=(self.project.id,))

@property
def storage(self):
return StorageBackend(self.project)

def test_remote_config_proxy(self):
"""Assert configurations are returned successfully."""
self.storage.set(
{
"features": [{"key": "abc", "value": "def"}],
"options": {"sample_rate": 0.5, "traces_sample_rate": 0},
},
)

keys = generate_key_pair()
relay = Relay.objects.create(
relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True
)

with self.feature(REMOTE_CONFIG_FEATURES):
response = self.client.get(
self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id
)
assert response.status_code == 200
assert response["ETag"] is not None
assert response["Cache-Control"] == "public, max-age=3600"
assert response["Content-Type"] == "application/json"

def test_remote_config_proxy_not_found(self):
"""Assert missing configurations 404."""
self.storage.pop()

keys = generate_key_pair()
relay = Relay.objects.create(
relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True
)

with self.feature(REMOTE_CONFIG_FEATURES):
response = self.client.get(
self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id
)
assert response.status_code == 404

def test_remote_config_proxy_feature_disabled(self):
"""Assert access is gated by feature flag."""
self.storage.pop()

keys = generate_key_pair()
relay = Relay.objects.create(
relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True
)

response = self.client.get(
self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id
)
assert response.status_code == 404
Loading