diff --git a/docs/test-results/evidence/issue-446/current-state-20261009.md b/docs/test-results/evidence/issue-446/current-state-20261009.md new file mode 100644 index 00000000..e51a3e3d --- /dev/null +++ b/docs/test-results/evidence/issue-446/current-state-20261009.md @@ -0,0 +1,19 @@ +# OpenSQL HA 관측 도입 전 현재 상태 — 2026-10-09 + +- 실행 ID: `ha446inventory20261009a` +- 수집 종료: 2026-10-09 04:38 KST (2026-10-08 19:38 UTC) +- 목적: 이전 시험 문서의 VM·배포·클러스터 상태를 현재 상태로 가정하지 않는다. +- 실행 위치: 로컬 `gcloud` 읽기 명령과 GCP VM 게스트의 읽기 전용 명령. VM 명칭·내부 주소·프로젝트 ID·계정·비밀은 기록 전에 제외했다. +- 성공 기준: 앱 2대·DB 3대·부하 VM·캐시 VM 실행, LB 2/2, 동일 배포 JAR, Patroni 리더 1·replica 2, etcd 멤버 3/3 건강, 프록시 A/B 실제 프로세스·6432 리스너 확인. + +| 확인 위치·목적 | 명령 또는 읽기 방법 | 결과 요약 | 해석 | +| --- | --- | --- | --- | +| 로컬 GCP API · VM 목록 | `gcloud compute instances list --format=json` 후 역할·상태만 출력 | 앱 **2/2**, DB **3/3**, 부하 **1/1**, 공용 캐시 **1/1 RUNNING**. 기존 비관측 VM **1대 TERMINATED** | 게스트 내부 서비스 건강을 뜻하지 않는다. | +| 로컬 GCP API · LB | `gcloud compute backend-services get-health` 결과의 상태만 집계 | 내부 앱 LB 백엔드 **2/2 HEALTHY** | HA 쓰기 성공·DB 복제 상태를 뜻하지 않는다. | +| 앱 A/B · 배포 일치 | IAP SSH에서 `systemctl is-active docgrid`, 실행 JAR의 `sha256sum`, `curl localhost:8081/actuator/prometheus` | 서비스 **2/2 active**, JAR SHA-256 **두 VM 동일** (`8510908ce6f5454d0e6cd64fa5c4de3b2b0ddf5ee63602eb8fab4c0fc46297fb`), metrics HTTP **200/200** | JAR 비트 동일성은 확인. 현재 소스 커밋은 JAR만으로 역추정하지 않았다. | +| DB 3대 · Patroni | 각 노드 `GET /patroni`; node1 `GET /cluster` | node3 **primary**, node1·node2 **replica**, 모두 `running`, timeline **14**, 클러스터 역할 **leader 1·replica 2** | 역할과 REST 응답은 수집 시점 관측. 실제 장애 결과가 아니다. | +| DB 3대 · etcd | 각 노드 로컬 `GET /health`; 한 노드의 읽기 전용 `POST /v3/cluster/member/list` | 로컬 health **3/3 true**, 실행 멤버 **3개** | 정족수 정상 상태만 확인. etcd 장애를 주입하지 않았다. | +| DB node2·node3 · OpenProxy | 프로세스 목록과 TCP 6432 `LISTEN` 확인 | 각 노드 프로세스 **1개**, 6432 리스너 **1개**. node1 프록시 **0개** | 이름을 모르는 systemd 유닛의 `inactive`만으로 프록시 중단이라 판정하지 않았다. | +| 앱·DB · metrics endpoint | 앱 A/B loopback 8081, DB 3대 loopback 8008 | 앱 **2/2 HTTP 200**, Patroni **3/3 HTTP 200** | 관측 VM에서의 네트워크 접근은 별도 시험으로 판정한다. | + +게스트 접근에는 **30분 자동 만료 OS Login 키**를 사용했다. 로컬 개인키는 명령 종료 시 삭제했다. 실행 중 상태가 바뀔 수 있으므로 이 표를 이후 장애 시험의 사전 점검으로 재사용하지 않는다. diff --git a/docs/test-results/evidence/issue-446/gateway-hardening-20261009.md b/docs/test-results/evidence/issue-446/gateway-hardening-20261009.md new file mode 100644 index 00000000..4ea5aa73 --- /dev/null +++ b/docs/test-results/evidence/issue-446/gateway-hardening-20261009.md @@ -0,0 +1,41 @@ +# Patroni 지표 경계 보강 — 2026-10-09 + +- 실행 ID: `ha446gateway20261009a` +- 확인 시각: 2026-10-09 약 04:45~05:00 KST. 각 명령의 정확한 초 단위 시각은 클라우드 감사 로그와 systemd journal에 남으며 이 비식별 요약에는 집계 구간만 기록한다. +- 목적: 관측 VM이 무인증 Patroni 관리 REST API에 접근하지 않으면서 DB 3대의 역할 지표를 계속 수집한다. +- 실행 위치: 로컬 독립 작업트리, GCP DB VM 3대, 관측 VM, 부하 VM, GCP 방화벽·LB API. +- 성공 기준: 게이트웨이 회귀 4/4, 각 DB의 `/metrics` 200·관리 GET/POST 거부, Prometheus 새 18008 active target 3/3, 관측 VM→8008 차단 3/3, 부하 VM→18008·앱 8081 차단, LB HEALTHY 2/2. +- 비밀·주소: 내부 IP와 프로젝트 ID, SSH 키, API 암호는 기록 전에 제외했다. SSH 개인키는 임시 디렉터리에서 실행 종료 시 삭제했고 공개키는 30분 만료로 등록했다. + +## 발견과 수정 순서 + +| 순서·실행 위치 | 명령·방법 | 결과 요약 | 해석 | +| --- | --- | --- | --- | +| 1. DB 컨테이너 3대 | 실행 Patroni YAML의 `restapi` 항목을 컨테이너 내부에서 읽고 인증·TLS 키 존재 여부만 판정 | 노드 **3/3 모두 인증·TLS 항목 없음** | 직접 8008 scrape는 관측 VM에 무인증 관리 REST API의 네트워크 경로까지 제공한다. 비밀 원문은 출력하지 않았다. | +| 2. 로컬 첫 테스트 | `python3 -m unittest scripts/opensql/test_patroni_metrics_gateway.py -v` | **import 오류 1회** | 테스트 모듈 경로를 잘못 지정한 실행 오류. 코드는 아직 검증되지 않았다. | +| 3. 로컬 재실행 | `PYTHONPATH=scripts/opensql python3 -m unittest test_patroni_metrics_gateway -v` | 초기 **4/4 통과**, RFC1918 바인딩 조건 추가 후 최종 **5/5 통과** | 고정 upstream `GET /metrics`만 전달, 관리 GET 404·변경 메서드 405, upstream 오류 502·와일드카드 바인딩 거부를 검증했다. 로컬 소켓 바인딩이 필요한 시험이다. | +| 4. DB node1 첫 설치 | `gcloud compute scp` → `sudo install` → `systemctl enable --now` | **기동 실패 1회**, `216/GROUP` | 해당 DB OS에는 `nogroup`이 없었다. 테스트 실패를 숨기지 않고 OS의 계정 구성을 재확인했다. | +| 5. DB node1 수정 | 그룹 확인 후 systemd unit을 `DynamicUser=yes`로 바꿔 재시작 | 서비스 active, `/metrics` **200** | 공용 `nobody` 계정 경고도 제거하고 전용 임시 사용자로 실행했다. 재시작 직후 첫 curl 한 번은 기동 경합으로 실패했으나 다음 조회는 200이었다. Patroni·DB는 재시작하지 않았다. | +| 6. DB node2/3 설치 | 동일 코드·unit 배포, private IP에만 바인딩, systemd active 확인 | **2/2 active**, `/metrics` **200 2/2**, `/patroni` **404 2/2**, `POST /switchover` **405 2/2** | 새 포트에서 지표와 관리 경로가 분리됐다. node1도 수정 전·후 지표 200과 관리 GET 404·POST 405를 개별 확인했다. | +| 7. GCP 방화벽 | 관측 VM `/32` → DB 18008 allow 규칙 생성 | 생성 **1건 성공** | 기본 내부 허용과 별개로 명시적인 수집 예외를 만들었다. | +| 8. 관측 VM | DB node1/2/3의 새 18008에 `GET /metrics`, `GET /patroni` | 지표 **200 3/3**, 관리 조회 **404 3/3** | 관측 VM에서 실제 네트워크 경로가 열렸다. | +| 9. 관측 VM | Prometheus 대상 8008→18008 3곳 변경, Prometheus 컨테이너만 재시작, `/api/v1/targets?state=active` 조회 | active target **5/5 up**, DB 포트 **18008 3/3**, 앱 포트 **8081 2/2** | 이전 8008 시계열은 TSDB 조회에서 잠시 함께 보였으므로 단순 `up` 시계열 수가 아니라 현재 active target으로 판정했다. `patroni_primary=1` 1개·`0` 2개, `patroni_postgres_running=1` 3개도 새 포트에서 확인했다. | +| 10. GCP 방화벽 첫 정리 | 우리가 만든 관측 VM→DB 8008 allow 규칙 삭제 후 관측 VM에서 8008 TCP 연결 확인 | 삭제 **성공**, 하지만 **8008 OPEN 3/3** | 기존 `default-allow-internal`이 모든 내부 TCP를 허용했다. 허용 규칙 삭제만으로는 접근 차단이 되지 않는다는 실패 사례다. | +| 11. GCP 방화벽 보강 | 관측 VM `/32`→DB 8008 명시 DENY(우선순위 650); DB 18008·앱 8081은 필요한 관측 VM/LB 예외보다 낮은 우선순위의 전체 출처 DENY(우선순위 1000) | DENY **3개 생성 성공** | 앱 8081의 기존 LB health checker ALLOW 우선순위 900을 유지했다. 다른 VM의 기존 8008 정책까지 수정하지 않았다. | +| 12. 양쪽 VM 재검증 | 관측 VM→DB 8008/18008·앱 8081, 부하 VM→DB 18008·앱 8081·관측 9090 | 관측 VM: 8008 **CLOSED 3/3**, 18008 **200 3/3**, 앱 8081 **200 2/2**. 부하 VM: DB·앱 지표 **CLOSED 2/2**, 9090 **OPEN 1/1** | 목적별 허용·거부를 양쪽 출처에서 검증했다. 18008의 관리 POST는 **405 3/3**(node1 초기, node2/3 설치, 관측 VM node1 재확인). | +| 13. GCP LB API | `gcloud compute backend-services get-health` | 앱 A/B **HEALTHY 2/2** | 8081 거부 규칙이 LB의 health check를 끊지 않았다. | +| 14. 로컬 최종 회귀·DB 순차 재배포 | `PYTHONPATH=scripts/opensql python3 -m unittest test_verify_ha_prometheus_rw test_ha_load_telemetry test_sanitize_ha_k6_events test_patroni_metrics_gateway`; `bash -n`; `node --check`; `git diff --check`; 게이트웨이 코드만 DB 3대 재배포 | Python **22/22**, 나머지 **각 종료 0**; 재배포 후 DB `/metrics` **200 3/3**, Prometheus active target **5/5 up** | 와일드카드·특수 주소 바인딩을 거부하도록 제한하고 실제 실행 경로가 유지됨을 확인했다. | + +## 경계와 남은 한계 + +```text +관측 VM ──▶ DB :18008 ── GET /metrics ──▶ 127.0.0.1:8008 Patroni + │ └─ 최대 1MiB·2초 timeout, 실패 시 502 + └─X─▶ DB :8008 (명시적인 출처별 DENY) + +부하 VM ──X─▶ DB :18008 / 앱 :8081 +부하 VM ─────▶ 관측 VM :9090 (Remote Write) +LB health checker ─────▶ 앱 :8081 (기존 허용 유지) +``` + +이 결과는 **관측 VM에서 Patroni 관리 포트로 가는 경로**를 차단했다는 뜻이다. 기존 `default-allow-internal`은 여전히 다른 내부 VM에 넓은 포트를 허용할 수 있다. 이 PR은 OpenSQL 운영망 전체의 REST 인증·TLS·세그먼테이션을 정비하지 않는다. 게이트웨이 역시 DB 호스트에 새 systemd 서비스 3개를 남기며, Patroni 재기동 후 지표 응답과 systemd 자동 재시작은 이번에는 따로 장애 주입하지 않았다. Prometheus·Grafana VM과 부하·앱·DB VM은 모두 중지하지 않았다. diff --git a/docs/test-results/evidence/issue-446/local-regression-20261009.md b/docs/test-results/evidence/issue-446/local-regression-20261009.md new file mode 100644 index 00000000..9336efec --- /dev/null +++ b/docs/test-results/evidence/issue-446/local-regression-20261009.md @@ -0,0 +1,21 @@ +# k6 계측 코드 로컬 회귀 — 2026-10-09 + +- 실행 ID: `ha446local20261009a` +- 기록 시각: 2026-10-09 04:38 KST +- 목적: JSONL 안전 게이트를 유지하면서 결과별 Counter와 원격 최종 합계 대조기를 추가한다. +- 실행 위치: 최신 `develop`에서 만든 독립 `test/446` 작업트리. 기존 작업 폴더의 수정·미추적 파일은 건드리지 않았다. +- 성공 기준: 관련 Python suite **17/17**, Bash·JS 구문 검사, diff whitespace 검사 통과. 원격 DB 장애 성능은 이 로컬 시험의 대상이 아니다. + +| 순서·명령 | 결과 요약 | 해석 | +| --- | --- | --- | +| 초기 `python3 -m unittest scripts/opensql/test_verify_ha_prometheus_rw.py scripts/opensql/test_ha_load_telemetry.py` | **9개 탐색 중 1개 import 오류**, 나머지 8개 통과 | 테스트 코드 실패가 아니라 루트 기준 직접 모듈명 호출에서 `scripts/opensql`이 import 경로에 없었다. 실패 시도를 지우지 않는다. | +| `python3 -m unittest discover -s scripts/opensql -p 'test_verify_ha_prometheus_rw.py'` | **6/6 통과** | 원장 이벤트 검증, 502 보존, 사설 수신 주소, 마지막 Counter, 누락 판정 확인. 네트워크는 mock이다. | +| `python3 -m unittest discover -s scripts/opensql -p 'test_ha_load_telemetry.py'` | **8/8 통과** | 새 태그 없는 Counter가 기존 JSONL 요약을 깨지 않음. 태그가 붙은 임의 지표를 허용하도록 게이트를 완화하지 않았다. | +| `python3 -m unittest discover -s scripts/opensql -p 'test_sanitize_ha_k6_events.py'` | **3/3 통과** | 원장 안전 이벤트 필드 검증의 기존 회귀 조건을 유지했다. | +| `bash -n scripts/opensql/run_ha_probe_k6.sh` | 종료 코드 **0** | 셸 구문 확인. 실제 부하 실행은 아니다. | +| `node --check scripts/opensql/ha_probe_load.js` | 종료 코드 **0** | JavaScript 구문 확인. k6 런타임 검사는 별도 수행. | +| `git diff --check` | 종료 코드 **0** | 변경 파일의 공백 오류 없음. | +| 부하 VM k6 2.3.0 `k6 inspect` 첫 시도 | 종료 코드 **107**, 필수 `HA_*` 값 없음 | `inspect`는 전달한 프로세스 환경 변수를 스크립트에 넣지 않았다. HTTP 요청은 발생하지 않았다. | +| 부하 VM `k6 inspect -e HA_RUN_ID=... -e HA_TARGET_URL=... -e HA_JWT=dummy -e HA_SUMMARY_FILE=... ha_probe_load.js` | 종료 코드 **0**, 구조 JSON 생성 | 합성값으로 실제 설치된 k6 런타임 파싱을 확인. 토큰이나 HTTP 요청을 사용하지 않았다. | + +현재 부하 VM에서 읽은 기존 후보 JWT 파일 **1개는 만료 전·0600 조건을 충족하지 않았다**. 따라서 수정된 전체 HA probe를 실제 앱 쓰기로 재실행하지 않았다. 원격 전송 자체는 [별도 합성 시험](remote-write-ha446rw20261009a.md)에서 확인했다. diff --git a/docs/test-results/evidence/issue-446/observer-security-20261009.md b/docs/test-results/evidence/issue-446/observer-security-20261009.md new file mode 100644 index 00000000..ff49bf70 --- /dev/null +++ b/docs/test-results/evidence/issue-446/observer-security-20261009.md @@ -0,0 +1,28 @@ +# 관측 VM 구성·접근 경계 — 2026-10-09 + +- 실행 ID: `ha446observer20261009a` +- 초기 구성 확인: 2026-10-09 04:44 KST. 이후 접근 경계를 보강했으며, **이 문서의 8008 허용 상태를 최종 상태로 읽으면 안 된다.** +- 목적: 앱/DB 장애와 분리된 관측 경로를 만들되 관리 포트를 외부 인터넷에 공개하지 않는다. +- 실행 위치: GCP API, 신규 관측 VM, 부하 VM, 앱 A VM. +- 성공 기준: 관측 VM RUNNING, Prometheus/Grafana 실행, 수집 대상 5/5 UP, k6만 9090 접근, Grafana 인증·loopback, 외부 포트 차단. + +| 작업·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| `gcloud compute firewall-rules create` — 관측 태그 DENY ingress 우선순위 800, IAP SSH·부하 VM 9090 ALLOW 우선순위 700 | 규칙 **3개 생성** | 외부 IP는 패키지 설치 outbound용이다. 우선순위가 높은 허용 경로 외의 신규 유입을 거부한다. | +| `gcloud compute instances create` — Debian 12, `e2-standard-2`, 균형 디스크 30GB, 서비스 계정 없음 | 관측 VM **1대 RUNNING**, 부착 디스크 **1개**, 서비스 계정 **0개** | VM 실행·디스크 보관에 추가 과금된다. 클러스터 장애 대상과 별도 VM이다. | +| `gcloud compute firewall-rules create` — 관측 VM `/32`에서 앱 8081·DB 8008, 부하 VM `/32`에서 관측 9090 | 세 규칙 **3/3 생성** | Patroni의 관리 API도 같은 8008에 있으므로 출처를 관측 VM 단일 내부 IP로만 제한했다. | +| 게스트 `apt-get install docker.io`, 공식 이미지 `prom/prometheus:v3.5.5`, `grafana/grafana:13.2.3` | Docker 설치 **성공**, 컨테이너 **2/2 running** | Prometheus retention **7일·15GB**, scrape 간격 **5초**, timeout **2초**. | +| 관측 VM Prometheus `query=up` | 앱 **2/2 UP**, Patroni **3/3 UP** | 각 metrics endpoint에서 실제 수집됨. `UP=1`만으로 DB의 쓰기 정합성은 증명하지 않는다. | +| Prometheus `patroni_primary`, `patroni_postgres_running` | `patroni_primary`: **1값 1개·0값 2개**; `patroni_postgres_running`: **1값 3개** | 단순 포트 열림이 아니라 역할·실행 메트릭도 수집됨. | +| Grafana `/api/datasources/uid/ha-prometheus` | 인증 없이 **401**, 보호된 관리자 인증 후 **200**, Prometheus 데이터소스 확인 | Grafana 3000은 **127.0.0.1에만** 바인딩. 관리자 암호는 관측 VM의 읽기 제한 파일에만 있고 문서·로그로 전송하지 않았다. | +| 부하 VM → 관측 VM `GET /-/ready`; 앱 A → 같은 주소 | 부하 VM **HTTP 200**, 앱 A **연결 불가** | 허용/비허용 원본 VM에서 경계를 각각 시험했다. | +| 로컬 외부 위치 → 관측 VM 공인 IP TCP 22·3000·9090 | 각 포트 **연결 불가 (3/3)** | IAP SSH는 별도 경로로 성공했다. 공인 IP가 있어도 관리 포트는 외부에서 열리지 않았다. | +| DB node1 Patroni 실행 설정 탐색 | 실행 프로세스 인자에서 YAML 후보 **1개** 발견 후 호스트 `sudo cat` 읽기 시도 | 호스트에서 읽기 **실패**, REST 쓰기 API의 인증·TLS 설정 **미판정** | `GET /metrics` 성공만으로 변경 API의 인증을 단정할 수 없다. 관리 API에 변이 요청을 보내지는 않았다. | + +기존 앱 8081 규칙은 GCP 내부 LB health check에 사용되며 출처 CIDR 두 개가 기존대로 남아 있다. 이를 관측 VM `/32` 하나로 바꾸면 LB 2/2 상태를 깨므로 **관측 VM과 LB health checker만 예외로 허용**한다. Patroni 8008은 방화벽이 URL 경로를 구분하지 못해 관측 VM에서 지표뿐 아니라 관리 API에도 네트워크상 도달할 수 있다. 이번 점검에서는 변경 API의 인증 구성을 판정하지 못했다. 프로젝트에는 정지된 과거 VM 태그를 대상으로 한 공인 3000 허용 규칙도 존재한다. 현재 실행 VM에는 적용되지 않았으며 이번 범위에서 타인의 과거 규칙을 변경하지 않았다. 향후 해당 VM을 재시작할 때 별도 감사가 필요하다. + +관측 VM을 계속 RUNNING으로 두면 **VM 계산 자원·30GB 디스크·사용 중 외부 IPv4** 비용이 지속되며 이미지 다운로드 등의 네트워크 요금도 발생할 수 있다. 외부 IPv4는 패키지·이미지 설치용 outbound를 위해 붙였고 모든 일반 inbound는 차단했다. 정확한 청구액은 계정의 SKU·할인·실제 사용량에 달려 있어 여기서 확정하지 않는다. 관측이 끝나면 VM 중지 여부를 사용자가 결정해야 하며, 중지 후에도 디스크 비용은 남는다. [Google Cloud VM 요금](https://cloud.google.com/products/compute/pricing), [디스크 요금](https://cloud.google.com/compute/disks-image-pricing), [네트워크·외부 IPv4 요금](https://cloud.google.com/vpc/network-pricing). 이번 작업에서는 VM·컨테이너를 내리지 않았다. + +## 뒤이은 보안 정정 + +호스트에서 읽히지 않던 실행 YAML은 **DB 컨테이너 내부**에서 다시 확인했다. 세 노드 모두 Patroni `restapi`에 인증·TLS 항목이 없었다. 초기의 관측 VM→8008 `/32` 허용은 관리 REST API에 대한 네트워크 접근도 열었으므로 최종 정책으로 부적절했다. 지표 전용 18008 게이트웨이를 설치하고 Prometheus active target 5/5를 확인한 뒤, 우리가 만든 8008 허용 규칙을 삭제했다. 그러나 기존 `default-allow-internal` 규칙 때문에 관측 VM→8008 연결이 여전히 **3/3 OPEN**인 실패 결과가 나왔다. 이후 관측 VM 출처의 DB 8008을 더 높은 우선순위로 명시적으로 거부했고, 최종 재검증에서는 **3/3 CLOSED**였다. 전체 변경·시험·남는 VPC 내부 위험은 [별도 보강 로그](gateway-hardening-20261009.md)에 보존한다. diff --git a/docs/test-results/evidence/issue-446/remote-write-ha446rw20261009a.md b/docs/test-results/evidence/issue-446/remote-write-ha446rw20261009a.md new file mode 100644 index 00000000..510fc0b3 --- /dev/null +++ b/docs/test-results/evidence/issue-446/remote-write-ha446rw20261009a.md @@ -0,0 +1,14 @@ +# k6 Remote Write 합성 전송 — `ha446rw20261009a` + +- 실제 이벤트 시각: 2026-10-09 **04:33:20.645–04:33:20.646 KST** (2026-10-08 19:33:20 UTC) +- 실행 위치: GCP 내부 부하 VM → GCP 내부 관측 VM. +- 목적: 실제 DB를 쓰지 않고 k6 2.3.0의 JSONL·Prometheus Remote Write 동시 출력과 마지막 카운터 대조를 확인한다. +- 성공 기준: 합성 `sent`·`acknowledged` **각 1건**, Prometheus `201` Counter **1건**, 나머지 **0건**, JSONL 태그 **0건**, 대조 일치. + +| 순서·방법 | 관측 결과 | 해석 | +| --- | --- | --- | +| 합성 k6 JS에서 태그 없는 `ha_outcome_201` Counter 1회와 안전 이벤트 두 줄 생성, `--out json=... --out experimental-prometheus-rw` 동시 실행 | k6 종료 코드 **0**, 이벤트 **2건**, JSONL 지표 Point **5건** | 실제 HTTP·DB 쓰기는 실행하지 않은 전송 경로 시험이다. | +| `K6_PROMETHEUS_RW_LABELS=run_id=ha446rw20261009a`, `K6_PROMETHEUS_RW_STALE_MARKERS=true` | JSONL에서 태그가 붙은 Point **0건**, `ha_outcome_201` Point **1건** | Remote Write 전용 라벨이 기존 JSONL 증거의 태그 금지 규칙에 섞이지 않았다. | +| `HA_PROM_RW_URL=<관측 VM 사설 주소>/api/v1/write python3 verify_ha_prometheus_rw.py verify --events ... --run-id ha446rw20261009a` | 예상 `201=1`, 관측 `201=1`, `500=0`, `503=0`, 기타 실패=0, 불명=0. **첫 조회에서 일치** | 마지막 Prometheus 카운터와 클라이언트 원장 합계가 같다. 중간 전송 시각의 공백은 이 검증으로 판정할 수 없다. | + +안전한 원본 이벤트·지표·대조 JSON은 부하 VM의 사용자 전용 `ha-observer-446/` 폴더에 실행 ID별 파일로 남겼다. 저장소에는 내부 주소가 들어갈 수 있는 원본 지표 파일을 넣지 않았다. **실제 HA probe HTTP 쓰기, 실패율·지연, 요청 ID의 DB 대조, 장애 전환은 이번 합성 시험에서 미실행**이다. diff --git a/docs/test-results/opensql-ha-observability-20261009.md b/docs/test-results/opensql-ha-observability-20261009.md new file mode 100644 index 00000000..746e3c60 --- /dev/null +++ b/docs/test-results/opensql-ha-observability-20261009.md @@ -0,0 +1,112 @@ +# OpenSQL HA 실시간 관측 경로 구축과 k6 최종 카운터 대조 — 2026-10-09 + +관련: [이슈 #446](https://github.com/DocGrid/docgrid/issues/446). 코드 기준은 `develop`의 `6e27543`에서 분기한 `test/446`이다. + +**결론:** 별도 관측 VM에서 Prometheus·Grafana가 실행되고 앱 A/B·Patroni 3대 **5/5 target**이 수집된다. 부하 VM의 k6 Remote Write **합성 1건**을 수신해 안전한 클라이언트 원장과 최종 카운터가 일치했다. 이는 실제 장애·HTTP 쓰기·RPO 검증 결과가 아니다. + +```text +그림 1 — 현재 연결된 수집 경로 + +GCP 내부 부하 VM ── k6 결과별 Counter ── Remote Write :9090 ──┐ + │ └─ JSONL 안전 이벤트 2건은 부하 VM에 별도 보존 │ + │ ▼ +앱 A ─ Actuator :8081 ─┐ 관측 VM Prometheus +앱 B ─ Actuator :8081 ─┤ 5초 scrape / 2초 timeout +DB node1 ─ metrics-only :18008 ─┤ 7일·15GB 보존 상한 +DB node2 ─ metrics-only :18008 ─┤ │ +DB node3 ─ metrics-only :18008 ─┘ ▼ + Grafana :3000 (loopback) + 관리자 로그인 + IAP SSH 터널 + +각 DB의 게이트웨이: GET /metrics만 로컬 Patroni :8008로 전달 + 관리 경로·변경 메서드는 전달하지 않음 + +OpenProxy A/B는 현재 프로세스·6432 리스너만 재확인했다. +OpenProxy 자체의 시계열 패널은 이 단계에서 아직 연결하지 않았다. +``` + +앱 지표 2개와 Patroni 지표 3개는 실제 Prometheus의 **active target 5/5**에서 모두 `up`이었다. Patroni의 `patroni_primary` 값은 1인 노드 하나와 0인 노드 둘, `patroni_postgres_running`은 3/3이었다. 기존 8008 target의 오래된 시계열이 조회 시점에 잠시 함께 남아 있었으므로 단순 `up` 결과 개수 대신 active target 포트를 확인했다. 상세 원본 명령·숫자는 [현재 상태 로그](evidence/issue-446/current-state-20261009.md), [관측 VM·접근 경계 로그](evidence/issue-446/observer-security-20261009.md), [지표 전용 경계 보강 로그](evidence/issue-446/gateway-hardening-20261009.md)에 목적별로 나눴다. + +```text +그림 2 — k6의 두 출력과 각자 주장할 수 있는 범위 + +수정된 ha_probe_load.js + ├─ 요청 종료 상태 분류 + │ 201 / 500 / 503 / 기타 HTTP 실패 / 결과 불명 + ├─ 기존 JSONL 출력 ──▶ 1초 계측·요청별 외부 원장 + │ └─ run_id 외의 임의 지표 태그를 거부 + └─ Prometheus Remote Write ──▶ Grafana 시간축 + └─ run_id 라벨은 이 출력에만 추가 + +시험 종료 후 verify_ha_prometheus_rw.py + 외부 원장의 종료 이벤트 수 ── 비교 ── Prometheus 마지막 Counter + 일치: 관측 전송의 최종 합계만 확인 + 불일치: 원격 지표 검증 실패로 별도 표시 + +DB request_id 행 대조는 기존 도구의 별도 판정이다. +Prometheus 합계가 맞아도 DB의 누락·중복이 0이라고 주장하지 않는다. +``` + +| 변경 파일·구성 | 목적 | +| --- | --- | +| `scripts/opensql/ha_probe_load.js` | 결과별 태그 없는 Counter 5종을 추가해 502/504 같은 기타 HTTP 오류도 누락하지 않는다. | +| `scripts/opensql/run_ha_probe_k6.sh` | `HA_PROM_RW_URL`이 있을 때만 JSONL과 Remote Write를 병행하고, `run_id` 원격 라벨·stale marker·최종 카운터 대조를 적용한다. 미설정이면 기존 JSONL 전용 실행을 유지한다. | +| `scripts/opensql/verify_ha_prometheus_rw.py` | 사설 IPv4 수신 주소만 허용하고, 안전 원장의 완료 상태와 Prometheus 최종 Counter를 실행 ID별로 비교한다. 내부 주소·원시 HTTP 오류는 출력하지 않는다. | +| `scripts/opensql/test_verify_ha_prometheus_rw.py`, `scripts/opensql/test_ha_load_telemetry.py` | 불완전·중복 원장, 기타 오류, 태그 금지, 최종 합계의 한계를 회귀 시험한다. | +| `scripts/opensql/patroni_metrics_gateway.py`, `scripts/opensql/patroni_metrics_gateway.service` | DB 호스트의 비권한 프로세스가 정확한 `GET /metrics`만 로컬 Patroni로 전달한다. 기존 Patroni·PostgreSQL 프로세스는 재시작하지 않는다. | +| `scripts/opensql/test_patroni_metrics_gateway.py` | 관리 경로·변경 메서드 거부, 고정 upstream, upstream 실패, RFC1918 바인딩의 회귀 시험 5건. | +| GCP 관측 VM (저장소 비공개 운영 설정) | Prometheus 3.5.5, Grafana 13.2.3, 30GB 디스크, IAP 관리 경로. 실제 내부 대상 주소·Grafana 비밀은 Git에 넣지 않는다. | + +```text +그림 3 — 관리 포트 접근 경계 + +외부 인터넷 ──X──▶ 관측 VM :22/:3000/:9090 + 우선순위 800 DENY ingress + ▲ ▲ + │ 우선순위 700 │ 우선순위 700 + IAP SSH 출처 부하 VM 내부 IP /32 → :9090 + │ + └─ SSH 터널 → 관측 VM loopback :3000 (Grafana 로그인) + +관측 VM 내부 IP /32 ──▶ 앱 A/B :8081 (metrics) +관측 VM 내부 IP /32 ──▶ DB 3대 :18008 (metrics-only) +관측 VM ──X──▶ DB 3대 :8008 (Patroni 관리 REST) +부하 VM ──X──▶ 앱 :8081 / DB :18008 +GCP LB health checker ──▶ 앱 A/B :8081 (기존 예외 유지) + +앱 A에서 관측 VM :9090 연결: 실패 관측 +부하 VM에서 관측 VM :9090 readiness: HTTP 200 관측 +외부에서 관측 VM :22/:3000/:9090 연결: 3/3 실패 관측 +``` + +설치된 DB 컨테이너 안의 실행 Patroni 설정을 확인한 결과 REST API 인증·TLS 항목이 **세 노드 모두 없었다**. 초기에는 관측 VM에서 8008로 직접 수집했고, 이를 위험한 임시 경계로 판정했다. DB 호스트의 지표 전용 게이트웨이를 검증·배포한 뒤 수집을 18008로 전환했다. 기본 VPC 내부 허용 규칙 때문에 초기 8008 허용 규칙만 삭제해도 관측 VM에서 관리 포트에 계속 접속할 수 있었으므로, 관측 VM 출처의 8008을 명시적으로 거부했다. 현재 관측 VM→8008은 3/3 연결 불가이고 18008 지표는 3/3 HTTP 200이다. 다른 기존 VPC 내부 VM의 8008 접근 정책 전체를 바꾼 것은 아니므로, 이는 클러스터 전반의 Patroni 관리 API 보안 완료를 뜻하지 않는다. 관측 VM에는 GCP 서비스 계정을 붙이지 않았다. 순서와 실패·수정 증거는 [지표 전용 경계 보강 로그](evidence/issue-446/gateway-hardening-20261009.md)에 있다. + +| 목적·실행 위치 | 실행 명령·방법 | 숫자 결과 | 판정 | +| --- | --- | --- | --- | +| 현재 클러스터 · GCP/VM 읽기 | Compute 목록·LB health, 앱 JAR 해시, Patroni REST, etcd 로컬 health·멤버 목록, 프록시 프로세스·리스너 | 앱 **2/2**, DB **3/3**, LB **2/2**, leader **1**·replica **2**, etcd health·멤버 **3/3**, 프록시 **2/2** | 수집 시점 기준 상태 확인. [로그](evidence/issue-446/current-state-20261009.md) | +| 코드 회귀 · 로컬/부하 VM | Python 기존 세 suite + 게이트웨이 suite, `bash -n`, `node --check`, k6 2.3.0 `inspect -e ...` | Python **22/22**, Bash·JS·k6 inspect 최종 **각 통과**. 초기 잘못된 unittest 탐색 2회·inspect 환경값 누락 1회 실패 보존 | 코드·런타임 파싱 확인. [기존 로그](evidence/issue-446/local-regression-20261009.md), [게이트웨이 로그](evidence/issue-446/gateway-hardening-20261009.md) | +| 관측 인프라 · GCP | Docker 컨테이너·Prometheus active targets·Patroni 지표·Grafana API·내외부 연결 | 컨테이너 **2/2**, active targets **5/5**, primary metric **1/3**, Grafana 무인증 **401**·인증 **200** | 수집·접근 경계 통과. [초기 로그](evidence/issue-446/observer-security-20261009.md), [보강 로그](evidence/issue-446/gateway-hardening-20261009.md) | +| k6 전송 · GCP 내부 부하 VM | `--out json` + `--out experimental-prometheus-rw`, 원장/Counter 대조 | 실행 `ha446rw20261009a`, 안전 이벤트 **2건**, 201 예상·관측 **1/1**, 태그 붙은 JSONL Point **0/5** | **합성 전송 경로 통과**. [로그](evidence/issue-446/remote-write-ha446rw20261009a.md) | + +```text +그림 4 — 아직 검증하지 않은 장면 + +이번 완료: + 앱·Patroni 지표 수집 → Grafana datasource 연결 → 합성 k6 Counter 대조 + +다음 장애 시연 전 필요: + 새 유효 시험 JWT 준비 → 실제 HA probe 소량 기준선 + │ + ▼ + 장애 주입 → 요청별 JSONL 원장 → 최종 primary의 request_id 대조 + │ + ├─ Grafana: 시간대별 실패·복구를 설명하는 보조 화면 + └─ 원장/DB: 201 누락·중복·결과 불명의 최종 근거 + +미실행: 이번 변경으로 실제 primary/OpenProxy를 중단하거나 + 실제 HA probe HTTP 쓰기를 재시험하지 않았다. +``` + +현재 부하 VM에는 재사용 가능한 **만료 전·0600 시험 JWT가 0개**라 실제 앱 쓰기 부하까지 확대하지 않았다. 또한 Remote Write의 마지막 카운터가 일치해도 중간 전송 공백은 증명하지 않는다. k6 Trend의 누적 p95/p99를 ‘최근 5초 지연’처럼 그리지 않으며, OpenProxy·etcd 자체 시계열은 아직 수집하지 않는다. 안전한 이벤트·지표 원본은 부하 VM의 실행 ID 전용 폴더에 보존했고, 비식별 숫자만 이 문서에 남겼다. + +관측 VM은 계속 실행 중이다. 추가 비용은 VM 가동 시간, 30GB 디스크, 사용 중 외부 IPv4와 가능한 네트워크 사용량에 발생한다. 중지하면 VM 계산 자원 사용료는 멈추지만 디스크 비용은 남는다. 정확한 청구액은 [Google Cloud 요금표](https://cloud.google.com/products/compute/pricing)와 청구 계정에서 확인해야 한다. 이 작업에서는 앱·DB·부하 VM과 관측 VM을 중지하거나 장애 주입하지 않았다. diff --git a/scripts/opensql/ha_probe_load.js b/scripts/opensql/ha_probe_load.js index 768316a4..ad797f19 100644 --- a/scripts/opensql/ha_probe_load.js +++ b/scripts/opensql/ha_probe_load.js @@ -1,6 +1,7 @@ import http from 'k6/http'; import exec from 'k6/execution'; import { check } from 'k6'; +import { Counter } from 'k6/metrics'; // Only run IDs, request IDs, numeric outcomes and times are emitted to the live ledger. const runId = __ENV.HA_RUN_ID; @@ -9,6 +10,13 @@ const token = __ENV.HA_JWT; const rate = Number(__ENV.HA_RATE || 10); const duration = __ENV.HA_DURATION || '60s'; +// Keep outcome metrics tag-free so the existing JSONL evidence gate stays strict. +const outcome201 = new Counter('ha_outcome_201'); +const outcome500 = new Counter('ha_outcome_500'); +const outcome503 = new Counter('ha_outcome_503'); +const outcomeOtherFailed = new Counter('ha_outcome_other_failed'); +const outcomeUnknown = new Counter('ha_outcome_unknown'); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,59}$/.test(runId || '') || !target || !token || !__ENV.HA_SUMMARY_FILE) { throw new Error('A short HA_RUN_ID, HA_TARGET_URL, HA_JWT and HA_SUMMARY_FILE are required'); } @@ -54,15 +62,22 @@ export default function () { timeout: __ENV.HA_TIMEOUT || '10s', }); } catch (_) { + outcomeUnknown.add(1); event(requestId, 'unknown', { reason: 'other' }); return; } if (response.status === 201) { + outcome201.add(1); event(requestId, 'acknowledged', { http_status: 201 }); } else if (response.status >= 300 && response.status < 600) { + // 2. Keep unexpected HTTP failures visible alongside the main 500/503 buckets. + if (response.status === 500) outcome500.add(1); + else if (response.status === 503) outcome503.add(1); + else outcomeOtherFailed.add(1); event(requestId, 'failed', { http_status: response.status }); } else { // A status of 0 alone cannot distinguish a timeout from a connection failure. + outcomeUnknown.add(1); event(requestId, 'unknown', { reason: 'other' }); } check(response, { '201 commit response': (r) => r.status === 201 }); diff --git a/scripts/opensql/patroni_metrics_gateway.py b/scripts/opensql/patroni_metrics_gateway.py new file mode 100644 index 00000000..a8135021 --- /dev/null +++ b/scripts/opensql/patroni_metrics_gateway.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Expose only Patroni metrics while keeping its unauthenticated REST API private. + +This read-only gateway runs on each DB host, forwards one fixed GET path to the +local Patroni listener, and does not accept management methods or paths. +""" + +from __future__ import annotations + +import argparse +import ipaddress +from http.client import HTTPConnection, HTTPException +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +UPSTREAM_HOST = "127.0.0.1" +UPSTREAM_PORT = 8008 +UPSTREAM_PATH = "/metrics" +MAX_METRICS_BYTES = 1024 * 1024 +PRIVATE_NETWORKS = tuple( + ipaddress.ip_network(cidr) for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") +) + + +class MetricsOnlyHandler(BaseHTTPRequestHandler): + """Forward GET /metrics only, without exposing Patroni's management API.""" + + def log_message(self, format_string: str, *args: object) -> None: + """Avoid writing caller addresses or untrusted paths to host logs.""" + + def do_GET(self) -> None: + """Fetch bounded metrics from the fixed loopback upstream.""" + if self.path != UPSTREAM_PATH: + self.send_error(404) + return + connection = HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=2) + try: + # 1. A fixed HTTPConnection cannot follow a redirect to another host. + connection.request("GET", UPSTREAM_PATH) + response = connection.getresponse() + if response.status != 200: + raise ValueError("upstream status") + body = response.read(MAX_METRICS_BYTES + 1) + if len(body) > MAX_METRICS_BYTES: + raise ValueError("upstream body too large") + except (OSError, HTTPException, ValueError): + self.send_error(502) + return + finally: + connection.close() + # 2. Return the scrape payload without proxying upstream headers or redirects. + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: + """Reject Patroni management writes even from the allowed observer VM.""" + self.send_error(405) + + def do_PATCH(self) -> None: + """Reject Patroni configuration writes.""" + self.send_error(405) + + def do_DELETE(self) -> None: + """Reject Patroni resource deletion.""" + self.send_error(405) + + +def validate_private_bind(value: str) -> str: + """Reject wildcard and non-RFC1918 addresses, including special-use ranges.""" + address = ipaddress.ip_address(value) + if address.version != 4 or not any(address in network for network in PRIVATE_NETWORKS): + raise ValueError("--bind must be an RFC1918 IPv4 address") + return str(address) + + +def main() -> None: + """Bind only a supplied RFC1918 interface address on the metrics-only port.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bind", required=True) + args = parser.parse_args() + try: + bind_address = validate_private_bind(args.bind) + except ValueError as error: + parser.error(str(error)) + with ThreadingHTTPServer((bind_address, 18008), MetricsOnlyHandler) as server: + server.serve_forever(poll_interval=0.5) + + +if __name__ == "__main__": + main() diff --git a/scripts/opensql/patroni_metrics_gateway.service b/scripts/opensql/patroni_metrics_gateway.service new file mode 100644 index 00000000..1db071f9 --- /dev/null +++ b/scripts/opensql/patroni_metrics_gateway.service @@ -0,0 +1,20 @@ +[Unit] +Description=DocGrid Patroni metrics-only gateway +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +DynamicUser=yes +EnvironmentFile=/etc/default/docgrid-patroni-metrics-gateway +ExecStart=/usr/bin/python3 /usr/local/lib/docgrid/patroni_metrics_gateway.py --bind ${BIND_IP} +Restart=always +RestartSec=2 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target diff --git a/scripts/opensql/run_ha_probe_k6.sh b/scripts/opensql/run_ha_probe_k6.sh index 9b50457a..f2b11efd 100644 --- a/scripts/opensql/run_ha_probe_k6.sh +++ b/scripts/opensql/run_ha_probe_k6.sh @@ -58,6 +58,23 @@ export HA_SUMMARY_FILE="$run_dir/k6-summary.json" export HA_VUS="$initial_vus" HA_MAX_VUS="$max_vus" HA_TIMEOUT=10s printf '초기 VU=%s\n설정 최대 VU=%s\n계측 간격=1초\n지표 태그=모두 비활성\n' \ "$HA_VUS" "$HA_MAX_VUS" >> "$run_dir/실행-기록.txt" +outputs=(--out "json=$run_dir/k6-metrics.jsonl") +remote_write_enabled=0 +if [[ -n "${HA_PROM_RW_URL:-}" ]]; then + if ! python3 "$script_dir/verify_ha_prometheus_rw.py" validate-url >/dev/null 2>/dev/null; then + echo '관측 서버 주소가 허용된 사설 IPv4 형식이 아닙니다' >&2 + exit 2 + fi + # Only remote write receives run_id; JSONL stays tag-free for the evidence gate. + export K6_PROMETHEUS_RW_SERVER_URL="$HA_PROM_RW_URL" + export K6_PROMETHEUS_RW_LABELS="run_id=$run_id" + export K6_PROMETHEUS_RW_STALE_MARKERS=true + export K6_PROMETHEUS_RW_TREND_STATS=count,sum + outputs+=(--out experimental-prometheus-rw) + remote_write_enabled=1 +fi +printf '원격 지표 전송=%s\n실시간 백분위=사용하지 않음\n' \ + "$remote_write_enabled" >> "$run_dir/실행-기록.txt" # 1. Stream tag-free k6 points and numeric /proc samples into this run alone. cleanup_children() { if [[ -n "${sampler_pid:-}" ]]; then kill "$sampler_pid" 2>/dev/null || true; fi @@ -66,7 +83,7 @@ cleanup_children() { trap cleanup_children EXIT set +e k6 run --quiet --log-format=raw --console-output="$run_dir/k6-events.jsonl" \ - --out "json=$run_dir/k6-metrics.jsonl" \ + "${outputs[@]}" \ "$script_dir/ha_probe_load.js" >/dev/null 2>/dev/null & k6_pid=$! env -u HA_JWT -u HA_TARGET_URL python3 "$script_dir/ha_load_telemetry.py" sample --pid "$k6_pid" \ @@ -87,10 +104,27 @@ python3 "$script_dir/ha_load_telemetry.py" summarize \ --metrics "$run_dir/k6-metrics.jsonl" --host "$run_dir/host-samples.csv" \ --summary "$run_dir/k6-summary.json" --out "$run_dir/계측-1초.csv" \ > "$run_dir/계측-요약.json" 2>/dev/null || telemetry_exit=$? +remote_write_exit=0 +if (( remote_write_enabled )); then + python3 "$script_dir/verify_ha_prometheus_rw.py" verify \ + --events "$run_dir/k6-events.jsonl" --run-id "$run_id" \ + > "$run_dir/원격-지표-대조.json" 2>/dev/null || remote_write_exit=$? + if (( remote_write_exit == 2 )); then + printf '{"run_id":"%s","status":"검증 불가","final_totals_match":false}\n' \ + "$run_id" > "$run_dir/원격-지표-대조.json" + fi +fi printf '종료 UTC=%s\nk6 종료 코드=%s\nVM 표본기 종료 코드=%s\n1초 계측 검증 종료 코드=%s\n실시간 안전 이벤트=%s건\n원시 k6 stderr=비밀·내부 주소 사전 제거 보장 불가로 수집하지 않음\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$exit_code" "$sampler_exit" "$telemetry_exit" \ "$(wc -l < "$run_dir/k6-events.jsonl")" >> "$run_dir/실행-기록.txt" +if (( remote_write_enabled )); then + printf '원격 지표 최종 합계 검증 종료 코드=%s\n중간 전송 공백=이 검증으로는 판정 불가\n' \ + "$remote_write_exit" >> "$run_dir/실행-기록.txt" +else + printf '원격 지표 최종 합계 검증=미사용\n' >> "$run_dir/실행-기록.txt" +fi printf 'run_id=%s k6_exit=%s events=%s\n' "$run_id" "$exit_code" \ "$(wc -l < "$run_dir/k6-events.jsonl")" if (( sampler_exit != 0 || telemetry_exit != 0 )); then exit 98; fi +if (( remote_write_exit != 0 )); then exit 97; fi exit "$exit_code" diff --git a/scripts/opensql/test_ha_load_telemetry.py b/scripts/opensql/test_ha_load_telemetry.py index b40aef15..ef0bc5df 100644 --- a/scripts/opensql/test_ha_load_telemetry.py +++ b/scripts/opensql/test_ha_load_telemetry.py @@ -84,6 +84,17 @@ def test_rejects_raw_url_tag_before_derived_file_is_written(self): TELEMETRY.summarize(self.metrics, self.host, self.summary, self.output) self.assertFalse(self.output.exists()) + def test_tag_free_dashboard_counter_keeps_existing_jsonl_summary(self): + """Remote-write outcome counters must not weaken the strict JSONL tag gate.""" + self.write_fixture() + with self.metrics.open("a", encoding="utf-8") as output: + output.write(json.dumps({ + "type": "Point", "metric": "ha_outcome_201", + "data": {"time": "2026-10-04T01:00:01.000Z", "value": 1, "tags": None}, + }) + "\n") + result = TELEMETRY.summarize(self.metrics, self.host, self.summary, self.output) + self.assertEqual(2, result["requests"]) + def test_rejects_missing_metric_points(self): """Do not claim a complete 1s timeline when dropped samples went missing.""" self.write_fixture(dropped=1) diff --git a/scripts/opensql/test_patroni_metrics_gateway.py b/scripts/opensql/test_patroni_metrics_gateway.py new file mode 100644 index 00000000..4b33d615 --- /dev/null +++ b/scripts/opensql/test_patroni_metrics_gateway.py @@ -0,0 +1,85 @@ +"""Deterministic checks for the metrics-only Patroni access boundary.""" + +from __future__ import annotations + +import http.client +import threading +import unittest +from unittest.mock import patch + +from patroni_metrics_gateway import MetricsOnlyHandler, ThreadingHTTPServer, validate_private_bind + + +class FakeUpstream: + """A fixed in-memory Patroni metrics response for the gateway tests.""" + + status = 200 + + def read(self, _): + """Return only a small Prometheus metrics payload.""" + return b"patroni_primary 1\n" + + +class PatroniMetricsGatewayTest(unittest.TestCase): + """Ensure no management method or path reaches the Patroni REST listener.""" + + def setUp(self): + """Start a local ephemeral gateway for one isolated test.""" + self.server = ThreadingHTTPServer(("127.0.0.1", 0), MetricsOnlyHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self.thread.join, 2) + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + + def request(self, method: str, path: str): + """Send one local request and return status and body.""" + connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2) + self.addCleanup(connection.close) + connection.request(method, path) + response = connection.getresponse() + return response.status, response.read() + + def test_only_exact_metrics_get_reaches_fixed_upstream(self): + """The upstream URL is fixed even when the caller controls the request.""" + with patch("patroni_metrics_gateway.HTTPConnection") as connection: + connection.return_value.getresponse.return_value = FakeUpstream() + status, body = self.request("GET", "/metrics") + self.assertEqual(200, status) + self.assertEqual(b"patroni_primary 1\n", body) + connection.assert_called_once_with("127.0.0.1", 8008, timeout=2) + connection.return_value.request.assert_called_once_with("GET", "/metrics") + + def test_other_get_path_never_reaches_upstream(self): + """Management reads and query variants are not forwarded.""" + with patch("patroni_metrics_gateway.HTTPConnection") as fetch: + for path in ("/patroni", "/config", "/metrics?extra=1"): + status, _ = self.request("GET", path) + self.assertEqual(404, status) + fetch.assert_not_called() + + def test_management_write_methods_never_reach_upstream(self): + """Switchover, configuration and deletion calls cannot pass this port.""" + with patch("patroni_metrics_gateway.HTTPConnection") as fetch: + for method in ("POST", "PATCH", "DELETE"): + status, _ = self.request(method, "/switchover") + self.assertEqual(405, status) + fetch.assert_not_called() + + def test_upstream_failure_is_a_failed_scrape(self): + """Patroni errors must not be turned into a healthy empty scrape.""" + with patch("patroni_metrics_gateway.HTTPConnection") as connection: + connection.return_value.request.side_effect = OSError("unavailable") + status, _ = self.request("GET", "/metrics") + self.assertEqual(502, status) + + def test_bind_requires_rfc1918_interface(self): + """A wildcard or special-use address must not widen the listener.""" + self.assertEqual("10.0.0.7", validate_private_bind("10.0.0.7")) + for address in ("0.0.0.0", "127.0.0.1", "169.254.1.1", "255.255.255.255", "8.8.8.8", "::1"): + with self.subTest(address=address), self.assertRaises(ValueError): + validate_private_bind(address) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/opensql/test_verify_ha_prometheus_rw.py b/scripts/opensql/test_verify_ha_prometheus_rw.py new file mode 100644 index 00000000..c483b3d4 --- /dev/null +++ b/scripts/opensql/test_verify_ha_prometheus_rw.py @@ -0,0 +1,130 @@ +"""Regression tests for the HA dashboard's safe final-count verdict.""" + +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import verify_ha_prometheus_rw as remote_write + + +class HaPrometheusRemoteWriteTest(unittest.TestCase): + """Keep dashboard totals separate from live delivery and DB reconciliation.""" + + def setUp(self): + """Create one private event ledger for each test case.""" + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.events = Path(temporary.name) / "events.jsonl" + self.run_id = "ha-demo-001" + + def write_event(self, request_id: str, kind: str, **fields): + """Write only the same allowlisted fields the k6 script emits.""" + item = { + "at": "2026-10-09T00:00:00.000Z", + "event_id": f"{request_id}-{kind}", + "kind": kind, + "request_id": request_id, + "run_id": self.run_id, + **fields, + } + with self.events.open("a", encoding="utf-8") as output: + output.write(json.dumps(item) + "\n") + + def test_counts_all_terminal_buckets_without_collapsing_other_failures(self): + """A 502 remains visible when the dashboard highlights 500/503.""" + cases = ( + ("acknowledged", {"http_status": 201}), + ("failed", {"http_status": 500}), + ("failed", {"http_status": 503}), + ("failed", {"http_status": 502}), + ("unknown", {"reason": "other"}), + ) + for index, (kind, fields) in enumerate(cases): + request_id = f"{self.run_id}-v1-i{index}" + self.write_event(request_id, "sent", operation="ha_probe_write") + self.write_event(request_id, kind, **fields) + self.assertEqual({name: 1 for name in remote_write.OUTCOMES}, + remote_write.expected_outcomes(self.events, self.run_id)) + + def test_rejects_missing_or_duplicate_terminal_events(self): + """An incomplete client ledger cannot validate remote-write delivery.""" + request_id = f"{self.run_id}-v1-i0" + self.write_event(request_id, "sent", operation="ha_probe_write") + with self.assertRaisesRegex(ValueError, "빠졌습니다"): + remote_write.expected_outcomes(self.events, self.run_id) + self.write_event(request_id, "unknown", reason="other") + self.write_event(request_id, "unknown", reason="other") + with self.assertRaisesRegex(ValueError, "중복 종료"): + remote_write.expected_outcomes(self.events, self.run_id) + + def test_accepts_only_private_ipv4_receiver_without_credentials(self): + """A mistake cannot send run metrics to a public or credentialed URL.""" + self.assertEqual("http://10.1.2.3:9090/api/v1/query", + remote_write.query_base("http://10.1.2.3:9090/api/v1/write")) + for url in ( + "http://8.8.8.8:9090/api/v1/write", + "http://127.0.0.1:9090/api/v1/write", + "http://user:secret@10.1.2.3:9090/api/v1/write", + "http://10.1.2.3:9090/api/v1/write?token=secret", + "https://10.1.2.3:9090/api/v1/write", + ): + with self.subTest(url=url), self.assertRaises(ValueError): + remote_write.query_base(url) + + def test_final_remote_count_uses_one_safe_prometheus_query(self): + """Read the final non-stale Counter without a network endpoint.""" + payload = {"status": "success", "data": {"resultType": "vector", "result": [ + {"metric": {"run_id": self.run_id}, "value": [1, "3"]} + ]}} + + class FakeOpener: + """Return an in-memory response and preserve the requested URL.""" + + def open(self, request, timeout): + """Record the query without sending data to a network endpoint.""" + self.url = request.full_url + return io.BytesIO(json.dumps(payload).encode()) + + opener = FakeOpener() + with patch.object(remote_write, "build_opener", return_value=opener): + count = remote_write.remote_count( + "http://10.1.2.3:9090/api/v1/query", self.run_id, "201") + self.assertEqual(3, count) + self.assertIn("k6_ha_outcome_201_total", opener.url) + self.assertIn("last_over_time", opener.url) + + def test_verdict_explicitly_excludes_live_gaps_and_database_effects(self): + """Matching totals do not claim a continuous graph or RPO 0.""" + request_id = f"{self.run_id}-v1-i0" + self.write_event(request_id, "sent", operation="ha_probe_write") + self.write_event(request_id, "acknowledged", http_status=201) + with patch.object(remote_write, "remote_count", side_effect=lambda _, __, name: + 1 if name == "201" else 0): + result = remote_write.verify( + self.events, self.run_id, "http://10.1.2.3:9090/api/v1/write") + self.assertTrue(result["final_totals_match"]) + self.assertEqual(1, result["query_attempts"]) + self.assertFalse(result["intermediate_delivery_verified"]) + self.assertFalse(result["db_reconciliation_verified"]) + + def test_missing_final_counter_fails_after_bounded_ingestion_wait(self): + """A broken remote output must not be labeled a successful run.""" + request_id = f"{self.run_id}-v1-i0" + self.write_event(request_id, "sent", operation="ha_probe_write") + self.write_event(request_id, "acknowledged", http_status=201) + with patch.object(remote_write, "remote_count", return_value=0), \ + patch.object(remote_write.time, "sleep") as sleep: + result = remote_write.verify( + self.events, self.run_id, "http://10.1.2.3:9090/api/v1/write") + self.assertFalse(result["final_totals_match"]) + self.assertEqual(4, result["query_attempts"]) + self.assertEqual(3, sleep.call_count) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/opensql/verify_ha_prometheus_rw.py b/scripts/opensql/verify_ha_prometheus_rw.py new file mode 100644 index 00000000..77e80bc6 --- /dev/null +++ b/scripts/opensql/verify_ha_prometheus_rw.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Compare a run's final Prometheus counts with its sanitized client ledger. + +Intermediate dashboard delivery and database effects remain separate verdicts. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import math +import os +import sys +import time +from collections import Counter +from pathlib import Path +from urllib.parse import urlencode, urlsplit +from urllib.request import ProxyHandler, Request, build_opener + +from sanitize_ha_k6_events import RUN_ID, safe_event + + +OUTCOMES = ("201", "500", "503", "other_failed", "unknown") +PRIVATE_NETWORKS = tuple(ipaddress.ip_network(cidr) for cidr in ( + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" +)) + + +def query_base(write_url: str) -> str: + """Allow only a private IPv4 Prometheus receiver without credentials.""" + parsed = urlsplit(write_url) + try: + address = ipaddress.ip_address(parsed.hostname or "") + port = parsed.port + except ValueError as error: + raise ValueError("관측 서버 주소 형식이 올바르지 않습니다") from error + if (parsed.scheme != "http" or address.version != 4 or + not any(address in network for network in PRIVATE_NETWORKS) or + port != 9090 or parsed.path != "/api/v1/write" or + parsed.username or parsed.password or parsed.query or parsed.fragment): + raise ValueError("관측 서버는 사설 IPv4의 9090 원격 쓰기 경로여야 합니다") + return f"http://{address}:9090/api/v1/query" + + +def expected_outcomes(events_path: Path, run_id: str) -> dict[str, int]: + """Count exactly one terminal outcome per sent request in the safe ledger.""" + if not RUN_ID.fullmatch(run_id): + raise ValueError("실행 ID가 올바르지 않습니다") + seen: dict[str, set[str]] = {} + counts: Counter[str] = Counter() + with events_path.open(encoding="utf-8") as source: + for line in source: + item = safe_event(json.loads(line), run_id) + request_id = item["request_id"] + kind = item["kind"] + kinds = seen.setdefault(request_id, set()) + if (kind == "sent" and kinds) or (kind != "sent" and kinds != {"sent"}): + raise ValueError("요청 원장에 중복 종료 이벤트가 있습니다") + kinds.add(kind) + if kind == "acknowledged": + counts["201"] += 1 + elif kind == "failed": + status = item["http_status"] + counts[str(status) if status in (500, 503) else "other_failed"] += 1 + elif kind == "unknown": + counts["unknown"] += 1 + if not seen or any(len(kinds) != 2 or "sent" not in kinds for kinds in seen.values()): + raise ValueError("요청 원장에 전송 또는 종료 이벤트가 빠졌습니다") + return {name: counts[name] for name in OUTCOMES} + + +def remote_count(query_url: str, run_id: str, outcome: str) -> int: + """Read the last non-stale counter in a bounded window after final flush.""" + metric = f"k6_ha_outcome_{outcome}_total" + expression = f'last_over_time({metric}{{run_id="{run_id}"}}[10m])' + url = f"{query_url}?{urlencode({'query': expression})}" + # 1. Do not send a private endpoint through local proxy environment variables. + opener = build_opener(ProxyHandler({})) + with opener.open(Request(url, headers={"Accept": "application/json"}), timeout=5) as response: + payload = json.load(response) + if payload.get("status") != "success" or payload.get("data", {}).get("resultType") != "vector": + raise ValueError("관측 서버 응답 형식이 올바르지 않습니다") + results = payload["data"]["result"] + if not results: + return 0 + if len(results) != 1: + raise ValueError("실행 ID에 지표 시계열이 여러 개 있습니다") + value = float(results[0]["value"][1]) + if not math.isfinite(value) or value < 0 or not value.is_integer(): + raise ValueError("관측 건수가 올바르지 않습니다") + return int(value) + + +def verify(events_path: Path, run_id: str, write_url: str) -> dict: + """Compare dashboard totals with the ledger, not with final DB rows.""" + query_url = query_base(write_url) + expected = expected_outcomes(events_path, run_id) + # 2. Allow a bounded ingestion delay after k6's final remote-write flush. + for attempt in range(1, 5): + observed = {name: remote_count(query_url, run_id, name) for name in OUTCOMES} + if observed == expected or attempt == 4: + break + time.sleep(2) + return { + "run_id": run_id, + "expected": expected, + "observed": observed, + "query_attempts": attempt, + "final_totals_match": expected == observed, + "intermediate_delivery_verified": False, + "db_reconciliation_verified": False, + } + + +def main() -> int: + """Print only allowlisted counts, never the internal URL or raw HTTP error.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("validate-url", "verify")) + parser.add_argument("--events", type=Path) + parser.add_argument("--run-id") + args = parser.parse_args() + write_url = os.environ.get("HA_PROM_RW_URL", "") + try: + if args.action == "validate-url": + query_base(write_url) + return 0 + if args.events is None or args.run_id is None: + raise ValueError("원장 또는 실행 ID가 없습니다") + result = verify(args.events, args.run_id, write_url) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + print("원격 지표 검증 실패: 주소·원장·응답을 확인하세요", file=sys.stderr) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if result["final_totals_match"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())