0. Contents


1. Overview

사용자web 레플리카4개로Kukie 에이전트① 경로 판단PR 또는 kubectl② 고칠 곳 찾기파일과 칸 고르기③ 렌더 비교요청한 칸만 바뀌나④ PR 열기브랜치 · 커밋 · PRAI 의 몫: 어떤 리소스의 어떤 값인지 정해 propose_git_change 를 부른다나머지 네 단계는 코드다. plain · Kustomize 는 고칠 곳도 코드가 고른다클러스터리소스 · Applicationkubectl 조회Kukie 서버연결 목록1시간 설치 토큰GitHub레포 받기브랜치 · 커밋 · PR렌더 비교를 통과해야 PR 이 열린다 — 실패하면 PR 없이 이유를 돌려주고 kubectl 경로를 안내한다레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기
AI 는 무엇을 바꿀지 말하고, 어느 줄을 고칠지와 맞는지 확인은 코드가 맡는다

배경

  • 01편의 경고는 kubectl 변경이 되돌아간다는 사실만 알려 준다
  • 변경을 유지하려면 원본인 Git 을 고쳐야 한다
  • 채팅 요청을 매니페스트 레포의 PR 로 바꾸면 기록과 되돌리기도 Git 에 남는다

이 글의 질문

  • 레포 쓰기 권한을 어떻게 안전하게 받는가
  • PR 과 kubectl 중 어느 쪽으로 갈지 누가 정하는가
  • AI 가 옛 대화에 끌려갈 때 무엇이 막는가
  • 어느 줄을 고칠지 어떻게 찾고, 맞는지 어떻게 확인하는가

지원 범위

항목지원
형식plain YAML(Directory), Kustomize
기존 리소스 요청레플리카 수, 이미지 태그, CPU·메모리, 환경변수
새 리소스앱 폴더에 새 파일 추가
미지원Helm, ApplicationSet, app-of-apps, 리소스 삭제

2. Repository Connection — 레포 연결

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

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 도 고칠 수 있다. 남의 설치 번호를 넣어 자기 팀에 묶는 공격을 막아야 한다.

HandleGitHubSetupService.kt (요약)val state = consumeState(command.state)1val existing = repository.findByInstallationId(installationId)if (existing != null && existing.teamId != state.teamId)2throw InstallationTakenException()if (existing == null && properties.verifiesInstaller)3requireInstallerAccess(installationId, command)val info = gateway.findInstallation(installationId)4?: throw InstallationNotFoundException()if (existing == null && !properties.verifiesInstaller)5requireCreatedAfter(state, info, command)1서명된 1회용 stateGitHub 를 부르기 전에 확인한다2다른 팀의 설치 가로채기이미 다른 팀에 묶인 설치는 거부한다3OAuth 를 켠 경우사용자 토큰으로 그 설치가 보이는지 본다4설치 존재 확인우리 앱의 설치가 맞는지 GitHub 에 묻는다5OAuth 를 끈 경우state 발급 뒤 새로 생긴 설치만 받는다
설치 콜백 주소는 누구나 부를 수 있다 — 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 의 판단

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

3.1 설정을 없앤 이유

  • 처음 설계에는 클러스터마다 “변경 방식” 설정이 있었다
  • 대화 도중 설정이 바뀌면 진행 중인 대화를 어떻게 처리할지 정해야 했다
  • 설정을 없애고 변경 직전에 리소스마다 판단하면 이 문제가 사라진다
  • 레포 연결이 끊기거나 새로 생겨도 다음 판단부터 달라질 뿐이다

3.2 판단 코드

gitops/route.py (요약)if resource is None:1return Route("kubectl", f"{label} 가 클러스터에 없어요.")if tracking is None:2return Route("kubectl", f"{label} 는 ArgoCD 가 관리하지 않아요.")if item is None:3return Route("kubectl", ..., managed=True)if repository is None:4return Route("kubectl", "소스가 GitHub 레포가 아니라 …", **common)if repository not in (connected or set()):5return Route("kubectl", "레포를 연결하면 PR 로 바꿀 수 있어요.", **common)if source_type not in SUPPORTED_SOURCES:6return Route("kubectl", "Helm 앱이라 아직 PR 로 못 바꿔요.", **common)if isinstance(change, Other):7return Route("kubectl", "PR 로는 레플리카 수 · 이미지 …", **common)return Route("pr", "… 변경은 PR 로", **common)81·2관리 리소스 아님경고 없는 kubectl3앱을 찾지 못함경고가 붙는 kubectl4·5레포 조건 미달GitHub 아님, 연결 안 됨연결하면 PR 이 된다고 안내6·7지원 범위 밖Helm · 지원하지 않는 요청8PR 경로모든 조건을 통과했을 때만ArgoCD API 토큰 없이 kubectl 조회와 Kukie 서버의 연결 목록만으로 판단한다새 리소스는 클러스터에 표시가 없으므로, 요청이 고른 앱으로 판단한다
모르면 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 경로 통제

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

