diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d6f0f09 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore index 894a44c..ca90f81 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,9 @@ venv.bak/ # mypy .mypy_cache/ + +# ide +.vscode/ + +# portainer cli +.portainer-cli.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95463f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 Ilhasoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e7c84af --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +sdist: + pipenv run python setup.py sdist bdist_wheel + +install: + pipenv install --dev + +lint: + pipenv run flake8 diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..ee3b61b --- /dev/null +++ b/Pipfile @@ -0,0 +1,17 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +plac = ">=1.0.0" +requests = ">=2.20.0" +validators = ">=0.12.2" + +[dev-packages] +"flake8" = "*" +setuptools = "*" +wheel = "*" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..b973aae --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,124 @@ +{ + "_meta": { + "hash": { + "sha256": "2e28e1ee11aba53785a7bee2ec9b68d004035f148a7655dbdc0e537eeab9c2e3" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", + "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" + ], + "version": "==2018.10.15" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "decorator": { + "hashes": [ + "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", + "sha256:c39efa13fbdeb4506c476c9b3babf6a718da943dab7811c206005a4a956c080c" + ], + "version": "==4.3.0" + }, + "idna": { + "hashes": [ + "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", + "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + ], + "version": "==2.7" + }, + "plac": { + "hashes": [ + "sha256:879d3009bee474cc96b5d7a4ebdf6fa0c4931008ecb858caf09eed9ca302c8da", + "sha256:b03f967f535b3bf5a71b191fa5eb09872a5cfb1e3b377efc4138995e10ba36d7" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "requests": { + "hashes": [ + "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", + "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" + ], + "index": "pypi", + "version": "==2.20.0" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + }, + "urllib3": { + "hashes": [ + "sha256:41c3db2fc01e5b907288010dec72f9d0a74e37d6994e6eb56849f59fea2265ae", + "sha256:8819bba37a02d143296a4d032373c4dd4aca11f6d4c9973335ca75f9c8475f59" + ], + "version": "==1.24" + }, + "validators": { + "hashes": [ + "sha256:172ac45f7d1944ce4beca3c5c53ca7c83e9759e39fd3fedc1cf28e2130268706" + ], + "index": "pypi", + "version": "==0.12.2" + } + }, + "develop": { + "flake8": { + "hashes": [ + "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0", + "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37" + ], + "index": "pypi", + "version": "==3.5.0" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "pycodestyle": { + "hashes": [ + "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766", + "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9" + ], + "version": "==2.3.1" + }, + "pyflakes": { + "hashes": [ + "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f", + "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" + ], + "version": "==1.6.0" + }, + "wheel": { + "hashes": [ + "sha256:9fa1f772f1a2df2bd00ddb4fa57e1cc349301e1facb98fbe62329803a9ff1196", + "sha256:d215f4520a1ba1851a3c00ba2b4122665cd3d6b0834d2ba2816198b1e3024a0e" + ], + "index": "pypi", + "version": "==0.32.1" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7e3337 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Portainer CLI + +Powered by [Ilhasoft's Web Team](http://www.ilhasoft.com.br/en/). + +Portainer CLI is a Python software to use in command line. Use this command line interface to easy communicate to your [Portainer](https://portainer.io/) application, like in a continuous integration and continuous deploy environments. + +## Install + +``` +pip install [--user] portainer-cli +``` + +## Usage + +### Global flags + +| Flag | Description | +|--|--| +| `-l` or `--local` | Save and load configuration file (`.portainer-cli.json`) in current directory. | +| `-d` or `--debug` | Enable DEBUG messages in stdout | + +### configure command + +Configure Portainer HTTP service base url. + +```bash +portainer-cli configure base_url +``` + +**E.g:** + +```bash +portainer-cli configure http://10.0.0.1:9000/ +``` + +### login command + +Identify yourself and take action. + +```bash +portainer-cli login username password +``` + +**E.g:** + +```bash +portainer-cli login douglas d1234 +``` + +### update_stack command + +Update stack. + +```bash +portainer-cli update_stack id endpoint_id [stack_file] +``` + +**E.g:** + +```bash +portainer-cli update_stack 2 1 docker-compose.yml +``` + +#### update_stack command environment variables arguments + +```bash +portainer-cli update_stack id endpoint_id [stack_file] --env.var=value +``` + +Where `var` is environment variable name and `value` is the environment variable value. + +#### Flags + +| Flag | Description | +|--|--| +| `-p` or `--prune` | Prune services | +| `-c` or `--clear-env` | Clear all environment variables | + +### request command + +Make a request. + +```bash +portainer-cli request path [method=GET] [data] +``` + +**E.g:** + +```bash +portainer-cli request status +``` + +#### Flags + +| Flag | Description | +|--|--| +| `-p` or `--printc` | Print response content in stdout. | + +## Development + +This project use [Pipenv](https://pipenv.readthedocs.io/en/latest/) to manager Python packages. + +With Pipenv installed, run `make install` to install all development packages dependencies. + +Run `make lint` to run [flake8](http://flake8.pycqa.org/en/latest/) following PEP8 rules. + +Run `make` or `make sdist` to create/update `dist` directory. diff --git a/portainer-cli b/portainer-cli new file mode 100644 index 0000000..63546ca --- /dev/null +++ b/portainer-cli @@ -0,0 +1,6 @@ +#!/usr/bin/env python +import plac +from portainer_cli import PortainerCLI + +p = PortainerCLI() +plac.call(p.main) diff --git a/portainer_cli/__init__.py b/portainer_cli/__init__.py new file mode 100755 index 0000000..424f4f3 --- /dev/null +++ b/portainer_cli/__init__.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +import logging +import plac +import json +import re +import validators +from pathlib import Path +from requests import Request, Session + +__version__ = '0.1.0' + +logger = logging.getLogger('portainer-cli') + +env_arg_regex = r'--env\.(.+)=(.+)' + + +def env_arg_to_dict(s): + split = re.split(env_arg_regex, s) + return { + 'name': split[1], + 'value': split[2], + } + + +class PortainerCLI: + COMMAND_CONFIGURE = 'configure' + COMMAND_LOGIN = 'login' + COMMAND_REQUEST = 'request' + COMMAND_UPDATE_STACK = 'update_stack' + COMMANDS = [ + COMMAND_CONFIGURE, + COMMAND_LOGIN, + COMMAND_REQUEST, + COMMAND_UPDATE_STACK, + ] + + METHOD_GET = 'GET' + METHOD_POST = 'POST' + METHOD_PUT = 'PUT' + + local = False + _base_url = 'http://localhost:9000/' + _jwt = None + + @property + def base_url(self): + return self._base_url + + @base_url.setter + def base_url(self, value): + if not validators.url(value): + raise Exception('Insert a valid base URL') + self._base_url = value if value.endswith('/') else f'{value}/' + self.persist() + + @property + def jwt(self): + return self._jwt + + @jwt.setter + def jwt(self, value): + self._jwt = value + self.persist() + + @property + def data_path(self): + if self.local: + logger.debug('using local configuration file') + return '.portainer-cli.json' + logger.debug('using user configuration file') + return Path.joinpath(Path.home(), '.portainer-cli.json') + + def persist(self): + data = { + 'base_url': self.base_url, + 'jwt': self.jwt, + } + logger.info(f'persisting configuration: {data}') + data_file = open(self.data_path, 'w+') + data_file.write(json.dumps(data)) + + def load(self): + try: + data_file = open(self.data_path) + except FileNotFoundError: + return + data = json.loads(data_file.read()) + logger.info(f'configuration loaded: {data}') + self._base_url = data.get('base_url') + self._jwt = data.get('jwt') + + def configure(self, base_url): + self.base_url = base_url + + def login(self, username, password): + response = self.request( + 'auth', + self.METHOD_POST, + { + 'username': username, + 'password': password, + } + ) + r = response.json() + jwt = r.get('jwt') + logger.info(f'logged with jwt: {jwt}') + self.jwt = jwt + + @plac.annotations( + prune=('Prune services', 'flag', 'p'), + clear_env=('Clear all env vars', 'flag', 'c'), + ) + def update_stack(self, id, endpoint_id, stack_file='', prune=False, + clear_env=False, *args): + stack_url = f'stacks/{id}?endpointId={endpoint_id}' + current = self.request(stack_url).json() + stack_file_content = '' + if stack_file: + stack_file_content = open(stack_file).read() + else: + stack_file_content = self.request( + f'stacks/{id}/file?endpointId={endpoint_id}').json().get( + 'StackFileContent') + env_args = filter( + lambda x: re.match(env_arg_regex, x), + args, + ) + env = list(map( + lambda x: env_arg_to_dict(x), + env_args, + )) + data = { + 'Id': id, + 'StackFileContent': stack_file_content, + 'Prune': prune, + 'Env': env if len(env) > 0 or clear_env else current.get('Env'), + } + self.request( + stack_url, + self.METHOD_PUT, + data, + ) + + @plac.annotations( + printc=('Print response content', 'flag', 'p'), + ) + def request(self, path, method=METHOD_GET, data='', printc=False): + url = f'{self.base_url}api/{path}' + session = Session() + request = Request(method, url) + prepped = request.prepare() + if data: + prepped.headers['Content-Type'] = 'application/json' + try: + json.loads(data) + prepped.body = data + except Exception as e: + prepped.body = json.dumps(data) + prepped.headers['Content-Length'] = len(prepped.body) + if self.jwt: + prepped.headers['Authorization'] = f'Bearer {self.jwt}' + response = session.send(prepped) + logger.debug(f'request response: {response.content}') + response.raise_for_status() + if printc: + print(response.content.decode()) + return response + + @plac.annotations( + command=( + 'Command', + 'positional', + None, + str, + COMMANDS, + ), + debug=('Enable debug mode', 'flag', 'd'), + local=('Use local/dir configuration', 'flag', 'l'), + ) + def main(self, command, debug=False, local=local, *args): + if debug: + logging.basicConfig(level=logging.DEBUG) + self.local = local + self.load() + if command == self.COMMAND_CONFIGURE: + plac.call(self.configure, args) + elif command == self.COMMAND_LOGIN: + plac.call(self.login, args) + elif command == self.COMMAND_UPDATE_STACK: + plac.call(self.update_stack, args) + elif command == self.COMMAND_REQUEST: + plac.call(self.request, args) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3c6e79c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..210e988 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +import setuptools +from portainer_cli import __version__ + +with open('README.md', 'r') as fh: + long_description = fh.read() + +setuptools.setup( + name='portainer-cli', + version=__version__, + author='Ilhasoft\'s Web Team', + author_email='contato@ilhasoft.com.br', + description='Command line interface to easy communicate to your ' + + 'Portainer application.', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/Ilhasoft/portainer-cli', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + ], + packages=setuptools.find_packages(), + scripts=['portainer-cli'], + install_requires=[ + 'plac>=1.0.0', + 'requests>=2.20.0', + 'validators>=0.12.2', + ], + python_requires='>=3.6', +)