0. Contents#
1. Overview#
배경
- 01편의 경고는 kubectl 변경이 되돌아간다는 사실만 알려 준다
- 변경을 유지하려면 원본인 Git 을 고쳐야 한다
- 채팅 요청을 매니페스트 레포의 PR 로 바꾸면 기록과 되돌리기도 Git 에 남는다
이 글의 질문
- 레포 쓰기 권한을 어떻게 안전하게 받는가
- PR 과 kubectl 중 어느 쪽으로 갈지 누가 정하는가
- AI 가 옛 대화에 끌려갈 때 무엇이 막는가
- 어느 줄을 고칠지 어떻게 찾고, 맞는지 어떻게 확인하는가
지원 범위
| 항목 | 지원 |
|---|
| 형식 | plain YAML(Directory), Kustomize |
| 기존 리소스 요청 | 레플리카 수, 이미지 태그, CPU·메모리, 환경변수 |
| 새 리소스 | 앱 폴더에 새 파일 추가 |
| 미지원 | Helm, ApplicationSet, app-of-apps, 리소스 삭제 |
2. Repository Connection — 레포 연결#
2.1 사용자가 하는 일#
사용자는 레포 링크를 넣고 GitHub 에서 권한을 한 번 승인한다. 어느 앱이 그 레포를 쓰는지, 경로와 형식은 Application 에서 읽으므로 따로 입력하지 않는다.

