656 lines
22 KiB
Python
656 lines
22 KiB
Python
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
|