hyapp-deploly/deploy/tencent-tat/hyapp_testbox_config.py
2026-05-21 16:43:57 +08:00

931 lines
33 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-]*$")
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())