Skip to content

Commit b3cd9f5

Browse files
authored
feat: svc-rabbitmq support policy (#2735)
1 parent 618f68e commit b3cd9f5

8 files changed

Lines changed: 443 additions & 9 deletions

File tree

svc-rabbitmq/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,8 @@ dependencies = [
5454
'urllib3 (==1.26.20)',
5555
'pyjwt (==2.4.0)'
5656
]
57+
58+
59+
[tool.pytest.ini_options]
60+
DJANGO_SETTINGS_MODULE = "svc_rabbitmq.settings"
61+
python_files = ["test_*.py"]

svc-rabbitmq/svc_rabbitmq/settings/__init__.py

100755100644
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@ class PatchFeatures:
3535

3636
@cached_property
3737
def minimum_database_version(self):
38-
if self.connection.mysql_is_mariadb: # noqa
38+
if self.connection.mysql_is_mariadb:
3939
return (10, 4)
4040
else:
4141
return (5, 7)
4242

4343

4444
# Django 4.2+ 不再官方支持 Mysql 5.7,但目前 Django 仅是对 5.7 做了软性的不兼容改动,
4545
# 在没有使用 8.0 特异的功能时,对 5.7 版本的使用无影响,为兼容存量的 Mysql 5.7 DB 做此 Patch
46-
DatabaseFeatures.minimum_database_version = PatchFeatures.minimum_database_version # noqa
46+
DatabaseFeatures.minimum_database_version = PatchFeatures.minimum_database_version
4747

4848
pymysql.install_as_MySQLdb()
4949
# Patch version info to force pass Django client check
@@ -176,7 +176,7 @@ def minimum_database_version(self):
176176
"disable_existing_loggers": True,
177177
"formatters": {
178178
"verbose": {
179-
"format": "%(name)s %(levelname)s [%(asctime)s] %(name)s(ln:%(lineno)d): %(message)s", # noqa
179+
"format": "%(name)s %(levelname)s [%(asctime)s] %(name)s(ln:%(lineno)d): %(message)s",
180180
"datefmt": "%Y-%m-%d %H:%M:%S",
181181
},
182182
"simple": {"format": "%(levelname)s %(message)s"},
@@ -254,6 +254,7 @@ def minimum_database_version(self):
254254
RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE = env.str("RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE", "dlx.queue")
255255
RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE_DURABLE = env.bool("RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE_DURABLE", False)
256256
RABBITMQ_MANAGEMENT_API_CACHE_SECONDS = env.int("RABBITMQ_MANAGEMENT_API_CACHE_SECONDS", 3)
257+
RABBITMQ_HA_POLICY_ENABLED = env.bool("RABBITMQ_HA_POLICY_ENABLED", True)
257258

258259
MODEL_TAG_CLUSTER = "cluster"
259260

svc-rabbitmq/tests/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
5+
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
6+
Licensed under the MIT License (the "License"); you may not use this file except
7+
in compliance with the License. You may obtain a copy of the License at
8+
9+
http://opensource.org/licenses/MIT
10+
11+
Unless required by applicable law or agreed to in writing, software distributed under
12+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
We undertake not to change the open source license (MIT license) applicable
17+
to the current version of the project delivered to anyone in the future.
18+
"""

svc-rabbitmq/tests/conftest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
5+
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
6+
Licensed under the MIT License (the "License"); you may not use this file except
7+
in compliance with the License. You may obtain a copy of the License at
8+
9+
http://opensource.org/licenses/MIT
10+
11+
Unless required by applicable law or agreed to in writing, software distributed under
12+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
We undertake not to change the open source license (MIT license) applicable
17+
to the current version of the project delivered to anyone in the future.
18+
"""
19+
20+
from unittest.mock import MagicMock, create_autospec
21+
22+
import pytest
23+
from vendor.client import ManagementClient, VirtualHostHandler
24+
from vendor.clusters import Cluster
25+
26+
27+
@pytest.fixture()
28+
def mock_client() -> MagicMock:
29+
"""Fixture to provide a mocked ManagementClient."""
30+
31+
client: ManagementClient = create_autospec(ManagementClient, instance=True)
32+
# Ensure nested handlers are plain MagicMocks for easy assertion
33+
client.virtual_host = MagicMock(spec=VirtualHostHandler)
34+
client.user_policy = MagicMock()
35+
client.limit_policy = MagicMock()
36+
client.exchange = MagicMock()
37+
client.queue = MagicMock()
38+
client.user = MagicMock()
39+
40+
return client
41+
42+
43+
def make_cluster(version: str = "3.9.0", **overrides) -> Cluster:
44+
"""Build a Cluster pydantic model for testing."""
45+
46+
defaults = {
47+
"host": "127.0.0.1",
48+
"port": 5672,
49+
"management_api": "http://127.0.0.1:15672",
50+
"admin": "admin",
51+
"password": "password",
52+
"version": version,
53+
"tls": {},
54+
}
55+
defaults.update(overrides)
56+
57+
return Cluster(**defaults)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
5+
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
6+
Licensed under the MIT License (the "License"); you may not use this file except
7+
in compliance with the License. You may obtain a copy of the License at
8+
9+
http://opensource.org/licenses/MIT
10+
11+
Unless required by applicable law or agreed to in writing, software distributed under
12+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
We undertake not to change the open source license (MIT license) applicable
17+
to the current version of the project delivered to anyone in the future.
18+
"""
19+
20+
from unittest.mock import MagicMock, patch
21+
22+
import pytest
23+
from vendor.provider import Provider
24+
25+
from .conftest import make_cluster
26+
27+
28+
class TestProviderPickCluster:
29+
"""pick_cluster: fallback from flat fields vs. weighted clusters list."""
30+
31+
def test_from_flat_fields(self):
32+
provider = Provider(
33+
host="10.0.0.1",
34+
port=5672,
35+
management_api="http://10.0.0.1:15672",
36+
admin="admin",
37+
password="secret",
38+
version="3.9.0",
39+
)
40+
cluster = provider.pick_cluster()
41+
assert cluster.host == "10.0.0.1"
42+
assert cluster.version == "3.9.0"
43+
44+
def test_raises_on_invalid_flat_fields(self):
45+
provider = Provider(host="10.0.0.1")
46+
with pytest.raises(ValueError, match="cluster 配置不正确"):
47+
provider.pick_cluster()
48+
49+
def test_from_clusters_list(self):
50+
provider = Provider(
51+
clusters=[
52+
{
53+
"weight": 100,
54+
"values": {
55+
"host": "10.0.0.2",
56+
"port": 5672,
57+
"management_api": "http://10.0.0.2:15672",
58+
"admin": "admin",
59+
"password": "secret",
60+
"version": "3.8.0",
61+
},
62+
}
63+
]
64+
)
65+
cluster = provider.pick_cluster()
66+
assert cluster.host == "10.0.0.2"
67+
68+
69+
class TestProviderResolveClusterPK:
70+
"""resolve_cluster_pk: match by management_api or fallback to host/port."""
71+
72+
def _db_cluster(self, pk, management_api, host="127.0.0.1", port=5672):
73+
obj = MagicMock()
74+
obj.pk = pk
75+
obj.management_api = management_api
76+
obj.host = host
77+
obj.port = port
78+
return obj
79+
80+
@patch("vendor.provider.ClusterModel.objects")
81+
def test_match_by_management_api(self, mock_objects):
82+
db_cluster = self._db_cluster(pk=42, management_api="http://10.0.0.1:15672/")
83+
mock_objects.filter.return_value = [db_cluster]
84+
85+
cluster = make_cluster(management_api="http://10.0.0.1:15672")
86+
assert Provider().resolve_cluster_pk(cluster) == 42
87+
88+
@patch("vendor.provider.ClusterModel.objects")
89+
def test_returns_none_when_no_match(self, mock_objects):
90+
db_cluster = self._db_cluster(pk=1, management_api="http://other:15672")
91+
mock_objects.filter.return_value = [db_cluster]
92+
93+
cluster = make_cluster(management_api="http://10.0.0.1:15672")
94+
assert Provider().resolve_cluster_pk(cluster) is None
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
5+
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
6+
Licensed under the MIT License (the "License"); you may not use this file except
7+
in compliance with the License. You may obtain a copy of the License at
8+
9+
http://opensource.org/licenses/MIT
10+
11+
Unless required by applicable law or agreed to in writing, software distributed under
12+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
We undertake not to change the open source license (MIT license) applicable
17+
to the current version of the project delivered to anyone in the future.
18+
"""
19+
20+
import pytest
21+
from django.test import override_settings
22+
from vendor.provider import DeadLetterRoutingProviderPlugin, HAProviderPlugin
23+
24+
from .conftest import make_cluster
25+
26+
27+
@pytest.fixture()
28+
def cluster():
29+
"""Cluster with version 3.9.0 (>= 3.8, supports quorum)."""
30+
return make_cluster("3.9.0")
31+
32+
33+
@pytest.fixture()
34+
def old_cluster():
35+
"""Cluster with version 3.7.0 (< 3.8, classic mirror only)."""
36+
return make_cluster("3.7.0")
37+
38+
39+
class TestHAProviderPlugin:
40+
"""HAProviderPlugin: version-based branching between quorum and classic mirror."""
41+
42+
def test_quorum_path_for_version_gte_3_8(self, cluster, mock_client):
43+
"""For RabbitMQ >= 3.8, HAProviderPlugin should use quorum queues."""
44+
plugin = HAProviderPlugin(context={}, client=mock_client, cluster=cluster, virtual_host="vh")
45+
plugin.on_create()
46+
47+
mock_client.virtual_host.create.assert_called_once_with("vh", default_queue_type="quorum")
48+
mock_client.user_policy.create.assert_not_called()
49+
50+
def test_classic_mirror_path_for_version_lt_3_8(self, old_cluster, mock_client):
51+
"""For RabbitMQ < 3.8, HAProviderPlugin should use classic mirrored queues."""
52+
plugin = HAProviderPlugin(context={}, client=mock_client, cluster=old_cluster, virtual_host="vh")
53+
plugin.on_create()
54+
55+
mock_client.virtual_host.create.assert_not_called()
56+
mock_client.user_policy.create.assert_called_once()
57+
_, args, _ = mock_client.user_policy.create.mock_calls[0]
58+
assert args[0] == "vh"
59+
assert args[1] == HAProviderPlugin.HA_POLICY_NAME
60+
assert args[2]["definition"]["ha-mode"] == "all"
61+
62+
@override_settings(RABBITMQ_HA_POLICY_ENABLED=False)
63+
def test_noop_when_disabled(self, cluster, mock_client):
64+
"""Setting disabled → no calls at all."""
65+
plugin = HAProviderPlugin(context={}, client=mock_client, cluster=cluster, virtual_host="vh")
66+
plugin.on_create()
67+
68+
mock_client.virtual_host.create.assert_not_called()
69+
mock_client.user_policy.create.assert_not_called()
70+
71+
72+
class TestDeadLetterRoutingProviderPlugin:
73+
"""DeadLetterRoutingProviderPlugin: dead letter exchange/queue setup with quorum awareness."""
74+
75+
DLX_SETTINGS = {
76+
"RABBITMQ_DEFAULT_DEAD_LETTER_ROUTING_KEY": "",
77+
"RABBITMQ_DEFAULT_DEAD_LETTER_EXCHANGE": "dlx.exchange",
78+
"RABBITMQ_DEFAULT_DEAD_LETTER_EXCHANGE_TYPE": "direct",
79+
"RABBITMQ_DEFAULT_DEAD_LETTER_EXCHANGE_DURABLE": False,
80+
"RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE": "dlx.queue",
81+
"RABBITMQ_DEFAULT_DEAD_LETTER_QUEUE_DURABLE": False,
82+
}
83+
84+
@override_settings(**DLX_SETTINGS)
85+
def test_quorum_queue_for_version_gte_3_8(self, cluster, mock_client):
86+
"""DLQ declared as quorum type with durable=True for >= 3.8."""
87+
ctx = {}
88+
plugin = DeadLetterRoutingProviderPlugin(context=ctx, client=mock_client, cluster=cluster, virtual_host="vh")
89+
plugin.on_create()
90+
91+
_, kwargs = mock_client.queue.declare.call_args
92+
assert kwargs["arguments"] == {"x-queue-type": "quorum"}
93+
assert kwargs["durable"] is True
94+
95+
override_settings(**DLX_SETTINGS)
96+
97+
def test_classic_queue_for_version_lt_3_8(self, old_cluster, mock_client):
98+
"""DLQ declared without quorum arguments for < 3.8."""
99+
plugin = DeadLetterRoutingProviderPlugin(
100+
context={}, client=mock_client, cluster=old_cluster, virtual_host="vh"
101+
)
102+
plugin.on_create()
103+
104+
_, kwargs = mock_client.queue.declare.call_args
105+
assert kwargs["arguments"] == {}
106+
assert kwargs["durable"] is False
107+
108+
@override_settings(**DLX_SETTINGS)
109+
def test_skipped_for_version_lt_2_8(self, mock_client):
110+
"""Versions below 2.8 → on_create is a no-op."""
111+
c = make_cluster("2.7.0")
112+
plugin = DeadLetterRoutingProviderPlugin(context={}, client=mock_client, cluster=c, virtual_host="vh")
113+
plugin.on_create()
114+
115+
mock_client.exchange.declare.assert_not_called()
116+
mock_client.queue.declare.assert_not_called()
117+
118+
@override_settings(**DLX_SETTINGS)
119+
def test_context_populated(self, cluster, mock_client):
120+
"""After on_create, context should contain dlx-* keys."""
121+
ctx = {}
122+
plugin = DeadLetterRoutingProviderPlugin(context=ctx, client=mock_client, cluster=cluster, virtual_host="vh")
123+
plugin.on_create()
124+
125+
assert ctx["dlx-exchange"] == "dlx.exchange"
126+
assert ctx["dlx-queue"] == "dlx.queue"
127+
assert ctx["dlx-routing-key"] == ""

svc-rabbitmq/vendor/client.py

100755100644
Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,26 @@ def partial(self, path: "str", method: "str", headers=None) -> "typing.Callable"
8181
return partial(self.request, path=path, method=method, headers=headers)
8282

8383

84+
class VirtualHostHandler(VirtualHost):
85+
"""Extended VirtualHost handler that supports default_queue_type parameter."""
86+
87+
path = "vhosts/"
88+
89+
def create(self, name: str, default_queue_type: str | None = None):
90+
"""Create a virtual host.
91+
92+
:param name: The name of the virtual host to create.
93+
:param default_queue_type: Default queue type for the vhost (Optional, e.g. 'quorum', 'classic', 'stream')
94+
"""
95+
payload = {}
96+
if default_queue_type:
97+
payload["default_queue_type"] = default_queue_type
98+
self.http_client.put(
99+
urljoin(self.path, quote(name)),
100+
payload=payload,
101+
)
102+
103+
84104
class DefinitionsHandler(ManagementHandler):
85105
path = "definitions/"
86106

@@ -228,7 +248,7 @@ def __init__(self, api_url, username, password, timeout=10, verify=None, cert=No
228248
self.health_checks = HealthChecks(self.http_client)
229249
self.queue = Queue(self.http_client)
230250
self.user = User(self.http_client)
231-
self.virtual_host = VirtualHost(self.http_client)
251+
self.virtual_host = VirtualHostHandler(self.http_client)
232252
self.definitions = DefinitionsHandler(self.http_client)
233253
self.user_policy = UserPolicyHandler(self.http_client)
234254
self.limit_policy = LimitPolicyHandler(self.http_client)

0 commit comments

Comments
 (0)