├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── modules.rst ├── index.rst ├── caprover_api.rst ├── Makefile ├── make.bat ├── installation.rst ├── conf.py └── usage.rst ├── tests ├── __init__.py └── test_caprover_api.py ├── caprover_api ├── __init__.py └── caprover_api.py ├── .idea ├── vcs.xml ├── misc.xml ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── modules.xml ├── Caprover-API.iml └── workspace.xml ├── requirements_dev.txt ├── AUTHORS.rst ├── MANIFEST.in ├── tox.ini ├── .travis.yml ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── setup.cfg ├── HISTORY.rst ├── LICENSE ├── setup.py ├── .gitignore ├── Makefile ├── README.rst └── CONTRIBUTING.rst /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for caprover_api.""" 2 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | caprover_api 2 | ============ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | caprover_api 8 | -------------------------------------------------------------------------------- /caprover_api/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for Caprover API.""" 2 | 3 | __author__ = """Akash Agarwal""" 4 | __email__ = 'agwl.akash@gmail.com' 5 | __version__ = '0.1.24' 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==19.2.3 2 | bump2version==0.5.11 3 | wheel==0.33.6 4 | watchdog==0.9.0 5 | flake8==3.7.8 6 | tox==3.14.0 7 | coverage==4.5.4 8 | Sphinx==1.8.5 9 | twine==1.14.0 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Akash Agarwal 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 caprover_api tests 14 | 15 | [testenv] 16 | deps = pytest 17 | commands = pytest {posargs:tests} 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.com 2 | 3 | language: python 4 | python: 5 | - 3.8 6 | - 3.7 7 | - 3.6 8 | 9 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 10 | install: pip install -U tox-travis 11 | 12 | # Command to run tests, e.g. python setup.py test 13 | script: tox 14 | 15 | 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.idea/Caprover-API.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Caprover API version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Caprover API's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.24 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:caprover_api/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | -------------------------------------------------------------------------------- /docs/caprover_api.rst: -------------------------------------------------------------------------------- 1 | caprover\_api package 2 | ===================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | caprover\_api.caprover\_api module 8 | ---------------------------------- 9 | 10 | .. automodule:: caprover_api.caprover_api 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: caprover_api 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.24 (2024-12-16) 6 | ------------------- 7 | 8 | * Fix & test update from novel kwargs (#12) 9 | * update method lets you set httpAuth (#11) 10 | * `update()` now handles persistent directories that use hostPath (#7) 11 | * `gen_random_hex` works across whole one-click-app YAML (#6) 12 | * Bugfix: `update()` should not change notExposeAsWebApp (#8) 13 | * Enable SSL on base domain (#9) 14 | * Allow optional override one-click repository path (#5) 15 | 16 | 0.1.0 (2021-06-11) 17 | ------------------ 18 | 19 | * First release on PyPI. 20 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = caprover_api 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=caprover_api 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, Akash Agarwal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install Caprover API, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install caprover_api 16 | 17 | This is the preferred method to install Caprover API, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for Caprover API can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/ak4zh/caprover_api 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/ak4zh/caprover_api/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/ak4zh/caprover_api 51 | .. _tarball: https://github.com/ak4zh/caprover_api/tarball/master 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open('README.rst') as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open('HISTORY.rst') as history_file: 11 | history = history_file.read() 12 | 13 | requirements = [ 14 | 'requests>=2.25.1', 15 | 'PyYAML>=5.4.1' 16 | ] 17 | 18 | test_requirements = [ 19 | 'requests>=2.25.1', 20 | 'PyYAML>=5.4.1' 21 | ] 22 | 23 | setup( 24 | author="Akash Agarwal", 25 | author_email='agwl.akash@gmail.com', 26 | python_requires='>=3.6', 27 | classifiers=[ 28 | 'Development Status :: 2 - Pre-Alpha', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Natural Language :: English', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.6', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | ], 37 | description="unofficial caprover api to deploy apps to caprover", 38 | install_requires=requirements, 39 | license="MIT license", 40 | long_description=readme + '\n\n' + history, 41 | include_package_data=True, 42 | keywords='caprover_api', 43 | name='caprover_api', 44 | packages=find_packages(include=['caprover_api', 'caprover_api.*']), 45 | test_suite='tests', 46 | tests_require=test_requirements, 47 | url='https://github.com/ak4zh/caprover-api', 48 | version='0.1.24', 49 | zip_safe=False, 50 | ) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 1633313140924 30 | 36 | 37 | 38 | 39 | 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -fr .tox/ 46 | rm -f .coverage 47 | rm -fr htmlcov/ 48 | rm -fr .pytest_cache 49 | 50 | lint: ## check style with flake8 51 | flake8 caprover_api tests 52 | 53 | test: ## run tests quickly with the default Python 54 | python setup.py test 55 | 56 | test-all: ## run tests on every Python version with tox 57 | tox 58 | 59 | coverage: ## check code coverage quickly with the default Python 60 | coverage run --source caprover_api setup.py test 61 | coverage report -m 62 | coverage html 63 | $(BROWSER) htmlcov/index.html 64 | 65 | docs: ## generate Sphinx HTML documentation, including API docs 66 | rm -f docs/caprover_api.rst 67 | rm -f docs/modules.rst 68 | sphinx-apidoc -o docs/ caprover_api 69 | $(MAKE) -C docs clean 70 | $(MAKE) -C docs html 71 | $(BROWSER) docs/_build/html/index.html 72 | 73 | servedocs: docs ## compile the docs watching for changes 74 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 75 | 76 | release: dist ## package and upload a release 77 | twine upload dist/* 78 | 79 | dist: clean ## builds source and wheel package 80 | python setup.py sdist 81 | python setup.py bdist_wheel 82 | ls -l dist 83 | 84 | install: clean ## install the package to the active Python's site-packages 85 | python setup.py install 86 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Caprover API 3 | ============ 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/caprover_api.svg 7 | :target: https://pypi.python.org/pypi/caprover_api 8 | 9 | .. image:: https://img.shields.io/travis/ak4zh/caprover_api.svg 10 | :target: https://travis-ci.com/ak4zh/caprover_api 11 | 12 | .. image:: https://readthedocs.org/projects/caprover-api/badge/?version=latest 13 | :target: https://caprover-api.readthedocs.io/en/latest/?version=latest 14 | :alt: Documentation Status 15 | 16 | 17 | 18 | 19 | unofficial caprover api to deploy apps to caprover 20 | 21 | 22 | * Free software: MIT license 23 | * Full Documentation: https://caprover-api.readthedocs.io. 24 | 25 | 26 | Features 27 | -------- 28 | 29 | * create app 30 | * add custom domain 31 | * enable ssl 32 | * update app with port mappings, env variables, repo info etc 33 | * deploy one click apps 34 | * get list of all apps 35 | * get list of all projects 36 | * get app by name 37 | * delete app 38 | * delete app and it's volumes 39 | * stop app 40 | * scale app 41 | 42 | 43 | Usage 44 | ----- 45 | 46 | To use Caprover API in a project:: 47 | 48 | from caprover_api import caprover_api 49 | 50 | cap = caprover_api.CaproverAPI( 51 | dashboard_url="cap-dashboard-url", 52 | password="cap-dashboard-password" 53 | ) 54 | 55 | 56 | One Click Apps 57 | ^^^^^^^^^^^^^^^ 58 | 59 | get app name from `List of one-click-apps `_ 60 | 61 | automated deploy:: 62 | 63 | app_variables = { 64 | "$$cap_redis_password": "REDIS-PASSWORD-HERE" 65 | } 66 | cap.deploy_one_click_app( 67 | one_click_app_name='redis', 68 | app_variables=app_variables, 69 | automated=True 70 | ) 71 | 72 | 73 | manual deploy (you will be asked to enter required variables during runtime):: 74 | 75 | cap.deploy_one_click_app( 76 | one_click_app_name='redis' 77 | ) 78 | 79 | rename app (to install under a different name than the name in the one-click repo):: 80 | 81 | cap.deploy_one_click_app( 82 | one_click_app_name='redis', 83 | app_name='cache' 84 | ) 85 | 86 | 87 | Custom Apps 88 | ^^^^^^^^^^^^ 89 | 90 | create a new app (if project_id is not specified, app will be created in root ):: 91 | 92 | cap.create_app( 93 | app_name="new-app", 94 | has_persistent_data=False 95 | ) 96 | 97 | create a new app under an existing project:: 98 | 99 | cap.create_app( 100 | app_name="new-app", 101 | project_id="9b402fa8-423a-423a-8c2e-0c5498d21774", 102 | has_persistent_data=False 103 | ) 104 | 105 | 106 | create and deploy redis app from docker hub:: 107 | 108 | cap.create_and_update_app( 109 | app_name="new-app-redis", 110 | has_persistent_data=False, 111 | image_name='redis:5', 112 | persistent_directories=['new-app-redis-data:/data', ] 113 | ) 114 | 115 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/ak4zh/caprover_api/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Caprover API could always use more documentation, whether as part of the 42 | official Caprover API docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/ak4zh/caprover_api/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `caprover_api` for local development. 61 | 62 | 1. Fork the `caprover_api` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/caprover_api.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv caprover_api 70 | $ cd caprover_api/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 caprover_api tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 106 | https://travis-ci.com/ak4zh/caprover_api/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | 115 | $ python -m unittest tests.test_caprover_api 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # caprover_api documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | import caprover_api 25 | 26 | # -- General configuration --------------------------------------------- 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'Caprover API' 50 | copyright = "2021, Akash Agarwal" 51 | author = "Akash Agarwal" 52 | 53 | # The version info for the project you're documenting, acts as replacement 54 | # for |version| and |release|, also used in various other places throughout 55 | # the built documents. 56 | # 57 | # The short X.Y version. 58 | version = caprover_api.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = caprover_api.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = 'alabaster' 87 | 88 | # Theme options are theme-specific and customize the look and feel of a 89 | # theme further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ['_static'] 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = 'caprover_apidoc' 104 | 105 | 106 | # -- Options for LaTeX output ------------------------------------------ 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, author, documentclass 128 | # [howto, manual, or own class]). 129 | latex_documents = [ 130 | (master_doc, 'caprover_api.tex', 131 | 'Caprover API Documentation', 132 | 'Akash Agarwal', 'manual'), 133 | ] 134 | 135 | 136 | # -- Options for manual page output ------------------------------------ 137 | 138 | # One entry per manual page. List of tuples 139 | # (source start file, name, description, authors, manual section). 140 | man_pages = [ 141 | (master_doc, 'caprover_api', 142 | 'Caprover API Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'caprover_api', 154 | 'Caprover API Documentation', 155 | author, 156 | 'caprover_api', 157 | 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Caprover API in a project:: 6 | 7 | from caprover_api import caprover_api 8 | 9 | cap = caprover_api.CaproverAPI( 10 | dashboard_url="cap-dashboard-url", 11 | password="cap-dashboard-password" 12 | ) 13 | 14 | 15 | One Click Apps 16 | ^^^^^^^^^^^^^^^ 17 | 18 | get app name from `List of one-click-apps `_ 19 | 20 | automated deploy:: 21 | 22 | app_variables = { 23 | "$$cap_redis_password": "REDIS-PASSWORD-HERE" 24 | } 25 | cap.deploy_one_click_app( 26 | one_click_app_name='redis', 27 | app_variables=app_variables, 28 | automated=True 29 | ) 30 | 31 | 32 | manual deploy (you will be asked to enter required variables during runtime):: 33 | 34 | cap.deploy_one_click_app( 35 | one_click_app_name='redis' 36 | ) 37 | 38 | rename app (to install under a different name than the name in the one-click repo):: 39 | 40 | cap.deploy_one_click_app( 41 | one_click_app_name='redis', 42 | app_name='cache' 43 | ) 44 | 45 | 46 | Custom Apps 47 | ^^^^^^^^^^^^ 48 | 49 | create a new app:: 50 | 51 | cap.create_app( 52 | app_name="new-app", 53 | has_persistent_data=False 54 | ) 55 | 56 | 57 | deploy app from docker hub:: 58 | 59 | # app must already exists 60 | cap.deploy_app( 61 | app_name="new-app", 62 | image_name='redis:5' 63 | ) 64 | 65 | 66 | App CRUD (Create, Update, Delete) 67 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 68 | 69 | add domain to an existing app:: 70 | 71 | cap.add_domain( 72 | app_name="new-app", 73 | custom_domain="my-app.example.com" 74 | ) 75 | 76 | enable ssl:: 77 | 78 | cap.enable_ssl( 79 | app_name='new-app', 80 | custom_domain='my-app.example.com' 81 | ) 82 | 83 | add environment variables to app:: 84 | 85 | environment_variables = { 86 | "key1": "val1", 87 | "key2": "val2" 88 | } 89 | cap.update_app( 90 | app_name='new-app', 91 | environment_variables=environment_variables 92 | ) 93 | 94 | add environment variables and volumes to app:: 95 | 96 | environment_variables = { 97 | "key1": "val1", 98 | "key2": "val2" 99 | } 100 | persistent_directories = [ 101 | "volumeName:/pathInApp", 102 | "volumeNameTwo:/pathTwoInApp" 103 | ] 104 | cap.update_app( 105 | app_name='new-app', 106 | environment_variables=environment_variables, 107 | persistent_directories=persistent_directories 108 | ) 109 | 110 | add environment variables and volumes and port mappings to app:: 111 | 112 | environment_variables = { 113 | "key1": "val1", 114 | "key2": "val2" 115 | } 116 | persistent_directories = [ 117 | "volumeName:/pathInApp", 118 | "volumeNameTwo:/pathTwoInApp" 119 | ] 120 | port_mapping = [ 121 | "serverPort:containerPort", 122 | ] 123 | cap.update_app( 124 | app_name='new-app', 125 | environment_variables=environment_variables, 126 | persistent_directories=persistent_directories, 127 | port_mapping=port_mapping 128 | ) 129 | 130 | create app and add custom domain:: 131 | 132 | cap.create_and_update_app( 133 | app_name="new-app", 134 | has_persistent_data=False, 135 | custom_domain="my-app.example.com" 136 | ) 137 | 138 | create app with custom domain and enable ssl:: 139 | 140 | cap.create_and_update_app( 141 | app_name="new-app", 142 | has_persistent_data=False, 143 | custom_domain="my-app.example.com", 144 | enable_ssl=True 145 | ) 146 | 147 | 148 | create app and deploy redis from docker hub:: 149 | 150 | cap.create_and_update_app( 151 | app_name="new-app", 152 | has_persistent_data=False, 153 | image_name='redis:5', 154 | persistent_directories=['new-app-redis-data:/data', ] 155 | ) 156 | 157 | replace tags on an app:: 158 | 159 | cap.update_app(app_name="new-app", tags=["tag1", "tag2"]) 160 | 161 | clear all tags from an app:: 162 | 163 | cap.update_app(app_name="new-app", tags=[]) # set to empty list 164 | 165 | delete an app:: 166 | 167 | cap.delete_app(app_name="new-app") 168 | 169 | delete an app and it's volumes:: 170 | 171 | cap.delete_app( 172 | app_name="new-app", delete_volumes=True 173 | ) 174 | 175 | delete apps matching regex pattern (with confirmation):: 176 | 177 | cap.delete_app_matching_pattern( 178 | app_name_pattern=".*new-app.*", 179 | delete_volumes=True 180 | ) 181 | 182 | delete apps matching regex pattern (☠️ without confirmation):: 183 | 184 | cap.delete_app_matching_pattern( 185 | app_name_pattern=".*new-app.*", 186 | delete_volumes=True, 187 | automated=True 188 | ) 189 | 190 | stop an app temporarily:: 191 | 192 | cap.stop_app(app_name="new-app") 193 | 194 | start a temporarily stopped app:: 195 | 196 | cap.update_app(app_name="new-app", instance_count=1) 197 | 198 | scale app to 3 instances:: 199 | 200 | cap.update_app(app_name="new-app", instance_count=3) 201 | 202 | 203 | Backup 204 | ^^^^^^ 205 | 206 | Create a backup of CapRover configs in order to be able to spin up a clone of this server. 207 | Note that your application data (volumes, and images) are not part of this backup. This backup only includes the server configuration details, such as root domains, app names, SSL certs and etc.:: 208 | 209 | cap.create_backup() 210 | 211 | You can pass an optional file_name, the default file name is `{captain_namespace}-bck-%Y-%m-%d %H:%M:%S.rar` 212 | 213 | -------------------------------------------------------------------------------- /tests/test_caprover_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Tests for `caprover_api` package.""" 4 | 5 | import json 6 | import unittest 7 | from unittest.mock import MagicMock, patch 8 | 9 | import yaml 10 | 11 | from caprover_api.caprover_api import CaproverAPI 12 | 13 | 14 | class TestAppVariables(unittest.TestCase): 15 | """Tests for app variable resolution in one-click-app definitions""" 16 | 17 | def setUp(self): 18 | """Set up test fixtures""" 19 | with patch.object(CaproverAPI, "get_system_info"), patch.object( 20 | CaproverAPI, "_login" 21 | ): 22 | self.api = CaproverAPI( 23 | dashboard_url="http://dummy", password="dummy" 24 | ) 25 | self.api.root_domain = ( 26 | "example.com" # This is used by _resolve_app_variables 27 | ) 28 | # This is the JSON that's built by `npm run build` 29 | # as described at 30 | # https://github.com/caprover/one-click-apps/blob/master/README.md#build-your-own-one-click-app-repository 31 | self.raw_json_appdef = """ 32 | { 33 | "captainVersion": 4, 34 | "services": { 35 | "$$cap_appname": { 36 | "image": "$$cap_docker_image", 37 | "environment": { 38 | "SECRET": "$$cap_gen_random_hex(10)", 39 | "VRD": "$$cap_var_with_random_default", 40 | "VSD": "$$cap_var_with_static_default" 41 | } 42 | } 43 | }, 44 | "caproverOneClickApp": { 45 | "variables": [ 46 | { 47 | "id": "$$cap_docker_image", 48 | "label": "Docker Image" 49 | }, 50 | { 51 | "id": "$$cap_var_with_random_default", 52 | "label": "Var with random default", 53 | "defaultValue": "$$cap_gen_random_hex(6)" 54 | }, 55 | { 56 | "id": "$$cap_var_with_static_default", 57 | "label": "Var with static default", 58 | "defaultValue": "Abcde" 59 | } 60 | ], 61 | "description": "Transform Your Daily Life Forever!", 62 | "instructions": { 63 | "start": "ready set go", 64 | "end": "all done" 65 | } 66 | } 67 | } 68 | """ 69 | 70 | def test_appname_replacement(self): 71 | """Test that $$cap_appname is replaced correctly""" 72 | result = self.api._resolve_app_variables( 73 | self.raw_json_appdef, "testapp", {}, automated=True 74 | ) 75 | parsed = yaml.safe_load(result) 76 | self.assertIn("testapp", parsed["services"]) 77 | 78 | def test_override_default(self): 79 | """A defaultValue can be overridden by app_variables""" 80 | result = self.api._resolve_app_variables( 81 | self.raw_json_appdef, 82 | "testapp", 83 | {"$$cap_var_with_static_default": "CustomValue"}, 84 | automated=True, 85 | ) 86 | parsed = yaml.safe_load(result) 87 | self.assertEqual( 88 | parsed["services"]["testapp"]["environment"]["VSD"], "CustomValue" 89 | ) 90 | 91 | def test_static_default_variable(self): 92 | """Variable with (static) defaultValue gets that value when not set""" 93 | result = self.api._resolve_app_variables( 94 | self.raw_json_appdef, "testapp", {}, automated=True 95 | ) 96 | parsed = yaml.safe_load(result) 97 | self.assertEqual( 98 | parsed["services"]["testapp"]["environment"]["VSD"], "Abcde" 99 | ) 100 | 101 | def test_gen_random_hex_default_variable(self): 102 | """Variable with random default gets a random value when not set""" 103 | result = self.api._resolve_app_variables( 104 | self.raw_json_appdef, "testapp", {}, automated=True 105 | ) 106 | parsed = yaml.safe_load(result) 107 | self.assertRegex( 108 | str(parsed["services"]["testapp"]["environment"]["VRD"]), 109 | r"^[0-9a-f]{6}$", 110 | ) 111 | 112 | def test_gen_random_hex_in_service(self): 113 | """gen_random_hex is applied in service definitions (not just variables)""" 114 | result = self.api._resolve_app_variables( 115 | self.raw_json_appdef, "testapp", {}, automated=True 116 | ) 117 | parsed = yaml.safe_load(result) 118 | self.assertRegex( 119 | parsed["services"]["testapp"]["environment"]["SECRET"], 120 | r"^[0-9a-f]{10}$", 121 | ) 122 | 123 | 124 | class TestServiceUpdateOverride(unittest.TestCase): 125 | """Test unsupported docker-compose params as service override definition 126 | 127 | Reference implementation: 128 | https://github.com/caprover/caprover-frontend/blob/master/src/utils/DockerComposeToServiceOverride.ts 129 | """ 130 | 131 | def setUp(self): 132 | """Set up test fixtures""" 133 | with patch.object(CaproverAPI, "get_system_info"), patch.object( 134 | CaproverAPI, "_login" 135 | ): 136 | self.api = CaproverAPI( 137 | dashboard_url="http://dummy", password="dummy" 138 | ) 139 | 140 | def test_parse_command_single_string(self): 141 | """ 142 | Test that a single string command is parsed into a list. 143 | """ 144 | command = "redis-server --port 6380 --maxmemory 200mb" 145 | expected_output = { 146 | "TaskTemplate": { 147 | "ContainerSpec": { 148 | "Command": [ 149 | "redis-server", 150 | "--port", 151 | "6380", # keep numbers as str 152 | "--maxmemory", 153 | "200mb", 154 | ] 155 | } 156 | } 157 | } 158 | result = self.api._parse_command(command) 159 | self.assertEqual(result, expected_output) 160 | 161 | def test_parse_command_list(self): 162 | command = ["sh", "-c", "redis-server --port 6380"] 163 | expected_output = { 164 | "TaskTemplate": { 165 | "ContainerSpec": { 166 | "Command": [ 167 | "sh", 168 | "-c", 169 | "redis-server --port 6380", 170 | ] 171 | } 172 | } 173 | } 174 | result = self.api._parse_command(command) 175 | self.assertEqual(result, expected_output) 176 | 177 | def test_parse_command_multiline_string(self): 178 | """ 179 | Test a multi-line string command (e.g. from YAML >| operator) 180 | """ 181 | command = """sh -c " 182 | echo 'Starting...' 183 | redis-server 184 | " """ 185 | expected_output = { 186 | "TaskTemplate": { 187 | "ContainerSpec": { 188 | "Command": [ 189 | "sh", 190 | "-c", 191 | """ 192 | echo 'Starting...' 193 | redis-server 194 | """, 195 | ] 196 | } 197 | } 198 | } 199 | result = self.api._parse_command(command) 200 | self.assertEqual(result, expected_output) 201 | 202 | 203 | class TestUpdateApp(unittest.TestCase): 204 | def setUp(self): 205 | with patch.object(CaproverAPI, "get_system_info"), patch.object( 206 | CaproverAPI, "_login" 207 | ): 208 | self.api = CaproverAPI( 209 | dashboard_url="http://dummy", password="dummy" 210 | ) 211 | self.api.headers = {"Authorization": "Bearer mock_token"} 212 | 213 | # Mock session.post so we can sniff how update calls it 214 | self.api.session = MagicMock() 215 | self.api.session.post = MagicMock( 216 | return_value=MagicMock( 217 | json=lambda: {"description": "Saved", "status": 100} 218 | ) 219 | ) 220 | 221 | # Mock get_app response to simulate the current app information 222 | self.api.get_app = MagicMock( 223 | return_value={ 224 | "appName": "test_app", 225 | "redirectDomain": "", 226 | "envVars": [{"key": "EXISTING_ENV_VAR", "value": "old_value"}], 227 | "volumes": [ 228 | { 229 | "hostPath": "/old_path", 230 | "containerPath": "/container_path", 231 | } 232 | ], 233 | "appPushWebhook": {}, 234 | } 235 | ) 236 | 237 | def test_add_environment_variables(self): 238 | self.api.update_app( 239 | "test_app", 240 | environment_variables={"ANOTHER": "foobar"}, 241 | ) 242 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 243 | 244 | expected = [ 245 | {"key": "EXISTING_ENV_VAR", "value": "old_value"}, 246 | {"key": "ANOTHER", "value": "foobar"}, 247 | ] 248 | self.assertEqual(post_data["envVars"], expected) 249 | 250 | def test_update_no_change(self): 251 | """ 252 | update_app, without passing persistent_directories arg, should 253 | keep existing volumes from get_app. 254 | """ 255 | self.api.update_app("test_app") 256 | 257 | # Capture post data 258 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 259 | 260 | # Assert 'volumes' is unchanged from get_app() 261 | expected_volumes = [ 262 | {"hostPath": "/old_path", "containerPath": "/container_path"} 263 | ] 264 | self.assertEqual(post_data["volumes"], expected_volumes) 265 | 266 | def test_add_http_auth(self): 267 | self.api.update_app( 268 | "test_app", http_auth={"user": "admin", "password": "example"} 269 | ) 270 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 271 | 272 | self.assertEqual( 273 | post_data["httpAuth"], {"user": "admin", "password": "example"} 274 | ) 275 | 276 | def test_update_with_persistent_directories_host_path(self): 277 | """ 278 | Test update_app with persistent_directories using hostPath format. 279 | """ 280 | self.api.update_app( 281 | "test_app", 282 | persistent_directories=["/new_host_path:/new_container_path"], 283 | ) 284 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 285 | 286 | # Assert 'volumes' is replaced 287 | expected_volumes = [ 288 | { 289 | "hostPath": "/new_host_path", 290 | "containerPath": "/new_container_path", 291 | } 292 | ] 293 | self.assertEqual(post_data["volumes"], expected_volumes) 294 | 295 | def test_update_with_persistent_directories_volume_name(self): 296 | """ 297 | Test update_app with persistent_directories using volumeName format. 298 | """ 299 | self.api.update_app( 300 | "test_app", 301 | persistent_directories=["new_volume:/new_container_path"], 302 | ) 303 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 304 | 305 | # Assert 'volumes' is replaced with the new volumeName entry 306 | expected_volumes = [ 307 | {"volumeName": "new_volume", "containerPath": "/new_container_path"} 308 | ] 309 | self.assertEqual(post_data["volumes"], expected_volumes) 310 | 311 | def test_update_port_mapping(self): 312 | self.api.update_app("test_app", port_mapping=["8080:80", "443:443"]) 313 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 314 | 315 | expected = [ 316 | {"containerPort": "80", "hostPort": "8080"}, 317 | {"containerPort": "443", "hostPort": "443"}, 318 | ] 319 | self.assertEqual(post_data["ports"], expected) 320 | 321 | def test_update_via_unspecified_kwarg(self): 322 | """You can use kwargs to override any other not explicitly listed as a method arg.""" 323 | new_redirect_domain = "test_app.example.com" 324 | self.api.update_app("test_app", redirectDomain=new_redirect_domain) 325 | post_data = json.loads(self.api.session.post.call_args[1]["data"]) 326 | 327 | self.assertEqual(post_data["redirectDomain"], new_redirect_domain) 328 | -------------------------------------------------------------------------------- /caprover_api/caprover_api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import functools 3 | import json 4 | import logging 5 | import os 6 | import re 7 | import secrets 8 | import time 9 | from collections import Counter, namedtuple 10 | 11 | import requests 12 | import yaml 13 | 14 | 15 | class TooManyRequestsError(Exception): 16 | """Raised when we encounter HTTP 429 response from CapRover. 17 | 18 | CapRover uses this status to do its own locking to prevent certain 19 | concurrent operations. 20 | There's no problem retrying it until the lock is released. 21 | """ 22 | pass 23 | 24 | 25 | RetrySettings = namedtuple("RetrySettings", ("times", "delay")) 26 | 27 | # Retry behavior depends on what happened: 28 | # - TooManyRequestsError -> 15s delay, max 6 tries 29 | # - requests.ConnectionError -> 1s delay, max 3 tries 30 | TRANSIENT_ERRORS = { 31 | TooManyRequestsError: RetrySettings(6, 15), 32 | requests.exceptions.ConnectionError: RetrySettings(3, 1), 33 | } 34 | 35 | 36 | def retry(exception_settings: dict[Exception, RetrySettings]): 37 | """ 38 | Retry Decorator 39 | Retries the wrapped function/method if the exceptions that key 40 | ``exception_settings`` are thrown. 41 | 42 | :param exception_settings: exceptions that trigger a retry attempt, 43 | mapping to the retry configuration for that exception. 44 | Retry tracking is per-class in this dict. 45 | """ 46 | 47 | def decorator(func): 48 | @functools.wraps(func) 49 | def wrapper(*args, **kwargs): 50 | retries_by_exc = Counter() 51 | 52 | while True: 53 | try: 54 | return func(*args, **kwargs) 55 | except tuple(exception_settings.keys()) as e: 56 | # Determine the exc type to look up its settings. 57 | # Must iteratively call isinstance to be inheritance-aware. 58 | for key, settings in exception_settings.items(): 59 | if isinstance(e, key): 60 | exc_type = key 61 | break 62 | 63 | if retries_by_exc[exc_type] < settings.times: 64 | retries_by_exc[exc_type] += 1 65 | logging.error( 66 | "%s raised %s. " 67 | "Waiting %ds before retry attempt %d of %d", 68 | func.__name__, 69 | exc_type.__name__, 70 | settings.delay, 71 | retries_by_exc[exc_type], 72 | settings.times, 73 | ) 74 | time.sleep(settings.delay) 75 | else: 76 | raise # exhausted retries 77 | return wrapper 78 | return decorator 79 | 80 | 81 | PUBLIC_ONE_CLICK_APP_PATH = "https://oneclickapps.caprover.com/v4/apps/" 82 | 83 | class CaproverAPI: 84 | class Status: 85 | STATUS_ERROR_GENERIC = 1000 86 | STATUS_OK = 100 87 | STATUS_OK_DEPLOY_STARTED = 101 88 | STATUS_OK_PARTIALLY = 102 89 | STATUS_ERROR_CAPTAIN_NOT_INITIALIZED = 1001 90 | STATUS_ERROR_USER_NOT_INITIALIZED = 1101 91 | STATUS_ERROR_NOT_AUTHORIZED = 1102 92 | STATUS_ERROR_ALREADY_EXIST = 1103 93 | STATUS_ERROR_BAD_NAME = 1104 94 | STATUS_WRONG_PASSWORD = 1105 95 | STATUS_AUTH_TOKEN_INVALID = 1106 96 | VERIFICATION_FAILED = 1107 97 | ILLEGAL_OPERATION = 1108 98 | BUILD_ERROR = 1109 99 | ILLEGAL_PARAMETER = 1110 100 | NOT_FOUND = 1111 101 | AUTHENTICATION_FAILED = 1112 102 | STATUS_PASSWORD_BACK_OFF = 1113 103 | 104 | LOGIN_PATH = '/api/v2/login' 105 | SYSTEM_INFO_PATH = "/api/v2/user/system/info" 106 | APP_LIST_PATH = "/api/v2/user/apps/appDefinitions" 107 | APP_LIST_PROJECTS = "/api/v2/user/projects" 108 | APP_REGISTER_PATH = '/api/v2/user/apps/appDefinitions/register' 109 | APP_DELETE_PATH = '/api/v2/user/apps/appDefinitions/delete' 110 | ADD_CUSTOM_DOMAIN_PATH = '/api/v2/user/apps/appDefinitions/customdomain' 111 | UPDATE_APP_PATH = '/api/v2/user/apps/appDefinitions/update' 112 | ENABLE_BASE_DOMAIN_SSL_PATH = '/api/v2/user/apps/appDefinitions/enablebasedomainssl' 113 | ENABLE_CUSTOM_DOMAIN_SSL_PATH = '/api/v2/user/apps/appDefinitions/enablecustomdomainssl' 114 | APP_DATA_PATH = '/api/v2/user/apps/appData' 115 | CREATE_BACKUP_PATH = '/api/v2/user/system/createbackup' 116 | DOWNLOAD_BACKUP_PATH = '/api/v2/downloads/' 117 | TRIGGER_BUILD_PATH = '/api/v2/user/apps/webhooks/triggerbuild' 118 | 119 | def __init__( 120 | self, dashboard_url: str, password: str, 121 | protocol: str = 'https://', schema_version: int = 2, 122 | captain_namespace='captain' 123 | ): 124 | """ 125 | :param dashboard_url: captain dashboard url 126 | :param password: captain dashboard password 127 | :param protocol: http protocol to use 128 | """ 129 | self.session = requests.Session() 130 | self.headers = { 131 | 'accept': 'application/json, text/plain, */*', 132 | 'x-namespace': captain_namespace, 133 | 'content-type': 'application/json;charset=UTF-8', 134 | } 135 | self.dashboard_url = dashboard_url.split("/#")[0].strip("/") 136 | self.password = password 137 | self.captain_namespace = captain_namespace 138 | self.schema_version = schema_version 139 | self.base_url = self.dashboard_url if re.search( 140 | r"^https?://", self.dashboard_url 141 | ) else protocol + self.dashboard_url 142 | self.token = self._login()['data']['token'] 143 | self.headers['x-captain-auth'] = self.token 144 | # root_domain with regex re.sub(r"^captain\.", "", self.dashboard_url) 145 | self.root_domain = self.get_system_info()['data']['rootDomain'] 146 | 147 | def _build_url(self, api_endpoint): 148 | return self.base_url + api_endpoint 149 | 150 | @staticmethod 151 | def _check_errors(response: requests.Response): 152 | # Check for HTTP status code 429, which is likely to 153 | # be retried because it's in COMMON_ERRORS. 154 | if response.status_code == 429: 155 | raise TooManyRequestsError( 156 | f"HTTP 429 Too Many Requests for {response.url}" 157 | ) 158 | 159 | response_json = response.json() 160 | description = response_json.get('description', '') 161 | if response_json['status'] not in [ 162 | CaproverAPI.Status.STATUS_OK, 163 | CaproverAPI.Status.STATUS_OK_PARTIALLY 164 | ]: 165 | logging.error(description) 166 | raise Exception(description) 167 | logging.info(description) 168 | return response_json 169 | 170 | @staticmethod 171 | def _download_one_click_app_defn(repository_path: str, one_click_app_name: str): 172 | """Retrieve the raw app definition from the public one-click app repository. 173 | 174 | :return raw_app_definition (str) containing JSON 175 | """ 176 | r = requests.get( 177 | repository_path + one_click_app_name 178 | ) 179 | r.raise_for_status() 180 | return r.text 181 | 182 | def _resolve_app_variables( 183 | self, raw_app_definition, cap_app_name, 184 | app_variables, automated: bool = False 185 | ): 186 | """ 187 | Resolve the app variables for a CapRover one-click app. 188 | 189 | The function injects the `app_variables` into the app definiition, 190 | including resolving default values and random hex generator expressions. 191 | 192 | If required variables are missing or have an invalid value, the function will 193 | either raise an exception (if `automated` is True) or prompt the user to enter a 194 | valid value. 195 | 196 | :param raw_app_definition (str): The unparsed JSON text of the one-click app definition. 197 | :param cap_app_name (str): The name under which the app will be installed. 198 | :param app_variables (dict): A dictionary of $$cap_variables and their values. 199 | This will get updated to also include the $$cap_appname and $$cap_root domain. 200 | :param automated (bool, optional): Whether the function is being called in an 201 | automated context. Defaults to False. 202 | 203 | :return The updated raw app definiton with all variables resolved. 204 | """ 205 | raw_app_data = raw_app_definition 206 | # Replace any random hex generators in the raw data first 207 | for match in re.finditer(r"\$\$cap_gen_random_hex\((\d+)\)", raw_app_data): 208 | requested_length = int(match.group(1)) 209 | raw_app_data = raw_app_data.replace( 210 | match.group(0), 211 | # slice notation is because secrets.token_hex generates the hex 212 | # representation of n bytes, which is twice as many hex chars. 213 | secrets.token_hex(requested_length)[:requested_length] 214 | ) 215 | 216 | app_variables.update( 217 | { 218 | "$$cap_appname": cap_app_name, 219 | "$$cap_root_domain": self.root_domain 220 | } 221 | ) 222 | _app_data = json.loads(raw_app_data) 223 | 224 | variables = _app_data.get( 225 | "caproverOneClickApp", {} 226 | ).get("variables", {}) 227 | for app_variable in variables: 228 | if app_variables.get(app_variable['id']) is None: 229 | default_value = app_variable.get('defaultValue', '') 230 | is_valid = re.search( 231 | app_variable.get('validRegex', '.*').strip('/'), 232 | default_value 233 | ) if default_value is not None else False 234 | is_invalid = not is_valid 235 | if is_invalid: 236 | if automated: 237 | raise Exception( 238 | 'Missing or Invalid value for >>{}<<'.format( 239 | app_variable['id'] 240 | ) 241 | ) 242 | else: 243 | ask_variable = "{label} [{description}]: ".format( 244 | label=app_variable['label'], 245 | description=app_variable.get('description', '') 246 | ) 247 | default_value = input(ask_variable) 248 | app_variables[app_variable['id']] = default_value 249 | for variable_id, variable_value in app_variables.items(): 250 | raw_app_data = raw_app_data.replace( 251 | variable_id, str(variable_value) 252 | ) 253 | return raw_app_data 254 | 255 | @staticmethod 256 | def _parse_command(command): 257 | """ 258 | Parse Docker Compose service command into a Docker override. 259 | 260 | The override is compatible with Docker API's Service Update Object 261 | and therefore is a valid Caprover Service Update Override. 262 | 263 | Mimics caprover-frontend's functionality from: 264 | https://github.com/caprover/caprover-frontend/blob/ffb2b69c1143262a241cd8005dddf263eece6bb1/src/utils/DockerComposeToServiceOverride.ts#L37 265 | 266 | :param command: The command from the Docker Compose service definition 267 | a string or a list of str 268 | :return: A dict that can be converted to YAML for the service override. 269 | """ 270 | 271 | def parse_docker_cmd(cmd_string): 272 | # Matches sequences inside quotes or sequences without spaces 273 | regex = r'[^\s"\'\n]+|"([^"]*)"|\'([^\']*)\'' 274 | args = [] 275 | for match in re.finditer(regex, cmd_string): 276 | args.append(match.group(1) or match.group(2) or match.group(0)) 277 | return args 278 | 279 | # Convert command to a list if it is a string 280 | command_list = ( 281 | command if isinstance(command, list) else parse_docker_cmd(command) 282 | ) 283 | 284 | # Build the service override dictionary 285 | return {"TaskTemplate": {"ContainerSpec": {"Command": command_list}}} 286 | 287 | @retry(TRANSIENT_ERRORS) 288 | def get_system_info(self): 289 | response = self.session.get( 290 | self._build_url(CaproverAPI.SYSTEM_INFO_PATH), headers=self.headers 291 | ) 292 | return CaproverAPI._check_errors(response) 293 | 294 | @retry(TRANSIENT_ERRORS) 295 | def get_app_info(self, app_name): 296 | logging.info("Getting app info...") 297 | response = self.session.get( 298 | self._build_url(CaproverAPI.APP_DATA_PATH) + '/' + app_name, 299 | headers=self.headers 300 | ) 301 | return CaproverAPI._check_errors(response) 302 | 303 | @retry(TRANSIENT_ERRORS) 304 | def _wait_until_app_ready(self, app_name): 305 | timeout = 60 306 | while timeout: 307 | timeout -= 1 308 | time.sleep(1) 309 | app_info = self.get_app_info(app_name) 310 | if not app_info.get("data", {}).get("isAppBuilding"): 311 | logging.info("App building finished...") 312 | return app_info 313 | raise Exception("App building timeout reached") 314 | 315 | def _ensure_app_build_success(self, app_name: str): 316 | app_info = self.get_app_info(app_name) 317 | if app_info.get("data", {}).get("isBuildFailed"): 318 | raise Exception("App building failed") 319 | return app_info 320 | 321 | @retry(TRANSIENT_ERRORS) 322 | def list_apps(self): 323 | response = self.session.get( 324 | self._build_url(CaproverAPI.APP_LIST_PATH), 325 | headers=self.headers 326 | ) 327 | return CaproverAPI._check_errors(response) 328 | 329 | @retry(TRANSIENT_ERRORS) 330 | def list_projects(self): 331 | response = self.session.get( 332 | self._build_url(CaproverAPI.APP_LIST_PROJECTS), 333 | headers=self.headers 334 | ) 335 | return CaproverAPI._check_errors(response) 336 | 337 | def get_app(self, app_name: str): 338 | app_list = self.list_apps() 339 | for app in app_list.get('data').get("appDefinitions"): 340 | if app['appName'] == app_name: 341 | return app 342 | return {} 343 | 344 | def deploy_one_click_app( 345 | self, 346 | one_click_app_name: str, 347 | app_name: str = None, 348 | app_variables: dict = None, 349 | automated: bool = False, 350 | one_click_repository: str = PUBLIC_ONE_CLICK_APP_PATH, 351 | tags: list[str] = None, 352 | ): 353 | """ 354 | Deploys a one-click app on the CapRover platform. 355 | 356 | :param one_click_app_name: one click app name in the repository 357 | :param app_name: The name under which the app will be installed. 358 | (optional) If unset, the `one_click_app_name` will be used. 359 | :param app_variables: dict containing required app variables 360 | :param automated: set to true 361 | if you have supplied all required variables 362 | :param one_click_repository: where to download the one-click app from 363 | :param tags: list of tags to apply to all services 364 | (optional) If unset (None), a tag with the app_name will be used. 365 | Pass an empty list to create no tags. 366 | :return dict containing the deployment "status" and "description". 367 | """ 368 | app_variables = app_variables or {} 369 | if not app_name: 370 | app_name = one_click_app_name 371 | 372 | # Default tag is the app_name if no custom tags provided 373 | if tags is None: 374 | tags = [app_name] 375 | 376 | raw_app_definition = self._download_one_click_app_defn( 377 | one_click_repository, one_click_app_name 378 | ) 379 | resolved_app_data = self._resolve_app_variables( 380 | raw_app_definition=raw_app_definition, 381 | cap_app_name=app_name, 382 | app_variables=app_variables, 383 | automated=automated 384 | ) 385 | app_data = json.loads(resolved_app_data) 386 | services = app_data.get('services') 387 | apps_to_deploy = list(services.keys()) 388 | apps_deployed = [] 389 | while set(apps_to_deploy) != set(apps_deployed): 390 | for service_name, service_data in services.items(): 391 | depends_on = service_data.get('depends_on', []) 392 | if service_name in apps_deployed: 393 | logging.info("app already deployed") 394 | continue 395 | if not set(depends_on).issubset(set(apps_deployed)): 396 | logging.info( 397 | "Skipping because {} depends on {}".format( 398 | service_name, ', '.join(depends_on) 399 | ) 400 | ) 401 | continue 402 | 403 | has_persistent_data = bool(service_data.get("volumes")) 404 | persistent_directories = service_data.get("volumes", []) 405 | environment_variables = service_data.get("environment", {}) 406 | caprover_extras = service_data.get("caproverExtra", {}) 407 | expose_as_web_app = True if caprover_extras.get( 408 | "notExposeAsWebApp", 'false') == 'false' else False 409 | container_http_port = int( 410 | caprover_extras.get("containerHttpPort", 80) 411 | ) 412 | 413 | # Parse command (if it exists) into service update override 414 | command = service_data.get("command") 415 | service_update_override = None 416 | if command: 417 | service_override_dict = self._parse_command(command) 418 | service_update_override = yaml.dump( 419 | service_override_dict, default_style="|" 420 | ) 421 | 422 | # create app 423 | self.create_app( 424 | app_name=service_name, 425 | has_persistent_data=has_persistent_data 426 | ) 427 | 428 | # update app 429 | self.update_app( 430 | app_name=service_name, 431 | instance_count=1, 432 | persistent_directories=persistent_directories, 433 | environment_variables=environment_variables, 434 | expose_as_web_app=expose_as_web_app, 435 | container_http_port=container_http_port, 436 | serviceUpdateOverride=service_update_override, 437 | tags=tags, 438 | ) 439 | image_name = service_data.get("image") 440 | docker_file_lines = caprover_extras.get("dockerfileLines") 441 | self.deploy_app( 442 | service_name, 443 | image_name=image_name, 444 | docker_file_lines=docker_file_lines 445 | ) 446 | apps_deployed.append(service_name) 447 | return { 448 | "status": CaproverAPI.Status.STATUS_OK, 449 | "description": "Deployed all services in >>{}<<".format( 450 | one_click_app_name 451 | ) 452 | } 453 | 454 | @retry(TRANSIENT_ERRORS) 455 | def deploy_app( 456 | self, app_name: str, 457 | image_name: str = None, 458 | docker_file_lines: list = None 459 | ): 460 | """ 461 | :param app_name: app name 462 | :param image_name: docker hub image name 463 | :param docker_file_lines: docker file lines as list 464 | :return: 465 | """ 466 | if image_name: 467 | definition = { 468 | "schemaVersion": self.schema_version, 469 | "imageName": image_name 470 | } 471 | elif docker_file_lines: 472 | definition = { 473 | "schemaVersion": self.schema_version, 474 | "dockerfileLines": docker_file_lines 475 | } 476 | else: 477 | definition = {} 478 | data = json.dumps({ 479 | "captainDefinitionContent": json.dumps(definition), 480 | "gitHash": "" 481 | }) 482 | response = self.session.post( 483 | self._build_url( 484 | CaproverAPI.APP_DATA_PATH 485 | ) + '/' + app_name, 486 | headers=self.headers, data=data 487 | ) 488 | self._check_errors(response) 489 | self._wait_until_app_ready(app_name=app_name) 490 | time.sleep(0.50) 491 | self._ensure_app_build_success(app_name=app_name) 492 | return response.json() 493 | 494 | @retry(TRANSIENT_ERRORS) 495 | def _login(self): 496 | data = json.dumps({"password": self.password}) 497 | logging.info("Attempting to login to caprover dashboard...") 498 | response = self.session.post( 499 | self._build_url(CaproverAPI.LOGIN_PATH), 500 | headers=self.headers, data=data 501 | ) 502 | return CaproverAPI._check_errors(response) 503 | 504 | @retry(TRANSIENT_ERRORS) 505 | def stop_app(self, app_name: str): 506 | return self.update_app(app_name=app_name, instance_count=0) 507 | 508 | def delete_app_matching_pattern( 509 | self, app_name_pattern: str, 510 | delete_volumes: bool = False, 511 | automated: bool = False 512 | ): 513 | """ 514 | :param app_name_pattern: regex pattern to match app name 515 | :param delete_volumes: set to true to delete volumes 516 | :param automated: set to true to disable confirmation 517 | :return: 518 | """ 519 | app_list = self.list_apps() 520 | for app in app_list.get('data').get("appDefinitions"): 521 | app_name = app['appName'] 522 | if re.search(app_name_pattern, app_name): 523 | if not automated: 524 | confirmation = None 525 | while confirmation not in ['y', 'n', 'Y', 'N']: 526 | confirmation = input( 527 | "Do you want to delete app ({})?\n" 528 | "Answer (Y or N): ".format( 529 | app_name 530 | ) 531 | ) 532 | if confirmation.lower() == 'n': 533 | logging.info("Skipping app deletion...") 534 | continue 535 | self.delete_app( 536 | app_name=app['appName'], delete_volumes=delete_volumes 537 | ) 538 | time.sleep(0.20) 539 | return { 540 | "description": "All apps matching pattern deleted", 541 | "status": CaproverAPI.Status.STATUS_OK 542 | } 543 | 544 | @retry(TRANSIENT_ERRORS) 545 | def delete_app(self, app_name, delete_volumes: bool = False): 546 | """ 547 | :param app_name: app name 548 | :param delete_volumes: set to true to delete volumes 549 | :return: 550 | """ 551 | if delete_volumes: 552 | logging.info( 553 | "Deleting app {} and it's volumes...".format( 554 | app_name 555 | ) 556 | ) 557 | app = self.get_app(app_name=app_name) 558 | data = json.dumps( 559 | { 560 | "appName": app_name, 561 | "volumes": [ 562 | volume['volumeName'] for volume in app['volumes'] 563 | ] 564 | } 565 | ) 566 | else: 567 | logging.info( 568 | "Deleting app {}".format(app_name) 569 | ) 570 | data = json.dumps({"appName": app_name}) 571 | response = requests.post( 572 | self._build_url(CaproverAPI.APP_DELETE_PATH), 573 | headers=self.headers, data=data 574 | ) 575 | return CaproverAPI._check_errors(response) 576 | 577 | @retry(TRANSIENT_ERRORS) 578 | def create_app( 579 | self, app_name: str, 580 | project_id: str = '', 581 | has_persistent_data: bool = False, 582 | wait_for_app_build: bool = True 583 | ): 584 | """ 585 | :param app_name: app name 586 | :param project_id: leave it emtpy to create in root 587 | :param has_persistent_data: true if requires persistent data 588 | :param wait_for_app_build: set false to skip waiting 589 | :return: 590 | """ 591 | params = ( 592 | ('detached', '1'), 593 | ) 594 | data = json.dumps( 595 | {"appName": app_name, "projectId": project_id, "hasPersistentData": has_persistent_data} 596 | ) 597 | logging.info("Creating new app: {}".format(app_name)) 598 | response = self.session.post( 599 | self._build_url(CaproverAPI.APP_REGISTER_PATH), 600 | headers=self.headers, params=params, data=data 601 | ) 602 | if wait_for_app_build: 603 | self._wait_until_app_ready(app_name=app_name) 604 | return CaproverAPI._check_errors(response) 605 | 606 | @retry(TRANSIENT_ERRORS) 607 | def add_domain(self, app_name: str, custom_domain: str): 608 | """ 609 | :param app_name: 610 | :param custom_domain: custom domain to add 611 | It must already point to this IP in DNS 612 | :return: 613 | """ 614 | data = json.dumps({"appName": app_name, "customDomain": custom_domain}) 615 | logging.info("{} | Adding domain: {}".format(custom_domain, app_name)) 616 | response = self.session.post( 617 | self._build_url(CaproverAPI.ADD_CUSTOM_DOMAIN_PATH), 618 | headers=self.headers, data=data 619 | ) 620 | return CaproverAPI._check_errors(response) 621 | 622 | @retry(TRANSIENT_ERRORS) 623 | def enable_ssl(self, app_name: str, custom_domain: str = None): 624 | """Enable SSL on a domain. 625 | 626 | :param app_name: app name 627 | :param custom_domain: if set, SSL is enabled on this custom domain. 628 | Otherwise, SSL is enabled on the base domain. 629 | :return: 630 | """ 631 | if custom_domain: 632 | logging.info( 633 | "{} | Enabling SSL for domain {}".format(app_name, custom_domain) 634 | ) 635 | path = CaproverAPI.ENABLE_CUSTOM_DOMAIN_SSL_PATH 636 | data = json.dumps({"appName": app_name, "customDomain": custom_domain}) 637 | else: 638 | logging.info( 639 | "{} | Enabling SSL for root domain".format(app_name) 640 | ) 641 | path = CaproverAPI.ENABLE_BASE_DOMAIN_SSL_PATH 642 | data = json.dumps({"appName": app_name}) 643 | response = self.session.post( 644 | self._build_url(path), 645 | headers=self.headers, data=data 646 | ) 647 | return CaproverAPI._check_errors(response) 648 | 649 | @retry(TRANSIENT_ERRORS) 650 | def update_app( 651 | self, 652 | app_name: str, 653 | project_id: str = None, 654 | instance_count: int = None, 655 | captain_definition_path: str = None, 656 | environment_variables: dict = None, 657 | expose_as_web_app: bool = None, 658 | force_ssl: bool = None, 659 | support_websocket: bool = None, 660 | port_mapping: list = None, 661 | persistent_directories: list = None, 662 | container_http_port: int = None, 663 | description: str = None, 664 | service_update_override: str = None, 665 | pre_deploy_function: str = None, 666 | app_push_webhook: dict = None, 667 | repo_info: dict = None, 668 | http_auth: dict = None, 669 | tags: list[str] = None, 670 | **kwargs, 671 | ): 672 | """ 673 | :param app_name: name of the app you want to update 674 | :param project_id: project id of the project you want to move this app to 675 | :param instance_count: instances count, set 0 to stop the app 676 | :param captain_definition_path: captain-definition file relative path 677 | :param environment_variables: dict of env variables 678 | will be merged with the current set: 679 | There is no way to DELETE an env variable from this API. 680 | :param expose_as_web_app: set true to expose the app as web app 681 | :param force_ssl: force traffic to use ssl 682 | :param support_websocket: set to true to enable webhook support 683 | :param port_mapping: list of port mapping 684 | :param persistent_directories: list of dict 685 | fields hostPath OR volumeName, containerPath 686 | If a list is passed, it replaces the entire set of persistent 687 | directories on the app. 688 | Set to None (default) to leave as-is, or empty list to clear 689 | existing mounts. 690 | :param container_http_port: port to use for your container app 691 | :param description: app description 692 | :param service_update_override: service override 693 | :param pre_deploy_function: 694 | :param app_push_webhook: 695 | :param repo_info: dict with repo info 696 | fields repo, user, password, sshKey, branch 697 | :param http_auth: dict with http auth info 698 | fields user, password 699 | :param tags: list of strings to set as the app tags, allowing to better 700 | group and view your apps in the table. 701 | If a list is passed, it replaces the entire set of tags on the app. 702 | Set to None (default) to leave tags as-is, 703 | or pass an empty list to clear existing tags. 704 | :return: dict 705 | """ 706 | current_app_info = self.get_app(app_name=app_name) 707 | if not current_app_info.get("appPushWebhook"): 708 | current_app_info["appPushWebhook"] = {} 709 | if repo_info and isinstance(repo_info, dict): 710 | current_app_info["appPushWebhook"]["repoInfo"] = repo_info 711 | 712 | # handle environment_variables 713 | # gets current envVars and overwrite with new data 714 | current_env_vars = current_app_info["envVars"] 715 | current_env_vars_as_dict = { 716 | item['key']: item['value'] for item in current_env_vars 717 | } 718 | if environment_variables: 719 | current_env_vars_as_dict.update(environment_variables) 720 | updated_environment_variables = [ 721 | { 722 | "key": k, "value": v 723 | } for k, v in current_env_vars_as_dict.items() 724 | ] 725 | current_app_info['envVars'] = updated_environment_variables 726 | 727 | # handle volumes 728 | if persistent_directories is not None: 729 | updated_volumes = [] 730 | for volume_pair in persistent_directories: 731 | volume_name, container_path = volume_pair.split(':') 732 | if volume_name.startswith("/"): 733 | updated_volumes.append( 734 | { 735 | "hostPath": volume_name, 736 | "containerPath": container_path, 737 | } 738 | ) 739 | else: 740 | updated_volumes.append( 741 | { 742 | "volumeName": volume_name, 743 | "containerPath": container_path, 744 | } 745 | ) 746 | persistent_directories = updated_volumes 747 | 748 | if port_mapping: 749 | ports = [ 750 | { 751 | "hostPort": ports.split(':')[0], 752 | "containerPort": ports.split(':')[1] 753 | } for ports in port_mapping 754 | ] 755 | else: 756 | ports = None 757 | _data = { 758 | "appName": app_name, 759 | "projectId": project_id, 760 | "instanceCount": instance_count, 761 | "preDeployFunction": pre_deploy_function, 762 | "captainDefinitionRelativeFilePath": captain_definition_path, 763 | "notExposeAsWebApp": None if expose_as_web_app is None else (not expose_as_web_app), 764 | "forceSsl": force_ssl, 765 | "websocketSupport": support_websocket, 766 | "ports": ports, 767 | "volumes": persistent_directories, 768 | "containerHttpPort": container_http_port, 769 | "description": description, 770 | "appPushWebhook": app_push_webhook, 771 | "serviceUpdateOverride": service_update_override, 772 | "httpAuth": http_auth, 773 | "tags": None if tags is None else [{"tagName": t} for t in tags], 774 | } 775 | for k, v in _data.items(): 776 | if v is None: 777 | # skip as value not changed 778 | continue 779 | # update current value with new value 780 | current_app_info[k] = v 781 | 782 | # Any other kwarg is an automatic override. 783 | current_app_info.update(kwargs) 784 | 785 | logging.info("{} | Updating app info...".format(app_name)) 786 | response = self.session.post( 787 | self._build_url(CaproverAPI.UPDATE_APP_PATH), 788 | headers=self.headers, data=json.dumps(current_app_info) 789 | ) 790 | return CaproverAPI._check_errors(response) 791 | 792 | def create_and_update_app( 793 | self, app_name: str, project_id: str = '', 794 | has_persistent_data: bool = False, 795 | custom_domain: str = None, enable_ssl: bool = False, 796 | image_name: str = None, docker_file_lines: list = None, 797 | instance_count: int = 1, **kwargs 798 | ): 799 | """ 800 | :param app_name: app name 801 | :param project_id: leave it emtpy to create in root 802 | :param has_persistent_data: set to true to use persistent dirs 803 | :param custom_domain: custom domain for app 804 | :param enable_ssl: set to true to enable ssl 805 | :param image_name: docker hub image name 806 | :param docker_file_lines: docker file lines 807 | :param instance_count: int number of instances to run 808 | :param kwargs: extra kwargs check 809 | :func:`~caprover_api.CaproverAPI.update_app` 810 | :return: 811 | """ 812 | if kwargs.get("persistent_directories"): 813 | has_persistent_data = True 814 | response = self.create_app( 815 | app_name=app_name, project_id=project_id, 816 | has_persistent_data=has_persistent_data 817 | ) 818 | if custom_domain: 819 | time.sleep(0.10) 820 | response = self.add_domain( 821 | app_name=app_name, custom_domain=custom_domain 822 | ) 823 | if enable_ssl: 824 | time.sleep(0.10) 825 | response = self.enable_ssl( 826 | app_name=app_name, custom_domain=custom_domain 827 | ) 828 | if kwargs: 829 | time.sleep(0.10) 830 | response = self.update_app( 831 | app_name=app_name, 832 | project_id=project_id, 833 | instance_count=instance_count, 834 | **kwargs 835 | ) 836 | if image_name or docker_file_lines: 837 | response = self.deploy_app( 838 | app_name=app_name, 839 | image_name=image_name, 840 | docker_file_lines=docker_file_lines 841 | ) 842 | return response 843 | 844 | def create_app_with_custom_domain( 845 | self, app_name: str, 846 | custom_domain: str, 847 | has_persistent_data: bool = False, 848 | ): 849 | """ 850 | :param app_name: app name 851 | :param has_persistent_data: set to true to use persistent dirs 852 | :param custom_domain: custom domain for app 853 | :return: 854 | """ 855 | return self.create_and_update_app( 856 | app_name=app_name, 857 | has_persistent_data=has_persistent_data, 858 | custom_domain=custom_domain 859 | ) 860 | 861 | def create_app_with_custom_domain_and_ssl( 862 | self, app_name: str, 863 | custom_domain: str, 864 | has_persistent_data: bool = False, 865 | ): 866 | """ 867 | :param app_name: app name 868 | :param has_persistent_data: set to true to use persistent dirs 869 | :param custom_domain: custom domain for app 870 | :return: 871 | """ 872 | return self.create_and_update_app( 873 | app_name=app_name, 874 | has_persistent_data=has_persistent_data, 875 | custom_domain=custom_domain, 876 | enable_ssl=True 877 | ) 878 | 879 | def _create_backup(self, file_name): 880 | data = json.dumps({"postDownloadFileName": file_name}) 881 | response = self.session.post( 882 | self._build_url(CaproverAPI.CREATE_BACKUP_PATH), 883 | headers=self.headers, data=data 884 | ) 885 | return CaproverAPI._check_errors(response) 886 | 887 | def _download_backup(self, download_token, file_name): 888 | response = self.session.get( 889 | self._build_url(CaproverAPI.DOWNLOAD_BACKUP_PATH), 890 | headers=self.headers, 891 | params={ 892 | 'namespace': self.captain_namespace, 893 | 'downloadToken': download_token 894 | } 895 | ) 896 | assert response.status_code == 200 897 | with open(file_name, 'wb') as f: 898 | f.write(response.content) 899 | return os.path.abspath(f.name) 900 | 901 | def create_backup(self, file_name=None): 902 | if not file_name: 903 | date_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 904 | file_name = f'{self.captain_namespace}-bck-{date_str}.rar' 905 | 906 | valid_response = self._create_backup(file_name=file_name) 907 | download_token = valid_response.get('data', {}).get('downloadToken') 908 | return self._download_backup( 909 | download_token=download_token, file_name=file_name 910 | ) 911 | 912 | def trigger_build(self, app_name: str, captain_namespace: str = 'captain'): 913 | app = self.get_app(app_name=app_name) 914 | push_web_token = app.get('appPushWebhook', {}).get('pushWebhookToken') 915 | params = ( 916 | ('namespace', captain_namespace), 917 | ('token', push_web_token) 918 | ) 919 | data = '{}' 920 | logging.info("Triggering build process: {}".format(app_name)) 921 | response = self.session.post( 922 | self._build_url(CaproverAPI.TRIGGER_BUILD_PATH), 923 | headers=self.headers, params=params, data=data 924 | ) 925 | return CaproverAPI._check_errors(response) 926 | --------------------------------------------------------------------------------