diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02cd36d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +venv +.pytest_cache + diff --git a/galaxy.yml b/galaxy.yml new file mode 100644 index 0000000..cb7fb85 --- /dev/null +++ b/galaxy.yml @@ -0,0 +1,62 @@ +### REQUIRED +# The namespace of the collection. This can be a company/brand/organization or product namespace under which all +# content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with +# underscores or numbers and cannot contain consecutive underscores +namespace: chatton + +# The name of the collection. Has the same character restrictions as 'namespace' +name: portainer + +# The version of the collection. Must be compatible with semantic versioning +version: 1.0.0 + +# The path to the Markdown (.md) readme file. This path is relative to the root of the collection +readme: README.md + +# A list of the collection's content authors. Can be just the name or in the format 'Full Name (url) +# @nicks:irc/im.site#channel' +authors: +- Cian Hatton + + +### OPTIONAL but strongly recommended +# A short summary description of the collection +description: your collection description + +# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only +# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' +license: +- GPL-2.0-or-later + +# The path to the license file for the collection. This path is relative to the root of the collection. This key is +# mutually exclusive with 'license' +license_file: '' + +# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character +# requirements as 'namespace' and 'name' +tags: [] + +# Collections that this collection requires to be installed for it to be usable. The key of the dict is the +# collection label 'namespace.name'. The value is a version range +# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version +# range specifiers can be set and are separated by ',' +dependencies: {} + +# The URL of the originating SCM repository +repository: https://github.com/chatton/ansible-portainer + +# The URL to any online docs +documentation: http://docs.example.com + +# The URL to the homepage of the collection/project +homepage: http://example.com + +# The URL to the collection issue tracker +issues: https://github.com/chatton/ansible-portainer/issues + +# A list of file glob-like patterns used to filter any files or directories that should not be included in the build +# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This +# uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', +# and '.git' are always filtered +build_ignore: [] + diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..29aa319 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,31 @@ +# Collections Plugins Directory + +This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that +is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that +would contain module utils and modules respectively. + +Here is an example directory of the majority of plugins currently supported by Ansible: + +``` +└── plugins + ├── action + ├── become + ├── cache + ├── callback + ├── cliconf + ├── connection + ├── filter + ├── httpapi + ├── inventory + ├── lookup + ├── module_utils + ├── modules + ├── netconf + ├── shell + ├── strategy + ├── terminal + ├── test + └── vars +``` + +A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.13/plugins/plugins.html). diff --git a/plugins/module_utils/portainer.py b/plugins/module_utils/portainer.py new file mode 100644 index 0000000..fe12986 --- /dev/null +++ b/plugins/module_utils/portainer.py @@ -0,0 +1,59 @@ +import requests + + +def _query_params_to_string(params): + s = "?" + for k, v in params.items(): + s += f"&{k}={v}" + return s + + +class PortainerClient: + def __init__(self, base_url, endpoint): + self.endpoint = endpoint + self.base_url = base_url + self.token = "" + self.headers = {} + + def login(self, username, password): + payload = { + "Username": username, + "Password": password, + } + auth_url = f"{self.base_url}/api/auth" + resp = requests.post(auth_url, json=payload) + resp.raise_for_status() + self.token = resp.json()["jwt"] + self.headers = {"Authorization": f"Bearer {self.token}"} + + def get(self, get_endpoint, query_params=None): + url = f"{self.base_url}/api/{get_endpoint}" + if query_params: + url = url + _query_params_to_string(query_params) + + res = requests.get(url, headers=self.headers) + res.raise_for_status() + return res.json() + + def delete(self, endpoint): + url = f"{self.base_url}/api/{endpoint}" + try: + # TODO: deletion works, but the request fails? + res = requests.delete(url, headers=self.headers) + res.raise_for_status() + except Exception: + pass + return {} + + def put(self, endpoint, body): + url = f"{self.base_url}/api/{endpoint}" + res = requests.put(url, json=body, headers=self.headers) + res.raise_for_status() + return res.json() + + def post(self, endpoint, body, query_params=None): + url = f"{self.base_url}/api/{endpoint}" + _query_params_to_string(query_params) + + res = requests.post(url, json=body, headers=self.headers) + res.raise_for_status() + return res.json() diff --git a/plugins/modules/portainer_stack.py b/plugins/modules/portainer_stack.py new file mode 100644 index 0000000..9ec354f --- /dev/null +++ b/plugins/modules/portainer_stack.py @@ -0,0 +1,239 @@ +#!/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(client, module): + result = dict(changed=False, stack_name=module.params["stack_name"]) + + already_exists = False + stacks = client.get("stacks") + result["stacks"] = stacks + + with open(module.params["docker_compose_file_path"]) as f: + file_contents = f.read() + + target_stack_name = module.params["stack_name"] + for stack in stacks: + if stack["Name"] == target_stack_name: + already_exists = True + result["stack_id"] = stack["Id"] + break + + if not already_exists: + stack = _create_stack(client, module, file_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"] == file_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_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"), + 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_if = [ + # docker compose file is only required if we are ensuring the stack is present. + ["state", "present", ("docker_compose_file_path",)], + ] + + 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_if=required_if, + # 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..715a65b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +ansible==6.3.0 +ansible-core==2.13.3 +certifi==2022.6.15 +cffi==1.15.1 +charset-normalizer==2.1.1 +click==8.1.3 +colorama==0.4.5 +cryptography==37.0.4 +idna==3.3 +Jinja2==3.1.2 +MarkupSafe==2.1.1 +packaging==21.3 +portainer-py==0.7.6 +pycparser==2.21 +pyparsing==3.0.9 +PyYAML==6.0 +requests==2.28.1 +resolvelib==0.8.1 +urllib3==1.26.12 diff --git a/tests/unit/plugins/modules/test_portainer_stack.py b/tests/unit/plugins/modules/test_portainer_stack.py new file mode 100644 index 0000000..9aad61c --- /dev/null +++ b/tests/unit/plugins/modules/test_portainer_stack.py @@ -0,0 +1,9 @@ +import unittest + + +class TestMyModule(unittest.TestCase): + def test_foo(self): + assert True + + def test_foo2(self): + assert False