From 7ad6d65a1136beef4b90f27158d4c25200933000 Mon Sep 17 00:00:00 2001 From: Hyemin Lee Date: Wed, 17 Jun 2026 13:31:15 +0900 Subject: [PATCH] feat: gated deploy with preview, audit log, rollback trail (stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pulp_client: create_publication / update_distribution / wait_for_task - GET /repos/{uuid}/deploy/confirm: 미리보기(현재→대상, 순 변화) + type-to-confirm 모달 - POST /repos/{uuid}/deploy: 검증 게이트(서버측 재확인) → publication 생성 → distribution 교체 → 감사 로그. 2단계 실패 시 '운영망 변경 여부' 명확화 - audit.record_deploy: 누가/언제/repo/이전버전→대상버전 JSONL (롤백 추적) - 배포 버튼은 검증 통과 + 미배포 버전만 활성, 성공 시 버전목록 OOB 갱신 - 인증은 TODO (operator placeholder, 배포 비밀번호 재확인 예정) Co-Authored-By: Claude Opus 4.8 (1M context) --- PLAN.md | 2 +- app/audit.py | 37 ++++ app/config.py | 2 + app/demo.py | 20 +- app/main.py | 3 +- app/pulp_client.py | 37 ++++ app/routes/deploy.py | 218 +++++++++++++++++++++ app/routes/versions.py | 44 +++-- app/static/app.css | 9 + app/templates/partials/deploy_modal.html | 41 ++++ app/templates/partials/deploy_success.html | 20 ++ app/templates/partials/version_list.html | 13 +- app/templates/versions.html | 4 + app/views.py | 32 ++- tests/test_deploy.py | 167 ++++++++++++++++ tests/test_pulp_client.py | 40 ++++ tests/test_views.py | 32 +++ 17 files changed, 692 insertions(+), 29 deletions(-) create mode 100644 app/audit.py create mode 100644 app/routes/deploy.py create mode 100644 app/templates/partials/deploy_modal.html create mode 100644 app/templates/partials/deploy_success.html create mode 100644 tests/test_deploy.py diff --git a/PLAN.md b/PLAN.md index 5617481..631c9a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -111,7 +111,7 @@ --- -## [ ] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작) +## [x] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작) **목표:** 검증 완료 + 미배포 버전만 배포 가능. 확인 모달 → publication 생성 → distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록. diff --git a/app/audit.py b/app/audit.py new file mode 100644 index 0000000..bf299f6 --- /dev/null +++ b/app/audit.py @@ -0,0 +1,37 @@ +"""배포 확정 감사 로그 (스펙 §5-2). + +누가 / 언제 / 어떤 repo 를 / 어떤 버전으로 배포했는지 append-only JSONL 로 남긴다. +이전 버전(from_version)도 기록해 사고 시 롤백 대상을 즉시 파악할 수 있게 한다. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from .config import get_settings + + +def record_deploy( + *, + repo_uuid: str, + repo_name: str, + from_version: int | None, + to_version: int | None, + publication_href: str, + operator: str = "operator", # TODO(auth): 로그인 도입 시 실제 사용자. 배포 시 비밀번호 재확인 예정. +) -> dict[str, Any]: + entry = { + "ts": datetime.now(timezone.utc).isoformat(), + "action": "deploy", + "operator": operator, + "repo_uuid": repo_uuid, + "repo_name": repo_name, + "from_version": from_version, + "to_version": to_version, + "publication": publication_href, + } + with open(get_settings().audit_log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry diff --git a/app/config.py b/app/config.py index 0817999..7a0df46 100644 --- a/app/config.py +++ b/app/config.py @@ -23,6 +23,8 @@ class Settings(BaseSettings): pulp_ca_file: str | None = None # 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능) pulp_demo: bool = False + # 배포 감사 로그 (append-only JSONL) + audit_log_path: str = "audit-log.jsonl" @lru_cache diff --git a/app/demo.py b/app/demo.py index 66605e1..226350f 100644 --- a/app/demo.py +++ b/app/demo.py @@ -121,7 +121,10 @@ class DemoPulpClient: # 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩) deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1} return [ - {"publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/"} + { + "pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/", + "publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/", + } for u, v in deployed.items() ] @@ -133,3 +136,18 @@ class DemoPulpClient: f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/{number}/" ) } + + def create_publication(self, version_href: str) -> str: + return "/pulp/api/v3/tasks/demo-pub/" + + def update_distribution(self, dist_href: str, publication_href: str) -> str: + return "/pulp/api/v3/tasks/demo-dist/" + + def wait_for_task(self, task_href: str, attempts: int = 120, delay: float = 1.0): + # 데모: 즉시 완료. publication task 면 새 publication 리소스를 돌려준다. + created = ( + ["/pulp/api/v3/publications/rpm/rpm/demo-new/"] + if "pub" in task_href + else [] + ) + return {"state": "completed", "created_resources": created} diff --git a/app/main.py b/app/main.py index dbc7b74..0a45aef 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, versions +from .routes import dashboard, deploy, sync, versions BASE_DIR = Path(__file__).resolve().parent @@ -24,6 +24,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat app.include_router(dashboard.router) app.include_router(sync.router) app.include_router(versions.router) +app.include_router(deploy.router) __all__ = ["app", "get_pulp_client"] diff --git a/app/pulp_client.py b/app/pulp_client.py index 1918e30..29a5bd6 100644 --- a/app/pulp_client.py +++ b/app/pulp_client.py @@ -9,6 +9,7 @@ from __future__ import annotations +import time from typing import Any import httpx @@ -105,3 +106,39 @@ class PulpClient: def get_task(self, task_href: str) -> dict[str, Any]: """task 단건 조회 (state, progress_reports, created_resources, error).""" return self.get(task_href) + + def create_publication(self, version_href: str) -> str: + """특정 버전을 배포 가능한 형태로: POST /publications/rpm/rpm/ → task_href. + + 완료 후 task.created_resources 에서 publication href 를 얻는다. + """ + data = self.post( + f"{API_PREFIX}/publications/rpm/rpm/", + json={"repository_version": version_href}, + ) + return data["task"] + + def update_distribution(self, dist_href: str, publication_href: str) -> str: + """배포 확정: PATCH {dist_href} publication 교체 → task_href. + + 운영망이 보는 URL 이 이 publication 을 가리키게 된다(실제 배포 동작, 스펙 §4). + """ + data = self.patch(dist_href, json={"publication": publication_href}) + return data["task"] + + def wait_for_task( + self, task_href: str, attempts: int = 120, delay: float = 1.0 + ) -> dict[str, Any]: + """task 가 종료 상태(completed/failed/canceled)가 될 때까지 대기 후 반환. + + 배포는 (publication 생성 → distribution 교체) 2단계 task 라 결과 확정이 필요해 + 서버측에서 짧게 대기한다. 화면 폴링이 어려운 복합 동작에 한정해서 쓴다. + """ + terminal = {"completed", "failed", "canceled"} + task = self.get_task(task_href) + for _ in range(attempts): + if task.get("state") in terminal: + return task + time.sleep(delay) + task = self.get_task(task_href) + return task diff --git a/app/routes/deploy.py b/app/routes/deploy.py new file mode 100644 index 0000000..6c9ebcc --- /dev/null +++ b/app/routes/deploy.py @@ -0,0 +1,218 @@ +"""배포 확정 + 감사 로그 (화면 3 후반, 위험 동작 — 스펙 §5-2). + +GET /repos/{uuid}/deploy/confirm → 확인 모달(미리보기 + type-to-confirm) +POST /repos/{uuid}/deploy → publication 생성 → distribution 교체 → 감사 로그 + +안전장치: +- 검증 게이트: 검증 통과(pass) 저장소만 배포 가능 (서버측에서도 재확인) +- type-to-confirm: 저장소 이름을 정확히 입력해야 실행 +- 2단계 실패 명확화: 어느 단계에서 실패하든 "운영망 변경 여부"를 분명히 안내 +- 감사 로그: 누가/언제/어떤 repo/어느 버전→어느 버전 (이전 버전 기록 = 롤백 추적) +""" + +from __future__ import annotations + +import httpx +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse + +from .. import audit, views +from ..deps import get_pulp_client, templates +from ..pulp_client import PulpClient +from .versions import _deployed_version_number, load_version_context + +router = APIRouter() + + +def _distribution_for_repo(pulp: PulpClient, uuid: str) -> str | None: + """이 저장소에 연결된 distribution href. repository 직접 참조 또는 현재 publication 으로 매핑.""" + needle = f"/repositories/rpm/rpm/{uuid}/" + for dist in pulp.list_distributions(): + if needle in (dist.get("repository") or ""): + return dist.get("pulp_href") + pub_href = dist.get("publication") + if pub_href: + try: + pub = pulp.get_publication(pub_href) + except httpx.HTTPError: + continue + if needle in (pub.get("repository_version") or ""): + return dist.get("pulp_href") + return None + + +def _preview(pulp: PulpClient, uuid: str, version_href: str): + """확인 모달용 컨텍스트(repo_name, gpg, preview, deployed).""" + repo = pulp.get_repo(uuid) + raw = pulp.list_versions(uuid) + gpg = views.gpg_status(repo) + deployed = _deployed_version_number(pulp, uuid) + vv = sorted( + (views.build_version_view(v, deployed, gpg) for v in raw), + key=lambda v: v["number"] or 0, + reverse=True, + ) + target = views.version_number(version_href) + return { + "uuid": uuid, + "repo_name": repo.get("name", uuid), + "version_href": version_href, + "gpg": gpg, + "deployed": deployed, + "preview": views.deploy_preview(vv, target, deployed), + "target_number": target, + } + + +def _modal(request: Request, ctx: dict, error: str | None = None, status: int = 200): + return templates.TemplateResponse( + request, + "partials/deploy_modal.html", + {**ctx, "error": error}, + status_code=status, + ) + + +@router.get("/repos/{uuid}/deploy/confirm", response_class=HTMLResponse) +def deploy_confirm( + request: Request, + uuid: str, + version_href: str, + pulp: PulpClient = Depends(get_pulp_client), +) -> HTMLResponse: + try: + ctx = _preview(pulp, uuid, version_href) + except httpx.HTTPError as exc: + return templates.TemplateResponse( + request, + "partials/deploy_modal.html", + { + "uuid": uuid, + "repo_name": uuid, + "version_href": version_href, + "preview": None, + "error": f"조회 실패: {exc}", + }, + status_code=502, + ) + return _modal(request, ctx) + + +@router.post("/repos/{uuid}/deploy", response_class=HTMLResponse) +def deploy( + request: Request, + uuid: str, + version_href: str = Form(...), + confirm_name: str = Form(""), + pulp: PulpClient = Depends(get_pulp_client), +) -> HTMLResponse: + try: + ctx = _preview(pulp, uuid, version_href) + except httpx.HTTPError as exc: + return _modal( + request, + { + "uuid": uuid, + "repo_name": uuid, + "version_href": version_href, + "preview": None, + }, + error=f"조회 실패: {exc}", + status=502, + ) + + repo_name = ctx["repo_name"] + + # 1) 검증 게이트 (서버측 재확인 — 클라이언트만 믿지 않는다) + if ctx["gpg"]["level"] != "pass": + return _modal( + request, ctx, "검증을 통과하지 못한 저장소는 배포할 수 없습니다.", 403 + ) + + # 2) type-to-confirm + if confirm_name != repo_name: + return _modal(request, ctx, "저장소 이름이 일치하지 않습니다.", 400) + + # 3) 대상 distribution 확인 + dist_href = _distribution_for_repo(pulp, uuid) + if not dist_href: + return _modal( + request, ctx, "이 저장소에 연결된 distribution 을 찾을 수 없습니다.", 404 + ) + + # 4) publication 생성 (실패 시 운영망은 절대 변경되지 않음) + try: + pub_task = pulp.wait_for_task(pulp.create_publication(version_href)) + except httpx.HTTPError as exc: + return _modal( + request, + ctx, + f"배포 실패(publication 생성). 운영망은 변경되지 않았습니다: {exc}", + 502, + ) + if pub_task.get("state") != "completed": + return _modal( + request, + ctx, + "배포 실패(publication 생성 단계). 운영망은 변경되지 않았습니다.", + 502, + ) + pub_href = next( + (h for h in pub_task.get("created_resources") or [] if "/publications/" in h), + None, + ) + if not pub_href: + return _modal( + request, + ctx, + "publication 결과를 찾을 수 없습니다. 운영망은 변경되지 않았습니다.", + 502, + ) + + # 5) distribution 교체 = 실제 운영 배포 + try: + dist_task = pulp.wait_for_task(pulp.update_distribution(dist_href, pub_href)) + except httpx.HTTPError as exc: + return _modal( + request, + ctx, + f"배포 실패(distribution 교체). 운영망 변경에 실패했습니다: {exc}", + 502, + ) + if dist_task.get("state") != "completed": + return _modal( + request, + ctx, + "배포 실패(distribution 교체 단계). 운영망 변경에 실패했습니다.", + 502, + ) + + # 6) 감사 로그 (이전 버전 기록 = 롤백 추적). 기록 실패해도 배포는 이미 완료. + audit_ok = True + try: + audit.record_deploy( + repo_uuid=uuid, + repo_name=repo_name, + from_version=ctx["deployed"], + to_version=ctx["target_number"], + publication_href=pub_href, + ) + except OSError: + audit_ok = False + + # 7) 성공: 버전 목록 OOB 갱신 + 모달 성공 표시 + try: + new_ctx = load_version_context(pulp, uuid) + except httpx.HTTPError: + new_ctx = {"versions": [], "uuid": uuid} + return templates.TemplateResponse( + request, + "partials/deploy_success.html", + { + **new_ctx, + "uuid": uuid, + "from_version": ctx["deployed"], + "to_version": ctx["target_number"], + "audit_ok": audit_ok, + }, + ) diff --git a/app/routes/versions.py b/app/routes/versions.py index f299710..da2c972 100644 --- a/app/routes/versions.py +++ b/app/routes/versions.py @@ -3,11 +3,13 @@ GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순). 현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다 -(스펙 §4). 배포 버튼 활성화/확인은 단계 4에서 연결. +(스펙 §4). 배포 버튼 활성화/확인은 routes/deploy.py(단계 4). """ from __future__ import annotations +from typing import Any + import httpx from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse @@ -45,34 +47,34 @@ def _deployed_version_number(pulp: PulpClient, uuid: str) -> int | None: return None +def load_version_context(pulp: PulpClient, uuid: str) -> dict[str, Any]: + """버전 페이지/조각 렌더용 컨텍스트. get_repo/list_versions 실패는 httpx 예외로 전파.""" + repo = pulp.get_repo(uuid) + raw_versions = pulp.list_versions(uuid) + 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 { + "repo_name": repo.get("name", uuid), + "uuid": uuid, + "versions": version_views, + "verification": views.verification_detail(repo), + "deployed": deployed, + "error": 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) + ctx = load_version_context(pulp, 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, - }, - ) + return templates.TemplateResponse(request, "versions.html", ctx) diff --git a/app/static/app.css b/app/static/app.css index 941b865..ebe710d 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -243,6 +243,15 @@ button:disabled, opacity: 0.45; } +/* 위험 버튼: 배포 확정 등 확인 모달에서만 (design-ref §4) */ +button.danger, +[role="button"].danger { + --pico-background-color: var(--danger); + --pico-border-color: var(--danger); + --pico-color: #ffffff; + font-weight: 500; +} + /* 동기화 진행률 바 */ .sync-progress { display: flex; diff --git a/app/templates/partials/deploy_modal.html b/app/templates/partials/deploy_modal.html new file mode 100644 index 0000000..eddbfd7 --- /dev/null +++ b/app/templates/partials/deploy_modal.html @@ -0,0 +1,41 @@ + +
+
+

이 버전을 운영망에 배포

+
+ + {% if error %} +

{{ error }}

+ {% endif %} + +

운영 서버(yum/dnf)가 실제로 내려받는 버전이 바뀝니다. 신중히 확인하세요.

+ + {% if preview %} + + + + + + + + + +
현재 운영{% if preview.current_version is not none %}v{{ preview.current_version }} ({{ preview.current_count }}개){% else %}없음{% endif %}
배포 대상v{{ preview.target_version }} ({{ preview.target_count }}개)
순 변화{{ preview.net_sign }}{{ preview.net }}개
+ {% endif %} + +
+ + + {# TODO(auth): 로그인 도입 시 여기서 배포 비밀번호 재확인 입력 추가 #} +
+ + +
+
+
+
diff --git a/app/templates/partials/deploy_success.html b/app/templates/partials/deploy_success.html new file mode 100644 index 0000000..e2e85aa --- /dev/null +++ b/app/templates/partials/deploy_success.html @@ -0,0 +1,20 @@ +{# 배포 성공: 버전 목록을 OOB 로 갱신 + flash 배너 + 모달 자리에 완료 알림 #} +{% set oob = true %} +{% include "partials/version_list.html" %} + +
+

+ 배포 완료 · v{{ to_version }}{% if from_version is not none %} (이전 v{{ from_version }}){% endif %} + {% if not audit_ok %} — ⚠ 감사 로그 기록 실패{% endif %} +

+
+ + +
+

배포 완료

+

운영 배포 버전이 v{{ to_version }} 로 교체되었습니다.

+
+ +
+
+
diff --git a/app/templates/partials/version_list.html b/app/templates/partials/version_list.html index c19d1e4..8c22d5d 100644 --- a/app/templates/partials/version_list.html +++ b/app/templates/partials/version_list.html @@ -1,4 +1,4 @@ -
+
{% if versions %} @@ -26,9 +26,14 @@ diff --git a/app/templates/versions.html b/app/templates/versions.html index 81c18e1..3911627 100644 --- a/app/templates/versions.html +++ b/app/templates/versions.html @@ -5,6 +5,7 @@

{{ repo_name }} · 버전

+
{% if error %}

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

@@ -21,4 +22,7 @@ {% include "partials/version_list.html" %} {% endif %}
+ + + {% endblock %} diff --git a/app/views.py b/app/views.py index 706e80c..e6ed643 100644 --- a/app/views.py +++ b/app/views.py @@ -85,14 +85,44 @@ def build_version_view( 호출부에서 repo 의 gpg_status() 를 주입한다. """ number = version_number(version.get("pulp_href")) + is_deployed = number is not None and number == deployed_number + # 배포 가능: 검증 통과(pass) + 현재 배포 중이 아님 (스펙 §5-2 게이트) + deployable = (gpg or {}).get("level") == "pass" and not is_deployed return { "number": number, + "href": version.get("pulp_href"), "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, + "is_deployed": is_deployed, + "deployable": deployable, + } + + +def deploy_preview( + version_views: list[dict[str, Any]], + target_number: int | None, + deployed_number: int | None, +) -> dict[str, Any]: + """배포 확인 모달용 미리보기: 현재 운영 버전 → 대상 버전, 순 패키지 변화.""" + + def find(n: int | None) -> dict[str, Any] | None: + return next((v for v in version_views if v["number"] == n), None) + + current = find(deployed_number) + target = find(target_number) + current_count = current["package_count"] if current else 0 + target_count = target["package_count"] if target else 0 + net = target_count - current_count + return { + "current_version": deployed_number, + "current_count": current_count, + "target_version": target_number, + "target_count": target_count, + "net": abs(net), + "net_sign": "+" if net >= 0 else "−", } diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..cbe082b --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,167 @@ +import json + +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.main import app, get_pulp_client + +client = TestClient(app) + +VH = "/pulp/api/v3/repositories/rpm/rpm/abc/versions/3/" +REPO = {"name": "rocky9-baseos", "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}} +VERSIONS = [ + { + "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/", + "content_summary": {"present": {"rpm.package": {"count": 4700}}}, + }, + { + "pulp_href": VH, + "content_summary": {"present": {"rpm.package": {"count": 4821}}}, + }, +] +DISTRIBUTIONS = [ + { + "pulp_href": "/pulp/api/v3/distributions/rpm/rpm/dabc/", + "publication": "/pulp/api/v3/publications/rpm/rpm/abc-2/", + } +] +PUBLICATIONS = { + "/pulp/api/v3/publications/rpm/rpm/abc-2/": { + "repository_version": "/pulp/api/v3/repositories/rpm/rpm/abc/versions/2/" + } +} + + +class _FakePulp: + def __init__( + self, + repo=None, + distributions=None, + pub_state="completed", + dist_state="completed", + pub_created=True, + ): + self._repo = REPO if repo is None else repo + self._distributions = DISTRIBUTIONS if distributions is None else distributions + self._pub_state = pub_state + self._dist_state = dist_state + self._pub_created = pub_created + self.created_with = None + self.dist_args = None + + def get_repo(self, uuid): + return self._repo + + def list_versions(self, uuid): + return VERSIONS + + def list_distributions(self): + return self._distributions + + def get_publication(self, href): + return PUBLICATIONS.get(href, {}) + + def create_publication(self, version_href): + self.created_with = version_href + return "/pulp/api/v3/tasks/pub/" + + def update_distribution(self, dist_href, publication_href): + self.dist_args = (dist_href, publication_href) + return "/pulp/api/v3/tasks/dist/" + + def wait_for_task(self, task_href, attempts=120, delay=1.0): + if "pub" in task_href: + created = ( + ["/pulp/api/v3/publications/rpm/rpm/new/"] if self._pub_created else [] + ) + return {"state": self._pub_state, "created_resources": created} + return {"state": self._dist_state, "created_resources": []} + + +def _use(fake): + app.dependency_overrides[get_pulp_client] = lambda: fake + + +def teardown_function(): + app.dependency_overrides.clear() + + +def _post(confirm_name): + return client.post( + "/repos/abc/deploy", + data={"version_href": VH, "confirm_name": confirm_name}, + ) + + +def test_confirm_modal_shows_preview_and_type_to_confirm(): + _use(_FakePulp()) + resp = client.get("/repos/abc/deploy/confirm", params={"version_href": VH}) + assert resp.status_code == 200 + body = resp.text + assert "운영망에 배포" in body + assert "rocky9-baseos" in body # type-to-confirm 대상 이름 + assert "배포 확정" in body + assert "+121" in body # 4821 - 4700 미리보기 + + +def test_deploy_blocked_when_not_verified(): + _use(_FakePulp(repo={"name": "r", "repo_config": {"gpgcheck": 0}})) + resp = client.post( + "/repos/abc/deploy", data={"version_href": VH, "confirm_name": "r"} + ) + assert resp.status_code == 403 + assert "검증" in resp.text + + +def test_deploy_rejects_name_mismatch(): + _use(_FakePulp()) + resp = _post("wrong-name") + assert resp.status_code == 400 + assert "이름이 일치" in resp.text + + +def test_deploy_requires_existing_distribution(): + _use(_FakePulp(distributions=[])) + resp = _post("rocky9-baseos") + assert resp.status_code == 404 + assert "distribution" in resp.text + + +def test_deploy_publication_failure_keeps_prod_unchanged(): + _use(_FakePulp(pub_state="failed")) + resp = _post("rocky9-baseos") + assert resp.status_code == 502 + assert "운영망은 변경되지 않았습니다" in resp.text + + +def test_deploy_distribution_failure_reported(): + _use(_FakePulp(dist_state="failed")) + resp = _post("rocky9-baseos") + assert resp.status_code == 502 + assert "운영망 변경에 실패" in resp.text + + +def test_deploy_success_writes_audit_and_refreshes_list(tmp_path, monkeypatch): + monkeypatch.setenv("AUDIT_LOG_PATH", str(tmp_path / "audit.jsonl")) + get_settings.cache_clear() + + fake = _FakePulp() + _use(fake) + resp = _post("rocky9-baseos") + assert resp.status_code == 200 + assert "배포 완료" in resp.text + assert 'hx-swap-oob="true"' in resp.text # 버전 목록 OOB 갱신 + + # publication 은 대상 버전으로, distribution 은 새 publication 으로 교체 + assert fake.created_with == VH + assert fake.dist_args == ( + "/pulp/api/v3/distributions/rpm/rpm/dabc/", + "/pulp/api/v3/publications/rpm/rpm/new/", + ) + + # 감사 로그: from=2, to=3, repo 이름 기록 + entry = json.loads((tmp_path / "audit.jsonl").read_text(encoding="utf-8").strip()) + assert entry["action"] == "deploy" + assert entry["from_version"] == 2 + assert entry["to_version"] == 3 + assert entry["repo_name"] == "rocky9-baseos" diff --git a/tests/test_pulp_client.py b/tests/test_pulp_client.py index b04650e..05596de 100644 --- a/tests/test_pulp_client.py +++ b/tests/test_pulp_client.py @@ -61,3 +61,43 @@ def test_get_task_returns_state(): with PulpClient() as pulp: task = pulp.get_task("/pulp/api/v3/tasks/t1/") assert task["state"] == "running" + + +@respx.mock +def test_create_publication_posts_version_and_returns_task(): + import json + + route = respx.post(f"{PULP_TEST_URL}/pulp/api/v3/publications/rpm/rpm/").mock( + return_value=httpx.Response(202, json={"task": "/pulp/api/v3/tasks/p1/"}) + ) + vh = "/pulp/api/v3/repositories/rpm/rpm/abc/versions/3/" + with PulpClient() as pulp: + href = pulp.create_publication(vh) + assert href == "/pulp/api/v3/tasks/p1/" + assert json.loads(route.calls.last.request.content) == {"repository_version": vh} + + +@respx.mock +def test_update_distribution_patches_publication_and_returns_task(): + import json + + dist = "/pulp/api/v3/distributions/rpm/rpm/d1/" + route = respx.patch(f"{PULP_TEST_URL}{dist}").mock( + return_value=httpx.Response(202, json={"task": "/pulp/api/v3/tasks/d1/"}) + ) + with PulpClient() as pulp: + href = pulp.update_distribution(dist, "/pulp/api/v3/publications/rpm/rpm/p1/") + assert href == "/pulp/api/v3/tasks/d1/" + assert json.loads(route.calls.last.request.content) == { + "publication": "/pulp/api/v3/publications/rpm/rpm/p1/" + } + + +@respx.mock +def test_wait_for_task_returns_on_terminal_state(): + respx.get(f"{PULP_TEST_URL}/pulp/api/v3/tasks/t1/").mock( + return_value=httpx.Response(200, json={"state": "completed"}) + ) + with PulpClient() as pulp: + task = pulp.wait_for_task("/pulp/api/v3/tasks/t1/", attempts=1, delay=0) + assert task["state"] == "completed" diff --git a/tests/test_views.py b/tests/test_views.py index a522eed..a41dfca 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -135,3 +135,35 @@ def test_verification_detail(): assert d["gpgcheck"] == 1 assert d["repo_gpgcheck"] == 0 assert d["status"]["level"] == "warn" + + +def test_build_version_view_deployable_only_when_verified_and_not_deployed(): + version = {"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/x/versions/3/"} + verified = {"level": "pass", "label": "검증됨"} + unverified = {"level": "unset", "label": "미설정"} + # 검증 통과 + 미배포 → 배포 가능 + assert views.build_version_view(version, 2, verified)["deployable"] is True + # 검증 통과 but 현재 배포 중 → 불가 + assert views.build_version_view(version, 3, verified)["deployable"] is False + # 미검증 → 불가 + assert views.build_version_view(version, 2, unverified)["deployable"] is False + + +def test_deploy_preview_net_change(): + vv = [ + {"number": 3, "package_count": 4821}, + {"number": 2, "package_count": 4700}, + ] + p = views.deploy_preview(vv, target_number=3, deployed_number=2) + assert (p["current_version"], p["current_count"]) == (2, 4700) + assert (p["target_version"], p["target_count"]) == (3, 4821) + assert p["net"] == 121 + assert p["net_sign"] == "+" + + +def test_deploy_preview_handles_no_current_deploy(): + vv = [{"number": 1, "package_count": 50}] + p = views.deploy_preview(vv, target_number=1, deployed_number=None) + assert p["current_count"] == 0 + assert p["net"] == 50 + assert p["net_sign"] == "+"
{% if v.gpg %}{{ v.gpg.label }}{% endif %} {% if v.is_deployed %}운영 배포 중{% endif %} - {% if not v.is_deployed %} - - + {% if v.is_deployed %} + {# 배포 상태 칸에 배지 표시 #} + {% elif v.deployable %} + + {% else %} + {% endif %}