hy-app-monitor/monitors/yumi/runtime_config.py

656 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
# 返回给前端的摘要信息会带 sha256。
import hashlib
# env 值里有 JSON 字符串时直接复用标准库解析。
import json
from typing import Any
from core.config import RUNTIME_BASE_DIR, load_hosts, utc_now
from .ssh_remote import read_remote_text as ssh_read_remote_text
from .ssh_remote import write_remote_text as ssh_write_remote_text
# Go 配置按源码里的运行分段排序,页面上也按这个顺序展示。
GO_ENV_SPECS: list[dict[str, Any]] = [
{"section": "HTTP", "primary": "CHATAPP_HTTP_LISTEN_ADDR", "aliases": ("INVITE_HTTP_ADDR",), "kind": "string"},
{
"section": "HTTP",
"primary": "CHATAPP_HTTP_TIMEOUT_SECONDS",
"aliases": ("INVITE_HTTP_TIMEOUT_SECONDS",),
"kind": "int",
},
{
"section": "HTTP",
"primary": "CHATAPP_HTTP_SHUTDOWN_TIMEOUT_SECONDS",
"aliases": ("INVITE_HTTP_SHUTDOWN_TIMEOUT_SECONDS",),
"kind": "int",
},
{
"section": "HTTP",
"primary": "CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET",
"aliases": ("GAME_INTERNAL_CALLBACK_SECRET",),
"kind": "string",
},
{"section": "Store", "primary": "CHATAPP_STORE_MYSQL_DSN", "aliases": ("INVITE_MYSQL_DSN",), "kind": "string"},
{"section": "Store", "primary": "CHATAPP_STORE_REDIS_ADDR", "aliases": ("INVITE_REDIS_ADDR",), "kind": "string"},
{
"section": "Store",
"primary": "CHATAPP_STORE_REDIS_PASSWORD",
"aliases": ("INVITE_REDIS_PASSWORD",),
"kind": "string",
},
{"section": "Store", "primary": "CHATAPP_STORE_REDIS_DB", "aliases": ("INVITE_REDIS_DB",), "kind": "int"},
{
"section": "Java",
"primary": "CHATAPP_JAVA_AUTH_BASE_URL",
"aliases": ("INVITE_JAVA_AUTH_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_CONSOLE_BASE_URL",
"aliases": ("JAVA_CONSOLE_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_DEVICE_BASE_URL",
"aliases": ("INVITE_JAVA_DEVICE_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_APP_BASE_URL",
"aliases": ("INVITE_JAVA_APP_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_BRIDGE_BASE_URL",
"aliases": ("INVITE_JAVA_BRIDGE_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_EXTERNAL_BASE_URL",
"aliases": ("INVITE_JAVA_EXTERNAL_BASE_URL",),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_OTHER_BASE_URL",
"aliases": ("GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"),
"kind": "string",
},
{
"section": "Java",
"primary": "CHATAPP_JAVA_WALLET_BASE_URL",
"aliases": ("GAME_JAVA_WALLET_BASE_URL",),
"kind": "string",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_STREAM_KEY",
"aliases": ("REGISTER_REWARD_STREAM_KEY",),
"kind": "string",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_CONSUMER_GROUP",
"aliases": ("REGISTER_REWARD_CONSUMER_GROUP",),
"kind": "string",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_CONSUMER_NAME",
"aliases": ("REGISTER_REWARD_CONSUMER_NAME",),
"kind": "string",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_BATCH_SIZE",
"aliases": ("REGISTER_REWARD_BATCH_SIZE",),
"kind": "int",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_BLOCK_MILLIS",
"aliases": ("REGISTER_REWARD_BLOCK_MILLIS",),
"kind": "int",
},
{
"section": "Worker",
"primary": "CHATAPP_REGISTER_REWARD_MIN_IDLE_MILLIS",
"aliases": ("REGISTER_REWARD_MIN_IDLE_MILLIS",),
"kind": "int",
},
{
"section": "Invite",
"primary": "CHATAPP_INVITE_PUBLIC_BASE_URL",
"aliases": ("INVITE_PUBLIC_BASE_URL",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN",
"aliases": ("WEEK_STAR_DEFAULT_SYS_ORIGIN",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_TIMEZONE",
"aliases": ("WEEK_STAR_TIMEZONE",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_RETRY_STREAM_KEY",
"aliases": ("WEEK_STAR_RETRY_STREAM_KEY",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED",
"aliases": ("WEEK_STAR_ROCKETMQ_ENABLED",),
"kind": "bool",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT",
"aliases": ("WEEK_STAR_ROCKETMQ_ENDPOINT",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE",
"aliases": ("WEEK_STAR_ROCKETMQ_NAMESPACE",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY",
"aliases": ("WEEK_STAR_ROCKETMQ_ACCESS_KEY",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET",
"aliases": ("WEEK_STAR_ROCKETMQ_ACCESS_SECRET",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN",
"aliases": ("WEEK_STAR_ROCKETMQ_SECURITY_TOKEN",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_CONSUMER_GROUP",
"aliases": ("WEEK_STAR_ROCKETMQ_CONSUMER_GROUP",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC",
"aliases": ("WEEK_STAR_ROCKETMQ_TOPIC",),
"kind": "string",
},
{
"section": "WeekStar",
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_TAG",
"aliases": ("WEEK_STAR_ROCKETMQ_TAG",),
"kind": "string",
},
{
"section": "Baishun",
"primary": "CHATAPP_BAISHUN_PLATFORM_BASE_URL",
"aliases": ("BAISHUN_PLATFORM_BASE_URL",),
"kind": "string",
},
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_APP_ID", "aliases": ("BAISHUN_APP_ID",), "kind": "int"},
{
"section": "Baishun",
"primary": "CHATAPP_BAISHUN_APP_NAME",
"aliases": ("BAISHUN_APP_NAME",),
"kind": "string",
},
{
"section": "Baishun",
"primary": "CHATAPP_BAISHUN_APP_CHANNEL",
"aliases": ("BAISHUN_APP_CHANNEL",),
"kind": "string",
},
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_APP_KEY", "aliases": ("BAISHUN_APP_KEY",), "kind": "string"},
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_GSP", "aliases": ("BAISHUN_GSP",), "kind": "int"},
{
"section": "Baishun",
"primary": "CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS",
"aliases": ("BAISHUN_LAUNCH_CODE_TTL_SECONDS",),
"kind": "int",
},
{
"section": "Baishun",
"primary": "CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS",
"aliases": ("BAISHUN_SS_TOKEN_TTL_SECONDS",),
"kind": "int",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_ENABLED",
"aliases": ("LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"),
"kind": "bool",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS",
"aliases": ("LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"),
"kind": "int",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY",
"aliases": ("LUCKY_GIFT_REWARD_STREAM_KEY",),
"kind": "string",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_CONFIGS_JSON",
"aliases": ("LUCKY_GIFT_CONFIGS_JSON", "LUCKY_GIFT_CONFIGS"),
"kind": "json",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_STANDARD_ID",
"aliases": ("LUCKY_GIFT_STANDARD_ID",),
"kind": "int",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_URL",
"aliases": ("LUCKY_GIFT_URL", "LUCKY_GIFT_API_DEFAULT_URL"),
"kind": "string",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_APP_ID",
"aliases": ("LUCKY_GIFT_APP_ID", "LUCKY_GIFT_API_DEFAULT_APP_ID"),
"kind": "string",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_APP_CHANNEL",
"aliases": ("LUCKY_GIFT_APP_CHANNEL", "LUCKY_GIFT_API_DEFAULT_APP_CHANNEL"),
"kind": "string",
},
{
"section": "LuckyGift",
"primary": "CHATAPP_LUCKY_GIFT_APP_KEY",
"aliases": ("LUCKY_GIFT_APP_KEY", "LUCKY_GIFT_API_DEFAULT_APP_KEY"),
"kind": "string",
},
]
# 把所有可接受的 key 映射回唯一主键,方便校验和保存时去重。
GO_ENV_PRIMARY_BY_KEY = {
key: spec["primary"]
for spec in GO_ENV_SPECS
for key in (spec["primary"], *spec["aliases"])
}
# 同时保留按主键索引的 spec后续查类型更直接。
GO_ENV_SPECS_BY_PRIMARY = {spec["primary"]: spec for spec in GO_ENV_SPECS}
def go_service_hosts() -> list[dict[str, Any]]:
# 只挑出当前监控目标里真正承载 golang 服务的主机。
hosts = [
host
for host in load_hosts()
if any(str(service.get("name") or "").strip() == "golang" for service in host.get("services") or [])
]
if not hosts:
raise RuntimeError("no golang hosts found in config/targets.json")
return hosts
def service_env_path(host_name: str) -> str:
# 线上运行目录沿用现有部署结构。
return f"{RUNTIME_BASE_DIR.rstrip('/')}/{host_name}/service.env"
def parse_env_assignment(raw_line: str) -> tuple[str, str] | None:
# 保留原始内容用于判断注释和空行。
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
return None
if stripped.startswith("export "):
stripped = stripped[7:].strip()
if "=" not in stripped:
return None
# 只按第一个等号切分,避免 URL / JSON 被截断。
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
return None
return key, decode_env_value(value)
def decode_env_value(raw_value: str) -> str:
# 双引号优先按 JSON 字符串解码,兼容反斜杠和 Unicode。
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] == '"':
try:
return str(json.loads(raw_value))
except Exception: # noqa: BLE001
return raw_value[1:-1]
# 单引号值按最简单规则去壳。
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] == "'":
return raw_value[1:-1]
# 其余值原样返回。
return raw_value
def render_env_value(value: str) -> str:
# 统一用 JSON 风格双引号输出,避免空格和特殊字符破坏 env 语法。
return json.dumps(value, ensure_ascii=False)
def parse_env_text(raw: str) -> dict[str, str]:
# env 文件最终按 key/value 平铺。
values: dict[str, str] = {}
for raw_line in raw.splitlines():
parsed = parse_env_assignment(raw_line)
if parsed is None:
continue
key, value = parsed
values[key] = value
return values
def read_remote_text(host: dict[str, Any], path: str) -> str:
# 远端 env / compose 文本现在统一走 SSH 直连读取。
return ssh_read_remote_text(host, path)
def write_remote_text(host: dict[str, Any], path: str, content: str) -> None:
# 远端文本覆盖同样走 SSH避免继续依赖 TAT 输出长度和轮询结果。
ssh_write_remote_text(host, path, content)
def host_sha256(content: str) -> str:
# 前端需要一个稳定摘要判断读写前后是否一致。
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def first_present_value(values: dict[str, str], keys: tuple[str, ...]) -> str:
# 配置读取语义和 getEnvAny 对齐,优先取第一个非空值。
for key in keys:
candidate = values.get(key, "")
if candidate != "":
return candidate
# 如果全是空串,但确实存在某个 key也保留空串语义。
for key in keys:
if key in values:
return values[key]
return ""
def extract_go_values(values: dict[str, str]) -> dict[str, str]:
# 统一抽成“主键 -> 当前值”,方便页面和保存逻辑复用。
extracted: dict[str, str] = {}
for spec in GO_ENV_SPECS:
keys = (spec["primary"], *spec["aliases"])
extracted[spec["primary"]] = first_present_value(values, keys)
return extracted
def render_go_editor_content(values: dict[str, str]) -> str:
# 生成带分段注释的可编辑 env 文本,用户一眼能看出结构。
lines: list[str] = []
current_section = ""
for spec in GO_ENV_SPECS:
if spec["section"] != current_section:
if lines:
lines.append("")
current_section = spec["section"]
lines.append(f"# {current_section}")
lines.append(f"{spec['primary']}={render_env_value(values.get(spec['primary'], ''))}")
return "\n".join(lines).rstrip() + "\n"
def validate_scalar_kind(primary_key: str, value: str, kind: str) -> None:
# 空串允许保存,运行时会按 fallback / 默认值处理。
if value == "":
return
if kind == "string":
return
if kind == "int":
int(value)
return
if kind == "bool":
if value.strip().lower() not in {"1", "0", "true", "false", "yes", "no", "on", "off"}:
raise ValueError(f"{primary_key} must be a boolean")
return
if kind == "json":
json.loads(value)
return
def validate_go_editor_content(content: str) -> dict[str, Any]:
# 这里按“可编辑文本”逐行解析,保留重复和未知 key 的校验能力。
values: dict[str, str] = {}
seen_primaries: set[str] = set()
for line_no, raw_line in enumerate(content.splitlines(), start=1):
parsed = parse_env_assignment(raw_line)
if parsed is None:
continue
key, value = parsed
primary_key = GO_ENV_PRIMARY_BY_KEY.get(key)
if not primary_key:
return {
"ok": False,
"strict": True,
"type": "env",
"message": f"unsupported golang env key: {key}",
"details": {"line": line_no, "key": key},
}
if primary_key in seen_primaries:
return {
"ok": False,
"strict": True,
"type": "env",
"message": f"duplicated golang env key: {primary_key}",
"details": {"line": line_no, "key": primary_key},
}
spec = GO_ENV_SPECS_BY_PRIMARY[primary_key]
try:
validate_scalar_kind(primary_key, value, spec["kind"])
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"strict": True,
"type": "env",
"message": str(exc),
"details": {"line": line_no, "key": primary_key},
}
seen_primaries.add(primary_key)
values[primary_key] = value
return {
"ok": True,
"strict": True,
"type": "env",
"message": f"golang env format ok ({len(values)} keys)",
"details": {"keysCount": len(values)},
"values": values,
}
def expand_host_updates(current_values: dict[str, str], editor_values: dict[str, str]) -> dict[str, str]:
# 保存时尽量沿用宿主机现有 key 形态,只在缺失时补主键。
updates: dict[str, str] = {}
for primary_key, value in editor_values.items():
spec = GO_ENV_SPECS_BY_PRIMARY[primary_key]
host_keys = [key for key in (primary_key, *spec["aliases"]) if key in current_values]
if not host_keys:
host_keys = [primary_key]
for key in host_keys:
updates[key] = value
return updates
def merge_env_text(raw_content: str, updates: dict[str, str]) -> str:
# 在原文件基础上按行覆盖,尽量保留注释和无关配置。
output_lines: list[str] = []
seen_keys: set[str] = set()
for raw_line in raw_content.splitlines():
parsed = parse_env_assignment(raw_line)
if parsed is None:
output_lines.append(raw_line)
continue
key, _ = parsed
if key not in updates:
output_lines.append(raw_line)
continue
output_lines.append(f"{key}={render_env_value(updates[key])}")
seen_keys.add(key)
# 原文件没有的 key 统一追加到末尾。
missing_keys = [key for key in updates if key not in seen_keys]
if missing_keys:
if output_lines and output_lines[-1].strip():
output_lines.append("")
output_lines.append("# hy-app-monitor golang config")
for key in missing_keys:
output_lines.append(f"{key}={render_env_value(updates[key])}")
return "\n".join(output_lines).rstrip() + "\n"
def go_host_snapshots() -> list[dict[str, Any]]:
# 逐台读取实际运行中的 service.env。
snapshots: list[dict[str, Any]] = []
for host in go_service_hosts():
env_path = service_env_path(host["host"])
raw_content = read_remote_text(host, env_path)
env_values = parse_env_text(raw_content)
go_values = extract_go_values(env_values)
snapshots.append(
{
"host": host["host"],
"ip": host["ip"],
"instanceId": host["instanceId"],
"path": env_path,
"sha256": host_sha256(raw_content),
"lineCount": len(raw_content.splitlines()) if raw_content else 0,
"content": raw_content,
"values": env_values,
"goValues": go_values,
}
)
return snapshots
def go_diff_keys(snapshots: list[dict[str, Any]]) -> list[str]:
# 同步看两台 app 机器上的 Go 配置是否已经一致。
if not snapshots:
return []
baseline = snapshots[0]["goValues"]
diff: list[str] = []
for key in baseline:
if any(snapshot["goValues"].get(key, "") != baseline.get(key, "") for snapshot in snapshots[1:]):
diff.append(key)
return diff
def get_go_config_payload() -> dict[str, Any]:
# 读取所有 golang 宿主机当前运行配置。
snapshots = go_host_snapshots()
diff_keys = go_diff_keys(snapshots)
baseline_values = snapshots[0]["goValues"] if snapshots else {}
return {
"ok": True,
"updatedAt": utc_now(),
"hosts": [
{
"host": snapshot["host"],
"ip": snapshot["ip"],
"instanceId": snapshot["instanceId"],
"path": snapshot["path"],
"sha256": snapshot["sha256"],
"lineCount": snapshot["lineCount"],
}
for snapshot in snapshots
],
"diffKeys": diff_keys,
"editorContent": render_go_editor_content(baseline_values),
"keysCount": len([line for line in render_go_editor_content(baseline_values).splitlines() if "=" in line]),
}
def validate_go_config_payload(content: str) -> dict[str, Any]:
# 对页面编辑器内容做显式格式校验。
result = validate_go_editor_content(content)
if result.get("ok"):
result["updatedAt"] = utc_now()
return result
def save_go_config_payload(content: str) -> dict[str, Any]:
# 保存前先做一次强校验,避免把非法配置下发到线上。
validation = validate_go_editor_content(content)
if not validation.get("ok"):
raise RuntimeError(str(validation.get("message") or "invalid golang env content"))
editor_values = dict(validation.get("values") or {})
if not editor_values:
raise RuntimeError("no golang env keys found in editor content")
saved_hosts: list[dict[str, Any]] = []
for snapshot in go_host_snapshots():
host_updates = expand_host_updates(snapshot["values"], editor_values)
merged_content = merge_env_text(snapshot["content"], host_updates)
write_remote_text(
{"host": snapshot["host"], "instanceId": snapshot["instanceId"]},
snapshot["path"],
merged_content,
)
# 写完立即回读,确认关键值确实落盘。
verified_content = read_remote_text(
{"host": snapshot["host"], "instanceId": snapshot["instanceId"]},
snapshot["path"],
)
verified_values = extract_go_values(parse_env_text(verified_content))
for primary_key, expected_value in editor_values.items():
if verified_values.get(primary_key, "") != expected_value:
raise RuntimeError(f"verify golang config failed on {snapshot['host']}: {primary_key}")
saved_hosts.append(
{
"host": snapshot["host"],
"ip": snapshot["ip"],
"instanceId": snapshot["instanceId"],
"path": snapshot["path"],
"sha256": host_sha256(verified_content),
}
)
payload = get_go_config_payload()
payload["savedHosts"] = saved_hosts
payload["message"] = "golang service.env saved"
return payload