diff --git a/PLAN.md b/PLAN.md index ae451b9..a3a2623 100644 --- a/PLAN.md +++ b/PLAN.md @@ -68,7 +68,7 @@ --- -## [ ] 단계 2 — 동기화 실행 + 진행률 폴링 (화면 2) +## [x] 단계 2 — 동기화 실행 + 진행률 폴링 (화면 2) **목표:** [동기화] 클릭 → sync task 시작 → 3초 폴링으로 진행률 갱신 → 완료/실패 표시. @@ -148,6 +148,7 @@ distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록. wheel/오프라인 인덱스로 설치 가능하게(litellm 반입 방식 참고, 스펙 §9). 3. 실행 문서화: `README` 또는 CLAUDE.md "Conventions"에 기동 명령/환경변수 정리. 4. (선택, §5-3) 읽기전용 / 배포권한 계정 분리 여지를 남긴 인증 훅 위치 표시. +5. 디자인 가이드는 ref 하위의 markdown 파일들을 참고. **검증:** 네트워크 차단 상태에서 컨테이너/venv만으로 앱이 뜨고 모든 정적 자산이 로드된다. diff --git a/app/demo.py b/app/demo.py index 47334ce..a180f90 100644 --- a/app/demo.py +++ b/app/demo.py @@ -67,3 +67,17 @@ class DemoPulpClient: def get_version(self, version_href: str) -> dict[str, Any]: return _VERSIONS.get(version_href, {}) + + def get_repo(self, uuid: str) -> dict[str, Any]: + return {"remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} + + def sync_repo(self, uuid: str, remote_href: str) -> str: + return f"/pulp/api/v3/tasks/demo-{uuid}/" + + def get_task(self, task_href: str) -> dict[str, Any]: + # 데모: 첫 폴링에서 바로 완료 처리 + return { + "state": "completed", + "progress_reports": [{"done": 120, "total": 120}], + "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], + } diff --git a/app/main.py b/app/main.py index d5f4fe7..566d690 100644 --- a/app/main.py +++ b/app/main.py @@ -15,13 +15,14 @@ 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 +from .routes import dashboard, sync 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) +app.include_router(sync.router) __all__ = ["app", "get_pulp_client"] diff --git a/app/pulp_client.py b/app/pulp_client.py index 4580c9c..8ad0fd7 100644 --- a/app/pulp_client.py +++ b/app/pulp_client.py @@ -48,7 +48,9 @@ class PulpClient: resp.raise_for_status() return resp.json() - def patch(self, href: str, json: Any | None = None, **kwargs: Any) -> dict[str, Any]: + 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() @@ -66,3 +68,23 @@ class PulpClient: def get_version(self, version_href: str) -> dict[str, Any]: """RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함.""" return self.get(version_href) + + def get_repo(self, uuid: str) -> dict[str, Any]: + """저장소 단건 조회 (remote href 등 확인용).""" + return self.get(f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/") + + def sync_repo(self, uuid: str, remote_href: str) -> str: + """동기화 시작 → 비동기 task. 반환 task_href 를 폴링으로 추적한다(스펙 §4). + + POST .../repositories/rpm/rpm/{uuid}/sync/ body {"remote": } + 응답 {"task": "/pulp/api/v3/tasks//"} 에서 task href 추출. + """ + data = self.post( + f"{API_PREFIX}/repositories/rpm/rpm/{uuid}/sync/", + json={"remote": remote_href}, + ) + return data["task"] + + def get_task(self, task_href: str) -> dict[str, Any]: + """task 단건 조회 (state, progress_reports, created_resources, error).""" + return self.get(task_href) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 7c444e9..53e9e76 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -12,7 +12,7 @@ import httpx from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse -from .. import views +from .. import task_store, views from ..deps import get_pulp_client, templates from ..pulp_client import PulpClient @@ -21,10 +21,11 @@ router = APIRouter() def _load_repos(pulp: PulpClient) -> dict[str, Any]: """저장소 목록을 읽어 표시 모델 + 요약 + (실패 시) 에러 메시지를 만든다.""" + syncing = task_store.active_count() try: repos = pulp.list_repos() except httpx.HTTPError as exc: - return {"repos": [], "summary": views.summarize([]), "error": str(exc)} + return {"repos": [], "summary": views.summarize([], syncing), "error": str(exc)} repo_views: list[dict[str, Any]] = [] for repo in repos: @@ -37,7 +38,11 @@ def _load_repos(pulp: PulpClient) -> dict[str, Any]: version = None # 버전 상세 실패는 치명적이지 않음 — 행은 표시 repo_views.append(views.build_repo_view(repo, version)) - return {"repos": repo_views, "summary": views.summarize(repo_views), "error": None} + return { + "repos": repo_views, + "summary": views.summarize(repo_views, syncing), + "error": None, + } @router.get("/", response_class=HTMLResponse) diff --git a/app/routes/sync.py b/app/routes/sync.py new file mode 100644 index 0000000..cfb4039 --- /dev/null +++ b/app/routes/sync.py @@ -0,0 +1,114 @@ +"""동기화 실행 + 진행률 폴링 (화면 2). + +POST /repos/{uuid}/sync → 동기화 시작, 진행률 바 조각 반환 (이후 3초 폴링) +GET /repos/{uuid}/progress → 진행률 바 조각 (폴링 대상) + +동기화는 외부 미러에서 새 스냅샷을 만들 뿐 운영망 배포가 아니므로 비교적 안전 → +확인 모달 없이 바로 실행한다(스펙 §5-1). 폴링은 화면(HTMX)이 담당한다(스펙 §7). +""" + +from __future__ import annotations + +import httpx +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse + +from .. import task_store, views +from ..deps import get_pulp_client, templates +from ..pulp_client import PulpClient + +router = APIRouter() + + +def _render(request: Request, uuid: str, progress: dict, status_code: int = 200): + return templates.TemplateResponse( + request, + "partials/progress.html", + {"uuid": uuid, "progress": progress}, + status_code=status_code, + ) + + +@router.post("/repos/{uuid}/sync", response_class=HTMLResponse) +def start_sync( + request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client) +) -> HTMLResponse: + try: + repo = pulp.get_repo(uuid) + remote = repo.get("remote") + if not remote: + return _render( + request, + uuid, + { + "running": False, + "completed": False, + "failed": True, + "error": "이 저장소에 remote(미러 소스)가 설정되어 있지 않습니다.", + }, + status_code=400, + ) + task_href = pulp.sync_repo(uuid, remote) + except httpx.HTTPError as exc: + return _render( + request, + uuid, + { + "running": False, + "completed": False, + "failed": True, + "error": f"동기화 시작 실패: {exc}", + }, + status_code=502, + ) + + task_store.set_task(uuid, task_href) + # 첫 조각: 폴링 시작 (아직 progress_reports 없음 → 준비 중) + return _render( + request, + uuid, + { + "running": True, + "completed": False, + "failed": False, + "state": "waiting", + "done": 0, + "total": 0, + "percent": 0, + }, + ) + + +@router.get("/repos/{uuid}/progress", response_class=HTMLResponse) +def progress( + request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client) +) -> HTMLResponse: + task_href = task_store.get_task(uuid) + if not task_href: + # 추적 중인 sync 없음 → 폴링 중단(빈 조각) + return _render( + request, + uuid, + {"running": False, "completed": False, "failed": False, "idle": True}, + ) + + try: + task = pulp.get_task(task_href) + except httpx.HTTPError as exc: + task_store.clear_task(uuid) + return _render( + request, + uuid, + { + "running": False, + "completed": False, + "failed": True, + "error": f"작업 상태 조회 실패: {exc}", + }, + status_code=502, + ) + + prog = views.task_progress(task) + if prog["completed"] or prog["failed"]: + task_store.clear_task(uuid) # 폴링 종료 + 동기화중 카운트에서 제외 + return _render(request, uuid, prog) diff --git a/app/static/app.css b/app/static/app.css index a773caa..94eeae2 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -220,6 +220,21 @@ button:disabled, opacity: 0.45; } +/* 동기화 진행률 바 */ +.sync-progress { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 160px; +} +.sync-progress progress { + margin: 0; + height: 6px; +} +.sync-progress small { + color: var(--text-muted); +} + /* 연결 상태 표시 (dot + 텍스트) */ .status-line { display: inline-flex; diff --git a/app/task_store.py b/app/task_store.py new file mode 100644 index 0000000..1808d0d --- /dev/null +++ b/app/task_store.py @@ -0,0 +1,26 @@ +"""진행 중 동기화 task 의 메모리 저장소 (uuid → task_href). + +폐쇄망 단일 인스턴스 가정. 다중 워커/프로세스로 띄우면 워커 간 공유되지 않으므로, +규모가 커지면 Redis 등 외부 저장소로 교체해야 한다. 폴링 자체는 화면(HTMX)이 한다(스펙 §7). +""" + +from __future__ import annotations + +_active: dict[str, str] = {} + + +def set_task(uuid: str, task_href: str) -> None: + _active[uuid] = task_href + + +def get_task(uuid: str) -> str | None: + return _active.get(uuid) + + +def clear_task(uuid: str) -> None: + _active.pop(uuid, None) + + +def active_count() -> int: + """대시보드 '동기화 중' 카드용 — 아직 완료/실패로 정리되지 않은 sync 수.""" + return len(_active) diff --git a/app/templates/partials/progress.html b/app/templates/partials/progress.html new file mode 100644 index 0000000..981fc7d --- /dev/null +++ b/app/templates/partials/progress.html @@ -0,0 +1,21 @@ +{# 진행률 바 조각. running 일 때만 hx-trigger 를 달아 3초 폴링; 완료/실패 시 폴링 중단. #} +
+ {% if progress.failed %} + 동기화 실패: {{ progress.error }} + {% elif progress.completed %} + 동기화 완료{% if progress.new_version %} · v{{ progress.new_version }} 생성됨{% endif %} + {% elif progress.idle %} + {# 추적 중인 작업 없음 — 빈 조각 #} + {% else %} +
+ {% if progress.total %} + + {{ progress.done }}/{{ progress.total }} 패키지 · {{ progress.percent }}% + {% else %} + + 동기화 준비 중… + {% endif %} +
+ {% endif %} +
diff --git a/app/templates/partials/repo_list.html b/app/templates/partials/repo_list.html index 2866902..3b5d22a 100644 --- a/app/templates/partials/repo_list.html +++ b/app/templates/partials/repo_list.html @@ -31,8 +31,13 @@ {% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %} {{ repo.last_sync or "—" }} - - +
+ +
{% endfor %} diff --git a/app/views.py b/app/views.py index 4c9d98b..5aa4bcc 100644 --- a/app/views.py +++ b/app/views.py @@ -69,12 +69,47 @@ def build_repo_view( } -def summarize(repo_views: list[dict[str, Any]]) -> dict[str, int]: - """상단 요약 카드 수치.""" +def summarize(repo_views: list[dict[str, Any]], syncing: int = 0) -> dict[str, int]: + """상단 요약 카드 수치. syncing 은 task_store.active_count() 에서 주입.""" return { "total": len(repo_views), - # TODO(단계2): 진행 중 sync task 수를 메모리 task 맵에서 집계 - "syncing": 0, + "syncing": syncing, "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), } + + +def task_progress(task: dict[str, Any]) -> dict[str, Any]: + """Pulp task → 진행률 표시 모델. + + progress_reports[] 의 done/total 을 합산해 퍼센트를 낸다. 완료 시 + created_resources 에서 새 RepositoryVersion 번호(vN)를 추출한다. + """ + state = task.get("state", "waiting") + reports = task.get("progress_reports") or [] + done = sum((r or {}).get("done") or 0 for r in reports) + total = sum((r or {}).get("total") or 0 for r in reports) + percent = int(done / total * 100) if total else 0 + + new_version = None + for href in task.get("created_resources") or []: + if "/versions/" in href: + new_version = version_number(href) + break + + error = None + if state == "failed": + err = task.get("error") or {} + error = err.get("description") or "동기화 작업이 실패했습니다." + + return { + "state": state, + "done": done, + "total": total, + "percent": percent, + "new_version": new_version, + "error": error, + "completed": state == "completed", + "failed": state == "failed", + "running": state in ("waiting", "running"), + } diff --git a/tests/test_pulp_client.py b/tests/test_pulp_client.py index 64fbd74..b04650e 100644 --- a/tests/test_pulp_client.py +++ b/tests/test_pulp_client.py @@ -37,3 +37,27 @@ def test_get_raises_on_http_error(): ) with PulpClient() as pulp, pytest.raises(httpx.HTTPStatusError): pulp.status() + + +@respx.mock +def test_sync_repo_posts_remote_and_returns_task(): + import json + + route = respx.post( + f"{PULP_TEST_URL}/pulp/api/v3/repositories/rpm/rpm/abc/sync/" + ).mock(return_value=httpx.Response(202, json={"task": "/pulp/api/v3/tasks/t1/"})) + with PulpClient() as pulp: + href = pulp.sync_repo("abc", "/pulp/api/v3/remotes/rpm/rpm/r1/") + assert href == "/pulp/api/v3/tasks/t1/" + body = json.loads(route.calls.last.request.content) + assert body == {"remote": "/pulp/api/v3/remotes/rpm/rpm/r1/"} + + +@respx.mock +def test_get_task_returns_state(): + respx.get(f"{PULP_TEST_URL}/pulp/api/v3/tasks/t1/").mock( + return_value=httpx.Response(200, json={"state": "running"}) + ) + with PulpClient() as pulp: + task = pulp.get_task("/pulp/api/v3/tasks/t1/") + assert task["state"] == "running" diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..aeeb512 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,115 @@ +import httpx +from fastapi.testclient import TestClient + +from app import task_store +from app.main import app, get_pulp_client + +client = TestClient(app) + + +class _FakePulp: + def __init__(self, repo=None, task=None, sync_raises=False): + self._repo = ( + {"remote": "/pulp/api/v3/remotes/rpm/rpm/r1/"} if repo is None else repo + ) + self._task = task + self._sync_raises = sync_raises + self.synced = None + + def get_repo(self, uuid): + return self._repo + + def sync_repo(self, uuid, remote_href): + if self._sync_raises: + raise httpx.ConnectError("connection refused") + self.synced = (uuid, remote_href) + return f"/pulp/api/v3/tasks/t-{uuid}/" + + def get_task(self, task_href): + return self._task + + +def _use(fake): + app.dependency_overrides[get_pulp_client] = lambda: fake + + +def teardown_function(): + app.dependency_overrides.clear() + task_store._active.clear() + + +def test_start_sync_begins_polling_and_records_task(): + fake = _FakePulp() + _use(fake) + resp = client.post("/repos/abc/sync") + assert resp.status_code == 200 + assert 'hx-get="/repos/abc/progress"' in resp.text + assert 'hx-trigger="every 3s"' in resp.text + assert task_store.get_task("abc") == "/pulp/api/v3/tasks/t-abc/" + assert fake.synced == ("abc", "/pulp/api/v3/remotes/rpm/rpm/r1/") + + +def test_start_sync_without_remote_is_rejected(): + _use(_FakePulp(repo={})) # remote 미설정 + resp = client.post("/repos/abc/sync") + assert resp.status_code == 400 + assert "remote" in resp.text + assert task_store.get_task("abc") is None + + +def test_start_sync_handles_pulp_error(): + _use(_FakePulp(sync_raises=True)) + resp = client.post("/repos/abc/sync") + assert resp.status_code == 502 + assert "동기화 시작 실패" in resp.text + assert task_store.get_task("abc") is None + + +def test_progress_running_renders_bar_and_keeps_polling(): + task_store.set_task("abc", "/pulp/api/v3/tasks/t1/") + _use( + _FakePulp( + task={"state": "running", "progress_reports": [{"done": 4, "total": 8}]} + ) + ) + resp = client.get("/repos/abc/progress") + assert resp.status_code == 200 + assert "50%" in resp.text + assert 'hx-trigger="every 3s"' in resp.text + assert task_store.get_task("abc") is not None # 아직 추적 중 + + +def test_progress_completed_stops_polling_and_clears(): + task_store.set_task("abc", "/pulp/api/v3/tasks/t1/") + _use( + _FakePulp( + task={ + "state": "completed", + "created_resources": [ + "/pulp/api/v3/repositories/rpm/rpm/abc/versions/7/" + ], + } + ) + ) + resp = client.get("/repos/abc/progress") + assert "동기화 완료" in resp.text + assert "v7" in resp.text + assert "hx-trigger" not in resp.text # 폴링 중단 + assert task_store.get_task("abc") is None # 정리됨 + + +def test_progress_failed_shows_error_and_clears(): + task_store.set_task("abc", "/pulp/api/v3/tasks/t1/") + _use(_FakePulp(task={"state": "failed", "error": {"description": "GPG 검증 실패"}})) + resp = client.get("/repos/abc/progress") + assert "동기화 실패" in resp.text + assert "GPG 검증 실패" in resp.text + assert "hx-trigger" not in resp.text + assert task_store.get_task("abc") is None + + +def test_progress_idle_when_no_active_task(): + _use(_FakePulp()) + resp = client.get("/repos/zzz/progress") + assert resp.status_code == 200 + assert "hx-trigger" not in resp.text # 폴링하지 않음 diff --git a/tests/test_views.py b/tests/test_views.py index 3a573d4..797047c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -70,3 +70,34 @@ def test_summarize_counts(): "gpg_pass": 2, "total_packages": 150, } + + +def test_summarize_includes_syncing_count(): + assert views.summarize([], syncing=2)["syncing"] == 2 + + +def test_task_progress_running_sums_reports(): + task = { + "state": "running", + "progress_reports": [{"done": 5, "total": 10}, {"done": 3, "total": 10}], + } + p = views.task_progress(task) + assert p["running"] is True + assert p["completed"] is False + assert (p["done"], p["total"], p["percent"]) == (8, 20, 40) + + +def test_task_progress_completed_extracts_version(): + task = { + "state": "completed", + "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/x/versions/4/"], + } + p = views.task_progress(task) + assert p["completed"] is True + assert p["new_version"] == 4 + + +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"