diff --git a/consul/__init__.py b/consul/__init__.py index 27d8b76..f764d34 100644 --- a/consul/__init__.py +++ b/consul/__init__.py @@ -1,4 +1,5 @@ __version__ = "1.3.0" -from consul.base import ACLDisabled, ACLPermissionDenied, Check, ConsulException, NotFound, Timeout +from consul.check import Check +from consul.exceptions import ACLDisabled, ACLPermissionDenied, ConsulException, NotFound, Timeout from consul.std import Consul diff --git a/consul/aio.py b/consul/aio.py index 9472ea7..d3e1ae3 100644 --- a/consul/aio.py +++ b/consul/aio.py @@ -2,7 +2,7 @@ import aiohttp -from consul import base +from consul import Timeout, base __all__ = ["Consul"] @@ -31,7 +31,7 @@ async def _request(self, callback, method, uri, data=None, connections_timeout=N resp = await self._session.request(method, uri, data=data, **session_kwargs) body = await resp.text(encoding="utf-8") if resp.status == 599: - raise base.Timeout + raise Timeout r = base.Response(resp.status, resp.headers, body) return callback(r) diff --git a/consul/api/__init__.py b/consul/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/consul/api/acl.py b/consul/api/acl.py new file mode 100644 index 0000000..017b04a --- /dev/null +++ b/consul/api/acl.py @@ -0,0 +1,124 @@ +import json + +from consul.callback import CB + + +class ACL: + def __init__(self, agent): + self.agent = agent + + def list(self, token=None): + """ + Lists all the active ACL tokens. This is a privileged endpoint, and + requires a management token. *token* will override this client's + default token. + Requires a token with acl:read capability. ACLPermissionDenied raised otherwise + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + return self.agent.http.get(CB.json(), "/v1/acl/tokens", params=params) + + def read(self, accessor_id, token=None): + """ + Returns the token information for *accessor_id*. Requires a token with acl:read capability. + :param accessor_id: The accessor ID of the token to read + :param token: token with acl:read capability + :return: selected token information + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + return self.agent.http.get(CB.json(), f"/v1/acl/token/{accessor_id}", params=params) + + def delete(self, accessor_id, token=None): + """ + Deletes the token with *accessor_id*. This is a privileged endpoint, and requires a token with acl:write. + :param accessor_id: The accessor ID of the token to delete + :param token: token with acl:write capability + :return: True if the token was deleted + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + return self.agent.http.delete(CB.bool(), f"/v1/acl/token/{accessor_id}", params=params) + + def clone(self, accessor_id, token=None, description=""): + """ + Clones the token identified by *accessor_id*. This is a privileged endpoint, and requires a token with acl:write. + :param accessor_id: The accessor ID of the token to clone + :param token: token with acl:write capability + :param description: Optional new token description + :return: The cloned token information + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + json_data = {"Description": description} + return self.agent.http.put( + CB.json(), + f"/v1/acl/token/{accessor_id}/clone", + params=params, + data=json.dumps(json_data), + ) + + def create(self, token=None, accessor_id=None, secret_id=None, description=""): + """ + Create a token (optionally identified by *secret_id* and *accessor_id*). + This is a privileged endpoint, and requires a token with acl:write. + :param token: token with acl:write capability + :param accessor_id: The accessor ID of the token to create + :param secret_id: The secret ID of the token to create + :param description: Optional new token description + :return: The cloned token information + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + json_data = {} + if accessor_id: + json_data["AccessorID"] = accessor_id + if secret_id: + json_data["SecretID"] = secret_id + if description: + json_data["Description"] = description + return self.agent.http.put( + CB.json(), + "/v1/acl/token", + params=params, + data=json.dumps(json_data), + ) + + def update(self, accessor_id, token=None, secret_id=None, description=""): + """ + Update a token (optionally identified by *secret_id* and *accessor_id*). + This is a privileged endpoint, and requires a token with acl:write. + :param accessor_id: The accessor ID of the token to update + :param token: token with acl:write capability + :param secret_id: Optional secret ID of the token to update + :param description: Optional new token description + :return: The updated token information + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + json_data = {"AccessorID": accessor_id} + if secret_id: + json_data["SecretID"] = secret_id + if description: + json_data["Description"] = description + return self.agent.http.put( + CB.json(), + f"/v1/acl/token/{accessor_id}", + params=params, + data=json.dumps(json_data), + ) diff --git a/consul/api/agent.py b/consul/api/agent.py new file mode 100644 index 0000000..05c1310 --- /dev/null +++ b/consul/api/agent.py @@ -0,0 +1,447 @@ +import json + +from consul import Check +from consul.callback import CB + + +class Agent: + """ + The Agent endpoints are used to interact with a local Consul agent. + Usually, services and checks are registered with an agent, which then + takes on the burden of registering with the Catalog and performing + anti-entropy to recover from outages. + """ + + def __init__(self, agent): + self.agent = agent + self.service = Agent.Service(agent) + self.check = Agent.Check(agent) + self.connect = Agent.Connect(agent) + + def self(self): + """ + Returns configuration of the local agent and member information. + """ + return self.agent.http.get(CB.json(), "/v1/agent/self") + + def services(self): + """ + Returns all the services that are registered with the local agent. + These services were either provided through configuration files, or + added dynamically using the HTTP API. It is important to note that + the services known by the agent may be different than those + reported by the Catalog. This is usually due to changes being made + while there is no leader elected. The agent performs active + anti-entropy, so in most situations everything will be in sync + within a few seconds. + """ + return self.agent.http.get(CB.json(), "/v1/agent/services") + + def service_definition(self, service_id): + """ + Returns a service definition for a single instance that is registered + with the local agent. + """ + return self.agent.http.get(CB.json(), f"/v1/agent/service/{service_id}") + + def checks(self): + """ + Returns all the checks that are registered with the local agent. + These checks were either provided through configuration files, or + added dynamically using the HTTP API. Similar to services, + the checks known by the agent may be different than those + reported by the Catalog. This is usually due to changes being made + while there is no leader elected. The agent performs active + anti-entropy, so in most situations everything will be in sync + within a few seconds. + """ + return self.agent.http.get(CB.json(), "/v1/agent/checks") + + def members(self, wan=False): + """ + Returns all the members that this agent currently sees. This may + vary by agent, use the nodes api of Catalog to retrieve a cluster + wide consistent view of members. + + For agents running in server mode, setting *wan* to *True* returns + the list of WAN members instead of the LAN members which is + default. + """ + params = [] + if wan: + params.append(("wan", 1)) + return self.agent.http.get(CB.json(), "/v1/agent/members", params=params) + + def maintenance(self, enable, reason=None, token=None): + """ + The node maintenance endpoint can place the agent into + "maintenance mode". + + *enable* is either 'true' or 'false'. 'true' enables maintenance + mode, 'false' disables maintenance mode. + + *reason* is an optional string. This is simply to aid human + operators. + """ + + params = [] + + params.append(("enable", enable)) + if reason: + params.append(("reason", reason)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), "/v1/agent/maintenance", params=params) + + def join(self, address, wan=False, token=None): + """ + This endpoint instructs the agent to attempt to connect to a + given address. + + *address* is the ip to connect to. + + *wan* is either 'true' or 'false'. For agents running in server + mode, 'true' causes the agent to attempt to join using the WAN + pool. Default is 'false'. + """ + + params = [] + + if wan: + params.append(("wan", 1)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/join/{address}", params=params) + + def force_leave(self, node): + """ + This endpoint instructs the agent to force a node into the left + state. If a node fails unexpectedly, then it will be in a failed + state. Once in the failed state, Consul will attempt to reconnect, + and the services and checks belonging to that node will not be + cleaned up. Forcing a node into the left state allows its old + entries to be removed. + + *node* is the node to change state for. + """ + + return self.agent.http.put(CB.bool(), f"/v1/agent/force-leave/{node}") + + class Service: + def __init__(self, agent): + self.agent = agent + + def register( + self, + name, + service_id=None, + address=None, + port=None, + tags=None, + check=None, + token=None, + meta=None, + weights=None, + # *deprecated* use check parameter + script=None, + interval=None, + ttl=None, + http=None, + timeout=None, + enable_tag_override=False, + extra_checks=None, + ): + """ + Add a new service to the local agent. There is more + documentation on services + `here `_. + + *name* is the name of the service. + + If the optional *service_id* is not provided it is set to + *name*. You cannot have duplicate *service_id* entries per + agent, so it may be necessary to provide one. + + *address* will default to the address of the agent if not + provided. + + An optional health *check* can be created for this service is + one of `Check.script`_, `Check.http`_, `Check.tcp`_, + `Check.ttl`_ or `Check.docker`_. + + *token* is an optional `ACL token`_ to apply to this request. + Note this call will return successful even if the token doesn't + have permissions to register this service. + + *meta* specifies arbitrary KV metadata linked to the service + formatted as {k1:v1, k2:v2}. + + *weights* specifies weights for the service; default to + {"Passing": 1, "Warning": 1}. + + *script*, *interval*, *ttl*, *http*, and *timeout* arguments + are deprecated. use *check* instead. + + *enable_tag_override* is an optional bool that enable you + to modify a service tags from servers(consul agent role server) + Default is set to False. + This option is only for >=v0.6.0 version on both agent and + servers. + for more information + https://www.consul.io/docs/agent/services.html + """ + + if extra_checks is None: + extra_checks = [] + payload = {} + + payload["name"] = name + if enable_tag_override: + payload["enabletagoverride"] = enable_tag_override + if service_id: + payload["id"] = service_id + if address: + payload["address"] = address + if port: + payload["port"] = port + if tags: + payload["tags"] = tags + if meta: + payload["meta"] = meta + if check: + payload["checks"] = [check] + extra_checks + if weights: + payload["weights"] = weights + + else: + payload.update( + Check._compat( # pylint: disable=protected-access + script=script, interval=interval, ttl=ttl, http=http, timeout=timeout + ) + ) + + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), "/v1/agent/service/register", params=params, data=json.dumps(payload)) + + def deregister(self, service_id, token=None): + """ + Used to remove a service from the local agent. The agent will + take care of deregistering the service with the Catalog. If + there is an associated check, that is also deregistered. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/service/deregister/{service_id}", params=params) + + def maintenance(self, service_id, enable, reason=None, token=None): + """ + The service maintenance endpoint allows placing a given service + into "maintenance mode". + + *service_id* is the id of the service that is to be targeted + for maintenance. + + *enable* is either 'true' or 'false'. 'true' enables + maintenance mode, 'false' disables maintenance mode. + + *reason* is an optional string. This is simply to aid human + operators. + """ + + params = [] + + params.append(("enable", enable)) + if reason: + params.append(("reason", reason)) + + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/service/maintenance/{service_id}", params=params) + + class Check: + def __init__(self, agent): + self.agent = agent + + def register( + self, + name, + check=None, + check_id=None, + notes=None, + service_id=None, + token=None, + # *deprecated* use check parameter + script=None, + interval=None, + ttl=None, + http=None, + timeout=None, + ): + """ + Register a new check with the local agent. More documentation + on checks can be found `here + `_. + + *name* is the name of the check. + + *check* is one of `Check.script`_, `Check.http`_, `Check.tcp`_ + `Check.ttl`_ or `Check.docker`_ and is required. + + If the optional *check_id* is not provided it is set to *name*. + *check_id* must be unique for this agent. + + *notes* is not used by Consul, and is meant to be human + readable. + + Optionally, a *service_id* can be specified to associate a + registered check with an existing service. + + *token* is an optional `ACL token`_ to apply to this request. + Note this call will return successful even if the token doesn't + have permissions to register this check. + + *script*, *interval*, *ttl*, *http*, and *timeout* arguments + are deprecated. use *check* instead. + + Returns *True* on success. + """ + payload = {"name": name} + + assert check or script or ttl or http, "check is required" + + if check: + payload.update(check) + + else: + payload.update( + Check._compat(script=script, interval=interval, ttl=ttl, http=http, timeout=timeout)["check"] + ) + + if check_id: + payload["id"] = check_id + if notes: + payload["notes"] = notes + if service_id: + payload["serviceid"] = service_id + + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), "/v1/agent/check/register", params=params, data=json.dumps(payload)) + + def deregister(self, check_id, token=None): + """ + Remove a check from the local agent. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/check/deregister/{check_id}", params=params) + + def ttl_pass(self, check_id, notes=None, token=None): + """ + Mark a ttl based check as passing. Optional notes can be + attached to describe the status of the check. + """ + params = [] + if notes: + params.append(("note", notes)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/check/pass/{check_id}", params=params) + + def ttl_fail(self, check_id, notes=None, token=None): + """ + Mark a ttl based check as failing. Optional notes can be + attached to describe why check is failing. The status of the + check will be set to critical and the ttl clock will be reset. + """ + params = [] + if notes: + params.append(("note", notes)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/check/fail/{check_id}", params=params) + + def ttl_warn(self, check_id, notes=None, token=None): + """ + Mark a ttl based check with warning. Optional notes can be + attached to describe the warning. The status of the + check will be set to warn and the ttl clock will be reset. + """ + params = [] + if notes: + params.append(("note", notes)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.bool(), f"/v1/agent/check/warn/{check_id}", params=params) + + class Connect: + def __init__(self, agent): + self.agent = agent + self.ca = Agent.Connect.CA(agent) + + def authorize(self, target, client_cert_uri, client_cert_serial, token=None): + """ + Tests whether a connection attempt is authorized between + two services. + More information is available + `here `_. + + *target* is the name of the service that is being requested. + + *client_cert_uri* The unique identifier for the requesting + client. + + *client_cert_serial* The colon-hex-encoded serial number for + the requesting client cert. + """ + + payload = {"Target": target, "ClientCertURI": client_cert_uri, "ClientCertSerial": client_cert_serial} + + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put( + CB.json(), "/v1/agent/connect/authorize", params=params, data=json.dumps(payload) + ) + + class CA: + def __init__(self, agent): + self.agent = agent + + def roots(self): + return self.agent.http.get(CB.json(), "/v1/agent/connect/ca/roots") + + def leaf(self, service, token=None): + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.get(CB.json(), f"/v1/agent/connect/ca/leaf/{service}", params=params) diff --git a/consul/api/catalog.py b/consul/api/catalog.py new file mode 100644 index 0000000..e868811 --- /dev/null +++ b/consul/api/catalog.py @@ -0,0 +1,387 @@ +import json + +from consul.callback import CB + + +class Catalog: + def __init__(self, agent): + self.agent = agent + + def register(self, node, address, service=None, check=None, dc=None, token=None, node_meta=None): + """ + A low level mechanism for directly registering or updating entries + in the catalog. It is usually recommended to use + agent.service.register and agent.check.register, as they are + simpler and perform anti-entropy. + + *node* is the name of the node to register. + + *address* is the ip of the node. + + *service* is an optional service to register. if supplied this is a + dict:: + + { + "Service": "redis", + "ID": "redis1", + "Tags": [ + "master", + "v1" + ], + "Port": 8000 + } + + where + + *Service* is required and is the name of the service + + *ID* is optional, and will be set to *Service* if not provided. + Note *ID* must be unique for the given *node*. + + *Tags* and *Port* are optional. + + *check* is an optional check to register. if supplied this is a + dict:: + + { + "Node": "foobar", + "CheckID": "service:redis1", + "Name": "Redis health check", + "Notes": "Script based health check", + "Status": "passing", + "ServiceID": "redis1" + } + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + + This manipulates the health check entry, but does not setup a + script or TTL to actually update the status. The full documentation + is `here `_. + + Returns *True* on success. + """ + data = {"node": node, "address": address} + params = [] + dc = dc or self.agent.dc + if dc: + data["datacenter"] = dc + if service: + data["service"] = service + if check: + data["check"] = check + token = token or self.agent.token + if token: + data["WriteRequest"] = {"Token": token} + params.append(("token", token)) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.put(CB.bool(), "/v1/catalog/register", data=json.dumps(data), params=params) + + def deregister(self, node, service_id=None, check_id=None, dc=None, token=None): + """ + A low level mechanism for directly removing entries in the catalog. + It is usually recommended to use the agent APIs, as they are + simpler and perform anti-entropy. + + *node* and *dc* specify which node on which datacenter to remove. + If *service_id* and *check_id* are not provided, all associated + services and checks are deleted. Otherwise only one of *service_id* + and *check_id* should be provided and only that service or check + will be removed. + + *token* is an optional `ACL token`_ to apply to this request. + + Returns *True* on success. + """ + assert not (service_id and check_id) + data = {"node": node} + dc = dc or self.agent.dc + if dc: + data["datacenter"] = dc + if service_id: + data["serviceid"] = service_id + if check_id: + data["checkid"] = check_id + token = token or self.agent.token + if token: + data["WriteRequest"] = {"Token": token} + return self.agent.http.put(CB.bool(), "/v1/catalog/deregister", data=json.dumps(data)) + + def datacenters(self): + """ + Returns all the datacenters that are known by the Consul server. + """ + return self.agent.http.get(CB.json(), "/v1/catalog/datacenters") + + def nodes(self, index=None, wait=None, consistency=None, dc=None, near=None, token=None, node_meta=None): + """ + Returns a tuple of (*index*, *nodes*) of all nodes known + about in the *dc* datacenter. *dc* defaults to the current + datacenter of this agent. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + + The response looks like this:: + + (index, [ + { + "Node": "baz", + "Address": "10.1.10.11" + }, + { + "Node": "foobar", + "Address": "10.1.10.12" + } + ]) + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + if near: + params.append(("near", near)) + token = token or self.agent.token + if token: + params.append(("token", token)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), "/v1/catalog/nodes", params=params) + + def services(self, index=None, wait=None, consistency=None, dc=None, token=None, node_meta=None): + """ + Returns a tuple of (*index*, *services*) of all services known + about in the *dc* datacenter. *dc* defaults to the current + datacenter of this agent. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + + The response looks like this:: + + (index, { + "consul": [], + "redis": [], + "postgresql": [ + "master", + "slave" + ] + }) + + The main keys are the service names and the list provides all the + known tags for a given service. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + token = token or self.agent.token + if token: + params.append(("token", token)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), "/v1/catalog/services", params=params) + + def node(self, node, index=None, wait=None, consistency=None, dc=None, token=None): + """ + Returns a tuple of (*index*, *services*) of all services provided + by *node*. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *token* is an optional `ACL token`_ to apply to this request. + + The response looks like this:: + + (index, { + "Node": { + "Node": "foobar", + "Address": "10.1.10.12" + }, + "Services": { + "consul": { + "ID": "consul", + "Service": "consul", + "Tags": null, + "Port": 8300 + }, + "redis": { + "ID": "redis", + "Service": "redis", + "Tags": [ + "v1" + ], + "Port": 8000 + } + } + }) + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + token = token or self.agent.token + if token: + params.append(("token", token)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + return self.agent.http.get(CB.json(index=True), f"/v1/catalog/node/{node}", params=params) + + def _service( + self, + internal_uri, + index=None, + wait=None, + tag=None, + consistency=None, + dc=None, + near=None, + token=None, + node_meta=None, + ): + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if tag: + params.append(("tag", tag)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + if near: + params.append(("near", near)) + token = token or self.agent.token + if token: + params.append(("token", token)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), internal_uri, params=params) + + def service(self, service, **kwargs): + """ + Returns a tuple of (*index*, *nodes*) of the nodes providing + *service* in the *dc* datacenter. *dc* defaults to the current + datacenter of this agent. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + If *tag* is provided, the list of nodes returned will be filtered + by that tag. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + + The response looks like this:: + + (index, [ + { + "Node": "foobar", + "Address": "10.1.10.12", + "ServiceID": "redis", + "ServiceName": "redis", + "ServiceTags": null, + "ServicePort": 8000 + } + ]) + """ + internal_uri = f"/v1/catalog/service/{service}" + return self._service(internal_uri=internal_uri, **kwargs) + + def connect(self, service, **kwargs): + """ + Returns a tuple of (*index*, *nodes*) of the nodes providing + connect-capable *service* in the *dc* datacenter. *dc* defaults + to the current datacenter of this agent. + + Request arguments and response format are the same as catalog.service + """ + internal_uri = f"/v1/catalog/connect/{service}" + return self._service(internal_uri=internal_uri, **kwargs) diff --git a/consul/api/connect.py b/consul/api/connect.py new file mode 100644 index 0000000..c29bd29 --- /dev/null +++ b/consul/api/connect.py @@ -0,0 +1,28 @@ +from consul.callback import CB + + +class Connect: + def __init__(self, agent): + self.agent = agent + self.ca = Connect.CA(agent) + + class CA: + def __init__(self, agent): + self.agent = agent + + def roots(self, pem=False, token=None): + params = [] + params.append(("pem", int(pem))) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.get(CB.json(), "/v1/connect/ca/roots", params=params) + + def configuration(self, token=None): + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.get(CB.json(), "/v1/connect/ca/configuration", params=params) diff --git a/consul/api/coordinates.py b/consul/api/coordinates.py new file mode 100644 index 0000000..415a572 --- /dev/null +++ b/consul/api/coordinates.py @@ -0,0 +1,41 @@ +from consul.callback import CB + + +class Coordinate: + def __init__(self, agent): + self.agent = agent + + def datacenters(self): + """ + Returns the WAN network coordinates for all Consul servers, + organized by DCs. + """ + return self.agent.http.get(CB.json(), "/v1/coordinate/datacenters") + + def nodes(self, dc=None, index=None, wait=None, consistency=None): + """ + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + """ + params = [] + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + return self.agent.http.get(CB.json(index=True), "/v1/coordinate/nodes", params=params) diff --git a/consul/api/event.py b/consul/api/event.py new file mode 100644 index 0000000..f548a26 --- /dev/null +++ b/consul/api/event.py @@ -0,0 +1,102 @@ +from consul.callback import CB + + +class Event: + """ + The event command provides a mechanism to fire a custom user event to + an entire datacenter. These events are opaque to Consul, but they can + be used to build scripting infrastructure to do automated deploys, + restart services, or perform any other orchestration action. + + Unlike most Consul data, which is replicated using consensus, event + data is purely peer-to-peer over gossip. + + This means it is not persisted and does not have a total ordering. In + practice, this means you cannot rely on the order of message delivery. + An advantage however is that events can still be used even in the + absence of server nodes or during an outage.""" + + def __init__(self, agent): + self.agent = agent + + def fire(self, name, body="", node=None, service=None, tag=None, token=None): + """ + Sends an event to Consul's gossip protocol. + + *name* is the Consul-opaque name of the event. This can be filtered + on in calls to list, below + + *body* is the Consul-opaque body to be delivered with the event. + From the Consul documentation: + The underlying gossip also sets limits on the size of a user + event message. It is hard to give an exact number, as it + depends on various parameters of the event, but the payload + should be kept very small (< 100 bytes²). Specifying too large + of an event will return an error. + + *node*, *service*, and *tag* are regular expressions which remote + agents will filter against to determine if they should store the + event + + *token* is an optional `ACL token`_ to apply to this request. If + the token's policy is not allowed to fire an event of this *name* + an *ACLPermissionDenied* exception will be raised. + """ + assert not name.startswith("/"), "keys should not start with a forward slash" + params = [] + if node is not None: + params.append(("node", node)) + if service is not None: + params.append(("service", service)) + if tag is not None: + params.append(("tag", tag)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.put(CB.json(), f"/v1/event/fire/{name}", params=params, data=body) + + def list(self, name=None, index=None, wait=None): + """ + Returns a tuple of (*index*, *events*) + Note: Since Consul's event protocol uses gossip, there is no + ordering, and instead index maps to the newest event that + matches the query. + + *name* is the type of events to list, if None, lists all available. + + *index* is the current event Consul index, suitable for making + subsequent calls to wait for changes since this query was last run. + Check https://consul.io/docs/agent/http/event.html#event_list for + more infos about indexes on events. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. This parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + Consul agents only buffer the most recent entries. The current + buffer size is 256, but this value could change in the future. + + Each *event* looks like this:: + + { + { + "ID": "b54fe110-7af5-cafc-d1fb-afc8ba432b1c", + "Name": "deploy", + "Payload": "1609030", + "NodeFilter": "", + "ServiceFilter": "", + "TagFilter": "", + "Version": 1, + "LTime": 19 + }, + } + """ + params = [] + if name is not None: + params.append(("name", name)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + return self.agent.http.get(CB.json(index=True, decode="Payload"), "/v1/event/list", params=params) diff --git a/consul/api/health.py b/consul/api/health.py new file mode 100644 index 0000000..1d40686 --- /dev/null +++ b/consul/api/health.py @@ -0,0 +1,212 @@ +from consul.callback import CB + + +class Health: + # TODO: All of the health endpoints support all consistency modes + def __init__(self, agent): + self.agent = agent + + def _service( + self, + internal_uri, + index=None, + wait=None, + passing=None, + tag=None, + dc=None, + near=None, + token=None, + node_meta=None, + ): + params = [] + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + if passing: + params.append(("passing", "1")) + if tag is not None: + if not isinstance(tag, list): + tag = [tag] + for tag_item in tag: + params.append(("tag", tag_item)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if near: + params.append(("near", near)) + token = token or self.agent.token + if token: + params.append(("token", token)) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), internal_uri, params=params) + + def service(self, service, **kwargs): + """ + Returns a tuple of (*index*, *nodes*) + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *nodes* are the nodes providing the given service. + + Calling with *passing* set to True will filter results to only + those nodes whose checks are currently passing. + + Calling with *tag* will filter the results by tag, multiple tags + using list possible. + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + """ + internal_uri = f"/v1/health/service/{service}" + return self._service(internal_uri=internal_uri, **kwargs) + + def connect(self, service, **kwargs): + """ + Returns a tuple of (*index*, *nodes*) of the nodes providing + connect-capable *service* in the *dc* datacenter. *dc* defaults + to the current datacenter of this agent. + + Request arguments and response format are the same as health.service + """ + internal_uri = f"/v1/health/connect/{service}" + return self._service(internal_uri=internal_uri, **kwargs) + + def checks(self, service, index=None, wait=None, dc=None, near=None, token=None, node_meta=None): + """ + Returns a tuple of (*index*, *checks*) with *checks* being the + checks associated with the service. + + *service* is the name of the service being checked. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + """ + params = [] + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if near: + params.append(("near", near)) + token = token or self.agent.token + if token: + params.append(("token", token)) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), f"/v1/health/checks/{service}", params=params) + + def state(self, name, index=None, wait=None, dc=None, near=None, token=None, node_meta=None): + """ + Returns a tuple of (*index*, *nodes*) + + *name* is a supported state. From the Consul docs: + + The supported states are any, unknown, passing, warning, or + critical. The any state is a wildcard that can be used to + return all checks. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *token* is an optional `ACL token`_ to apply to this request. + + *node_meta* is an optional meta data used for filtering, a + dictionary formatted as {k1:v1, k2:v2}. + + *nodes* are the nodes providing the given service. + """ + assert name in ["any", "unknown", "passing", "warning", "critical"] + params = [] + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if near: + params.append(("near", near)) + token = token or self.agent.token + if token: + params.append(("token", token)) + if node_meta: + for nodemeta_name, nodemeta_value in node_meta.items(): + params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) + return self.agent.http.get(CB.json(index=True), f"/v1/health/state/{name}", params=params) + + def node(self, node, index=None, wait=None, dc=None, token=None): + """ + Returns a tuple of (*index*, *checks*) + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *dc* is the datacenter of the node and defaults to this agents + datacenter. + + *token* is an optional `ACL token`_ to apply to this request. + + *nodes* are the nodes providing the given service. + """ + params = [] + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + token = token or self.agent.token + if token: + params.append(("token", token)) + + return self.agent.http.get(CB.json(index=True), f"/v1/health/node/{node}", params=params) diff --git a/consul/api/kv.py b/consul/api/kv.py new file mode 100644 index 0000000..567366a --- /dev/null +++ b/consul/api/kv.py @@ -0,0 +1,205 @@ +from consul.callback import CB + + +class KV: + """ + The KV endpoint is used to expose a simple key/value store. This can be + used to store service configurations or other meta data in a simple + way. + """ + + def __init__(self, agent): + self.agent = agent + + def get( + self, + key, + index=None, + recurse=False, + wait=None, + token=None, + consistency=None, + keys=False, + separator=None, + dc=None, + connections_timeout=None, + ): + """ + Returns a tuple of (*index*, *value[s]*) + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *token* is an optional `ACL token`_ to apply to this request. + + *keys* is a boolean which, if True, says to return a flat list of + keys without values or other metadata. *separator* can be used + with *keys* to list keys only up to a given separator character. + + *dc* is the optional datacenter that you wish to communicate with. + If None is provided, defaults to the agent's datacenter. + + The *value* returned is for the specified key, or if *recurse* is + True a list of *values* for all keys with the given prefix is + returned. + + Each *value* looks like this:: + + { + "CreateIndex": 100, + "ModifyIndex": 200, + "LockIndex": 200, + "Key": "foo", + "Flags": 0, + "Value": "bar", + "Session": "adf4238a-882b-9ddc-4a9d-5b6758e4159e" + } + + Note, if the requested key does not exists *(index, None)* is + returned. It's then possible to long poll on the index for when the + key is created. + """ + assert not key.startswith("/"), "keys should not start with a forward slash" + params = [] + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + if recurse: + params.append(("recurse", "1")) + token = token or self.agent.token + if token: + params.append(("token", token)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if keys: + params.append(("keys", True)) + if separator: + params.append(("separator", separator)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + + one = False + decode = False + + if not keys: + decode = "Value" + if not recurse and not keys: + one = True + http_kwargs = {} + if connections_timeout: + http_kwargs["connections_timeout"] = connections_timeout + return self.agent.http.get( + CB.json(index=True, decode=decode, one=one), f"/v1/kv/{key}", params=params, **http_kwargs + ) + + def put( + self, + key, + value, + cas=None, + flags=None, + acquire=None, + release=None, + token=None, + dc=None, + connections_timeout=None, + ): + """ + Sets *key* to the given *value*. + + *value* can either be None (useful for marking a key as a + directory) or any string type, including binary data (e.g. a + msgpack'd data structure) + + The optional *cas* parameter is used to turn the PUT into a + Check-And-Set operation. This is very useful as it allows clients + to build more complex syncronization primitives on top. If the + index is 0, then Consul will only put the key if it does not + already exist. If the index is non-zero, then the key is only set + if the index matches the ModifyIndex of that key. + + An optional *flags* can be set. This can be used to specify an + unsigned value between 0 and 2^64-1. + + *acquire* is an optional session_id. if supplied a lock acquisition + will be attempted. + + *release* is an optional session_id. if supplied a lock release + will be attempted. + + *token* is an optional `ACL token`_ to apply to this request. If + the token's policy is not allowed to write to this key an + *ACLPermissionDenied* exception will be raised. + + *dc* is the optional datacenter that you wish to communicate with. + If None is provided, defaults to the agent's datacenter. + + The return value is simply either True or False. If False is + returned, then the update has not taken place. + """ + assert not key.startswith("/"), "keys should not start with a forward slash" + assert value is None or isinstance(value, (str, bytes)), "value should be None or a string / binary data" + + params = [] + if cas is not None: + params.append(("cas", cas)) + if flags is not None: + params.append(("flags", flags)) + if acquire: + params.append(("acquire", acquire)) + if release: + params.append(("release", release)) + token = token or self.agent.token + if token: + params.append(("token", token)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + http_kwargs = {} + if connections_timeout: + http_kwargs["connections_timeout"] = connections_timeout + return self.agent.http.put(CB.json(), f"/v1/kv/{key}", params=params, data=value, **http_kwargs) + + def delete(self, key, recurse=None, cas=None, token=None, dc=None, connections_timeout=None): + """ + Deletes a single key or if *recurse* is True, all keys sharing a + prefix. + + *cas* is an optional flag is used to turn the DELETE into a + Check-And-Set operation. This is very useful as a building block + for more complex synchronization primitives. Unlike PUT, the index + must be greater than 0 for Consul to take any action: a 0 index + will not delete the key. If the index is non-zero, the key is only + deleted if the index matches the ModifyIndex of that key. + + *token* is an optional `ACL token`_ to apply to this request. If + the token's policy is not allowed to delete to this key an + *ACLPermissionDenied* exception will be raised. + + *dc* is the optional datacenter that you wish to communicate with. + If None is provided, defaults to the agent's datacenter. + """ + assert not key.startswith("/"), "keys should not start with a forward slash" + + params = [] + if recurse: + params.append(("recurse", "1")) + if cas is not None: + params.append(("cas", cas)) + token = token or self.agent.token + if token: + params.append(("token", token)) + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + http_kwargs = {} + if connections_timeout: + http_kwargs["connections_timeout"] = connections_timeout + return self.agent.http.delete(CB.json(), f"/v1/kv/{key}", params=params, **http_kwargs) diff --git a/consul/api/operator.py b/consul/api/operator.py new file mode 100644 index 0000000..3abf6d3 --- /dev/null +++ b/consul/api/operator.py @@ -0,0 +1,12 @@ +from consul.callback import CB + + +class Operator: + def __init__(self, agent): + self.agent = agent + + def raft_config(self): + """ + Returns raft configuration. + """ + return self.agent.http.get(CB.json(), "/v1/operator/raft/configuration") diff --git a/consul/api/query.py b/consul/api/query.py new file mode 100644 index 0000000..b34a8f3 --- /dev/null +++ b/consul/api/query.py @@ -0,0 +1,240 @@ +import json + +from consul.callback import CB + + +class Query: + def __init__(self, agent): + self.agent = agent + + def list(self, dc=None, token=None): + """ + Lists all the active queries. This is a privileged endpoint, + therefore you will only be able to get the prepared queries + which the token supplied has read privileges to. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + + *token* is an optional `ACL token`_ to apply to this request. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + if dc: + params.append(("dc", dc)) + + return self.agent.http.get(CB.json(), "/v1/query", params=params) + + def _query_data( + self, + service=None, + name=None, + session=None, + token=None, + nearestn=None, + datacenters=None, + onlypassing=None, + tags=None, + ttl=None, + regexp=None, + ): + service_body = { + k: v + for k, v in { + "service": service, + "onlypassing": onlypassing, + "tags": tags, + "failover": { + k: v for k, v in {"nearestn": nearestn, "datacenters": datacenters}.items() if v is not None + }, + }.items() + if v is not None + } + + data = { + k: v + for k, v in { + "name": name, + "session": session, + "token": token or self.agent.token, + "dns": {"ttl": ttl} if ttl is not None else None, + "template": {k: v for k, v in {"type": "name_prefix_match", "regexp": regexp}.items() if v is not None}, + "service": service_body, + }.items() + if v is not None + } + return json.dumps(data) + + def create( + self, + service, + name=None, + dc=None, + session=None, + token=None, + nearestn=None, + datacenters=None, + onlypassing=None, + tags=None, + ttl=None, + regexp=None, + ): + """ + Creates a new query. This is a privileged endpoint, and + requires a management token for a certain query name.*token* will + override this client's default token. + + *service* is mandatory for new query. represent service name to + query. + + *name* is an optional name for this query. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + + *token* is an optional `ACL token`_ to apply to this request. + + *nearestn* if set to a value greater than zero, then the query will + be forwarded to up to NearestN other datacenters based on their + estimated network round trip time using Network Coordinates from + the WAN gossip pool. + + *datacenters* is a fixed list of remote datacenters to forward the + query to if there are no healthy nodes in the local datacenter. + + *onlypassing* controls the behavior of the query's health check + filtering. + + *tags* is a list of service tags to filter the query results. + + *ttl* is a duration string that can use "s" as a suffix for + seconds. It controls how the TTL is set when query results are + served over DNS. + + *regexp* is optional for template this option is only supported + in Consul 0.6.4 or later. The only option for type is + name_prefix_match so if you want a query template with no regexp + enter an empty string. + + For more information about query + https://www.consul.io/docs/agent/http/query.html + """ + path = "/v1/query" + params = None if dc is None else [("dc", dc)] + data = self._query_data(service, name, session, token, nearestn, datacenters, onlypassing, tags, ttl, regexp) + return self.agent.http.post(CB.json(), path, params=params, data=data) + + def update( + self, + query_id, + service=None, + name=None, + dc=None, + session=None, + token=None, + nearestn=None, + datacenters=None, + onlypassing=None, + tags=None, + ttl=None, + regexp=None, + ): + """ + This endpoint will update a certain query + + *query_id* is the query id for update + + all the other setting remains the same as the query create method + """ + path = f"/v1/query/{query_id}" + params = None if dc is None else [("dc", dc)] + data = self._query_data(service, name, session, token, nearestn, datacenters, onlypassing, tags, ttl, regexp) + return self.agent.http.put(CB.bool(), path, params=params, data=data) + + def get(self, query_id, token=None, dc=None): + """ + This endpoint will return information about a certain query + + *query_id* the query id to retrieve information about + + *token* is an optional `ACL token`_ to apply to this request. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + if dc: + params.append(("dc", dc)) + return self.agent.http.get(CB.json(), f"/v1/query/{query_id}", params=params) + + def delete(self, query_id, token=None, dc=None): + """ + This endpoint will delete certain query + + *query_id* the query id delete + + *token* is an optional `ACL token`_ to apply to this request. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + if dc: + params.append(("dc", dc)) + return self.agent.http.delete(CB.bool(), f"/v1/query/{query_id}", params=params) + + def execute(self, query, token=None, dc=None, near=None, limit=None): + """ + This endpoint will execute certain query + + *query* name or query id to execute + + *token* is an optional `ACL token`_ to apply to this request. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + + *near* is a node name to sort the resulting list in ascending + order based on the estimated round trip time from that node + + *limit* is used to limit the size of the list to the given number + of nodes. This is applied after any sorting or shuffling. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + if dc: + params.append(("dc", dc)) + if near: + params.append(("near", near)) + if limit: + params.append(("limit", limit)) + return self.agent.http.get(CB.json(), f"/v1/query/{query}/execute", params=params) + + def explain(self, query, token=None, dc=None): + """ + This endpoint shows a fully-rendered query for a given name + + *query* name to explain. This cannot be query id. + + *token* is an optional `ACL token`_ to apply to this request. + + *dc* is the datacenter that this agent will communicate with. By + default the datacenter of the host is used. + """ + params = [] + token = token or self.agent.token + if token: + params.append(("token", token)) + if dc: + params.append(("dc", dc)) + return self.agent.http.get(CB.json(), f"/v1/query/{query}/explain", params=params) diff --git a/consul/api/session.py b/consul/api/session.py new file mode 100644 index 0000000..48ab4f2 --- /dev/null +++ b/consul/api/session.py @@ -0,0 +1,196 @@ +import json + +from consul.callback import CB + + +class Session: + def __init__(self, agent): + self.agent = agent + + def create(self, name=None, node=None, checks=None, lock_delay=15, behavior="release", ttl=None, dc=None): + """ + Creates a new session. There is more documentation for sessions + `here `_. + + *name* is an optional human readable name for the session. + + *node* is the node to create the session on. if not provided the + current agent's node will be used. + + *checks* is a list of checks to associate with the session. if not + provided it defaults to the *serfHealth* check. It is highly + recommended that, if you override this list, you include the + default *serfHealth*. + + *lock_delay* is an integer of seconds. + + *behavior* can be set to either 'release' or 'delete'. This + controls the behavior when a session is invalidated. By default, + this is 'release', causing any locks that are held to be released. + Changing this to 'delete' causes any locks that are held to be + deleted. 'delete' is useful for creating ephemeral key/value + entries. + + when *ttl* is provided, the session is invalidated if it is not + renewed before the TTL expires. If specified, it is an integer of + seconds. Currently it must be between 10 and 86400 seconds. + + By default the session will be created in the current datacenter + but an optional *dc* can be provided. + + Returns the string *session_id* for the session. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + data = {} + if name: + data["name"] = name + if node: + data["node"] = node + if checks is not None: + data["checks"] = checks + if lock_delay != 15: + data["lockdelay"] = f"{lock_delay}s" + assert behavior in ("release", "delete"), "behavior must be release or delete" + if behavior != "release": + data["behavior"] = behavior + if ttl: + assert 10 <= ttl <= 86400 + data["ttl"] = f"{ttl}s" + data = json.dumps(data) if data else "" + + return self.agent.http.put(CB.json(is_id=True), "/v1/session/create", params=params, data=data) + + def destroy(self, session_id, dc=None): + """ + Destroys the session *session_id* + + Returns *True* on success. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + return self.agent.http.put(CB.bool(), f"/v1/session/destroy/{session_id}", params=params) + + def list(self, index=None, wait=None, consistency=None, dc=None): + """ + Returns a tuple of (*index*, *sessions*) of all active sessions in + the *dc* datacenter. *dc* defaults to the current datacenter of + this agent. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + + The response looks like this:: + + (index, [ + { + "LockDelay": 1.5e+10, + "Checks": [ + "serfHealth" + ], + "Node": "foobar", + "ID": "adf4238a-882b-9ddc-4a9d-5b6758e4159e", + "CreateIndex": 1086449 + }, + ... + ]) + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + return self.agent.http.get(CB.json(index=True), "/v1/session/list", params=params) + + def node(self, node, index=None, wait=None, consistency=None, dc=None): + """ + Returns a tuple of (*index*, *sessions*) as per *session.list*, but + filters the sessions returned to only those active for *node*. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + return self.agent.http.get(CB.json(index=True), f"/v1/session/node/{node}", params=params) + + def info(self, session_id, index=None, wait=None, consistency=None, dc=None): + """ + Returns a tuple of (*index*, *session*) for the session + *session_id* in the *dc* datacenter. *dc* defaults to the current + datacenter of this agent. + + *index* is the current Consul index, suitable for making subsequent + calls to wait for changes since this query was last run. + + *wait* the maximum duration to wait (e.g. '10s') to retrieve + a given index. this parameter is only applied if *index* is also + specified. the wait time by default is 5 minutes. + + *consistency* can be either 'default', 'consistent' or 'stale'. if + not specified *consistency* will the consistency level this client + was configured with. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + if index: + params.append(("index", index)) + if wait: + params.append(("wait", wait)) + consistency = consistency or self.agent.consistency + if consistency in ("consistent", "stale"): + params.append((consistency, "1")) + return self.agent.http.get(CB.json(index=True, one=True), f"/v1/session/info/{session_id}", params=params) + + def renew(self, session_id, dc=None): + """ + This is used with sessions that have a TTL, and it extends the + expiration by the TTL. + + *dc* is the optional datacenter that you wish to communicate with. + If None is provided, defaults to the agent's datacenter. + + Returns the session. + """ + params = [] + dc = dc or self.agent.dc + if dc: + params.append(("dc", dc)) + return self.agent.http.put(CB.json(one=True, allow_404=False), f"/v1/session/renew/{session_id}", params=params) diff --git a/consul/api/status.py b/consul/api/status.py new file mode 100644 index 0000000..5c192dc --- /dev/null +++ b/consul/api/status.py @@ -0,0 +1,25 @@ +from consul.callback import CB + + +class Status: + """ + The Status endpoints are used to get information about the status + of the Consul cluster. + """ + + def __init__(self, agent): + self.agent = agent + + def leader(self): + """ + This endpoint is used to get the Raft leader for the datacenter + in which the agent is running. + """ + return self.agent.http.get(CB.json(), "/v1/status/leader") + + def peers(self): + """ + This endpoint retrieves the Raft peers for the datacenter in which + the the agent is running. + """ + return self.agent.http.get(CB.json(), "/v1/status/peers") diff --git a/consul/api/txn.py b/consul/api/txn.py new file mode 100644 index 0000000..8c0dabd --- /dev/null +++ b/consul/api/txn.py @@ -0,0 +1,37 @@ +import json + +from consul.callback import CB + + +class Txn: + """ + The Transactions endpoints manage updates or fetches of multiple keys + inside a single, atomic transaction. + """ + + def __init__(self, agent): + self.agent = agent + + def put(self, payload): + """ + Create a transaction by submitting a list of operations to apply to + the KV store inside of a transaction. If any operation fails, the + transaction is rolled back and none of the changes are applied. + + *payload* is a list of operations where each operation is a `dict` + with a single key value pair, with the key specifying operation the + type. An example payload of operation type "KV" is + dict:: + + { + "KV": { + "Verb": "", + "Key": "", + "Value": "", + "Flags": 0, + "Index": 0, + "Session": "" + } + } + """ + return self.agent.http.put(CB.json(), "/v1/txn", data=json.dumps(payload)) diff --git a/consul/base.py b/consul/base.py index 91e9b65..dd62550 100644 --- a/consul/base.py +++ b/consul/base.py @@ -1,233 +1,34 @@ import abc -import base64 import collections -import json import logging import os import urllib -import warnings -log = logging.getLogger(__name__) - - -class ConsulException(Exception): - pass - - -class ACLDisabled(ConsulException): - pass - - -class ACLPermissionDenied(ConsulException): - pass - - -class NotFound(ConsulException): - pass - - -class Timeout(ConsulException): - pass - - -class BadRequest(ConsulException): - pass +from consul.api.acl import ACL +from consul.api.agent import Agent +from consul.api.catalog import Catalog +from consul.api.connect import Connect +from consul.api.coordinates import Coordinate +from consul.api.event import Event +from consul.api.health import Health +from consul.api.kv import KV +from consul.api.operator import Operator +from consul.api.query import Query +from consul.api.session import Session +from consul.api.status import Status +from consul.api.txn import Txn +from consul.exceptions import ConsulException - -class ClientError(ConsulException): - """Encapsulates 4xx Http error code""" +log = logging.getLogger(__name__) # # Convenience to define checks -class Check: - """ - There are three different kinds of checks: script, http and ttl - """ - - @classmethod - def script(cls, args, interval, deregister=None): - """ - Run the script *args* every *interval* (e.g. "10s") to peform health - check - """ - if isinstance(args, (str, bytes)): - warnings.warn("Check.script should take a list of args", DeprecationWarning) - args = ["sh", "-c", args] - ret = {"args": args, "interval": interval} - if deregister: - ret["DeregisterCriticalServiceAfter"] = deregister - return ret - - @classmethod - def http(cls, url, interval, timeout=None, deregister=None, header=None): - """ - Peform a HTTP GET against *url* every *interval* (e.g. "10s") to peform - health check with an optional *timeout* and optional *deregister* after - which a failing service will be automatically deregistered. Optional - parameter *header* specifies headers sent in HTTP request. *header* - paramater is in form of map of lists of strings, - e.g. {"x-foo": ["bar", "baz"]}. - """ - ret = {"http": url, "interval": interval} - if timeout: - ret["timeout"] = timeout - if deregister: - ret["DeregisterCriticalServiceAfter"] = deregister - if header: - ret["header"] = header - return ret - - @classmethod - def tcp(cls, host, port, interval, timeout=None, deregister=None): - """ - Attempt to establish a tcp connection to the specified *host* and - *port* at a specified *interval* with optional *timeout* and optional - *deregister* after which a failing service will be automatically - deregistered. - """ - ret = {"tcp": f"{host:s}:{port:d}", "interval": interval} - if timeout: - ret["timeout"] = timeout - if deregister: - ret["DeregisterCriticalServiceAfter"] = deregister - return ret - - @classmethod - def ttl(cls, ttl): - """ - Set check to be marked as critical after *ttl* (e.g. "10s") unless the - check is periodically marked as passing. - """ - return {"ttl": ttl} - - @classmethod - def docker(cls, container_id, shell, script, interval, deregister=None): - """ - Invoke *script* packaged within a running docker container with - *container_id* at a specified *interval* on the configured - *shell* using the Docker Exec API. Optional *register* after which a - failing service will be automatically deregistered. - """ - ret = {"docker_container_id": container_id, "shell": shell, "script": script, "interval": interval} - if deregister: - ret["DeregisterCriticalServiceAfter"] = deregister - return ret - - @classmethod - def _compat(cls, script=None, interval=None, ttl=None, http=None, timeout=None, deregister=None): - if not script and not http and not ttl: - return {} - - log.warning("DEPRECATED: use consul.Check.script/http/ttl to specify check") - - ret = {"check": {}} - - if script: - assert interval - assert not ttl - assert not http - ret["check"] = {"script": script, "interval": interval} - if ttl: - assert not interval or script - assert not http - ret["check"] = {"ttl": ttl} - if http: - assert interval - assert not script - assert not ttl - ret["check"] = {"http": http, "interval": interval} - if timeout: - assert http - ret["check"]["timeout"] = timeout - - if deregister: - ret["check"]["DeregisterCriticalServiceAfter"] = deregister - - return ret - - Response = collections.namedtuple("Response", ["code", "headers", "body"]) -# -# Conveniences to create consistent callback handlers for endpoints - - -class CB: - @classmethod - def _status(cls, response, allow_404=True): - # status checking - if 400 <= response.code < 500: - if response.code == 400: - raise BadRequest(f"{response.code} {response.body}") - if response.code == 401: - raise ACLDisabled(response.body) - if response.code == 403: - raise ACLPermissionDenied(response.body) - if response.code == 404: - if not allow_404: - raise NotFound(response.body) - else: - raise ClientError(f"{response.code} {response.body}") - elif 500 <= response.code < 600: - raise ConsulException(f"{response.code} {response.body}") - - @classmethod - def bool(cls): - # returns True on successful response - def cb(response): - CB._status(response) - return response.code == 200 - - return cb - - @classmethod - def json(cls, postprocess=None, allow_404=True, one=False, decode=False, is_id=False, index=False): - """ - *postprocess* is a function to apply to the final result. - - *allow_404* if set, None will be returned on 404, instead of raising - NotFound. - - *index* if set, a tuple of index, data will be returned. - - *one* returns only the first item of the list of items. empty lists are - coerced to None. - - *decode* if specified this key will be base64 decoded. - - *is_id* only the 'ID' field of the json object will be returned. - """ - - def cb(response): - CB._status(response, allow_404=allow_404) - if response.code == 404: - data = None - else: - data = json.loads(response.body) - if decode: - for item in data: - if item.get(decode) is not None: - item[decode] = base64.b64decode(item[decode]) - if is_id: - data = data["ID"] - if one: - if data == []: - data = None - if data is not None: - data = data[0] - if postprocess: - data = postprocess(data) - if index: - return response.headers["X-Consul-Index"], data - return data - - return cb - - class HTTPClient(metaclass=abc.ABCMeta): def __init__(self, host="127.0.0.1", port=8500, scheme="http", verify=True, cert=None): self.host = host @@ -321,19 +122,19 @@ def __init__( ), "consistency must be either default, consistent or state" self.consistency = consistency - self.event = Consul.Event(self) - self.kv = Consul.KV(self) - self.txn = Consul.Txn(self) - self.agent = Consul.Agent(self) - self.catalog = Consul.Catalog(self) - self.health = Consul.Health(self) - self.session = Consul.Session(self) - self.acl = Consul.ACL(self) - self.status = Consul.Status(self) - self.query = Consul.Query(self) - self.coordinate = Consul.Coordinate(self) - self.operator = Consul.Operator(self) - self.connect = Consul.Connect(self) + self.event = Event(self) + self.kv = KV(self) + self.txn = Txn(self) + self.agent = Agent(self) + self.catalog = Catalog(self) + self.health = Health(self) + self.session = Session(self) + self.acl = ACL(self) + self.status = Status(self) + self.query = Query(self) + self.coordinate = Coordinate(self) + self.operator = Operator(self) + self.connect = Connect(self) def __enter__(self): return self @@ -350,2032 +151,3 @@ async def __aexit__(self, exc_type, exc, tb): @abc.abstractmethod def http_connect(self, host, port, scheme, verify=True, cert=None): pass - - class Event: - """ - The event command provides a mechanism to fire a custom user event to - an entire datacenter. These events are opaque to Consul, but they can - be used to build scripting infrastructure to do automated deploys, - restart services, or perform any other orchestration action. - - Unlike most Consul data, which is replicated using consensus, event - data is purely peer-to-peer over gossip. - - This means it is not persisted and does not have a total ordering. In - practice, this means you cannot rely on the order of message delivery. - An advantage however is that events can still be used even in the - absence of server nodes or during an outage.""" - - def __init__(self, agent): - self.agent = agent - - def fire(self, name, body="", node=None, service=None, tag=None, token=None): - """ - Sends an event to Consul's gossip protocol. - - *name* is the Consul-opaque name of the event. This can be filtered - on in calls to list, below - - *body* is the Consul-opaque body to be delivered with the event. - From the Consul documentation: - The underlying gossip also sets limits on the size of a user - event message. It is hard to give an exact number, as it - depends on various parameters of the event, but the payload - should be kept very small (< 100 bytes²). Specifying too large - of an event will return an error. - - *node*, *service*, and *tag* are regular expressions which remote - agents will filter against to determine if they should store the - event - - *token* is an optional `ACL token`_ to apply to this request. If - the token's policy is not allowed to fire an event of this *name* - an *ACLPermissionDenied* exception will be raised. - """ - assert not name.startswith("/"), "keys should not start with a forward slash" - params = [] - if node is not None: - params.append(("node", node)) - if service is not None: - params.append(("service", service)) - if tag is not None: - params.append(("tag", tag)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.json(), f"/v1/event/fire/{name}", params=params, data=body) - - def list(self, name=None, index=None, wait=None): - """ - Returns a tuple of (*index*, *events*) - Note: Since Consul's event protocol uses gossip, there is no - ordering, and instead index maps to the newest event that - matches the query. - - *name* is the type of events to list, if None, lists all available. - - *index* is the current event Consul index, suitable for making - subsequent calls to wait for changes since this query was last run. - Check https://consul.io/docs/agent/http/event.html#event_list for - more infos about indexes on events. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. This parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - Consul agents only buffer the most recent entries. The current - buffer size is 256, but this value could change in the future. - - Each *event* looks like this:: - - { - { - "ID": "b54fe110-7af5-cafc-d1fb-afc8ba432b1c", - "Name": "deploy", - "Payload": "1609030", - "NodeFilter": "", - "ServiceFilter": "", - "TagFilter": "", - "Version": 1, - "LTime": 19 - }, - } - """ - params = [] - if name is not None: - params.append(("name", name)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - return self.agent.http.get(CB.json(index=True, decode="Payload"), "/v1/event/list", params=params) - - class KV: - """ - The KV endpoint is used to expose a simple key/value store. This can be - used to store service configurations or other meta data in a simple - way. - """ - - def __init__(self, agent): - self.agent = agent - - def get( - self, - key, - index=None, - recurse=False, - wait=None, - token=None, - consistency=None, - keys=False, - separator=None, - dc=None, - connections_timeout=None, - ): - """ - Returns a tuple of (*index*, *value[s]*) - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *token* is an optional `ACL token`_ to apply to this request. - - *keys* is a boolean which, if True, says to return a flat list of - keys without values or other metadata. *separator* can be used - with *keys* to list keys only up to a given separator character. - - *dc* is the optional datacenter that you wish to communicate with. - If None is provided, defaults to the agent's datacenter. - - The *value* returned is for the specified key, or if *recurse* is - True a list of *values* for all keys with the given prefix is - returned. - - Each *value* looks like this:: - - { - "CreateIndex": 100, - "ModifyIndex": 200, - "LockIndex": 200, - "Key": "foo", - "Flags": 0, - "Value": "bar", - "Session": "adf4238a-882b-9ddc-4a9d-5b6758e4159e" - } - - Note, if the requested key does not exists *(index, None)* is - returned. It's then possible to long poll on the index for when the - key is created. - """ - assert not key.startswith("/"), "keys should not start with a forward slash" - params = [] - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - if recurse: - params.append(("recurse", "1")) - token = token or self.agent.token - if token: - params.append(("token", token)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if keys: - params.append(("keys", True)) - if separator: - params.append(("separator", separator)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - - one = False - decode = False - - if not keys: - decode = "Value" - if not recurse and not keys: - one = True - http_kwargs = {} - if connections_timeout: - http_kwargs["connections_timeout"] = connections_timeout - return self.agent.http.get( - CB.json(index=True, decode=decode, one=one), f"/v1/kv/{key}", params=params, **http_kwargs - ) - - def put( - self, - key, - value, - cas=None, - flags=None, - acquire=None, - release=None, - token=None, - dc=None, - connections_timeout=None, - ): - """ - Sets *key* to the given *value*. - - *value* can either be None (useful for marking a key as a - directory) or any string type, including binary data (e.g. a - msgpack'd data structure) - - The optional *cas* parameter is used to turn the PUT into a - Check-And-Set operation. This is very useful as it allows clients - to build more complex syncronization primitives on top. If the - index is 0, then Consul will only put the key if it does not - already exist. If the index is non-zero, then the key is only set - if the index matches the ModifyIndex of that key. - - An optional *flags* can be set. This can be used to specify an - unsigned value between 0 and 2^64-1. - - *acquire* is an optional session_id. if supplied a lock acquisition - will be attempted. - - *release* is an optional session_id. if supplied a lock release - will be attempted. - - *token* is an optional `ACL token`_ to apply to this request. If - the token's policy is not allowed to write to this key an - *ACLPermissionDenied* exception will be raised. - - *dc* is the optional datacenter that you wish to communicate with. - If None is provided, defaults to the agent's datacenter. - - The return value is simply either True or False. If False is - returned, then the update has not taken place. - """ - assert not key.startswith("/"), "keys should not start with a forward slash" - assert value is None or isinstance(value, (str, bytes)), "value should be None or a string / binary data" - - params = [] - if cas is not None: - params.append(("cas", cas)) - if flags is not None: - params.append(("flags", flags)) - if acquire: - params.append(("acquire", acquire)) - if release: - params.append(("release", release)) - token = token or self.agent.token - if token: - params.append(("token", token)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - http_kwargs = {} - if connections_timeout: - http_kwargs["connections_timeout"] = connections_timeout - return self.agent.http.put(CB.json(), f"/v1/kv/{key}", params=params, data=value, **http_kwargs) - - def delete(self, key, recurse=None, cas=None, token=None, dc=None, connections_timeout=None): - """ - Deletes a single key or if *recurse* is True, all keys sharing a - prefix. - - *cas* is an optional flag is used to turn the DELETE into a - Check-And-Set operation. This is very useful as a building block - for more complex synchronization primitives. Unlike PUT, the index - must be greater than 0 for Consul to take any action: a 0 index - will not delete the key. If the index is non-zero, the key is only - deleted if the index matches the ModifyIndex of that key. - - *token* is an optional `ACL token`_ to apply to this request. If - the token's policy is not allowed to delete to this key an - *ACLPermissionDenied* exception will be raised. - - *dc* is the optional datacenter that you wish to communicate with. - If None is provided, defaults to the agent's datacenter. - """ - assert not key.startswith("/"), "keys should not start with a forward slash" - - params = [] - if recurse: - params.append(("recurse", "1")) - if cas is not None: - params.append(("cas", cas)) - token = token or self.agent.token - if token: - params.append(("token", token)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - http_kwargs = {} - if connections_timeout: - http_kwargs["connections_timeout"] = connections_timeout - return self.agent.http.delete(CB.json(), f"/v1/kv/{key}", params=params, **http_kwargs) - - class Txn: - """ - The Transactions endpoints manage updates or fetches of multiple keys - inside a single, atomic transaction. - """ - - def __init__(self, agent): - self.agent = agent - - def put(self, payload): - """ - Create a transaction by submitting a list of operations to apply to - the KV store inside of a transaction. If any operation fails, the - transaction is rolled back and none of the changes are applied. - - *payload* is a list of operations where each operation is a `dict` - with a single key value pair, with the key specifying operation the - type. An example payload of operation type "KV" is - dict:: - - { - "KV": { - "Verb": "", - "Key": "", - "Value": "", - "Flags": 0, - "Index": 0, - "Session": "" - } - } - """ - return self.agent.http.put(CB.json(), "/v1/txn", data=json.dumps(payload)) - - class Agent: - """ - The Agent endpoints are used to interact with a local Consul agent. - Usually, services and checks are registered with an agent, which then - takes on the burden of registering with the Catalog and performing - anti-entropy to recover from outages. - """ - - def __init__(self, agent): - self.agent = agent - self.service = Consul.Agent.Service(agent) - self.check = Consul.Agent.Check(agent) - self.connect = Consul.Agent.Connect(agent) - - def self(self): - """ - Returns configuration of the local agent and member information. - """ - return self.agent.http.get(CB.json(), "/v1/agent/self") - - def services(self): - """ - Returns all the services that are registered with the local agent. - These services were either provided through configuration files, or - added dynamically using the HTTP API. It is important to note that - the services known by the agent may be different than those - reported by the Catalog. This is usually due to changes being made - while there is no leader elected. The agent performs active - anti-entropy, so in most situations everything will be in sync - within a few seconds. - """ - return self.agent.http.get(CB.json(), "/v1/agent/services") - - def service_definition(self, service_id): - """ - Returns a service definition for a single instance that is registered - with the local agent. - """ - return self.agent.http.get(CB.json(), f"/v1/agent/service/{service_id}") - - def checks(self): - """ - Returns all the checks that are registered with the local agent. - These checks were either provided through configuration files, or - added dynamically using the HTTP API. Similar to services, - the checks known by the agent may be different than those - reported by the Catalog. This is usually due to changes being made - while there is no leader elected. The agent performs active - anti-entropy, so in most situations everything will be in sync - within a few seconds. - """ - return self.agent.http.get(CB.json(), "/v1/agent/checks") - - def members(self, wan=False): - """ - Returns all the members that this agent currently sees. This may - vary by agent, use the nodes api of Catalog to retrieve a cluster - wide consistent view of members. - - For agents running in server mode, setting *wan* to *True* returns - the list of WAN members instead of the LAN members which is - default. - """ - params = [] - if wan: - params.append(("wan", 1)) - return self.agent.http.get(CB.json(), "/v1/agent/members", params=params) - - def maintenance(self, enable, reason=None, token=None): - """ - The node maintenance endpoint can place the agent into - "maintenance mode". - - *enable* is either 'true' or 'false'. 'true' enables maintenance - mode, 'false' disables maintenance mode. - - *reason* is an optional string. This is simply to aid human - operators. - """ - - params = [] - - params.append(("enable", enable)) - if reason: - params.append(("reason", reason)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), "/v1/agent/maintenance", params=params) - - def join(self, address, wan=False, token=None): - """ - This endpoint instructs the agent to attempt to connect to a - given address. - - *address* is the ip to connect to. - - *wan* is either 'true' or 'false'. For agents running in server - mode, 'true' causes the agent to attempt to join using the WAN - pool. Default is 'false'. - """ - - params = [] - - if wan: - params.append(("wan", 1)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/join/{address}", params=params) - - def force_leave(self, node): - """ - This endpoint instructs the agent to force a node into the left - state. If a node fails unexpectedly, then it will be in a failed - state. Once in the failed state, Consul will attempt to reconnect, - and the services and checks belonging to that node will not be - cleaned up. Forcing a node into the left state allows its old - entries to be removed. - - *node* is the node to change state for. - """ - - return self.agent.http.put(CB.bool(), f"/v1/agent/force-leave/{node}") - - class Service: - def __init__(self, agent): - self.agent = agent - - def register( - self, - name, - service_id=None, - address=None, - port=None, - tags=None, - check=None, - token=None, - meta=None, - weights=None, - # *deprecated* use check parameter - script=None, - interval=None, - ttl=None, - http=None, - timeout=None, - enable_tag_override=False, - extra_checks=None, - ): - """ - Add a new service to the local agent. There is more - documentation on services - `here `_. - - *name* is the name of the service. - - If the optional *service_id* is not provided it is set to - *name*. You cannot have duplicate *service_id* entries per - agent, so it may be necessary to provide one. - - *address* will default to the address of the agent if not - provided. - - An optional health *check* can be created for this service is - one of `Check.script`_, `Check.http`_, `Check.tcp`_, - `Check.ttl`_ or `Check.docker`_. - - *token* is an optional `ACL token`_ to apply to this request. - Note this call will return successful even if the token doesn't - have permissions to register this service. - - *meta* specifies arbitrary KV metadata linked to the service - formatted as {k1:v1, k2:v2}. - - *weights* specifies weights for the service; default to - {"Passing": 1, "Warning": 1}. - - *script*, *interval*, *ttl*, *http*, and *timeout* arguments - are deprecated. use *check* instead. - - *enable_tag_override* is an optional bool that enable you - to modify a service tags from servers(consul agent role server) - Default is set to False. - This option is only for >=v0.6.0 version on both agent and - servers. - for more information - https://www.consul.io/docs/agent/services.html - """ - - if extra_checks is None: - extra_checks = [] - payload = {} - - payload["name"] = name - if enable_tag_override: - payload["enabletagoverride"] = enable_tag_override - if service_id: - payload["id"] = service_id - if address: - payload["address"] = address - if port: - payload["port"] = port - if tags: - payload["tags"] = tags - if meta: - payload["meta"] = meta - if check: - payload["checks"] = [check] + extra_checks - if weights: - payload["weights"] = weights - - else: - payload.update( - Check._compat( # pylint: disable=protected-access - script=script, interval=interval, ttl=ttl, http=http, timeout=timeout - ) - ) - - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put( - CB.bool(), "/v1/agent/service/register", params=params, data=json.dumps(payload) - ) - - def deregister(self, service_id, token=None): - """ - Used to remove a service from the local agent. The agent will - take care of deregistering the service with the Catalog. If - there is an associated check, that is also deregistered. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/service/deregister/{service_id}", params=params) - - def maintenance(self, service_id, enable, reason=None, token=None): - """ - The service maintenance endpoint allows placing a given service - into "maintenance mode". - - *service_id* is the id of the service that is to be targeted - for maintenance. - - *enable* is either 'true' or 'false'. 'true' enables - maintenance mode, 'false' disables maintenance mode. - - *reason* is an optional string. This is simply to aid human - operators. - """ - - params = [] - - params.append(("enable", enable)) - if reason: - params.append(("reason", reason)) - - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/service/maintenance/{service_id}", params=params) - - class Check: - def __init__(self, agent): - self.agent = agent - - def register( - self, - name, - check=None, - check_id=None, - notes=None, - service_id=None, - token=None, - # *deprecated* use check parameter - script=None, - interval=None, - ttl=None, - http=None, - timeout=None, - ): - """ - Register a new check with the local agent. More documentation - on checks can be found `here - `_. - - *name* is the name of the check. - - *check* is one of `Check.script`_, `Check.http`_, `Check.tcp`_ - `Check.ttl`_ or `Check.docker`_ and is required. - - If the optional *check_id* is not provided it is set to *name*. - *check_id* must be unique for this agent. - - *notes* is not used by Consul, and is meant to be human - readable. - - Optionally, a *service_id* can be specified to associate a - registered check with an existing service. - - *token* is an optional `ACL token`_ to apply to this request. - Note this call will return successful even if the token doesn't - have permissions to register this check. - - *script*, *interval*, *ttl*, *http*, and *timeout* arguments - are deprecated. use *check* instead. - - Returns *True* on success. - """ - payload = {"name": name} - - assert check or script or ttl or http, "check is required" - - if check: - payload.update(check) - - else: - payload.update( - Check._compat(script=script, interval=interval, ttl=ttl, http=http, timeout=timeout)["check"] - ) - - if check_id: - payload["id"] = check_id - if notes: - payload["notes"] = notes - if service_id: - payload["serviceid"] = service_id - - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put( - CB.bool(), "/v1/agent/check/register", params=params, data=json.dumps(payload) - ) - - def deregister(self, check_id, token=None): - """ - Remove a check from the local agent. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/check/deregister/{check_id}", params=params) - - def ttl_pass(self, check_id, notes=None, token=None): - """ - Mark a ttl based check as passing. Optional notes can be - attached to describe the status of the check. - """ - params = [] - if notes: - params.append(("note", notes)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/check/pass/{check_id}", params=params) - - def ttl_fail(self, check_id, notes=None, token=None): - """ - Mark a ttl based check as failing. Optional notes can be - attached to describe why check is failing. The status of the - check will be set to critical and the ttl clock will be reset. - """ - params = [] - if notes: - params.append(("note", notes)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/check/fail/{check_id}", params=params) - - def ttl_warn(self, check_id, notes=None, token=None): - """ - Mark a ttl based check with warning. Optional notes can be - attached to describe the warning. The status of the - check will be set to warn and the ttl clock will be reset. - """ - params = [] - if notes: - params.append(("note", notes)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put(CB.bool(), f"/v1/agent/check/warn/{check_id}", params=params) - - class Connect: - def __init__(self, agent): - self.agent = agent - self.ca = Consul.Agent.Connect.CA(agent) - - def authorize(self, target, client_cert_uri, client_cert_serial, token=None): - """ - Tests whether a connection attempt is authorized between - two services. - More information is available - `here `_. - - *target* is the name of the service that is being requested. - - *client_cert_uri* The unique identifier for the requesting - client. - - *client_cert_serial* The colon-hex-encoded serial number for - the requesting client cert. - """ - - payload = {"Target": target, "ClientCertURI": client_cert_uri, "ClientCertSerial": client_cert_serial} - - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.put( - CB.json(), "/v1/agent/connect/authorize", params=params, data=json.dumps(payload) - ) - - class CA: - def __init__(self, agent): - self.agent = agent - - def roots(self): - return self.agent.http.get(CB.json(), "/v1/agent/connect/ca/roots") - - def leaf(self, service, token=None): - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.get(CB.json(), f"/v1/agent/connect/ca/leaf/{service}", params=params) - - class Catalog: - def __init__(self, agent): - self.agent = agent - - def register(self, node, address, service=None, check=None, dc=None, token=None, node_meta=None): - """ - A low level mechanism for directly registering or updating entries - in the catalog. It is usually recommended to use - agent.service.register and agent.check.register, as they are - simpler and perform anti-entropy. - - *node* is the name of the node to register. - - *address* is the ip of the node. - - *service* is an optional service to register. if supplied this is a - dict:: - - { - "Service": "redis", - "ID": "redis1", - "Tags": [ - "master", - "v1" - ], - "Port": 8000 - } - - where - - *Service* is required and is the name of the service - - *ID* is optional, and will be set to *Service* if not provided. - Note *ID* must be unique for the given *node*. - - *Tags* and *Port* are optional. - - *check* is an optional check to register. if supplied this is a - dict:: - - { - "Node": "foobar", - "CheckID": "service:redis1", - "Name": "Redis health check", - "Notes": "Script based health check", - "Status": "passing", - "ServiceID": "redis1" - } - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - - This manipulates the health check entry, but does not setup a - script or TTL to actually update the status. The full documentation - is `here `_. - - Returns *True* on success. - """ - data = {"node": node, "address": address} - params = [] - dc = dc or self.agent.dc - if dc: - data["datacenter"] = dc - if service: - data["service"] = service - if check: - data["check"] = check - token = token or self.agent.token - if token: - data["WriteRequest"] = {"Token": token} - params.append(("token", token)) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.put(CB.bool(), "/v1/catalog/register", data=json.dumps(data), params=params) - - def deregister(self, node, service_id=None, check_id=None, dc=None, token=None): - """ - A low level mechanism for directly removing entries in the catalog. - It is usually recommended to use the agent APIs, as they are - simpler and perform anti-entropy. - - *node* and *dc* specify which node on which datacenter to remove. - If *service_id* and *check_id* are not provided, all associated - services and checks are deleted. Otherwise only one of *service_id* - and *check_id* should be provided and only that service or check - will be removed. - - *token* is an optional `ACL token`_ to apply to this request. - - Returns *True* on success. - """ - assert not (service_id and check_id) - data = {"node": node} - dc = dc or self.agent.dc - if dc: - data["datacenter"] = dc - if service_id: - data["serviceid"] = service_id - if check_id: - data["checkid"] = check_id - token = token or self.agent.token - if token: - data["WriteRequest"] = {"Token": token} - return self.agent.http.put(CB.bool(), "/v1/catalog/deregister", data=json.dumps(data)) - - def datacenters(self): - """ - Returns all the datacenters that are known by the Consul server. - """ - return self.agent.http.get(CB.json(), "/v1/catalog/datacenters") - - def nodes(self, index=None, wait=None, consistency=None, dc=None, near=None, token=None, node_meta=None): - """ - Returns a tuple of (*index*, *nodes*) of all nodes known - about in the *dc* datacenter. *dc* defaults to the current - datacenter of this agent. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - - The response looks like this:: - - (index, [ - { - "Node": "baz", - "Address": "10.1.10.11" - }, - { - "Node": "foobar", - "Address": "10.1.10.12" - } - ]) - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - if near: - params.append(("near", near)) - token = token or self.agent.token - if token: - params.append(("token", token)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), "/v1/catalog/nodes", params=params) - - def services(self, index=None, wait=None, consistency=None, dc=None, token=None, node_meta=None): - """ - Returns a tuple of (*index*, *services*) of all services known - about in the *dc* datacenter. *dc* defaults to the current - datacenter of this agent. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - - The response looks like this:: - - (index, { - "consul": [], - "redis": [], - "postgresql": [ - "master", - "slave" - ] - }) - - The main keys are the service names and the list provides all the - known tags for a given service. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - token = token or self.agent.token - if token: - params.append(("token", token)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), "/v1/catalog/services", params=params) - - def node(self, node, index=None, wait=None, consistency=None, dc=None, token=None): - """ - Returns a tuple of (*index*, *services*) of all services provided - by *node*. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *token* is an optional `ACL token`_ to apply to this request. - - The response looks like this:: - - (index, { - "Node": { - "Node": "foobar", - "Address": "10.1.10.12" - }, - "Services": { - "consul": { - "ID": "consul", - "Service": "consul", - "Tags": null, - "Port": 8300 - }, - "redis": { - "ID": "redis", - "Service": "redis", - "Tags": [ - "v1" - ], - "Port": 8000 - } - } - }) - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - token = token or self.agent.token - if token: - params.append(("token", token)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - return self.agent.http.get(CB.json(index=True), f"/v1/catalog/node/{node}", params=params) - - def _service( - self, - internal_uri, - index=None, - wait=None, - tag=None, - consistency=None, - dc=None, - near=None, - token=None, - node_meta=None, - ): - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if tag: - params.append(("tag", tag)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - if near: - params.append(("near", near)) - token = token or self.agent.token - if token: - params.append(("token", token)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), internal_uri, params=params) - - def service(self, service, **kwargs): - """ - Returns a tuple of (*index*, *nodes*) of the nodes providing - *service* in the *dc* datacenter. *dc* defaults to the current - datacenter of this agent. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - If *tag* is provided, the list of nodes returned will be filtered - by that tag. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - - The response looks like this:: - - (index, [ - { - "Node": "foobar", - "Address": "10.1.10.12", - "ServiceID": "redis", - "ServiceName": "redis", - "ServiceTags": null, - "ServicePort": 8000 - } - ]) - """ - internal_uri = f"/v1/catalog/service/{service}" - return self._service(internal_uri=internal_uri, **kwargs) - - def connect(self, service, **kwargs): - """ - Returns a tuple of (*index*, *nodes*) of the nodes providing - connect-capable *service* in the *dc* datacenter. *dc* defaults - to the current datacenter of this agent. - - Request arguments and response format are the same as catalog.service - """ - internal_uri = f"/v1/catalog/connect/{service}" - return self._service(internal_uri=internal_uri, **kwargs) - - class Health: - # TODO: All of the health endpoints support all consistency modes - def __init__(self, agent): - self.agent = agent - - def _service( - self, - internal_uri, - index=None, - wait=None, - passing=None, - tag=None, - dc=None, - near=None, - token=None, - node_meta=None, - ): - params = [] - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - if passing: - params.append(("passing", "1")) - if tag is not None: - if not isinstance(tag, list): - tag = [tag] - for tag_item in tag: - params.append(("tag", tag_item)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if near: - params.append(("near", near)) - token = token or self.agent.token - if token: - params.append(("token", token)) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), internal_uri, params=params) - - def service(self, service, **kwargs): - """ - Returns a tuple of (*index*, *nodes*) - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *nodes* are the nodes providing the given service. - - Calling with *passing* set to True will filter results to only - those nodes whose checks are currently passing. - - Calling with *tag* will filter the results by tag, multiple tags - using list possible. - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - """ - internal_uri = f"/v1/health/service/{service}" - return self._service(internal_uri=internal_uri, **kwargs) - - def connect(self, service, **kwargs): - """ - Returns a tuple of (*index*, *nodes*) of the nodes providing - connect-capable *service* in the *dc* datacenter. *dc* defaults - to the current datacenter of this agent. - - Request arguments and response format are the same as health.service - """ - internal_uri = f"/v1/health/connect/{service}" - return self._service(internal_uri=internal_uri, **kwargs) - - def checks(self, service, index=None, wait=None, dc=None, near=None, token=None, node_meta=None): - """ - Returns a tuple of (*index*, *checks*) with *checks* being the - checks associated with the service. - - *service* is the name of the service being checked. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - """ - params = [] - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if near: - params.append(("near", near)) - token = token or self.agent.token - if token: - params.append(("token", token)) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), f"/v1/health/checks/{service}", params=params) - - def state(self, name, index=None, wait=None, dc=None, near=None, token=None, node_meta=None): - """ - Returns a tuple of (*index*, *nodes*) - - *name* is a supported state. From the Consul docs: - - The supported states are any, unknown, passing, warning, or - critical. The any state is a wildcard that can be used to - return all checks. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *token* is an optional `ACL token`_ to apply to this request. - - *node_meta* is an optional meta data used for filtering, a - dictionary formatted as {k1:v1, k2:v2}. - - *nodes* are the nodes providing the given service. - """ - assert name in ["any", "unknown", "passing", "warning", "critical"] - params = [] - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if near: - params.append(("near", near)) - token = token or self.agent.token - if token: - params.append(("token", token)) - if node_meta: - for nodemeta_name, nodemeta_value in node_meta.items(): - params.append(("node-meta", f"{nodemeta_name}:{nodemeta_value}")) - return self.agent.http.get(CB.json(index=True), f"/v1/health/state/{name}", params=params) - - def node(self, node, index=None, wait=None, dc=None, token=None): - """ - Returns a tuple of (*index*, *checks*) - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *dc* is the datacenter of the node and defaults to this agents - datacenter. - - *token* is an optional `ACL token`_ to apply to this request. - - *nodes* are the nodes providing the given service. - """ - params = [] - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.get(CB.json(index=True), f"/v1/health/node/{node}", params=params) - - class Session: - def __init__(self, agent): - self.agent = agent - - def create(self, name=None, node=None, checks=None, lock_delay=15, behavior="release", ttl=None, dc=None): - """ - Creates a new session. There is more documentation for sessions - `here `_. - - *name* is an optional human readable name for the session. - - *node* is the node to create the session on. if not provided the - current agent's node will be used. - - *checks* is a list of checks to associate with the session. if not - provided it defaults to the *serfHealth* check. It is highly - recommended that, if you override this list, you include the - default *serfHealth*. - - *lock_delay* is an integer of seconds. - - *behavior* can be set to either 'release' or 'delete'. This - controls the behavior when a session is invalidated. By default, - this is 'release', causing any locks that are held to be released. - Changing this to 'delete' causes any locks that are held to be - deleted. 'delete' is useful for creating ephemeral key/value - entries. - - when *ttl* is provided, the session is invalidated if it is not - renewed before the TTL expires. If specified, it is an integer of - seconds. Currently it must be between 10 and 86400 seconds. - - By default the session will be created in the current datacenter - but an optional *dc* can be provided. - - Returns the string *session_id* for the session. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - data = {} - if name: - data["name"] = name - if node: - data["node"] = node - if checks is not None: - data["checks"] = checks - if lock_delay != 15: - data["lockdelay"] = f"{lock_delay}s" - assert behavior in ("release", "delete"), "behavior must be release or delete" - if behavior != "release": - data["behavior"] = behavior - if ttl: - assert 10 <= ttl <= 86400 - data["ttl"] = f"{ttl}s" - data = json.dumps(data) if data else "" - - return self.agent.http.put(CB.json(is_id=True), "/v1/session/create", params=params, data=data) - - def destroy(self, session_id, dc=None): - """ - Destroys the session *session_id* - - Returns *True* on success. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - return self.agent.http.put(CB.bool(), f"/v1/session/destroy/{session_id}", params=params) - - def list(self, index=None, wait=None, consistency=None, dc=None): - """ - Returns a tuple of (*index*, *sessions*) of all active sessions in - the *dc* datacenter. *dc* defaults to the current datacenter of - this agent. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - - The response looks like this:: - - (index, [ - { - "LockDelay": 1.5e+10, - "Checks": [ - "serfHealth" - ], - "Node": "foobar", - "ID": "adf4238a-882b-9ddc-4a9d-5b6758e4159e", - "CreateIndex": 1086449 - }, - ... - ]) - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - return self.agent.http.get(CB.json(index=True), "/v1/session/list", params=params) - - def node(self, node, index=None, wait=None, consistency=None, dc=None): - """ - Returns a tuple of (*index*, *sessions*) as per *session.list*, but - filters the sessions returned to only those active for *node*. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - return self.agent.http.get(CB.json(index=True), f"/v1/session/node/{node}", params=params) - - def info(self, session_id, index=None, wait=None, consistency=None, dc=None): - """ - Returns a tuple of (*index*, *session*) for the session - *session_id* in the *dc* datacenter. *dc* defaults to the current - datacenter of this agent. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - return self.agent.http.get(CB.json(index=True, one=True), f"/v1/session/info/{session_id}", params=params) - - def renew(self, session_id, dc=None): - """ - This is used with sessions that have a TTL, and it extends the - expiration by the TTL. - - *dc* is the optional datacenter that you wish to communicate with. - If None is provided, defaults to the agent's datacenter. - - Returns the session. - """ - params = [] - dc = dc or self.agent.dc - if dc: - params.append(("dc", dc)) - return self.agent.http.put( - CB.json(one=True, allow_404=False), f"/v1/session/renew/{session_id}", params=params - ) - - class ACL: - def __init__(self, agent): - self.agent = agent - - def list(self, token=None): - """ - Lists all the active ACL tokens. This is a privileged endpoint, and - requires a management token. *token* will override this client's - default token. - Requires a token with acl:read capability. ACLPermissionDenied raised otherwise - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - return self.agent.http.get(CB.json(), "/v1/acl/tokens", params=params) - - def read(self, accessor_id, token=None): - """ - Returns the token information for *accessor_id*. Requires a token with acl:read capability. - :param accessor_id: The accessor ID of the token to read - :param token: token with acl:read capability - :return: selected token information - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - return self.agent.http.get(CB.json(), f"/v1/acl/token/{accessor_id}", params=params) - - def delete(self, accessor_id, token=None): - """ - Deletes the token with *accessor_id*. This is a privileged endpoint, and requires a token with acl:write. - :param accessor_id: The accessor ID of the token to delete - :param token: token with acl:write capability - :return: True if the token was deleted - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - return self.agent.http.delete(CB.bool(), f"/v1/acl/token/{accessor_id}", params=params) - - def clone(self, accessor_id, token=None, description=""): - """ - Clones the token identified by *accessor_id*. This is a privileged endpoint, and requires a token with acl:write. - :param accessor_id: The accessor ID of the token to clone - :param token: token with acl:write capability - :param description: Optional new token description - :return: The cloned token information - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - json_data = {"Description": description} - return self.agent.http.put( - CB.json(), - f"/v1/acl/token/{accessor_id}/clone", - params=params, - data=json.dumps(json_data), - ) - - def create(self, token=None, accessor_id=None, secret_id=None, description=""): - """ - Create a token (optionally identified by *secret_id* and *accessor_id*). - This is a privileged endpoint, and requires a token with acl:write. - :param token: token with acl:write capability - :param accessor_id: The accessor ID of the token to create - :param secret_id: The secret ID of the token to create - :param description: Optional new token description - :return: The cloned token information - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - json_data = {} - if accessor_id: - json_data["AccessorID"] = accessor_id - if secret_id: - json_data["SecretID"] = secret_id - if description: - json_data["Description"] = description - return self.agent.http.put( - CB.json(), - "/v1/acl/token", - params=params, - data=json.dumps(json_data), - ) - - def update(self, accessor_id, token=None, secret_id=None, description=""): - """ - Update a token (optionally identified by *secret_id* and *accessor_id*). - This is a privileged endpoint, and requires a token with acl:write. - :param accessor_id: The accessor ID of the token to update - :param token: token with acl:write capability - :param secret_id: Optional secret ID of the token to update - :param description: Optional new token description - :return: The updated token information - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - json_data = {"AccessorID": accessor_id} - if secret_id: - json_data["SecretID"] = secret_id - if description: - json_data["Description"] = description - return self.agent.http.put( - CB.json(), - f"/v1/acl/token/{accessor_id}", - params=params, - data=json.dumps(json_data), - ) - - class Status: - """ - The Status endpoints are used to get information about the status - of the Consul cluster. - """ - - def __init__(self, agent): - self.agent = agent - - def leader(self): - """ - This endpoint is used to get the Raft leader for the datacenter - in which the agent is running. - """ - return self.agent.http.get(CB.json(), "/v1/status/leader") - - def peers(self): - """ - This endpoint retrieves the Raft peers for the datacenter in which - the the agent is running. - """ - return self.agent.http.get(CB.json(), "/v1/status/peers") - - class Query: - def __init__(self, agent): - self.agent = agent - - def list(self, dc=None, token=None): - """ - Lists all the active queries. This is a privileged endpoint, - therefore you will only be able to get the prepared queries - which the token supplied has read privileges to. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - - *token* is an optional `ACL token`_ to apply to this request. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - if dc: - params.append(("dc", dc)) - - return self.agent.http.get(CB.json(), "/v1/query", params=params) - - def _query_data( - self, - service=None, - name=None, - session=None, - token=None, - nearestn=None, - datacenters=None, - onlypassing=None, - tags=None, - ttl=None, - regexp=None, - ): - service_body = { - k: v - for k, v in { - "service": service, - "onlypassing": onlypassing, - "tags": tags, - "failover": { - k: v for k, v in {"nearestn": nearestn, "datacenters": datacenters}.items() if v is not None - }, - }.items() - if v is not None - } - - data = { - k: v - for k, v in { - "name": name, - "session": session, - "token": token or self.agent.token, - "dns": {"ttl": ttl} if ttl is not None else None, - "template": { - k: v for k, v in {"type": "name_prefix_match", "regexp": regexp}.items() if v is not None - }, - "service": service_body, - }.items() - if v is not None - } - return json.dumps(data) - - def create( - self, - service, - name=None, - dc=None, - session=None, - token=None, - nearestn=None, - datacenters=None, - onlypassing=None, - tags=None, - ttl=None, - regexp=None, - ): - """ - Creates a new query. This is a privileged endpoint, and - requires a management token for a certain query name.*token* will - override this client's default token. - - *service* is mandatory for new query. represent service name to - query. - - *name* is an optional name for this query. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - - *token* is an optional `ACL token`_ to apply to this request. - - *nearestn* if set to a value greater than zero, then the query will - be forwarded to up to NearestN other datacenters based on their - estimated network round trip time using Network Coordinates from - the WAN gossip pool. - - *datacenters* is a fixed list of remote datacenters to forward the - query to if there are no healthy nodes in the local datacenter. - - *onlypassing* controls the behavior of the query's health check - filtering. - - *tags* is a list of service tags to filter the query results. - - *ttl* is a duration string that can use "s" as a suffix for - seconds. It controls how the TTL is set when query results are - served over DNS. - - *regexp* is optional for template this option is only supported - in Consul 0.6.4 or later. The only option for type is - name_prefix_match so if you want a query template with no regexp - enter an empty string. - - For more information about query - https://www.consul.io/docs/agent/http/query.html - """ - path = "/v1/query" - params = None if dc is None else [("dc", dc)] - data = self._query_data( - service, name, session, token, nearestn, datacenters, onlypassing, tags, ttl, regexp - ) - return self.agent.http.post(CB.json(), path, params=params, data=data) - - def update( - self, - query_id, - service=None, - name=None, - dc=None, - session=None, - token=None, - nearestn=None, - datacenters=None, - onlypassing=None, - tags=None, - ttl=None, - regexp=None, - ): - """ - This endpoint will update a certain query - - *query_id* is the query id for update - - all the other setting remains the same as the query create method - """ - path = f"/v1/query/{query_id}" - params = None if dc is None else [("dc", dc)] - data = self._query_data( - service, name, session, token, nearestn, datacenters, onlypassing, tags, ttl, regexp - ) - return self.agent.http.put(CB.bool(), path, params=params, data=data) - - def get(self, query_id, token=None, dc=None): - """ - This endpoint will return information about a certain query - - *query_id* the query id to retrieve information about - - *token* is an optional `ACL token`_ to apply to this request. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - if dc: - params.append(("dc", dc)) - return self.agent.http.get(CB.json(), f"/v1/query/{query_id}", params=params) - - def delete(self, query_id, token=None, dc=None): - """ - This endpoint will delete certain query - - *query_id* the query id delete - - *token* is an optional `ACL token`_ to apply to this request. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - if dc: - params.append(("dc", dc)) - return self.agent.http.delete(CB.bool(), f"/v1/query/{query_id}", params=params) - - def execute(self, query, token=None, dc=None, near=None, limit=None): - """ - This endpoint will execute certain query - - *query* name or query id to execute - - *token* is an optional `ACL token`_ to apply to this request. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - - *near* is a node name to sort the resulting list in ascending - order based on the estimated round trip time from that node - - *limit* is used to limit the size of the list to the given number - of nodes. This is applied after any sorting or shuffling. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - if dc: - params.append(("dc", dc)) - if near: - params.append(("near", near)) - if limit: - params.append(("limit", limit)) - return self.agent.http.get(CB.json(), f"/v1/query/{query}/execute", params=params) - - def explain(self, query, token=None, dc=None): - """ - This endpoint shows a fully-rendered query for a given name - - *query* name to explain. This cannot be query id. - - *token* is an optional `ACL token`_ to apply to this request. - - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - """ - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - if dc: - params.append(("dc", dc)) - return self.agent.http.get(CB.json(), f"/v1/query/{query}/explain", params=params) - - class Coordinate: - def __init__(self, agent): - self.agent = agent - - def datacenters(self): - """ - Returns the WAN network coordinates for all Consul servers, - organized by DCs. - """ - return self.agent.http.get(CB.json(), "/v1/coordinate/datacenters") - - def nodes(self, dc=None, index=None, wait=None, consistency=None): - """ - *dc* is the datacenter that this agent will communicate with. By - default the datacenter of the host is used. - - *index* is the current Consul index, suitable for making subsequent - calls to wait for changes since this query was last run. - - *wait* the maximum duration to wait (e.g. '10s') to retrieve - a given index. this parameter is only applied if *index* is also - specified. the wait time by default is 5 minutes. - - *consistency* can be either 'default', 'consistent' or 'stale'. if - not specified *consistency* will the consistency level this client - was configured with. - """ - params = [] - if dc: - params.append(("dc", dc)) - if index: - params.append(("index", index)) - if wait: - params.append(("wait", wait)) - consistency = consistency or self.agent.consistency - if consistency in ("consistent", "stale"): - params.append((consistency, "1")) - return self.agent.http.get(CB.json(index=True), "/v1/coordinate/nodes", params=params) - - class Operator: - def __init__(self, agent): - self.agent = agent - - def raft_config(self): - """ - Returns raft configuration. - """ - return self.agent.http.get(CB.json(), "/v1/operator/raft/configuration") - - class Connect: - def __init__(self, agent): - self.agent = agent - self.ca = Consul.Connect.CA(agent) - - class CA: - def __init__(self, agent): - self.agent = agent - - def roots(self, pem=False, token=None): - params = [] - params.append(("pem", int(pem))) - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.get(CB.json(), "/v1/connect/ca/roots", params=params) - - def configuration(self, token=None): - params = [] - token = token or self.agent.token - if token: - params.append(("token", token)) - - return self.agent.http.get(CB.json(), "/v1/connect/ca/configuration", params=params) diff --git a/consul/callback.py b/consul/callback.py new file mode 100644 index 0000000..64c85c0 --- /dev/null +++ b/consul/callback.py @@ -0,0 +1,79 @@ +import base64 +import json + +from consul.exceptions import ACLDisabled, ACLPermissionDenied, BadRequest, ClientError, ConsulException, NotFound + +# +# Conveniences to create consistent callback handlers for endpoints + + +class CB: + @classmethod + def _status(cls, response, allow_404=True): + # status checking + if 400 <= response.code < 500: + if response.code == 400: + raise BadRequest(f"{response.code} {response.body}") + if response.code == 401: + raise ACLDisabled(response.body) + if response.code == 403: + raise ACLPermissionDenied(response.body) + if response.code == 404: + if not allow_404: + raise NotFound(response.body) + else: + raise ClientError(f"{response.code} {response.body}") + elif 500 <= response.code < 600: + raise ConsulException(f"{response.code} {response.body}") + + @classmethod + def bool(cls): + # returns True on successful response + def cb(response): + CB._status(response) + return response.code == 200 + + return cb + + @classmethod + def json(cls, postprocess=None, allow_404=True, one=False, decode=False, is_id=False, index=False): + """ + *postprocess* is a function to apply to the final result. + + *allow_404* if set, None will be returned on 404, instead of raising + NotFound. + + *index* if set, a tuple of index, data will be returned. + + *one* returns only the first item of the list of items. empty lists are + coerced to None. + + *decode* if specified this key will be base64 decoded. + + *is_id* only the 'ID' field of the json object will be returned. + """ + + def cb(response): + CB._status(response, allow_404=allow_404) + if response.code == 404: + data = None + else: + data = json.loads(response.body) + if decode: + for item in data: + if item.get(decode) is not None: + item[decode] = base64.b64decode(item[decode]) + if is_id: + data = data["ID"] + if one: + if data == []: + data = None + if data is not None: + data = data[0] + if postprocess: + data = postprocess(data) + if index: + return response.headers["X-Consul-Index"], data + return data + + return cb diff --git a/consul/check.py b/consul/check.py new file mode 100644 index 0000000..eb8c519 --- /dev/null +++ b/consul/check.py @@ -0,0 +1,111 @@ +import logging +import warnings + +log = logging.getLogger(__name__) + + +class Check: + """ + There are three different kinds of checks: script, http and ttl + """ + + @classmethod + def script(cls, args, interval, deregister=None): + """ + Run the script *args* every *interval* (e.g. "10s") to peform health + check + """ + if isinstance(args, (str, bytes)): + warnings.warn("Check.script should take a list of args", DeprecationWarning) + args = ["sh", "-c", args] + ret = {"args": args, "interval": interval} + if deregister: + ret["DeregisterCriticalServiceAfter"] = deregister + return ret + + @classmethod + def http(cls, url, interval, timeout=None, deregister=None, header=None): + """ + Peform a HTTP GET against *url* every *interval* (e.g. "10s") to peform + health check with an optional *timeout* and optional *deregister* after + which a failing service will be automatically deregistered. Optional + parameter *header* specifies headers sent in HTTP request. *header* + paramater is in form of map of lists of strings, + e.g. {"x-foo": ["bar", "baz"]}. + """ + ret = {"http": url, "interval": interval} + if timeout: + ret["timeout"] = timeout + if deregister: + ret["DeregisterCriticalServiceAfter"] = deregister + if header: + ret["header"] = header + return ret + + @classmethod + def tcp(cls, host, port, interval, timeout=None, deregister=None): + """ + Attempt to establish a tcp connection to the specified *host* and + *port* at a specified *interval* with optional *timeout* and optional + *deregister* after which a failing service will be automatically + deregistered. + """ + ret = {"tcp": f"{host:s}:{port:d}", "interval": interval} + if timeout: + ret["timeout"] = timeout + if deregister: + ret["DeregisterCriticalServiceAfter"] = deregister + return ret + + @classmethod + def ttl(cls, ttl): + """ + Set check to be marked as critical after *ttl* (e.g. "10s") unless the + check is periodically marked as passing. + """ + return {"ttl": ttl} + + @classmethod + def docker(cls, container_id, shell, script, interval, deregister=None): + """ + Invoke *script* packaged within a running docker container with + *container_id* at a specified *interval* on the configured + *shell* using the Docker Exec API. Optional *register* after which a + failing service will be automatically deregistered. + """ + ret = {"docker_container_id": container_id, "shell": shell, "script": script, "interval": interval} + if deregister: + ret["DeregisterCriticalServiceAfter"] = deregister + return ret + + @classmethod + def _compat(cls, script=None, interval=None, ttl=None, http=None, timeout=None, deregister=None): + if not script and not http and not ttl: + return {} + + log.warning("DEPRECATED: use consul.Check.script/http/ttl to specify check") + + ret = {"check": {}} + + if script: + assert interval + assert not ttl + assert not http + ret["check"] = {"script": script, "interval": interval} + if ttl: + assert not interval or script + assert not http + ret["check"] = {"ttl": ttl} + if http: + assert interval + assert not script + assert not ttl + ret["check"] = {"http": http, "interval": interval} + if timeout: + assert http + ret["check"]["timeout"] = timeout + + if deregister: + ret["check"]["DeregisterCriticalServiceAfter"] = deregister + + return ret diff --git a/consul/exceptions.py b/consul/exceptions.py new file mode 100644 index 0000000..cbc86aa --- /dev/null +++ b/consul/exceptions.py @@ -0,0 +1,26 @@ +class ConsulException(Exception): + pass + + +class ACLDisabled(ConsulException): + pass + + +class ACLPermissionDenied(ConsulException): + pass + + +class NotFound(ConsulException): + pass + + +class Timeout(ConsulException): + pass + + +class BadRequest(ConsulException): + pass + + +class ClientError(ConsulException): + """Encapsulates 4xx Http error code""" diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_acl.py b/tests/api/test_acl.py similarity index 100% rename from tests/test_acl.py rename to tests/api/test_acl.py diff --git a/tests/api/test_agent.py b/tests/api/test_agent.py new file mode 100644 index 0000000..45d7e30 --- /dev/null +++ b/tests/api/test_agent.py @@ -0,0 +1,259 @@ +import time + +import pytest +from packaging import version + +import consul.check +from tests.utils import should_skip + +Check = consul.check.Check + + +class TestAgent: + def test_agent_checks(self, consul_port): + consul_port, _consul_version = consul_port + c = consul.Consul(port=consul_port) + + def verify_and_dereg_check(check_id): + assert set(c.agent.checks().keys()) == {check_id} + assert c.agent.check.deregister(check_id) is True + assert set(c.agent.checks().keys()) == set() + + def verify_check_status(check_id, status, notes=None): + checks = c.agent.checks() + assert checks[check_id]["Status"] == status + if notes: + assert checks[check_id]["Output"] == notes + + # test setting notes on a check + c.agent.check.register("check", Check.ttl("1s"), notes="foo") + assert c.agent.checks()["check"]["Notes"] == "foo" + c.agent.check.deregister("check") + + assert set(c.agent.checks().keys()) == set() + assert c.agent.check.register("script_check", Check.script("/bin/true", 10)) is True + verify_and_dereg_check("script_check") + + assert c.agent.check.register("check name", Check.script("/bin/true", 10, "10m"), check_id="check_id") is True + verify_and_dereg_check("check_id") + + http_addr = f"http://127.0.0.1:{consul_port}" + assert c.agent.check.register("http_check", Check.http(http_addr, "10ms")) is True + time.sleep(1) + verify_check_status("http_check", "passing") + verify_and_dereg_check("http_check") + + assert c.agent.check.register("http_timeout_check", Check.http(http_addr, "100ms", timeout="2s")) is True + verify_and_dereg_check("http_timeout_check") + + assert c.agent.check.register("ttl_check", Check.ttl("100ms")) is True + + assert c.agent.check.ttl_warn("ttl_check") is True + verify_check_status("ttl_check", "warning") + assert c.agent.check.ttl_warn("ttl_check", notes="its not quite right") is True + verify_check_status("ttl_check", "warning", "its not quite right") + + assert c.agent.check.ttl_fail("ttl_check") is True + verify_check_status("ttl_check", "critical") + assert c.agent.check.ttl_fail("ttl_check", notes="something went boink!") is True + verify_check_status("ttl_check", "critical", notes="something went boink!") + + assert c.agent.check.ttl_pass("ttl_check") is True + verify_check_status("ttl_check", "passing") + assert c.agent.check.ttl_pass("ttl_check", notes="all hunky dory!") is True + verify_check_status("ttl_check", "passing", notes="all hunky dory!") + # wait for ttl to expire + time.sleep(120 / 1000.0) + verify_check_status("ttl_check", "critical") + verify_and_dereg_check("ttl_check") + + def test_service_multi_check(self, consul_port): + consul_port, _consul_version = consul_port + c = consul.Consul(port=consul_port) + http_addr = f"http://127.0.0.1:{consul_port}" + c.agent.service.register( + "foo1", + check=Check.http(http_addr, "10ms"), + extra_checks=[ + Check.http(http_addr, "20ms"), + Check.http(http_addr, "30ms"), + ], + ) + + time.sleep(200 / 1000.0) + + _index, nodes = c.health.service("foo1") + assert {check["ServiceID"] for node in nodes for check in node["Checks"]} == {"foo1", ""} + + assert {check["CheckID"] for node in nodes for check in node["Checks"]} == { + "service:foo1:1", + "service:foo1:2", + "service:foo1:3", + "serfHealth", + } + time.sleep(1) + + _index, checks = c.health.checks(service="foo1") + assert [check["CheckID"] for check in checks] == ["service:foo1:1", "service:foo1:2", "service:foo1:3"] + assert [check["Status"] for check in checks] == ["passing", "passing", "passing"] + + def test_service_dereg_issue_156(self, consul_port): + consul_port, _consul_version = consul_port + # https://github.com/cablehead/python-consul/issues/156 + service_name = "app#127.0.0.1#3000" + c = consul.Consul(port=consul_port) + c.agent.service.register(service_name) + + time.sleep(80 / 1000.0) + + _index, nodes = c.health.service(service_name) + assert [node["Service"]["ID"] for node in nodes] == [service_name] + + # Clean up tasks + assert c.agent.service.deregister(service_name) is True + + time.sleep(40 / 1000.0) + + _index, nodes = c.health.service(service_name) + assert [node["Service"]["ID"] for node in nodes] == [] + + def test_agent_checks_service_id(self, consul_obj): + c, _consul_version = consul_obj + c.agent.service.register("foo1") + + time.sleep(40 / 1000.0) + + _index, nodes = c.health.service("foo1") + assert [node["Service"]["ID"] for node in nodes] == ["foo1"] + + c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") + c.agent.check.register("foo2", Check.ttl("100ms"), service_id="foo1") + + time.sleep(40 / 1000.0) + + _index, nodes = c.health.service("foo1") + assert {check["ServiceID"] for node in nodes for check in node["Checks"]} == {"foo1", ""} + assert {check["CheckID"] for node in nodes for check in node["Checks"]} == {"foo", "foo2", "serfHealth"} + + # Clean up tasks + assert c.agent.check.deregister("foo") is True + assert c.agent.check.deregister("foo2") is True + + time.sleep(40 / 1000.0) + + assert c.agent.service.deregister("foo1") is True + + time.sleep(40 / 1000.0) + + def test_agent_register_check_no_service_id(self, consul_obj): + c, _consul_version = consul_obj + _index, nodes = c.health.service("foo1") + assert nodes == [] + + if should_skip(_consul_version, ">", "1.11.0"): + with pytest.raises(consul.std.base.ConsulException): + c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") + else: + assert not c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") + + time.sleep(40 / 1000.0) + + assert c.agent.checks() == {} + + # Cleanup tasks + c.agent.check.deregister("foo") + + time.sleep(40 / 1000.0) + + def test_agent_register_enable_tag_override(self, consul_obj): + c, _consul_version = consul_obj + _index, nodes = c.health.service("foo1") + assert nodes == [] + + c.agent.service.register("foo", enable_tag_override=True) + + assert c.agent.services()["foo"]["EnableTagOverride"] + # Cleanup tasks + c.agent.check.deregister("foo") + + def test_agent_service_maintenance(self, consul_obj): + c, _consul_version = consul_obj + + c.agent.service.register("foo", check=Check.ttl("100ms")) + + time.sleep(40 / 1000.0) + + c.agent.service.maintenance("foo", "true", "test") + + time.sleep(40 / 1000.0) + + checks_pre = c.agent.checks() + assert "_service_maintenance:foo" in checks_pre + assert checks_pre["_service_maintenance:foo"]["Notes"] == "test" + + c.agent.service.maintenance("foo", "false") + + time.sleep(40 / 1000.0) + + checks_post = c.agent.checks() + assert "_service_maintenance:foo" not in checks_post + + # Cleanup + c.agent.service.deregister("foo") + + time.sleep(40 / 1000.0) + + def test_agent_node_maintenance(self, consul_obj): + c, _consul_version = consul_obj + + c.agent.maintenance("true", "test") + + time.sleep(40 / 1000.0) + + checks_pre = c.agent.checks() + assert "_node_maintenance" in checks_pre + assert checks_pre["_node_maintenance"]["Notes"] == "test" + + c.agent.maintenance("false") + + time.sleep(40 / 1000.0) + + checks_post = c.agent.checks() + assert "_node_maintenance" not in checks_post + + def test_agent_members(self, consul_obj): + c, _consul_version = consul_obj + members = c.agent.members() + for x in members: + assert x["Status"] == 1 + assert x["Name"] is not None + assert x["Tags"] is not None + assert c.agent.self()["Member"] in members + + wan_members = c.agent.members(wan=True) + for x in wan_members: + assert "dc1" in x["Name"] + + def test_agent_self(self, consul_obj): + c, _consul_version = consul_obj + + EXPECTED = { + "v1": {"Member", "Stats", "Config", "Coord", "DebugConfig", "Meta"}, + "v2": {"Member", "xDS", "Stats", "Config", "Coord", "DebugConfig", "Meta"}, + } + expected = EXPECTED["v1"] + if version.parse(_consul_version) >= version.parse("1.13.8"): + expected = EXPECTED["v2"] + assert set(c.agent.self().keys()) == expected + + def test_agent_services(self, consul_obj): + c, _consul_version = consul_obj + assert c.agent.service.register("foo") is True + assert set(c.agent.services().keys()) == {"foo"} + assert c.agent.service.deregister("foo") is True + assert set(c.agent.services().keys()) == set() + + # test address param + assert c.agent.service.register("foo", address="10.10.10.1") is True + assert [v["Address"] for k, v in c.agent.services().items() if k == "foo"][0] == "10.10.10.1" + assert c.agent.service.deregister("foo") is True diff --git a/tests/api/test_catalog.py b/tests/api/test_catalog.py new file mode 100644 index 0000000..46645b4 --- /dev/null +++ b/tests/api/test_catalog.py @@ -0,0 +1,63 @@ +class TestCatalog: + pass + # def test_catalog(self, consul_obj): + # c, _consul_version = consul_obj + # + # # grab the node our server created, so we can ignore it + # _, nodes = c.catalog.nodes() + # assert len(nodes) == 1 + # current = nodes[0] + # + # # test catalog.datacenters + # assert c.catalog.datacenters() == ["dc1"] + # + # # test catalog.register + # pytest.raises(consul.ConsulException, c.catalog.register, "foo", "10.1.10.11", dc="dc2") + # + # assert c.catalog.register("n1", "10.1.10.11", service={"service": "s1"}, check={"name": "c1"}) is True + # assert c.catalog.register("n1", "10.1.10.11", service={"service": "s2"}) is True + # assert c.catalog.register("n2", "10.1.10.12", service={"service": "s1", "tags": ["master"]}) is True + # + # # test catalog.nodes + # pytest.raises(consul.ConsulException, c.catalog.nodes, dc="dc2") + # _, nodes = c.catalog.nodes() + # nodes.remove(current) + # assert [x["Node"] for x in nodes] == ["n1", "n2"] + # + # # test catalog.services + # pytest.raises(consul.ConsulException, c.catalog.services, dc="dc2") + # _, services = c.catalog.services() + # assert services == {"s1": ["master"], "s2": [], "consul": []} + # + # # test catalog.node + # pytest.raises(consul.ConsulException, c.catalog.node, "n1", dc="dc2") + # _, node = c.catalog.node("n1") + # assert set(node["Services"].keys()) == {"s1", "s2"} + # _, node = c.catalog.node("n3") + # assert node is None + # + # # test catalog.service + # pytest.raises(consul.ConsulException, c.catalog.service, "s1", dc="dc2") + # _, nodes = c.catalog.service("s1") + # assert {x["Node"] for x in nodes} == {"n1", "n2"} + # _, nodes = c.catalog.service("s1", tag="master") + # assert {x["Node"] for x in nodes} == {"n2"} + # + # # test catalog.deregister + # pytest.raises(consul.ConsulException, c.catalog.deregister, "n2", dc="dc2") + # assert c.catalog.deregister("n1", check_id="c1") is True + # assert c.catalog.deregister("n2", service_id="s1") is True + # # check the nodes weren't removed + # _, nodes = c.catalog.nodes() + # nodes.remove(current) + # assert [x["Node"] for x in nodes] == ["n1", "n2"] + # # check n2's s1 service was removed though + # _, nodes = c.catalog.service("s1") + # assert {x["Node"] for x in nodes} == {"n1"} + # + # # cleanup + # assert c.catalog.deregister("n1") is True + # assert c.catalog.deregister("n2") is True + # _, nodes = c.catalog.nodes() + # nodes.remove(current) + # assert [x["Node"] for x in nodes] == [] diff --git a/tests/api/test_coordinates.py b/tests/api/test_coordinates.py new file mode 100644 index 0000000..11f4c5d --- /dev/null +++ b/tests/api/test_coordinates.py @@ -0,0 +1,6 @@ +class TestCoordinates: + def test_coordinate(self, consul_obj): + c, _consul_version = consul_obj + c.coordinate.nodes() + c.coordinate.datacenters() + assert set(c.coordinate.datacenters()[0].keys()) == {"Datacenter", "Coordinates", "AreaID"} diff --git a/tests/api/test_event.py b/tests/api/test_event.py new file mode 100644 index 0000000..3badbb4 --- /dev/null +++ b/tests/api/test_event.py @@ -0,0 +1,19 @@ +class TestEvent: + def test_event(self, consul_obj): + c, _consul_version = consul_obj + + assert c.event.fire("fooname", "foobody") + _index, events = c.event.list() + assert [x["Name"] == "fooname" for x in events] + assert [x["Payload"] == "foobody" for x in events] + + def test_event_targeted(self, consul_obj): + c, _consul_version = consul_obj + + assert c.event.fire("fooname", "foobody") + _index, events = c.event.list(name="othername") + assert events == [] + + _index, events = c.event.list(name="fooname") + assert [x["Name"] == "fooname" for x in events] + assert [x["Payload"] == "foobody" for x in events] diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..13ea08f --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,150 @@ +import time + +import consul.check + +Check = consul.check.Check + + +class TestHealth: + def test_health_service(self, consul_obj): + c, _consul_version = consul_obj + + # check there are no nodes for the service 'foo' + _index, nodes = c.health.service("foo") + assert nodes == [] + + # register two nodes, one with a long ttl, the other shorter + c.agent.service.register("foo", service_id="foo:1", check=Check.ttl("10s"), tags=["tag:foo:1"]) + c.agent.service.register("foo", service_id="foo:2", check=Check.ttl("100ms")) + + time.sleep(40 / 1000.0) + + # check the nodes show for the /health/service endpoint + _index, nodes = c.health.service("foo") + assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] + + # but that they aren't passing their health check + _index, nodes = c.health.service("foo", passing=True) + assert nodes == [] + + # ping the two node's health check + c.agent.check.ttl_pass("service:foo:1") + c.agent.check.ttl_pass("service:foo:2") + + time.sleep(40 / 1000.0) + + # both nodes are now available + _index, nodes = c.health.service("foo", passing=True) + assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] + + # wait until the short ttl node fails + time.sleep(120 / 1000.0) + + # only one node available + _index, nodes = c.health.service("foo", passing=True) + assert [node["Service"]["ID"] for node in nodes] == ["foo:1"] + + # ping the failed node's health check + c.agent.check.ttl_pass("service:foo:2") + + time.sleep(40 / 1000.0) + + # check both nodes are available + _index, nodes = c.health.service("foo", passing=True) + assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] + + # check that tag works + _index, nodes = c.health.service("foo", tag="tag:foo:1") + assert [node["Service"]["ID"] for node in nodes] == ["foo:1"] + + # deregister the nodes + c.agent.service.deregister("foo:1") + c.agent.service.deregister("foo:2") + + time.sleep(40 / 1000.0) + + _index, nodes = c.health.service("foo") + assert nodes == [] + + def test_health_state(self, consul_obj): + c, _consul_version = consul_obj + + # The empty string is for the Serf Health Status check, which has an + # empty ServiceID + _index, nodes = c.health.state("any") + assert [node["ServiceID"] for node in nodes] == [""] + + # register two nodes, one with a long ttl, the other shorter + c.agent.service.register("foo", service_id="foo:1", check=Check.ttl("10s")) + c.agent.service.register("foo", service_id="foo:2", check=Check.ttl("100ms")) + + time.sleep(40 / 1000.0) + + # check the nodes show for the /health/state/any endpoint + _index, nodes = c.health.state("any") + assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} + + # but that they aren't passing their health check + _index, nodes = c.health.state("passing") + assert [node["ServiceID"] for node in nodes] != "foo" + + # ping the two node's health check + c.agent.check.ttl_pass("service:foo:1") + c.agent.check.ttl_pass("service:foo:2") + + time.sleep(40 / 1000.0) + + # both nodes are now available + _index, nodes = c.health.state("passing") + assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} + + # wait until the short ttl node fails + time.sleep(2200 / 1000.0) + + # only one node available + _index, nodes = c.health.state("passing") + assert {node["ServiceID"] for node in nodes} == {"", "foo:1"} + + # ping the failed node's health check + c.agent.check.ttl_pass("service:foo:2") + + time.sleep(40 / 1000.0) + + # check both nodes are available + _index, nodes = c.health.state("passing") + assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} + + # deregister the nodes + c.agent.service.deregister("foo:1") + c.agent.service.deregister("foo:2") + + time.sleep(40 / 1000.0) + + _index, nodes = c.health.state("any") + assert [node["ServiceID"] for node in nodes] == [""] + + def test_health_node(self, consul_obj): + c, _consul_version = consul_obj + # grab local node name + node = c.agent.self()["Config"]["NodeName"] + _index, checks = c.health.node(node) + assert node in [check["Node"] for check in checks] + + def test_health_checks(self, consul_obj): + c, _consul_version = consul_obj + + c.agent.service.register("foobar", service_id="foobar", check=Check.ttl("10s")) + + time.sleep(40 / 1000.00) + + _index, checks = c.health.checks("foobar") + + assert [check["ServiceID"] for check in checks] == ["foobar"] + assert [check["CheckID"] for check in checks] == ["service:foobar"] + + c.agent.service.deregister("foobar") + + time.sleep(40 / 1000.0) + + _index, checks = c.health.checks("foobar") + assert len(checks) == 0 diff --git a/tests/api/test_kv.py b/tests/api/test_kv.py new file mode 100644 index 0000000..695b682 --- /dev/null +++ b/tests/api/test_kv.py @@ -0,0 +1,141 @@ +import struct + +import pytest + +from consul import ConsulException + + +class TestConsul: + def test_kv(self, consul_obj): + c, _consul_version = consul_obj + _index, data = c.kv.get("foo") + assert data is None + assert c.kv.put("foo", "bar") is True + _index, data = c.kv.get("foo") + assert data["Value"] == b"bar" + + def test_kv_wait(self, consul_obj): + c, _consul_version = consul_obj + assert c.kv.put("foo", "bar") is True + index, _data = c.kv.get("foo") + check, _data = c.kv.get("foo", index=index, wait="20ms") + assert index == check + + def test_kv_encoding(self, consul_obj): + c, _consul_version = consul_obj + + # test binary + c.kv.put("foo", struct.pack("i", 1000)) + _index, data = c.kv.get("foo") + assert struct.unpack("i", data["Value"]) == (1000,) + + # test unicode + c.kv.put("foo", "bar") + _index, data = c.kv.get("foo") + assert data["Value"] == b"bar" + + # test empty-string comes back as `None` + c.kv.put("foo", "") + _index, data = c.kv.get("foo") + assert data["Value"] is None + + # test None + c.kv.put("foo", None) + _index, data = c.kv.get("foo") + assert data["Value"] is None + + # check unencoded values raises assert + pytest.raises(AssertionError, c.kv.put, "foo", {1: 2}) + + def test_kv_put_cas(self, consul_obj): + c, _consul_version = consul_obj + assert c.kv.put("foo", "bar", cas=50) is False + assert c.kv.put("foo", "bar", cas=0) is True + _index, data = c.kv.get("foo") + + assert c.kv.put("foo", "bar2", cas=data["ModifyIndex"] - 1) is False + assert c.kv.put("foo", "bar2", cas=data["ModifyIndex"]) is True + _index, data = c.kv.get("foo") + assert data["Value"] == b"bar2" + + def test_kv_put_flags(self, consul_obj): + c, _consul_version = consul_obj + c.kv.put("foo", "bar") + _index, data = c.kv.get("foo") + assert data["Flags"] == 0 + + assert c.kv.put("foo", "bar", flags=50) is True + _index, data = c.kv.get("foo") + assert data["Flags"] == 50 + + def test_kv_recurse(self, consul_obj): + c, _consul_version = consul_obj + _index, data = c.kv.get("foo/", recurse=True) + assert data is None + + c.kv.put("foo/", None) + _index, data = c.kv.get("foo/", recurse=True) + assert len(data) == 1 + + c.kv.put("foo/bar1", "1") + c.kv.put("foo/bar2", "2") + c.kv.put("foo/bar3", "3") + _index, data = c.kv.get("foo/", recurse=True) + assert [x["Key"] for x in data] == ["foo/", "foo/bar1", "foo/bar2", "foo/bar3"] + assert [x["Value"] for x in data] == [None, b"1", b"2", b"3"] + + def test_kv_delete(self, consul_obj): + c, _consul_version = consul_obj + c.kv.put("foo1", "1") + c.kv.put("foo2", "2") + c.kv.put("foo3", "3") + _index, data = c.kv.get("foo", recurse=True) + assert [x["Key"] for x in data] == ["foo1", "foo2", "foo3"] + + assert c.kv.delete("foo2") is True + _index, data = c.kv.get("foo", recurse=True) + assert [x["Key"] for x in data] == ["foo1", "foo3"] + assert c.kv.delete("foo", recurse=True) is True + _index, data = c.kv.get("foo", recurse=True) + assert data is None + + def test_kv_delete_cas(self, consul_obj): + c, _consul_version = consul_obj + + c.kv.put("foo", "bar") + index, data = c.kv.get("foo") + + assert c.kv.delete("foo", cas=data["ModifyIndex"] - 1) is False + assert c.kv.get("foo") == (index, data) + + assert c.kv.delete("foo", cas=data["ModifyIndex"]) is True + index, data = c.kv.get("foo") + assert data is None + + def test_kv_acquire_release(self, consul_obj): + c, _consul_version = consul_obj + + pytest.raises(ConsulException, c.kv.put, "foo", "bar", acquire="foo") + + s1 = c.session.create() + s2 = c.session.create() + + assert c.kv.put("foo", "1", acquire=s1) is True + assert c.kv.put("foo", "2", acquire=s2) is False + assert c.kv.put("foo", "1", acquire=s1) is True + assert c.kv.put("foo", "1", release="foo") is False + assert c.kv.put("foo", "2", release=s2) is False + assert c.kv.put("foo", "2", release=s1) is True + + c.session.destroy(s1) + c.session.destroy(s2) + + def test_kv_keys_only(self, consul_obj): + c, _consul_version = consul_obj + + assert c.kv.put("bar", "4") is True + assert c.kv.put("base/foo", "1") is True + assert c.kv.put("base/base/foo", "5") is True + + _index, data = c.kv.get("base/", keys=True, separator="/") + assert data == ["base/base/", "base/foo"] diff --git a/tests/api/test_operator.py b/tests/api/test_operator.py new file mode 100644 index 0000000..8fcca95 --- /dev/null +++ b/tests/api/test_operator.py @@ -0,0 +1,22 @@ +from packaging import version + + +class TestOperator: + def test_operator(self, consul_obj): + c, _consul_version = consul_obj + config = c.operator.raft_config() + + expected_index = 1 + if version.parse(_consul_version) >= version.parse("1.13.8"): + expected_index = 0 + + assert config["Index"] == expected_index + leader = False + voter = False + for server in config["Servers"]: + if server["Leader"]: + leader = True + if server["Voter"]: + voter = True + assert leader + assert voter diff --git a/tests/api/test_query.py b/tests/api/test_query.py new file mode 100644 index 0000000..2604c4d --- /dev/null +++ b/tests/api/test_query.py @@ -0,0 +1,30 @@ +class testQuery: + def test_query(self, consul_obj): + c, _consul_version = consul_obj + + # check that query list is empty + queries = c.query.list() + assert queries == [] + + # create a new named query + query_service = "foo" + query_name = "fooquery" + query = c.query.create(query_service, query_name) + + # assert response contains query ID + assert "ID" in query + assert query["ID"] is not None + assert str(query["ID"]) != "" + + # retrieve query using id and name + queries = c.query.get(query["ID"]) + assert queries != [] + assert len(queries) == 1 + assert queries[0]["Name"] == query_name + assert queries[0]["ID"] == query["ID"] + + # explain query + assert c.query.explain(query_name)["Query"] + + # delete query + assert c.query.delete(query["ID"]) diff --git a/tests/api/test_session.py b/tests/api/test_session.py new file mode 100644 index 0000000..b3c5482 --- /dev/null +++ b/tests/api/test_session.py @@ -0,0 +1,58 @@ +import pytest + +import consul + + +class TestSession: + def test_session(self, consul_obj): + c, _consul_version = consul_obj + + # session.create + pytest.raises(consul.ConsulException, c.session.create, node="n2") + pytest.raises(consul.ConsulException, c.session.create, dc="dc2") + session_id = c.session.create("my-session") + + # session.list + pytest.raises(consul.ConsulException, c.session.list, dc="dc2") + _, sessions = c.session.list() + assert [x["Name"] for x in sessions] == ["my-session"] + + # session.info + pytest.raises(consul.ConsulException, c.session.info, session_id, dc="dc2") + _index, session = c.session.info("1" * 36) + assert session is None + _index, session = c.session.info(session_id) + assert session["Name"] == "my-session" + + # session.node + node = session["Node"] + pytest.raises(consul.ConsulException, c.session.node, node, dc="dc2") + _, sessions = c.session.node(node) + assert [x["Name"] for x in sessions] == ["my-session"] + + # session.destroy + pytest.raises(consul.ConsulException, c.session.destroy, session_id, dc="dc2") + assert c.session.destroy(session_id) is True + _, sessions = c.session.list() + assert sessions == [] + + def test_session_delete_ttl_renew(self, consul_obj): + c, _consul_version = consul_obj + + s = c.session.create(behavior="delete", ttl=20) + + # attempt to renew an unknown session + pytest.raises(consul.NotFound, c.session.renew, "1" * 36) + + session = c.session.renew(s) + assert session["Behavior"] == "delete" + assert session["TTL"] == "20s" + + # trying out the behavior + assert c.kv.put("foo", "1", acquire=s) is True + _index, data = c.kv.get("foo") + assert data["Value"] == b"1" + + c.session.destroy(s) + _index, data = c.kv.get("foo") + assert data is None diff --git a/tests/api/test_status.py b/tests/api/test_status.py new file mode 100644 index 0000000..0952b37 --- /dev/null +++ b/tests/api/test_status.py @@ -0,0 +1,19 @@ +class TestStatus: + def test_status_leader(self, consul_obj): + c, _consul_version = consul_obj + + agent_self = c.agent.self() + leader = c.status.leader() + addr_port = agent_self["Stats"]["consul"]["leader_addr"] + + assert leader == addr_port, f"Leader value was {leader}, expected value was {addr_port}" + + def test_status_peers(self, consul_obj): + c, _consul_version = consul_obj + + agent_self = c.agent.self() + + addr_port = agent_self["Stats"]["consul"]["leader_addr"] + peers = c.status.peers() + + assert addr_port in peers, f"Expected value '{addr_port}' in peer list but it was not present" diff --git a/tests/api/test_txn.py b/tests/api/test_txn.py new file mode 100644 index 0000000..49c4d52 --- /dev/null +++ b/tests/api/test_txn.py @@ -0,0 +1,14 @@ +import base64 + + +class TestTxn: + def test_transaction(self, consul_obj): + c, _consul_version = consul_obj + value = base64.b64encode(b"1").decode("utf8") + d = {"KV": {"Verb": "set", "Key": "asdf", "Value": value}} + r = c.txn.put([d]) + assert r["Errors"] is None + + d = {"KV": {"Verb": "get", "Key": "asdf"}} + r = c.txn.put([d]) + assert r["Results"][0]["KV"]["Value"] == value diff --git a/tests/conftest.py b/tests/conftest.py index 114a49d..e48031b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,8 @@ import pytest import requests +from consul import Consul + collect_ignore = [] CONSUL_BINARIES = { @@ -136,3 +138,10 @@ def acl_consul(acl_consul_instance): ACLConsul = collections.namedtuple("ACLConsul", ["port", "token", "version"]) port, token, version = acl_consul_instance return ACLConsul(port, token, version) + + +@pytest.fixture() +def consul_obj(consul_port): + consul_port, consul_version = consul_port + c = Consul(port=consul_port) + return c, consul_version diff --git a/tests/test_aio.py b/tests/test_aio.py index 9e1d3a7..fd8c27e 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -7,8 +7,9 @@ import consul import consul.aio +import consul.check -Check = consul.Check +Check = consul.check.Check @pytest.fixture() diff --git a/tests/test_base.py b/tests/test_base.py index f6c75ad..079c63d 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -4,9 +4,7 @@ import pytest import consul - -CB = consul.base.CB -Response = consul.base.Response +import consul.check Request = collections.namedtuple("Request", ["method", "path", "params", "data"]) @@ -129,50 +127,6 @@ def test_meta(self): assert sorted(d["meta"]) == sorted({"env": "prod", "net": 1}) -class TestCB: - # pylint: disable=protected-access - def test_status_200_passes(self): - response = consul.base.Response(200, None, None) - CB._status(response) - - @pytest.mark.parametrize( - ("response", "expected_exception"), - [ - (Response(400, None, None), consul.base.BadRequest), - (Response(401, None, None), consul.base.ACLDisabled), - (Response(403, None, None), consul.base.ACLPermissionDenied), - ], - ) - def test_status_4xx_raises_error(self, response, expected_exception): - with pytest.raises(expected_exception): - CB._status(response) - - def test_status_404_allow_404(self): - response = Response(404, None, None) - CB._status(response, allow_404=True) - - def test_status_404_dont_allow_404(self): - response = Response(404, None, None) - with pytest.raises(consul.base.NotFound): - CB._status(response, allow_404=False) - - def test_status_405_raises_generic_ClientError(self): - response = Response(405, None, None) - with pytest.raises(consul.base.ClientError): - CB._status(response) - - @pytest.mark.parametrize( - "response", - [ - Response(500, None, None), - Response(599, None, None), - ], - ) - def test_status_5xx_raises_error(self, response): - with pytest.raises(consul.base.ConsulException): - CB._status(response) - - class TestChecks: """ Check constructor helpers return valid check configurations. @@ -246,7 +200,7 @@ class TestChecks: ], ) def test_http_check(self, url, interval, timeout, deregister, header, want): - ch = consul.base.Check.http(url, interval, timeout=timeout, deregister=deregister, header=header) + ch = consul.check.Check.http(url, interval, timeout=timeout, deregister=deregister, header=header) assert ch == want @pytest.mark.parametrize( @@ -303,7 +257,7 @@ def test_http_check(self, url, interval, timeout, deregister, header, want): ], ) def test_tcp_check(self, host, port, interval, timeout, deregister, want): - ch = consul.base.Check.tcp(host, port, interval, timeout=timeout, deregister=deregister) + ch = consul.check.Check.tcp(host, port, interval, timeout=timeout, deregister=deregister) assert ch == want @pytest.mark.parametrize( @@ -339,9 +293,9 @@ def test_tcp_check(self, host, port, interval, timeout, deregister, want): ], ) def test_docker_check(self, container_id, shell, script, interval, deregister, want): - ch = consul.base.Check.docker(container_id, shell, script, interval, deregister=deregister) + ch = consul.check.Check.docker(container_id, shell, script, interval, deregister=deregister) assert ch == want def test_ttl_check(self): - ch = consul.base.Check.ttl("1m") + ch = consul.check.Check.ttl("1m") assert ch == {"ttl": "1m"} diff --git a/tests/test_callback.py b/tests/test_callback.py new file mode 100644 index 0000000..0364165 --- /dev/null +++ b/tests/test_callback.py @@ -0,0 +1,50 @@ +import pytest + +import consul +from consul.base import Response +from consul.callback import CB +from consul.exceptions import ACLDisabled, ACLPermissionDenied, BadRequest, ClientError, NotFound + + +class TestCB: + # pylint: disable=protected-access + def test_status_200_passes(self): + response = consul.base.Response(200, None, None) + CB._status(response) + + @pytest.mark.parametrize( + ("response", "expected_exception"), + [ + (Response(400, None, None), BadRequest), + (Response(401, None, None), ACLDisabled), + (Response(403, None, None), ACLPermissionDenied), + ], + ) + def test_status_4xx_raises_error(self, response, expected_exception): + with pytest.raises(expected_exception): + CB._status(response) + + def test_status_404_allow_404(self): + response = Response(404, None, None) + CB._status(response, allow_404=True) + + def test_status_404_dont_allow_404(self): + response = Response(404, None, None) + with pytest.raises(NotFound): + CB._status(response, allow_404=False) + + def test_status_405_raises_generic_ClientError(self): + response = Response(405, None, None) + with pytest.raises(ClientError): + CB._status(response) + + @pytest.mark.parametrize( + "response", + [ + Response(500, None, None), + Response(599, None, None), + ], + ) + def test_status_5xx_raises_error(self, response): + with pytest.raises(consul.base.ConsulException): + CB._status(response) diff --git a/tests/test_std.py b/tests/test_std.py index 2bdc249..1aaeee3 100644 --- a/tests/test_std.py +++ b/tests/test_std.py @@ -1,15 +1,6 @@ -import base64 -import struct -import time - -import pytest -from packaging import version - import consul +import consul.check import consul.std -from tests.utils import should_skip - -Check = consul.Check class TestHTTPClient: @@ -17,756 +8,3 @@ def test_uri(self): http = consul.std.HTTPClient() assert http.uri("/v1/kv") == "http://127.0.0.1:8500/v1/kv" assert http.uri("/v1/kv", params={"index": 1}) == "http://127.0.0.1:8500/v1/kv?index=1" - - -@pytest.fixture() -def consul_obj(consul_port): - consul_port, consul_version = consul_port - c = consul.std.Consul(port=consul_port) - return c, consul_version - - -class TestConsul: - def test_kv(self, consul_obj): - c, _consul_version = consul_obj - _index, data = c.kv.get("foo") - assert data is None - assert c.kv.put("foo", "bar") is True - _index, data = c.kv.get("foo") - assert data["Value"] == b"bar" - - def test_kv_wait(self, consul_obj): - c, _consul_version = consul_obj - assert c.kv.put("foo", "bar") is True - index, _data = c.kv.get("foo") - check, _data = c.kv.get("foo", index=index, wait="20ms") - assert index == check - - def test_kv_encoding(self, consul_obj): - c, _consul_version = consul_obj - - # test binary - c.kv.put("foo", struct.pack("i", 1000)) - _index, data = c.kv.get("foo") - assert struct.unpack("i", data["Value"]) == (1000,) - - # test unicode - c.kv.put("foo", "bar") - _index, data = c.kv.get("foo") - assert data["Value"] == b"bar" - - # test empty-string comes back as `None` - c.kv.put("foo", "") - _index, data = c.kv.get("foo") - assert data["Value"] is None - - # test None - c.kv.put("foo", None) - _index, data = c.kv.get("foo") - assert data["Value"] is None - - # check unencoded values raises assert - pytest.raises(AssertionError, c.kv.put, "foo", {1: 2}) - - def test_kv_put_cas(self, consul_obj): - c, _consul_version = consul_obj - assert c.kv.put("foo", "bar", cas=50) is False - assert c.kv.put("foo", "bar", cas=0) is True - _index, data = c.kv.get("foo") - - assert c.kv.put("foo", "bar2", cas=data["ModifyIndex"] - 1) is False - assert c.kv.put("foo", "bar2", cas=data["ModifyIndex"]) is True - _index, data = c.kv.get("foo") - assert data["Value"] == b"bar2" - - def test_kv_put_flags(self, consul_obj): - c, _consul_version = consul_obj - c.kv.put("foo", "bar") - _index, data = c.kv.get("foo") - assert data["Flags"] == 0 - - assert c.kv.put("foo", "bar", flags=50) is True - _index, data = c.kv.get("foo") - assert data["Flags"] == 50 - - def test_kv_recurse(self, consul_obj): - c, _consul_version = consul_obj - _index, data = c.kv.get("foo/", recurse=True) - assert data is None - - c.kv.put("foo/", None) - _index, data = c.kv.get("foo/", recurse=True) - assert len(data) == 1 - - c.kv.put("foo/bar1", "1") - c.kv.put("foo/bar2", "2") - c.kv.put("foo/bar3", "3") - _index, data = c.kv.get("foo/", recurse=True) - assert [x["Key"] for x in data] == ["foo/", "foo/bar1", "foo/bar2", "foo/bar3"] - assert [x["Value"] for x in data] == [None, b"1", b"2", b"3"] - - def test_kv_delete(self, consul_obj): - c, _consul_version = consul_obj - c.kv.put("foo1", "1") - c.kv.put("foo2", "2") - c.kv.put("foo3", "3") - _index, data = c.kv.get("foo", recurse=True) - assert [x["Key"] for x in data] == ["foo1", "foo2", "foo3"] - - assert c.kv.delete("foo2") is True - _index, data = c.kv.get("foo", recurse=True) - assert [x["Key"] for x in data] == ["foo1", "foo3"] - assert c.kv.delete("foo", recurse=True) is True - _index, data = c.kv.get("foo", recurse=True) - assert data is None - - def test_kv_delete_cas(self, consul_obj): - c, _consul_version = consul_obj - - c.kv.put("foo", "bar") - index, data = c.kv.get("foo") - - assert c.kv.delete("foo", cas=data["ModifyIndex"] - 1) is False - assert c.kv.get("foo") == (index, data) - - assert c.kv.delete("foo", cas=data["ModifyIndex"]) is True - index, data = c.kv.get("foo") - assert data is None - - def test_kv_acquire_release(self, consul_obj): - c, _consul_version = consul_obj - - pytest.raises(consul.ConsulException, c.kv.put, "foo", "bar", acquire="foo") - - s1 = c.session.create() - s2 = c.session.create() - - assert c.kv.put("foo", "1", acquire=s1) is True - assert c.kv.put("foo", "2", acquire=s2) is False - assert c.kv.put("foo", "1", acquire=s1) is True - assert c.kv.put("foo", "1", release="foo") is False - assert c.kv.put("foo", "2", release=s2) is False - assert c.kv.put("foo", "2", release=s1) is True - - c.session.destroy(s1) - c.session.destroy(s2) - - def test_kv_keys_only(self, consul_obj): - c, _consul_version = consul_obj - - assert c.kv.put("bar", "4") is True - assert c.kv.put("base/foo", "1") is True - assert c.kv.put("base/base/foo", "5") is True - - _index, data = c.kv.get("base/", keys=True, separator="/") - assert data == ["base/base/", "base/foo"] - - def test_transaction(self, consul_obj): - c, _consul_version = consul_obj - value = base64.b64encode(b"1").decode("utf8") - d = {"KV": {"Verb": "set", "Key": "asdf", "Value": value}} - r = c.txn.put([d]) - assert r["Errors"] is None - - d = {"KV": {"Verb": "get", "Key": "asdf"}} - r = c.txn.put([d]) - assert r["Results"][0]["KV"]["Value"] == value - - def test_event(self, consul_obj): - c, _consul_version = consul_obj - - assert c.event.fire("fooname", "foobody") - _index, events = c.event.list() - assert [x["Name"] == "fooname" for x in events] - assert [x["Payload"] == "foobody" for x in events] - - def test_event_targeted(self, consul_obj): - c, _consul_version = consul_obj - - assert c.event.fire("fooname", "foobody") - _index, events = c.event.list(name="othername") - assert events == [] - - _index, events = c.event.list(name="fooname") - assert [x["Name"] == "fooname" for x in events] - assert [x["Payload"] == "foobody" for x in events] - - def test_agent_checks(self, consul_port): - consul_port, _consul_version = consul_port - c = consul.Consul(port=consul_port) - - def verify_and_dereg_check(check_id): - assert set(c.agent.checks().keys()) == {check_id} - assert c.agent.check.deregister(check_id) is True - assert set(c.agent.checks().keys()) == set() - - def verify_check_status(check_id, status, notes=None): - checks = c.agent.checks() - assert checks[check_id]["Status"] == status - if notes: - assert checks[check_id]["Output"] == notes - - # test setting notes on a check - c.agent.check.register("check", Check.ttl("1s"), notes="foo") - assert c.agent.checks()["check"]["Notes"] == "foo" - c.agent.check.deregister("check") - - assert set(c.agent.checks().keys()) == set() - assert c.agent.check.register("script_check", Check.script("/bin/true", 10)) is True - verify_and_dereg_check("script_check") - - assert c.agent.check.register("check name", Check.script("/bin/true", 10, "10m"), check_id="check_id") is True - verify_and_dereg_check("check_id") - - http_addr = f"http://127.0.0.1:{consul_port}" - assert c.agent.check.register("http_check", Check.http(http_addr, "10ms")) is True - time.sleep(1) - verify_check_status("http_check", "passing") - verify_and_dereg_check("http_check") - - assert c.agent.check.register("http_timeout_check", Check.http(http_addr, "100ms", timeout="2s")) is True - verify_and_dereg_check("http_timeout_check") - - assert c.agent.check.register("ttl_check", Check.ttl("100ms")) is True - - assert c.agent.check.ttl_warn("ttl_check") is True - verify_check_status("ttl_check", "warning") - assert c.agent.check.ttl_warn("ttl_check", notes="its not quite right") is True - verify_check_status("ttl_check", "warning", "its not quite right") - - assert c.agent.check.ttl_fail("ttl_check") is True - verify_check_status("ttl_check", "critical") - assert c.agent.check.ttl_fail("ttl_check", notes="something went boink!") is True - verify_check_status("ttl_check", "critical", notes="something went boink!") - - assert c.agent.check.ttl_pass("ttl_check") is True - verify_check_status("ttl_check", "passing") - assert c.agent.check.ttl_pass("ttl_check", notes="all hunky dory!") is True - verify_check_status("ttl_check", "passing", notes="all hunky dory!") - # wait for ttl to expire - time.sleep(120 / 1000.0) - verify_check_status("ttl_check", "critical") - verify_and_dereg_check("ttl_check") - - def test_service_multi_check(self, consul_port): - consul_port, _consul_version = consul_port - c = consul.Consul(port=consul_port) - http_addr = f"http://127.0.0.1:{consul_port}" - c.agent.service.register( - "foo1", - check=Check.http(http_addr, "10ms"), - extra_checks=[ - Check.http(http_addr, "20ms"), - Check.http(http_addr, "30ms"), - ], - ) - - time.sleep(200 / 1000.0) - - _index, nodes = c.health.service("foo1") - assert {check["ServiceID"] for node in nodes for check in node["Checks"]} == {"foo1", ""} - - assert {check["CheckID"] for node in nodes for check in node["Checks"]} == { - "service:foo1:1", - "service:foo1:2", - "service:foo1:3", - "serfHealth", - } - time.sleep(1) - - _index, checks = c.health.checks(service="foo1") - assert [check["CheckID"] for check in checks] == ["service:foo1:1", "service:foo1:2", "service:foo1:3"] - assert [check["Status"] for check in checks] == ["passing", "passing", "passing"] - - def test_service_dereg_issue_156(self, consul_port): - consul_port, _consul_version = consul_port - # https://github.com/cablehead/python-consul/issues/156 - service_name = "app#127.0.0.1#3000" - c = consul.Consul(port=consul_port) - c.agent.service.register(service_name) - - time.sleep(80 / 1000.0) - - _index, nodes = c.health.service(service_name) - assert [node["Service"]["ID"] for node in nodes] == [service_name] - - # Clean up tasks - assert c.agent.service.deregister(service_name) is True - - time.sleep(40 / 1000.0) - - _index, nodes = c.health.service(service_name) - assert [node["Service"]["ID"] for node in nodes] == [] - - def test_agent_checks_service_id(self, consul_obj): - c, _consul_version = consul_obj - c.agent.service.register("foo1") - - time.sleep(40 / 1000.0) - - _index, nodes = c.health.service("foo1") - assert [node["Service"]["ID"] for node in nodes] == ["foo1"] - - c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") - c.agent.check.register("foo2", Check.ttl("100ms"), service_id="foo1") - - time.sleep(40 / 1000.0) - - _index, nodes = c.health.service("foo1") - assert {check["ServiceID"] for node in nodes for check in node["Checks"]} == {"foo1", ""} - assert {check["CheckID"] for node in nodes for check in node["Checks"]} == {"foo", "foo2", "serfHealth"} - - # Clean up tasks - assert c.agent.check.deregister("foo") is True - assert c.agent.check.deregister("foo2") is True - - time.sleep(40 / 1000.0) - - assert c.agent.service.deregister("foo1") is True - - time.sleep(40 / 1000.0) - - def test_agent_register_check_no_service_id(self, consul_obj): - c, _consul_version = consul_obj - _index, nodes = c.health.service("foo1") - assert nodes == [] - - if should_skip(_consul_version, ">", "1.11.0"): - with pytest.raises(consul.std.base.ConsulException): - c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") - else: - assert not c.agent.check.register("foo", Check.ttl("100ms"), service_id="foo1") - - time.sleep(40 / 1000.0) - - assert c.agent.checks() == {} - - # Cleanup tasks - c.agent.check.deregister("foo") - - time.sleep(40 / 1000.0) - - def test_agent_register_enable_tag_override(self, consul_obj): - c, _consul_version = consul_obj - _index, nodes = c.health.service("foo1") - assert nodes == [] - - c.agent.service.register("foo", enable_tag_override=True) - - assert c.agent.services()["foo"]["EnableTagOverride"] - # Cleanup tasks - c.agent.check.deregister("foo") - - def test_agent_service_maintenance(self, consul_obj): - c, _consul_version = consul_obj - - c.agent.service.register("foo", check=Check.ttl("100ms")) - - time.sleep(40 / 1000.0) - - c.agent.service.maintenance("foo", "true", "test") - - time.sleep(40 / 1000.0) - - checks_pre = c.agent.checks() - assert "_service_maintenance:foo" in checks_pre - assert checks_pre["_service_maintenance:foo"]["Notes"] == "test" - - c.agent.service.maintenance("foo", "false") - - time.sleep(40 / 1000.0) - - checks_post = c.agent.checks() - assert "_service_maintenance:foo" not in checks_post - - # Cleanup - c.agent.service.deregister("foo") - - time.sleep(40 / 1000.0) - - def test_agent_node_maintenance(self, consul_obj): - c, _consul_version = consul_obj - - c.agent.maintenance("true", "test") - - time.sleep(40 / 1000.0) - - checks_pre = c.agent.checks() - assert "_node_maintenance" in checks_pre - assert checks_pre["_node_maintenance"]["Notes"] == "test" - - c.agent.maintenance("false") - - time.sleep(40 / 1000.0) - - checks_post = c.agent.checks() - assert "_node_maintenance" not in checks_post - - def test_agent_members(self, consul_obj): - c, _consul_version = consul_obj - members = c.agent.members() - for x in members: - assert x["Status"] == 1 - assert x["Name"] is not None - assert x["Tags"] is not None - assert c.agent.self()["Member"] in members - - wan_members = c.agent.members(wan=True) - for x in wan_members: - assert "dc1" in x["Name"] - - def test_agent_self(self, consul_obj): - c, _consul_version = consul_obj - - EXPECTED = { - "v1": {"Member", "Stats", "Config", "Coord", "DebugConfig", "Meta"}, - "v2": {"Member", "xDS", "Stats", "Config", "Coord", "DebugConfig", "Meta"}, - } - expected = EXPECTED["v1"] - if version.parse(_consul_version) >= version.parse("1.13.8"): - expected = EXPECTED["v2"] - assert set(c.agent.self().keys()) == expected - - def test_agent_services(self, consul_obj): - c, _consul_version = consul_obj - assert c.agent.service.register("foo") is True - assert set(c.agent.services().keys()) == {"foo"} - assert c.agent.service.deregister("foo") is True - assert set(c.agent.services().keys()) == set() - - # test address param - assert c.agent.service.register("foo", address="10.10.10.1") is True - assert [v["Address"] for k, v in c.agent.services().items() if k == "foo"][0] == "10.10.10.1" - assert c.agent.service.deregister("foo") is True - - # def test_catalog(self, consul_obj): - # c, _consul_version = consul_obj - # - # # grab the node our server created, so we can ignore it - # _, nodes = c.catalog.nodes() - # assert len(nodes) == 1 - # current = nodes[0] - # - # # test catalog.datacenters - # assert c.catalog.datacenters() == ["dc1"] - # - # # test catalog.register - # pytest.raises(consul.ConsulException, c.catalog.register, "foo", "10.1.10.11", dc="dc2") - # - # assert c.catalog.register("n1", "10.1.10.11", service={"service": "s1"}, check={"name": "c1"}) is True - # assert c.catalog.register("n1", "10.1.10.11", service={"service": "s2"}) is True - # assert c.catalog.register("n2", "10.1.10.12", service={"service": "s1", "tags": ["master"]}) is True - # - # # test catalog.nodes - # pytest.raises(consul.ConsulException, c.catalog.nodes, dc="dc2") - # _, nodes = c.catalog.nodes() - # nodes.remove(current) - # assert [x["Node"] for x in nodes] == ["n1", "n2"] - # - # # test catalog.services - # pytest.raises(consul.ConsulException, c.catalog.services, dc="dc2") - # _, services = c.catalog.services() - # assert services == {"s1": ["master"], "s2": [], "consul": []} - # - # # test catalog.node - # pytest.raises(consul.ConsulException, c.catalog.node, "n1", dc="dc2") - # _, node = c.catalog.node("n1") - # assert set(node["Services"].keys()) == {"s1", "s2"} - # _, node = c.catalog.node("n3") - # assert node is None - # - # # test catalog.service - # pytest.raises(consul.ConsulException, c.catalog.service, "s1", dc="dc2") - # _, nodes = c.catalog.service("s1") - # assert {x["Node"] for x in nodes} == {"n1", "n2"} - # _, nodes = c.catalog.service("s1", tag="master") - # assert {x["Node"] for x in nodes} == {"n2"} - # - # # test catalog.deregister - # pytest.raises(consul.ConsulException, c.catalog.deregister, "n2", dc="dc2") - # assert c.catalog.deregister("n1", check_id="c1") is True - # assert c.catalog.deregister("n2", service_id="s1") is True - # # check the nodes weren't removed - # _, nodes = c.catalog.nodes() - # nodes.remove(current) - # assert [x["Node"] for x in nodes] == ["n1", "n2"] - # # check n2's s1 service was removed though - # _, nodes = c.catalog.service("s1") - # assert {x["Node"] for x in nodes} == {"n1"} - # - # # cleanup - # assert c.catalog.deregister("n1") is True - # assert c.catalog.deregister("n2") is True - # _, nodes = c.catalog.nodes() - # nodes.remove(current) - # assert [x["Node"] for x in nodes] == [] - - def test_health_service(self, consul_obj): - c, _consul_version = consul_obj - - # check there are no nodes for the service 'foo' - _index, nodes = c.health.service("foo") - assert nodes == [] - - # register two nodes, one with a long ttl, the other shorter - c.agent.service.register("foo", service_id="foo:1", check=Check.ttl("10s"), tags=["tag:foo:1"]) - c.agent.service.register("foo", service_id="foo:2", check=Check.ttl("100ms")) - - time.sleep(40 / 1000.0) - - # check the nodes show for the /health/service endpoint - _index, nodes = c.health.service("foo") - assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] - - # but that they aren't passing their health check - _index, nodes = c.health.service("foo", passing=True) - assert nodes == [] - - # ping the two node's health check - c.agent.check.ttl_pass("service:foo:1") - c.agent.check.ttl_pass("service:foo:2") - - time.sleep(40 / 1000.0) - - # both nodes are now available - _index, nodes = c.health.service("foo", passing=True) - assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] - - # wait until the short ttl node fails - time.sleep(120 / 1000.0) - - # only one node available - _index, nodes = c.health.service("foo", passing=True) - assert [node["Service"]["ID"] for node in nodes] == ["foo:1"] - - # ping the failed node's health check - c.agent.check.ttl_pass("service:foo:2") - - time.sleep(40 / 1000.0) - - # check both nodes are available - _index, nodes = c.health.service("foo", passing=True) - assert [node["Service"]["ID"] for node in nodes] == ["foo:1", "foo:2"] - - # check that tag works - _index, nodes = c.health.service("foo", tag="tag:foo:1") - assert [node["Service"]["ID"] for node in nodes] == ["foo:1"] - - # deregister the nodes - c.agent.service.deregister("foo:1") - c.agent.service.deregister("foo:2") - - time.sleep(40 / 1000.0) - - _index, nodes = c.health.service("foo") - assert nodes == [] - - def test_health_state(self, consul_obj): - c, _consul_version = consul_obj - - # The empty string is for the Serf Health Status check, which has an - # empty ServiceID - _index, nodes = c.health.state("any") - assert [node["ServiceID"] for node in nodes] == [""] - - # register two nodes, one with a long ttl, the other shorter - c.agent.service.register("foo", service_id="foo:1", check=Check.ttl("10s")) - c.agent.service.register("foo", service_id="foo:2", check=Check.ttl("100ms")) - - time.sleep(40 / 1000.0) - - # check the nodes show for the /health/state/any endpoint - _index, nodes = c.health.state("any") - assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} - - # but that they aren't passing their health check - _index, nodes = c.health.state("passing") - assert [node["ServiceID"] for node in nodes] != "foo" - - # ping the two node's health check - c.agent.check.ttl_pass("service:foo:1") - c.agent.check.ttl_pass("service:foo:2") - - time.sleep(40 / 1000.0) - - # both nodes are now available - _index, nodes = c.health.state("passing") - assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} - - # wait until the short ttl node fails - time.sleep(2200 / 1000.0) - - # only one node available - _index, nodes = c.health.state("passing") - assert {node["ServiceID"] for node in nodes} == {"", "foo:1"} - - # ping the failed node's health check - c.agent.check.ttl_pass("service:foo:2") - - time.sleep(40 / 1000.0) - - # check both nodes are available - _index, nodes = c.health.state("passing") - assert {node["ServiceID"] for node in nodes} == {"", "foo:1", "foo:2"} - - # deregister the nodes - c.agent.service.deregister("foo:1") - c.agent.service.deregister("foo:2") - - time.sleep(40 / 1000.0) - - _index, nodes = c.health.state("any") - assert [node["ServiceID"] for node in nodes] == [""] - - def test_health_node(self, consul_obj): - c, _consul_version = consul_obj - # grab local node name - node = c.agent.self()["Config"]["NodeName"] - _index, checks = c.health.node(node) - assert node in [check["Node"] for check in checks] - - def test_health_checks(self, consul_obj): - c, _consul_version = consul_obj - - c.agent.service.register("foobar", service_id="foobar", check=Check.ttl("10s")) - - time.sleep(40 / 1000.00) - - _index, checks = c.health.checks("foobar") - - assert [check["ServiceID"] for check in checks] == ["foobar"] - assert [check["CheckID"] for check in checks] == ["service:foobar"] - - c.agent.service.deregister("foobar") - - time.sleep(40 / 1000.0) - - _index, checks = c.health.checks("foobar") - assert len(checks) == 0 - - def test_session(self, consul_obj): - c, _consul_version = consul_obj - - # session.create - pytest.raises(consul.ConsulException, c.session.create, node="n2") - pytest.raises(consul.ConsulException, c.session.create, dc="dc2") - session_id = c.session.create("my-session") - - # session.list - pytest.raises(consul.ConsulException, c.session.list, dc="dc2") - _, sessions = c.session.list() - assert [x["Name"] for x in sessions] == ["my-session"] - - # session.info - pytest.raises(consul.ConsulException, c.session.info, session_id, dc="dc2") - _index, session = c.session.info("1" * 36) - assert session is None - _index, session = c.session.info(session_id) - assert session["Name"] == "my-session" - - # session.node - node = session["Node"] - pytest.raises(consul.ConsulException, c.session.node, node, dc="dc2") - _, sessions = c.session.node(node) - assert [x["Name"] for x in sessions] == ["my-session"] - - # session.destroy - pytest.raises(consul.ConsulException, c.session.destroy, session_id, dc="dc2") - assert c.session.destroy(session_id) is True - _, sessions = c.session.list() - assert sessions == [] - - def test_session_delete_ttl_renew(self, consul_obj): - c, _consul_version = consul_obj - - s = c.session.create(behavior="delete", ttl=20) - - # attempt to renew an unknown session - pytest.raises(consul.NotFound, c.session.renew, "1" * 36) - - session = c.session.renew(s) - assert session["Behavior"] == "delete" - assert session["TTL"] == "20s" - - # trying out the behavior - assert c.kv.put("foo", "1", acquire=s) is True - _index, data = c.kv.get("foo") - assert data["Value"] == b"1" - - c.session.destroy(s) - _index, data = c.kv.get("foo") - assert data is None - - def test_status_leader(self, consul_obj): - c, _consul_version = consul_obj - - agent_self = c.agent.self() - leader = c.status.leader() - addr_port = agent_self["Stats"]["consul"]["leader_addr"] - - assert leader == addr_port, f"Leader value was {leader}, expected value was {addr_port}" - - def test_status_peers(self, consul_obj): - c, _consul_version = consul_obj - - agent_self = c.agent.self() - - addr_port = agent_self["Stats"]["consul"]["leader_addr"] - peers = c.status.peers() - - assert addr_port in peers, f"Expected value '{addr_port}' in peer list but it was not present" - - def test_query(self, consul_obj): - c, _consul_version = consul_obj - - # check that query list is empty - queries = c.query.list() - assert queries == [] - - # create a new named query - query_service = "foo" - query_name = "fooquery" - query = c.query.create(query_service, query_name) - - # assert response contains query ID - assert "ID" in query - assert query["ID"] is not None - assert str(query["ID"]) != "" - - # retrieve query using id and name - queries = c.query.get(query["ID"]) - assert queries != [] - assert len(queries) == 1 - assert queries[0]["Name"] == query_name - assert queries[0]["ID"] == query["ID"] - - # explain query - assert c.query.explain(query_name)["Query"] - - # delete query - assert c.query.delete(query["ID"]) - - def test_coordinate(self, consul_obj): - c, _consul_version = consul_obj - c.coordinate.nodes() - c.coordinate.datacenters() - assert set(c.coordinate.datacenters()[0].keys()) == {"Datacenter", "Coordinates", "AreaID"} - - def test_operator(self, consul_obj): - c, _consul_version = consul_obj - config = c.operator.raft_config() - - expected_index = 1 - if version.parse(_consul_version) >= version.parse("1.13.8"): - expected_index = 0 - - assert config["Index"] == expected_index - leader = False - voter = False - for server in config["Servers"]: - if server["Leader"]: - leader = True - if server["Voter"]: - voter = True - assert leader - assert voter