Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/test-results/patroni-role-compatibility-20261017/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 격리 OpenProxy의 Patroni 동기 역할 호환 경계

## 실제 발견

원본 OpenProxy는 strict 지정형 동기의 `sync_standby` 역할을 역직렬화하지 못해 종료 코드 **78**로 시작에 실패했습니다. HTTP·TCP 연결 행렬은 모든 후보 3개에서 성공했고 scope도 일치했습니다. 연결 장애와 parser 호환 결함을 구분했습니다.

- [지정형 원본 관측](vendor-designated-run01.log)
- [quorum 원본 관측](vendor-quorum-run01.log): 실제 `synchronous_mode=quorum`, strict=true, node_count=1 적용 후 native 역할과 원본 parser 결과를 기록합니다.
- [어댑터 HTTP 계약](adapter-http-run01.log): **9개, 실패 0개**. 실제 로컬 HTTP 상류·어댑터를 사용했습니다.

## 어댑터 경계

Python 표준 라이브러리만 사용합니다. 원본 바이너리·Patroni·DB는 고치지 않습니다. 매 GET에서 fresh `/patroni`의 scope를 확인하고 `/cluster`의 `sync_standby`·`quorum_standby`를 `replica`로만 변환합니다. leader·primary와 나머지 Metadata는 보존합니다. 모르는 역할·다른 scope·상류 실패는 502, 변경 요청은 405입니다. 로그에 원시 주소나 payload를 남기지 않습니다. 지정한 private IPv4에만 바인딩합니다.

```text
원본 OpenProxy → native /cluster → sync_standby → parser 거부
원본 OpenProxy → 읽기 전용 어댑터 → fresh scope 확인
→ standby 역할 별칭만 replica로 변환
→ Primary/Leader는 그대로 전달
```

## 한계

