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 @@ + 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 %} +
+| {% 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 %} | 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 @@