클러스터 설정 — 앱이 쓰는 레포를 먼저 제안하고, 링크를 직접 넣어 연결할 수도 있다. 픽스처 화면이며 이름은 예시로 바꿨다
- 링크를 넣지 않아도 된다. 클러스터의 Application 이 쓰는 레포를 모아 “연결할까요?“로 먼저 제안한다
https://github.com/org/repo.git 과 git@github.com:org/repo.git 은 같은 레포로 맞춘다- 회사 조직의 레포는 관리자 승인이 필요할 수 있어서, “승인 대기” 상태를 따로 보여 준다
- Kukie 는 ArgoCD 설정을 건드리지 않는다. ArgoCD 가 레포를 읽는 자격증명과 Kukie 가 쓰는 권한은 별개다
2.2 GitHub App 을 고른 이유#
| 항목 | 개인 토큰(PAT) | GitHub App |
|---|
| 권한 범위 | 만든 사람의 권한에 묶임 | 고른 레포와 고른 권한만 |
| 수명 | 길게 보관 | 짧은 설치 토큰을 그때마다 발급 |
| 사람이 나갈 때 | 토큰이 함께 깨질 수 있음 | 설치는 조직에 남음 |
권한은 Contents·Pull requests 쓰기와 Metadata 읽기만 받는다. App 개인키는 서버에만 두고, 에이전트에는 주지 않는다.
2.3 설치 콜백 검증#
GitHub 는 설치가 끝나면 브라우저를 서버의 Setup URL 로 돌려보낸다. 이 주소는 누구나 부를 수 있고, 주소에 실린 installation_id 도 고칠 수 있다. 남의 설치 번호를 넣어 자기 팀에 묶는 공격을 막아야 한다.
설치 콜백 주소는 누구나 부를 수 있다 — installation_id 를 믿지 않고 네 번 대조한다
- state 를 확인하기 전에는 GitHub 를 부르지 않는다. 누구나 부를 수 있는 주소가 외부 호출 통로가 되면 안 되기 때문이다
- 같은 설치를 두 팀이 동시에 묶으면 DB 제약에서 걸려
InstallationTakenException 이 된다 - GitHub 호출 중에 DB 연결을 쥐지 않도록 이 처리에는 트랜잭션을 걸지 않는다
- state 없이 오는
update 요청은 누가 보냈는지 알 수 없어 아무것도 바꾸지 않는다. 레포 목록 갱신은 팀 관리자 API 로만 한다
2.4 설치 토큰#
- 에이전트는 서버의 내부 API 로 그 레포 범위의 설치 토큰을 받는다. 수명은 1시간이다
- 서버는 토큰을 주기 전에 그 클러스터에 레포가 연결돼 있는지 확인한다. 연결을 끊으면 발급이 거부된다
- 토큰은 Plan, 카드, 로그에 남기지 않는다.
RepoToken 은 repr=False 로 찍히지 않게 했다 - main 에 직접 push 하지 않는다. 항상 브랜치와 PR 을 거친다
3. Path Decision — PR 과 kubectl 의 판단#
3.1 설정을 없앤 이유#
- 처음 설계에는 클러스터마다 “변경 방식” 설정이 있었다
- 대화 도중 설정이 바뀌면 진행 중인 대화를 어떻게 처리할지 정해야 했다
- 설정을 없애고 변경 직전에 리소스마다 판단하면 이 문제가 사라진다
- 레포 연결이 끊기거나 새로 생겨도 다음 판단부터 달라질 뿐이다
3.2 판단 코드#
모르면 kubectl 쪽으로 간다 — 판단 실패가 변경 자체를 막지 않게 하기 위해서다
- 여기서 조회가 실패하면
known=False 로 kubectl 경로를 돌려준다. 어떤 실패도 위로 올리지 않는다 targetRevision 이 브랜치가 아니면 PR 경로가 없다. 이 확인은 GitHub 에 물어야 해서 PR 을 만들 때 한다- 전체 조회는 10초 예산 안에서 끝낸다. 호출마다 남은 시간을 넘기고, 스레드 슬롯을 기다리는 시간도 예산에 포함한다
3.3 툴과 프롬프트#
- 변경 툴
propose_git_change 는 Shared 대화에서만 열린다. Private 대화는 읽기 툴만 있다 - 시스템 프롬프트에 “이 클러스터는 GitOps” 같은 문구를 넣지 않는다
- AI 는 툴 결과로 어느 경로인지 안다. 읽기 툴 결과에 “ArgoCD 앱 X 가 관리하는 리소스다"가 붙고, 틀린 툴을 부르면 훅이 돌려보낸다
4. Code over Prompt — LLM 경로 통제#
4.1 대화 기록의 옛 경로 문제#
- 모드 기능이 있던 시절, AI 가 대화 기록의 옛 답변을 보고 옛 모드처럼 행동한 적이 있다
- 레포 연결도 같은 문제가 생긴다. 기록에 “PR 로 올릴게요"가 남아 있는데 그 뒤 연결이 끊길 수 있다
- 프롬프트에 “최신 상태를 따르라"고 적어도 지켜진다는 보장이 없다
- 그래서 세 겹으로 막는다: 코드의 경로 강제, 바뀐 때만 넣는 메모, 툴 결과의 현재 정보
AI 가 틀린 툴을 불러도 실행되지 않는다 — 이유가 다음 요청의 재료로 돌아간다
처음에는 항상 ModelRetry 만 던졌다. 이미 재시도를 쓴 툴이 또 던지면 한도를 넘어 run 전체가 실패해서, 그다음부터는 같은 문구를 ToolFailed 로 돌려주도록 고쳤다.
4.3 숨은 칸과 메모#
경로 강제만으로도 잘못된 실행은 막을 수 있다. 다만 AI 는 왜 거절됐는지 모른 채 옛 말투로 답할 수 있다. 그래서 연결이 바뀐 순간을 AI 에게 한 번 알린다.
| 구분 | 숨은 칸 | 메모 |
|---|
| 모습 | 값 하나 ({"repos": [...]}) | 문장 한 줄 |
| 읽는 쪽 | 코드만 | AI 만 (채팅 화면에 안 나옴) |
| 저장 위치 | 서버의 대화 정보 | 서버의 agent_messages |
| 바뀌는 때 | 상태가 바뀔 때마다 덮어씀 | 바뀐 순간 한 번 쓰고 그대로 남음 |
AI 를 부를 때마다 돌지만, 메모는 상태가 바뀐 순간에 한 번만 들어간다
숨은 칸은 코드만 읽는 스위치이고, 메모는 AI 만 읽는 쪽지다
4.4 메모 위치#
앞부분을 그대로 두면 캐시가 살아 있고, 맨 끝은 이번 run 의 기록으로 저장된다
- 툴을 연결 상태에 따라 숨기는 방법도 있지만, 툴 목록이 바뀌면 캐시가 깨진다. 그래서 툴은 늘 그대로 두고
ModelRetry 로 막는다 - 메모는
MARK 로 시작한다. 화면 블록이 “이번 턴"을 찾을 때 메모를 새 질문으로 보지 않게 하기 위해서다
4.5 저장 순서#
처음 설계는 메모를 넣을 때 숨은 칸도 바로 쓰는 것이었다. 리뷰에서 이 순서의 문제가 나왔다. 칸을 먼저 쓴 뒤 기록 저장이 실패하면, 칸은 “알렸다"로 앞서 가고 메모는 사라진다. 다음 run 은 칸과 값이 같다고 보고 메모를 다시 넣지 않는다.
메모가 든 기록과 숨은 칸이 어긋나면, 다음 run 이 메모를 영영 넣지 않는다
- 메모 처리기는 값을 run 별로 맡겨 두기만 한다
- 기록이 저장된 뒤에
settle 이 칸을 쓴다 - 메모 처리 자체는 어떤 실패도 위로 올리지 않는다. 메모는 경로 강제를 돕는 장치이고, 실제로 막는 쪽은 코드다
5. Finding the Edit — 고칠 곳 찾기#
5.1 AI 와 코드의 역할#
처음 설계 문서는 “AI 가 고르고 코드가 확인한다"였다. 실제 구현에서 plain 과 Kustomize 는 코드가 고른다.
| 단계 | 담당 |
|---|
| 어떤 리소스의 어떤 값을 바꿀지 | AI (요청을 해석해 툴 인자로 전달) |
| 어느 파일의 어느 줄을 고칠지 | 코드 (plan_change) |
| 고친 결과가 맞는지 | 코드 (verify) |
형식별로 위치가 정해져 있어서 AI 가 추측할 이유가 없다. 형식도 추측하지 않고 Application 의 status.sourceType 을 읽는다.
5.2 plain#
- Application 의 path 아래 YAML 을 모두 읽는다
- apiVersion · kind · name · namespace 가 같은 문서를 찾는다
- 그 문서가 정확히 하나일 때만 고친다. 없거나 여러 개면 이유를 돌려준다
- 파일을 다시 쓸 때 주석과 형식을 유지하도록 ruamel 로 고친다. 모양이 조금 바뀔 수밖에 없는 파일은 경고를 붙인다
5.3 Kustomize#
base 를 고치면 다른 환경도 바뀌므로, Application 이 가리키는 폴더만 고친다
패치 파일에는 target 을 함께 적는다. 패치 안의 이름이나 namespace 와 상관없이 그 리소스에 걸리게 하기 위해서다.
5.4 고치지 않는 경우#
고치기 전 단계(prepare)에서 다음 경우를 걸러낸다.
| 경우 | 동작 |
|---|
| HPA 가 있는 리소스의 레플리카 | HPA 가 되돌리므로 거절하고, HPA 최소·최대값을 안내 |
| git 에 원본이 없는 리소스 | 오퍼레이터가 만든 리소스로 보고 거절 |
| 여러 namespace 에 같은 이름 | namespace 를 다시 물음 |
| 이미 그 값 | 바꿀 것이 없다고 답함 |
| 비밀처럼 보이는 환경변수 이름 | 평문으로 git 에 남기지 않음 (PASSWORD, TOKEN 등) |
RespectIgnoreDifferences 로 sync 때 빠지는 칸 | git 을 고쳐도 반영되지 않으므로 거절 |
ignoreDifferences 로 차이만 안 보는 칸 | 경고를 붙임. sync 하면 반영된다 |
6. Render Comparison — 렌더 비교#
6.1 비교 절차#
레포를 임시 폴더에 복사해 고친 파일을 쓰고, ArgoCD 처럼 렌더링한 뒤 비교한다. 레포 폴더 자체는 읽기만 한다.
앞의 넷은 큰 틀을 거르고, 마지막 하나가 칸 단위로 대조한다
6.2 기대값 대조#
처음 구현은 resources·env 요청에서 요청하지 않은 키나 환경변수가 바뀌거나 지워져도 통과했다. 리뷰에서 이 문제가 나와, 지금 렌더 결과에 요청만 직접 적용한 값과 칸마다 같은지를 보도록 바꿨다.
예시 — replicas 만 요청했는데 selector 까지 바뀌면 3번째와 2번째가 달라 거절된다
요청 종류별 기대값은 코드에서 직접 계산한다.
def expected_after(ctx: Context) -> dict:
obj = copy.deepcopy(ctx.obj)
change = ctx.change
if isinstance(change, Replicas):
_child(obj, "spec")["replicas"] = change.count
return obj
target = container(obj, ctx.target.kind, ctx.container or "")
if isinstance(change, ImageTag):
target["image"] = with_tag(str(target.get("image", "")), change.tag)
...
6.3 다른 환경 확인#
대상 앱 밖 폴더도 전후를 렌더링한다 — 한쪽 환경의 요청이 다른 환경을 건드리지 못하게 한다
6.4 ArgoCD 와 같은 조건#
- 렌더 결과가 ArgoCD 와 다르면 비교가 틀릴 수 있다
- 에이전트 이미지의 kustomize 를 ArgoCD 가 쓰는 버전과 같은 v5.8.1 로 고정하고, 해시를 대조한다
- 고객 ArgoCD 의 버전은 다를 수 있다. 이 차이는 아직 남아 있다
6.5 사용자에게 보이는 결과#

