Skip to content

Commit 6bf7a23

Browse files
feat: stateless warehouse connection test endpoint (#8060)
Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent 02a92d7 commit 6bf7a23

6 files changed

Lines changed: 191 additions & 9 deletions

File tree

api/app/settings/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,9 @@
601601
"WebhookScopeTypeEnum": ["organisation", "environment"],
602602
"SegmentRuleTypeEnum": "segments.models.SegmentRule.RULE_TYPES",
603603
"FeatureValueTypeEnum": ["integer", "string", "boolean"],
604+
"WarehouseConnectionStatusEnum": (
605+
"experimentation.models.WarehouseConnectionStatus.choices"
606+
),
604607
},
605608
"COMPONENT_NO_READ_ONLY_REQUIRED": True,
606609
}

api/experimentation/services.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -818,9 +818,13 @@ def _describe_verification_error(error: Exception) -> str:
818818
return "Verification failed."
819819

820820

821-
def verify_clickhouse_connection(connection: WarehouseConnection) -> None:
821+
def verify_clickhouse_connection(
822+
connection: WarehouseConnection,
823+
persist: bool = True,
824+
) -> None:
822825
"""Run SELECT 1 against the customer's ClickHouse and set the status to
823-
connected or errored; never raises."""
826+
connected or errored; never raises. With persist=False, the status is only
827+
set on the in-memory instance, allowing unsaved connections to be tested."""
824828
log = logger.bind(environment__id=connection.environment_id)
825829
try:
826830
log = log.bind(organisation__id=connection.environment.project.organisation_id)
@@ -847,7 +851,8 @@ def verify_clickhouse_connection(connection: WarehouseConnection) -> None:
847851
except Exception as error:
848852
connection.status = WarehouseConnectionStatus.ERRORED
849853
connection.status_detail = _describe_verification_error(error)
850-
connection.save(update_fields=["status", "status_detail"])
854+
if persist:
855+
connection.save(update_fields=["status", "status_detail"])
851856
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
852857
result="failure"
853858
).inc()
@@ -856,7 +861,8 @@ def verify_clickhouse_connection(connection: WarehouseConnection) -> None:
856861

857862
connection.status = WarehouseConnectionStatus.CONNECTED
858863
connection.status_detail = None
859-
connection.save(update_fields=["status", "status_detail"])
864+
if persist:
865+
connection.save(update_fields=["status", "status_detail"])
860866
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
861867
result="success"
862868
).inc()

api/experimentation/views.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from django.shortcuts import get_object_or_404
88
from django.utils import timezone
99
from django.utils.decorators import method_decorator
10-
from drf_spectacular.utils import OpenApiParameter, extend_schema
10+
from drf_spectacular.utils import (
11+
OpenApiParameter,
12+
extend_schema,
13+
inline_serializer,
14+
)
1115
from rest_framework import mixins, serializers, status
1216
from rest_framework.decorators import action
1317
from rest_framework.exceptions import Throttled, ValidationError
@@ -34,6 +38,7 @@
3438
ExperimentStatus,
3539
Metric,
3640
WarehouseConnection,
41+
WarehouseConnectionStatus,
3742
WarehouseType,
3843
)
3944
from experimentation.permissions import (
@@ -97,6 +102,7 @@ def get_throttles(self) -> list[BaseThrottle]:
97102
"update",
98103
"partial_update",
99104
"test_warehouse_connection",
105+
"test_warehouse_connection_config",
100106
):
101107
self.throttle_scope = "warehouse_connection_write"
102108
return [*super().get_throttles(), ScopedRateThrottle()]
@@ -167,6 +173,52 @@ def test_warehouse_connection(self, request: Request, **kwargs: object) -> Respo
167173
serializer = self.get_serializer(connection)
168174
return Response(serializer.data)
169175

176+
@extend_schema(
177+
operation_id=(
178+
"api_v1_environments_warehouse_connections_"
179+
"test_warehouse_connection_config_create"
180+
),
181+
responses={
182+
200: inline_serializer(
183+
name="WarehouseConnectionTestResult",
184+
fields={
185+
"status": serializers.ChoiceField(
186+
choices=WarehouseConnectionStatus.choices
187+
),
188+
"status_detail": serializers.CharField(allow_null=True),
189+
},
190+
)
191+
},
192+
)
193+
@action(
194+
detail=False,
195+
methods=["post"],
196+
url_path="test-warehouse-connection",
197+
url_name="test-warehouse-connection-config",
198+
)
199+
def test_warehouse_connection_config(
200+
self, request: Request, **kwargs: object
201+
) -> Response:
202+
serializer = self.get_serializer(data=request.data)
203+
serializer.is_valid(raise_exception=True)
204+
if serializer.validated_data.get("warehouse_type") != WarehouseType.CLICKHOUSE:
205+
return Response(
206+
{
207+
"detail": "Connection testing is not supported for this warehouse type."
208+
},
209+
status=status.HTTP_400_BAD_REQUEST,
210+
)
211+
connection = WarehouseConnection(
212+
environment=self._get_environment(),
213+
warehouse_type=serializer.validated_data["warehouse_type"],
214+
config=serializer.validated_data.get("config"),
215+
credentials=serializer.validated_data.get("credentials"),
216+
)
217+
verify_clickhouse_connection(connection, persist=False)
218+
return Response(
219+
{"status": connection.status, "status_detail": connection.status_detail}
220+
)
221+
170222
def create(self, request: Request, *args: object, **kwargs: object) -> Response:
171223
environment = self._get_environment()
172224
serializer = self.get_serializer(data=request.data)

api/tests/unit/experimentation/test_views.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import socket
22

33
import pytest
4+
from clickhouse_driver import errors as clickhouse_errors
45
from django.urls import reverse
56
from pytest_django.fixtures import SettingsWrapper
67
from pytest_mock import MockerFixture
@@ -1090,7 +1091,13 @@ def test_test_warehouse_connection__non_admin__returns_403(
10901091

10911092
@pytest.mark.parametrize(
10921093
"action",
1093-
["create", "update", "partial_update", "test_warehouse_connection"],
1094+
[
1095+
"create",
1096+
"update",
1097+
"partial_update",
1098+
"test_warehouse_connection",
1099+
"test_warehouse_connection_config",
1100+
],
10941101
)
10951102
def test_get_throttles__write_actions__returns_scoped_throttle(action: str) -> None:
10961103
# Given
@@ -1666,6 +1673,74 @@ def test_test_warehouse_connection__clickhouse__reverifies_and_returns_status(
16661673
assert clickhouse_connection.status == WarehouseConnectionStatus.CONNECTED
16671674

16681675

1676+
CLICKHOUSE_TEST_PAYLOAD = {
1677+
"warehouse_type": "clickhouse",
1678+
"config": {"host": "ch.example.com"},
1679+
"credentials": {"password": "hunter2"},
1680+
}
1681+
1682+
1683+
@pytest.mark.parametrize(
1684+
"data, client_side_effect, expected_status_code, expected_response",
1685+
[
1686+
pytest.param(
1687+
CLICKHOUSE_TEST_PAYLOAD,
1688+
None,
1689+
status.HTTP_200_OK,
1690+
{"status": "connected", "status_detail": None},
1691+
id="reachable",
1692+
),
1693+
pytest.param(
1694+
CLICKHOUSE_TEST_PAYLOAD,
1695+
clickhouse_errors.NetworkError("boom"),
1696+
status.HTTP_200_OK,
1697+
{"status": "errored", "status_detail": "Could not connect to the host."},
1698+
id="unreachable",
1699+
),
1700+
pytest.param(
1701+
{"warehouse_type": "flagsmith"},
1702+
None,
1703+
status.HTTP_400_BAD_REQUEST,
1704+
{"detail": "Connection testing is not supported for this warehouse type."},
1705+
id="non_clickhouse_type",
1706+
),
1707+
pytest.param(
1708+
{"warehouse_type": "clickhouse", "config": {"host": "ch.example.com"}},
1709+
None,
1710+
status.HTTP_400_BAD_REQUEST,
1711+
{"credentials": {"password": "This field is required."}},
1712+
id="invalid_payload",
1713+
),
1714+
],
1715+
)
1716+
def test_test_warehouse_connection_config__payload__returns_expected_response(
1717+
admin_client: APIClient,
1718+
client_side_effect: Exception | None,
1719+
data: dict[str, object],
1720+
enable_features: EnableFeaturesFixture,
1721+
environment: Environment,
1722+
expected_response: dict[str, object],
1723+
expected_status_code: int,
1724+
mocker: MockerFixture,
1725+
) -> None:
1726+
# Given
1727+
enable_features("experimentation_warehouse_connection")
1728+
mocker.patch("experimentation.services.Client", side_effect=client_side_effect)
1729+
url = reverse(
1730+
"api-v1:environments:experimentation:"
1731+
"warehouse-connections-test-warehouse-connection-config",
1732+
args=[environment.api_key],
1733+
)
1734+
1735+
# When
1736+
response = admin_client.post(url, data=data, format="json")
1737+
1738+
# Then
1739+
assert response.status_code == expected_status_code
1740+
assert response.json() == expected_response
1741+
assert not WarehouseConnection.objects.filter(environment=environment).exists()
1742+
1743+
16691744
def test_patch__clickhouse_to_flagsmith__resets_connection_state(
16701745
admin_client: APIClient,
16711746
clickhouse_connection: WarehouseConnection,

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ Attributes:
597597
### `warehouse.connection.connected`
598598

599599
Logged at `info` from:
600-
- `api/experimentation/services.py:878`
600+
- `api/experimentation/services.py:884`
601601

602602
Attributes:
603603
- `environment.id`
@@ -615,7 +615,7 @@ Attributes:
615615
### `warehouse.connection.verification_failed`
616616

617617
Logged at `warning` from:
618-
- `api/experimentation/services.py:854`
618+
- `api/experimentation/services.py:859`
619619

620620
Attributes:
621621
- `environment.id`
@@ -625,7 +625,7 @@ Attributes:
625625
### `warehouse.connection.verification_succeeded`
626626

627627
Logged at `info` from:
628-
- `api/experimentation/services.py:863`
628+
- `api/experimentation/services.py:869`
629629

630630
Attributes:
631631
- `environment.id`

openapi.yaml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7090,6 +7090,40 @@ paths:
70907090
- Master API Key: []
70917091
tags:
70927092
- Environments
7093+
'/api/v1/environments/{environment_api_key}/warehouse-connections/test-warehouse-connection/':
7094+
post:
7095+
operationId: api_v1_environments_warehouse_connections_test_warehouse_connection_config_create
7096+
description: Manage data warehouse connections for an environment.
7097+
parameters:
7098+
- name: environment_api_key
7099+
in: path
7100+
required: true
7101+
schema:
7102+
type: string
7103+
requestBody:
7104+
required: true
7105+
content:
7106+
application/json:
7107+
schema:
7108+
$ref: '#/components/schemas/WarehouseConnection'
7109+
application/x-www-form-urlencoded:
7110+
schema:
7111+
$ref: '#/components/schemas/WarehouseConnection'
7112+
multipart/form-data:
7113+
schema:
7114+
$ref: '#/components/schemas/WarehouseConnection'
7115+
responses:
7116+
'200':
7117+
description: ''
7118+
content:
7119+
application/json:
7120+
schema:
7121+
$ref: '#/components/schemas/WarehouseConnectionTestResult'
7122+
security:
7123+
- tokenAuth: []
7124+
- Master API Key: []
7125+
tags:
7126+
- Environments
70937127
'/api/v1/environments/{environment_api_key}/webhooks/':
70947128
get:
70957129
operationId: api_v1_environments_webhooks_list
@@ -27862,6 +27896,18 @@ components:
2786227896
- pending_connection
2786327897
- connected
2786427898
- errored
27899+
WarehouseConnectionTestResult:
27900+
type: object
27901+
properties:
27902+
status:
27903+
$ref: '#/components/schemas/WarehouseConnectionStatusEnum'
27904+
status_detail:
27905+
type:
27906+
- string
27907+
- 'null'
27908+
required:
27909+
- status
27910+
- status_detail
2786527911
WarehouseTypeEnum:
2786627912
description: |-
2786727913
* `flagsmith` - Flagsmith

0 commit comments

Comments
 (0)