907 lines
32 KiB
Python
Executable File
907 lines
32 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:
|
|
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 = 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 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))
|
|
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:
|
|
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())
|