#!/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())