Add `portainer_stack` module (#1)

pull/2/head
Cian Hatton 3 years ago committed by GitHub
parent 083e817418
commit 65e19e9fdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

4
.gitignore vendored

@ -0,0 +1,4 @@
.idea
venv
.pytest_cache

@ -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 <email> (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: []

@ -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).

@ -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()

@ -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()

@ -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

@ -0,0 +1,9 @@
import unittest
class TestMyModule(unittest.TestCase):
def test_foo(self):
assert True
def test_foo2(self):
assert False
Loading…
Cancel
Save