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

View File

@@ -111,7 +111,7 @@
--- ---
## [ ] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작) ## [x] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작)
**목표:** 검증 완료 + 미배포 버전만 배포 가능. 확인 모달 → publication 생성 → **목표:** 검증 완료 + 미배포 버전만 배포 가능. 확인 모달 → publication 생성 →
distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록. distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록.

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_ca_file: str | None = None
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능) # 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능)
pulp_demo: bool = False pulp_demo: bool = False
# 배포 감사 로그 (append-only JSONL)
audit_log_path: str = "audit-log.jsonl"
@lru_cache @lru_cache

View File

@@ -121,7 +121,10 @@ class DemoPulpClient:
# 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩) # 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩)
deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1} deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1}
return [ 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() for u, v in deployed.items()
] ]
@@ -133,3 +136,18 @@ class DemoPulpClient:
f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/{number}/" 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 함) # 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함)
from .deps import get_pulp_client, templates from .deps import get_pulp_client, templates
from .pulp_client import PulpClient from .pulp_client import PulpClient
from .routes import dashboard, sync, versions from .routes import dashboard, deploy, sync, versions
BASE_DIR = Path(__file__).resolve().parent 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(dashboard.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(versions.router) app.include_router(versions.router)
app.include_router(deploy.router)
__all__ = ["app", "get_pulp_client"] __all__ = ["app", "get_pulp_client"]

View File

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import time
from typing import Any from typing import Any
import httpx import httpx
@@ -105,3 +106,39 @@ class PulpClient:
def get_task(self, task_href: str) -> dict[str, Any]: def get_task(self, task_href: str) -> dict[str, Any]:
"""task 단건 조회 (state, progress_reports, created_resources, error).""" """task 단건 조회 (state, progress_reports, created_resources, error)."""
return self.get(task_href) 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 목록 페이지(최신순). GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순).
현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다 현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다
(스펙 §4). 배포 버튼 활성화/확인은 단계 4에서 연결. (스펙 §4). 배포 버튼 활성화/확인은 routes/deploy.py(단계 4).
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -45,34 +47,34 @@ def _deployed_version_number(pulp: PulpClient, uuid: str) -> int | None:
return 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) @router.get("/repos/{uuid}/versions", response_class=HTMLResponse)
def versions( def versions(
request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client) request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client)
) -> HTMLResponse: ) -> HTMLResponse:
try: try:
repo = pulp.get_repo(uuid) ctx = load_version_context(pulp, uuid)
raw_versions = pulp.list_versions(uuid)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"versions.html", "versions.html",
{"repo_name": uuid, "uuid": uuid, "error": str(exc)}, {"repo_name": uuid, "uuid": uuid, "error": str(exc)},
) )
return templates.TemplateResponse(request, "versions.html", ctx)
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,
},
)

View File

