hyapp-deploly/deploy/tencent_tat/hyapp_data_resources.py
2026-05-27 17:52:40 +08:00

919 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
DEFAULT_RESOURCE_META = {
"mq": {
"backup_status": "云产品托管,平台不执行恢复动作",
"name": "Tencent Cloud MQ",
"owner": "后端平台",
"topology": "托管消息队列",
"type": "mq",
},
"mysql": {
"backup_status": "云产品自动备份,平台不执行恢复动作",
"name": "TencentDB MySQL",
"owner": "后端平台",
"topology": "托管主从 MySQL",
"type": "mysql",
},
"redis": {
"backup_status": "缓存资源不作为恢复事实源",
"name": "TencentDB Redis",
"owner": "后端平台",
"topology": "托管 Redis",
"type": "redis",
},
}
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 load_inventory(path: str) -> dict[str, Any]:
inventory_path = Path(path).expanduser().resolve()
with inventory_path.open("r", encoding="utf-8") as handle:
inventory = json.load(handle)
validate_inventory(inventory)
return inventory
def validate_inventory(inventory: dict[str, Any]) -> None:
if not isinstance(inventory.get("deploy_server"), dict):
raise RuntimeError("inventory.deploy_server is required")
if not str(inventory["deploy_server"].get("instance_id") or "").strip():
raise RuntimeError("inventory.deploy_server.instance_id is required")
if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]:
raise RuntimeError("inventory.hosts is required")
if not isinstance(inventory.get("services"), dict) or not inventory["services"]:
raise RuntimeError("inventory.services is required")
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 region(inventory: dict[str, Any]) -> str:
return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "")
def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]:
sid = secret_id()
skey = secret_key()
reg = region(inventory)
missing = []
if not sid:
missing.append("TENCENTCLOUD_SECRET_ID")
if not skey:
missing.append("TENCENTCLOUD_SECRET_KEY")
if not reg:
missing.append("TENCENTCLOUD_REGION")
if missing:
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
return sid, skey, reg
def build_tat_client(inventory: dict[str, Any]) -> Any:
sid, skey, reg = require_tencent_credentials(inventory)
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),
reg,
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 normalize_data_resources(inventory: dict[str, Any]) -> list[dict[str, Any]]:
raw_resources = inventory.get("data_resources")
resources: list[dict[str, Any]] = []
if isinstance(raw_resources, dict) and raw_resources:
for resource_id, raw in raw_resources.items():
if isinstance(raw, dict):
resource = dict(raw)
else:
resource = {"description": str(raw)}
resource["id"] = str(resource.get("id") or resource_id)
resources.append(resource)
else:
managed_dependencies = inventory.get("managed_dependencies") or {}
if isinstance(managed_dependencies, dict):
for resource_id, description in managed_dependencies.items():
resource = {"description": str(description), "id": str(resource_id)}
resources.append(resource)
service_names = sorted(str(name) for name in inventory.get("services", {}).keys())
normalized = []
for resource in resources:
resource_id = str(resource.get("id") or "").strip()
defaults = DEFAULT_RESOURCE_META.get(resource_id, {})
resource_type = str(resource.get("type") or defaults.get("type") or resource_id or "unknown")
related_services = resource.get("related_services") or resource.get("relatedServices") or service_names
normalized.append(
{
"backupStatus": str(resource.get("backup_status") or resource.get("backupStatus") or defaults.get("backup_status") or "未采集"),
"capacityUsedPercent": resource.get("capacity_used_percent") or resource.get("capacityUsedPercent"),
"connections": resource.get("connections"),
"description": str(resource.get("description") or ""),
"endpoints": resource.get("endpoints") or ([resource.get("endpoint")] if resource.get("endpoint") else []),
"id": resource_id,
"name": str(resource.get("name") or defaults.get("name") or resource_id),
"owner": str(resource.get("owner") or defaults.get("owner") or "后端平台"),
"relatedServices": [str(item) for item in related_services if str(item).strip()],
"slowQueryCount": resource.get("slow_query_count") or resource.get("slowQueryCount"),
"topology": str(resource.get("topology") or defaults.get("topology") or "托管资源"),
"type": resource_type,
}
)
return normalized
def build_remote_payload(inventory: dict[str, Any], *, max_bytes: int) -> dict[str, Any]:
services = []
for service_name, service in inventory["services"].items():
services.append(
{
"configPath": service.get("config_path") or f"/etc/hyapp/{service_name}/config.yaml",
"envFile": service.get("env_file") or "",
"hosts": list(service.get("hosts") or []),
"name": service_name,
"unit": service.get("unit") or "",
}
)
return {
"dataResources": normalize_data_resources(inventory),
"hosts": inventory["hosts"],
"maxBytes": max_bytes,
"region": region(inventory),
"services": services,
"sshUser": os.environ.get("DEPLOY_DATA_SSH_USER", "root"),
"tatFallbackEnvFiles": [
item.strip()
for item in os.environ.get(
"DEPLOY_DATA_TAT_FALLBACK_ENV_FILES",
"/opt/hy-app-monitor/.env,/etc/hyapp/deploy-platform/docker.env",
).split(",")
if item.strip()
],
"tatFallbackTimeout": int(inventory.get("tat_data_fallback_timeout_seconds") or 120),
"tatPollSeconds": int(inventory.get("tat_poll_seconds") or 2),
}
def remote_collect_script(payload: dict[str, Any]) -> str:
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 socket
import subprocess
import sys
import time
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
services = payload["services"]
hosts = payload["hosts"]
data_resources = payload["dataResources"]
ssh_user = payload.get("sshUser") or "root"
max_bytes = int(payload.get("maxBytes") or 24000)
region_name = payload.get("region") or ""
tat_fallback_env_files = payload.get("tatFallbackEnvFiles") or []
tat_fallback_timeout = int(payload.get("tatFallbackTimeout") or 120)
tat_poll_seconds = int(payload.get("tatPollSeconds") or 2)
ssh_options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=1",
"-o", "StrictHostKeyChecking=accept-new",
]
def clean_env_value(value):
return str(value or "").strip().replace("export ", "", 1).strip().strip('"').strip("'")
def load_env_file(path):
if not path:
return
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
lines = handle.read().splitlines()
except Exception:
return
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().replace("export ", "", 1)
if key and key not in os.environ:
os.environ[key] = clean_env_value(value)
if "TENCENTCLOUD_SECRET_ID" not in os.environ and os.environ.get("TENCENT_SECRET_ID"):
os.environ["TENCENTCLOUD_SECRET_ID"] = os.environ["TENCENT_SECRET_ID"]
if "TENCENTCLOUD_SECRET_KEY" not in os.environ and os.environ.get("TENCENT_SECRET_KEY"):
os.environ["TENCENTCLOUD_SECRET_KEY"] = os.environ["TENCENT_SECRET_KEY"]
def cloud_secret_id():
return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or ""
def cloud_secret_key():
return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or ""
def build_tat_client():
for env_file in tat_fallback_env_files:
load_env_file(env_file)
sid = cloud_secret_id()
skey = cloud_secret_key()
missing = []
if not sid:
missing.append("TENCENTCLOUD_SECRET_ID")
if not skey:
missing.append("TENCENTCLOUD_SECRET_KEY")
if not region_name:
missing.append("TENCENTCLOUD_REGION")
if missing:
raise RuntimeError("missing Tencent Cloud credentials on deploy machine: " + ", ".join(missing))
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
return tat_client.TatClient(
credential.Credential(sid, skey),
region_name,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def invoke_tencent(action, label, retries=3):
try:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
except Exception:
TencentCloudSDKException = Exception
last_error = None
for attempt in range(1, retries + 1):
try:
return action()
except TencentCloudSDKException as exc:
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):
if not output:
return ""
return base64.b64decode(output).decode("utf-8", errors="replace")
def run_tat_shell(instance_id, script, command_name):
from tencentcloud.tat.v20201028 import models as tat_models
client = build_tat_client()
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": tat_fallback_timeout,
"WorkingDirectory": "/root",
}}
)
)
response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string())
invocation_id = response["InvocationId"]
deadline = time.time() + tat_fallback_timeout + 60
while time.time() < deadline:
time.sleep(max(tat_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": ""}}
remote_reader = r'''
import json
import os
import subprocess
import sys
payload = json.load(sys.stdin)
max_bytes = int(payload.get("maxBytes") or 24000)
def read_path(path):
item = {{"content": "", "exists": False, "path": path, "truncated": False}}
if not path:
return item
item["exists"] = os.path.exists(path)
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"] = content[:max_bytes]
return item
def command(args):
try:
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=5).strip()
except Exception as exc:
return str(exc)
items = []
for service in payload.get("services") or []:
seen = []
paths = []
for path in [service.get("configPath") or "", service.get("envFile") or ""]:
if path and path not in seen:
seen.append(path)
paths.append(read_path(path))
items.append({{
"name": service.get("name") or "",
"paths": paths,
"unit": service.get("unit") or "",
"unitState": command(["systemctl", "is-active", service.get("unit") or ""]) if service.get("unit") else "",
}})
print(json.dumps({{"ok": True, "services": items}}, ensure_ascii=False))
'''
def run_tat_fallback(host_name, host, host_services, reason):
instance_id = str(host.get("instance_id") or "").strip()
private_ip = str(host.get("private_ip") or "").strip()
if not instance_id:
return {{
"error": "ssh failed and instance_id missing: " + reason,
"host": host_name,
"privateIp": private_ip,
"services": [],
"status": "TAT_FALLBACK_UNAVAILABLE",
"transport": "tat-fallback",
}}
remote_payload = {{
"maxBytes": max_bytes,
"services": host_services,
}}
reader_blob = base64.b64encode(remote_reader.encode("utf-8")).decode("ascii")
payload_blob = base64.b64encode(json.dumps(remote_payload).encode("utf-8")).decode("ascii")
script = '''set -euo pipefail
python3 - <<'FALLBACKPY'
import base64
import subprocess
import sys
code = base64.b64decode(%r).decode("utf-8")
payload_text = base64.b64decode(%r).decode("utf-8")
completed = subprocess.run(
[sys.executable, "-c", code],
input=payload_text,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=30,
check=False,
)
sys.stdout.write(completed.stdout)
raise SystemExit(completed.returncode)
FALLBACKPY
''' % (reader_blob, payload_blob)
try:
result = run_tat_shell(instance_id, script, "hyapp-data-resource-fallback-" + host_name)
except Exception as exc:
return {{
"error": "ssh failed: " + reason + "; tat fallback failed: " + str(exc),
"host": host_name,
"privateIp": private_ip,
"services": [],
"status": "TAT_FALLBACK_ERROR",
"transport": "tat-fallback",
}}
if result["status"] != "SUCCESS":
return {{
"error": "ssh failed: " + reason + "; tat fallback status: " + result["status"],
"host": host_name,
"privateIp": private_ip,
"services": [],
"status": "TAT_FALLBACK_ERROR",
"transport": "tat-fallback",
}}
try:
decoded = json.loads(result.get("output") or "{{}}")
except Exception as exc:
return {{
"error": "ssh failed: " + reason + "; invalid tat fallback json: " + str(exc),
"host": host_name,
"privateIp": private_ip,
"services": [],
"status": "TAT_FALLBACK_ERROR",
"transport": "tat-fallback",
}}
return {{
"fallbackReason": reason[-500:],
"host": host_name,
"privateIp": private_ip,
"services": decoded.get("services") or [],
"status": "TAT_FALLBACK_SUCCESS",
"transport": "tat-fallback",
}}
def run_ssh(host_name, host, host_services):
private_ip = str(host.get("private_ip") or "").strip()
if not private_ip:
return {{
"error": "private_ip missing",
"host": host_name,
"privateIp": "",
"services": [],
"status": "ERROR",
"transport": "ssh",
}}
ssh_host = str(host.get("ssh_user") or ssh_user) + "@" + private_ip
remote_payload = {{
"maxBytes": max_bytes,
"services": host_services,
}}
try:
completed = subprocess.run(
["ssh", *ssh_options, ssh_host, "python3", "-c", remote_reader],
input=json.dumps(remote_payload),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=20,
check=False,
)
except Exception as exc:
return run_tat_fallback(host_name, host, host_services, str(exc))
if completed.returncode != 0:
return run_tat_fallback(host_name, host, host_services, completed.stdout[-1000:])
try:
decoded = json.loads(completed.stdout)
except Exception as exc:
return run_tat_fallback(host_name, host, host_services, f"invalid ssh json: {{exc}}")
return {{
"host": host_name,
"privateIp": private_ip,
"services": decoded.get("services") or [],
"status": "SUCCESS",
"transport": "ssh",
}}
def endpoint_from_host_port(host, port):
host = str(host or "").strip().strip('"').strip("'")
port = str(port or "").strip()
if not host:
return ""
return host + ((":" + port) if port else "")
def normalize_endpoint(value, default_port=""):
text = str(value or "").strip().strip('"').strip("'")
if not text:
return ""
tcp_match = re.search(r"@tcp\\(([^)]+)\\)", text)
if tcp_match:
text = tcp_match.group(1)
if "://" in text:
text = text.split("://", 1)[1].split("/", 1)[0]
text = text.split(",", 1)[0].strip()
if not text:
return ""
if ":" not in text and default_port:
text = text + ":" + default_port
return text
def endpoint_candidates(line, default_port=""):
found = []
for host, port in re.findall(r"((?:\\d{{1,3}}\\.){{3}}\\d{{1,3}}|[A-Za-z0-9][A-Za-z0-9.-]*):([0-9]{{2,5}})", line):
endpoint = endpoint_from_host_port(host, port)
if endpoint:
found.append(endpoint)
if not found:
endpoint = normalize_endpoint(line.split("=", 1)[-1].split(":", 1)[-1], default_port)
if endpoint and re.search(r"[A-Za-z0-9.:-]", endpoint):
found.append(endpoint)
return found
def extract_config_observations(text):
observations = {{
"mq": {{"endpoints": set(), "services": set(), "values": set()}},
"mysql": {{"databases": set(), "endpoints": set(), "services": set(), "values": set()}},
"redis": {{"endpoints": set(), "services": set(), "values": set()}},
}}
for match in re.finditer(r"@tcp\\(([^)]+)\\)/([^?\\s\\\"']+)", text):
endpoint = normalize_endpoint(match.group(1), "3306")
if endpoint:
observations["mysql"]["endpoints"].add(endpoint)
observations["mysql"]["databases"].add(match.group(2))
observations["mysql"]["values"].add(endpoint + "/" + match.group(2))
section = ""
for raw_line in text.splitlines():
indent = len(raw_line) - len(raw_line.lstrip())
line = raw_line.strip()
if not line or line.startswith("#"):
continue
section_match = re.match(r"^([A-Za-z0-9_.-]+):\\s*$", line)
if section_match:
key = section_match.group(1).lower()
if indent == 0 or key in ("redis", "rocketmq"):
section = key
continue
lower = line.lower()
if "mysql" in lower and ("dsn" in lower or "@tcp(" in lower):
endpoint = normalize_endpoint(line, "3306")
if endpoint:
observations["mysql"]["endpoints"].add(endpoint)
if "redis" in lower and ("addr" in lower or "host" in lower or "endpoint" in lower):
for endpoint in endpoint_candidates(line, "6379"):
observations["redis"]["endpoints"].add(endpoint)
if section == "redis" and re.match(r"^(addr|host|endpoint)\\s*:", lower):
for endpoint in endpoint_candidates(line, "6379"):
observations["redis"]["endpoints"].add(endpoint)
if "rocketmq" in lower or "name_server" in lower or "nameserver" in lower or section == "rocketmq":
if re.search(r"((?:\\d{{1,3}}\\.){{3}}\\d{{1,3}}|[A-Za-z0-9][A-Za-z0-9.-]*):([0-9]{{2,5}})", line) or any(
key in lower for key in ("addr", "endpoint", "host", "name_server", "nameserver")
):
for endpoint in endpoint_candidates(line, "9876"):
observations["mq"]["endpoints"].add(endpoint)
if "topic" in lower or "group" in lower:
observations["mq"]["values"].add(line[:160])
return observations
def merge_observation(target, source, service_name):
for resource_id, fields in source.items():
target[resource_id]["services"].add(service_name)
for key, values in fields.items():
if key == "services":
continue
target[resource_id].setdefault(key, set()).update(values)
def tcp_check(endpoint):
host, sep, raw_port = endpoint.rpartition(":")
if not sep:
return {{"detail": "missing port", "endpoint": endpoint, "status": "unknown"}}
try:
port = int(raw_port)
except ValueError:
return {{"detail": "invalid port", "endpoint": endpoint, "status": "unknown"}}
started = time.time()
try:
with socket.create_connection((host, port), timeout=4):
pass
return {{
"detail": "connect ok",
"endpoint": endpoint,
"latencyMs": round((time.time() - started) * 1000, 1),
"status": "success",
}}
except Exception as exc:
return {{
"detail": str(exc),
"endpoint": endpoint,
"status": "failed",
}}
services_by_host = {{}}
for service in services:
for host_name in service.get("hosts") or []:
services_by_host.setdefault(host_name, []).append(service)
host_results = []
for host_name in services_by_host:
host = hosts.get(host_name) or {{}}
host_results.append(run_ssh(host_name, host, services_by_host[host_name]))
observations = {{
"mq": {{"endpoints": set(), "services": set(), "values": set()}},
"mysql": {{"databases": set(), "endpoints": set(), "services": set(), "values": set()}},
"redis": {{"endpoints": set(), "services": set(), "values": set()}},
}}
for host_result in host_results:
if host_result["status"] not in ("SUCCESS", "TAT_FALLBACK_SUCCESS"):
continue
for service in host_result.get("services") or []:
text_parts = []
for path in service.get("paths") or []:
if path.get("exists"):
text_parts.append(str(path.get("content") or ""))
service_observations = extract_config_observations("\\n".join(text_parts))
merge_observation(observations, service_observations, str(service.get("name") or ""))
resources = []
successful_sources = [item for item in host_results if item["status"] in ("SUCCESS", "TAT_FALLBACK_SUCCESS")]
for resource in data_resources:
resource_id = str(resource.get("id") or "")
resource_type = str(resource.get("type") or resource_id)
observation_key = "mq" if resource_type in ("mq", "rocketmq", "kafka") else resource_type
observed = observations.get(observation_key, {{"endpoints": set(), "services": set(), "values": set()}})
endpoints = set(str(item) for item in observed.get("endpoints", set()) if str(item).strip())
for endpoint in resource.get("endpoints") or []:
normalized = normalize_endpoint(endpoint)
if normalized:
endpoints.add(normalized)
checks = []
for endpoint in sorted(endpoints)[:12]:
checks.append(tcp_check(endpoint))
if not checks and resource.get("endpoints"):
checks.append({{"detail": "endpoint 格式无法识别", "status": "unknown"}})
if not successful_sources:
status = "critical"
elif checks and all(check["status"] == "success" for check in checks):
status = "healthy"
elif any(check["status"] == "success" for check in checks):
status = "warning"
elif checks:
status = "critical"
else:
status = "unknown"
related_services = sorted(observed.get("services", set())) or resource.get("relatedServices") or []
databases = sorted(observed.get("databases", set())) if observation_key == "mysql" else []
resources.append({{
"accessPath": "TAT -> deploy服务器配置 SSH/TAT 兜底",
"backupStatus": resource.get("backupStatus") or "未采集",
"capacityUsedPercent": resource.get("capacityUsedPercent"),
"checks": checks,
"connections": resource.get("connections"),
"description": resource.get("description") or "",
"endpoints": sorted(endpoints),
"id": resource_id,
"name": resource.get("name") or resource_id,
"observedDatabases": databases,
"owner": resource.get("owner") or "后端平台",
"relatedServices": related_services,
"slowQueryCount": resource.get("slowQueryCount"),
"sourceHosts": [
{{
"error": item.get("error") or "",
"fallbackReason": item.get("fallbackReason") or "",
"host": item["host"],
"privateIp": item.get("privateIp") or "",
"serviceCount": len(item.get("services") or []),
"status": item["status"],
"transport": item.get("transport") or "",
}}
for item in host_results
],
"status": status,
"topology": resource.get("topology") or "托管资源",
"type": resource_type,
}})
summary = {{
"critical": len([item for item in resources if item["status"] == "critical"]),
"healthy": len([item for item in resources if item["status"] == "healthy"]),
"unknown": len([item for item in resources if item["status"] == "unknown"]),
"warning": len([item for item in resources if item["status"] == "warning"]),
}}
print(json.dumps({{
"lastSyncedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"ok": True,
"resources": resources,
"sourceHosts": [
{{
"error": item.get("error") or "",
"fallbackReason": item.get("fallbackReason") or "",
"host": item["host"],
"privateIp": item.get("privateIp") or "",
"serviceCount": len(item.get("services") or []),
"status": item["status"],
"transport": item.get("transport") or "",
}}
for item in host_results
],
"summary": summary,
}}, ensure_ascii=False))
PY
"""
def target_payload(inventory: dict[str, Any]) -> dict[str, Any]:
deploy_server = inventory["deploy_server"]
return {
"instanceId": str(deploy_server.get("instance_id") or ""),
"name": str(deploy_server.get("name") or "deploy"),
"privateIp": str(deploy_server.get("private_ip") or ""),
"publicIp": str(deploy_server.get("public_ip") or ""),
}
def dry_run_payload(inventory: dict[str, Any], *, max_bytes: int) -> dict[str, Any]:
return {
"dryRun": True,
"ok": True,
"resources": [
{
"accessPath": "TAT -> deploy服务器配置 SSH/TAT 兜底",
"backupStatus": resource["backupStatus"],
"capacityUsedPercent": resource["capacityUsedPercent"],
"checks": [],
"connections": resource["connections"],
"description": resource["description"],
"endpoints": resource["endpoints"],
"id": resource["id"],
"name": resource["name"],
"observedDatabases": [],
"owner": resource["owner"],
"relatedServices": resource["relatedServices"],
"slowQueryCount": resource["slowQueryCount"],
"sourceHosts": [],
"status": "unknown",
"topology": resource["topology"],
"type": resource["type"],
}
for resource in normalize_data_resources(inventory)
],
"sourceHosts": [],
"summary": {"critical": 0, "healthy": 0, "unknown": len(normalize_data_resources(inventory)), "warning": 0},
"target": target_payload(inventory),
"tat": {"status": "DRY_RUN"},
"maxBytes": max_bytes,
}
def read_resources(args: argparse.Namespace) -> dict[str, Any]:
inventory = load_inventory(args.inventory)
if args.dry_run:
return dry_run_payload(inventory, max_bytes=args.max_bytes)
deploy_server = inventory["deploy_server"]
deploy_instance_id = str(deploy_server.get("instance_id") or "").strip()
client = build_tat_client(inventory)
result = run_tat_shell(
client,
instance_id=deploy_instance_id,
script=remote_collect_script(build_remote_payload(inventory, max_bytes=args.max_bytes)),
command_name="hyapp-data-resources",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
tat = {"instanceId": deploy_instance_id, "status": result["status"], "target": target_payload(inventory)}
if result["status"] != "SUCCESS":
return {"error": "TAT command failed", "ok": False, "output": result.get("output") or "", "resources": [], "tat": tat}
try:
payload = json.loads(str(result.get("output") or "{}"))
except json.JSONDecodeError:
return {"error": "invalid TAT json output", "ok": False, "output": result.get("output") or "", "resources": [], "tat": tat}
payload["target"] = target_payload(inventory)
payload["tat"] = tat
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Read HYApp data resource status through Tencent TAT on deploy machine.")
parser.add_argument("action", choices=("resources",))
parser.add_argument("--dry-run", action="store_true", help="Print resources configured in inventory without calling Tencent APIs.")
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
parser.add_argument("--inventory", default="deploy/tencent_tat/hyapp-services.inventory.prod.example.json")
parser.add_argument("--max-bytes", type=int, default=24_000)
parser.add_argument("--poll-seconds", type=int, default=2)
parser.add_argument("--timeout-seconds", type=int, default=180)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
load_env_file(args.env_file)
payload = read_resources(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())