"""배포 확정 + 감사 로그 (화면 3 후반, 위험 동작 — 스펙 §5-2). GET /repos/{uuid}/deploy/confirm → 확인 모달(미리보기 + type-to-confirm) POST /repos/{uuid}/deploy → publication 생성 → distribution 교체 → 감사 로그 안전장치: - 검증 게이트: 검증 통과(pass) 저장소만 배포 가능 (서버측에서도 재확인) - type-to-confirm: 저장소 이름을 정확히 입력해야 실행 - 2단계 실패 명확화: 어느 단계에서 실패하든 "운영망 변경 여부"를 분명히 안내 - 감사 로그: 누가/언제/어떤 repo/어느 버전→어느 버전 (이전 버전 기록 = 롤백 추적) """ from __future__ import annotations import httpx from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse from .. import audit, views from ..deps import get_pulp_client, templates from ..pulp_client import PulpClient from .versions import _deployed_version_number, load_version_context router = APIRouter() def _distribution_for_repo(pulp: PulpClient, uuid: str) -> str | None: """이 저장소에 연결된 distribution href. repository 직접 참조 또는 현재 publication 으로 매핑.""" needle = f"/repositories/rpm/rpm/{uuid}/" for dist in pulp.list_distributions(): if needle in (dist.get("repository") or ""): return dist.get("pulp_href") pub_href = dist.get("publication") if pub_href: try: pub = pulp.get_publication(pub_href) except httpx.HTTPError: continue if needle in (pub.get("repository_version") or ""): return dist.get("pulp_href") return None def _preview(pulp: PulpClient, uuid: str, version_href: str): """확인 모달용 컨텍스트(repo_name, gpg, preview, deployed).""" repo = pulp.get_repo(uuid) raw = pulp.list_versions(uuid) gpg = views.gpg_status(repo) deployed = _deployed_version_number(pulp, uuid) vv = sorted( (views.build_version_view(v, deployed, gpg) for v in raw), key=lambda v: v["number"] or 0, reverse=True, ) target = views.version_number(version_href) return { "uuid": uuid, "repo_name": repo.get("name", uuid), "version_href": version_href, "gpg": gpg, "deployed": deployed, "preview": views.deploy_preview(vv, target, deployed), "target_number": target, } def _modal(request: Request, ctx: dict, error: str | None = None, status: int = 200): return templates.TemplateResponse( request, "partials/deploy_modal.html", {**ctx, "error": error}, status_code=status, ) @router.get("/repos/{uuid}/deploy/confirm", response_class=HTMLResponse) def deploy_confirm( request: Request, uuid: str, version_href: str, pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: try: ctx = _preview(pulp, uuid, version_href) except httpx.HTTPError as exc: return templates.TemplateResponse( request, "partials/deploy_modal.html", { "uuid": uuid, "repo_name": uuid, "version_href": version_href, "preview": None, "error": f"조회 실패: {exc}", }, status_code=502, ) return _modal(request, ctx) @router.post("/repos/{uuid}/deploy", response_class=HTMLResponse) def deploy( request: Request, uuid: str, version_href: str = Form(...), confirm_name: str = Form(""), pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: try: ctx = _preview(pulp, uuid, version_href) except httpx.HTTPError as exc: return _modal( request, { "uuid": uuid, "repo_name": uuid, "version_href": version_href, "preview": None, }, error=f"조회 실패: {exc}", status=502, ) repo_name = ctx["repo_name"] # 1) 검증 게이트 (서버측 재확인 — 클라이언트만 믿지 않는다) if ctx["gpg"]["level"] != "pass": return _modal( request, ctx, "검증을 통과하지 못한 저장소는 배포할 수 없습니다.", 403 ) # 2) type-to-confirm if confirm_name != repo_name: return _modal(request, ctx, "저장소 이름이 일치하지 않습니다.", 400) # 3) 대상 distribution 확인 dist_href = _distribution_for_repo(pulp, uuid) if not dist_href: return _modal( request, ctx, "이 저장소에 연결된 distribution 을 찾을 수 없습니다.", 404 ) # 4) publication 생성 (실패 시 운영망은 절대 변경되지 않음) try: pub_task = pulp.wait_for_task(pulp.create_publication(version_href)) except httpx.HTTPError as exc: return _modal( request, ctx, f"배포 실패(publication 생성). 운영망은 변경되지 않았습니다: {exc}", 502, ) if pub_task.get("state") != "completed": return _modal( request, ctx, "배포 실패(publication 생성 단계). 운영망은 변경되지 않았습니다.", 502, ) pub_href = next( (h for h in pub_task.get("created_resources") or [] if "/publications/" in h), None, ) if not pub_href: return _modal( request, ctx, "publication 결과를 찾을 수 없습니다. 운영망은 변경되지 않았습니다.", 502, ) # 5) distribution 교체 = 실제 운영 배포 try: dist_task = pulp.wait_for_task(pulp.update_distribution(dist_href, pub_href)) except httpx.HTTPError as exc: return _modal( request, ctx, f"배포 실패(distribution 교체). 운영망 변경에 실패했습니다: {exc}", 502, ) if dist_task.get("state") != "completed": return _modal( request, ctx, "배포 실패(distribution 교체 단계). 운영망 변경에 실패했습니다.", 502, ) # 6) 감사 로그 (이전 버전 기록 = 롤백 추적). 기록 실패해도 배포는 이미 완료. audit_ok = True try: audit.record_deploy( repo_uuid=uuid, repo_name=repo_name, from_version=ctx["deployed"], to_version=ctx["target_number"], publication_href=pub_href, ) except OSError: audit_ok = False # 7) 성공: 버전 목록 OOB 갱신 + 모달 성공 표시 try: new_ctx = load_version_context(pulp, uuid) except httpx.HTTPError: new_ctx = {"versions": [], "uuid": uuid} return templates.TemplateResponse( request, "partials/deploy_success.html", { **new_ctx, "uuid": uuid, "from_version": ctx["deployed"], "to_version": ctx["target_number"], "audit_ok": audit_ok, }, )