305 lines
10 KiB
Python
Executable File
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())
|