401 lines
13 KiB
Python
401 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import difflib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
try:
|
|
import yaml
|
|
except Exception as exc: # noqa: BLE001
|
|
raise SystemExit(
|
|
f"需要 PyYAML。请使用: uvx --with pyyaml python scripts/gitops_v2/render_shadow_diff.py\n{exc}"
|
|
)
|
|
|
|
from profile_matrix import (
|
|
get_profile_payload,
|
|
get_profile_registry_host,
|
|
get_profile_service_map_path,
|
|
load_profile_matrix,
|
|
resolve_target_profile,
|
|
)
|
|
|
|
|
|
JsonObject = dict[str, object]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComponentUpdate:
|
|
service_name: str
|
|
image_ref: str
|
|
digest: str
|
|
overlay_rel: str
|
|
image_name: str
|
|
deploy_image: str
|
|
|
|
|
|
def ensure_mapping(value: object, label: str) -> JsonObject:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} 必须是对象")
|
|
return cast(JsonObject, value)
|
|
|
|
|
|
def ensure_list(value: object, label: str) -> list[object]:
|
|
if not isinstance(value, list):
|
|
raise ValueError(f"{label} 必须是数组")
|
|
return value
|
|
|
|
|
|
def get_required_str(payload: JsonObject, key: str) -> str:
|
|
value = payload.get(key)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{key} 必须是非空字符串")
|
|
return value.strip()
|
|
|
|
|
|
def read_json_file(path: Path) -> JsonObject:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
return ensure_mapping(payload, str(path))
|
|
|
|
|
|
def read_yaml_file(path: Path) -> JsonObject:
|
|
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
return ensure_mapping(payload, str(path))
|
|
|
|
|
|
def render_yaml(payload: JsonObject) -> str:
|
|
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
|
|
|
|
|
def repo_slug(repo_name: str) -> str:
|
|
return repo_name.replace("/", "__")
|
|
|
|
|
|
def digest_from_image(image_ref: str) -> str:
|
|
if "@sha256:" not in image_ref:
|
|
raise ValueError(f"image 缺少 digest: {image_ref}")
|
|
return image_ref.rsplit("@", 1)[1]
|
|
|
|
|
|
def image_repo(image_ref: str) -> str:
|
|
return image_ref.split("@", 1)[0]
|
|
|
|
|
|
def image_registry_host(image_repository: str) -> str:
|
|
first_segment = image_repository.split("/", 1)[0].strip().lower()
|
|
if not first_segment:
|
|
return ""
|
|
if "." in first_segment or ":" in first_segment or first_segment == "localhost":
|
|
return first_segment.rstrip("/")
|
|
return ""
|
|
|
|
|
|
def preferred_deploy_image(
|
|
target_payload: JsonObject, image_ref: str, profile_payload: JsonObject
|
|
) -> str:
|
|
source_image_repo = image_repo(image_ref)
|
|
deploy_image = str(target_payload.get("deployImage", "")).strip()
|
|
harbor_image = str(target_payload.get("harborImage", "")).strip()
|
|
image_name = str(target_payload.get("kustomizeImageName", "")).strip()
|
|
deploy_registry_host = get_profile_registry_host(profile_payload, "deploy")
|
|
|
|
if (
|
|
not deploy_image
|
|
and deploy_registry_host
|
|
and image_registry_host(source_image_repo) == deploy_registry_host
|
|
):
|
|
deploy_image = source_image_repo
|
|
if not deploy_image and harbor_image:
|
|
deploy_image = harbor_image
|
|
if not deploy_image and source_image_repo:
|
|
deploy_image = source_image_repo
|
|
if not deploy_image and image_name:
|
|
deploy_image = image_name
|
|
if not deploy_image:
|
|
raise ValueError("缺少 deploy image,无法解析目标仓库")
|
|
return deploy_image
|
|
|
|
|
|
def update_overlay(
|
|
overlay_payload: JsonObject,
|
|
image_name: str,
|
|
deploy_image: str,
|
|
digest: str,
|
|
) -> JsonObject:
|
|
updated = copy.deepcopy(overlay_payload)
|
|
images_value = updated.get("images", [])
|
|
images = ensure_list(images_value, "images")
|
|
|
|
target_index: int | None = None
|
|
for index, raw_item in enumerate(images):
|
|
item = ensure_mapping(raw_item, f"images[{index}]")
|
|
existing_name = str(item.get("name", "")).strip()
|
|
existing_new_name = str(item.get("newName", "")).strip()
|
|
if image_name in {existing_name, existing_new_name} or deploy_image in {
|
|
existing_name,
|
|
existing_new_name,
|
|
}:
|
|
target_index = index
|
|
break
|
|
|
|
target_item: JsonObject
|
|
if target_index is None:
|
|
target_item = {}
|
|
else:
|
|
target_item = ensure_mapping(
|
|
images[target_index], f"images[{target_index}]"
|
|
).copy()
|
|
|
|
target_item["name"] = image_name
|
|
target_item["newName"] = deploy_image
|
|
target_item["digest"] = digest
|
|
if "newTag" in target_item:
|
|
del target_item["newTag"]
|
|
|
|
if target_index is None:
|
|
images.append(target_item)
|
|
else:
|
|
images[target_index] = target_item
|
|
updated["images"] = images
|
|
return updated
|
|
|
|
|
|
def resolve_target(
|
|
service_map: JsonObject, service_name: str, env_name: str
|
|
) -> JsonObject:
|
|
services = ensure_mapping(service_map.get("services", {}), "services")
|
|
service_payload = ensure_mapping(
|
|
services.get(service_name, {}), f"services.{service_name}"
|
|
)
|
|
envs = ensure_mapping(
|
|
service_payload.get("envs", {}), f"services.{service_name}.envs"
|
|
)
|
|
return ensure_mapping(
|
|
envs.get(env_name, {}), f"services.{service_name}.envs.{env_name}"
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Render shadow diff from DeployResolution"
|
|
)
|
|
parser.add_argument(
|
|
"--repo-root", type=Path, default=Path("."), help="ljwx-deploy root"
|
|
)
|
|
parser.add_argument(
|
|
"--state-root",
|
|
type=Path,
|
|
default=Path("/root/codes/ljwx-release-state"),
|
|
help="ljwx-release-state root",
|
|
)
|
|
parser.add_argument(
|
|
"--service-map",
|
|
type=Path,
|
|
default=Path("release/services.yaml"),
|
|
help="legacy fallback service map path relative to deploy repo root",
|
|
)
|
|
parser.add_argument(
|
|
"--profile-matrix",
|
|
type=Path,
|
|
default=Path("platform/assembly/env-matrix.yaml"),
|
|
help="profile matrix path relative to deploy repo root",
|
|
)
|
|
parser.add_argument(
|
|
"--resolution-file",
|
|
type=Path,
|
|
default=Path(""),
|
|
help="single resolution file; defaults to all state resolutions",
|
|
)
|
|
parser.add_argument(
|
|
"--out-dir",
|
|
type=Path,
|
|
default=Path(""),
|
|
help="optional output directory for *.diff files",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_service_map_for_target(
|
|
repo_root: Path,
|
|
matrix_payload: JsonObject,
|
|
cache: dict[str, JsonObject],
|
|
target_payload: JsonObject,
|
|
fallback_service_map: JsonObject,
|
|
) -> tuple[str, JsonObject, JsonObject]:
|
|
env_name = get_required_str(target_payload, "env")
|
|
requested_profile = str(target_payload.get("profile", "")).strip()
|
|
profile_name = resolve_target_profile(matrix_payload, env_name, requested_profile)
|
|
profile_payload = get_profile_payload(matrix_payload, profile_name)
|
|
service_map_rel = get_profile_service_map_path(profile_payload)
|
|
service_map = cache.get(service_map_rel)
|
|
if service_map is None:
|
|
service_map_path = repo_root / service_map_rel
|
|
if service_map_path.exists():
|
|
service_map = read_yaml_file(service_map_path)
|
|
cache[service_map_rel] = service_map
|
|
else:
|
|
raise ValueError(
|
|
f"profile `{profile_name}` 对应的 service map 不存在: {service_map_rel}"
|
|
)
|
|
return profile_name, profile_payload, service_map
|
|
|
|
|
|
def load_resolution_files(state_root: Path, explicit_path: Path) -> list[Path]:
|
|
if explicit_path != Path(""):
|
|
return [explicit_path]
|
|
return sorted(
|
|
path
|
|
for path in (state_root / "resolutions").rglob("*.json")
|
|
if path.name != ".gitkeep"
|
|
)
|
|
|
|
|
|
def bundle_components(bundle_payload: JsonObject) -> list[JsonObject]:
|
|
components = ensure_list(bundle_payload.get("components", []), "components")
|
|
if not components:
|
|
raise ValueError("bundle.components 不能为空")
|
|
return [
|
|
ensure_mapping(item, f"components[{index}]")
|
|
for index, item in enumerate(components)
|
|
]
|
|
|
|
|
|
def build_component_updates(
|
|
service_map: JsonObject,
|
|
profile_payload: JsonObject,
|
|
bundle_payload: JsonObject,
|
|
env_name: str,
|
|
) -> list[ComponentUpdate]:
|
|
updates: list[ComponentUpdate] = []
|
|
for component in bundle_components(bundle_payload):
|
|
service_name = get_required_str(component, "name")
|
|
image_ref = get_required_str(component, "image")
|
|
target_payload = resolve_target(service_map, service_name, env_name)
|
|
updates.append(
|
|
ComponentUpdate(
|
|
service_name=service_name,
|
|
image_ref=image_ref,
|
|
digest=digest_from_image(image_ref),
|
|
overlay_rel=get_required_str(target_payload, "overlayPath"),
|
|
image_name=get_required_str(target_payload, "kustomizeImageName"),
|
|
deploy_image=preferred_deploy_image(
|
|
target_payload, image_ref, profile_payload
|
|
),
|
|
)
|
|
)
|
|
return updates
|
|
|
|
|
|
def render_single_diff(
|
|
repo_root: Path,
|
|
state_root: Path,
|
|
matrix_payload: JsonObject,
|
|
service_map_cache: dict[str, JsonObject],
|
|
fallback_service_map: JsonObject,
|
|
resolution_path: Path,
|
|
out_dir: Path,
|
|
) -> str:
|
|
resolution = read_json_file(resolution_path)
|
|
decision = ensure_mapping(resolution.get("decision", {}), "decision")
|
|
if get_required_str(decision, "status") != "resolved":
|
|
return f"跳过 {resolution_path.name}: 决议状态不是 resolved"
|
|
|
|
repo_name = get_required_str(resolution, "repo")
|
|
bundle_id = get_required_str(resolution, "bundleId")
|
|
target = ensure_mapping(resolution.get("target", {}), "target")
|
|
env_name = get_required_str(target, "env")
|
|
profile_name, profile_payload, service_map = load_service_map_for_target(
|
|
repo_root,
|
|
matrix_payload,
|
|
service_map_cache,
|
|
target,
|
|
fallback_service_map,
|
|
)
|
|
bundle_path = state_root / "bundles" / repo_slug(repo_name) / f"{bundle_id}.json"
|
|
bundle = read_json_file(bundle_path)
|
|
component_updates = build_component_updates(
|
|
service_map, profile_payload, bundle, env_name
|
|
)
|
|
|
|
diffs: list[str] = []
|
|
updates_by_overlay: dict[str, list[ComponentUpdate]] = {}
|
|
for update in component_updates:
|
|
updates_by_overlay.setdefault(update.overlay_rel, []).append(update)
|
|
|
|
resolution_id = get_required_str(resolution, "resolutionId")
|
|
if out_dir != Path(""):
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for overlay_rel, overlay_updates in sorted(updates_by_overlay.items()):
|
|
overlay_path = repo_root / overlay_rel
|
|
current_overlay = read_yaml_file(overlay_path)
|
|
rendered_overlay = copy.deepcopy(current_overlay)
|
|
for update in overlay_updates:
|
|
rendered_overlay = update_overlay(
|
|
rendered_overlay,
|
|
update.image_name,
|
|
update.deploy_image,
|
|
update.digest,
|
|
)
|
|
|
|
current_text = render_yaml(current_overlay).splitlines(keepends=True)
|
|
rendered_text = render_yaml(rendered_overlay).splitlines(keepends=True)
|
|
diff = "".join(
|
|
difflib.unified_diff(
|
|
current_text,
|
|
rendered_text,
|
|
fromfile=str(overlay_path),
|
|
tofile=f"{overlay_path} (shadow)",
|
|
)
|
|
)
|
|
if diff:
|
|
diffs.append(diff)
|
|
if out_dir != Path(""):
|
|
diff_name = overlay_rel.replace("/", "__")
|
|
(out_dir / f"{resolution_id}__{diff_name}.diff").write_text(
|
|
diff, encoding="utf-8"
|
|
)
|
|
|
|
if not diffs:
|
|
return f"{resolution_id} ({profile_name}): 无差异"
|
|
return f"{resolution_id} ({profile_name}):\n" + "\n".join(diffs)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
repo_root = args.repo_root.resolve()
|
|
state_root = args.state_root.resolve()
|
|
matrix_payload = load_profile_matrix(repo_root, args.profile_matrix)
|
|
fallback_service_map = read_yaml_file(repo_root / args.service_map)
|
|
service_map_cache: dict[str, JsonObject] = {}
|
|
resolution_files = load_resolution_files(state_root, args.resolution_file)
|
|
|
|
if not resolution_files:
|
|
print("未找到 resolution 文件")
|
|
return 0
|
|
|
|
rendered_outputs: list[str] = []
|
|
for resolution_path in resolution_files:
|
|
rendered_outputs.append(
|
|
render_single_diff(
|
|
repo_root=repo_root,
|
|
state_root=state_root,
|
|
matrix_payload=matrix_payload,
|
|
service_map_cache=service_map_cache,
|
|
fallback_service_map=fallback_service_map,
|
|
resolution_path=resolution_path,
|
|
out_dir=args.out_dir,
|
|
)
|
|
)
|
|
|
|
print("\n\n".join(rendered_outputs))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|