refactor: audit log to Postgres + split /healthz liveness from Pulp status

- audit.record_deploy 를 JSONL 파일 → Postgres deploy_audit 테이블로 (재배포 휘발 방지)
  - psycopg 지연 import, _write 는 테스트에서 monkeypatch 지점, DATABASE_URL 사용
  - 감사 기록 실패는 배포 완료를 막지 않고 UI 경고(audit_ok)
- /healthz: Pulp 무관 라이브니스 {"ok":true} (컨테이너 health check),
  Pulp 연결 배지는 /pulp-status 로 분리 (대시보드가 폴링)
- config: DATABASE_URL 추가, audit_log_path 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:42:11 +09:00
parent 7ad6d65a11
commit aef4124da0
8 changed files with 95 additions and 35 deletions

View File

@@ -1,17 +1,50 @@
"""배포 확정 감사 로그 (스펙 §5-2). """배포 확정 감사 로그 (스펙 §5-2) — Postgres 영속화.
누가 / 언제 / 어떤 repo 를 / 어떤 버전으로 배포했는지 append-only JSONL 로 남긴다. 컨테이너 파일시스템은 재배포 시 휘발하므로 감사 기록은 제공된 Postgres(DATABASE_URL)에 남긴다.
이전 버전(from_version)도 기록해 사고 시 롤백 대상을 즉시 파악할 수 있게 한다. 누가 / 언제 / 어떤 repo / 이전 버전 → 대상 버전 (롤백 추적).
psycopg 는 지연 import 한다 — DB 미사용 환경/테스트에서 드라이버 없이도 모듈이 로드되도록.
""" """
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any from typing import Any
from .config import get_settings from .config import get_settings
_SCHEMA = """
CREATE TABLE IF NOT EXISTS deploy_audit (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
operator text NOT NULL,
repo_uuid text NOT NULL,
repo_name text NOT NULL,
from_version integer,
to_version integer,
publication text NOT NULL
)
"""
_INSERT = """
INSERT INTO deploy_audit
(operator, repo_uuid, repo_name, from_version, to_version, publication)
VALUES
(%(operator)s, %(repo_uuid)s, %(repo_name)s,
%(from_version)s, %(to_version)s, %(publication)s)
"""
def _write(entry: dict[str, Any]) -> None:
"""감사 항목 1건을 DB 에 기록 (테이블 없으면 생성). 테스트에서 monkeypatch 지점."""
import psycopg # 지연 import
dsn = get_settings().database_url
if not dsn:
raise RuntimeError("DATABASE_URL 미설정 — 감사 로그를 기록할 수 없습니다.")
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(_SCHEMA)
cur.execute(_INSERT, entry)
def record_deploy( def record_deploy(
*, *,
@@ -23,8 +56,6 @@ def record_deploy(
operator: str = "operator", # TODO(auth): 로그인 도입 시 실제 사용자. 배포 시 비밀번호 재확인 예정. operator: str = "operator", # TODO(auth): 로그인 도입 시 실제 사용자. 배포 시 비밀번호 재확인 예정.
) -> dict[str, Any]: ) -> dict[str, Any]:
entry = { entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"action": "deploy",
"operator": operator, "operator": operator,
"repo_uuid": repo_uuid, "repo_uuid": repo_uuid,
"repo_name": repo_name, "repo_name": repo_name,
@@ -32,6 +63,5 @@ def record_deploy(
"to_version": to_version, "to_version": to_version,
"publication": publication_href, "publication": publication_href,
} }
with open(get_settings().audit_log_path, "a", encoding="utf-8") as fh: _write(entry)
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry return entry

View File

@@ -21,10 +21,10 @@ class Settings(BaseSettings):
pulp_password: str = "" pulp_password: str = ""
pulp_verify_tls: bool = True pulp_verify_tls: bool = True
pulp_ca_file: str | None = None pulp_ca_file: str | None = None
# 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능) # 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인
pulp_demo: bool = False pulp_demo: bool = False
# 배포 감사 로그 (append-only JSONL) # 배포 감사 로그 저장용 Postgres (MANUAL: 제공 DB). 없으면 감사 기록 불가.
audit_log_path: str = "audit-log.jsonl" database_url: str | None = None
@lru_cache @lru_cache

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import httpx import httpx
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함) # 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함)
@@ -29,11 +29,21 @@ app.include_router(deploy.router)
__all__ = ["app", "get_pulp_client"] __all__ = ["app", "get_pulp_client"]
@app.get("/healthz", response_class=HTMLResponse) @app.get("/healthz")
def healthz( def healthz() -> JSONResponse:
"""앱 라이브니스(컨테이너 헬스체크용). Pulp 상태와 무관하게 항상 200.
Pulp 미연결이라도 앱 자체는 살아있으므로, Coolify 가 컨테이너를 unhealthy 로
오판하지 않도록 여기서는 Pulp 를 확인하지 않는다. 연결 확인은 /pulp-status.
"""
return JSONResponse({"ok": True})
@app.get("/pulp-status", response_class=HTMLResponse)
def pulp_status(
request: Request, pulp: PulpClient = Depends(get_pulp_client) request: Request, pulp: PulpClient = Depends(get_pulp_client)
) -> HTMLResponse: ) -> HTMLResponse:
"""Pulp status 프록시 → 연결 상태 배지 조각.""" """Pulp status 프록시 → 연결 상태 배지 조각 (대시보드 폴링 대상)."""
try: try:
pulp.status() pulp.status()
online, detail = True, "Pulp 연결됨" online, detail = True, "Pulp 연결됨"

