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:
48
app/audit.py
48
app/audit.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
app/main.py
18
app/main.py
@@ -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 연결됨"
|
||||||
|
|||||||
@@ -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 갱신 + 모달 성공 표시
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user