Add catalog update agent

This commit is contained in:
wbsong111
2026-03-06 17:06:07 +09:00
parent d4fecb4ae5
commit ca64890bb6
73 changed files with 4718 additions and 0 deletions
+91
View File
@@ -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) — 초기 설계 원본
@@ -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<br/>(helm_diff, breaking_check,<br/>generate_doc, update_docs, create_pr, deploy_validate)"]
Repo["dip-catalog Repo<br/>(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<br/>(helm_diff, breaking_check,<br/>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/`에 이동하거나, 시스템 설계 문서에 통합해도 돼.
@@ -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가 조건부 호출
@@ -0,0 +1,50 @@
# ADR 002: dip-catalog 디렉토리 기반 Helm Chart 구조를 기준으로 설계
**상태**: 채택 (2026-03-03)
---
## 배경
대상 카탈로그는 GitHub 레포 내 `manifests/helm/<chart>/<version>/` 구조로 관리된다.
각 버전 디렉토리는 완전한 Helm chart 구조를 포함하며, 추가 문서와 기본 배포 values가 존재한다.
- `README.md`, `BUILD-README.md`, `CUSTOM-README.md` (버전 디렉토리 내)
- `custom-values.yaml` (배포 시 기본 values)
이 구조는 일반 Helm repo/index 기반 감지 방식과 다르므로, 감지/입력/문서 생성 방식을 조정해야 한다.
---
## 결정
1. **버전 감지는 디렉토리 기반**으로 수행한다.
- `manifests/helm/<chart>/` 하위 버전 디렉토리 변화 또는 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 구조에는 적용 불가
- **기각 이유**: 실제 운영 구조와 불일치
@@ -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/<chart>/<to_version>/ 생성
│ 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/<chart>/<version>/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)
@@ -0,0 +1,281 @@
# Helm Diff Engine 설계
## 1. 개요
두 버전의 Helm Chart를 비교하여 **Structured Diff JSON**을 생성하는 컴포넌트.
모든 출력은 deterministic하며 LLM을 사용하지 않는다.
---
## 2. Chart Version Detector
### 역할
새로운 Helm Chart 버전 출시를 감지한다.
### 감지 방법
| 방법 | 설명 | 적합 대상 |
|------|------|---------|
| **Repo 디렉토리 스캔** | `manifests/helm/<chart>/` 하위 버전 디렉토리 변화를 감지 | dip-catalog 구조 |
| Git diff 기반 감지 | 최신 커밋에서 추가된 `<version>/` 디렉토리 파악 | 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 <repo>/<chart> --version <new> # 최신 차트 다운로드
2) tar xzf <chart>-<new>.tgz # 압축 해제
3) manifests/helm/<chart>/<new>/ 로 이동 # 버전 디렉토리 생성
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 <repo>/<chart> --version <from> → chart_old/
2. (repo 구조) helm pull <repo>/<chart> --version <to> → chart_new/
1'. (dip-catalog) manifests/helm/<chart>/<from>/ 복사 → chart_old/
2'. (dip-catalog) manifests/helm/<chart>/<to>/ 복사 → 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 <chart> \
--values values_override.yaml \
--include-crds \
--kube-version <target_k8s_version> \
--api-versions <explicit_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 <chart_old> --values values_override.yaml > old.yaml
helm template <chart_new> --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 인터페이스
@@ -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 인터페이스
@@ -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: <from_version>
- to_version: <to_version>
- <핵심 변경 사항 bullet points>
### custom-values.yaml 수정 필요 항목
<breaking=true면 항목별 수정 방법>
<breaking=false면 "없음">
### 배포 시 주의사항
<warnings가 있으면 나열>
<없으면 섹션 생략>
### 참고
- severity: <severity>
- breaking: <true/false>
---
Input:
<Structured Diff JSON>
```
### 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/<chart>/<to_version>/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/<chart>/<to_version>/)
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
@@ -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/<chart>/<version>/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/<chart>/<to_version>/CUSTOM-README.md", # to_version으로 경로 결정
version="<from_version> → <to_version>",
content=<markdown>
) # 항상 실행
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로 사용하는 방법
@@ -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)
@@ -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/<chart>/<version>/` 직접 사용
- [ ] 신규 버전 감지 시 `manifests/helm/<chart>/<new>/` 디렉토리 생성 (기존 유지)
- [ ] 최신 차트 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/<chart>/<to_version>/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/<chart>/` 하위 버전 변화 감지)
- [ ] **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/<chart>/<to_version>/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)
@@ -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 # breaking=true or 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) — 파이프라인 건너뛰기 결정 배경
+35
View File
@@ -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)
- 기본적으로 스킬은 **내부 포함 소스**를 사용 (독립형)
+16
View File
@@ -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/<chart>/<to_version>/CUSTOM-README.md) |
| J | deploy_validate | ❌ 미구현 | Phase 2 예정 |
| K | create_pr | ✅ 구현 (미테스트) | create_pr |
+6
View File
@@ -0,0 +1,6 @@
# 테스트 문서 구조
- env.md: 테스트 환경 정리
- unit.md: 단위 테스트 케이스
- integration.md: 통합 테스트 시나리오
- logs/: 테스트 실행 기록
+17
View File
@@ -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/
+14
View File
@@ -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 단계 포함 문서
+14
View File
@@ -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
+70
View File
@@ -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/<chart>/<version>/ 디렉토리 생성(기존 유지).
- 이전 버전 파일 복사: 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/에 기록.
+198
View File
@@ -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 - <<PY
import json, sys
from pathlib import Path
d = json.loads(Path("$VERSION_JSON").read_text())
if d.get("error"):
print(f"ERROR: {d['error']['message']}", file=sys.stderr)
sys.exit(1)
from_v = d.get("current_version") or ""
to_v = d.get("latest_version") or ""
repo = d.get("repo") or ""
if not from_v:
print("ERROR: current_version not detected", file=sys.stderr)
sys.exit(1)
if not to_v or from_v == to_v:
Path("$CHART_OUT_DIR/no_update").write_text(from_v)
sys.exit(0)
Path("$CHART_OUT_DIR/versions.env").write_text(
f"FROM_VERSION={from_v}\n"
f"TO_VERSION={to_v}\n"
f"REPO={repo}\n"
)
PY
if [[ -f "$CHART_OUT_DIR/no_update" ]]; then
echo "No update: $CHART is already at latest ($(cat "$CHART_OUT_DIR/no_update"))"
exit 0
fi
# shellcheck source=/dev/null
source "$CHART_OUT_DIR/versions.env"
CHART_PATH="${CHART_PATH:-$CATALOG_ROOT/manifests/helm/$CHART/$FROM_VERSION}"
echo "Detected: $CHART $FROM_VERSION$TO_VERSION (repo: $REPO)"
# 1) chart_updater: TO_VERSION 차트 pull → manifests/helm/<chart>/<to_version>/ 생성
# + 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 - <<PY
import json
from pathlib import Path
r = json.loads(Path("$CHART_UPDATE_JSON").read_text())
status = "already existed" if r.get("already_existed") else "pulled"
copied = r.get("copied_files", [])
print(f"chart_updater: {r.get('dest_dir')} [{status}]")
if copied:
print(f" copied from $FROM_VERSION: {', '.join(copied)}")
PY
# 2) helm_diff
python3 "$UPDATE_CATALOG_ROOT/skills/helm_diff/scripts/run.py" \
--chart "$CHART" --repo "$REPO" \
--chart-path "$CHART_PATH" \
--from-version "$FROM_VERSION" --to-version "$TO_VERSION" \
> "$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 - <<PY
import json
from pathlib import Path
b = json.loads(Path("$BREAKING_JSON").read_text())
breaking = b.get("breaking", False)
severity = b.get("severity", "warning")
reasons = b.get("reasons", [])
warnings = b.get("warnings", [])
print(f"breaking_check: breaking={breaking}, severity={severity}, reasons={len(reasons)}, warnings={len(warnings)}")
PY
# 4) generate_upgrade_doc: 항상 실행
# breaking=true → USE_CLAUDE_CLI=1 시 LLM 상세 가이드
# breaking=false → 템플릿 기반 간단 요약
CUSTOM_README_FROM="$CATALOG_ROOT/manifests/helm/$CHART/$FROM_VERSION/CUSTOM-README.md"
DOCS_CONTEXT_ARG=""
if [[ -f "$CUSTOM_README_FROM" ]]; then
DOCS_CONTEXT_ARG="--docs-context $CUSTOM_README_FROM"
fi
python3 "$UPDATE_CATALOG_ROOT/skills/generate_upgrade_doc/scripts/run.py" \
--diff-file "$DIFF_JSON" \
--breaking-file "$BREAKING_JSON" \
$DOCS_CONTEXT_ARG \
> "$UPGRADE_DOC_JSON"
python3 - <<PY
import json
from pathlib import Path
d = json.loads(Path("$UPGRADE_DOC_JSON").read_text())
truncated = d.get("truncated", False)
print(f"generate_upgrade_doc: markdown generated (truncated={truncated})")
PY
# 5) update_docs_file: TO_VERSION의 CUSTOM-README.md에 업그레이드 주의사항 추가
CUSTOM_README_TO_RELATIVE="manifests/helm/$CHART/$TO_VERSION/CUSTOM-README.md"
python3 - <<PY
import json, subprocess, sys
from pathlib import Path
upgrade_doc = json.loads(Path("$UPGRADE_DOC_JSON").read_text())
markdown = upgrade_doc.get("markdown", "")
payload = {
"repo_path": "$CATALOG_ROOT",
"docs_file": "$CUSTOM_README_TO_RELATIVE",
"version": "$FROM_VERSION → $TO_VERSION",
"content": markdown,
"overwrite": False,
}
import sys
sys.path.insert(0, "$UPDATE_CATALOG_ROOT/src")
from update_catalog.update_docs_file import update_docs_file
result = update_docs_file(payload)
import json as _json
Path("$UPDATE_DOCS_JSON").write_text(_json.dumps(result))
status = "already existed" if result.get("already_existed") else "updated"
print(f"update_docs_file: {result.get('file_path')} [{status}]")
PY
# # 6) create_pr: 브랜치 생성 → 커밋 → 푸시 → PR 생성 (항상 실행)
# python3 "$UPDATE_CATALOG_ROOT/skills/create_pr/scripts/run.py" \
# --repo-path "$CATALOG_ROOT" \
# --chart "$CHART" \
# --from-version "$FROM_VERSION" \
# --to-version "$TO_VERSION" \
# --breaking-file "$BREAKING_JSON" \
# > "$PR_JSON"
# python3 - <<PY
# import json
# from pathlib import Path
# pr = json.loads(Path("$PR_JSON").read_text())
# b = json.loads(Path("$BREAKING_JSON").read_text())
# labels = pr.get("labels", [])
# print(f"PR: {pr.get('pr_url')} [{', '.join(labels)}]")
# if b.get("breaking"):
# print(" → custom-values.yaml 수정 필요. 담당자 확인 후 merge 하세요. (needs-review)")
# else:
# print(" → 주의사항만 있음. CUSTOM-README.md 확인 후 merge 가능. (auto-update)")
# PY
# echo "Done. Chart updated: $CATALOG_ROOT/manifests/helm/$CHART/$TO_VERSION"
# exit 0
+4
View File
@@ -0,0 +1,4 @@
# Skills
이 폴더는 update_catalog 프로젝트의 Skill 정의(SKill.md) 모음입니다.
각 스킬은 OpenClaw Skill 포맷(SKILL.md + YAML frontmatter)을 따릅니다.
@@ -0,0 +1,41 @@
---
name: breaking_change_check
description: Determine breaking changes from Structured Diff JSON using deterministic rules.
user-invokable: false
---
# breaking_change_check (Skill)
Rule-engine based breaking change detector.
## Input schema
```json
{
"diff_json": "object",
"custom_values": "object (optional)"
}
```
`custom_values`가 제공되면 `values_key_removed` 판단 시 custom-values.yaml에서 실제 사용 중인 key만 breaking으로 분류하고, 나머지는 warnings로 기록한다. 제공되지 않으면 제거된 모든 key를 breaking으로 판단한다.
## Breaking 판단 기준
| rule | severity | breaking 조건 |
|------|----------|--------------|
| `values_key_removed` | high | custom-values.yaml에 해당 key가 존재하는 경우 |
| `values_type_changed` | high | 항상 |
| `resource_removed` | high | 항상 |
| `service_port_changed` | high | 항상 |
| `dependency_major_changed` | high | 항상 (sub-chart 내부 breaking 가능) |
`breaking = severity in ("high", "critical")`
## Output schema
```json
{
"breaking": "boolean",
"severity": "critical|high|medium|warning",
"reasons": "array",
"warnings": "array"
}
```
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
DEFAULT_REPO = None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--diff-file", required=True)
parser.add_argument("--custom-values", default=None, help="custom-values.yaml 경로 (없으면 전체 key 검사)")
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.breaking_change_check import breaking_change_check # type: ignore
with open(args.diff_file, "r", encoding="utf-8") as f:
diff_json = json.load(f)
custom_values = None
if args.custom_values:
try:
import yaml # type: ignore
with open(args.custom_values, "r", encoding="utf-8") as f:
custom_values = yaml.safe_load(f) or {}
except Exception as e:
print(f"WARNING: custom-values.yaml 로드 실패, 전체 key 검사로 대체: {e}", file=sys.stderr)
out = breaking_change_check({"diff_json": diff_json, "custom_values": custom_values})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,143 @@
"""breaking_change_check skill implementation (deterministic rules).
Breaking 정의: custom-values.yaml을 수정해야 하는 상황.
카탈로그는 신규 배포용 차트 보관소이며, 운영 클러스터 직접 변경과 무관하다.
판단 기준:
- values_key_removed / type_changed: custom-values.yaml에 해당 key가 있을 때만 breaking
- service_port_changed, resource_removed: warning (custom-values.yaml 수정 불필요)
- dependency_major_changed: custom-values.yaml에 subchart prefix key가 있으면 breaking, 없으면 warning
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Set
from .skill_interface import BreakingCheckInput, BreakingCheckOutput, BreakingReason, Severity
def _severity_max(current: Severity, candidate: Severity) -> Severity:
order = ["warning", "medium", "high", "critical"]
return order[max(order.index(current), order.index(candidate))] # type: ignore
def _flatten_keys(d: Any, prefix: str = "") -> Set[str]:
"""YAML dict를 dotted key path 집합으로 평탄화한다.
예: {"webserver": {"defaultUser": {"enabled": true}}}
{"webserver", "webserver.defaultUser", "webserver.defaultUser.enabled"}
"""
keys: Set[str] = set()
if not isinstance(d, dict):
return keys
for k, v in d.items():
full_key = f"{prefix}.{k}" if prefix else k
keys.add(full_key)
if isinstance(v, dict):
keys.update(_flatten_keys(v, full_key))
return keys
def breaking_change_check(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = BreakingCheckInput(**payload)
diff = inp.diff_json
# custom-values.yaml key 집합 (없으면 None → 모든 key가 "미사용"으로 처리)
custom_keys: Optional[Set[str]] = (
_flatten_keys(inp.custom_values) if inp.custom_values else None
)
reasons: List[BreakingReason] = []
warnings: List[BreakingReason] = []
severity: Severity = "warning"
# values rules: custom-values.yaml에 있는 key 변경만 breaking
values = diff.get("values", {})
for k in values.get("removed", []):
if custom_keys is not None and k in custom_keys:
# 실제 사용 중인 key 삭제 → breaking (custom-values.yaml 수정 필요)
reasons.append(BreakingReason(
type="values_key_removed",
key=k,
detail="custom-values.yaml에서 사용 중인 key 삭제 — 수정 필요",
))
severity = _severity_max(severity, "high")
else:
# 미사용 key 삭제 → warning (수정 불필요)
warnings.append(BreakingReason(
type="values_key_removed",
key=k,
detail="custom-values.yaml에서 사용하지 않는 key 삭제 — 수정 불필요",
))
for item in values.get("type_changed", []):
k = item.get("key", "")
detail = f"{item.get('old_type')}{item.get('new_type')}"
if custom_keys is not None and k in custom_keys:
reasons.append(BreakingReason(
type="values_type_changed",
key=k,
detail=f"custom-values.yaml에서 사용 중인 key 타입 변경 ({detail}) — 수정 필요",
))
severity = _severity_max(severity, "high")
else:
warnings.append(BreakingReason(
type="values_type_changed",
key=k,
detail=f"미사용 key 타입 변경 ({detail}) — 수정 불필요",
))
# template rules: 카탈로그 맥락에서 custom-values.yaml 수정 요인 아님 → warning
templates = diff.get("templates", {})
for rid, info in templates.items():
if info.get("removed"):
warnings.append(BreakingReason(
type="resource_removed",
resource=rid,
detail="리소스 삭제 — 신규 배포 시 참고",
))
if rid.startswith("Service/") and info.get("port_changed"):
warnings.append(BreakingReason(
type="service_port_changed",
resource=rid,
detail="Service port 변경 — 신규 배포 시 Ingress/LB 설정 확인",
))
# dependency rules: custom-values.yaml에 subchart prefix key가 있으면 breaking
deps = diff.get("dependencies", {})
for dep, change in deps.get("version_changed", {}).items():
old = change.get("old") or ""
new = change.get("new") or ""
try:
old_major = int(str(old).split(".")[0])
new_major = int(str(new).split(".")[0])
if new_major > old_major:
dep_prefix = f"{dep}."
dep_in_custom = (
custom_keys is not None
and any(k.startswith(dep_prefix) for k in custom_keys)
)
if dep_in_custom:
reasons.append(BreakingReason(
type="dependency_major_changed",
key=dep,
detail=f"{old}{new} — custom-values.yaml에 해당 subchart 설정 있음, 수정 검토 필요",
))
severity = _severity_max(severity, "medium")
else:
warnings.append(BreakingReason(
type="dependency_major_changed",
key=dep,
detail=f"{old}{new} — custom-values.yaml에 해당 subchart 설정 없음",
))
except Exception:
pass
breaking = len(reasons) > 0
out = BreakingCheckOutput(
breaking=breaking,
severity=severity,
reasons=reasons,
warnings=warnings,
)
return json.loads(out.model_dump_json())
@@ -0,0 +1,147 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
custom_values: Optional[Dict[str, Any]] = None # custom-values.yaml 내용 (없으면 전체 key 검사)
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "docs/upgrade.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
committed: bool = False
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
@@ -0,0 +1,33 @@
---
name: chart_updater
description: Pull a Helm chart version and extract it into manifests/helm/<chart>/<version>.
---
# chart_updater
Pull a chart from helm repo and create a versioned directory in dip-catalog.
`from_version`이 주어지면 이전 버전 디렉토리에서 아래 파일을 새 버전 디렉토리로 복사한다 (이미 존재하는 파일은 건너뜀):
- `BUILD-README.md` — 복사 + `from_version``version` 버전 번호 치환
- `CUSTOM-README.md` — 그대로 복사
- `custom-values.yaml` — 그대로 복사
## Input schema
```json
{
"catalog_root": "string",
"chart": "string",
"repo": "string",
"version": "string",
"from_version": "string (optional)"
}
```
## Output schema
```json
{
"success": "boolean",
"already_existed": "boolean",
"dest_dir": "string",
"copied_files": ["string"]
}
```
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--catalog-root", required=True)
parser.add_argument("--chart", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--from-version", default=None)
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.chart_updater import chart_updater # type: ignore
out = chart_updater({
"catalog_root": args.catalog_root,
"chart": args.chart,
"repo": args.repo,
"version": args.version,
"from_version": args.from_version,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,97 @@
"""chart_updater skill implementation.
Pull latest chart from helm repo and extract into manifests/helm/<chart>/<version>.
"""
from __future__ import annotations
import shutil
import subprocess
import tarfile
from pathlib import Path
from typing import Any, Dict, List
def _run(cmd: list[str], cwd: str | None = None) -> str:
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
return res.stdout
# Files to carry over from the previous version directory.
# (filename, replace_version): replace_version=True → substitute from_version with to_version in content.
_CARRY_OVER_FILES = [
("BUILD-README.md", True),
("CUSTOM-README.md", False),
("custom-values.yaml", False),
]
def _copy_custom_files(
src_dir: Path, dest_dir: Path, from_version: str, to_version: str
) -> List[str]:
"""Copy carry-over files from src_dir to dest_dir.
Skips files that already exist in dest_dir (idempotent).
Returns list of copied file names.
"""
copied: List[str] = []
for fname, needs_replace in _CARRY_OVER_FILES:
src = src_dir / fname
dest = dest_dir / fname
if not src.exists() or dest.exists():
continue
content = src.read_text(encoding="utf-8")
if needs_replace:
content = content.replace(from_version, to_version)
dest.write_text(content, encoding="utf-8")
copied.append(fname)
return copied
def chart_updater(payload: Dict[str, Any]) -> Dict[str, Any]:
catalog_root = Path(payload["catalog_root"])
chart = payload["chart"]
repo = payload["repo"]
version = payload["version"]
from_version: str | None = payload.get("from_version")
dest_dir = catalog_root / "manifests" / "helm" / chart / version
already_existed = dest_dir.exists()
if not already_existed:
work_dir = catalog_root / ".tmp_chart_pull"
work_dir.mkdir(parents=True, exist_ok=True)
_run(["helm", "pull", f"{repo}/{chart}", "--version", version], cwd=str(work_dir))
tgz = next(work_dir.glob(f"{chart}-*.tgz"), None)
if tgz is None:
raise FileNotFoundError("pulled chart archive not found")
with tarfile.open(tgz, "r:gz") as tar:
tar.extractall(path=work_dir)
extracted = work_dir / chart
if not extracted.exists():
# some charts may use different folder name; fallback to first dir
dirs = [p for p in work_dir.iterdir() if p.is_dir() and p.name != "__pycache__"]
if dirs:
extracted = dirs[0]
dest_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(extracted), str(dest_dir))
tgz.unlink(missing_ok=True)
copied_files: List[str] = []
if from_version:
src_dir = catalog_root / "manifests" / "helm" / chart / from_version
if src_dir.exists():
copied_files = _copy_custom_files(src_dir, dest_dir, from_version, version)
return {
"success": True,
"already_existed": already_existed,
"dest_dir": str(dest_dir),
"copied_files": copied_files,
}
@@ -0,0 +1,144 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "docs/upgrade.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
@@ -0,0 +1,29 @@
---
name: chart_version_detector
description: Detect current chart version from dip-catalog and resolve latest version from helm repo.
---
# chart_version_detector
Determine current_version from manifests/helm/<chart>/ directories and parse repo info from BUILD-README.md.
Then resolve latest_version from helm repo.
## Input schema
```json
{
"catalog_root": "string",
"chart": "string"
}
```
## Output schema
```json
{
"chart": "string",
"catalog_root": "string",
"current_version": "string",
"repo": "string | null",
"repo_url": "string | null",
"latest_version": "string | null"
}
```
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--catalog-root", required=True)
parser.add_argument("--chart", required=True)
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.chart_version_detector import chart_version_detector # type: ignore
out = chart_version_detector({
"catalog_root": args.catalog_root,
"chart": args.chart,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,131 @@
"""chart_version_detector skill implementation.
Determine current_version from dip-catalog directory, parse repo info from BUILD-README,
then resolve latest_version from helm repo.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
def _run(cmd: List[str]) -> str:
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or f"command failed: {' '.join(cmd)}")
return res.stdout
def _parse_version(v: str) -> Tuple[int, ...]:
# basic semver compare: take numeric parts only
v = v.strip()
v = re.split(r"[+-]", v)[0]
parts = v.split(".")
out = []
for p in parts:
try:
out.append(int(p))
except ValueError:
out.append(0)
return tuple(out)
def _current_version_from_dirs(chart_dir: Path) -> Optional[str]:
versions = [p.name for p in chart_dir.iterdir() if p.is_dir()]
if not versions:
return None
versions_sorted = sorted(versions, key=_parse_version)
return versions_sorted[-1]
def _current_version_from_chart_yaml(chart_dir: Path) -> Optional[str]:
chart_yaml = chart_dir / "Chart.yaml"
if not chart_yaml.exists():
return None
with chart_yaml.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("version")
def _parse_repo_from_build_readme(path: Path) -> Tuple[Optional[str], Optional[str]]:
if not path.exists():
return None, None
text = path.read_text(encoding="utf-8")
m = re.search(r"helm\s+repo\s+add\s+(\S+)\s+(\S+)", text)
if not m:
return None, None
return m.group(1), m.group(2)
def _ensure_repo(repo: str, url: str) -> None:
try:
out = _run(["helm", "repo", "list"])
if repo in out:
return
except Exception:
pass
_run(["helm", "repo", "add", repo, url])
_run(["helm", "repo", "update"])
def _latest_version(repo: str, chart: str) -> Optional[str]:
out = _run(["helm", "search", "repo", f"{repo}/{chart}", "--versions"])
lines = [l for l in out.splitlines() if l.strip()]
if len(lines) < 2:
return None
# header at line 0; next line is latest
parts = re.split(r"\s+", lines[1].strip())
if len(parts) >= 2:
return parts[1]
return None
def chart_version_detector(payload: Dict[str, Any]) -> Dict[str, Any]:
catalog_root = Path(payload["catalog_root"])
chart = payload["chart"]
chart_dir = catalog_root / "manifests" / "helm" / chart
if not chart_dir.exists():
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": None,
"repo": None,
"repo_url": None,
"latest_version": None,
"error": {"code": "CHART_NOT_FOUND", "message": f"chart path not found: {chart_dir}"},
}
current = _current_version_from_dirs(chart_dir)
if current is None:
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": None,
"repo": None,
"repo_url": None,
"latest_version": None,
"error": {"code": "VERSION_NOT_FOUND", "message": f"no version directories under {chart_dir}"},
}
build_readme = chart_dir / current / "BUILD-README.md"
repo, url = _parse_repo_from_build_readme(build_readme)
latest = None
if repo and url:
_ensure_repo(repo, url)
latest = _latest_version(repo, chart)
return {
"chart": chart,
"catalog_root": str(catalog_root),
"current_version": current,
"repo": repo,
"repo_url": url,
"latest_version": latest,
}
@@ -0,0 +1,144 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "docs/upgrade.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
+49
View File
@@ -0,0 +1,49 @@
---
name: create_pr
description: Create a GitHub PR for the chart upgrade.
user-invokable: false
---
# create_pr (Skill)
브랜치 생성 → 커밋 → 푸시 → GitHub PR 생성을 수행한다.
breaking=true이면 `needs-review` 레이블, 아니면 `auto-update` 레이블을 붙인다.
실행 후 `main` 브랜치로 복귀하여 다음 cronjob 실행을 위한 클린 상태를 유지한다.
## 브랜치 명명 규칙
`update-{chart}/{to_version}` — 예: `update-airflow/1.19.0`
## 재실행 안전성 (Idempotent)
- 브랜치가 이미 존재하면 체크아웃 후 추가 커밋
- PR이 이미 존재하면 URL만 반환 (중복 PR 생성 안 함)
## Input schema
```json
{
"repo_path": "string",
"chart": "string",
"from_version": "string",
"to_version": "string",
"breaking": "boolean",
"severity": "critical|high|medium|warning",
"reasons": "array (breaking_change_check.reasons)",
"warnings": "array (breaking_change_check.warnings)"
}
```
## Output schema
```json
{
"pr_url": "string",
"branch_name": "string",
"labels": ["string"],
"committed": "boolean"
}
```
## 의존 도구
- `git` — 브랜치/커밋/푸시
- `gh` (GitHub CLI) — PR 생성 및 레이블 관리. `gh auth login` 완료 필요.
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repo-path", required=True, help="dip-catalog git repo 경로")
parser.add_argument("--chart", required=True)
parser.add_argument("--from-version", required=True)
parser.add_argument("--to-version", required=True)
parser.add_argument("--breaking-file", required=True, help="breaking_change_check 출력 JSON 경로")
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.create_pr import create_pr # type: ignore
with open(args.breaking_file, "r", encoding="utf-8") as f:
breaking_data = json.load(f)
out = create_pr({
"repo_path": args.repo_path,
"chart": args.chart,
"from_version": args.from_version,
"to_version": args.to_version,
"breaking": breaking_data.get("breaking", False),
"severity": breaking_data.get("severity", "warning"),
"reasons": breaking_data.get("reasons", []),
"warnings": breaking_data.get("warnings", []),
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""create_pr skill implementation.
Branch → Commit → Push → GitHub PR 생성.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from .skill_interface import CreatePRInput, CreatePROutput, BreakingReason # type: ignore
def _git(args: List[str], cwd: Path) -> str:
res = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.strip()}")
return res.stdout.strip()
def _gh(args: List[str], cwd: Path) -> str:
res = subprocess.run(["gh"] + args, cwd=cwd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"gh {' '.join(args)} failed: {res.stderr.strip()}")
return res.stdout.strip()
def _get_existing_pr_url(branch_name: str, cwd: Path) -> Optional[str]:
try:
url = _gh(
["pr", "list", "--head", branch_name, "--json", "url", "--jq", ".[0].url"],
cwd=cwd,
)
return url.strip() or None
except Exception:
return None
def _ensure_label(label: str, cwd: Path) -> None:
"""레이블이 없으면 생성한다."""
label_colors = {
"needs-review": "e11d48", # 빨강
"auto-update": "16a34a", # 초록
}
try:
_gh(["label", "list", "--json", "name", "--jq", f'.[] | select(.name == "{label}") | .name'], cwd=cwd)
except Exception:
pass
color = label_colors.get(label, "0075ca")
try:
_gh(["label", "create", label, "--color", color, "--force"], cwd=cwd)
except Exception:
pass # 레이블 생성 실패해도 PR 생성은 계속
def _build_pr_body(
chart: str,
from_version: str,
to_version: str,
breaking: bool,
severity: str,
reasons: List[BreakingReason],
warnings: List[BreakingReason],
) -> str:
lines = [
f"## Helm Chart Update: {chart} `{from_version}` → `{to_version}`",
"",
f"**Severity**: `{severity}` ",
f"**Breaking**: {'✅ Human review required before merge' if breaking else '❌ No breaking changes'}",
"",
]
if reasons:
lines.append("### Breaking Changes")
for r in reasons:
key = r.get("key") or r.get("resource") or ""
lines.append(f"- `[{r['type']}]` {key}{r.get('detail', '')}")
lines.append("")
if warnings:
lines.append(f"### Warnings ({len(warnings)} removed keys not used in custom-values.yaml)")
for w in warnings:
lines.append(f"- `[{w['type']}]` {w.get('key', '')}{w.get('detail', '')}")
lines.append("")
if not reasons:
lines += ["No breaking changes detected.", ""]
lines.append("---")
lines.append("*Generated by update-catalog automation*")
return "\n".join(lines)
def create_pr(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = CreatePRInput(**payload)
repo_path = Path(inp.repo_path)
chart = inp.chart
from_version = inp.from_version
to_version = inp.to_version
breaking = inp.breaking
severity = inp.severity
reasons = inp.reasons
warnings = inp.warnings
branch_name = f"update-{chart}/{to_version}"
chart_dir = f"manifests/helm/{chart}/{to_version}"
# 1. main으로 이동 후 최신화
_git(["checkout", "main"], cwd=repo_path)
_git(["pull"], cwd=repo_path)
# 2. 브랜치 생성 또는 기존 브랜치 체크아웃
local_exists = bool(_git(["branch", "--list", branch_name], cwd=repo_path).strip())
remote_exists = bool(_git(["branch", "-r", "--list", f"origin/{branch_name}"], cwd=repo_path).strip())
if local_exists:
_git(["checkout", branch_name], cwd=repo_path)
elif remote_exists:
_git(["checkout", "-b", branch_name, f"origin/{branch_name}"], cwd=repo_path)
else:
_git(["checkout", "-b", branch_name], cwd=repo_path)
# 3. 변경 파일 스테이징
_git(["add", chart_dir], cwd=repo_path)
# 4. 변경사항이 있으면 커밋
status = _git(["status", "--porcelain", chart_dir], cwd=repo_path)
committed = False
if status.strip():
breaking_tag = " [BREAKING]" if breaking else ""
commit_msg = f"update {chart}/{to_version}{breaking_tag}"
_git(["commit", "-m", commit_msg], cwd=repo_path)
committed = True
# 5. 원격 브랜치에 푸시
_git(["push", "-u", "origin", branch_name], cwd=repo_path)
# 6. 레이블 준비
labels = ["needs-review"] if breaking else ["auto-update"]
for label in labels:
_ensure_label(label, cwd=repo_path)
# 7. PR 생성 (이미 존재하면 URL만 반환)
pr_url = _get_existing_pr_url(branch_name, repo_path)
if not pr_url:
breaking_tag = " [BREAKING]" if breaking else ""
title = f"update {chart}: {from_version}{to_version}{breaking_tag}"
body = _build_pr_body(chart, from_version, to_version, breaking, severity, reasons, warnings)
label_args: List[str] = []
for label in labels:
label_args += ["--label", label]
pr_url = _gh(
["pr", "create", "--title", title, "--body", body, "--base", "main"] + label_args,
cwd=repo_path,
)
# 8. main으로 복귀 (다음 cronjob 실행을 위해 클린 상태 유지)
_git(["checkout", "main"], cwd=repo_path)
return json.loads(
CreatePROutput(
pr_url=pr_url,
branch_name=branch_name,
labels=labels,
committed=committed,
).model_dump_json()
)
@@ -0,0 +1,28 @@
"""Skill Interface for create_pr."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
Severity = Literal["critical", "high", "medium", "warning"]
# BreakingReason은 dict로 처리 (breaking_change_check 출력 그대로 수신)
BreakingReason = Dict[str, Any]
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
committed: bool = False
@@ -0,0 +1,33 @@
---
name: deploy_validate
description: Deploy chart to a test namespace and validate health.
user-invokable: false
---
# deploy_validate (Skill)
Runs helm upgrade in a test namespace and reports health.
## Input schema
```json
{
"chart": "string",
"repo": "string | null",
"chart_path": "string | null",
"version": "string",
"values_override": "object | null",
"namespace": "string",
"timeout": "number (default 300)"
}
```
## Output schema
```json
{
"success": "boolean",
"dry_run_passed": "boolean",
"pod_status": "object",
"events": "array",
"logs": "string | null"
}
```
@@ -0,0 +1,27 @@
---
name: generate_upgrade_doc
description: Generate upgrade Markdown from structured diff and breaking results.
user-invokable: false
---
# generate_upgrade_doc
Produce Markdown upgrade notes from Structured Diff JSON and breaking analysis.
## Input schema
```json
{
"diff_json": "object",
"breaking_result": "object",
"docs_context": "object | null",
"max_tokens": "number (default 50000)"
}
```
## Output schema
```json
{
"markdown": "string",
"truncated": "boolean"
}
```
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
DEFAULT_REPO = None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--diff-file", required=True)
parser.add_argument("--breaking-file", required=True)
parser.add_argument("--docs-context", default=None)
parser.add_argument("--max-tokens", type=int, default=50000)
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.generate_upgrade_doc import generate_upgrade_doc # type: ignore
with open(args.diff_file, "r", encoding="utf-8") as f:
diff_json = json.load(f)
with open(args.breaking_file, "r", encoding="utf-8") as f:
breaking = json.load(f)
docs_context = None
if args.docs_context:
with open(args.docs_context, "r", encoding="utf-8") as f:
text = f.read()
docs_context = {"CUSTOM-README.md": text}
out = generate_upgrade_doc({
"diff_json": diff_json,
"breaking_result": breaking,
"docs_context": docs_context,
"max_tokens": args.max_tokens,
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,150 @@
"""generate_upgrade_doc skill implementation.
항상 실행된다 (breaking 여부 무관).
- breaking=true + USE_CLAUDE_CLI=1: LLM으로 custom-values.yaml 수정 방법 포함 상세 가이드 생성
- 그 외: 템플릿 기반 간단 요약 생성
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Any, Dict, List
from .skill_interface import GenerateDocInput, GenerateDocOutput
def _fallback_summary(diff: Dict[str, Any], breaking: Dict[str, Any]) -> str:
chart = diff.get("chart")
to_version = diff.get("to_version")
from_version = diff.get("from_version")
values = diff.get("values", {})
templates = diff.get("templates", {})
deps = diff.get("dependencies", {})
values_added = len(values.get("added", []))
values_removed = len(values.get("removed", []))
values_changed = len(values.get("changed", {}))
values_type_changed = len(values.get("type_changed", []))
template_added = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("added"))
template_removed = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("removed"))
breaking_flag = breaking.get("breaking", False)
severity = breaking.get("severity", "warning")
reasons = breaking.get("reasons", [])
warnings_list = breaking.get("warnings", [])
lines: List[str] = []
# update_docs_file이 ## {version} 헤더를 별도 추가하므로 여기서는 생략
lines.append("### 변경 요약")
if from_version:
lines.append(f"- from_version: {from_version}")
if to_version:
lines.append(f"- to_version: {to_version}")
if chart and from_version and to_version:
lines.append(f"- Chart `{chart}` {from_version}{to_version} 업데이트")
lines.append(f"- Values: +{values_added} / -{values_removed} / ~{values_changed} / type~{values_type_changed}")
lines.append(f"- Templates: +{template_added} / -{template_removed}")
if deps:
added = len(deps.get("added", []))
removed = len(deps.get("removed", []))
changed = len(deps.get("version_changed", {}))
lines.append(f"- Dependencies: +{added} / -{removed} / ~{changed}")
lines.append("\n### custom-values.yaml 수정 필요 항목")
if breaking_flag:
for r in reasons:
key = r.get("key") or r.get("resource") or ""
detail = r.get("detail") or ""
lines.append(f"- **`{key}`**: {detail}".rstrip())
else:
lines.append("없음")
if warnings_list:
lines.append("\n### 배포 시 주의사항")
for w in warnings_list:
resource = w.get("resource") or w.get("key") or ""
detail = w.get("detail") or ""
wtype = w.get("type", "")
lines.append(f"- **{wtype}** {resource}: {detail}".rstrip())
lines.append("\n### 참고")
lines.append(f"- severity: {severity}")
lines.append(f"- breaking: {str(breaking_flag).lower()}")
return "\n".join(lines) + "\n"
def _build_prompt(diff: Dict[str, Any], breaking: Dict[str, Any], docs_context: Dict[str, Any] | None) -> str:
prompt = (
"당신은 Kubernetes Helm 업그레이드 문서를 작성하는 전문가입니다.\n\n"
"두 Helm 차트 버전 간 변경점을 담은 Structured Diff JSON이 제공됩니다.\n\n"
"## 작업\n"
"1. 핵심 변경 사항을 평문으로 요약합니다.\n"
"2. 각 변경의 운영 영향(예: 롤링 업데이트, 재시작 등)을 설명합니다.\n"
"3. Breaking Change를 강조하고 구체적인 마이그레이션 절차를 제공합니다.\n"
"4. 아래 형식의 간결한 Markdown 문서를 생성합니다.\n\n"
"## 규칙\n"
"- 입력 JSON에 없는 내용은 절대 추가하지 마세요.\n"
"- 추측하지 마세요.\n"
"- 값이 비어 있거나 null인 필드는 언급하지 마세요.\n"
"- docs_context는 보조 설명에만 사용하고, diff에 없는 변경을 추가하지 마세요.\n"
"- errors가 있으면 분석이 불완전할 수 있음을 명시하세요.\n"
"- SRE/DevOps 엔지니어가 바로 실행할 수 있도록 명확하게 작성하세요.\n\n"
"## 출력 형식\n"
"아래 Markdown 구조를 정확히 지키세요 (## {to_version} 헤더는 포함하지 마세요):\n\n"
"### 변경 요약\n"
"- from_version: <from_version>\n"
"- to_version: <to_version>\n"
"- <핵심 변경 사항 요약>\n\n"
"### custom-values.yaml 수정 필요 항목\n"
"<breaking=true면 항목별 key와 수정 방법>\n"
"<breaking=false면 \"없음\">\n\n"
"### 배포 시 주의사항\n"
"<warnings가 있으면 나열>\n"
"<없으면 섹션 생략>\n\n"
"### 참고\n"
"- severity: <severity>\n"
"- breaking: <true/false>\n\n"
"---\n\n"
"Input:\n"
)
payload = {
**diff,
**breaking,
}
if docs_context:
payload["docs_context"] = docs_context
prompt += json.dumps(payload, ensure_ascii=False)
return prompt
def _run_claude(prompt: str) -> str:
res = subprocess.run(["claude", "-p", prompt], capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or "claude CLI failed")
return res.stdout.strip()
def generate_upgrade_doc(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = GenerateDocInput(**payload)
use_claude = os.environ.get("USE_CLAUDE_CLI", "0") == "1"
breaking = inp.breaking_result.get("breaking", False)
markdown: str
# LLM 호출 조건: USE_CLAUDE_CLI=1 AND breaking=true
if use_claude and breaking:
try:
prompt = _build_prompt(inp.diff_json, inp.breaking_result, inp.docs_context)
markdown = _run_claude(prompt)
except Exception:
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
else:
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
out = GenerateDocOutput(markdown=markdown, truncated=False)
return json.loads(out.model_dump_json())
@@ -0,0 +1,145 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
breaking_reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "BUILD-README.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
+36
View File
@@ -0,0 +1,36 @@
---
name: helm_diff
description: Compare two Helm chart versions and emit Structured Diff JSON.
user-invokable: false
---
# helm_diff (Skill)
Deterministic Helm diff generator. Produces a structured JSON diff across values, templates, CRDs, and dependencies.
## Input schema
```json
{
"chart": "string",
"repo": "string | null",
"chart_path": "string | null",
"from_version": "string",
"to_version": "string",
"values_override": "string | object | null"
}
```
## Output schema
```json
{
"chart": "string",
"from_version": "string",
"to_version": "string",
"generated_at": "string (ISO8601)",
"values": "object",
"templates": "object",
"crd": "object",
"dependencies": "object",
"errors": "array"
}
```
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
DEFAULT_REPO = None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--chart", required=True)
parser.add_argument("--repo")
parser.add_argument("--chart-path")
parser.add_argument("--from-version", required=True)
parser.add_argument("--to-version", required=True)
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.helm_diff import helm_diff # type: ignore
payload = {
"chart": args.chart,
"repo": args.repo,
"chart_path": args.chart_path,
"from_version": args.from_version,
"to_version": args.to_version,
"values_override": None,
}
out = helm_diff(payload)
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,252 @@
"""helm_diff skill implementation (deterministic)."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple
import yaml
from .skill_interface import HelmDiffInput, HelmDiffOutput
def _run(cmd: List[str], cwd: str | None = None, timeout: int = 60) -> str:
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
if res.returncode != 0:
raise RuntimeError(f"command failed: {' '.join(cmd)}\n{res.stderr}")
return res.stdout
def _load_yaml(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def _flatten(d: Dict[str, Any], parent_key: str = "", sep: str = ".") -> Dict[str, Any]:
items: Dict[str, Any] = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else str(k)
if isinstance(v, dict):
items.update(_flatten(v, new_key, sep=sep))
else:
items[new_key] = v
return items
def _diff_values(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
old_flat = _flatten(old)
new_flat = _flatten(new)
added = sorted([k for k in new_flat.keys() if k not in old_flat])
removed = sorted([k for k in old_flat.keys() if k not in new_flat])
changed: Dict[str, Dict[str, Any]] = {}
type_changed: List[Dict[str, Any]] = []
for k in old_flat.keys() & new_flat.keys():
if old_flat[k] != new_flat[k]:
if type(old_flat[k]) != type(new_flat[k]):
type_changed.append({"key": k, "old_type": type(old_flat[k]).__name__, "new_type": type(new_flat[k]).__name__})
else:
changed[k] = {"old": old_flat[k], "new": new_flat[k]}
return {
"values": {
"added": added,
"removed": removed,
"changed": changed,
"type_changed": type_changed,
}
}
def _split_manifest(yaml_text: str) -> List[Dict[str, Any]]:
docs = []
for doc in yaml.safe_load_all(yaml_text):
if not doc or not isinstance(doc, dict):
continue
docs.append(doc)
return docs
def _resource_id(obj: Dict[str, Any], fallback_idx: int) -> str:
kind = obj.get("kind", "Unknown")
meta = obj.get("metadata", {}) or {}
name = meta.get("name")
if not name and meta.get("generateName"):
name = f"{meta.get('generateName')}__gen__{fallback_idx}"
ns = meta.get("namespace")
if kind and name:
if kind in {"Namespace", "CustomResourceDefinition"}:
return f"{kind}/{name}"
return f"{kind}/{name}" if not ns else f"{kind}/{name}"
return f"{kind}/__unknown__{fallback_idx}"
def _index_resources(docs: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
idx: Dict[str, Dict[str, Any]] = {}
for i, doc in enumerate(docs):
rid = _resource_id(doc, i)
idx[rid] = doc
return idx
def _diff_templates(old_yaml: str, new_yaml: str) -> Dict[str, Any]:
old_docs = _split_manifest(old_yaml)
new_docs = _split_manifest(new_yaml)
old_idx = _index_resources(old_docs)
new_idx = _index_resources(new_docs)
changes: Dict[str, Any] = {}
for rid, new_obj in new_idx.items():
if rid not in old_idx:
changes[rid] = {"added": True}
continue
old_obj = old_idx[rid]
# Minimal diff: detect image/env/ports for Deployments/StatefulSets/Services
kind = new_obj.get("kind")
if kind in {"Deployment", "StatefulSet"}:
def _containers(obj: Dict[str, Any]) -> List[Dict[str, Any]]:
return (((obj.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
old_cont = _containers(old_obj)
new_cont = _containers(new_obj)
old_images = {c.get("name"): c.get("image") for c in old_cont}
new_images = {c.get("name"): c.get("image") for c in new_cont}
image_changed = old_images != new_images
env_added: List[str] = []
env_removed: List[str] = []
def _env_keys(cont: Dict[str, Any]) -> set:
return {e.get("name") for e in (cont.get("env") or []) if e.get("name")}
for name, cont in {c.get("name"): c for c in new_cont}.items():
old_env = _env_keys({c.get("name"): c for c in old_cont}.get(name, {}) or {})
new_env = _env_keys(cont)
env_added += sorted(list(new_env - old_env))
env_removed += sorted(list(old_env - new_env))
changes[rid] = {
"image_changed": image_changed,
"image": {"old": old_images, "new": new_images},
"env_added": sorted(list(set(env_added))),
"env_removed": sorted(list(set(env_removed))),
}
elif kind == "Service":
def _ports(obj: Dict[str, Any]) -> List[Tuple[Any, Any]]:
ports = (obj.get("spec") or {}).get("ports") or []
return [(p.get("port"), p.get("targetPort")) for p in ports]
changes[rid] = {
"port_changed": _ports(old_obj) != _ports(new_obj)
}
for rid in old_idx.keys() - new_idx.keys():
changes[rid] = {"removed": True}
return {"templates": changes}
def _diff_chart_yaml(old_chart: Dict[str, Any], new_chart: Dict[str, Any]) -> Dict[str, Any]:
old_deps = {d.get("name"): d for d in (old_chart.get("dependencies") or [])}
new_deps = {d.get("name"): d for d in (new_chart.get("dependencies") or [])}
added = sorted([k for k in new_deps.keys() if k not in old_deps])
removed = sorted([k for k in old_deps.keys() if k not in new_deps])
version_changed: Dict[str, Dict[str, Any]] = {}
for k in old_deps.keys() & new_deps.keys():
if old_deps[k].get("version") != new_deps[k].get("version"):
version_changed[k] = {"old": old_deps[k].get("version"), "new": new_deps[k].get("version")}
return {"dependencies": {"added": added, "removed": removed, "version_changed": version_changed}}
def _resolve_chart_dir(base: Path, chart_name: str) -> Path:
# repo pull: chart is under base/<chart>
candidate = base / chart_name
if (candidate / "Chart.yaml").exists():
return candidate
# local copy: Chart.yaml at base root
if (base / "Chart.yaml").exists():
return base
# fallback: first subdir with Chart.yaml
for p in base.iterdir():
if p.is_dir() and (p / "Chart.yaml").exists():
return p
return candidate
def helm_diff(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = HelmDiffInput(**payload)
errors: List[Dict[str, Any]] = []
with tempfile.TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
chart_old = tmpdir / "chart_old"
chart_new = tmpdir / "chart_new"
chart_old.mkdir()
chart_new.mkdir()
try:
if inp.chart_path:
# dip-catalog local path for from_version
from_path = Path(inp.chart_path)
if not from_path.exists():
raise FileNotFoundError(f"chart_path not found: {from_path}")
shutil.copytree(from_path, chart_old, dirs_exist_ok=True)
# to_version: if repo provided, pull from repo (mixed mode)
if inp.repo:
_run([
"helm",
"pull",
f"{inp.repo}/{inp.chart}",
"--version",
inp.to_version,
"--untar",
"--untardir",
str(chart_new),
])
else:
# local-to-local mode (use sibling version directory)
to_path = from_path.parent / inp.to_version
shutil.copytree(to_path, chart_new, dirs_exist_ok=True)
else:
if not inp.repo:
raise ValueError("repo is required when chart_path is not provided")
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.from_version, "--untar", "--untardir", str(chart_old)])
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.to_version, "--untar", "--untardir", str(chart_new)])
except Exception as e:
errors.append({"stage": "helm_pull", "message": str(e)})
chart_old_dir = _resolve_chart_dir(chart_old, inp.chart)
chart_new_dir = _resolve_chart_dir(chart_new, inp.chart)
# values diff
values_old = _load_yaml(chart_old_dir / "values.yaml")
values_new = _load_yaml(chart_new_dir / "values.yaml")
values_diff = _diff_values(values_old, values_new)
# template diff
templates_diff: Dict[str, Any] = {"templates": {}}
try:
old_yaml = _run(["helm", "template", str(chart_old_dir), "--include-crds"])
new_yaml = _run(["helm", "template", str(chart_new_dir), "--include-crds"])
templates_diff = _diff_templates(old_yaml, new_yaml)
except Exception as e:
errors.append({"stage": "helm_template", "message": str(e)})
# CRD diff placeholder (minimal)
crd_diff = {"crd": {}}
# dependencies diff
chart_old_yaml = _load_yaml(chart_old_dir / "Chart.yaml")
chart_new_yaml = _load_yaml(chart_new_dir / "Chart.yaml")
dep_diff = _diff_chart_yaml(chart_old_yaml, chart_new_yaml)
out = HelmDiffOutput(
chart=inp.chart,
from_version=inp.from_version,
to_version=inp.to_version,
generated_at=datetime.now(timezone.utc).isoformat(),
values=values_diff.get("values", {}),
templates=templates_diff.get("templates", {}),
crd=crd_diff.get("crd", {}),
dependencies=dep_diff.get("dependencies", {}),
errors=errors,
)
return json.loads(out.model_dump_json())
@@ -0,0 +1,144 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str = "docs/upgrade.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
@@ -0,0 +1,29 @@
---
name: update_docs_file
description: Insert upgrade content into BUILD-README.md for a target chart version.
user-invokable: false
---
# update_docs_file (Skill)
Update a chart version directory's BUILD-README.md by inserting upgrade content.
## Input schema
```json
{
"repo_path": "string",
"docs_file": "string (default: BUILD-README.md)",
"version": "string",
"content": "string",
"overwrite": "boolean"
}
```
## Output schema
```json
{
"success": "boolean",
"file_path": "string",
"already_existed": "boolean"
}
```
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
DEFAULT_REPO = None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repo-path", required=True)
parser.add_argument("--docs-file", default="BUILD-README.md")
parser.add_argument("--version", required=True)
parser.add_argument("--content-file", required=True)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
if repo_root:
sys.path.insert(0, str(Path(repo_root) / "src"))
else:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from update_catalog.update_docs_file import update_docs_file # type: ignore
content = Path(args.content_file).read_text(encoding="utf-8")
out = update_docs_file({
"repo_path": args.repo_path,
"docs_file": args.docs_file,
"version": args.version,
"content": content,
"overwrite": bool(args.overwrite),
})
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__all__ = ["skill_interface"]
@@ -0,0 +1,145 @@
"""Skill Interface v1.0 for Helm upgrade automation."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
SKILL_INTERFACE_VERSION = "1.0"
# -------------------------
# Common Error Schema
# -------------------------
class SkillError(BaseModel):
code: str
message: str
retryable: bool = False
details: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
error: SkillError
# -------------------------
# Shared Types
# -------------------------
Severity = Literal["critical", "high", "medium", "warning"]
class BreakingReason(BaseModel):
type: str
resource: Optional[str] = None
key: Optional[str] = None
detail: Optional[str] = None
# -------------------------
# helm_diff
# -------------------------
class HelmDiffInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
from_version: str
to_version: str
values_override: Optional[Dict[str, Any]] = None
class HelmDiffOutput(BaseModel):
chart: str
from_version: str
to_version: str
generated_at: str
values: Dict[str, Any]
templates: Dict[str, Any]
crd: Dict[str, Any]
dependencies: Dict[str, Any]
errors: List[Dict[str, Any]] = Field(default_factory=list)
# -------------------------
# breaking_change_check
# -------------------------
class BreakingCheckInput(BaseModel):
diff_json: Dict[str, Any]
class BreakingCheckOutput(BaseModel):
breaking: bool
severity: Severity
breaking_reasons: List[BreakingReason] = Field(default_factory=list)
warnings: List[BreakingReason] = Field(default_factory=list)
# -------------------------
# generate_upgrade_doc
# -------------------------
class GenerateDocInput(BaseModel):
diff_json: Dict[str, Any]
breaking_result: Dict[str, Any]
docs_context: Optional[Dict[str, str]] = None
max_tokens: int = 50000
class GenerateDocOutput(BaseModel):
markdown: str
truncated: bool = False
# -------------------------
# update_docs_file
# -------------------------
class UpdateDocsInput(BaseModel):
repo_path: str
docs_file: str # 예: "manifests/helm/<chart>/<to_version>/CUSTOM-README.md"
version: str
content: str
overwrite: bool = False
class UpdateDocsOutput(BaseModel):
success: bool
file_path: str
already_existed: bool = False
# -------------------------
# create_pr
# -------------------------
class CreatePRInput(BaseModel):
chart: str
from_version: str
to_version: str
repo_path: str
doc_content: str
breaking: bool
severity: Severity
class CreatePROutput(BaseModel):
pr_url: str
branch_name: str
labels: List[str]
# -------------------------
# deploy_validate
# -------------------------
class DeployValidateInput(BaseModel):
chart: str
repo: Optional[str] = None
chart_path: Optional[str] = None
version: str
values_override: Optional[Dict[str, Any]] = None
namespace: str
timeout: int = 300
class DeployValidateOutput(BaseModel):
success: bool
dry_run_passed: bool
pod_status: Dict[str, int]
events: List[Dict[str, Any]] = Field(default_factory=list)
logs: Optional[str] = None
@@ -0,0 +1,62 @@
"""update_docs_file skill implementation."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict
from .skill_interface import UpdateDocsInput, UpdateDocsOutput
HEADER = "# Upgrade History"
def _insert_section(text: str, version: str, content: str) -> tuple[str, bool]:
marker = f"## {version}"
if marker in text:
return text, True
if HEADER in text:
parts = text.split(HEADER, 1)
head = parts[0] + HEADER
rest = parts[1].lstrip("\n")
new_section = f"\n\n{marker}\n{content.strip()}\n"
return head + new_section + "\n" + rest, False
# If no header, prepend
new_text = f"{HEADER}\n\n{marker}\n{content.strip()}\n\n{text}"
return new_text, False
def update_docs_file(payload: Dict[str, Any]) -> Dict[str, Any]:
inp = UpdateDocsInput(**payload)
repo_path = Path(inp.repo_path)
docs_path = repo_path / inp.docs_file
text = ""
if docs_path.exists():
text = docs_path.read_text(encoding="utf-8")
new_text, existed = _insert_section(text, inp.version, inp.content)
if existed and not inp.overwrite:
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=True)
return json.loads(out.model_dump_json())
if existed and inp.overwrite:
# overwrite: replace section between marker and next header
marker = f"## {inp.version}"
parts = new_text.split(marker, 1)
if len(parts) > 1:
after = parts[1]
tail_idx = after.find("\n## ")
if tail_idx != -1:
after = after[tail_idx:]
else:
after = ""
new_text = parts[0] + marker + "\n" + inp.content.strip() + "\n" + after
docs_path.parent.mkdir(parents=True, exist_ok=True)
docs_path.write_text(new_text, encoding="utf-8")
out = UpdateDocsOutput(success=True, file_path=str(docs_path), already_existed=existed)
return json.loads(out.model_dump_json())