4.1 대화 기록의 옛 경로 문제

  • 모드 기능이 있던 시절, AI 가 대화 기록의 옛 답변을 보고 옛 모드처럼 행동한 적이 있다
  • 레포 연결도 같은 문제가 생긴다. 기록에 “PR 로 올릴게요"가 남아 있는데 그 뒤 연결이 끊길 수 있다
  • 프롬프트에 “최신 상태를 따르라"고 적어도 지켜진다는 보장이 없다
  • 그래서 세 겹으로 막는다: 코드의 경로 강제, 바뀐 때만 넣는 메모, 툴 결과의 현재 정보

4.2 ModelRetry 와 ToolFailed

guardrail/gitops_hook.py (줄 나눔)def _again(ctx, message: str) -> Exception:return ToolFailed(message) if getattr(ctx, "retry", 0) >= 1 \1else ModelRetry(message)2def _kubectl_retry(ctx, reason: str) -> Exception:return _again(ctx, (3f"이 리소스는 지금 kubectl 길이다. 이유: {reason} ""propose_git_change 대신 kubectl 변경 툴로 다시 하라 — ""ArgoCD 관리 리소스면 승인 카드에 경고가 같이 뜬다."))1재시도를 이미 썼으면: ToolFailed또 ModelRetry 면 한도를 넘어 run 이 실패한다같은 문구를 보여 주되 한도를 안 쓴다2처음 틀렸으면: ModelRetry이유를 보고 모델이 다른 툴을 고른다틀린 툴은 실행되지 않는다3문구의 내용경로와 이유, 다음에 할 일을 적는다프롬프트로 부탁하지 않는다 — 훅이 실행 직전에 경로를 다시 판단하고, 틀리면 실행 자체를 막는다
AI 가 틀린 툴을 불러도 실행되지 않는다 — 이유가 다음 요청의 재료로 돌아간다

처음에는 항상 ModelRetry 만 던졌다. 이미 재시도를 쓴 툴이 또 던지면 한도를 넘어 run 전체가 실패해서, 그다음부터는 같은 문구를 ToolFailed 로 돌려주도록 고쳤다.

4.3 숨은 칸과 메모

경로 강제만으로도 잘못된 실행은 막을 수 있다. 다만 AI 는 왜 거절됐는지 모른 채 옛 말투로 답할 수 있다. 그래서 연결이 바뀐 순간을 AI 에게 한 번 알린다.

구분숨은 칸메모
모습값 하나 ({"repos": [...]})문장 한 줄
읽는 쪽코드만AI 만 (채팅 화면에 안 나옴)
저장 위치서버의 대화 정보서버의 agent_messages
바뀌는 때상태가 바뀔 때마다 덮어씀바뀐 순간 한 번 쓰고 그대로 남음
gitops/memo.py (요약)now = sorted({repo.repository for repo in await manifest_repos(cluster)})1seen = await server_api.repo_link_state(conversation)state = {"repos": now}if noted == state:2return messages_pending[deps.run_id] = state3before = noted.get("repos") if isinstance(noted, dict) else Noneif not isinstance(before, list) or set(before) == set(now):4return messagesmemo = UserPromptPart(content=memo_text(before, now))5return [*messages[:-1], replace(last, parts=[memo, *last.parts])]51지금 연결 상태서버의 연결 목록을 읽는다2달라지지 않음아무것도 넣지 않는다3값을 맡겨 둠칸은 아직 쓰지 않는다4처음 보는 대화알릴 변화가 없다5메모 한 줄마지막 요청의 맨 앞에 넣는다
AI 를 부를 때마다 돌지만, 메모는 상태가 바뀐 순간에 한 번만 들어간다
턴 1지금 상태연결됨숨은 칸(턴 끝)연결됨메모없음처음 값을 칸에 저장턴 2지금 상태끊김숨은 칸(턴 끝)끊김메모한 줄 넣음칸과 달라 메모 추가턴 3지금 상태끊김숨은 칸(턴 끝)끊김메모없음칸과 같아 그대로턴 2 메모: 레포 X 연결이 끊겼다 — … 앞의 답변은 그 전 기준이다. 길은 툴이 지금 상태로 다시 판단한다메모는 대화 기록에 남는다 — 그래서 끊겨 있는 동안 매번 넣으면 같은 줄이 쌓인다
숨은 칸은 코드만 읽는 스위치이고, 메모는 AI 만 읽는 쪽지다

