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>
This commit is contained in:
2
PLAN.md
2
PLAN.md
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [ ] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작)
|
## [x] 단계 4 — 배포 확정 + 감사 로그 (화면 3 후반, 위험 동작)
|
||||||
|
|
||||||
**목표:** 검증 완료 + 미배포 버전만 배포 가능. 확인 모달 → publication 생성 →
|
**목표:** 검증 완료 + 미배포 버전만 배포 가능. 확인 모달 → publication 생성 →
|
||||||
distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록.
|
distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록.
|
||||||
|
|||||||
37
app/audit.py
Normal file
37
app/audit.py
Normal file
@@ -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
|
||||||
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
|||||||
pulp_ca_file: str | None = None
|
pulp_ca_file: str | None = None
|
||||||
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능)
|
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능)
|
||||||
pulp_demo: bool = False
|
pulp_demo: bool = False
|
||||||
|
# 배포 감사 로그 (append-only JSONL)
|
||||||
|
audit_log_path: str = "audit-log.jsonl"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
20
app/demo.py
20
app/demo.py
@@ -121,7 +121,10 @@ class DemoPulpClient:
|
|||||||
# 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩)
|
# 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩)
|
||||||
deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1}
|
deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1}
|
||||||
return [
|
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()
|
for u, v in deployed.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -133,3 +136,18 @@ class DemoPulpClient:
|
|||||||
f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/{number}/"
|
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}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
# 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함)
|
# 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함)
|
||||||
from .deps import get_pulp_client, templates
|
from .deps import get_pulp_client, templates
|
||||||
from .pulp_client import PulpClient
|
from .pulp_client import PulpClient
|
||||||
from .routes import dashboard, sync, versions
|
from .routes import dashboard, deploy, sync, versions
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
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(dashboard.router)
|
||||||
app.include_router(sync.router)
|
app.include_router(sync.router)
|
||||||
app.include_router(versions.router)
|
app.include_router(versions.router)
|
||||||
|
app.include_router(deploy.router)
|
||||||
|
|
||||||
__all__ = ["app", "get_pulp_client"]
|
__all__ = ["app", "get_pulp_client"]
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -105,3 +106,39 @@ class PulpClient:
|
|||||||
def get_task(self, task_href: str) -> dict[str, Any]:
|
def get_task(self, task_href: str) -> dict[str, Any]:
|
||||||
"""task 단건 조회 (state, progress_reports, created_resources, error)."""
|
"""task 단건 조회 (state, progress_reports, created_resources, error)."""
|
||||||
return self.get(task_href)
|
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
|
||||||
|
|||||||
218
app/routes/deploy.py
Normal file
218
app/routes/deploy.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순).
|
GET /repos/{uuid}/versions → 해당 저장소의 RepositoryVersion 목록 페이지(최신순).
|
||||||
|
|
||||||
현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다
|
현재 운영 배포 버전은 Distribution → publication → repository_version 역추적으로 판별한다
|
||||||
(스펙 §4). 배포 버튼 활성화/확인은 단계 4에서 연결.
|
(스펙 §4). 배포 버튼 활성화/확인은 routes/deploy.py(단계 4).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -45,34 +47,34 @@ def _deployed_version_number(pulp: PulpClient, uuid: str) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repos/{uuid}/versions", response_class=HTMLResponse)
|
def load_version_context(pulp: PulpClient, uuid: str) -> dict[str, Any]:
|
||||||
def versions(
|
"""버전 페이지/조각 렌더용 컨텍스트. get_repo/list_versions 실패는 httpx 예외로 전파."""
|
||||||
request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client)
|
|
||||||
) -> HTMLResponse:
|
|
||||||
try:
|
|
||||||
repo = pulp.get_repo(uuid)
|
repo = pulp.get_repo(uuid)
|
||||||
raw_versions = pulp.list_versions(uuid)
|
raw_versions = pulp.list_versions(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)
|
gpg = views.gpg_status(repo)
|
||||||
deployed = _deployed_version_number(pulp, uuid)
|
deployed = _deployed_version_number(pulp, uuid)
|
||||||
version_views = [views.build_version_view(v, deployed, gpg) for v in raw_versions]
|
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) # 최신순
|
version_views.sort(key=lambda v: v["number"] or 0, reverse=True) # 최신순
|
||||||
|
return {
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"versions.html",
|
|
||||||
{
|
|
||||||
"repo_name": repo.get("name", uuid),
|
"repo_name": repo.get("name", uuid),
|
||||||
"uuid": uuid,
|
"uuid": uuid,
|
||||||
"versions": version_views,
|
"versions": version_views,
|
||||||
"verification": views.verification_detail(repo),
|
"verification": views.verification_detail(repo),
|
||||||
"deployed": deployed,
|
"deployed": deployed,
|
||||||
"error": None,
|
"error": None,
|
||||||
},
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repos/{uuid}/versions", response_class=HTMLResponse)
|
||||||
|
def versions(
|
||||||
|
request: Request, uuid: str, pulp: PulpClient = Depends(get_pulp_client)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
try:
|
||||||
|
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)},
|
||||||
)
|
)
|
||||||
|
return templates.TemplateResponse(request, "versions.html", ctx)
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ button:disabled,
|
|||||||
opacity: 0.45;
|
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 {
|
.sync-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
41
app/templates/partials/deploy_modal.html
Normal file
41
app/templates/partials/deploy_modal.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<dialog open>
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>이 버전을 운영망에 배포</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="status-line status-bad">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>운영 서버(yum/dnf)가 실제로 내려받는 버전이 바뀝니다. 신중히 확인하세요.</p>
|
||||||
|
|
||||||
|
{% if preview %}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>현재 운영</td>
|
||||||
|
<td class="num">{% if preview.current_version is not none %}v{{ preview.current_version }} ({{ preview.current_count }}개){% else %}없음{% endif %}</td></tr>
|
||||||
|
<tr><td>배포 대상</td>
|
||||||
|
<td class="num">v{{ preview.target_version }} ({{ preview.target_count }}개)</td></tr>
|
||||||
|
<tr><td>순 변화</td>
|
||||||
|
<td class="num">{{ preview.net_sign }}{{ preview.net }}개</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form hx-post="/repos/{{ uuid }}/deploy" hx-target="#modal" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="version_href" value="{{ version_href }}">
|
||||||
|
<label>
|
||||||
|
확인을 위해 저장소 이름 <strong>{{ repo_name }}</strong> 을(를) 입력하세요
|
||||||
|
<input type="text" name="confirm_name" autocomplete="off" required
|
||||||
|
oninput="document.getElementById('deploy-confirm-btn').disabled = (this.value !== {{ repo_name | tojson }})">
|
||||||
|
</label>
|
||||||
|
{# TODO(auth): 로그인 도입 시 여기서 배포 비밀번호 재확인 입력 추가 #}
|
||||||
|
<footer>
|
||||||
|
<button type="button" class="secondary"
|
||||||
|
onclick="document.getElementById('modal').innerHTML=''">취소</button>
|
||||||
|
<button type="submit" id="deploy-confirm-btn" class="danger" disabled>배포 확정</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
20
app/templates/partials/deploy_success.html
Normal file
20
app/templates/partials/deploy_success.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{# 배포 성공: 버전 목록을 OOB 로 갱신 + flash 배너 + 모달 자리에 완료 알림 #}
|
||||||
|
{% set oob = true %}
|
||||||
|
{% include "partials/version_list.html" %}
|
||||||
|
|
||||||
|
<div id="flash" hx-swap-oob="true">
|
||||||
|
<p class="status-line status-ok">
|
||||||
|
배포 완료 · v{{ to_version }}{% if from_version is not none %} (이전 v{{ from_version }}){% endif %}
|
||||||
|
{% if not audit_ok %} — ⚠ 감사 로그 기록 실패{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog open>
|
||||||
|
<article>
|
||||||
|
<header><h3>배포 완료</h3></header>
|
||||||
|
<p>운영 배포 버전이 <strong>v{{ to_version }}</strong> 로 교체되었습니다.</p>
|
||||||
|
<footer>
|
||||||
|
<button onclick="document.getElementById('modal').innerHTML=''">닫기</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div id="version-list" class="table-card">
|
<div id="version-list" class="table-card"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||||
{% if versions %}
|
{% if versions %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -26,9 +26,14 @@
|
|||||||
<td>{% if v.gpg %}<span class="badge badge-{{ v.gpg.level }}">{{ v.gpg.label }}</span>{% endif %}</td>
|
<td>{% if v.gpg %}<span class="badge badge-{{ v.gpg.level }}">{{ v.gpg.label }}</span>{% endif %}</td>
|
||||||
<td>{% if v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}</td>
|
<td>{% if v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if not v.is_deployed %}
|
{% if v.is_deployed %}
|
||||||
<!-- 배포 버튼 활성화/확인 모달은 단계4에서 연결 -->
|
{# 배포 상태 칸에 배지 표시 #}
|
||||||
<button class="secondary" disabled>이 버전 배포</button>
|
{% elif v.deployable %}
|
||||||
|
<button class="secondary"
|
||||||
|
hx-get="/repos/{{ uuid }}/deploy/confirm?version_href={{ v.href | urlencode }}"
|
||||||
|
hx-target="#modal" hx-swap="innerHTML">이 버전 배포</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="secondary" disabled title="검증을 통과하지 못해 배포할 수 없습니다">이 버전 배포</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>{{ repo_name }} · 버전</h2>
|
<h2>{{ repo_name }} · 버전</h2>
|
||||||
|
<div id="flash"></div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="status-line status-bad">버전 목록을 불러오지 못했습니다: {{ error }}</p>
|
<p class="status-line status-bad">버전 목록을 불러오지 못했습니다: {{ error }}</p>
|
||||||
@@ -21,4 +22,7 @@
|
|||||||
{% include "partials/version_list.html" %}
|
{% include "partials/version_list.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 배포 확인 모달이 여기에 로드된다 -->
|
||||||
|
<div id="modal"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
32
app/views.py
32
app/views.py
@@ -85,14 +85,44 @@ def build_version_view(
|
|||||||
호출부에서 repo 의 gpg_status() 를 주입한다.
|
호출부에서 repo 의 gpg_status() 를 주입한다.
|
||||||
"""
|
"""
|
||||||
number = version_number(version.get("pulp_href"))
|
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 {
|
return {
|
||||||
"number": number,
|
"number": number,
|
||||||
|
"href": version.get("pulp_href"),
|
||||||
"created": version.get("pulp_created"),
|
"created": version.get("pulp_created"),
|
||||||
"package_count": _content_count(version, "present"),
|
"package_count": _content_count(version, "present"),
|
||||||
"added": _content_count(version, "added"),
|
"added": _content_count(version, "added"),
|
||||||
"removed": _content_count(version, "removed"),
|
"removed": _content_count(version, "removed"),
|
||||||
"gpg": gpg,
|
"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 "−",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
167
tests/test_deploy.py
Normal file
167
tests/test_deploy.py
Normal file
@@ -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"
|
||||||
@@ -61,3 +61,43 @@ def test_get_task_returns_state():
|
|||||||
with PulpClient() as pulp:
|
with PulpClient() as pulp:
|
||||||
task = pulp.get_task("/pulp/api/v3/tasks/t1/")
|
task = pulp.get_task("/pulp/api/v3/tasks/t1/")
|
||||||
assert task["state"] == "running"
|
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"
|
||||||
|
|||||||
@@ -135,3 +135,35 @@ def test_verification_detail():
|
|||||||
assert d["gpgcheck"] == 1
|
assert d["gpgcheck"] == 1
|
||||||
assert d["repo_gpgcheck"] == 0
|
assert d["repo_gpgcheck"] == 0
|
||||||
assert d["status"]["level"] == "warn"
|
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"] == "+"
|
||||||
|
|||||||
Reference in New Issue
Block a user