diff --git a/plugins/modules/acm_certificate.py b/plugins/modules/acm_certificate.py new file mode 100644 index 00000000000..6b48579d5bc --- /dev/null +++ b/plugins/modules/acm_certificate.py @@ -0,0 +1,571 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . +# +# Author: +# - Matthew Davis +# on behalf of Telstra Corporation Limited + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: acm_certificate +short_description: Upload and delete certificates in the AWS Certificate Manager service +version_added: 1.0.0 +description: + - > + Import and delete certificates in Amazon Web Service's Certificate + Manager (AWS ACM). + - > + This module does not currently interact with AWS-provided certificates. + It currently only manages certificates provided to AWS by the user. + - The ACM API allows users to upload multiple certificates for the same domain + name, and even multiple identical certificates. This module attempts to + restrict such freedoms, to be idempotent, as per the Ansible philosophy. + It does this through applying AWS resource "Name" tags to ACM certificates. + - > + When I(state=present), + if there is one certificate in ACM + with a C(Name) tag equal to the I(name_tag) parameter, + and an identical body and chain, + this task will succeed without effect. + - > + When I(state=present), + if there is one certificate in ACM + a I(Name) tag equal to the I(name_tag) parameter, + and a different body, + this task will overwrite that certificate. + - > + When I(state=present), + if there are multiple certificates in ACM + with a I(Name) tag equal to the I(name_tag) parameter, + this task will fail. + - > + When I(state=absent) and I(certificate_arn) is defined, + this module will delete the ACM resource with that ARN if it exists in this + region, and succeed without effect if it doesn't exist. + - > + When I(state=absent) and I(domain_name) is defined, this module will delete + all ACM resources in this AWS region with a corresponding domain name. + If there are none, it will succeed without effect. + - > + When I(state=absent) and I(certificate_arn) is not defined, + and I(domain_name) is not defined, this module will delete all ACM resources + in this AWS region with a corresponding I(Name) tag. + If there are none, it will succeed without effect. + - > + Note that this may not work properly with keys of size 4096 bits, due to a + limitation of the ACM API. + - Prior to release 5.0.0 this module was called C(community.aws.aws_acm). + The usage did not change. +options: + certificate: + description: + - The body of the PEM encoded public certificate. + - Required when I(state) is not C(absent) and the certificate does not exist. + - > + If your certificate is in a file, + use C(lookup('file', 'path/to/cert.pem')). + type: str + certificate_arn: + description: + - The ARN of a certificate in ACM to modify or delete. + - > + If I(state=present), the certificate with the specified ARN can be updated. + For example, this can be used to add/remove tags to an existing certificate. + - > + If I(state=absent), you must provide one of + I(certificate_arn), I(domain_name) or I(name_tag). + - > + If I(state=absent) and no resource exists with this ARN in this region, + the task will succeed with no effect. + - > + If I(state=absent) and the corresponding resource exists in a different + region, this task may report success without deleting that resource. + type: str + aliases: [arn] + certificate_chain: + description: + - The body of the PEM encoded chain for your certificate. + - > + If your certificate chain is in a file, + use C(lookup('file', 'path/to/chain.pem')). + - Ignored when I(state=absent) + type: str + domain_name: + description: + - The domain name of the certificate. + - > + If I(state=absent) and I(domain_name) is specified, + this task will delete all ACM certificates with this domain. + - > + Exactly one of I(domain_name), I(name_tag) and I(certificate_arn) + must be provided. + - > + If I(state=present) this must not be specified. + (Since the domain name is encoded within the public certificate's body.) + type: str + aliases: [domain] + name_tag: + description: + - > + The unique identifier for tagging resources using AWS tags, + with key I(Name). + - This can be any set of characters accepted by AWS for tag values. + - > + This is to ensure Ansible can treat certificates idempotently, + even though the ACM API allows duplicate certificates. + - If I(state=preset), this must be specified. + - > + If I(state=absent) and I(name_tag) is specified, + this task will delete all ACM certificates with this Name tag. + - > + If I(state=absent), you must provide exactly one of + I(certificate_arn), I(domain_name) or I(name_tag). + - > + If both I(name_tag) and the 'Name' tag in I(tags) are set, + the values must be the same. + - > + If the 'Name' tag in I(tags) is not set and I(name_tag) is set, + the I(name_tag) value is copied to I(tags). + type: str + aliases: [name] + private_key: + description: + - The body of the PEM encoded private key. + - Required when I(state=present) and the certificate does not exist. + - Ignored when I(state=absent). + - > + If your private key is in a file, + use C(lookup('file', 'path/to/key.pem')). + type: str + state: + description: + - > + If I(state=present), the specified public certificate and private key + will be uploaded, with I(Name) tag equal to I(name_tag). + - > + If I(state=absent), any certificates in this region + with a corresponding I(domain_name), I(name_tag) or I(certificate_arn) + will be deleted. + choices: [present, absent] + default: present + type: str + +notes: + - Support for I(tags) and I(purge_tags) was added in release 3.2.0 +author: + - Matthew Davis (@matt-telstra) on behalf of Telstra Corporation Limited +extends_documentation_fragment: + - amazon.aws.aws + - amazon.aws.ec2 + - amazon.aws.tags.deprecated_purge +''' + +EXAMPLES = ''' + +- name: upload a self-signed certificate + community.aws.aws_acm: + certificate: "{{ lookup('file', 'cert.pem' ) }}" + privateKey: "{{ lookup('file', 'key.pem' ) }}" + name_tag: my_cert # to be applied through an AWS tag as "Name":"my_cert" + region: ap-southeast-2 # AWS region + +- name: create/update a certificate with a chain + community.aws.aws_acm: + certificate: "{{ lookup('file', 'cert.pem' ) }}" + private_key: "{{ lookup('file', 'key.pem' ) }}" + name_tag: my_cert + certificate_chain: "{{ lookup('file', 'chain.pem' ) }}" + state: present + region: ap-southeast-2 + register: cert_create + +- name: print ARN of cert we just created + ansible.builtin.debug: + var: cert_create.certificate.arn + +- name: delete the cert we just created + community.aws.aws_acm: + name_tag: my_cert + state: absent + region: ap-southeast-2 + +- name: delete a certificate with a particular ARN + community.aws.aws_acm: + certificate_arn: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" + state: absent + region: ap-southeast-2 + +- name: delete all certificates with a particular domain name + community.aws.aws_acm: + domain_name: acm.ansible.com + state: absent + region: ap-southeast-2 + +- name: add tags to an existing certificate with a particular ARN + community.aws.aws_acm: + certificate_arn: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" + tags: + Name: my_certificate + Application: search + Environment: development + purge_tags: true +''' + +RETURN = ''' +certificate: + description: Information about the certificate which was uploaded + type: complex + returned: when I(state=present) + contains: + arn: + description: The ARN of the certificate in ACM + type: str + returned: when I(state=present) and not in check mode + sample: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" + domain_name: + description: The domain name encoded within the public certificate + type: str + returned: when I(state=present) + sample: acm.ansible.com +arns: + description: A list of the ARNs of the certificates in ACM which were deleted + type: list + elements: str + returned: when I(state=absent) + sample: + - "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" +''' + + +import base64 +from copy import deepcopy +import re # regex library + +try: + import botocore +except ImportError: + pass # handled by AnsibleAWSModule + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.acm import ACMServiceManager +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import compare_aws_tags +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ( + boto3_tag_list_to_ansible_dict, + ansible_dict_to_boto3_tag_list, +) +from ansible.module_utils._text import to_text + + +def ensure_tags(client, module, resource_arn, existing_tags, tags, purge_tags): + if tags is None: + return (False, existing_tags) + + tags_to_add, tags_to_remove = compare_aws_tags(existing_tags, tags, purge_tags) + changed = bool(tags_to_add or tags_to_remove) + if tags_to_add and not module.check_mode: + try: + client.add_tags_to_certificate( + CertificateArn=resource_arn, + Tags=ansible_dict_to_boto3_tag_list(tags_to_add), + ) + except ( + botocore.exceptions.ClientError, + botocore.exceptions.BotoCoreError, + ) as e: + module.fail_json_aws( + e, "Couldn't add tags to certificate {0}".format(resource_arn) + ) + if tags_to_remove and not module.check_mode: + # remove_tags_from_certificate wants a list of key, value pairs, not a list of keys. + tags_list = [{'Key': key, 'Value': existing_tags.get(key)} for key in tags_to_remove] + try: + client.remove_tags_from_certificate( + CertificateArn=resource_arn, + Tags=tags_list, + ) + except ( + botocore.exceptions.ClientError, + botocore.exceptions.BotoCoreError, + ) as e: + module.fail_json_aws( + e, "Couldn't remove tags from certificate {0}".format(resource_arn) + ) + new_tags = deepcopy(existing_tags) + for key, value in tags_to_add.items(): + new_tags[key] = value + for key in tags_to_remove: + new_tags.pop(key, None) + return (changed, new_tags) + + +# Takes in two text arguments +# Each a PEM encoded certificate +# Or a chain of PEM encoded certificates +# May include some lines between each chain in the cert, e.g. "Subject: ..." +# Returns True iff the chains/certs are functionally identical (including chain order) +def chain_compare(module, a, b): + + chain_a_pem = pem_chain_split(module, a) + chain_b_pem = pem_chain_split(module, b) + + if len(chain_a_pem) != len(chain_b_pem): + return False + + # Chain length is the same + for (ca, cb) in zip(chain_a_pem, chain_b_pem): + der_a = PEM_body_to_DER(module, ca) + der_b = PEM_body_to_DER(module, cb) + if der_a != der_b: + return False + + return True + + +# Takes in PEM encoded data with no headers +# returns equivilent DER as byte array +def PEM_body_to_DER(module, pem): + try: + der = base64.b64decode(to_text(pem)) + except (ValueError, TypeError) as e: + module.fail_json_aws(e, msg="Unable to decode certificate chain") + return der + + +# Store this globally to avoid repeated recompilation +pem_chain_split_regex = re.compile(r"------?BEGIN [A-Z0-9. ]*CERTIFICATE------?([a-zA-Z0-9\+\/=\s]+)------?END [A-Z0-9. ]*CERTIFICATE------?") + + +# Use regex to split up a chain or single cert into an array of base64 encoded data +# Using "-----BEGIN CERTIFICATE-----" and "----END CERTIFICATE----" +# Noting that some chains have non-pem data in between each cert +# This function returns only what's between the headers, excluding the headers +def pem_chain_split(module, pem): + + pem_arr = re.findall(pem_chain_split_regex, to_text(pem)) + + if len(pem_arr) == 0: + # This happens if the regex doesn't match at all + module.fail_json(msg="Unable to split certificate chain. Possibly zero-length chain?") + + return pem_arr + + +def update_imported_certificate(client, module, acm, old_cert, desired_tags): + """ + Update the existing certificate that was previously imported in ACM. + """ + module.debug("Existing certificate found in ACM") + if ('tags' not in old_cert) or ('Name' not in old_cert['tags']): + # shouldn't happen + module.fail_json(msg="Internal error, unsure which certificate to update", certificate=old_cert) + if module.params.get('name_tag') is not None and (old_cert['tags']['Name'] != module.params.get('name_tag')): + # This could happen if the user identified the certificate using 'certificate_arn' or 'domain_name', + # and the 'Name' tag in the AWS API does not match the ansible 'name_tag'. + module.fail_json(msg="Internal error, Name tag does not match", certificate=old_cert) + if 'certificate' not in old_cert: + # shouldn't happen + module.fail_json(msg="Internal error, unsure what the existing cert in ACM is", certificate=old_cert) + + cert_arn = None + # Are the existing certificate in ACM and the local certificate the same? + same = True + if module.params.get('certificate') is not None: + same &= chain_compare(module, old_cert['certificate'], module.params['certificate']) + if module.params['certificate_chain']: + # Need to test this + # not sure if Amazon appends the cert itself to the chain when self-signed + same &= chain_compare(module, old_cert['certificate_chain'], module.params['certificate_chain']) + else: + # When there is no chain with a cert + # it seems Amazon returns the cert itself as the chain + same &= chain_compare(module, old_cert['certificate_chain'], module.params['certificate']) + + if same: + module.debug("Existing certificate in ACM is the same") + cert_arn = old_cert['certificate_arn'] + changed = False + else: + absent_args = ['certificate', 'name_tag', 'private_key'] + if sum([(module.params[a] is not None) for a in absent_args]) < 3: + module.fail_json(msg="When importing a certificate, all of 'name_tag', 'certificate' and 'private_key' must be specified") + module.debug("Existing certificate in ACM is different, overwriting") + changed = True + if module.check_mode: + cert_arn = old_cert['certificate_arn'] + # note: returned domain will be the domain of the previous cert + else: + # update cert in ACM + cert_arn = acm.import_certificate( + client, + module, + certificate=module.params['certificate'], + private_key=module.params['private_key'], + certificate_chain=module.params['certificate_chain'], + arn=old_cert['certificate_arn'], + tags=desired_tags, + ) + return (changed, cert_arn) + + +def import_certificate(client, module, acm, desired_tags): + """ + Import a certificate to ACM. + """ + # Validate argument requirements + absent_args = ['certificate', 'name_tag', 'private_key'] + cert_arn = None + if sum([(module.params[a] is not None) for a in absent_args]) < 3: + module.fail_json(msg="When importing a new certificate, all of 'name_tag', 'certificate' and 'private_key' must be specified") + module.debug("No certificate in ACM. Creating new one.") + changed = True + if module.check_mode: + domain = 'example.com' + module.exit_json(certificate=dict(domain_name=domain), changed=True) + else: + cert_arn = acm.import_certificate( + client, + module, + certificate=module.params['certificate'], + private_key=module.params['private_key'], + certificate_chain=module.params['certificate_chain'], + tags=desired_tags, + ) + return (changed, cert_arn) + + +def ensure_certificates_present(client, module, acm, certificates, desired_tags, filter_tags): + cert_arn = None + changed = False + if len(certificates) > 1: + msg = "More than one certificate with Name=%s exists in ACM in this region" % module.params['name_tag'] + module.fail_json(msg=msg, certificates=certificates) + elif len(certificates) == 1: + # Update existing certificate that was previously imported to ACM. + (changed, cert_arn) = update_imported_certificate(client, module, acm, certificates[0], desired_tags) + else: # len(certificates) == 0 + # Import new certificate to ACM. + (changed, cert_arn) = import_certificate(client, module, acm, desired_tags) + + # Add/remove tags to/from certificate + try: + existing_tags = boto3_tag_list_to_ansible_dict(client.list_tags_for_certificate(CertificateArn=cert_arn)['Tags']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, "Couldn't get tags for certificate") + + purge_tags = module.params.get('purge_tags') + (c, new_tags) = ensure_tags(client, module, cert_arn, existing_tags, desired_tags, purge_tags) + changed |= c + domain = acm.get_domain_of_cert(client=client, module=module, arn=cert_arn) + module.exit_json(certificate=dict(domain_name=domain, arn=cert_arn, tags=new_tags), changed=changed) + + +def ensure_certificates_absent(client, module, acm, certificates): + for cert in certificates: + if not module.check_mode: + acm.delete_certificate(client, module, cert['certificate_arn']) + module.exit_json(arns=[cert['certificate_arn'] for cert in certificates], changed=(len(certificates) > 0)) + + +def main(): + argument_spec = dict( + certificate=dict(), + certificate_arn=dict(aliases=['arn']), + certificate_chain=dict(), + domain_name=dict(aliases=['domain']), + name_tag=dict(aliases=['name']), + private_key=dict(no_log=True), + tags=dict(type='dict', aliases=['resource_tags']), + purge_tags=dict(type='bool'), + state=dict(default='present', choices=['present', 'absent']), + ) + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + acm = ACMServiceManager(module) + + if module.params.get('purge_tags') is None: + module.deprecate( + 'The purge_tags parameter currently defaults to False.' + ' For consistency across the collection, this default value' + ' will change to True in release 5.0.0.', + version='5.0.0', collection_name='community.aws') + module.params['purge_tags'] = False + + # Check argument requirements + if module.params['state'] == 'present': + # at least one of these should be specified. + absent_args = ['certificate_arn', 'domain_name', 'name_tag'] + if sum([(module.params[a] is not None) for a in absent_args]) < 1: + for a in absent_args: + module.debug("%s is %s" % (a, module.params[a])) + module.fail_json(msg="If 'state' is specified as 'present' then at least one of 'name_tag', 'certificate_arn' or 'domain_name' must be specified") + else: # absent + # exactly one of these should be specified + absent_args = ['certificate_arn', 'domain_name', 'name_tag'] + if sum([(module.params[a] is not None) for a in absent_args]) != 1: + for a in absent_args: + module.debug("%s is %s" % (a, module.params[a])) + module.fail_json(msg="If 'state' is specified as 'absent' then exactly one of 'name_tag', 'certificate_arn' or 'domain_name' must be specified") + + filter_tags = None + desired_tags = None + if module.params.get('tags') is not None: + desired_tags = module.params['tags'] + if module.params.get('name_tag') is not None: + # The module was originally implemented to filter certificates based on the 'Name' tag. + # Other tags are not used to filter certificates. + # It would make sense to replace the existing name_tag, domain, certificate_arn attributes + # with a 'filter' attribute, but that would break backwards-compatibility. + filter_tags = dict(Name=module.params['name_tag']) + if desired_tags is not None: + if 'Name' in desired_tags: + if desired_tags['Name'] != module.params['name_tag']: + module.fail_json(msg="Value of 'name_tag' conflicts with value of 'tags.Name'") + else: + desired_tags['Name'] = module.params['name_tag'] + else: + desired_tags = deepcopy(filter_tags) + + client = module.client('acm') + + # fetch the list of certificates currently in ACM + certificates = acm.get_certificates( + client=client, + module=module, + domain_name=module.params['domain_name'], + arn=module.params['certificate_arn'], + only_tags=filter_tags, + ) + + module.debug("Found %d corresponding certificates in ACM" % len(certificates)) + if module.params['state'] == 'present': + ensure_certificates_present(client, module, acm, certificates, desired_tags, filter_tags) + + else: # state == absent + ensure_certificates_absent(client, module, acm, certificates) + + +if __name__ == '__main__': + # tests() + main() diff --git a/plugins/modules/acm_certificate_info.py b/plugins/modules/acm_certificate_info.py new file mode 100644 index 00000000000..8e16162cedb --- /dev/null +++ b/plugins/modules/acm_certificate_info.py @@ -0,0 +1,294 @@ +#!/usr/bin/python +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r''' +module: acm_certificate_info +short_description: Retrieve certificate information from AWS Certificate Manager service +version_added: 1.0.0 +description: + - Retrieve information for ACM certificates. + - Note that this will not return information about uploaded keys of size 4096 bits, due to a limitation of the ACM API. + - Prior to release 5.0.0 this module was called C(community.aws.aws_acm_info). + The usage did not change. +options: + certificate_arn: + description: + - If provided, the results will be filtered to show only the certificate with this ARN. + - If no certificate with this ARN exists, this task will fail. + - If a certificate with this ARN exists in a different region, this task will fail + aliases: + - arn + type: str + domain_name: + description: + - The domain name of an ACM certificate to limit the search to. + aliases: + - name + type: str + statuses: + description: + - Status to filter the certificate results. + choices: ['PENDING_VALIDATION', 'ISSUED', 'INACTIVE', 'EXPIRED', 'VALIDATION_TIMED_OUT', 'REVOKED', 'FAILED'] + type: list + elements: str + tags: + description: + - Filter results to show only certificates with tags that match all the tags specified here. + type: dict +author: + - Will Thames (@willthames) +extends_documentation_fragment: +- amazon.aws.aws +- amazon.aws.ec2 +''' + +EXAMPLES = r''' +- name: obtain all ACM certificates + community.aws.aws_acm_info: + +- name: obtain all information for a single ACM certificate + community.aws.aws_acm_info: + domain_name: "*.example_com" + +- name: obtain all certificates pending validation + community.aws.aws_acm_info: + statuses: + - PENDING_VALIDATION + +- name: obtain all certificates with tag Name=foo and myTag=bar + community.aws.aws_acm_info: + tags: + Name: foo + myTag: bar + + +# The output is still a list of certificates, just one item long. +- name: obtain information about a certificate with a particular ARN + community.aws.aws_acm_info: + certificate_arn: "arn:aws:acm:ap-southeast-2:123456789876:certificate/abcdeabc-abcd-1234-4321-abcdeabcde12" + +''' + +RETURN = r''' +certificates: + description: A list of certificates + returned: always + type: complex + contains: + certificate: + description: The ACM Certificate body + returned: when certificate creation is complete + sample: '-----BEGIN CERTIFICATE-----\\nMII.....-----END CERTIFICATE-----\\n' + type: str + certificate_arn: + description: Certificate ARN + returned: always + sample: arn:aws:acm:ap-southeast-2:123456789012:certificate/abcd1234-abcd-1234-abcd-123456789abc + type: str + certificate_chain: + description: Full certificate chain for the certificate + returned: when certificate creation is complete + sample: '-----BEGIN CERTIFICATE-----\\nMII...\\n-----END CERTIFICATE-----\\n-----BEGIN CERTIFICATE-----\\n...' + type: str + created_at: + description: Date certificate was created + returned: always + sample: '2017-08-15T10:31:19+10:00' + type: str + domain_name: + description: Domain name for the certificate + returned: always + sample: '*.example.com' + type: str + domain_validation_options: + description: Options used by ACM to validate the certificate + returned: when certificate type is AMAZON_ISSUED + type: complex + contains: + domain_name: + description: Fully qualified domain name of the certificate + returned: always + sample: example.com + type: str + validation_domain: + description: The domain name ACM used to send validation emails + returned: always + sample: example.com + type: str + validation_emails: + description: A list of email addresses that ACM used to send domain validation emails + returned: always + sample: + - admin@example.com + - postmaster@example.com + type: list + elements: str + validation_status: + description: Validation status of the domain + returned: always + sample: SUCCESS + type: str + failure_reason: + description: Reason certificate request failed + returned: only when certificate issuing failed + type: str + sample: NO_AVAILABLE_CONTACTS + in_use_by: + description: A list of ARNs for the AWS resources that are using the certificate. + returned: always + sample: [] + type: list + elements: str + issued_at: + description: Date certificate was issued + returned: always + sample: '2017-01-01T00:00:00+10:00' + type: str + issuer: + description: Issuer of the certificate + returned: always + sample: Amazon + type: str + key_algorithm: + description: Algorithm used to generate the certificate + returned: always + sample: RSA-2048 + type: str + not_after: + description: Date after which the certificate is not valid + returned: always + sample: '2019-01-01T00:00:00+10:00' + type: str + not_before: + description: Date before which the certificate is not valid + returned: always + sample: '2017-01-01T00:00:00+10:00' + type: str + renewal_summary: + description: Information about managed renewal process + returned: when certificate is issued by Amazon and a renewal has been started + type: complex + contains: + domain_validation_options: + description: Options used by ACM to validate the certificate + returned: when certificate type is AMAZON_ISSUED + type: complex + contains: + domain_name: + description: Fully qualified domain name of the certificate + returned: always + sample: example.com + type: str + validation_domain: + description: The domain name ACM used to send validation emails + returned: always + sample: example.com + type: str + validation_emails: + description: A list of email addresses that ACM used to send domain validation emails + returned: always + sample: + - admin@example.com + - postmaster@example.com + type: list + elements: str + validation_status: + description: Validation status of the domain + returned: always + sample: SUCCESS + type: str + renewal_status: + description: Status of the domain renewal + returned: always + sample: PENDING_AUTO_RENEWAL + type: str + revocation_reason: + description: Reason for certificate revocation + returned: when the certificate has been revoked + sample: SUPERCEDED + type: str + revoked_at: + description: Date certificate was revoked + returned: when the certificate has been revoked + sample: '2017-09-01T10:00:00+10:00' + type: str + serial: + description: The serial number of the certificate + returned: always + sample: 00:01:02:03:04:05:06:07:08:09:0a:0b:0c:0d:0e:0f + type: str + signature_algorithm: + description: Algorithm used to sign the certificate + returned: always + sample: SHA256WITHRSA + type: str + status: + description: Status of the certificate in ACM + returned: always + sample: ISSUED + type: str + subject: + description: The name of the entity that is associated with the public key contained in the certificate + returned: always + sample: CN=*.example.com + type: str + subject_alternative_names: + description: Subject Alternative Names for the certificate + returned: always + sample: + - '*.example.com' + type: list + elements: str + tags: + description: Tags associated with the certificate + returned: always + type: dict + sample: + Application: helloworld + Environment: test + type: + description: The source of the certificate + returned: always + sample: AMAZON_ISSUED + type: str +''' + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.acm import ACMServiceManager + + +def main(): + argument_spec = dict( + certificate_arn=dict(aliases=['arn']), + domain_name=dict(aliases=['name']), + statuses=dict( + type='list', + elements='str', + choices=['PENDING_VALIDATION', 'ISSUED', 'INACTIVE', 'EXPIRED', 'VALIDATION_TIMED_OUT', 'REVOKED', 'FAILED'] + ), + tags=dict(type='dict'), + ) + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) + acm_info = ACMServiceManager(module) + + client = module.client('acm') + + certificates = acm_info.get_certificates(client, module, + domain_name=module.params['domain_name'], + statuses=module.params['statuses'], + arn=module.params['certificate_arn'], + only_tags=module.params['tags']) + + if module.params['certificate_arn'] and len(certificates) != 1: + module.fail_json(msg="No certificate exists in this region with ARN %s" % module.params['certificate_arn']) + + module.exit_json(certificates=certificates) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/acm_certificate/aliases b/tests/integration/targets/acm_certificate/aliases new file mode 100644 index 00000000000..26ae3a05950 --- /dev/null +++ b/tests/integration/targets/acm_certificate/aliases @@ -0,0 +1,6 @@ +# https://github.com/ansible/ansible/issues/67788 +# unstable + +cloud/aws + +acm_certificate_info diff --git a/tests/integration/targets/acm_certificate/defaults/main.yml b/tests/integration/targets/acm_certificate/defaults/main.yml new file mode 100644 index 00000000000..5d3648f8e60 --- /dev/null +++ b/tests/integration/targets/acm_certificate/defaults/main.yml @@ -0,0 +1,40 @@ +--- +# we'll generate 3 certificates locally for the test +# Upload the first +# overwrite it with the second +# and the third is unrelated, to check we only get info about the first when we want +local_certs: + - priv_key: "{{ remote_tmp_dir }}/private-1.pem" + cert: "{{ remote_tmp_dir }}/public-1.pem" + csr: "{{ remote_tmp_dir }}/csr-1.csr" + domain: "acm1.{{ aws_acm_test_uuid }}.ansible.com" + name: "{{ resource_prefix }}_{{ aws_acm_test_uuid }}_1" + + - priv_key: "{{ remote_tmp_dir }}/private-2.pem" + cert: "{{ remote_tmp_dir }}/public-2.pem" + csr: "{{ remote_tmp_dir }}/csr-2.csr" + domain: "acm2.{{ aws_acm_test_uuid }}.ansible.com" + name: "{{ resource_prefix }}_{{ aws_acm_test_uuid }}_2" + + - priv_key: "{{ remote_tmp_dir }}/private-3.pem" + cert: "{{ remote_tmp_dir }}/public-3.pem" + csr: "{{ remote_tmp_dir }}/csr-3.csr" + domain: "acm3.{{ aws_acm_test_uuid }}.ansible.com" + name: "{{ resource_prefix }}_{{ aws_acm_test_uuid }}_3" + +# we'll have one private key +# make 2 chains using it +# so we can test what happens when you change just the chain +# not the domain or key +chained_cert: + priv_key: "{{ remote_tmp_dir }}/private-ch-0.pem" + domain: "acm-ch.{{ aws_acm_test_uuid }}.ansible.com" + name: "{{ resource_prefix }}_{{ aws_acm_test_uuid }}_4" + chains: + - cert: "{{ remote_tmp_dir }}/public-ch-0.pem" + csr: "{{ remote_tmp_dir }}/csr-ch-0.csr" + ca: 0 # index into local_certs + - cert: "{{ remote_tmp_dir }}/public-ch-1.pem" + csr: "{{ remote_tmp_dir }}/csr-ch-1.csr" + ca: 1 # index into local_certs + \ No newline at end of file diff --git a/tests/integration/targets/acm_certificate/meta/main.yml b/tests/integration/targets/acm_certificate/meta/main.yml new file mode 100644 index 00000000000..1810d4bec98 --- /dev/null +++ b/tests/integration/targets/acm_certificate/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_remote_tmp_dir diff --git a/tests/integration/targets/acm_certificate/tasks/full_acm_test.yml b/tests/integration/targets/acm_certificate/tasks/full_acm_test.yml new file mode 100644 index 00000000000..a02b07b3a37 --- /dev/null +++ b/tests/integration/targets/acm_certificate/tasks/full_acm_test.yml @@ -0,0 +1,504 @@ +- name: AWS ACM integration test + module_defaults: + group/aws: + aws_region: '{{ aws_region }}' + aws_access_key: '{{ aws_access_key }}' + aws_secret_key: '{{ aws_secret_key }}' + security_token: '{{ security_token | default(omit) }}' + block: + - name: list certs + aws_acm_info: null + register: list_all + - name: list certs with check mode + aws_acm_info: null + register: list_all_check + check_mode: yes # read-only task, should work the same as with no + - name: check certificate listing worked + assert: + that: + - list_all.certificates is defined + - list_all_check.certificates is defined + - list_all.certificates == list_all_check.certificates + - name: ensure absent cert which doesn't exist - first time + aws_acm: + name_tag: '{{ item.name }}' + state: absent + with_items: '{{ local_certs }}' + - name: ensure absent cert which doesn't exist - second time + aws_acm: + name_tag: '{{ item[0].name }}' + state: absent + check_mode: '{{ item[1] }}' + with_nested: + - '{{ local_certs }}' + - [true, false] + register: absent_start_two + - name: check no change when ensuring absent cert is absent + assert: + that: + - not item.changed + with_items: "{{ absent_start_two.results }}" + - name: list cert which shouldn't exist + aws_acm_info: + tags: + Name: '{{ item.name }}' + register: list_tag + with_items: '{{ local_certs }}' + - name: check listing of missing cert returns no result + with_items: '{{ list_tag.results }}' + assert: + that: + - (item.certificates | length) == 0 + - not list_tag.changed + - name: check directory was made + assert: + that: + - remote_tmp_dir is defined + - name: Generate private key for local certs + with_items: '{{ local_certs }}' + community.crypto.openssl_privatekey: + path: '{{ item.priv_key }}' + type: RSA + size: 2048 + - name: Generate an OpenSSL Certificate Signing Request for own certs + with_items: '{{ local_certs }}' + community.crypto.openssl_csr: + path: '{{ item.csr }}' + privatekey_path: '{{ item.priv_key }}' + common_name: '{{ item.domain }}' + - name: Generate a Self Signed OpenSSL certificate for own certs + with_items: '{{ local_certs }}' + community.crypto.x509_certificate: + provider: selfsigned + path: '{{ item.cert }}' + csr_path: '{{ item.csr }}' + privatekey_path: '{{ item.priv_key }}' + selfsigned_digest: sha256 + - name: upload certificate with check mode + aws_acm: + name_tag: '{{ item.name }}' + certificate: '{{ lookup(''file'', item.cert ) }}' + private_key: '{{ lookup(''file'', item.priv_key ) }}' + state: present + check_mode: yes + register: upload_check + with_items: '{{ local_certs }}' + - name: check whether cert was uploaded in check mode + aws_acm_info: + tags: + Name: '{{ item.name }}' + register: list_after_check_mode_upload + with_items: '{{ local_certs }}' + - name: check cert was not really uploaded in check mode + with_items: "{{ list_after_check_mode_upload.results }}" + assert: + that: + - upload_check.changed + - (item.certificates | length) == 0 + - name: upload certificates first time + aws_acm: + name_tag: '{{ item.name }}' + certificate: '{{ lookup(''file'', item.cert ) }}' + private_key: '{{ lookup(''file'', item.priv_key ) }}' + state: present + register: upload + check_mode: no + with_items: '{{ local_certs }}' + until: upload is succeeded + retries: 5 + delay: 10 + - assert: + that: + - prev_task.certificate.arn is defined + - ('arn:aws:acm:123' | regex_search( 'arn:aws:acm:' )) is defined + - (prev_task.certificate.arn | regex_search( 'arn:aws:acm:' )) is defined + - prev_task.certificate.domain_name == original_cert.domain + - prev_task.changed + with_items: '{{ upload.results }}' + vars: + original_cert: '{{ item.item }}' + prev_task: '{{ item }}' + - name: fetch data about cert just uploaded, by ARN + aws_acm_info: + certificate_arn: '{{ item.certificate.arn }}' + register: fetch_after_up + with_items: '{{ upload.results }}' + - name: check output of prior task (fetch data about cert just uploaded, by ARN) + assert: + that: + - fetch_after_up_result.certificates | length == 1 + - fetch_after_up_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup( 'file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '' )) + - '''Name'' in fetch_after_up_result.certificates[0].tags' + - fetch_after_up_result.certificates[0].tags['Name'] == original_cert.name + with_items: '{{ fetch_after_up.results }}' + vars: + fetch_after_up_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: fetch data about cert just uploaded, by name + aws_acm_info: + tags: + Name: '{{ original_cert.name }}' + register: fetch_after_up_name + with_items: '{{ upload.results }}' + vars: + upload_result: '{{ item }}' + original_cert: '{{ item.item }}' + - name: check fetched data of cert we just uploaded + assert: + that: + - fetch_after_up_name_result.certificates | length == 1 + - fetch_after_up_name_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_name_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_name_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_up_name_result.certificates[0].tags' + - fetch_after_up_name_result.certificates[0].tags['Name'] == original_cert.name + with_items: '{{ fetch_after_up_name.results }}' + vars: + fetch_after_up_name_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: fetch data about cert just uploaded, by domain name + aws_acm_info: + domain_name: '{{ original_cert.domain }}' + register: fetch_after_up_domain + with_items: '{{ upload.results }}' + vars: + original_cert: '{{ item.item }}' + - name: compare fetched data of cert just uploaded to upload task + assert: + that: + - fetch_after_up_domain_result.certificates | length == 1 + - fetch_after_up_domain_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_domain_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_domain_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_up_domain_result.certificates[0].tags' + - fetch_after_up_domain_result.certificates[0].tags['Name'] == original_cert.name + with_items: '{{ fetch_after_up_domain.results }}' + vars: + fetch_after_up_domain_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: upload certificates again, check not changed + aws_acm: + name_tag: '{{ item.name }}' + certificate: '{{ lookup(''file'', item.cert ) }}' + private_key: '{{ lookup(''file'', item.priv_key ) }}' + state: present + register: upload2 + with_items: '{{ local_certs }}' + failed_when: upload2.changed + - name: update first cert with body of the second, first time, check mode + aws_acm: + state: present + name_tag: '{{ local_certs[0].name }}' + certificate: '{{ lookup(''file'', local_certs[1].cert ) }}' + private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}' + check_mode: yes + register: overwrite_check + - name: check update in check mode detected required update + assert: + that: + - overwrite_check.changed + - name: check previous tasks did not change real cert + aws_acm_info: + tags: + Name: '{{ local_certs[0].name }}' + register: fetch_after_overwrite_check + - name: check update with check mode did not change real cert + assert: + that: + - fetch_after_overwrite_check.certificates | length == 1 + - fetch_after_overwrite_check.certificates[0].certificate_arn == fetch_after_up.results[0].certificates[0].certificate_arn + - fetch_after_overwrite_check.certificates[0].domain_name == local_certs[0].domain + - (fetch_after_overwrite_check.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[0].cert )| replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_overwrite_check.certificates[0].tags' + - fetch_after_overwrite_check.certificates[0].tags['Name'] == local_certs[0].name + - name: update first cert with body of the second, first real time + aws_acm: + state: present + name_tag: '{{ local_certs[0].name }}' + certificate: '{{ lookup(''file'', local_certs[1].cert ) }}' + private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}' + register: overwrite + - name: check output of previous task (update first cert with body of the second, first time) + assert: + that: + - overwrite.certificate.arn is defined + - overwrite.certificate.arn | regex_search( 'arn:aws:acm:' ) is defined + - overwrite.certificate.arn == upload.results[0].certificate.arn + - overwrite.certificate.domain_name == local_certs[1].domain + - overwrite.changed + - name: check update was sucessfull + aws_acm_info: + tags: + Name: '{{ local_certs[0].name }}' + register: fetch_after_overwrite + - name: check output of update fetch + assert: + that: + - fetch_after_overwrite.certificates | length == 1 + - fetch_after_overwrite.certificates[0].certificate_arn == fetch_after_up.results[0].certificates[0].certificate_arn + - fetch_after_overwrite.certificates[0].domain_name == local_certs[1].domain + - (fetch_after_overwrite.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[1].cert )| replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_overwrite.certificates[0].tags' + - fetch_after_overwrite.certificates[0].tags['Name'] == local_certs[0].name + - name: fetch other cert + aws_acm_info: + tags: + Name: '{{ local_certs[1].name }}' + register: check_after_overwrite + - name: check other cert unaffected + assert: + that: + - check_after_overwrite.certificates | length == 1 + - check_after_overwrite.certificates[0].certificate_arn == fetch_after_up.results[1].certificates[0].certificate_arn + - check_after_overwrite.certificates[0].domain_name == local_certs[1].domain + - (check_after_overwrite.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[1].cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in check_after_overwrite.certificates[0].tags' + - check_after_overwrite.certificates[0].tags['Name'] == local_certs[1].name + - name: update first cert with body of the second again + aws_acm: + state: present + name_tag: '{{ local_certs[0].name }}' + certificate: '{{ lookup(''file'', local_certs[1].cert ) }}' + private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}' + register: overwrite2 + - name: check output of previous task (update first cert with body of the second again) + assert: + that: + - overwrite2.certificate.arn is defined + - overwrite2.certificate.arn | regex_search( 'arn:aws:acm:' ) is defined + - overwrite2.certificate.arn == upload.results[0].certificate.arn + - overwrite2.certificate.domain_name == local_certs[1].domain + - not overwrite2.changed + - name: delete certs 1 and 2 in check mode + aws_acm: + state: absent + domain_name: '{{ local_certs[1].domain }}' + check_mode: yes + register: delete_both_check + - name: test deletion with check mode detected change + assert: + that: + - delete_both_check.changed + - name: fetch info for certs 1 and 2 + aws_acm_info: + tags: + Name: '{{ local_certs[item].name }}' + register: check_del_one_check + with_items: + - 0 + - 1 + - name: test deletion with check mode detected change + with_items: '{{ check_del_one_check.results }}' + assert: + that: + - (item.certificates | length) == 1 + - name: delete certs 1 and 2 real + aws_acm: + state: absent + domain_name: '{{ local_certs[1].domain }}' + register: delete_both + - name: test prev task + assert: + that: + - delete_both.arns is defined + - check_after_overwrite.certificates[0].certificate_arn in delete_both.arns + - upload.results[0].certificate.arn in delete_both.arns + - delete_both.changed + - name: fetch info for certs 1 and 2 + aws_acm_info: + tags: + Name: '{{ local_certs[item].name }}' + register: check_del_one + with_items: + - 0 + - 1 + retries: 2 + until: + - check_del_one is not failed + - check_del_one.certificates | length == 0 + delay: 10 + - name: check certs 1 and 2 were already deleted + with_items: '{{ check_del_one.results }}' + assert: + that: (item.certificates | length) == 0 + - name: check cert 3 + aws_acm_info: + tags: + Name: '{{ local_certs[2].name }}' + register: check_del_one_remain + - name: check cert 3 not deleted + assert: + that: + - (check_del_one_remain.certificates | length) == 1 + - name: delete cert 3 + aws_acm: + state: absent + domain_name: '{{ local_certs[2].domain }}' + register: delete_third + - name: check cert 3 deletion went as expected + assert: + that: + - delete_third.arns is defined + - delete_third.arns | length == 1 + - delete_third.arns[0] == upload.results[2].certificate.arn + - delete_third.changed + - name: check cert 3 was deleted + aws_acm_info: + tags: + Name: '{{ local_certs[2].name }}' + register: check_del_three + failed_when: check_del_three.certificates | length != 0 + - name: delete cert 3 again + aws_acm: + state: absent + domain_name: '{{ local_certs[2].domain }}' + register: delete_third + - name: check deletion of cert 3 not changed, because already deleted + assert: + that: + - delete_third.arns is defined + - delete_third.arns | length == 0 + - not delete_third.changed + - name: delete cert 3 again, check mode + aws_acm: + state: absent + domain_name: '{{ local_certs[2].domain }}' + check_mode: yes + register: delete_third_check + - name: test deletion in check mode detected required change + assert: + that: + - not delete_third_check.changed + - name: check directory was made + assert: + that: + - remote_tmp_dir is defined + - name: Generate private key for cert to be chained + community.crypto.openssl_privatekey: + path: '{{ chained_cert.priv_key }}' + type: RSA + size: 2048 + - name: Generate two OpenSSL Certificate Signing Requests for cert to be chained + with_items: '{{ chained_cert.chains }}' + community.crypto.openssl_csr: + path: '{{ item.csr }}' + privatekey_path: '{{ chained_cert.priv_key }}' + common_name: '{{ chained_cert.domain }}' + - name: Sign new certs with cert 0 and 1 + with_items: '{{ chained_cert.chains }}' + community.crypto.x509_certificate: + provider: ownca + path: '{{ item.cert }}' + csr_path: '{{ item.csr }}' + ownca_path: '{{ local_certs[item.ca].cert }}' + ownca_privatekey_path: '{{ local_certs[item.ca].priv_key }}' + selfsigned_digest: sha256 + - name: check files exist (for next task) + file: + path: '{{ item }}' + state: file + with_items: + - '{{ local_certs[chained_cert.chains[0].ca].cert }}' + - '{{ local_certs[chained_cert.chains[1].ca].cert }}' + - '{{ chained_cert.chains[0].cert }}' + - '{{ chained_cert.chains[1].cert }}' + - name: Find chains + with_items: '{{ chained_cert.chains }}' + register: chains + community.crypto.certificate_complete_chain: + input_chain: '{{ lookup(''file'', item.cert ) }}' + root_certificates: + - '{{ local_certs[item.ca].cert }}' + - name: upload chained cert, first chain, first time + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[0].cert ) }}' + certificate_chain: '{{ chains.results[0].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain + failed_when: not upload_chain.changed + - name: fetch chain of cert we just uploaded + aws_acm_info: + tags: + Name: '{{ chained_cert.name }}' + register: check_chain + - name: check chain of cert we just uploaded + assert: + that: + - (check_chain.certificates[0].certificate_chain | replace( ' ', '' ) | replace( '\n', '')) == ( chains.results[0].complete_chain | join( '\n' ) | replace( ' ', '' ) | replace( '\n', '') ) + - (check_chain.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == ( lookup('file', chained_cert.chains[0].cert ) | replace( ' ', '' ) | replace( '\n', '') ) + - name: upload chained cert again, check not changed + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[0].cert ) }}' + certificate_chain: '{{ chains.results[0].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain_2 + - name: check previous task not changed + assert: + that: + - upload_chain_2.certificate.arn == upload_chain.certificate.arn + - not upload_chain_2.changed + - name: upload chained cert, different chain + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[1].cert ) }}' + certificate_chain: '{{ chains.results[1].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain_3 + - name: check uploading with different chain is changed + assert: + that: + - upload_chain_3.changed + - upload_chain_3.certificate.arn == upload_chain.certificate.arn + - name: fetch info about chain of cert we just updated + aws_acm_info: + tags: + Name: '{{ chained_cert.name }}' + register: check_chain_2 + - name: check chain of cert we just uploaded + assert: + that: + - (check_chain_2.certificates[0].certificate_chain | replace( ' ', '' ) | replace( '\n', '')) == ( chains.results[1].complete_chain | join( '\n' ) | replace( ' ', '' ) | replace( '\n', '') ) + - (check_chain_2.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == ( lookup('file', chained_cert.chains[1].cert ) | replace( ' ', '' ) | replace( '\n', '') ) + - name: delete chained cert + aws_acm: + name_tag: '{{ chained_cert.name }}' + state: absent + register: delete_chain_3 + - name: check deletion of chained cert 3 is changed + assert: + that: + - delete_chain_3.changed + - upload_chain.certificate.arn in delete_chain_3.arns + always: + - name: delete first bunch of certificates + aws_acm: + name_tag: '{{ item.name }}' + state: absent + with_items: '{{ local_certs }}' + ignore_errors: true + - name: delete chained cert + aws_acm: + state: absent + name_tag: '{{ chained_cert.name }}' + ignore_errors: true + - name: deleting local directory with test artefacts + file: + path: '{{ remote_tmp_dir }}' + state: directory + ignore_errors: true diff --git a/tests/integration/targets/acm_certificate/tasks/main.yml b/tests/integration/targets/acm_certificate/tasks/main.yml new file mode 100644 index 00000000000..118fca74498 --- /dev/null +++ b/tests/integration/targets/acm_certificate/tasks/main.yml @@ -0,0 +1,579 @@ +- name: AWS ACM integration test + module_defaults: + group/aws: + aws_region: '{{ aws_region }}' + aws_access_key: '{{ aws_access_key }}' + aws_secret_key: '{{ aws_secret_key }}' + security_token: '{{ security_token | default(omit) }}' + block: + # The CI runs many of these tests in parallel + # Use this random ID to differentiate which resources + # are from which test + - set_fact: + aws_acm_test_uuid: "{{ (10**9) | random }}" + - name: attempt to delete cert without specifying required parameter + aws_acm: + state: absent + register: result + ignore_errors: true + - name: assert failure when name_tag conflicts with tags.Name + assert: + that: + - 'result.failed' + - '"If ''state'' is specified as ''absent'' then exactly one of ''name_tag''" in result.msg' + - name: list certs + aws_acm_info: null + register: list_all + failed_when: list_all.certificates is not defined + - name: ensure absent cert which doesn't exist - first time + aws_acm: + name_tag: '{{ item.name }}' + state: absent + with_items: '{{ local_certs }}' + - name: ensure absent cert which doesn't exist - second time + aws_acm: + name_tag: '{{ item.name }}' + state: absent + with_items: '{{ local_certs }}' + register: absent_start_two + failed_when: absent_start_two.changed + - name: list cert which shouldn't exist + aws_acm_info: + tags: + Name: '{{ item.name }}' + register: list_tag + with_items: '{{ local_certs }}' + failed_when: list_tag.certificates | length > 0 + - name: check directory was made + assert: + that: + - remote_tmp_dir is defined + - name: Generate private key for local certs + with_items: '{{ local_certs }}' + community.crypto.openssl_privatekey: + path: '{{ item.priv_key }}' + type: RSA + size: 2048 + - name: Generate an OpenSSL Certificate Signing Request for own certs + with_items: '{{ local_certs }}' + community.crypto.openssl_csr: + path: '{{ item.csr }}' + privatekey_path: '{{ item.priv_key }}' + common_name: '{{ item.domain }}' + - name: Generate a Self Signed OpenSSL certificate for own certs + with_items: '{{ local_certs }}' + community.crypto.x509_certificate: + provider: selfsigned + path: '{{ item.cert }}' + csr_path: '{{ item.csr }}' + privatekey_path: '{{ item.priv_key }}' + selfsigned_digest: sha256 + - name: try to upload certificate, but name_tag conflicts with tags.Name + vars: + local_cert: '{{ local_certs[0] }}' + aws_acm: + name_tag: '{{ local_cert.name }}' + certificate: '{{ lookup(''file'', local_cert.cert ) }}' + private_key: '{{ lookup(''file'', local_cert.priv_key ) }}' + state: present + tags: + Name: '{{ local_cert.name }}-other' + Application: search + Environment: development + register: result + ignore_errors: true + - name: assert failure when name_tag conflicts with tags.Name + assert: + that: + - 'result.failed' + - '"conflicts with value of" in result.msg' + - name: upload certificates first time + aws_acm: + name_tag: '{{ item.name }}' + certificate: '{{ lookup(''file'', item.cert ) }}' + private_key: '{{ lookup(''file'', item.priv_key ) }}' + state: present + # Add tags + tags: + Application: search + Environment: development + purge_tags: false + register: upload + with_items: '{{ local_certs }}' + until: upload is succeeded + retries: 5 + delay: 10 + - assert: + that: + - prev_task.certificate.arn is defined + - ('arn:aws:acm:123' | regex_search( 'arn:aws:acm:' )) is defined + - (prev_task.certificate.arn | regex_search( 'arn:aws:acm:' )) is defined + - prev_task.certificate.domain_name == original_cert.domain + - prev_task.changed + with_items: '{{ upload.results }}' + vars: + original_cert: '{{ item.item }}' + prev_task: '{{ item }}' + - name: fetch data about cert just uploaded, by ARN + aws_acm_info: + certificate_arn: '{{ item.certificate.arn }}' + register: fetch_after_up + with_items: '{{ upload.results }}' + - name: check output of prior task (fetch data about cert just uploaded, by ARN) + assert: + that: + - fetch_after_up_result.certificates | length == 1 + - fetch_after_up_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup( 'file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '' )) + - '''Name'' in fetch_after_up_result.certificates[0].tags' + - '''Application'' in fetch_after_up_result.certificates[0].tags' + - '''Environment'' in fetch_after_up_result.certificates[0].tags' + - fetch_after_up_result.certificates[0].tags['Name'] == original_cert.name + - fetch_after_up_result.certificates[0].tags['Application'] == 'search' + - fetch_after_up_result.certificates[0].tags['Environment'] == 'development' + with_items: '{{ fetch_after_up.results }}' + vars: + fetch_after_up_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: fetch data about cert just uploaded, by name + aws_acm_info: + tags: + Name: '{{ original_cert.name }}' + register: fetch_after_up_name + with_items: '{{ upload.results }}' + vars: + upload_result: '{{ item }}' + original_cert: '{{ item.item }}' + - name: check fetched data of cert we just uploaded + assert: + that: + - fetch_after_up_name_result.certificates | length == 1 + - fetch_after_up_name_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_name_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_name_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_up_name_result.certificates[0].tags' + - fetch_after_up_name_result.certificates[0].tags['Name'] == original_cert.name + with_items: '{{ fetch_after_up_name.results }}' + vars: + fetch_after_up_name_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: fetch data about cert just uploaded, by domain name + aws_acm_info: + domain_name: '{{ original_cert.domain }}' + register: fetch_after_up_domain + with_items: '{{ upload.results }}' + vars: + original_cert: '{{ item.item }}' + - name: compare fetched data of cert just uploaded to upload task + assert: + that: + - fetch_after_up_domain_result.certificates | length == 1 + - fetch_after_up_domain_result.certificates[0].certificate_arn == upload_result.certificate.arn + - fetch_after_up_domain_result.certificates[0].domain_name == original_cert.domain + - (fetch_after_up_domain_result.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', original_cert.cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_up_domain_result.certificates[0].tags' + - fetch_after_up_domain_result.certificates[0].tags['Name'] == original_cert.name + with_items: '{{ fetch_after_up_domain.results }}' + vars: + fetch_after_up_domain_result: '{{ item }}' + upload_result: '{{ item.item }}' + original_cert: '{{ item.item.item }}' + - name: upload certificates again, check not changed + aws_acm: + name_tag: '{{ item.name }}' + certificate: '{{ lookup(''file'', item.cert ) }}' + private_key: '{{ lookup(''file'', item.priv_key ) }}' + state: present + register: upload2 + with_items: '{{ local_certs }}' + failed_when: upload2.changed + - name: change tags of existing certificate, check mode + aws_acm: + certificate_arn: '{{ certificate_arn }}' + tags: + Name: '{{ name_tag }}' + Application: search + Environment: staging + Owner: Bob + register: certificate_with_tags + check_mode: true + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + domain_name: '{{ upload2.results[0].certificate.domain_name }}' + - assert: + that: + - certificate_with_tags.changed + - name: change tags of existing certificate, changes expected + aws_acm: + # When applying tags to an existing certificate, it is sufficient to specify the 'certificate_arn'. + # Previously, the 'aws_acm' module was requiring the 'certificate', 'name_tag' and 'domain_name' + # attributes. + certificate_arn: '{{ certificate_arn }}' + tags: + Name: '{{ name_tag }}' + Application: search + Environment: staging + Owner: Bob + register: certificate_with_tags + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + domain_name: '{{ upload2.results[0].certificate.domain_name }}' + - name: assert certificate tags + assert: + that: + - certificate_with_tags.certificate.tags | length == 4 + - '''Name'' in certificate_with_tags.certificate.tags' + - '''Application'' in certificate_with_tags.certificate.tags' + - '''Environment'' in certificate_with_tags.certificate.tags' + - '''Owner'' in certificate_with_tags.certificate.tags' + - certificate_with_tags.certificate.tags['Name'] == name_tag + - certificate_with_tags.certificate.tags['Application'] == 'search' + - certificate_with_tags.certificate.tags['Environment'] == 'staging' + - certificate_with_tags.certificate.tags['Owner'] == 'Bob' + - certificate_with_tags.changed + vars: + name_tag: '{{ upload2.results[0].item.name }}' + - name: change tags of existing certificate, check mode again + aws_acm: + certificate_arn: '{{ certificate_arn }}' + tags: + Name: '{{ name_tag }}' + Application: search + Environment: staging + Owner: Bob + register: certificate_with_tags + check_mode: true + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + - assert: + that: + - not certificate_with_tags.changed + - name: change tags of existing certificate, no change expected + aws_acm: + certificate_arn: '{{ certificate_arn }}' + tags: + Name: '{{ name_tag }}' + Application: search + Environment: staging + Owner: Bob + register: certificate_with_tags + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + - name: assert certificate tags + assert: + that: + - certificate_with_tags.certificate.tags | length == 4 + - '''Name'' in certificate_with_tags.certificate.tags' + - '''Application'' in certificate_with_tags.certificate.tags' + - '''Environment'' in certificate_with_tags.certificate.tags' + - '''Owner'' in certificate_with_tags.certificate.tags' + - certificate_with_tags.certificate.tags['Name'] == name_tag + - certificate_with_tags.certificate.tags['Application'] == 'search' + - certificate_with_tags.certificate.tags['Environment'] == 'staging' + - certificate_with_tags.certificate.tags['Owner'] == 'Bob' + - not certificate_with_tags.changed + vars: + name_tag: '{{ upload2.results[0].item.name }}' + - name: check fetched data of cert we just uploaded + vars: + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + domain_name: '{{ upload2.results[0].certificate.domain_name }}' + name_tag: '{{ upload2.results[0].item.name }}' + assert: + that: + - certificate_with_tags.certificate.arn == certificate_arn + - certificate_with_tags.certificate.tags | length == 4 + - '''Name'' in certificate_with_tags.certificate.tags' + - '''Application'' in certificate_with_tags.certificate.tags' + - '''Environment'' in certificate_with_tags.certificate.tags' + - '''Owner'' in certificate_with_tags.certificate.tags' + - certificate_with_tags.certificate.tags['Name'] == name_tag + - certificate_with_tags.certificate.tags['Application'] == 'search' + - certificate_with_tags.certificate.tags['Environment'] == 'staging' + - certificate_with_tags.certificate.tags['Owner'] == 'Bob' + - name: change tags of existing certificate, purge tags + aws_acm: + certificate_arn: '{{ certificate_arn }}' + tags: + Name: '{{ name_tag }}' + Application: search + Environment: staging + # 'Owner' tag should be removed because 'purge_tags: true' + purge_tags: true + register: certificate_with_tags + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + domain_name: '{{ upload2.results[0].certificate.domain_name }}' + - name: check fetched data of cert we just uploaded + vars: + name_tag: '{{ upload2.results[0].item.name }}' + certificate_arn: '{{ upload2.results[0].certificate.arn }}' + domain_name: '{{ upload2.results[0].certificate.domain_name }}' + assert: + that: + - certificate_with_tags.certificate.arn == certificate_arn + - certificate_with_tags.certificate.tags | length == 3 + - '''Name'' in certificate_with_tags.certificate.tags' + - '''Application'' in certificate_with_tags.certificate.tags' + - '''Environment'' in certificate_with_tags.certificate.tags' + - certificate_with_tags.certificate.tags['Name'] == name_tag + - certificate_with_tags.certificate.tags['Application'] == 'search' + - certificate_with_tags.certificate.tags['Environment'] == 'staging' + - name: update first cert with body of the second, first time + aws_acm: + state: present + name_tag: '{{ local_certs[0].name }}' + certificate: '{{ lookup(''file'', local_certs[1].cert ) }}' + private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}' + register: overwrite + - name: check output of previous task (update first cert with body of the second, first time) + assert: + that: + - overwrite.certificate.arn is defined + - overwrite.certificate.arn | regex_search( 'arn:aws:acm:' ) is defined + - overwrite.certificate.arn == upload.results[0].certificate.arn + - overwrite.certificate.domain_name == local_certs[1].domain + - overwrite.changed + - name: check update was sucessfull + aws_acm_info: + tags: + Name: '{{ local_certs[0].name }}' + register: fetch_after_overwrite + - name: check output of update fetch + assert: + that: + - fetch_after_overwrite.certificates | length == 1 + - fetch_after_overwrite.certificates[0].certificate_arn == fetch_after_up.results[0].certificates[0].certificate_arn + - fetch_after_overwrite.certificates[0].domain_name == local_certs[1].domain + - (fetch_after_overwrite.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[1].cert )| replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in fetch_after_overwrite.certificates[0].tags' + - fetch_after_overwrite.certificates[0].tags['Name'] == local_certs[0].name + - name: fetch other cert + aws_acm_info: + tags: + Name: '{{ local_certs[1].name }}' + register: check_after_overwrite + - name: check other cert unaffected + assert: + that: + - check_after_overwrite.certificates | length == 1 + - check_after_overwrite.certificates[0].certificate_arn == fetch_after_up.results[1].certificates[0].certificate_arn + - check_after_overwrite.certificates[0].domain_name == local_certs[1].domain + - (check_after_overwrite.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[1].cert ) | replace( ' ', '' ) | replace( '\n', '')) + - '''Name'' in check_after_overwrite.certificates[0].tags' + - check_after_overwrite.certificates[0].tags['Name'] == local_certs[1].name + - name: update first cert with body of the second again + aws_acm: + state: present + name_tag: '{{ local_certs[0].name }}' + certificate: '{{ lookup(''file'', local_certs[1].cert ) }}' + private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}' + register: overwrite2 + - name: check output of previous task (update first cert with body of the second again) + assert: + that: + - overwrite2.certificate.arn is defined + - overwrite2.certificate.arn | regex_search( 'arn:aws:acm:' ) is defined + - overwrite2.certificate.arn == upload.results[0].certificate.arn + - overwrite2.certificate.domain_name == local_certs[1].domain + - not overwrite2.changed + - name: delete certs 1 and 2 + aws_acm: + state: absent + domain_name: '{{ local_certs[1].domain }}' + register: delete_both + - name: test prev task + assert: + that: + - delete_both.arns is defined + - check_after_overwrite.certificates[0].certificate_arn in delete_both.arns + - upload.results[0].certificate.arn in delete_both.arns + - delete_both.changed + - name: fetch info for certs 1 and 2 + aws_acm_info: + tags: + Name: '{{ local_certs[item].name }}' + register: check_del_one + with_items: + - 0 + - 1 + retries: 2 + until: + - check_del_one is not failed + - check_del_one.certificates | length == 0 + delay: 10 + - name: check certs 1 and 2 were already deleted + with_items: '{{ check_del_one.results }}' + assert: + that: item.certificates | length == 0 + - name: check cert 3 not deleted + aws_acm_info: + tags: + Name: '{{ local_certs[2].name }}' + register: check_del_one_remain + failed_when: check_del_one_remain.certificates | length != 1 + - name: delete cert 3 + aws_acm: + state: absent + domain_name: '{{ local_certs[2].domain }}' + register: delete_third + - name: check cert 3 deletion went as expected + assert: + that: + - delete_third.arns is defined + - delete_third.arns | length == 1 + - delete_third.arns[0] == upload.results[2].certificate.arn + - delete_third.changed + - name: check cert 3 was deleted + aws_acm_info: + tags: + Name: '{{ local_certs[2].name }}' + register: check_del_three + failed_when: check_del_three.certificates | length != 0 + - name: delete cert 3 again + aws_acm: + state: absent + domain_name: '{{ local_certs[2].domain }}' + register: delete_third + - name: check deletion of cert 3 not changed, because already deleted + assert: + that: + - delete_third.arns is defined + - delete_third.arns | length == 0 + - not delete_third.changed + - name: check directory was made + assert: + that: + - remote_tmp_dir is defined + - name: Generate private key for cert to be chained + community.crypto.openssl_privatekey: + path: '{{ chained_cert.priv_key }}' + type: RSA + size: 2048 + - name: Generate two OpenSSL Certificate Signing Requests for cert to be chained + with_items: '{{ chained_cert.chains }}' + community.crypto.openssl_csr: + path: '{{ item.csr }}' + privatekey_path: '{{ chained_cert.priv_key }}' + common_name: '{{ chained_cert.domain }}' + - name: Sign new certs with cert 0 and 1 + with_items: '{{ chained_cert.chains }}' + community.crypto.x509_certificate: + provider: ownca + path: '{{ item.cert }}' + csr_path: '{{ item.csr }}' + ownca_path: '{{ local_certs[item.ca].cert }}' + ownca_privatekey_path: '{{ local_certs[item.ca].priv_key }}' + selfsigned_digest: sha256 + - name: check files exist (for next task) + file: + path: '{{ item }}' + state: file + with_items: + - '{{ local_certs[chained_cert.chains[0].ca].cert }}' + - '{{ local_certs[chained_cert.chains[1].ca].cert }}' + - '{{ chained_cert.chains[0].cert }}' + - '{{ chained_cert.chains[1].cert }}' + - name: Find chains + with_items: '{{ chained_cert.chains }}' + register: chains + community.crypto.certificate_complete_chain: + input_chain: '{{ lookup(''file'', item.cert ) }}' + root_certificates: + - '{{ local_certs[item.ca].cert }}' + - name: upload chained cert, first chain, first time + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[0].cert ) }}' + certificate_chain: '{{ chains.results[0].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain + failed_when: not upload_chain.changed + - name: fetch chain of cert we just uploaded + aws_acm_info: + tags: + Name: '{{ chained_cert.name }}' + register: check_chain + - name: check chain of cert we just uploaded + assert: + that: + - (check_chain.certificates[0].certificate_chain | replace( ' ', '' ) | replace( '\n', '')) == ( chains.results[0].complete_chain | join( '\n' ) | replace( ' ', '' ) | replace( '\n', '') ) + - (check_chain.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == ( lookup('file', chained_cert.chains[0].cert ) | replace( ' ', '' ) | replace( '\n', '') ) + - name: upload chained cert again, check not changed + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[0].cert ) }}' + certificate_chain: '{{ chains.results[0].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain_2 + - name: check previous task not changed + assert: + that: + - upload_chain_2.certificate.arn == upload_chain.certificate.arn + - not upload_chain_2.changed + - name: upload chained cert, different chain + aws_acm: + name_tag: '{{ chained_cert.name }}' + certificate: '{{ lookup(''file'', chained_cert.chains[1].cert ) }}' + certificate_chain: '{{ chains.results[1].complete_chain | join('' + + '') }}' + private_key: '{{ lookup(''file'', chained_cert.priv_key ) }}' + state: present + register: upload_chain_3 + - name: check uploading with different chain is changed + assert: + that: + - upload_chain_3.changed + - upload_chain_3.certificate.arn == upload_chain.certificate.arn + - name: fetch info about chain of cert we just updated + aws_acm_info: + tags: + Name: '{{ chained_cert.name }}' + register: check_chain_2 + - name: check chain of cert we just uploaded + assert: + that: + - (check_chain_2.certificates[0].certificate_chain | replace( ' ', '' ) | replace( '\n', '')) == ( chains.results[1].complete_chain | join( '\n' ) | replace( ' ', '' ) | replace( '\n', '') ) + - (check_chain_2.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == ( lookup('file', chained_cert.chains[1].cert ) | replace( ' ', '' ) | replace( '\n', '') ) + - name: delete chained cert + aws_acm: + name_tag: '{{ chained_cert.name }}' + state: absent + register: delete_chain_3 + - name: check deletion of chained cert 3 is changed + assert: + that: + - delete_chain_3.changed + - upload_chain.certificate.arn in delete_chain_3.arns + always: + - name: delete first bunch of certificates + aws_acm: + name_tag: '{{ item.name }}' + state: absent + with_items: '{{ local_certs }}' + ignore_errors: true + - name: delete chained cert + aws_acm: + state: absent + name_tag: '{{ chained_cert.name }}' + ignore_errors: true + - name: deleting local directory with test artefacts + file: + path: '{{ remote_tmp_dir }}' + state: directory + ignore_errors: true