4.4 메모 위치

instructions고정툴 목록연결과 무관지난 대화 기록저장된 agent_messages마지막 요청메모 + 새 질문 또는 툴 결과프롬프트 캐시가 재사용하는 앞부분 — 바꾸면 캐시가 깨진다new_messages() 에 잡히는 부분연결 상태를 instructions 에 넣는 경우연결이 바뀔 때마다 앞부분이 달라져 캐시 전체가 깨진다메모를 기록 중간에 넣는 경우옛 메시지로 취급돼 저장에서 빠지고, 그 턴에만 보일 수 있다메모는 SystemPromptPart 가 아니라 UserPromptPart 다 — system 조각이 시스템 프롬프트로 모이면 캐시가 깨질 수 있다
앞부분을 그대로 두면 캐시가 살아 있고, 맨 끝은 이번 run 의 기록으로 저장된다
  • 툴을 연결 상태에 따라 숨기는 방법도 있지만, 툴 목록이 바뀌면 캐시가 깨진다. 그래서 툴은 늘 그대로 두고 ModelRetry 로 막는다
  • 메모는 MARK 로 시작한다. 화면 블록이 “이번 턴"을 찾을 때 메모를 새 질문으로 보지 않게 하기 위해서다

4.5 저장 순서

처음 설계는 메모를 넣을 때 숨은 칸도 바로 쓰는 것이었다. 리뷰에서 이 순서의 문제가 나왔다. 칸을 먼저 쓴 뒤 기록 저장이 실패하면, 칸은 “알렸다"로 앞서 가고 메모는 사라진다. 다음 run 은 칸과 값이 같다고 보고 메모를 다시 넣지 않는다.

gitops/memo.pyasync def settle(deps: Deps, *, saved: bool) -> None:state = _pending.pop(deps.run_id, None)1if not saved or state is None or deps.conversation_id is None:2returntry:3await server_api.update_repo_link_state(conversation, state)except Exception:4_unsaved[deps.conversation_id] = statereturn_unsaved.pop(deps.conversation_id, None)1맡겨 둔 값처리기가 넣어 둔 상태2기록 저장 실패칸을 안 쓴다. 다음에 다시 비교3기록 저장 성공이제 칸을 쓴다4칸 쓰기 실패메모리에 두고 그 값으로 비교
메모가 든 기록과 숨은 칸이 어긋나면, 다음 run 이 메모를 영영 넣지 않는다
  • 메모 처리기는 값을 run 별로 맡겨 두기만 한다
  • 기록이 저장된 뒤에 settle 이 칸을 쓴다
  • 메모 처리 자체는 어떤 실패도 위로 올리지 않는다. 메모는 경로 강제를 돕는 장치이고, 실제로 막는 쪽은 코드다

5. Finding the Edit — 고칠 곳 찾기

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

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

apps/kustomize/overlays/prod/kustomization.yamlapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources:- ../../basereplicas:1- name: apicount: 21images:2- name: public.ecr.aws/docker/library/nginxnewTag: 1.29-alpine2patches:3- path: env.yaml1레플리카replicas: 칸의 count 를 고친다. 없으면 줄을 더한다2이미지 태그images: 칸의 newTag 를 고친다digest 가 있으면 뗀다 — 태그보다 먼저 쓰이기 때문이다3resources · 환경변수폴더에 kukie-<kind>-<name>-<요청>.yaml 패치를 만들고patches: 에 path 와 target 을 더한다같은 요청이 다시 오면 그 파일을 고친다plain 은 apiVersion · kind · name · namespace 가 같은 문서를 찾아 그 문서만 고친다같은 문서가 둘 이상이거나 없으면 고치지 않고 이유를 돌려준다
base 를 고치면 다른 환경도 바뀌므로, Application 이 가리키는 폴더만 고친다

