From 0a904d60cd63b4b32f75e8a324d4afe40482922a Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 10:33:33 +0100 Subject: [PATCH] [PR #7878/29f98654 backport][stable-8] Add new consul modules and reuse code between them. (#7902) Add new consul modules and reuse code between them. (#7878) Refactored consul modules and added new roles. (cherry picked from commit 29f98654978590a69d3042d0f1bbb20271369b55) Co-authored-by: Florian Apolloner --- .github/BOTMETA.yml | 2 +- .../7826-consul-modules-refactoring.yaml | 7 +- plugins/doc_fragments/consul.py | 13 +- plugins/module_utils/consul.py | 258 +++++++++- plugins/modules/consul_acl_bootstrap.py | 108 ++++ plugins/modules/consul_auth_method.py | 206 ++++++++ plugins/modules/consul_binding_rule.py | 182 +++++++ plugins/modules/consul_policy.py | 187 ++----- plugins/modules/consul_role.py | 460 ++++-------------- plugins/modules/consul_session.py | 5 +- plugins/modules/consul_token.py | 324 ++++++++++++ .../consul/tasks/consul_auth_method.yml | 79 +++ .../consul/tasks/consul_binding_rule.yml | 78 +++ .../targets/consul/tasks/consul_policy.yml | 13 +- .../targets/consul/tasks/consul_role.yml | 61 ++- .../targets/consul/tasks/consul_token.yml | 82 ++++ .../integration/targets/consul/tasks/main.yml | 11 +- 17 files changed, 1508 insertions(+), 568 deletions(-) create mode 100644 plugins/modules/consul_acl_bootstrap.py create mode 100644 plugins/modules/consul_auth_method.py create mode 100644 plugins/modules/consul_binding_rule.py create mode 100644 plugins/modules/consul_token.py create mode 100644 tests/integration/targets/consul/tasks/consul_auth_method.yml create mode 100644 tests/integration/targets/consul/tasks/consul_binding_rule.yml create mode 100644 tests/integration/targets/consul/tasks/consul_token.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 8b748c06fc..5700445910 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1494,7 +1494,7 @@ macros: team_ansible_core: team_aix: MorrisA bcoca d-little flynn1973 gforster kairoaraujo marvin-sinister mator molekuul ramooncamacho wtcross team_bsd: JoergFiedler MacLemon bcoca dch jasperla mekanix opoplawski overhacked tuxillo - team_consul: sgargan + team_consul: sgargan apollo13 team_cyberark_conjur: jvanderhoof ryanprior team_e_spirit: MatrixCrawler getjack team_flatpak: JayKayy oolongbrothers diff --git a/changelogs/fragments/7826-consul-modules-refactoring.yaml b/changelogs/fragments/7826-consul-modules-refactoring.yaml index b9e5a92849..a51352d88e 100644 --- a/changelogs/fragments/7826-consul-modules-refactoring.yaml +++ b/changelogs/fragments/7826-consul-modules-refactoring.yaml @@ -1,2 +1,7 @@ minor_changes: - - 'consul_policy, consul_role, consul_session - removed dependency on ``requests`` and factored out common parts (https://github.com/ansible-collections/community.general/pull/7826).' \ No newline at end of file + - 'consul_policy, consul_role, consul_session - removed dependency on ``requests`` and factored out common parts (https://github.com/ansible-collections/community.general/pull/7826, https://github.com/ansible-collections/community.general/pull/7878).' + - consul_policy - added support for diff and check mode (https://github.com/ansible-collections/community.general/pull/7878). + - consul_role - added support for diff mode (https://github.com/ansible-collections/community.general/pull/7878). + - consul_role - added support for templated policies (https://github.com/ansible-collections/community.general/pull/7878). + - consul_role - ``service_identities`` now expects a ``service_name`` option to match the Consul API, the old ``name`` is still supported as alias (https://github.com/ansible-collections/community.general/pull/7878). + - consul_role - ``node_identities`` now expects a ``node_name`` option to match the Consul API, the old ``name`` is still supported as alias (https://github.com/ansible-collections/community.general/pull/7878). \ No newline at end of file diff --git a/plugins/doc_fragments/consul.py b/plugins/doc_fragments/consul.py index c2407439c8..67fef5b1b3 100644 --- a/plugins/doc_fragments/consul.py +++ b/plugins/doc_fragments/consul.py @@ -5,6 +5,7 @@ from __future__ import absolute_import, division, print_function + __metaclass__ = type @@ -33,12 +34,16 @@ options: description: - Whether to verify the TLS certificate of the consul agent. default: true - token: - description: - - The token to use for authorization. - type: str ca_path: description: - The CA bundle to use for https connections type: str """ + + TOKEN = r""" +options: + token: + description: + - The token to use for authorization. + type: str +""" diff --git a/plugins/module_utils/consul.py b/plugins/module_utils/consul.py index cb89179855..c20d29b7ee 100644 --- a/plugins/module_utils/consul.py +++ b/plugins/module_utils/consul.py @@ -5,8 +5,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type +import copy import json from ansible.module_utils.six.moves.urllib import error as urllib_error @@ -15,35 +17,84 @@ from ansible.module_utils.urls import open_url def get_consul_url(configuration): - return '%s://%s:%s/v1' % (configuration.scheme, - configuration.host, configuration.port) + return "%s://%s:%s/v1" % ( + configuration.scheme, + configuration.host, + configuration.port, + ) def get_auth_headers(configuration): if configuration.token is None: return {} else: - return {'X-Consul-Token': configuration.token} + return {"X-Consul-Token": configuration.token} class RequestError(Exception): - pass + def __init__(self, status, response_data=None): + self.status = status + self.response_data = response_data + + def __str__(self): + if self.response_data is None: + # self.status is already the message (backwards compat) + return self.status + return "HTTP %d: %s" % (self.status, self.response_data) def handle_consul_response_error(response): if 400 <= response.status_code < 600: - raise RequestError('%d %s' % (response.status_code, response.content)) + raise RequestError("%d %s" % (response.status_code, response.content)) -def auth_argument_spec(): - return dict( - host=dict(default="localhost"), - port=dict(type="int", default=8500), - scheme=dict(default="http"), - validate_certs=dict(type="bool", default=True), - token=dict(no_log=True), - ca_path=dict(), - ) +AUTH_ARGUMENTS_SPEC = dict( + host=dict(default="localhost"), + port=dict(type="int", default=8500), + scheme=dict(default="http"), + validate_certs=dict(type="bool", default=True), + token=dict(no_log=True), + ca_path=dict(), +) + + +def camel_case_key(key): + parts = [] + for part in key.split("_"): + if part in {"id", "ttl", "jwks", "jwt", "oidc", "iam", "sts"}: + parts.append(part.upper()) + else: + parts.append(part.capitalize()) + return "".join(parts) + + +STATE_PARAMETER = "state" +STATE_PRESENT = "present" +STATE_ABSENT = "absent" + +OPERATION_READ = "read" +OPERATION_CREATE = "create" +OPERATION_UPDATE = "update" +OPERATION_DELETE = "remove" + + +def _normalize_params(params, arg_spec): + final_params = {} + for k, v in params.items(): + if k not in arg_spec: # Alias + continue + spec = arg_spec[k] + if ( + spec.get("type") == "list" + and spec.get("elements") == "dict" + and spec.get("options") + and v + ): + v = [_normalize_params(d, spec["options"]) for d in v] + elif spec.get("type") == "dict" and spec.get("options") and v: + v = _normalize_params(v, spec["options"]) + final_params[k] = v + return final_params class _ConsulModule: @@ -53,13 +104,160 @@ class _ConsulModule: As such backwards incompatible changes can occur even in bugfix releases. """ + api_endpoint = None # type: str + unique_identifier = None # type: str + result_key = None # type: str + create_only_fields = set() + params = {} + def __init__(self, module): - self.module = module + self._module = module + self.params = _normalize_params(module.params, module.argument_spec) + self.api_params = { + k: camel_case_key(k) + for k in self.params + if k not in STATE_PARAMETER and k not in AUTH_ARGUMENTS_SPEC + } + + def execute(self): + obj = self.read_object() + + changed = False + diff = {} + if self.params[STATE_PARAMETER] == STATE_PRESENT: + obj_from_module = self.module_to_obj(obj is not None) + if obj is None: + operation = OPERATION_CREATE + new_obj = self.create_object(obj_from_module) + diff = {"before": {}, "after": new_obj} + changed = True + else: + operation = OPERATION_UPDATE + if self._needs_update(obj, obj_from_module): + new_obj = self.update_object(obj, obj_from_module) + diff = {"before": obj, "after": new_obj} + changed = True + else: + new_obj = obj + elif self.params[STATE_PARAMETER] == STATE_ABSENT: + operation = OPERATION_DELETE + if obj is not None: + self.delete_object(obj) + changed = True + diff = {"before": obj, "after": {}} + else: + diff = {"before": {}, "after": {}} + new_obj = None + else: + raise RuntimeError("Unknown state supplied.") + + result = {"changed": changed} + if changed: + result["operation"] = operation + if self._module._diff: + result["diff"] = diff + if self.result_key: + result[self.result_key] = new_obj + self._module.exit_json(**result) + + def module_to_obj(self, is_update): + obj = {} + for k, v in self.params.items(): + result = self.map_param(k, v, is_update) + if result: + obj[result[0]] = result[1] + return obj + + def map_param(self, k, v, is_update): + def helper(item): + return {camel_case_key(k): v for k, v in item.items()} + + def needs_camel_case(k): + spec = self._module.argument_spec[k] + return ( + spec.get("type") == "list" + and spec.get("elements") == "dict" + and spec.get("options") + ) or (spec.get("type") == "dict" and spec.get("options")) + + if k in self.api_params and v is not None: + if isinstance(v, dict) and needs_camel_case(k): + v = helper(v) + elif isinstance(v, (list, tuple)) and needs_camel_case(k): + v = [helper(i) for i in v] + if is_update and k in self.create_only_fields: + return + return camel_case_key(k), v + + def _needs_update(self, api_obj, module_obj): + api_obj = copy.deepcopy(api_obj) + module_obj = copy.deepcopy(module_obj) + return self.needs_update(api_obj, module_obj) + + def needs_update(self, api_obj, module_obj): + for k, v in module_obj.items(): + if k not in api_obj: + return True + if api_obj[k] != v: + return True + return False + + def prepare_object(self, existing, obj): + operational_attributes = {"CreateIndex", "CreateTime", "Hash", "ModifyIndex"} + existing = { + k: v for k, v in existing.items() if k not in operational_attributes + } + for k, v in obj.items(): + existing[k] = v + return existing + + def endpoint_url(self, operation, identifier=None): + if operation == OPERATION_CREATE: + return self.api_endpoint + elif identifier: + return "/".join([self.api_endpoint, identifier]) + raise RuntimeError("invalid arguments passed") + + def read_object(self): + url = self.endpoint_url(OPERATION_READ, self.params.get(self.unique_identifier)) + try: + return self.get(url) + except RequestError as e: + if e.status == 404: + return + elif e.status == 403 and b"ACL not found" in e.response_data: + return + raise + + def create_object(self, obj): + if self._module.check_mode: + return obj + else: + return self.put(self.api_endpoint, data=self.prepare_object({}, obj)) + + def update_object(self, existing, obj): + url = self.endpoint_url( + OPERATION_UPDATE, existing.get(camel_case_key(self.unique_identifier)) + ) + merged_object = self.prepare_object(existing, obj) + if self._module.check_mode: + return merged_object + else: + return self.put(url, data=merged_object) + + def delete_object(self, obj): + if self._module.check_mode: + return {} + else: + url = self.endpoint_url( + OPERATION_DELETE, obj.get(camel_case_key(self.unique_identifier)) + ) + return self.delete(url) def _request(self, method, url_parts, data=None, params=None): - module_params = self.module.params + module_params = self.params - if isinstance(url_parts, str): + if not isinstance(url_parts, (tuple, list)): url_parts = [url_parts] if params: # Remove values that are None @@ -74,7 +272,7 @@ class _ConsulModule: url = "/".join([base_url] + list(url_parts)) headers = {} - token = self.module.params.get("token") + token = self.params.get("token") if token: headers["X-Consul-Token"] = token @@ -93,19 +291,25 @@ class _ConsulModule: ca_path=ca_path, ) response_data = response.read() - except urllib_error.URLError as e: - self.module.fail_json( - msg="Could not connect to consul agent at %s:%s, error was %s" - % (module_params["host"], module_params["port"], str(e)) - ) - else: status = ( response.status if hasattr(response, "status") else response.getcode() ) - if 400 <= status < 600: - raise RequestError("%d %s" % (status, response_data)) - return json.loads(response_data) + except urllib_error.URLError as e: + if isinstance(e, urllib_error.HTTPError): + status = e.code + response_data = e.fp.read() + else: + self._module.fail_json( + msg="Could not connect to consul agent at %s:%s, error was %s" + % (module_params["host"], module_params["port"], str(e)) + ) + raise + + if 400 <= status < 600: + raise RequestError(status, response_data) + + return json.loads(response_data) def get(self, url_parts, **kwargs): return self._request("GET", url_parts, **kwargs) diff --git a/plugins/modules/consul_acl_bootstrap.py b/plugins/modules/consul_acl_bootstrap.py new file mode 100644 index 0000000000..bf1da110bf --- /dev/null +++ b/plugins/modules/consul_acl_bootstrap.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +module: consul_acl_bootstrap +short_description: Bootstrap ACLs in Consul +version_added: 8.3.0 +description: + - Allows bootstrapping of ACLs in a Consul cluster, see + U(https://developer.hashicorp.com/consul/api-docs/acl#bootstrap-acls) for details. +author: + - Florian Apolloner (@apollo13) +extends_documentation_fragment: + - community.general.consul + - community.general.attributes +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + state: + description: + - Whether the token should be present or absent. + choices: ['present', 'bootstrapped'] + default: present + type: str + bootstrap_secret: + description: + - The secret to be used as secret ID for the initial token. + - Needs to be an UUID. + type: str +""" + +EXAMPLES = """ +- name: Bootstrap the ACL system + community.general.consul_acl_bootstrap: + bootstrap_secret: 22eaeed1-bdbd-4651-724e-42ae6c43e387 +""" + +RETURN = """ +result: + description: + - The bootstrap result as returned by the consul HTTP API. + - "B(Note:) If O(bootstrap_secret) has been specified the C(SecretID) and + C(ID) will not contain the secret but C(VALUE_SPECIFIED_IN_NO_LOG_PARAMETER). + If you pass O(bootstrap_secret), make sure your playbook/role does not depend + on this return value!" + returned: changed + type: dict + sample: + AccessorID: 834a5881-10a9-a45b-f63c-490e28743557 + CreateIndex: 25 + CreateTime: '2024-01-21T20:26:27.114612038+01:00' + Description: Bootstrap Token (Global Management) + Hash: X2AgaFhnQGRhSSF/h0m6qpX1wj/HJWbyXcxkEM/5GrY= + ID: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER + Local: false + ModifyIndex: 25 + Policies: + - ID: 00000000-0000-0000-0000-000000000001 + Name: global-management + SecretID: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + RequestError, + _ConsulModule, +) + +_ARGUMENT_SPEC = { + "state": dict(type="str", choices=["present", "bootstrapped"], default="present"), + "bootstrap_secret": dict(type="str", no_log=True), +} +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) +_ARGUMENT_SPEC.pop("token") + + +def main(): + module = AnsibleModule(_ARGUMENT_SPEC) + consul_module = _ConsulModule(module) + + data = {} + if "bootstrap_secret" in module.params: + data["BootstrapSecret"] = module.params["bootstrap_secret"] + + try: + response = consul_module.put("acl/bootstrap", data=data) + except RequestError as e: + if e.status == 403 and b"ACL bootstrap no longer allowed" in e.response_data: + return module.exit_json(changed=False) + raise + else: + return module.exit_json(changed=True, result=response) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/consul_auth_method.py b/plugins/modules/consul_auth_method.py new file mode 100644 index 0000000000..96b2469ae5 --- /dev/null +++ b/plugins/modules/consul_auth_method.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +module: consul_auth_method +short_description: Manipulate Consul auth methods +version_added: 8.3.0 +description: + - Allows the addition, modification and deletion of auth methods in a consul + cluster via the agent. For more details on using and configuring ACLs, + see U(https://www.consul.io/docs/guides/acl.html). +author: + - Florian Apolloner (@apollo13) +extends_documentation_fragment: + - community.general.consul + - community.general.consul.token + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: partial + details: + - In check mode the diff will miss operational attributes. +options: + state: + description: + - Whether the token should be present or absent. + choices: ['present', 'absent'] + default: present + type: str + name: + description: + - Specifies a name for the ACL auth method. + - The name can contain alphanumeric characters, dashes C(-), and underscores C(_). + type: str + required: true + type: + description: + - The type of auth method being configured. + - This field is immutable. + - Required when the auth method is created. + type: str + choices: ['kubernetes', 'jwt', 'oidc', 'aws-iam'] + description: + description: + - Free form human readable description of the auth method. + type: str + display_name: + description: + - An optional name to use instead of O(name) when displaying information about this auth method. + type: str + max_token_ttl: + description: + - This specifies the maximum life of any token created by this auth method. + - Can be specified in the form of V(60s) or V(5m) (that is, 60 seconds or 5 minutes, respectively). + type: str + token_locality: + description: + - Defines the kind of token that this auth method should produce. + type: str + choices: ['local', 'global'] + config: + description: + - The raw configuration to use for the chosen auth method. + - Contents will vary depending upon the type chosen. + - Required when the auth method is created. + type: dict +""" + +EXAMPLES = """ +- name: Create an auth method + community.general.consul_auth_method: + name: test + type: jwt + config: + jwt_validation_pubkeys: + - | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo + 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u + +qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh + kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ + 0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg + cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc + mwIDAQAB + -----END PUBLIC KEY----- + token: "{{ consul_management_token }}" + +- name: Delete auth method + community.general.consul_auth_method: + name: test + state: absent + token: "{{ consul_management_token }}" +""" + +RETURN = """ +auth_method: + description: The auth method as returned by the consul HTTP API. + returned: always + type: dict + sample: + Config: + JWTValidationPubkeys: + - |- + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo + 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u + +qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh + kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ + 0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg + cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc + mwIDAQAB + -----END PUBLIC KEY----- + CreateIndex: 416 + ModifyIndex: 487 + Name: test + Type: jwt +operation: + description: The operation performed. + returned: changed + type: str + sample: update +""" + +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + _ConsulModule, + camel_case_key, +) + + +def normalize_ttl(ttl): + matches = re.findall(r"(\d+)(:h|m|s)", ttl) + ttl = 0 + for value, unit in matches: + value = int(value) + if unit == "m": + value *= 60 + elif unit == "h": + value *= 60 * 60 + ttl += value + + new_ttl = "" + hours, remainder = divmod(ttl, 3600) + if hours: + new_ttl += "{0}h".format(hours) + minutes, seconds = divmod(remainder, 60) + if minutes: + new_ttl += "{0}m".format(minutes) + if seconds: + new_ttl += "{0}s".format(seconds) + return new_ttl + + +class ConsulAuthMethodModule(_ConsulModule): + api_endpoint = "acl/auth-method" + result_key = "auth_method" + unique_identifier = "name" + + def map_param(self, k, v, is_update): + if k == "config" and v: + v = {camel_case_key(k2): v2 for k2, v2 in v.items()} + return super(ConsulAuthMethodModule, self).map_param(k, v, is_update) + + def needs_update(self, api_obj, module_obj): + if "MaxTokenTTL" in module_obj: + module_obj["MaxTokenTTL"] = normalize_ttl(module_obj["MaxTokenTTL"]) + return super(ConsulAuthMethodModule, self).needs_update(api_obj, module_obj) + + +_ARGUMENT_SPEC = { + "name": dict(type="str", required=True), + "type": dict(type="str", choices=["kubernetes", "jwt", "oidc", "aws-iam"]), + "description": dict(type="str"), + "display_name": dict(type="str"), + "max_token_ttl": dict(type="str", no_log=False), + "token_locality": dict(type="str", choices=["local", "global"]), + "config": dict(type="dict"), + "state": dict(default="present", choices=["present", "absent"]), +} +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) + + +def main(): + module = AnsibleModule( + _ARGUMENT_SPEC, + supports_check_mode=True, + ) + consul_module = ConsulAuthMethodModule(module) + consul_module.execute() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/consul_binding_rule.py b/plugins/modules/consul_binding_rule.py new file mode 100644 index 0000000000..065981c6a3 --- /dev/null +++ b/plugins/modules/consul_binding_rule.py @@ -0,0 +1,182 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +module: consul_binding_rule +short_description: Manipulate Consul binding rules +version_added: 8.3.0 +description: + - Allows the addition, modification and deletion of binding rules in a consul + cluster via the agent. For more details on using and configuring binding rules, + see U(https://developer.hashicorp.com/consul/api-docs/acl/binding-rules). +author: + - Florian Apolloner (@apollo13) +extends_documentation_fragment: + - community.general.consul + - community.general.consul.token + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: partial + details: + - In check mode the diff will miss operational attributes. +options: + state: + description: + - Whether the binding rule should be present or absent. + choices: ['present', 'absent'] + default: present + type: str + name: + description: + - Specifies a name for the binding rule. + - 'Note: This is used to identify the binding rule. But since the API does not support a name, it is prefixed to the description.' + type: str + required: true + description: + description: + - Free form human readable description of the binding rule. + type: str + auth_method: + description: + - The name of the auth method that this rule applies to. + type: str + required: true + selector: + description: + - Specifies the expression used to match this rule against valid identities returned from an auth method validation. + - If empty this binding rule matches all valid identities returned from the auth method. + type: str + bind_type: + description: + - Specifies the way the binding rule affects a token created at login. + type: str + choices: [service, node, role, templated-policy] + bind_name: + description: + - The name to bind to a token at login-time. + - What it binds to can be adjusted with different values of the O(bind_type) parameter. + type: str + bind_vars: + description: + - Specifies the templated policy variables when O(bind_type) is set to V(templated-policy). + type: dict +""" + +EXAMPLES = """ +- name: Create a binding rule + community.general.consul_binding_rule: + name: my_name + description: example rule + auth_method: minikube + bind_type: service + bind_name: "{{ serviceaccount.name }}" + token: "{{ consul_management_token }}" + +- name: Remove a binding rule + community.general.consul_binding_rule: + name: my_name + auth_method: minikube + state: absent +""" + +RETURN = """ +binding_rule: + description: The binding rule as returned by the consul HTTP API. + returned: always + type: dict + sample: + Description: "my_name: example rule" + AuthMethod: minikube + Selector: serviceaccount.namespace==default + BindType: service + BindName: "{{ serviceaccount.name }}" + CreateIndex: 30 + ID: 59c8a237-e481-4239-9202-45f117950c5f + ModifyIndex: 33 +operation: + description: The operation performed. + returned: changed + type: str + sample: update +""" + + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + RequestError, + _ConsulModule, +) + + +class ConsulBindingRuleModule(_ConsulModule): + api_endpoint = "acl/binding-rule" + result_key = "binding_rule" + unique_identifier = "id" + + def read_object(self): + url = "acl/binding-rules?authmethod={0}".format(self.params["auth_method"]) + try: + results = self.get(url) + for result in results: + if result.get("Description").startswith( + "{0}: ".format(self.params["name"]) + ): + return result + except RequestError as e: + if e.status == 404: + return + elif e.status == 403 and b"ACL not found" in e.response_data: + return + raise + + def module_to_obj(self, is_update): + obj = super(ConsulBindingRuleModule, self).module_to_obj(is_update) + del obj["Name"] + return obj + + def prepare_object(self, existing, obj): + final = super(ConsulBindingRuleModule, self).prepare_object(existing, obj) + name = self.params["name"] + description = final.pop("Description", "").split(": ", 1)[-1] + final["Description"] = "{0}: {1}".format(name, description) + return final + + +_ARGUMENT_SPEC = { + "name": dict(type="str", required=True), + "description": dict(type="str"), + "auth_method": dict(type="str", required=True), + "selector": dict(type="str"), + "bind_type": dict( + type="str", choices=["service", "node", "role", "templated-policy"] + ), + "bind_name": dict(type="str"), + "bind_vars": dict(type="dict"), + "state": dict(default="present", choices=["present", "absent"]), +} +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) + + +def main(): + module = AnsibleModule( + _ARGUMENT_SPEC, + supports_check_mode=True, + ) + consul_module = ConsulBindingRuleModule(module) + consul_module.execute() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/consul_policy.py b/plugins/modules/consul_policy.py index 6b62009f8d..9f7498c005 100644 --- a/plugins/modules/consul_policy.py +++ b/plugins/modules/consul_policy.py @@ -6,9 +6,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = """ module: consul_policy short_description: Manipulate Consul policies version_added: 7.2.0 @@ -20,12 +21,17 @@ author: - Håkon Lerring (@Hakon) extends_documentation_fragment: - community.general.consul + - community.general.consul.token - community.general.attributes attributes: check_mode: - support: none + support: full + version_added: 8.3.0 diff_mode: - support: none + support: partial + version_added: 8.3.0 + details: + - In check mode the diff will miss operational attributes. options: state: description: @@ -36,7 +42,6 @@ options: valid_datacenters: description: - Valid datacenters for the policy. All if list is empty. - default: [] type: list elements: str name: @@ -49,12 +54,11 @@ options: description: - Description of the policy. type: str - default: '' rules: type: str description: - Rule document that should be associated with the current policy. -''' +""" EXAMPLES = """ - name: Create a policy with rules @@ -95,8 +99,24 @@ EXAMPLES = """ """ RETURN = """ +policy: + description: The policy as returned by the consul HTTP API. + returned: always + type: dict + sample: + CreateIndex: 632 + Description: Testing + Hash: rj5PeDHddHslkpW7Ij4OD6N4bbSXiecXFmiw2SYXg2A= + Name: foo-access + Rules: |- + key "foo" { + policy = "read" + } + key "private/foo" { + policy = "deny" + } operation: - description: The operation performed on the policy. + description: The operation performed. returned: changed type: str sample: update @@ -104,146 +124,39 @@ operation: from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.general.plugins.module_utils.consul import ( - _ConsulModule, auth_argument_spec) - -NAME_PARAMETER_NAME = "name" -DESCRIPTION_PARAMETER_NAME = "description" -RULES_PARAMETER_NAME = "rules" -VALID_DATACENTERS_PARAMETER_NAME = "valid_datacenters" -STATE_PARAMETER_NAME = "state" - - -PRESENT_STATE_VALUE = "present" -ABSENT_STATE_VALUE = "absent" - -REMOVE_OPERATION = "remove" -UPDATE_OPERATION = "update" -CREATE_OPERATION = "create" + AUTH_ARGUMENTS_SPEC, + OPERATION_READ, + _ConsulModule, +) _ARGUMENT_SPEC = { - NAME_PARAMETER_NAME: dict(required=True), - DESCRIPTION_PARAMETER_NAME: dict(required=False, type='str', default=''), - RULES_PARAMETER_NAME: dict(type='str'), - VALID_DATACENTERS_PARAMETER_NAME: dict(type='list', elements='str', default=[]), - STATE_PARAMETER_NAME: dict(default=PRESENT_STATE_VALUE, choices=[PRESENT_STATE_VALUE, ABSENT_STATE_VALUE]) + "name": dict(required=True), + "description": dict(required=False, type="str"), + "rules": dict(type="str"), + "valid_datacenters": dict(type="list", elements="str"), + "state": dict(default="present", choices=["present", "absent"]), } -_ARGUMENT_SPEC.update(auth_argument_spec()) +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) -def update_policy(policy, configuration, consul_module): - updated_policy = consul_module.put(('acl', 'policy', policy['ID']), data={ - 'Name': configuration.name, # should be equal at this point. - 'Description': configuration.description, - 'Rules': configuration.rules, - 'Datacenters': configuration.valid_datacenters - }) +class ConsulPolicyModule(_ConsulModule): + api_endpoint = "acl/policy" + result_key = "policy" + unique_identifier = "id" - changed = ( - policy.get('Rules', "") != updated_policy.get('Rules', "") or - policy.get('Description', "") != updated_policy.get('Description', "") or - policy.get('Datacenters', []) != updated_policy.get('Datacenters', []) - ) - - return Output(changed=changed, operation=UPDATE_OPERATION, policy=updated_policy) - - -def create_policy(configuration, consul_module): - created_policy = consul_module.put('acl/policy', data={ - 'Name': configuration.name, - 'Description': configuration.description, - 'Rules': configuration.rules, - 'Datacenters': configuration.valid_datacenters - }) - return Output(changed=True, operation=CREATE_OPERATION, policy=created_policy) - - -def remove_policy(configuration, consul_module): - policies = get_policies(consul_module) - - if configuration.name in policies: - - policy_id = policies[configuration.name]['ID'] - policy = get_policy(policy_id, consul_module) - consul_module.delete(('acl', 'policy', policy['ID'])) - - changed = True - else: - changed = False - return Output(changed=changed, operation=REMOVE_OPERATION) - - -def get_policies(consul_module): - policies = consul_module.get('acl/policies') - existing_policies_mapped_by_name = dict( - (policy['Name'], policy) for policy in policies if policy['Name'] is not None) - return existing_policies_mapped_by_name - - -def get_policy(id, consul_module): - return consul_module.get(('acl', 'policy', id)) - - -def set_policy(configuration, consul_module): - policies = get_policies(consul_module) - - if configuration.name in policies: - index_policy_object = policies[configuration.name] - policy_id = policies[configuration.name]['ID'] - rest_policy_object = get_policy(policy_id, consul_module) - # merge dicts as some keys are only available in the partial policy - policy = index_policy_object.copy() - policy.update(rest_policy_object) - return update_policy(policy, configuration, consul_module) - else: - return create_policy(configuration, consul_module) - - -class Configuration: - """ - Configuration for this module. - """ - - def __init__(self, name=None, description=None, rules=None, valid_datacenters=None, state=None): - self.name = name # type: str - self.description = description # type: str - self.rules = rules # type: str - self.valid_datacenters = valid_datacenters # type: str - self.state = state # type: str - - -class Output: - """ - Output of an action of this module. - """ - - def __init__(self, changed=None, operation=None, policy=None): - self.changed = changed # type: bool - self.operation = operation # type: str - self.policy = policy # type: dict + def endpoint_url(self, operation, identifier=None): + if operation == OPERATION_READ: + return [self.api_endpoint, "name", self.params["name"]] + return super(ConsulPolicyModule, self).endpoint_url(operation, identifier) def main(): - """ - Main method. - """ - module = AnsibleModule(_ARGUMENT_SPEC, supports_check_mode=False) - consul_module = _ConsulModule(module) - - configuration = Configuration( - name=module.params.get(NAME_PARAMETER_NAME), - description=module.params.get(DESCRIPTION_PARAMETER_NAME), - rules=module.params.get(RULES_PARAMETER_NAME), - valid_datacenters=module.params.get(VALID_DATACENTERS_PARAMETER_NAME), - state=module.params.get(STATE_PARAMETER_NAME), + module = AnsibleModule( + _ARGUMENT_SPEC, + supports_check_mode=True, ) - - if configuration.state == PRESENT_STATE_VALUE: - output = set_policy(configuration, consul_module) - else: - output = remove_policy(configuration, consul_module) - - return_values = dict(changed=output.changed, operation=output.operation, policy=output.policy) - module.exit_json(**return_values) + consul_module = ConsulPolicyModule(module) + consul_module.execute() if __name__ == "__main__": diff --git a/plugins/modules/consul_role.py b/plugins/modules/consul_role.py index e38afba562..76f1b96580 100644 --- a/plugins/modules/consul_role.py +++ b/plugins/modules/consul_role.py @@ -6,9 +6,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = """ module: consul_role short_description: Manipulate Consul roles version_added: 7.5.0 @@ -20,12 +21,16 @@ author: - Håkon Lerring (@Hakon) extends_documentation_fragment: - community.general.consul + - community.general.consul.token - community.general.attributes attributes: check_mode: support: full diff_mode: - support: none + support: partial + details: + - In check mode the diff will miss operational attributes. + version_added: 8.3.0 options: name: description: @@ -61,6 +66,23 @@ options: - The ID of the policy to attach to this role; see M(community.general.consul_policy) for more info. - Either this or O(policies[].name) must be specified. type: str + templated_policies: + description: + - The list of templated policies that should be applied to the role. + type: list + elements: dict + version_added: 8.3.0 + suboptions: + template_name: + description: + - The templated policy name. + type: str + required: true + template_variables: + description: + - The templated policy variables. + - Not all templated policies require variables. + type: dict service_identities: type: list elements: dict @@ -69,13 +91,17 @@ options: - If not specified, any service identities currently assigned will not be changed. - If the parameter is an empty array (V([])), any node identities assigned will be unassigned. suboptions: - name: + service_name: description: - The name of the node. - Must not be longer than 256 characters, must start and end with a lowercase alphanumeric character. - May only contain lowercase alphanumeric characters as well as - and _. + - This suboption has been renamed from O(service_identities[].name) to O(service_identities[].service_name) + in community.general 8.3.0. The old name can still be used. type: str required: true + aliases: + - name datacenters: description: - The datacenters the policies will be effective. @@ -84,7 +110,6 @@ options: - including those which do not yet exist but may in the future. type: list elements: str - required: true node_identities: type: list elements: dict @@ -93,20 +118,24 @@ options: - If not specified, any node identities currently assigned will not be changed. - If the parameter is an empty array (V([])), any node identities assigned will be unassigned. suboptions: - name: + node_name: description: - The name of the node. - Must not be longer than 256 characters, must start and end with a lowercase alphanumeric character. - May only contain lowercase alphanumeric characters as well as - and _. + - This suboption has been renamed from O(node_identities[].name) to O(node_identities[].node_name) + in community.general 8.3.0. The old name can still be used. type: str required: true + aliases: + - name datacenter: description: - The nodes datacenter. - This will result in effective policy only being valid in this datacenter. type: str required: true -''' +""" EXAMPLES = """ - name: Create a role with 2 policies @@ -171,373 +200,80 @@ operation: from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.general.plugins.module_utils.consul import ( - _ConsulModule, auth_argument_spec) - -NAME_PARAMETER_NAME = "name" -DESCRIPTION_PARAMETER_NAME = "description" -POLICIES_PARAMETER_NAME = "policies" -SERVICE_IDENTITIES_PARAMETER_NAME = "service_identities" -NODE_IDENTITIES_PARAMETER_NAME = "node_identities" -STATE_PARAMETER_NAME = "state" - -PRESENT_STATE_VALUE = "present" -ABSENT_STATE_VALUE = "absent" - -REMOVE_OPERATION = "remove" -UPDATE_OPERATION = "update" -CREATE_OPERATION = "create" - -POLICY_RULE_SPEC = dict( - name=dict(type='str'), - id=dict(type='str'), + AUTH_ARGUMENTS_SPEC, + OPERATION_READ, + _ConsulModule, ) -NODE_ID_RULE_SPEC = dict( - name=dict(type='str', required=True), - datacenter=dict(type='str', required=True), + +class ConsulRoleModule(_ConsulModule): + api_endpoint = "acl/role" + result_key = "role" + unique_identifier = "id" + + def endpoint_url(self, operation, identifier=None): + if operation == OPERATION_READ: + return [self.api_endpoint, "name", self.params["name"]] + return super(ConsulRoleModule, self).endpoint_url(operation, identifier) + + +NAME_ID_SPEC = dict( + name=dict(type="str"), + id=dict(type="str"), ) -SERVICE_ID_RULE_SPEC = dict( - name=dict(type='str', required=True), - datacenters=dict(type='list', elements='str', required=True), +NODE_ID_SPEC = dict( + node_name=dict(type="str", required=True, aliases=["name"]), + datacenter=dict(type="str", required=True), +) + +SERVICE_ID_SPEC = dict( + service_name=dict(type="str", required=True, aliases=["name"]), + datacenters=dict(type="list", elements="str"), +) + +TEMPLATE_POLICY_SPEC = dict( + template_name=dict(type="str", required=True), + template_variables=dict(type="dict"), ) _ARGUMENT_SPEC = { - NAME_PARAMETER_NAME: dict(required=True), - DESCRIPTION_PARAMETER_NAME: dict(required=False, type='str', default=None), - POLICIES_PARAMETER_NAME: dict(type='list', elements='dict', options=POLICY_RULE_SPEC, - mutually_exclusive=[('name', 'id')], required_one_of=[('name', 'id')], default=None), - SERVICE_IDENTITIES_PARAMETER_NAME: dict(type='list', elements='dict', options=SERVICE_ID_RULE_SPEC, default=None), - NODE_IDENTITIES_PARAMETER_NAME: dict(type='list', elements='dict', options=NODE_ID_RULE_SPEC, default=None), - STATE_PARAMETER_NAME: dict(default=PRESENT_STATE_VALUE, choices=[PRESENT_STATE_VALUE, ABSENT_STATE_VALUE]) + "name": dict(type="str", required=True), + "description": dict(type="str"), + "policies": dict( + type="list", + elements="dict", + options=NAME_ID_SPEC, + mutually_exclusive=[("name", "id")], + required_one_of=[("name", "id")], + ), + "templated_policies": dict( + type="list", + elements="dict", + options=TEMPLATE_POLICY_SPEC, + ), + "node_identities": dict( + type="list", + elements="dict", + options=NODE_ID_SPEC, + ), + "service_identities": dict( + type="list", + elements="dict", + options=SERVICE_ID_SPEC, + ), + "state": dict(default="present", choices=["present", "absent"]), } -_ARGUMENT_SPEC.update(auth_argument_spec()) - - -def compare_consul_api_role_policy_objects(first, second): - # compare two lists of dictionaries, ignoring the ID element - for x in first: - x.pop('ID', None) - - for x in second: - x.pop('ID', None) - - return first == second - - -def update_role(role, configuration, consul_module): - update_role_data = { - 'Name': configuration.name, - 'Description': configuration.description, - } - - # check if the user omitted the description, policies, service identities, or node identities - - description_specified = configuration.description is not None - - policy_specified = True - if len(configuration.policies) == 1 and configuration.policies[0] is None: - policy_specified = False - - service_id_specified = True - if len(configuration.service_identities) == 1 and configuration.service_identities[0] is None: - service_id_specified = False - - node_id_specified = True - if len(configuration.node_identities) == 1 and configuration.node_identities[0] is None: - node_id_specified = False - - if description_specified: - update_role_data["Description"] = configuration.description - - if policy_specified: - update_role_data["Policies"] = [x.to_dict() for x in configuration.policies] - - if configuration.version >= ConsulVersion("1.5.0") and service_id_specified: - update_role_data["ServiceIdentities"] = [ - x.to_dict() for x in configuration.service_identities] - - if configuration.version >= ConsulVersion("1.8.0") and node_id_specified: - update_role_data["NodeIdentities"] = [ - x.to_dict() for x in configuration.node_identities] - - if configuration.check_mode: - description_changed = False - if description_specified: - description_changed = role.get('Description') != update_role_data["Description"] - else: - update_role_data["Description"] = role.get("Description") - - policies_changed = False - if policy_specified: - policies_changed = not ( - compare_consul_api_role_policy_objects(role.get('Policies', []), update_role_data.get('Policies', []))) - else: - if role.get('Policies') is not None: - update_role_data["Policies"] = role.get('Policies') - - service_ids_changed = False - if service_id_specified: - service_ids_changed = role.get('ServiceIdentities') != update_role_data.get('ServiceIdentities') - else: - if role.get('ServiceIdentities') is not None: - update_role_data["ServiceIdentities"] = role.get('ServiceIdentities') - - node_ids_changed = False - if node_id_specified: - node_ids_changed = role.get('NodeIdentities') != update_role_data.get('NodeIdentities') - else: - if role.get('NodeIdentities'): - update_role_data["NodeIdentities"] = role.get('NodeIdentities') - - changed = ( - description_changed or - policies_changed or - service_ids_changed or - node_ids_changed - ) - return Output(changed=changed, operation=UPDATE_OPERATION, role=update_role_data) - else: - # if description, policies, service or node id are not specified; we need to get the existing value and apply it - if not description_specified and role.get('Description') is not None: - update_role_data["Description"] = role.get('Description') - - if not policy_specified and role.get('Policies') is not None: - update_role_data["Policies"] = role.get('Policies') - - if not service_id_specified and role.get('ServiceIdentities') is not None: - update_role_data["ServiceIdentities"] = role.get('ServiceIdentities') - - if not node_id_specified and role.get('NodeIdentities') is not None: - update_role_data["NodeIdentities"] = role.get('NodeIdentities') - - resulting_role = consul_module.put(('acl', 'role', role['ID']), data=update_role_data) - changed = ( - role['Description'] != resulting_role['Description'] or - role.get('Policies', None) != resulting_role.get('Policies', None) or - role.get('ServiceIdentities', None) != resulting_role.get('ServiceIdentities', None) or - role.get('NodeIdentities', None) != resulting_role.get('NodeIdentities', None) - ) - - return Output(changed=changed, operation=UPDATE_OPERATION, role=resulting_role) - - -def create_role(configuration, consul_module): - # check if the user omitted policies, service identities, or node identities - policy_specified = True - if len(configuration.policies) == 1 and configuration.policies[0] is None: - policy_specified = False - - service_id_specified = True - if len(configuration.service_identities) == 1 and configuration.service_identities[0] is None: - service_id_specified = False - - node_id_specified = True - if len(configuration.node_identities) == 1 and configuration.node_identities[0] is None: - node_id_specified = False - - # get rid of None item so we can set an empty list for policies, service identities and node identities - if not policy_specified: - configuration.policies.pop() - - if not service_id_specified: - configuration.service_identities.pop() - - if not node_id_specified: - configuration.node_identities.pop() - - create_role_data = { - 'Name': configuration.name, - 'Description': configuration.description, - 'Policies': [x.to_dict() for x in configuration.policies], - } - if configuration.version >= ConsulVersion("1.5.0"): - create_role_data["ServiceIdentities"] = [x.to_dict() for x in configuration.service_identities] - - if configuration.version >= ConsulVersion("1.8.0"): - create_role_data["NodeIdentities"] = [x.to_dict() for x in configuration.node_identities] - - if not configuration.check_mode: - resulting_role = consul_module.put('acl/role', data=create_role_data) - return Output(changed=True, operation=CREATE_OPERATION, role=resulting_role) - else: - return Output(changed=True, operation=CREATE_OPERATION) - - -def remove_role(configuration, consul_module): - roles = get_roles(consul_module) - - if configuration.name in roles: - - role_id = roles[configuration.name]['ID'] - - if not configuration.check_mode: - consul_module.delete(('acl', 'role', role_id)) - - changed = True - else: - changed = False - return Output(changed=changed, operation=REMOVE_OPERATION) - - -def get_roles(consul_module): - roles = consul_module.get('acl/roles') - existing_roles_mapped_by_id = dict((role['Name'], role) for role in roles if role['Name'] is not None) - return existing_roles_mapped_by_id - - -def get_consul_version(consul_module): - config = consul_module.get('agent/self')["Config"] - return ConsulVersion(config["Version"]) - - -def set_role(configuration, consul_module): - roles = get_roles(consul_module) - - if configuration.name in roles: - role = roles[configuration.name] - return update_role(role, configuration, consul_module) - else: - return create_role(configuration, consul_module) - - -class ConsulVersion: - def __init__(self, version_string): - split = version_string.split('.') - self.major = split[0] - self.minor = split[1] - self.patch = split[2] - - def __ge__(self, other): - return int(self.major + self.minor + - self.patch) >= int(other.major + other.minor + other.patch) - - def __le__(self, other): - return int(self.major + self.minor + - self.patch) <= int(other.major + other.minor + other.patch) - - -class ServiceIdentity: - def __init__(self, input): - if not isinstance(input, dict) or 'name' not in input: - raise ValueError( - "Each element of service_identities must be a dict with the keys name and optionally datacenters") - self.name = input["name"] - self.datacenters = input["datacenters"] if "datacenters" in input else None - - def to_dict(self): - return { - "ServiceName": self.name, - "Datacenters": self.datacenters - } - - -class NodeIdentity: - def __init__(self, input): - if not isinstance(input, dict) or 'name' not in input: - raise ValueError( - "Each element of node_identities must be a dict with the keys name and optionally datacenter") - self.name = input["name"] - self.datacenter = input["datacenter"] if "datacenter" in input else None - - def to_dict(self): - return { - "NodeName": self.name, - "Datacenter": self.datacenter - } - - -class RoleLink: - def __init__(self, dict): - self.id = dict.get("id", None) - self.name = dict.get("name", None) - - def to_dict(self): - return { - "ID": self.id, - "Name": self.name - } - - -class PolicyLink: - def __init__(self, dict): - self.id = dict.get("id", None) - self.name = dict.get("name", None) - - def to_dict(self): - return { - "ID": self.id, - "Name": self.name - } - - -class Configuration: - """ - Configuration for this module. - """ - - def __init__(self, name=None, description=None, policies=None, service_identities=None, - node_identities=None, state=None, check_mode=None): - self.name = name # type: str - self.description = description # type: str - if policies is not None: - self.policies = [PolicyLink(p) for p in policies] # type: list(PolicyLink) - else: - self.policies = [None] - if service_identities is not None: - self.service_identities = [ServiceIdentity(s) for s in service_identities] # type: list(ServiceIdentity) - else: - self.service_identities = [None] - if node_identities is not None: - self.node_identities = [NodeIdentity(n) for n in node_identities] # type: list(NodeIdentity) - else: - self.node_identities = [None] - self.state = state # type: str - self.check_mode = check_mode # type: bool - - -class Output: - """ - Output of an action of this module. - """ - - def __init__(self, changed=None, operation=None, role=None): - self.changed = changed # type: bool - self.operation = operation # type: str - self.role = role # type: dict +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) def main(): - """ - Main method. - """ - module = AnsibleModule(_ARGUMENT_SPEC, supports_check_mode=True) - consul_module = _ConsulModule(module) - - try: - configuration = Configuration( - name=module.params.get(NAME_PARAMETER_NAME), - description=module.params.get(DESCRIPTION_PARAMETER_NAME), - policies=module.params.get(POLICIES_PARAMETER_NAME), - service_identities=module.params.get(SERVICE_IDENTITIES_PARAMETER_NAME), - node_identities=module.params.get(NODE_IDENTITIES_PARAMETER_NAME), - state=module.params.get(STATE_PARAMETER_NAME), - check_mode=module.check_mode, - ) - except ValueError as err: - module.fail_json(msg='Configuration error: %s' % str(err)) - return - - version = get_consul_version(consul_module) - configuration.version = version - - if configuration.state == PRESENT_STATE_VALUE: - output = set_role(configuration, consul_module) - else: - output = remove_role(configuration, consul_module) - - return_values = dict(changed=output.changed, operation=output.operation, role=output.role) - module.exit_json(**return_values) + module = AnsibleModule( + _ARGUMENT_SPEC, + supports_check_mode=True, + ) + consul_module = ConsulRoleModule(module) + consul_module.execute() if __name__ == "__main__": diff --git a/plugins/modules/consul_session.py b/plugins/modules/consul_session.py index 6ab071e143..5629454c52 100644 --- a/plugins/modules/consul_session.py +++ b/plugins/modules/consul_session.py @@ -21,6 +21,7 @@ author: - Håkon Lerring (@Hakon) extends_documentation_fragment: - community.general.consul + - community.general.consul.token - community.general.attributes attributes: check_mode: @@ -124,7 +125,7 @@ EXAMPLES = ''' from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.general.plugins.module_utils.consul import ( - auth_argument_spec, _ConsulModule + AUTH_ARGUMENTS_SPEC, _ConsulModule ) @@ -281,7 +282,7 @@ def main(): 'node', 'present']), datacenter=dict(type='str'), - **auth_argument_spec() + **AUTH_ARGUMENTS_SPEC ) module = AnsibleModule( diff --git a/plugins/modules/consul_token.py b/plugins/modules/consul_token.py new file mode 100644 index 0000000000..97f433ae29 --- /dev/null +++ b/plugins/modules/consul_token.py @@ -0,0 +1,324 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +module: consul_token +short_description: Manipulate Consul tokens +version_added: 8.3.0 +description: + - Allows the addition, modification and deletion of tokens in a consul + cluster via the agent. For more details on using and configuring ACLs, + see U(https://www.consul.io/docs/guides/acl.html). +author: + - Florian Apolloner (@apollo13) +extends_documentation_fragment: + - community.general.consul + - community.general.consul.token + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: partial + details: + - In check mode the diff will miss operational attributes. +options: + state: + description: + - Whether the token should be present or absent. + choices: ['present', 'absent'] + default: present + type: str + accessor_id: + description: + - Specifies a UUID to use as the token's Accessor ID. + If not specified a UUID will be generated for this field. + type: str + secret_id: + description: + - Specifies a UUID to use as the token's Secret ID. + If not specified a UUID will be generated for this field. + type: str + description: + description: + - Free form human readable description of the token. + type: str + policies: + type: list + elements: dict + description: + - List of policies to attach to the token. Each policy is a dict. + - If the parameter is left blank, any policies currently assigned will not be changed. + - Any empty array (V([])) will clear any policies previously set. + suboptions: + name: + description: + - The name of the policy to attach to this token; see M(community.general.consul_policy) for more info. + - Either this or O(policies[].id) must be specified. + type: str + id: + description: + - The ID of the policy to attach to this token; see M(community.general.consul_policy) for more info. + - Either this or O(policies[].name) must be specified. + type: str + roles: + type: list + elements: dict + description: + - List of roles to attach to the token. Each role is a dict. + - If the parameter is left blank, any roles currently assigned will not be changed. + - Any empty array (V([])) will clear any roles previously set. + suboptions: + name: + description: + - The name of the role to attach to this token; see M(community.general.consul_role) for more info. + - Either this or O(roles[].id) must be specified. + type: str + id: + description: + - The ID of the role to attach to this token; see M(community.general.consul_role) for more info. + - Either this or O(roles[].name) must be specified. + type: str + templated_policies: + description: + - The list of templated policies that should be applied to the role. + type: list + elements: dict + suboptions: + template_name: + description: + - The templated policy name. + type: str + required: true + template_variables: + description: + - The templated policy variables. + - Not all templated policies require variables. + type: dict + service_identities: + type: list + elements: dict + description: + - List of service identities to attach to the token. + - If not specified, any service identities currently assigned will not be changed. + - If the parameter is an empty array (V([])), any node identities assigned will be unassigned. + suboptions: + service_name: + description: + - The name of the service. + - Must not be longer than 256 characters, must start and end with a lowercase alphanumeric character. + - May only contain lowercase alphanumeric characters as well as V(-) and V(_). + type: str + required: true + datacenters: + description: + - The datacenters the token will be effective. + - If an empty array (V([])) is specified, the token will valid in all datacenters. + - including those which do not yet exist but may in the future. + type: list + elements: str + node_identities: + type: list + elements: dict + description: + - List of node identities to attach to the token. + - If not specified, any node identities currently assigned will not be changed. + - If the parameter is an empty array (V([])), any node identities assigned will be unassigned. + suboptions: + node_name: + description: + - The name of the node. + - Must not be longer than 256 characters, must start and end with a lowercase alphanumeric character. + - May only contain lowercase alphanumeric characters as well as V(-) and V(_). + type: str + required: true + datacenter: + description: + - The nodes datacenter. + - This will result in effective token only being valid in this datacenter. + type: str + required: true + local: + description: + - If true, indicates that the token should not be replicated globally + and instead be local to the current datacenter. + type: bool + expiration_ttl: + description: + - This is a convenience field and if set will initialize the C(expiration_time). + Can be specified in the form of V(60s) or V(5m) (that is, 60 seconds or 5 minutes, + respectively). Ingored when the token is updated! + type: str +""" + +EXAMPLES = """ +- name: Create / Update a token by accessor_id + community.general.consul_token: + state: present + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: 8adddd91-0bd6-d41d-ae1a-3b49cfa9a0e8 + roles: + - name: role1 + - name: role2 + service_identities: + - service_name: service1 + datacenters: [dc1, dc2] + node_identities: + - node_name: node1 + datacenter: dc1 + expiration_ttl: 50m + +- name: Delete a token + community.general.consul_token: + state: absent + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: 8adddd91-0bd6-d41d-ae1a-3b49cfa9a0e8 +""" + +RETURN = """ +token: + description: The token as returned by the consul HTTP API. + returned: always + type: dict + sample: + AccessorID: 07a7de84-c9c7-448a-99cc-beaf682efd21 + CreateIndex: 632 + CreateTime: "2024-01-14T21:53:01.402749174+01:00" + Description: Testing + Hash: rj5PeDHddHslkpW7Ij4OD6N4bbSXiecXFmiw2SYXg2A= + Local: false + ModifyIndex: 633 + SecretID: bd380fba-da17-7cee-8576-8d6427c6c930 + ServiceIdentities: [{"ServiceName": "test"}] +operation: + description: The operation performed. + returned: changed + type: str + sample: update +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + _ConsulModule, +) + + +def normalize_link_obj(api_obj, module_obj, key): + api_objs = api_obj.get(key) + module_objs = module_obj.get(key) + if api_objs is None or module_objs is None: + return + name_to_id = {i["Name"]: i["ID"] for i in api_objs} + id_to_name = {i["ID"]: i["Name"] for i in api_objs} + + for obj in module_objs: + identifier = obj.get("ID") + name = obj.get("Name)") + if identifier and not name and identifier in id_to_name: + obj["Name"] = id_to_name[identifier] + if not identifier and name and name in name_to_id: + obj["ID"] = name_to_id[name] + + +class ConsulTokenModule(_ConsulModule): + api_endpoint = "acl/token" + result_key = "token" + unique_identifier = "accessor_id" + + create_only_fields = {"expiration_ttl"} + + def needs_update(self, api_obj, module_obj): + # SecretID is usually not supplied + if "SecretID" not in module_obj and "SecretID" in api_obj: + del api_obj["SecretID"] + normalize_link_obj(api_obj, module_obj, "Roles") + normalize_link_obj(api_obj, module_obj, "Policies") + # ExpirationTTL is only supported on create, not for update + # it writes to ExpirationTime, so we need to remove that as well + if "ExpirationTTL" in module_obj: + del module_obj["ExpirationTTL"] + return super(ConsulTokenModule, self).needs_update(api_obj, module_obj) + + +NAME_ID_SPEC = dict( + name=dict(type="str"), + id=dict(type="str"), +) + +NODE_ID_SPEC = dict( + node_name=dict(type="str", required=True), + datacenter=dict(type="str", required=True), +) + +SERVICE_ID_SPEC = dict( + service_name=dict(type="str", required=True), + datacenters=dict(type="list", elements="str"), +) + +TEMPLATE_POLICY_SPEC = dict( + template_name=dict(type="str", required=True), + template_variables=dict(type="dict"), +) + + +_ARGUMENT_SPEC = { + "description": dict(), + "accessor_id": dict(), + "secret_id": dict(no_log=True), + "roles": dict( + type="list", + elements="dict", + options=NAME_ID_SPEC, + mutually_exclusive=[("name", "id")], + required_one_of=[("name", "id")], + ), + "policies": dict( + type="list", + elements="dict", + options=NAME_ID_SPEC, + mutually_exclusive=[("name", "id")], + required_one_of=[("name", "id")], + ), + "templated_policies": dict( + type="list", + elements="dict", + options=TEMPLATE_POLICY_SPEC, + ), + "node_identities": dict( + type="list", + elements="dict", + options=NODE_ID_SPEC, + ), + "service_identities": dict( + type="list", + elements="dict", + options=SERVICE_ID_SPEC, + ), + "local": dict(type="bool"), + "expiration_ttl": dict(type="str"), + "state": dict(default="present", choices=["present", "absent"]), +} +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) + + +def main(): + module = AnsibleModule( + _ARGUMENT_SPEC, + required_if=[("state", "absent", ["accessor_id"])], + supports_check_mode=True, + ) + consul_module = ConsulTokenModule(module) + consul_module.execute() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/consul/tasks/consul_auth_method.yml b/tests/integration/targets/consul/tasks/consul_auth_method.yml new file mode 100644 index 0000000000..2a5c8f1b8e --- /dev/null +++ b/tests/integration/targets/consul/tasks/consul_auth_method.yml @@ -0,0 +1,79 @@ +--- +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create an auth method + community.general.consul_auth_method: + name: test + type: jwt + config: + jwt_validation_pubkeys: + - | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo + 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u + +qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh + kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ + 0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg + cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc + mwIDAQAB + -----END PUBLIC KEY----- + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is changed + - result.auth_method.Type == 'jwt' + - result.operation == 'create' + +- name: Update auth method + community.general.consul_auth_method: + name: test + max_token_ttl: 30m80s + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is changed + - result.auth_method.Type == 'jwt' + - result.operation == 'update' + +- name: Update auth method (noop) + community.general.consul_auth_method: + name: test + max_token_ttl: 30m80s + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is not changed + - result.auth_method.Type == 'jwt' + - result.operation is not defined + +- name: Delete auth method + community.general.consul_auth_method: + name: test + state: absent + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is changed + - result.operation == 'remove' + +- name: Delete auth method (noop) + community.general.consul_auth_method: + name: test + state: absent + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is not changed + - result.operation is not defined diff --git a/tests/integration/targets/consul/tasks/consul_binding_rule.yml b/tests/integration/targets/consul/tasks/consul_binding_rule.yml new file mode 100644 index 0000000000..20ff5fc696 --- /dev/null +++ b/tests/integration/targets/consul/tasks/consul_binding_rule.yml @@ -0,0 +1,78 @@ +--- +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create an auth method + community.general.consul_auth_method: + name: test + type: jwt + config: + jwt_validation_pubkeys: + - | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo + 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u + +qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh + kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ + 0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg + cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc + mwIDAQAB + -----END PUBLIC KEY----- + token: "{{ consul_management_token }}" + +- name: Create a binding rule + community.general.consul_binding_rule: + name: test-binding + description: my description + auth_method: test + token: "{{ consul_management_token }}" + bind_type: service + bind_name: yolo + register: result + +- assert: + that: + - result is changed + - result.binding_rule.AuthMethod == 'test' + - result.binding.Description == 'test-binding: my description' + - result.operation == 'create' + +- name: Update a binding rule + community.general.consul_binding_rule: + name: test-binding + auth_method: test + token: "{{ consul_management_token }}" + bind_name: yolo2 + register: result + +- assert: + that: + - result is changed + - result.binding.Description == 'test-binding: my description' + - result.operation == 'update' + +- name: Update a binding rule (noop) + community.general.consul_binding_rule: + name: test-binding + auth_method: test + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is not changed + - result.binding.Description == 'test-binding: my description' + - result.operation is not defined + +- name: Delete a binding rule + community.general.consul_binding_rule: + name: test-binding + auth_method: test + state: absent + token: "{{ consul_management_token }}" + register: result +- assert: + that: + - result is changed + - result.operation == 'remove' \ No newline at end of file diff --git a/tests/integration/targets/consul/tasks/consul_policy.yml b/tests/integration/targets/consul/tasks/consul_policy.yml index a28ec09865..bfcd67368f 100644 --- a/tests/integration/targets/consul/tasks/consul_policy.yml +++ b/tests/integration/targets/consul/tasks/consul_policy.yml @@ -19,7 +19,9 @@ - assert: that: - result is changed - - result['policy']['Name'] == 'foo-access' + - result.policy.Name == 'foo-access' + - result.operation == 'create' + - name: Update the rules associated to a policy consul_policy: name: foo-access @@ -35,9 +37,12 @@ } token: "{{ consul_management_token }}" register: result + - assert: that: - result is changed + - result.operation == 'update' + - name: Update reports not changed when updating again without changes consul_policy: name: foo-access @@ -53,9 +58,12 @@ } token: "{{ consul_management_token }}" register: result + - assert: that: - result is not changed + - result.operation is not defined + - name: Remove a policy consul_policy: name: foo-access @@ -64,4 +72,5 @@ register: result - assert: that: - - result is changed \ No newline at end of file + - result is changed + - result.operation == 'remove' \ No newline at end of file diff --git a/tests/integration/targets/consul/tasks/consul_role.yml b/tests/integration/targets/consul/tasks/consul_role.yml index 638af9b1c9..e761b3679f 100644 --- a/tests/integration/targets/consul/tasks/consul_role.yml +++ b/tests/integration/targets/consul/tasks/consul_role.yml @@ -40,7 +40,8 @@ - assert: that: - result is changed - - result['role']['Name'] == 'foo-role-with-policy' + - result.role.Name == 'foo-role-with-policy' + - result.operation == 'create' - name: Update policy description, in check mode consul_role: @@ -53,8 +54,9 @@ - assert: that: - result is changed - - result['role']['Description'] == "Testing updating description" - - result['role']['Policies'][0]['Name'] == 'foo-access-for-role' + - result.role.Description == "Testing updating description" + - result.role.Policies.0.Name == 'foo-access-for-role' + - result.operation == 'update' - name: Update policy to add the description consul_role: @@ -66,8 +68,9 @@ - assert: that: - result is changed - - result['role']['Description'] == "Role for testing policies" - - result['role']['Policies'][0]['Name'] == 'foo-access-for-role' + - result.role.Description == "Role for testing policies" + - result.role.Policies.0.Name == 'foo-access-for-role' + - result.operation == 'update' - name: Update the role with another policy, also testing leaving description blank consul_role: @@ -81,9 +84,10 @@ - assert: that: - result is changed - - result['role']['Policies'][0]['Name'] == 'foo-access-for-role' - - result['role']['Policies'][1]['Name'] == 'bar-access-for-role' - - result['role']['Description'] == "Role for testing policies" + - result.role.Policies.0.Name == 'foo-access-for-role' + - result.role.Policies.1.Name == 'bar-access-for-role' + - result.role.Description == "Role for testing policies" + - result.operation == 'update' - name: Create a role with service identity consul_role: @@ -98,8 +102,8 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'][0]['ServiceName'] == "web" - - result['role']['ServiceIdentities'][0]['Datacenters'][0] == "dc1" + - result.role.ServiceIdentities.0.ServiceName == "web" + - result.role.ServiceIdentities.0.Datacenters.0 == "dc1" - name: Update the role with service identity in check mode consul_role: @@ -115,8 +119,8 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'][0]['ServiceName'] == "web" - - result['role']['ServiceIdentities'][0]['Datacenters'][0] == "dc2" + - result.role.ServiceIdentities.0.ServiceName == "web" + - result.role.ServiceIdentities.0.Datacenters.0 == "dc2" - name: Update the role with service identity to add a policy, leaving the service id unchanged consul_role: @@ -129,9 +133,9 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'][0]['ServiceName'] == "web" - - result['role']['ServiceIdentities'][0]['Datacenters'][0] == "dc1" - - result['role']['Policies'][0]['Name'] == 'foo-access-for-role' + - result.role.ServiceIdentities.0.ServiceName == "web" + - result.role.ServiceIdentities.0.Datacenters.0 == "dc1" + - result.role.Policies.0.Name == 'foo-access-for-role' - name: Update the role with service identity to remove the policies consul_role: @@ -143,9 +147,9 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'][0]['ServiceName'] == "web" - - result['role']['ServiceIdentities'][0]['Datacenters'][0] == "dc1" - - result['role']['Policies'] is not defined + - result.role.ServiceIdentities.0.ServiceName == "web" + - result.role.ServiceIdentities.0.Datacenters.0 == "dc1" + - result.role.Policies is not defined - name: Update the role with service identity to remove the node identities, in check mode consul_role: @@ -158,10 +162,10 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'][0]['ServiceName'] == "web" - - result['role']['ServiceIdentities'][0]['Datacenters'][0] == "dc1" - - result['role']['Policies'] is not defined - - result['role']['NodeIdentities'] == [] # in check mode the cleared field is returned as an empty array + - result.role.ServiceIdentities.0.ServiceName == "web" + - result.role.ServiceIdentities.0.Datacenters.0 == "dc1" + - result.role.Policies is not defined + - result.role.NodeIdentities == [] # in check mode the cleared field is returned as an empty array - name: Update the role with service identity to remove the service identities consul_role: @@ -173,8 +177,8 @@ - assert: that: - result is changed - - result['role']['ServiceIdentities'] is not defined # in normal mode the dictionary is removed from the result - - result['role']['Policies'] is not defined + - result.role.ServiceIdentities is not defined # in normal mode the dictionary is removed from the result + - result.role.Policies is not defined - name: Create a role with node identity consul_role: @@ -188,14 +192,17 @@ - assert: that: - result is changed - - result['role']['NodeIdentities'][0]['NodeName'] == "node-1" - - result['role']['NodeIdentities'][0]['Datacenter'] == "dc2" + - result.role.NodeIdentities.0.NodeName == "node-1" + - result.role.NodeIdentities.0.Datacenter == "dc2" - name: Remove the last role consul_role: token: "{{ consul_management_token }}" name: role-with-node-identity state: absent + register: result + - assert: that: - - result is changed \ No newline at end of file + - result is changed + - result.operation == 'remove' \ No newline at end of file diff --git a/tests/integration/targets/consul/tasks/consul_token.yml b/tests/integration/targets/consul/tasks/consul_token.yml new file mode 100644 index 0000000000..88e5011eab --- /dev/null +++ b/tests/integration/targets/consul/tasks/consul_token.yml @@ -0,0 +1,82 @@ +--- +# Copyright (c) 2024, Florian Apolloner (@apollo13) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a policy with rules + community.general.consul_policy: + name: "{{ item }}" + rules: | + key "foo" { + policy = "read" + } + token: "{{ consul_management_token }}" + loop: + - foo-access + - foo-access2 + +- name: Create token + community.general.consul_token: + state: present + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: "{{ consul_management_token }}" + service_identities: + - service_name: test + datacenters: [test1, test2] + node_identities: + - node_name: test + datacenter: test + policies: + - name: foo-access + - name: foo-access2 + expiration_ttl: 1h + register: create_result + +- assert: + that: + - create_result is changed + - create_result.token.AccessorID == "07a7de84-c9c7-448a-99cc-beaf682efd21" + - create_result.operation == 'create' + +- name: Update token + community.general.consul_token: + state: present + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: "{{ consul_management_token }}" + description: Testing + policies: + - id: "{{ create_result.token.Policies[-1].ID }}" + service_identities: [] + register: result + +- assert: + that: + - result is changed + - result.operation == 'update' + +- name: Update token (noop) + community.general.consul_token: + state: present + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: "{{ consul_management_token }}" + policies: + - id: "{{ create_result.token.Policies[-1].ID }}" + register: result + +- assert: + that: + - result is not changed + - result.operation is not defined + +- name: Remove token + community.general.consul_token: + state: absent + accessor_id: 07a7de84-c9c7-448a-99cc-beaf682efd21 + token: "{{ consul_management_token }}" + register: result + +- assert: + that: + - result is changed + - not result.token + - result.operation == 'remove' diff --git a/tests/integration/targets/consul/tasks/main.yml b/tests/integration/targets/consul/tasks/main.yml index 9f5677cf11..d172a7eaa3 100644 --- a/tests/integration/targets/consul/tasks/main.yml +++ b/tests/integration/targets/consul/tasks/main.yml @@ -77,12 +77,10 @@ - name: Start Consul (dev mode enabled) shell: nohup {{ consul_cmd }} agent -dev -config-file {{ remote_tmp_dir }}/consul_config.hcl /dev/null 2>&1 & - name: Bootstrap ACL - command: '{{ consul_cmd }} acl bootstrap --format=json' - register: consul_bootstrap_result_string + consul_acl_bootstrap: + register: consul_bootstrap_result - set_fact: - consul_management_token: '{{ consul_bootstrap_json_result["SecretID"] }}' - vars: - consul_bootstrap_json_result: '{{ consul_bootstrap_result_string.stdout | from_json }}' + consul_management_token: '{{ consul_bootstrap_result.result.SecretID }}' - name: Create some data command: '{{ consul_cmd }} kv put -token={{consul_management_token}} data/value{{ item }} foo{{ item }}' loop: @@ -94,6 +92,9 @@ - import_tasks: consul_session.yml - import_tasks: consul_policy.yml - import_tasks: consul_role.yml + - import_tasks: consul_token.yml + - import_tasks: consul_auth_method.yml + - import_tasks: consul_binding_rule.yml always: - name: Kill consul process shell: kill $(cat {{ remote_tmp_dir }}/consul.pid)