hyapp-deploly/deploy/tencent-tat/hyapp_testbox_config.py
2026-05-20 16:49:19 +08:00

305 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any
DEFAULT_REGION = "ap-jakarta"
DEFAULT_INSTANCE_ID = "lhins-q0m38zc6"
DEFAULT_ROOT = "/opt/hyapp-server-test"
DEFAULT_SERVICES = (
"gateway-service",
"room-service",
"wallet-service",
"user-service",
"activity-service",
"cron-service",
"game-service",
"notice-service",
"admin-server",
)
SERVICE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
def load_env_file(path: str) -> None:
if not path:
return
env_path = Path(path).expanduser().resolve()
if not env_path.exists():
raise RuntimeError(f"env file not found: {env_path}")
for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
def secret_id() -> str:
return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or ""
def secret_key() -> str:
return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or ""
def require_credentials() -> tuple[str, str]:
sid = secret_id()
skey = secret_key()
missing = []
if not sid:
missing.append("TENCENTCLOUD_SECRET_ID")
if not skey:
missing.append("TENCENTCLOUD_SECRET_KEY")
if missing:
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
return sid, skey
def csv_values(text: str) -> list[str]:
return [item.strip() for item in str(text or "").split(",") if item.strip()]
def parse_services(raw_services: str) -> list[str]:
services = csv_values(raw_services) or list(DEFAULT_SERVICES)
invalid = [service for service in services if not SERVICE_NAME_RE.match(service)]
if invalid:
raise RuntimeError(f"invalid service names: {', '.join(invalid)}")
return list(dict.fromkeys(services))
def build_tat_client(region: str) -> Any:
sid, skey = require_credentials()
try:
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.tat.v20201028 import tat_client
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
return tat_client.TatClient(
credential.Credential(sid, skey),
region,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any:
try:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
except Exception: # noqa: BLE001
TencentCloudSDKException = Exception # type: ignore[assignment]
last_error: Exception | None = None
for attempt in range(1, retries + 1):
try:
return action()
except TencentCloudSDKException as exc: # type: ignore[misc]
last_error = exc
if attempt == retries:
break
time.sleep(2 * attempt)
raise RuntimeError(f"{label} failed: {last_error}") from last_error
def decode_tat_output(output: str) -> str:
if not output:
return ""
return base64.b64decode(output).decode("utf-8", errors="replace")
def run_tat_shell(
client: Any,
*,
instance_id: str,
script: str,
command_name: str,
timeout_seconds: int,
poll_seconds: int,
) -> dict[str, Any]:
from tencentcloud.tat.v20201028 import models as tat_models
request = tat_models.RunCommandRequest()
request.from_json_string(
json.dumps(
{
"CommandName": command_name,
"CommandType": "SHELL",
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
"InstanceIds": [instance_id],
"Timeout": timeout_seconds,
"WorkingDirectory": "/root",
}
)
)
response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string())
invocation_id = response["InvocationId"]
deadline = time.time() + timeout_seconds + 60
while time.time() < deadline:
time.sleep(max(poll_seconds, 1))
query = tat_models.DescribeInvocationTasksRequest()
query.from_json_string(
json.dumps(
{
"HideOutput": False,
"Limit": 50,
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
}
)
)
payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string())
for task in payload.get("InvocationTaskSet") or []:
if str(task.get("InstanceId") or "") != instance_id:
continue
status = str(task.get("TaskStatus") or "")
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
return {
"status": status,
"output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")),
}
return {"status": "TIMEOUT", "output": ""}
def remote_config_script(*, root: str, services: list[str], redact: bool, max_bytes: int) -> str:
payload = {
"maxBytes": max_bytes,
"redact": redact,
"root": root,
"services": services,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import json
import os
import re
import subprocess
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
root = payload["root"]
services = payload["services"]
redact = bool(payload["redact"])
max_bytes = int(payload["maxBytes"])
secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|dsn)", re.I)
def service_path(service):
if service == "admin-server":
return os.path.join(root, "admin-server", "config.yaml")
return os.path.join(root, "source", "services", service, "configs", "config.docker.yaml")
def redact_yaml(text):
if not redact:
return text
lines = []
for line in text.splitlines():
match = re.match(r"^(\\s*[-\\w.]+\\s*:\\s*)(.*)$", line)
if match and secret_key_re.search(match.group(1)):
lines.append(match.group(1) + '"***"')
else:
lines.append(line)
return "\\n".join(lines) + ("\\n" if text.endswith("\\n") else "")
files = []
for service in services:
path = service_path(service)
item = {{
"content": "",
"exists": os.path.exists(path),
"path": path,
"service": service,
"truncated": False,
}}
if item["exists"]:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
content = handle.read(max_bytes + 1)
item["truncated"] = len(content) > max_bytes
item["content"] = redact_yaml(content[:max_bytes])
files.append(item)
def command_output(args):
try:
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=8).strip()
except Exception as exc:
return str(exc)
result = {{
"deployedCommit": command_output(["bash", "-lc", f"cat {{root}}/.deployed_commit 2>/dev/null || true"]),
"files": files,
"ok": True,
"redacted": redact,
"target": {{
"instanceId": "lhins-q0m38zc6",
"privateIp": "10.11.0.2",
"publicIp": "43.165.195.39",
"root": root,
"timer": "hyapp-server-test-deploy.timer",
}},
"timerState": command_output(["systemctl", "is-active", "hyapp-server-test-deploy.timer"]),
}}
print(json.dumps(result, ensure_ascii=False))
PY
"""
def read_configs(args: argparse.Namespace) -> dict[str, Any]:
services = parse_services(args.services)
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_config_script(root=args.root, services=services, redact=not args.raw, max_bytes=args.max_bytes),
command_name="hyapp-testbox-read-config",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
if result["status"] != "SUCCESS":
return {"ok": False, "status": result["status"], "output": result.get("output") or ""}
try:
payload = json.loads(str(result.get("output") or "{}"))
except json.JSONDecodeError:
return {"ok": False, "status": result["status"], "output": result.get("output") or ""}
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Read HYApp testbox deployment config via Tencent TAT.")
parser.add_argument("action", choices=("configs",))
parser.add_argument("--env-file", default="", help="optional env file with Tencent Cloud credentials")
parser.add_argument("--region", default=DEFAULT_REGION)
parser.add_argument("--instance-id", default=DEFAULT_INSTANCE_ID)
parser.add_argument("--root", default=DEFAULT_ROOT)
parser.add_argument("--services", default=",".join(DEFAULT_SERVICES))
parser.add_argument("--raw", action="store_true", help="return unredacted YAML")
parser.add_argument("--max-bytes", type=int, default=20_000)
parser.add_argument("--timeout-seconds", type=int, default=120)
parser.add_argument("--poll-seconds", type=int, default=2)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
load_env_file(args.env_file)
payload = read_configs(args)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload.get("ok") else 1
except Exception as exc: # noqa: BLE001
print(json.dumps({"error": str(exc), "ok": False}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())