승인 카드의 "클러스터에서 실제로 바뀔 것" — 렌더 비교를 통과한 PR 만 카드로 나온다. 픽스처 화면이며 이름은 예시로 바꿨다
- git diff 와 함께 “클러스터에서 실제로 바뀔 것"을 보여 준다. Kustomize 처럼 파일과 결과가 다른 형식에서 특히 필요하다
- Secret 이 바뀌는 diff 는 어떤 이유로도 만들지 않는다
- 렌더 비교가 실패하면 PR 을 열지 않는다. 시험 레포 PR #2 는
apps/plain/deployment.yaml 의 한 줄만 바뀌었다
--- a/apps/plain/deployment.yaml
+++ b/apps/plain/deployment.yaml
- replicas: 3
+ replicas: 4
7. New Resources — 새 리소스 추가#
7.1 시험에서 드러난 빈자리#
- 시험 클러스터에서 “caddy Deployment·Service 새로 만들어줘"가 거절됐다
- 경로 판단은 클러스터에 있는 리소스의 표시를 보는데, 새 리소스는 아직 표시가 없다
- ArgoCD 는 보는 폴더에 새 파일이 생기면 sync 때 만든다. 막힌 곳은 Kukie 의 입력과 판단뿐이었다
- 새 툴을 만들지 않고
Change 에 new_resource 를 더했다. 승인과 머지 흐름은 그대로 쓴다
7.2 기본 허용#
전체 권한 클러스터에서는 승인 후 kubectl 로도 만들 수 있다. PR 경로만 엄격하면 사용자는 kubectl 이나 직접 PR 로 빠진다. 그래서 종류 허용 목록을 두지 않고, 막는 것만 적었다.
막는 것만 적고 나머지는 허용한다 — 너무 막으면 사용자가 직접 PR 을 올리는 쪽을 고른다

