feat: gated deploy with preview, audit log, rollback trail (stage 4)
- pulp_client: create_publication / update_distribution / wait_for_task
- GET /repos/{uuid}/deploy/confirm: 미리보기(현재→대상, 순 변화) + type-to-confirm 모달
- POST /repos/{uuid}/deploy: 검증 게이트(서버측 재확인) → publication 생성 →
distribution 교체 → 감사 로그. 2단계 실패 시 '운영망 변경 여부' 명확화
- audit.record_deploy: 누가/언제/repo/이전버전→대상버전 JSONL (롤백 추적)
- 배포 버튼은 검증 통과 + 미배포 버전만 활성, 성공 시 버전목록 OOB 갱신
- 인증은 TODO (operator placeholder, 배포 비밀번호 재확인 예정)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
app/routes/deploy.py
Normal file
218
app/routes/deploy.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""배포 확정 + 감사 로그 (화면 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,
|
||||
},
|
||||
)
|
||||
@@ -3,11 +3,13 @@
|
||||
GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순).
|
||||
|
||||
현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다
|
||||
(스펙 §4). 배포 버튼 활성화/확인은 단계 4에서 연결.
|
||||
(스펙 §4). 배포 버튼 활성화/확인은 routes/deploy.py(단계 4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -45,34 +47,34 @@ def _deployed_version_number(pulp: PulpClient, uuid: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def load_version_context(pulp: PulpClient, uuid: str) -> dict[str, Any]:
|
||||
"""버전 페이지/조각 렌더용 컨텍스트. get_repo/list_versions 실패는 httpx 예외로 전파."""
|
||||
repo = pulp.get_repo(uuid)
|
||||
raw_versions = pulp.list_versions(uuid)
|
||||
gpg = views.gpg_status(repo)
|
||||
deployed = _deployed_version_number(pulp, uuid)
|
||||
version_views = [views.build_version_view(v, deployed, gpg) for v in raw_versions]
|
||||
version_views.sort(key=lambda v: v["number"] or 0, reverse=True) # 최신순
|
||||
return {
|
||||
"repo_name": repo.get("name", uuid),
|
||||
"uuid": uuid,
|
||||
"versions": version_views,
|
||||
"verification": views.verification_detail(repo),
|
||||
"deployed": deployed,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/repos/{uuid}/versions", response_class=HTMLResponse)
|
||||
def versions(
|
||||
request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client)
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
repo = pulp.get_repo(uuid)
|
||||
raw_versions = pulp.list_versions(uuid)
|
||||
ctx = load_version_context(pulp, uuid)
|
||||
except httpx.HTTPError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"versions.html",
|
||||
{"repo_name": uuid, "uuid": uuid, "error": str(exc)},
|
||||
)
|
||||
|
||||
gpg = views.gpg_status(repo)
|
||||
deployed = _deployed_version_number(pulp, uuid)
|
||||
version_views = [views.build_version_view(v, deployed, gpg) for v in raw_versions]
|
||||
version_views.sort(key=lambda v: v["number"] or 0, reverse=True) # 최신순
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"versions.html",
|
||||
{
|
||||
"repo_name": repo.get("name", uuid),
|
||||
"uuid": uuid,
|
||||
"versions": version_views,
|
||||
"verification": views.verification_detail(repo),
|
||||
"deployed": deployed,
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(request, "versions.html", ctx)
|
||||
|
||||
Reference in New Issue
Block a user