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:
2026-06-17 13:31:15 +09:00
parent 72c75252d3
commit 7ad6d65a11
17 changed files with 692 additions and 29 deletions

37
app/audit.py Normal file
View File

@@ -0,0 +1,37 @@
"""배포 확정 감사 로그 (스펙 §5-2).
누가 / 언제 / 어떤 repo 를 / 어떤 버전으로 배포했는지 append-only JSONL 로 남긴다.
이전 버전(from_version)도 기록해 사고 시 롤백 대상을 즉시 파악할 수 있게 한다.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from .config import get_settings
def record_deploy(
*,
repo_uuid: str,
repo_name: str,
from_version: int | None,
to_version: int | None,
publication_href: str,
operator: str = "operator", # TODO(auth): 로그인 도입 시 실제 사용자. 배포 시 비밀번호 재확인 예정.
) -> dict[str, Any]:
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"action": "deploy",
"operator": operator,
"repo_uuid": repo_uuid,
"repo_name": repo_name,
"from_version": from_version,
"to_version": to_version,
"publication": publication_href,
}
with open(get_settings().audit_log_path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry

View File

@@ -23,6 +23,8 @@ class Settings(BaseSettings):
pulp_ca_file: str | None = None
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능)
pulp_demo: bool = False
# 배포 감사 로그 (append-only JSONL)
audit_log_path: str = "audit-log.jsonl"
@lru_cache

View File

@@ -121,7 +121,10 @@ class DemoPulpClient:
# 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩)
deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1}
return [
{"publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/"}
{
"pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/",
"publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/",
}
for u, v in deployed.items()
]
@@ -133,3 +136,18 @@ class DemoPulpClient:
f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/{number}/"
)
}
def create_publication(self, version_href: str) -> str:
return "/pulp/api/v3/tasks/demo-pub/"
def update_distribution(self, dist_href: str, publication_href: str) -> str:
return "/pulp/api/v3/tasks/demo-dist/"
def wait_for_task(self, task_href: str, attempts: int = 120, delay: float = 1.0):
# 데모: 즉시 완료. publication task 면 새 publication 리소스를 돌려준다.
created = (
["/pulp/api/v3/publications/rpm/rpm/demo-new/"]
if "pub" in task_href
else []
)
return {"state": "completed", "created_resources": created}

View File

@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
# 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함)
from .deps import get_pulp_client, templates
from .pulp_client import PulpClient
from .routes import dashboard, sync, versions
from .routes import dashboard, deploy, sync, versions
BASE_DIR = Path(__file__).resolve().parent
@@ -24,6 +24,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
app.include_router(dashboard.router)
app.include_router(sync.router)
app.include_router(versions.router)
app.include_router(deploy.router)
__all__ = ["app", "get_pulp_client"]

View File

@@ -9,6 +9,7 @@
from __future__ import annotations
import time
from typing import Any
import httpx
@@ -105,3 +106,39 @@ class PulpClient:
def get_task(self, task_href: str) -> dict[str, Any]:
"""task 단건 조회 (state, progress_reports, created_resources, error)."""
return self.get(task_href)
def create_publication(self, version_href: str) -> str:
"""특정 버전을 배포 가능한 형태로: POST /publications/rpm/rpm/ → task_href.
완료 후 task.created_resources 에서 publication href 를 얻는다.
"""
data = self.post(
f"{API_PREFIX}/publications/rpm/rpm/",
json={"repository_version": version_href},
)
return data["task"]
def update_distribution(self, dist_href: str, publication_href: str) -> str:
"""배포 확정: PATCH {dist_href} publication 교체 → task_href.
운영망이 보는 URL 이 이 publication 을 가리키게 된다(실제 배포 동작, 스펙 §4).
"""
data = self.patch(dist_href, json={"publication": publication_href})
return data["task"]
def wait_for_task(
self, task_href: str, attempts: int = 120, delay: float = 1.0
) -> dict[str, Any]:
"""task 가 종료 상태(completed/failed/canceled)가 될 때까지 대기 후 반환.
배포는 (publication 생성 → distribution 교체) 2단계 task 라 결과 확정이 필요해
서버측에서 짧게 대기한다. 화면 폴링이 어려운 복합 동작에 한정해서 쓴다.
"""
terminal = {"completed", "failed", "canceled"}
task = self.get_task(task_href)
for _ in range(attempts):
if task.get("state") in terminal:
return task
time.sleep(delay)
task = self.get_task(task_href)
return task

218
app/routes/deploy.py Normal file
View 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,
},
)

View File

@@ -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)

View File

@@ -243,6 +243,15 @@ button:disabled,
opacity: 0.45;
}
/* 위험 버튼: 배포 확정 등 확인 모달에서만 (design-ref §4) */
button.danger,
[role="button"].danger {
--pico-background-color: var(--danger);
--pico-border-color: var(--danger);
--pico-color: #ffffff;
font-weight: 500;
}
/* 동기화 진행률 바 */
.sync-progress {
display: flex;

View File

@@ -0,0 +1,41 @@
<dialog open>
<article>
<header>
<h3>이 버전을 운영망에 배포</h3>
</header>
{% if error %}
<p class="status-line status-bad">{{ error }}</p>
{% endif %}
<p>운영 서버(yum/dnf)가 실제로 내려받는 버전이 바뀝니다. 신중히 확인하세요.</p>
{% if preview %}
<table>
<tbody>
<tr><td>현재 운영</td>
<td class="num">{% if preview.current_version is not none %}v{{ preview.current_version }} ({{ preview.current_count }}개){% else %}없음{% endif %}</td></tr>
<tr><td>배포 대상</td>
<td class="num">v{{ preview.target_version }} ({{ preview.target_count }}개)</td></tr>
<tr><td>순 변화</td>
<td class="num">{{ preview.net_sign }}{{ preview.net }}개</td></tr>
</tbody>
</table>
{% endif %}
<form hx-post="/repos/{{ uuid }}/deploy" hx-target="#modal" hx-swap="innerHTML">
<input type="hidden" name="version_href" value="{{ version_href }}">
<label>
확인을 위해 저장소 이름 <strong>{{ repo_name }}</strong> 을(를) 입력하세요
<input type="text" name="confirm_name" autocomplete="off" required
oninput="document.getElementById('deploy-confirm-btn').disabled = (this.value !== {{ repo_name | tojson }})">
</label>
{# TODO(auth): 로그인 도입 시 여기서 배포 비밀번호 재확인 입력 추가 #}
<footer>
<button type="button" class="secondary"
onclick="document.getElementById('modal').innerHTML=''">취소</button>
<button type="submit" id="deploy-confirm-btn" class="danger" disabled>배포 확정</button>
</footer>
</form>
</article>
</dialog>

View File

@@ -0,0 +1,20 @@
{# 배포 성공: 버전 목록을 OOB 로 갱신 + flash 배너 + 모달 자리에 완료 알림 #}
{% set oob = true %}
{% include "partials/version_list.html" %}
<div id="flash" hx-swap-oob="true">
<p class="status-line status-ok">
배포 완료 · v{{ to_version }}{% if from_version is not none %} (이전 v{{ from_version }}){% endif %}
{% if not audit_ok %} — ⚠ 감사 로그 기록 실패{% endif %}
</p>
</div>
<dialog open>
<article>
<header><h3>배포 완료</h3></header>
<p>운영 배포 버전이 <strong>v{{ to_version }}</strong> 로 교체되었습니다.</p>
<footer>
<button onclick="document.getElementById('modal').innerHTML=''">닫기</button>
</footer>
</article>
</dialog>

View File

@@ -1,4 +1,4 @@
<div id="version-list" class="table-card">
<div id="version-list" class="table-card"{% if oob %} hx-swap-oob="true"{% endif %}>
{% if versions %}
<table>
<thead>
@@ -26,9 +26,14 @@
<td>{% if v.gpg %}<span class="badge badge-{{ v.gpg.level }}">{{ v.gpg.label }}</span>{% endif %}</td>
<td>{% if v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}</td>
<td>
{% if not v.is_deployed %}
<!-- 배포 버튼 활성화/확인 모달은 단계4에서 연결 -->
<button class="secondary" disabled>이 버전 배포</button>
{% if v.is_deployed %}
{# 배포 상태 칸에 배지 표시 #}
{% elif v.deployable %}
<button class="secondary"
hx-get="/repos/{{ uuid }}/deploy/confirm?version_href={{ v.href | urlencode }}"
hx-target="#modal" hx-swap="innerHTML">이 버전 배포</button>
{% else %}
<button class="secondary" disabled title="검증을 통과하지 못해 배포할 수 없습니다">이 버전 배포</button>
{% endif %}
</td>
</tr>

View File

@@ -5,6 +5,7 @@
<section>
<h2>{{ repo_name }} · 버전</h2>
<div id="flash"></div>
{% if error %}
<p class="status-line status-bad">버전 목록을 불러오지 못했습니다: {{ error }}</p>
@@ -21,4 +22,7 @@
{% include "partials/version_list.html" %}
{% endif %}
</section>
<!-- 배포 확인 모달이 여기에 로드된다 -->
<div id="modal"></div>
{% endblock %}

View File

@@ -85,14 +85,44 @@ def build_version_view(
호출부에서 repo 의 gpg_status() 를 주입한다.
"""
number = version_number(version.get("pulp_href"))
is_deployed = number is not None and number == deployed_number
# 배포 가능: 검증 통과(pass) + 현재 배포 중이 아님 (스펙 §5-2 게이트)
deployable = (gpg or {}).get("level") == "pass" and not is_deployed
return {
"number": number,
"href": version.get("pulp_href"),
"created": version.get("pulp_created"),
"package_count": _content_count(version, "present"),
"added": _content_count(version, "added"),
"removed": _content_count(version, "removed"),
"gpg": gpg,
"is_deployed": number is not None and number == deployed_number,
"is_deployed": is_deployed,
"deployable": deployable,
}
def deploy_preview(
version_views: list[dict[str, Any]],
target_number: int | None,
deployed_number: int | None,
) -> dict[str, Any]:
"""배포 확인 모달용 미리보기: 현재 운영 버전 → 대상 버전, 순 패키지 변화."""
def find(n: int | None) -> dict[str, Any] | None:
return next((v for v in version_views if v["number"] == n), None)
current = find(deployed_number)
target = find(target_number)
current_count = current["package_count"] if current else 0
target_count = target["package_count"] if target else 0
net = target_count - current_count
return {
"current_version": deployed_number,
"current_count": current_count,
"target_version": target_number,
"target_count": target_count,
"net": abs(net),
"net_sign": "+" if net >= 0 else "",
}