패치 파일에는 target 을 함께 적는다. 패치 안의 이름이나 namespace 와 상관없이 그 리소스에 걸리게 하기 위해서다.

5.4 고치지 않는 경우

고치기 전 단계(prepare)에서 다음 경우를 걸러낸다.

경우동작
HPA 가 있는 리소스의 레플리카HPA 가 되돌리므로 거절하고, HPA 최소·최대값을 안내
git 에 원본이 없는 리소스오퍼레이터가 만든 리소스로 보고 거절
여러 namespace 에 같은 이름namespace 를 다시 물음
이미 그 값바꿀 것이 없다고 답함
비밀처럼 보이는 환경변수 이름평문으로 git 에 남기지 않음 (PASSWORD, TOKEN 등)
RespectIgnoreDifferences 로 sync 때 빠지는 칸git 을 고쳐도 반영되지 않으므로 거절
ignoreDifferences 로 차이만 안 보는 칸경고를 붙임. sync 하면 반영된다

6. Render Comparison — 렌더 비교

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

6.1 비교 절차

레포를 임시 폴더에 복사해 고친 파일을 쓰고, ArgoCD 처럼 렌더링한 뒤 비교한다. 레포 폴더 자체는 읽기만 한다.

manifests/change.py _compare_app (요약)changed = [k for k in sorted(set(before) | set(after))if before.get(k) != after.get(k)]if any(k[1] == "Secret" for k in changed):1return _secret()others = [k for k in changed if k != ctx.key]2if others:2return Refused(code="render_mismatch", ...)if ctx.key not in after:3return Refused(code="render_mismatch", ...)if not compare.diff_paths(ctx.obj, after[ctx.key]):4return Refused(code="render_mismatch", ...)expected = _normalized(expected_after(ctx), kind)5got = _normalized(after[ctx.key], kind)5stray = compare.diff_paths(expected, got)5if stray:return Refused(code="render_mismatch", ...)1Secret 변경이유와 상관없이 diff 를 만들지 않는다2다른 리소스 변경요청한 리소스 외에 바뀌면 거절한다3리소스가 사라짐고친 뒤 렌더에 없으면 거절한다4아무것도 안 바뀜고친 곳이 이 리소스가 아니라는 뜻이다5기대값과 칸별 대조요청만 직접 적용한 결과와 같아야 한다
앞의 넷은 큰 틀을 거르고, 마지막 하나가 칸 단위로 대조한다

6.2 기대값 대조

처음 구현은 resources·env 요청에서 요청하지 않은 키나 환경변수가 바뀌거나 지워져도 통과했다. 리뷰에서 이 문제가 나와, 지금 렌더 결과에 요청만 직접 적용한 값과 칸마다 같은지를 보도록 바꿨다.

① 지금 렌더spec:replicas: 2template:spec:containers:- name: apiimage: nginx:1.29② 지금 + 요청 (기대값)spec:replicas: 3template:spec:containers:- name: apiimage: nginx:1.29③ 고친 뒤 렌더 (거절 예)spec:replicas: 3template:spec:containers:- name: apiimage: nginx:1.30diff_paths(②, ③) 결과spec.template.spec.containers[api].image거절 문구: 고친 뒤 결과가 요청과 달라요 — 요청대로면 nginx:1.29, 고친 뒤 nginx:1.30containers 와 env 는 순서가 아니라 name 으로 맞춘다 — 순서만 바뀐 것을 변경으로 잘못 읽지 않기 위해서다Kustomize 가 붙이는 값까지 포함한 렌더 결과를 비교하므로, 파일 diff 만 볼 때 놓치는 변경도 잡는다
예시 — 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 다른 환경 확인

base/deployment.yaml두 환경이 함께 쓴다overlays/dev이번 요청의 Application path여기만 고친다overlays/prod고친 뒤 렌더가 달라지면render_mismatch 로 거절other_environments() 가 레포의 다른 kustomization 폴더를 고치기 전·후로 렌더링해 비교한다시간 초과는 "달라졌다"로 읽지 않고 바쁨으로 돌려준다 — 한쪽만 늦으면 잘못 판단할 수 있기 때문이다
대상 앱 밖 폴더도 전후를 렌더링한다 — 한쪽 환경의 요청이 다른 환경을 건드리지 못하게 한다

6.4 ArgoCD 와 같은 조건

  • 렌더 결과가 ArgoCD 와 다르면 비교가 틀릴 수 있다
  • 에이전트 이미지의 kustomize 를 ArgoCD 가 쓰는 버전과 같은 v5.8.1 로 고정하고, 해시를 대조한다
  • 고객 ArgoCD 의 버전은 다를 수 있다. 이 차이는 아직 남아 있다

