diff --git a/ENDPOINT_STATUS.md b/ENDPOINT_STATUS.md index aaac0db..553157e 100644 --- a/ENDPOINT_STATUS.md +++ b/ENDPOINT_STATUS.md @@ -25,14 +25,14 @@ File: `consul/api/agent.py` | Endpoint | Python Method | Status | Missing Parameters | | :--- | :--- | :--- | :--- | | `GET /v1/agent/self` | `Agent.self` | ✅ | - | -| `GET /v1/agent/services` | `Agent.services` | ⚠️ | `filter` | +| `GET /v1/agent/services` | `Agent.services` | ✅ | - | | `GET /v1/agent/service/:service_id` | `Agent.service_definition` | ✅ | - | -| `GET /v1/agent/checks` | `Agent.checks` | ⚠️ | `filter` | +| `GET /v1/agent/checks` | `Agent.checks` | ✅ | - | | `GET /v1/agent/members` | `Agent.members` | ✅ | (`segment` is Ent) | | `PUT /v1/agent/maintenance` | `Agent.maintenance` | ✅ | - | | `PUT /v1/agent/join/:address` | `Agent.join` | ✅ | - | | `PUT /v1/agent/leave` | `Agent.leave` | ✅ | - | -| `PUT /v1/agent/force-leave/:node` | `Agent.force_leave` | ⚠️ | `prune` (added v1.13) | +| `PUT /v1/agent/force-leave/:node` | `Agent.force_leave` | ✅ | - | | `PUT /v1/agent/reload` | `Agent.reload` | ✅ | - | | `GET /v1/agent/metrics` | `Agent.metrics` | ✅ | - | | `GET /v1/agent/monitor` | `Agent.monitor` | ⚠️ | Not true streaming — single blocking read, see docstring | @@ -45,7 +45,7 @@ File: `consul/api/agent.py` ### Agent Services | Endpoint | Python Method | Status | Missing Parameters | | :--- | :--- | :--- | :--- | -| `PUT /v1/agent/service/register` | `Agent.Service.register` | ⚠️ | Body: `Kind`, `Proxy`, `SocketPath`, `Locality` | +| `PUT /v1/agent/service/register` | `Agent.Service.register` | ✅ | - | | `PUT /v1/agent/service/deregister/:id` | `Agent.Service.deregister` | ✅ | - | | `PUT /v1/agent/service/maintenance/:id` | `Agent.Service.maintenance` | ✅ | - | @@ -112,18 +112,18 @@ File: `consul/api/acl/*.py` | `PUT /v1/acl/bootstrap` | `ACL.bootstrap` | ✅ | - | | `POST /v1/acl/login` | `ACL.login` | ✅ | - | | `POST /v1/acl/logout` | `ACL.logout` | ✅ | - | -| `GET /v1/acl/tokens` | `Token.list` | ⚠️ | `policy`, `role`, `authmethod`, `secondary` (filters) | -| `PUT /v1/acl/token` | `Token.create` | ⚠️ | `ServiceIdentities`, `NodeIdentities`, `ExpirationTime`, `ExpirationTTL`, `Local` | +| `GET /v1/acl/tokens` | `Token.list` | ✅ | - | +| `PUT /v1/acl/token` | `Token.create` | ✅ | - | | `GET /v1/acl/token/:accessor` | `Token.read` | ✅ | - | -| `PUT /v1/acl/token/:accessor` | `Token.update` | ⚠️ | `ServiceIdentities`, `NodeIdentities`, `ExpirationTime`, `ExpirationTTL`, `Local` | +| `PUT /v1/acl/token/:accessor` | `Token.update` | ✅ | - | | `DELETE /v1/acl/token/:accessor` | `Token.delete` | ✅ | - | | `PUT /v1/acl/token/:accessor/clone` | `Token.clone` | ✅ | - | | `GET /v1/acl/token/self` | `Token.read_self` | ✅ | - | | `GET /v1/acl/policies` | `Policy.list` | ✅ | - | | `PUT /v1/acl/policy` | `Policy.create` | ✅ | - | | `GET /v1/acl/policy/:id` | `Policy.read` | ✅ | - | -| `PUT /v1/acl/policy/:id` | - | ❌ | - | -| `DELETE /v1/acl/policy/:id` | - | ❌ | - | +| `PUT /v1/acl/policy/:id` | `Policy.update` | ✅ | - | +| `DELETE /v1/acl/policy/:id` | `Policy.delete` | ✅ | - | | `GET /v1/acl/roles` | `Role.list` | ✅ | - | | `PUT /v1/acl/role` | `Role.create` | ✅ | - | | `GET /v1/acl/role/:id` | `Role.read` | ✅ | - | @@ -211,7 +211,7 @@ File: `consul/api/connect.py` | :--- | :--- | :--- | :--- | | `GET /v1/connect/ca/roots` | `Connect.CA.roots` | ✅ | - | | `GET /v1/connect/ca/configuration` | `Connect.CA.configuration` | ✅ | - | -| `PUT /v1/connect/ca/configuration` | - | ❌ | - | +| `PUT /v1/connect/ca/configuration` | `Connect.CA.update_configuration` | ✅ | - | | `PUT /v1/connect/intentions/exact` | `Connect.Intentions.upsert` | ✅ | - | | `GET /v1/connect/intentions/exact` | `Connect.Intentions.read` | ✅ | - | | `DELETE /v1/connect/intentions/exact` | `Connect.Intentions.delete` | ✅ | - | diff --git a/consul/api/acl/policy.py b/consul/api/acl/policy.py index d8a9040..a8b1e4f 100644 --- a/consul/api/acl/policy.py +++ b/consul/api/acl/policy.py @@ -52,3 +52,38 @@ def create(self, name: str, token: str | None = None, description: str | None = headers=headers, data=json.dumps(json_data), ) + + def update(self, uuid: str, name: str, token: str | None = None, description: str | None = None, rules=None): + """ + Update the policy identified by *uuid*. This is a privileged endpoint, and + requires a token with acl:write. + :param uuid: Specifies the UUID of the policy to update. + :param name: Specifies a name for the ACL policy. + :param token: token with acl:write capability + :param description: Free form human-readable description of the policy. + :param rules: Specifies rules for the ACL policy. + :return: The updated policy information + """ + json_data = {"ID": uuid, "Name": name} + if rules: + json_data["Rules"] = json.dumps(rules) + if description: + json_data["Description"] = description + headers = self.agent.prepare_headers(token) + return self.agent.http.put( + CB.json(), + f"/v1/acl/policy/{uuid}", + headers=headers, + data=json.dumps(json_data), + ) + + def delete(self, uuid: str, token: str | None = None) -> bool: + """ + Deletes the policy with *uuid*. This is a privileged endpoint, and requires a + token with acl:write. + :param uuid: Specifies the UUID of the policy to delete. + :param token: token with acl:write capability + :return: True if the policy was deleted + """ + headers = self.agent.prepare_headers(token) + return self.agent.http.delete(CB.boolean(), f"/v1/acl/policy/{uuid}", headers=headers) diff --git a/consul/api/acl/token.py b/consul/api/acl/token.py index 6f156e1..b1e717a 100644 --- a/consul/api/acl/token.py +++ b/consul/api/acl/token.py @@ -23,6 +23,35 @@ def parse_identities(node_identities: list[str] | None) -> dict[str, list[dict[s return {} +def _policy_links(policies_id: list[str] | None, policies_name: list[str] | None) -> list[dict[str, str]]: + links: list[dict[str, str]] = [] + if policies_id: + links.extend({"ID": policy} for policy in policies_id) + if policies_name: + links.extend({"Name": policy} for policy in policies_name) + return links + + +def _role_links(roles_id: list[str] | None, roles_name: list[str] | None) -> list[dict[str, str]]: + links: list[dict[str, str]] = [] + if roles_id: + links.extend({"ID": role} for role in roles_id) + if roles_name: + links.extend({"Name": role} for role in roles_name) + return links + + +def _templated_policy_entries( + templated_policies: list[dict[str, dict[str, str]]] | None, +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + if templated_policies is not None: + for templated_policy in templated_policies: + for name, variables in templated_policy.items(): + entries.append({"TemplateName": name, "TemplateVariables": variables}) + return entries + + class AclPolicyLink(TypedDict, total=False): ID: str Name: str @@ -49,15 +78,36 @@ class Token: def __init__(self, agent) -> None: self.agent = agent - def list(self, token: str | None = None) -> builtins.list[AclToken]: + def list( + self, + policy: str | None = None, + role: str | None = None, + authmethod: str | None = None, + servicename: str | None = None, + token: str | None = None, + ) -> builtins.list[AclToken]: """ 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 + :param policy: Optional policy ID to filter the results by. + :param role: Optional role ID to filter the results by. + :param authmethod: Optional auth method name to filter the results by. + :param servicename: Optional service name to filter the results by + (matches tokens with a ServiceIdentity for this service). """ + params: list[tuple[str, Any]] = [] + if policy: + params.append(("policy", policy)) + if role: + params.append(("role", role)) + if authmethod: + params.append(("authmethod", authmethod)) + if servicename: + params.append(("servicename", servicename)) headers = self.agent.prepare_headers(token) - return self.agent.http.get(CB.json(), "/v1/acl/tokens", headers=headers) + return self.agent.http.get(CB.json(), "/v1/acl/tokens", params=params, headers=headers) def read(self, accessor_id: str, token: str | None = None) -> AclToken: """ @@ -114,12 +164,16 @@ def create( accessor_id: str | None = None, secret_id: str | None = None, node_identities: builtins.list[str] | None = None, + service_identities: builtins.list[builtins.dict[str, Any]] | None = None, policies_id: builtins.list[str] | None = None, description: str = "", policies_name: builtins.list[str] | None = None, roles_id: builtins.list[str] | None = None, roles_name: builtins.list[str] | None = None, templated_policies: builtins.list[builtins.dict[str, builtins.dict[str, str]]] | None = None, + expiration_time: str | None = None, + expiration_ttl: str | None = None, + local: bool | None = None, ) -> AclToken: """ Create a token (optionally identified by *secret_id* and *accessor_id*). @@ -128,11 +182,19 @@ def create( :param accessor_id: The accessor ID of the token to create :param secret_id: The secret ID of the token to create :param node_identities: Optional list of node identities (format: 'nodename:datacenter'), requires consul>=1.8.1 + :param service_identities: Optional list of service identities, e.g. + [{"ServiceName": "web", "Datacenters": ["dc1"]}] ("Datacenters" is optional) :param description: Optional new token description :param policies_id: Optional list of policies id :param roles_id: Optional list of roles id :param roles_name: Optional list of roles name :param templated_policies: Optional list of templated policies, + :param expiration_time: Optional absolute expiration timestamp (RFC3339), 1 minute + to 24 hours in the future. Mutually exclusive with expiration_ttl. + :param expiration_ttl: Optional expiration duration (e.g. "1h") relative to the + token's creation time, 1 minute to 24 hours. Mutually exclusive with expiration_time. + :param local: Optional, if True the token is local to this datacenter and not + replicated globally. :return: The cloned token information """ @@ -143,31 +205,28 @@ def create( json_data["SecretID"] = secret_id if description: json_data["Description"] = description + if service_identities is not None: + json_data["ServiceIdentities"] = service_identities + if expiration_time: + json_data["ExpirationTime"] = expiration_time + if expiration_ttl: + json_data["ExpirationTTL"] = expiration_ttl + if local is not None: + json_data["Local"] = local json_data.update(parse_identities(node_identities)) - policies: list[dict[str, str]] = [] - if policies_id: - policies.extend({"ID": policy} for policy in policies_id) - if policies_name: - policies.extend({"Name": policy} for policy in policies_name) + policies = _policy_links(policies_id, policies_name) if policies: json_data["Policies"] = policies - roles: list[dict[str, str]] = [] - if roles_id: - roles.extend({"ID": role} for role in roles_id) - if roles_name: - roles.extend({"Name": role} for role in roles_name) + roles = _role_links(roles_id, roles_name) if roles: json_data["Roles"] = roles - if templated_policies is not None: - json_data["TemplatedPolicies"] = [] - for templated_policy in templated_policies: - for name, variables in templated_policy.items(): - policy_dict = {"TemplateName": name, "TemplateVariables": variables} - json_data["TemplatedPolicies"].append(policy_dict) + templated_policy_entries = _templated_policy_entries(templated_policies) + if templated_policy_entries: + json_data["TemplatedPolicies"] = templated_policy_entries headers = self.agent.prepare_headers(token) return self.agent.http.put( @@ -183,12 +242,16 @@ def update( token: str | None = None, secret_id: str | None = None, node_identities: builtins.list[str] | None = None, + service_identities: builtins.list[builtins.dict[str, Any]] | None = None, description: str = "", policies_id: builtins.list[str] | None = None, policies_name: builtins.list[str] | None = None, roles_id: builtins.list[str] | None = None, roles_name: builtins.list[str] | None = None, templated_policies: builtins.list[builtins.dict[str, builtins.dict[str, str]]] | None = None, + expiration_time: str | None = None, + expiration_ttl: str | None = None, + local: bool | None = None, ) -> AclToken: """ Update a token (optionally identified by *secret_id* and *accessor_id*). @@ -197,11 +260,18 @@ def update( :param token: token with acl:write capability :param secret_id: Optional secret ID of the token to update :param node_identities: Optional list of node identities (format: 'nodename:datacenter'), requires consul>=1.8.1 + :param service_identities: Optional list of service identities, e.g. + [{"ServiceName": "web", "Datacenters": ["dc1"]}] ("Datacenters" is optional) :param description: Optional new token description :param policies_id: Optional list of policies id :param roles_id: Optional list of roles id :param roles_name: Optional list of roles name :param templated_policies: Optional list of templated policies + :param expiration_time: Optional absolute expiration timestamp (RFC3339). Note + Consul does not allow extending a token's expiration once set. + :param expiration_ttl: Optional expiration duration (e.g. "1h") relative to the + token's creation time. Mutually exclusive with expiration_time. + :param local: Optional, if True the token is local to this datacenter. :return: The updated token information """ @@ -211,31 +281,28 @@ def update( json_data["SecretID"] = secret_id if description: json_data["Description"] = description + if service_identities is not None: + json_data["ServiceIdentities"] = service_identities + if expiration_time: + json_data["ExpirationTime"] = expiration_time + if expiration_ttl: + json_data["ExpirationTTL"] = expiration_ttl + if local is not None: + json_data["Local"] = local json_data.update(parse_identities(node_identities)) - policies: list[dict[str, str]] = [] - if policies_id: - policies.extend({"ID": policy} for policy in policies_id) - if policies_name: - policies.extend({"Name": policy} for policy in policies_name) + policies = _policy_links(policies_id, policies_name) if policies: json_data["Policies"] = policies - roles: list[dict[str, str]] = [] - if roles_id: - roles.extend({"ID": role} for role in roles_id) - if roles_name: - roles.extend({"Name": role} for role in roles_name) + roles = _role_links(roles_id, roles_name) if roles: json_data["Roles"] = roles - if templated_policies is not None: - json_data["TemplatedPolicies"] = [] - for templated_policy in templated_policies: - for name, variables in templated_policy.items(): - policy_dict = {"TemplateName": name, "TemplateVariables": variables} - json_data["TemplatedPolicies"].append(policy_dict) + templated_policy_entries = _templated_policy_entries(templated_policies) + if templated_policy_entries: + json_data["TemplatedPolicies"] = templated_policy_entries headers = self.agent.prepare_headers(token) return self.agent.http.put( diff --git a/consul/api/agent.py b/consul/api/agent.py index 9b3b44e..640e1e1 100644 --- a/consul/api/agent.py +++ b/consul/api/agent.py @@ -28,7 +28,7 @@ def self(self): """ return self.agent.http.get(CB.json(), "/v1/agent/self") - def services(self) -> Any: + def services(self, filter_expr: str | None = None, token: str | None = None) -> Any: """ Returns all the services that are registered with the local agent. These services were either provided through configuration files, or @@ -38,8 +38,14 @@ def services(self) -> Any: 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. + :param filter_expr: Optional bexpr filter expression. + :param token: token with node:read,service:read capability """ - return self.agent.http.get(CB.json(), "/v1/agent/services") + params: list[tuple[str, Any]] = [] + if filter_expr: + params.append(("filter", filter_expr)) + headers = self.agent.prepare_headers(token) + return self.agent.http.get(CB.json(), "/v1/agent/services", params=params, headers=headers) def service_definition(self, service_id): """ @@ -48,7 +54,7 @@ def service_definition(self, service_id): """ return self.agent.http.get(CB.json(), f"/v1/agent/service/{service_id}") - def checks(self) -> Any: + def checks(self, filter_expr: str | None = None, token: str | None = None) -> Any: """ Returns all the checks that are registered with the local agent. These checks were either provided through configuration files, or @@ -58,8 +64,14 @@ def checks(self) -> Any: 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. + :param filter_expr: Optional bexpr filter expression. + :param token: token with node:read,service:read capability """ - return self.agent.http.get(CB.json(), "/v1/agent/checks") + params: list[tuple[str, Any]] = [] + if filter_expr: + params.append(("filter", filter_expr)) + headers = self.agent.prepare_headers(token) + return self.agent.http.get(CB.json(), "/v1/agent/checks", params=params, headers=headers) def members(self, wan: bool = False): """ @@ -116,7 +128,7 @@ def join(self, address: str, wan: bool = False, token: str | None = None): headers = self.agent.prepare_headers(token) return self.agent.http.put(CB.boolean(), f"/v1/agent/join/{address}", params=params, headers=headers) - def force_leave(self, node: str, token: str | None = None): + def force_leave(self, node: str, prune: bool = False, token: str | None = None): """ 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 @@ -126,10 +138,17 @@ def force_leave(self, node: str, token: str | None = None): entries to be removed. *node* is the node to change state for. + + *prune* if set to *True* removes the node from the list of members + entirely, instead of leaving it in the "left" state. Added in + Consul 1.13. """ + params: list[tuple[str, Any]] = [] + if prune: + params.append(("prune", "1")) headers = self.agent.prepare_headers(token) - return self.agent.http.put(CB.boolean(), f"/v1/agent/force-leave/{node}", headers=headers) + return self.agent.http.put(CB.boolean(), f"/v1/agent/force-leave/{node}", params=params, headers=headers) def leave(self, token: str | None = None): """ @@ -239,6 +258,24 @@ class Service: def __init__(self, agent) -> None: self.agent = agent + @staticmethod + def _gateway_proxy_fields( + kind: str | None, + proxy: dict[str, Any] | None, + socket_path: str | None, + locality: dict[str, str] | None, + ) -> dict[str, Any]: + fields: dict[str, Any] = {} + if kind: + fields["Kind"] = kind + if proxy: + fields["Proxy"] = proxy + if socket_path: + fields["SocketPath"] = socket_path + if locality: + fields["Locality"] = locality + return fields + # pylint: disable=too-many-branches def register( self, @@ -262,6 +299,10 @@ def register( replace_existing_checks=False, tagged_addresses: dict | None = None, connect: dict[str, Any] | None = None, + kind: str | None = None, + proxy: dict[str, Any] | None = None, + socket_path: str | None = None, + locality: dict[str, str] | None = None, ): """ Add a new service to the local agent. There is more @@ -296,6 +337,19 @@ def register( *connect* specifies configuration for Connect. Formatted as { "sidecar_service": {} }. + *kind* identifies this as a proxy/gateway instance rather than a plain + service, e.g. "connect-proxy", "mesh-gateway", "terminating-gateway" or + "ingress-gateway". + + *proxy* specifies the connect proxy configuration when *kind* is + "connect-proxy", e.g. {"DestinationServiceName": "web", "LocalServicePort": 8080}. + + *socket_path* specifies a Unix domain socket path the service listens on, + as an alternative to *address*/*port*. + + *locality* specifies the region/zone this service instance is deployed to, + e.g. {"Region": "us-west-1", "Zone": "us-west-1a"}. + *script*, *interval*, *ttl*, *http*, and *timeout* arguments are deprecated. use *check* instead. @@ -344,6 +398,7 @@ def register( payload["tagged_addresses"] = tagged_addresses if connect: payload["connect"] = connect + payload.update(self._gateway_proxy_fields(kind, proxy, socket_path, locality)) params = [] if replace_existing_checks: params.append(("replace-existing-checks", "true")) diff --git a/consul/api/connect.py b/consul/api/connect.py index 3d0b32b..5a05a72 100644 --- a/consul/api/connect.py +++ b/consul/api/connect.py @@ -49,6 +49,31 @@ def configuration(self, token: str | None = None): headers = self.agent.prepare_headers(token) return self.agent.http.get(CB.json(), "/v1/connect/ca/configuration", headers=headers) + def update_configuration( + self, + provider: str, + config: dict[str, Any], + force_without_cross_signing: bool = False, + token: str | None = None, + ) -> bool: + """ + Updates the configuration for the CA provider. Requires a token with + operator:write capability. + :param provider: The CA provider type to use, e.g. "consul" or "vault". + :param config: The raw, provider-specific configuration. + :param force_without_cross_signing: If True, force a CA change without + cross-signing support from the old provider. Defaults to False. + :param token: token with operator:write capability + :return: True if the configuration was updated + """ + json_data: dict[str, Any] = {"Provider": provider, "Config": config} + if force_without_cross_signing: + json_data["ForceWithoutCrossSigning"] = force_without_cross_signing + headers = self.agent.prepare_headers(token) + return self.agent.http.put( + CB.boolean(), "/v1/connect/ca/configuration", headers=headers, data=json.dumps(json_data) + ) + class Intentions: """ Manages intentions using the exact-match model (Consul 1.9+). The legacy diff --git a/tests/api/test_acl.py b/tests/api/test_acl.py index 1a2e7de..84ba660 100644 --- a/tests/api/test_acl.py +++ b/tests/api/test_acl.py @@ -232,6 +232,78 @@ def test_acl_policy_read(self, acl_consul) -> None: policy = c.acl.policy.read(uuid="00000000-0000-0000-0000-000000000001", token=master_token) assert find_recursive(policy, {"ID": "00000000-0000-0000-0000-000000000001", "Name": "global-management"}) + def test_acl_policy_update(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + created = c.acl.policy.create(name="test-policy", description="original", token=master_token) + policy_id = created["ID"] + + updated = c.acl.policy.update( + uuid=policy_id, + name="test-policy", + description="updated", + rules={"key_prefix": {"": {"policy": "read"}}}, + token=master_token, + ) + assert updated["Description"] == "updated" + + read_back = c.acl.policy.read(uuid=policy_id, token=master_token) + assert read_back["Description"] == "updated" + assert "read" in read_back["Rules"] + + def test_acl_policy_delete(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + created = c.acl.policy.create(name="delete-me-policy", token=master_token) + policy_id = created["ID"] + + assert c.acl.policy.delete(uuid=policy_id, token=master_token) is True + assert c.acl.policy.read(uuid=policy_id, token=master_token) is None + + def test_acl_token_list_filters(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + policy = c.acl.policy.create(name="filter-test-policy", token=master_token) + c.acl.token.create( + accessor_id="00000000-F17E-0000-0000-000000000000", + policies_id=[policy["ID"]], + token=master_token, + ) + + filtered = c.acl.token.list(policy=policy["ID"], token=master_token) + assert {t["AccessorID"] for t in filtered} == {"00000000-F17E-0000-0000-000000000000"} + + assert c.acl.token.list(policy="00000000-0000-0000-0000-000000000099", token=master_token) == [] + + def test_acl_token_service_identities(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + token_info = c.acl.token.create( + description="service identity token", + service_identities=[{"ServiceName": "web", "Datacenters": ["dc1"]}], + token=master_token, + ) + # find_recursive doesn't handle a list-of-strings leaf value (Datacenters), only + # lists of dicts, so this is asserted directly rather than via find_recursive. + assert len(token_info["ServiceIdentities"]) == 1 + assert token_info["ServiceIdentities"][0]["ServiceName"] == "web" + assert token_info["ServiceIdentities"][0]["Datacenters"] == ["dc1"] + + def test_acl_token_local(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + # Consul rejects combining a service identity's Datacenters scoping + # with Local=true ("cannot specify a list of datacenters on a local + # token"), so this is exercised separately from service_identities. + token_info = c.acl.token.create(description="local token", local=True, token=master_token) + assert find_recursive(token_info, {"Local": True}) + + def test_acl_token_expiration_ttl(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + token_info = c.acl.token.create(description="expiring token", expiration_ttl="10m", token=master_token) + assert "ExpirationTime" in token_info + def test_acl_token_create_templated(self, acl_consul) -> None: c, master_token, _consul_version = acl_consul diff --git a/tests/api/test_agent.py b/tests/api/test_agent.py index 6922658..7e2009a 100644 --- a/tests/api/test_agent.py +++ b/tests/api/test_agent.py @@ -271,7 +271,53 @@ def test_agent_service_tagged_addresses(self, consul_obj) -> None: assert "foo_tagged" in services assert services["foo_tagged"]["TaggedAddresses"] == expected_tagged_addresses - assert c.agent.service.deregister("foo_tagged") is True + def test_agent_services_and_checks_filter(self, consul_obj) -> None: + c, _consul_version = consul_obj + + assert c.agent.service.register("filtered-foo", tags=["keep-me"]) is True + assert c.agent.service.register("filtered-bar", tags=["skip-me"]) is True + + filtered = c.agent.services(filter_expr='"keep-me" in Tags') + assert set(filtered.keys()) == {"filtered-foo"} + + assert isinstance(c.agent.checks(filter_expr="Status == `passing`"), dict) + + c.agent.service.deregister("filtered-foo") + c.agent.service.deregister("filtered-bar") + + def test_agent_service_register_kind_proxy_locality(self, consul_obj) -> None: + c, _consul_version = consul_obj + + assert c.agent.service.register("web") is True + assert ( + c.agent.service.register( + "web-sidecar-proxy", + port=21000, + kind="connect-proxy", + proxy={"DestinationServiceName": "web", "LocalServicePort": 8080}, + locality={"Region": "us-west-1", "Zone": "us-west-1a"}, + ) + is True + ) + + services = c.agent.services() + assert services["web-sidecar-proxy"]["Kind"] == "connect-proxy" + assert services["web-sidecar-proxy"]["Proxy"]["DestinationServiceName"] == "web" + assert services["web-sidecar-proxy"]["Locality"] == {"Region": "us-west-1", "Zone": "us-west-1a"} + + c.agent.service.deregister("web") + c.agent.service.deregister("web-sidecar-proxy") + + def test_agent_force_leave_prune(self, consul_obj) -> None: + c, _consul_version = consul_obj + + # There's no other node to actually force-leave in a single-node dev + # setup, and Consul rejects force-leave for a node name it has never + # seen ("No node found with name ...") rather than treating it as a + # no-op success -- this just verifies the prune param is forwarded + # and reaches Consul (a real, server-side error), not swallowed or + # rejected client-side. + pytest.raises(consul.ConsulException, c.agent.force_leave, "nonexistent-node", prune=True) def test_agent_service_connect(self, consul_obj) -> None: c, _consul_version = consul_obj diff --git a/tests/api/test_connect.py b/tests/api/test_connect.py index 33b099a..237d718 100644 --- a/tests/api/test_connect.py +++ b/tests/api/test_connect.py @@ -32,3 +32,21 @@ def test_intentions_deny_check(self, acl_consul) -> None: c.connect.intentions.upsert(source="web", destination="db", action="deny", token=master_token) assert c.connect.intentions.check(source="web", destination="db", token=master_token) is False + + +class TestConsulConnectCA: + def test_ca_update_configuration(self, acl_consul) -> None: + c, master_token, _consul_version = acl_consul + + original = c.connect.ca.configuration(token=master_token) + + assert ( + c.connect.ca.update_configuration(provider="consul", config={"LeafCertTTL": "72h"}, token=master_token) + is True + ) + + updated = c.connect.ca.configuration(token=master_token) + assert updated["Config"]["LeafCertTTL"] == "72h" + + # restore original config so this test doesn't leak state + c.connect.ca.update_configuration(provider=original["Provider"], config=original["Config"], token=master_token)