From 17ad72dcceee742e27496ccfa49b62f29b289f87 Mon Sep 17 00:00:00 2001 From: aubin bikouo Date: Thu, 6 Feb 2025 15:09:54 +0100 Subject: [PATCH] add new modules ssm_document and ssm_document_info --- meta/runtime.yml | 2 + plugins/module_utils/ssm.py | 113 ++++ plugins/modules/ssm_document.py | 583 ++++++++++++++++++ plugins/modules/ssm_document_info.py | 492 +++++++++++++++ .../tasks/cleanup.yml | 5 +- .../tasks/ssm_document.yml | 7 +- .../integration/targets/ssm_document/aliases | 3 + .../files/ssm-custom-document-2.json | 25 + .../files/ssm-custom-document.json | 20 + .../targets/ssm_document/tasks/main.yml | 393 ++++++++++++ 10 files changed, 1639 insertions(+), 4 deletions(-) create mode 100644 plugins/module_utils/ssm.py create mode 100644 plugins/modules/ssm_document.py create mode 100644 plugins/modules/ssm_document_info.py create mode 100644 tests/integration/targets/ssm_document/aliases create mode 100644 tests/integration/targets/ssm_document/files/ssm-custom-document-2.json create mode 100644 tests/integration/targets/ssm_document/files/ssm-custom-document.json create mode 100644 tests/integration/targets/ssm_document/tasks/main.yml diff --git a/meta/runtime.yml b/meta/runtime.yml index 5d54de6274b..1be42cc631a 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -178,6 +178,8 @@ action_groups: - sns_topic - sns_topic_info - sqs_queue + - ssm_document_info + - ssm_document - ssm_inventory_info - ssm_parameter - stepfunctions_state_machine diff --git a/plugins/module_utils/ssm.py b/plugins/module_utils/ssm.py new file mode 100644 index 00000000000..3f6553b96c8 --- /dev/null +++ b/plugins/module_utils/ssm.py @@ -0,0 +1,113 @@ +# -*- 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) + +from typing import Any +from typing import Dict +from typing import List + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.errors import AWSErrorHandler +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.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list +from ansible_collections.amazon.aws.plugins.module_utils.tagging import compare_aws_tags + + +class AnsibleSSMError(AnsibleAWSError): + pass + + +# SSM Documents +class SSMDocumentErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleSSMError + + @classmethod + def _is_missing(cls): + return is_boto3_error_code("InvalidDocument") + + +@SSMDocumentErrorHandler.deletion_error_handler("delete document") +@AWSRetry.jittered_backoff() +def delete_document(client, name: str, **kwargs: Dict[str, Any]) -> bool: + client.delete_document(Name=name, **kwargs) + return True + + +@SSMDocumentErrorHandler.list_error_handler("describe document", {}) +@AWSRetry.jittered_backoff() +def describe_document(client, name: str, **params: Dict[str, str]) -> Dict[str, Any]: + return client.describe_document(Name=name, **params)["Document"] + + +@SSMDocumentErrorHandler.common_error_handler("create document") +@AWSRetry.jittered_backoff() +def create_document(client, name: str, content: str, **params: Dict[str, Any]) -> Dict[str, Any]: + return client.create_document(Name=name, Content=content, **params)["DocumentDescription"] + + +@SSMDocumentErrorHandler.common_error_handler("update document") +@AWSRetry.jittered_backoff() +def update_document(client, name: str, content: str, **params: Dict[str, Any]) -> Dict[str, Any]: + return client.update_document(Name=name, Content=content, **params)["DocumentDescription"] + + +@SSMDocumentErrorHandler.common_error_handler("update document default version") +@AWSRetry.jittered_backoff() +def update_document_default_version(client, name: str, default_version: str) -> Dict[str, Any]: + return client.update_document_default_version(Name=name, DocumentVersion=default_version) + + +@SSMDocumentErrorHandler.list_error_handler("list documents", {}) +@AWSRetry.jittered_backoff() +def list_documents(client, **kwargs: Dict[str, Any]) -> List[Dict[str, Any]]: + paginator = client.get_paginator("list_documents") + return paginator.paginate(**kwargs).build_full_result()["DocumentIdentifiers"] + + +@SSMDocumentErrorHandler.list_error_handler("list document versions", {}) +@AWSRetry.jittered_backoff() +def list_document_versions(ssm: Any, name: str) -> List[Dict[str, Any]]: + paginator = ssm.get_paginator("list_document_versions") + return paginator.paginate(Name=name).build_full_result()["DocumentVersions"] + + +# Tags +def add_tags_to_resource(client, resource_type: str, resource_id: str, tags: List[Dict[str, Any]]) -> None: + client.add_tags_to_resource(ResourceType=resource_type, ResourceId=resource_id, Tags=tags) + + +def remove_tags_from_resource(client, resource_type: str, resource_id: str, tag_keys: List[str]) -> None: + client.remove_tags_from_resource(ResourceType=resource_type, ResourceId=resource_id, TagKeys=tag_keys) + + +def ensure_ssm_resource_tags( + client, module: AnsibleAWSModule, current_tags: Dict[str, str], resource_id: str, resource_type: str +) -> bool: + """Update resources tags""" + tags = module.params.get("tags") + purge_tags = module.params.get("purge_tags") + tags_to_set, tags_to_unset = compare_aws_tags(current_tags, tags, purge_tags) + + if purge_tags and not tags: + tags_to_unset = current_tags + + changed = False + if tags_to_set: + changed = True + if not module.check_mode: + add_tags_to_resource( + client, + resource_type=resource_type, + resource_id=resource_id, + tags=ansible_dict_to_boto3_tag_list(tags_to_set), + ) + if tags_to_unset: + changed = True + if not module.check_mode: + remove_tags_from_resource( + client, resource_type=resource_type, resource_id=resource_id, tag_keys=tags_to_unset + ) + return changed diff --git a/plugins/modules/ssm_document.py b/plugins/modules/ssm_document.py new file mode 100644 index 00000000000..6bffa916a45 --- /dev/null +++ b/plugins/modules/ssm_document.py @@ -0,0 +1,583 @@ +#!/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: ssm_document +version_added: 9.2.0 +short_description: Manage SSM document +description: + - Create, update or delete a Amazon Web Services Systems Manager (SSM document). +author: + - Aubin Bikouo (@abikouo) +options: + name: + description: + - The name of the document. + required: true + type: str + state: + description: + - Create or delete SSM document. + required: false + default: present + choices: [ 'present', 'absent' ] + type: str + content: + description: + - The content for the new SSM document in JSON or YAML format. + - Specify this option with O(state=present) to create a new document or a new document version. + - Mutually exclusive with O(content_path). + type: raw + content_path: + description: + - The path to a file containing the data for the new SSM document in JSON or YAML format. + - Specify this option with O(state=present) to create a new document or a new document version. + - Mutually exclusive with O(content). + type: path + document_default_version: + description: + - The version of a custom document that you want to set as the default version. + - If not provided, all versions of the document are deleted. + type: str + document_version: + description: + - When O(state=absent), The version of the document that you want to delete, if not provided, + all versions of the document are deleted. + - When O(state=present) and the document exists, this value corresponds to the document version + to update, default to V($LATEST) when not provided. + type: str + force_delete: + description: + - Some SSM document types require that you specify a Force flag before you can delete the document. + - Ignored if O(state=present). + type: bool + default: false + document_version_name: + description: + - When O(state=absent), specify the version name of the document that you want to delete. + - When O(state=present), specify the version of the artifact you are creating with the document. + type: str + aliases: ['version_name'] + document_format: + description: + - Specify the document format. The document format can be JSON, YAML, or TEXT. + type: str + default: 'JSON' + choices: ['JSON', 'YAML', 'TEXT'] + document_type: + description: + - The type of document to create. + type: str + choices: + - 'Command' + - 'Policy' + - 'Automation' + - 'Session' + - 'Package' + - 'ApplicationConfiguration' + - 'ApplicationConfigurationSchema' + - 'DeploymentStrategy' + - 'ChangeCalendar' + - 'Automation.ChangeTemplate' + - 'ProblemAnalysis' + - 'ProblemAnalysisTemplate' + - 'CloudFormation' + - 'ConformancePackTemplate' + - 'QuickSetup' + target_type: + description: + - Specify a target type to define the kinds of resources the document can run on. + type: str + display_name: + description: + - Specify a friendly name for the SSM document. This value can differ for each version of the document. + type: str + attachments: + description: + - A list of key-value pairs that describe attachments to a version of a document. + type: list + elements: dict + suboptions: + key: + description: + - The key of a key-value pair that identifies the location of an attachment to a document. + type: str + values: + description: + - The value of a key-value pair that identifies the location of an attachment to a document. + type: list + elements: str + name: + description: + - The name of the document attachment file. + type: str + requires: + description: + - A list of SSM documents required by a document. + type: list + elements: dict + suboptions: + name: + description: + - The name of the required SSM document. The name can be an Amazon Resource Name (ARN). + type: str + required: true + version: + description: + - The document version required by the current document. + type: str + require_type: + description: + - The document type of the required SSM document. + type: str + version_name: + description: + - The version of the artifact associated with the document. + type: str +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.tags + - amazon.aws.boto3 + +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. +- name: Delete SSM document + community.aws.ssm_parameter: + name: "Hello" + state: absent + +- name: Create SSM Command document + community.aws.ssm_document: + name: "SampleDocument" + content_path: ssm-custom-document.json + document_type: Command + +- name: Update SSM document tags + community.aws.ssm_document: + name: "SampleDocument" + state: present + resource_tags: + foo: bar + purge_tags: true +""" + +RETURN = r""" +document: + description: Information about the SSM document created or updated. + type: dict + returned: On success + contains: + created_date: + description: The date when the document was created. + returned: always. + type: str + sample: "2025-03-17T19:07:14.611000+01:00" + sha1: + description: The SHA1 hash of the document, which you can use for verification. + returned: if defined + type: str + hash: + description: The Sha256 or Sha1 hash created by the system when the document was created.. + returned: always. + type: str + sample: "087cfdbb52b14aca6d357272426521c327cdccbf59a40ca77f8d53d367a6095b" + hash_type: + description: The hash type of the document. Valid values include Sha256 or Sha1. + returned: always. + type: str + sample: "Sha256" + name: + description: The name of the SSM document. + returned: always. + type: str + sample: "DocumentName" + display_name: + description: The friendly name of the SSM document. + returned: if defined + type: str + version_name: + description: The version of the artifact associated with the document. + returned: if defined + type: str + owner: + description: The Amazon Web Services user that created the document. + returned: always. + type: str + sample: "0123456789" + status: + description: The status of the SSM document. + returned: always. + type: str + sample: "Creating" + status_information: + description: A message returned by Amazon Web Services Systems Manager that explains the RV(document.status) value. + returned: always. + type: str + document_version: + description: The document version. + returned: always. + type: str + sample: "1" + description: + description: A description of the document. + returned: always. + type: str + sample: "A document description" + parameters: + description: A description of the parameters for a document. + returned: if defined + type: list + elements: dict + sample: [ + { + "default_value": "Ansible is super", + "description": "message to display", + "name": "Message", + "type": "String" + } + ] + platform_types: + description: The list of operating system (OS) platforms compatible with this SSM document. + returned: always. + type: str + sample: [ + "Windows", + "Linux", + "MacOS" + ] + document_type: + description: The type of document. + returned: always. + type: str + sample: "Command" + schema_version: + description: The schema version. + returned: always. + type: str + sample: "Creating" + latest_version: + description: The latest version of the document. + returned: always. + type: str + sample: "1" + default_version: + description: The default version. + returned: always. + type: str + sample: "1" + document_format: + description: The document format, either JSON or YAML. + returned: always. + type: str + sample: "JSON" + target_type: + description: The target type which defines the kinds of resources the document can run on. + returned: if defined + type: str + attachments_information: + description: Details about the document attachments, including names, locations, sizes, and so on. + returned: always. + type: list + elements: dict + contains: + name: + description: The name of the attachment. + returned: always. + type: str + requires: + description: A list of SSM documents required by a document. + returned: always. + type: list + elements: dict + contains: + name: + description: The name of the required SSM document. + returned: always. + type: str + version: + description: The document version required by the current document. + returned: always. + type: str + require_type: + description: The document type of the required SSM document. + returned: always. + type: str + version_name: + description: An optional field specifying the version of the artifact associated with the document. + returned: always. + type: str + author: + description: The user in your organization who created the document. + returned: always. + type: str + review_information: + description: Details about the review of a document. + returned: always. + type: list + elements: dict + contains: + reviewed_time: + description: The time that the reviewer took action on the document review request. + returned: always. + type: str + status: + description: The current status of the document review request. + returned: always. + type: str + reviewer: + description: The reviewer assigned to take action on the document review request. + returned: always. + type: str + approved_version: + description: The version of the document currently approved for use in the organization. + returned: always. + type: str + pending_review_version: + description: The version of the document that is currently under review. + returned: always. + type: str + review_status: + description: The current status of the review. + returned: always. + type: str + category: + description: The classification of a document to help you identify and categorize its use. + returned: always. + type: list + elements: str + category_enum: + description: The value that identifies a category. + returned: always. + type: list + elements: str + tags: + description: Tags of the s3 object. + returned: always + type: dict + sample: { + "Owner": "dev001", + "env": "test" + } +""" + +from typing import Any +from typing import Dict +from typing import Optional + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict + +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import is_ansible_aws_error_code +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list +from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict +from ansible_collections.amazon.aws.plugins.module_utils.transformation import scrub_none_parameters + +from ansible_collections.community.aws.plugins.module_utils.ssm import AnsibleSSMError +from ansible_collections.community.aws.plugins.module_utils.ssm import create_document +from ansible_collections.community.aws.plugins.module_utils.ssm import delete_document +from ansible_collections.community.aws.plugins.module_utils.ssm import describe_document +from ansible_collections.community.aws.plugins.module_utils.ssm import ensure_ssm_resource_tags +from ansible_collections.community.aws.plugins.module_utils.ssm import list_document_versions +from ansible_collections.community.aws.plugins.module_utils.ssm import update_document +from ansible_collections.community.aws.plugins.module_utils.ssm import update_document_default_version + + +def format_document(client, name: str) -> Optional[Dict[str, Any]]: + # Format result + document = describe_document(client, name) + if document: + # Add document version + document["DocumentVersions"] = list_document_versions(client, name) + tags = boto3_tag_list_to_ansible_dict(document.pop("Tags", {})) + document = camel_dict_to_snake_dict(document) + document.update({"tags": tags}) + return document + + +def delete_ssm_document(module: AnsibleAWSModule, ssm_client: Any, document: Optional[Dict[str, Any]]) -> bool: + if not document: + return False + if module.check_mode: + module.exit_json(msg="Would have delete SSM document if not in check mode.", changed=True) + + params = {} + name = module.params.get("name") + document_version = module.params.get("document_version") + version_name = module.params.get("version_name") + force_delete = module.params.get("force_delete") + if document_version: + params.update({"DocumentVersion": document_version}) + if version_name: + params.update({"VersionName": version_name}) + if force_delete: + params.update({"Force": force_delete}) + + return delete_document(ssm_client, name, **params) + + +def build_request_arguments(module: AnsibleAWSModule, document: Optional[Dict[str, Any]]) -> Dict[str, Any]: + attachments = module.params.get("attachments") + requires = module.params.get("requires") + tags = module.params.get("tags") + + # display_name, document_type, document_version_name, document_format, target_type + params = { + "DisplayName": module.params.get("display_name"), + "VersionName": module.params.get("document_version_name"), + "DocumentFormat": module.params.get("document_format"), + "TargetType": module.params.get("target_type"), + } + + # Requires + if requires: + params["Requires"] = snake_dict_to_camel_dict(requires) + # Attachments + if attachments: + params["Attachments"] = snake_dict_to_camel_dict(attachments) + # Tags (The ``update_document()`` does not accept the Tags and DocumentType parameters) + if not document: + params["Tags"] = ansible_dict_to_boto3_tag_list(tags) + params["DocumentType"] = module.params.get("document_type") + else: + params["DocumentVersion"] = module.params.get("document_version") or "$LATEST" + + return scrub_none_parameters(params) + + +def create_or_update_document(module: AnsibleAWSModule, ssm_client: Any, document: Optional[Dict[str, Any]]) -> bool: + name = module.params.get("name") + content = module.params.get("content") + content_path = module.params.get("content_path") + if content_path: + with open(content_path) as f: + content = f.read() + + if not document and not content: + module.fail_json(msg="One of 'content' or 'content_path' is required to create/update SSM document.") + + changed = False + # Create/update document + if content: + if module.check_mode: + operation = "create" if not document else "update" + module.exit_json(changed=True, msg=f"Would have {operation} SSM document if not in check mode.") + + params = build_request_arguments(module, document) + if not document: + document = create_document(ssm_client, name=name, content=content, **params) + changed = True + else: + try: + document = update_document(ssm_client, name=name, content=content, **params) + changed = True + except is_ansible_aws_error_code("DuplicateDocumentContent"): + pass + + # Ensure tags + tags = module.params.get("tags") + if tags is not None: + current_tags = boto3_tag_list_to_ansible_dict(document.get("Tags", {})) + changed |= ensure_ssm_resource_tags( + ssm_client, module, current_tags, resource_id=name, resource_type="Document" + ) + + return changed + + +def main(): + argument_spec = dict( + name=dict(required=True), + content=dict(type="raw"), + content_path=dict(type="path"), + state=dict(default="present", choices=["present", "absent"]), + document_version=dict(), + document_default_version=dict(), + force_delete=dict(type="bool", default=False), + document_version_name=dict(type="str", aliases=["version_name"]), + document_type=dict( + choices=[ + "Command", + "Policy", + "Automation", + "Session", + "Package", + "ApplicationConfiguration", + "ApplicationConfigurationSchema", + "DeploymentStrategy", + "ChangeCalendar", + "Automation.ChangeTemplate", + "ProblemAnalysis", + "ProblemAnalysisTemplate", + "CloudFormation", + "ConformancePackTemplate", + "QuickSetup", + ] + ), + document_format=dict(type="str", default="JSON", choices=["JSON", "YAML", "TEXT"]), + target_type=dict(), + display_name=dict(), + attachments=dict( + type="list", + elements="dict", + options=dict( + key=dict(no_log=False), + values=dict(type="list", elements="str"), + name=dict(), + ), + ), + requires=dict( + type="list", + elements="dict", + options=dict( + version_name=dict(), + require_type=dict(), + version=dict(), + name=dict(required=True), + ), + ), + tags=dict(required=False, type="dict", aliases=["resource_tags"]), + purge_tags=dict(required=False, type="bool", default=True), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + mutually_exclusive=[["content", "content_path"]], + supports_check_mode=True, + ) + + ssm_client = module.client("ssm") + state = module.params.get("state") + name = module.params.get("name") + document_default_version = module.params.get("document_default_version") + try: + document = describe_document(ssm_client, name) + + changed = False + if state == "absent": + changed = delete_ssm_document(module, ssm_client, document) + else: + changed = create_or_update_document(module, ssm_client, document) + + # document default version + document = format_document(ssm_client, name) + if document_default_version and document and document_default_version != document.get("default_version"): + if not module.check_mode: + update_document_default_version(ssm_client, name, document_default_version) + changed = True + document.update({"default_version": document_default_version}) + + module.exit_json(changed=changed, document=document) + except AnsibleSSMError as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/ssm_document_info.py b/plugins/modules/ssm_document_info.py new file mode 100644 index 00000000000..b46e5f3b3de --- /dev/null +++ b/plugins/modules/ssm_document_info.py @@ -0,0 +1,492 @@ +#!/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 = """ +module: ssm_document_info +version_added: 9.2.0 +short_description: Obtain information about one or more AWS Systems Manager document +description: + - Obtain information about one or more AWS Systems Manager document. +author: 'Aubin Bikouo (@abikouo)' +options: + name: + description: + - The name of the SSM document to describe. + - Mutually exclusive with O(filters). + required: false + type: str + filters: + description: + - A dict of filters to apply. Each dict item consists of a filter key and a filter value. See + U(https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_ListDocuments.html) for possible filters. Filter + names and values are case sensitive. + - Mutually exclusive with O(name). + required: false + type: dict + +extends_documentation_fragment: +- amazon.aws.common.modules +- amazon.aws.region.modules +- amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +# Gather information about all SSM documents +- name: Gather information about all SSM documents + community.aws.ssm_document_info: + +# Gather information about all SSM documents owned by 'user-001' +- name: Gather information about all SSM documents owned by 'user-001' + community.aws.ssm_document_info: + filters: + Owner: 'user-001' + +# Gather detailed information about one specific SSM document +- name: Gather information about one document + community.aws.ssm_document_info: + name: 'sample-document-001' +""" + + +RETURN = """ +document: + returned: when O(name) is provided. + description: Information about the SSM document. + type: dict + contains: + sha1: + description: The SHA1 hash of the document, which you can use for verification. + returned: always. + type: str + hash: + description: The Sha256 or Sha1 hash created by the system when the document was created.. + returned: always. + type: str + hash_type: + description: The hash type of the document. Valid values include Sha256 or Sha1. + returned: always. + type: str + name: + description: The name of the SSM document. + returned: always. + type: str + display_name: + description: The friendly name of the SSM document. + returned: always. + type: str + version_name: + description: The version of the artifact associated with the document. + returned: always. + type: str + owner: + description: The Amazon Web Services user that created the document. + returned: always. + type: str + created_date: + description: The date when the document was created. + returned: always. + type: str + status: + description: The status of the SSM document. + returned: always. + type: str + status_information: + description: A message returned by Amazon Web Services Systems Manager that explains the RV(document.status) value. + returned: always. + type: str + document_version: + description: The document version. + returned: always. + type: str + description: + description: A description of the document. + returned: always. + type: str + parameters: + description: A description of the parameters for a document. + returned: always. + type: dict + contains: + name: + description: The name of the parameter. + returned: always. + type: str + type: + description: The type of parameter. + returned: always. + type: str + description: + description: A description of what the parameter does, how to use it, the default value, and whether or not the parameter is optional. + returned: always. + type: str + default_value: + description: The default values for the parameters. + returned: If specified. + type: str + platform_types: + description: The list of operating system (OS) platforms compatible with this SSM document. + returned: always. + type: str + document_type: + description: The type of document. + returned: always. + type: str + schema_version: + description: The schema version. + returned: always. + type: str + latest_version: + description: The latest version of the document. + returned: always. + type: str + default_version: + description: The default version. + returned: always. + type: str + document_format: + description: The document format, either JSON or YAML. + returned: always. + type: str + target_type: + description: The target type which defines the kinds of resources the document can run on. + returned: always. + type: str + attachments_information: + description: Details about the document attachments, including names, locations, sizes, and so on. + returned: always. + type: list + elements: dict + contains: + name: + description: The name of the attachment. + returned: always. + type: str + requires: + description: A list of SSM documents required by a document. + returned: always. + type: list + elements: dict + contains: + name: + description: The name of the required SSM document. + returned: always. + type: str + version: + description: The document version required by the current document. + returned: always. + type: str + require_type: + description: The document type of the required SSM document. + returned: always. + type: str + version_name: + description: An optional field specifying the version of the artifact associated with the document. + returned: always. + type: str + author: + description: The user in your organization who created the document. + returned: always. + type: str + review_information: + description: Details about the review of a document. + returned: always. + type: list + elements: dict + contains: + reviewed_time: + description: The time that the reviewer took action on the document review request. + returned: always. + type: str + status: + description: The current status of the document review request. + returned: always. + type: str + reviewer: + description: The reviewer assigned to take action on the document review request. + returned: always. + type: str + approved_version: + description: The version of the document currently approved for use in the organization. + returned: always. + type: str + pending_review_version: + description: The version of the document that is currently under review. + returned: always. + type: str + review_status: + description: The current status of the review. + returned: always. + type: str + category: + description: The classification of a document to help you identify and categorize its use. + returned: always. + type: list + elements: str + category_enum: + description: The value that identifies a category. + returned: always. + type: list + elements: str + tags: + description: Tags of the s3 object. + returned: always + type: dict + sample: { + "Owner": "dev001", + "env": "test" + } + document_versions: + description: The document versions. + returned: always + type: dict + elements: list + contains: + name: + description: The document name. + returned: always. + type: str + display_name: + description: The friendly name of the SSM document. + returned: always. + type: str + document_version: + description: The document version. + returned: always. + type: str + version_name: + description: The version of the artifact associated with the document. + returned: always. + type: str + created_data: + description: The date the document was created. + returned: always. + type: str + is_default_version: + description: An identifier for the default version of the document. + returned: always. + type: bool + document_format: + description: The document format, either JSON or YAML. + returned: always. + type: str + status: + description: The status of the SSM document, such as Creating, Active, Failed, and Deleting. + returned: always. + type: str + status_information: + description: A message returned by Amazon Web Services Systems Manager that explains the RV(document.document_versions.status) value. + returned: always. + type: str + review_status: + description: The current status of the approval review for the latest version of the document. + returned: always. + type: str +documents: + returned: when O(filters) is provided. + description: Information about the SSM document. + type: list + elements: dict + contains: + name: + description: The name of the SSM document. + returned: always. + type: str + display_name: + description: The friendly name of the SSM document. + returned: always. + type: str + version_name: + description: The version of the artifact associated with the document. + returned: always. + type: str + owner: + description: The Amazon Web Services user that created the document. + returned: always. + type: str + created_date: + description: The date when the document was created. + returned: always. + type: str + document_version: + description: The document version. + returned: always. + type: str + platform_types: + description: The list of operating system (OS) platforms compatible with this SSM document. + returned: always. + type: str + document_type: + description: The type of document. + returned: always. + type: str + schema_version: + description: The schema version. + returned: always. + type: str + document_format: + description: The document format, either JSON or YAML. + returned: always. + type: str + target_type: + description: The target type which defines the kinds of resources the document can run on. + returned: always. + type: str + requires: + description: A list of SSM documents required by a document. + returned: always. + type: list + elements: dict + contains: + name: + description: The name of the required SSM document. + returned: always. + type: str + version: + description: The document version required by the current document. + returned: always. + type: str + require_type: + description: The document type of the required SSM document. + returned: always. + type: str + version_name: + description: An optional field specifying the version of the artifact associated with the document. + returned: always. + type: str + author: + description: The user in your organization who created the document. + returned: always. + type: str + review_status: + description: The current status of the review. + returned: always. + type: str + tags: + description: Tags of the s3 object. + returned: always + type: dict + sample: { + "Owner": "dev001", + "env": "test" + } +""" + + +from typing import Any +from typing import Dict +from typing import List + +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.tagging import boto3_tag_list_to_ansible_dict + +from ansible_collections.community.aws.plugins.module_utils.ssm import AnsibleSSMError +from ansible_collections.community.aws.plugins.module_utils.ssm import describe_document +from ansible_collections.community.aws.plugins.module_utils.ssm import list_document_versions +from ansible_collections.community.aws.plugins.module_utils.ssm import list_documents + + +def format_documents(documents: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + results = [] + for doc in documents: + tags = boto3_tag_list_to_ansible_dict(doc.pop("Tags", {})) + doc = camel_dict_to_snake_dict(doc) + doc.update({"tags": tags}) + results.append(doc) + return results + + +def ansible_dict_to_aws_filters_list(filters_dict): + """Convert an Ansible dict of filters to list of dicts that boto3 can use + Args: + filters_dict (dict): Dict of AWS filters. + Basic Usage: + >>> filters = {'some-aws-id': 'i-01234567'} + >>> ansible_dict_to_boto3_filter_list(filters) + { + 'some-aws-id': 'i-01234567' + } + Returns: + List: List of AWS filters and their values + [ + { + 'Key': 'some-aws-id', + 'Values': [ + 'i-01234567', + ] + } + ] + """ + + filters_list = [] + for k, v in filters_dict.items(): + filter_dict = {"Key": k} + if isinstance(v, bool): + filter_dict["Values"] = [str(v).lower()] + elif isinstance(v, int): + filter_dict["Values"] = [str(v)] + elif isinstance(v, str): + filter_dict["Values"] = [v] + else: + filter_dict["Values"] = v + + filters_list.append(filter_dict) + + return filters_list + + +def list_ssm_documents(client: Any, module: AnsibleAWSModule) -> None: + params = {} + filters = module.params.get("filters") + if filters: + params["Filters"] = ansible_dict_to_aws_filters_list(filters) + + documents = list_documents(client, **params) + module.exit_json(documents=format_documents(documents)) + + +def describe_ssm_document(client: Any, module: AnsibleAWSModule) -> None: + name = module.params.get("name") + document = None + + # Describe document + document = describe_document(client, name) + if document: + # Add document version + document["DocumentVersions"] = list_document_versions(client, name) + + tags = boto3_tag_list_to_ansible_dict(document.pop("Tags", {})) + document = camel_dict_to_snake_dict(document) + document.update({"tags": tags}) + module.exit_json(document=document) + + +def main(): + module = AnsibleAWSModule( + argument_spec=dict( + name=dict(type="str"), + filters=dict(type="dict"), + ), + supports_check_mode=True, + mutually_exclusive=[["name", "filters"]], + ) + ssm = module.client("ssm") + name = module.params.get("name") + + try: + if name: + describe_ssm_document(ssm, module) + else: + list_ssm_documents(ssm, module) + except AnsibleSSMError as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/setup_connection_aws_ssm/tasks/cleanup.yml b/tests/integration/targets/setup_connection_aws_ssm/tasks/cleanup.yml index 81a9d2d8df8..7de2730fb3e 100644 --- a/tests/integration/targets/setup_connection_aws_ssm/tasks/cleanup.yml +++ b/tests/integration/targets/setup_connection_aws_ssm/tasks/cleanup.yml @@ -81,8 +81,9 @@ alias: '{{ kms_key_name }}' - name: Delete SSM document - command: "aws ssm delete-document --name {{ ssm_document_name }}" - environment: "{{ connection_env }}" + community.aws.ssm_document: + state: absent + name: "{{ ssm_document_name }}" ignore_errors: true - name: Delete AWS keys environement diff --git a/tests/integration/targets/setup_connection_aws_ssm/tasks/ssm_document.yml b/tests/integration/targets/setup_connection_aws_ssm/tasks/ssm_document.yml index 4acc7f21858..548f097e4cd 100644 --- a/tests/integration/targets/setup_connection_aws_ssm/tasks/ssm_document.yml +++ b/tests/integration/targets/setup_connection_aws_ssm/tasks/ssm_document.yml @@ -1,8 +1,11 @@ --- - block: - name: Create custom SSM document - command: "aws ssm create-document --content file://{{ role_path }}/files/ssm-document.json --name {{ ssm_document_name }} --document-type Session" - environment: "{{ connection_env }}" + community.aws.ssm_document: + state: present + document_type: Session + content_path: "{{ role_path }}/files/ssm-document.json" + name: "{{ ssm_document_name }}" always: - name: Create SSM vars_to_delete.yml template: diff --git a/tests/integration/targets/ssm_document/aliases b/tests/integration/targets/ssm_document/aliases new file mode 100644 index 00000000000..87f88f6fb54 --- /dev/null +++ b/tests/integration/targets/ssm_document/aliases @@ -0,0 +1,3 @@ +time=4m +cloud/aws +ssm_document_info \ No newline at end of file diff --git a/tests/integration/targets/ssm_document/files/ssm-custom-document-2.json b/tests/integration/targets/ssm_document/files/ssm-custom-document-2.json new file mode 100644 index 00000000000..2d93b32950d --- /dev/null +++ b/tests/integration/targets/ssm_document/files/ssm-custom-document-2.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "2.2", + "description": "Sample document to display a message", + "parameters": { + "Message": { + "type": "String", + "description": "message to display", + "default": "Ansible is super" + }, + "Users": { + "type": "String", + "description": "users", + "default": "partners" + } + }, + "mainSteps": [ + { + "action": "aws:runPowerShellScript", + "name": "example", + "inputs": { + "runCommand": ["Write-Output {{Message}} for {{Users}}"] + } + } + ] +} \ No newline at end of file diff --git a/tests/integration/targets/ssm_document/files/ssm-custom-document.json b/tests/integration/targets/ssm_document/files/ssm-custom-document.json new file mode 100644 index 00000000000..9fc1ec5af17 --- /dev/null +++ b/tests/integration/targets/ssm_document/files/ssm-custom-document.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "2.2", + "description": "Sample document to execute hello world", + "parameters": { + "Message": { + "type": "String", + "description": "message to display", + "default": "Ansible is super" + } + }, + "mainSteps": [ + { + "action": "aws:runPowerShellScript", + "name": "example", + "inputs": { + "runCommand": ["Write-Output {{Message}}"] + } + } + ] +} \ No newline at end of file diff --git a/tests/integration/targets/ssm_document/tasks/main.yml b/tests/integration/targets/ssm_document/tasks/main.yml new file mode 100644 index 00000000000..39d16417daa --- /dev/null +++ b/tests/integration/targets/ssm_document/tasks/main.yml @@ -0,0 +1,393 @@ +--- +- name: Test modules ssm_document and ssm_document_info + vars: + document_name: "{{ resource_prefix }}" + document_type: Command + resource_tags: + Test: Integration + ResourcePrefix: "{{ resource_prefix }}" + updated_tags: + Foo: Bar + files_path: + - "{{ role_path }}/files/ssm-custom-document.json" + - "{{ role_path }}/files/ssm-custom-document-2.json" + 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: Create SSM document (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + content_path: "{{ files_path[0] }}" + document_type: "{{ document_type }}" + check_mode: true + register: create_check + + - name: Describe document + community.aws.ssm_document_info: + filters: + Name: "{{ document_name }}" + register: ssm_docs + + - name: Ensure the module reported change while the document was not created + ansible.builtin.assert: + that: + - create_check is changed + - ssm_docs.documents | length == 0 + + # Create + - name: Create SSM document + community.aws.ssm_document: + name: "{{ document_name }}" + content_path: "{{ files_path[0] }}" + document_type: "{{ document_type }}" + register: create_doc + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure the module reported and the document was created + ansible.builtin.assert: + that: + - create_doc is changed + - '"document" in create_doc' + - document_info.document is defined + - document_info.document.default_version == "1" + - document_info.document.document_versions | length == 1 + + # Create idempotency + - name: Create SSM document (idempotency) + community.aws.ssm_document: + name: "{{ document_name }}" + content_path: "{{ files_path[0] }}" + document_type: "{{ document_type }}" + register: create_idempotency + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure the module reported and the document was created + ansible.builtin.assert: + that: + - create_idempotency is not changed + - '"document" in create_doc' + - document_info.document is defined + - document_info.document.default_version == "1" + - document_info.document.document_versions | length == 1 + + # Update (add new document version and update default version) + - name: Update SSM document (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + content_path: "{{ files_path[1] }}" + document_type: "{{ document_type }}" + register: update_check + check_mode: true + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure the module reported and the document was created + ansible.builtin.assert: + that: + - update_check is changed + - document_info.document is defined + - document_info.document.default_version == "1" + - document_info.document.document_versions | length == 1 + + - name: Update SSM document + community.aws.ssm_document: + name: "{{ document_name }}" + content_path: "{{ files_path[1] }}" + document_type: "{{ document_type }}" + register: update_doc + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure the module reported and the document was created + ansible.builtin.assert: + that: + - update_doc is changed + - document_info.document is defined + - document_info.document.default_version == "1" + - document_info.document.document_versions | length == 2 + + # Update document default version + - name: Update document default version (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + document_default_version: "2" + register: update_version_check + check_mode: true + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure document default version has not changed + ansible.builtin.assert: + that: + - update_version_check is changed + - document_info.document is defined + - document_info.document.default_version == "1" + - document_info.document.document_versions | length == 2 + + - name: Update document default version + community.aws.ssm_document: + name: "{{ document_name }}" + document_default_version: "2" + register: update_version + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure document default version has changed + ansible.builtin.assert: + that: + - update_version is changed + - document_info.document is defined + - document_info.document.default_version == "2" + - document_info.document.document_versions | length == 2 + + - name: Update document default version (idempotency) + community.aws.ssm_document: + name: "{{ document_name }}" + document_default_version: "2" + register: update_version_idempotency + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure document default version has not changed + ansible.builtin.assert: + that: + - update_version_idempotency is not changed + - document_info.document is defined + - document_info.document.default_version == "2" + - document_info.document.document_versions | length == 2 + + # Update tags + - name: Update document tags (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + resource_tags: "{{ resource_tags }}" + register: update_tags_check + check_mode: true + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module reported change but the resource tags were not updated + ansible.builtin.assert: + that: + - update_tags_check is changed + - document_info.document is defined + - document_info.document.tags == {} + + - name: Update document tags + community.aws.ssm_document: + name: "{{ document_name }}" + resource_tags: "{{ resource_tags }}" + register: update_tags + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module reported change but the resource tags were not updated + ansible.builtin.assert: + that: + - update_tags is changed + - document_info.document is defined + - document_info.document.tags == resource_tags + + - name: Update document tags (idempotency) + community.aws.ssm_document: + name: "{{ document_name }}" + resource_tags: "{{ resource_tags }}" + register: update_tags_idempotency + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module did not reported change + ansible.builtin.assert: + that: + - update_tags_idempotency is not changed + - document_info.document is defined + - document_info.document.tags == resource_tags + + - name: Update document tags (purge_tags=False) + community.aws.ssm_document: + name: "{{ document_name }}" + resource_tags: "{{ updated_tags }}" + purge_tags: false + register: update_tags_not_purge + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure resource tags were updated + ansible.builtin.assert: + that: + - update_tags_not_purge is changed + - document_info.document is defined + - document_info.document.tags == resource_tags | combine(updated_tags) + + - name: Update document tags (purge_tags=true) + community.aws.ssm_document: + name: "{{ document_name }}" + resource_tags: "{{ updated_tags }}" + purge_tags: true + register: update_tags_purge + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure resource tags were updated + ansible.builtin.assert: + that: + - update_tags_purge is changed + - document_info.document is defined + - document_info.document.tags == updated_tags + + # Delete Document version + - name: Delete document version (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + document_version: "1" + state: absent + check_mode: true + register: delete_check + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module reported change while the document version was not deleted + ansible.builtin.assert: + that: + - delete_check is changed + - document_info.document is defined + - '"1" in document_info.document.document_versions | map(attribute="document_version") | list' + + - name: Delete document version + community.aws.ssm_document: + name: "{{ document_name }}" + document_version: "1" + state: absent + register: delete_version + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module did reported change and the document version was not deleted + ansible.builtin.assert: + that: + - delete_version is changed + - document_info.document is defined + - '"1" not in document_info.document.document_versions | map(attribute="document_version") | list' + - document_info.document.document_versions | length > 0 + + - name: Delete document version (idempotency) + community.aws.ssm_document: + name: "{{ document_name }}" + document_version: "1" + state: absent + register: delete_version_idempotency + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure version deletion idempotency + ansible.builtin.assert: + that: + - delete_version_idempotency is not changed + - document_info.document is defined + - '"1" not in document_info.document.document_versions | map(attribute="document_version") | list' + - document_info.document.document_versions | length > 0 + + # Delete document + - name: Delete document (check_mode=true) + community.aws.ssm_document: + name: "{{ document_name }}" + state: absent + register: delete_check + check_mode: true + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module reported change while the document was not deleted + ansible.builtin.assert: + that: + - delete_check is changed + - document_info.document is defined + - document_info.document.document_versions | length > 0 + + - name: Delete document + community.aws.ssm_document: + name: "{{ document_name }}" + state: absent + register: delete_doc + + - name: Describe document + community.aws.ssm_document_info: + name: "{{ document_name }}" + register: document_info + + - name: Ensure module reported change and the document was deleted + ansible.builtin.assert: + that: + - delete_doc is changed + - document_info.document == {} + + - name: Delete document (idempotency) + community.aws.ssm_document: + name: "{{ document_name }}" + state: absent + register: delete_idempotency + + - name: Ensure module did not reported change (idempotency) + ansible.builtin.assert: + that: + - delete_idempotency is not changed + + always: + - name: Delete SSM document + community.aws.ssm_document: + state: absent + name: "{{ document_name }}"