Move directory
This commit is contained in:
@@ -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
@@ -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"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user