View File

@@ -187,7 +187,9 @@ def deploy(
502, 502,
) )
# 6) 감사 로그 (이전 버전 기록 = 롤백 추적). 기록 실패해도 배포는 이미 완료. # 6) 감사 로그 (이전 버전 기록 = 롤백 추적). DB 기록 실패해도 배포는 이미 완료됐으므로
# 500 으로 죽이지 않고 audit_ok=False 로 UI 에 경고한다.
# TODO(운영): 감사 필수 정책이면 기록 실패 시 배포를 거부(hard-fail)하도록 강화.
audit_ok = True audit_ok = True
try: try:
audit.record_deploy( audit.record_deploy(
@@ -197,7 +199,7 @@ def deploy(
to_version=ctx["target_number"], to_version=ctx["target_number"],
publication_href=pub_href, publication_href=pub_href,
) )
except OSError: except Exception: # noqa: BLE001 - DB/드라이버 오류 등 기록 실패는 배포 완료를 막지 않음
audit_ok = False audit_ok = False
# 7) 성공: 버전 목록 OOB 갱신 + 모달 성공 표시 # 7) 성공: 버전 목록 OOB 갱신 + 모달 성공 표시

View File

@@ -4,7 +4,7 @@
<section> <section>
<h2>상태</h2> <h2>상태</h2>
<!-- 로드 시 1회 + 10초마다 Pulp 연결 상태 폴링 --> <!-- 로드 시 1회 + 10초마다 Pulp 연결 상태 폴링 -->
<div hx-get="/healthz" hx-trigger="load, every 10s" hx-swap="innerHTML" style="margin-top:12px;"> <div hx-get="/pulp-status" hx-trigger="load, every 10s" hx-swap="innerHTML" style="margin-top:12px;">
<span class="status-line status-loading">연결 확인 중…</span> <span class="status-line status-loading">연결 확인 중…</span>
</div> </div>
</section> </section>

View File

@@ -5,3 +5,4 @@ httpx==0.28.1
jinja2==3.1.4 jinja2==3.1.4
python-multipart==0.0.20 python-multipart==0.0.20
pydantic-settings==2.7.1 pydantic-settings==2.7.1
psycopg[binary]==3.3.4

View File

@@ -1,8 +1,5 @@
import json
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app, get_pulp_client from app.main import app, get_pulp_client
client = TestClient(app) client = TestClient(app)
@@ -141,9 +138,10 @@ def test_deploy_distribution_failure_reported():
assert "운영망 변경에 실패" in resp.text assert "운영망 변경에 실패" in resp.text
def test_deploy_success_writes_audit_and_refreshes_list(tmp_path, monkeypatch): def test_deploy_success_writes_audit_and_refreshes_list(monkeypatch):
monkeypatch.setenv("AUDIT_LOG_PATH", str(tmp_path / "audit.jsonl")) # 감사 DB 쓰기는 _write 를 가로채 캡처(실 DB 불필요)
get_settings.cache_clear() records = []
monkeypatch.setattr("app.audit._write", lambda entry: records.append(entry))
fake = _FakePulp() fake = _FakePulp()
_use(fake) _use(fake)
@@ -159,9 +157,21 @@ def test_deploy_success_writes_audit_and_refreshes_list(tmp_path, monkeypatch):
"/pulp/api/v3/publications/rpm/rpm/new/", "/pulp/api/v3/publications/rpm/rpm/new/",
) )
# 감사 로그: from=2, to=3, repo 이름 기록 # 감사 기록: from=2, to=3, repo 이름
entry = json.loads((tmp_path / "audit.jsonl").read_text(encoding="utf-8").strip()) assert len(records) == 1
assert entry["action"] == "deploy" assert records[0]["from_version"] == 2
assert entry["from_version"] == 2 assert records[0]["to_version"] == 3
assert entry["to_version"] == 3 assert records[0]["repo_name"] == "rocky9-baseos"
assert entry["repo_name"] == "rocky9-baseos"
def test_deploy_succeeds_but_flags_when_audit_fails(monkeypatch):
def _boom(entry):
raise RuntimeError("DATABASE_URL 미설정")
monkeypatch.setattr("app.audit._write", _boom)
_use(_FakePulp())
resp = _post("rocky9-baseos")
# 배포는 이미 완료 → 200 이되, 감사 기록 실패를 UI 에 경고
assert resp.status_code == 200
assert "배포 완료" in resp.text
assert "감사 로그 기록 실패" in resp.text

View File

@@ -16,20 +16,27 @@ class _FakeDown:
raise httpx.ConnectError("connection refused") raise httpx.ConnectError("connection refused")
def test_healthz_online(): def test_healthz_is_liveness_always_ok():
# Pulp 와 무관하게 앱 생존만 본다 → 항상 200 {"ok": true}
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_pulp_status_online():
app.dependency_overrides[get_pulp_client] = lambda: _FakeOnline() app.dependency_overrides[get_pulp_client] = lambda: _FakeOnline()
try: try:
resp = client.get("/healthz") resp = client.get("/pulp-status")
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert resp.status_code == 200 assert resp.status_code == 200
assert "연결됨" in resp.text assert "연결됨" in resp.text
def test_healthz_offline_when_pulp_down(): def test_pulp_status_offline_returns_503():
app.dependency_overrides[get_pulp_client] = lambda: _FakeDown() app.dependency_overrides[get_pulp_client] = lambda: _FakeDown()
try: try:
resp = client.get("/healthz") resp = client.get("/pulp-status")
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert resp.status_code == 503 assert resp.status_code == 503