hy-app-monitor/monitor/runtime_config.py

741 lines
24 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
# 远端写文件时需要 base64 传输,避免引号和换行转义问题。
import base64
# 返回给前端的摘要信息会带 sha256。
import hashlib
# env 值里有 JSON 字符串时直接复用标准库解析。
import json
# 远端路径和 shell 参数需要安全转义。
import shlex
from typing import Any
from .config import RUNTIME_BASE_DIR, TAT_METRIC_TIMEOUT_SECONDS, load_hosts, utc_now
from .tencent_tat import run_shell_command
# 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}
# 远端文件不存在时,用固定标记避免和 shell 错误混淆。
REMOTE_FILE_MISSING_MARKER = "__HY_APP_MONITOR_FILE_MISSING__"
# TAT 单次输出有长度上限,读取较大的 env / compose 文件时需要分块。
REMOTE_TEXT_CHUNK_BYTES = 4096
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 remote_host_result(host: dict[str, Any], script: str, command_name: str, *, timeout_seconds: int = TAT_METRIC_TIMEOUT_SECONDS) -> dict[str, Any]:
# 统一执行单机 TAT 命令,并把错误折叠成 RuntimeError。
result_map = run_shell_command(
[host["instanceId"]],
script,
command_name=command_name,
timeout_seconds=timeout_seconds,
)
result = result_map[host["instanceId"]]
if result.get("status") != "SUCCESS":
raise RuntimeError(f"{host['host']} {command_name} failed: {result.get('status')}")
return result
def remote_file_size(host: dict[str, Any], path: str) -> int:
quoted_path = shlex.quote(path)
script = f"""set -eu
FILE={quoted_path}
if [ ! -f "$FILE" ]; then
printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)}
exit 0
fi
python3 - "$FILE" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).stat().st_size, end="")
PY
"""
result = remote_host_result(host, script, f"stat-{host['host']}-remote-file")
output = str(result.get("output") or "")
if output.strip() == REMOTE_FILE_MISSING_MARKER:
raise RuntimeError(f"{host['host']} missing file: {path}")
return int(output.strip() or "0")
def read_remote_text(host: dict[str, Any], path: str) -> str:
quoted_path = shlex.quote(path)
file_size = remote_file_size(host, path)
if file_size == 0:
return ""
# 通过多次小输出读取完整文件,避免 TAT 把大文本截断。
chunks: list[bytes] = []
for offset in range(0, file_size, REMOTE_TEXT_CHUNK_BYTES):
script = f"""set -eu
FILE={quoted_path}
if [ ! -f "$FILE" ]; then
printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)}
exit 0
fi
python3 - "$FILE" {offset} {REMOTE_TEXT_CHUNK_BYTES} <<'PY'
from pathlib import Path
import base64
import sys
path = Path(sys.argv[1])
offset = int(sys.argv[2])
chunk_size = int(sys.argv[3])
data = path.read_bytes()[offset : offset + chunk_size]
print(base64.b64encode(data).decode("ascii"), end="")
PY
"""
result = remote_host_result(host, script, f"read-{host['host']}-remote-file-chunk-{offset}")
output = str(result.get("output") or "")
if output.strip() == REMOTE_FILE_MISSING_MARKER:
raise RuntimeError(f"{host['host']} missing file: {path}")
chunks.append(base64.b64decode(output.strip() or ""))
return b"".join(chunks).decode("utf-8")
def write_remote_text(host: dict[str, Any], path: str, content: str) -> None:
# 把文本整体 base64 编码后再下发,避免 shell here-doc 被内容污染。
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
quoted_path = shlex.quote(path)
script = f"""set -eu
FILE={quoted_path}
TMP="${{FILE}}.hyapp.$$"
mkdir -p "$(dirname "$FILE")"
printf '%s' {shlex.quote(encoded)} | base64 -d > "$TMP"
mv "$TMP" "$FILE"
"""
remote_host_result(host, script, f"write-{host['host']}-service-env")
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