Files
ljwx-deploy/scripts/gitops_v2/render_shadow_diff.py
T

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())