From f3da9005d4343b01d2e80cb249e750bb387b7601 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sat, 19 Sep 2026 14:25:11 +1000 Subject: [PATCH] feat(coordination): accept seed routing and report SDK capability mismatches in the NoKV helper Signed-off-by: wchwawa --- examples/nokv-authority-store/README.md | 16 +- .../coordination/nokv_jsonl_helper.py | 58 +++++- .../nokv_jsonl_transport.test.ts | 40 +++- ...nokv_stage2a_qualification_harness.test.ts | 4 + tests/fixtures/nokv_fake_sdk/nokv/__init__.py | 6 + tests/test_nokv_jsonl_helper.py | 176 ++++++++++++++++++ 6 files changed, 284 insertions(+), 16 deletions(-) diff --git a/examples/nokv-authority-store/README.md b/examples/nokv-authority-store/README.md index e8ed9d31e6..96f601e37a 100644 --- a/examples/nokv-authority-store/README.md +++ b/examples/nokv-authority-store/README.md @@ -53,9 +53,19 @@ run is Stage 2A single-node storage conformance evidence only. ## Inputs Use a current NoKV Python environment. Keep the client configuration in an -ignored local file; do not commit credentials. Static routing is valid for a -single-node NoKV deployment—etcd is not required by this probe. The following -shape is illustrative: +ignored local file; do not commit credentials. The helper accepts three routing +kinds and passes each to the matching `RoutingConfig` constructor of the +installed SDK: `etcd` and `static` (the 0.11.0 release wheel) and `seeds` +(`{"kind": "seeds", "endpoints": ["IP:PORT", ...]}`, the NoKV +metadata-runtimes line, which names serving owners directly and drops the etcd +constructor). Static routing is valid for a single-node NoKV deployment; etcd +is not required by this probe. A routing kind the installed wheel cannot build +fails the open handshake with `nokv_sdk_capability_mismatch` before any client +is constructed, so a seeds configuration against a 0.11.0 wheel (or an etcd +configuration against a metadata-runtimes wheel) is reported as the wrong +wheel, not as an outage. The `ready` handshake echoes `nokv_protocol_schema`: +the SDK's `WORKSPACE_PROTOCOL_SCHEMA` when the wheel exports one, otherwise +`null` (the 0.11.0 release does not). The following shape is illustrative: ```json { diff --git a/loopx/control_plane/coordination/nokv_jsonl_helper.py b/loopx/control_plane/coordination/nokv_jsonl_helper.py index bbcf8094d9..8e11ecd406 100644 --- a/loopx/control_plane/coordination/nokv_jsonl_helper.py +++ b/loopx/control_plane/coordination/nokv_jsonl_helper.py @@ -36,6 +36,13 @@ } ) _ETCD_ROUTING_KEYS = frozenset({"kind", "endpoints", "key_prefix", "lease_ttl_seconds"}) +# Seed routing names one or more serving NoKV owners directly (numeric IP:port); +# the SDK rejects hostnames, empty lists and unspecified ports itself. +_SEEDS_ROUTING_KEYS = frozenset({"kind", "endpoints"}) +# Every routing kind this helper can express. A kind outside this set is a +# configuration error; a kind inside it that the installed SDK cannot build is +# a capability mismatch between the wheel and the configuration. +_ROUTING_KINDS = frozenset({"etcd", "seeds", "static"}) _STATIC_ROUTING_KEYS = frozenset( { "kind", @@ -67,6 +74,14 @@ class RequestError(ValueError): """The JSON-lines caller violated the raw storage protocol.""" +class SdkCapabilityMismatch(RequestError): + """The installed NoKV SDK lacks the constructor this configuration needs. + + Raised only after the configuration itself validated, so the caller can + tell "wrong wheel for this routing kind" apart from "invalid config". + """ + + class ProviderProtocolError(RuntimeError): """The NoKV SDK returned a shape that violates its reviewed contract.""" @@ -420,6 +435,8 @@ def build_client(config_value: object) -> Any: _require_exact_keys(config, _CONFIG_KEYS, "config") routing_value = _mapping(config.get("routing"), "routing") routing_kind = _required_string(routing_value, "kind") + if routing_kind not in _ROUTING_KINDS: + raise RequestError(f"unsupported routing kind {routing_kind!r}") if routing_kind == "etcd": _require_exact_keys(routing_value, _ETCD_ROUTING_KEYS, "routing") routing_arguments: tuple[object, ...] = ( @@ -443,7 +460,8 @@ def build_client(config_value: object) -> Any: _generation(routing_value.get("owner_epoch"), "owner_epoch"), ) else: - raise RequestError(f"unsupported routing kind {routing_kind!r}") + _require_exact_keys(routing_value, _SEEDS_ROUTING_KEYS, "routing") + routing_arguments = (_string_list(routing_value, "endpoints"),) object_value = _mapping(config.get("object_store"), "object_store") object_kind = _required_string(object_value, "kind") @@ -516,12 +534,17 @@ def build_client(config_value: object) -> Any: except AttributeError as error: raise RequestError("the NoKV Python SDK surface is incomplete") from error - try: - routing = ( - RoutingConfig.etcd(*routing_arguments) - if routing_kind == "etcd" - else RoutingConfig.static(*routing_arguments) + # The kind was checked against _ROUTING_KINDS above, so this attribute + # lookup never reaches an arbitrary caller-chosen name. The 0.11.0 release + # wheel provides etcd/static; the metadata-runtimes line provides seeds. + routing_constructor = getattr(RoutingConfig, routing_kind, None) + if not callable(routing_constructor): + raise SdkCapabilityMismatch( + "the installed NoKV Python SDK does not provide " + f"RoutingConfig.{routing_kind}" ) + try: + routing = routing_constructor(*routing_arguments) except (TypeError, ValueError) as error: raise RequestError("NoKV routing configuration is invalid") from error try: @@ -550,6 +573,19 @@ def build_client(config_value: object) -> Any: raise RequestError("NoKV client configuration is invalid") from error +def _sdk_protocol_schema() -> str | None: + """Return the wire schema the imported SDK declares, if it declares one. + + The 0.11.0 release wheel has no such attribute; newer wheels export + ``WORKSPACE_PROTOCOL_SCHEMA`` so a deployment can compare it against the + server before trusting a nominally equal ``__version__``. + """ + schema = getattr(sys.modules.get("nokv"), "WORKSPACE_PROTOCOL_SCHEMA", None) + if isinstance(schema, str) and schema and schema.strip() == schema: + return schema + return None + + def main() -> int: first = sys.stdin.readline() try: @@ -561,6 +597,15 @@ def main() -> int: client = build_client(values.get("config")) except json.JSONDecodeError as error: result = _failure(None, "failed", "invalid_json", error) + except SdkCapabilityMismatch as error: + result = _failure( + _request_id(value.get("request_id")) + if isinstance(value, Mapping) + else None, + "failed", + "nokv_sdk_capability_mismatch", + error, + ) except RequestError as error: result = _failure( _request_id(value.get("request_id")) @@ -592,6 +637,7 @@ def main() -> int: "ready", nokv_sdk_version=QUALIFIED_NOKV_SDK_VERSION, nokv_api_version=QUALIFIED_NOKV_API_VERSION, + nokv_protocol_schema=_sdk_protocol_schema(), ), sort_keys=True, separators=(",", ":"), diff --git a/tests/control_plane_ts/nokv_jsonl_transport.test.ts b/tests/control_plane_ts/nokv_jsonl_transport.test.ts index 54641a31b3..02a536437d 100644 --- a/tests/control_plane_ts/nokv_jsonl_transport.test.ts +++ b/tests/control_plane_ts/nokv_jsonl_transport.test.ts @@ -21,17 +21,22 @@ const FAKE_SDK_ROOT = fileURLToPath( new URL("../fixtures/nokv_fake_sdk", import.meta.url), ); -async function openSdkHelper() { +const ETCD_ROUTING = { + kind: "etcd", + endpoints: ["http://127.0.0.1:2379"], + key_prefix: "/nokv/control", + lease_ttl_seconds: 10, +}; +// Seed routing names serving owners directly; it is the routing kind of the +// NoKV metadata-runtimes line, which drops the etcd constructor. +const SEEDS_ROUTING = { kind: "seeds", endpoints: ["127.0.0.1:7750"] }; + +async function openSdkHelper(routing: Record = ETCD_ROUTING) { return await NoKVJsonLinesTransport.open({ argv: [PYTHON, SDK_HELPER], config: { root_id: "0".repeat(32), - routing: { - kind: "etcd", - endpoints: ["http://127.0.0.1:2379"], - key_prefix: "/nokv/control", - lease_ttl_seconds: 10, - }, + routing, object_store: { kind: "memory" }, }, env: { @@ -201,3 +206,24 @@ test("NoKV AuthorityStore preserves helper protocol failure as failed, not missi assert.equal(loaded.reason_code, "provider_protocol_violation"); } }); + +test("JSON-lines transport opens the real helper with seed routing", async () => { + const transport = await openSdkHelper(SEEDS_ROUTING); + try { + const identity = await transport.storeIdentity("authority-workbench"); + assert.equal(identity.status, "available"); + if (identity.status !== "available") throw new Error("unreachable"); + assert.equal(identity.store_identity, `nokv:authority-workbench:${"a".repeat(32)}`); + } finally { + await transport.close(); + } +}); + +test("JSON-lines transport surfaces an unknown routing kind as a typed protocol failure", async () => { + await assert.rejects( + openSdkHelper({ kind: "gossip", endpoints: ["127.0.0.1:7750"] }), + (error: unknown) => + error instanceof NoKVTransportProtocolError && /routing kind/.test(error.message), + ); +}); + diff --git a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts index 5b290895cd..258e58ee41 100644 --- a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts +++ b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts @@ -50,6 +50,10 @@ class RoutingConfig: def etcd(*values): return ("etcd", values) + @staticmethod + def seeds(*values): + return ("seeds", values) + @staticmethod def static(*values): return ("static", values) diff --git a/tests/fixtures/nokv_fake_sdk/nokv/__init__.py b/tests/fixtures/nokv_fake_sdk/nokv/__init__.py index 4ad7970ef2..52ce1920a0 100644 --- a/tests/fixtures/nokv_fake_sdk/nokv/__init__.py +++ b/tests/fixtures/nokv_fake_sdk/nokv/__init__.py @@ -7,6 +7,12 @@ class RoutingConfig: + # Union of the two real wheels the helper is qualified against: the 0.11.0 + # release provides etcd/static, the metadata-runtimes line provides seeds. + @staticmethod + def seeds(endpoints: list[str]) -> object: + return ("seeds", endpoints) + @staticmethod def etcd(endpoints: list[str], key_prefix: str, lease_ttl_seconds: int) -> object: return ("etcd", endpoints, key_prefix, lease_ttl_seconds) diff --git a/tests/test_nokv_jsonl_helper.py b/tests/test_nokv_jsonl_helper.py index b67bf1d467..a17f8551de 100644 --- a/tests/test_nokv_jsonl_helper.py +++ b/tests/test_nokv_jsonl_helper.py @@ -12,6 +12,7 @@ from loopx.control_plane.coordination.nokv_jsonl_helper import ( ClientAdmissionUnavailable, RequestError, + SdkCapabilityMismatch, build_client, handle_request, main, @@ -586,5 +587,180 @@ def test_open_handshake_reports_the_qualified_sdk_contract( "request_id": "open-a", "status": "ready", "nokv_api_version": 1, + "nokv_protocol_schema": None, + "nokv_sdk_version": "0.11.0", + } + + +def _sdk_module(routing: object, **overrides: Any) -> types.SimpleNamespace: + module = types.SimpleNamespace( + __version__="0.11.0", + API_VERSION=1, + Client=lambda **_kwargs: object(), + ObjectStoreConfig=types.SimpleNamespace(memory=lambda: object()), + RoutingConfig=routing, + ) + for name, value in overrides.items(): + setattr(module, name, value) + return module + + +def _seeds_config(**routing_extra: Any) -> dict[str, Any]: + return { + "root_id": "a" * 32, + "routing": {"kind": "seeds", "endpoints": ["127.0.0.1:7750"], **routing_extra}, + "object_store": {"kind": "memory"}, + } + + +def test_seeds_route_uses_the_sdk_seeds_constructor_with_exact_keys( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seeds_calls: list[tuple[Any, ...]] = [] + + class RoutingConfig: + @staticmethod + def seeds(*args: Any) -> object: + seeds_calls.append(args) + return object() + + monkeypatch.setitem(sys.modules, "nokv", _sdk_module(RoutingConfig)) + + build_client(_seeds_config()) + assert seeds_calls == [(["127.0.0.1:7750"],)] + + for invalid in ( + _seeds_config(key_prefix="/nokv/control"), + {**_seeds_config(), "routing": {"kind": "seeds"}}, + {**_seeds_config(), "routing": {"kind": "seeds", "endpoints": []}}, + ): + with pytest.raises(RequestError): + build_client(invalid) + assert len(seeds_calls) == 1 + + +@pytest.mark.parametrize( + ("routing_config", "sdk_routing"), + [ + ( + {"kind": "seeds", "endpoints": ["127.0.0.1:7750"]}, + types.SimpleNamespace( + etcd=lambda *_args: object(), static=lambda *_args: object() + ), + ), + ( + { + "kind": "etcd", + "endpoints": ["http://unused.invalid"], + "key_prefix": "/nokv/control", + "lease_ttl_seconds": 10, + }, + types.SimpleNamespace(seeds=lambda *_args: object()), + ), + ], +) +def test_routing_kind_the_sdk_cannot_build_is_a_typed_capability_mismatch( + monkeypatch: pytest.MonkeyPatch, + routing_config: dict[str, Any], + sdk_routing: types.SimpleNamespace, +) -> None: + constructed: list[str] = [] + module = _sdk_module( + sdk_routing, + Client=lambda **_kwargs: constructed.append("client"), + ) + module.ObjectStoreConfig = types.SimpleNamespace( + memory=lambda: constructed.append("object_store") + ) + monkeypatch.setitem(sys.modules, "nokv", module) + + with pytest.raises(SdkCapabilityMismatch) as raised: + build_client( + { + "root_id": "a" * 32, + "routing": routing_config, + "object_store": {"kind": "memory"}, + } + ) + + assert isinstance(raised.value, RequestError) + assert f"RoutingConfig.{routing_config['kind']}" in str(raised.value) + assert "127.0.0.1" not in str(raised.value) + assert "unused.invalid" not in str(raised.value) + assert constructed == [] + + +def test_unknown_routing_kind_is_invalid_config_not_a_capability_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _sdk_module(types.SimpleNamespace(seeds=lambda *_args: object())) + monkeypatch.setitem(sys.modules, "nokv", module) + + with pytest.raises(RequestError) as raised: + build_client( + {**_seeds_config(), "routing": {"kind": "gossip", "endpoints": ["x"]}} + ) + assert not isinstance(raised.value, SdkCapabilityMismatch) + + +def test_open_handshake_reports_capability_mismatch_as_a_typed_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _sdk_module(types.SimpleNamespace(etcd=lambda *_args: object())) + monkeypatch.setitem(sys.modules, "nokv", module) + incoming = io.StringIO( + json.dumps( + {"request_id": "open-b", "operation": "open", "config": _seeds_config()} + ) + + "\n" + ) + outgoing = io.StringIO() + monkeypatch.setattr(sys, "stdin", incoming) + monkeypatch.setattr(sys, "stdout", outgoing) + + assert main() == 2 + response = json.loads(outgoing.getvalue().splitlines()[0]) + assert response["request_id"] == "open-b" + assert response["status"] == "failed" + assert response["reason_code"] == "nokv_sdk_capability_mismatch" + assert "RoutingConfig.seeds" in response["reason"] + assert "127.0.0.1" not in response["reason"] + + +@pytest.mark.parametrize( + ("declared", "expected"), + [ + ("nokv.workspace.rpc.v10", "nokv.workspace.rpc.v10"), + (None, None), + (10, None), + ("", None), + ], +) +def test_open_handshake_echoes_only_a_well_formed_sdk_protocol_schema( + monkeypatch: pytest.MonkeyPatch, + declared: object, + expected: str | None, +) -> None: + extra: dict[str, Any] = {} + if declared is not None: + extra["WORKSPACE_PROTOCOL_SCHEMA"] = declared + module = _sdk_module(types.SimpleNamespace(seeds=lambda *_args: object()), **extra) + monkeypatch.setitem(sys.modules, "nokv", module) + incoming = io.StringIO( + json.dumps( + {"request_id": "open-c", "operation": "open", "config": _seeds_config()} + ) + + "\n" + ) + outgoing = io.StringIO() + monkeypatch.setattr(sys, "stdin", incoming) + monkeypatch.setattr(sys, "stdout", outgoing) + + assert main() == 0 + assert json.loads(outgoing.getvalue().splitlines()[0]) == { + "request_id": "open-c", + "status": "ready", + "nokv_api_version": 1, + "nokv_protocol_schema": expected, "nokv_sdk_version": "0.11.0", }