From 72c75252d300d29ac787b2cf03e7280fc4c11566 Mon Sep 17 00:00:00 2001 From: Hyemin Lee Date: Wed, 17 Jun 2026 11:15:31 +0900 Subject: [PATCH] feat: version list with verification + deployed badge (stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- PLAN.md | 2 +- app/demo.py | 52 ++++++++++++ app/main.py | 3 +- app/pulp_client.py | 17 ++++ app/routes/versions.py | 78 +++++++++++++++++ app/static/app.css | 23 +++++ app/templates/partials/repo_list.html | 2 +- app/templates/partials/version_list.html | 41 +++++++++ app/templates/versions.html | 24 ++++++ app/views.py | 44 +++++++++- tests/test_versions.py | 104 +++++++++++++++++++++++ tests/test_views.py | 34 ++++++++ 12 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 app/routes/versions.py create mode 100644 app/templates/partials/version_list.html create mode 100644 app/templates/versions.html create mode 100644 tests/test_versions.py diff --git a/PLAN.md b/PLAN.md index a3a2623..5617481 100644 --- a/PLAN.md +++ b/PLAN.md @@ -94,7 +94,7 @@ --- -## [ ] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반) +## [x] 단계 3 — 버전(스냅샷) 목록 + 검증 배지 (화면 3 전반) **목표:** 저장소별 RepositoryVersion 목록을 최신순으로, 검증/배포 상태와 함께 표시. diff --git a/app/demo.py b/app/demo.py index a180f90..66605e1 100644 --- a/app/demo.py +++ b/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}/" + ) + } diff --git a/app/main.py b/app/main.py index 566d690..dbc7b74 100644 --- a/app/main.py +++ b/app/main.py @@ -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"] diff --git a/app/pulp_client.py b/app/pulp_client.py index 8ad0fd7..1918e30 100644 --- a/app/pulp_client.py +++ b/app/pulp_client.py @@ -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}/") diff --git a/app/routes/versions.py b/app/routes/versions.py new file mode 100644 index 0000000..f299710 --- /dev/null +++ b/app/routes/versions.py @@ -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, + }, + ) diff --git a/app/static/app.css b/app/static/app.css index 94eeae2..941b865 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -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, diff --git a/app/templates/partials/repo_list.html b/app/templates/partials/repo_list.html index 3b5d22a..57834e3 100644 --- a/app/templates/partials/repo_list.html +++ b/app/templates/partials/repo_list.html @@ -25,7 +25,7 @@ {% for repo in repos %} - {{ repo.name }} + {{ repo.name }} {{ repo.gpg.label }} {% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %} {% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %} diff --git a/app/templates/partials/version_list.html b/app/templates/partials/version_list.html new file mode 100644 index 0000000..c19d1e4 --- /dev/null +++ b/app/templates/partials/version_list.html @@ -0,0 +1,41 @@ +
+ {% if versions %} + + + + + + + + + + + + + + {% for v in versions %} + + + + + + + + + + {% endfor %} + +
버전생성일시패키지증감검증배포 상태
v{{ v.number }}{{ v.created or "—" }}{{ v.package_count }} + {%- if v.added %}+{{ v.added }}{% endif -%} + {%- if v.removed %} −{{ v.removed }}{% endif -%} + {%- if not v.added and not v.removed %}—{% endif -%} + {% if v.gpg %}{{ v.gpg.label }}{% endif %}{% if v.is_deployed %}운영 배포 중{% endif %} + {% if not v.is_deployed %} + + + {% endif %} +
+ {% else %} +

버전이 없습니다. 먼저 동기화를 실행하세요.

+ {% endif %} +
diff --git a/app/templates/versions.html b/app/templates/versions.html new file mode 100644 index 0000000..81c18e1 --- /dev/null +++ b/app/templates/versions.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +

← 대시보드

+ +
+

{{ repo_name }} · 버전

+ + {% if error %} +

버전 목록을 불러오지 못했습니다: {{ error }}

+ {% else %} +
+ {{ verification.status.label }} + + gpgcheck={{ verification.gpgcheck }} · + repo-gpgcheck={{ verification.repo_gpgcheck }} · + checksum={{ verification.checksum_type }} + +
+ + {% include "partials/version_list.html" %} + {% endif %} +
+{% endblock %} diff --git a/app/views.py b/app/views.py index 5aa4bcc..706e80c 100644 --- a/app/views.py +++ b/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 { diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..20e75a1 --- /dev/null +++ b/tests/test_versions.py @@ -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 diff --git a/tests/test_views.py b/tests/test_views.py index 797047c..a522eed 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -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"