补充 gateway 发布 CLB 摘流链路
This commit is contained in:
parent
aa3259583f
commit
463d45614d
@ -101,6 +101,11 @@ DEPLOY_REGION=me-saudi-arabia
|
|||||||
DEPLOY_INSTANCE_ID=
|
DEPLOY_INSTANCE_ID=
|
||||||
TENCENT_SECRET_ID=
|
TENCENT_SECRET_ID=
|
||||||
TENCENT_SECRET_KEY=
|
TENCENT_SECRET_KEY=
|
||||||
|
GATEWAY_CLB_LOAD_BALANCER_ID=
|
||||||
|
GATEWAY_CLB_LISTENER_IDS=
|
||||||
|
GATEWAY_CLB_POLL_SECONDS=2
|
||||||
|
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
|
||||||
|
GATEWAY_CLB_DRAIN_SECONDS=0
|
||||||
DEPLOY_SSH_HOST=
|
DEPLOY_SSH_HOST=
|
||||||
DEPLOY_SSH_USER=root
|
DEPLOY_SSH_USER=root
|
||||||
DEPLOY_SSH_PORT=22
|
DEPLOY_SSH_PORT=22
|
||||||
|
|||||||
14
README.md
14
README.md
@ -63,6 +63,11 @@ DEPLOY_REGION=me-saudi-arabia
|
|||||||
DEPLOY_INSTANCE_ID=
|
DEPLOY_INSTANCE_ID=
|
||||||
TENCENT_SECRET_ID=***
|
TENCENT_SECRET_ID=***
|
||||||
TENCENT_SECRET_KEY=***
|
TENCENT_SECRET_KEY=***
|
||||||
|
GATEWAY_CLB_LOAD_BALANCER_ID=lb-xxxxxxxx
|
||||||
|
GATEWAY_CLB_LISTENER_IDS=lbl-http,lbl-https
|
||||||
|
GATEWAY_CLB_POLL_SECONDS=2
|
||||||
|
GATEWAY_CLB_TASK_TIMEOUT_SECONDS=120
|
||||||
|
GATEWAY_CLB_DRAIN_SECONDS=0
|
||||||
SSH_USER=root
|
SSH_USER=root
|
||||||
SSH_PORT=22
|
SSH_PORT=22
|
||||||
SSH_PRIVATE_KEY_PATH=/opt/hy-app-monitor/run/ssh/id_ed25519
|
SSH_PRIVATE_KEY_PATH=/opt/hy-app-monitor/run/ssh/id_ed25519
|
||||||
@ -260,6 +265,14 @@ python3 ops/sync_runtime_compose.py --sync-remote
|
|||||||
6. 通过 `SSH` 先在目标机 `docker pull` 新镜像
|
6. 通过 `SSH` 先在目标机 `docker pull` 新镜像
|
||||||
7. 最后只对你选中的服务执行 `docker compose up -d --force-recreate --no-deps <service>`
|
7. 最后只对你选中的服务执行 `docker compose up -d --force-recreate --no-deps <service>`
|
||||||
|
|
||||||
|
如果选中的是 `gateway`,在真正重建单节点之前还会额外执行:
|
||||||
|
|
||||||
|
1. 读取 `GATEWAY_CLB_LOAD_BALANCER_ID` + `GATEWAY_CLB_LISTENER_IDS` 下当前节点的全部后端绑定
|
||||||
|
2. 把这些绑定的权重统一改成 `0`,避免新请求继续打到待重启节点
|
||||||
|
3. 可选按 `GATEWAY_CLB_DRAIN_SECONDS` 额外等待连接自然排空
|
||||||
|
4. 等实例本身 HTTP / Nacos 恢复后,再轮询 CLB 健康检查恢复
|
||||||
|
5. 最后把每条绑定恢复到摘流前的原权重
|
||||||
|
|
||||||
如果选中 `admin` 前端目标,则会改走:
|
如果选中 `admin` 前端目标,则会改走:
|
||||||
|
|
||||||
1. 在 deploy 机拉取 / 更新 `chatapp3-admin`
|
1. 在 deploy 机拉取 / 更新 `chatapp3-admin`
|
||||||
@ -273,6 +286,7 @@ python3 ops/sync_runtime_compose.py --sync-remote
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `other` 现在也按 Harbor 业务镜像更新,不再继续依赖本地 `./other/service.jar` 挂载
|
- `other` 现在也按 Harbor 业务镜像更新,不再继续依赖本地 `./other/service.jar` 挂载
|
||||||
|
- `gateway` 发布现在强依赖 CLB 摘流配置;缺少 `GATEWAY_CLB_LOAD_BALANCER_ID` 或 `GATEWAY_CLB_LISTENER_IDS` 时,页面会给 warning,实际执行也会直接失败而不是冒险重启
|
||||||
- Java Maven 默认走增量 `package`,并支持通过 `JAVA_MAVEN_THREADS` 开启 reactor 并行
|
- Java Maven 默认走增量 `package`,并支持通过 `JAVA_MAVEN_THREADS` 开启 reactor 并行
|
||||||
- 多个 Java 服务会按 `JAVA_IMAGE_BUILD_MAX_WORKERS` 受限并行执行 `docker build + push`
|
- 多个 Java 服务会按 `JAVA_IMAGE_BUILD_MAX_WORKERS` 受限并行执行 `docker build + push`
|
||||||
- `admin` 前端不会走 Harbor 镜像链路,而是直接发布静态文件到 `site`
|
- `admin` 前端不会走 Harbor 镜像链路,而是直接发布静态文件到 `site`
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .env import env_float, env_int, env_int_list, env_str, load_env_file
|
from .env import env_float, env_int, env_int_list, env_str, env_str_list, load_env_file
|
||||||
|
|
||||||
|
|
||||||
# 项目根目录。
|
# 项目根目录。
|
||||||
@ -114,6 +114,16 @@ DEPLOY_REGION = env_str("DEPLOY_REGION", "me-saudi-arabia")
|
|||||||
TENCENT_SECRET_ID = env_str("TENCENT_SECRET_ID", "")
|
TENCENT_SECRET_ID = env_str("TENCENT_SECRET_ID", "")
|
||||||
# 腾讯云 SecretKey。
|
# 腾讯云 SecretKey。
|
||||||
TENCENT_SECRET_KEY = env_str("TENCENT_SECRET_KEY", "")
|
TENCENT_SECRET_KEY = env_str("TENCENT_SECRET_KEY", "")
|
||||||
|
# gateway 发布做 CLB 摘流时使用的负载均衡实例 ID。
|
||||||
|
GATEWAY_CLB_LOAD_BALANCER_ID = env_str("GATEWAY_CLB_LOAD_BALANCER_ID", "")
|
||||||
|
# gateway 可能同时挂在 HTTP / HTTPS 等多个监听器下,这里统一允许逗号分隔配置。
|
||||||
|
GATEWAY_CLB_LISTENER_IDS = env_str_list("GATEWAY_CLB_LISTENER_IDS", [])
|
||||||
|
# CLB 异步任务轮询间隔。
|
||||||
|
GATEWAY_CLB_POLL_SECONDS = env_float("GATEWAY_CLB_POLL_SECONDS", 2.0)
|
||||||
|
# CLB 摘流 / 恢复异步任务最长等待秒数。
|
||||||
|
GATEWAY_CLB_TASK_TIMEOUT_SECONDS = env_int("GATEWAY_CLB_TASK_TIMEOUT_SECONDS", 120)
|
||||||
|
# 摘流后额外等待多少秒再重启;默认 0 表示只停新流量,不额外等待连接自然排空。
|
||||||
|
GATEWAY_CLB_DRAIN_SECONDS = env_float("GATEWAY_CLB_DRAIN_SECONDS", 0.0)
|
||||||
|
|
||||||
|
|
||||||
def pick_existing_path(candidates: list[Path]) -> Path:
|
def pick_existing_path(candidates: list[Path]) -> Path:
|
||||||
|
|||||||
15
core/env.py
15
core/env.py
@ -82,3 +82,18 @@ def env_int_list(name: str, default: list[int]) -> tuple[int, ...]:
|
|||||||
|
|
||||||
# 转成整数元组返回。
|
# 转成整数元组返回。
|
||||||
return tuple(int(item) for item in values)
|
return tuple(int(item) for item in values)
|
||||||
|
|
||||||
|
|
||||||
|
def env_str_list(name: str, default: list[str]) -> tuple[str, ...]:
|
||||||
|
# 先读取原始文本。
|
||||||
|
raw_value = env_str(name, "")
|
||||||
|
|
||||||
|
# 没配置时直接返回默认值。
|
||||||
|
if not raw_value:
|
||||||
|
return tuple(str(item).strip() for item in default if str(item).strip())
|
||||||
|
|
||||||
|
# 逐项切分并过滤空项。
|
||||||
|
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
# 返回去重后的字符串元组,避免调用方重复处理。
|
||||||
|
return tuple(dict.fromkeys(values))
|
||||||
|
|||||||
@ -42,6 +42,7 @@ from core.config import (
|
|||||||
ADMIN_FRONTEND_SSH_USE_SUDO,
|
ADMIN_FRONTEND_SSH_USE_SUDO,
|
||||||
ADMIN_FRONTEND_SSH_USER,
|
ADMIN_FRONTEND_SSH_USER,
|
||||||
BUILD_DOCKER_PLATFORM,
|
BUILD_DOCKER_PLATFORM,
|
||||||
|
GATEWAY_CLB_DRAIN_SECONDS,
|
||||||
GOLANG_REPO_ROOT,
|
GOLANG_REPO_ROOT,
|
||||||
HARBOR_PASSWORD,
|
HARBOR_PASSWORD,
|
||||||
HARBOR_PROJECT,
|
HARBOR_PROJECT,
|
||||||
@ -69,6 +70,13 @@ from .service_ops import (
|
|||||||
wait_java_registration_ready,
|
wait_java_registration_ready,
|
||||||
)
|
)
|
||||||
from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display, write_remote_text
|
from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display, write_remote_text
|
||||||
|
from .tencent_clb import (
|
||||||
|
drain_gateway_release_target,
|
||||||
|
gateway_clb_configuration_error,
|
||||||
|
prepare_gateway_release_clb_snapshot,
|
||||||
|
restore_gateway_release_target,
|
||||||
|
wait_gateway_release_target_healthy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Java 仓里当前支持 Harbor 更新的服务集合。
|
# Java 仓里当前支持 Harbor 更新的服务集合。
|
||||||
@ -1467,6 +1475,10 @@ def build_update_targets_payload() -> dict[str, Any]:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
# 某些主机模板暂时不可读时,仍然保留服务条目,只把原因透出去。
|
# 某些主机模板暂时不可读时,仍然保留服务条目,只把原因透出去。
|
||||||
|
if service_name == "gateway":
|
||||||
|
gateway_warning = gateway_clb_configuration_error()
|
||||||
|
if gateway_warning and gateway_warning not in compose_warnings:
|
||||||
|
compose_warnings.append(gateway_warning)
|
||||||
if compose_warnings:
|
if compose_warnings:
|
||||||
item["warnings"] = compose_warnings
|
item["warnings"] = compose_warnings
|
||||||
items.append(item)
|
items.append(item)
|
||||||
@ -1611,8 +1623,39 @@ def deploy_runtime_services_payload(
|
|||||||
"image": service_images[service_name],
|
"image": service_images[service_name],
|
||||||
"status": "running",
|
"status": "running",
|
||||||
}
|
}
|
||||||
|
gateway_clb_snapshot: dict[str, Any] | None = None
|
||||||
|
gateway_clb_health_ready = False
|
||||||
|
gateway_clb_restored = False
|
||||||
try:
|
try:
|
||||||
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
|
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
|
||||||
|
|
||||||
|
if service_name == "gateway":
|
||||||
|
emit_progress(progress, step_stage, "读取 gateway 当前 CLB 绑定")
|
||||||
|
gateway_clb_snapshot = prepare_gateway_release_clb_snapshot(service)
|
||||||
|
step["clb"] = {
|
||||||
|
"loadBalancerId": gateway_clb_snapshot["loadBalancerId"],
|
||||||
|
"listenerIds": list(gateway_clb_snapshot["listenerIds"]),
|
||||||
|
"bindingCount": len(gateway_clb_snapshot["bindings"]),
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"listenerId": binding["listenerId"],
|
||||||
|
"locationId": binding["locationId"],
|
||||||
|
"domain": binding["domain"],
|
||||||
|
"url": binding["url"],
|
||||||
|
"port": binding["port"],
|
||||||
|
"originalWeight": binding["weight"],
|
||||||
|
}
|
||||||
|
for binding in gateway_clb_snapshot["bindings"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
emit_progress(progress, step_stage, f"CLB 摘流前权重:{gateway_clb_snapshot['bindingSummary']}")
|
||||||
|
drain_result = drain_gateway_release_target(gateway_clb_snapshot)
|
||||||
|
step["clb"]["drainedSummary"] = drain_result["summary"]
|
||||||
|
emit_progress(progress, step_stage, f"CLB 摘流完成:{drain_result['summary']}")
|
||||||
|
if GATEWAY_CLB_DRAIN_SECONDS > 0:
|
||||||
|
emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s")
|
||||||
|
time.sleep(GATEWAY_CLB_DRAIN_SECONDS)
|
||||||
|
|
||||||
restart_result = restart_service_instance(service)
|
restart_result = restart_service_instance(service)
|
||||||
step["remoteStatus"] = restart_result.get("status")
|
step["remoteStatus"] = restart_result.get("status")
|
||||||
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
||||||
@ -1646,6 +1689,22 @@ def deploy_runtime_services_payload(
|
|||||||
f"Nacos 注册恢复:{step['nacos'].get('source') or 'ok'}",
|
f"Nacos 注册恢复:{step['nacos'].get('source') or 'ok'}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if gateway_clb_snapshot is not None:
|
||||||
|
clb_health = wait_gateway_release_target_healthy(
|
||||||
|
gateway_clb_snapshot,
|
||||||
|
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
gateway_clb_health_ready = True
|
||||||
|
step["clb"]["health"] = {
|
||||||
|
"ok": True,
|
||||||
|
"targets": clb_health,
|
||||||
|
}
|
||||||
|
emit_progress(progress, step_stage, "CLB 健康检查恢复,开始恢复转发权重")
|
||||||
|
restore_result = restore_gateway_release_target(gateway_clb_snapshot)
|
||||||
|
gateway_clb_restored = True
|
||||||
|
step["clb"]["restoredSummary"] = restore_result["summary"]
|
||||||
|
emit_progress(progress, step_stage, f"CLB 权重恢复完成:{restore_result['summary']}")
|
||||||
|
|
||||||
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
||||||
emit_progress(
|
emit_progress(
|
||||||
progress,
|
progress,
|
||||||
@ -1663,6 +1722,14 @@ def deploy_runtime_services_payload(
|
|||||||
f"实例更新成功,耗时 {step['durationSeconds']}s",
|
f"实例更新成功,耗时 {step['durationSeconds']}s",
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
|
if gateway_clb_snapshot is not None and gateway_clb_health_ready and not gateway_clb_restored:
|
||||||
|
try:
|
||||||
|
restore_result = restore_gateway_release_target(gateway_clb_snapshot)
|
||||||
|
gateway_clb_restored = True
|
||||||
|
step.setdefault("clb", {})["restoreRecovered"] = restore_result["summary"]
|
||||||
|
emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}")
|
||||||
|
except Exception as restore_exc: # noqa: BLE001
|
||||||
|
step.setdefault("clb", {})["restoreError"] = str(restore_exc)
|
||||||
step["status"] = "failed"
|
step["status"] = "failed"
|
||||||
step["error"] = str(exc)
|
step["error"] = str(exc)
|
||||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||||
|
|||||||
439
monitors/yumi/tencent_clb.py
Normal file
439
monitors/yumi/tencent_clb.py
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.config import (
|
||||||
|
DEPLOY_REGION,
|
||||||
|
GATEWAY_CLB_LISTENER_IDS,
|
||||||
|
GATEWAY_CLB_LOAD_BALANCER_ID,
|
||||||
|
GATEWAY_CLB_POLL_SECONDS,
|
||||||
|
GATEWAY_CLB_TASK_TIMEOUT_SECONDS,
|
||||||
|
TENCENT_SECRET_ID,
|
||||||
|
TENCENT_SECRET_KEY,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_clb_configuration_error() -> str:
|
||||||
|
# gateway 发布一旦要走摘流,就必须拿到完整的腾讯云和 CLB 配置。
|
||||||
|
missing: list[str] = []
|
||||||
|
if not TENCENT_SECRET_ID:
|
||||||
|
missing.append("TENCENT_SECRET_ID")
|
||||||
|
if not TENCENT_SECRET_KEY:
|
||||||
|
missing.append("TENCENT_SECRET_KEY")
|
||||||
|
if not DEPLOY_REGION:
|
||||||
|
missing.append("DEPLOY_REGION")
|
||||||
|
if not GATEWAY_CLB_LOAD_BALANCER_ID:
|
||||||
|
missing.append("GATEWAY_CLB_LOAD_BALANCER_ID")
|
||||||
|
if not GATEWAY_CLB_LISTENER_IDS:
|
||||||
|
missing.append("GATEWAY_CLB_LISTENER_IDS")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
return f"gateway CLB protection requires .env: {', '.join(missing)}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_clb_settings() -> dict[str, Any]:
|
||||||
|
# 配置检查单独收口,发布页预警和真正执行都复用同一套口径。
|
||||||
|
error = gateway_clb_configuration_error()
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"loadBalancerId": GATEWAY_CLB_LOAD_BALANCER_ID,
|
||||||
|
"listenerIds": list(GATEWAY_CLB_LISTENER_IDS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_clb_client() -> tuple[Any | None, str]:
|
||||||
|
# 先检查最基本的环境变量,缺任何一项都不继续请求腾讯云。
|
||||||
|
error = gateway_clb_configuration_error()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tencentcloud.common import credential
|
||||||
|
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||||
|
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||||
|
from tencentcloud.clb.v20180317 import clb_client
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, f"tencentcloud sdk unavailable: {exc}"
|
||||||
|
|
||||||
|
cred = credential.Credential(TENCENT_SECRET_ID, TENCENT_SECRET_KEY)
|
||||||
|
client = clb_client.ClbClient(
|
||||||
|
cred,
|
||||||
|
DEPLOY_REGION,
|
||||||
|
ClientProfile(httpProfile=HttpProfile(endpoint="clb.tencentcloudapi.com")),
|
||||||
|
)
|
||||||
|
return client, ""
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_clb(action: Any, label: str, retries: int = 3, delay_seconds: int = 2) -> Any:
|
||||||
|
# SDK 异常这里集中做简单重试,避免上层每一步都手写同样的退避逻辑。
|
||||||
|
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
return action()
|
||||||
|
except TencentCloudSDKException as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt == retries:
|
||||||
|
break
|
||||||
|
time.sleep(delay_seconds * attempt)
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise RuntimeError(f"{label} failed without error")
|
||||||
|
|
||||||
|
|
||||||
|
def call_clb(method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# CLB 调用统一走 json 请求体,后面加字段时不用反复和 SDK 模型属性打交道。
|
||||||
|
client, error = build_clb_client()
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
|
from tencentcloud.clb.v20180317 import models as clb_models
|
||||||
|
|
||||||
|
request_class = getattr(clb_models, f"{method_name}Request")
|
||||||
|
request = request_class()
|
||||||
|
request.from_json_string(json.dumps(payload))
|
||||||
|
response = invoke_clb(lambda: getattr(client, method_name)(request), method_name)
|
||||||
|
return dict(json.loads(response.to_json_string()).get("Response") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def wait_clb_task(task_id: str, *, timeout_seconds: int = GATEWAY_CLB_TASK_TIMEOUT_SECONDS) -> dict[str, Any]:
|
||||||
|
# 所有异步修改都轮询到终态,避免“接口返回成功但后端任务失败”的假成功。
|
||||||
|
if not task_id:
|
||||||
|
raise RuntimeError("missing CLB task id")
|
||||||
|
|
||||||
|
deadline = time.time() + max(float(timeout_seconds), 1.0)
|
||||||
|
last_payload: dict[str, Any] = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
last_payload = call_clb("DescribeTaskStatus", {"TaskId": task_id})
|
||||||
|
status = int(last_payload.get("Status") if last_payload.get("Status") is not None else 2)
|
||||||
|
if status == 0:
|
||||||
|
return last_payload
|
||||||
|
if status == 1:
|
||||||
|
message = str(last_payload.get("Message") or "CLB task failed").strip()
|
||||||
|
raise RuntimeError(f"CLB task failed: {message}")
|
||||||
|
time.sleep(max(float(GATEWAY_CLB_POLL_SECONDS), 0.1))
|
||||||
|
|
||||||
|
raise RuntimeError(f"CLB task timed out: {task_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def target_matches_service(target: dict[str, Any], service: dict[str, Any]) -> bool:
|
||||||
|
# 匹配优先认实例 ID,其次再用私网 IP 和端口兜底,避免误把另一台 gateway 当成当前节点。
|
||||||
|
target_instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
|
||||||
|
service_instance_id = str(service.get("instanceId") or "").strip()
|
||||||
|
if service_instance_id and target_instance_id and target_instance_id != service_instance_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target_port = int(target.get("Port") or 0)
|
||||||
|
service_port = int(service.get("port") or 0)
|
||||||
|
if service_port and target_port and target_port != service_port:
|
||||||
|
return False
|
||||||
|
|
||||||
|
service_ip = str(service.get("ip") or "").strip()
|
||||||
|
target_ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
|
||||||
|
if service_ip and target_ips and service_ip not in target_ips:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(target_instance_id or target_ips)
|
||||||
|
|
||||||
|
|
||||||
|
def binding_key(binding: dict[str, Any]) -> str:
|
||||||
|
# 同一实例可能同时挂在多个监听器/规则下,这里把绑定维度完整拼成稳定 key。
|
||||||
|
return "|".join(
|
||||||
|
[
|
||||||
|
str(binding.get("listenerId") or "").strip(),
|
||||||
|
str(binding.get("locationId") or "").strip(),
|
||||||
|
str(binding.get("instanceId") or "").strip(),
|
||||||
|
str(int(binding.get("port") or 0)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def binding_route_label(binding: dict[str, Any]) -> str:
|
||||||
|
# 日志里优先显示规则 ID,没有规则 ID 时退回到监听器 ID。
|
||||||
|
parts = [str(binding.get("listenerId") or "").strip()]
|
||||||
|
location_id = str(binding.get("locationId") or "").strip()
|
||||||
|
if location_id:
|
||||||
|
parts.append(location_id)
|
||||||
|
domain = str(binding.get("domain") or "").strip()
|
||||||
|
url = str(binding.get("url") or "").strip()
|
||||||
|
if domain or url:
|
||||||
|
parts.append(f"{domain or '*'}{url or '/'}")
|
||||||
|
return " / ".join(part for part in parts if part)
|
||||||
|
|
||||||
|
|
||||||
|
def build_binding(listener: dict[str, Any], service: dict[str, Any], target: dict[str, Any], *, rule: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
# 摘流和恢复都只需要最小必要字段,这里统一整理成稳定结构。
|
||||||
|
private_ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or []) if str(item or "").strip()]
|
||||||
|
binding = {
|
||||||
|
"listenerId": str(listener.get("ListenerId") or "").strip(),
|
||||||
|
"locationId": str((rule or {}).get("LocationId") or "").strip(),
|
||||||
|
"domain": str((rule or {}).get("Domain") or "").strip(),
|
||||||
|
"url": str((rule or {}).get("Url") or "").strip(),
|
||||||
|
"instanceId": str(target.get("InstanceId") or target.get("TargetId") or service.get("instanceId") or "").strip(),
|
||||||
|
"port": int(target.get("Port") or service.get("port") or 0),
|
||||||
|
"weight": int(target.get("Weight") or 0),
|
||||||
|
"privateIp": private_ips[0] if private_ips else str(service.get("ip") or "").strip(),
|
||||||
|
"routeLabel": "",
|
||||||
|
}
|
||||||
|
binding["routeLabel"] = binding_route_label(binding)
|
||||||
|
return binding
|
||||||
|
|
||||||
|
|
||||||
|
def collect_service_bindings(listeners: list[dict[str, Any]], service: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
# 自动扫描当前实例在配置监听器里的全部绑定,避免只摘一条规则导致仍有流量漏进来。
|
||||||
|
bindings_by_key: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for listener in listeners:
|
||||||
|
for target in listener.get("Targets") or []:
|
||||||
|
if not target_matches_service(target, service):
|
||||||
|
continue
|
||||||
|
binding = build_binding(listener, service, target, rule=None)
|
||||||
|
bindings_by_key[binding_key(binding)] = binding
|
||||||
|
|
||||||
|
for rule in listener.get("Rules") or []:
|
||||||
|
for target in rule.get("Targets") or []:
|
||||||
|
if not target_matches_service(target, service):
|
||||||
|
continue
|
||||||
|
binding = build_binding(listener, service, target, rule=rule)
|
||||||
|
bindings_by_key[binding_key(binding)] = binding
|
||||||
|
|
||||||
|
return list(bindings_by_key.values())
|
||||||
|
|
||||||
|
|
||||||
|
def describe_gateway_targets() -> dict[str, Any]:
|
||||||
|
# 直接按配置监听器拉绑定列表,摘流只关心这几个 listener 的后端状态。
|
||||||
|
settings = gateway_clb_settings()
|
||||||
|
return call_clb(
|
||||||
|
"DescribeTargets",
|
||||||
|
{
|
||||||
|
"LoadBalancerId": settings["loadBalancerId"],
|
||||||
|
"ListenerIds": settings["listenerIds"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_gateway_bindings(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
# 每次修改后都重新读一遍 CLB 当前值,确认线上状态已经真正落地。
|
||||||
|
payload = describe_gateway_targets()
|
||||||
|
return collect_service_bindings(list(payload.get("Listeners") or []), snapshot["service"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_weight_summary(bindings: list[dict[str, Any]]) -> str:
|
||||||
|
# 日志只保留规则和权重摘要,避免一条进度消息塞太多冗余字段。
|
||||||
|
parts = [f"{binding['routeLabel']} -> {int(binding.get('weight') or 0)}" for binding in bindings]
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_gateway_release_clb_snapshot(service: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# 进入单节点重启前,先把这个节点当前在 CLB 里的全部绑定关系记下来。
|
||||||
|
if str(service.get("service") or "").strip() != "gateway":
|
||||||
|
raise RuntimeError("gateway CLB protection only supports gateway service")
|
||||||
|
|
||||||
|
settings = gateway_clb_settings()
|
||||||
|
listeners = list(describe_gateway_targets().get("Listeners") or [])
|
||||||
|
bindings = collect_service_bindings(listeners, service)
|
||||||
|
if not bindings:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"gateway target not found in configured CLB listeners: {service.get('host')} {service.get('instanceId') or service.get('ip')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"service": {
|
||||||
|
"host": str(service.get("host") or "").strip(),
|
||||||
|
"service": str(service.get("service") or "").strip(),
|
||||||
|
"ip": str(service.get("ip") or "").strip(),
|
||||||
|
"instanceId": str(service.get("instanceId") or "").strip(),
|
||||||
|
"port": int(service.get("port") or 0),
|
||||||
|
},
|
||||||
|
"loadBalancerId": settings["loadBalancerId"],
|
||||||
|
"listenerIds": list(settings["listenerIds"]),
|
||||||
|
"bindings": bindings,
|
||||||
|
"bindingSummary": render_weight_summary(bindings),
|
||||||
|
"updatedAt": utc_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def modify_gateway_binding_weight(binding: dict[str, Any], weight: int) -> dict[str, Any]:
|
||||||
|
# 单条绑定单独改权重,出错时能直接定位到具体监听器/规则。
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"LoadBalancerId": GATEWAY_CLB_LOAD_BALANCER_ID,
|
||||||
|
"ListenerId": binding["listenerId"],
|
||||||
|
"Targets": [
|
||||||
|
{
|
||||||
|
"InstanceId": binding["instanceId"],
|
||||||
|
"Port": int(binding["port"]),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Weight": int(weight),
|
||||||
|
}
|
||||||
|
if str(binding.get("locationId") or "").strip():
|
||||||
|
payload["LocationId"] = binding["locationId"]
|
||||||
|
elif str(binding.get("domain") or "").strip() or str(binding.get("url") or "").strip():
|
||||||
|
payload["Domain"] = str(binding.get("domain") or "").strip()
|
||||||
|
payload["Url"] = str(binding.get("url") or "").strip()
|
||||||
|
|
||||||
|
response = call_clb("ModifyTargetWeight", payload)
|
||||||
|
wait_clb_task(str(response.get("RequestId") or "").strip())
|
||||||
|
return {
|
||||||
|
"routeLabel": binding["routeLabel"],
|
||||||
|
"weight": int(weight),
|
||||||
|
"updatedAt": utc_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_gateway_binding_weights(snapshot: dict[str, Any], expected_weights: dict[str, int], *, timeout_seconds: int) -> list[dict[str, Any]]:
|
||||||
|
# 权重调整是异步任务,必须读回线上值确认全部命中目标权重后才能继续重启。
|
||||||
|
deadline = time.time() + max(float(timeout_seconds), 1.0)
|
||||||
|
last_bindings: list[dict[str, Any]] = []
|
||||||
|
while time.time() < deadline:
|
||||||
|
last_bindings = current_gateway_bindings(snapshot)
|
||||||
|
current_by_key = {binding_key(item): item for item in last_bindings}
|
||||||
|
if current_by_key and all(
|
||||||
|
key in current_by_key and int(current_by_key[key].get("weight") or 0) == int(expected_weights[key])
|
||||||
|
for key in expected_weights
|
||||||
|
):
|
||||||
|
return last_bindings
|
||||||
|
time.sleep(max(float(GATEWAY_CLB_POLL_SECONDS), 0.1))
|
||||||
|
|
||||||
|
if not last_bindings:
|
||||||
|
raise RuntimeError("gateway CLB bindings disappeared while waiting for target weight")
|
||||||
|
|
||||||
|
current_by_key = {binding_key(item): item for item in last_bindings}
|
||||||
|
raise RuntimeError(
|
||||||
|
"gateway CLB weight not ready: "
|
||||||
|
+ ", ".join(
|
||||||
|
f"{current_by_key.get(key, {}).get('routeLabel', key)}={int(current_by_key.get(key, {}).get('weight') or -1)}"
|
||||||
|
for key in expected_weights
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drain_gateway_release_target(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# 摘流直接把当前节点在所有匹配规则下的权重调成 0,避免新请求继续打到待重启节点。
|
||||||
|
expected_weights: dict[str, int] = {}
|
||||||
|
operations: list[dict[str, Any]] = []
|
||||||
|
for binding in snapshot["bindings"]:
|
||||||
|
operations.append(modify_gateway_binding_weight(binding, 0))
|
||||||
|
expected_weights[binding_key(binding)] = 0
|
||||||
|
|
||||||
|
verified = wait_gateway_binding_weights(
|
||||||
|
snapshot,
|
||||||
|
expected_weights,
|
||||||
|
timeout_seconds=GATEWAY_CLB_TASK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"updatedAt": utc_now(),
|
||||||
|
"operations": operations,
|
||||||
|
"bindings": verified,
|
||||||
|
"summary": render_weight_summary(verified),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_gateway_release_target(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# 服务恢复后把每条绑定恢复成摘流前的原权重,避免线上流量分配被改坏。
|
||||||
|
expected_weights: dict[str, int] = {}
|
||||||
|
operations: list[dict[str, Any]] = []
|
||||||
|
for binding in snapshot["bindings"]:
|
||||||
|
original_weight = int(binding.get("weight") or 0)
|
||||||
|
operations.append(modify_gateway_binding_weight(binding, original_weight))
|
||||||
|
expected_weights[binding_key(binding)] = original_weight
|
||||||
|
|
||||||
|
verified = wait_gateway_binding_weights(
|
||||||
|
snapshot,
|
||||||
|
expected_weights,
|
||||||
|
timeout_seconds=GATEWAY_CLB_TASK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"updatedAt": utc_now(),
|
||||||
|
"operations": operations,
|
||||||
|
"bindings": verified,
|
||||||
|
"summary": render_weight_summary(verified),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def health_key(listener_id: str, location_id: str, target_id: str, port: int) -> str:
|
||||||
|
# 健康状态和绑定状态用同一套 key,便于直接按绑定反查对应健康条目。
|
||||||
|
return "|".join([listener_id.strip(), location_id.strip(), target_id.strip(), str(int(port or 0))])
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gateway_health_states(load_balancers: list[dict[str, Any]], snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
# CLB 健康查询返回的是 loadBalancer -> listener -> rule -> target 结构,这里拍平后只留当前实例。
|
||||||
|
matched: dict[str, dict[str, Any]] = {}
|
||||||
|
for load_balancer in load_balancers:
|
||||||
|
for listener in load_balancer.get("Listeners") or []:
|
||||||
|
listener_id = str(listener.get("ListenerId") or "").strip()
|
||||||
|
for rule in listener.get("Rules") or []:
|
||||||
|
location_id = str(rule.get("LocationId") or "").strip()
|
||||||
|
for target in rule.get("Targets") or []:
|
||||||
|
if not target_matches_service(target, snapshot["service"]):
|
||||||
|
continue
|
||||||
|
state = {
|
||||||
|
"listenerId": listener_id,
|
||||||
|
"locationId": location_id,
|
||||||
|
"targetId": str(target.get("TargetId") or "").strip(),
|
||||||
|
"port": int(target.get("Port") or 0),
|
||||||
|
"healthStatus": bool(target.get("HealthStatus")),
|
||||||
|
"detail": str(target.get("HealthStatusDetail") or target.get("HealthStatusDetial") or "").strip(),
|
||||||
|
}
|
||||||
|
matched[health_key(state["listenerId"], state["locationId"], state["targetId"], state["port"])] = state
|
||||||
|
return list(matched.values())
|
||||||
|
|
||||||
|
|
||||||
|
def describe_gateway_target_health(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
# 健康查询只拉当前负载均衡和监听器,避免把无关 listener 的状态混进来。
|
||||||
|
payload = call_clb(
|
||||||
|
"DescribeTargetHealth",
|
||||||
|
{
|
||||||
|
"LoadBalancerIds": [snapshot["loadBalancerId"]],
|
||||||
|
"ListenerIds": list(snapshot["listenerIds"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return collect_gateway_health_states(list(payload.get("LoadBalancers") or []), snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_gateway_release_target_healthy(snapshot: dict[str, Any], *, timeout_seconds: int) -> list[dict[str, Any]]:
|
||||||
|
# 只有等 CLB 自己也判定节点健康,才允许恢复权重并继续下一台,避免下一轮摘流时只剩空节点。
|
||||||
|
expected_keys = {
|
||||||
|
health_key(
|
||||||
|
str(binding.get("listenerId") or "").strip(),
|
||||||
|
str(binding.get("locationId") or "").strip(),
|
||||||
|
str(binding.get("instanceId") or "").strip(),
|
||||||
|
int(binding.get("port") or 0),
|
||||||
|
)
|
||||||
|
for binding in snapshot["bindings"]
|
||||||
|
}
|
||||||
|
deadline = time.time() + max(float(timeout_seconds), 1.0)
|
||||||
|
last_states: list[dict[str, Any]] = []
|
||||||
|
while time.time() < deadline:
|
||||||
|
last_states = describe_gateway_target_health(snapshot)
|
||||||
|
current_by_key = {
|
||||||
|
health_key(
|
||||||
|
str(item.get("listenerId") or "").strip(),
|
||||||
|
str(item.get("locationId") or "").strip(),
|
||||||
|
str(item.get("targetId") or "").strip(),
|
||||||
|
int(item.get("port") or 0),
|
||||||
|
): item
|
||||||
|
for item in last_states
|
||||||
|
}
|
||||||
|
if current_by_key and all(
|
||||||
|
key in current_by_key and bool(current_by_key[key].get("healthStatus"))
|
||||||
|
for key in expected_keys
|
||||||
|
):
|
||||||
|
return last_states
|
||||||
|
time.sleep(max(float(GATEWAY_CLB_POLL_SECONDS), 0.1))
|
||||||
|
|
||||||
|
detail = ", ".join(
|
||||||
|
f"{item.get('listenerId')}/{item.get('locationId') or '-'}={item.get('detail') or item.get('healthStatus')}"
|
||||||
|
for item in last_states
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"gateway CLB health not ready: {detail or 'no matching target health'}")
|
||||||
407
tests/test_gateway_release_clb.py
Normal file
407
tests/test_gateway_release_clb.py
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from monitors.yumi.service_release import deploy_runtime_services_payload
|
||||||
|
from monitors.yumi.tencent_clb import collect_service_bindings, prepare_gateway_release_clb_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayClbDiscoveryTests(unittest.TestCase):
|
||||||
|
@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)},
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -8,6 +8,47 @@ from monitors.yumi.service_release import build_update_targets_payload
|
|||||||
|
|
||||||
|
|
||||||
class ServiceReleaseTargetsTests(unittest.TestCase):
|
class ServiceReleaseTargetsTests(unittest.TestCase):
|
||||||
|
@patch("monitors.yumi.service_release.gateway_clb_configuration_error", return_value="gateway CLB protection requires .env")
|
||||||
|
@patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False)
|
||||||
|
@patch("monitors.yumi.service_release.service_repository_ref", side_effect=lambda service: f"repo/{service}")
|
||||||
|
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["gateway"])
|
||||||
|
@patch("monitors.yumi.service_release.service_records_by_name")
|
||||||
|
@patch("monitors.yumi.service_release.load_local_host_compose_model")
|
||||||
|
def test_build_update_targets_payload_adds_gateway_clb_warning(
|
||||||
|
self,
|
||||||
|
load_local_host_compose_model_mock,
|
||||||
|
service_records_by_name_mock,
|
||||||
|
buildable_service_names_mock,
|
||||||
|
service_repository_ref_mock,
|
||||||
|
frontend_release_enabled_mock,
|
||||||
|
gateway_clb_configuration_error_mock,
|
||||||
|
) -> None:
|
||||||
|
del buildable_service_names_mock
|
||||||
|
del service_repository_ref_mock
|
||||||
|
del frontend_release_enabled_mock
|
||||||
|
del gateway_clb_configuration_error_mock
|
||||||
|
service_records_by_name_mock.return_value = {
|
||||||
|
"gateway": [
|
||||||
|
{
|
||||||
|
"host": "gateway-1",
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"instanceId": "ins-gateway-1",
|
||||||
|
"port": 8080,
|
||||||
|
"path": "/health",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
load_local_host_compose_model_mock.return_value = {
|
||||||
|
"services": {
|
||||||
|
"gateway": {"image": "registry/gateway:main"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = build_update_targets_payload()
|
||||||
|
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["items"][0]["warnings"], ["gateway CLB protection requires .env"])
|
||||||
|
|
||||||
@patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False)
|
@patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False)
|
||||||
@patch("monitors.yumi.service_release.service_repository_ref", side_effect=lambda service: f"repo/{service}")
|
@patch("monitors.yumi.service_release.service_repository_ref", side_effect=lambda service: f"repo/{service}")
|
||||||
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth", "gateway"])
|
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth", "gateway"])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user