From ca64890bb61c063565eab4aa20499b2195307f65 Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Fri, 6 Mar 2026 17:06:07 +0900 Subject: [PATCH] Add catalog update agent --- update_catalog/docs/README.md | 91 ++++++ .../docs/architecture-local-vs-k8s.md | 87 ++++++ .../docs/decisions/001-agentic-first.md | 99 ++++++ .../decisions/002-dip-catalog-structure.md | 50 ++++ .../docs/design/00-architecture-overview.md | 145 +++++++++ .../docs/design/01-helm-diff-engine.md | 281 ++++++++++++++++++ .../docs/design/02-breaking-change-rules.md | 158 ++++++++++ .../docs/design/03-llm-summarizer.md | 243 +++++++++++++++ .../docs/design/04-skill-interface.md | 242 +++++++++++++++ .../docs/design/05-on-cluster-agent.md | 189 ++++++++++++ .../docs/implementation/01-skills.md | 157 ++++++++++ .../docs/implementation/02-agent.md | 139 +++++++++ update_catalog/docs/skill-test-guide.md | 35 +++ update_catalog/docs/status.md | 16 + update_catalog/docs/test/README.md | 6 + update_catalog/docs/test/env.md | 17 ++ update_catalog/docs/test/integration.md | 14 + update_catalog/docs/test/unit.md | 14 + update_catalog/docs/working-memory.md | 70 +++++ update_catalog/scripts/run_flow.sh | 198 ++++++++++++ update_catalog/skills/README.md | 4 + .../skills/breaking_change_check/SKILL.md | 41 +++ .../breaking_change_check/scripts/run.py | 42 +++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 302 bytes .../breaking_change_check.cpython-312.pyc | Bin 0 -> 6632 bytes .../skill_interface.cpython-312.pyc | Bin 0 -> 5704 bytes .../update_catalog/breaking_change_check.py | 143 +++++++++ .../scripts/update_catalog/skill_interface.py | 147 +++++++++ update_catalog/skills/chart_updater/SKILL.md | 33 ++ .../skills/chart_updater/scripts/run.py | 37 +++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 285 bytes .../__pycache__/chart_updater.cpython-312.pyc | Bin 0 -> 4382 bytes .../scripts/update_catalog/chart_updater.py | 97 ++++++ .../scripts/update_catalog/skill_interface.py | 144 +++++++++ .../skills/chart_version_detector/SKILL.md | 29 ++ .../chart_version_detector/scripts/run.py | 31 ++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 303 bytes .../chart_version_detector.cpython-312.pyc | Bin 0 -> 5784 bytes .../update_catalog/chart_version_detector.py | 131 ++++++++ .../scripts/update_catalog/skill_interface.py | 144 +++++++++ update_catalog/skills/create_pr/SKILL.md | 49 +++ .../skills/create_pr/scripts/run.py | 43 +++ .../scripts/update_catalog/__init__.py | 0 .../scripts/update_catalog/create_pr.py | 164 ++++++++++ .../scripts/update_catalog/skill_interface.py | 28 ++ .../skills/deploy_validate/SKILL.md | 33 ++ .../skills/generate_upgrade_doc/SKILL.md | 27 ++ .../generate_upgrade_doc/scripts/run.py | 49 +++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 301 bytes .../generate_upgrade_doc.cpython-312.pyc | Bin 0 -> 8775 bytes .../skill_interface.cpython-312.pyc | Bin 0 -> 5545 bytes .../update_catalog/generate_upgrade_doc.py | 150 ++++++++++ .../scripts/update_catalog/skill_interface.py | 145 +++++++++ update_catalog/skills/helm_diff/SKILL.md | 36 +++ .../skills/helm_diff/scripts/run.py | 41 +++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 290 bytes .../__pycache__/helm_diff.cpython-312.pyc | Bin 0 -> 14329 bytes .../skill_interface.cpython-312.pyc | Bin 0 -> 5490 bytes .../scripts/update_catalog/helm_diff.py | 252 ++++++++++++++++ .../scripts/update_catalog/skill_interface.py | 144 +++++++++ .../skills/update_docs_file/SKILL.md | 29 ++ .../skills/update_docs_file/scripts/run.py | 41 +++ .../scripts/update_catalog/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 297 bytes .../skill_interface.cpython-312.pyc | Bin 0 -> 5532 bytes .../update_docs_file.cpython-312.pyc | Bin 0 -> 3135 bytes .../scripts/update_catalog/skill_interface.py | 145 +++++++++ .../update_catalog/update_docs_file.py | 62 ++++ 73 files changed, 4718 insertions(+) create mode 100644 update_catalog/docs/README.md create mode 100644 update_catalog/docs/architecture-local-vs-k8s.md create mode 100644 update_catalog/docs/decisions/001-agentic-first.md create mode 100644 update_catalog/docs/decisions/002-dip-catalog-structure.md create mode 100644 update_catalog/docs/design/00-architecture-overview.md create mode 100644 update_catalog/docs/design/01-helm-diff-engine.md create mode 100644 update_catalog/docs/design/02-breaking-change-rules.md create mode 100644 update_catalog/docs/design/03-llm-summarizer.md create mode 100644 update_catalog/docs/design/04-skill-interface.md create mode 100644 update_catalog/docs/design/05-on-cluster-agent.md create mode 100644 update_catalog/docs/implementation/01-skills.md create mode 100644 update_catalog/docs/implementation/02-agent.md create mode 100644 update_catalog/docs/skill-test-guide.md create mode 100644 update_catalog/docs/status.md create mode 100644 update_catalog/docs/test/README.md create mode 100644 update_catalog/docs/test/env.md create mode 100644 update_catalog/docs/test/integration.md create mode 100644 update_catalog/docs/test/unit.md create mode 100644 update_catalog/docs/working-memory.md create mode 100755 update_catalog/scripts/run_flow.sh create mode 100644 update_catalog/skills/README.md create mode 100644 update_catalog/skills/breaking_change_check/SKILL.md create mode 100755 update_catalog/skills/breaking_change_check/scripts/run.py create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/breaking_change_check.cpython-312.pyc create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/breaking_change_check.py create mode 100644 update_catalog/skills/breaking_change_check/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/chart_updater/SKILL.md create mode 100755 update_catalog/skills/chart_updater/scripts/run.py create mode 100644 update_catalog/skills/chart_updater/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/chart_updater.cpython-312.pyc create mode 100644 update_catalog/skills/chart_updater/scripts/update_catalog/chart_updater.py create mode 100644 update_catalog/skills/chart_updater/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/chart_version_detector/SKILL.md create mode 100755 update_catalog/skills/chart_version_detector/scripts/run.py create mode 100644 update_catalog/skills/chart_version_detector/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/chart_version_detector.cpython-312.pyc create mode 100644 update_catalog/skills/chart_version_detector/scripts/update_catalog/chart_version_detector.py create mode 100644 update_catalog/skills/chart_version_detector/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/create_pr/SKILL.md create mode 100644 update_catalog/skills/create_pr/scripts/run.py create mode 100644 update_catalog/skills/create_pr/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/create_pr/scripts/update_catalog/create_pr.py create mode 100644 update_catalog/skills/create_pr/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/deploy_validate/SKILL.md create mode 100644 update_catalog/skills/generate_upgrade_doc/SKILL.md create mode 100755 update_catalog/skills/generate_upgrade_doc/scripts/run.py create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/generate_upgrade_doc.cpython-312.pyc create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/generate_upgrade_doc.py create mode 100644 update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/helm_diff/SKILL.md create mode 100755 update_catalog/skills/helm_diff/scripts/run.py create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/helm_diff.cpython-312.pyc create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/helm_diff.py create mode 100644 update_catalog/skills/helm_diff/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/update_docs_file/SKILL.md create mode 100755 update_catalog/skills/update_docs_file/scripts/run.py create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/__init__.py create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/update_docs_file.cpython-312.pyc create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/skill_interface.py create mode 100644 update_catalog/skills/update_docs_file/scripts/update_catalog/update_docs_file.py diff --git a/update_catalog/docs/README.md b/update_catalog/docs/README.md new file mode 100644 index 0000000..2c8697d --- /dev/null +++ b/update_catalog/docs/README.md @@ -0,0 +1,91 @@ +# Helm Chart Upgrade 자동화 시스템 + +## 개요 + +Helm Chart 업그레이드 시 기존 버전과 신규 버전 간의 변경점을 **정형 데이터(Structured Diff JSON)**로 생성하고, LLM이 이를 해석하여 변경 요약·Breaking Change 분석·업그레이드 문서를 자동으로 생성하는 시스템이다. + +> 핵심 원칙: LLM은 Diff 생성자가 아니라 **Diff 해석자(Summarizer)** 역할만 수행한다. + +--- + +## 로드맵 + +| Step | 설명 | 상태 | +|------|------|------| +| **Step 1** | Skills 구현 (helm_diff · breaking_check · generate_doc · create_pr · deploy_validate) | 🔨 구현 중 | +| **Step 2** | Agent 프레임워크 POC (OpenClaw vs Nanobot — 클러스터 배포 후 결정) | 📋 계획 | +| **Step 3** | Agent 워크플로 정의 (Skills를 에이전트에 등록 + 스케줄/트리거 설정) | 💡 구상 | +| **Step 4** | 보안 정책 수립 후 운영 (RBAC · NetworkPolicy · Skill allowlist) | 💡 구상 | + +> **결정**: 파이프라인 오케스트레이션 코드는 구현하지 않는다. +> Skills(핵심 로직)만 구현하고, 순서 제어는 Agent가 담당한다. +> → [결정 배경](decisions/001-agentic-first.md) + +--- + +## 전체 아키텍처 흐름 + +``` +[Step 1] Skills (독립 실행 가능한 개별 도구) +helm_diff → breaking_change_check → generate_upgrade_doc → update_docs_file → deploy_validate → create_pr + +[Step 2~3] On-Cluster Agent (OpenClaw / Nanobot) +Agent가 위 Skills를 등록하여 워크플로로 오케스트레이션 +트리거: Cron 스케줄 또는 K8s 이벤트 +``` + +--- + +## 문서 구조 + +- [status.md](status.md) — 구현 진행 현황 + +### 설계 문서 (`design/`) + +아키텍처 결정 사항과 컴포넌트 상세 설계를 담는다. +구현과 독립적으로 유지되며, 결정이 바뀔 때만 업데이트한다. + +| 파일 | 설명 | +|------|------| +| [00-architecture-overview.md](design/00-architecture-overview.md) | 전체 아키텍처 + 설계 원칙 + 기술 스택 | +| [01-helm-diff-engine.md](design/01-helm-diff-engine.md) | Chart Version Detector · Helm Diff Engine · Values/Template/CRD Diff 설계 | +| [02-breaking-change-rules.md](design/02-breaking-change-rules.md) | Breaking Change Rule Engine 판단 로직 + 조건 정의 | +| [03-llm-summarizer.md](design/03-llm-summarizer.md) | LLM Summarizer · Prompt 설계 · BUILD-README 갱신 설계 | +| [04-skill-interface.md](design/04-skill-interface.md) | Skill 인터페이스 정의 (Agent-Skill 계약) | +| [05-on-cluster-agent.md](design/05-on-cluster-agent.md) | On-Cluster Agent 설계 (Step 2~3) + 보안 고려사항 | + +### 구현 계획 (`implementation/`) + +각 Step의 작업 목록과 실행 순서를 담는다. +작업이 진행되면서 자주 업데이트되며, 완료 후에는 참고용으로만 유지된다. + +| 파일 | 설명 | +|------|------| +| [01-skills.md](implementation/01-skills.md) | Step 1 Skills 구현 태스크 목록 + 우선순위 + 완료 기준 | +| [02-agent.md](implementation/02-agent.md) | Step 2~4 Agent 구현 계획 (POC → 워크플로 → 보안) | + +### 의사결정 기록 (`decisions/`) + +중요한 아키텍처 결정 사항과 배경을 ADR(Architecture Decision Record) 형식으로 유지한다. + +| 파일 | 설명 | +|------|------| +| [001-agentic-first.md](decisions/001-agentic-first.md) | 파이프라인 오케스트레이션 건너뛰고 Agent 직접 구현 결정 | + +--- + +## 설계 원칙 + +1. **LLM은 Helm CLI를 직접 실행하지 않는다.** +2. **Diff 생성은 100% deterministic 해야 한다.** +3. **LLM 입력은 반드시 Structured JSON 형식이다.** +4. **Breaking Change 판단은 코드 기반 Rule Engine이 먼저 수행한다.** +5. **LLM은 설명 및 Markdown 생성만 담당한다.** +6. **각 컴포넌트는 독립적인 Skill로 노출한다.** (Agent가 호출하는 계약) +7. **운영 환경은 Git PR을 통해서만 변경한다.** + +--- + +## 관련 문서 + +- [1.구조 설계(structure tool+llm summarizer).md](../1.구조%20설계(structure%20tool+llm%20summarizer).md) — 초기 설계 원본 diff --git a/update_catalog/docs/architecture-local-vs-k8s.md b/update_catalog/docs/architecture-local-vs-k8s.md new file mode 100644 index 0000000..b35eecc --- /dev/null +++ b/update_catalog/docs/architecture-local-vs-k8s.md @@ -0,0 +1,87 @@ +# OpenClaw 기반 MCP Tool 아키텍처 (로컬 vs K8s) + +아래는 동일한 MCP Tool 세트를 **로컬 환경**과 **K8s 환경**에서 실행할 때의 아키텍처 비교다. + +--- + +## 1) 로컬 환경 아키텍처 + +```mermaid +flowchart LR + subgraph LocalHost[Local Host] + OC[OpenClaw Agent] + Skills[Skills Registry] + Tools["MCP Tools
(helm_diff, breaking_check,
generate_doc, update_docs, create_pr, deploy_validate)"] + Repo["dip-catalog Repo
(manifests/helm/...)"] + Files["Docs/Outputs +(upgrade.md, PR metadata)] + Secrets[Local Secrets +(.env / keychain)"] + end + + OC -->|Skill 호출| Tools + OC --> Skills + Tools --> Repo + Tools --> Files + Tools --> Secrets + + OC -->|GitHub API| GH[GitHub] + OC -->|LLM API| LLM[LLM Provider] +``` + +**특징** +- OpenClaw와 MCP Tools가 같은 머신에서 실행 +- `chart_path`는 로컬 디렉토리 기준으로 바로 접근 +- Secrets는 로컬 파일/.env/키체인으로 관리 +- 배포 검증이 필요하면 로컬 K8s(kind/k3d) 사용 + +--- + +## 2) K8s 환경 아키텍처 + +```mermaid +flowchart LR + subgraph K8s["Kubernetes Cluster"] + OC["OpenClaw Agent Pod"] + Skills["Skills Registry"] + Tools["MCP Tools
(helm_diff, breaking_check,
generate_doc, update_docs, create_pr, deploy_validate)"] + Repo["Git Repo Volume +(dip-catalog)"] + NS[Test Namespace] + end + + OC -->|Skill 호출| Tools + OC --> Skills + Tools --> Repo + Tools --> NS + + OC -->|GitHub API| GH[GitHub] + OC -->|LLM API| LLM[LLM Provider] + + Secrets[Secrets/ConfigMap] --> OC + Secrets --> Tools + NP[NetworkPolicy] --- OC +``` + +**특징** +- OpenClaw가 Pod로 실행되고 Tools는 같은 Pod 또는 별도 Service로 동작 +- repo는 볼륨으로 마운트하거나 git clone으로 동기화 +- secrets는 K8s Secret/ConfigMap으로 주입 +- deploy_validate는 테스트 네임스페이스에 실제 배포 +- NetworkPolicy/RBAC로 최소 권한 운영 + +--- + +## 차이 요약 + +| 구분 | 로컬 환경 | K8s 환경 | +|------|-----------|----------| +| 실행 위치 | 로컬 머신 | K8s Pod/Service | +| repo 접근 | 로컬 파일 시스템 | 볼륨 마운트/클론 | +| secrets | .env/키체인 | Secret/ConfigMap | +| 배포 검증 | kind/k3d | 테스트 네임스페이스 | +| 보안 통제 | OS 권한 | RBAC/NetworkPolicy | + +--- + +필요하면 이 문서를 `design/`에 이동하거나, 시스템 설계 문서에 통합해도 돼. diff --git a/update_catalog/docs/decisions/001-agentic-first.md b/update_catalog/docs/decisions/001-agentic-first.md new file mode 100644 index 0000000..b8f69ab --- /dev/null +++ b/update_catalog/docs/decisions/001-agentic-first.md @@ -0,0 +1,99 @@ +# ADR 001: 파이프라인 오케스트레이션 건너뛰고 Agent 직접 구현 + +**상태**: 채택 (2026-03-03) + +--- + +## 배경 + +초기 설계는 다음 3단계 로드맵이었다: + +``` +Phase 1: 정적 분석 파이프라인 + (MCP Tools + Python 오케스트레이션 코드) + ↓ +Phase 2A: GitHub Actions로 배포 검증 추가 + ↓ +Phase 2B: On-Cluster AI Agent (OpenClaw/Nanobot) +``` + +이 구조에서 Phase 1은 두 가지를 포함하고 있었다: +1. **MCP Tools** — `helm_diff`, `breaking_check` 등 핵심 로직 +2. **파이프라인 오케스트레이션 코드** — Tools를 순서대로 호출하는 Python 코드 + +--- + +## 결정 + +**파이프라인 오케스트레이션 코드는 구현하지 않는다.** + +MCP Tools(핵심 로직)만 구현하고, 순서 제어는 처음부터 Agent(OpenClaw/Nanobot)가 담당하도록 한다. + +수정된 로드맵: + +``` +Step 1: MCP Tools 구현 (오케스트레이션 없이) + ↓ +Step 2: Agent 프레임워크 POC (OpenClaw vs Nanobot) + ↓ +Step 3: Agent 워크플로에 MCP Tools를 Skills로 연결 + ↓ +Step 4: 보안 정책 수립 후 운영 +``` + +--- + +## 이유 + +### 낭비가 되는 코드 + +파이프라인 오케스트레이션 코드는 Phase 2B에서 Agent 워크플로 정의로 대체된다. + +```python +# 이 코드는 Phase 2B에서 쓸모없어진다 +diff = helm_diff(...) +breaking = breaking_check(diff) +doc = generate_doc(diff, breaking) +pr = create_pr(doc) +``` + +처음부터 Agent를 목표로 한다면 이 코드를 작성하고 나중에 버리는 것은 낭비다. + +### MCP Tools는 낭비가 아니다 + +반면 MCP Tools 자체(helm_diff, breaking_check 등의 구현 로직)는 Agent에서도 그대로 사용된다. +버려지는 코드가 없다. + +### K8s 클러스터 보유 + +Staging K8s 클러스터가 이미 있으므로 On-Cluster Agent를 바로 구성할 수 있다. +"클러스터 없이 로컬 파이프라인으로 먼저 검증"이라는 이유가 성립하지 않는다. + +### 테스트 용이성 + +개별 MCP Tool은 파이프라인 없이도 독립적으로 단위 테스트 가능하다. + +--- + +## 결과 + +- 구현 시간 단축: 파이프라인 오케스트레이션 코드 작성 + 나중에 Agent로 재작성하는 이중 작업 제거 +- Agent 프레임워크 선택이 중요해짐: Step 2 POC로 검증 후 결정 필요 +- 중간 산출물 없음: 파이프라인이 없으므로 MCP Tools 완성 전까지는 E2E 동작 확인 불가 + → POC 단계에서 단일 Tool 호출부터 점진적으로 검증 + +--- + +## 대안으로 고려했던 것 + +### 파이프라인 먼저 구현 후 Agent로 전환 + +- 장점: 중간 단계에서 E2E 동작 확인 가능, 디버깅 용이 +- 단점: 파이프라인 오케스트레이션 코드가 버려짐 (낭비), 구현 기간 길어짐 +- **기각 이유**: K8s 클러스터가 이미 있어 이 중간 단계가 불필요 + +### GitHub Actions Phase 2A 추가 + +- 장점: 배포 검증을 클러스터 없이 CI에서 먼저 구현 가능 +- 단점: Agent에서 동일 기능을 다시 구현해야 함 (이중 작업) +- **기각 이유**: `deploy_validate` MCP Tool로 직접 구현, Agent가 조건부 호출 diff --git a/update_catalog/docs/decisions/002-dip-catalog-structure.md b/update_catalog/docs/decisions/002-dip-catalog-structure.md new file mode 100644 index 0000000..17fb05c --- /dev/null +++ b/update_catalog/docs/decisions/002-dip-catalog-structure.md @@ -0,0 +1,50 @@ +# ADR 002: dip-catalog 디렉토리 기반 Helm Chart 구조를 기준으로 설계 + +**상태**: 채택 (2026-03-03) + +--- + +## 배경 + +대상 카탈로그는 GitHub 레포 내 `manifests/helm///` 구조로 관리된다. +각 버전 디렉토리는 완전한 Helm chart 구조를 포함하며, 추가 문서와 기본 배포 values가 존재한다. + +- `README.md`, `BUILD-README.md`, `CUSTOM-README.md` (버전 디렉토리 내) +- `custom-values.yaml` (배포 시 기본 values) + +이 구조는 일반 Helm repo/index 기반 감지 방식과 다르므로, 감지/입력/문서 생성 방식을 조정해야 한다. + +--- + +## 결정 + +1. **버전 감지는 디렉토리 기반**으로 수행한다. + - `manifests/helm//` 하위 버전 디렉토리 변화 또는 git diff로 신규 버전 감지 +2. **helm_diff 입력에 `chart_path`를 추가**하고 dip-catalog에서는 이를 우선 사용한다. +3. **`custom-values.yaml`을 기본 values_override로 적용**한다. +4. **README/BUILD/CUSTOM 문서를 요약하여 LLM 입력 컨텍스트(`docs_context`)로 제공**한다. + - 단, 컨텍스트는 “참고용”으로만 사용하고 diff에 없는 변경을 생성하지 않는다. + +--- + +## 이유 + +- 레포 구조가 Helm repo/index.yaml 방식이 아니므로 기존 감지 방법이 부정확하다. +- 동일 chart라도 버전 디렉토리마다 커스텀 문서 및 기본 values가 존재해, + 업그레이드 문서 생성에 중요한 맥락이 된다. + +--- + +## 결과 + +- MCP Tool 인터페이스 및 설계 문서에 `chart_path`, `docs_context` 반영 +- LLM Summarizer는 문서 컨텍스트를 참고하되, Structured Diff JSON을 우선한다 + +--- + +## 대안으로 고려했던 것 + +### Helm repo/index 기반 감지 유지 +- 장점: 기존 설계 재사용 가능 +- 단점: dip-catalog 구조에는 적용 불가 +- **기각 이유**: 실제 운영 구조와 불일치 diff --git a/update_catalog/docs/design/00-architecture-overview.md b/update_catalog/docs/design/00-architecture-overview.md new file mode 100644 index 0000000..b6b3b07 --- /dev/null +++ b/update_catalog/docs/design/00-architecture-overview.md @@ -0,0 +1,145 @@ +# 전체 아키텍처 개요 + +## 1. 목적 + +Helm Chart 신규 버전을 카탈로그에 자동으로 추가하고, 해당 버전으로 배포/업그레이드 시 참고할 주의사항을 문서화한다. + +> **카탈로그 역할**: 신규 배포를 위한 Helm 차트 버전 보관소. 운영 클러스터 직접 변경과 무관. + +자동화 항목: +- 신규 버전 차트 pull 및 카탈로그 디렉토리 추가 +- Structured Diff JSON 기반 변경 분석 +- `custom-values.yaml` 수정 필요 여부(Breaking) 판단 +- 업그레이드 주의사항 문서 자동 생성 (`CUSTOM-README.md`에 추가) +- Git PR 자동 생성 + +--- + +## 2. 설계 원칙 + +| # | 원칙 | +|---|------| +| 1 | LLM은 Helm CLI를 직접 구성하거나 실행하지 않는다. helm 실행은 결정론적 Skill이 전담하며, Agent/LLM은 Skill을 언제 호출할지만 결정한다 | +| 2 | Diff 생성은 100% deterministic 해야 한다 | +| 3 | LLM 입력은 반드시 Structured JSON 형식이다 | +| 4 | Breaking Change 판단은 코드 기반 Rule Engine이 먼저 수행한다 | +| 5 | LLM은 설명 및 Markdown 생성만 담당한다 | +| 6 | 각 컴포넌트는 Agent Skill로 노출한다 (Agent가 직접 호출) | +| 7 | 카탈로그 업데이트는 Git PR로 관리한다 (운영 환경 직접 변경 아님) | +| 8 | 외부 입력(PR comment, webhook 등)은 신뢰 경계 밖으로 취급하며, 프롬프트 주입 방어를 기본 전제로 한다 | + +## 2.1 운영 기준 (Non-Functional) + +- **성능**: Diff 생성 + 요약 전체 파이프라인 5분 내 완료를 목표로 한다 (대형 차트는 예외). +- **신뢰성**: 실패 시 재시도 3회, 재시도 후 실패는 알림 전송 + 중단. +- **검토 정책**: `breaking=true` PR에는 `needs-review` 레이블을 부착한다. 담당자가 `custom-values.yaml` 수정 후 merge 여부를 판단한다. (`breaking=false` PR은 자동 merge 가능) + +--- + +## 3. 전체 시스템 아키텍처 + +### Step 1: Skills (에이전트 내부에서 호출되는 개별 기능) + +``` +[Chart Version Detector] + │ chart명, current/new version + ▼ +[chart_updater Skill] + │ helm pull → manifests/helm/// 생성 + │ CUSTOM-README.md carry-over from previous version + ▼ +[helm_diff Skill] + │ values/template/CRD 비교 + ▼ +[Structured Diff JSON] + │ { values, templates, crd, dependencies } + ▼ +[breaking_change_check Skill] + │ custom-values.yaml 기준 판단 (LLM 없음) + │ breaking=true: custom-values.yaml 수정 필요 + │ breaking=false: 수정 불필요 (주의사항만) + ▼ +[generate_upgrade_doc Skill] ← 항상 실행 + │ breaking=true → LLM 상세 가이드 (custom-values.yaml 수정 방법 포함) + │ breaking=false → 템플릿 기반 간단 요약 + ▼ +[update_docs_file Skill] + │ CUSTOM-README.md에 업그레이드 주의사항 섹션 추가 + ▼ +[create_pr Skill] + │ branch 생성 → PR 생성 (항상) + │ breaking=true → label: needs-review + │ breaking=false → label: auto-update +``` + +> 각 Skill은 독립적으로 호출 가능. 호출 순서는 Agent(Step 2~3)가 담당. + +> `deploy_validate` Skill은 Phase 2에서 구현 예정 (현재 스코프 밖). + +### Step 2~3: On-Cluster AI Agent + +``` +[OpenClaw / Nanobot on K8s] + │ + ├── Skill: chart_version_detector → 신규 버전 감지 + ├── Skill: chart_updater → 신규 버전 차트 pull + ├── Skill: helm_diff → Structured Diff JSON 생성 + ├── Skill: breaking_check → custom-values.yaml 수정 필요 여부 판단 + ├── Skill: generate_doc → 업그레이드 주의사항 문서 생성 (항상) + ├── Skill: update_docs → CUSTOM-README.md 업데이트 + ├── Skill: create_pr → GitHub PR 생성 + └── Channel: Discord → 알림 발송 (추후 Slack으로 변경 가능) + +트리거: Cron 스케줄 또는 K8s 이벤트 +``` + +--- + +## 4. 컴포넌트 책임 분리 + +| 컴포넌트 | 역할 | 결정성 | LLM 사용 | +|---------|------|--------|---------| +| Chart Version Detector | 신규 버전 감지 | ✅ | ❌ | +| Chart Updater | 신규 버전 차트 pull → 버전 디렉토리 생성 | ✅ | ❌ | +| Helm Diff Engine | Structured Diff JSON 생성 | ✅ | ❌ | +| Breaking Change Rule Engine | custom-values.yaml 수정 필요 여부 판단 | ✅ | ❌ | +| LLM Summarizer | 업그레이드 주의사항 문서 생성 | ❌ | ✅ (breaking=true 시만) | +| Docs Updater | CUSTOM-README.md 업그레이드 주의사항 섹션 추가 | ✅ | ❌ | +| Git PR Bot | branch / commit / PR 생성 | ✅ | ❌ | + +--- + +## 5. 기술 스택 + +| 영역 | 선택 | 비고 | +|------|------|------| +| Helm CLI | helm 3.x | pull, template, diff | +| 버전 감지 | ArtifactHub API / GitHub Release Webhook | | +| Diff 처리 | Python (deepdiff 또는 직접 구현) | | +| Rule Engine | Python | 코드 기반, custom-values.yaml 기준 | +| LLM | Claude (Anthropic) | via Skill or API, breaking=true 시만 호출 | +| 문서 저장 | `manifests/helm///CUSTOM-README.md` | 업그레이드 주의사항 섹션 추가 | +| PR 생성 | GitHub API (PyGithub / gh CLI) | | +| 스케줄링 | Agent 내장 스케줄러 (Cron) | Step 2~3 | +| On-Cluster Agent | OpenClaw / Nanobot | Step 2~3 | +| Skill Contract | JSON 스키마 (Skill I/O) | Step 1 계약 (Agent-Skill 인터페이스) | + +--- + +## 6. 데이터 흐름 요약 + +``` +입력: chart명 + current_version + new_version (레포 내 디렉토리 기준) +중간: Structured Diff JSON (values/templates/crd/breaking) + CUSTOM-README.md (docs_context) +출력: CUSTOM-README.md 업그레이드 주의사항 섹션 + GitHub PR +``` + +--- + +## 7. 관련 설계 문서 + +- [01-helm-diff-engine.md](01-helm-diff-engine.md) — Diff Engine 상세 설계 +- [02-breaking-change-rules.md](02-breaking-change-rules.md) — Rule Engine 판단 로직 +- [03-llm-summarizer.md](03-llm-summarizer.md) — LLM Summarizer + Docs Updater +- [04-skill-interface.md](04-skill-interface.md) — Skill 인터페이스 정의 +- [05-on-cluster-agent.md](05-on-cluster-agent.md) — On-Cluster Agent 설계 (Step 2~3) diff --git a/update_catalog/docs/design/01-helm-diff-engine.md b/update_catalog/docs/design/01-helm-diff-engine.md new file mode 100644 index 0000000..06f1109 --- /dev/null +++ b/update_catalog/docs/design/01-helm-diff-engine.md @@ -0,0 +1,281 @@ +# Helm Diff Engine 설계 + +## 1. 개요 + +두 버전의 Helm Chart를 비교하여 **Structured Diff JSON**을 생성하는 컴포넌트. +모든 출력은 deterministic하며 LLM을 사용하지 않는다. + +--- + +## 2. Chart Version Detector + +### 역할 +새로운 Helm Chart 버전 출시를 감지한다. + +### 감지 방법 + +| 방법 | 설명 | 적합 대상 | +|------|------|---------| +| **Repo 디렉토리 스캔** | `manifests/helm//` 하위 버전 디렉토리 변화를 감지 | dip-catalog 구조 | +| Git diff 기반 감지 | 최신 커밋에서 추가된 `/` 디렉토리 파악 | dip-catalog 구조 | +| Helm repo `index.yaml` 주기 스캔 | 등록된 repo의 index 파일 직접 파싱 | 외부/사내 Helm repo | +| ArtifactHub API 조회 | `https://artifacthub.io/api/v1/packages/helm/{org}/{chart}` | 공개 chart | +| GitHub Release Webhook | 차트 소스 저장소의 release 이벤트 구독 | GitHub 기반 차트 | +| Cron 기반 스케줄링 | 위 방법들을 주기적으로 실행 | 공통 | + +### 출력 + +```json +{ + "chart": "airflow", + "repo": "apache", + "current_version": "1.2.3", + "latest_version": "1.3.0", + "detected_at": "2025-01-01T00:00:00Z" +} +``` + +### dip-catalog 차트 업데이트 흐름 (버전 디렉토리 유지) + +신규 버전이 감지되면 dip-catalog 구조에 맞춰 **버전 디렉토리를 추가**한다. +기존 버전 디렉토리는 유지한다. + +``` +1) helm pull / --version # 최신 차트 다운로드 +2) tar xzf -.tgz # 압축 해제 +3) manifests/helm/// 로 이동 # 버전 디렉토리 생성 +4) chart_updater: 이전 버전 디렉토리에서 파일 복사 + - custom-values.yaml → 그대로 복사 + - CUSTOM-README.md → 그대로 복사 (배포 관련 내용 유지) + - BUILD-README.md → 복사 + 버전 번호 치환 (from_version → to_version) +5) generate_upgrade_doc → CUSTOM-README.md에 업그레이드 주의사항 섹션 추가 (항상 실행) +``` + +--- + +## 3. Helm Diff Engine + +### 입력 + +```json +{ + "chart": "airflow", + "from_version": "1.2.3", + "to_version": "1.3.0", + "values_override": {}, + "chart_path": "manifests/helm/airflow/1.3.0" +} +``` + +> dip-catalog 구조에서는 `chart_path`를 우선 사용한다. + +### 처리 단계 + +``` +1. (repo 구조) helm pull / --version → chart_old/ +2. (repo 구조) helm pull / --version → chart_new/ +1'. (dip-catalog) manifests/helm/// 복사 → chart_old/ +2'. (dip-catalog) manifests/helm/// 복사 → chart_new/ +3. values.yaml 비교 → Values Diff +4. helm template 결과 비교 → Template Diff +5. CRD schema 비교 → CRD Diff +6. Chart.yaml 비교 → Dependency Diff +7. 결과 병합 → Structured Diff JSON +``` + +### helm template 표준 옵션 + +재현성을 위해 아래 옵션을 고정한다. + +``` +helm template \ + --values values_override.yaml \ + --include-crds \ + --kube-version \ + --api-versions +``` + +> `target_k8s_version`과 `api-versions`는 환경별 설정값으로 관리한다. +> dip-catalog의 경우 `custom-values.yaml`이 존재하면 `values_override` 기본값으로 적용한다. + +### 에러 처리 + +| 상황 | 대응 | +|------|------| +| helm pull 실패 | 재시도 3회 → 실패 시 알림 후 중단 | +| chart 미존재 | 로그 기록 + 스킵 | +| helm template 렌더링 오류 | 오류 내용 JSON에 포함, partial diff 생성 | +| 네트워크 타임아웃 | 60s timeout 설정, 재시도 | + +--- + +## 4. Values Diff 설계 + +### 비교 항목 + +- `added`: 신규 버전에서 추가된 key +- `removed`: 신규 버전에서 삭제된 key +- `changed`: default 값이 변경된 key +- `type_changed`: 값의 타입이 변경된 key (string → int 등) + +### 처리 방식 + +values.yaml을 **flat key** 형태로 변환 후 비교한다. + +- dip-catalog에서는 `custom-values.yaml`을 기본 values_override로 사용한다. + +```yaml +# 중첩 구조 예시 +image: + tag: 1.2.3 + pullPolicy: IfNotPresent + +# flat key 변환 결과 +image.tag: 1.2.3 +image.pullPolicy: IfNotPresent +``` + +> **주의**: key 이동(rename)은 `removed` + `added`로 표현된다. 의미적 rename 감지는 기본 비활성화하며, 필요한 경우 heuristic(유사도 기반) 옵션으로 제공한다. + +### 출력 + +```json +{ + "values": { + "added": ["resources.limits.cpu", "resources.limits.memory"], + "removed": ["ingress.enabled"], + "changed": { + "image.tag": { "old": "1.2.3", "new": "1.3.0" }, + "replicaCount": { "old": 1, "new": 2 } + }, + "type_changed": [ + { "key": "workers.replicas", "old_type": "string", "new_type": "integer" } + ] + } +} +``` + +--- + +## 5. Template Diff 설계 + +### 처리 방식 + +```bash +helm template --values values_override.yaml > old.yaml +helm template --values values_override.yaml > new.yaml +``` + +YAML을 **리소스 단위**로 분리 후 비교한다 (`kind` + `metadata.name` 기준). +- `generateName`만 존재하는 리소스는 템플릿 파일명 + 순번으로 안정적 ID를 생성한다. +- Cluster-scoped 리소스는 namespace를 무시한다. + +### 비교 리소스 타입 + +| 리소스 | 분석 항목 | +|--------|---------| +| Deployment / StatefulSet | container image, env, resource limits, replicas | +| Service | port, targetPort, type | +| Ingress | rules, TLS, annotations | +| ConfigMap | data key 추가/삭제/변경 | +| CRD | 별도 CRD Diff로 처리 | +| ServiceAccount | annotations | + +### 출력 + +```json +{ + "templates": { + "Deployment/airflow-scheduler": { + "image_changed": true, + "image": { "old": "apache/airflow:1.2.3", "new": "apache/airflow:1.3.0" }, + "env_added": ["AIRFLOW__CORE__NEW_SETTING"], + "env_removed": [], + "resource_limits_changed": true + }, + "Service/airflow-webserver": { + "port_changed": false + } + } +} +``` + +--- + +## 6. CRD Diff 설계 + +### 비교 항목 + +| 항목 | 설명 | +|------|------| +| schema 변경 | OpenAPI v3 schema 필드 변경 | +| required 필드 추가 | 기존 CR에 영향 | +| field 제거 | 기존 CR의 해당 필드 무시됨 | +| version 변경 | storage version 변경 시 migration 필요 | +| webhook 변경 | conversion webhook 추가/제거 | + +### 출력 + +```json +{ + "crd": { + "AirflowCluster": { + "changed": true, + "breaking": true, + "breaking_reasons": ["required field added: spec.executor"], + "schema_changed": true, + "version_changed": false, + "fields_removed": [], + "fields_added": ["spec.executor"], + "required_fields_added": ["spec.executor"] + } + } +} +``` + +--- + +## 7. Dependency Diff 설계 + +Chart.yaml의 `dependencies` 블록 비교. + +```json +{ + "dependencies": { + "added": ["redis"], + "removed": [], + "version_changed": { + "postgresql": { "old": "12.1.0", "new": "13.0.0" } + } + } +} +``` + +--- + +## 8. 최종 Structured Diff JSON + +LLM Summarizer에 전달되는 통합 출력: + +```json +{ + "chart": "airflow", + "from_version": "1.2.3", + "to_version": "1.3.0", + "generated_at": "2025-01-01T00:00:00Z", + "values": { ... }, + "templates": { ... }, + "crd": { ... }, + "dependencies": { ... }, + "errors": [] +} +``` + +`errors` 필드에는 렌더링 실패 등의 부분적 오류를 포함한다. LLM은 이를 참고하여 분석 범위를 명시해야 한다. + +--- + +## 9. 관련 문서 + +- [02-breaking-change-rules.md](02-breaking-change-rules.md) — Breaking Change 판단 로직 +- [04-skill-interface.md](04-skill-interface.md) — `helm_diff` Skill 인터페이스 diff --git a/update_catalog/docs/design/02-breaking-change-rules.md b/update_catalog/docs/design/02-breaking-change-rules.md new file mode 100644 index 0000000..efae7bc --- /dev/null +++ b/update_catalog/docs/design/02-breaking-change-rules.md @@ -0,0 +1,158 @@ +# Breaking Change Rule Engine 설계 + +## 1. 개요 + +Structured Diff JSON을 입력받아 **코드 기반**으로 Breaking Change 여부를 판단한다. +LLM을 사용하지 않으며, 결과는 완전히 deterministic하다. + +> **카탈로그 맥락에서의 Breaking 정의**: `custom-values.yaml`을 수정해야 하는 상황. +> 카탈로그는 신규 배포를 위한 차트 보관소이며, 운영 클러스터 직접 변경과 무관하다. +> Breaking 판단의 기준은 "기존 `custom-values.yaml`이 새 차트 버전에서 유효한가?"이다. + +> 설계 의도: LLM이 "이건 Breaking인 것 같아"라고 추론하는 대신, 코드가 명확한 기준으로 판단한다. + +--- + +## 2. Breaking Change 판단 기준 + +### 2.0 공통 원칙 + +모든 values 관련 규칙에서 **`custom-values.yaml`에 해당 key가 존재하는지 여부**가 breaking 판단의 핵심 조건이다. + +- `custom-values.yaml`이 없거나 해당 key를 사용하지 않는 경우 → `warning`으로 처리 (실제 수정 불필요) +- `custom-values.yaml`에 해당 key가 존재하는 경우 → `breaking`으로 처리 (수정 필요) + +### 2.1 Values 관련 + +| 조건 | 판정 | 조건 상세 | +|------|------|---------| +| values key 삭제 | ✅ Breaking | 해당 key가 `custom-values.yaml`에 **있을** 때 — 기존 override가 무시됨 | +| values key 삭제 | ⚠️ Warning | 해당 key가 `custom-values.yaml`에 **없을** 때 (또는 custom-values.yaml 없음) | +| values type 변경 | ✅ Breaking | 해당 key가 `custom-values.yaml`에 **있을** 때 — 파싱 오류 또는 예상치 못한 동작 | +| values type 변경 | ⚠️ Warning | 해당 key가 `custom-values.yaml`에 **없을** 때 | +| values key 추가 | ℹ️ Info | 항상 — 신규 기능, 필요 시 custom-values.yaml에 추가 검토 | +| default 값 변경 | ⚠️ Warning | custom-values.yaml에서 명시적으로 override하지 않는 경우 동작 변경 가능 | + +> **`custom-values.yaml` 없는 차트**: 해당 차트에 custom override가 없으므로 values 삭제는 모두 `warning`으로 처리. + +### 2.2 Template / 리소스 관련 + +카탈로그 맥락에서 Template 변경은 `custom-values.yaml` 수정 요인이 아니므로 **Warning**으로 처리한다. +담당자가 신규 배포 시 참고할 수 있도록 업그레이드 주의사항 문서에 기록한다. + +| 조건 | 판정 | 이유 | +|------|------|------| +| Service port 변경 | ⚠️ Warning | 카탈로그 신규 배포 시 주의사항. custom-values.yaml에서 port override 가능 | +| Service type 변경 (ClusterIP → NodePort 등) | ⚠️ Warning | 네트워크 구성 변경 필요, 주의사항으로 기록 | +| Deployment selector 변경 | ⚠️ Warning | 재배포 시 주의사항. custom-values.yaml 수정 요인 아님 | +| StatefulSet selector 변경 | ⚠️ Warning | 동일 | +| StatefulSet volumeClaimTemplate 변경 | ⚠️ Warning | 동일 | +| container 이름 변경 | ⚠️ Warning | 참조 변경 필요, 주의사항 기록 | +| 리소스 삭제 | ⚠️ Warning | 의존 서비스 변경 필요, 주의사항 기록 | +| resource limits 변경 | ℹ️ Info | 참고 사항 | +| replicas 기본값 변경 | ℹ️ Info | custom-values.yaml에서 명시적 설정 시 영향 없음 | + +### 2.3 CRD 관련 + +CRD 변경은 기존 Custom Resource의 유효성에 영향을 미치므로 Breaking으로 처리한다. + +| 조건 | 판정 | 이유 | +|------|------|------| +| CRD field 삭제 | ✅ Breaking | 기존 CR의 해당 필드 손실 | +| required field 추가 | ✅ Breaking | 기존 CR validation 실패 | +| storage version 변경 | ✅ Breaking | migration 없이 롤백 불가 | +| conversion webhook 제거 | ✅ Breaking | 구버전 API 호출 실패 | +| field 추가 (optional) | ❌ Not Breaking | 하위 호환 | +| schema 제약 완화 | ❌ Not Breaking | 기존 CR은 계속 유효 | + +### 2.4 Dependency 관련 + +서브차트의 values를 `custom-values.yaml`에서 override하는 경우에만 breaking이다. + +| 조건 | 판정 | 조건 상세 | +|------|------|---------| +| 하위 chart major version 변경 | ✅ Breaking | `custom-values.yaml`에 해당 subchart prefix key가 **있을** 때 (예: `postgresql.*`) | +| 하위 chart major version 변경 | ⚠️ Warning | `custom-values.yaml`에 해당 subchart 관련 key가 **없을** 때 | +| 하위 chart 제거 | ⚠️ Warning | 항상 — 주의사항으로 기록 | +| 하위 chart 추가 | ℹ️ Info | 항상 — 신규 리소스 생성만 발생 | + +### 2.5 Deprecated K8s API 감지 + +| 조건 | 판정 | 이유 | +|------|------|------| +| Deprecated API 사용 (extensions/v1beta1 등) | ⚠️ Warning | K8s 버전에 따라 영향 있을 수 있음 | + +감지 방법: `helm template` 결과의 `apiVersion` 필드를 K8s deprecated API 목록과 대조. + +--- + +## 3. Rule Engine 출력 + +## 2.6 Rule 우선순위 및 충돌 처리 + +- 동일 리소스에 여러 Rule이 매칭되면 **가장 높은 severity**를 최종 severity로 채택한다. +- reasons는 모두 남기되, `severity`는 max 기준으로 집계한다. +- `breaking=true`는 `reasons`에 항목이 하나라도 있을 때만 설정된다. + +### 3.1 기본 출력 + +```json +{ + "breaking": true, + "severity": "high", + "reasons": [ + { + "type": "values_key_removed", + "key": "ingress.enabled", + "detail": "custom-values.yaml에서 사용 중인 key가 삭제됨 — custom-values.yaml 수정 필요" + } + ], + "warnings": [ + { + "type": "service_port_changed", + "resource": "Service/airflow-webserver", + "detail": "port changed from 8080 to 8081 — 신규 배포 시 참고" + }, + { + "type": "values_key_removed", + "key": "old_setting", + "detail": "custom-values.yaml에서 사용하지 않는 key 삭제 — 수정 불필요" + } + ] +} +``` + +### 3.2 severity 정의 + +| severity | 조건 | +|----------|------| +| `critical` | CRD storage version 변경 | +| `high` | custom-values.yaml에서 사용 중인 values key 삭제/타입 변경, CRD required field 추가 | +| `medium` | custom-values.yaml에서 사용 중인 subchart dependency major version 변경 | +| `warning` | Deprecated API 사용, 미사용 values key 삭제, template 변경 (port, resource 등) | + +--- + +## 4. 확장 방법 + +새로운 Breaking 조건을 추가하려면 Rule 정의 파일에 항목을 추가한다. +각 Rule은 다음 인터페이스를 구현한다: + +```python +class BreakingRule: + name: str + severity: str # critical / high / medium / warning + + def check(self, diff: StructuredDiffJSON, custom_keys: set[str]) -> list[BreakingReason]: + ... +``` + +Rule 목록은 설정 파일(`rules.yaml`)로 활성화/비활성화 가능하도록 설계한다. + +--- + +## 5. 관련 문서 + +- [01-helm-diff-engine.md](01-helm-diff-engine.md) — Structured Diff JSON 생성 +- [03-llm-summarizer.md](03-llm-summarizer.md) — Breaking Change 결과를 LLM에 전달 +- [04-skill-interface.md](04-skill-interface.md) — `breaking_change_check` Skill 인터페이스 diff --git a/update_catalog/docs/design/03-llm-summarizer.md b/update_catalog/docs/design/03-llm-summarizer.md new file mode 100644 index 0000000..224cbf6 --- /dev/null +++ b/update_catalog/docs/design/03-llm-summarizer.md @@ -0,0 +1,243 @@ +# LLM Summarizer + Docs Updater 설계 + +## 1. 개요 + +Structured Diff JSON과 Breaking Change 판단 결과를 입력받아 자연어 업그레이드 주의사항 문서를 생성하고, +해당 차트 버전의 `CUSTOM-README.md`에 반영한다. + +> LLM의 유일한 역할: Structured JSON → Markdown 문서 변환. Diff 생성이나 Breaking 판단은 수행하지 않는다. + +--- + +## 2. LLM Summarizer + +### 2.0 실행 조건 +- `generate_upgrade_doc`은 **항상 실행**한다 (breaking 여부 무관). +- `breaking=true` → LLM을 사용하여 `custom-values.yaml` 수정 방법을 포함한 상세 가이드 생성. +- `breaking=false` → 템플릿 기반 간단 요약 생성 (LLM 미호출). + +> 카탈로그는 신규 배포를 위한 차트 보관소이므로, breaking 여부와 무관하게 모든 업그레이드에 주의사항 기록이 필요하다. + +### 2.1 입력 + +```json +{ + "chart": "airflow", + "from_version": "1.2.3", + "to_version": "1.3.0", + "values": { ... }, + "templates": { ... }, + "crd": { ... }, + "dependencies": { ... }, + "breaking": true, + "severity": "high", + "breaking_reasons": [ + { + "type": "values_key_removed", + "key": "ingress.enabled", + "detail": "custom-values.yaml에서 사용 중인 key가 삭제됨 — 수정 필요" + } + ], + "warnings": [ + { + "type": "service_port_changed", + "resource": "Service/airflow-webserver", + "detail": "port changed from 8080 to 8081" + } + ], + "docs_context": { + "CUSTOM-README.md": "..." + } +} +``` + +> dip-catalog 구조에서는 버전 디렉토리 내 `CUSTOM-README.md` 내용을 `docs_context`로 제공한다. + +### 2.2 Prompt 설계 + +``` +당신은 Kubernetes Helm 업그레이드 주의사항 문서를 작성하는 전문가입니다. + +두 Helm 차트 버전 간 변경점을 담은 Structured Diff JSON이 제공됩니다. +이 카탈로그는 신규 배포를 위한 것이며, custom-values.yaml 수정 필요 여부가 핵심입니다. + +## 작업 +1. 핵심 변경 사항을 평문으로 요약합니다. +2. custom-values.yaml 수정이 필요한 항목(breaking_reasons)을 상세히 설명합니다. +3. 신규 배포 시 참고할 주의사항(warnings)을 기록합니다. +4. 아래 형식의 간결한 Markdown 문서를 생성합니다. + +## 규칙 +- 입력 JSON에 없는 내용은 절대 추가하지 마세요. +- 추측하지 마세요. +- docs_context는 보조 설명에만 사용하고, diff에 없는 변경을 추가하지 마세요. +- errors가 있으면 분석이 불완전할 수 있음을 명시하세요. +- 배포 담당자가 바로 참고할 수 있도록 명확하게 작성하세요. + +## 출력 형식 +아래 Markdown 구조를 정확히 지키세요 (`## {to_version}` 헤더는 포함하지 마세요 — `update_docs_file`이 관리): + +### 변경 요약 +- from_version: +- to_version: +- <핵심 변경 사항 bullet points> + +### custom-values.yaml 수정 필요 항목 + + + +### 배포 시 주의사항 + +<없으면 섹션 생략> + +### 참고 +- severity: +- breaking: + +--- + +Input: + +``` + +### 2.3 토큰 예산 관리 + +대형 chart(airflow, kafka 등)는 Structured Diff JSON이 매우 클 수 있다. + +| 전략 | 설명 | +|------|------| +| 중요도 기반 필터링 | breaking_reasons와 changed 항목만 포함, unchanged는 제외 | +| template diff 요약 | 리소스별 상세 diff 대신 `변경된 리소스 목록`만 전달 | +| 청크 분할 | values / templates / crd를 각각 별도 LLM 호출 후 결과 합산 | +| 토큰 상한 설정 | 입력 JSON 최대 크기 제한 (예: 50,000 tokens), 초과 시 요약 버전 사용 | + +### 2.4 출력 검증 + +- **금지 규칙**: 입력 JSON에 없는 버전/리소스를 언급하면 실패 처리. +- **서식 검증**: Markdown 구조가 규정과 다르면 1회 재생성, 실패 시 fallback 템플릿으로 대체. +- **요약 품질 기준**: breaking_reasons 누락/허위 서술은 오류로 간주. + +### 2.5 예상 출력 (breaking=true) + +> `update_docs_file`이 `## {to_version}` 헤더를 추가하므로, LLM 출력은 `###` 수준부터 시작한다. + +```markdown +### 변경 요약 +- from_version: 1.2.3 +- to_version: 1.3.0 +- Image tag 1.2.3 → 1.3.0 업데이트 +- CPU limit 기본값 추가 (all containers) +- 새 환경변수 `AIRFLOW__CORE__NEW_SETTING` 추가 (scheduler) + +### custom-values.yaml 수정 필요 항목 +- **`ingress.enabled` 키 삭제**: 차트에서 해당 key가 제거되었습니다. + 현재 custom-values.yaml에서 `ingress.enabled: true`로 설정하고 있는 경우, + 신규 방식(`ingress.create: true` 등)으로 수정이 필요합니다. + +### 배포 시 주의사항 +- **Service port 변경**: `airflow-webserver` port 8080 → 8081. + Ingress, load balancer 설정 확인 필요. + +### 참고 +- severity: high +- breaking: true +``` + +### 2.6 예상 출력 (breaking=false) + +> 템플릿 기반 fallback 출력 (LLM 미호출). + +```markdown +### 변경 요약 +- from_version: 1.2.3 +- to_version: 1.3.0 +- Chart airflow 1.2.3 → 1.3.0 업데이트 +- Values: +2 추가 / -0 삭제 / ~3 변경 +- Templates: +0 추가 / -0 삭제 + +### custom-values.yaml 수정 필요 항목 +없음 + +### 참고 +- severity: warning +- breaking: false +``` + +--- + +## 3. Docs Updater 설계 + +### 3.1 역할 + +생성한 업그레이드 주의사항 Markdown을 해당 차트 버전의 `CUSTOM-README.md`에 반영한다. +(`CUSTOM-README.md`는 배포에 관한 내용을 포함하는 문서로, 업그레이드 주의사항의 적합한 위치다.) + +### 3.2 처리 방식 + +``` +1. diff/breaking 결과로 업그레이드 주의사항 생성 (breaking=true면 LLM, 아니면 템플릿) +2. manifests/helm///CUSTOM-README.md에 반영 + - "# Upgrade History" 섹션이 없으면 파일 끝에 추가 + - 해당 버전 항목이 이미 있으면 skip (idempotent) +3. 파일 저장 +``` + +### 3.3 파일 구조 규칙 + +- 각 차트 버전 디렉토리의 `CUSTOM-README.md`에 업그레이드 주의사항을 추가한다. +- `CUSTOM-README.md`는 `chart_updater`가 이전 버전에서 carry-over하므로 기존 배포 내용은 유지된다. +- `BUILD-README.md`는 차트 메타 정보(repo, 설치 명령어 등)를 유지한다. +- 별도의 `docs/upgrade.md`는 생성하지 않는다. + +### 3.4 중복 방지 + +이미 해당 버전 업그레이드 섹션이 존재하면 덮어쓰기 또는 스킵한다 (설정으로 제어). + +--- + +## 4. Git PR Bot 설계 + +### 4.1 자동화 단계 + +``` +1. 새 branch 생성: update-{chart}/{to_version} +2. 카탈로그 신규 버전 디렉토리 커밋 (manifests/helm///) +3. CUSTOM-README.md 업그레이드 주의사항 섹션 포함 +4. git commit +5. PR 생성 +``` + +### 4.2 PR 메타데이터 + +**PR 제목**: +``` +update {chart}: {from_version} → {to_version} +``` + +**PR labels**: +- `needs-review` (breaking=true 시) — 담당자 확인 권장 +- `auto-update` (breaking=false 시) — 자동 업데이트, 검토 선택적 + +**PR description**: +```markdown +## Helm Chart Update: {chart} `{from_version}` → `{to_version}` + +**Severity**: `high` +**Breaking**: ✅ custom-values.yaml 수정 필요 (담당자 확인 권장) + +### Breaking Changes +- `[values_key_removed]` ingress.enabled — custom-values.yaml에서 사용 중인 key 삭제 + +### Warnings +- `[service_port_changed]` Service/airflow-webserver — port 8080 → 8081 + +--- +*Generated by update-catalog automation* +``` + +--- + +## 5. 관련 문서 + +- [02-breaking-change-rules.md](02-breaking-change-rules.md) — Breaking Change 입력 생성 +- [04-skill-interface.md](04-skill-interface.md) — `generate_upgrade_doc`, `create_pr` Skill diff --git a/update_catalog/docs/design/04-skill-interface.md b/update_catalog/docs/design/04-skill-interface.md new file mode 100644 index 0000000..662ab06 --- /dev/null +++ b/update_catalog/docs/design/04-skill-interface.md @@ -0,0 +1,242 @@ +# Skill 인터페이스 정의 + +## 1. 개요 + +각 컴포넌트를 **OpenClaw Skill**로 노출한다. +이 인터페이스는 Skills(Step 1)와 On-Cluster Agent(Step 2~3) 간의 **계약(contract)**이다. + +> Agent(OpenClaw/Nanobot)가 이 Skill들을 워크플로로 호출한다. + +--- + +## 2. Skill 목록 + +| Skill 이름 | 역할 | 해당 컴포넌트 | +|-----------|------|-------------| +| `helm_diff` | 두 버전 간 Structured Diff JSON 생성 | Helm Diff Engine | +| `breaking_change_check` | Diff JSON에서 Breaking Change 판단 | Breaking Change Rule Engine | +| `generate_upgrade_doc` | 업그레이드 주의사항 Markdown 문서 생성 (항상 실행) | LLM Summarizer | +| `update_docs_file` | CUSTOM-README.md에 업그레이드 주의사항 섹션 추가 | Docs Updater | +| `create_pr` | GitHub PR 생성 | Git PR Bot | +| `deploy_validate` | test namespace에 배포 후 health 검증 **(Phase 2, 미구현)** | Deploy Validator | + +--- + +## 3. Skill 상세 정의 + +## 2.1 공통 에러 스키마 + +모든 Skill은 실패 시 아래 형식으로 에러를 반환한다. + +```json +{ + "error": { + "code": "ERR_HELM_PULL" , + "message": "helm pull failed", + "retryable": true, + "details": { "exit_code": 1 } + } +} +``` + +- `retryable=true`인 경우에만 자동 재시도를 수행한다. + +## 2.2 Idempotency / Retry 정책 + +- **read-only Skill**(helm_diff, breaking_change_check, generate_upgrade_doc)는 안전 재시도 가능. +- **side-effect Skill**(update_docs_file, create_pr, deploy_validate)은 idempotency key를 사용한다. +- `create_pr`는 동일 key 요청 시 기존 PR URL을 반환해야 한다. + +## 2.3 Auth/Secret 전달 + +- 토큰/시크릿은 **env var 또는 K8s Secret**으로 주입한다. +- 입력 payload에 직접 포함하지 않는다. + +### 3.1 `helm_diff` + +```yaml +name: helm_diff +description: > + 두 버전의 Helm Chart를 비교하여 Structured Diff JSON을 생성한다. + values, templates, CRD, dependencies 변경사항을 포함한다. + +input: + chart: string # 차트 이름 (예: "airflow") + repo: string # Helm repo 이름 또는 URL (repo 기반일 때만) + chart_path: string # 로컬 차트 경로 (dip-catalog 구조) + from_version: string # 기존 버전 (예: "1.2.3") + to_version: string # 신규 버전 (예: "1.3.0") + values_override: object # 사용자 정의 values (선택, dip-catalog은 custom-values.yaml 기본) + +output: + chart: string + from_version: string + to_version: string + generated_at: string # ISO 8601 timestamp + values: object # Values Diff + templates: object # Template Diff + crd: object # CRD Diff + dependencies: object # Dependency Diff + errors: array # 부분 실패 정보 +``` + +### 3.2 `breaking_change_check` + +```yaml +name: breaking_change_check +description: > + Structured Diff JSON을 입력받아 Breaking Change 여부를 코드 기반으로 판단한다. + LLM을 사용하지 않으며 결과는 완전히 deterministic하다. + +input: + diff_json: object # helm_diff 출력 (Structured Diff JSON) + +output: + breaking: boolean + severity: string # critical / high / medium / warning + reasons: array # Breaking 사유 목록 + warnings: array # 비중단 경고 목록 +``` + +### 3.3 `generate_upgrade_doc` + +```yaml +name: generate_upgrade_doc +description: > + Structured Diff JSON과 Breaking Change 결과를 기반으로 업그레이드 주의사항 Markdown을 생성한다. + 항상 실행된다. breaking=true면 LLM 상세 가이드, breaking=false면 템플릿 기반 간단 요약. + +input: + diff_json: object # helm_diff 출력 + breaking_result: object # breaking_change_check 출력 + docs_context: object # CUSTOM-README.md 내용 (dip-catalog) + max_tokens: integer # LLM 입력 최대 토큰 수 (기본: 50000) + +output: + markdown: string # 생성된 Markdown 문서 + truncated: boolean # 토큰 제한으로 입력이 잘렸는지 여부 +``` + +### 3.4 `update_docs_file` + +```yaml +name: update_docs_file +description: > + CUSTOM-README.md에 업그레이드 주의사항 섹션을 추가한다. + CUSTOM-README.md는 배포 관련 정보를 담는 문서로, 업그레이드 주의사항의 적합한 위치다. + BUILD-README.md는 chart_updater의 carry-over로만 관리된다. + +input: + repo_path: string # 로컬 Git 저장소 경로 + docs_file: string # 문서 파일 경로 (예: "manifests/helm///CUSTOM-README.md") + version: string # 삽입할 버전 표기 (예: "1.2.3 → 1.3.0") + content: string # 삽입할 Markdown 내용 + overwrite: boolean # 기존 버전 섹션 덮어쓰기 여부 (기본: false) + +output: + success: boolean + file_path: string + already_existed: boolean +``` + +### 3.5 `create_pr` + +```yaml +name: create_pr +description: > + Helm Chart 업그레이드를 위한 GitHub PR을 생성한다. + branch 생성, commit, PR 생성을 포함한다. + +input: + chart: string # 차트 이름 + from_version: string + to_version: string + repo_path: string # 로컬 Git 저장소 경로 + doc_content: string # upgrade.md에 삽입할 내용 + breaking: boolean # PR label 결정에 사용 + severity: string # PR label 결정에 사용 + +output: + pr_url: string + branch_name: string + labels: array +``` + +### 3.6 `deploy_validate` (Phase 2A+) + +```yaml +name: deploy_validate +description: > + Ephemeral test namespace에 Helm Chart를 배포하고 health를 검증한다. + 성공/실패 결과와 Pod 상태를 반환한다. + +input: + chart: string + repo: string + version: string + values_override: object + namespace: string # test namespace (예: "helm-test-airflow") + timeout: integer # 초 단위, 기본 300 + +output: + success: boolean + dry_run_passed: boolean + pod_status: object # { running: int, pending: int, failed: int } + events: array # 비정상 K8s events + logs: string # 실패 시 관련 Pod 로그 +``` + +--- + +## 4. Agent 워크플로 호출 순서 + +Agent(OpenClaw/Nanobot)가 Skill들을 등록하고, 아래 순서를 워크플로로 정의한다. + +```yaml +# 개념적 호출 순서 (실제 워크플로 정의는 implementation/02-agent.md 참고) +1. helm_diff(chart, repo, from_version, to_version) + ↓ +2. breaking_change_check(diff_json, custom_values) # custom-values.yaml 기준 판단 + ↓ +3. generate_upgrade_doc(diff_json, breaking_result) # 항상 실행 + │ breaking=true → LLM 상세 가이드 생성 + │ breaking=false → 템플릿 기반 간단 요약 + ↓ +4. update_docs_file( + repo_path, + docs_file="manifests/helm///CUSTOM-README.md", # to_version으로 경로 결정 + version="", + content= + ) # 항상 실행 + ↓ +5. create_pr(chart, from_version, to_version, ...) # 항상 실행 + │ breaking=true → label: needs-review + │ breaking=false → label: auto-update + ↓ exit 0 (항상) + +# Phase 2 (미구현): +6. deploy_validate(chart, repo, to_version, namespace) +``` + +--- + +## 5. 버전 관리 + +이 인터페이스는 **명시적 버전**을 관리한다. + +```yaml +skill_interface_version: "1.0" +``` + +Skill 입출력 변경 시: +- **하위 호환 변경** (필드 추가): 마이너 버전 증가 +- **Breaking 변경** (필드 삭제/타입 변경): 메이저 버전 증가 + 마이그레이션 가이드 작성 + +--- + +## 6. 관련 문서 + +- [01-helm-diff-engine.md](01-helm-diff-engine.md) — `helm_diff` 구현 설계 +- [02-breaking-change-rules.md](02-breaking-change-rules.md) — `breaking_change_check` 구현 설계 +- [03-llm-summarizer.md](03-llm-summarizer.md) — `generate_upgrade_doc`, `create_pr` 구현 설계 +- [05-on-cluster-agent.md](05-on-cluster-agent.md) — Agent가 이 인터페이스를 Skills로 사용하는 방법 diff --git a/update_catalog/docs/design/05-on-cluster-agent.md b/update_catalog/docs/design/05-on-cluster-agent.md new file mode 100644 index 0000000..fb6a462 --- /dev/null +++ b/update_catalog/docs/design/05-on-cluster-agent.md @@ -0,0 +1,189 @@ +# On-Cluster AI Agent 설계 (Step 2~3) + +## 1. 개요 + +Skills(Step 1)를 **클러스터 위에서 상시 동작하는 자율 에이전트**로 오케스트레이션한다. +OpenClaw 또는 Nanobot을 오케스트레이터로 삼아, Step 1에서 구현한 Skill들을 등록하여 Helm 업그레이드 자동화 전체를 수행한다. + +> **전제**: Skills(Step 1) 구현 완료 + 인터페이스 버전 `1.0` 확정 이후 진행. + +--- + +## 2. 아키텍처 + +``` +[OpenClaw / Nanobot on K8s] + │ + ├── Skill: helm_diff → Structured Diff JSON 생성 + ├── Skill: breaking_check → Breaking Change 판단 + ├── Skill: generate_doc → 업그레이드 주의사항 문서 생성 (항상 실행) + ├── Skill: update_docs → CUSTOM-README.md 업그레이드 주의사항 섹션 추가 + ├── Skill: create_pr → GitHub PR 생성 + # deploy_validate: Phase 2 예정 + ├── Skill: k8s_event_watch → 클러스터 이벤트 감지 + └── Channel: Slack / Telegram → 알림 발송 +``` + +트리거: +- Cron 스케줄 (신규 chart 버전 주기 감지) +- K8s Event Watch (ArgoCD App 상태 변화 등) + +--- + +## 3. OpenClaw vs Nanobot + +| 항목 | OpenClaw | Nanobot | +|------|---------|---------| +| 코드 규모 | 430K+ lines | ~4,000 lines | +| 성숙도 | 높음 (100K+ GitHub stars) | 낮음 (신생) | +| Skills 생태계 | 풍부 (ClawHub) | 기본 지원 | +| K8s 배포 | 공식 Helm chart + K8s Operator | 직접 구성 필요 | +| 수평 확장 | 불가 (Recreate 전략) | 미정 | +| LLM 지원 | Claude, OpenAI, 로컬 모델 | Claude, OpenAI, Qwen 등 | +| 커스터마이징 | TypeScript / YAML 워크플로 | 코드 직접 수정 용이 | +| 보안 이슈 | 커뮤니티 이슈 있음 (Cisco, Palo Alto 조사) | 미검증 | + +**권장**: +- 운영 안정성 우선 → **OpenClaw** (K8s Operator, 성숙한 생태계) +- 경량 커스터마이징 우선 → **Nanobot** (코드 소규모, 직접 수정) + +--- + +## 4. Skills → Agent 연결 전략 + +Step 1에서 Skill 인터페이스를 표준으로 구현해두면 Agent 연결이 매끄럽다. + +``` +Step 1 (Skills) Step 2~3 (Agent) +───────────────────────────── ───────────────────────────── +helm_diff, breaking_check 등 OpenClaw/Nanobot Agent가 +Skill로 구현 완료 → 동일한 Skill들을 등록 후 + 워크플로로 오케스트레이션 +``` + +Agent 연결 시 추가되는 부분: +- 실행 환경: K8s Pod (Agent) +- 오케스트레이션: Agent 워크플로 정의 (Cron 스케줄 + 조건 분기) +- 스케줄링: Agent 내장 스케줄러 + +변경되지 않는 부분: +- Skill 구현체 (helm_diff, breaking_check 등) +- Structured Diff JSON 포맷 +- Breaking Change 규칙 + +--- + +## 5. 보안 고려사항 + +## 4.1 운영 정책 + +- `breaking=true` PR에는 `needs-review` 레이블을 부착한다. 담당자가 `custom-values.yaml` 수정 후 merge 여부를 판단한다. +- `breaking=false` PR은 `auto-update` 레이블을 부착하며, 자동 merge가 가능하다. +- Agent는 PR 생성까지만 수행한다. merge 책임은 담당자에게 있다. + +## 4.2 실패 처리 + +- Skill 실패 시: 알림 전송 + 자동 중단. 재시도는 최대 3회. +- `deploy_validate`는 Phase 2에서 구현 예정 (현재 스코프 밖). + +## 4.3 관찰성 + +- 모든 Skill 호출은 audit log에 남긴다 (input hash + output status). +- Prometheus metrics: 성공/실패 카운트, 평균 처리 시간, 재시도 횟수. +- LLM 호출은 request_id를 부여하여 추적 가능해야 한다. + +On-Cluster AI Agent는 구조적 위험이 있다. + +| 위험 | 내용 | 대응 | +|------|------|------| +| 과도한 K8s 권한 | helm upgrade 권한 남용 | RBAC: test namespace만 허용, ServiceAccount 최소 권한 | +| Skill 취약점 | 3rd-party Skill의 26%가 취약 (Cisco 조사) | 허용 Skill allowlist 관리, ClawHub Skill 검토 필수 | +| Prompt Injection | PR comment, webhook 등 외부 콘텐츠로 에이전트 조작 | 입력 sanitization, 신뢰 범위(trust boundary) 명확화 | +| 외부 통신 데이터 유출 | GitHub API, Slack 등 외부 전송 | NetworkPolicy: 허용 egress 목록 명시, 민감 데이터 마스킹 | +| Shell 접근 | RCE 가능성 | shell skill 비활성화, read-only root filesystem, UID 1000 | + +> Palo Alto Networks 평가: "Shell 접근 + 개인 데이터 + 외부 통신" = **"lethal trifecta"** + +### 최소 보안 요구사항 (운영 투입 전 필수) + +```yaml +# RBAC 예시: test namespace만 허용 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: helm-test +rules: + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + verbs: ["get", "list", "create", "update", "delete"] + - apiGroups: [""] + resources: ["pods", "services", "configmaps"] + verbs: ["get", "list", "create", "update", "delete"] +``` + +```yaml +# NetworkPolicy: 허용 egress만 통과 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: agent-egress-policy +spec: + podSelector: + matchLabels: + app: helm-upgrade-agent + policyTypes: ["Egress"] + egress: + - to: # GitHub API + - ipBlock: + cidr: 140.82.112.0/20 + - to: # Slack API + - ipBlock: + cidr: 35.190.0.0/16 + - ports: + - port: 443 +``` + +--- + +## 6. K8s 배포 구성 + +```yaml +# OpenClaw Helm values 예시 +image: + tag: latest + +skills: + allowlist: + - helm_diff + - breaking_change_check + - generate_upgrade_doc + - update_docs_file + - create_pr + # deploy_validate: Phase 2에서 추가 예정 + +securityContext: + runAsNonRoot: true + runAsUser: 1000 + readOnlyRootFilesystem: true + +resources: + limits: + memory: "1Gi" + cpu: "500m" +``` + +--- + +## 7. 도입 순서 권장 + +1. Staging 클러스터에서 먼저 검증 +2. 보안 정책 확립 (RBAC, NetworkPolicy, Skill allowlist) +3. Skill을 하나씩 추가하며 동작 확인 +4. 운영 클러스터 투입 시 `breaking=true` PR은 사람이 직접 머지 승인 유지 + +--- + +## 8. 관련 문서 + +- [04-skill-interface.md](04-skill-interface.md) — Agent가 호출하는 Skill 인터페이스 +- [../implementation/02-agent.md](../implementation/02-agent.md) — Agent 구현 계획 (Step 2~4) diff --git a/update_catalog/docs/implementation/01-skills.md b/update_catalog/docs/implementation/01-skills.md new file mode 100644 index 0000000..be5c0d5 --- /dev/null +++ b/update_catalog/docs/implementation/01-skills.md @@ -0,0 +1,157 @@ +# Skills 구현 태스크 (Step 1) + +## 목표 + +Agent(OpenClaw/Nanobot)가 직접 호출할 수 있는 Skill들을 구현한다. +파이프라인 오케스트레이션 코드는 구현하지 않는다. 순서 제어는 Agent가 담당한다. + +> **범위**: 각 Skill이 독립적으로 호출 가능한 상태. Skill 간 호출 순서 코드는 제외. + +--- + +## 태스크 목록 + +### 🔴 높음 (핵심 기능) + +#### T1. Helm Diff Engine 구현 (`helm_diff` Skill) +- [ ] **dip-catalog 경로 지원**: `chart_path` 입력으로 `manifests/helm///` 직접 사용 +- [ ] 신규 버전 감지 시 `manifests/helm///` 디렉토리 생성 (기존 유지) +- [ ] 최신 차트 pull + 압축 해제 후 신규 버전 디렉토리에 반영 +- [ ] (repo 구조) `helm pull`로 두 버전 다운로드 +- [ ] (dip-catalog) 로컬 디렉토리 복사로 `chart_old/chart_new` 구성 +- [ ] values.yaml flat key 비교 (added / removed / changed / type_changed) +- [ ] **`custom-values.yaml` 기본 적용** (values_override 기본값) +- [ ] `helm template` 렌더링 후 리소스 단위 비교 +- [ ] CRD schema 비교 (schema / required / version / webhook) +- [ ] dependency (Chart.yaml) 비교 +- [ ] Structured Diff JSON 스키마 확정 및 출력 + +**완료 기준**: 임의의 두 chart 버전에 대해 Structured Diff JSON이 정상 생성됨 + +#### T2. Breaking Change Rule Engine 구현 (`breaking_change_check` Skill) +- **Breaking 정의**: `custom-values.yaml`을 수정해야 하는 상황 (카탈로그 맥락) +- [ ] Values key 삭제 감지 — custom-values.yaml에 해당 key 있을 때만 breaking, 없으면 warning +- [ ] Values type 변경 감지 — 동일 기준 (custom-values.yaml에 있을 때만 breaking) +- [ ] Service port / type 변경 감지 — ⚠️ warning (custom-values.yaml 수정 불필요) +- [ ] resource 삭제 감지 — ⚠️ warning (custom-values.yaml 수정 불필요) +- [ ] Dependency major version 변경 — custom-values.yaml에 해당 subchart prefix key 있을 때만 breaking +- [ ] CRD required field 추가 / field 삭제 / storage version 변경 감지 +- [ ] severity 분류 (critical / high / medium / warning) + +**완료 기준**: custom-values.yaml 기준으로 올바르게 breaking/warning 분류, False Positive 최소화 + +#### T3. LLM Summarizer 구현 (`generate_upgrade_doc` Skill) +- [ ] Structured Diff JSON + Breaking 결과를 LLM API에 전달 +- [ ] **docs_context 반영**: README/BUILD/CUSTOM 요약을 참고 컨텍스트로 포함 +- [ ] Prompt 구현 (설계 문서 기반) +- [ ] 토큰 예산 관리 (50,000 token 상한, 초과 시 중요도 기반 필터링) +- [ ] Markdown 출력 검증 (형식 일치 여부) + +**완료 기준**: 생성된 Markdown이 설계 문서의 Output Format을 준수하고, 없는 내용을 만들어내지 않음 +- breaking=true → LLM 상세 가이드 (USE_CLAUDE_CLI=1 필요) +- breaking=false → 템플릿 기반 간단 요약 +- 항상 실행 (breaking 여부와 무관) + +#### T3-1. CUSTOM-README.md 업그레이드 주의사항 추가 +- [ ] generate_upgrade_doc 결과를 `CUSTOM-README.md`의 `# Upgrade History` 섹션에 추가 +- [ ] `update_docs_file` Skill 호출: `docs_file="manifests/helm///CUSTOM-README.md"` + +#### T4. Skill 인터페이스 구현 +- [ ] `helm_diff` Skill 구현 및 노출 +- [ ] `breaking_change_check` Skill 구현 및 노출 +- [ ] `generate_upgrade_doc` Skill 구현 및 노출 +- [ ] `update_docs_file` Skill 구현 및 노출 +- [ ] `create_pr` Skill 구현 및 노출 +- [ ] `deploy_validate` Skill 구현 및 노출 +- [ ] 인터페이스 버전 `1.0` 확정 + +**완료 기준**: 각 Skill이 독립적으로 호출 가능하고 입출력 스키마가 문서와 일치 + +--- + +### 🟡 중간 (안정성) + +#### T5. 에러 처리 및 Fallback +- [ ] `helm pull` 실패 시 재시도 (3회) + 알림 +- [ ] `helm template` 렌더링 실패 시 partial diff 생성 +- [ ] chart 미존재 시 스킵 + 로그 +- [ ] 네트워크 타임아웃 (60s) 처리 +- [ ] 각 Skill의 오류 시 에러 응답 포맷 일관화 + +#### T6. Chart Version Detector 구현 +- [ ] **dip-catalog 디렉토리 스캔** (`manifests/helm//` 하위 버전 변화 감지) +- [ ] **git diff 기반 신규 버전 탐지** +- [ ] ArtifactHub API 연동 +- [ ] Helm repo `index.yaml` 스캔 +- [ ] GitHub Release Webhook 수신 +- [ ] Cron 스케줄 설정 + +#### T7. Docs Updater 구현 (`update_docs_file` Skill) +- [ ] `CUSTOM-README.md`에 `# Upgrade History` 섹션 추가 (기존 내용 유지) +- [ ] 대상 경로: `manifests/helm///CUSTOM-README.md` +- [ ] 중복 버전 처리 (overwrite 옵션) +- [ ] `BUILD-README.md`는 `chart_updater`가 carry-over + 버전 번호 치환으로 관리 (update_docs_file 대상 아님) + +#### T8. Git PR Bot 구현 (`create_pr` Skill) +- [ ] branch 생성 (`helm-upgrade/{chart}/{version}`) +- [ ] commit (chart version 업데이트 + docs 수정) +- [ ] PR 생성 (제목, labels, machine-readable metadata 블록 포함) + +#### T9. Deploy Validator 구현 (`deploy_validate` Skill) +- [ ] `helm upgrade --dry-run` 실행 +- [ ] test namespace에 `helm upgrade` 배포 +- [ ] Pod Running + Ready 상태 확인 (타임아웃: 5분) +- [ ] 비정상 K8s events 수집 +- [ ] 실패 시 Pod 로그 수집 +- [ ] namespace teardown + +--- + +### 🟢 낮음 (개선) + +#### T10. 사용자 정의 values 오버라이드 지원 +- [ ] `values_override` 파일 경로 또는 inline YAML 지원 +- [ ] Diff 생성 시 오버라이드 values 적용 +- [ ] **dip-catalog 기본값**: `custom-values.yaml` 존재 시 자동 적용 + +--- + +## 구현 진행 현황 + +- 현황 표는 [docs/status.md](../status.md)에서 관리 + +--- + +## 우선순위 요약 + +``` +T4 (Skill 인터페이스 스키마 확정) ← 가장 먼저 (다른 모든 태스크의 계약) + ↓ +T1 (Helm Diff) + T2 (Rule Engine) ← 병렬 구현 가능 + ↓ +T3 (LLM Summarizer) → T3-1 (CUSTOM-README.md 업그레이드 주의사항 추가) + ↓ +T7 (Docs Updater) + T8 (PR Bot) + T9 (Deploy Validator) ← 병렬 구현 가능 + ↓ +T5 (에러 처리) + T6 (Version Detector) + T10 (values 오버라이드) +``` + +--- + +## 완료 기준 (Step 1 전체) + +- [ ] 각 Skill이 독립적으로 호출 가능 +- [ ] airflow chart 임의 두 버전에 대해 각 Skill 단독 동작 확인 +- [ ] Breaking Change가 있는 버전과 없는 버전 모두 올바르게 처리 +- [ ] 생성된 PR에 machine-readable metadata 블록 포함 +- [ ] 에러 발생 시 해당 Skill만 실패하고 에러 응답 반환 (전체 중단 없음) +- [ ] Skill 인터페이스 버전 `1.0` 확정 + +--- + +## 관련 설계 문서 + +- [design/01-helm-diff-engine.md](../design/01-helm-diff-engine.md) +- [design/02-breaking-change-rules.md](../design/02-breaking-change-rules.md) +- [design/03-llm-summarizer.md](../design/03-llm-summarizer.md) +- [design/04-skill-interface.md](../design/04-skill-interface.md) diff --git a/update_catalog/docs/implementation/02-agent.md b/update_catalog/docs/implementation/02-agent.md new file mode 100644 index 0000000..11ef633 --- /dev/null +++ b/update_catalog/docs/implementation/02-agent.md @@ -0,0 +1,139 @@ +# Agent 구현 태스크 (Step 2~4) + +## 전제 조건 + +- [ ] Step 1 Skills 구현 완료 (인터페이스 버전 `1.0` 확정) +- [ ] Staging K8s 클러스터 접근 가능 + +--- + +## Step 2: Agent 프레임워크 POC + +**목표**: OpenClaw와 Nanobot 중 하나를 실제 클러스터에서 검증하여 프레임워크를 확정한다. + +### POC 범위 (최소) + +- [ ] 각 프레임워크를 Staging 클러스터에 배포 +- [ ] `helm_diff` Skill 하나를 등록 +- [ ] Agent가 Skill을 호출하여 실제 결과 반환 확인 +- [ ] 두 프레임워크 비교 후 결정 + +### 비교 기준 + +| 항목 | OpenClaw | Nanobot | 결과 | +|------|---------|---------|------| +| K8s 배포 난이도 | 공식 Helm chart | 직접 구성 | - | +| Skill 연결 방식 | - | - | - | +| 로그/디버깅 편의성 | - | - | - | +| 보안 설정 가능 여부 | - | - | - | + +> POC 완료 후 이 표를 채운다. + +**완료 기준**: 두 프레임워크 중 하나 선택, 선택 이유를 [decisions/](../decisions/) 에 기록 + +--- + +## Step 3: Agent 워크플로 정의 + +**목표**: 선택한 프레임워크에서 모든 Skill을 등록하고 워크플로를 정의한다. + +### 태스크 + +- [ ] 모든 Skill 등록 + - `helm_diff`, `breaking_change_check`, `generate_upgrade_doc` + - `update_docs_file`, `create_pr`, `deploy_validate` +- [ ] 워크플로 정의 (Cron 스케줄 기반) + +```yaml +workflow: helm-upgrade-automation +schedule: "0 8 * * *" + +steps: + - name: detect-versions + skill: chart_version_detector + output: new_versions[] # current_version + latest_version + + - name: generate-diffs + skill: helm_diff + for_each: new_versions + output: diff_json[] + # 내부에서 최신 차트 pull + 버전 디렉토리 생성 수행 + + - name: check-breaking + skill: breaking_change_check + for_each: diff_json + output: breaking_results[] + + - name: generate-docs + skill: generate_upgrade_doc + # Skill 내부에서 severity 분기: breaking=true or severity≥high → LLM 심층 요약, 그 외 → 간이 템플릿 + for_each: [diff_json, breaking_results] + output: docs[] + + - name: update-docs + skill: update_docs_file + for_each: docs + output: updated[] + + - name: validate-deployments + skill: deploy_validate + condition: breaking=false and severity=high이면 스킵 → 사람 승인 필요 + for_each: updated + output: validation_results[] + + - name: create-prs + skill: create_pr + for_each: updated + output: pr_urls[] + + - name: notify + channel: slack + message: "Helm upgrade PRs created: {pr_urls}" +``` + +- [ ] K8s 이벤트 기반 트리거 설정 (선택: ArgoCD App 상태 변화 등) +- [ ] Staging 클러스터에서 E2E 동작 확인 + +**완료 기준**: Staging에서 airflow chart 신규 버전 감지 → PR 생성까지 E2E 자동 실행 + +--- + +## Step 4: 보안 정책 수립 후 운영 + +**목표**: 운영 클러스터 투입 전 보안 정책을 확립한다. + +### 태스크 + +- [ ] RBAC 설정 (test namespace만 허용) + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: helm-test +rules: + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + verbs: ["get", "list", "create", "update", "delete"] + - apiGroups: [""] + resources: ["pods", "services", "configmaps"] + verbs: ["get", "list", "create", "update", "delete"] +``` + +- [ ] NetworkPolicy 설정 (허용 egress만 통과) + - GitHub API (`api.github.com`) + - LLM API (`api.anthropic.com` 또는 `api.openai.com`) + - Slack API (알림용) +- [ ] Skill allowlist 관리 (shell skill 비활성화 필수) +- [ ] 입력 sanitization (Prompt Injection 방지) +- [ ] `breaking=true` PR은 사람이 직접 머지 승인 유지 + +**완료 기준**: 보안 체크리스트 전항목 충족, 운영 클러스터 투입 + +--- + +## 관련 문서 + +- [design/05-on-cluster-agent.md](../design/05-on-cluster-agent.md) — Agent 배포 설계 + 보안 고려사항 +- [design/04-skill-interface.md](../design/04-skill-interface.md) — Agent가 호출할 Skill 인터페이스 +- [decisions/001-agentic-first.md](../decisions/001-agentic-first.md) — 파이프라인 건너뛰기 결정 배경 diff --git a/update_catalog/docs/skill-test-guide.md b/update_catalog/docs/skill-test-guide.md new file mode 100644 index 0000000..9cf8c52 --- /dev/null +++ b/update_catalog/docs/skill-test-guide.md @@ -0,0 +1,35 @@ +# OpenClaw Skill 테스트 가이드 + +이 문서는 update_catalog 로컬 구현을 OpenClaw Skill로 테스트하는 방법을 정리한다. + +## 1) 사전 조건 +- OpenClaw 워크스페이스에 스킬 폴더가 있어야 함 + - `~/.openclaw/workspace/skills/update-catalog-helm-diff/` + - `~/.openclaw/workspace/skills/update-catalog-breaking-check/` +- Python/Helm 설치 + +## 2) helm_diff 실행 +```bash +/skill update-catalog-helm-diff --chart airflow --repo apache-airflow \ + --chart-path /Users/songwonbin/openclaw-workspace/dip-catalog/manifests/helm/airflow/1.16.0 \ + --from-version 1.16.0 --to-version 1.19.0 +``` + +## 3) breaking_change_check 실행 +1) helm_diff 결과를 파일로 저장 +```bash +python3 ~/.openclaw/workspace/skills/update-catalog-helm-diff/scripts/run.py \ + --chart airflow --repo apache-airflow \ + --chart-path /Users/songwonbin/openclaw-workspace/dip-catalog/manifests/helm/airflow/1.16.0 \ + --from-version 1.16.0 --to-version 1.19.0 \ + > /tmp/helm_diff.json +``` + +2) breaking_check 실행 +```bash +/skill update-catalog-breaking-check --diff-file /tmp/helm_diff.json +``` + +## 4) 참고 +- repo 경로 변경 시 `UPDATE_CATALOG_ROOT` 환경변수로 수정 가능 (외부 repo override) +- 기본적으로 스킬은 **내부 포함 소스**를 사용 (독립형) diff --git a/update_catalog/docs/status.md b/update_catalog/docs/status.md new file mode 100644 index 0000000..235a2a0 --- /dev/null +++ b/update_catalog/docs/status.md @@ -0,0 +1,16 @@ +# 구현 진행 현황 + +## 워크플로 단계별 구현 현황(로컬 환경) + +| 단계 | 기능/스킬 | 구현 상태 | 비고 | +|---|---|---|---| +| B | current_version 결정 | ✅ 구현 | chart_version_detector | +| C | BUILD-README repo 파싱 | ✅ 구현 | chart_version_detector | +| D | latest_version 감지 | ✅ 구현 | chart_version_detector | +| E | 최신 차트 pull + 버전 디렉토리 생성 | ✅ 구현 | chart_updater | +| F | Diff 생성 | ✅ 구현 | helm_diff | +| G | Breaking 판단 | ✅ 구현 | breaking_change_check | +| H | 업그레이드 문서 생성 | ✅ 구현 | generate_upgrade_doc (항상 실행; breaking=true → LLM, breaking=false → 템플릿) | +| I | CUSTOM-README.md 업그레이드 주의사항 추가 | ✅ 구현 | update_docs_file (manifests/helm///CUSTOM-README.md) | +| J | deploy_validate | ❌ 미구현 | Phase 2 예정 | +| K | create_pr | ✅ 구현 (미테스트) | create_pr | diff --git a/update_catalog/docs/test/README.md b/update_catalog/docs/test/README.md new file mode 100644 index 0000000..184a5f6 --- /dev/null +++ b/update_catalog/docs/test/README.md @@ -0,0 +1,6 @@ +# 테스트 문서 구조 + +- env.md: 테스트 환경 정리 +- unit.md: 단위 테스트 케이스 +- integration.md: 통합 테스트 시나리오 +- logs/: 테스트 실행 기록 diff --git a/update_catalog/docs/test/env.md b/update_catalog/docs/test/env.md new file mode 100644 index 0000000..a725cbe --- /dev/null +++ b/update_catalog/docs/test/env.md @@ -0,0 +1,17 @@ +# 테스트 환경 (env) + +## 로컬 환경 +- OS: Darwin 24.6.0 (arm64) / Darwin Kernel Version 24.6.0 +- Python: 3.12.8 +- Helm: v3.18.3+g6838ebc +- kubectl: v1.33.2 (Kustomize v5.6.0) +- 기타 의존성: 미기록 + +## 네트워크 +- Helm repo 접근: 미확인 +- GitHub API 접근: 미확인 +- LLM API 접근: 미확인 + +## 실행 경로 +- 작업 디렉토리: ~/openclaw-workspace/ai_agent_test/update_catalog +- 로그/결과 저장 위치: docs/test/logs/ diff --git a/update_catalog/docs/test/integration.md b/update_catalog/docs/test/integration.md new file mode 100644 index 0000000..49a5180 --- /dev/null +++ b/update_catalog/docs/test/integration.md @@ -0,0 +1,14 @@ +# 통합 테스트 (integration) + +## 목적 +- helm_diff → breaking_change_check → generate_upgrade_doc 흐름 검증 + +## 시나리오 + +### I1. 기본 업그레이드 +- 입력: chart, from/to 버전 +- 기대: diff 생성 → breaking 판단 → 문서 생성 + +### I2. breaking 발생 시나리오 +- 입력: Service port 변경 포함 chart +- 기대: breaking=true, migration 단계 포함 문서 diff --git a/update_catalog/docs/test/unit.md b/update_catalog/docs/test/unit.md new file mode 100644 index 0000000..fc42db0 --- /dev/null +++ b/update_catalog/docs/test/unit.md @@ -0,0 +1,14 @@ +# 단위 테스트 (unit) + +## 목적 +- 핵심 함수별 동작 검증 (helm_diff, breaking_change_check 등) + +## 테스트 케이스 + +### U1. helm_diff 기본 동작 +- 입력: chart, from/to 버전 +- 기대: values/templates/dependencies 구조 출력 + +### U2. breaking_change_check 기본 규칙 +- 입력: values removed / type_changed 포함 diff +- 기대: breaking=true, severity>=high diff --git a/update_catalog/docs/working-memory.md b/update_catalog/docs/working-memory.md new file mode 100644 index 0000000..6242f1b --- /dev/null +++ b/update_catalog/docs/working-memory.md @@ -0,0 +1,70 @@ +# 업데이트 카탈로그 문서 메모리 (요약) + +이 문서는 docs/design 및 docs/implementation 내용의 요점을 요약 저장한다. +변경 시 이 파일을 업데이트한다. + +## design 요약 + +### 00-architecture-overview +- **목적**: Helm chart 신규 버전을 카탈로그(신규 배포용)에 추가하고, 업그레이드 주의사항을 CUSTOM-README.md에 자동 문서화. +- **카탈로그 역할**: 운영 클러스터 직접 변경 아님. 신규 배포를 위한 차트 보관소. +- **파이프라인**: chart_version_detector → helm_diff → breaking_change_check → generate_upgrade_doc(항상) → update_docs_file(CUSTOM-README.md) → create_pr. +- **검토 정책**: breaking=true → needs-review 레이블(담당자 판단), breaking=false → auto-update 레이블. deploy_validate는 Phase 2. +- 구성요소 책임 분리 및 스택(helm3, Python diff/rule, LLM, Git PR, OpenClaw/Nanobot). + +### 01-helm-diff-engine +- 차트 버전 감지 방식: repo 디렉토리 스캔, git diff, index.yaml, ArtifactHub API, GitHub Release, cron. +- dip-catalog 흐름: 최신 chart pull/untar → manifests/helm/// 디렉토리 생성(기존 유지). +- 이전 버전 파일 복사: custom-values.yaml(그대로), CUSTOM-README.md(그대로), BUILD-README.md(버전 번호 치환). +- generate_upgrade_doc → CUSTOM-README.md에 업그레이드 주의사항 섹션 추가 (항상 실행). +- helm_diff 처리: values, template, CRD, dependencies 비교 → Structured Diff JSON. +- values diff: flat key 비교(added/removed/changed/type_changed), rename은 removed+added. +- template diff: 리소스 단위(kind+name) 비교, generateName 처리. +- CRD diff: schema/required/version/webhook 변화 감지. +- errors 포함하여 부분 실패 기록. + +### 02-breaking-change-rules +- **Breaking 정의**: custom-values.yaml을 수정해야 하는 상황. +- values_key_removed: custom-values.yaml에 해당 key가 있을 때만 breaking. 없으면 warning. +- values_type_changed: 동일 기준. +- service_port_changed, resource_removed: 카탈로그 맥락에서 warning (custom-values.yaml 수정 불필요). +- dependency_major_changed: custom-values.yaml에 해당 subchart prefix key 있으면 breaking, 없으면 warning. +- CRD 변경(field 삭제, required 추가, storage version 변경): breaking. +- deprecated API는 warning. +- severity: critical/high/medium/warning, 우선순위 규칙. + +### 03-llm-summarizer +- **실행 조건**: generate_upgrade_doc 항상 실행. breaking=true → LLM 상세 가이드, breaking=false → 템플릿 요약. +- LLM 입력: diff + breaking 결과 + docs_context(CUSTOM-README.md). +- 규칙: 입력 JSON 외 내용 금지, 정해진 Markdown 포맷. +- 토큰 관리: 필터링/요약/청크. +- 출력 검증(포맷/허위 서술 금지). +- **대상 파일**: CUSTOM-README.md (배포 관련 문서). BUILD-README.md는 차트 메타 정보 유지. + +### 04-skill-interface +- Skill 계약: helm_diff, breaking_change_check, generate_upgrade_doc, update_docs_file, create_pr. +- deploy_validate: Phase 2 예정 (현재 인터페이스만 정의, 구현 안 됨). +- update_docs_file: CUSTOM-README.md에 업그레이드 주의사항 추가. docs_file 경로에 to_version 포함. +- 워크플로: helm_diff → breaking_check → generate_doc(항상) → update_docs(CUSTOM-README.md, to_version 경로) → create_pr. +- 공통 에러 스키마, idempotency, secret 전달 방식. +- 인터페이스 버전 관리(1.0). + +### 05-on-cluster-agent +- OpenClaw/Nanobot로 K8s 상시 에이전트 오케스트레이션. +- 보안 고려: RBAC 최소권한, NetworkPolicy, skill allowlist, prompt injection 방지. +- 운영 정책: breaking=true → needs-review 레이블(담당자 판단). breaking=false → auto-update. +- skills allowlist: helm_diff, breaking_change_check, generate_upgrade_doc, update_docs_file, create_pr (deploy_validate는 Phase 2). + +## implementation 요약 + +### 01-skills +- Step 1 Skills 구현 태스크(T1~T10) + dip-catalog 최신 차트 pull/untar 반영. +- 핵심: helm_diff, breaking_check, generate_upgrade_doc(항상 실행), 인터페이스 노출. +- 문서 갱신은 CUSTOM-README.md 대상. +- 안정성: 에러 처리, version detector, docs updater, PR bot. +- 우선순위 로드맵 및 완료 기준. + +### 02-agent +- Step 2~4: 프레임워크 POC(OpenClaw vs Nanobot), 워크플로 정의, 보안 정책 후 운영. +- 워크플로: generate_upgrade_doc 항상 실행, deploy_validate는 Phase 2. +- 결정사항은 decisions/에 기록. diff --git a/update_catalog/scripts/run_flow.sh b/update_catalog/scripts/run_flow.sh new file mode 100755 index 0000000..e038e38 --- /dev/null +++ b/update_catalog/scripts/run_flow.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 이 스크립트가 위치한 디렉토리 기준으로 프로젝트 루트를 설정 +UPDATE_CATALOG_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# dip-catalog 레포 경로 (환경변수로 오버라이드 가능) +CATALOG_ROOT="${CATALOG_ROOT:-/Users/songwonbin/openclaw-workspace/dip-catalog}" + +CHART="${CHART:-airflow}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" +OUT_DIR="${OUT_DIR:-$(pwd)/update_catalog}" + +# 차트별 하위 디렉토리로 격리 → 다중 차트 동시 실행 시 충돌 방지 +CHART_OUT_DIR="$OUT_DIR/$CHART" +mkdir -p "$CHART_OUT_DIR" + +# 이전 실행의 임시 마커 파일 정리 +rm -f "$CHART_OUT_DIR/no_update" + +VERSION_JSON="$CHART_OUT_DIR/chart_version.json" +CHART_UPDATE_JSON="$CHART_OUT_DIR/chart_update.json" +DIFF_JSON="$CHART_OUT_DIR/helm_diff.json" +BREAKING_JSON="$CHART_OUT_DIR/breaking.json" +UPGRADE_DOC_JSON="$CHART_OUT_DIR/upgrade_doc.json" +UPDATE_DOCS_JSON="$CHART_OUT_DIR/update_docs.json" +PR_JSON="$CHART_OUT_DIR/pr.json" + +# dip-catalog main 브랜치 최신화 (create_pr이 브랜치를 main에서 분기하므로 선행 필요) +git -C "$CATALOG_ROOT" checkout "$DEFAULT_BRANCH" +git -C "$CATALOG_ROOT" pull origin "$DEFAULT_BRANCH" + +# 0) chart_version_detector: FROM_VERSION / TO_VERSION / REPO 자동 감지 +python3 "$UPDATE_CATALOG_ROOT/skills/chart_version_detector/scripts/run.py" \ + --catalog-root "$CATALOG_ROOT" --chart "$CHART" \ + > "$VERSION_JSON" + +python3 - <// 생성 +# + FROM_VERSION에서 BUILD-README.md(버전 치환), CUSTOM-README.md, custom-values.yaml 복사 +python3 "$UPDATE_CATALOG_ROOT/skills/chart_updater/scripts/run.py" \ + --catalog-root "$CATALOG_ROOT" \ + --chart "$CHART" \ + --repo "$REPO" \ + --version "$TO_VERSION" \ + --from-version "$FROM_VERSION" \ + > "$CHART_UPDATE_JSON" + +python3 - < "$DIFF_JSON" + +# 3) breaking_change_check +# FROM_VERSION의 custom-values.yaml을 기준으로 실제 사용 key만 breaking 판단 +CUSTOM_VALUES_PATH="$CATALOG_ROOT/manifests/helm/$CHART/$FROM_VERSION/custom-values.yaml" +CUSTOM_VALUES_ARG="" +if [[ -f "$CUSTOM_VALUES_PATH" ]]; then + CUSTOM_VALUES_ARG="--custom-values $CUSTOM_VALUES_PATH" +fi +python3 "$UPDATE_CATALOG_ROOT/skills/breaking_change_check/scripts/run.py" \ + --diff-file "$DIFF_JSON" \ + $CUSTOM_VALUES_ARG \ + > "$BREAKING_JSON" + +python3 - < "$UPGRADE_DOC_JSON" + +python3 - < "$PR_JSON" + +# python3 - <ez& A-T(jq literal 0 HcmV?d00001 diff --git a/update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/breaking_change_check.cpython-312.pyc b/update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/breaking_change_check.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a738971d417f0e5e3bc20390b003a23c9351e16 GIT binary patch literal 6632 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 literal 0 HcmV?d00001 diff --git a/update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc b/update_catalog/skills/breaking_change_check/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e9c35bf145d54e149c7ca46c57a441a53725249 GIT binary patch literal 5704 zcmbtYOK=m(8J@9PPwU~g!5AZB%pw*r5UA8X!pp{B*2okF5=1$Zv8HX$GMdqLj{rFr zRh+Hjl=5vkY)&~*n?sJ=bIiplDOI8`o2pbQl~fK}h4#WJ|KB6oVQw-JUvD89Ou1CdkzzP&d zjue8qU?G$X6~ei2A(D&8I$}i&v0SXso9l(=U@qRH+)#|ruN5OqV_yZlv2zKDMgWca z&?F5Tv4hYgmy-3pP#^cz_enGXXwrxFOEd*&pAQ|7Xg{C>K6Fr`gMbeC&>@Kq13KbE zhb1}+=m8%(BGH3@j``40i5>!U+=m{J=wU!7eCR=m9sxA%L&qdK3FuKDdPt&EfFARq z>M>JS3mOxu6k&rzk#^ z!knW4`?s%V zoKrlNx^N(kE zo|#3LckH8?Ek&FumRTIWQ<@|c_#~Lmm3l0(wNOv=Z7tSE4pkPOyH#?ie&ST+#!h0o zMouwwLD{GC>K=gV&Z}DvWQa(}CA&*`(XoLa`~VT8xQy8hVM93fFq#oGqiAqI@1eS!Jrgm00fCoo;(A&kbDYDc?L2Ok-XMinY&a61uzJsiXd*e2s%vc>JTKjA|i}( zr^ND91Rm3}2zgq}ggcUP9L8bqp*ex3jhiXN&>Vq_PlIVK=fu&<&F6(GIa)t>xH9|v zaFrackEbj1&u`aA+9MA8b!O!O)cg;upj%5uq+3UtyoDgB2aH&gsa}!5cr<6Otu5Ha zlIsx_1UWM|%WwvQFj{m(OhQ^wcQ-_QjX4GF33wNT9}#n%R+$Z8htw0@Dp9UE(1Mu; z?Ly@#XvagUAF9aozSS)$OVo63v151+L z9)%Jb{C)3Nuv6m?L>h;>grq*1Nr_~O`lXWF;-0+^E!i0~Ghjpc8;te%o?5FM?O(fUBTO46^VPP`hb zO-jk9Io>UqBnL~!F}NZB40=hv_irx25C?27hnJpH9-B^rw}Hxo`4hi z5{GSB^T^EvJ4S-5(6ya`%ZQ=DN{$;6FyPJd%=yagox%5Oa+S9EGiWa32v(?e)Q1MWnT#iO4bq?={`Bi122KeE?0G!fB1* zMOWCsA=(0eTtu4Too8Q0I?{$3Vn0E16U_peHrJm;49z@T93upk&3){`SuGV=OZ z=GC#3VXGr#|L`a^)jz8g)qoOAMfQes!& zts>Q8QG=ZYoG!cBDf7X-X9XP9$#X65KvM9`HShcpx(6Ub5V3;(Omm&b)P|&}BavBn z6}y9`jlM<1&|oE>0<$Z^;i)Q_s-HerS=<>oStaK%!ML>@sF6b+Y1peXY2Vw^LYc7W z_mSfZZ6Z?8*<-_bYR@w%x2VfXHe^)P$aZnEd+lYp5C!x=ehn0YP!r7 zmGFIXgWdB|Ga$!&!MYd5h~pmmPF>i*y1g7BF$-L{^v>NqWwoUU4E;bl6r01+bu<_{ zWx!@Pu(TU%v+Nc=w+UtyF*HTEI40r1noPEQJGUh8hlXJsv@^pWLd|zFyqEBYKv!X4 zW^WrywwUpUm{5^vC1#0UIO*B>4SAN_jLKw3Ci@{vU!@jr`B>g56ioNOXlw(%vYjmh zF*Lt~iysC9Y{gSspVa$@s${4hm&vGicH4C;Y~Ps(bhs`=>`P)~SZbYjf_r30+)F)U zkYesi3zD9M7@VU;%PBw5EfdeLZ6fkAd?qvThwvFP{gLUoh~inB7vY}^^AzN-YzeAG z*fk5(DY;$pLpcsc31*>b6WOPTq4^vxj?G#k+so)>Kx$_2RdT^|+QE^^jpxfXGU7Qc z4)KGT+;iGpNi|(B;p{MiL^>y^oRG!qv?`{u6G9V469FS4#j>H>aQbIa#A1kzE`PGHxTq~;?_Iw;e|7e{_Tc*6<%OlJ z_ZP@Gwv&Nt3QH%^oJS)w>RBvdZX&mRxwFX_FGH5}M5!w|^)^mK41gyw z{xz6y0;;OMR(||<<>)_@i+@oreyvRZb?nHFGTMj}_4M|^h61-nQdLK`7a9uOzDX+a z!JWYm|J?ggCDI5Hb#nV5Hkxb 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()) diff --git a/update_catalog/skills/breaking_change_check/scripts/update_catalog/skill_interface.py b/update_catalog/skills/breaking_change_check/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..3ab7c14 --- /dev/null +++ b/update_catalog/skills/breaking_change_check/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/chart_updater/SKILL.md b/update_catalog/skills/chart_updater/SKILL.md new file mode 100644 index 0000000..1fcb1e4 --- /dev/null +++ b/update_catalog/skills/chart_updater/SKILL.md @@ -0,0 +1,33 @@ +--- +name: chart_updater +description: Pull a Helm chart version and extract it into manifests/helm//. +--- + +# 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"] +} +``` diff --git a/update_catalog/skills/chart_updater/scripts/run.py b/update_catalog/skills/chart_updater/scripts/run.py new file mode 100755 index 0000000..3d29823 --- /dev/null +++ b/update_catalog/skills/chart_updater/scripts/run.py @@ -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() diff --git a/update_catalog/skills/chart_updater/scripts/update_catalog/__init__.py b/update_catalog/skills/chart_updater/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..16f23e1 --- /dev/null +++ b/update_catalog/skills/chart_updater/scripts/update_catalog/__init__.py @@ -0,0 +1 @@ +__all__ = ["skill_interface"] diff --git a/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc b/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4074caae1da4b95456f618a964d68153a958e67 GIT binary patch literal 285 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/. +""" +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, + } diff --git a/update_catalog/skills/chart_updater/scripts/update_catalog/skill_interface.py b/update_catalog/skills/chart_updater/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..4690204 --- /dev/null +++ b/update_catalog/skills/chart_updater/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/chart_version_detector/SKILL.md b/update_catalog/skills/chart_version_detector/SKILL.md new file mode 100644 index 0000000..a4a7c7b --- /dev/null +++ b/update_catalog/skills/chart_version_detector/SKILL.md @@ -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// 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" +} +``` diff --git a/update_catalog/skills/chart_version_detector/scripts/run.py b/update_catalog/skills/chart_version_detector/scripts/run.py new file mode 100755 index 0000000..dd09331 --- /dev/null +++ b/update_catalog/skills/chart_version_detector/scripts/run.py @@ -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() diff --git a/update_catalog/skills/chart_version_detector/scripts/update_catalog/__init__.py b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..16f23e1 --- /dev/null +++ b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__init__.py @@ -0,0 +1 @@ +__all__ = ["skill_interface"] diff --git a/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8004075fbe3a76ec166bd5801646c30ede6cdcaf GIT binary patch literal 303 zcmZ8cu}Z{15X~wQISKxN+Fzmb17f44XdyN>!(MibgK;=3YdopkY8voIF1L|Su`Y+ y$Ndm;&y|G#CXDG>5O=DkUERHMrY>pEr!jQ@cE}?9EGwlx*ET)|o42W7iOe6h&tp3P literal 0 HcmV?d00001 diff --git a/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/chart_version_detector.cpython-312.pyc b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/chart_version_detector.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d27026320e086caead4576e2c2460e2d1c559fd GIT binary patch literal 5784 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 literal 0 HcmV?d00001 diff --git a/update_catalog/skills/chart_version_detector/scripts/update_catalog/chart_version_detector.py b/update_catalog/skills/chart_version_detector/scripts/update_catalog/chart_version_detector.py new file mode 100644 index 0000000..e643c60 --- /dev/null +++ b/update_catalog/skills/chart_version_detector/scripts/update_catalog/chart_version_detector.py @@ -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, + } diff --git a/update_catalog/skills/chart_version_detector/scripts/update_catalog/skill_interface.py b/update_catalog/skills/chart_version_detector/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..4690204 --- /dev/null +++ b/update_catalog/skills/chart_version_detector/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/create_pr/SKILL.md b/update_catalog/skills/create_pr/SKILL.md new file mode 100644 index 0000000..688e012 --- /dev/null +++ b/update_catalog/skills/create_pr/SKILL.md @@ -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` 완료 필요. diff --git a/update_catalog/skills/create_pr/scripts/run.py b/update_catalog/skills/create_pr/scripts/run.py new file mode 100644 index 0000000..2427ab0 --- /dev/null +++ b/update_catalog/skills/create_pr/scripts/run.py @@ -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() diff --git a/update_catalog/skills/create_pr/scripts/update_catalog/__init__.py b/update_catalog/skills/create_pr/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/update_catalog/skills/create_pr/scripts/update_catalog/create_pr.py b/update_catalog/skills/create_pr/scripts/update_catalog/create_pr.py new file mode 100644 index 0000000..3c1b341 --- /dev/null +++ b/update_catalog/skills/create_pr/scripts/update_catalog/create_pr.py @@ -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() + ) diff --git a/update_catalog/skills/create_pr/scripts/update_catalog/skill_interface.py b/update_catalog/skills/create_pr/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..da375f4 --- /dev/null +++ b/update_catalog/skills/create_pr/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/deploy_validate/SKILL.md b/update_catalog/skills/deploy_validate/SKILL.md new file mode 100644 index 0000000..6a7cb7e --- /dev/null +++ b/update_catalog/skills/deploy_validate/SKILL.md @@ -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" +} +``` diff --git a/update_catalog/skills/generate_upgrade_doc/SKILL.md b/update_catalog/skills/generate_upgrade_doc/SKILL.md new file mode 100644 index 0000000..a4ef368 --- /dev/null +++ b/update_catalog/skills/generate_upgrade_doc/SKILL.md @@ -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" +} +``` diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/run.py b/update_catalog/skills/generate_upgrade_doc/scripts/run.py new file mode 100755 index 0000000..3b1020d --- /dev/null +++ b/update_catalog/skills/generate_upgrade_doc/scripts/run.py @@ -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() diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__init__.py b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..16f23e1 --- /dev/null +++ b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__init__.py @@ -0,0 +1 @@ +__all__ = ["skill_interface"] diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38a728c91a19565a79313ec7d45425f16c939126 GIT binary patch literal 301 zcmZ8cyGq4C5X~wQxf1*VwZDbV4~UJHqJ`Ml44dp22IFp+-6-i4?zQm)>_rf<)<3Ya zv6OVd9}q6~ZbTbTF^@Cnz?s)98zm9XFZa9_`^T{SLH!Q1L2{Frq*O;qtHyKPvOnRf zLbieASQoT6R$YpyBZm*kZ18>E#R7+(#DjOXJa;x{3%i_)mKL@v*O-IgM3)5nmUe{C zXuY3Y^wZmZa@|j#`m0B9xo5-Lf@X|D8!(U$(9}i;!a@gKxe|WDc!+nBfp6+kv>`OE x0AC2#!T+~l%+3LEqi(y!%~NNZinb(;k%y;!7R%?nQtEwam literal 0 HcmV?d00001 diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/generate_upgrade_doc.cpython-312.pyc b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/generate_upgrade_doc.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..268cabd3d081f4a8ad60990a12463dc5a45641ab GIT binary patch literal 8775 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 literal 0 HcmV?d00001 diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/generate_upgrade_doc.py b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/generate_upgrade_doc.py new file mode 100644 index 0000000..c785552 --- /dev/null +++ b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/generate_upgrade_doc.py @@ -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: \n" + "- to_version: \n" + "- <핵심 변경 사항 요약>\n\n" + "### custom-values.yaml 수정 필요 항목\n" + "\n" + "\n\n" + "### 배포 시 주의사항\n" + "\n" + "<없으면 섹션 생략>\n\n" + "### 참고\n" + "- severity: \n" + "- breaking: \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()) diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/skill_interface.py b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..f5be9b9 --- /dev/null +++ b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/helm_diff/SKILL.md b/update_catalog/skills/helm_diff/SKILL.md new file mode 100644 index 0000000..339a645 --- /dev/null +++ b/update_catalog/skills/helm_diff/SKILL.md @@ -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" +} +``` diff --git a/update_catalog/skills/helm_diff/scripts/run.py b/update_catalog/skills/helm_diff/scripts/run.py new file mode 100755 index 0000000..056b46f --- /dev/null +++ b/update_catalog/skills/helm_diff/scripts/run.py @@ -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() diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/__init__.py b/update_catalog/skills/helm_diff/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..16f23e1 --- /dev/null +++ b/update_catalog/skills/helm_diff/scripts/update_catalog/__init__.py @@ -0,0 +1 @@ +__all__ = ["skill_interface"] diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2f0d0dfc7cc49e074c4200651b00d952c3b2d07 GIT binary patch literal 290 zcmX@j%ge<81ci5&XUYNT#~=<2FhUuhK}x1Gq%fp2Mln<}YBJs8FV4=)$%)U*D@iR% zOH5An(`3BG9v`0w6pLTU@EN4<>r5b_A6lGRRIFc|pO;>qpO=)Gr|*-QRFqg$sqdVV zUz!qJl3$dVo~rMkm+DfKS(d8%u%+Q)>!OD(-49#0K5Sd9pPy8mnUa~9r=OS^4^)^} z5?_*9T%uoEkdj!E8lRk4l9-d9t`Bxhv3^EsPHuckW?GtladJ^+K}j*Xx_F?2GfU#* t^$IF)aoFVMrKD~s<0)eQvkk0>L3axp7y?Q2wz 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 X9>PZ9)$9$*C!T-*E2w)yEablc4pZuv literal 0 HcmV?d00001 diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/helm_diff.py b/update_catalog/skills/helm_diff/scripts/update_catalog/helm_diff.py new file mode 100644 index 0000000..f77dcc0 --- /dev/null +++ b/update_catalog/skills/helm_diff/scripts/update_catalog/helm_diff.py @@ -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/ + 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()) diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/skill_interface.py b/update_catalog/skills/helm_diff/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..4690204 --- /dev/null +++ b/update_catalog/skills/helm_diff/scripts/update_catalog/skill_interface.py @@ -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 diff --git a/update_catalog/skills/update_docs_file/SKILL.md b/update_catalog/skills/update_docs_file/SKILL.md new file mode 100644 index 0000000..dcdf27d --- /dev/null +++ b/update_catalog/skills/update_docs_file/SKILL.md @@ -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" +} +``` diff --git a/update_catalog/skills/update_docs_file/scripts/run.py b/update_catalog/skills/update_docs_file/scripts/run.py new file mode 100755 index 0000000..158924e --- /dev/null +++ b/update_catalog/skills/update_docs_file/scripts/run.py @@ -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() diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/__init__.py b/update_catalog/skills/update_docs_file/scripts/update_catalog/__init__.py new file mode 100644 index 0000000..16f23e1 --- /dev/null +++ b/update_catalog/skills/update_docs_file/scripts/update_catalog/__init__.py @@ -0,0 +1 @@ +__all__ = ["skill_interface"] diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef4bd9550e83b9d6339a070aaad6ba0c943f447d GIT binary patch literal 297 zcmX|6u}TCn5KRLv}N_48~+hvdVS}cQ$^2t=NcI>mOLz zSju+69}o_Ev*P-SH#2YEz?-)`AF+g&*9Y22^|K!a@V{o>TW*F9lHX8!?0cOn@nMbrkNT)qr#=Z1(PpW!= z@o5w%5{DdoTMnCCBSa24a*UK?E{@99ClX!YTh>J!3bsZ6YNj_21QB zy}$bEufOIG>2yMY-)QUC=F60#{0%>Pzp$53-#k|p<#WYQN{XQx1D0ADC=IBX4q2fJ zDUnLJ6s|-{kxH}_t;9+(nMbU6B~eOLlBFbkhfApe<(gtdex?{vns^!V+AgIf8Ur-$ zLo+mLBo0H7QdZ_CAwT8IACzbs(2Nfql4ustK_5CS(IG&GedvfpM*z+F(40g^0Uh(9 zqY@nl^pFo7ljvbUCw%C*M2`SE=|c}mbPCXEA9`4#M*+?I&fO6}v9Tf?0M2SuwdQqAMn}saqnpTE&XGRTvQQt2(DQ9fMjTykt_# z*a|^^Wp6<}+g)At%_aE$T%k%y1@#Qv1>M@JR-lMrLYl$uQ-0y zqlJV>$oVZY<}eZBE)%2-y^Cmta;`t5OM15a#YVuqP~L!5 zzWNoIxx1V)KF1yV;S$hM0`_}tzncE~s+NaMnuI;8zw#XTVyHC~$ zUf4sB9lWshP=ScFT&*?AtBwuS-~dFJ;tCdD5SGKXN70O-8ApQ+dYw*SYO-gr7>|L0 zAty&$WYjB!`Or@cIB?A}D3olP4>v`mLJhN45z!|)vrYS9@ZhgP<>0{^A&u`(5* zN3<>?o(v1oKr+4o?Xc5m3TXPcIe{3Oqww&PV0z0reY|n~S*1yiw+~M>=ATV9$y9qX z-&lHfvqkbAaab>ym3xr$Kd^#3S~4Pcbgau;1pIl(NOYM>iZr^S1#@F#*{;@HkEn2Y zQ)h0Goq~=9VYKRqgoL!J?rw_I26HOfW7t^Wc|^i>dTBO{6|#?Yt46uzKnZ3V^Z+Vn zp)5O#<~*7{D!+*s8swQ{36xo8`$l_Wy0P$Vwn?Vj`4f#BJLx7l(HG3619Dd`vuOi6kN6iR6L zH_4Z9NaGJgnu5Hvq&`!~icF9C)tcMmo}Gb`Y!1yiFd_xs2OPai4NZ67X5Mw0BH~dk z;x4UJEhxoBsLTwJGH8|B2DQs3dmTzUG1>(h>0D0V{?9 zi69o#WZ%O9_AS}B5JU4JJp3}4-jey2$4jOrnq;D#mD?ab-X!Dg;e3nDQ`rW6+EI6G9_w~VW*+tez3x~Z^+zN_a@CbPT`wb8O zvIA%PQ`fxpd#E0QgI**m`cuty9#I>@nm{I3pet5H)5qTp#L%q5!)L$%f8LH6ooSMp z_U!q_%I+{wcOK(}8#|#EIpUFqwSq}QhklSe{pV{vQizHnA2~h`C1MqwJu;jp_7aoA zi@U65Ly$v_Vj&zj$u;PWN8};nX2FnD`24(l2*#x<6#Db#P zL?J1%cV&17QsBO`5Q5jLBhS69?A5!=D+{x0i&qwIE}pFz;758fpNRI3!>Ck&?e@sQ zQ}79FMp`^T0XTp5DVjb7+(8V@kKpM=6It9rqo*J)QFPOD+kaG}g zJr@PHLL#FE$rZ%^2G12)IAE@*goBTGpH85hcf{|cB{0D!(2p@2jROzIfD;Ii;g9bu zm0S$vnk3hAtSvI;QG!*2DZ%*Szf!UnXiGQ$*l~3YS(!;Dh16d@O<{iY5j|#Hw{ex8Y*V;)vnM#rW+{mRDA^<>H;i zwWTZbi`u=#wcE?9MepB*Nh~M54PG$il}ZNb>@t4NqrrGV9>wysk$zYD9=SuM!sOKZ zI1#ary}xEIvB%H=UUK-K!F(A~RrR&|Djy`lXCGd%E`Y@9NksMJ1L^h?i}tY z@akk#b!=z3qrmISjFKAJ9r@^w$?rB|od{8n?R<)bj&*Vo^&%F6S7$h`ejjmob*5Ey z`Z+WNuTG4pSJdYPtZ+r`-#8@ahbP0XI7eygGv+^*EM+S7$h? hj$;q->f}N&BS6wAeJeaicvBxyEHoO zin(Y)Ga3_Ppl3BU&BZud#%X+7hzV(T%ndv*3z{b_#>BKY<^|pz>yo{)=T~gZCwIvr z^!`OA;>!=^vU0*y;&LWw#Aj7a={M$8P3u?FSxreRx|uN5jQ;#1vZM%&y$M~869+nib2$?v`CWtk=d*JtAuIh; zs$pi(Vv-^Av>O}?1w8$D2wcK3&=&nZb=Os3EXJa)Qi!!E*){8$rcB3zvgI+0N=M&r z-X)5sA}r>oCSRSHd=qmD#(C4$3ilrn-Bq*Pbbj4 zf-sj&Ak)C?6(u2K7Ab~_y}EKOZYW6-B-l+D;=}O3#QjeD=Lf(=&l})2YGm}eYZ?8r zsz+Z_FQWunjE-xWoIDM;pO{mk=X7NPsaKVezpdT*``z_u=CYy6YC?}D)HsamCipkZ zXgg?=2{WN(=Aw4U8jf^3l%qxxHfC-QIO;n?{`u@8mg1@oD>R+Wpl4vwe)x<};a8-Z z((dw!a-K|KOetS;_dRS20zJ#(l6dEM^+%1sb0w}R^_1VZxo~Hw zF7=n#Hh25R%^MZ7AsqloXm91M%2f5I)oULXepmSYjoQ$OHB{>zEqRIW`MR{fI#HJn ztsJXM(X~A_>3Ge3{9B&t?kk5XJ(bzY+toK$gpYjpe5+FJ;EU_*`po*NnlxUVYI?iN z=S#2D*zjXmJe6!mE1@YKK0B*4_%BoD_L%HbA%Ffr|AsEm=63#Abc(g@zW3XXf0yL~ z4dQc0%X=X^0HPqnm=pV1jcW0Ab_nGZkSkvc>TP(~+?qx>;GMuwKvOu;_ z5G^q^=;(EN+3i>qycQIW`4eEpTO!a@7_4*`+^q;+MB>GA+eqfMQSGTWk$SG<+o7EE zc*0gBG@vh&8gjHceJawS9ZClLzTf5SnWn(Md>xnxo?4D2B#IG%-~ilu~x0B9HE$^sNeMMfk`Y+TQ? zB4GTZe?hY$9(BP8lw1Mu86eUC-^3)InZHLxdXbyhktD_*q+~NK@1AXx#JsHybCc<8 z{IeD8K@YTO#zr=PRk@@154?QtY~pLm(IqC9FY{ojHc+A zomF)ik>tdDYk`6M%ANOBrz7x0LL8W!5;ftgIs zL_I`KG8c1+Sy&%-n+a8mtMYyjG{Vt_^g_-3!dGB(bou0aCm(e8g7G^` z@08QET?a~hGZ0$#FZnB1tG{RjA|-Ci>o4Wr9j(x}#+p5SmHkUorO9S+?{a=AUp?3e z{;)LFl!BEbx2`us`zlu|YAtkViF*(VmpRa?ys@-U9jXUO};?nnq(kUXG4_`90wcP zV2?c%Vf>S$Yl|O`)uv+`;srQguXKC#=4d&&*&C_%MjE|`8{Xj>JB;?ig+$z~Yaa%> z6^%AULJ|=Q^GGpsNY4k%9K2R9KvWL*U(RH-OA!|R7!6gI`3# zLYzE$<8ds;s?ESO?(ET#xN={S85CAbn%CB( literal 0 HcmV?d00001 diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/skill_interface.py b/update_catalog/skills/update_docs_file/scripts/update_catalog/skill_interface.py new file mode 100644 index 0000000..86212a1 --- /dev/null +++ b/update_catalog/skills/update_docs_file/scripts/update_catalog/skill_interface.py @@ -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///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 diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/update_docs_file.py b/update_catalog/skills/update_docs_file/scripts/update_catalog/update_docs_file.py new file mode 100644 index 0000000..f44c7b8 --- /dev/null +++ b/update_catalog/skills/update_docs_file/scripts/update_catalog/update_docs_file.py @@ -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())