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:
2
PLAN.md
2
PLAN.md
@@ -94,7 +94,7 @@
|
||||
|
||||
---
|
||||
|
||||
## [ ] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반)
|
||||
## [x] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반)
|
||||
|
||||
**목표:** 저장소별 RepositoryVersion 목록을 최신순으로, 검증/배포 상태와 함께 표시.
|
||||
|
||||
|
||||
52
app/demo.py
52
app/demo.py
@@ -69,6 +69,9 @@ class DemoPulpClient:
|
||||
return _VERSIONS.get(version_href, {})
|
||||
|
||||
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/"}
|
||||
|
||||
def sync_repo(self, uuid: str, remote_href: str) -> str:
|
||||
@@ -81,3 +84,52 @@ class DemoPulpClient:
|
||||
"progress_reports": [{"done": 120, "total": 120}],
|
||||
"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}/"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
from .routes import dashboard, sync, versions
|
||||
|
||||
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.include_router(dashboard.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(versions.router)
|
||||
|
||||
__all__ = ["app", "get_pulp_client"]
|
||||
|
||||
|
||||
@@ -69,6 +69,23 @@ class PulpClient:
|
||||
"""RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함."""
|
||||
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]:
|
||||
"""저장소 단건 조회 (remote href 등 확인용)."""
|
||||
return self.get(f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/")
|
||||
|
||||
78
app/routes/versions.py
Normal file
78
app/routes/versions.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -205,6 +205,29 @@ th.num {
|
||||
color: var(--text-muted);
|
||||
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) */
|
||||
button.secondary,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tbody>
|
||||
{% for repo in repos %}
|
||||
<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 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>
|
||||
|
||||
41
app/templates/partials/version_list.html
Normal file
41
app/templates/partials/version_list.html
Normal 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>
|
||||
24
app/templates/versions.html
Normal file
24
app/templates/versions.html
Normal 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 %}
|
||||
44
app/views.py
44
app/views.py
@@ -37,12 +37,17 @@ def version_number(version_href: str | None) -> int | 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:
|
||||
"""RepositoryVersion 의 content_summary.present 카운트 합계."""
|
||||
if version is None:
|
||||
return None
|
||||
present = (version.get("content_summary") or {}).get("present") or {}
|
||||
return sum((info or {}).get("count", 0) for info in present.values())
|
||||
return _content_count(version, "present")
|
||||
|
||||
|
||||
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]:
|
||||
"""상단 요약 카드 수치. syncing 은 task_store.active_count() 에서 주입."""
|
||||
return {
|
||||
|
||||
104
tests/test_versions.py
Normal file
104
tests/test_versions.py
Normal 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
|
||||
@@ -101,3 +101,37 @@ def test_task_progress_failed_surfaces_error():
|
||||
p = views.task_progress({"state": "failed", "error": {"description": "boom"}})
|
||||
assert p["failed"] is True
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user