#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any import yaml CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml") 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 validate_service_templates(service_name: str) -> list[str]: errors: list[str] = [] config_dir = Path("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(service_name: str, host_name: str, rendered_dir: Path) -> list[str]: contract = load_yaml(Path("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/inventory.prod.example.json") parser.add_argument("--rendered-dir", default="", help="Directory with //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).") args = parser.parse_args(argv) try: inventory = load_inventory(Path(args.inventory)) 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): errors.extend(validate_service_templates(service_name)) if rendered_dir is None: continue for host_name in selected_hosts(inventory["services"][service_name], args.hosts): errors.extend(validate_rendered_config(service_name, 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:]))