#!/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-]*$") MIGRATION_FILE_RE = re.compile(r"^[0-9][A-Za-z0-9_.-]*\.sql$") SERVICE_OPERATIONS = {"restart", "start", "stop", "update"} TIMER_OPERATIONS = {"start", "stop", "restart", "status"} 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 parse_migration_files(raw_files: str) -> list[str]: files = csv_values(raw_files) invalid = [file for file in files if not MIGRATION_FILE_RE.match(file)] if invalid: raise RuntimeError(f"invalid migration file names: {', '.join(invalid)}") return list(dict.fromkeys(files)) def parse_operation(operation: str, allowed: set[str], label: str) -> str: value = str(operation or "").strip() if value not in allowed: raise RuntimeError(f"invalid {label} operation: {value}") return value 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|sign[_-]?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 remote_status_script(*, root: str, services: list[str]) -> str: payload = { "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 subprocess payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) root = payload["root"] services = payload["services"] source = os.path.join(root, "source") timer = "hyapp-server-test-deploy.timer" deploy_service = "hyapp-server-test-deploy.service" def command_output(args, timeout=12): try: return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=timeout).strip() except Exception as exc: return str(exc) def compose_args(): args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] override = os.path.join(root, "docker-compose.testbox.yml") if os.path.exists(override): args.extend(["-f", override]) return args def service_path(service): if service == "admin-server": return os.path.join(root, "admin-server", "config.yaml") return os.path.join(source, "services", service, "configs", "config.docker.yaml") def inspect_container(name): raw = command_output(["docker", "inspect", name], timeout=8) try: data = json.loads(raw)[0] except Exception: return {{ "composeService": name, "configPath": service_path(name), "exists": False, "health": "unknown", "image": "", "name": name, "restartCount": 0, "service": name, "startedAt": "", "state": "missing", "status": "unknown", }} state = data.get("State") or {{}} health = (state.get("Health") or {{}}).get("Status") or "none" return {{ "composeService": name, "configPath": service_path(name), "containerId": str(data.get("Id") or "")[:12], "exists": True, "health": health, "image": (data.get("Config") or {{}}).get("Image") or "", "name": data.get("Name", "").lstrip("/") or name, "restartCount": state.get("RestartCount") or 0, "service": name, "startedAt": state.get("StartedAt") or "", "state": state.get("Status") or "unknown", "status": "healthy" if state.get("Status") == "running" and health in ("healthy", "none") else ("running" if state.get("Status") == "running" else "critical"), }} service_states = [] for service in services: if service == "admin-server": active = command_output(["systemctl", "is-active", "hyapp-admin-server.service"], timeout=5) service_states.append({{ "composeService": "hyapp-admin-server.service", "configPath": service_path(service), "exists": active not in ("inactive", "unknown"), "health": "systemd", "image": "systemd", "name": "hyapp-admin-server.service", "restartCount": 0, "service": service, "startedAt": command_output(["systemctl", "show", "hyapp-admin-server.service", "--property=ActiveEnterTimestamp", "--value"], timeout=5), "state": active, "status": "healthy" if active == "active" else ("unknown" if active.startswith("Command") else "critical"), }}) else: service_states.append(inspect_container(service)) remote_test_raw = command_output(["git", "-C", source, "ls-remote", "origin", "refs/heads/test"], timeout=15) result = {{ "deployedCommit": command_output(["bash", "-lc", f"cat {{root}}/.deployed_commit 2>/dev/null || true"], timeout=5), "deployServiceState": command_output(["systemctl", "is-active", deploy_service], timeout=5), "git": {{ "branch": command_output(["git", "-C", source, "rev-parse", "--abbrev-ref", "HEAD"], timeout=8), "localCommit": command_output(["git", "-C", source, "rev-parse", "HEAD"], timeout=8), "remoteTestCommit": remote_test_raw.split()[0] if remote_test_raw else "", }}, "ok": True, "services": service_states, "target": {{ "instanceId": "lhins-q0m38zc6", "privateIp": "10.11.0.2", "publicIp": "43.165.195.39", "root": root, "timer": timer, }}, "timerState": command_output(["systemctl", "is-active", timer], timeout=5), "updatedAt": command_output(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"], timeout=5), }} print(json.dumps(result, ensure_ascii=False)) PY """ def remote_service_action_script(*, root: str, services: list[str], operation: str) -> str: payload = { "operation": operation, "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 subprocess payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) root = payload["root"] source = os.path.join(root, "source") services = payload["services"] operation = payload["operation"] def run(args, cwd=None, timeout=900): proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} def compose_args(): args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] override = os.path.join(root, "docker-compose.testbox.yml") if os.path.exists(override): args.extend(["-f", override]) return args go_services = [service for service in services if service != "admin-server"] has_admin = "admin-server" in services commands = [] ok = True def record(result): global ok commands.append(result) if result["code"] != 0: ok = False if operation == "update": record(run(["git", "fetch", "origin", "test"], cwd=source, timeout=120)) if ok: record(run(["git", "checkout", "test"], cwd=source, timeout=60)) if ok: record(run(["git", "pull", "--ff-only", "origin", "test"], cwd=source, timeout=180)) if ok and go_services: record(run(compose_args() + ["up", "-d", "--build"] + go_services, cwd=source, timeout=1200)) if ok and has_admin: record(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180)) if ok and not services: record(run([os.path.join(root, "bin", "deploy.sh")], timeout=1800)) elif operation in ("restart", "start", "stop"): if go_services: compose_operation = "up" if operation == "start" else operation extra = ["-d"] + go_services if operation == "start" else go_services record(run(compose_args() + [compose_operation] + extra, cwd=source, timeout=420)) if has_admin: record(run(["systemctl", operation, "hyapp-admin-server.service"], timeout=180)) else: ok = False commands.append({{"args": [], "code": 1, "output": f"unsupported operation: {{operation}}"}}) status = run(compose_args() + ["ps"], cwd=source, timeout=60) print(json.dumps({{ "commands": commands, "ok": ok, "operation": operation, "services": services, "status": status, }}, ensure_ascii=False)) PY """ def remote_timer_action_script(*, root: str, operation: str) -> str: payload = { "operation": operation, "root": root, } blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") return f"""set -euo pipefail python3 - <<'PY' import base64 import json import subprocess payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) operation = payload["operation"] timer = "hyapp-server-test-deploy.timer" def run(args, timeout=120): proc = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} commands = [] if operation == "status": commands.append(run(["systemctl", "status", timer, "--no-pager"], timeout=30)) else: commands.append(run(["systemctl", operation, timer], timeout=120)) state = run(["systemctl", "is-active", timer], timeout=20) print(json.dumps({{ "commands": commands, "ok": all(command["code"] == 0 for command in commands), "operation": operation, "timerState": state["output"], }}, ensure_ascii=False)) PY """ def remote_write_config_script(*, root: str, service: str, content_b64: str, restart: bool) -> str: payload = { "content": content_b64, "restart": restart, "root": root, "service": service, } 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 shutil import subprocess import time payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) root = payload["root"] source = os.path.join(root, "source") service = payload["service"] restart = bool(payload["restart"]) content = base64.b64decode(payload["content"].encode("ascii")) if b"\\x00" in content: raise RuntimeError("config content contains NUL byte") if len(content) > 60000: raise RuntimeError("config content is too large") def service_path(name): if name == "admin-server": return os.path.join(root, "admin-server", "config.yaml") return os.path.join(source, "services", name, "configs", "config.docker.yaml") def compose_args(): args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] override = os.path.join(root, "docker-compose.testbox.yml") if os.path.exists(override): args.extend(["-f", override]) return args def run(args, cwd=None, timeout=300): proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} path = service_path(service) os.makedirs(os.path.dirname(path), exist_ok=True) backup = "" if os.path.exists(path): backup = path + ".bak." + time.strftime("%Y%m%d%H%M%S", time.gmtime()) shutil.copy2(path, backup) with open(path, "wb") as handle: handle.write(content) if backup: shutil.copymode(backup, path) else: os.chmod(path, 0o600) commands = [] if restart: if service == "admin-server": commands.append(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180)) else: commands.append(run(compose_args() + ["restart", service], cwd=source, timeout=300)) print(json.dumps({{ "backupPath": backup, "commands": commands, "ok": all(command["code"] == 0 for command in commands), "path": path, "restarted": restart, "service": service, }}, ensure_ascii=False)) PY """ def remote_migrations_script(*, root: str, files: list[str], operation: str, dry_run: bool) -> str: payload = { "dryRun": dry_run, "files": files, "operation": operation, "root": root, } blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") return f"""set -euo pipefail python3 - <<'PY' import base64 import glob import hashlib import json import os import re import shlex import shutil import subprocess import time payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) root = payload["root"] source = os.path.join(root, "source") operation = payload["operation"] requested_files = payload["files"] dry_run = bool(payload["dryRun"]) migration_dir = os.path.join(source, "server", "admin", "migrations") config_path = os.path.join(root, "admin-server", "config.yaml") def read_text(path): with open(path, "r", encoding="utf-8", errors="replace") as handle: return handle.read() def sha256_file(path): with open(path, "rb") as handle: return hashlib.sha256(handle.read()).hexdigest() def parse_dsn(): if not os.path.exists(config_path): return None text = read_text(config_path) match = re.search(r'^mysql_dsn:\\s*["\\']?([^"\\'\\n]+)', text, re.M) if not match: return None dsn = match.group(1).strip() match = re.match(r"([^:@/]+):([^@]*)@tcp\\(([^:)]+)(?::(\\d+))?\\)/([^?]+)", dsn) if not match: return None return {{ "database": match.group(5), "host": match.group(3), "password": match.group(2), "port": match.group(4) or "3306", "user": match.group(1), }} def mysql_args(target): return [ "mysql", "--batch", "--raw", "--skip-column-names", "-h", target["host"], "-P", target["port"], "-u", target["user"], f"-p{{target['password']}}", target["database"], ] def run_mysql(target, sql, timeout=120): proc = subprocess.run(mysql_args(target), input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) return {{"code": proc.returncode, "output": proc.stdout.strip()}} def list_files(): rows = [] for path in sorted(glob.glob(os.path.join(migration_dir, "*.sql"))): name = os.path.basename(path) rows.append({{ "applied": False, "appliedAtMs": 0, "checksum": sha256_file(path), "dirty": False, "path": path, "status": "pending", "version": name, }}) return rows def sql_quote(value): return "'" + str(value).replace("\\\\", "\\\\\\\\").replace("'", "''") + "'" target = parse_dsn() mysql_available = bool(shutil.which("mysql")) migrations = list_files() applied = {{}} error = "" if target and mysql_available: ensure = run_mysql(target, \"\"\" CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(128) PRIMARY KEY, checksum CHAR(64) NOT NULL DEFAULT '', dirty BOOLEAN NOT NULL DEFAULT FALSE, applied_at_ms BIGINT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \"\"\", timeout=60) if ensure["code"] == 0: query = run_mysql(target, "SELECT version, checksum, dirty, applied_at_ms FROM schema_migrations;", timeout=60) if query["code"] == 0: for line in query["output"].splitlines(): parts = line.split("\\t") if len(parts) >= 4: applied[parts[0]] = {{"checksum": parts[1], "dirty": parts[2] in ("1", "true", "TRUE"), "appliedAtMs": int(parts[3] or 0)}} else: error = query["output"] else: error = ensure["output"] for row in migrations: current = applied.get(row["version"]) if current: row["applied"] = True row["appliedAtMs"] = current["appliedAtMs"] row["dirty"] = current["dirty"] if current["dirty"]: row["status"] = "dirty" elif current["checksum"] and current["checksum"] != row["checksum"]: row["status"] = "checksum_changed" else: row["status"] = "applied" commands = [] if operation == "apply": if dry_run: commands.append({{"code": 0, "output": "dry run only"}}) elif not target: commands.append({{"code": 1, "output": "admin-server mysql_dsn not found or unsupported"}}) elif not mysql_available: commands.append({{"code": 1, "output": "mysql client is not installed on testbox"}}) elif error: commands.append({{"code": 1, "output": error}}) else: selected = requested_files or [row["version"] for row in migrations if row["status"] == "pending"] by_version = {{row["version"]: row for row in migrations}} for version in selected: row = by_version.get(version) if not row: commands.append({{"code": 1, "output": f"migration not found: {{version}}"}}) break if row["status"] == "applied": commands.append({{"code": 0, "output": f"skip applied {{version}}"}}) continue if row["status"] in ("dirty", "checksum_changed"): commands.append({{"code": 1, "output": f"migration {{version}} is {{row['status']}}"}}) break checksum = row["checksum"] insert = run_mysql(target, f"INSERT INTO schema_migrations(version, checksum, dirty) VALUES ({{sql_quote(version)}}, {{sql_quote(checksum)}}, TRUE);", timeout=60) commands.append({{"code": insert["code"], "output": f"mark dirty {{version}}\\n{{insert['output']}}".strip()}}) if insert["code"] != 0: break with open(row["path"], "r", encoding="utf-8", errors="replace") as handle: body = handle.read() apply = run_mysql(target, body, timeout=600) commands.append({{"code": apply["code"], "output": f"apply {{version}}\\n{{apply['output']}}".strip()}}) if apply["code"] != 0: break applied_at_ms = int(time.time() * 1000) finish = run_mysql(target, f"UPDATE schema_migrations SET dirty = FALSE, checksum = {{sql_quote(checksum)}}, applied_at_ms = {{applied_at_ms}} WHERE version = {{sql_quote(version)}};", timeout=60) commands.append({{"code": finish["code"], "output": f"finish {{version}}\\n{{finish['output']}}".strip()}}) if finish["code"] != 0: break ok = not commands or all(command["code"] == 0 for command in commands) print(json.dumps({{ "commands": commands, "configPath": config_path, "database": {{"database": target["database"], "host": target["host"], "port": target["port"], "user": target["user"]}} if target else None, "dryRun": dry_run, "error": error, "migrationDir": migration_dir, "migrations": migrations, "mysqlAvailable": mysql_available, "ok": ok, "operation": operation, }}, 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 parse_tat_json(result: dict[str, Any]) -> dict[str, Any]: 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 read_status(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_status_script(root=args.root, services=services), command_name="hyapp-testbox-status", timeout_seconds=args.timeout_seconds, poll_seconds=args.poll_seconds, ) return parse_tat_json(result) def run_service_action(args: argparse.Namespace) -> dict[str, Any]: services = parse_services(args.services) operation = parse_operation(args.operation, SERVICE_OPERATIONS, "service") client = build_tat_client(args.region) result = run_tat_shell( client, instance_id=args.instance_id, script=remote_service_action_script(root=args.root, services=services, operation=operation), command_name=f"hyapp-testbox-service-{operation}", timeout_seconds=args.timeout_seconds, poll_seconds=args.poll_seconds, ) return parse_tat_json(result) def run_timer_action(args: argparse.Namespace) -> dict[str, Any]: operation = parse_operation(args.operation, TIMER_OPERATIONS, "timer") client = build_tat_client(args.region) result = run_tat_shell( client, instance_id=args.instance_id, script=remote_timer_action_script(root=args.root, operation=operation), command_name=f"hyapp-testbox-timer-{operation}", timeout_seconds=args.timeout_seconds, poll_seconds=args.poll_seconds, ) return parse_tat_json(result) def write_config(args: argparse.Namespace) -> dict[str, Any]: if not args.service: raise RuntimeError("service is required for write-config") service = parse_services(args.service)[0] raw_content = base64.b64decode(args.content_base64.encode("ascii")) if len(raw_content) > 60_000: raise RuntimeError("config content is too large") content_b64 = base64.b64encode(raw_content).decode("ascii") client = build_tat_client(args.region) result = run_tat_shell( client, instance_id=args.instance_id, script=remote_write_config_script(root=args.root, service=service, content_b64=content_b64, restart=args.restart), command_name=f"hyapp-testbox-write-config-{service}", timeout_seconds=args.timeout_seconds, poll_seconds=args.poll_seconds, ) return parse_tat_json(result) def read_or_apply_migrations(args: argparse.Namespace) -> dict[str, Any]: operation = "apply" if args.action == "migrations-apply" else "list" files = parse_migration_files(args.files) client = build_tat_client(args.region) result = run_tat_shell( client, instance_id=args.instance_id, script=remote_migrations_script(root=args.root, files=files, operation=operation, dry_run=args.dry_run), command_name=f"hyapp-testbox-migrations-{operation}", timeout_seconds=args.timeout_seconds, poll_seconds=args.poll_seconds, ) return parse_tat_json(result) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Manage HYApp testbox through Tencent TAT.") parser.add_argument("action", choices=("configs", "status", "service", "timer", "write-config", "migrations", "migrations-apply")) 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("--service", default="", help="single service for write-config") parser.add_argument("--operation", default="", help="service or timer operation") parser.add_argument("--content-base64", default="", help="base64 encoded YAML content for write-config") parser.add_argument("--restart", action="store_true", help="restart service after write-config") parser.add_argument("--files", default="", help="comma separated migration file names") parser.add_argument("--dry-run", action="store_true", help="validate action without applying migrations") 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=1200) 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) if args.action == "configs": payload = read_configs(args) elif args.action == "status": payload = read_status(args) elif args.action == "service": payload = run_service_action(args) elif args.action == "timer": payload = run_timer_action(args) elif args.action == "write-config": payload = write_config(args) elif args.action in {"migrations", "migrations-apply"}: payload = read_or_apply_migrations(args) else: raise RuntimeError(f"unsupported action: {args.action}") 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())