6.5 사용자에게 보이는 결과

PR 머지 승인 카드의 렌더 비교 결과
승인 카드의 "클러스터에서 실제로 바뀔 것" — 렌더 비교를 통과한 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 — 새 리소스 추가

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기

7.1 시험에서 드러난 빈자리

  • 시험 클러스터에서 “caddy Deployment·Service 새로 만들어줘"가 거절됐다
  • 경로 판단은 클러스터에 있는 리소스의 표시를 보는데, 새 리소스는 아직 표시가 없다
  • ArgoCD 는 보는 폴더에 새 파일이 생기면 sync 때 만든다. 막힌 곳은 Kukie 의 입력과 판단뿐이었다
  • 새 툴을 만들지 않고 Change 에 new_resource 를 더했다. 승인과 머지 흐름은 그대로 쓴다

7.2 기본 허용

전체 권한 클러스터에서는 승인 후 kubectl 로도 만들 수 있다. PR 경로만 엄격하면 사용자는 kubectl 이나 직접 PR 로 빠진다. 그래서 종류 허용 목록을 두지 않고, 막는 것만 적었다.

manifests/new_resource.pydef _blocked_kind(docs: list[dict]) -> Refused | None:for doc in docs:kind = doc["kind"]if _same_kind(kind, "Secret") \1and (doc.get("data") or doc.get("stringData")):1return Refused(code="secret", retry=True, ...)if _same_kind(kind, "Kustomization") \2or _same_kind(kind, "Component") \2or group == "kustomize.config.k8s.io":2return Refused(code="kustomize_config", retry=True, ...)return None1평문 Secret 차단값이 git 기록에 영원히 남는다ExternalSecret 등 대안을 함께 알려 준다2외부를 끌어오는 설정 차단레포 밖 내용을 가져올 수 있다경고 후 허용클러스터 범위 리소스, 다른 namespaceprivileged, hostPath, hostNetwork:latest, 태그 없는 이미지경고는 렌더 결과 기준으로 판단한다그 밖의 차단: 앱 폴더 밖 쓰기, 기존 파일 덮어쓰기, 렌더 실패, 기존 리소스 변경, 요청하지 않은 리소스 생성종류 허용 목록은 두지 않는다. 경고는 위치 · 일어날 수 있는 일 · 대안의 세 칸으로 보여 준다
막는 것만 적고 나머지는 허용한다 — 너무 막으면 사용자가 직접 PR 을 올리는 쪽을 고른다
새 리소스 PR 승인 카드의 경고
새 리소스 카드 — 경고마다 위치, 일어날 수 있는 일, 대안을 보여 주고 승인은 막지 않는다. 픽스처 화면이며 이름은 예시로 바꿨다

7.3 형식별 추가 방식

형식만드는 것
plain앱 폴더에 새 YAML 파일. directory.recurse 를 따른다
Kustomize그 폴더에 새 파일 + kustomization.yaml 의 resources: 한 줄

Plan 의 payload 에 경고 목록을 넣어 “이 경고를 보고 승인했다"를 남긴다. 저장소를 따로 만들지 않고 기존 Plan 을 쓴다.

8. Discussion

8.1 렌더 비교 다음 단계

레포 연결 절로 이동레포 연결① 경로 판단 절로 이동① 경로 판단② 고칠 곳 찾기 절로 이동② 고칠 곳 찾기③ 렌더 비교 절로 이동③ 렌더 비교④ PR 열기 절로 이동④ PR 열기
  • 렌더 비교를 통과하면 새 브랜치 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 의 근거다.
→ GitHub Docs — About the setup URL
저장소 범위로 좁힌 설치 토큰 발급. 2.4 의 근거다.
→ GitHub REST API — Create an installation access token
ModelRetry 와 ProcessHistory 의 동작. 4.2 와 4.3 의 근거다.
→ Pydantic AI 문서
Application 이 형식을 정하는 방식과 Kustomize 의 replicas · images · patches. 5장의 근거다.
→ Argo CD — Kustomize
→ Kustomize — kustomization 참조
ignoreDifferences 와 RespectIgnoreDifferences 의 차이. 5.4 표의 근거다.
→ Argo CD — Diffing Customization
→ Argo CD — Sync Options