feat: version list with verification + deployed badge (stage 3)

- pulp_client: list_versions / list_distributions / get_publication
- GET /repos/{uuid}/versions: 버전 목록 페이지(최신순)
- 현재 운영 배포 버전 판별: Distribution→publication→repository_version 역추적
- views.build_version_view: vN, 생성일시, 패키지 수, 증감(+/-), 검증 배지, 배포 여부
- views.verification_detail: gpgcheck/repo-gpgcheck/checksum 상세 (화면4)
- 대시보드 저장소명 → 버전 페이지 링크, [이 버전 배포] 버튼은 단계4용 placeholder
- 단계1 TODO(배포 버전 역추적) 해소

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:15:31 +09:00
parent 6c41b89c53
commit 72c75252d3
12 changed files with 419 additions and 5 deletions

View File

@@ -94,7 +94,7 @@
--- ---
## [ ] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반) ## [x] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반)
**목표:** 저장소별 RepositoryVersion 목록을 최신순으로, 검증/배포 상태와 함께 표시. **목표:** 저장소별 RepositoryVersion 목록을 최신순으로, 검증/배포 상태와 함께 표시.

View File

@@ -69,6 +69,9 @@ class DemoPulpClient:
return _VERSIONS.get(version_href, {}) return _VERSIONS.get(version_href, {})
def get_repo(self, uuid: str) -> dict[str, Any]: def get_repo(self, uuid: str) -> dict[str, Any]:
for r in _REPOS:
if r["pulp_href"].rstrip("/").endswith(uuid):
return {**r, "remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"}
return {"remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} return {"remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"}
def sync_repo(self, uuid: str, remote_href: str) -> str: def sync_repo(self, uuid: str, remote_href: str) -> str:
@@ -81,3 +84,52 @@ class DemoPulpClient:
"progress_reports": [{"done": 120, "total": 120}], "progress_reports": [{"done": 120, "total": 120}],
"created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"],
} }
def list_versions(self, uuid: str) -> list[dict[str, Any]]:
base = f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/"
return [
{
"pulp_href": base + "1/",
"pulp_created": "2026-05-02T01:00:00Z",
"content_summary": {
"present": {"rpm.package": {"count": 4500}},
"added": {"rpm.package": {"count": 4500}},
"removed": {},
},
},
{
"pulp_href": base + "2/",
"pulp_created": "2026-05-28T04:30:00Z",
"content_summary": {
"present": {"rpm.package": {"count": 4700}},
"added": {"rpm.package": {"count": 250}},
"removed": {"rpm.package": {"count": 50}},
},
},
{
"pulp_href": base + "3/",
"pulp_created": "2026-06-15T02:11:00Z",
"content_summary": {
"present": {"rpm.package": {"count": 4821}},
"added": {"rpm.package": {"count": 130}},
"removed": {"rpm.package": {"count": 9}},
},
},
]
def list_distributions(self) -> list[dict[str, Any]]:
# 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩)
deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1}
return [
{"publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/"}
for u, v in deployed.items()
]
def get_publication(self, publication_href: str) -> dict[str, Any]:
tail = publication_href.rstrip("/").split("/")[-1] # "0001-2"
uuid, number = tail.split("-")
return {
"repository_version": (
f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/{number}/"
)
}

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 from .routes import dashboard, sync, versions
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@@ -23,6 +23,7 @@ app = FastAPI(title="Pulp 패치 관리 콘솔")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
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)
__all__ = ["app", "get_pulp_client"] __all__ = ["app", "get_pulp_client"]

View File

