diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b468a3..5be89c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,5 +28,5 @@ repos: - id: mypy name: mypy language: system - entry: mypy --non-interactive --install-types + entry: mypy --show-traceback --no-sqlite-cache types: [python] \ No newline at end of file diff --git a/consul/api/config.py b/consul/api/config.py new file mode 100644 index 0000000..b978f50 --- /dev/null +++ b/consul/api/config.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import typing +from typing import Any, TypedDict + +from consul.callback import CB + +if typing.TYPE_CHECKING: + import builtins + + +class ConfigEntry(TypedDict, total=False): + Kind: str + Name: str + CreateIndex: int + ModifyIndex: int + + +class Config: + """ + Generic CRUD for Consul config entries (service-defaults, service-router, + service-splitter, service-resolver, service-intentions, ingress-gateway, + terminating-gateway, proxy-defaults, mesh, exported-services, api-gateway, + http-route, tcp-route, inline-certificate, file-system-certificate, and + others) via the single /v1/config endpoint. Required ACLs and the body + schema vary by *kind* -- see Consul's config entry reference for the + schema of each kind; this client does not model kind-specific fields. + """ + + def __init__(self, agent) -> None: + self.agent = agent + + def set( + self, + kind: str, + name: str, + config_entry: builtins.dict[str, Any] | None = None, + token: str | None = None, + dc: str | None = None, + cas: int | None = None, + ) -> bool: + """ + Creates or updates the config entry of *kind* named *name*. + :param kind: The config entry kind, e.g. "service-defaults". + :param name: The config entry name. + :param config_entry: The kind-specific body; merged with Kind/Name. + :param token: token with the write capability required by *kind* (varies by kind) + :param dc: Optional datacenter to target; defaults to the client's own dc. + :param cas: Optional ModifyIndex to check-and-set against; the write only + applies if it still matches the entry's current ModifyIndex. + :return: True if the config entry was created or updated + """ + json_data: dict[str, Any] = dict(config_entry or {}) + json_data["Kind"] = kind + json_data["Name"] = name + + params: list[tuple[str, Any]] = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if cas is not None: + params.append(("cas", cas)) + + headers = self.agent.prepare_headers(token) + return self.agent.http.put(CB.json(), "/v1/config", params=params, headers=headers, data=json.dumps(json_data)) + + def get(self, kind: str, name: str, token: str | None = None, dc: str | None = None) -> ConfigEntry: + """ + Reads the config entry of *kind* named *name*. + :param token: token with the read capability required by *kind* + :param dc: Optional datacenter to target; defaults to the client's own dc. + """ + params: list[tuple[str, Any]] = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + headers = self.agent.prepare_headers(token) + return self.agent.http.get(CB.json(), f"/v1/config/{kind}/{name}", params=params, headers=headers) + + def list( + self, kind: str, token: str | None = None, dc: str | None = None, filter_expr: str | None = None + ) -> builtins.list[ConfigEntry]: + """ + Lists all config entries of *kind*. + :param token: token with the read capability required by *kind* + :param dc: Optional datacenter to target; defaults to the client's own dc. + :param filter_expr: Optional bexpr filter expression. + """ + params: list[tuple[str, Any]] = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if filter_expr: + params.append(("filter", filter_expr)) + headers = self.agent.prepare_headers(token) + return self.agent.http.get(CB.json(), f"/v1/config/{kind}", params=params, headers=headers) + + def delete( + self, kind: str, name: str, token: str | None = None, dc: str | None = None, cas: int | None = None + ) -> bool: + """ + Deletes the config entry of *kind* named *name*. + :param token: token with the write capability required by *kind* + :param dc: Optional datacenter to target; defaults to the client's own dc. + :param cas: Optional ModifyIndex to check-and-set against; the delete only + applies if it still matches the entry's current ModifyIndex. Without it, + deletion is unconditional (Consul returns an empty object rather than a + boolean in that case, hence the two different callbacks below). + :return: True if the config entry was deleted + """ + params: list[tuple[str, Any]] = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + headers = self.agent.prepare_headers(token) + if cas is not None: + params.append(("cas", cas)) + return self.agent.http.delete(CB.json(), f"/v1/config/{kind}/{name}", params=params, headers=headers) + return self.agent.http.delete(CB.boolean(), f"/v1/config/{kind}/{name}", params=params, headers=headers) diff --git a/consul/api/discovery_chain.py b/consul/api/discovery_chain.py new file mode 100644 index 0000000..8ca1d4a --- /dev/null +++ b/consul/api/discovery_chain.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from typing import Any, TypedDict + +from consul.callback import CB + + +class DiscoveryChainResponse(TypedDict, total=False): + Chain: dict[str, Any] + + +class DiscoveryChain: + def __init__(self, agent) -> None: + self.agent = agent + + def get( + self, + service: str, + compile_dc: str | None = None, + token: str | None = None, + override_connect_timeout: str | None = None, + override_protocol: str | None = None, + override_mesh_gateway_mode: str | None = None, + ) -> DiscoveryChainResponse: + """ + Reads the compiled discovery chain for *service*. Uses POST instead of GET + whenever an override is supplied, matching Consul's documented behavior for + passing override parameters in the request body. + :param service: The service name to compile the discovery chain for. + :param compile_dc: Optional datacenter to use as the basis of compilation. + :param token: token with service:read capability + :param override_connect_timeout: Optional duration override, e.g. "10s". + :param override_protocol: Optional protocol override, e.g. "http". + :param override_mesh_gateway_mode: Optional mesh gateway Mode override, e.g. "local". + :return: The compiled discovery chain + """ + params: list[tuple[str, Any]] = [] + if compile_dc: + params.append(("compile-dc", compile_dc)) + headers = self.agent.prepare_headers(token) + + overrides: dict[str, Any] = {} + if override_connect_timeout: + overrides["OverrideConnectTimeout"] = override_connect_timeout + if override_protocol: + overrides["OverrideProtocol"] = override_protocol + if override_mesh_gateway_mode: + overrides["OverrideMeshGateway"] = {"Mode": override_mesh_gateway_mode} + + if overrides: + return self.agent.http.post( + CB.json(), + f"/v1/discovery-chain/{service}", + params=params, + headers=headers, + data=json.dumps(overrides), + ) + return self.agent.http.get(CB.json(), f"/v1/discovery-chain/{service}", params=params, headers=headers) diff --git a/consul/base.py b/consul/base.py index 505bcb0..8de02b2 100644 --- a/consul/base.py +++ b/consul/base.py @@ -11,8 +11,10 @@ from consul.api.acl import ACL from consul.api.agent import Agent from consul.api.catalog import Catalog +from consul.api.config import Config from consul.api.connect import Connect from consul.api.coordinates import Coordinate +from consul.api.discovery_chain import DiscoveryChain from consul.api.event import Event from consul.api.health import Health from consul.api.kv import KV @@ -163,6 +165,8 @@ def __init__( self.coordinate = Coordinate(self) self.operator = Operator(self) self.connect = Connect(self) + self.config = Config(self) + self.discovery_chain = DiscoveryChain(self) def __enter__(self): return self diff --git a/tests/api/test_config.py b/tests/api/test_config.py new file mode 100644 index 0000000..f4a098b --- /dev/null +++ b/tests/api/test_config.py @@ -0,0 +1,52 @@ +class TestConfig: + def test_config_entry_crud(self, consul_obj) -> None: + c, _consul_version = consul_obj + + assert c.config.set(kind="service-defaults", name="test-svc", config_entry={"Protocol": "http"}) is True + + entry = c.config.get(kind="service-defaults", name="test-svc") + assert entry["Kind"] == "service-defaults" + assert entry["Name"] == "test-svc" + assert entry["Protocol"] == "http" + + entries = c.config.list(kind="service-defaults") + assert any(e["Name"] == "test-svc" for e in entries) + + assert c.config.set(kind="service-defaults", name="test-svc", config_entry={"Protocol": "grpc"}) is True + updated = c.config.get(kind="service-defaults", name="test-svc") + assert updated["Protocol"] == "grpc" + + assert c.config.delete(kind="service-defaults", name="test-svc") is True + assert c.config.get(kind="service-defaults", name="test-svc") is None + + def test_config_entry_cas(self, consul_obj) -> None: + c, _consul_version = consul_obj + + c.config.set(kind="service-defaults", name="cas-svc", config_entry={"Protocol": "http"}) + modify_index = c.config.get(kind="service-defaults", name="cas-svc")["ModifyIndex"] + + # A stale cas value must not apply + stale_cas = int(modify_index) + 999999 + assert ( + c.config.set(kind="service-defaults", name="cas-svc", config_entry={"Protocol": "grpc"}, cas=stale_cas) + is False + ) + assert c.config.get(kind="service-defaults", name="cas-svc")["Protocol"] == "http" + + # The correct cas value applies + assert ( + c.config.set( + kind="service-defaults", name="cas-svc", config_entry={"Protocol": "grpc"}, cas=int(modify_index) + ) + is True + ) + assert c.config.get(kind="service-defaults", name="cas-svc")["Protocol"] == "grpc" + + # Deleting with a stale cas must not apply + modify_index = c.config.get(kind="service-defaults", name="cas-svc")["ModifyIndex"] + assert c.config.delete(kind="service-defaults", name="cas-svc", cas=int(modify_index) + 999999) is False + assert c.config.get(kind="service-defaults", name="cas-svc") is not None + + # Deleting with the correct cas applies + assert c.config.delete(kind="service-defaults", name="cas-svc", cas=int(modify_index)) is True + assert c.config.get(kind="service-defaults", name="cas-svc") is None diff --git a/tests/api/test_discovery_chain.py b/tests/api/test_discovery_chain.py new file mode 100644 index 0000000..fa93d61 --- /dev/null +++ b/tests/api/test_discovery_chain.py @@ -0,0 +1,14 @@ +class TestDiscoveryChain: + def test_discovery_chain_get(self, consul_obj) -> None: + c, _consul_version = consul_obj + + c.agent.service.register("web") + + try: + chain = c.discovery_chain.get("web") + assert chain["Chain"]["ServiceName"] == "web" + + overridden = c.discovery_chain.get("web", override_protocol="http") + assert overridden["Chain"]["ServiceName"] == "web" + finally: + c.agent.service.deregister("web")