Add catalog update agent
This commit is contained in:
@@ -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) — 파이프라인 건너뛰기 결정 배경
|
||||
@@ -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)
|
||||
- 기본적으로 스킬은 **내부 포함 소스**를 사용 (독립형)
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,6 @@
|
||||
# 테스트 문서 구조
|
||||
|
||||
- env.md: 테스트 환경 정리
|
||||
- unit.md: 단위 테스트 케이스
|
||||
- integration.md: 통합 테스트 시나리오
|
||||
- logs/: 테스트 실행 기록
|
||||
@@ -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/
|
||||
@@ -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 단계 포함 문서
|
||||
@@ -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
|
||||
@@ -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/에 기록.
|
||||
Executable
+198
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# Skills
|
||||
|
||||
이 폴더는 update_catalog 프로젝트의 Skill 정의(SKill.md) 모음입니다.
|
||||
각 스킬은 OpenClaw Skill 포맷(SKILL.md + YAML frontmatter)을 따릅니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: breaking_change_check
|
||||
description: Determine breaking changes from Structured Diff JSON using deterministic rules.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# breaking_change_check (Skill)
|
||||
|
||||
Rule-engine based breaking change detector.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"diff_json": "object",
|
||||
"custom_values": "object (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
`custom_values`가 제공되면 `values_key_removed` 판단 시 custom-values.yaml에서 실제 사용 중인 key만 breaking으로 분류하고, 나머지는 warnings로 기록한다. 제공되지 않으면 제거된 모든 key를 breaking으로 판단한다.
|
||||
|
||||
## Breaking 판단 기준
|
||||
|
||||
| rule | severity | breaking 조건 |
|
||||
|------|----------|--------------|
|
||||
| `values_key_removed` | high | custom-values.yaml에 해당 key가 존재하는 경우 |
|
||||
| `values_type_changed` | high | 항상 |
|
||||
| `resource_removed` | high | 항상 |
|
||||
| `service_port_changed` | high | 항상 |
|
||||
| `dependency_major_changed` | high | 항상 (sub-chart 내부 breaking 가능) |
|
||||
|
||||
`breaking = severity in ("high", "critical")`
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"breaking": "boolean",
|
||||
"severity": "critical|high|medium|warning",
|
||||
"reasons": "array",
|
||||
"warnings": "array"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--diff-file", required=True)
|
||||
parser.add_argument("--custom-values", default=None, help="custom-values.yaml 경로 (없으면 전체 key 검사)")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.breaking_change_check import breaking_change_check # type: ignore
|
||||
|
||||
with open(args.diff_file, "r", encoding="utf-8") as f:
|
||||
diff_json = json.load(f)
|
||||
|
||||
custom_values = None
|
||||
if args.custom_values:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
with open(args.custom_values, "r", encoding="utf-8") as f:
|
||||
custom_values = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"WARNING: custom-values.yaml 로드 실패, 전체 key 검사로 대체: {e}", file=sys.stderr)
|
||||
|
||||
out = breaking_change_check({"diff_json": diff_json, "custom_values": custom_values})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+143
@@ -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())
|
||||
+147
@@ -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
@@ -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"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+131
@@ -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,
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: create_pr
|
||||
description: Create a GitHub PR for the chart upgrade.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# create_pr (Skill)
|
||||
|
||||
브랜치 생성 → 커밋 → 푸시 → GitHub PR 생성을 수행한다.
|
||||
breaking=true이면 `needs-review` 레이블, 아니면 `auto-update` 레이블을 붙인다.
|
||||
실행 후 `main` 브랜치로 복귀하여 다음 cronjob 실행을 위한 클린 상태를 유지한다.
|
||||
|
||||
## 브랜치 명명 규칙
|
||||
|
||||
`update-{chart}/{to_version}` — 예: `update-airflow/1.19.0`
|
||||
|
||||
## 재실행 안전성 (Idempotent)
|
||||
|
||||
- 브랜치가 이미 존재하면 체크아웃 후 추가 커밋
|
||||
- PR이 이미 존재하면 URL만 반환 (중복 PR 생성 안 함)
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"repo_path": "string",
|
||||
"chart": "string",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"breaking": "boolean",
|
||||
"severity": "critical|high|medium|warning",
|
||||
"reasons": "array (breaking_change_check.reasons)",
|
||||
"warnings": "array (breaking_change_check.warnings)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"pr_url": "string",
|
||||
"branch_name": "string",
|
||||
"labels": ["string"],
|
||||
"committed": "boolean"
|
||||
}
|
||||
```
|
||||
|
||||
## 의존 도구
|
||||
|
||||
- `git` — 브랜치/커밋/푸시
|
||||
- `gh` (GitHub CLI) — PR 생성 및 레이블 관리. `gh auth login` 완료 필요.
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-path", required=True, help="dip-catalog git repo 경로")
|
||||
parser.add_argument("--chart", required=True)
|
||||
parser.add_argument("--from-version", required=True)
|
||||
parser.add_argument("--to-version", required=True)
|
||||
parser.add_argument("--breaking-file", required=True, help="breaking_change_check 출력 JSON 경로")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.create_pr import create_pr # type: ignore
|
||||
|
||||
with open(args.breaking_file, "r", encoding="utf-8") as f:
|
||||
breaking_data = json.load(f)
|
||||
|
||||
out = create_pr({
|
||||
"repo_path": args.repo_path,
|
||||
"chart": args.chart,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"breaking": breaking_data.get("breaking", False),
|
||||
"severity": breaking_data.get("severity", "warning"),
|
||||
"reasons": breaking_data.get("reasons", []),
|
||||
"warnings": breaking_data.get("warnings", []),
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""create_pr skill implementation.
|
||||
|
||||
Branch → Commit → Push → GitHub PR 생성.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .skill_interface import CreatePRInput, CreatePROutput, BreakingReason # type: ignore
|
||||
|
||||
|
||||
def _git(args: List[str], cwd: Path) -> str:
|
||||
res = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.strip()}")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _gh(args: List[str], cwd: Path) -> str:
|
||||
res = subprocess.run(["gh"] + args, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"gh {' '.join(args)} failed: {res.stderr.strip()}")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _get_existing_pr_url(branch_name: str, cwd: Path) -> Optional[str]:
|
||||
try:
|
||||
url = _gh(
|
||||
["pr", "list", "--head", branch_name, "--json", "url", "--jq", ".[0].url"],
|
||||
cwd=cwd,
|
||||
)
|
||||
return url.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_label(label: str, cwd: Path) -> None:
|
||||
"""레이블이 없으면 생성한다."""
|
||||
label_colors = {
|
||||
"needs-review": "e11d48", # 빨강
|
||||
"auto-update": "16a34a", # 초록
|
||||
}
|
||||
try:
|
||||
_gh(["label", "list", "--json", "name", "--jq", f'.[] | select(.name == "{label}") | .name'], cwd=cwd)
|
||||
except Exception:
|
||||
pass
|
||||
color = label_colors.get(label, "0075ca")
|
||||
try:
|
||||
_gh(["label", "create", label, "--color", color, "--force"], cwd=cwd)
|
||||
except Exception:
|
||||
pass # 레이블 생성 실패해도 PR 생성은 계속
|
||||
|
||||
|
||||
def _build_pr_body(
|
||||
chart: str,
|
||||
from_version: str,
|
||||
to_version: str,
|
||||
breaking: bool,
|
||||
severity: str,
|
||||
reasons: List[BreakingReason],
|
||||
warnings: List[BreakingReason],
|
||||
) -> str:
|
||||
lines = [
|
||||
f"## Helm Chart Update: {chart} `{from_version}` → `{to_version}`",
|
||||
"",
|
||||
f"**Severity**: `{severity}` ",
|
||||
f"**Breaking**: {'✅ Human review required before merge' if breaking else '❌ No breaking changes'}",
|
||||
"",
|
||||
]
|
||||
if reasons:
|
||||
lines.append("### Breaking Changes")
|
||||
for r in reasons:
|
||||
key = r.get("key") or r.get("resource") or ""
|
||||
lines.append(f"- `[{r['type']}]` {key} — {r.get('detail', '')}")
|
||||
lines.append("")
|
||||
if warnings:
|
||||
lines.append(f"### Warnings ({len(warnings)} removed keys not used in custom-values.yaml)")
|
||||
for w in warnings:
|
||||
lines.append(f"- `[{w['type']}]` {w.get('key', '')} — {w.get('detail', '')}")
|
||||
lines.append("")
|
||||
if not reasons:
|
||||
lines += ["No breaking changes detected.", ""]
|
||||
lines.append("---")
|
||||
lines.append("*Generated by update-catalog automation*")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_pr(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = CreatePRInput(**payload)
|
||||
repo_path = Path(inp.repo_path)
|
||||
chart = inp.chart
|
||||
from_version = inp.from_version
|
||||
to_version = inp.to_version
|
||||
breaking = inp.breaking
|
||||
severity = inp.severity
|
||||
reasons = inp.reasons
|
||||
warnings = inp.warnings
|
||||
|
||||
branch_name = f"update-{chart}/{to_version}"
|
||||
chart_dir = f"manifests/helm/{chart}/{to_version}"
|
||||
|
||||
# 1. main으로 이동 후 최신화
|
||||
_git(["checkout", "main"], cwd=repo_path)
|
||||
_git(["pull"], cwd=repo_path)
|
||||
|
||||
# 2. 브랜치 생성 또는 기존 브랜치 체크아웃
|
||||
local_exists = bool(_git(["branch", "--list", branch_name], cwd=repo_path).strip())
|
||||
remote_exists = bool(_git(["branch", "-r", "--list", f"origin/{branch_name}"], cwd=repo_path).strip())
|
||||
|
||||
if local_exists:
|
||||
_git(["checkout", branch_name], cwd=repo_path)
|
||||
elif remote_exists:
|
||||
_git(["checkout", "-b", branch_name, f"origin/{branch_name}"], cwd=repo_path)
|
||||
else:
|
||||
_git(["checkout", "-b", branch_name], cwd=repo_path)
|
||||
|
||||
# 3. 변경 파일 스테이징
|
||||
_git(["add", chart_dir], cwd=repo_path)
|
||||
|
||||
# 4. 변경사항이 있으면 커밋
|
||||
status = _git(["status", "--porcelain", chart_dir], cwd=repo_path)
|
||||
committed = False
|
||||
if status.strip():
|
||||
breaking_tag = " [BREAKING]" if breaking else ""
|
||||
commit_msg = f"update {chart}/{to_version}{breaking_tag}"
|
||||
_git(["commit", "-m", commit_msg], cwd=repo_path)
|
||||
committed = True
|
||||
|
||||
# 5. 원격 브랜치에 푸시
|
||||
_git(["push", "-u", "origin", branch_name], cwd=repo_path)
|
||||
|
||||
# 6. 레이블 준비
|
||||
labels = ["needs-review"] if breaking else ["auto-update"]
|
||||
for label in labels:
|
||||
_ensure_label(label, cwd=repo_path)
|
||||
|
||||
# 7. PR 생성 (이미 존재하면 URL만 반환)
|
||||
pr_url = _get_existing_pr_url(branch_name, repo_path)
|
||||
if not pr_url:
|
||||
breaking_tag = " [BREAKING]" if breaking else ""
|
||||
title = f"update {chart}: {from_version} → {to_version}{breaking_tag}"
|
||||
body = _build_pr_body(chart, from_version, to_version, breaking, severity, reasons, warnings)
|
||||
label_args: List[str] = []
|
||||
for label in labels:
|
||||
label_args += ["--label", label]
|
||||
pr_url = _gh(
|
||||
["pr", "create", "--title", title, "--body", body, "--base", "main"] + label_args,
|
||||
cwd=repo_path,
|
||||
)
|
||||
|
||||
# 8. main으로 복귀 (다음 cronjob 실행을 위해 클린 상태 유지)
|
||||
_git(["checkout", "main"], cwd=repo_path)
|
||||
|
||||
return json.loads(
|
||||
CreatePROutput(
|
||||
pr_url=pr_url,
|
||||
branch_name=branch_name,
|
||||
labels=labels,
|
||||
committed=committed,
|
||||
).model_dump_json()
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Skill Interface for create_pr."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
# BreakingReason은 dict로 처리 (breaking_change_check 출력 그대로 수신)
|
||||
BreakingReason = Dict[str, Any]
|
||||
|
||||
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
committed: bool = False
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: deploy_validate
|
||||
description: Deploy chart to a test namespace and validate health.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# deploy_validate (Skill)
|
||||
|
||||
Runs helm upgrade in a test namespace and reports health.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"repo": "string | null",
|
||||
"chart_path": "string | null",
|
||||
"version": "string",
|
||||
"values_override": "object | null",
|
||||
"namespace": "string",
|
||||
"timeout": "number (default 300)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"success": "boolean",
|
||||
"dry_run_passed": "boolean",
|
||||
"pod_status": "object",
|
||||
"events": "array",
|
||||
"logs": "string | null"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: generate_upgrade_doc
|
||||
description: Generate upgrade Markdown from structured diff and breaking results.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# generate_upgrade_doc
|
||||
|
||||
Produce Markdown upgrade notes from Structured Diff JSON and breaking analysis.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"diff_json": "object",
|
||||
"breaking_result": "object",
|
||||
"docs_context": "object | null",
|
||||
"max_tokens": "number (default 50000)"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"markdown": "string",
|
||||
"truncated": "boolean"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--diff-file", required=True)
|
||||
parser.add_argument("--breaking-file", required=True)
|
||||
parser.add_argument("--docs-context", default=None)
|
||||
parser.add_argument("--max-tokens", type=int, default=50000)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.generate_upgrade_doc import generate_upgrade_doc # type: ignore
|
||||
|
||||
with open(args.diff_file, "r", encoding="utf-8") as f:
|
||||
diff_json = json.load(f)
|
||||
with open(args.breaking_file, "r", encoding="utf-8") as f:
|
||||
breaking = json.load(f)
|
||||
|
||||
docs_context = None
|
||||
if args.docs_context:
|
||||
with open(args.docs_context, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
docs_context = {"CUSTOM-README.md": text}
|
||||
|
||||
out = generate_upgrade_doc({
|
||||
"diff_json": diff_json,
|
||||
"breaking_result": breaking,
|
||||
"docs_context": docs_context,
|
||||
"max_tokens": args.max_tokens,
|
||||
})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+150
@@ -0,0 +1,150 @@
|
||||
"""generate_upgrade_doc skill implementation.
|
||||
|
||||
항상 실행된다 (breaking 여부 무관).
|
||||
- breaking=true + USE_CLAUDE_CLI=1: LLM으로 custom-values.yaml 수정 방법 포함 상세 가이드 생성
|
||||
- 그 외: 템플릿 기반 간단 요약 생성
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .skill_interface import GenerateDocInput, GenerateDocOutput
|
||||
|
||||
|
||||
def _fallback_summary(diff: Dict[str, Any], breaking: Dict[str, Any]) -> str:
|
||||
chart = diff.get("chart")
|
||||
to_version = diff.get("to_version")
|
||||
from_version = diff.get("from_version")
|
||||
|
||||
values = diff.get("values", {})
|
||||
templates = diff.get("templates", {})
|
||||
deps = diff.get("dependencies", {})
|
||||
|
||||
values_added = len(values.get("added", []))
|
||||
values_removed = len(values.get("removed", []))
|
||||
values_changed = len(values.get("changed", {}))
|
||||
values_type_changed = len(values.get("type_changed", []))
|
||||
|
||||
template_added = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("added"))
|
||||
template_removed = sum(1 for v in templates.values() if isinstance(v, dict) and v.get("removed"))
|
||||
|
||||
breaking_flag = breaking.get("breaking", False)
|
||||
severity = breaking.get("severity", "warning")
|
||||
reasons = breaking.get("reasons", [])
|
||||
warnings_list = breaking.get("warnings", [])
|
||||
|
||||
lines: List[str] = []
|
||||
|
||||
# update_docs_file이 ## {version} 헤더를 별도 추가하므로 여기서는 생략
|
||||
lines.append("### 변경 요약")
|
||||
if from_version:
|
||||
lines.append(f"- from_version: {from_version}")
|
||||
if to_version:
|
||||
lines.append(f"- to_version: {to_version}")
|
||||
if chart and from_version and to_version:
|
||||
lines.append(f"- Chart `{chart}` {from_version} → {to_version} 업데이트")
|
||||
lines.append(f"- Values: +{values_added} / -{values_removed} / ~{values_changed} / type~{values_type_changed}")
|
||||
lines.append(f"- Templates: +{template_added} / -{template_removed}")
|
||||
if deps:
|
||||
added = len(deps.get("added", []))
|
||||
removed = len(deps.get("removed", []))
|
||||
changed = len(deps.get("version_changed", {}))
|
||||
lines.append(f"- Dependencies: +{added} / -{removed} / ~{changed}")
|
||||
|
||||
lines.append("\n### custom-values.yaml 수정 필요 항목")
|
||||
if breaking_flag:
|
||||
for r in reasons:
|
||||
key = r.get("key") or r.get("resource") or ""
|
||||
detail = r.get("detail") or ""
|
||||
lines.append(f"- **`{key}`**: {detail}".rstrip())
|
||||
else:
|
||||
lines.append("없음")
|
||||
|
||||
if warnings_list:
|
||||
lines.append("\n### 배포 시 주의사항")
|
||||
for w in warnings_list:
|
||||
resource = w.get("resource") or w.get("key") or ""
|
||||
detail = w.get("detail") or ""
|
||||
wtype = w.get("type", "")
|
||||
lines.append(f"- **{wtype}** {resource}: {detail}".rstrip())
|
||||
|
||||
lines.append("\n### 참고")
|
||||
lines.append(f"- severity: {severity}")
|
||||
lines.append(f"- breaking: {str(breaking_flag).lower()}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _build_prompt(diff: Dict[str, Any], breaking: Dict[str, Any], docs_context: Dict[str, Any] | None) -> str:
|
||||
prompt = (
|
||||
"당신은 Kubernetes Helm 업그레이드 문서를 작성하는 전문가입니다.\n\n"
|
||||
"두 Helm 차트 버전 간 변경점을 담은 Structured Diff JSON이 제공됩니다.\n\n"
|
||||
"## 작업\n"
|
||||
"1. 핵심 변경 사항을 평문으로 요약합니다.\n"
|
||||
"2. 각 변경의 운영 영향(예: 롤링 업데이트, 재시작 등)을 설명합니다.\n"
|
||||
"3. Breaking Change를 강조하고 구체적인 마이그레이션 절차를 제공합니다.\n"
|
||||
"4. 아래 형식의 간결한 Markdown 문서를 생성합니다.\n\n"
|
||||
"## 규칙\n"
|
||||
"- 입력 JSON에 없는 내용은 절대 추가하지 마세요.\n"
|
||||
"- 추측하지 마세요.\n"
|
||||
"- 값이 비어 있거나 null인 필드는 언급하지 마세요.\n"
|
||||
"- docs_context는 보조 설명에만 사용하고, diff에 없는 변경을 추가하지 마세요.\n"
|
||||
"- errors가 있으면 분석이 불완전할 수 있음을 명시하세요.\n"
|
||||
"- SRE/DevOps 엔지니어가 바로 실행할 수 있도록 명확하게 작성하세요.\n\n"
|
||||
"## 출력 형식\n"
|
||||
"아래 Markdown 구조를 정확히 지키세요 (## {to_version} 헤더는 포함하지 마세요):\n\n"
|
||||
"### 변경 요약\n"
|
||||
"- from_version: <from_version>\n"
|
||||
"- to_version: <to_version>\n"
|
||||
"- <핵심 변경 사항 요약>\n\n"
|
||||
"### custom-values.yaml 수정 필요 항목\n"
|
||||
"<breaking=true면 항목별 key와 수정 방법>\n"
|
||||
"<breaking=false면 \"없음\">\n\n"
|
||||
"### 배포 시 주의사항\n"
|
||||
"<warnings가 있으면 나열>\n"
|
||||
"<없으면 섹션 생략>\n\n"
|
||||
"### 참고\n"
|
||||
"- severity: <severity>\n"
|
||||
"- breaking: <true/false>\n\n"
|
||||
"---\n\n"
|
||||
"Input:\n"
|
||||
)
|
||||
payload = {
|
||||
**diff,
|
||||
**breaking,
|
||||
}
|
||||
if docs_context:
|
||||
payload["docs_context"] = docs_context
|
||||
prompt += json.dumps(payload, ensure_ascii=False)
|
||||
return prompt
|
||||
|
||||
|
||||
def _run_claude(prompt: str) -> str:
|
||||
res = subprocess.run(["claude", "-p", prompt], capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(res.stderr.strip() or "claude CLI failed")
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def generate_upgrade_doc(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = GenerateDocInput(**payload)
|
||||
|
||||
use_claude = os.environ.get("USE_CLAUDE_CLI", "0") == "1"
|
||||
breaking = inp.breaking_result.get("breaking", False)
|
||||
markdown: str
|
||||
|
||||
# LLM 호출 조건: USE_CLAUDE_CLI=1 AND breaking=true
|
||||
if use_claude and breaking:
|
||||
try:
|
||||
prompt = _build_prompt(inp.diff_json, inp.breaking_result, inp.docs_context)
|
||||
markdown = _run_claude(prompt)
|
||||
except Exception:
|
||||
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
|
||||
else:
|
||||
markdown = _fallback_summary(inp.diff_json, inp.breaking_result)
|
||||
|
||||
out = GenerateDocOutput(markdown=markdown, truncated=False)
|
||||
return json.loads(out.model_dump_json())
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
breaking_reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "BUILD-README.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: helm_diff
|
||||
description: Compare two Helm chart versions and emit Structured Diff JSON.
|
||||
user-invokable: false
|
||||
---
|
||||
|
||||
# helm_diff (Skill)
|
||||
|
||||
Deterministic Helm diff generator. Produces a structured JSON diff across values, templates, CRDs, and dependencies.
|
||||
|
||||
## Input schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"repo": "string | null",
|
||||
"chart_path": "string | null",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"values_override": "string | object | null"
|
||||
}
|
||||
```
|
||||
|
||||
## Output schema
|
||||
```json
|
||||
{
|
||||
"chart": "string",
|
||||
"from_version": "string",
|
||||
"to_version": "string",
|
||||
"generated_at": "string (ISO8601)",
|
||||
"values": "object",
|
||||
"templates": "object",
|
||||
"crd": "object",
|
||||
"dependencies": "object",
|
||||
"errors": "array"
|
||||
}
|
||||
```
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--chart", required=True)
|
||||
parser.add_argument("--repo")
|
||||
parser.add_argument("--chart-path")
|
||||
parser.add_argument("--from-version", required=True)
|
||||
parser.add_argument("--to-version", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = os.environ.get("UPDATE_CATALOG_ROOT")
|
||||
if repo_root:
|
||||
sys.path.insert(0, str(Path(repo_root) / "src"))
|
||||
else:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from update_catalog.helm_diff import helm_diff # type: ignore
|
||||
|
||||
payload = {
|
||||
"chart": args.chart,
|
||||
"repo": args.repo,
|
||||
"chart_path": args.chart_path,
|
||||
"from_version": args.from_version,
|
||||
"to_version": args.to_version,
|
||||
"values_override": None,
|
||||
}
|
||||
out = helm_diff(payload)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ["skill_interface"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,252 @@
|
||||
"""helm_diff skill implementation (deterministic)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .skill_interface import HelmDiffInput, HelmDiffOutput
|
||||
|
||||
|
||||
def _run(cmd: List[str], cwd: str | None = None, timeout: int = 60) -> str:
|
||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"command failed: {' '.join(cmd)}\n{res.stderr}")
|
||||
return res.stdout
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _flatten(d: Dict[str, Any], parent_key: str = "", sep: str = ".") -> Dict[str, Any]:
|
||||
items: Dict[str, Any] = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else str(k)
|
||||
if isinstance(v, dict):
|
||||
items.update(_flatten(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
|
||||
def _diff_values(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_flat = _flatten(old)
|
||||
new_flat = _flatten(new)
|
||||
added = sorted([k for k in new_flat.keys() if k not in old_flat])
|
||||
removed = sorted([k for k in old_flat.keys() if k not in new_flat])
|
||||
changed: Dict[str, Dict[str, Any]] = {}
|
||||
type_changed: List[Dict[str, Any]] = []
|
||||
for k in old_flat.keys() & new_flat.keys():
|
||||
if old_flat[k] != new_flat[k]:
|
||||
if type(old_flat[k]) != type(new_flat[k]):
|
||||
type_changed.append({"key": k, "old_type": type(old_flat[k]).__name__, "new_type": type(new_flat[k]).__name__})
|
||||
else:
|
||||
changed[k] = {"old": old_flat[k], "new": new_flat[k]}
|
||||
return {
|
||||
"values": {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"type_changed": type_changed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _split_manifest(yaml_text: str) -> List[Dict[str, Any]]:
|
||||
docs = []
|
||||
for doc in yaml.safe_load_all(yaml_text):
|
||||
if not doc or not isinstance(doc, dict):
|
||||
continue
|
||||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
|
||||
def _resource_id(obj: Dict[str, Any], fallback_idx: int) -> str:
|
||||
kind = obj.get("kind", "Unknown")
|
||||
meta = obj.get("metadata", {}) or {}
|
||||
name = meta.get("name")
|
||||
if not name and meta.get("generateName"):
|
||||
name = f"{meta.get('generateName')}__gen__{fallback_idx}"
|
||||
ns = meta.get("namespace")
|
||||
if kind and name:
|
||||
if kind in {"Namespace", "CustomResourceDefinition"}:
|
||||
return f"{kind}/{name}"
|
||||
return f"{kind}/{name}" if not ns else f"{kind}/{name}"
|
||||
return f"{kind}/__unknown__{fallback_idx}"
|
||||
|
||||
|
||||
def _index_resources(docs: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
idx: Dict[str, Dict[str, Any]] = {}
|
||||
for i, doc in enumerate(docs):
|
||||
rid = _resource_id(doc, i)
|
||||
idx[rid] = doc
|
||||
return idx
|
||||
|
||||
|
||||
def _diff_templates(old_yaml: str, new_yaml: str) -> Dict[str, Any]:
|
||||
old_docs = _split_manifest(old_yaml)
|
||||
new_docs = _split_manifest(new_yaml)
|
||||
old_idx = _index_resources(old_docs)
|
||||
new_idx = _index_resources(new_docs)
|
||||
|
||||
changes: Dict[str, Any] = {}
|
||||
for rid, new_obj in new_idx.items():
|
||||
if rid not in old_idx:
|
||||
changes[rid] = {"added": True}
|
||||
continue
|
||||
old_obj = old_idx[rid]
|
||||
# Minimal diff: detect image/env/ports for Deployments/StatefulSets/Services
|
||||
kind = new_obj.get("kind")
|
||||
if kind in {"Deployment", "StatefulSet"}:
|
||||
def _containers(obj: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return (((obj.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
|
||||
old_cont = _containers(old_obj)
|
||||
new_cont = _containers(new_obj)
|
||||
old_images = {c.get("name"): c.get("image") for c in old_cont}
|
||||
new_images = {c.get("name"): c.get("image") for c in new_cont}
|
||||
image_changed = old_images != new_images
|
||||
env_added: List[str] = []
|
||||
env_removed: List[str] = []
|
||||
def _env_keys(cont: Dict[str, Any]) -> set:
|
||||
return {e.get("name") for e in (cont.get("env") or []) if e.get("name")}
|
||||
for name, cont in {c.get("name"): c for c in new_cont}.items():
|
||||
old_env = _env_keys({c.get("name"): c for c in old_cont}.get(name, {}) or {})
|
||||
new_env = _env_keys(cont)
|
||||
env_added += sorted(list(new_env - old_env))
|
||||
env_removed += sorted(list(old_env - new_env))
|
||||
changes[rid] = {
|
||||
"image_changed": image_changed,
|
||||
"image": {"old": old_images, "new": new_images},
|
||||
"env_added": sorted(list(set(env_added))),
|
||||
"env_removed": sorted(list(set(env_removed))),
|
||||
}
|
||||
elif kind == "Service":
|
||||
def _ports(obj: Dict[str, Any]) -> List[Tuple[Any, Any]]:
|
||||
ports = (obj.get("spec") or {}).get("ports") or []
|
||||
return [(p.get("port"), p.get("targetPort")) for p in ports]
|
||||
changes[rid] = {
|
||||
"port_changed": _ports(old_obj) != _ports(new_obj)
|
||||
}
|
||||
for rid in old_idx.keys() - new_idx.keys():
|
||||
changes[rid] = {"removed": True}
|
||||
return {"templates": changes}
|
||||
|
||||
|
||||
def _diff_chart_yaml(old_chart: Dict[str, Any], new_chart: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_deps = {d.get("name"): d for d in (old_chart.get("dependencies") or [])}
|
||||
new_deps = {d.get("name"): d for d in (new_chart.get("dependencies") or [])}
|
||||
added = sorted([k for k in new_deps.keys() if k not in old_deps])
|
||||
removed = sorted([k for k in old_deps.keys() if k not in new_deps])
|
||||
version_changed: Dict[str, Dict[str, Any]] = {}
|
||||
for k in old_deps.keys() & new_deps.keys():
|
||||
if old_deps[k].get("version") != new_deps[k].get("version"):
|
||||
version_changed[k] = {"old": old_deps[k].get("version"), "new": new_deps[k].get("version")}
|
||||
return {"dependencies": {"added": added, "removed": removed, "version_changed": version_changed}}
|
||||
|
||||
|
||||
def _resolve_chart_dir(base: Path, chart_name: str) -> Path:
|
||||
# repo pull: chart is under base/<chart>
|
||||
candidate = base / chart_name
|
||||
if (candidate / "Chart.yaml").exists():
|
||||
return candidate
|
||||
# local copy: Chart.yaml at base root
|
||||
if (base / "Chart.yaml").exists():
|
||||
return base
|
||||
# fallback: first subdir with Chart.yaml
|
||||
for p in base.iterdir():
|
||||
if p.is_dir() and (p / "Chart.yaml").exists():
|
||||
return p
|
||||
return candidate
|
||||
|
||||
|
||||
def helm_diff(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inp = HelmDiffInput(**payload)
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
chart_old = tmpdir / "chart_old"
|
||||
chart_new = tmpdir / "chart_new"
|
||||
chart_old.mkdir()
|
||||
chart_new.mkdir()
|
||||
|
||||
try:
|
||||
if inp.chart_path:
|
||||
# dip-catalog local path for from_version
|
||||
from_path = Path(inp.chart_path)
|
||||
if not from_path.exists():
|
||||
raise FileNotFoundError(f"chart_path not found: {from_path}")
|
||||
shutil.copytree(from_path, chart_old, dirs_exist_ok=True)
|
||||
|
||||
# to_version: if repo provided, pull from repo (mixed mode)
|
||||
if inp.repo:
|
||||
_run([
|
||||
"helm",
|
||||
"pull",
|
||||
f"{inp.repo}/{inp.chart}",
|
||||
"--version",
|
||||
inp.to_version,
|
||||
"--untar",
|
||||
"--untardir",
|
||||
str(chart_new),
|
||||
])
|
||||
else:
|
||||
# local-to-local mode (use sibling version directory)
|
||||
to_path = from_path.parent / inp.to_version
|
||||
shutil.copytree(to_path, chart_new, dirs_exist_ok=True)
|
||||
else:
|
||||
if not inp.repo:
|
||||
raise ValueError("repo is required when chart_path is not provided")
|
||||
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.from_version, "--untar", "--untardir", str(chart_old)])
|
||||
_run(["helm", "pull", f"{inp.repo}/{inp.chart}", "--version", inp.to_version, "--untar", "--untardir", str(chart_new)])
|
||||
except Exception as e:
|
||||
errors.append({"stage": "helm_pull", "message": str(e)})
|
||||
|
||||
chart_old_dir = _resolve_chart_dir(chart_old, inp.chart)
|
||||
chart_new_dir = _resolve_chart_dir(chart_new, inp.chart)
|
||||
|
||||
# values diff
|
||||
values_old = _load_yaml(chart_old_dir / "values.yaml")
|
||||
values_new = _load_yaml(chart_new_dir / "values.yaml")
|
||||
values_diff = _diff_values(values_old, values_new)
|
||||
|
||||
# template diff
|
||||
templates_diff: Dict[str, Any] = {"templates": {}}
|
||||
try:
|
||||
old_yaml = _run(["helm", "template", str(chart_old_dir), "--include-crds"])
|
||||
new_yaml = _run(["helm", "template", str(chart_new_dir), "--include-crds"])
|
||||
templates_diff = _diff_templates(old_yaml, new_yaml)
|
||||
except Exception as e:
|
||||
errors.append({"stage": "helm_template", "message": str(e)})
|
||||
|
||||
# CRD diff placeholder (minimal)
|
||||
crd_diff = {"crd": {}}
|
||||
|
||||
# dependencies diff
|
||||
chart_old_yaml = _load_yaml(chart_old_dir / "Chart.yaml")
|
||||
chart_new_yaml = _load_yaml(chart_new_dir / "Chart.yaml")
|
||||
dep_diff = _diff_chart_yaml(chart_old_yaml, chart_new_yaml)
|
||||
|
||||
out = HelmDiffOutput(
|
||||
chart=inp.chart,
|
||||
from_version=inp.from_version,
|
||||
to_version=inp.to_version,
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
values=values_diff.get("values", {}),
|
||||
templates=templates_diff.get("templates", {}),
|
||||
crd=crd_diff.get("crd", {}),
|
||||
dependencies=dep_diff.get("dependencies", {}),
|
||||
errors=errors,
|
||||
)
|
||||
return json.loads(out.model_dump_json())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Skill Interface v1.0 for Helm upgrade automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SKILL_INTERFACE_VERSION = "1.0"
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Common Error Schema
|
||||
# -------------------------
|
||||
class SkillError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: SkillError
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Shared Types
|
||||
# -------------------------
|
||||
Severity = Literal["critical", "high", "medium", "warning"]
|
||||
|
||||
|
||||
class BreakingReason(BaseModel):
|
||||
type: str
|
||||
resource: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------
|
||||
# helm_diff
|
||||
# -------------------------
|
||||
class HelmDiffInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
chart_path: Optional[str] = None
|
||||
from_version: str
|
||||
to_version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HelmDiffOutput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
generated_at: str
|
||||
values: Dict[str, Any]
|
||||
templates: Dict[str, Any]
|
||||
crd: Dict[str, Any]
|
||||
dependencies: Dict[str, Any]
|
||||
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# breaking_change_check
|
||||
# -------------------------
|
||||
class BreakingCheckInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
|
||||
|
||||
class BreakingCheckOutput(BaseModel):
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
reasons: List[BreakingReason] = Field(default_factory=list)
|
||||
warnings: List[BreakingReason] = Field(default_factory=list)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# generate_upgrade_doc
|
||||
# -------------------------
|
||||
class GenerateDocInput(BaseModel):
|
||||
diff_json: Dict[str, Any]
|
||||
breaking_result: Dict[str, Any]
|
||||
docs_context: Optional[Dict[str, str]] = None
|
||||
max_tokens: int = 50000
|
||||
|
||||
|
||||
class GenerateDocOutput(BaseModel):
|
||||
markdown: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# update_docs_file
|
||||
# -------------------------
|
||||
class UpdateDocsInput(BaseModel):
|
||||
repo_path: str
|
||||
docs_file: str = "docs/upgrade.md"
|
||||
version: str
|
||||
content: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class UpdateDocsOutput(BaseModel):
|
||||
success: bool
|
||||
file_path: str
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# create_pr
|
||||
# -------------------------
|
||||
class CreatePRInput(BaseModel):
|
||||
chart: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
repo_path: str
|
||||
doc_content: str
|
||||
breaking: bool
|
||||
severity: Severity
|
||||
|
||||
|
||||
class CreatePROutput(BaseModel):
|
||||
pr_url: str
|
||||
branch_name: str
|
||||
labels: List[str]
|
||||
|
||||
|
||||
# -------------------------
|
||||
# deploy_validate
|
||||
# -------------------------
|
||||
class DeployValidateInput(BaseModel):
|
||||
chart: str
|
||||
repo: Optional[str] = None
|
||||
version: str
|
||||
values_override: Optional[Dict[str, Any]] = None
|
||||
namespace: str
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
class DeployValidateOutput(BaseModel):
|
||||
success: bool
|
||||
dry_run_passed: bool
|
||||
pod_status: Dict[str, int]
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
logs: Optional[str] = None
|
||||
@@ -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
@@ -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"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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())
|
||||
Reference in New Issue
Block a user