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
singleuserlifecycle hook +gend-git-bootstrap.sh자동 mount - Gitea webhook → MLflow
mlflow.source.git.commit자동 태깅
필수 Secret / ConfigMap
| Key | Source | 설명 |
|---|---|---|
GEND_GITEA_URL | ConfigMap | Gitea ClusterIP 내부 URL (http://gitea-http.gend.svc.cluster.local:3000) |
GEND_GITEA_EXTERNAL_URL | ConfigMap | 사용자 향 외부 URL (https://gend.genon.ai/git) |
GEND_GITEA_ADMIN_TOKEN | SealedSecret | Gitea admin 권한 PAT — 사용자/repo/webhook 프로비저닝 |
GEND_GITEA_WEBHOOK_SECRET | SealedSecret | Gitea webhook ↔ gend-api HMAC-SHA256 공유 비밀 |
GEND_GITEA_PAT_DEFAULT_TTL_MINUTES | ConfigMap (옵션) | 단명 PAT 기본 TTL (default 60, 정책 cap 도 60) |
GEND_API_URL (JupyterHub) | env | bootstrap 스크립트가 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_bindingXOR / non-negative CHECKgitea_pat(user_id, revoked_at)인덱스notebook_commit_linkUNIQUE 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
엔드포인트 빠른 참조
| Method | Path | Auth | 설명 |
|---|---|---|---|
| POST | /api/v1/git/users/sync | JWT | Keycloak → Gitea 사용자 idempotent sync |
| POST | /api/v1/git/repos | JWT | 사용자/그룹 repo 생성 (owner_login XOR group_id) |
| GET | /api/v1/git/repos | JWT | 내 repo 목록 (Gitea proxy) |
| POST | /api/v1/git/notebooks/{sid}/init | JWT | JupyterHub 세션 단위 repo 자동 init + PAT 반환 |
| POST | /api/v1/git/notebooks/{sid}/clone | JWT | 기존 repo clone (검증 + PAT 발급) |
| POST | /api/v1/git/tokens | JWT | 60 분 단명 PAT 단독 발급 |
| GET | /api/v1/git/commits/{owner}/{repo}/{sha} | JWT | 커밋 메타 + 매칭된 MLflow run id 목록 |
| POST | /api/v1/git/webhook | HMAC | Gitea 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.yaml 의 singleuser.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 단계:
~/.gitconfig시드 (author identity)POST /api/v1/git/users/sync— Keycloak↔Gitea 멱등 동기화POST /api/v1/git/tokens— 60 분 단명 PAT 발급~/.git-credentials시드 (HTTPS 자동 인증)- 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.committag 값으로 커밋 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/webhook | kubectl get secret gend-gitea-webhook-secret -o yaml + Gitea hook config 의 secret 동일성 |
503 from POST /api/v1/git/webhook | GEND_GITEA_WEBHOOK_SECRET 환경변수 주입 여부 (kubectl exec gend-api -- env | grep GEND_GITEA) |
502 from /users/sync | GEND_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_sync | keycloak_group_id UNIQUE, gitea_org_id UNIQUE, gitea_org_login UNIQUE | Keycloak 그룹 ↔ 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
| Method | Path | Auth | 설명 |
|---|---|---|---|
| POST | /api/v1/git/orgs/sync | admin | Keycloak 그룹 1건 → Gitea org idempotent sync. |
| POST | /api/v1/git/orgs/{org_login}/repos | JWT (member) | 해당 그룹 namespace 아래 repo 생성. |
| POST | /api/v1/git/orgs/membership/sync | admin | 사용자의 Keycloak 그룹 ↔ Gitea team 멤버십 동기화 (additive). |
엔드포인트별 동작:
POST /api/v1/git/orgs/sync— body{keycloak_group_id, keycloak_group_name}. 첫 호출은 GiteaPOST /api/v1/orgs로 org 생성 (없으면) +gitea_org_syncinsert. 후속 호출은 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_syncbinding 이 없는 그룹명. 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_orgswrapper + 차집합 계산 추가. - pre-receive hook (force-push 차단) + Hybrid profile (사용자/그룹 dual-namespace).