From 68dc13804cd8a27d13a8102c5657a0069c3ad70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 17 Oct 2026 06:08:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EA=B2=A9=EB=A6=AC=20Patroni=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=8F=99=EA=B8=B0=20standby=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0=20=ED=98=B8=ED=99=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/opensql/patroni_role_compatibility.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 scripts/opensql/patroni_role_compatibility.py diff --git a/scripts/opensql/patroni_role_compatibility.py b/scripts/opensql/patroni_role_compatibility.py new file mode 100644 index 00000000..57219331 --- /dev/null +++ b/scripts/opensql/patroni_role_compatibility.py @@ -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() From a83cceb86c05453a606d1d1253aec895d27347c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 17 Oct 2026 06:08:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=9B=90=EB=B3=B8=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=97=AD=ED=95=A0=20=ED=98=B8=ED=99=98=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=99=80=20=EC=96=B4=EB=8C=91=ED=84=B0=20HTTP=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../README.md | 24 ++++++ .../adapter-http-run01.log | 21 +++++ .../vendor-designated-run01.log | 7 ++ .../vendor-quorum-run01.log | 4 + .../test_patroni_role_compatibility.py | 77 +++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 docs/test-results/patroni-role-compatibility-20261017/README.md create mode 100644 docs/test-results/patroni-role-compatibility-20261017/adapter-http-run01.log create mode 100644 docs/test-results/patroni-role-compatibility-20261017/vendor-designated-run01.log create mode 100644 docs/test-results/patroni-role-compatibility-20261017/vendor-quorum-run01.log create mode 100644 scripts/opensql/test_patroni_role_compatibility.py diff --git a/docs/test-results/patroni-role-compatibility-20261017/README.md b/docs/test-results/patroni-role-compatibility-20261017/README.md new file mode 100644 index 00000000..cbaa41c4 --- /dev/null +++ b/docs/test-results/patroni-role-compatibility-20261017/README.md @@ -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·클러스터에는 적용하지 않았습니다. diff --git a/docs/test-results/patroni-role-compatibility-20261017/adapter-http-run01.log b/docs/test-results/patroni-role-compatibility-20261017/adapter-http-run01.log new file mode 100644 index 00000000..763481ad --- /dev/null +++ b/docs/test-results/patroni-role-compatibility-20261017/adapter-http-run01.log @@ -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 diff --git a/docs/test-results/patroni-role-compatibility-20261017/vendor-designated-run01.log b/docs/test-results/patroni-role-compatibility-20261017/vendor-designated-run01.log new file mode 100644 index 00000000..a305fe35 --- /dev/null +++ b/docs/test-results/patroni-role-compatibility-20261017/vendor-designated-run01.log @@ -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 일치. 단순 네트워크 실패와 구분. diff --git a/docs/test-results/patroni-role-compatibility-20261017/vendor-quorum-run01.log b/docs/test-results/patroni-role-compatibility-20261017/vendor-quorum-run01.log new file mode 100644 index 00000000..8439d4c1 --- /dev/null +++ b/docs/test-results/patroni-role-compatibility-20261017/vendor-quorum-run01.log @@ -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 "]}} diff --git a/scripts/opensql/test_patroni_role_compatibility.py b/scripts/opensql/test_patroni_role_compatibility.py new file mode 100644 index 00000000..34dfb591 --- /dev/null +++ b/scripts/opensql/test_patroni_role_compatibility.py @@ -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()