from __future__ import annotations import json import unittest from unittest.mock import patch from monitors.yumi.service_release import deploy_runtime_services_payload from monitors.yumi.tencent_clb import call_clb, collect_service_bindings, prepare_gateway_release_clb_snapshot class GatewayClbClientTests(unittest.TestCase): @patch("monitors.yumi.tencent_clb.invoke_clb") @patch("monitors.yumi.tencent_clb.build_clb_client") def test_call_clb_accepts_top_level_payload_shape( self, build_clb_client_mock, invoke_clb_mock, ) -> None: class DummyRequest: def from_json_string(self, text: str) -> None: self.text = text class DummyModels: DescribeTargetsRequest = DummyRequest class DummyResponse: def to_json_string(self) -> str: return json.dumps({"Listeners": [{"ListenerId": "lbl-http"}], "RequestId": "req-1"}) class DummyClient: def DescribeTargets(self, request): return request build_clb_client_mock.return_value = (DummyClient(), "") invoke_clb_mock.return_value = DummyResponse() import tencentcloud.clb.v20180317 as clb_pkg with patch.object(clb_pkg, "models", DummyModels(), create=True): payload = call_clb("DescribeTargets", {"LoadBalancerId": "lb-test"}) self.assertEqual(payload["RequestId"], "req-1") self.assertEqual(payload["Listeners"][0]["ListenerId"], "lbl-http") class GatewayClbDiscoveryTests(unittest.TestCase): @patch("monitors.yumi.tencent_clb.GATEWAY_CLB_ALLOWED_DOMAINS", ("jvapi.example.com",)) def test_collect_service_bindings_filters_by_allowed_domains(self) -> None: listeners = [ { "ListenerId": "lbl-http", "Rules": [ { "LocationId": "loc-a", "Domain": "api.example.com", "Url": "/", "Targets": [ { "InstanceId": "ins-gw-1", "Port": 8080, "Weight": 20, "PrivateIpAddresses": ["10.0.0.1"], } ], }, { "LocationId": "loc-b", "Domain": "jvapi.example.com", "Url": "/", "Targets": [ { "InstanceId": "ins-gw-1", "Port": 8080, "Weight": 10, "PrivateIpAddresses": ["10.0.0.1"], } ], }, ], } ] bindings = collect_service_bindings( listeners, { "host": "gateway-1", "service": "gateway", "ip": "10.0.0.1", "instanceId": "ins-gw-1", "port": 8080, }, ) self.assertEqual( [(item["domain"], item["locationId"], item["weight"]) for item in bindings], [("jvapi.example.com", "loc-b", 10)], ) @patch("monitors.yumi.tencent_clb.GATEWAY_CLB_ALLOWED_DOMAINS", ()) @patch("monitors.yumi.tencent_clb.GATEWAY_CLB_LOAD_BALANCER_ID", "lb-test") @patch("monitors.yumi.tencent_clb.GATEWAY_CLB_LISTENER_IDS", ("lbl-http", "lbl-https")) @patch("monitors.yumi.tencent_clb.describe_gateway_targets") def test_prepare_gateway_release_clb_snapshot_collects_all_matching_rules( self, describe_gateway_targets_mock, ) -> None: describe_gateway_targets_mock.return_value = { "Listeners": [ { "ListenerId": "lbl-http", "Rules": [ { "LocationId": "loc-a", "Domain": "api.example.com", "Url": "/", "Targets": [ { "InstanceId": "ins-gw-1", "Port": 8080, "Weight": 20, "PrivateIpAddresses": ["10.0.0.1"], } ], }, { "LocationId": "loc-b", "Domain": "admin.example.com", "Url": "/", "Targets": [ { "InstanceId": "ins-gw-1", "Port": 8080, "Weight": 10, "PrivateIpAddresses": ["10.0.0.1"], } ], }, ], }, { "ListenerId": "lbl-https", "Rules": [ { "LocationId": "loc-c", "Domain": "api.example.com", "Url": "/", "Targets": [ { "InstanceId": "ins-gw-2", "Port": 8080, "Weight": 15, "PrivateIpAddresses": ["10.0.0.2"], } ], } ], }, ] } snapshot = prepare_gateway_release_clb_snapshot( { "host": "gateway-1", "service": "gateway", "ip": "10.0.0.1", "instanceId": "ins-gw-1", "port": 8080, } ) self.assertEqual(snapshot["loadBalancerId"], "lb-test") self.assertEqual(snapshot["listenerIds"], ["lbl-http", "lbl-https"]) self.assertEqual( {(item["listenerId"], item["locationId"], item["weight"]) for item in snapshot["bindings"]}, {("lbl-http", "loc-a", 20), ("lbl-http", "loc-b", 10)}, ) @patch("monitors.yumi.tencent_clb.GATEWAY_CLB_ALLOWED_DOMAINS", ()) def test_collect_service_bindings_ignores_other_targets(self) -> None: listeners = [ { "ListenerId": "lbl-http", "Rules": [ { "LocationId": "loc-a", "Targets": [ { "InstanceId": "ins-gw-1", "Port": 8080, "Weight": 20, "PrivateIpAddresses": ["10.0.0.1"], }, { "InstanceId": "ins-gw-2", "Port": 8080, "Weight": 20, "PrivateIpAddresses": ["10.0.0.2"], }, ], } ], } ] bindings = collect_service_bindings( listeners, { "host": "gateway-1", "service": "gateway", "ip": "10.0.0.1", "instanceId": "ins-gw-1", "port": 8080, }, ) self.assertEqual(len(bindings), 1) self.assertEqual(bindings[0]["instanceId"], "ins-gw-1") class GatewayReleaseFlowTests(unittest.TestCase): @patch("monitors.yumi.service_release.build_service_images") @patch("monitors.yumi.service_release.service_records_by_name") @patch("monitors.yumi.service_release.buildable_service_names", return_value=["gateway", "auth"]) @patch("monitors.yumi.service_release.host_image_plan") @patch("monitors.yumi.service_release.update_local_service_image") @patch("monitors.yumi.service_release.sync_remote_host_compose_template") @patch("monitors.yumi.service_release.prepull_host_images") @patch("monitors.yumi.service_release.prepare_gateway_release_clb_snapshot") @patch("monitors.yumi.service_release.drain_gateway_release_target") @patch("monitors.yumi.service_release.restart_service_instance") @patch("monitors.yumi.service_release.wait_http_ready") @patch("monitors.yumi.service_release.wait_java_registration_ready") @patch("monitors.yumi.service_release.wait_gateway_release_target_healthy") @patch("monitors.yumi.service_release.restore_gateway_release_target") def test_gateway_update_drains_before_restart_and_restores_after_health( self, restore_gateway_release_target_mock, wait_gateway_release_target_healthy_mock, wait_java_registration_ready_mock, wait_http_ready_mock, restart_service_instance_mock, drain_gateway_release_target_mock, prepare_gateway_release_clb_snapshot_mock, prepull_host_images_mock, sync_remote_host_compose_template_mock, update_local_service_image_mock, host_image_plan_mock, buildable_service_names_mock, service_records_by_name_mock, build_service_images_mock, ) -> None: del buildable_service_names_mock gateway_record = { "host": "gateway-1", "ip": "10.0.0.1", "instanceId": "ins-gw-1", "port": 8080, "path": "/health", "service": "gateway", "kind": "java", } service_records_by_name_mock.return_value = {"gateway": [gateway_record]} build_service_images_mock.return_value = { "serviceImages": {"gateway": "registry/gateway:new"}, "builds": [], } host_image_plan_mock.return_value = [ { "host": "gateway-1", "ip": "10.0.0.1", "services": ["gateway"], "images": ["registry/gateway:new"], } ] update_local_service_image_mock.return_value = {"host": "gateway-1", "service": "gateway"} sync_remote_host_compose_template_mock.return_value = { "path": "/tmp/docker-compose.yml", "changed": False, "source": "local", "backupPath": "", } prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"} prepare_gateway_release_clb_snapshot_mock.return_value = { "loadBalancerId": "lb-test", "listenerIds": ["lbl-http"], "bindings": [{"listenerId": "lbl-http", "locationId": "loc-a", "domain": "", "url": "", "port": 8080, "weight": 10}], "bindingSummary": "lbl-http / loc-a -> 10", } drain_gateway_release_target_mock.return_value = {"summary": "lbl-http / loc-a -> 0"} restart_service_instance_mock.return_value = {"status": "SUCCESS", "output": "restarted"} wait_http_ready_mock.return_value = {"ok": True, "statusCode": 200, "latencyMs": 12, "detail": "ok"} wait_java_registration_ready_mock.return_value = { "checked": True, "healthy": True, "enabled": True, "source": "instance/list", } wait_gateway_release_target_healthy_mock.return_value = [{"listenerId": "lbl-http", "locationId": "loc-a", "healthStatus": True}] restore_gateway_release_target_mock.return_value = {"summary": "lbl-http / loc-a -> 10"} call_order: list[str] = [] prepare_gateway_release_clb_snapshot_mock.side_effect = lambda service: call_order.append("prepare") or { "loadBalancerId": "lb-test", "listenerIds": ["lbl-http"], "bindings": [{"listenerId": "lbl-http", "locationId": "loc-a", "domain": "", "url": "", "port": 8080, "weight": 10}], "bindingSummary": "lbl-http / loc-a -> 10", } drain_gateway_release_target_mock.side_effect = lambda snapshot: call_order.append("drain") or {"summary": "lbl-http / loc-a -> 0"} restart_service_instance_mock.side_effect = lambda service: call_order.append("restart") or {"status": "SUCCESS", "output": "restarted"} wait_http_ready_mock.side_effect = lambda service, timeout_seconds: call_order.append("http") or { "ok": True, "statusCode": 200, "latencyMs": 12, "detail": "ok", } wait_java_registration_ready_mock.side_effect = lambda service, timeout_seconds: call_order.append("nacos") or { "checked": True, "healthy": True, "enabled": True, "source": "instance/list", } wait_gateway_release_target_healthy_mock.side_effect = lambda snapshot, timeout_seconds: call_order.append("clb-health") or [ {"listenerId": "lbl-http", "locationId": "loc-a", "healthStatus": True} ] restore_gateway_release_target_mock.side_effect = lambda snapshot: call_order.append("restore") or { "summary": "lbl-http / loc-a -> 10" } result = deploy_runtime_services_payload( ["gateway"], java_git_ref="main", go_git_ref="main", image_tag="20260423", skip_maven_build=True, ) self.assertTrue(result["ok"]) self.assertEqual(call_order, ["prepare", "drain", "restart", "http", "nacos", "clb-health", "restore"]) self.assertEqual(result["steps"][0]["clb"]["restoredSummary"], "lbl-http / loc-a -> 10") @patch("monitors.yumi.service_release.build_service_images") @patch("monitors.yumi.service_release.service_records_by_name") @patch("monitors.yumi.service_release.buildable_service_names", return_value=["gateway"]) @patch("monitors.yumi.service_release.host_image_plan") @patch("monitors.yumi.service_release.update_local_service_image") @patch("monitors.yumi.service_release.sync_remote_host_compose_template") @patch("monitors.yumi.service_release.prepull_host_images") @patch("monitors.yumi.service_release.prepare_gateway_release_clb_snapshot") @patch("monitors.yumi.service_release.drain_gateway_release_target") @patch("monitors.yumi.service_release.restart_service_instance", side_effect=RuntimeError("restart failed")) @patch("monitors.yumi.service_release.restore_gateway_release_target") def test_gateway_update_keeps_target_drained_when_restart_fails( self, restore_gateway_release_target_mock, restart_service_instance_mock, drain_gateway_release_target_mock, prepare_gateway_release_clb_snapshot_mock, prepull_host_images_mock, sync_remote_host_compose_template_mock, update_local_service_image_mock, host_image_plan_mock, buildable_service_names_mock, service_records_by_name_mock, build_service_images_mock, ) -> None: del buildable_service_names_mock, restart_service_instance_mock gateway_record = { "host": "gateway-1", "ip": "10.0.0.1", "instanceId": "ins-gw-1", "port": 8080, "path": "/health", "service": "gateway", "kind": "java", } service_records_by_name_mock.return_value = {"gateway": [gateway_record]} build_service_images_mock.return_value = { "serviceImages": {"gateway": "registry/gateway:new"}, "builds": [], } host_image_plan_mock.return_value = [ { "host": "gateway-1", "ip": "10.0.0.1", "services": ["gateway"], "images": ["registry/gateway:new"], } ] update_local_service_image_mock.return_value = {"host": "gateway-1", "service": "gateway"} sync_remote_host_compose_template_mock.return_value = { "path": "/tmp/docker-compose.yml", "changed": False, "source": "local", "backupPath": "", } prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"} prepare_gateway_release_clb_snapshot_mock.return_value = { "loadBalancerId": "lb-test", "listenerIds": ["lbl-http"], "bindings": [{"listenerId": "lbl-http", "locationId": "loc-a", "domain": "", "url": "", "port": 8080, "weight": 10}], "bindingSummary": "lbl-http / loc-a -> 10", } drain_gateway_release_target_mock.return_value = {"summary": "lbl-http / loc-a -> 0"} result = deploy_runtime_services_payload( ["gateway"], java_git_ref="main", go_git_ref="main", image_tag="20260423", skip_maven_build=True, ) self.assertFalse(result["ok"]) self.assertIn("restart failed", result["error"]) restore_gateway_release_target_mock.assert_not_called() self.assertEqual(result["steps"][0]["clb"]["drainedSummary"], "lbl-http / loc-a -> 0") @patch("monitors.yumi.service_release.build_service_images") @patch("monitors.yumi.service_release.service_records_by_name") @patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth"]) @patch("monitors.yumi.service_release.host_image_plan") @patch("monitors.yumi.service_release.update_local_service_image") @patch("monitors.yumi.service_release.sync_remote_host_compose_template") @patch("monitors.yumi.service_release.prepull_host_images") @patch("monitors.yumi.service_release.prepare_gateway_release_clb_snapshot") @patch("monitors.yumi.service_release.restart_service_instance") @patch("monitors.yumi.service_release.wait_http_ready") @patch("monitors.yumi.service_release.wait_java_registration_ready") def test_non_gateway_update_skips_clb_protection( self, wait_java_registration_ready_mock, wait_http_ready_mock, restart_service_instance_mock, prepare_gateway_release_clb_snapshot_mock, prepull_host_images_mock, sync_remote_host_compose_template_mock, update_local_service_image_mock, host_image_plan_mock, buildable_service_names_mock, service_records_by_name_mock, build_service_images_mock, ) -> None: del buildable_service_names_mock auth_record = { "host": "app-1", "ip": "10.0.0.3", "instanceId": "ins-app-1", "port": 1000, "path": "/actuator/health", "service": "auth", "kind": "java", } service_records_by_name_mock.return_value = {"auth": [auth_record]} build_service_images_mock.return_value = { "serviceImages": {"auth": "registry/auth:new"}, "builds": [], } host_image_plan_mock.return_value = [ { "host": "app-1", "ip": "10.0.0.3", "services": ["auth"], "images": ["registry/auth:new"], } ] update_local_service_image_mock.return_value = {"host": "app-1", "service": "auth"} sync_remote_host_compose_template_mock.return_value = { "path": "/tmp/docker-compose.yml", "changed": False, "source": "local", "backupPath": "", } prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"} restart_service_instance_mock.return_value = {"status": "SUCCESS", "output": "restarted"} wait_http_ready_mock.return_value = {"ok": True, "statusCode": 200, "latencyMs": 11, "detail": "ok"} wait_java_registration_ready_mock.return_value = { "checked": True, "healthy": True, "enabled": True, "source": "instance/list", } result = deploy_runtime_services_payload( ["auth"], java_git_ref="main", go_git_ref="main", image_tag="20260423", skip_maven_build=True, ) self.assertTrue(result["ok"]) prepare_gateway_release_clb_snapshot_mock.assert_not_called() @patch("monitors.yumi.service_release.build_service_images") @patch("monitors.yumi.service_release.service_records_by_name") @patch("monitors.yumi.service_release.buildable_service_names", return_value=["other"]) @patch("monitors.yumi.service_release.host_image_plan") @patch("monitors.yumi.service_release.update_local_service_image") @patch("monitors.yumi.service_release.sync_remote_host_compose_template") @patch("monitors.yumi.service_release.prepull_host_images") @patch("monitors.yumi.service_release.drain_java_registration") @patch("monitors.yumi.service_release.restart_service_instance") @patch("monitors.yumi.service_release.wait_http_ready") @patch("monitors.yumi.service_release.restore_java_registration") def test_other_update_drains_nacos_before_restart( self, restore_java_registration_mock, wait_http_ready_mock, restart_service_instance_mock, drain_java_registration_mock, prepull_host_images_mock, sync_remote_host_compose_template_mock, update_local_service_image_mock, host_image_plan_mock, buildable_service_names_mock, service_records_by_name_mock, build_service_images_mock, ) -> None: del buildable_service_names_mock other_record = { "host": "app-1", "ip": "10.0.0.3", "instanceId": "ins-app-1", "port": 2400, "path": "/actuator/health", "service": "other", "kind": "java", } service_records_by_name_mock.return_value = {"other": [other_record]} build_service_images_mock.return_value = { "serviceImages": {"other": "registry/other:new"}, "builds": [], } host_image_plan_mock.return_value = [ { "host": "app-1", "ip": "10.0.0.3", "services": ["other"], "images": ["registry/other:new"], } ] update_local_service_image_mock.return_value = {"host": "app-1", "service": "other"} sync_remote_host_compose_template_mock.return_value = { "path": "/tmp/docker-compose.yml", "changed": False, "source": "local", "backupPath": "", } prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"} wait_http_ready_mock.return_value = {"ok": True, "statusCode": 200, "latencyMs": 11, "detail": "ok"} drain_java_registration_mock.return_value = { "checked": True, "enabled": False, "source": "instance/list", "drainSeconds": 8.0, } restore_java_registration_mock.return_value = { "checked": True, "healthy": True, "enabled": True, "source": "instance/list", "restored": True, } call_order: list[str] = [] drain_java_registration_mock.side_effect = lambda service: call_order.append("drain") or { "checked": True, "enabled": False, "source": "instance/list", "drainSeconds": 8.0, } restart_service_instance_mock.side_effect = lambda service: call_order.append("restart") or { "status": "SUCCESS", "output": "restarted", } wait_http_ready_mock.side_effect = lambda service, timeout_seconds: call_order.append("http") or { "ok": True, "statusCode": 200, "latencyMs": 11, "detail": "ok", } restore_java_registration_mock.side_effect = lambda service: call_order.append("restore") or { "checked": True, "healthy": True, "enabled": True, "source": "instance/list", "restored": True, } result = deploy_runtime_services_payload( ["other"], java_git_ref="main", go_git_ref="main", image_tag="20260423", skip_maven_build=True, ) self.assertTrue(result["ok"]) self.assertEqual(call_order, ["drain", "restart", "http", "restore"]) self.assertEqual(result["steps"][0]["nacosDrain"]["enabled"], False) self.assertEqual(result["steps"][0]["nacos"]["restored"], True) if __name__ == "__main__": unittest.main()