본문으로 건너뛰기

Git Service Operator Guide

본 문서는 Epic #1081 M1 Step 3 에서 추가된 gend-api 측 Gitea 통합 컴포넌트의 운영 가이드입니다. 사용자용 흐름은 Notebook Git 사용자 가이드 에 별도 정리되어 있습니다.

범위

  • 4 개 DB 테이블 (gitea_user_sync / gitea_repo_binding / gitea_pat / notebook_commit_link)
  • 8 개 REST 엔드포인트 (/api/v1/git/*) — 7 protected + 1 webhook (HMAC)
  • JupyterHub singleuser lifecycle hook + gend-git-bootstrap.sh 자동 mount
  • Gitea webhook → MLflow mlflow.source.git.commit 자동 태깅

필수 Secret / ConfigMap

KeySource설명
GEND_GITEA_URLConfigMapGitea ClusterIP 내부 URL (http://gitea-http.gend.svc.cluster.local:3000)
GEND_GITEA_EXTERNAL_URLConfigMap사용자 향 외부 URL (https://gend.genon.ai/git)
GEND_GITEA_ADMIN_TOKENSealedSecretGitea admin 권한 PAT — 사용자/repo/webhook 프로비저닝
GEND_GITEA_WEBHOOK_SECRETSealedSecretGitea webhook ↔ gend-api HMAC-SHA256 공유 비밀
GEND_GITEA_PAT_DEFAULT_TTL_MINUTESConfigMap (옵션)단명 PAT 기본 TTL (default 60, 정책 cap 도 60)
GEND_API_URL (JupyterHub)envbootstrap 스크립트가 PAT 발급에 호출
GEND_API_JWT (JupyterHub)env사용자 토큰 (singleuser ServiceAccount 또는 KubeSpawner injection)

GEND_GITEA_ADMIN_TOKEN 발급 절차:

# 1. Gitea UI 로그인 (admin 계정) → Settings → Applications → Generate New Token
# Name: gend-api-prod
# Scopes: write:admin, write:repository, write:user
# 2. plaintext 토큰을 한 번만 복사 → kubeseal 로 SealedSecret 생성
echo -n "<plaintext>" | kubectl create secret generic gend-gitea-admin-token \
--dry-run=client \
--from-file=token=/dev/stdin \
-o yaml | kubeseal --controller-namespace sealed-secrets -o yaml \
> infra/sealed-secrets/gend-gitea-admin-token.yaml
# 3. ArgoCD sync → gend-api Pod 재기동

GEND_GITEA_WEBHOOK_SECRET 도 동일한 SealedSecret 패턴이며 PR #1116 에서 키 이름이 webhook-secret 으로 통일됐습니다.

데이터베이스 마이그레이션

ADR-002 baseline 정책에 따라 별도 Alembic 마이그레이션은 생성하지 않습니다.

  • Base.metadata.create_all (init_db) 이 fresh DB 에 4 테이블 생성
  • 기존 DB 는 _PG_POST_MIGRATIONS 의 DO $$ 블록이
    • gitea_repo_binding XOR / non-negative CHECK
    • gitea_pat (user_id, revoked_at) 인덱스
    • notebook_commit_link UNIQUE 3-튜플 (mlflow_run_id, commit_sha, notebook_path) 를 idempotent 하게 추가

검증 쿼리:

SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
AND tablename IN ('gitea_user_sync','gitea_repo_binding','gitea_pat','notebook_commit_link')
ORDER BY tablename;
-- 4 rows expected

SELECT conname FROM pg_constraint
WHERE conname IN (
'ck_gitea_repo_binding_owner_xor_group',
'ck_gitea_repo_binding_notebook_count_nonneg',
'uq_notebook_commit_link_run_sha_path'
);
-- 3 rows expected

엔드포인트 빠른 참조

MethodPathAuth설명
POST/api/v1/git/users/syncJWTKeycloak → Gitea 사용자 idempotent sync
POST/api/v1/git/reposJWT사용자/그룹 repo 생성 (owner_login XOR group_id)
GET/api/v1/git/reposJWT내 repo 목록 (Gitea proxy)
POST/api/v1/git/notebooks/{sid}/initJWTJupyterHub 세션 단위 repo 자동 init + PAT 반환
POST/api/v1/git/notebooks/{sid}/cloneJWT기존 repo clone (검증 + PAT 발급)
POST/api/v1/git/tokensJWT60 분 단명 PAT 단독 발급
GET/api/v1/git/commits/{owner}/{repo}/{sha}JWT커밋 메타 + 매칭된 MLflow run id 목록
POST/api/v1/git/webhookHMACGitea push event 수신 (JWT 면제)

전 endpoint 는 try/except + logger.error + HTTPException 패턴 + except HTTPException: raise 를 일관 적용합니다. _protected_routers 등록 회귀는 tests/test_protected_routers_registration.py 가 매 CI 마다 강제합니다.

단명 PAT 정책 (AC #2)

  • TTL 은 Pydantic ttl_minutes: int = Field(..., le=60) 로 60분 cap. 60 초과 요청은 422.
  • 평문 토큰은 응답 본문(pat)에 단 한 번 노출됨. DB 에는 SHA-256 hex 64자만 저장 (gitea_pat.token_hash).
  • 회수 (M2 router) 는 revoked_at 갱신 (soft delete). 30 일 이상된 revoked 행은 Dagster GC asset (예정) 으로 정리.
  • bootstrap 스크립트가 매 singleuser 시작 시 새 PAT 발급 — 사용자가 의식하지 않아도 자동 rotate.

JupyterHub 부트스트랩

infra/jupyterhub/values.yaml + values-azure.yamlsingleuser.extraFiles/usr/local/bin/gend-git-bootstrap.sh 를 stub 으로 mount 하고, 실제 스크립트는 릴리즈 시 deploy/sync-bootstrap-script.sh (예정) 가 infra/jupyterhub/scripts/ 의 source 본을 복사합니다 (드리프트 방지).

lifecycleHooks.postStart 가 컨테이너 시작 직후 (PVC mount + working-dir 부착 후, jupyter-server 가 요청 처리하기 전) 스크립트를 호출합니다. 스크립트는 fail-soft — Gitea 장애가 있어도 JupyterLab 자체 부팅을 막지 않습니다.

스크립트 동작 5 단계:

  1. ~/.gitconfig 시드 (author identity)
  2. POST /api/v1/git/users/sync — Keycloak↔Gitea 멱등 동기화
  3. POST /api/v1/git/tokens — 60 분 단명 PAT 발급
  4. ~/.git-credentials 시드 (HTTPS 자동 인증)
  5. summary 로그 ([gend-git-bootstrap] summary: N ok / M fail)

Webhook 운영

  • gend-api 가 Gitea POST /api/v1/repos/{owner}/{repo}/hooks 로 등록하는 webhook URL: <EXTERNAL_API_URL>/api/v1/git/webhook.
  • Gitea 가 보내는 헤더: X-Gitea-Signature: <hex> (HMAC-SHA256 hex digest of body).
  • gend-api 검증 실패 시 401. GEND_GITEA_WEBHOOK_SECRET 미설정 시 503 (fail-closed).
  • 검증 성공 후 push 이벤트만 처리 (M1). 그 중에서도 default branch 로의 push 만 MLflow tag 갱신 — feature branch 실험 link 는 M2 (PR merge hook) 에서 처리.

MLflow 연동

  • gend-api 가 호출하는 MLflow API: runs/search + runs/set-tag (POST JSON).
  • mlflow.source.git.commit tag 값으로 커밋 SHA 를 기록.
  • MLflow 가 다운된 경우 webhook handler 는 여전히 200 을 반환하고 notebook_commit_link 만 기록 (best-effort). Gitea 재시도 폭주 방지.
  • 매칭 알고리즘: tags."mlflow.source.git.commit" = '<sha>' 로 미리 태깅된 run 을 찾음. 매칭 0건이면 orphan:<sha-12> 합성 run_id 로 link 만 저장.

트러블슈팅

증상점검
401 from POST /api/v1/git/webhookkubectl get secret gend-gitea-webhook-secret -o yaml + Gitea hook config 의 secret 동일성
503 from POST /api/v1/git/webhookGEND_GITEA_WEBHOOK_SECRET 환경변수 주입 여부 (kubectl exec gend-api -- env | grep GEND_GITEA)
502 from /users/syncGEND_GITEA_ADMIN_TOKEN 만료/회수 — Gitea UI 재발급
init 응답 409사용자가 sync 안 됨 — UI 가 /users/sync 먼저 호출하도록 흐름 점검
notebook_commit_link 누락webhook 전달 자체가 안 됨 — Gitea UI Settings → Webhooks → Recent Deliveries
set-tag 0 회노트북에 pre-commit hook mlflow.source.git.commit 미설정 — 사용자 가이드 참조

검증 명령

# JWT 토큰 발급 (Keycloak)
TOKEN=$(curl -s -X POST \
"$KEYCLOAK_URL/realms/gend/protocol/openid-connect/token" \
-d "client_id=gend-api" -d "client_secret=$CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)

# 1) sync
curl -fsS -H "Authorization: Bearer $TOKEN" \
-X POST "$API/api/v1/git/users/sync" | jq

# 2) repo list
curl -fsS -H "Authorization: Bearer $TOKEN" "$API/api/v1/git/repos" | jq '.total'

# 3) 60분 PAT
curl -fsS -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-X POST "$API/api/v1/git/tokens" \
-d '{"scopes":["repo"],"ttl_minutes":60}' | jq '.expires_at, .scopes'

# 4) webhook (HMAC 자체 서명)
SECRET="<webhook-secret>"
BODY='{"ref":"refs/heads/main","repository":{"id":1,"full_name":"a/b","default_branch":"main"},"commits":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -fsS -X POST "$API/api/v1/git/webhook" \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: $SIG" \
-d "$BODY" | jq

M2 — 그룹 namespace (Keycloak Group → Gitea Org)

M1 은 사용자/그룹 repo 의 owner 분기를 partial 하게 처리했고, group repo 는 라우터 안에서 g-<group_id-short> 형태의 ad-hoc 슬러그를 즉석에서 합성해 Gitea organization 을 생성했습니다. 그 결과:

  • gitea_org_login → keycloak_group_id 역추적이 불가능 (binding 행 부재).
  • Gitea UI URL 이 사람이 읽기 어려운 /g-1a2b3c4d/foo 형태로 노출.
  • 그룹 멤버십 reconciliation 불가능.

M2 는 이를 정식 binding 테이블 + 3 endpoint 로 정리합니다.

신규 테이블

테이블키 컬럼설명
gitea_org_synckeycloak_group_id UNIQUE, gitea_org_id UNIQUE, gitea_org_login UNIQUEKeycloak 그룹 ↔ Gitea organization 1:1 매핑. last_synced_at 으로 reconcile 시점 추적.

ADR-002 baseline 정책 그대로 — Base.metadata.create_all 이 fresh DB 에 테이블을 생성하고, 기존 DB 는 다음 startup 에 동일 create_all 이 멱등하게 행을 추가합니다. 별도 _PG_POST_MIGRATIONS 행은 없음 (전 컬럼이 ORM 레벨 UNIQUE, partial index 없음 — M3 에서 멤버십 cache 가 도입될 때 함께 갱신).

신규 endpoint

MethodPathAuth설명
POST/api/v1/git/orgs/syncadminKeycloak 그룹 1건 → Gitea org idempotent sync.
POST/api/v1/git/orgs/{org_login}/reposJWT (member)해당 그룹 namespace 아래 repo 생성.
POST/api/v1/git/orgs/membership/syncadmin사용자의 Keycloak 그룹 ↔ Gitea team 멤버십 동기화 (additive).

엔드포인트별 동작:

  • POST /api/v1/git/orgs/sync — body {keycloak_group_id, keycloak_group_name}. 첫 호출은 Gitea POST /api/v1/orgs 로 org 생성 (없으면) + gitea_org_sync insert. 후속 호출은 fast-path 로 동일 행 반환 (Gitea 미호출).
  • POST /api/v1/git/orgs/{org_login}/repos — body {name, description?, private?, default_branch?}. gitea_org_sync 에 binding 이 없으면 404 (admin 이 먼저 sync 필요). Non-admin 호출자는 토큰의 groups 클레임에 keycloak_group_id 가 있어야 403 통과. 생성된 binding 은 group_id 가 set 된 gitea_repo_binding 행 (XOR 으로 owner_id 는 NULL, visibility='group').
  • POST /api/v1/git/orgs/membership/sync — body {keycloak_sub, group_names}. 사용자별 Gitea org Owners team 멤버십 reconcile. 응답: {added: [...], skipped: [...]} — skipped 는 gitea_org_sync binding 이 없는 그룹명. M2 는 additive only, removal 은 M3 (Gitea "내 org 목록" API wrap 필요).

운영 체크리스트 (M2)

# 1. 신규 Keycloak 그룹 등록 직후 — admin 토큰
curl -fsS -X POST "$API/api/v1/git/orgs/sync" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"keycloak_group_id":"<uuid>","keycloak_group_name":"Team Alpha"}'
# → 200 + gitea_org_login (e.g. "team-alpha")

# 2. 그룹 멤버 PAT 발급 후 사용자별 멤버십 동기화 (admin)
curl -fsS -X POST "$API/api/v1/git/orgs/membership/sync" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"keycloak_sub":"<sub>","group_names":["Team Alpha"]}'
# → 200 + {"added":["team-alpha"],"skipped":[]}

# 3. 그룹 멤버가 그룹 namespace 에 repo 생성 (멤버 토큰)
curl -fsS -X POST "$API/api/v1/git/orgs/team-alpha/repos" \
-H "Authorization: Bearer $MEMBER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"notebooks"}'
# → 201 + full_name="team-alpha/notebooks"

M3 deferred

  • Keycloak event listener (keycloak.events.AdminEventListenerProvider) 로 그룹 create / 멤버십 변경시 위 3 endpoint 자동 호출 — admin 클릭 제거.
  • Removal 경로 (Keycloak 그룹 제거 → Gitea team 멤버십 revoke) — GiteaClient.list_user_orgs wrapper + 차집합 계산 추가.
  • pre-receive hook (force-push 차단) + Hybrid profile (사용자/그룹 dual-namespace).

관련 PR / 이슈

  • Epic #1081 — Notebook ↔ Git 형상관리
  • PR #1101 — M1 Step 1+2 (Gitea infra + JupyterHub VS Code)
  • PR #1116 — Gitea SealedSecret env wiring
  • 이슈 #1108 — 본 M1 Step 3 작업 단위
  • M2 — Group namespace 동기화 + 멤버십 reconciler (본 PR)