@@ -69,6 +69,23 @@ class PulpClient:
"""RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함.""" """RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함."""
return self.get(version_href) return self.get(version_href)
def list_versions(self, uuid: str) -> list[dict[str, Any]]:
"""저장소의 RepositoryVersion 목록: GET .../{uuid}/versions/ → results."""
data = self.get(f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/versions/")
return data.get("results", [])
def list_distributions(self) -> list[dict[str, Any]]:
"""Distribution 목록: GET /pulp/api/v3/distributions/rpm/rpm/ → results.
운영자가 yum/dnf baseurl 로 바라보는 공개 URL들. publication 으로 배포 버전 추적.
"""
data = self.get(f"{API_PREFIX}/distributions/rpm/rpm/")
return data.get("results", [])
def get_publication(self, publication_href: str) -> dict[str, Any]:
"""Publication 단건 조회 (repository_version 역추적용)."""
return self.get(publication_href)
def get_repo(self, uuid: str) -> dict[str, Any]: def get_repo(self, uuid: str) -> dict[str, Any]:
"""저장소 단건 조회 (remote href 등 확인용).""" """저장소 단건 조회 (remote href 등 확인용)."""
return self.get(f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/") return self.get(f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/")

78
app/routes/versions.py Normal file
View File

@@ -0,0 +1,78 @@
"""버전(스냅샷) 목록 + 검증 배지 (화면 3 전반).
GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순).
현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다
(스펙 §4). 배포 버튼 활성화/확인은 단계 4에서 연결.
"""
from __future__ import annotations
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from .. import views
from ..deps import get_pulp_client, templates
from ..pulp_client import PulpClient
router = APIRouter()
def _deployed_version_number(pulp: PulpClient, uuid: str) -> int | None:
"""이 저장소가 현재 운영 배포 중인 버전 번호. 없거나 조회 실패 시 None.
Distribution.publication → Publication.repository_version 이 이 저장소의
버전을 가리키면 그 번호를 반환.
"""
needle = f"/repositories/rpm/rpm/{uuid}/versions/"
try:
distributions = pulp.list_distributions()
except httpx.HTTPError:
return None
for dist in distributions:
pub_href = dist.get("publication")
if not pub_href:
continue # repository 직접 배포(최신 추종)는 단계4 TODO
try:
pub = pulp.get_publication(pub_href)
except httpx.HTTPError:
continue
rv = pub.get("repository_version") or ""
if needle in rv:
return views.version_number(rv)
return 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)
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,
},
)

View File

