hyapp-deploly/deploy/tencent_tat/validate_rendered_configs.py
2026-07-08 22:33:12 +08:00

148 lines
6.3 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 json
import os
import sys
from pathlib import Path
from typing import Any
import yaml
CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml")
def default_hyapp_root() -> Path:
env_root = os.environ.get("HYAPP_SERVER_ROOT") or os.environ.get("HYAPP_ROOT") or ""
if env_root:
return Path(env_root).expanduser().resolve()
return (Path.cwd().parent / "hyapp-server").resolve()
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise RuntimeError(f"config file missing: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise RuntimeError(f"config file must be a mapping: {path}")
return data
def load_inventory(path: Path) -> dict[str, Any]:
if not path.exists():
raise RuntimeError(f"inventory missing: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data.get("services"), dict):
raise RuntimeError("inventory.services is required")
return data
def mapping_paths(value: Any, prefix: str = "") -> set[str]:
# 只比较 YAML object key不比较值密钥和环境差异不会被打印或误判。
if not isinstance(value, dict):
return set()
paths: set[str] = set()
for key, child in value.items():
current = f"{prefix}.{key}" if prefix else str(key)
paths.add(current)
paths.update(mapping_paths(child, current))
return paths
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
names = [item.strip() for item in raw_services.split(",") if item.strip()]
if not names:
return sorted(str(name) for name in inventory["services"].keys())
unknown = [name for name in names if name not in inventory["services"]]
if unknown:
raise RuntimeError("unknown services: " + ", ".join(unknown))
return list(dict.fromkeys(names))
def selected_hosts(service: dict[str, Any], raw_hosts: str) -> list[str]:
hosts = [str(item) for item in service.get("hosts") or []]
requested = [item.strip() for item in raw_hosts.split(",") if item.strip()]
if not requested:
return hosts
invalid = [host for host in requested if host not in hosts]
if invalid:
raise RuntimeError("selected hosts are not assigned to this service: " + ", ".join(invalid))
return requested
def service_config_required(service: dict[str, Any]) -> bool:
# 少数边缘入口是 env-only 进程,例如 luck-gateway 只承载 HTTP 转发边界;
# inventory 显式标记后跳过 YAML contract 校验,避免为了校验脚本伪造无效 config。
return bool(service.get("config_required", True))
def validate_service_templates(hyapp_root: Path, service_name: str, service: dict[str, Any]) -> list[str]:
if not service_config_required(service):
return []
errors: list[str] = []
config_dir = hyapp_root / "services" / service_name / "configs"
configs = {name: load_yaml(config_dir / name) for name in CONFIG_FILENAMES}
contract_paths = mapping_paths(configs["config.tencent.example.yaml"])
for filename in ("config.yaml", "config.docker.yaml"):
missing = sorted(mapping_paths(configs[filename]) - contract_paths)
if missing:
errors.append(
f"{service_name}: {filename} contains keys missing from config.tencent.example.yaml: "
+ ", ".join(missing)
)
return errors
def validate_rendered_config(hyapp_root: Path, service_name: str, service: dict[str, Any], host_name: str, rendered_dir: Path) -> list[str]:
if not service_config_required(service):
return []
contract = load_yaml(hyapp_root / "services" / service_name / "configs" / "config.tencent.example.yaml")
rendered_path = rendered_dir / host_name / service_name / "config.yaml"
rendered = load_yaml(rendered_path)
missing = sorted(mapping_paths(contract) - mapping_paths(rendered))
if not missing:
return []
return [
f"{service_name}@{host_name}: rendered config missing keys required by config.tencent.example.yaml: "
+ ", ".join(missing)
]
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Validate service config contracts before TAT deployment.")
parser.add_argument("--inventory", default="deploy/tencent_tat/hyapp-services.inventory.prod.example.json")
parser.add_argument("--rendered-dir", default="", help="Directory with <host>/<service>/config.yaml files.")
parser.add_argument("--services", default="", help="Comma separated service names. Defaults to all inventory services.")
parser.add_argument("--hosts", default="", help="Optional comma separated hosts assigned to the selected service(s).")
parser.add_argument("--hyapp-root", default="", help="Path to hyapp-server source tree. Defaults to HYAPP_SERVER_ROOT or ../hyapp-server.")
args = parser.parse_args(argv)
try:
inventory = load_inventory(Path(args.inventory))
hyapp_root = Path(args.hyapp_root).expanduser().resolve() if args.hyapp_root else default_hyapp_root()
if not (hyapp_root / "services").is_dir():
raise RuntimeError(f"hyapp services directory missing: {hyapp_root / 'services'}")
rendered_dir = Path(args.rendered_dir).expanduser().resolve() if args.rendered_dir else None
errors: list[str] = []
for service_name in selected_service_names(inventory, args.services):
service = inventory["services"][service_name]
errors.extend(validate_service_templates(hyapp_root, service_name, service))
if rendered_dir is None:
continue
for host_name in selected_hosts(service, args.hosts):
errors.extend(validate_rendered_config(hyapp_root, service_name, service, host_name, rendered_dir))
if errors:
print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
print(json.dumps({"ok": True}, ensure_ascii=False))
return 0
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))