├── 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 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | 1633313140924
30 |
31 |
32 | 1633313140924
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
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 |
--------------------------------------------------------------------------------