From a606effdb97b48ef399a2759632eb1e201bf3d38 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 12:39:11 +0100 Subject: [PATCH 1/8] Add route53_ksk module Signed-off-by: Alina Buzachis --- meta/runtime.yml | 1 + plugins/modules/route53_ksk.py | 306 ++++++++++++++++++ plugins/modules/route53_zone.py | 29 ++ tests/integration/targets/route53_ksk/aliases | 1 + .../targets/route53_ksk/tasks/main.yml | 194 +++++++++++ .../route53_ksk/templates/kms_policy.j2 | 29 ++ 6 files changed, 560 insertions(+) create mode 100644 plugins/modules/route53_ksk.py create mode 100644 tests/integration/targets/route53_ksk/aliases create mode 100644 tests/integration/targets/route53_ksk/tasks/main.yml create mode 100644 tests/integration/targets/route53_ksk/templates/kms_policy.j2 diff --git a/meta/runtime.yml b/meta/runtime.yml index 0de862ef237..d35813dee79 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -140,6 +140,7 @@ action_groups: - route53 - route53_health_check - route53_info + - route53_ksk - route53_zone - s3_bucket - s3_bucket_info diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py new file mode 100644 index 00000000000..2130437f292 --- /dev/null +++ b/plugins/modules/route53_ksk.py @@ -0,0 +1,306 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: route53_ksk +short_description: Manages a key-signing key (KSK) +version_added: 5.0.0 +description: + - Creates a new key-signing key (KSK) associated with a hosted zone. + You can only have two KSKs per hosted zone. + - Deletes a key-signing key (KSK). Before you can delete a KSK, you must deactivate it. + The KSK must be deactivated before you can delete it regardless of whether the hosted + zone is enabled for DNSSEC signing. + - Activates a key-signing key (KSK) so that it can be used for signing by DNSSEC. + - Deactivates a key-signing key (KSK) so that it will not be used for signing by DNSSEC. +options: + state: + description: + - Whether or not the zone should exist or not. + default: present + choices: [ "present", "absent" ] + type: str + caller_reference: + description: + - A unique string that identifies the request. + required: true + type: str + hosted_zone_id: + description: + - The unique string (ID) used to identify a hosted zone. + type: str + aliases: ["zone_id"] + key_management_service_arn: + description: + - The Amazon resource name (ARN) for a customer managed key in Key Management Service (KMS). + type: str + aliases: ["kms_arn"] + name: + description: + - A string used to identify a key-signing key (KSK). + type: str + required: true + status: + description: + - A string specifying the initial status of the key-signing key (KSK). + You can set the value to V(ACTIVE) or V(INACTIVE). + type: str + default: "ACTIVE" + wait: + description: + - Wait until the changes have been replicated. + type: bool + default: false + wait_timeout: + description: + - How long to wait for the changes to be replicated, in seconds. + default: 300 + type: int +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +author: + - Alina Buzachis (@alinabuzachis) +""" + +EXAMPLES = r""" +- name: Create a Key Signing Key Request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + +- name: Activate KSK + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "ACTIVE" + state: present + +- name: Delete KSK and deactivate it + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent +""" + +RETURN = r""" +comment: + description: Optional hosted zone comment. + returned: when hosted zone exists + type: str + sample: "Private zone" +name: + description: Hosted zone name. + returned: when hosted zone exists + type: str + sample: "private.local." +private_zone: + description: Whether hosted zone is private or public. + returned: when hosted zone exists + type: bool + sample: true +vpc_id: + description: Id of the first vpc attached to private hosted zone (use vpcs for associating multiple). + returned: for private hosted zone + type: str + sample: "vpc-1d36c84f" +vpc_region: + description: Region of the first vpc attached to private hosted zone (use vpcs for assocaiting multiple). + returned: for private hosted zone + type: str + sample: "eu-west-1" +vpcs: + version_added: 5.3.0 + description: The list of VPCs attached to the private hosted zone. + returned: for private hosted zone + type: list + elements: dict + sample: "[{'id': 'vpc-123456', 'region': 'us-west-2'}]" + contains: + id: + description: ID of the VPC. + returned: for private hosted zone + type: str + sample: "vpc-123456" + region: + description: Region of the VPC. + returned: for private hosted zone + type: str + sample: "eu-west-2" +zone_id: + description: Hosted zone id. + returned: when hosted zone exists + type: str + sample: "Z6JQG9820BEFMW" +delegation_set_id: + description: Id of the associated reusable delegation set. + returned: for public hosted zones, if they have been associated with a reusable delegation set + type: str + sample: "A1BCDEF2GHIJKL" +tags: + description: Tags associated with the zone. + returned: when tags are defined + type: dict +""" + +import datetime + +try: + import botocore +except ImportError: + pass # Handled by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter + + +def deactivate(client, hosted_zone_id, name): + return client.deactivate_key_signing_key( + HostedZoneId=hosted_zone_id, + Name=name + ) + + +def activate(client, hosted_zone_id, name): + return client.activate_key_signing_key( + HostedZoneId=hosted_zone_id, + Name=name + ) + + +def wait(client, module, change_id): + try: + waiter = get_waiter(client, "resource_record_sets_changed") + waiter.wait( + Id=change_id, + WaiterConfig=dict( + Delay=5, + MaxAttempts=module.params.get("wait_timeout") // 5, + ), + ) + except botocore.exceptions.WaiterError as e: + module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") + + +def create(client, module: AnsibleAWSModule): + # The API does not raise KeySigningKeyAlreadyExists when a request with the same name and + # KMS arn already exist. It will always try to create a new KSK request. + changed: bool = True + zone_id = module.params.get("hosted_zone_id") + name = module.params.get("name") + status = module.params.get("status") + request_time = datetime.datetime.utcnow() + + response = client.create_key_signing_key( + CallerReference=module.params.get("caller_reference"), + KeyManagementServiceArn=module.params.get("key_management_service_arn"), + HostedZoneId=zone_id, + Name=name, + Status=status + ) + + if response and response.get("ChangeInfo", {}): + submitted_at = response["ChangeInfo"].get("SubmittedAt").replace(tzinfo=None) + if submitted_at < request_time: + # A KSK request already exists. + if response["KeySigningKey"]["Status"] != status: + # Wait before activating or deactivating to reach INSYNC state + change_id = response["ChangeInfo"]["Id"] + wait(client, module, change_id) + + if module.params.get("status") == "ACTIVE": + response = activate(client, zone_id, name) + elif module.params.get('status') == "INACTIVE": + response = deactivate(client, zone_id, name) + else: + changed = False + + if module.params.get("wait"): + change_id = response["ChangeInfo"]["Id"] + wait(client, module, change_id) + else: + changed = False + + return changed, response + + +def delete(client, module: AnsibleAWSModule): + changed: bool = False + zone_id = module.params.get("hosted_zone_id") + name = module.params.get("name") + + if module.params.get('status') == "INACTIVE": + # Deactivate the Key Signing Request before deleting + result = deactivate(client, zone_id, name) + change_id = result["ChangeInfo"]["Id"] + wait(client, module, change_id) + try: + response = client.delete_key_signing_key( + HostedZoneId=zone_id, + Name=name + ) + changed = True + + if module.params.get("wait"): + change_id = response["ChangeInfo"]["Id"] + wait(client, module, change_id) + except is_boto3_error_code("NoSuchKeySigningKey"): + pass + + return changed, response + + +def main() -> None: + argument_spec = dict( + caller_reference=dict(type="str"), + hosted_zone_id=dict(type="str", aliases=["zone_id"], required=True), + key_management_service_arn=dict(type="str", aliases=["kms_arn"]), + name=dict(type="str", required=True), + status=dict(type="str", default=["ACTIVE"], choices=["ACTIVE", "INACTIVE"]), + state=dict(default="present", choices=["present", "absent"]), + wait=dict(type="bool", default=False), + wait_timeout=dict(type="int", default=300), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + required_if=[["state", "present", ["caller_reference", "key_management_service_arn"]]], + ) + + try: + client = module.client("route53") + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to connect to AWS.") + + changed = False + state = module.params.get("state") + + try: + if state == "present": + changed, result = create(client, module) + else: + changed, result = delete(client, module) + except AnsibleAWSError as e: + module.fail_json_aws_error(e) + + del result["ResponseMetadata"] + module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index 1b20d9261e4..0436831ec92 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -63,6 +63,12 @@ - The reusable delegation set ID to be associated with the zone. - Note that you can't associate a reusable delegation set with a private hosted zone. type: str + dnssec: + description: + - Enables DNSSEC signing in a specific hosted zone. + type: bool + default: false + version_added: 9.2.0 extends_documentation_fragment: - amazon.aws.common.modules - amazon.aws.region.modules @@ -254,6 +260,29 @@ def create(matching_zones): changed, result = create_or_update_public(matching_zones, record) zone_id = result.get("zone_id") + + # enable/disable dnssec if not already enabled + #dnssec = module.params.get("dnssec") + # get dnsec on zonde_if + #response = client.get_dnssec(HostedZoneId='string') + # If get-dnssec command output returns "NOT_SIGNING", + # the Domain Name System Security Extensions (DNSSEC) signing is not enabled for the + # Amazon Route 53 hosted zone. + # if dnssec is True: + # if response.get("Status", None).get("ServeSignature", None) == "NOT_SIGNING": + # # enable + # client.enable_hosted_zone_dnssec(HostedZoneId=zone_id) + # elif response.get("Status", None).get("ServeSignature", None) == "DELETING": + # # wait and enabled + # pass + # elif dnssec is False: + # if response.get("Status", None).get("ServeSignature", None) == "SIGNING": + # # disable + # client.disable_hosted_zone_dnssec(HostedZoneId=zone_id) + # elif response.get("Status", None).get("ServeSignature", None) == "DELETING": + # # changed false + # pass + if zone_id: if tags is not None: changed |= manage_tags(module, client, "hostedzone", zone_id, tags, purge_tags) diff --git a/tests/integration/targets/route53_ksk/aliases b/tests/integration/targets/route53_ksk/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/route53_ksk/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/route53_ksk/tasks/main.yml b/tests/integration/targets/route53_ksk/tasks/main.yml new file mode 100644 index 00000000000..0c69df8a04f --- /dev/null +++ b/tests/integration/targets/route53_ksk/tasks/main.yml @@ -0,0 +1,194 @@ +--- +- name: Integration tests for the route53_ksk module + module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + block: + - name: Get ARN of calling user + amazon.aws.aws_caller_info: + register: aws_caller_info + + - name: Create a KMS key + amazon.aws.kms_key: + alias: "{{ resource_prefix }}-kms-route53" + policy: "{{ lookup('template', 'kms_policy.j2') }}" + state: present + enabled: true + customer_master_key_spec: "ECC_NIST_P256" + key_usage: "SIGN_VERIFY" + register: kms_key + + - name: Create a Route53 public zone + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + comment: "Route53 Zone for KSK Testing" + state: present + register: _hosted_zone + + - name: Create a Key Signing Key request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - _ksk_request.changed + - '"key_signing_key" in _ksk_request' + + - name: Create a Key Signing Key request (idempotency) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - not _ksk_request.changed + - '"key_signing_key" in _ksk_request' + + - name: Activate the Key Signing Key request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "ACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - _ksk_request.changed + + - name: Activate KSK (idempotency) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "ACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - not _ksk_request.changed + + - name: Deactivate the Key Signing Key request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - _ksk_request.changed + + - name: Deactivate the Key Signing Key request (idempotency) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - not _ksk_request.changed + + - name: Delete the Key Signing Key request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - _ksk_request.changed + + - name: Delete the Key Signing Key request (idempotency) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - '"change_info" in _ksk_request' + - not _ksk_request.changed + + # it shouldn't fail, but fails for the moment + - name: Delete a not existing Key Signing Key request + amazon.aws.route53_ksk: + name: "not existing ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent + ignore_errors: true + + always: + - name: Delete the Key Signing Key Request + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent + ignore_errors: true + + - name: Delete the Route53 public zone + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: absent + ignore_errors: true + + - name: Delete the KSM key + amazon.aws.kms_key: + state: absent + alias: "{{ resource_prefix }}-kms-route53" + pending_window: 7 + ignore_errors: true diff --git a/tests/integration/targets/route53_ksk/templates/kms_policy.j2 b/tests/integration/targets/route53_ksk/templates/kms_policy.j2 new file mode 100644 index 00000000000..4b7a8bbf473 --- /dev/null +++ b/tests/integration/targets/route53_ksk/templates/kms_policy.j2 @@ -0,0 +1,29 @@ +{ + "Id": "dnssec-policy", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Allow Route 53 DNSSEC Service", + "Effect": "Allow", + "Principal": { + "Service": "dnssec-route53.amazonaws.com" + }, + "Action": [ + "kms:DescribeKey", + "kms:GetPublicKey", + "kms:Sign", + "kms:Verify" + ], + "Resource": "*" + }, + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::{{ aws_caller_info.account }}:root" + }, + "Action": "kms:*", + "Resource": "*" + } + ] +} From f958e86843707e60b02c6fa5f0bedad4024a8bb6 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 17:01:41 +0100 Subject: [PATCH 2/8] Add support for dnssec Signed-off-by: Alina Buzachis --- plugins/modules/route53_ksk.py | 162 ++++++++----- plugins/modules/route53_zone.py | 220 ++++++++++++++---- .../targets/route53_ksk/tasks/main.yml | 79 ++++++- 3 files changed, 361 insertions(+), 100 deletions(-) diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py index 2130437f292..250db891b87 100644 --- a/plugins/modules/route53_ksk.py +++ b/plugins/modules/route53_ksk.py @@ -95,63 +95,103 @@ """ RETURN = r""" -comment: - description: Optional hosted zone comment. - returned: when hosted zone exists - type: str - sample: "Private zone" -name: - description: Hosted zone name. - returned: when hosted zone exists - type: str - sample: "private.local." -private_zone: - description: Whether hosted zone is private or public. - returned: when hosted zone exists - type: bool - sample: true -vpc_id: - description: Id of the first vpc attached to private hosted zone (use vpcs for associating multiple). - returned: for private hosted zone - type: str - sample: "vpc-1d36c84f" -vpc_region: - description: Region of the first vpc attached to private hosted zone (use vpcs for assocaiting multiple). - returned: for private hosted zone - type: str - sample: "eu-west-1" -vpcs: - version_added: 5.3.0 - description: The list of VPCs attached to the private hosted zone. - returned: for private hosted zone - type: list - elements: dict - sample: "[{'id': 'vpc-123456', 'region': 'us-west-2'}]" +change_info: + description: A dictionary that escribes change information about changes made to your hosted zone. + returned: when the Key Signing Request to be deleted exists + type: dict contains: id: - description: ID of the VPC. - returned: for private hosted zone + description: Change ID. type: str - sample: "vpc-123456" - region: - description: Region of the VPC. - returned: for private hosted zone + status: + description: The current state of the request. type: str - sample: "eu-west-2" -zone_id: - description: Hosted zone id. - returned: when hosted zone exists - type: str - sample: "Z6JQG9820BEFMW" -delegation_set_id: - description: Id of the associated reusable delegation set. - returned: for public hosted zones, if they have been associated with a reusable delegation set + submitted_at: + description: The date and time that the change request was submitted in ISO 8601 format and Coordinated Universal Time (UTC). + type: str + comment: + description: A comment you can provide. + type: str + sample: { + "id": "/change/C090307813XORZJ5J3U4", + "status": "PENDING", + "submitted_at": "2024-12-04T15:15:36.743000+00:00" + } +location: + description: The unique URL representing the new key-signing key (KSK). + returned: when O(state=present) type: str - sample: "A1BCDEF2GHIJKL" -tags: - description: Tags associated with the zone. - returned: when tags are defined + sample: "https://route53.amazonaws.com/2013-04-01/keysigningkey/xxx/ansible-test-ksk" +key_signing_key: + description: + returned: only when a new Key Signing Request is created type: dict + contains: + name: + description: A string used to identify a key-signing key (KSK). + type: str + kms_arn: + description: The Amazon resource name (ARN) used to identify the customer managed key in Key Management Service (KMS). + type: str + flag: + description: An integer that specifies how the key is used. + type: int + signing_algorithm_mnemonic: + description: A string used to represent the signing algorithm. + type: str + signing_algorithm_type: + description: An integer used to represent the signing algorithm. + type: str + digest_algorithm_mnemonic: + description: A string used to represent the delegation signer digest algorithm. + type: str + digest_algorithm_type: + description: An integer used to represent the delegation signer digest algorithm. + type: str + key_tag: + description: An integer used to identify the DNSSEC record for the domain name. + type: str + digest_value: + description: A cryptographic digest of a DNSKEY resource record (RR). + type: str + public_key: + description: The public key, represented as a Base64 encoding. + type: str + ds_record: + description: A string that represents a delegation signer (DS) record. + type: str + dnskey_record: + description: A string that represents a DNSKEY record. + type: str + status: + description: A string that represents the current key-signing key (KSK) status. + type: str + status_message: + description: The status message provided for ACTION_NEEDED or INTERNAL_FAILURE statuses. + type: str + created_date: + description: The date when the key-signing key (KSK) was created. + type: str + last_modified_date: + description: The last time that the key-signing key (KSK) was changed. + type: str + sample: { + "created_date": "2024-12-04T15:15:36.715000+00:00", + "digest_algorithm_mnemonic": "SHA-256", + "digest_algorithm_type": 2, + "digest_value": "xxx", + "dnskey_record": "xxx", + "ds_record": "xxx", + "flag": 257, + "key_tag": 18948, + "kms_arn": "arn:aws:kms:us-east-1:xxx:key/xxx", + "last_modified_date": "2024-12-04T15:15:36.715000+00:00", + "name": "ansible-test-44230979--ksk", + "public_key": "xxxx", + "signing_algorithm_mnemonic": "ECDSAP256SHA256", + "signing_algorithm_type": 13, + "status": "INACTIVE" + } """ import datetime @@ -183,6 +223,10 @@ def activate(client, hosted_zone_id, name): ) +def get_change(client, change_id): + return client.get_change(Id=change_id) + + def wait(client, module, change_id): try: waiter = get_waiter(client, "resource_record_sets_changed") @@ -233,6 +277,7 @@ def create(client, module: AnsibleAWSModule): if module.params.get("wait"): change_id = response["ChangeInfo"]["Id"] wait(client, module, change_id) + response["ChangeInfo"] = get_change(client, change_id) else: changed = False @@ -245,8 +290,12 @@ def delete(client, module: AnsibleAWSModule): name = module.params.get("name") if module.params.get('status') == "INACTIVE": - # Deactivate the Key Signing Request before deleting - result = deactivate(client, zone_id, name) + try: + # Deactivate the Key Signing Request before deleting + result = deactivate(client, zone_id, name) + except is_boto3_error_code("NoSuchKeySigningKey"): + return changed, {} + change_id = result["ChangeInfo"]["Id"] wait(client, module, change_id) try: @@ -259,8 +308,9 @@ def delete(client, module: AnsibleAWSModule): if module.params.get("wait"): change_id = response["ChangeInfo"]["Id"] wait(client, module, change_id) + response["ChangeInfo"] = get_change(client, change_id) except is_boto3_error_code("NoSuchKeySigningKey"): - pass + return changed, {} return changed, response @@ -298,7 +348,9 @@ def main() -> None: except AnsibleAWSError as e: module.fail_json_aws_error(e) - del result["ResponseMetadata"] + if "ResponseMetadata" in result: + del result["ResponseMetadata"] + module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index 0436831ec92..c2eb956e2d0 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -184,6 +184,93 @@ returned: for public hosted zones, if they have been associated with a reusable delegation set type: str sample: "A1BCDEF2GHIJKL" +dnssec: + description: Information about DNSSEC for a specific hosted zone. + returned: when O(state=present) + version_added: 9.2.0 + type: dict + contains: + key_signing_key: + description: + returned: when O(state=present) + type: list + elements: dict + contains: + name: + description: A string used to identify a key-signing key (KSK). + type: str + kms_arn: + description: The Amazon resource name (ARN) used to identify the customer managed key in Key Management Service (KMS). + type: str + flag: + description: An integer that specifies how the key is used. + type: int + signing_algorithm_mnemonic: + description: A string used to represent the signing algorithm. + type: str + signing_algorithm_type: + description: An integer used to represent the signing algorithm. + type: str + digest_algorithm_mnemonic: + description: A string used to represent the delegation signer digest algorithm. + type: str + digest_algorithm_type: + description: An integer used to represent the delegation signer digest algorithm. + type: str + key_tag: + description: An integer used to identify the DNSSEC record for the domain name. + type: str + digest_value: + description: A cryptographic digest of a DNSKEY resource record (RR). + type: str + public_key: + description: The public key, represented as a Base64 encoding. + type: str + ds_record: + description: A string that represents a delegation signer (DS) record. + type: str + dnskey_record: + description: A string that represents a DNSKEY record. + type: str + status: + description: A string that represents the current key-signing key (KSK) status. + type: str + status_message: + description: The status message provided for ACTION_NEEDED or INTERNAL_FAILURE statuses. + type: str + created_date: + description: The date when the key-signing key (KSK) was created. + type: str + last_modified_date: + description: The last time that the key-signing key (KSK) was changed. + type: str + sample: [{ + "created_date": "2024-12-04T15:15:36.715000+00:00", + "digest_algorithm_mnemonic": "SHA-256", + "digest_algorithm_type": 2, + "digest_value": "xxx", + "dnskey_record": "xxx", + "ds_record": "xxx", + "flag": 257, + "key_tag": 18948, + "kms_arn": "arn:aws:kms:us-east-1:xxx:key/xxx", + "last_modified_date": "2024-12-04T15:15:36.715000+00:00", + "name": "ansible-test-44230979--ksk", + "public_key": "xxxx", + "signing_algorithm_mnemonic": "ECDSAP256SHA256", + "signing_algorithm_type": 13, + "status": "INACTIVE" + }] + status: + description: A dictionary representing the status of DNSSEC. + type: dict + contains: + serve_signature: + description: A string that represents the current hosted zone signing status. + type: str + sample: { + "serve_signature": "SIGNING" + } tags: description: Tags associated with the zone. returned: when tags are defined @@ -192,14 +279,20 @@ import time +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry from ansible_collections.amazon.aws.plugins.module_utils.route53 import get_tags from ansible_collections.amazon.aws.plugins.module_utils.route53 import manage_tags +from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code + try: from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError + from botocore.exceptions import WaiterError except ImportError: pass # caught by AnsibleAWSModule @@ -228,6 +321,68 @@ def find_zones(zone_in, private_zone): return zones +def get_dnssec(client, module, zone_id): + try: + response = client.get_dnssec(HostedZoneId=zone_id) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Could not get DNSSEC for {zone_id}") + return response + + +def ensure_dnssec(client, module, zone_id): + changed = False + dnssec = module.params.get("dnssec") + + response = get_dnssec(client, module, zone_id) + dnssec_status = response["Status"]["ServeSignature"] + + # If get_dnssec command output returns "NOT_SIGNING", + # the Domain Name System Security Extensions (DNSSEC) signing is not enabled for the + # Amazon Route 53 hosted zone. + if dnssec is True: + if dnssec_status == "NOT_SIGNING": + # Enable DNSSEC + if not module.check_mode: + try: + client.enable_hosted_zone_dnssec(HostedZoneId=zone_id) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Could not enable DNSSEC for {zone_id}") + changed = True + elif dnssec_status == "DELETING": + # DNSSEC signing is in the process of being removed for the hosted zone. + module.warn( + f"DNSSEC signing is in the process of being removed for the hosted zone: {zone_id}." + "Could not enable it." + ) + elif dnssec is False: + if dnssec_status == "SIGNING": + # Disable DNSSEC + if not module.check_mode: + try: + client.disable_hosted_zone_dnssec(HostedZoneId=zone_id) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Could not enable DNSSEC for {zone_id}") + changed = True + # if dnssec_status == "DELETING": + # DNSSEC signing is in the process of being removed for the hosted zone. + + return changed + +def wait(client, module, change_id): + try: + waiter = get_waiter(client, "resource_record_sets_changed") + waiter.wait( + Id=change_id, + WaiterConfig=dict( + Delay=5, + MaxAttempts=10, + ), + ) + except WaiterError as e: + module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") + + + def create(matching_zones): zone_in = module.params.get("zone").lower() vpc_id = module.params.get("vpc_id") @@ -261,29 +416,15 @@ def create(matching_zones): zone_id = result.get("zone_id") - # enable/disable dnssec if not already enabled - #dnssec = module.params.get("dnssec") - # get dnsec on zonde_if - #response = client.get_dnssec(HostedZoneId='string') - # If get-dnssec command output returns "NOT_SIGNING", - # the Domain Name System Security Extensions (DNSSEC) signing is not enabled for the - # Amazon Route 53 hosted zone. - # if dnssec is True: - # if response.get("Status", None).get("ServeSignature", None) == "NOT_SIGNING": - # # enable - # client.enable_hosted_zone_dnssec(HostedZoneId=zone_id) - # elif response.get("Status", None).get("ServeSignature", None) == "DELETING": - # # wait and enabled - # pass - # elif dnssec is False: - # if response.get("Status", None).get("ServeSignature", None) == "SIGNING": - # # disable - # client.disable_hosted_zone_dnssec(HostedZoneId=zone_id) - # elif response.get("Status", None).get("ServeSignature", None) == "DELETING": - # # changed false - # pass - if zone_id: + # Enable/Disable DNSSEC + changed |= ensure_dnssec(client, module, zone_id) + + # Update result with information about DNSSEC + result["dnssec"] = camel_dict_to_snake_dict(get_dnssec(client, module, zone_id)) + del result["dnssec"]["response_metadata"] + + # Handle Tags if tags is not None: changed |= manage_tags(module, client, "hostedzone", zone_id, tags, purge_tags) result["tags"] = get_tags(module, client, "hostedzone", zone_id) @@ -437,10 +578,7 @@ def delete_private(matching_zones, vpcs): if isinstance(vpc_details, dict): if vpc_details["VPC"]["VPCId"] == vpcs[0]["id"] and vpcs[0]["region"] == vpc_details["VPC"]["VPCRegion"]: if not module.check_mode: - try: - client.delete_hosted_zone(Id=z["Id"]) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Could not delete hosted zone {z['Id']}") + delete_hosted_zone(client, module, z["Id"]) return True, f"Successfully deleted {zone_details['Name']}" else: # Sort the lists and compare them to make sure they contain the same items @@ -448,10 +586,7 @@ def delete_private(matching_zones, vpcs): [vpc["region"] for vpc in vpcs] ) == sorted([v["VPCRegion"] for v in vpc_details]): if not module.check_mode: - try: - client.delete_hosted_zone(Id=z["Id"]) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Could not delete hosted zone {z['Id']}") + delete_hosted_zone(client, module, z["Id"]) return True, f"Successfully deleted {zone_details['Name']}" return False, "The VPCs do not match a private hosted zone." @@ -463,10 +598,7 @@ def delete_public(matching_zones): msg = "There are multiple zones that match. Use hosted_zone_id to specify the correct zone." else: if not module.check_mode: - try: - client.delete_hosted_zone(Id=matching_zones[0]["Id"]) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Could not get delete hosted zone {matching_zones[0]['Id']}") + delete_hosted_zone(client, module, matching_zones[0]["Id"]) changed = True msg = f"Successfully deleted {matching_zones[0]['Id']}" return changed, msg @@ -478,18 +610,12 @@ def delete_hosted_id(hosted_zone_id, matching_zones): for z in matching_zones: deleted.append(z["Id"]) if not module.check_mode: - try: - client.delete_hosted_zone(Id=z["Id"]) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Could not delete hosted zone {z['Id']}") + delete_hosted_zone(client, module, z["Id"]) changed = True msg = f"Successfully deleted zones: {deleted}" elif hosted_zone_id in [zo["Id"].replace("/hostedzone/", "") for zo in matching_zones]: if not module.check_mode: - try: - client.delete_hosted_zone(Id=hosted_zone_id) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Could not delete hosted zone {hosted_zone_id}") + delete_hosted_zone(client, module, hosted_zone_id) changed = True msg = f"Successfully deleted zone: {hosted_zone_id}" else: @@ -498,6 +624,15 @@ def delete_hosted_id(hosted_zone_id, matching_zones): return changed, msg +def delete_hosted_zone(client, module, hosted_zone_id): + try: + client.delete_hosted_zone(Id=hosted_zone_id) + except is_boto3_error_code("HostedZoneNotEmpty") as e: + module.fail_json_aws(e, msg=f"Could not get delete hosted zone {hosted_zone_id}") + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Could not delete hosted zone {hosted_zone_id}") + + def delete(matching_zones): zone_in = module.params.get("zone").lower() vpc_id = module.params.get("vpc_id") @@ -542,6 +677,7 @@ def main(): delegation_set_id=dict(), tags=dict(type="dict", aliases=["resource_tags"]), purge_tags=dict(type="bool", default=True), + dnssec=dict(type="bool", default=False), ) mutually_exclusive = [ diff --git a/tests/integration/targets/route53_ksk/tasks/main.yml b/tests/integration/targets/route53_ksk/tasks/main.yml index 0c69df8a04f..433ab4c696f 100644 --- a/tests/integration/targets/route53_ksk/tasks/main.yml +++ b/tests/integration/targets/route53_ksk/tasks/main.yml @@ -72,6 +72,7 @@ caller_reference: "{{ aws_caller_info.arn }}" status: "ACTIVE" state: present + wait: true register: _ksk_request - name: Assert success @@ -98,6 +99,74 @@ - '"change_info" in _ksk_request' - not _ksk_request.changed + - name: Enable DNSSEC for Route53 public zone (check_mode) + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: present + dnssec: true + check_mode: true + register: _hosted_zone_dnssec + + - name: Assert success + ansible.builtin.assert: + that: + - _hosted_zone_dnssec is successful + - _hosted_zone_dnssec.changed + + - name: Enable DNSSEC for Route53 public zone + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: present + dnssec: true + register: _hosted_zone_dnssec + + - name: Assert success + ansible.builtin.assert: + that: + - _hosted_zone_dnssec is successful + - _hosted_zone_dnssec.changed + + - name: Disable DNSSEC for Route53 public zone (check_mode) + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: present + dnssec: false + check_mode: true + register: _hosted_zone_dnssec + + - name: Assert success + ansible.builtin.assert: + that: + - _hosted_zone_dnssec is successful + - _hosted_zone_dnssec.changed + + - name: Disable DNSSEC for Route53 public zone + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: present + dnssec: false + register: _hosted_zone_dnssec + + - name: Assert success + ansible.builtin.assert: + that: + - _hosted_zone_dnssec is successful + - _hosted_zone_dnssec.changed + + - name: Delete the Route53 public zone (expected to fail) + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: absent + register: _delete_hosted_zone + ignore_errors: true + + - name: Assert success + ansible.builtin.assert: + that: + - _delete_hosted_zone is not successful + - '"msg" in _delete_hosted_zone' + - '"The specified hosted zone contains DNSSEC Key Signing Keys and so cannot be deleted" in _delete_hosted_zone.msg' + - name: Deactivate the Key Signing Key request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" @@ -159,17 +228,21 @@ ansible.builtin.assert: that: - _ksk_request is successful - - '"change_info" in _ksk_request' - not _ksk_request.changed - # it shouldn't fail, but fails for the moment - name: Delete a not existing Key Signing Key request amazon.aws.route53_ksk: name: "not existing ksk" hosted_zone_id: "{{ _hosted_zone.zone_id }}" status: "INACTIVE" state: absent - ignore_errors: true + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - not _ksk_request.changed always: - name: Delete the Key Signing Key Request From 201ee31f597c2eb4ebe9241ec1ceeb6c8f9fadbd Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 17:04:06 +0100 Subject: [PATCH 3/8] Add changelog fragment Signed-off-by: Alina Buzachis --- changelogs/fragments/20241203-route53-dnssec.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/20241203-route53-dnssec.yml diff --git a/changelogs/fragments/20241203-route53-dnssec.yml b/changelogs/fragments/20241203-route53-dnssec.yml new file mode 100644 index 00000000000..e2cfb475eb2 --- /dev/null +++ b/changelogs/fragments/20241203-route53-dnssec.yml @@ -0,0 +1,2 @@ +minor_changes: + - route53_zone - Add support for enableing DNSSEC signing in a specific hosted zone (https://github.com/ansible-collections/amazon.aws/issues/1976). From c66a5bda8f40dfcd159e9c89aea0ab5e485ad20e Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 17:04:44 +0100 Subject: [PATCH 4/8] Run black and isort Signed-off-by: Alina Buzachis --- plugins/modules/route53_ksk.py | 29 ++++++++++------------------- plugins/modules/route53_zone.py | 5 ++--- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py index 250db891b87..4a8eac14f7f 100644 --- a/plugins/modules/route53_ksk.py +++ b/plugins/modules/route53_ksk.py @@ -203,24 +203,18 @@ from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict -from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule -from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter def deactivate(client, hosted_zone_id, name): - return client.deactivate_key_signing_key( - HostedZoneId=hosted_zone_id, - Name=name - ) + return client.deactivate_key_signing_key(HostedZoneId=hosted_zone_id, Name=name) def activate(client, hosted_zone_id, name): - return client.activate_key_signing_key( - HostedZoneId=hosted_zone_id, - Name=name - ) + return client.activate_key_signing_key(HostedZoneId=hosted_zone_id, Name=name) def get_change(client, change_id): @@ -238,7 +232,7 @@ def wait(client, module, change_id): ), ) except botocore.exceptions.WaiterError as e: - module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") + module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") def create(client, module: AnsibleAWSModule): @@ -255,7 +249,7 @@ def create(client, module: AnsibleAWSModule): KeyManagementServiceArn=module.params.get("key_management_service_arn"), HostedZoneId=zone_id, Name=name, - Status=status + Status=status, ) if response and response.get("ChangeInfo", {}): @@ -269,7 +263,7 @@ def create(client, module: AnsibleAWSModule): if module.params.get("status") == "ACTIVE": response = activate(client, zone_id, name) - elif module.params.get('status') == "INACTIVE": + elif module.params.get("status") == "INACTIVE": response = deactivate(client, zone_id, name) else: changed = False @@ -289,7 +283,7 @@ def delete(client, module: AnsibleAWSModule): zone_id = module.params.get("hosted_zone_id") name = module.params.get("name") - if module.params.get('status') == "INACTIVE": + if module.params.get("status") == "INACTIVE": try: # Deactivate the Key Signing Request before deleting result = deactivate(client, zone_id, name) @@ -299,10 +293,7 @@ def delete(client, module: AnsibleAWSModule): change_id = result["ChangeInfo"]["Id"] wait(client, module, change_id) try: - response = client.delete_key_signing_key( - HostedZoneId=zone_id, - Name=name - ) + response = client.delete_key_signing_key(HostedZoneId=zone_id, Name=name) changed = True if module.params.get("wait"): @@ -310,7 +301,7 @@ def delete(client, module: AnsibleAWSModule): wait(client, module, change_id) response["ChangeInfo"] = get_change(client, change_id) except is_boto3_error_code("NoSuchKeySigningKey"): - return changed, {} + return changed, {} return changed, response diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index c2eb956e2d0..6f0c1443c74 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -281,13 +281,12 @@ from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry from ansible_collections.amazon.aws.plugins.module_utils.route53 import get_tags from ansible_collections.amazon.aws.plugins.module_utils.route53 import manage_tags from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code - try: from botocore.exceptions import BotoCoreError @@ -368,6 +367,7 @@ def ensure_dnssec(client, module, zone_id): return changed + def wait(client, module, change_id): try: waiter = get_waiter(client, "resource_record_sets_changed") @@ -382,7 +382,6 @@ def wait(client, module, change_id): module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") - def create(matching_zones): zone_in = module.params.get("zone").lower() vpc_id = module.params.get("vpc_id") From bb0d46d704aabf8a989b887cbb687314b38ffbbf Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 17:49:59 +0100 Subject: [PATCH 5/8] Minor updates Signed-off-by: Alina Buzachis --- plugins/modules/route53_ksk.py | 10 ++++++---- plugins/modules/route53_zone.py | 22 +++------------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py index 4a8eac14f7f..b0c52ccc6e4 100644 --- a/plugins/modules/route53_ksk.py +++ b/plugins/modules/route53_ksk.py @@ -7,7 +7,7 @@ DOCUMENTATION = r""" module: route53_ksk short_description: Manages a key-signing key (KSK) -version_added: 5.0.0 +version_added: 9.2.0 description: - Creates a new key-signing key (KSK) associated with a hosted zone. You can only have two KSKs per hosted zone. @@ -63,6 +63,8 @@ - amazon.aws.common.modules - amazon.aws.region.modules - amazon.aws.boto3 +notes: + - This module does not support check_mode. author: - Alina Buzachis (@alinabuzachis) """ @@ -141,16 +143,16 @@ type: str signing_algorithm_type: description: An integer used to represent the signing algorithm. - type: str + type: int digest_algorithm_mnemonic: description: A string used to represent the delegation signer digest algorithm. type: str digest_algorithm_type: description: An integer used to represent the delegation signer digest algorithm. - type: str + type: int key_tag: description: An integer used to identify the DNSSEC record for the domain name. - type: str + type: int digest_value: description: A cryptographic digest of a DNSKEY resource record (RR). type: str diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index 6f0c1443c74..5fdf453280a 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -210,16 +210,16 @@ type: str signing_algorithm_type: description: An integer used to represent the signing algorithm. - type: str + type: int digest_algorithm_mnemonic: description: A string used to represent the delegation signer digest algorithm. type: str digest_algorithm_type: description: An integer used to represent the delegation signer digest algorithm. - type: str + type: int key_tag: description: An integer used to identify the DNSSEC record for the domain name. - type: str + type: int digest_value: description: A cryptographic digest of a DNSKEY resource record (RR). type: str @@ -286,12 +286,10 @@ from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry from ansible_collections.amazon.aws.plugins.module_utils.route53 import get_tags from ansible_collections.amazon.aws.plugins.module_utils.route53 import manage_tags -from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter try: from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError - from botocore.exceptions import WaiterError except ImportError: pass # caught by AnsibleAWSModule @@ -368,20 +366,6 @@ def ensure_dnssec(client, module, zone_id): return changed -def wait(client, module, change_id): - try: - waiter = get_waiter(client, "resource_record_sets_changed") - waiter.wait( - Id=change_id, - WaiterConfig=dict( - Delay=5, - MaxAttempts=10, - ), - ) - except WaiterError as e: - module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") - - def create(matching_zones): zone_in = module.params.get("zone").lower() vpc_id = module.params.get("vpc_id") From 01aaf1e8390458d7943c4af1a5ea6bac7ba39ef0 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Wed, 4 Dec 2024 18:34:27 +0100 Subject: [PATCH 6/8] Fix sanity Signed-off-by: Alina Buzachis --- plugins/modules/route53_ksk.py | 9 +++++---- plugins/modules/route53_zone.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py index b0c52ccc6e4..1e3c2f52c14 100644 --- a/plugins/modules/route53_ksk.py +++ b/plugins/modules/route53_ksk.py @@ -26,12 +26,12 @@ caller_reference: description: - A unique string that identifies the request. - required: true type: str hosted_zone_id: description: - The unique string (ID) used to identify a hosted zone. type: str + required: true aliases: ["zone_id"] key_management_service_arn: description: @@ -49,6 +49,7 @@ You can set the value to V(ACTIVE) or V(INACTIVE). type: str default: "ACTIVE" + choices: ["ACTIVE", "INACTIVE"] wait: description: - Wait until the changes have been replicated. @@ -125,7 +126,7 @@ type: str sample: "https://route53.amazonaws.com/2013-04-01/keysigningkey/xxx/ansible-test-ksk" key_signing_key: - description: + description: The key-signing key (KSK) that the request creates. returned: only when a new Key Signing Request is created type: dict contains: @@ -312,9 +313,9 @@ def main() -> None: argument_spec = dict( caller_reference=dict(type="str"), hosted_zone_id=dict(type="str", aliases=["zone_id"], required=True), - key_management_service_arn=dict(type="str", aliases=["kms_arn"]), + key_management_service_arn=dict(type="str", aliases=["kms_arn"], no_log=False), name=dict(type="str", required=True), - status=dict(type="str", default=["ACTIVE"], choices=["ACTIVE", "INACTIVE"]), + status=dict(type="str", default="ACTIVE", choices=["ACTIVE", "INACTIVE"]), state=dict(default="present", choices=["present", "absent"]), wait=dict(type="bool", default=False), wait_timeout=dict(type="int", default=300), diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index 5fdf453280a..9aabd243c97 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -191,7 +191,7 @@ type: dict contains: key_signing_key: - description: + description: The key-signing key (KSK) that the request creates. returned: when O(state=present) type: list elements: dict From f0289dc2b4c533fe0a030a0c641d6582915b7bb1 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Thu, 5 Dec 2024 15:30:27 +0100 Subject: [PATCH 7/8] Update Signed-off-by: Alina Buzachis --- plugins/modules/route53_ksk.py | 142 +++++++++--------- .../targets/route53_ksk/tasks/main.yml | 67 ++++++++- 2 files changed, 138 insertions(+), 71 deletions(-) diff --git a/plugins/modules/route53_ksk.py b/plugins/modules/route53_ksk.py index 1e3c2f52c14..5455742acec 100644 --- a/plugins/modules/route53_ksk.py +++ b/plugins/modules/route53_ksk.py @@ -64,8 +64,6 @@ - amazon.aws.common.modules - amazon.aws.region.modules - amazon.aws.boto3 -notes: - - This module does not support check_mode. author: - Alina Buzachis (@alinabuzachis) """ @@ -80,7 +78,7 @@ status: "INACTIVE" state: present -- name: Activate KSK +- name: Activate a Key Signing Key Request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" hosted_zone_id: "{{ _hosted_zone.zone_id }}" @@ -89,7 +87,7 @@ status: "ACTIVE" state: present -- name: Delete KSK and deactivate it +- name: Delete a Key Signing Key Request and deactivate it amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" hosted_zone_id: "{{ _hosted_zone.zone_id }}" @@ -99,8 +97,8 @@ RETURN = r""" change_info: - description: A dictionary that escribes change information about changes made to your hosted zone. - returned: when the Key Signing Request to be deleted exists + description: A dictionary that describes change information about changes made to the hosted zone. + returned: when the Key Signing Request is created or updated type: dict contains: id: @@ -122,12 +120,12 @@ } location: description: The unique URL representing the new key-signing key (KSK). - returned: when O(state=present) + returned: when only a new Key Signing Key is created type: str sample: "https://route53.amazonaws.com/2013-04-01/keysigningkey/xxx/ansible-test-ksk" key_signing_key: description: The key-signing key (KSK) that the request creates. - returned: only when a new Key Signing Request is created + returned: always type: dict contains: name: @@ -197,7 +195,6 @@ } """ -import datetime try: import botocore @@ -206,7 +203,6 @@ from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.waiters import get_waiter @@ -224,6 +220,23 @@ def get_change(client, change_id): return client.get_change(Id=change_id) +def get_hosted_zone(client, hosted_zone_id): + return client.get_hosted_zone(Id=hosted_zone_id) + + +def get_dnssec(client, hosted_zone_id): + return client.get_dnssec(HostedZoneId=hosted_zone_id) + + +def find_ksk(client, module): + hosted_zone_dnssec = get_dnssec(client, module.params.get("hosted_zone_id")) + if hosted_zone_dnssec["KeySigningKeys"] != []: + for ksk in hosted_zone_dnssec["KeySigningKeys"]: + if ksk["Name"] == module.params.get("name"): + return ksk + return None + + def wait(client, module, change_id): try: waiter = get_waiter(client, "resource_record_sets_changed") @@ -238,73 +251,63 @@ def wait(client, module, change_id): module.fail_json_aws(e, msg="Timeout waiting for changes to be applied") -def create(client, module: AnsibleAWSModule): - # The API does not raise KeySigningKeyAlreadyExists when a request with the same name and - # KMS arn already exist. It will always try to create a new KSK request. - changed: bool = True +def create_or_update(client, module: AnsibleAWSModule, ksk): + changed: bool = False zone_id = module.params.get("hosted_zone_id") name = module.params.get("name") status = module.params.get("status") - request_time = datetime.datetime.utcnow() - - response = client.create_key_signing_key( - CallerReference=module.params.get("caller_reference"), - KeyManagementServiceArn=module.params.get("key_management_service_arn"), - HostedZoneId=zone_id, - Name=name, - Status=status, - ) - if response and response.get("ChangeInfo", {}): - submitted_at = response["ChangeInfo"].get("SubmittedAt").replace(tzinfo=None) - if submitted_at < request_time: - # A KSK request already exists. - if response["KeySigningKey"]["Status"] != status: - # Wait before activating or deactivating to reach INSYNC state - change_id = response["ChangeInfo"]["Id"] - wait(client, module, change_id) - - if module.params.get("status") == "ACTIVE": - response = activate(client, zone_id, name) - elif module.params.get("status") == "INACTIVE": - response = deactivate(client, zone_id, name) - else: - changed = False - - if module.params.get("wait"): - change_id = response["ChangeInfo"]["Id"] - wait(client, module, change_id) - response["ChangeInfo"] = get_change(client, change_id) - else: - changed = False + if ksk is not None: + response = {"KeySigningKey": ksk} + if ksk["Status"] != status: + changed = True + + if module.check_mode: + module.exit_json( + changed=changed, + msg=f"Would have updated the Key Signing Key status to {status} if not in check_mode.", + ) + + if status == "ACTIVE": + response.update(activate(client, zone_id, name)) + elif status == "INACTIVE": + response.update(deactivate(client, zone_id, name)) + else: + changed = True + if module.check_mode: + module.exit_json(changed=changed, msg="Would have created the Key Signing Key if not in check_mode.") + + response = client.create_key_signing_key( + CallerReference=module.params.get("caller_reference"), + KeyManagementServiceArn=module.params.get("key_management_service_arn"), + HostedZoneId=zone_id, + Name=name, + Status=status, + ) + + del response["ResponseMetadata"] return changed, response -def delete(client, module: AnsibleAWSModule): +def delete(client, module: AnsibleAWSModule, ksk): changed: bool = False zone_id = module.params.get("hosted_zone_id") name = module.params.get("name") + response = {"KeySigningRequest": {}} - if module.params.get("status") == "INACTIVE": - try: - # Deactivate the Key Signing Request before deleting - result = deactivate(client, zone_id, name) - except is_boto3_error_code("NoSuchKeySigningKey"): - return changed, {} - - change_id = result["ChangeInfo"]["Id"] - wait(client, module, change_id) - try: - response = client.delete_key_signing_key(HostedZoneId=zone_id, Name=name) + if ksk is not None: changed = True + response["KeySigningRequest"] = ksk + if module.check_mode: + module.exit_json(changed=changed, msg="Would have deleted the Key Signing Key if not in check_mode.") - if module.params.get("wait"): - change_id = response["ChangeInfo"]["Id"] + if module.params.get("status") == "INACTIVE": + result = deactivate(client, zone_id, name) + change_id = result["ChangeInfo"]["Id"] wait(client, module, change_id) - response["ChangeInfo"] = get_change(client, change_id) - except is_boto3_error_code("NoSuchKeySigningKey"): - return changed, {} + + response = client.delete_key_signing_key(HostedZoneId=zone_id, Name=name) return changed, response @@ -323,6 +326,7 @@ def main() -> None: module = AnsibleAWSModule( argument_spec=argument_spec, + supports_check_mode=True, required_if=[["state", "present", ["caller_reference", "key_management_service_arn"]]], ) @@ -335,16 +339,20 @@ def main() -> None: state = module.params.get("state") try: + ksk = find_ksk(client, module) if state == "present": - changed, result = create(client, module) + changed, result = create_or_update(client, module, ksk) else: - changed, result = delete(client, module) + changed, result = delete(client, module, ksk) + + if module.params.get("wait") and result.get("ChangeInfo"): + change_id = result["ChangeInfo"]["Id"] + wait(client, module, change_id) + result["ChangeInfo"] = get_change(client, change_id) + except AnsibleAWSError as e: module.fail_json_aws_error(e) - if "ResponseMetadata" in result: - del result["ResponseMetadata"] - module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) diff --git a/tests/integration/targets/route53_ksk/tasks/main.yml b/tests/integration/targets/route53_ksk/tasks/main.yml index 433ab4c696f..b5ceaf14ae2 100644 --- a/tests/integration/targets/route53_ksk/tasks/main.yml +++ b/tests/integration/targets/route53_ksk/tasks/main.yml @@ -28,6 +28,25 @@ state: present register: _hosted_zone + - name: Create a Key Signing Key request (check_mode) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "INACTIVE" + state: present + check_mode: true + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - _ksk_request.changed + - '"msg" in _ksk_request' + - '"Would have created the Key Signing Key if not in check_mode." in _ksk_request.msg' + - name: Create a Key Signing Key request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" @@ -60,10 +79,28 @@ ansible.builtin.assert: that: - _ksk_request is successful - - '"change_info" in _ksk_request' - not _ksk_request.changed - '"key_signing_key" in _ksk_request' + - name: Activate the Key Signing Key request (check_mode) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + key_management_service_arn: "{{ kms_key.key_arn }}" + caller_reference: "{{ aws_caller_info.arn }}" + status: "ACTIVE" + state: present + check_mode: true + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - _ksk_request.changed + - '"msg" in _ksk_request' + - '"Would have updated the Key Signing Key status to ACTIVE if not in check_mode." in _ksk_request.msg' + - name: Activate the Key Signing Key request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" @@ -82,7 +119,7 @@ - '"change_info" in _ksk_request' - _ksk_request.changed - - name: Activate KSK (idempotency) + - name: Activate the Key Signing Key request (idempotency) amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" hosted_zone_id: "{{ _hosted_zone.zone_id }}" @@ -96,7 +133,6 @@ ansible.builtin.assert: that: - _ksk_request is successful - - '"change_info" in _ksk_request' - not _ksk_request.changed - name: Enable DNSSEC for Route53 public zone (check_mode) @@ -198,9 +234,23 @@ ansible.builtin.assert: that: - _ksk_request is successful - - '"change_info" in _ksk_request' - not _ksk_request.changed + - name: Delete the Key Signing Key request (check_mode) + amazon.aws.route53_ksk: + name: "{{ resource_prefix }}-ksk" + hosted_zone_id: "{{ _hosted_zone.zone_id }}" + status: "INACTIVE" + state: absent + check_mode: true + register: _ksk_request + + - name: Assert success + ansible.builtin.assert: + that: + - _ksk_request is successful + - _ksk_request.changed + - name: Delete the Key Signing Key request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" @@ -215,6 +265,8 @@ - _ksk_request is successful - '"change_info" in _ksk_request' - _ksk_request.changed + - '"msg" in _ksk_request' + - '"Would have deteled the Key Signing Key if not in check_mode." in _ksk_request.msg' - name: Delete the Key Signing Key request (idempotency) amazon.aws.route53_ksk: @@ -245,6 +297,13 @@ - not _ksk_request.changed always: + - name: Disable DNSSEC for Route53 public zone + amazon.aws.route53_zone: + zone: "{{ resource_prefix }}.public" + state: present + dnssec: false + ignore_errors: true + - name: Delete the Key Signing Key Request amazon.aws.route53_ksk: name: "{{ resource_prefix }}-ksk" From 7af2ab3c3a8fd9b0e71a131cad004432ccb2cb6f Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Sun, 8 Dec 2024 12:19:34 +0100 Subject: [PATCH 8/8] Fix Signed-off-by: Alina Buzachis --- plugins/modules/route53_zone.py | 13 +++++++------ .../integration/targets/route53_ksk/tasks/main.yml | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/modules/route53_zone.py b/plugins/modules/route53_zone.py index 9aabd243c97..d7ccda14597 100644 --- a/plugins/modules/route53_zone.py +++ b/plugins/modules/route53_zone.py @@ -186,7 +186,7 @@ sample: "A1BCDEF2GHIJKL" dnssec: description: Information about DNSSEC for a specific hosted zone. - returned: when O(state=present) + returned: when O(state=present) and the hosted zone is public version_added: 9.2.0 type: dict contains: @@ -400,12 +400,13 @@ def create(matching_zones): zone_id = result.get("zone_id") if zone_id: - # Enable/Disable DNSSEC - changed |= ensure_dnssec(client, module, zone_id) + if not private_zone: + # Enable/Disable DNSSEC + changed |= ensure_dnssec(client, module, zone_id) - # Update result with information about DNSSEC - result["dnssec"] = camel_dict_to_snake_dict(get_dnssec(client, module, zone_id)) - del result["dnssec"]["response_metadata"] + # Update result with information about DNSSEC + result["dnssec"] = camel_dict_to_snake_dict(get_dnssec(client, module, zone_id)) + del result["dnssec"]["response_metadata"] # Handle Tags if tags is not None: diff --git a/tests/integration/targets/route53_ksk/tasks/main.yml b/tests/integration/targets/route53_ksk/tasks/main.yml index b5ceaf14ae2..daf152556e4 100644 --- a/tests/integration/targets/route53_ksk/tasks/main.yml +++ b/tests/integration/targets/route53_ksk/tasks/main.yml @@ -250,6 +250,8 @@ that: - _ksk_request is successful - _ksk_request.changed + - '"msg" in _ksk_request' + - '"Would have deleted the Key Signing Key if not in check_mode." in _ksk_request.msg' - name: Delete the Key Signing Key request amazon.aws.route53_ksk: @@ -265,8 +267,6 @@ - _ksk_request is successful - '"change_info" in _ksk_request' - _ksk_request.changed - - '"msg" in _ksk_request' - - '"Would have deteled the Key Signing Key if not in check_mode." in _ksk_request.msg' - name: Delete the Key Signing Key request (idempotency) amazon.aws.route53_ksk: