Files
pulp-console/app/views.py
Hyemin Lee 7ad6d65a11 feat: gated deploy with preview, audit log, rollback trail (stage 4)
- 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) <noreply@anthropic.com>
2026-06-17 13:31:15 +09:00

186 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _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
return _content_count(version, "present")
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 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"))
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": 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 "",
}
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 {
"total": len(repo_views),
"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"),
}