이는 **격리 시험용 호환 계층**이며 공급사 공식 quorum 지원이나 운영 도입 완료가 아닙니다. 실제 HA 승격·쓰기 보존은 후속 격리 부하에서 검증합니다. 어댑터가 추가된 경로와 원본 그대로의 실패를 구분해야 합니다. 공개 OpenProxy·클러스터에는 적용하지 않았습니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
실행 ID: adapter-http-run01
위치: 로컬 실제 HTTP 서버 2개 / Python 표준 라이브러리
목적: 동기 standby 별칭 변환·최신 scope·쓰기 차단·상류 실패 계약
명령: python3 -m unittest discover -s scripts/opensql -p test_patroni_role_compatibility.py -v
성공 기준: 정상 변환·실패 차단 9개 전부 통과
시작: 2026-10-17T06:05:52.127649+09:00
test_identity_is_fresh_and_primary_is_preserved (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_missing_members_is_closed (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_mutations_never_reach_upstream (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_quorum_alias_is_replica (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_scope_mismatch_is_closed (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_sync_alias_preserves_leader_and_metadata (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_unknown_path_never_reaches_upstream (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_unknown_role_is_closed (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok
test_upstream_failure_is_closed (test_patroni_role_compatibility.CompatibilityHttpTest) ... ok

----------------------------------------------------------------------
Ran 9 tests in 8.087s

OK
exit_code=0
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
실행 ID: vendor-designated-run01
관측: 2026-10-17T06:02:32+09:00
목적: 원본 OpenProxy와 strict 지정형 동기 Patroni의 역할 계약 확인
위치: 격리 라이선스 호스트 / 원본 OpenProxy / Patroni 4.0.5
절차: 실제 /cluster 응답을 원본 OpenProxy가 읽도록 별도 TOML로 시작
관측: native sync_standby 역할 역직렬화 거부, 모든 Patroni endpoint 조회 실패, 프로세스 종료 코드 78
원시 주소·경로·접속 비밀은 수집 전에 제거. HTTP 연결 행렬은 후보 3개 모두 TCP·REST·scope 일치. 단순 네트워크 실패와 구분.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"at_kst": "2026-10-17T06:06:59.509054+09:00", "run_id": "vendor-quorum-run01", "purpose": "원본 OpenProxy의 실제 quorum 역할 호환 확인", "location": "격리 OpenSQL·Patroni·원본 OpenProxy", "success_criteria": "native quorum 적용과 원본 parser 결과를 관측"}
{"at_kst": "2026-10-17T06:07:00.336602+09:00", "applied": {"synchronous_mode": "quorum", "synchronous_mode_strict": true, "synchronous_node_count": 1}}
{"at_kst": "2026-10-17T06:07:05.739562+09:00", "native_roles": ["leader", "quorum_standby", "quorum_standby"]}
{"at_kst": "2026-10-17T06:07:06.575338+09:00", "vendor_observation": {"startup_exit": 78, "safe_diagnostics": ["2026-10-16T21:07:06.545305526+00:00 DEBUG ThreadId(01) reqwest::connect: starting new connection: [주소/경로 생략] ", "2026-10-16T21:07:06.548028016+00:00 WARN ThreadId(01) openproxy::config: Failed to deserialize Patroni server response: unknown variant `quorum_standby`, expected one of `Leader`, `Primary`, `leader`, `primary`, `Replica`, `replica`, `Mirror`, `mirror`, `Auto`, `auto` at line 1 column 232. Retrying other endpoint.. ", "2026-10-16T21:07:06.548138686+00:00 DEBUG ThreadId(01) reqwest::connect: starting new connection: [주소/경로 생략] ", "2026-10-16T21:07:06.550000996+00:00 WARN ThreadId(01) openproxy::config: Failed to deserialize Patroni server response: unknown variant `quorum_standby`, expected one of `Leader`, `Primary`, `leader`, `primary`, `Replica`, `replica`, `Mirror`, `mirror`, `Auto`, `auto` at line 1 column 232. Retrying other endpoint.. ", "2026-10-16T21:07:06.550074956+00:00 DEBUG ThreadId(01) reqwest::connect: starting new connection: [주소/경로 생략] ", "2026-10-16T21:07:06.553789035+00:00 WARN ThreadId(01) openproxy::config: Failed to deserialize Patroni server response: unknown variant `quorum_standby`, expected one of `Leader`, `Primary`, `leader`, `primary`, `Replica`, `replica`, `Mirror`, `mirror`, `Auto`, `auto` at line 1 column 232. Retrying other endpoint.. ", "2026-10-16T21:07:06.553830795+00:00 ERROR ThreadId(01) openproxy::config: Querying all Patroni endpoint failed "]}}
123 changes: 123 additions & 0 deletions scripts/opensql/patroni_role_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""격리 OpenProxy 시험에서 Patroni 동기 standby 역할 이름만 replica로 변환한다.

원본 공급사 바이너리는 수정하지 않는다. 쓰기·캐시·설정 변경을 전달하지 않으며
상류의 scope와 알려진 역할을 매 요청 검증한다. 공급사 공식 지원을 대신하지 않는다.
"""
import argparse
import ipaddress
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from urllib.request import urlopen

MAX_BYTES = 1024 * 1024
ROLE_ALIASES = {"sync_standby": "replica", "quorum_standby": "replica"}
KNOWN_ROLES = {"leader", "primary", "replica"} | set(ROLE_ALIASES)


def read_json(url):
with urlopen(url, timeout=2) as response:
raw = response.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError("upstream response too large")
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("upstream object required")
return value


def normalized_role(value):
if value not in KNOWN_ROLES:
raise ValueError("unsupported role")
return ROLE_ALIASES.get(value, value)


class CompatibilityServer(ThreadingHTTPServer):
"""읽기 전용 시험 어댑터의 상류·scope만 보관하며 권한·클러스터 상태는 저장하지 않는다."""
daemon_threads = True

def __init__(self, address, upstream, scope):
self.upstream = upstream.rstrip("/")
self.scope = scope
super().__init__(address, CompatibilityHandler)


class CompatibilityHandler(BaseHTTPRequestHandler):
"""fresh scope 검증 후 역할 별칭만 변환하고 실패는 내용 없이 502로 닫는다."""
def log_message(self, *_args):
# 주소·상류 payload·오류의 원시 값을 로그로 보존하지 않는다.
pass

def do_GET(self):
if self.path not in ("/cluster", "/patroni"):
self.send_empty(404)
return
try:
# 1. 다른 클러스터나 재사용된 포트의 상태를 역할 근거로 삼지 않는다.
identity = read_json(self.server.upstream + "/patroni")
if identity.get("patroni", {}).get("scope") != self.server.scope:
raise ValueError("scope mismatch")
# 2. 캐시 없이 실제 상류 응답을 읽고 standby 별칭만 변경한다.
if self.path == "/cluster":
value = read_json(self.server.upstream + "/cluster")
members = value.get("members")
if not isinstance(members, list) or not members or len(members) > 128:
raise ValueError("invalid members")
for member in members:
member["role"] = normalized_role(member.get("role"))
else:
value = identity
value["role"] = normalized_role(value.get("role"))
raw = json.dumps(value).encode()
except Exception:
self.send_empty(502)
return
# 3. Primary·주소·복제 수·기타 원장 값은 그대로 전달한다. 내용은 로깅하지 않는다.
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)

def send_empty(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
self.end_headers()

def reject_mutation(self):
self.send_empty(405)

do_POST = reject_mutation
do_PATCH = reject_mutation
do_DELETE = reject_mutation
do_PUT = reject_mutation
do_HEAD = reject_mutation


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--listen", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18010)
parser.add_argument("--upstream", required=True)
parser.add_argument("--scope", required=True)
args = parser.parse_args()
address = ipaddress.ip_address(args.listen)
target = urlsplit(args.upstream)
if not isinstance(address, ipaddress.IPv4Address) or address.is_unspecified or not address.is_private:
parser.error("listen must be a specified private IPv4 address")
if target.scheme != "http" or target.username or target.password or target.path not in ("", "/") or target.query or target.fragment:
parser.error("plain private upstream origin required")
host = ipaddress.ip_address(target.hostname)
if host.is_unspecified or not host.is_private or not target.port:
parser.error("private upstream with explicit port required")
server = CompatibilityServer((args.listen, args.port), args.upstream, args.scope)
try:
server.serve_forever()
finally:
server.server_close()


if __name__ == "__main__":
main()
77 changes: 77 additions & 0 deletions scripts/opensql/test_patroni_role_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""실제 HTTP에서 역할 정규화·scope 거부·쓰기 차단·상류 실패를 검증한다."""
import json
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from unittest.mock import patch
import patroni_role_compatibility as adapter


class UpstreamHandler(BaseHTTPRequestHandler):
"""합성 Patroni JSON만 반환하는 HTTP 시험 상류이며 실제 VM에 접근하지 않는다."""
def log_message(self, *_args):
pass
def do_GET(self):
self.server.calls += 1
value = self.server.identity if self.path == "/patroni" else self.server.cluster
raw = json.dumps(value).encode()
self.send_response(200)
self.end_headers()
self.wfile.write(raw)


class CompatibilityHttpTest(unittest.TestCase):
"""어댑터가 실제 응답을 바꾸는 범위와 실패 시 차단을 HTTP 경계에서 검증한다."""
def setUp(self):
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), UpstreamHandler)
self.upstream.calls = 0
self.upstream.identity = {"role": "primary", "patroni": {"scope": "isolated-test"}}
self.upstream.cluster = {"members": [{"name": "synthetic-primary", "role": "leader"}, {"name": "synthetic-standby", "role": "sync_standby", "lag": 0}]}
self.adapter = adapter.CompatibilityServer(("127.0.0.1", 0), "http://127.0.0.1:" + str(self.upstream.server_port), "isolated-test")
for server in (self.upstream, self.adapter):
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
def request(self, path="/cluster", method="GET"):
try:
with urlopen(Request("http://127.0.0.1:" + str(self.adapter.server_port) + path, method=method), timeout=3) as response:
return response.status, json.loads(response.read())
except HTTPError as error:
return error.code, None
def test_sync_alias_preserves_leader_and_metadata(self):
code, body = self.request()
self.assertEqual(code, 200)
self.assertEqual(body["members"][0]["role"], "leader")
self.assertEqual(body["members"][1], {"name": "synthetic-standby", "role": "replica", "lag": 0})
def test_quorum_alias_is_replica(self):
self.upstream.cluster["members"][1]["role"] = "quorum_standby"
self.assertEqual(self.request()[1]["members"][1]["role"], "replica")
def test_scope_mismatch_is_closed(self):
self.upstream.identity["patroni"]["scope"] = "other"
self.assertEqual(self.request()[0], 502)
def test_unknown_role_is_closed(self):
self.upstream.cluster["members"][1]["role"] = "unknown"
self.assertEqual(self.request()[0], 502)
def test_missing_members_is_closed(self):
self.upstream.cluster = {}
self.assertEqual(self.request()[0], 502)
def test_mutations_never_reach_upstream(self):
for method in ("POST", "PUT", "PATCH", "DELETE"):
self.assertEqual(self.request("/config", method)[0], 405)
self.assertEqual(self.upstream.calls, 0)
def test_unknown_path_never_reaches_upstream(self):
self.assertEqual(self.request("/config")[0], 404)
self.assertEqual(self.upstream.calls, 0)
def test_upstream_failure_is_closed(self):
with patch.object(adapter, "read_json", side_effect=OSError("synthetic unavailable")):
self.assertEqual(self.request()[0], 502)
def test_identity_is_fresh_and_primary_is_preserved(self):
self.assertEqual(self.request("/patroni")[1]["role"], "primary")
self.upstream.identity["role"] = "replica"
self.assertEqual(self.request("/patroni")[1]["role"], "replica")


if __name__ == "__main__":
unittest.main()
Loading