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:
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:
|
||||
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"
|
||||
|
||||
@@ -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"] == "+"
|
||||
|
||||
Reference in New Issue
Block a user