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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
120 changes: 120 additions & 0 deletions consul/api/config.py
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 59 additions & 0 deletions consul/api/discovery_chain.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions consul/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/api/test_config.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions tests/api/test_discovery_chain.py
Original file line number Diff line number Diff line change
@@ -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")
Loading