#!/usr/bin/python from __future__ import absolute_import, division, print_function __metaclass__ = type from ansible.module_utils.basic import AnsibleModule try: # FIXME: Hack to make imports work with IDE. The ansible import path is not valid for a regular python # project. from plugins.module_utils.portainer import * except ImportError: from ansible_collections.chatton.portainer.plugins.module_utils.portainer import ( PortainerClient, _query_params_to_string, ) DOCUMENTATION = r""" --- module: portainer_stack short_description: This is my test module # If this is part of a collection, you need to use semantic versioning, # i.e. the version is of the form "2.5.0" and not "2.4". version_added: "1.0.0" description: This is my longer description explaining my test module. options: name: description: This is the message to send to the test module. required: true type: str new: description: - Control to demo if the result of this module is changed or not. - Parameter description can be a list as well. required: false type: bool # Specify this value according to your collection # in format of namespace.collection.doc_fragment_name extends_documentation_fragment: - my_namespace.my_collection.my_doc_fragment_name author: - Your Name (@chatton) """ EXAMPLES = r""" # Deploy Gitea, Plex and Mealie stacks to portainer provided the files exist. - name: Portainer | Update Stack chatton.portainer.portainer_stack: username: admin password: "{{portainer.password}}" docker_compose_file_path: "/etc/docker-compose/{{ item.name }}/docker-compose.yml" stack_name: "{{ item.name }}" endpoint_id: "{{ item.endpoint_id }}" state: present with_items: - name: gitea endpoint_id: 1 - name: plex endpoint_id: 2 - name: mealie endpoint_id: 3 # Delete plex stack - name: Portainer | Delete Plex Stack chatton.portainer.portainer_stack: username: admin password: "{{portainer.password}}" stack_name: "plex" endpoint_id: "2" state: absent """ RETURN = r""" # These are examples of possible return values, and in general should use other names for return values. username: description: The Portainer username. type: str returned: always sample: 'admin' password: description: The provided user's password. type: str returned: never sample: 'MyS00p3rS3cretPassw0rd' docker_compose_file_path: description: The path to a docker compose file which will be used to create the Portainer stack. type: str returned: never sample: '' """ COMPOSE_STACK = 2 STRING_METHOD = "string" def _create_stack(client, module, file_contents): target_stack_name = module.params["stack_name"] body = { "name": target_stack_name, "stackFileContent": file_contents, } query_params = { "type": COMPOSE_STACK, "method": STRING_METHOD, "endpointId": client.endpoint, } return client.post("stacks", body=body, query_params=query_params) def _update_stack(client, module, stack_id): target_stack_name = module.params["stack_name"] with open(module.params["docker_compose_file_path"]) as f: file_contents = f.read() return client.put( f"stacks/{stack_id}?&endpointId={client.endpoint}", body={ "name": target_stack_name, "stackFileContent": file_contents, }, ) def _handle_state_present(contents, result, client, module) already_exists = False target_stack_name = module.params["stack_name"] for stack in stacks: if stack["Name"] == target_stack_name and int(stack["EndpointId"]) == module.params["endpoint_id"]: already_exists = True result["stack_id"] = stack["Id"] break if not already_exists: stack = _create_stack(client, module, contents) result["changed"] = True result["stack_id"] = stack["Id"] module.exit_json(**result) return stack_id = result["stack_id"] current_file_contents_resp = client.get( f"stacks/{stack_id}/file", query_params={"endpointId": client.endpoint} ) result["are_equal"] = ( current_file_contents_resp["StackFileContent"] == contents ) if result["are_equal"]: module.exit_json(**result) return # the stack exists and we have a new config. _update_stack(client, module, stack_id) result["changed"] = True module.exit_json(**result) def handle_state_present(client, module): result = dict(changed=False, stack_name=module.params["stack_name"]) stacks = client.get("stacks") result["stacks"] = stacks contents = "" if "docker_compose_file_path" in module.params: with open(module.params["docker_compose_file_path"]) as f: contents = f.read() elif "definition" in module.params: contents = module.params["stack_definition"] else: raise ValueError("Should not be able to be here!") _handle_state_present(contents, result, client, module) def handle_state_absent(client, module): result = dict(changed=False, stack_name=module.params["stack_name"]) already_exists = False target_stack_name = module.params["stack_name"] stacks = client.get("stacks") for stack in stacks: if stack["Name"] == target_stack_name: already_exists = True result["stack_id"] = stack["Id"] break if not already_exists: module.exit_json(**result) return stack_id = result["stack_id"] client.delete( f"stacks/{stack_id}" + _query_params_to_string({"endpointId": client.endpoint}) ) result["changed"] = True module.exit_json(**result) def run_module(): # define available arguments/parameters a user can pass to the module module_args = dict( stack_name=dict(type="str", required=True), docker_compose_file_path=dict(type="str"), stack_definition=dict(type="str"), username=dict(type="str", default="admin"), password=dict(type="str", required=True, no_log=True), endpoint_id=dict(type="int", required=True), base_url=dict(type="str", default="http://localhost:9000"), state=dict(type="str", default="present", choices=["present", "absent"]), ) required_one_of = [ # docker compose file is only required if we are ensuring the stack is present. ["state", "present", ("docker_compose_file_path", "stack_definition")], ] state_fns = {"present": handle_state_present, "absent": handle_state_absent} # the AnsibleModule object will be our abstraction working with Ansible # this includes instantiation, a couple of common attr would be the # args/params passed to the execution, as well as if the module # supports check mode module = AnsibleModule( argument_spec=module_args, required_one_of=required_one_of, # TODO: support check mode supports_check_mode=False, ) client = PortainerClient( base_url=module.params["base_url"], endpoint=module.params["endpoint_id"] ) client.login(module.params["username"], module.params["password"]) state_fns[module.params["state"]](client, module) def main(): run_module() if __name__ == "__main__": main()