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 - < 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 0000000..d4074ca Binary files /dev/null and b/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc differ diff --git a/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/chart_updater.cpython-312.pyc b/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/chart_updater.cpython-312.pyc new file mode 100644 index 0000000..a110ebb Binary files /dev/null and b/update_catalog/skills/chart_updater/scripts/update_catalog/__pycache__/chart_updater.cpython-312.pyc differ diff --git a/update_catalog/skills/chart_updater/scripts/update_catalog/chart_updater.py b/update_catalog/skills/chart_updater/scripts/update_catalog/chart_updater.py new file mode 100644 index 0000000..4add081 --- /dev/null +++ b/update_catalog/skills/chart_updater/scripts/update_catalog/chart_updater.py @@ -0,0 +1,97 @@ +"""chart_updater skill implementation. + +Pull latest chart from helm repo and extract into manifests/helm//. +""" +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 0000000..8004075 Binary files /dev/null and b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc differ 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 0000000..5d27026 Binary files /dev/null and b/update_catalog/skills/chart_version_detector/scripts/update_catalog/__pycache__/chart_version_detector.cpython-312.pyc differ 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 0000000..38a728c Binary files /dev/null and b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc differ 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 0000000..268cabd Binary files /dev/null and b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/generate_upgrade_doc.cpython-312.pyc differ diff --git a/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc new file mode 100644 index 0000000..3569ff1 Binary files /dev/null and b/update_catalog/skills/generate_upgrade_doc/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc differ 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 0000000..c2f0d0d Binary files /dev/null and b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc differ diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/helm_diff.cpython-312.pyc b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/helm_diff.cpython-312.pyc new file mode 100644 index 0000000..068203e Binary files /dev/null and b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/helm_diff.cpython-312.pyc differ diff --git a/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc new file mode 100644 index 0000000..3a54459 Binary files /dev/null and b/update_catalog/skills/helm_diff/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc differ 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 0000000..ef4bd95 Binary files /dev/null and b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/__init__.cpython-312.pyc differ diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc new file mode 100644 index 0000000..3d2f075 Binary files /dev/null and b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/skill_interface.cpython-312.pyc differ diff --git a/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/update_docs_file.cpython-312.pyc b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/update_docs_file.cpython-312.pyc new file mode 100644 index 0000000..d24a5b0 Binary files /dev/null and b/update_catalog/skills/update_docs_file/scripts/update_catalog/__pycache__/update_docs_file.cpython-312.pyc differ 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())