@@ -243,6 +243,15 @@ button:disabled,
opacity: 0.45; 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 { .sync-progress {
display: flex; 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 %} {% if versions %}
<table> <table>
<thead> <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.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 v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}</td>
<td> <td>
{% if not v.is_deployed %} {% if v.is_deployed %}
<!-- 배포 버튼 활성화/확인 모달은 단계4에서 연결 --> {# 배포 상태 칸에 배지 표시 #}
<button class="secondary" disabled>이 버전 배포</button> {% 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 %} {% endif %}
</td> </td>
</tr> </tr>

View File

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

View File

@@ -85,14 +85,44 @@ def build_version_view(
호출부에서 repo 의 gpg_status() 를 주입한다. 호출부에서 repo 의 gpg_status() 를 주입한다.
""" """
number = version_number(version.get("pulp_href")) 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 { return {
"number": number, "number": number,
"href": version.get("pulp_href"),
"created": version.get("pulp_created"), "created": version.get("pulp_created"),
"package_count": _content_count(version, "present"), "package_count": _content_count(version, "present"),
"added": _content_count(version, "added"), "added": _content_count(version, "added"),
"removed": _content_count(version, "removed"), "removed": _content_count(version, "removed"),
"gpg": gpg, "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 "",
} }

167
tests/test_deploy.py Normal file
View File

@@ -0,0 +1,167 @@
import json
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app, get_pulp_client
client = TestClient(app)
VH = "/pulp/api/v3/repositories/rpm/rpm/abc/versions/3/"
REPO = {"name": "rocky9-baseos", "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}}
VERSIONS = [
{
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/",
"content_summary": {"present": {"rpm.package": {"count": 4700}}},
},
{
"pulp_href": VH,
"content_summary": {"present": {"rpm.package": {"count": 4821}}},
},
]
DISTRIBUTIONS = [
{
"pulp_href": "/pulp/api/v3/distributions/rpm/rpm/dabc/",
"publication": "/pulp/api/v3/publications/rpm/rpm/abc-2/",
}
]
PUBLICATIONS = {
"/pulp/api/v3/publications/rpm/rpm/abc-2/": {
"repository_version": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/"
}
}
class _FakePulp:
def __init__(
self,
repo=None,
distributions=None,
pub_state="completed",
dist_state="completed",
pub_created=True,
):
self._repo = REPO if repo is None else repo
self._distributions = DISTRIBUTIONS if distributions is None else distributions
self._pub_state = pub_state
self._dist_state = dist_state
self._pub_created = pub_created
self.created_with = None
self.dist_args = None
def get_repo(self, uuid):
return self._repo
def list_versions(self, uuid):
return VERSIONS
def list_distributions(self):
return self._distributions
def get_publication(self, href):
return PUBLICATIONS.get(href, {})
def create_publication(self, version_href):
self.created_with = version_href
return "/pulp/api/v3/tasks/pub/"
def update_distribution(self, dist_href, publication_href):
self.dist_args = (dist_href, publication_href)
return "/pulp/api/v3/tasks/dist/"
def wait_for_task(self, task_href, attempts=120, delay=1.0):
if "pub" in task_href:
created = (
["/pulp/api/v3/publications/rpm/rpm/new/"] if self._pub_created else []
)
return {"state": self._pub_state, "created_resources": created}
return {"state": self._dist_state, "created_resources": []}
def _use(fake):
app.dependency_overrides[get_pulp_client] = lambda: fake
def teardown_function():
app.dependency_overrides.clear()
def _post(confirm_name):
return client.post(
"/repos/abc/deploy",
data={"version_href": VH, "confirm_name": confirm_name},
)
def test_confirm_modal_shows_preview_and_type_to_confirm():
_use(_FakePulp())
resp = client.get("/repos/abc/deploy/confirm", params={"version_href": VH})
assert resp.status_code == 200
body = resp.text
assert "운영망에 배포" in body
assert "rocky9-baseos" in body # type-to-confirm 대상 이름
assert "배포 확정" in body
assert "+121" in body # 4821 - 4700 미리보기
def test_deploy_blocked_when_not_verified():
_use(_FakePulp(repo={"name": "r", "repo_config": {"gpgcheck": 0}}))
resp = client.post(
"/repos/abc/deploy", data={"version_href": VH, "confirm_name": "r"}
)
assert resp.status_code == 403
assert "검증" in resp.text
def test_deploy_rejects_name_mismatch():
_use(_FakePulp())
resp = _post("wrong-name")
assert resp.status_code == 400
assert "이름이 일치" in resp.text
def test_deploy_requires_existing_distribution():
_use(_FakePulp(distributions=[]))
resp = _post("rocky9-baseos")
assert resp.status_code == 404
assert "distribution" in resp.text
def test_deploy_publication_failure_keeps_prod_unchanged():
_use(_FakePulp(pub_state="failed"))
resp = _post("rocky9-baseos")
assert resp.status_code == 502
assert "운영망은 변경되지 않았습니다" in resp.text
def test_deploy_distribution_failure_reported():
_use(_FakePulp(dist_state="failed"))
resp = _post("rocky9-baseos")
assert resp.status_code == 502
assert "운영망 변경에 실패" in resp.text
def test_deploy_success_writes_audit_and_refreshes_list(tmp_path, monkeypatch):
monkeypatch.setenv("AUDIT_LOG_PATH", str(tmp_path / "audit.jsonl"))
get_settings.cache_clear()
fake = _FakePulp()
_use(fake)
resp = _post("rocky9-baseos")
assert resp.status_code == 200
assert "배포 완료" in resp.text
assert 'hx-swap-oob="true"' in resp.text # 버전 목록 OOB 갱신
# publication 은 대상 버전으로, distribution 은 새 publication 으로 교체
assert fake.created_with == VH
assert fake.dist_args == (
"/pulp/api/v3/distributions/rpm/rpm/dabc/",
"/pulp/api/v3/publications/rpm/rpm/new/",
)
# 감사 로그: from=2, to=3, repo 이름 기록
entry = json.loads((tmp_path / "audit.jsonl").read_text(encoding="utf-8").strip())
assert entry["action"] == "deploy"
assert entry["from_version"] == 2
assert entry["to_version"] == 3
assert entry["repo_name"] == "rocky9-baseos"

View File

@@ -61,3 +61,43 @@ def test_get_task_returns_state():
with PulpClient() as pulp: with PulpClient() as pulp:
task = pulp.get_task("/pulp/api/v3/tasks/t1/") task = pulp.get_task("/pulp/api/v3/tasks/t1/")
assert task["state"] == "running" assert task["state"] == "running"
@respx.mock
def test_create_publication_posts_version_and_returns_task():
import json
route = respx.post(f"{PULP_TEST_URL}/pulp/api/v3/publications/rpm/rpm/").mock(
return_value=httpx.Response(202, json={"task": "/pulp/api/v3/tasks/p1/"})
)
vh = "/pulp/api/v3/repositories/rpm/rpm/abc/versions/3/"
with PulpClient() as pulp:
href = pulp.create_publication(vh)
assert href == "/pulp/api/v3/tasks/p1/"
assert json.loads(route.calls.last.request.content) == {"repository_version": vh}
@respx.mock
def test_update_distribution_patches_publication_and_returns_task():
import json
dist = "/pulp/api/v3/distributions/rpm/rpm/d1/"
route = respx.patch(f"{PULP_TEST_URL}{dist}").mock(
return_value=httpx.Response(202, json={"task": "/pulp/api/v3/tasks/d1/"})
)
with PulpClient() as pulp:
href = pulp.update_distribution(dist, "/pulp/api/v3/publications/rpm/rpm/p1/")
assert href == "/pulp/api/v3/tasks/d1/"
assert json.loads(route.calls.last.request.content) == {
"publication": "/pulp/api/v3/publications/rpm/rpm/p1/"
}
@respx.mock
def test_wait_for_task_returns_on_terminal_state():
respx.get(f"{PULP_TEST_URL}/pulp/api/v3/tasks/t1/").mock(
return_value=httpx.Response(200, json={"state": "completed"})
)
with PulpClient() as pulp:
task = pulp.wait_for_task("/pulp/api/v3/tasks/t1/", attempts=1, delay=0)
assert task["state"] == "completed"

View File

@@ -135,3 +135,35 @@ def test_verification_detail():
assert d["gpgcheck"] == 1 assert d["gpgcheck"] == 1
assert d["repo_gpgcheck"] == 0 assert d["repo_gpgcheck"] == 0
assert d["status"]["level"] == "warn" assert d["status"]["level"] == "warn"
def test_build_version_view_deployable_only_when_verified_and_not_deployed():
version = {"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/x/versions/3/"}
verified = {"level": "pass", "label": "검증됨"}
unverified = {"level": "unset", "label": "미설정"}
# 검증 통과 + 미배포 → 배포 가능
assert views.build_version_view(version, 2, verified)["deployable"] is True
# 검증 통과 but 현재 배포 중 → 불가
assert views.build_version_view(version, 3, verified)["deployable"] is False
# 미검증 → 불가
assert views.build_version_view(version, 2, unverified)["deployable"] is False
def test_deploy_preview_net_change():
vv = [
{"number": 3, "package_count": 4821},
{"number": 2, "package_count": 4700},
]
p = views.deploy_preview(vv, target_number=3, deployed_number=2)
assert (p["current_version"], p["current_count"]) == (2, 4700)
assert (p["target_version"], p["target_count"]) == (3, 4821)
assert p["net"] == 121
assert p["net_sign"] == "+"
def test_deploy_preview_handles_no_current_deploy():
vv = [{"number": 1, "package_count": 50}]
p = views.deploy_preview(vv, target_number=1, deployed_number=None)
assert p["current_count"] == 0
assert p["net"] == 50
assert p["net_sign"] == "+"