feat: FastAPI console with dashboard, Pulp client, healthz (stages 0-1)
- config: PULP_* env + 사내 CA(verify=) 대응 - pulp_client: Basic Auth get/post/patch, status/list_repos/get_version - BFF 라우트: GET / (대시보드), GET /repos (조각), GET /healthz - views: Pulp JSON → 표시 모델 (GPG 검증 배지 3단계, 요약 집계) - HTMX + Jinja 템플릿, vendored pico.css/htmx.js (CDN 의존 0) - 브라우저는 Pulp 직접 호출 안 함 — 모두 BFF 경유 (스펙 §2) - 테스트: pulp_client(respx), 라우트(dependency_overrides), views 순수함수 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
37
app/config.py
Normal file
37
app/config.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""환경변수 기반 설정 (스펙 §8).
|
||||||
|
|
||||||
|
사내망 TLS(self-signed CA) 대응을 처음부터 넣어둔다 — httpx 의 verify 인자에
|
||||||
|
CA 파일 경로 또는 bool 을 그대로 넘길 수 있게 ``tls_verify`` 로 변환한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
pulp_base_url: str = "http://localhost:8080"
|
||||||
|
pulp_username: str = "admin"
|
||||||
|
pulp_password: str = ""
|
||||||
|
pulp_verify_tls: bool = True
|
||||||
|
pulp_ca_file: str | None = None
|
||||||
|
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능)
|
||||||
|
pulp_demo: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def tls_verify(settings: Settings) -> str | bool:
|
||||||
|
"""httpx ``verify=`` 에 넘길 값. CA 파일 경로가 있으면 우선, 없으면 bool."""
|
||||||
|
if settings.pulp_ca_file:
|
||||||
|
return settings.pulp_ca_file
|
||||||
|
return settings.pulp_verify_tls
|
||||||
33
app/deps.py
Normal file
33
app/deps.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""공용 의존성: 템플릿 엔진, 요청 단위 Pulp 클라이언트.
|
||||||
|
|
||||||
|
라우터 모듈들이 main 을 import 하지 않도록 여기로 분리(순환참조 방지).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .pulp_client import PulpClient
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def get_pulp_client() -> Iterator[PulpClient]:
|
||||||
|
"""요청 단위 Pulp 클라이언트. 테스트에서 dependency_overrides 로 교체한다."""
|
||||||
|
if get_settings().pulp_demo:
|
||||||
|
# 임시 데모 모드: 가짜 데이터 (단계5 전 제거 가능)
|
||||||
|
from .demo import DemoPulpClient
|
||||||
|
|
||||||
|
yield DemoPulpClient() # type: ignore[misc]
|
||||||
|
return
|
||||||
|
|
||||||
|
pulp = PulpClient()
|
||||||
|
try:
|
||||||
|
yield pulp
|
||||||
|
finally:
|
||||||
|
pulp.close()
|
||||||
45
app/main.py
Normal file
45
app/main.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""FastAPI 진입점: 정적 자산 마운트, 라우터 등록, 헬스체크.
|
||||||
|
|
||||||
|
화면용 라우트는 JSON 이 아니라 HTML(전체 페이지 또는 조각)을 반환한다(스펙 §7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
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
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
app = FastAPI(title="Pulp 패치 관리 콘솔")
|
||||||
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
|
||||||
|
__all__ = ["app", "get_pulp_client"]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", response_class=HTMLResponse)
|
||||||
|
def healthz(
|
||||||
|
request: Request, pulp: PulpClient = Depends(get_pulp_client)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Pulp status 프록시 → 연결 상태 배지 조각."""
|
||||||
|
try:
|
||||||
|
pulp.status()
|
||||||
|
online, detail = True, "Pulp 연결됨"
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
online, detail = False, f"Pulp 연결 실패: {exc}"
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/health.html",
|
||||||
|
{"online": online, "detail": detail},
|
||||||
|
status_code=200 if online else 503,
|
||||||
|
)
|
||||||
68
app/pulp_client.py
Normal file
68
app/pulp_client.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Pulp REST API 호출 래퍼 (스펙 §7).
|
||||||
|
|
||||||
|
브라우저가 아니라 이 BFF 가 Basic Auth 를 쥐고 Pulp 를 호출한다(스펙 §2).
|
||||||
|
객체는 이름이 아니라 ``pulp_href``(경로 문자열) 기준으로 참조한다(스펙 §4).
|
||||||
|
|
||||||
|
폴링은 화면(HTMX)이 담당하므로 여기서는 task 단발 조회만 둔다. sync/deploy 등
|
||||||
|
도메인 메서드는 이후 단계에서 추가한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import Settings, get_settings, tls_verify
|
||||||
|
|
||||||
|
API_PREFIX = "/pulp/api/v3"
|
||||||
|
|
||||||
|
|
||||||
|
class PulpClient:
|
||||||
|
def __init__(self, settings: Settings | None = None) -> None:
|
||||||
|
self.settings = settings or get_settings()
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=self.settings.pulp_base_url.rstrip("/"),
|
||||||
|
auth=(self.settings.pulp_username, self.settings.pulp_password),
|
||||||
|
verify=tls_verify(self.settings),
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> PulpClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# --- 저수준 호출 (Basic Auth 포함) ---------------------------------
|
||||||
|
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
resp = self._client.get(path, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def post(self, path: str, json: Any | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
resp = self._client.post(path, json=json, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def patch(self, href: str, json: Any | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
resp = self._client.patch(href, json=json, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# --- 도메인 헬퍼 ----------------------------------------------------
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
"""헬스체크: GET /pulp/api/v3/status/"""
|
||||||
|
return self.get(f"{API_PREFIX}/status/")
|
||||||
|
|
||||||
|
def list_repos(self) -> list[dict[str, Any]]:
|
||||||
|
"""RPM 저장소 목록: GET /pulp/api/v3/repositories/rpm/rpm/ → results."""
|
||||||
|
data = self.get(f"{API_PREFIX}/repositories/rpm/rpm/")
|
||||||
|
return data.get("results", [])
|
||||||
|
|
||||||
|
def get_version(self, version_href: str) -> dict[str, Any]:
|
||||||
|
"""RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함."""
|
||||||
|
return self.get(version_href)
|
||||||
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
56
app/routes/dashboard.py
Normal file
56
app/routes/dashboard.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""대시보드 (화면 1, 읽기 전용).
|
||||||
|
|
||||||
|
GET / → 전체 페이지(상태 + 요약 카드 + 저장소 목록)
|
||||||
|
GET /repos → 저장소 목록 조각 (새로고침/폴링용 partial)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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 _load_repos(pulp: PulpClient) -> dict[str, Any]:
|
||||||
|
"""저장소 목록을 읽어 표시 모델 + 요약 + (실패 시) 에러 메시지를 만든다."""
|
||||||
|
try:
|
||||||
|
repos = pulp.list_repos()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"repos": [], "summary": views.summarize([]), "error": str(exc)}
|
||||||
|
|
||||||
|
repo_views: list[dict[str, Any]] = []
|
||||||
|
for repo in repos:
|
||||||
|
version = None
|
||||||
|
vhref = repo.get("latest_version_href")
|
||||||
|
if vhref:
|
||||||
|
try:
|
||||||
|
version = pulp.get_version(vhref)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
version = None # 버전 상세 실패는 치명적이지 않음 — 행은 표시
|
||||||
|
repo_views.append(views.build_repo_view(repo, version))
|
||||||
|
|
||||||
|
return {"repos": repo_views, "summary": views.summarize(repo_views), "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def dashboard(
|
||||||
|
request: Request, pulp: PulpClient = Depends(get_pulp_client)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", _load_repos(pulp))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repos", response_class=HTMLResponse)
|
||||||
|
def repos_fragment(
|
||||||
|
request: Request, pulp: PulpClient = Depends(get_pulp_client)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/repo_list.html", _load_repos(pulp)
|
||||||
|
)
|
||||||
15
app/static/README.md
Normal file
15
app/static/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# static/ — 로컬 동봉 자산
|
||||||
|
|
||||||
|
폐쇄망 대비 CDN 의존 없이 실물을 여기 둔다(스펙 §9). 현재 동봉된 것:
|
||||||
|
|
||||||
|
- `pico.min.css` — Pico CSS v2 (베이스 스타일)
|
||||||
|
- `htmx.min.js` — htmx v2 (부분 갱신/폴링)
|
||||||
|
- `app.css` — 프로젝트 커스텀(@font-face, Pico 변수 오버라이드)
|
||||||
|
- `fonts/PretendardVariable.woff2` — Pretendard variable 폰트
|
||||||
|
|
||||||
|
반입/갱신 시 인터넷 가능 PC에서 받아 교체:
|
||||||
|
- Pico: https://github.com/picocss/pico
|
||||||
|
- htmx: https://unpkg.com/htmx.org/dist/htmx.min.js
|
||||||
|
- Pretendard: https://github.com/orioncactus/pretendard (dist/web/variable/woff2)
|
||||||
|
|
||||||
|
사내망은 TLS 가로채기(boknet CA)가 있어 `curl` 시 `--ssl-no-revoke` 가 필요할 수 있다.
|
||||||
1
app/static/htmx.min.js
vendored
Normal file
1
app/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
app/static/pico.min.css
vendored
Normal file
4
app/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
22
app/templates/base.html
Normal file
22
app/templates/base.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Pulp 패치 관리 콘솔{% endblock %}</title>
|
||||||
|
<!-- 정적 자산은 폐쇄망 대비 local 동봉 (CDN 의존 금지, 스펙 §9) -->
|
||||||
|
<link rel="stylesheet" href="/static/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="app-nav">
|
||||||
|
<div class="container">
|
||||||
|
<p class="brand"><span class="accent">Pulp</span> 패치 관리 콘솔</p>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
app/templates/dashboard.html
Normal file
21
app/templates/dashboard.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
<h2>상태</h2>
|
||||||
|
<!-- 로드 시 1회 + 10초마다 Pulp 연결 상태 폴링 -->
|
||||||
|
<div hx-get="/healthz" hx-trigger="load, every 10s" hx-swap="innerHTML" style="margin-top:12px;">
|
||||||
|
<span class="status-line status-loading">연결 확인 중…</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>저장소</h2>
|
||||||
|
<button hx-get="/repos" hx-target="#repo-list" hx-swap="outerHTML" class="secondary">
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% include "partials/repo_list.html" %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
5
app/templates/partials/health.html
Normal file
5
app/templates/partials/health.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% if online %}
|
||||||
|
<span class="status-line status-ok">{{ detail }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-line status-bad">{{ detail }}</span>
|
||||||
|
{% endif %}
|
||||||
46
app/templates/partials/repo_list.html
Normal file
46
app/templates/partials/repo_list.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<div id="repo-list">
|
||||||
|
{% if error %}
|
||||||
|
<p role="alert" class="status-line status-bad">저장소 목록을 불러오지 못했습니다: {{ error }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="summary">
|
||||||
|
<div class="card"><div class="value">{{ summary.total }}</div><div class="label">전체 저장소</div></div>
|
||||||
|
<div class="card"><div class="value">{{ summary.syncing }}</div><div class="label">동기화 중</div></div>
|
||||||
|
<div class="card"><div class="value">{{ summary.gpg_pass }}</div><div class="label">GPG 검증 통과</div></div>
|
||||||
|
<div class="card"><div class="value">{{ summary.total_packages }}</div><div class="label">총 패키지</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if repos %}
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>저장소</th>
|
||||||
|
<th>검증</th>
|
||||||
|
<th class="num">최신 버전</th>
|
||||||
|
<th class="num">패키지 수</th>
|
||||||
|
<th>마지막 동기화</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for repo in repos %}
|
||||||
|
<tr>
|
||||||
|
<td class="repo-name">{{ repo.name }}</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>
|
||||||
|
<td>{{ repo.last_sync or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
<!-- 동기화 버튼은 단계2에서 동작 연결 -->
|
||||||
|
<button disabled class="secondary">동기화</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>등록된 저장소가 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
80
app/views.py
Normal file
80
app/views.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Pulp JSON → 화면용 표시 모델 변환 (순수 함수, httpx 의존 없음).
|
||||||
|
|
||||||
|
GPG 검증 배지 로직(✅/⚠️/⚪)은 화면 4 에서도 재사용하므로 여기 함수로 둔다(스펙 §6 화면4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def gpg_status(repo: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""repo_config 의 gpgcheck / repo-gpgcheck 로 3단계 검증 배지 산출.
|
||||||
|
|
||||||
|
- pass(✅): 패키지 서명 + repo 메타데이터 서명 모두 검증
|
||||||
|
- warn(⚠️): 둘 중 하나만 검증
|
||||||
|
- unset(⚪): 검증 미설정
|
||||||
|
"""
|
||||||
|
cfg = repo.get("repo_config") or {}
|
||||||
|
pkg = bool(cfg.get("gpgcheck", 0))
|
||||||
|
meta = bool(cfg.get("repo-gpgcheck", 0))
|
||||||
|
# level 은 색상 칩(badge-{level})으로 렌더 — 장식 이모지는 쓰지 않는다(design-ref §7).
|
||||||
|
if pkg and meta:
|
||||||
|
return {"level": "pass", "label": "검증됨"}
|
||||||
|
if pkg or meta:
|
||||||
|
return {"level": "warn", "label": "부분 검증"}
|
||||||
|
return {"level": "unset", "label": "미설정"}
|
||||||
|
|
||||||
|
|
||||||
|
def version_number(version_href: str | None) -> int | None:
|
||||||
|
"""latest_version_href 끝의 정수(vN) 추출. 예: .../versions/3/ → 3"""
|
||||||
|
if not version_href:
|
||||||
|
return None
|
||||||
|
parts = [p for p in version_href.split("/") if p]
|
||||||
|
try:
|
||||||
|
return int(parts[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_from_href(href: str) -> str:
|
||||||
|
parts = [p for p in href.split("/") if p]
|
||||||
|
return parts[-1] if parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_repo_view(
|
||||||
|
repo: dict[str, Any], version: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""저장소 1건 + (선택) 최신 버전 상세 → 대시보드 행 모델."""
|
||||||
|
return {
|
||||||
|
"name": repo.get("name", ""),
|
||||||
|
"uuid": _uuid_from_href(repo.get("pulp_href", "")),
|
||||||
|
"href": repo.get("pulp_href", ""),
|
||||||
|
"latest_version": version_number(repo.get("latest_version_href")),
|
||||||
|
"package_count": package_count(version),
|
||||||
|
# 최신 스냅샷 생성 시각 ≈ 마지막 동기화 시각
|
||||||
|
"last_sync": (version or {}).get("pulp_created"),
|
||||||
|
"gpg": gpg_status(repo),
|
||||||
|
# TODO(단계3/4): 현재 운영 배포 버전은 Distribution→publication→version 역추적 필요.
|
||||||
|
# 단계1에선 최신 버전만 표시한다.
|
||||||
|
"deployed_version": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(repo_views: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
|
"""상단 요약 카드 수치."""
|
||||||
|
return {
|
||||||
|
"total": len(repo_views),
|
||||||
|
# TODO(단계2): 진행 중 sync task 수를 메모리 task 맵에서 집계
|
||||||
|
"syncing": 0,
|
||||||
|
"gpg_pass": sum(1 for r in repo_views if r["gpg"]["level"] == "pass"),
|
||||||
|
"total_packages": sum(r["package_count"] or 0 for r in repo_views),
|
||||||
|
}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
PULP_TEST_URL = "http://pulp.test"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _settings_env(monkeypatch):
|
||||||
|
"""모든 테스트를 알려진 Pulp base_url 로 고정하고 settings 캐시를 초기화."""
|
||||||
|
monkeypatch.setenv("PULP_BASE_URL", PULP_TEST_URL)
|
||||||
|
monkeypatch.setenv("PULP_USERNAME", "admin")
|
||||||
|
monkeypatch.setenv("PULP_PASSWORD", "secret")
|
||||||
|
monkeypatch.setenv("PULP_VERIFY_TLS", "false")
|
||||||
|
monkeypatch.setenv("PULP_CA_FILE", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
73
tests/test_dashboard.py
Normal file
73
tests/test_dashboard.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, get_pulp_client
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
REPO = {
|
||||||
|
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/",
|
||||||
|
"name": "rocky9-baseos",
|
||||||
|
"latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/versions/2/",
|
||||||
|
"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1},
|
||||||
|
}
|
||||||
|
VERSION = {
|
||||||
|
"pulp_created": "2026-06-01T00:00:00Z",
|
||||||
|
"content_summary": {"present": {"rpm.package": {"count": 1500}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePulp:
|
||||||
|
def __init__(self, repos=None, version=None, fail=False):
|
||||||
|
self._repos = repos or []
|
||||||
|
self._version = version
|
||||||
|
self._fail = fail
|
||||||
|
|
||||||
|
def list_repos(self):
|
||||||
|
if self._fail:
|
||||||
|
raise httpx.ConnectError("connection refused")
|
||||||
|
return self._repos
|
||||||
|
|
||||||
|
def get_version(self, href):
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
|
||||||
|
def _use(fake):
|
||||||
|
app.dependency_overrides[get_pulp_client] = lambda: fake
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_lists_repos():
|
||||||
|
_use(_FakePulp(repos=[REPO], version=VERSION))
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.text
|
||||||
|
assert "rocky9-baseos" in body
|
||||||
|
assert "v2" in body
|
||||||
|
assert "1500" in body
|
||||||
|
assert "검증됨" in body # GPG pass 배지
|
||||||
|
|
||||||
|
|
||||||
|
def test_repos_fragment_standalone():
|
||||||
|
_use(_FakePulp(repos=[REPO], version=VERSION))
|
||||||
|
resp = client.get("/repos")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'id="repo-list"' in resp.text
|
||||||
|
assert "rocky9-baseos" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_empty():
|
||||||
|
_use(_FakePulp(repos=[]))
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "등록된 저장소가 없습니다" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_error_when_pulp_down():
|
||||||
|
_use(_FakePulp(fail=True))
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200 # 페이지는 뜨고 에러 메시지를 보여줌
|
||||||
|
assert "불러오지 못했습니다" in resp.text
|
||||||
36
tests/test_healthz.py
Normal file
36
tests/test_healthz.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, get_pulp_client
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeOnline:
|
||||||
|
def status(self):
|
||||||
|
return {"online": True}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDown:
|
||||||
|
def status(self):
|
||||||
|
raise httpx.ConnectError("connection refused")
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_online():
|
||||||
|
app.dependency_overrides[get_pulp_client] = lambda: _FakeOnline()
|
||||||
|
try:
|
||||||
|
resp = client.get("/healthz")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "연결됨" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_offline_when_pulp_down():
|
||||||
|
app.dependency_overrides[get_pulp_client] = lambda: _FakeDown()
|
||||||
|
try:
|
||||||
|
resp = client.get("/healthz")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "연결 실패" in resp.text
|
||||||
39
tests/test_pulp_client.py
Normal file
39
tests/test_pulp_client.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from app.config import get_settings, tls_verify
|
||||||
|
from app.pulp_client import PulpClient
|
||||||
|
from tests.conftest import PULP_TEST_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_verify_prefers_ca_file(monkeypatch):
|
||||||
|
monkeypatch.setenv("PULP_CA_FILE", "/etc/ssl/boknet-ca.pem")
|
||||||
|
monkeypatch.setenv("PULP_VERIFY_TLS", "false")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert tls_verify(get_settings()) == "/etc/ssl/boknet-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_verify_falls_back_to_bool():
|
||||||
|
# conftest: CA 비어있음, VERIFY_TLS=false
|
||||||
|
assert tls_verify(get_settings()) is False
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_status_returns_json():
|
||||||
|
route = respx.get(f"{PULP_TEST_URL}/pulp/api/v3/status/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"online": True, "versions": []})
|
||||||
|
)
|
||||||
|
with PulpClient() as pulp:
|
||||||
|
data = pulp.status()
|
||||||
|
assert route.called
|
||||||
|
assert data == {"online": True, "versions": []}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_raises_on_http_error():
|
||||||
|
respx.get(f"{PULP_TEST_URL}/pulp/api/v3/status/").mock(
|
||||||
|
return_value=httpx.Response(503)
|
||||||
|
)
|
||||||
|
with PulpClient() as pulp, pytest.raises(httpx.HTTPStatusError):
|
||||||
|
pulp.status()
|
||||||
72
tests/test_views.py
Normal file
72
tests/test_views.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from app import views
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpg_status_pass():
|
||||||
|
repo = {"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}}
|
||||||
|
assert views.gpg_status(repo)["level"] == "pass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpg_status_warn_when_partial():
|
||||||
|
repo = {"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 0}}
|
||||||
|
assert views.gpg_status(repo)["level"] == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpg_status_unset_when_no_config():
|
||||||
|
assert views.gpg_status({})["level"] == "unset"
|
||||||
|
assert views.gpg_status({"repo_config": {}})["level"] == "unset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_number_parses_trailing_int():
|
||||||
|
assert views.version_number("/pulp/api/v3/repositories/rpm/rpm/x/versions/7/") == 7
|
||||||
|
assert views.version_number(None) is None
|
||||||
|
assert views.version_number("/no/number/here/") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_count_sums_present_counts():
|
||||||
|
version = {
|
||||||
|
"content_summary": {
|
||||||
|
"present": {
|
||||||
|
"rpm.package": {"count": 1500},
|
||||||
|
"rpm.advisory": {"count": 42},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert views.package_count(version) == 1542
|
||||||
|
assert views.package_count(None) is None
|
||||||
|
assert views.package_count({}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_repo_view_combines_repo_and_version():
|
||||||
|
repo = {
|
||||||
|
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/",
|
||||||
|
"name": "rocky9-baseos",
|
||||||
|
"latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/versions/2/",
|
||||||
|
"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1},
|
||||||
|
}
|
||||||
|
version = {
|
||||||
|
"pulp_created": "2026-06-01T00:00:00Z",
|
||||||
|
"content_summary": {"present": {"rpm.package": {"count": 1500}}},
|
||||||
|
}
|
||||||
|
view = views.build_repo_view(repo, version)
|
||||||
|
assert view["name"] == "rocky9-baseos"
|
||||||
|
assert view["uuid"] == "abc-123"
|
||||||
|
assert view["latest_version"] == 2
|
||||||
|
assert view["package_count"] == 1500
|
||||||
|
assert view["last_sync"] == "2026-06-01T00:00:00Z"
|
||||||
|
assert view["gpg"]["level"] == "pass"
|
||||||
|
assert view["deployed_version"] is None # 단계1 미구현
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_counts():
|
||||||
|
repo_views = [
|
||||||
|
{"gpg": {"level": "pass"}, "package_count": 100},
|
||||||
|
{"gpg": {"level": "warn"}, "package_count": 50},
|
||||||
|
{"gpg": {"level": "pass"}, "package_count": None},
|
||||||
|
]
|
||||||
|
summary = views.summarize(repo_views)
|
||||||
|
assert summary == {
|
||||||
|
"total": 3,
|
||||||
|
"syncing": 0,
|
||||||
|
"gpg_pass": 2,
|
||||||
|
"total_packages": 150,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user