148 lines
6.3 KiB
Python
148 lines
6.3 KiB
Python
#!/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:]))
|