From aef4124da0a7012b2aa601e4f454308b243f704a Mon Sep 17 00:00:00 2001 From: Hyemin Lee Date: Wed, 17 Jun 2026 13:42:11 +0900 Subject: [PATCH] refactor: audit log to Postgres + split /healthz liveness from Pulp status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/audit.py | 48 +++++++++++++++++++++++++++++------- app/config.py | 6 ++--- app/main.py | 18 +++++++++++--- app/routes/deploy.py | 6 +++-- app/templates/dashboard.html | 2 +- requirements.txt | 1 + tests/test_deploy.py | 34 ++++++++++++++++--------- tests/test_healthz.py | 15 ++++++++--- 8 files changed, 95 insertions(+), 35 deletions(-) diff --git a/app/audit.py b/app/audit.py index bf299f6..2cf5c18 100644 --- a/app/audit.py +++ b/app/audit.py @@ -1,17 +1,50 @@ -"""배포 확정 감사 로그 (스펙 §5-2). +"""배포 확정 감사 로그 (스펙 §5-2) — Postgres 영속화. -누가 / 언제 / 어떤 repo 를 / 어떤 버전으로 배포했는지 append-only JSONL 로 남긴다. -이전 버전(from_version)도 기록해 사고 시 롤백 대상을 즉시 파악할 수 있게 한다. +컨테이너 파일시스템은 재배포 시 휘발하므로 감사 기록은 제공된 Postgres(DATABASE_URL)에 남긴다. +누가 / 언제 / 어떤 repo / 이전 버전 → 대상 버전 (롤백 추적). + +psycopg 는 지연 import 한다 — DB 미사용 환경/테스트에서 드라이버 없이도 모듈이 로드되도록. """ from __future__ import annotations -import json -from datetime import datetime, timezone from typing import Any 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( *, @@ -23,8 +56,6 @@ def record_deploy( 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, @@ -32,6 +63,5 @@ def record_deploy( "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") + _write(entry) return entry diff --git a/app/config.py b/app/config.py index 7a0df46..c7cc36b 100644 --- a/app/config.py +++ b/app/config.py @@ -21,10 +21,10 @@ class Settings(BaseSettings): pulp_password: str = "" pulp_verify_tls: bool = True pulp_ca_file: str | None = None - # 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 (단계5 전 제거 가능) + # 임시: 실제 Pulp 없이 가짜 데이터로 화면 확인 pulp_demo: bool = False - # 배포 감사 로그 (append-only JSONL) - audit_log_path: str = "audit-log.jsonl" + # 배포 감사 로그 저장용 Postgres (MANUAL: 제공 DB). 없으면 감사 기록 불가. + database_url: str | None = None @lru_cache diff --git a/app/main.py b/app/main.py index 0a45aef..a16ee52 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from pathlib import Path import httpx from fastapi import Depends, FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles # 테스트 호환을 위해 re-export (tests 가 app.main.get_pulp_client 를 override 함) @@ -29,11 +29,21 @@ app.include_router(deploy.router) __all__ = ["app", "get_pulp_client"] -@app.get("/healthz", response_class=HTMLResponse) -def healthz( +@app.get("/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) ) -> HTMLResponse: - """Pulp status 프록시 → 연결 상태 배지 조각.""" + """Pulp status 프록시 → 연결 상태 배지 조각 (대시보드 폴링 대상).""" try: pulp.status() online, detail = True, "Pulp 연결됨" diff --git a/app/routes/deploy.py b/app/routes/deploy.py index 6c9ebcc..182ac7f 100644 --- a/app/routes/deploy.py +++ b/app/routes/deploy.py @@ -187,7 +187,9 @@ def deploy( 502, ) - # 6) 감사 로그 (이전 버전 기록 = 롤백 추적). 기록 실패해도 배포는 이미 완료. + # 6) 감사 로그 (이전 버전 기록 = 롤백 추적). DB 기록 실패해도 배포는 이미 완료됐으므로 + # 500 으로 죽이지 않고 audit_ok=False 로 UI 에 경고한다. + # TODO(운영): 감사 필수 정책이면 기록 실패 시 배포를 거부(hard-fail)하도록 강화. audit_ok = True try: audit.record_deploy( @@ -197,7 +199,7 @@ def deploy( to_version=ctx["target_number"], publication_href=pub_href, ) - except OSError: + except Exception: # noqa: BLE001 - DB/드라이버 오류 등 기록 실패는 배포 완료를 막지 않음 audit_ok = False # 7) 성공: 버전 목록 OOB 갱신 + 모달 성공 표시 diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 9e6fade..78cc9ba 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -4,7 +4,7 @@

상태

-
+
연결 확인 중…
diff --git a/requirements.txt b/requirements.txt index 2e54687..2a0f762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ httpx==0.28.1 jinja2==3.1.4 python-multipart==0.0.20 pydantic-settings==2.7.1 +psycopg[binary]==3.3.4 diff --git a/tests/test_deploy.py b/tests/test_deploy.py index cbe082b..29688fb 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1,8 +1,5 @@ -import json - from fastapi.testclient import TestClient -from app.config import get_settings from app.main import app, get_pulp_client client = TestClient(app) @@ -141,9 +138,10 @@ def test_deploy_distribution_failure_reported(): 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() +def test_deploy_success_writes_audit_and_refreshes_list(monkeypatch): + # 감사 DB 쓰기는 _write 를 가로채 캡처(실 DB 불필요) + records = [] + monkeypatch.setattr("app.audit._write", lambda entry: records.append(entry)) fake = _FakePulp() _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/", ) - # 감사 로그: 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" + # 감사 기록: from=2, to=3, repo 이름 + assert len(records) == 1 + assert records[0]["from_version"] == 2 + assert records[0]["to_version"] == 3 + assert records[0]["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 diff --git a/tests/test_healthz.py b/tests/test_healthz.py index df50fa9..fbb998a 100644 --- a/tests/test_healthz.py +++ b/tests/test_healthz.py @@ -16,20 +16,27 @@ class _FakeDown: 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() try: - resp = client.get("/healthz") + resp = client.get("/pulp-status") finally: app.dependency_overrides.clear() assert resp.status_code == 200 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() try: - resp = client.get("/healthz") + resp = client.get("/pulp-status") finally: app.dependency_overrides.clear() assert resp.status_code == 503