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