새 리소스 카드 — 경고마다 위치, 일어날 수 있는 일, 대안을 보여 주고 승인은 막지 않는다. 픽스처 화면이며 이름은 예시로 바꿨다
7.3 형식별 추가 방식#
| 형식 | 만드는 것 |
|---|
| plain | 앱 폴더에 새 YAML 파일. directory.recurse 를 따른다 |
| Kustomize | 그 폴더에 새 파일 + kustomization.yaml 의 resources: 한 줄 |
Plan 의 payload 에 경고 목록을 넣어 “이 경고를 보고 승인했다"를 남긴다. 저장소를 따로 만들지 않고 기존 Plan 을 쓴다.
8. Discussion#
8.1 렌더 비교 다음 단계#
- 렌더 비교를 통과하면 새 브랜치
kukie/<plan code> 에 커밋 하나를 만들고 PR 을 연다 - 같은 파일을 고치는 Kukie PR 이 이미 열려 있으면 기다린다. 레포·파일 단위 키로 충돌을 막는다
- 여기까지는 머지가 일어나지 않는다. 승인 전에는 클러스터에 아무 변화가 없다
8.2 확인 범위#
| 내용 | 확인 방법 |
|---|
| 값 수정의 PR 생성 | 시험 레포 실제 PR 3건 (레플리카) |
| 설치 콜백 검증 | 단위 테스트 |
| 경로 판단, 메모, 저장 순서 | 단위 테스트 |
| plain 수정과 렌더 비교 | 단위 테스트, 시험 레포 실제 PR |
| Kustomize 수정 | 단위 테스트. 실제 클러스터 PR 기록은 아직 없다 |
| 이미지 태그, resources, 환경변수 | 단위 테스트. 실제 클러스터 PR 기록은 아직 없다 |
| 새 리소스, 경고, Secret 차단 | 테스트와 픽스처. 실제 클러스터 시험은 아직 하지 않았다 |
8.3 남은 한계#
Application 종류를 새 리소스로 넣으면 지금은 막지도 경고하지도 않는다. 새 ArgoCD 앱이 생길 수 있어 경고에 넣을지 정해야 한다- 원격 base 를 쓰는 Kustomize 앱은 렌더가 막혀 PR 경로로 고칠 수 없다
- 목록 밖의 커스텀 리소스는 클러스터 범위인지 판단하지 않는다
핵심 메시지#
- 역할 구분: AI 는 요청 해석, 코드는 위치와 검증
- 위치가 정해진 형식에서는 AI 가 추측하지 않는다
- 통제 위치: 프롬프트가 아니라 실행 직전
- 틀린 툴을 불러도 훅이 막으므로, AI 가 헷갈려도 잘못 실행되지 않는다
- 메모 위치: 캐시와 저장을 함께 고려
- 맨 끝에 넣고, 칸은 기록이 저장된 뒤에 쓴다
- 검증 기준: 허용 칸이 아니라 기대값
- 지금 렌더에 요청만 적용한 결과와 같아야 PR 이 열린다
9. References#
설치 후 돌아오는 Setup URL 과 state 전달. 2.3 의 근거다.
저장소 범위로 좁힌 설치 토큰 발급. 2.4 의 근거다.
ModelRetry 와 ProcessHistory 의 동작. 4.2 와 4.3 의 근거다.
Application 이 형식을 정하는 방식과 Kustomize 의 replicas · images · patches. 5장의 근거다.
ignoreDifferences 와 RespectIgnoreDifferences 의 차이. 5.4 표의 근거다.