@@ -205,6 +205,29 @@ th.num {
color: var(--text-muted); color: var(--text-muted);
background: var(--surface); background: var(--surface);
} }
.badge-deployed {
color: var(--accent-hover);
background: var(--accent-soft);
}
/* 버전 검증 상세 */
.verify-detail {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.verify-detail small {
color: var(--text-muted);
}
/* 패키지 증감 */
.delta-add {
color: var(--success-text);
}
.delta-del {
color: var(--danger);
}
/* 보조 버튼: 틴티드 블루 (design-ref §4 two-step blue) */ /* 보조 버튼: 틴티드 블루 (design-ref §4 two-step blue) */
button.secondary, button.secondary,

View File

@@ -25,7 +25,7 @@
<tbody> <tbody>
{% for repo in repos %} {% for repo in repos %}
<tr> <tr>
<td class="repo-name">{{ repo.name }}</td> <td class="repo-name"><a href="/repos/{{ repo.uuid }}/versions">{{ repo.name }}</a></td>
<td><span class="badge badge-{{ repo.gpg.level }}">{{ repo.gpg.label }}</span></td> <td><span class="badge badge-{{ repo.gpg.level }}">{{ repo.gpg.label }}</span></td>
<td class="num">{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %}</td> <td class="num">{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %}</td>
<td class="num">{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %}</td> <td class="num">{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %}</td>

View File

@@ -0,0 +1,41 @@
<div id="version-list" class="table-card">
{% if versions %}
<table>
<thead>
<tr>
<th class="num">버전</th>
<th>생성일시</th>
<th class="num">패키지</th>
<th class="num">증감</th>
<th>검증</th>
<th>배포 상태</th>
<th></th>
</tr>
</thead>
<tbody>
{% for v in versions %}
<tr>
<td class="num">v{{ v.number }}</td>
<td>{{ v.created or "—" }}</td>
<td class="num">{{ v.package_count }}</td>
<td class="num">
{%- if v.added %}<span class="delta-add">+{{ v.added }}</span>{% endif -%}
{%- if v.removed %} <span class="delta-del">{{ v.removed }}</span>{% endif -%}
{%- if not v.added and not v.removed %}—{% 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 not v.is_deployed %}
<!-- 배포 버튼 활성화/확인 모달은 단계4에서 연결 -->
<button class="secondary" disabled>이 버전 배포</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="padding:16px;">버전이 없습니다. 먼저 동기화를 실행하세요.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<p><a href="/">← 대시보드</a></p>
<section>
<h2>{{ repo_name }} · 버전</h2>
{% if error %}
<p class="status-line status-bad">버전 목록을 불러오지 못했습니다: {{ error }}</p>
{% else %}
<div class="verify-detail">
<span class="badge badge-{{ verification.status.level }}">{{ verification.status.label }}</span>
<small>
gpgcheck={{ verification.gpgcheck }} ·
repo-gpgcheck={{ verification.repo_gpgcheck }} ·
checksum={{ verification.checksum_type }}
</small>
</div>
{% include "partials/version_list.html" %}
{% endif %}
</section>
{% endblock %}

View File

@@ -37,12 +37,17 @@ def version_number(version_href: str | None) -> int | None:
return None return None
def _content_count(version: dict[str, Any], section: str) -> int:
"""content_summary[section] (present/added/removed) 의 카운트 합계."""
data = (version.get("content_summary") or {}).get(section) or {}
return sum((info or {}).get("count", 0) for info in data.values())
def package_count(version: dict[str, Any] | None) -> int | None: def package_count(version: dict[str, Any] | None) -> int | None:
"""RepositoryVersion 의 content_summary.present 카운트 합계.""" """RepositoryVersion 의 content_summary.present 카운트 합계."""
if version is None: if version is None:
return None return None
present = (version.get("content_summary") or {}).get("present") or {} return _content_count(version, "present")
return sum((info or {}).get("count", 0) for info in present.values())
def _uuid_from_href(href: str) -> str: def _uuid_from_href(href: str) -> str:
@@ -69,6 +74,41 @@ def build_repo_view(
} }
def build_version_view(
version: dict[str, Any],
deployed_number: int | None = None,
gpg: dict[str, str] | None = None,
) -> dict[str, Any]:
"""RepositoryVersion → 버전 목록 행 모델.
gpg 검증은 저장소 단위 설정(repo_config)이라 모든 버전이 같은 값을 공유한다 —
호출부에서 repo 의 gpg_status() 를 주입한다.
"""
number = version_number(version.get("pulp_href"))
return {
"number": number,
"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,
}
def verification_detail(repo: dict[str, Any]) -> dict[str, Any]:
"""검증 상태 상세 (화면 4): GPG/checksum 설정값 + 종합 배지."""
cfg = repo.get("repo_config") or {}
return {
"status": gpg_status(repo),
"gpgcheck": int(bool(cfg.get("gpgcheck", 0))),
"repo_gpgcheck": int(bool(cfg.get("repo-gpgcheck", 0))),
"checksum_type": repo.get("metadata_checksum_type")
or cfg.get("checksum_type")
or "",
}
def summarize(repo_views: list[dict[str, Any]], syncing: int = 0) -> dict[str, int]: def summarize(repo_views: list[dict[str, Any]], syncing: int = 0) -> dict[str, int]:
"""상단 요약 카드 수치. syncing 은 task_store.active_count() 에서 주입.""" """상단 요약 카드 수치. syncing 은 task_store.active_count() 에서 주입."""
return { return {

104
tests/test_versions.py Normal file
View File

@@ -0,0 +1,104 @@
import httpx
from fastapi.testclient import TestClient
from app.main import app, get_pulp_client
client = TestClient(app)
REPO = {"name": "rocky9-baseos", "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}}
VERSIONS = [
{
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/1/",
"pulp_created": "2026-05-01T00:00:00Z",
"content_summary": {"present": {"rpm.package": {"count": 100}}},
},
{
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/",
"pulp_created": "2026-06-01T00:00:00Z",
"content_summary": {"present": {"rpm.package": {"count": 150}}},
},
]
# v2 가 배포 중: distribution → publication → repository_version v2
DISTRIBUTIONS = [{"publication": "/pulp/api/v3/publications/rpm/rpm/p1/"}]
PUBLICATIONS = {
"/pulp/api/v3/publications/rpm/rpm/p1/": {
"repository_version": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/"
}
}
class _FakePulp:
def __init__(
self,
repo=None,
versions=None,
distributions=None,
publications=None,
versions_raises=False,
):
self._repo = REPO if repo is None else repo
self._versions = VERSIONS if versions is None else versions
self._distributions = DISTRIBUTIONS if distributions is None else distributions
self._publications = PUBLICATIONS if publications is None else publications
self._versions_raises = versions_raises
def get_repo(self, uuid):
return self._repo
def list_versions(self, uuid):
if self._versions_raises:
raise httpx.ConnectError("down")
return self._versions
def list_distributions(self):
return self._distributions
def get_publication(self, href):
return self._publications.get(href, {})
def _use(fake):
app.dependency_overrides[get_pulp_client] = lambda: fake
def teardown_function():
app.dependency_overrides.clear()
def test_versions_page_lists_newest_first():
_use(_FakePulp())
resp = client.get("/repos/abc/versions")
assert resp.status_code == 200
body = resp.text
assert "rocky9-baseos" in body
assert "v1" in body and "v2" in body
# 최신순: v2 가 v1 보다 먼저 나온다
assert body.index("v2") < body.index("v1")
def test_versions_page_marks_deployed_version():
_use(_FakePulp())
body = client.get("/repos/abc/versions").text
assert "운영 배포 중" in body
# 배포 중이 아닌 v1 에는 배포 버튼(비활성)이 있다
assert "이 버전 배포" in body
def test_versions_page_no_deployed_when_no_distribution():
_use(_FakePulp(distributions=[]))
body = client.get("/repos/abc/versions").text
assert "운영 배포 중" not in body
def test_versions_page_shows_verification_detail():
_use(_FakePulp())
body = client.get("/repos/abc/versions").text
assert "검증됨" in body
assert "gpgcheck=1" in body
def test_versions_page_error_when_list_fails():
_use(_FakePulp(versions_raises=True))
resp = client.get("/repos/abc/versions")
assert resp.status_code == 200
assert "불러오지 못했습니다" in resp.text

View File

@@ -101,3 +101,37 @@ def test_task_progress_failed_surfaces_error():
p = views.task_progress({"state": "failed", "error": {"description": "boom"}}) p = views.task_progress({"state": "failed", "error": {"description": "boom"}})
assert p["failed"] is True assert p["failed"] is True
assert p["error"] == "boom" assert p["error"] == "boom"
def test_build_version_view_with_deltas_and_deploy_flag():
version = {
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/x/versions/3/",
"pulp_created": "2026-06-01T00:00:00Z",
"content_summary": {
"present": {"rpm.package": {"count": 4821}},
"added": {"rpm.package": {"count": 130}},
"removed": {"rpm.package": {"count": 9}},
},
}
gpg = {"level": "pass", "label": "검증됨"}
v = views.build_version_view(version, deployed_number=3, gpg=gpg)
assert v["number"] == 3
assert v["package_count"] == 4821
assert (v["added"], v["removed"]) == (130, 9)
assert v["is_deployed"] is True
assert v["gpg"]["level"] == "pass"
def test_build_version_view_not_deployed():
version = {"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/x/versions/2/"}
v = views.build_version_view(version, deployed_number=3)
assert v["is_deployed"] is False
assert v["added"] == 0 and v["removed"] == 0
def test_verification_detail():
repo = {"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 0}}
d = views.verification_detail(repo)
assert d["gpgcheck"] == 1
assert d["repo_gpgcheck"] == 0
assert d["status"]["level"] == "warn"