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
+4
View File
@@ -0,0 +1,4 @@
# Skills
이 폴더는 update_catalog 프로젝트의 Skill 정의(SKill.md) 모음입니다.
각 스킬은 OpenClaw Skill 포맷(SKILL.md + YAML frontmatter)을 따릅니다.
@@ -0,0 +1,41 @@
---
name: breaking_change_check
description: Determine breaking changes from Structured Diff JSON using deterministic rules.
user-invokable: false
---
# breaking_change_check (Skill)
Rule-engine based breaking change detector.
## Input schema
```json
{
"diff_json": "object",
"custom_values": "object (optional)"
}
```
`custom_values`가 제공되면 `values_key_removed` 판단 시 custom-values.yaml에서 실제 사용 중인 key만 breaking으로 분류하고, 나머지는 warnings로 기록한다. 제공되지 않으면 제거된 모든 key를 breaking으로 판단한다.
## Breaking 판단 기준
| rule | severity | breaking 조건 |
|------|----------|--------------|
| `values_key_removed` | high | custom-values.yaml에 해당 key가 존재하는 경우 |
| `values_type_changed` | high | 항상 |
| `resource_removed` | high | 항상 |
| `service_port_changed` | high | 항상 |
| `dependency_major_changed` | high | 항상 (sub-chart 내부 breaking 가능) |
`breaking = severity in ("high", "critical")`
## Output schema
```json
{
"breaking": "boolean",
"severity": "critical|high|medium|warning",
"reasons": "array",
"warnings": "array"
}
```
@@ -0,0 +1,42 @@
#!/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("--diff-file", required=True)
parser.add_argument("--custom-values", default=None, help="custom-values.yaml 경로 (없으면 전체 key 검사)")
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.breaking_change_check import breaking_change_check # type: ignore
with open(args.diff_file, "r", encoding="utf-8") as f:
diff_json = json.load(f)
custom_values = None
if args.custom_values:
try:
import yaml # type: ignore
with open(args.custom_values, "r", encoding="utf-8") as f:
custom_values = yaml.safe_load(f) or {}
except Exception as e:
print(f"WARNING: custom-values.yaml 로드 실패, 전체 key 검사로 대체: {e}", file=sys.stderr)
out = breaking_change_check({"diff_json": diff_json, "custom_values": custom_values})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,143 @@
"""breaking_change_check skill implementation (deterministic rules).
Breaking 정의: custom-values.yaml을 수정해야 하는 상황.
카탈로그는 신규 배포용 차트 보관소이며, 운영 클러스터 직접 변경과 무관하다.
판단 기준:
- values_key_removed / type_changed: custom-values.yaml에 해당 key가 있을 때만 breaking
- service_port_changed, resource_removed: warning (custom-values.yaml 수정 불필요)
- dependency_major_changed: custom-values.yaml에 subchart prefix key가 있으면 breaking, 없으면 warning
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Set
from .skill_interface import BreakingCheckInput, BreakingCheckOutput, BreakingReason, Severity
def _severity_max(current: Severity, candidate: Severity) -> Severity:
order = ["warning", "medium", "high", "critical"]
return order[max(order.index(current), order.index(candidate))] # type: ignore
def _flatten_keys(d: Any, prefix: str = "") -> Set[str]:
"""YAML dict를 dotted key path 집합으로 평탄화한다.
예: {"webserver": {"defaultUser": {"enabled": true}}}
→ {"webserver", "webserver.defaultUser", "webserver.defaultUser.enabled"}
"""
keys: Set[str] = set()
if not isinstance(d, dict):
return keys
for k, v in d.items():
full_key = f"{prefix}.{k}" if prefix else k
keys.add(full_key)
if isinstance(v, dict):
keys.update(_flatten_keys(v, full_key))
return keys
def breaking_change_check(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = BreakingCheckInput(**payload)
diff = inp.diff_json
# custom-values.yaml key 집합 (없으면 None → 모든 key가 "미사용"으로 처리)
custom_keys: Optional[Set[str]] = (
_flatten_keys(inp.custom_values) if inp.custom_values else None
)
reasons: List[BreakingReason] = []
warnings: List[BreakingReason] = []
severity: Severity = "warning"
# values rules: custom-values.yaml에 있는 key 변경만 breaking
values = diff.get("values", {})
for k in values.get("removed", []):
if custom_keys is not None and k in custom_keys:
# 실제 사용 중인 key 삭제 → breaking (custom-values.yaml 수정 필요)
reasons.append(BreakingReason(
type="values_key_removed",
key=k,
detail="custom-values.yaml에서 사용 중인 key 삭제 — 수정 필요",
))
severity = _severity_max(severity, "high")
else:
# 미사용 key 삭제 → warning (수정 불필요)
warnings.append(BreakingReason(
type="values_key_removed",
key=k,
detail="custom-values.yaml에서 사용하지 않는 key 삭제 — 수정 불필요",
))
for item in values.get("type_changed", []):
k = item.get("key", "")
detail = f"{item.get('old_type')} → {item.get('new_type')}"
if custom_keys is not None and k in custom_keys:
reasons.append(BreakingReason(
type="values_type_changed",
key=k,
detail=f"custom-values.yaml에서 사용 중인 key 타입 변경 ({detail}) — 수정 필요",
))
severity = _severity_max(severity, "high")
else:
warnings.append(BreakingReason(
type="values_type_changed",
key=k,
detail=f"미사용 key 타입 변경 ({detail}) — 수정 불필요",
))
# template rules: 카탈로그 맥락에서 custom-values.yaml 수정 요인 아님 → warning
templates = diff.get("templates", {})
for rid, info in templates.items():
if info.get("removed"):
warnings.append(BreakingReason(
type="resource_removed",
resource=rid,
detail="리소스 삭제 — 신규 배포 시 참고",
))
if rid.startswith("Service/") and info.get("port_changed"):
warnings.append(BreakingReason(
type="service_port_changed",
resource=rid,
detail="Service port 변경 — 신규 배포 시 Ingress/LB 설정 확인",
))
# dependency rules: custom-values.yaml에 subchart prefix key가 있으면 breaking
deps = diff.get("dependencies", {})
for dep, change in deps.get("version_changed", {}).items():
old = change.get("old") or ""
new = change.get("new") or ""
try:
old_major = int(str(old).split(".")[0])
new_major = int(str(new).split(".")[0])
if new_major > old_major:
dep_prefix = f"{dep}."
dep_in_custom = (
custom_keys is not None
and any(k.startswith(dep_prefix) for k in custom_keys)
)
if dep_in_custom:
reasons.append(BreakingReason(
type="dependency_major_changed",
key=dep,
detail=f"{old} → {new} — custom-values.yaml에 해당 subchart 설정 있음, 수정 검토 필요",
))
severity = _severity_max(severity, "medium")
else:
warnings.append(BreakingReason(
type="dependency_major_changed",
key=dep,
detail=f"{old} → {new} — custom-values.yaml에 해당 subchart 설정 없음",
))
except Exception:
pass
breaking = len(reasons) > 0
out = BreakingCheckOutput(
breaking=breaking,
severity=severity,
reasons=reasons,
warnings=warnings,
)
return json.loads(out.model_dump_json())
@@ -0,0 +1,147 @@
"""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]
custom_values: Optional[Dict[str, Any]] = None # custom-values.yaml 내용 (없으면 전체 key 검사)
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
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
committed: bool = False
# -------------------------
# 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
@@ -0,0 +1,33 @@
---
name: chart_updater
description: Pull a Helm chart version and extract it into manifests/helm/<chart>/<version>.
---
# chart_updater
Pull a chart from helm repo and create a versioned directory in dip-catalog.
`from_version`이 주어지면 이전 버전 디렉토리에서 아래 파일을 새 버전 디렉토리로 복사한다 (이미 존재하는 파일은 건너뜀):
- `BUILD-README.md` — 복사 + `from_version` → `version` 버전 번호 치환
- `CUSTOM-README.md` — 그대로 복사
- `custom-values.yaml` — 그대로 복사
## Input schema
```json
{
"catalog_root": "string",
"chart": "string",
"repo": "string",
"version": "string",
"from_version": "string (optional)"
}
```
## Output schema
```json
{
"success": "boolean",
"already_existed": "boolean",
"dest_dir": "string",
"copied_files": ["string"]
}
```
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--catalog-root", required=True)
parser.add_argument("--chart", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--from-version", default=None)
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.chart_updater import chart_updater # type: ignore
out = chart_updater({
"catalog_root": args.catalog_root,
"chart": args.chart,
"repo": args.repo,
"version": args.version,
"from_version": args.from_version,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,97 @@
"""chart_updater skill implementation.
Pull latest chart from helm repo and extract into manifests/helm/<chart>/<version>.
"""
from __future__ import annotations
import shutil
import subprocess
import tarfile
from pathlib import Path
from typing import Any, Dict, List
def _run(cmd: list[str], cwd: str | None = None) -> str:
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
return res.stdout
# Files to carry over from the previous version directory.
# (filename, replace_version): replace_version=True → substitute from_version with to_version in content.
_CARRY_OVER_FILES = [
("BUILD-README.md", True),
("CUSTOM-README.md", False),
("custom-values.yaml", False),
]
def _copy_custom_files(
src_dir: Path, dest_dir: Path, from_version: str, to_version: str
) -> List[str]:
"""Copy carry-over files from src_dir to dest_dir.
Skips files that already exist in dest_dir (idempotent).
Returns list of copied file names.
"""
copied: List[str] = []
for fname, needs_replace in _CARRY_OVER_FILES:
src = src_dir / fname
dest = dest_dir / fname
if not src.exists() or dest.exists():
continue
content = src.read_text(encoding="utf-8")
if needs_replace:
content = content.replace(from_version, to_version)
dest.write_text(content, encoding="utf-8")
copied.append(fname)
return copied
def chart_updater(payload: Dict[str, Any]) -> Dict[str, Any]:
catalog_root = Path(payload["catalog_root"])
chart = payload["chart"]
repo = payload["repo"]
version = payload["version"]
from_version: str | None = payload.get("from_version")
dest_dir = catalog_root / "manifests" / "helm" / chart / version
already_existed = dest_dir.exists()
if not already_existed:
work_dir = catalog_root / ".tmp_chart_pull"
work_dir.mkdir(parents=True, exist_ok=True)
_run(["helm", "pull", f"{repo}/{chart}", "--version", version], cwd=str(work_dir))
tgz = next(work_dir.glob(f"{chart}-*.tgz"), None)
if tgz is None:
raise FileNotFoundError("pulled chart archive not found")
with tarfile.open(tgz, "r:gz") as tar:
tar.extractall(path=work_dir)
extracted = work_dir / chart
if not extracted.exists():
# some charts may use different folder name; fallback to first dir
dirs = [p for p in work_dir.iterdir() if p.is_dir() and p.name != "__pycache__"]
if dirs:
extracted = dirs[0]
dest_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(extracted), str(dest_dir))
tgz.unlink(missing_ok=True)
copied_files: List[str] = []
if from_version:
src_dir = catalog_root / "manifests" / "helm" / chart / from_version
if src_dir.exists():
copied_files = _copy_custom_files(src_dir, dest_dir, from_version, version)
return {
"success": True,
"already_existed": already_existed,
"dest_dir": str(dest_dir),
"copied_files": copied_files,
}
@@ -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
@@ -0,0 +1,29 @@
---
name: chart_version_detector
description: Detect current chart version from dip-catalog and resolve latest version from helm repo.
---
# chart_version_detector
Determine current_version from manifests/helm/<chart>/ directories and parse repo info from BUILD-README.md.
Then resolve latest_version from helm repo.
## Input schema
```json
{
"catalog_root": "string",
"chart": "string"
}
```
## Output schema
```json
{
"chart": "string",
"catalog_root": "string",
"current_version": "string",
"repo": "string | null",
"repo_url": "string | null",
"latest_version": "string | null"
}
```
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--catalog-root", required=True)
parser.add_argument("--chart", 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.chart_version_detector import chart_version_detector # type: ignore
out = chart_version_detector({
"catalog_root": args.catalog_root,
"chart": args.chart,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,131 @@
"""chart_version_detector skill implementation.
Determine current_version from dip-catalog directory, parse repo info from BUILD-README,
then resolve latest_version from helm repo.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
def _run(cmd: List[str]) -> str:
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
return res.stdout
def _parse_version(v: str) -> Tuple[int, ...]:
# basic semver compare: take numeric parts only
v = v.strip()
v = re.split(r"[+-]", v)[0]
parts = v.split(".")
out = []
for p in parts:
try:
out.append(int(p))
except ValueError:
out.append(0)
return tuple(out)
def _current_version_from_dirs(chart_dir: Path) -> Optional[str]:
versions = [p.name for p in chart_dir.iterdir() if p.is_dir()]
if not versions:
return None
versions_sorted = sorted(versions, key=_parse_version)
return versions_sorted[-1]
def _current_version_from_chart_yaml(chart_dir: Path) -> Optional[str]:
chart_yaml = chart_dir / "Chart.yaml"
if not chart_yaml.exists():
return None
with chart_yaml.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("version")
def _parse_repo_from_build_readme(path: Path) -> Tuple[Optional[str], Optional[str]]:
if not path.exists():
return None, None
text = path.read_text(encoding="utf-8")
m = re.search(r"helm\s+repo\s+add\s+(\S+)\s+(\S+)", text)
if not m:
return None, None
return m.group(1), m.group(2)
def _ensure_repo(repo: str, url: str) -> None:
try:
out = _run(["helm", "repo", "list"])
if repo in out:
return
except Exception:
pass
_run(["helm", "repo", "add", repo, url])
_run(["helm", "repo", "update"])
def _latest_version(repo: str, chart: str) -> Optional[str]:
out = _run(["helm", "search", "repo", f"{repo}/{chart}", "--versions"])
lines = [l for l in out.splitlines() if l.strip()]
if len(lines) < 2:
return None
# header at line 0; next line is latest
parts = re.split(r"\s+", lines[1].strip())
if len(parts) >= 2:
return parts[1]
return None
def chart_version_detector(payload: Dict[str, Any]) -> Dict[str, Any]:
catalog_root = Path(payload["catalog_root"])
chart = payload["chart"]
chart_dir = catalog_root / "manifests" / "helm" / chart
if not chart_dir.exists():
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": None,
"repo": None,
"repo_url": None,
"latest_version": None,
"error": {"code": "CHART_NOT_FOUND", "message": f"chart path not found: {chart_dir}"},
}
current = _current_version_from_dirs(chart_dir)
if current is None:
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": None,
"repo": None,
"repo_url": None,
"latest_version": None,
"error": {"code": "VERSION_NOT_FOUND", "message": f"no version directories under {chart_dir}"},
}
build_readme = chart_dir / current / "BUILD-README.md"
repo, url = _parse_repo_from_build_readme(build_readme)
latest = None
if repo and url:
_ensure_repo(repo, url)
latest = _latest_version(repo, chart)
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": current,
"repo": repo,
"repo_url": url,
"latest_version": latest,
}
@@ -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
@@ -0,0 +1,49 @@
---
name: create_pr
description: Create a GitHub PR for the chart upgrade.
user-invokable: false
---
# create_pr (Skill)
브랜치 생성 → 커밋 → 푸시 → GitHub PR 생성을 수행한다.
breaking=true이면 `needs-review` 레이블, 아니면 `auto-update` 레이블을 붙인다.
실행 후 `main` 브랜치로 복귀하여 다음 cronjob 실행을 위한 클린 상태를 유지한다.
## 브랜치 명명 규칙
`update-{chart}/{to_version}` — 예: `update-airflow/1.19.0`
## 재실행 안전성 (Idempotent)
- 브랜치가 이미 존재하면 체크아웃 후 추가 커밋
- PR이 이미 존재하면 URL만 반환 (중복 PR 생성 안 함)
## Input schema
```json
{
"repo_path": "string",
"chart": "string",
"from_version": "string",
"to_version": "string",
"breaking": "boolean",
"severity": "critical|high|medium|warning",
"reasons": "array (breaking_change_check.reasons)",
"warnings": "array (breaking_change_check.warnings)"
}
```
## Output schema
```json
{
"pr_url": "string",
"branch_name": "string",
"labels": ["string"],
"committed": "boolean"
}
```
## 의존 도구
- `git` — 브랜치/커밋/푸시
- `gh` (GitHub CLI) — PR 생성 및 레이블 관리. `gh auth login` 완료 필요.
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repo-path", required=True, help="dip-catalog git repo 경로")
parser.add_argument("--chart", required=True)
parser.add_argument("--from-version", required=True)
parser.add_argument("--to-version", required=True)
parser.add_argument("--breaking-file", required=True, help="breaking_change_check 출력 JSON 경로")
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.create_pr import create_pr # type: ignore
with open(args.breaking_file, "r", encoding="utf-8") as f:
breaking_data = json.load(f)
out = create_pr({
"repo_path": args.repo_path,
"chart": args.chart,
"from_version": args.from_version,
"to_version": args.to_version,
"breaking": breaking_data.get("breaking", False),
"severity": breaking_data.get("severity", "warning"),
"reasons": breaking_data.get("reasons", []),
"warnings": breaking_data.get("warnings", []),
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""create_pr skill implementation.
Branch → Commit → Push → GitHub PR 생성.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from .skill_interface import CreatePRInput, CreatePROutput, BreakingReason # type: ignore
def _git(args: List[str], cwd: Path) -> str:
res = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.strip()}")
return res.stdout.strip()
def _gh(args: List[str], cwd: Path) -> str:
res = subprocess.run(["gh"] + args, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"gh {' '.join(args)} failed: {res.stderr.strip()}")
return res.stdout.strip()
def _get_existing_pr_url(branch_name: str, cwd: Path) -> Optional[str]:
try:
url = _gh(
["pr", "list", "--head", branch_name, "--json", "url", "--jq", ".[0].url"],
cwd=cwd,
)
return url.strip() or None
except Exception:
return None
def _ensure_label(label: str, cwd: Path) -> None:
"""레이블이 없으면 생성한다."""
label_colors = {
"needs-review": "e11d48", # 빨강
"auto-update": "16a34a", # 초록
}
try:
_gh(["label", "list", "--json", "name", "--jq", f'.[] | select(.name == "{label}") | .name'], cwd=cwd)
except Exception:
pass
color = label_colors.get(label, "0075ca")
try:
_gh(["label", "create", label, "--color", color, "--force"], cwd=cwd)
except Exception:
pass # 레이블 생성 실패해도 PR 생성은 계속
def _build_pr_body(
chart: str,
from_version: str,
to_version: str,
breaking: bool,
severity: str,
reasons: List[BreakingReason],
warnings: List[BreakingReason],
) -> str:
lines = [
f"## Helm Chart Update: {chart} `{from_version}` → `{to_version}`",
"",
f"**Severity**: `{severity}` ",
f"**Breaking**: {'✅ Human review required before merge' if breaking else '❌ No breaking changes'}",
"",
]
if reasons:
lines.append("### Breaking Changes")
for r in reasons:
key = r.get("key") or r.get("resource") or ""
lines.append(f"- `[{r['type']}]` {key} — {r.get('detail', '')}")
lines.append("")
if warnings:
lines.append(f"### Warnings ({len(warnings)} removed keys not used in custom-values.yaml)")
for w in warnings:
lines.append(f"- `[{w['type']}]` {w.get('key', '')} — {w.get('detail', '')}")
lines.append("")
if not reasons:
lines += ["No breaking changes detected.", ""]
lines.append("---")
lines.append("*Generated by update-catalog automation*")
return "\n".join(lines)
def create_pr(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = CreatePRInput(**payload)
repo_path = Path(inp.repo_path)
chart = inp.chart
from_version = inp.from_version
to_version = inp.to_version
breaking = inp.breaking
severity = inp.severity
reasons = inp.reasons
warnings = inp.warnings
branch_name = f"update-{chart}/{to_version}"
chart_dir = f"manifests/helm/{chart}/{to_version}"
# 1. main으로 이동 후 최신화
_git(["checkout", "main"], cwd=repo_path)
_git(["pull"], cwd=repo_path)
# 2. 브랜치 생성 또는 기존 브랜치 체크아웃
local_exists = bool(_git(["branch", "--list", branch_name], cwd=repo_path).strip())
remote_exists = bool(_git(["branch", "-r", "--list", f"origin/{branch_name}"], cwd=repo_path).strip())
if local_exists:
_git(["checkout", branch_name], cwd=repo_path)
elif remote_exists:
_git(["checkout", "-b", branch_name, f"origin/{branch_name}"], cwd=repo_path)
else:
_git(["checkout", "-b", branch_name], cwd=repo_path)
# 3. 변경 파일 스테이징
_git(["add", chart_dir], cwd=repo_path)
# 4. 변경사항이 있으면 커밋
status = _git(["status", "--porcelain", chart_dir], cwd=repo_path)
committed = False
if status.strip():
breaking_tag = " [BREAKING]" if breaking else ""
commit_msg = f"update {chart}/{to_version}{breaking_tag}"
_git(["commit", "-m", commit_msg], cwd=repo_path)
committed = True
# 5. 원격 브랜치에 푸시
_git(["push", "-u", "origin", branch_name], cwd=repo_path)
# 6. 레이블 준비
labels = ["needs-review"] if breaking else ["auto-update"]
for label in labels:
_ensure_label(label, cwd=repo_path)
# 7. PR 생성 (이미 존재하면 URL만 반환)
pr_url = _get_existing_pr_url(branch_name, repo_path)
if not pr_url:
breaking_tag = " [BREAKING]" if breaking else ""
title = f"update {chart}: {from_version} → {to_version}{breaking_tag}"
body = _build_pr_body(chart, from_version, to_version, breaking, severity, reasons, warnings)
label_args: List[str] = []
for label in labels:
label_args += ["--label", label]
pr_url = _gh(
["pr", "create", "--title", title, "--body", body, "--base", "main"] + label_args,
cwd=repo_path,
)
# 8. main으로 복귀 (다음 cronjob 실행을 위해 클린 상태 유지)
_git(["checkout", "main"], cwd=repo_path)
return json.loads(
CreatePROutput(
pr_url=pr_url,
branch_name=branch_name,
labels=labels,
committed=committed,
).model_dump_json()
)
@@ -0,0 +1,28 @@
"""Skill Interface for create_pr."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
Severity = Literal["critical", "high", "medium", "warning"]
# BreakingReason은 dict로 처리 (breaking_change_check 출력 그대로 수신)
BreakingReason = Dict[str, Any]
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
committed: bool = False
@@ -0,0 +1,33 @@
---
name: deploy_validate
description: Deploy chart to a test namespace and validate health.
user-invokable: false
---
# deploy_validate (Skill)
Runs helm upgrade in a test namespace and reports health.
## Input schema
```json
{
"chart": "string",
"repo": "string | null",
"chart_path": "string | null",
"version": "string",
"values_override": "object | null",
"namespace": "string",
"timeout": "number (default 300)"
}
```
## Output schema
```json
{
"success": "boolean",
"dry_run_passed": "boolean",
"pod_status": "object",
"events": "array",
"logs": "string | null"
}
```
@@ -0,0 +1,27 @@
---
name: generate_upgrade_doc
description: Generate upgrade Markdown from structured diff and breaking results.
user-invokable: false
---
# generate_upgrade_doc
Produce Markdown upgrade notes from Structured Diff JSON and breaking analysis.
## Input schema
```json
{
"diff_json": "object",
"breaking_result": "object",
"docs_context": "object | null",
"max_tokens": "number (default 50000)"
}
```
## Output schema
```json
{
"markdown": "string",
"truncated": "boolean"
}
```
@@ -0,0 +1,49 @@
#!/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("--diff-file", required=True)
parser.add_argument("--breaking-file", required=True)
parser.add_argument("--docs-context", default=None)
parser.add_argument("--max-tokens", type=int, default=50000)
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.generate_upgrade_doc import generate_upgrade_doc # type: ignore
with open(args.diff_file, "r", encoding="utf-8") as f:
diff_json = json.load(f)
with open(args.breaking_file, "r", encoding="utf-8") as f:
breaking = json.load(f)
docs_context = None
if args.docs_context:
with open(args.docs_context, "r", encoding="utf-8") as f:
text = f.read()
docs_context = {"CUSTOM-README.md": text}
out = generate_upgrade_doc({
"diff_json": diff_json,
"breaking_result": breaking,
"docs_context": docs_context,
"max_tokens": args.max_tokens,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,150 @@
"""generate_upgrade_doc skill implementation.
항상 실행된다 (breaking 여부 무관).
- breaking=true + USE_CLAUDE_CLI=1: LLM으로 custom-values.yaml 수정 방법 포함 상세 가이드 생성
- 그 외: 템플릿 기반 간단 요약 생성
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Any, Dict, List
from .skill_interface import GenerateDocInput, GenerateDocOutput
def _fallback_summary(diff: Dict[str, Any], breaking: Dict[str, Any]) -> str:
chart = diff.get("chart")
to_version = diff.get("to_version")
from_version = diff.get("from_version")
values = diff.get("values", {})
templates = diff.get("templates", {})
deps = diff.get("dependencies", {})
values_added = len(values.get("added", []))
values_removed = len(values.get("removed", []))
values_changed = len(values.get("changed", {}))
values_type_changed = len(values.get("type_changed", []))
template_added = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("added"))
template_removed = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("removed"))
breaking_flag = breaking.get("breaking", False)
severity = breaking.get("severity", "warning")
reasons = breaking.get("reasons", [])
warnings_list = breaking.get("warnings", [])
lines: List[str] = []
# update_docs_file이 ## {version} 헤더를 별도 추가하므로 여기서는 생략
lines.append("### 변경 요약")
if from_version:
lines.append(f"- from_version: {from_version}")
if to_version:
lines.append(f"- to_version: {to_version}")
if chart and from_version and to_version:
lines.append(f"- Chart `{chart}` {from_version} → {to_version} 업데이트")
lines.append(f"- Values: +{values_added} / -{values_removed} / ~{values_changed} / type~{values_type_changed}")
lines.append(f"- Templates: +{template_added} / -{template_removed}")
if deps:
added = len(deps.get("added", []))
removed = len(deps.get("removed", []))
changed = len(deps.get("version_changed", {}))
lines.append(f"- Dependencies: +{added} / -{removed} / ~{changed}")
lines.append("\n### custom-values.yaml 수정 필요 항목")
if breaking_flag:
for r in reasons:
key = r.get("key") or r.get("resource") or ""
detail = r.get("detail") or ""
lines.append(f"- **`{key}`**: {detail}".rstrip())
else:
lines.append("없음")
if warnings_list:
lines.append("\n### 배포 시 주의사항")
for w in warnings_list:
resource = w.get("resource") or w.get("key") or ""
detail = w.get("detail") or ""
wtype = w.get("type", "")
lines.append(f"- **{wtype}** {resource}: {detail}".rstrip())
lines.append("\n### 참고")
lines.append(f"- severity: {severity}")
lines.append(f"- breaking: {str(breaking_flag).lower()}")
return "\n".join(lines) + "\n"
def _build_prompt(diff: Dict[str, Any], breaking: Dict[str, Any], docs_context: Dict[str, Any] | None) -> str:
prompt = (
"당신은 Kubernetes Helm 업그레이드 문서를 작성하는 전문가입니다.\n\n"
"두 Helm 차트 버전 간 변경점을 담은 Structured Diff JSON이 제공됩니다.\n\n"
"## 작업\n"
"1. 핵심 변경 사항을 평문으로 요약합니다.\n"
"2. 각 변경의 운영 영향(예: 롤링 업데이트, 재시작 등)을 설명합니다.\n"
"3. Breaking Change를 강조하고 구체적인 마이그레이션 절차를 제공합니다.\n"
"4. 아래 형식의 간결한 Markdown 문서를 생성합니다.\n\n"
"## 규칙\n"
"- 입력 JSON에 없는 내용은 절대 추가하지 마세요.\n"
"- 추측하지 마세요.\n"
"- 값이 비어 있거나 null인 필드는 언급하지 마세요.\n"
"- docs_context는 보조 설명에만 사용하고, diff에 없는 변경을 추가하지 마세요.\n"
"- errors가 있으면 분석이 불완전할 수 있음을 명시하세요.\n"
"- SRE/DevOps 엔지니어가 바로 실행할 수 있도록 명확하게 작성하세요.\n\n"
"## 출력 형식\n"
"아래 Markdown 구조를 정확히 지키세요 (## {to_version} 헤더는 포함하지 마세요):\n\n"
"### 변경 요약\n"
"- from_version: <from_version>\n"
"- to_version: <to_version>\n"
"- <핵심 변경 사항 요약>\n\n"
"### custom-values.yaml 수정 필요 항목\n"
"<breaking=true면 항목별 key와 수정 방법>\n"
"<breaking=false면 \"없음\">\n\n"
"### 배포 시 주의사항\n"
"<warnings가 있으면 나열>\n"
"<없으면 섹션 생략>\n\n"
"### 참고\n"
"- severity: <severity>\n"
"- breaking: <true/false>\n\n"
"---\n\n"
"Input:\n"
)
payload = {
**diff,
**breaking,
}
if docs_context:
payload["docs_context"] = docs_context
prompt += json.dumps(payload, ensure_ascii=False)
return prompt
def _run_claude(prompt: str) -> str:
res = subprocess.run(["claude", "-p", prompt], capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or "claude CLI failed")
return res.stdout.strip()
def generate_upgrade_doc(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = GenerateDocInput(**payload)
use_claude = os.environ.get("USE_CLAUDE_CLI", "0") == "1"
breaking = inp.breaking_result.get("breaking", False)
markdown: str
# LLM 호출 조건: USE_CLAUDE_CLI=1 AND breaking=true
if use_claude and breaking:
try:
prompt = _build_prompt(inp.diff_json, inp.breaking_result, inp.docs_context)
markdown = _run_claude(prompt)
except Exception:
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
else:
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
out = GenerateDocOutput(markdown=markdown, truncated=False)
return json.loads(out.model_dump_json())
@@ -0,0 +1,145 @@
"""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
breaking_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 = "BUILD-README.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
chart_path: 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
@@ -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
@@ -0,0 +1,29 @@
---
name: update_docs_file
description: Insert upgrade content into BUILD-README.md for a target chart version.
user-invokable: false
---
# update_docs_file (Skill)
Update a chart version directory's BUILD-README.md by inserting upgrade content.
## Input schema
```json
{
"repo_path": "string",
"docs_file": "string (default: BUILD-README.md)",
"version": "string",
"content": "string",
"overwrite": "boolean"
}
```
## Output schema
```json
{
"success": "boolean",
"file_path": "string",
"already_existed": "boolean"
}
```
@@ -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("--repo-path", required=True)
parser.add_argument("--docs-file", default="BUILD-README.md")
parser.add_argument("--version", required=True)
parser.add_argument("--content-file", required=True)
parser.add_argument("--overwrite", action="store_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.update_docs_file import update_docs_file # type: ignore
content = Path(args.content_file).read_text(encoding="utf-8")
out = update_docs_file({
"repo_path": args.repo_path,
"docs_file": args.docs_file,
"version": args.version,
"content": content,
"overwrite": bool(args.overwrite),
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,145 @@
"""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
breaking_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 # 예: "manifests/helm/<chart>/<to_version>/CUSTOM-README.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
chart_path: 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
@@ -0,0 +1,62 @@
"""update_docs_file skill implementation."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict
from .skill_interface import UpdateDocsInput, UpdateDocsOutput
HEADER = "# Upgrade History"
def _insert_section(text: str, version: str, content: str) -> tuple[str, bool]:
marker = f"## {version}"
if marker in text:
return text, True
if HEADER in text:
parts = text.split(HEADER, 1)
head = parts[0] + HEADER
rest = parts[1].lstrip("\n")
new_section = f"\n\n{marker}\n{content.strip()}\n"
return head + new_section + "\n" + rest, False
# If no header, prepend
new_text = f"{HEADER}\n\n{marker}\n{content.strip()}\n\n{text}"
return new_text, False
def update_docs_file(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = UpdateDocsInput(**payload)
repo_path = Path(inp.repo_path)
docs_path = repo_path / inp.docs_file
text = ""
if docs_path.exists():
text = docs_path.read_text(encoding="utf-8")
new_text, existed = _insert_section(text, inp.version, inp.content)
if existed and not inp.overwrite:
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=True)
return json.loads(out.model_dump_json())
if existed and inp.overwrite:
# overwrite: replace section between marker and next header
marker = f"## {inp.version}"
parts = new_text.split(marker, 1)
if len(parts) > 1:
after = parts[1]
tail_idx = after.find("\n## ")
if tail_idx != -1:
after = after[tail_idx:]
else:
after = ""
new_text = parts[0] + marker + "\n" + inp.content.strip() + "\n" + after
docs_path.parent.mkdir(parents=True, exist_ok=True)
docs_path.write_text(new_text, encoding="utf-8")
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=existed)
return json.loads(out.model_dump_json())