Move directory

This commit is contained in:
wbsong111
2026-03-06 17:08:31 +09:00
parent 4d99258344
commit 21addb6e88
73 changed files with 0 additions and 0 deletions
@@ -0,0 +1,36 @@
---
name: helm_diff
description: Compare two Helm chart versions and emit Structured Diff JSON.
user-invokable: false
---
# helm_diff (Skill)
Deterministic Helm diff generator. Produces a structured JSON diff across values, templates, CRDs, and dependencies.
## Input schema
```json
{
"chart": "string",
"repo": "string | null",
"chart_path": "string | null",
"from_version": "string",
"to_version": "string",
"values_override": "string | object | null"
}
```
## Output schema
```json
{
"chart": "string",
"from_version": "string",
"to_version": "string",
"generated_at": "string (ISO8601)",
"values": "object",
"templates": "object",
"crd": "object",
"dependencies": "object",
"errors": "array"
}
```
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
DEFAULT_REPO = None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--chart", required=True)
parser.add_argument("--repo")
parser.add_argument("--chart-path")
parser.add_argument("--from-version", required=True)
parser.add_argument("--to-version", required=True)
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.helm_diff import helm_diff # type: ignore
payload = {
"chart": args.chart,
"repo": args.repo,
"chart_path": args.chart_path,
"from_version": args.from_version,
"to_version": args.to_version,
"values_override": None,
}
out = helm_diff(payload)
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,252 @@
"""helm_diff skill implementation (deterministic)."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple
import yaml
from .skill_interface import HelmDiffInput, HelmDiffOutput
def _run(cmd: List[str], cwd: str | None = None, timeout: int = 60) -> str:
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
if res.returncode != 0:
raise RuntimeError(f"command failed: {' '.join(cmd)}\n{res.stderr}")
return res.stdout
def _load_yaml(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def _flatten(d: Dict[str, Any], parent_key: str = "", sep: str = ".") -> Dict[str, Any]:
items: Dict[str, Any] = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else str(k)
if isinstance(v, dict):
items.update(_flatten(v, new_key, sep=sep))
else:
items[new_key] = v
return items
def _diff_values(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
old_flat = _flatten(old)
new_flat = _flatten(new)
added = sorted([k for k in new_flat.keys() if k not in old_flat])
removed = sorted([k for k in old_flat.keys() if k not in new_flat])
changed: Dict[str, Dict[str, Any]] = {}
type_changed: List[Dict[str, Any]] = []
for k in old_flat.keys() & new_flat.keys():
if old_flat[k] != new_flat[k]:
if type(old_flat[k]) != type(new_flat[k]):
type_changed.append({"key": k, "old_type": type(old_flat[k]).__name__, "new_type": type(new_flat[k]).__name__})
else:
changed[k] = {"old": old_flat[k], "new": new_flat[k]}
return {
"values": {
"added": added,
"removed": removed,
"changed": changed,
"type_changed": type_changed,
}
}
def _split_manifest(yaml_text: str) -> List[Dict[str, Any]]:
docs = []
for doc in yaml.safe_load_all(yaml_text):
if not doc or not isinstance(doc, dict):
continue
docs.append(doc)
return docs
def _resource_id(obj: Dict[str, Any], fallback_idx: int) -> str:
kind = obj.get("kind", "Unknown")
meta = obj.get("metadata", {}) or {}
name = meta.get("name")
if not name and meta.get("generateName"):
name = f"{meta.get('generateName')}__gen__{fallback_idx}"
ns = meta.get("namespace")
if kind and name:
if kind in {"Namespace", "CustomResourceDefinition"}:
return f"{kind}/{name}"
return f"{kind}/{name}" if not ns else f"{kind}/{name}"
return f"{kind}/__unknown__{fallback_idx}"
def _index_resources(docs: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
idx: Dict[str, Dict[str, Any]] = {}
for i, doc in enumerate(docs):
rid = _resource_id(doc, i)
idx[rid] = doc
return idx
def _diff_templates(old_yaml: str, new_yaml: str) -> Dict[str, Any]:
old_docs = _split_manifest(old_yaml)
new_docs = _split_manifest(new_yaml)
old_idx = _index_resources(old_docs)
new_idx = _index_resources(new_docs)
changes: Dict[str, Any] = {}
for rid, new_obj in new_idx.items():
if rid not in old_idx:
changes[rid] = {"added": True}
continue
old_obj = old_idx[rid]
# Minimal diff: detect image/env/ports for Deployments/StatefulSets/Services
kind = new_obj.get("kind")
if kind in {"Deployment", "StatefulSet"}:
def _containers(obj: Dict[str, Any]) -> List[Dict[str, Any]]:
return (((obj.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
old_cont = _containers(old_obj)
new_cont = _containers(new_obj)
old_images = {c.get("name"): c.get("image") for c in old_cont}
new_images = {c.get("name"): c.get("image") for c in new_cont}
image_changed = old_images != new_images
env_added: List[str] = []
env_removed: List[str] = []
def _env_keys(cont: Dict[str, Any]) -> set:
return {e.get("name") for e in (cont.get("env") or []) if e.get("name")}
for name, cont in {c.get("name"): c for c in new_cont}.items():
old_env = _env_keys({c.get("name"): c for c in old_cont}.get(name, {}) or {})
new_env = _env_keys(cont)
env_added += sorted(list(new_env - old_env))
env_removed += sorted(list(old_env - new_env))
changes[rid] = {
"image_changed": image_changed,
"image": {"old": old_images, "new": new_images},
"env_added": sorted(list(set(env_added))),
"env_removed": sorted(list(set(env_removed))),
}
elif kind == "Service":
def _ports(obj: Dict[str, Any]) -> List[Tuple[Any, Any]]:
ports = (obj.get("spec") or {}).get("ports") or []
return [(p.get("port"), p.get("targetPort")) for p in ports]
changes[rid] = {
"port_changed": _ports(old_obj) != _ports(new_obj)
}
for rid in old_idx.keys() - new_idx.keys():
changes[rid] = {"removed": True}
return {"templates": changes}
def _diff_chart_yaml(old_chart: Dict[str, Any], new_chart: Dict[str, Any]) -> Dict[str, Any]:
old_deps = {d.get("name"): d for d in (old_chart.get("dependencies") or [])}
new_deps = {d.get("name"): d for d in (new_chart.get("dependencies") or [])}
added = sorted([k for k in new_deps.keys() if k not in old_deps])
removed = sorted([k for k in old_deps.keys() if k not in new_deps])
version_changed: Dict[str, Dict[str, Any]] = {}
for k in old_deps.keys() & new_deps.keys():
if old_deps[k].get("version") != new_deps[k].get("version"):
version_changed[k] = {"old": old_deps[k].get("version"), "new": new_deps[k].get("version")}
return {"dependencies": {"added": added, "removed": removed, "version_changed": version_changed}}
def _resolve_chart_dir(base: Path, chart_name: str) -> Path:
# repo pull: chart is under base/<chart>
candidate = base / chart_name
if (candidate / "Chart.yaml").exists():
return candidate
# local copy: Chart.yaml at base root
if (base / "Chart.yaml").exists():
return base
# fallback: first subdir with Chart.yaml
for p in base.iterdir():
if p.is_dir() and (p / "Chart.yaml").exists():
return p
return candidate
def helm_diff(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = HelmDiffInput(**payload)
errors: List[Dict[str, Any]] = []
with tempfile.TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
chart_old = tmpdir / "chart_old"
chart_new = tmpdir / "chart_new"
chart_old.mkdir()
chart_new.mkdir()
try:
if inp.chart_path:
# dip-catalog local path for from_version
from_path = Path(inp.chart_path)
if not from_path.exists():
raise FileNotFoundError(f"chart_path not found: {from_path}")
shutil.copytree(from_path, chart_old, dirs_exist_ok=True)
# to_version: if repo provided, pull from repo (mixed mode)
if inp.repo:
_run([
"helm",
"pull",
f"{inp.repo}/{inp.chart}",
"--version",
inp.to_version,
"--untar",
"--untardir",
str(chart_new),
])
else:
# local-to-local mode (use sibling version directory)
to_path = from_path.parent / inp.to_version
shutil.copytree(to_path, chart_new, dirs_exist_ok=True)
else:
if not inp.repo:
raise ValueError("repo is required when chart_path is not provided")
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.from_version, "--untar", "--untardir", str(chart_old)])
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.to_version, "--untar", "--untardir", str(chart_new)])
except Exception as e:
errors.append({"stage": "helm_pull", "message": str(e)})
chart_old_dir = _resolve_chart_dir(chart_old, inp.chart)
chart_new_dir = _resolve_chart_dir(chart_new, inp.chart)
# values diff
values_old = _load_yaml(chart_old_dir / "values.yaml")
values_new = _load_yaml(chart_new_dir / "values.yaml")
values_diff = _diff_values(values_old, values_new)
# template diff
templates_diff: Dict[str, Any] = {"templates": {}}
try:
old_yaml = _run(["helm", "template", str(chart_old_dir), "--include-crds"])
new_yaml = _run(["helm", "template", str(chart_new_dir), "--include-crds"])
templates_diff = _diff_templates(old_yaml, new_yaml)
except Exception as e:
errors.append({"stage": "helm_template", "message": str(e)})
# CRD diff placeholder (minimal)
crd_diff = {"crd": {}}
# dependencies diff
chart_old_yaml = _load_yaml(chart_old_dir / "Chart.yaml")
chart_new_yaml = _load_yaml(chart_new_dir / "Chart.yaml")
dep_diff = _diff_chart_yaml(chart_old_yaml, chart_new_yaml)
out = HelmDiffOutput(
chart=inp.chart,
from_version=inp.from_version,
to_version=inp.to_version,
generated_at=datetime.now(timezone.utc).isoformat(),
values=values_diff.get("values", {}),
templates=templates_diff.get("templates", {}),
crd=crd_diff.get("crd", {}),
dependencies=dep_diff.get("dependencies", {}),
errors=errors,
)
return json.loads(out.model_dump_json())
@@ -0,0 +1,144 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "docs/upgrade.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None