├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat └── marathon.rst ├── itests ├── docker │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── install-marathon.sh │ └── start-marathon.sh ├── environment.py ├── itest.sh ├── itest_utils.py ├── marathon_apps.feature ├── marathon_deployments.feature ├── marathon_tasks.feature └── steps │ └── marathon_steps.py ├── marathon ├── __init__.py ├── client.py ├── exceptions.py ├── models │ ├── __init__.py │ ├── app.py │ ├── base.py │ ├── constraint.py │ ├── container.py │ ├── deployment.py │ ├── endpoint.py │ ├── events.py │ ├── group.py │ ├── info.py │ ├── queue.py │ └── task.py └── util.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_api.py ├── test_exceptions.py ├── test_model_app.py ├── test_model_constraint.py ├── test_model_deployment.py ├── test_model_event.py ├── test_model_group.py ├── test_model_object.py └── test_util.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | .DS_Store 56 | 57 | # IntelliJ 58 | .idea 59 | *.iml 60 | 61 | # Packer http://packer.io 62 | packer_cache 63 | 64 | /gh-pages 65 | itests/marathon-version 66 | .pytest_cache/ 67 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: https://github.com/asottile/pyupgrade 2 | rev: v1.25.1 3 | hooks: 4 | - id: pyupgrade 5 | args: [--py36-plus] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - MARATHONVERSION: v1.10.19 3 | - MARATHONVERSION: v1.9.109 4 | - MARATHONVERSION: v1.6.322 5 | - MARATHONVERSION: v1.4.11 6 | - MARATHONVERSION: v1.3.0 7 | - MARATHONVERSION: v1.1.2 8 | 9 | language: python 10 | services: 11 | - docker 12 | python: 13 | - 3.6 14 | - 3.7 15 | before_install: 16 | - docker pull "missingcharacter/marathon-python:${MARATHONVERSION}" 17 | - docker run --name marathon-python -d -p 8080:8080 -p 5050:5050 "missingcharacter/marathon-python:${MARATHONVERSION}" 18 | install: 19 | - pip install tox 20 | script: 21 | - make test-py${TRAVIS_PYTHON_VERSION/./} 22 | - make itests-py${TRAVIS_PYTHON_VERSION/./} 23 | 24 | # Work around travis-ci/travis-ci#5227 25 | addons: 26 | hostname: localhost 27 | 28 | os: linux 29 | dist: xenial 30 | 31 | deploy: 32 | - provider: pypi 33 | user: yelplabs 34 | password: 35 | secure: "Wl8GWxsfPy4KoORYH26N3FllvMeWrifzeCbEx2Af4corcBQl43heeiFRRTlUOcSX0TIasER21PUvQ0R0cAgCjfknDb3SOROcRtcSBe16+cMmvwysfxcAx2OcF1UYBPY8e/qOsGge2Zyzx2PAPNEmJoWKbIT3vUJ4WvlLVeGYdJ0=" 36 | on: 37 | tags: true 38 | condition: $MARATHONVERSION == "v1.6.322" 39 | repo: thefactory/marathon-python 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 The Factory 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | itests: itests-py36 itests-py37 2 | 3 | itests-py36: 4 | tox -e itest-py36 5 | 6 | itests-py37: 7 | tox -e itest-py37 8 | 9 | test: test-py36 test-py37 10 | 11 | test-py36: 12 | tox -e pep8 13 | tox -e test-py36 14 | 15 | test-py37: 16 | tox -e test-py37 17 | 18 | clean: 19 | rm -rf dist/ build/ 20 | 21 | package: clean 22 | github_changelog_generator --user=thefactory --project=marathon-python --future-release=0.13.0 23 | pip install wheel 24 | python setup.py sdist bdist_wheel 25 | 26 | publish: package 27 | pip install twine 28 | twine upload dist/* 29 | 30 | .PHONY: itests test clean package publish 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # marathon-python 2 | 3 | [![Build Status](https://travis-ci.org/thefactory/marathon-python.svg?branch=master)](https://travis-ci.org/thefactory/marathon-python) 4 | 5 | This is a Python library for interfacing with [Marathon](https://github.com/mesosphere/marathon) servers via Marathon's [REST API](https://mesosphere.github.io/marathon/docs/rest-api.html). 6 | 7 | #### Compatibility 8 | 9 | * For Marathon 1.9.x and 1.10.x, use at least 0.13.0 10 | * For Marathon 1.6.x, use at least 0.10.0 11 | * For Marathon 1.4.1, use at least 0.8.13 12 | * For Marathon 1.1.1, use at least 0.8.1 13 | * For all version changes, please see `CHANGELOG.md` 14 | 15 | If you find a feature that is broken, please submit a PR that adds a test for 16 | it so it will be fixed and will continue to stay fixed as Marathon changes over 17 | time. 18 | 19 | Just because this library is tested against a specific version of Marathon, 20 | doesn't necessarily mean that it supports every feature and API Marathon 21 | provides. 22 | 23 | ## Installation 24 | 25 | #### From PyPi (recommended) 26 | ```bash 27 | pip install marathon 28 | ``` 29 | 30 | #### From GitHub 31 | ```bash 32 | pip install -e git+git@github.com:thefactory/marathon-python.git#egg=marathon 33 | ``` 34 | 35 | #### From source 36 | ```bash 37 | git clone git@github.com:thefactory/marathon-python 38 | python marathon-python/setup.py install 39 | ``` 40 | 41 | ## Testing 42 | 43 | `marathon-python` uses Travis to test the code against different versions of Marathon. 44 | You can run the tests locally on a Linux machine that has docker on it: 45 | 46 | ### Running The Tests 47 | 48 | ```bash 49 | make itests 50 | ``` 51 | 52 | ### Running The Tests Against a Specific Version of Marathon 53 | 54 | ```bash 55 | MARATHONVERSION=v1.6.322 make itests 56 | ``` 57 | 58 | ## Documentation 59 | 60 | API documentation is [here](http://thefactory.github.io/marathon-python). 61 | 62 | Or you can build the documentation yourself: 63 | ```bash 64 | pip install sphinx 65 | pip install sphinx_rtd_theme 66 | cd docs/ 67 | make html 68 | ``` 69 | 70 | The documentation will be in `/gh-pages/html`: 71 | ```bash 72 | open gh-pages/html/index.html 73 | ``` 74 | 75 | ## Basic Usage 76 | 77 | Create a `MarathonClient()` instance pointing at your Marathon server(s): 78 | ```python 79 | >>> from marathon import MarathonClient 80 | >>> c = MarathonClient('http://localhost:8080') 81 | 82 | >>> # or multiple servers: 83 | >>> c = MarathonClient(['http://host1:8080', 'http://host2:8080']) 84 | ``` 85 | 86 | Then try calling some methods: 87 | ```python 88 | >>> c.list_apps() 89 | [MarathonApp::myapp1, MarathonApp::myapp2] 90 | ``` 91 | 92 | ```python 93 | >>> from marathon.models import MarathonApp 94 | >>> c.create_app('myapp3', MarathonApp(cmd='sleep 100', mem=16, cpus=1)) 95 | MarathonApp::myapp3 96 | ``` 97 | 98 | ```python 99 | >>> app = c.get_app('myapp3') 100 | >>> app.ports 101 | [19671] 102 | >>> app.mem = 32 103 | >>> c.update_app('myapp3', app) 104 | {'deploymentId': '83b215a6-4e26-4e44-9333-5c385eda6438', 'version': '2014-08-26T07:37:50.462Z'} 105 | >>> c.get_app('myapp3').mem 106 | 32.0 107 | ``` 108 | 109 | ```python 110 | >>> c.get_app('myapp3').instances 111 | 1 112 | >>> c.scale_app('myapp3', instances=3) 113 | {'deploymentId': '611b89e3-99f2-4d8a-afe1-ec0b83fdbb88', 'version': '2014-08-26T07:40:20.121Z'} 114 | >>> c.get_app('myapp3').instances 115 | 3 116 | >>> c.scale_app('myapp3', delta=-1) 117 | {'deploymentId': '1081a99c-55e8-4404-907b-4a3697059848', 'version': '2014-08-26T07:43:30.232Z'} 118 | >>> c.get_app('myapp3').instances 119 | 2 120 | ``` 121 | 122 | ```python 123 | >>> c.list_tasks('myapp1') 124 | [MarathonTask:myapp1-1398201790254] 125 | >>> c.kill_tasks('myapp1', scale=True) 126 | [MarathonTask:myapp1-1398201790254] 127 | >>> c.list_tasks('myapp1') 128 | [] 129 | ``` 130 | 131 | ## License 132 | 133 | Open source under the MIT License. See [LICENSE](LICENSE). 134 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = ../gh-pages 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/marathon.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/marathon.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/marathon" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/marathon" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # marathon-python documentation build configuration file, created by 3 | # sphinx-quickstart on Tue Apr 22 11:36:23 2014. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath('../')) 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.viewcode', 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix of source filenames. 39 | source_suffix = '.rst' 40 | 41 | # The encoding of source files. 42 | #source_encoding = 'utf-8-sig' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = 'marathon-python' 49 | copyright = '2014, The Factory' 50 | 51 | # The version info for the project you're documenting, acts as replacement for 52 | # |version| and |release|, also used in various other places throughout the 53 | # built documents. 54 | # 55 | # The short X.Y version. 56 | version = '' 57 | # The full version, including alpha/beta/rc tags. 58 | release = '' 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | #language = None 63 | 64 | # There are two options for replacing |today|: either, you set today to some 65 | # non-false value, then it is used: 66 | #today = '' 67 | # Else, today_fmt is used as the format for a strftime call. 68 | #today_fmt = '%B %d, %Y' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | exclude_patterns = ['_build'] 73 | 74 | # The reST default role (used for this markup: `text`) to use for all 75 | # documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | # If true, keep warnings as "system message" paragraphs in the built documents. 96 | #keep_warnings = False 97 | 98 | 99 | # -- Options for HTML output ---------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | import sphinx_rtd_theme 104 | 105 | html_theme = "sphinx_rtd_theme" 106 | 107 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | #html_theme_options = {} 113 | 114 | # Add any paths that contain custom themes here, relative to this directory. 115 | #html_theme_path = [] 116 | 117 | # The name for this set of Sphinx documents. If None, it defaults to 118 | # " v documentation". 119 | #html_title = None 120 | 121 | # A shorter title for the navigation bar. Default is the same as html_title. 122 | #html_short_title = None 123 | 124 | # The name of an image file (relative to this directory) to place at the top 125 | # of the sidebar. 126 | #html_logo = None 127 | 128 | # The name of an image file (within the static path) to use as favicon of the 129 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 130 | # pixels large. 131 | #html_favicon = None 132 | 133 | # Add any paths that contain custom static files (such as style sheets) here, 134 | # relative to this directory. They are copied after the builtin static files, 135 | # so a file named "default.css" will overwrite the builtin "default.css". 136 | html_static_path = ['_static'] 137 | 138 | # Add any extra paths that contain custom files (such as robots.txt or 139 | # .htaccess) here, relative to this directory. These files are copied 140 | # directly to the root of the documentation. 141 | #html_extra_path = [] 142 | 143 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 144 | # using the given strftime format. 145 | #html_last_updated_fmt = '%b %d, %Y' 146 | 147 | # If true, SmartyPants will be used to convert quotes and dashes to 148 | # typographically correct entities. 149 | #html_use_smartypants = True 150 | 151 | # Custom sidebar templates, maps document names to template names. 152 | #html_sidebars = {} 153 | 154 | # Additional templates that should be rendered to pages, maps page names to 155 | # template names. 156 | #html_additional_pages = {} 157 | 158 | # If false, no module index is generated. 159 | #html_domain_indices = True 160 | 161 | # If false, no index is generated. 162 | #html_use_index = True 163 | 164 | # If true, the index is split into individual pages for each letter. 165 | #html_split_index = False 166 | 167 | # If true, links to the reST sources are added to the pages. 168 | #html_show_sourcelink = True 169 | 170 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 171 | #html_show_sphinx = True 172 | 173 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 174 | #html_show_copyright = True 175 | 176 | # If true, an OpenSearch description file will be output, and all pages will 177 | # contain a tag referring to it. The value of this option must be the 178 | # base URL from which the finished HTML is served. 179 | #html_use_opensearch = '' 180 | 181 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 182 | #html_file_suffix = None 183 | 184 | # Output file base name for HTML help builder. 185 | htmlhelp_basename = 'marathondoc' 186 | 187 | 188 | # -- Options for LaTeX output --------------------------------------------- 189 | 190 | latex_elements = { 191 | # The paper size ('letterpaper' or 'a4paper'). 192 | #'papersize': 'letterpaper', 193 | 194 | # The font size ('10pt', '11pt' or '12pt'). 195 | #'pointsize': '10pt', 196 | 197 | # Additional stuff for the LaTeX preamble. 198 | #'preamble': '', 199 | } 200 | 201 | # Grouping the document tree into LaTeX files. List of tuples 202 | # (source start file, target name, title, 203 | # author, documentclass [howto, manual, or own class]). 204 | latex_documents = [ 205 | ('index', 'marathon-python.tex', 'marathon-python Documentation', 206 | 'Mike Babineau', 'manual'), 207 | ] 208 | 209 | # The name of an image file (relative to this directory) to place at the top of 210 | # the title page. 211 | #latex_logo = None 212 | 213 | # For "manual" documents, if this is true, then toplevel headings are parts, 214 | # not chapters. 215 | #latex_use_parts = False 216 | 217 | # If true, show page references after internal links. 218 | #latex_show_pagerefs = False 219 | 220 | # If true, show URL addresses after external links. 221 | #latex_show_urls = False 222 | 223 | # Documents to append as an appendix to all manuals. 224 | #latex_appendices = [] 225 | 226 | # If false, no module index is generated. 227 | #latex_domain_indices = True 228 | 229 | 230 | # -- Options for manual page output --------------------------------------- 231 | 232 | # One entry per manual page. List of tuples 233 | # (source start file, name, description, authors, manual section). 234 | man_pages = [ 235 | ('index', 'marathon-python', 'marathon-python Documentation', 236 | ['Mike Babineau'], 1) 237 | ] 238 | 239 | # If true, show URL addresses after external links. 240 | #man_show_urls = False 241 | 242 | 243 | # -- Options for Texinfo output ------------------------------------------- 244 | 245 | # Grouping the document tree into Texinfo files. List of tuples 246 | # (source start file, target name, title, author, 247 | # dir menu entry, description, category) 248 | texinfo_documents = [ 249 | ('index', 'marathon-python', 'marathon-python Documentation', 250 | 'Mike Babineau', 'marathon-python', 'One line description of project.', 251 | 'Miscellaneous'), 252 | ] 253 | 254 | # Documents to append as an appendix to all manuals. 255 | #texinfo_appendices = [] 256 | 257 | # If false, no module index is generated. 258 | #texinfo_domain_indices = True 259 | 260 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 261 | #texinfo_show_urls = 'footnote' 262 | 263 | # If true, do not generate a @detailmenu in the "Top" node's menu. 264 | #texinfo_no_detailmenu = False 265 | 266 | 267 | # -- Options for Epub output ---------------------------------------------- 268 | 269 | # Bibliographic Dublin Core info. 270 | epub_title = 'marathon-python' 271 | epub_author = 'Mike Babineau' 272 | epub_publisher = 'Mike Babineau' 273 | epub_copyright = '2014, The Factory' 274 | 275 | # The basename for the epub file. It defaults to the project name. 276 | #epub_basename = u'marathon-python' 277 | 278 | # The HTML theme for the epub output. Since the default themes are not optimized 279 | # for small screen space, using the same theme for HTML and epub output is 280 | # usually not wise. This defaults to 'epub', a theme designed to save visual 281 | # space. 282 | #epub_theme = 'epub' 283 | 284 | 285 | 286 | # The language of the text. It defaults to the language option 287 | # or en if the language is not set. 288 | #epub_language = '' 289 | 290 | # The scheme of the identifier. Typical schemes are ISBN or URL. 291 | #epub_scheme = '' 292 | 293 | # The unique identifier of the text. This can be a ISBN number 294 | # or the project homepage. 295 | #epub_identifier = '' 296 | 297 | # A unique identification for the text. 298 | #epub_uid = '' 299 | 300 | # A tuple containing the cover image and cover page html template filenames. 301 | #epub_cover = () 302 | 303 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 304 | #epub_guide = () 305 | 306 | # HTML files that should be inserted before the pages created by sphinx. 307 | # The format is a list of tuples containing the path and title. 308 | #epub_pre_files = [] 309 | 310 | # HTML files shat should be inserted after the pages created by sphinx. 311 | # The format is a list of tuples containing the path and title. 312 | #epub_post_files = [] 313 | 314 | # A list of files that should not be packed into the epub file. 315 | epub_exclude_files = ['search.html'] 316 | 317 | # The depth of the table of contents in toc.ncx. 318 | #epub_tocdepth = 3 319 | 320 | # Allow duplicate toc entries. 321 | #epub_tocdup = True 322 | 323 | # Choose between 'default' and 'includehidden'. 324 | #epub_tocscope = 'default' 325 | 326 | # Fix unsupported image types using the PIL. 327 | #epub_fix_images = False 328 | 329 | # Scale large images. 330 | #epub_max_image_width = 0 331 | 332 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 333 | #epub_show_urls = 'inline' 334 | 335 | # If false, no index is generated. 336 | #epub_use_index = True 337 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. marathon documentation master file, created by 2 | sphinx-quickstart on Tue Apr 22 11:36:23 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | marathon-python documentation 7 | ============================= 8 | 9 | Python library for interfacing with `Marathon`_ servers via Marathon's `REST API`_. 10 | 11 | .. _Marathon: https://github.com/mesosphere/marathon 12 | .. _REST API: https://github.com/mesosphere/marathon/blob/master/docs/docs/rest-api.md 13 | 14 | Project home: https://github.com/thefactory/marathon-python 15 | 16 | Contents: 17 | 18 | .. toctree:: 19 | :maxdepth: 4 20 | 21 | marathon 22 | 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\marathon.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\marathon.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/marathon.rst: -------------------------------------------------------------------------------- 1 | marathon package 2 | ================ 3 | 4 | marathon.models 5 | --------------- 6 | 7 | .. automodule:: marathon.models.app 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | .. automodule:: marathon.models.base 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. automodule:: marathon.models.constraint 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: marathon.models.container 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: marathon.models.deployment 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | .. automodule:: marathon.models.endpoint 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | .. automodule:: marathon.models.group 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | .. automodule:: marathon.models.info 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | .. automodule:: marathon.models.queue 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | .. automodule:: marathon.models.task 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | marathon.client 58 | --------------- 59 | 60 | .. automodule:: marathon.client 61 | :members: 62 | :undoc-members: 63 | :show-inheritance: 64 | 65 | marathon.exceptions 66 | ------------------- 67 | 68 | .. automodule:: marathon.exceptions 69 | :members: 70 | :undoc-members: 71 | :show-inheritance: 72 | 73 | 74 | Module contents 75 | --------------- 76 | 77 | .. automodule:: marathon 78 | :members: 79 | :undoc-members: 80 | :show-inheritance: 81 | -------------------------------------------------------------------------------- /itests/docker/.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | .DS_Store 56 | 57 | # IntelliJ 58 | .idea 59 | *.iml 60 | 61 | # Packer http://packer.io 62 | packer_cache 63 | 64 | /gh-pages 65 | itests/marathon-version 66 | .pytest_cache/ 67 | -------------------------------------------------------------------------------- /itests/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG MARATHONVERSION=v1.6.322 2 | FROM mesosphere/marathon:$MARATHONVERSION 3 | ARG MARATHONVERSION 4 | USER root 5 | 6 | # Setup 7 | ADD ./install-marathon.sh /root/install-marathon.sh 8 | RUN echo "MARATHONVERSION=${MARATHONVERSION}" > /root/marathon-version \ 9 | && /root/install-marathon.sh 10 | 11 | EXPOSE 8080 5050 12 | ADD ./start-marathon.sh /root/start-marathon.sh 13 | ENTRYPOINT [] 14 | CMD ["/root/start-marathon.sh"] 15 | -------------------------------------------------------------------------------- /itests/docker/README.md: -------------------------------------------------------------------------------- 1 | # mini-marathon 2 | 3 | **Note:** We currently only support the marathon versions listed in [.travis.yml](https://github.com/thefactory/marathon-python/blob/acffecd307c38c3512b77487e2e83806963c7a8d/.travis.yml#L2-L7) 4 | 5 | ## How to build 6 | 7 | ``` 8 | docker build --build-arg "MARATHONVERSION=v1.6.322" . 9 | ``` 10 | -------------------------------------------------------------------------------- /itests/docker/install-marathon.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | # Default version of marathon to test against if not set by the user 6 | [[ -f /root/marathon-version ]] && source /root/marathon-version 7 | MARATHONVERSION="${MARATHONVERSION:-v1.6.322}" 8 | 9 | export DEBIAN_FRONTEND=noninteractive 10 | 11 | shopt -s extglob 12 | 13 | case "${MARATHONVERSION}" in 14 | @(v1.10.19|v1.9.109)) 15 | echo "Marathon version ${MARATHONVERSION} needs no specific changes" 16 | apt update 17 | ;; 18 | v1.6.322) 19 | sed -i 's!deb http://ftp.debian.org/debian jessie-backports main!!g' /etc/apt/sources.list 20 | apt update 21 | apt install -y mesos=1.6.* 22 | ;; 23 | v1.4.11) 24 | sed -i 's!deb http://ftp.debian.org/debian jessie-backports main!!g' /etc/apt/sources.list 25 | apt update 26 | ;; 27 | @(v1.3.0|v1.1.2)) 28 | rm /etc/apt/sources.list.d/jessie-backports.list 29 | apt update 30 | ;; 31 | *) 32 | echo "Marathon version ${MARATHONVERSION} is not supported" 33 | exit 1 34 | ;; 35 | esac 36 | 37 | apt install -y --force-yes zookeeperd curl lsof 38 | rm -rf /var/log/apt/* /var/log/alternatives.log /var/log/bootstrap.log /var/log/dpkg.log 39 | -------------------------------------------------------------------------------- /itests/docker/start-marathon.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | LOGGER="--logging_level info" 6 | # Default version of marathon to test against if not set by the user 7 | [[ -f /root/marathon-version ]] && source /root/marathon-version 8 | MARATHONVERSION="${MARATHONVERSION:-v1.6.322}" 9 | 10 | shopt -s extglob 11 | 12 | case "${MARATHONVERSION}" in 13 | @(v1.4.11|v1.3.0|v1.1.2)) 14 | ln -sf /marathon/bin/start /marathon/bin/marathon 15 | ;; 16 | *) 17 | echo "Marathon version ${MARATHONVERSION} needs no specific changes" 18 | ;; 19 | esac 20 | 21 | java -version 22 | export MESOS_WORK_DIR='/tmp/mesos' 23 | export ZK_HOST=$(cat /etc/mesos/zk) 24 | 25 | mkdir -p "${MESOS_WORK_DIR}" 26 | /etc/init.d/zookeeper start 27 | nohup mesos-master --work_dir=/tmp/mesosmaster --zk=${ZK_HOST} --quorum=1 &> mesos-master.log & 28 | nohup /usr/bin/env MESOS_SYSTEMD_ENABLE_SUPPORT=false mesos-slave --master=${ZK_HOST} --work_dir=/tmp/mesosagent --launcher=posix &> mesos-agent.log & 29 | eval "bin/marathon --master ${ZK_HOST} ${LOGGER}" 30 | -------------------------------------------------------------------------------- /itests/environment.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from itest_utils import wait_for_marathon 4 | 5 | 6 | def before_all(context): 7 | wait_for_marathon() 8 | 9 | 10 | def after_scenario(context, scenario): 11 | """If a marathon client object exists in our context, delete any apps in Marathon and wait until they die.""" 12 | if context.client: 13 | while True: 14 | apps = context.client.list_apps() 15 | if not apps: 16 | break 17 | for app in apps: 18 | context.client.delete_app(app.id, force=True) 19 | time.sleep(0.5) 20 | while context.client.list_deployments(): 21 | time.sleep(0.5) 22 | -------------------------------------------------------------------------------- /itests/itest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | [[ -n $TRAVIS ]] || docker pull "missingcharacter/marathon-python:${MARATHONVERSION}" 6 | [[ -n $TRAVIS ]] || docker run --rm --name marathon-python -d -p 18080:8080 -p 15050:5050 "missingcharacter/marathon-python:${MARATHONVERSION}" 7 | behave "$@" 8 | [[ -n $TRAVIS ]] || docker kill marathon-python 9 | -------------------------------------------------------------------------------- /itests/itest_utils.py: -------------------------------------------------------------------------------- 1 | import errno 2 | from functools import wraps 3 | import os 4 | import signal 5 | import time 6 | 7 | import requests 8 | 9 | 10 | class TimeoutError(Exception): 11 | pass 12 | 13 | 14 | def timeout(seconds=10, error_message=os.strerror(errno.ETIME)): 15 | def decorator(func): 16 | def _handle_timeout(signum, frame): 17 | raise TimeoutError(error_message) 18 | 19 | def wrapper(*args, **kwargs): 20 | signal.signal(signal.SIGALRM, _handle_timeout) 21 | signal.alarm(seconds) 22 | try: 23 | result = func(*args, **kwargs) 24 | finally: 25 | signal.alarm(0) 26 | return result 27 | 28 | return wraps(func)(wrapper) 29 | 30 | return decorator 31 | 32 | 33 | @timeout(30) 34 | def wait_for_marathon(): 35 | """Blocks until marathon is up""" 36 | marathon_service = get_marathon_connection_string() 37 | while True: 38 | print('Connecting to marathon on %s' % marathon_service) 39 | try: 40 | response = requests.get( 41 | 'http://%s/ping' % marathon_service, timeout=2) 42 | except ( 43 | requests.exceptions.ConnectionError, 44 | requests.exceptions.Timeout, 45 | ): 46 | time.sleep(2) 47 | continue 48 | if response.status_code == 200: 49 | print("Marathon is up and running!") 50 | break 51 | 52 | 53 | def get_marathon_connection_string(): 54 | # only reliable way I can detect travis.. 55 | if '/travis/' in os.environ.get('PATH'): 56 | return 'localhost:8080' 57 | else: 58 | return "localhost:18080" 59 | -------------------------------------------------------------------------------- /itests/marathon_apps.feature: -------------------------------------------------------------------------------- 1 | Feature: marathon-python can create and list marathon apps 2 | 3 | Scenario: Metadata can be fetched 4 | Given a working marathon instance 5 | Then we get the marathon instance's info 6 | 7 | Scenario: Trivial apps can be deployed 8 | Given a working marathon instance 9 | When we create a trivial new app 10 | Then we should see the trivial app running via the marathon api 11 | 12 | Scenario: Complex apps can be deployed 13 | Given a working marathon instance 14 | When we create a complex new app 15 | Then we should see the complex app running via the marathon api 16 | -------------------------------------------------------------------------------- /itests/marathon_deployments.feature: -------------------------------------------------------------------------------- 1 | Feature: marathon-python read deployments 2 | 3 | Scenario: deployments can be read 4 | Given a working marathon instance 5 | When we create a trivial new app 6 | Then we should be able to see a deployment 7 | -------------------------------------------------------------------------------- /itests/marathon_tasks.feature: -------------------------------------------------------------------------------- 1 | Feature: marathon-python can operate marathon app tasks 2 | 3 | Scenario: App tasks can be listed 4 | Given a working marathon instance 5 | When we create a trivial new app 6 | And we wait the trivial app deployment finish 7 | Then we should be able to list tasks of the trivial app 8 | 9 | Scenario: App tasks can be killed 10 | Given a working marathon instance 11 | When we create a trivial new app 12 | And we wait the trivial app deployment finish 13 | Then we should be able to kill the tasks 14 | 15 | Scenario: A list of app tasks can be killed 16 | Given a working marathon instance 17 | When we create a trivial new app 18 | And we wait the trivial app deployment finish 19 | Then we should be able to kill the #0,1,2 tasks of the trivial app 20 | 21 | Scenario: Events can be listened in stream 22 | Given a working marathon instance 23 | When marathon version is greater than 0.11.0 24 | And we start listening for events 25 | And we create a trivial new app 26 | And we wait the trivial app deployment finish 27 | Then we should be able to kill the tasks 28 | And we should see list of events 29 | -------------------------------------------------------------------------------- /itests/steps/marathon_steps.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import multiprocessing 4 | from distutils.version import LooseVersion 5 | 6 | import marathon 7 | from behave import given, when, then 8 | 9 | from itest_utils import get_marathon_connection_string 10 | sys.path.append('../') 11 | 12 | 13 | @given('a working marathon instance') 14 | def working_marathon(context): 15 | """Adds a working marathon client as context.client for the purposes of 16 | interacting with it in the test.""" 17 | if not hasattr(context, 'client'): 18 | marathon_connection_string = "http://%s" % \ 19 | get_marathon_connection_string() 20 | context.client = marathon.MarathonClient(marathon_connection_string) 21 | 22 | 23 | @then('we get the marathon instance\'s info') 24 | def get_marathon_info(context): 25 | assert context.client.get_info() 26 | 27 | 28 | @when('we create a trivial new app') 29 | def create_trivial_new_app(context): 30 | context.client.create_app('test-trivial-app', marathon.MarathonApp( 31 | cmd='sleep 3600', mem=16, cpus=0.1, instances=5)) 32 | 33 | 34 | @then('we should be able to kill the tasks') 35 | def kill_a_task(context): 36 | time.sleep(5) 37 | app = context.client.get_app('test-trivial-app') 38 | tasks = app.tasks 39 | context.client.kill_task( 40 | app_id='test-trivial-app', task_id=tasks[0].id, scale=True) 41 | 42 | 43 | @when('we create a complex new app') 44 | def create_complex_new_app_with_unicode(context): 45 | app_config = { 46 | 'container': { 47 | 'type': 'DOCKER', 48 | 'docker': { 49 | 'portMappings': 50 | [{'protocol': 'tcp', 51 | 'name': 'myport', 52 | 'containerPort': 8888, 53 | 'hostPort': 0}], 54 | 'image': 'localhost/fake_docker_url', 55 | 'network': 'BRIDGE', 56 | 'parameters': [{'key': 'add-host', 'value': 'google-public-dns-a.google.com:8.8.8.8'}], 57 | }, 58 | 'volumes': 59 | [{'hostPath': '/etc/stuff', 60 | 'containerPath': '/etc/stuff', 61 | 'mode': 'RO'}], 62 | }, 63 | 'instances': 1, 64 | 'mem': 30, 65 | 'args': [], 66 | 'backoff_factor': 2, 67 | 'cpus': 0.25, 68 | 'uris': ['file:///root/.dockercfg'], 69 | 'backoff_seconds': 1, 70 | 'constraints': None, 71 | 'cmd': '/bin/true', 72 | 'health_checks': [ 73 | { 74 | 'protocol': 'HTTP', 75 | 'path': '/health', 76 | 'gracePeriodSeconds': 3, 77 | 'intervalSeconds': 10, 78 | 'portIndex': 0, 79 | 'timeoutSeconds': 10, 80 | 'maxConsecutiveFailures': 3 81 | }, 82 | ], 83 | } 84 | context.client.create_app( 85 | 'test-complex-app', marathon.MarathonApp(**app_config)) 86 | 87 | 88 | @then('we should see the {which} app running via the marathon api') 89 | def see_complext_app_running(context, which): 90 | print(context.client.list_apps()) 91 | assert context.client.get_app('test-%s-app' % which) 92 | 93 | 94 | @when('we wait the {which} app deployment finish') 95 | def wait_deployment_finish(context, which): 96 | while True: 97 | time.sleep(1) 98 | app = context.client.get_app('test-%s-app' % which, embed_tasks=True) 99 | if not app.deployments: 100 | break 101 | 102 | 103 | @then('we should be able to kill the #{to_kill} tasks of the {which} app') 104 | def kill_tasks(context, to_kill, which): 105 | app_tasks = context.client.get_app( 106 | 'test-%s-app' % which, embed_tasks=True).tasks 107 | 108 | index_to_kill = eval("[" + to_kill + "]") 109 | task_to_kill = [app_tasks[index].id for index in index_to_kill] 110 | 111 | context.client.kill_given_tasks(task_to_kill) 112 | 113 | 114 | @then('we should be able to list tasks of the {which} app') 115 | def list_tasks(context, which): 116 | app = context.client.get_app('test-%s-app' % which) 117 | tasks = context.client.list_tasks('test-%s-app' % which) 118 | assert len(tasks) == app.instances, "we defined {} tasks, got {} tasks".format(app.instances, len(tasks)) 119 | 120 | 121 | def listen_for_events(client, events): 122 | for msg in client.event_stream(): 123 | events.append(msg) 124 | 125 | 126 | @when('marathon version is greater than {version}') 127 | def marathon_version_chech(context, version): 128 | info = context.client.get_info() 129 | if LooseVersion(info.version) < LooseVersion(version): 130 | context.scenario.skip(reason='Marathon version is too low for this scenario') 131 | 132 | 133 | @when('we start listening for events') 134 | def start_listening_stream(context): 135 | manager = multiprocessing.Manager() 136 | mlist = manager.list() 137 | context.manager = manager 138 | context.events = mlist 139 | p = multiprocessing.Process(target=listen_for_events, args=(context.client, mlist)) 140 | p.start() 141 | context.p = p 142 | 143 | 144 | @then('we should see list of events') 145 | def stop_listening_stream(context): 146 | time.sleep(10) 147 | context.p.terminate() 148 | assert len(context.events) >= 1, "We had %d events: %s" % (len(context.events), context.events) 149 | 150 | 151 | @then('we should be able to see a deployment') 152 | def see_a_deployment(context): 153 | deployments = context.client.list_deployments() 154 | assert len(deployments) == 1, "We had %d deployments: %s" % (len(deployments), deployments) 155 | -------------------------------------------------------------------------------- /marathon/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import MarathonClient 2 | from .models import MarathonResource, MarathonApp, MarathonTask, MarathonConstraint 3 | from .exceptions import MarathonError, MarathonHttpError, NotFoundError, InvalidChoiceError 4 | from .util import get_log 5 | 6 | log = get_log() 7 | -------------------------------------------------------------------------------- /marathon/client.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import time 3 | 4 | try: 5 | import json 6 | except ImportError: 7 | import simplejson as json 8 | 9 | import requests 10 | import requests.exceptions 11 | from requests_toolbelt.adapters import socket_options 12 | 13 | import marathon 14 | from .models import MarathonApp, MarathonDeployment, MarathonGroup, MarathonInfo, MarathonTask, MarathonEndpoint, MarathonQueueItem 15 | from .exceptions import ConflictError, InternalServerError, NotFoundError, MarathonHttpError, MarathonError, NoResponseError 16 | from .models.base import assert_valid_path 17 | from .models.events import EventFactory, MarathonEvent 18 | from .util import MarathonJsonEncoder, MarathonMinimalJsonEncoder 19 | 20 | 21 | class MarathonClient: 22 | 23 | """Client interface for the Marathon REST API.""" 24 | 25 | def __init__(self, servers, username=None, password=None, timeout=10, session=None, 26 | auth_token=None, verify=True, sse_session=None): 27 | """Create a MarathonClient instance. 28 | 29 | If multiple servers are specified, each will be tried in succession until a non-"Connection Error"-type 30 | response is received. Servers are expected to have the same username and password. 31 | 32 | :param servers: One or a priority-ordered list of Marathon URLs (e.g., 'http://host:8080' or 33 | ['http://host1:8080','http://host2:8080']) 34 | :type servers: str or list[str] 35 | :param str username: Basic auth username 36 | :param str password: Basic auth password 37 | :param requests.session session: requests.session for reusing the connections 38 | :param int timeout: Timeout (in seconds) for requests to Marathon 39 | :param str auth_token: Token-based auth token, used with DCOS + Oauth 40 | :param bool verify: Enable SSL certificate verification 41 | :param requests.session sse_session: requests.session for event stream connections, which by default enables tcp keepalive 42 | """ 43 | if session is None: 44 | self.session = requests.Session() 45 | else: 46 | self.session = session 47 | if sse_session is None: 48 | self.sse_session = requests.Session() 49 | keep_alive = socket_options.TCPKeepAliveAdapter() 50 | self.sse_session.mount('http://', keep_alive) 51 | self.sse_session.mount('https://', keep_alive) 52 | else: 53 | self.sse_session = sse_session 54 | self.servers = servers if isinstance(servers, list) else [servers] 55 | self.auth = (username, password) if username and password else None 56 | self.verify = verify 57 | self.timeout = timeout 58 | 59 | self.auth_token = auth_token 60 | if self.auth and self.auth_token: 61 | raise ValueError("Can't specify both auth token and username/password. Must select " 62 | "one type of authentication.") 63 | 64 | def __repr__(self): 65 | return 'Connection:%s' % self.servers 66 | 67 | @staticmethod 68 | def _parse_response(response, clazz, is_list=False, resource_name=None): 69 | """Parse a Marathon response into an object or list of objects.""" 70 | target = response.json()[ 71 | resource_name] if resource_name else response.json() 72 | if is_list: 73 | return [clazz.from_json(resource) for resource in target] 74 | else: 75 | return clazz.from_json(target) 76 | 77 | def _do_request(self, method, path, params=None, data=None): 78 | """Query Marathon server.""" 79 | headers = { 80 | 'Content-Type': 'application/json', 'Accept': 'application/json'} 81 | 82 | if self.auth_token: 83 | headers['Authorization'] = f"token={self.auth_token}" 84 | 85 | response = None 86 | servers = list(self.servers) 87 | while servers and response is None: 88 | server = servers.pop(0) 89 | url = ''.join([server.rstrip('/'), path]) 90 | try: 91 | response = self.session.request( 92 | method, url, params=params, data=data, headers=headers, 93 | auth=self.auth, timeout=self.timeout, verify=self.verify) 94 | marathon.log.info('Got response from %s', server) 95 | except requests.exceptions.RequestException as e: 96 | marathon.log.error( 97 | 'Error while calling %s: %s', url, str(e)) 98 | 99 | if response is None: 100 | raise NoResponseError('No remaining Marathon servers to try') 101 | 102 | if response.status_code >= 500: 103 | marathon.log.error('Got HTTP {code}: {body}'.format( 104 | code=response.status_code, body=response.text.encode('utf-8'))) 105 | raise InternalServerError(response) 106 | elif response.status_code >= 400: 107 | marathon.log.error('Got HTTP {code}: {body}'.format( 108 | code=response.status_code, body=response.text.encode('utf-8'))) 109 | if response.status_code == 404: 110 | raise NotFoundError(response) 111 | elif response.status_code == 409: 112 | raise ConflictError(response) 113 | else: 114 | raise MarathonHttpError(response) 115 | elif response.status_code >= 300: 116 | marathon.log.warn('Got HTTP {code}: {body}'.format( 117 | code=response.status_code, body=response.text.encode('utf-8'))) 118 | else: 119 | marathon.log.debug('Got HTTP {code}: {body}'.format( 120 | code=response.status_code, body=response.text.encode('utf-8'))) 121 | 122 | return response 123 | 124 | def _do_sse_request(self, path, params=None): 125 | """Query Marathon server for events.""" 126 | urls = [''.join([server.rstrip('/'), path]) for server in self.servers] 127 | while urls: 128 | url = urls.pop() 129 | try: 130 | # Requests does not set the original Authorization header on cross origin 131 | # redirects. If set allow_redirects=True we may get a 401 response. 132 | response = self.sse_session.get( 133 | url, 134 | params=params, 135 | stream=True, 136 | headers={'Accept': 'text/event-stream'}, 137 | auth=self.auth, 138 | verify=self.verify, 139 | allow_redirects=False 140 | ) 141 | except Exception as e: 142 | marathon.log.error( 143 | 'Error while calling %s: %s', url, e.message) 144 | else: 145 | if response.is_redirect and response.next: 146 | urls.append(response.next.url) 147 | marathon.log.debug(f"Got redirect to {response.next.url}") 148 | elif response.ok: 149 | return response.iter_lines() 150 | 151 | raise MarathonError('No remaining Marathon servers to try') 152 | 153 | def list_endpoints(self): 154 | """List the current endpoints for all applications 155 | 156 | :returns: list of endpoints 157 | :rtype: list[`MarathonEndpoint`] 158 | """ 159 | return MarathonEndpoint.from_tasks(self.list_tasks()) 160 | 161 | def create_app(self, app_id, app, minimal=True): 162 | """Create and start an app. 163 | 164 | :param str app_id: application ID 165 | :param :class:`marathon.models.app.MarathonApp` app: the application to create 166 | :param bool minimal: ignore nulls and empty collections 167 | 168 | :returns: the created app (on success) 169 | :rtype: :class:`marathon.models.app.MarathonApp` or False 170 | """ 171 | app.id = assert_valid_path(app_id) 172 | data = app.to_json(minimal=minimal) 173 | marathon.log.debug('create app JSON sent: {}'.format(data)) 174 | response = self._do_request('POST', '/v2/apps', data=data) 175 | if response.status_code == 201: 176 | return self._parse_response(response, MarathonApp) 177 | else: 178 | return False 179 | 180 | def list_apps(self, cmd=None, embed_tasks=False, embed_counts=False, 181 | embed_deployments=False, embed_readiness=False, 182 | embed_last_task_failure=False, embed_failures=False, 183 | embed_task_stats=False, app_id=None, label=None, **kwargs): 184 | """List all apps. 185 | 186 | :param str cmd: if passed, only show apps with a matching `cmd` 187 | :param bool embed_tasks: embed tasks in result 188 | :param bool embed_counts: embed all task counts 189 | :param bool embed_deployments: embed all deployment identifier 190 | :param bool embed_readiness: embed all readiness check results 191 | :param bool embed_last_task_failure: embeds the last task failure 192 | :param bool embed_failures: shorthand for embed_last_task_failure 193 | :param bool embed_task_stats: embed task stats in result 194 | :param str app_id: if passed, only show apps with an 'id' that matches or contains this value 195 | :param str label: if passed, only show apps with the selected labels 196 | :param kwargs: arbitrary search filters 197 | 198 | :returns: list of applications 199 | :rtype: list[:class:`marathon.models.app.MarathonApp`] 200 | """ 201 | params = {} 202 | if cmd: 203 | params['cmd'] = cmd 204 | if app_id: 205 | params['id'] = app_id 206 | if label: 207 | params['label'] = label 208 | 209 | embed_params = { 210 | 'app.tasks': embed_tasks, 211 | 'app.counts': embed_counts, 212 | 'app.deployments': embed_deployments, 213 | 'app.readiness': embed_readiness, 214 | 'app.lastTaskFailure': embed_last_task_failure, 215 | 'app.failures': embed_failures, 216 | 'app.taskStats': embed_task_stats 217 | } 218 | filtered_embed_params = [k for (k, v) in embed_params.items() if v] 219 | if filtered_embed_params: 220 | params['embed'] = filtered_embed_params 221 | 222 | response = self._do_request('GET', '/v2/apps', params=params) 223 | apps = self._parse_response( 224 | response, MarathonApp, is_list=True, resource_name='apps') 225 | for k, v in kwargs.items(): 226 | apps = [o for o in apps if getattr(o, k) == v] 227 | return apps 228 | 229 | def get_app(self, app_id, embed_tasks=False, embed_counts=False, 230 | embed_deployments=False, embed_readiness=False, 231 | embed_last_task_failure=False, embed_failures=False, 232 | embed_task_stats=False): 233 | """Get a single app. 234 | 235 | :param str app_id: application ID 236 | :param bool embed_tasks: embed tasks in result 237 | :param bool embed_counts: embed all task counts 238 | :param bool embed_deployments: embed all deployment identifier 239 | :param bool embed_readiness: embed all readiness check results 240 | :param bool embed_last_task_failure: embeds the last task failure 241 | :param bool embed_failures: shorthand for embed_last_task_failure 242 | :param bool embed_task_stats: embed task stats in result 243 | 244 | :returns: application 245 | :rtype: :class:`marathon.models.app.MarathonApp` 246 | """ 247 | params = {} 248 | embed_params = { 249 | 'app.tasks': embed_tasks, 250 | 'app.counts': embed_counts, 251 | 'app.deployments': embed_deployments, 252 | 'app.readiness': embed_readiness, 253 | 'app.lastTaskFailure': embed_last_task_failure, 254 | 'app.failures': embed_failures, 255 | 'app.taskStats': embed_task_stats 256 | } 257 | filtered_embed_params = [k for (k, v) in embed_params.items() if v] 258 | if filtered_embed_params: 259 | params['embed'] = filtered_embed_params 260 | 261 | response = self._do_request( 262 | 'GET', f'/v2/apps/{app_id}', params=params) 263 | return self._parse_response(response, MarathonApp, resource_name='app') 264 | 265 | def restart_app(self, app_id, force=False): 266 | """ 267 | Restarts given application by app_id 268 | :param str app_id: application ID 269 | :param bool force: apply even if a deployment is in progress 270 | :returns: a dict containing the deployment id and version 271 | :rtype: dict 272 | """ 273 | params = {'force': force} 274 | response = self._do_request( 275 | 'POST', f'/v2/apps/{app_id}/restart', params=params) 276 | return response.json() 277 | 278 | def update_app(self, app_id, app, force=False, minimal=True): 279 | """Update an app. 280 | 281 | Applies writable settings in `app` to `app_id` 282 | Note: this method can not be used to rename apps. 283 | 284 | :param str app_id: target application ID 285 | :param app: application settings 286 | :type app: :class:`marathon.models.app.MarathonApp` 287 | :param bool force: apply even if a deployment is in progress 288 | :param bool minimal: ignore nulls and empty collections 289 | 290 | :returns: a dict containing the deployment id and version 291 | :rtype: dict 292 | """ 293 | # Changes won't take if version is set - blank it for convenience 294 | app.version = None 295 | 296 | params = {'force': force} 297 | data = app.to_json(minimal=minimal) 298 | 299 | response = self._do_request( 300 | 'PUT', f'/v2/apps/{app_id}', params=params, data=data) 301 | return response.json() 302 | 303 | def update_apps(self, apps, force=False, minimal=True): 304 | """Update multiple apps. 305 | 306 | Applies writable settings in elements of apps either by upgrading existing ones or creating new ones 307 | 308 | :param apps: sequence of application settings 309 | :param bool force: apply even if a deployment is in progress 310 | :param bool minimal: ignore nulls and empty collections 311 | 312 | :returns: a dict containing the deployment id and version 313 | :rtype: dict 314 | """ 315 | json_repr_apps = [] 316 | for app in apps: 317 | # Changes won't take if version is set - blank it for convenience 318 | app.version = None 319 | json_repr_apps.append(app.json_repr(minimal=minimal)) 320 | 321 | params = {'force': force} 322 | encoder = MarathonMinimalJsonEncoder if minimal else MarathonJsonEncoder 323 | data = json.dumps(json_repr_apps, cls=encoder, sort_keys=True) 324 | 325 | response = self._do_request( 326 | 'PUT', '/v2/apps', params=params, data=data) 327 | return response.json() 328 | 329 | def rollback_app(self, app_id, version, force=False): 330 | """Roll an app back to a previous version. 331 | 332 | :param str app_id: application ID 333 | :param str version: application version 334 | :param bool force: apply even if a deployment is in progress 335 | 336 | :returns: a dict containing the deployment id and version 337 | :rtype: dict 338 | """ 339 | params = {'force': force} 340 | data = json.dumps({'version': version}) 341 | response = self._do_request( 342 | 'PUT', f'/v2/apps/{app_id}', params=params, data=data) 343 | return response.json() 344 | 345 | def delete_app(self, app_id, force=False): 346 | """Stop and destroy an app. 347 | 348 | :param str app_id: application ID 349 | :param bool force: apply even if a deployment is in progress 350 | 351 | :returns: a dict containing the deployment id and version 352 | :rtype: dict 353 | """ 354 | params = {'force': force} 355 | response = self._do_request( 356 | 'DELETE', f'/v2/apps/{app_id}', params=params) 357 | return response.json() 358 | 359 | def scale_app(self, app_id, instances=None, delta=None, force=False): 360 | """Scale an app. 361 | 362 | Scale an app to a target number of instances (with `instances`), or scale the number of 363 | instances up or down by some delta (`delta`). If the resulting number of instances would be negative, 364 | desired instances will be set to zero. 365 | 366 | If both `instances` and `delta` are passed, use `instances`. 367 | 368 | :param str app_id: application ID 369 | :param int instances: [optional] the number of instances to scale to 370 | :param int delta: [optional] the number of instances to scale up or down by 371 | :param bool force: apply even if a deployment is in progress 372 | 373 | :returns: a dict containing the deployment id and version 374 | :rtype: dict 375 | """ 376 | if instances is None and delta is None: 377 | marathon.log.error('instances or delta must be passed') 378 | return 379 | 380 | try: 381 | app = self.get_app(app_id) 382 | except NotFoundError: 383 | marathon.log.error(f'App "{app_id}" not found') 384 | return 385 | 386 | desired = instances if instances is not None else ( 387 | app.instances + delta) 388 | return self.update_app(app.id, MarathonApp(instances=desired), force=force) 389 | 390 | def create_group(self, group): 391 | """Create and start a group. 392 | 393 | :param :class:`marathon.models.group.MarathonGroup` group: the group to create 394 | 395 | :returns: success 396 | :rtype: dict containing the version ID 397 | """ 398 | data = group.to_json() 399 | response = self._do_request('POST', '/v2/groups', data=data) 400 | return response.json() 401 | 402 | def list_groups(self, **kwargs): 403 | """List all groups. 404 | 405 | :param kwargs: arbitrary search filters 406 | 407 | :returns: list of groups 408 | :rtype: list[:class:`marathon.models.group.MarathonGroup`] 409 | """ 410 | response = self._do_request('GET', '/v2/groups') 411 | groups = self._parse_response( 412 | response, MarathonGroup, is_list=True, resource_name='groups') 413 | for k, v in kwargs.items(): 414 | groups = [o for o in groups if getattr(o, k) == v] 415 | return groups 416 | 417 | def get_group(self, group_id): 418 | """Get a single group. 419 | 420 | :param str group_id: group ID 421 | 422 | :returns: group 423 | :rtype: :class:`marathon.models.group.MarathonGroup` 424 | """ 425 | response = self._do_request( 426 | 'GET', f'/v2/groups/{group_id}') 427 | return self._parse_response(response, MarathonGroup) 428 | 429 | def update_group(self, group_id, group, force=False, minimal=True): 430 | """Update a group. 431 | 432 | Applies writable settings in `group` to `group_id` 433 | Note: this method can not be used to rename groups. 434 | 435 | :param str group_id: target group ID 436 | :param group: group settings 437 | :type group: :class:`marathon.models.group.MarathonGroup` 438 | :param bool force: apply even if a deployment is in progress 439 | :param bool minimal: ignore nulls and empty collections 440 | 441 | :returns: a dict containing the deployment id and version 442 | :rtype: dict 443 | """ 444 | # Changes won't take if version is set - blank it for convenience 445 | group.version = None 446 | 447 | params = {'force': force} 448 | data = group.to_json(minimal=minimal) 449 | 450 | response = self._do_request( 451 | 'PUT', f'/v2/groups/{group_id}', data=data, params=params) 452 | return response.json() 453 | 454 | def rollback_group(self, group_id, version, force=False): 455 | """Roll a group back to a previous version. 456 | 457 | :param str group_id: group ID 458 | :param str version: group version 459 | :param bool force: apply even if a deployment is in progress 460 | 461 | :returns: a dict containing the deployment id and version 462 | :rtype: dict 463 | """ 464 | params = {'force': force} 465 | response = self._do_request( 466 | 'PUT', 467 | '/v2/groups/{group_id}/versions/{version}'.format( 468 | group_id=group_id, version=version), 469 | params=params) 470 | return response.json() 471 | 472 | def delete_group(self, group_id, force=False): 473 | """Stop and destroy a group. 474 | 475 | :param str group_id: group ID 476 | :param bool force: apply even if a deployment is in progress 477 | 478 | :returns: a dict containing the deleted version 479 | :rtype: dict 480 | """ 481 | params = {'force': force} 482 | response = self._do_request( 483 | 'DELETE', f'/v2/groups/{group_id}', params=params) 484 | return response.json() 485 | 486 | def scale_group(self, group_id, scale_by): 487 | """Scale a group by a factor. 488 | 489 | :param str group_id: group ID 490 | :param int scale_by: factor to scale by 491 | 492 | :returns: a dict containing the deployment id and version 493 | :rtype: dict 494 | """ 495 | data = {'scaleBy': scale_by} 496 | response = self._do_request( 497 | 'PUT', f'/v2/groups/{group_id}', data=json.dumps(data)) 498 | return response.json() 499 | 500 | def list_tasks(self, app_id=None, **kwargs): 501 | """List running tasks, optionally filtered by app_id. 502 | 503 | :param str app_id: if passed, only show tasks for this application 504 | :param kwargs: arbitrary search filters 505 | 506 | :returns: list of tasks 507 | :rtype: list[:class:`marathon.models.task.MarathonTask`] 508 | """ 509 | response = self._do_request( 510 | 'GET', '/v2/apps/%s/tasks' % app_id if app_id else '/v2/tasks') 511 | tasks = self._parse_response( 512 | response, MarathonTask, is_list=True, resource_name='tasks') 513 | [setattr(t, 'app_id', app_id) 514 | for t in tasks if app_id and t.app_id is None] 515 | for k, v in kwargs.items(): 516 | tasks = [o for o in tasks if getattr(o, k) == v] 517 | 518 | return tasks 519 | 520 | def kill_given_tasks(self, task_ids, scale=False, force=None): 521 | """Kill a list of given tasks. 522 | 523 | :param list[str] task_ids: tasks to kill 524 | :param bool scale: if true, scale down the app by the number of tasks killed 525 | :param bool force: if true, ignore any current running deployments 526 | 527 | :return: True on success 528 | :rtype: bool 529 | """ 530 | params = {'scale': scale} 531 | if force is not None: 532 | params['force'] = force 533 | data = json.dumps({"ids": task_ids}) 534 | response = self._do_request( 535 | 'POST', '/v2/tasks/delete', params=params, data=data) 536 | return response.status_code == 200 537 | 538 | def kill_tasks(self, app_id, scale=False, wipe=False, 539 | host=None, batch_size=0, batch_delay=0): 540 | """Kill all tasks belonging to app. 541 | 542 | :param str app_id: application ID 543 | :param bool scale: if true, scale down the app by the number of tasks killed 544 | :param str host: if provided, only terminate tasks on this Mesos slave 545 | :param int batch_size: if non-zero, terminate tasks in groups of this size 546 | :param int batch_delay: time (in seconds) to wait in between batched kills. If zero, automatically determine 547 | 548 | :returns: list of killed tasks 549 | :rtype: list[:class:`marathon.models.task.MarathonTask`] 550 | """ 551 | def batch(iterable, size): 552 | sourceiter = iter(iterable) 553 | while True: 554 | batchiter = itertools.islice(sourceiter, size) 555 | yield itertools.chain([next(batchiter)], batchiter) 556 | 557 | if batch_size == 0: 558 | # Terminate all at once 559 | params = {'scale': scale, 'wipe': wipe} 560 | if host: 561 | params['host'] = host 562 | response = self._do_request( 563 | 'DELETE', f'/v2/apps/{app_id}/tasks', params) 564 | # Marathon is inconsistent about what type of object it returns on the multi 565 | # task deletion endpoint, depending on the version of Marathon. See: 566 | # https://github.com/mesosphere/marathon/blob/06a6f763a75fb6d652b4f1660685ae234bd15387/src/main/scala/mesosphere/marathon/api/v2/AppTasksResource.scala#L88-L95 567 | if "tasks" in response.json(): 568 | return self._parse_response(response, MarathonTask, is_list=True, resource_name='tasks') 569 | else: 570 | return response.json() 571 | else: 572 | # Terminate in batches 573 | tasks = self.list_tasks( 574 | app_id, host=host) if host else self.list_tasks(app_id) 575 | for tbatch in batch(tasks, batch_size): 576 | killed_tasks = [self.kill_task(app_id, t.id, scale=scale, wipe=wipe) 577 | for t in tbatch] 578 | 579 | # Pause until the tasks have been killed to avoid race 580 | # conditions 581 | killed_task_ids = {t.id for t in killed_tasks} 582 | running_task_ids = killed_task_ids 583 | while killed_task_ids.intersection(running_task_ids): 584 | time.sleep(1) 585 | running_task_ids = { 586 | t.id for t in self.get_app(app_id).tasks} 587 | 588 | if batch_delay == 0: 589 | # Pause until the replacement tasks are healthy 590 | desired_instances = self.get_app(app_id).instances 591 | running_instances = 0 592 | while running_instances < desired_instances: 593 | time.sleep(1) 594 | running_instances = sum( 595 | t.started_at is None for t in self.get_app(app_id).tasks) 596 | else: 597 | time.sleep(batch_delay) 598 | 599 | return tasks 600 | 601 | def kill_task(self, app_id, task_id, scale=False, wipe=False): 602 | """Kill a task. 603 | 604 | :param str app_id: application ID 605 | :param str task_id: the task to kill 606 | :param bool scale: if true, scale down the app by one if the task exists 607 | 608 | :returns: the killed task 609 | :rtype: :class:`marathon.models.task.MarathonTask` 610 | """ 611 | params = {'scale': scale, 'wipe': wipe} 612 | response = self._do_request('DELETE', '/v2/apps/{app_id}/tasks/{task_id}' 613 | .format(app_id=app_id, task_id=task_id), params) 614 | # Marathon is inconsistent about what type of object it returns on the multi 615 | # task deletion endpoint, depending on the version of Marathon. See: 616 | # https://github.com/mesosphere/marathon/blob/06a6f763a75fb6d652b4f1660685ae234bd15387/src/main/scala/mesosphere/marathon/api/v2/AppTasksResource.scala#L88-L95 617 | if "task" in response.json(): 618 | return self._parse_response(response, MarathonTask, is_list=False, resource_name='task') 619 | else: 620 | return response.json() 621 | 622 | def list_versions(self, app_id): 623 | """List the versions of an app. 624 | 625 | :param str app_id: application ID 626 | 627 | :returns: list of versions 628 | :rtype: list[str] 629 | """ 630 | response = self._do_request( 631 | 'GET', f'/v2/apps/{app_id}/versions') 632 | return [version for version in response.json()['versions']] 633 | 634 | def get_version(self, app_id, version): 635 | """Get the configuration of an app at a specific version. 636 | 637 | :param str app_id: application ID 638 | :param str version: application version 639 | 640 | :return: application configuration 641 | :rtype: :class:`marathon.models.app.MarathonApp` 642 | """ 643 | response = self._do_request('GET', '/v2/apps/{app_id}/versions/{version}' 644 | .format(app_id=app_id, version=version)) 645 | return MarathonApp.from_json(response.json()) 646 | 647 | def list_event_subscriptions(self): 648 | """List the event subscriber callback URLs. 649 | 650 | :returns: list of callback URLs 651 | :rtype: list[str] 652 | """ 653 | response = self._do_request('GET', '/v2/eventSubscriptions') 654 | return [url for url in response.json()['callbackUrls']] 655 | 656 | def create_event_subscription(self, url): 657 | """Register a callback URL as an event subscriber. 658 | 659 | :param str url: callback URL 660 | 661 | :returns: the created event subscription 662 | :rtype: dict 663 | """ 664 | params = {'callbackUrl': url} 665 | response = self._do_request('POST', '/v2/eventSubscriptions', params) 666 | return response.json() 667 | 668 | def delete_event_subscription(self, url): 669 | """Deregister a callback URL as an event subscriber. 670 | 671 | :param str url: callback URL 672 | 673 | :returns: the deleted event subscription 674 | :rtype: dict 675 | """ 676 | params = {'callbackUrl': url} 677 | response = self._do_request('DELETE', '/v2/eventSubscriptions', params) 678 | return response.json() 679 | 680 | def list_deployments(self): 681 | """List all running deployments. 682 | 683 | :returns: list of deployments 684 | :rtype: list[:class:`marathon.models.deployment.MarathonDeployment`] 685 | """ 686 | response = self._do_request('GET', '/v2/deployments') 687 | return self._parse_response(response, MarathonDeployment, is_list=True) 688 | 689 | def list_queue(self, embed_last_unused_offers=False): 690 | """List all the tasks queued up or waiting to be scheduled. 691 | 692 | :returns: list of queue items 693 | :rtype: list[:class:`marathon.models.queue.MarathonQueueItem`] 694 | """ 695 | if embed_last_unused_offers: 696 | params = {'embed': 'lastUnusedOffers'} 697 | else: 698 | params = {} 699 | response = self._do_request('GET', '/v2/queue', params=params) 700 | return self._parse_response(response, MarathonQueueItem, is_list=True, resource_name='queue') 701 | 702 | def delete_deployment(self, deployment_id, force=False): 703 | """Cancel a deployment. 704 | 705 | :param str deployment_id: deployment id 706 | :param bool force: if true, don't create a rollback deployment to restore the previous configuration 707 | 708 | :returns: a dict containing the deployment id and version (empty dict if force=True) 709 | :rtype: dict 710 | """ 711 | if force: 712 | params = {'force': True} 713 | self._do_request('DELETE', '/v2/deployments/{deployment}'.format( 714 | deployment=deployment_id), params=params) 715 | # Successful DELETE with ?force=true returns empty text (and status 716 | # code 202). Client code should poll until deployment is removed. 717 | return {} 718 | else: 719 | response = self._do_request( 720 | 'DELETE', f'/v2/deployments/{deployment_id}') 721 | return response.json() 722 | 723 | def reset_delay(self, app_id): 724 | self._do_request( 725 | "DELETE", f'/v2/queue/{app_id}/delay' 726 | ) 727 | 728 | def get_info(self): 729 | """Get server configuration information. 730 | 731 | :returns: server config info 732 | :rtype: :class:`marathon.models.info.MarathonInfo` 733 | """ 734 | response = self._do_request('GET', '/v2/info') 735 | return self._parse_response(response, MarathonInfo) 736 | 737 | def get_leader(self): 738 | """Get the current marathon leader. 739 | 740 | :returns: leader endpoint 741 | :rtype: dict 742 | """ 743 | response = self._do_request('GET', '/v2/leader') 744 | return response.json() 745 | 746 | def delete_leader(self): 747 | """Causes the current leader to abdicate, triggers a new election. 748 | 749 | :returns: message saying leader abdicated 750 | :rtype: dict 751 | """ 752 | response = self._do_request('DELETE', '/v2/leader') 753 | return response.json() 754 | 755 | def ping(self): 756 | """Ping the Marathon server. 757 | 758 | :returns: the text response 759 | :rtype: str 760 | """ 761 | response = self._do_request('GET', '/ping') 762 | return response.text.encode('utf-8') 763 | 764 | def get_metrics(self): 765 | """Get server metrics 766 | 767 | :returns: metrics dict 768 | :rtype: dict 769 | """ 770 | response = self._do_request('GET', '/metrics') 771 | return response.json() 772 | 773 | def event_stream(self, raw=False, event_types=None): 774 | """Polls event bus using /v2/events 775 | 776 | :param bool raw: if true, yield raw event text, else yield MarathonEvent object 777 | :param event_types: a list of event types to consume 778 | :type event_types: list[type] or list[str] 779 | :returns: iterator with events 780 | :rtype: iterator 781 | """ 782 | 783 | ef = EventFactory() 784 | 785 | params = { 786 | 'event_type': [ 787 | EventFactory.class_to_event[et] if isinstance( 788 | et, type) and issubclass(et, MarathonEvent) else et 789 | for et in event_types or [] 790 | ] 791 | } 792 | 793 | for raw_message in self._do_sse_request('/v2/events', params=params): 794 | try: 795 | _data = raw_message.decode('utf8').split(':', 1) 796 | 797 | if _data[0] == 'data': 798 | if raw: 799 | yield _data[1] 800 | else: 801 | event_data = json.loads(_data[1].strip()) 802 | if 'eventType' not in event_data: 803 | raise MarathonError('Invalid event data received.') 804 | yield ef.process(event_data) 805 | except ValueError: 806 | raise MarathonError('Invalid event data received.') 807 | -------------------------------------------------------------------------------- /marathon/exceptions.py: -------------------------------------------------------------------------------- 1 | class MarathonError(Exception): 2 | pass 3 | 4 | 5 | class MarathonHttpError(MarathonError): 6 | 7 | def __init__(self, response): 8 | """ 9 | :param :class:`requests.Response` response: HTTP response 10 | """ 11 | self.error_message = response.reason or '' 12 | if response.content and 'application/json' in response.headers.get('content-type', ''): 13 | content = response.json() 14 | self.error_message = content.get('message', self.error_message) 15 | self.error_details = content.get('details') 16 | self.status_code = response.status_code 17 | super().__init__(self.__str__()) 18 | 19 | def __repr__(self): 20 | return 'MarathonHttpError: HTTP %s returned with message, "%s"' % \ 21 | (self.status_code, self.error_message) 22 | 23 | def __str__(self): 24 | return self.__repr__() 25 | 26 | 27 | class NotFoundError(MarathonHttpError): 28 | pass 29 | 30 | 31 | class InternalServerError(MarathonHttpError): 32 | pass 33 | 34 | 35 | class ConflictError(MarathonHttpError): 36 | pass 37 | 38 | 39 | class InvalidChoiceError(MarathonError): 40 | 41 | def __init__(self, param, value, options): 42 | super().__init__( 43 | 'Invalid choice "{value}" for param "{param}". Must be one of {options}'.format( 44 | param=param, value=value, options=options 45 | ) 46 | ) 47 | 48 | 49 | class NoResponseError(MarathonError): 50 | pass 51 | -------------------------------------------------------------------------------- /marathon/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import MarathonApp, MarathonHealthCheck 2 | from .base import MarathonResource, MarathonObject 3 | from .constraint import MarathonConstraint 4 | from .deployment import MarathonDeployment, MarathonDeploymentAction, MarathonDeploymentStep 5 | from .endpoint import MarathonEndpoint 6 | from .group import MarathonGroup 7 | from .info import MarathonInfo, MarathonConfig, MarathonZooKeeperConfig 8 | from .queue import MarathonQueueItem 9 | from .task import MarathonTask 10 | -------------------------------------------------------------------------------- /marathon/models/app.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import InvalidChoiceError 2 | from .base import MarathonResource, MarathonObject, assert_valid_path 3 | from .constraint import MarathonConstraint 4 | from .container import MarathonContainer 5 | from .deployment import MarathonDeployment 6 | from .task import MarathonTask 7 | from ..util import get_log 8 | from ..util import to_datetime 9 | 10 | log = get_log() 11 | 12 | 13 | class MarathonApp(MarathonResource): 14 | 15 | """Marathon Application resource. 16 | 17 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#post-/v2/apps 18 | 19 | :param list[str] accepted_resource_roles: a list of resource roles (the resource offer 20 | must contain at least one of these for the app 21 | to be launched on that host) 22 | :param list[str] args: args form of the command to run 23 | :param int backoff_factor: multiplier for subsequent backoff 24 | :param int backoff_seconds: base time, in seconds, for exponential backoff 25 | :param str cmd: cmd form of the command to run 26 | :param constraints: placement constraints 27 | :type constraints: list[:class:`marathon.models.constraint.MarathonConstraint`] or list[tuple] 28 | :param container: container info 29 | :type container: :class:`marathon.models.container.MarathonContainer` or dict 30 | :param float cpus: cpus required per instance 31 | :param list[str] dependencies: services (app IDs) on which this app depends 32 | :param int disk: disk required per instance 33 | :param deployments: (read-only) currently running deployments that affect this app 34 | :type deployments: list[:class:`marathon.models.deployment.MarathonDeployment`] 35 | :param dict env: env vars 36 | :param str executor: executor 37 | :param int gpus: gpus required per instance 38 | :param health_checks: health checks 39 | :type health_checks: list[:class:`marathon.models.MarathonHealthCheck`] or list[dict] 40 | :param str id: app id 41 | :param str role: mesos role 42 | :param int instances: instances 43 | :param last_task_failure: last task failure 44 | :type last_task_failure: :class:`marathon.models.app.MarathonTaskFailure` or dict 45 | :param float mem: memory (in MB) required per instance 46 | :param dict secrets: A map with named secret declarations. 47 | :type port_definitions: list[:class:`marathon.models.app.PortDefinitions`] or list[dict] 48 | :param list[int] ports: ports 49 | :param bool require_ports: require the specified `ports` to be available in the resource offer 50 | :param list[str] store_urls: store URLs 51 | :param float task_rate_limit: (Removed in Marathon 0.7.0) maximum number of tasks launched per second 52 | :param tasks: (read-only) tasks 53 | :type tasks: list[:class:`marathon.models.task.MarathonTask`] 54 | :param int tasks_running: (read-only) the number of running tasks 55 | :param int tasks_staged: (read-only) the number of staged tasks 56 | :param int tasks_healthy: (read-only) the number of healthy tasks 57 | :param int tasks_unhealthy: (read-only) the number of unhealthy tasks 58 | :param upgrade_strategy: strategy by which app instances are replaced during a deployment 59 | :type upgrade_strategy: :class:`marathon.models.app.MarathonUpgradeStrategy` or dict 60 | :param list[str] uris: uris 61 | :param str user: user 62 | :param str version: version id 63 | :param version_info: time of last scaling, last config change 64 | :type version_info: :class:`marathon.models.app.MarathonAppVersionInfo` or dict 65 | :param task_stats: task statistics 66 | :type task_stats: :class:`marathon.models.app.MarathonTaskStats` or dict 67 | :param dict labels 68 | :type readiness_checks: list[:class:`marathon.models.app.ReadinessCheck`] or list[dict] 69 | :type residency: :class:`marathon.models.app.Residency` or dict 70 | :param int task_kill_grace_period_seconds: Configures the termination signal escalation behavior of executors when stopping tasks. 71 | :param list[dict] unreachable_strategy: Handling for unreachable instances. 72 | :param str kill_selection: Defines which instance should be killed first in case of e.g. rescaling. 73 | """ 74 | 75 | UPDATE_OK_ATTRIBUTES = [ 76 | 'args', 'backoff_factor', 'backoff_seconds', 'cmd', 'constraints', 'container', 'cpus', 'dependencies', 'disk', 77 | 'env', 'executor', 'gpus', 'health_checks', 'instances', 'kill_selection', 'labels', 'max_launch_delay_seconds', 78 | 'mem', 'ports', 'require_ports', 'store_urls', 'task_rate_limit', 'upgrade_strategy', 'unreachable_strategy', 79 | 'uris', 'user', 'version', 'role' 80 | ] 81 | """List of attributes which may be updated/changed after app creation""" 82 | 83 | CREATE_ONLY_ATTRIBUTES = ['id', 'accepted_resource_roles'] 84 | """List of attributes that should only be passed on creation""" 85 | 86 | READ_ONLY_ATTRIBUTES = [ 87 | 'deployments', 'tasks', 'tasks_running', 'tasks_staged', 'tasks_healthy', 'tasks_unhealthy'] 88 | """List of read-only attributes""" 89 | 90 | KILL_SELECTIONS = ["YOUNGEST_FIRST", "OLDEST_FIRST"] 91 | 92 | def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=None, backoff_seconds=None, cmd=None, 93 | constraints=None, container=None, cpus=None, dependencies=None, deployments=None, disk=None, env=None, 94 | executor=None, health_checks=None, id=None, role=None, instances=None, kill_selection=None, labels=None, 95 | last_task_failure=None, max_launch_delay_seconds=None, mem=None, ports=None, require_ports=None, 96 | store_urls=None, task_rate_limit=None, tasks=None, tasks_running=None, tasks_staged=None, 97 | tasks_healthy=None, task_kill_grace_period_seconds=None, tasks_unhealthy=None, upgrade_strategy=None, 98 | unreachable_strategy=None, uris=None, user=None, version=None, version_info=None, 99 | ip_address=None, fetch=None, task_stats=None, readiness_checks=None, 100 | readiness_check_results=None, secrets=None, port_definitions=None, residency=None, gpus=None, networks=None): 101 | 102 | # self.args = args or [] 103 | self.accepted_resource_roles = accepted_resource_roles 104 | self.args = args 105 | # Marathon 0.7.0-RC1 throws a validation error if this is [] and cmd is passed: 106 | # "error": "AppDefinition must either contain a 'cmd' or a 'container'." 107 | 108 | self.backoff_factor = backoff_factor 109 | self.backoff_seconds = backoff_seconds 110 | self.cmd = cmd 111 | self.constraints = [ 112 | c if isinstance(c, MarathonConstraint) else MarathonConstraint(*c) 113 | for c in (constraints or []) 114 | ] 115 | self.container = container if (isinstance(container, MarathonContainer) or container is None) \ 116 | else MarathonContainer.from_json(container) 117 | self.cpus = cpus 118 | self.dependencies = dependencies or [] 119 | self.deployments = [ 120 | d if isinstance( 121 | d, MarathonDeployment) else MarathonDeployment().from_json(d) 122 | for d in (deployments or []) 123 | ] 124 | self.disk = disk 125 | self.env = env or dict() 126 | self.executor = executor 127 | self.gpus = gpus 128 | self.health_checks = health_checks or [] 129 | self.health_checks = [ 130 | hc if isinstance( 131 | hc, MarathonHealthCheck) else MarathonHealthCheck().from_json(hc) 132 | for hc in (health_checks or []) 133 | ] 134 | self.id = assert_valid_path(id) 135 | self.role = role 136 | self.instances = instances 137 | if kill_selection and kill_selection not in self.KILL_SELECTIONS: 138 | raise InvalidChoiceError( 139 | 'kill_selection', kill_selection, self.KILL_SELECTIONS) 140 | self.kill_selection = kill_selection 141 | self.labels = labels or {} 142 | self.last_task_failure = last_task_failure if (isinstance(last_task_failure, MarathonTaskFailure) or last_task_failure is None) \ 143 | else MarathonTaskFailure.from_json(last_task_failure) 144 | self.max_launch_delay_seconds = max_launch_delay_seconds 145 | self.mem = mem 146 | self.ports = ports or [] 147 | self.port_definitions = [ 148 | pd if isinstance( 149 | pd, PortDefinition) else PortDefinition.from_json(pd) 150 | for pd in (port_definitions or []) 151 | ] 152 | self.readiness_checks = [ 153 | rc if isinstance( 154 | rc, ReadinessCheck) else ReadinessCheck().from_json(rc) 155 | for rc in (readiness_checks or []) 156 | ] 157 | self.readiness_check_results = readiness_check_results or [] 158 | self.residency = residency 159 | self.require_ports = require_ports 160 | 161 | self.secrets = secrets or {} 162 | for k, s in self.secrets.items(): 163 | if not isinstance(s, Secret): 164 | self.secrets[k] = Secret().from_json(s) 165 | 166 | self.store_urls = store_urls or [] 167 | self.task_rate_limit = task_rate_limit 168 | self.tasks = [ 169 | t if isinstance(t, MarathonTask) else MarathonTask().from_json(t) 170 | for t in (tasks or []) 171 | ] 172 | self.tasks_running = tasks_running 173 | self.tasks_staged = tasks_staged 174 | self.tasks_healthy = tasks_healthy 175 | self.task_kill_grace_period_seconds = task_kill_grace_period_seconds 176 | self.tasks_unhealthy = tasks_unhealthy 177 | self.upgrade_strategy = upgrade_strategy if (isinstance(upgrade_strategy, MarathonUpgradeStrategy) or upgrade_strategy is None) \ 178 | else MarathonUpgradeStrategy.from_json(upgrade_strategy) 179 | self.unreachable_strategy = unreachable_strategy \ 180 | if (isinstance(unreachable_strategy, MarathonUnreachableStrategy) 181 | or unreachable_strategy is None) \ 182 | else MarathonUnreachableStrategy.from_json(unreachable_strategy) 183 | self.uris = uris or [] 184 | self.fetch = fetch or [] 185 | self.user = user 186 | self.version = version 187 | self.version_info = version_info if (isinstance(version_info, MarathonAppVersionInfo) or version_info is None) \ 188 | else MarathonAppVersionInfo.from_json(version_info) 189 | self.task_stats = task_stats if (isinstance(task_stats, MarathonTaskStats) or task_stats is None) \ 190 | else MarathonTaskStats.from_json(task_stats) 191 | self.networks = networks 192 | 193 | def add_env(self, key, value): 194 | self.env[key] = value 195 | 196 | 197 | class MarathonHealthCheck(MarathonObject): 198 | 199 | """Marathon health check. 200 | 201 | See https://mesosphere.github.io/marathon/docs/health-checks.html 202 | 203 | :param str command: health check command (if protocol == 'COMMAND') 204 | :param int grace_period_seconds: how long to ignore health check failures on initial task launch (before first healthy status) 205 | :param int interval_seconds: how long to wait between health checks 206 | :param int max_consecutive_failures: max number of consecutive failures before the task should be killed 207 | :param str path: health check target path (if protocol == 'HTTP') 208 | :param int port_index: target port as indexed in app's `ports` array 209 | :param str protocol: health check protocol ('HTTP', 'TCP', or 'COMMAND') 210 | :param int timeout_seconds: how long before a waiting health check is considered failed 211 | :param bool ignore_http1xx: Ignore HTTP informational status codes 100 to 199. 212 | :param dict kwargs: additional arguments for forward compatibility 213 | """ 214 | 215 | def __init__(self, command=None, grace_period_seconds=None, interval_seconds=None, max_consecutive_failures=None, 216 | path=None, port_index=None, protocol=None, timeout_seconds=None, ignore_http1xx=None, **kwargs): 217 | 218 | if command is None: 219 | self.command = None 220 | elif isinstance(command, str): 221 | self.command = { 222 | "value": command 223 | } 224 | elif type(command) is dict and 'value' in command: 225 | log.warn('Deprecated: Using command as dict instead of string is deprecated') 226 | self.command = { 227 | "value": command['value'] 228 | } 229 | else: 230 | raise ValueError(f'Invalid command format: {command}') 231 | 232 | self.grace_period_seconds = grace_period_seconds 233 | self.interval_seconds = interval_seconds 234 | self.max_consecutive_failures = max_consecutive_failures 235 | self.path = path 236 | self.port_index = port_index 237 | self.protocol = protocol 238 | self.timeout_seconds = timeout_seconds 239 | self.ignore_http1xx = ignore_http1xx 240 | # additional not previously known healthcheck attributes 241 | for k, v in kwargs.items(): 242 | setattr(self, k, v) 243 | 244 | 245 | class MarathonTaskFailure(MarathonObject): 246 | 247 | """Marathon Task Failure. 248 | 249 | :param str app_id: application id 250 | :param str host: mesos slave running the task 251 | :param str message: error message 252 | :param str task_id: task id 253 | :param str instance_id: instance id 254 | :param str state: task state 255 | :param timestamp: when this task failed 256 | :type timestamp: datetime or str 257 | :param str version: app version with which this task was started 258 | """ 259 | 260 | def __init__(self, app_id=None, host=None, message=None, task_id=None, instance_id=None, 261 | slave_id=None, state=None, timestamp=None, version=None): 262 | self.app_id = app_id 263 | self.host = host 264 | self.message = message 265 | self.task_id = task_id 266 | self.instance_id = instance_id 267 | self.slave_id = slave_id 268 | self.state = state 269 | self.timestamp = to_datetime(timestamp) 270 | self.version = version 271 | 272 | 273 | class MarathonUpgradeStrategy(MarathonObject): 274 | 275 | """Marathon health check. 276 | 277 | See https://mesosphere.github.io/marathon/docs/health-checks.html 278 | 279 | :param float minimum_health_capacity: minimum % of instances kept healthy on deploy 280 | """ 281 | 282 | def __init__(self, maximum_over_capacity=None, 283 | minimum_health_capacity=None): 284 | self.maximum_over_capacity = maximum_over_capacity 285 | self.minimum_health_capacity = minimum_health_capacity 286 | 287 | 288 | class MarathonUnreachableStrategy(MarathonObject): 289 | 290 | """Marathon unreachable Strategy. 291 | 292 | Define handling for unreachable instances. Given 293 | `unreachable_inactive_after_seconds = 60` and 294 | `unreachable_expunge_after = 120`, an instance will be expunged if it has 295 | been unreachable for more than 120 seconds or a second instance is started 296 | if it has been unreachable for more than 60 seconds.", 297 | 298 | See https://mesosphere.github.io/marathon/docs/? 299 | 300 | :param int unreachable_inactive_after_seconds: time an instance is 301 | unreachable for in seconds before marked as inactive. 302 | :param int unreachable_expunge_after_seconds: time an instance is 303 | unreachable for in seconds before expunged. 304 | :param int inactive_after_seconds 305 | :param int expunge_after_seconds 306 | """ 307 | DISABLED = 'disabled' 308 | 309 | def __init__(self, unreachable_inactive_after_seconds=None, 310 | unreachable_expunge_after_seconds=None, 311 | inactive_after_seconds=None, expunge_after_seconds=None): 312 | self.unreachable_inactive_after_seconds = unreachable_inactive_after_seconds 313 | self.unreachable_expunge_after_seconds = unreachable_expunge_after_seconds 314 | self.inactive_after_seconds = inactive_after_seconds 315 | self.expunge_after_seconds = expunge_after_seconds 316 | 317 | @classmethod 318 | def from_json(cls, attributes): 319 | if attributes == cls.DISABLED: 320 | return cls.DISABLED 321 | return super().from_json(attributes) 322 | 323 | 324 | class MarathonAppVersionInfo(MarathonObject): 325 | 326 | """Marathon App version info. 327 | 328 | See release notes for Marathon v0.11.0 329 | https://github.com/mesosphere/marathon/releases/tag/v0.11.0 330 | 331 | :param str app_id: application id 332 | :param str host: mesos slave running the task 333 | """ 334 | 335 | def __init__(self, last_scaling_at=None, last_config_change_at=None): 336 | self.last_scaling_at = to_datetime(last_scaling_at) 337 | self.last_config_change_at = to_datetime(last_config_change_at) 338 | 339 | 340 | class MarathonTaskStats(MarathonObject): 341 | 342 | """Marathon task statistics 343 | 344 | See https://mesosphere.github.io/marathon/docs/rest-api.html#taskstats-object-v0-11 345 | 346 | :param started_after_last_scaling: contains statistics about all tasks that were started after the last scaling or restart operation. 347 | :type started_after_last_scaling: :class:`marathon.models.app.MarathonTaskStatsType` or dict 348 | :param with_latest_config: contains statistics about all tasks that run with the same config as the latest app version. 349 | :type with_latest_config: :class:`marathon.models.app.MarathonTaskStatsType` or dict 350 | :param with_outdated_config: contains statistics about all tasks that were started before the last config change 351 | which was not simply a restart or scaling operation. 352 | :type with_outdated_config: :class:`marathon.models.app.MarathonTaskStatsType` or dict 353 | :param total_summary: contains statistics about all tasks. 354 | :type total_summary: :class:`marathon.models.app.MarathonTaskStatsType` or dict 355 | """ 356 | 357 | def __init__(self, started_after_last_scaling=None, 358 | with_latest_config=None, with_outdated_config=None, total_summary=None): 359 | self.started_after_last_scaling = started_after_last_scaling if \ 360 | (isinstance(started_after_last_scaling, MarathonTaskStatsType) or started_after_last_scaling is None) \ 361 | else MarathonTaskStatsType.from_json(started_after_last_scaling) 362 | self.with_latest_config = with_latest_config if \ 363 | (isinstance(with_latest_config, MarathonTaskStatsType) or with_latest_config is None) \ 364 | else MarathonTaskStatsType.from_json(with_latest_config) 365 | self.with_outdated_config = with_outdated_config if \ 366 | (isinstance(with_outdated_config, MarathonTaskStatsType) or with_outdated_config is None) \ 367 | else MarathonTaskStatsType.from_json(with_outdated_config) 368 | self.total_summary = total_summary if \ 369 | (isinstance(total_summary, MarathonTaskStatsType) or total_summary is None) \ 370 | else MarathonTaskStatsType.from_json(total_summary) 371 | 372 | 373 | class MarathonTaskStatsType(MarathonObject): 374 | 375 | """Marathon app task stats 376 | 377 | :param stats: stast about app tasks 378 | :type stats: :class:`marathon.models.app.MarathonTaskStatsStats` or dict 379 | """ 380 | 381 | def __init__(self, stats=None): 382 | self.stats = stats if (isinstance(stats, MarathonTaskStatsStats) or stats is None)\ 383 | else MarathonTaskStatsStats.from_json(stats) 384 | 385 | 386 | class MarathonTaskStatsStats(MarathonObject): 387 | 388 | """Marathon app task stats 389 | 390 | :param counts: app task count breakdown 391 | :type counts: :class:`marathon.models.app.MarathonTaskStatsCounts` or dict 392 | :param life_time: app task life time stats 393 | :type life_time: :class:`marathon.models.app.MarathonTaskStatsLifeTime` or dict 394 | """ 395 | 396 | def __init__(self, counts=None, life_time=None): 397 | self.counts = counts if (isinstance(counts, MarathonTaskStatsCounts) or counts is None)\ 398 | else MarathonTaskStatsCounts.from_json(counts) 399 | self.life_time = life_time if (isinstance(life_time, MarathonTaskStatsLifeTime) or life_time is None)\ 400 | else MarathonTaskStatsLifeTime.from_json(life_time) 401 | 402 | 403 | class MarathonTaskStatsCounts(MarathonObject): 404 | 405 | """Marathon app task counts 406 | 407 | Equivalent to tasksStaged, tasksRunning, tasksHealthy, tasksUnhealthy. 408 | 409 | :param int staged: Staged task count 410 | :param int running: Running task count 411 | :param int healthy: Healthy task count 412 | :param int unhealthy: unhealthy task count 413 | """ 414 | 415 | def __init__(self, staged=None, 416 | running=None, healthy=None, unhealthy=None): 417 | self.staged = staged 418 | self.running = running 419 | self.healthy = healthy 420 | self.unhealthy = unhealthy 421 | 422 | 423 | class MarathonTaskStatsLifeTime(MarathonObject): 424 | 425 | """Marathon app life time statistics 426 | 427 | Measured from `"startedAt"` (timestamp of the Mesos TASK_RUNNING status update) of each running task until now 428 | 429 | :param float average_seconds: Average seconds 430 | :param float median_seconds: Median seconds 431 | """ 432 | 433 | def __init__(self, average_seconds=None, median_seconds=None): 434 | self.average_seconds = average_seconds 435 | self.median_seconds = median_seconds 436 | 437 | 438 | class ReadinessCheck(MarathonObject): 439 | """Marathon readiness check: https://mesosphere.github.io/marathon/docs/readiness-checks.html 440 | 441 | :param string name (Optional. Default: "readinessCheck"): The name used to identify this readiness check. 442 | :param string protocol (Optional. Default: "HTTP"): Protocol of the requests to be performed. Either HTTP or HTTPS. 443 | :param string path (Optional. Default: "/"): Path to the endpoint the task exposes to provide readiness status. 444 | Example: /path/to/readiness. 445 | :param string port_name (Optional. Default: "http-api"): Name of the port to query as described in the 446 | portDefinitions. Example: http-api. 447 | :param int interval_seconds (Optional. Default: 30 seconds): Number of seconds to wait between readiness checks. 448 | :param int timeout_seconds (Optional. Default: 10 seconds): Number of seconds after which a readiness check 449 | times out, regardless of the response. This value must be smaller than interval_seconds. 450 | :param list http_status_codes_for_ready (Optional. Default: [200]): The HTTP/HTTPS status code to treat as ready. 451 | :param bool preserve_last_response (Optional. Default: false): If true, the last readiness check response will be 452 | preserved and exposed in the API as part of a deployment. 453 | 454 | """ 455 | 456 | def __init__(self, name=None, protocol=None, path=None, port_name=None, interval_seconds=None, 457 | http_status_codes_for_ready=None, preserve_last_response=None, timeout_seconds=None): 458 | self.name = name 459 | self.protocol = protocol 460 | self.path = path 461 | self.port_name = port_name 462 | self.interval_seconds = interval_seconds 463 | self.http_status_codes_for_ready = http_status_codes_for_ready 464 | self.preserve_last_response = preserve_last_response 465 | self.timeout_seconds = timeout_seconds 466 | 467 | 468 | class PortDefinition(MarathonObject): 469 | """Marathon port definitions: https://mesosphere.github.io/marathon/docs/ports.html 470 | 471 | :param int port: The port 472 | :param string protocol: tcp or udp 473 | :param string name: (optional) the name of the port 474 | :param dict labels: undocumented 475 | """ 476 | 477 | def __init__(self, port=None, protocol=None, name=None, labels=None): 478 | self.port = port 479 | self.protocol = protocol 480 | self.name = name 481 | self.labels = labels 482 | 483 | 484 | class Residency(MarathonObject): 485 | """Declares how "resident" an app is: https://mesosphere.github.io/marathon/docs/persistent-volumes.html 486 | 487 | :param int relaunch_escalation_timeout_seconds: How long marathon will try to relaunch where the volumes is, defaults to 3600 488 | :param string task_lost_behavior: What to do after a TASK_LOST. See the official Marathon docs for options 489 | 490 | """ 491 | 492 | def __init__(self, relaunch_escalation_timeout_seconds=None, task_lost_behavior=None): 493 | self.relaunch_escalation_timeout_seconds = relaunch_escalation_timeout_seconds 494 | self.task_lost_behavior = task_lost_behavior 495 | 496 | 497 | class Secret(MarathonObject): 498 | """Declares marathon secret object. 499 | :param str source: The source of the secret's value. The format depends on the secret store used by Mesos. 500 | 501 | """ 502 | 503 | def __init__(self, source=None): 504 | self.source = source 505 | -------------------------------------------------------------------------------- /marathon/models/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from marathon.util import to_camel_case, to_snake_case, MarathonJsonEncoder, MarathonMinimalJsonEncoder 5 | 6 | 7 | class MarathonObject: 8 | """Base Marathon object.""" 9 | 10 | def __repr__(self): 11 | return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json(minimal=False)) 12 | 13 | def __eq__(self, other): 14 | try: 15 | return self.__dict__ == other.__dict__ 16 | except Exception: 17 | return False 18 | 19 | def __hash__(self): 20 | # Technically this class shouldn't be hashable because it often 21 | # contains mutable fields, but in practice this class is used more 22 | # like a record or namedtuple. 23 | return hash(self.to_json()) 24 | 25 | def json_repr(self, minimal=False): 26 | """Construct a JSON-friendly representation of the object. 27 | 28 | :param bool minimal: Construct a minimal representation of the object (ignore nulls and empty collections) 29 | 30 | :rtype: dict 31 | """ 32 | if minimal: 33 | return {to_camel_case(k): v for k, v in vars(self).items() if (v or v is False or v == 0)} 34 | else: 35 | return {to_camel_case(k): v for k, v in vars(self).items()} 36 | 37 | @classmethod 38 | def from_json(cls, attributes): 39 | """Construct an object from a parsed response. 40 | 41 | :param dict attributes: object attributes from parsed response 42 | """ 43 | return cls(**{to_snake_case(k): v for k, v in attributes.items()}) 44 | 45 | def to_json(self, minimal=True): 46 | """Encode an object as a JSON string. 47 | 48 | :param bool minimal: Construct a minimal representation of the object (ignore nulls and empty collections) 49 | 50 | :rtype: str 51 | """ 52 | if minimal: 53 | return json.dumps(self.json_repr(minimal=True), cls=MarathonMinimalJsonEncoder, sort_keys=True) 54 | else: 55 | return json.dumps(self.json_repr(), cls=MarathonJsonEncoder, sort_keys=True) 56 | 57 | 58 | class MarathonResource(MarathonObject): 59 | 60 | """Base Marathon resource.""" 61 | 62 | def __repr__(self): 63 | if 'id' in list(vars(self).keys()): 64 | return f"{self.__class__.__name__}::{self.id}" 65 | else: 66 | return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json()) 67 | 68 | def __eq__(self, other): 69 | try: 70 | return self.__dict__ == other.__dict__ 71 | except Exception: 72 | return False 73 | 74 | def __hash__(self): 75 | # Technically this class shouldn't be hashable because it often 76 | # contains mutable fields, but in practice this class is used more 77 | # like a record or namedtuple. 78 | return hash(self.to_json()) 79 | 80 | def __str__(self): 81 | return f"{self.__class__.__name__}::" + str(self.__dict__) 82 | 83 | 84 | # See: 85 | # https://github.com/mesosphere/marathon/blob/2a9d1d20ec2f1cfcc49fbb1c0e7348b26418ef38/src/main/scala/mesosphere/marathon/api/ModelValidation.scala#L224 86 | ID_PATTERN = re.compile( 87 | '^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])|(\\.|\\.\\.)$') 88 | 89 | 90 | def assert_valid_path(path): 91 | """Checks if a path is a correct format that Marathon expects. Raises ValueError if not valid. 92 | 93 | :param str path: The app id. 94 | 95 | :rtype: str 96 | """ 97 | if path is None: 98 | return 99 | # As seen in: 100 | # https://github.com/mesosphere/marathon/blob/0c11661ca2f259f8a903d114ef79023649a6f04b/src/main/scala/mesosphere/marathon/state/PathId.scala#L71 101 | for id in filter(None, path.strip('/').split('/')): 102 | if not ID_PATTERN.match(id): 103 | raise ValueError( 104 | 'invalid path (allowed: lowercase letters, digits, hyphen, "/", ".", ".."): %r' % path) 105 | return path 106 | 107 | 108 | def assert_valid_id(id): 109 | """Checks if an id is the correct format that Marathon expects. Raises ValueError if not valid. 110 | 111 | :param str id: App or group id. 112 | 113 | :rtype: str 114 | """ 115 | if id is None: 116 | return 117 | if not ID_PATTERN.match(id.strip('/')): 118 | raise ValueError( 119 | 'invalid id (allowed: lowercase letters, digits, hyphen, ".", ".."): %r' % id) 120 | return id 121 | -------------------------------------------------------------------------------- /marathon/models/constraint.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonObject 2 | 3 | 4 | class MarathonConstraint(MarathonObject): 5 | 6 | """Marathon placement constraint. 7 | 8 | See https://mesosphere.github.io/marathon/docs/constraints.html 9 | 10 | :param str field: constraint operator target 11 | :param str operator: must be one of [UNIQUE, CLUSTER, GROUP_BY, LIKE, UNLIKE] 12 | :param value: [optional] if `operator` is CLUSTER, constrain tasks to servers where `field` == `value`. 13 | If `operator` is GROUP_BY, place at most `value` tasks per group. If `operator` 14 | is `LIKE` or `UNLIKE`, filter servers using regexp. 15 | :type value: str, int, or None 16 | """ 17 | 18 | """Valid operators""" 19 | 20 | def __init__(self, field, operator, value=None): 21 | self.field = field 22 | self.operator = operator 23 | self.value = value 24 | 25 | def __repr__(self): 26 | if self.value: 27 | template = "MarathonConstraint::{field}:{operator}:{value}" 28 | else: 29 | template = "MarathonConstraint::{field}:{operator}" 30 | return template.format(**self.__dict__) 31 | 32 | def json_repr(self, minimal=False): 33 | """Construct a JSON-friendly representation of the object. 34 | 35 | :param bool minimal: [ignored] 36 | 37 | :rtype: list 38 | """ 39 | if self.value: 40 | return [self.field, self.operator, self.value] 41 | else: 42 | return [self.field, self.operator] 43 | 44 | @classmethod 45 | def from_json(cls, obj): 46 | """Construct a MarathonConstraint from a parsed response. 47 | 48 | :param dict attributes: object attributes from parsed response 49 | 50 | :rtype: :class:`MarathonConstraint` 51 | """ 52 | if len(obj) == 2: 53 | (field, operator) = obj 54 | return cls(field, operator) 55 | if len(obj) > 2: 56 | (field, operator, value) = obj 57 | return cls(field, operator, value) 58 | 59 | @classmethod 60 | def from_string(cls, constraint): 61 | """ 62 | :param str constraint: The string representation of a constraint 63 | 64 | :rtype: :class:`MarathonConstraint` 65 | """ 66 | obj = constraint.split(':') 67 | marathon_constraint = cls.from_json(obj) 68 | 69 | if marathon_constraint: 70 | return marathon_constraint 71 | 72 | raise ValueError("Invalid string format. " 73 | "Expected `field:operator:value`") 74 | -------------------------------------------------------------------------------- /marathon/models/container.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import InvalidChoiceError 2 | from .base import MarathonObject 3 | 4 | 5 | class MarathonContainer(MarathonObject): 6 | 7 | """Marathon health check. 8 | 9 | See https://mesosphere.github.io/marathon/docs/native-docker.html 10 | 11 | :param docker: docker field (e.g., {"image": "mygroup/myimage"})' 12 | :type docker: :class:`marathon.models.container.MarathonDockerContainer` or dict 13 | :param str type: 14 | :param port_mappings: New in Marathon v1.5. container.docker.port_mappings moved here. 15 | :type port_mappings: list[:class:`marathon.models.container.MarathonContainerPortMapping`] or list[dict] 16 | :param volumes: 17 | :type volumes: list[:class:`marathon.models.container.MarathonContainerVolume`] or list[dict] 18 | """ 19 | 20 | TYPES = ['DOCKER', 'MESOS'] 21 | """Valid container types""" 22 | 23 | def __init__(self, docker=None, type='DOCKER', port_mappings=None, volumes=None): 24 | if type not in self.TYPES: 25 | raise InvalidChoiceError('type', type, self.TYPES) 26 | self.type = type 27 | 28 | # Marathon v1.5 moved portMappings from within container.docker object directly 29 | # under the container object 30 | if port_mappings: 31 | self.port_mappings = [ 32 | pm if isinstance( 33 | pm, MarathonContainerPortMapping) else MarathonContainerPortMapping().from_json(pm) 34 | for pm in (port_mappings or []) 35 | ] 36 | 37 | if docker: 38 | self.docker = docker if isinstance(docker, MarathonDockerContainer) \ 39 | else MarathonDockerContainer().from_json(docker) 40 | 41 | self.volumes = [ 42 | v if isinstance( 43 | v, MarathonContainerVolume) else MarathonContainerVolume().from_json(v) 44 | for v in (volumes or []) 45 | ] 46 | 47 | 48 | class MarathonDockerContainer(MarathonObject): 49 | 50 | """Docker options. 51 | 52 | See https://mesosphere.github.io/marathon/docs/native-docker.html 53 | 54 | :param str image: docker image 55 | :param str network: 56 | :param port_mappings: 57 | :type port_mappings: list[:class:`marathon.models.container.MarathonContainerPortMapping`] or list[dict] 58 | :param list[dict] parameters: 59 | :param bool privileged: run container in privileged mode 60 | :param bool force_pull_image: Force a docker pull before launching 61 | """ 62 | 63 | NETWORK_MODES = ['BRIDGE', 'HOST', 'USER', 'NONE'] 64 | """Valid network modes""" 65 | 66 | def __init__(self, image=None, network=None, port_mappings=None, parameters=None, privileged=None, 67 | force_pull_image=None, **kwargs): 68 | self.image = image 69 | if network: 70 | if network not in self.NETWORK_MODES: 71 | raise InvalidChoiceError( 72 | 'network', network, self.NETWORK_MODES) 73 | self.network = network 74 | self.port_mappings = [ 75 | pm if isinstance( 76 | pm, MarathonContainerPortMapping) else MarathonContainerPortMapping().from_json(pm) 77 | for pm in (port_mappings or []) 78 | ] 79 | self.parameters = parameters or [] 80 | self.privileged = privileged or False 81 | self.force_pull_image = force_pull_image or False 82 | 83 | 84 | class MarathonContainerPortMapping(MarathonObject): 85 | 86 | """Container port mapping. 87 | 88 | See https://mesosphere.github.io/marathon/docs/native-docker.html 89 | 90 | :param str name: 91 | :param int container_port: 92 | :param int host_port: 93 | :param str protocol: 94 | :param object labels: 95 | """ 96 | 97 | PROTOCOLS = ['tcp', 'udp', 'udp,tcp'] 98 | """Valid protocols""" 99 | 100 | def __init__(self, name=None, container_port=None, host_port=None, service_port=None, protocol='tcp', labels=None): 101 | self.name = name 102 | self.container_port = container_port 103 | self.host_port = host_port 104 | self.service_port = service_port 105 | if protocol not in self.PROTOCOLS: 106 | raise InvalidChoiceError('protocol', protocol, self.PROTOCOLS) 107 | self.protocol = protocol 108 | self.labels = labels 109 | 110 | 111 | class MarathonContainerVolume(MarathonObject): 112 | 113 | """Volume options. 114 | 115 | See https://mesosphere.github.io/marathon/docs/native-docker.html 116 | 117 | :param str container_path: container path 118 | :param str host_path: host path 119 | :param str mode: one of ['RO', 'RW'] 120 | :param object persistent: persistent volume options, should be of the form {'size': 1000} 121 | :param object external: external volume options 122 | """ 123 | 124 | MODES = ['RO', 'RW'] 125 | 126 | def __init__(self, container_path=None, host_path=None, mode='RW', persistent=None, external=None): 127 | self.container_path = container_path 128 | self.host_path = host_path 129 | if mode not in self.MODES: 130 | raise InvalidChoiceError('mode', mode, self.MODES) 131 | self.mode = mode 132 | self.persistent = persistent 133 | self.external = external 134 | -------------------------------------------------------------------------------- /marathon/models/deployment.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonObject, MarathonResource, assert_valid_path 2 | 3 | 4 | class MarathonDeployment(MarathonResource): 5 | 6 | """Marathon Application resource. 7 | 8 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#deployments 9 | https://mesosphere.github.io/marathon/docs/generated/api.html#v2_deployments_get 10 | 11 | :param list[str] affected_apps: list of affected app ids 12 | :param current_actions: current actions 13 | :type current_actions: list[:class:`marathon.models.deployment.MarathonDeploymentAction`] or list[dict] 14 | :param int current_step: current step 15 | :param str id: deployment id 16 | :param steps: deployment steps 17 | :type steps: list[:class:`marathon.models.deployment.MarathonDeploymentAction`] or list[dict] 18 | :param int total_steps: total number of steps 19 | :param str version: version id 20 | :param str affected_pods: list of strings 21 | """ 22 | 23 | def __init__(self, affected_apps=None, current_actions=None, current_step=None, id=None, steps=None, 24 | total_steps=None, version=None, affected_pods=None): 25 | self.affected_apps = affected_apps 26 | self.current_actions = [ 27 | a if isinstance( 28 | a, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(a) 29 | for a in (current_actions or []) 30 | ] 31 | self.current_step = current_step 32 | self.id = id 33 | self.steps = [self.parse_deployment_step(step) for step in (steps or [])] 34 | self.total_steps = total_steps 35 | self.version = version 36 | self.affected_pods = affected_pods 37 | 38 | def parse_deployment_step(self, step): 39 | if step.__class__ == dict: 40 | # This is what Marathon 1.0.0 returns: steps 41 | return MarathonDeploymentStep().from_json(step) 42 | elif step.__class__ == list: 43 | # This is Marathon < 1.0.0 style, a list of actions 44 | return [s if isinstance(s, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(s) for s in step] 45 | else: 46 | return step 47 | 48 | 49 | class MarathonDeploymentAction(MarathonObject): 50 | 51 | """Marathon Application resource. 52 | 53 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#deployments 54 | 55 | :param str action: action 56 | :param str app: app id 57 | :param str apps: app id (see https://github.com/mesosphere/marathon/pull/802) 58 | :param type readiness_check_results: Undocumented 59 | """ 60 | 61 | def __init__(self, action=None, app=None, apps=None, type=None, readiness_check_results=None, pod=None): 62 | self.action = action 63 | self.app = assert_valid_path(app) 64 | self.apps = assert_valid_path(apps) 65 | self.pod = pod 66 | self.type = type # TODO: Remove builtin shadow 67 | self.readiness_check_results = readiness_check_results # TODO: The docs say this is called just "readinessChecks?" 68 | 69 | 70 | class MarathonDeploymentPlan(MarathonObject): 71 | 72 | def __init__(self, original=None, target=None, 73 | steps=None, id=None, version=None): 74 | self.original = MarathonDeploymentOriginalState.from_json(original) 75 | self.target = MarathonDeploymentTargetState.from_json(target) 76 | self.steps = [MarathonDeploymentStep.from_json(x) for x in steps] 77 | self.id = id 78 | self.version = version 79 | 80 | 81 | class MarathonDeploymentStep(MarathonObject): 82 | 83 | def __init__(self, actions=None): 84 | self.actions = [a if isinstance(a, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(a) for a in (actions or [])] 85 | 86 | 87 | class MarathonDeploymentOriginalState(MarathonObject): 88 | 89 | def __init__(self, dependencies=None, 90 | apps=None, id=None, version=None, groups=None, pods=None): 91 | self.apps = apps 92 | self.groups = groups 93 | self.id = id 94 | self.version = version 95 | self.dependencies = dependencies 96 | self.pods = pods 97 | 98 | 99 | class MarathonDeploymentTargetState(MarathonObject): 100 | 101 | def __init__(self, groups=None, apps=None, 102 | dependencies=None, id=None, version=None, pods=None): 103 | self.apps = apps 104 | self.groups = groups 105 | self.id = id 106 | self.version = version 107 | self.dependencies = dependencies 108 | self.pods = pods 109 | -------------------------------------------------------------------------------- /marathon/models/endpoint.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonObject 2 | 3 | 4 | class MarathonEndpoint(MarathonObject): 5 | 6 | """Marathon Endpoint helper object for service discovery. It describes a single port mapping for a running task. 7 | 8 | :param str app_id: application id 9 | :param str host: mesos slave running the task 10 | :param str task_id: task id 11 | :param int service_port: application service port 12 | :param int task_port: port allocated on the slave 13 | """ 14 | 15 | def __repr__(self): 16 | return "{clazz}::{app_id}::{service_port}::{task_id}::{task_port}".format( 17 | clazz=self.__class__.__name__, 18 | app_id=self.app_id, 19 | service_port=self.service_port, 20 | task_id=self.task_id, 21 | task_port=self.task_port 22 | ) 23 | 24 | def __init__(self, app_id=None, service_port=None, 25 | host=None, task_id=None, task_port=None): 26 | self.app_id = app_id 27 | self.service_port = service_port 28 | self.host = host 29 | self.task_id = task_id 30 | self.task_port = task_port 31 | 32 | @classmethod 33 | def from_tasks(cls, tasks): 34 | """Construct a list of MarathonEndpoints from a list of tasks. 35 | 36 | :param list[:class:`marathon.models.MarathonTask`] tasks: list of tasks to parse 37 | 38 | :rtype: list[:class:`MarathonEndpoint`] 39 | """ 40 | 41 | endpoints = [ 42 | [ 43 | MarathonEndpoint(task.app_id, task.service_ports[ 44 | port_index], task.host, task.id, port) 45 | for port_index, port in enumerate(task.ports) 46 | ] 47 | for task in tasks 48 | ] 49 | # Flatten result 50 | return [item for sublist in endpoints for item in sublist] 51 | -------------------------------------------------------------------------------- /marathon/models/events.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is used to translate Events from Marathon's EventBus system. 3 | See: 4 | * https://mesosphere.github.io/marathon/docs/event-bus.html 5 | * https://github.com/mesosphere/marathon/blob/master/src/main/scala/mesosphere/marathon/core/event/Events.scala 6 | """ 7 | 8 | from marathon.models.base import MarathonObject 9 | from marathon.models.app import MarathonHealthCheck 10 | from marathon.models.task import MarathonIpAddress 11 | from marathon.models.deployment import MarathonDeploymentPlan 12 | from marathon.exceptions import MarathonError 13 | 14 | 15 | class MarathonEvent(MarathonObject): 16 | 17 | """ 18 | The MarathonEvent base class handles the translation of Event objects sent by the 19 | Marathon server into library MarathonObjects. 20 | """ 21 | 22 | KNOWN_ATTRIBUTES = [] 23 | attribute_name_to_marathon_object = { # Allows embedding of MarathonObjects inside events. 24 | 'health_check': MarathonHealthCheck, 25 | 'plan': MarathonDeploymentPlan, 26 | 'ip_address': MarathonIpAddress, 27 | } 28 | seq_name_to_singular = { 29 | 'ip_addresses': 'ip_address', 30 | } 31 | 32 | def __init__(self, event_type, timestamp, **kwargs): 33 | self.event_type = event_type # All events have these two attributes 34 | self.timestamp = timestamp 35 | for attribute in self.KNOWN_ATTRIBUTES: 36 | self._set(attribute, kwargs.get(attribute)) 37 | 38 | def __to_marathon_object(self, attribute_name, attribute): 39 | if attribute_name in self.attribute_name_to_marathon_object: 40 | clazz = self.attribute_name_to_marathon_object[attribute_name] 41 | # If this attribute already has a Marathon object instantiate it. 42 | attribute = clazz.from_json(attribute) 43 | return attribute 44 | 45 | def _set(self, attribute_name, attribute): 46 | if not attribute: 47 | return 48 | # Special handling for lists... 49 | if isinstance(attribute, list): 50 | name = self.seq_name_to_singular.get(attribute_name) 51 | attribute = [ 52 | self.__to_marathon_object(name, v) 53 | for v in attribute 54 | ] 55 | else: 56 | attribute = self.__to_marathon_object(attribute_name, attribute) 57 | setattr(self, attribute_name, attribute) 58 | 59 | 60 | class MarathonApiPostEvent(MarathonEvent): 61 | KNOWN_ATTRIBUTES = ['client_ip', 'app_definition', 'uri'] 62 | 63 | 64 | class MarathonStatusUpdateEvent(MarathonEvent): 65 | KNOWN_ATTRIBUTES = [ 66 | 'slave_id', 'task_id', 'task_status', 'app_id', 'host', 'ports', 'version', 'message', 'ip_addresses'] 67 | 68 | 69 | class MarathonFrameworkMessageEvent(MarathonEvent): 70 | KNOWN_ATTRIBUTES = ['slave_id', 'executor_id', 'message'] 71 | 72 | 73 | class MarathonSubscribeEvent(MarathonEvent): 74 | KNOWN_ATTRIBUTES = ['client_ip', 'callback_url'] 75 | 76 | 77 | class MarathonUnsubscribeEvent(MarathonEvent): 78 | KNOWN_ATTRIBUTES = ['client_ip', 'callback_url'] 79 | 80 | 81 | class MarathonAddHealthCheckEvent(MarathonEvent): 82 | KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'version'] 83 | 84 | 85 | class MarathonRemoveHealthCheckEvent(MarathonEvent): 86 | KNOWN_ATTRIBUTES = ['app_id', 'health_check'] 87 | 88 | 89 | class MarathonFailedHealthCheckEvent(MarathonEvent): 90 | KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id'] 91 | 92 | 93 | class MarathonHealthStatusChangedEvent(MarathonEvent): 94 | KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id', 'alive'] 95 | 96 | 97 | class MarathonGroupChangeSuccess(MarathonEvent): 98 | KNOWN_ATTRIBUTES = ['group_id', 'version'] 99 | 100 | 101 | class MarathonGroupChangeFailed(MarathonEvent): 102 | KNOWN_ATTRIBUTES = ['group_id', 'version', 'reason'] 103 | 104 | 105 | class MarathonDeploymentSuccess(MarathonEvent): 106 | KNOWN_ATTRIBUTES = ['id'] 107 | 108 | 109 | class MarathonDeploymentFailed(MarathonEvent): 110 | KNOWN_ATTRIBUTES = ['id'] 111 | 112 | 113 | class MarathonDeploymentInfo(MarathonEvent): 114 | KNOWN_ATTRIBUTES = ['plan', 'current_step'] 115 | 116 | 117 | class MarathonDeploymentStepSuccess(MarathonEvent): 118 | KNOWN_ATTRIBUTES = ['plan'] 119 | 120 | 121 | class MarathonDeploymentStepFailure(MarathonEvent): 122 | KNOWN_ATTRIBUTES = ['plan'] 123 | 124 | 125 | class MarathonEventStreamAttached(MarathonEvent): 126 | KNOWN_ATTRIBUTES = ['remote_address'] 127 | 128 | 129 | class MarathonEventStreamDetached(MarathonEvent): 130 | KNOWN_ATTRIBUTES = ['remote_address'] 131 | 132 | 133 | class MarathonUnhealthyTaskKillEvent(MarathonEvent): 134 | KNOWN_ATTRIBUTES = ['app_id', 'task_id', 'instance_id', 'version', 'reason'] 135 | 136 | 137 | class MarathonAppTerminatedEvent(MarathonEvent): 138 | KNOWN_ATTRIBUTES = ['app_id'] 139 | 140 | 141 | class MarathonInstanceChangedEvent(MarathonEvent): 142 | KNOWN_ATTRIBUTES = ['instance_id', 'slave_id', 'condition', 'host', 'run_spec_id', 'run_spec_version'] 143 | 144 | 145 | class MarathonUnknownInstanceTerminated(MarathonEvent): 146 | KNOWN_ATTRIBUTES = ['instance_id', 'run_spec_id', 'condition'] 147 | 148 | 149 | class MarathonInstanceHealthChangedEvent(MarathonEvent): 150 | KNOWN_ATTRIBUTES = ['instance_id', 'run_spec_id', 'run_spec_version', 'healthy'] 151 | 152 | 153 | class MarathonPodCreatedEvent(MarathonEvent): 154 | KNOWN_ATTRIBUTES = ['client_ip', 'uri'] 155 | 156 | 157 | class MarathonPodUpdatedEvent(MarathonEvent): 158 | KNOWN_ATTRIBUTES = ['client_ip', 'uri'] 159 | 160 | 161 | class MarathonPodDeletedEvent(MarathonEvent): 162 | KNOWN_ATTRIBUTES = ['client_ip', 'uri'] 163 | 164 | 165 | class MarathonUnhealthyInstanceKillEvent(MarathonEvent): 166 | KNOWN_ATTRIBUTES = ['app_id', 'task_id', 'instance_id', 'version', 'reason', 'host', 'slave_id'] 167 | 168 | 169 | class EventFactory: 170 | 171 | """ 172 | Handle an event emitted from the Marathon EventBus 173 | See: https://mesosphere.github.io/marathon/docs/event-bus.html 174 | """ 175 | 176 | def __init__(self): 177 | pass 178 | 179 | event_to_class = { 180 | 'api_post_event': MarathonApiPostEvent, 181 | 'status_update_event': MarathonStatusUpdateEvent, 182 | 'framework_message_event': MarathonFrameworkMessageEvent, 183 | 'subscribe_event': MarathonSubscribeEvent, 184 | 'unsubscribe_event': MarathonUnsubscribeEvent, 185 | 'add_health_check_event': MarathonAddHealthCheckEvent, 186 | 'remove_health_check_event': MarathonRemoveHealthCheckEvent, 187 | 'failed_health_check_event': MarathonFailedHealthCheckEvent, 188 | 'health_status_changed_event': MarathonHealthStatusChangedEvent, 189 | 'unhealthy_task_kill_event': MarathonUnhealthyTaskKillEvent, 190 | 'group_change_success': MarathonGroupChangeSuccess, 191 | 'group_change_failed': MarathonGroupChangeFailed, 192 | 'deployment_success': MarathonDeploymentSuccess, 193 | 'deployment_failed': MarathonDeploymentFailed, 194 | 'deployment_info': MarathonDeploymentInfo, 195 | 'deployment_step_success': MarathonDeploymentStepSuccess, 196 | 'deployment_step_failure': MarathonDeploymentStepFailure, 197 | 'event_stream_attached': MarathonEventStreamAttached, 198 | 'event_stream_detached': MarathonEventStreamDetached, 199 | 'app_terminated_event': MarathonAppTerminatedEvent, 200 | 'instance_changed_event': MarathonInstanceChangedEvent, 201 | 'unknown_instance_terminated_event': MarathonUnknownInstanceTerminated, 202 | 'unhealthy_instance_kill_event': MarathonUnhealthyInstanceKillEvent, 203 | 'instance_health_changed_event': MarathonInstanceHealthChangedEvent, 204 | 'pod_created_event': MarathonPodCreatedEvent, 205 | 'pod_updated_event': MarathonPodUpdatedEvent, 206 | 'pod_deleted_event': MarathonPodDeletedEvent, 207 | } 208 | 209 | class_to_event = {v: k for k, v in event_to_class.items()} 210 | 211 | def process(self, event): 212 | event_type = event['eventType'] 213 | if event_type in self.event_to_class: 214 | clazz = self.event_to_class[event_type] 215 | return clazz.from_json(event) 216 | else: 217 | raise MarathonError(f'Unknown event_type: {event_type}, data: {event}') 218 | -------------------------------------------------------------------------------- /marathon/models/group.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonResource 2 | from .app import MarathonApp 3 | 4 | 5 | class MarathonGroup(MarathonResource): 6 | 7 | """Marathon group resource. 8 | 9 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#groups 10 | 11 | :param apps: 12 | :type apps: list[:class:`marathon.models.app.MarathonApp`] or list[dict] 13 | :param list[str] dependencies: 14 | :param groups: 15 | :type groups: list[:class:`marathon.models.group.MarathonGroup`] or list[dict] 16 | :param str id: 17 | :param pods: 18 | :type pods: list[:class:`marathon.models.pod.MarathonPod`] or list[dict] 19 | :param str version: 20 | """ 21 | 22 | def __init__(self, apps=None, dependencies=None, 23 | groups=None, id=None, pods=None, version=None, enforce_role=None): 24 | self.apps = [ 25 | a if isinstance(a, MarathonApp) else MarathonApp().from_json(a) 26 | for a in (apps or []) 27 | ] 28 | self.dependencies = dependencies or [] 29 | self.groups = [ 30 | g if isinstance(g, MarathonGroup) else MarathonGroup().from_json(g) 31 | for g in (groups or []) 32 | ] 33 | self.pods = [] 34 | # ToDo: Create class MarathonPod 35 | # self.pods = [ 36 | # p if isinstance(p, MarathonPod) else MarathonPod().from_json(p) 37 | # for p in (pods or []) 38 | # ] 39 | self.id = id 40 | self.version = version 41 | self.enforce_role = enforce_role 42 | -------------------------------------------------------------------------------- /marathon/models/info.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonObject, MarathonResource 2 | 3 | 4 | class MarathonInfo(MarathonResource): 5 | 6 | """Marathon Info. 7 | 8 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-v2-info 9 | Also: https://mesosphere.github.io/marathon/docs/generated/api.html#v2_info_get 10 | 11 | :param str framework_id: 12 | :param str leader: 13 | :param marathon_config: 14 | :type marathon_config: :class:`marathon.models.info.MarathonConfig` or dict 15 | :param str name: 16 | :param str version: 17 | :param zookeeper_config: 18 | :type zookeeper_config: :class:`marathon.models.info.MarathonZooKeeperConfig` or dict 19 | :param http_config: 20 | :type http_config: :class:`marathon.models.info.MarathonHttpConfig` or dict 21 | :param event_subscriber: 22 | :type event_subscriber: :class`marathon.models.info.MarathonEventSubscriber` or dict 23 | :param bool elected: 24 | :param str buildref: 25 | """ 26 | 27 | def __init__(self, event_subscriber=None, framework_id=None, http_config=None, leader=None, marathon_config=None, 28 | name=None, version=None, elected=None, zookeeper_config=None, buildref=None): 29 | if isinstance(event_subscriber, MarathonEventSubscriber): 30 | self.event_subscriber = event_subscriber 31 | elif event_subscriber is not None: 32 | self.event_subscriber = MarathonEventSubscriber().from_json( 33 | event_subscriber) 34 | else: 35 | self.event_subscriber = None 36 | self.framework_id = framework_id 37 | self.http_config = http_config if isinstance(http_config, MarathonHttpConfig) \ 38 | else MarathonHttpConfig().from_json(http_config) 39 | self.leader = leader 40 | self.marathon_config = marathon_config if isinstance(marathon_config, MarathonConfig) \ 41 | else MarathonConfig().from_json(marathon_config) 42 | self.name = name 43 | self.version = version 44 | self.elected = elected 45 | self.zookeeper_config = zookeeper_config if isinstance(zookeeper_config, MarathonZooKeeperConfig) \ 46 | else MarathonZooKeeperConfig().from_json(zookeeper_config) 47 | self.buildref = buildref 48 | 49 | 50 | class MarathonConfig(MarathonObject): 51 | 52 | """Marathon config resource. 53 | 54 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info 55 | 56 | :param bool checkpoint: 57 | :param str executor: 58 | :param int failover_timeout: 59 | :param type features: Undocumented object 60 | :param str framework_name: 61 | :param bool ha: 62 | :param str hostname: 63 | :param int leader_proxy_connection_timeout_ms: 64 | :param int leader_proxy_read_timeout_ms: 65 | :param int local_port_min: 66 | :param int local_port_max: 67 | :param bool maintenance_mode: 68 | :param str master: 69 | :param str mesos_leader_ui_url: 70 | :param str mesos_role: 71 | :param str mesos_user: 72 | :param str new_group_enforce_role: 73 | :param str webui_url: 74 | :param int reconciliation_initial_delay: 75 | :param int reconciliation_interval: 76 | :param int task_launch_timeout: 77 | :param int task_reservation_timeout: 78 | :param int marathon_store_timeout: 79 | :param str access_control_allow_origin: 80 | :param int decline_offer_duration: 81 | :param str default_network_name: 82 | :param str env_vars_prefix: 83 | :param int launch_token: 84 | :param int launch_token_refresh_interval: 85 | :param int max_instances_per_offer: 86 | :param str mesos_bridge_name: 87 | :param int mesos_heartbeat_failure_threshold: 88 | :param int mesos_heartbeat_interval: 89 | :param int min_revive_offers_interval: 90 | :param int offer_matching_timeout: 91 | :param int on_elected_prepare_timeout: 92 | :param bool revive_offers_for_new_apps: 93 | :param int revive_offers_repetitions: 94 | :param int scale_apps_initial_delay: 95 | :param int scale_apps_interval: 96 | :param bool store_cache: 97 | :param int task_launch_confirm_timeout: 98 | :param int task_lost_expunge_initial_delay: 99 | :param int task_lost_expunge_interval: 100 | """ 101 | 102 | def __init__(self, checkpoint=None, executor=None, failover_timeout=None, framework_name=None, ha=None, 103 | hostname=None, leader_proxy_connection_timeout_ms=None, leader_proxy_read_timeout_ms=None, 104 | local_port_min=None, local_port_max=None, maintenance_mode=None, master=None, mesos_leader_ui_url=None, 105 | mesos_role=None, mesos_user=None, new_group_enforce_role=None, webui_url=None, 106 | reconciliation_initial_delay=None, reconciliation_interval=None, task_launch_timeout=None, 107 | marathon_store_timeout=None, task_reservation_timeout=None, features=None, 108 | access_control_allow_origin=None, decline_offer_duration=None, default_network_name=None, 109 | env_vars_prefix=None, launch_token=None, launch_token_refresh_interval=None, 110 | max_instances_per_offer=None, mesos_bridge_name=None, 111 | mesos_heartbeat_failure_threshold=None, 112 | mesos_heartbeat_interval=None, min_revive_offers_interval=None, 113 | offer_matching_timeout=None, on_elected_prepare_timeout=None, 114 | revive_offers_for_new_apps=None, 115 | revive_offers_repetitions=None, scale_apps_initial_delay=None, 116 | scale_apps_interval=None, store_cache=None, 117 | task_launch_confirm_timeout=None, 118 | task_lost_expunge_initial_delay=None, 119 | task_lost_expunge_interval=None 120 | ): 121 | self.checkpoint = checkpoint 122 | self.executor = executor 123 | self.failover_timeout = failover_timeout 124 | self.features = features 125 | self.ha = ha 126 | self.hostname = hostname 127 | self.local_port_min = local_port_min 128 | self.local_port_max = local_port_max 129 | self.maintenance_mode = maintenance_mode 130 | self.master = master 131 | self.mesos_leader_ui_url = mesos_leader_ui_url 132 | self.mesos_role = mesos_role 133 | self.mesos_user = mesos_user 134 | self.new_group_enforce_role = new_group_enforce_role 135 | self.webui_url = webui_url 136 | self.reconciliation_initial_delay = reconciliation_initial_delay 137 | self.reconciliation_interval = reconciliation_interval 138 | self.task_launch_timeout = task_launch_timeout 139 | self.task_reservation_timeout = task_reservation_timeout 140 | self.marathon_store_timeout = marathon_store_timeout 141 | self.access_control_allow_origin = access_control_allow_origin 142 | self.decline_offer_duration = decline_offer_duration 143 | self.default_network_name = default_network_name 144 | self.env_vars_prefix = env_vars_prefix 145 | self.launch_token = launch_token 146 | self.launch_token_refresh_interval = launch_token_refresh_interval 147 | self.max_instances_per_offer = max_instances_per_offer 148 | self.mesos_bridge_name = mesos_bridge_name 149 | self.mesos_heartbeat_failure_threshold = mesos_heartbeat_failure_threshold 150 | self.mesos_heartbeat_interval = mesos_heartbeat_interval 151 | self.min_revive_offers_interval = min_revive_offers_interval 152 | self.offer_matching_timeout = offer_matching_timeout 153 | self.on_elected_prepare_timeout = on_elected_prepare_timeout 154 | self.revive_offers_for_new_apps = revive_offers_for_new_apps 155 | self.revive_offers_repetitions = revive_offers_repetitions 156 | self.scale_apps_initial_delay = scale_apps_initial_delay 157 | self.scale_apps_interval = scale_apps_interval 158 | self.store_cache = store_cache 159 | self.task_launch_confirm_timeout = task_launch_confirm_timeout 160 | self.task_lost_expunge_initial_delay = task_lost_expunge_initial_delay 161 | self.task_lost_expunge_interval = task_lost_expunge_interval 162 | 163 | 164 | class MarathonZooKeeperConfig(MarathonObject): 165 | 166 | """Marathon zookeeper config resource. 167 | 168 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info 169 | 170 | :param str zk: 171 | :param dict zk_future_timeout: 172 | :param str zk_hosts: 173 | :param str zk_max_versions: 174 | :param str zk_path: 175 | :param str zk_session_timeout: 176 | :param str zk_state: 177 | :param int zk_timeout: 178 | :param int zk_connection_timeout: 179 | :param bool zk_compression: 180 | :param int zk_compression_threshold: 181 | :param int zk_max_node_size: 182 | """ 183 | 184 | def __init__(self, zk=None, zk_future_timeout=None, zk_hosts=None, zk_max_versions=None, zk_path=None, 185 | zk_session_timeout=None, zk_state=None, zk_timeout=None, zk_connection_timeout=None, 186 | zk_compression=None, zk_compression_threshold=None, 187 | zk_max_node_size=None): 188 | self.zk = zk 189 | self.zk_hosts = zk_hosts 190 | self.zk_path = zk_path 191 | self.zk_state = zk_state 192 | self.zk_max_versions = zk_max_versions 193 | self.zk_timeout = zk_timeout 194 | self.zk_connection_timeout = zk_connection_timeout 195 | self.zk_future_timeout = zk_future_timeout 196 | self.zk_session_timeout = zk_session_timeout 197 | self.zk_compression = zk_compression 198 | self.zk_compression_threshold = zk_compression_threshold 199 | self.zk_max_node_size = zk_max_node_size 200 | 201 | 202 | class MarathonHttpConfig(MarathonObject): 203 | 204 | """Marathon http config resource. 205 | 206 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info 207 | 208 | :param str assets_path: 209 | :param int http_port: 210 | :param int https_port: 211 | """ 212 | 213 | def __init__(self, assets_path=None, http_port=None, https_port=None): 214 | self.assets_path = assets_path 215 | self.http_port = http_port 216 | self.https_port = https_port 217 | 218 | 219 | class MarathonEventSubscriber(MarathonObject): 220 | 221 | """Marathon event subscriber resource. 222 | 223 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info 224 | 225 | :param str type: 226 | :param list[str] http_endpoints: 227 | """ 228 | 229 | def __init__(self, type=None, http_endpoints=None): 230 | self.type = type 231 | self.http_endpoints = http_endpoints 232 | -------------------------------------------------------------------------------- /marathon/models/queue.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonResource 2 | from .app import MarathonApp 3 | 4 | 5 | class MarathonQueueItem(MarathonResource): 6 | 7 | """Marathon queue item. 8 | 9 | See: https://mesosphere.github.io/marathon/docs/rest-api.html#queue 10 | 11 | List all the tasks queued up or waiting to be scheduled. This is mainly 12 | used for troubleshooting and occurs when scaling changes are requested and the 13 | volume of scaling changes out paces the ability to schedule those tasks. In 14 | addition to the application in the queue, you see also the task count that 15 | needs to be started. 16 | 17 | If the task has a rate limit, then a delay to the start gets applied. You 18 | can see this delay for every application with the seconds to wait before 19 | the next launch will be tried. 20 | 21 | :param app: 22 | :type app: :class:`marathon.models.app.MarathonApp` or dict 23 | :param delay: queue item delay 24 | :type delay: :class:`marathon.models.app.MarathonQueueItemDelay` or dict 25 | :param bool overdue: 26 | """ 27 | 28 | def __init__(self, app=None, overdue=None, count=None, delay=None, since=None, 29 | processed_offers_summary=None, last_unused_offers=None): 30 | self.app = app if isinstance( 31 | app, MarathonApp) else MarathonApp().from_json(app) 32 | self.overdue = overdue 33 | self.count = count 34 | self.delay = delay if isinstance( 35 | delay, MarathonQueueItemDelay) else MarathonQueueItemDelay().from_json(delay) 36 | self.since = since 37 | self.processed_offers_summary = processed_offers_summary 38 | self.last_unused_offers = last_unused_offers 39 | 40 | 41 | class MarathonQueueItemDelay(MarathonResource): 42 | 43 | """Marathon queue item delay. 44 | 45 | :param int time_left_seconds: Seconds to wait before the next launch will be tried. 46 | :param bool overdue: Is the queue item overdue. 47 | """ 48 | 49 | def __init__(self, time_left_seconds=None, overdue=None): 50 | self.time_left_seconds = time_left_seconds 51 | self.overdue = overdue 52 | -------------------------------------------------------------------------------- /marathon/models/task.py: -------------------------------------------------------------------------------- 1 | from .base import MarathonResource, MarathonObject 2 | from ..util import to_datetime 3 | 4 | 5 | class MarathonTask(MarathonResource): 6 | 7 | """Marathon Task resource. 8 | 9 | :param str app_id: application id 10 | :param health_check_results: health check results 11 | :type health_check_results: list[:class:`marathon.models.MarathonHealthCheckResult`] or list[dict] 12 | :param str host: mesos slave running the task 13 | :param str id: task id 14 | :param list[int] ports: allocated ports 15 | :param list[int] service_ports: ports exposed for load balancing 16 | :param str state: State of the task e.g. TASK_RUNNING 17 | :param str slave_id: Mesos slave id 18 | :param staged_at: when this task was staged 19 | :type staged_at: datetime or str 20 | :param started_at: when this task was started 21 | :type started_at: datetime or str 22 | :param str version: app version with which this task was started 23 | :type region: str 24 | :param region: fault domain region support in DCOS EE 25 | :type zone: str 26 | :param zone: fault domain zone support in DCOS EE 27 | :type role: str 28 | :param role: mesos role 29 | """ 30 | 31 | DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' 32 | 33 | def __init__(self, app_id=None, health_check_results=None, host=None, id=None, ports=None, service_ports=None, 34 | slave_id=None, staged_at=None, started_at=None, version=None, ip_addresses=[], state=None, local_volumes=None, 35 | region=None, zone=None, role=None): 36 | self.app_id = app_id 37 | self.health_check_results = health_check_results or [] 38 | self.health_check_results = [ 39 | hcr if isinstance( 40 | hcr, MarathonHealthCheckResult) else MarathonHealthCheckResult().from_json(hcr) 41 | for hcr in (health_check_results or []) if any(health_check_results) 42 | ] 43 | self.host = host 44 | self.id = id 45 | self.ports = ports or [] 46 | self.service_ports = service_ports or [] 47 | self.slave_id = slave_id 48 | self.staged_at = to_datetime(staged_at) 49 | self.started_at = to_datetime(started_at) 50 | self.state = state 51 | self.version = version 52 | self.ip_addresses = [ 53 | ipaddr if isinstance( 54 | ip_addresses, MarathonIpAddress) else MarathonIpAddress().from_json(ipaddr) 55 | for ipaddr in (ip_addresses or [])] 56 | self.local_volumes = local_volumes or [] 57 | self.region = region 58 | self.zone = zone 59 | self.role = role 60 | 61 | 62 | class MarathonIpAddress(MarathonObject): 63 | """ 64 | """ 65 | def __init__(self, ip_address=None, protocol=None): 66 | self.ip_address = ip_address 67 | self.protocol = protocol 68 | 69 | 70 | class MarathonHealthCheckResult(MarathonObject): 71 | 72 | """Marathon health check result. 73 | 74 | See https://mesosphere.github.io/marathon/docs/health-checks.html 75 | 76 | :param bool alive: boolean to determine task health 77 | :param int consecutive_failures: number of failed healthchecks in a row 78 | :param str first_success: first time when which healthcheck succeeded 79 | :param str last_failure: last time when which healthcheck failed 80 | :param str last_failure_cause: cause for last failure 81 | :param str last_success: last time when which healthcheck succeeded 82 | :param str task_id: task id 83 | :param str instance_id: instance id 84 | """ 85 | 86 | DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' 87 | 88 | def __init__(self, alive=None, consecutive_failures=None, first_success=None, 89 | last_failure=None, last_success=None, task_id=None, 90 | last_failure_cause=None, instance_id=None): 91 | self.alive = alive 92 | self.consecutive_failures = consecutive_failures 93 | self.first_success = to_datetime(first_success) 94 | self.last_failure = to_datetime(last_failure) 95 | self.last_success = to_datetime(last_success) 96 | self.task_id = task_id 97 | self.last_failure_cause = last_failure_cause 98 | self.instance_id = instance_id 99 | -------------------------------------------------------------------------------- /marathon/util.py: -------------------------------------------------------------------------------- 1 | # collections.abc new as of 3.3, and collections is deprecated. collections 2 | # will be unavailable in 3.9 3 | try: 4 | import collections.abc as collections 5 | except ImportError: 6 | import collections 7 | 8 | import datetime 9 | import logging 10 | 11 | try: 12 | import json 13 | except ImportError: 14 | import simplejson as json 15 | import re 16 | 17 | 18 | def get_log(): 19 | return logging.getLogger(__name__.split('.')[0]) 20 | 21 | 22 | class MarathonJsonEncoder(json.JSONEncoder): 23 | 24 | """Custom JSON encoder for Marathon object serialization.""" 25 | 26 | def default(self, obj): 27 | if hasattr(obj, 'json_repr'): 28 | return self.default(obj.json_repr()) 29 | 30 | if isinstance(obj, datetime.datetime): 31 | return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') 32 | 33 | if isinstance(obj, collections.Iterable) and not isinstance(obj, str): 34 | try: 35 | return {k: self.default(v) for k, v in obj.items()} 36 | except AttributeError: 37 | return [self.default(e) for e in obj] 38 | 39 | return obj 40 | 41 | 42 | class MarathonMinimalJsonEncoder(json.JSONEncoder): 43 | 44 | """Custom JSON encoder for Marathon object serialization.""" 45 | 46 | def default(self, obj): 47 | if hasattr(obj, 'json_repr'): 48 | return self.default(obj.json_repr(minimal=True)) 49 | 50 | if isinstance(obj, datetime.datetime): 51 | return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') 52 | 53 | if isinstance(obj, collections.Iterable) and not isinstance(obj, str): 54 | try: 55 | return {k: self.default(v) for k, v in obj.items() if (v or v in (False, 0))} 56 | except AttributeError: 57 | return [self.default(e) for e in obj if (e or e in (False, 0))] 58 | 59 | return obj 60 | 61 | 62 | def to_camel_case(snake_str): 63 | words = snake_str.split('_') 64 | return words[0] + ''.join(w.capitalize() for w in words[1:]) 65 | 66 | 67 | def to_snake_case(camel_str): 68 | s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str) 69 | return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 70 | 71 | 72 | DATETIME_FORMATS = [ 73 | '%Y-%m-%dT%H:%M:%S.%fZ', 74 | '%Y-%m-%dT%H:%M:%SZ', # Marathon omits milliseconds when they would be .000 75 | ] 76 | 77 | 78 | def to_datetime(timestamp): 79 | if (timestamp is None or isinstance(timestamp, datetime.datetime)): 80 | return timestamp 81 | else: 82 | for fmt in DATETIME_FORMATS: 83 | try: 84 | return datetime.datetime.strptime(timestamp, fmt).replace(tzinfo=datetime.timezone.utc) 85 | except ValueError: 86 | pass 87 | raise ValueError(f'Unrecognized datetime format: {timestamp}') 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.20.0 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from setuptools import setup 4 | 5 | extra = {} 6 | if sys.version_info >= (3,): 7 | extra['use_2to3'] = True 8 | 9 | setup( 10 | name='marathon', 11 | version='0.13.0', 12 | description='Marathon Client Library', 13 | long_description="""Python interface to the Mesos Marathon REST API.""", 14 | author='Mike Babineau', 15 | author_email='michael.babineau@gmail.com', 16 | install_requires=['requests>=2.4.0', 'requests-toolbelt>=0.4.0'], 17 | url='https://github.com/thefactory/marathon-python', 18 | packages=['marathon', 'marathon.models'], 19 | license='MIT', 20 | platforms='Posix; MacOS X; Windows', 21 | classifiers=[ 22 | 'Development Status :: 5 - Production/Stable', 23 | 'Intended Audience :: Developers', 24 | 'Intended Audience :: System Administrators', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.6', 29 | 'Programming Language :: Python :: 3.7', 30 | 'Programming Language :: Python', 31 | 'Topic :: Software Development :: Libraries :: Python Modules' 32 | ], 33 | **extra 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import requests_mock 2 | from marathon import MarathonClient 3 | from marathon import models 4 | 5 | 6 | def test_get_deployments_pre_1_0(): 7 | fake_response = """[ 8 | { 9 | "affectedApps": [ 10 | "/test" 11 | ], 12 | "id": "fakeid", 13 | "steps": [ 14 | [ 15 | { 16 | "action": "ScaleApplication", 17 | "app": "/test" 18 | } 19 | ] 20 | ], 21 | "currentActions": [ 22 | { 23 | "action": "ScaleApplication", 24 | "app": "/test" 25 | } 26 | ], 27 | "version": "fakeversion", 28 | "currentStep": 1, 29 | "totalSteps": 1 30 | } 31 | ]""" 32 | with requests_mock.mock() as m: 33 | m.get('http://fake_server/v2/deployments', text=fake_response) 34 | mock_client = MarathonClient(servers='http://fake_server') 35 | actual_deployments = mock_client.list_deployments() 36 | expected_deployments = [models.MarathonDeployment( 37 | id="fakeid", 38 | steps=[ 39 | [models.MarathonDeploymentAction( 40 | action="ScaleApplication", app="/test")]], 41 | current_actions=[models.MarathonDeploymentAction( 42 | action="ScaleApplication", app="/test")], 43 | current_step=1, 44 | total_steps=1, 45 | affected_apps=["/test"], 46 | version="fakeversion" 47 | )] 48 | assert expected_deployments == actual_deployments 49 | 50 | 51 | def test_get_deployments_post_1_0(): 52 | fake_response = """[ 53 | { 54 | "id": "4d2ff4d8-fbe5-4239-a886-f0831ed68d20", 55 | "version": "2016-04-20T18:00:20.084Z", 56 | "affectedApps": [ 57 | "/test-trivial-app" 58 | ], 59 | "steps": [ 60 | { 61 | "actions": [ 62 | { 63 | "type": "StartApplication", 64 | "app": "/test-trivial-app" 65 | } 66 | ] 67 | }, 68 | { 69 | "actions": [ 70 | { 71 | "type": "ScaleApplication", 72 | "app": "/test-trivial-app" 73 | } 74 | ] 75 | } 76 | ], 77 | "currentActions": [ 78 | { 79 | "action": "ScaleApplication", 80 | "app": "/test-trivial-app", 81 | "readinessCheckResults": [] 82 | } 83 | ], 84 | "currentStep": 2, 85 | "totalSteps": 2 86 | } 87 | ]""" 88 | with requests_mock.mock() as m: 89 | m.get('http://fake_server/v2/deployments', text=fake_response) 90 | mock_client = MarathonClient(servers='http://fake_server') 91 | actual_deployments = mock_client.list_deployments() 92 | expected_deployments = [models.MarathonDeployment( 93 | id="4d2ff4d8-fbe5-4239-a886-f0831ed68d20", 94 | steps=[ 95 | models.MarathonDeploymentStep( 96 | actions=[models.MarathonDeploymentAction( 97 | type="StartApplication", app="/test-trivial-app")], 98 | ), 99 | models.MarathonDeploymentStep( 100 | actions=[models.MarathonDeploymentAction( 101 | type="ScaleApplication", app="/test-trivial-app")], 102 | ), 103 | ], 104 | current_actions=[models.MarathonDeploymentAction( 105 | action="ScaleApplication", app="/test-trivial-app", readiness_check_results=[]) 106 | ], 107 | current_step=2, 108 | total_steps=2, 109 | affected_apps=["/test-trivial-app"], 110 | version="2016-04-20T18:00:20.084Z" 111 | )] 112 | # Helpful for tox to see the diff 113 | assert expected_deployments[0].__dict__ == actual_deployments[0].__dict__ 114 | assert expected_deployments == actual_deployments 115 | 116 | 117 | def test_list_tasks_with_app_id(): 118 | fake_response = '{ "tasks": [ { "appId": "/anapp", "healthCheckResults": ' \ 119 | '[ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", ' \ 120 | '"lastFailure": null, "lastSuccess": "2014-10-03T22:57:41.643Z", "taskId": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" } ],' \ 121 | ' "host": "10.141.141.10", "id": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", "ports": [ 31000 ], ' \ 122 | '"servicePorts": [ 9000 ], "stagedAt": "2014-10-03T22:16:27.811Z", "startedAt": "2014-10-03T22:57:41.587Z", ' \ 123 | '"version": "2014-10-03T22:16:23.634Z" }]}' 124 | with requests_mock.mock() as m: 125 | m.get('http://fake_server/v2/apps//anapp/tasks', text=fake_response) 126 | mock_client = MarathonClient(servers='http://fake_server') 127 | actual_deployments = mock_client.list_tasks(app_id='/anapp') 128 | expected_deployments = [models.task.MarathonTask( 129 | app_id="/anapp", 130 | health_check_results=[ 131 | models.task.MarathonHealthCheckResult( 132 | alive=True, 133 | consecutive_failures=0, 134 | first_success="2014-10-03T22:57:02.246Z", 135 | last_failure=None, 136 | last_success="2014-10-03T22:57:41.643Z", 137 | task_id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" 138 | ) 139 | ], 140 | host="10.141.141.10", 141 | id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", 142 | ports=[ 143 | 31000 144 | ], 145 | service_ports=[ 146 | 9000 147 | ], 148 | staged_at="2014-10-03T22:16:27.811Z", 149 | started_at="2014-10-03T22:57:41.587Z", 150 | version="2014-10-03T22:16:23.634Z" 151 | )] 152 | assert actual_deployments == expected_deployments 153 | 154 | 155 | def test_list_tasks_without_app_id(): 156 | fake_response = '{ "tasks": [ { "appId": "/anapp", "healthCheckResults": ' \ 157 | '[ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", "lastFailure": null, ' \ 158 | '"lastSuccess": "2014-10-03T22:57:41.643Z", "taskId": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" } ],' \ 159 | ' "host": "10.141.141.10", "id": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", "ports": [ 31000 ], ' \ 160 | '"servicePorts": [ 9000 ], "stagedAt": "2014-10-03T22:16:27.811Z", "startedAt": "2014-10-03T22:57:41.587Z", ' \ 161 | '"version": "2014-10-03T22:16:23.634Z" }, { "appId": "/anotherapp", ' \ 162 | '"healthCheckResults": [ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", ' \ 163 | '"lastFailure": null, "lastSuccess": "2014-10-03T22:57:41.649Z", "taskId": "bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799" } ], ' \ 164 | '"host": "10.141.141.10", "id": "bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799", "ports": [ 31001 ], "servicePorts": [ 9000 ], ' \ 165 | '"stagedAt": "2014-10-03T22:16:33.814Z", "startedAt": "2014-10-03T22:57:41.593Z", "version": "2014-10-03T22:16:23.634Z" } ] }' 166 | with requests_mock.mock() as m: 167 | m.get('http://fake_server/v2/tasks', text=fake_response) 168 | mock_client = MarathonClient(servers='http://fake_server') 169 | actual_deployments = mock_client.list_tasks() 170 | expected_deployments = [ 171 | models.task.MarathonTask( 172 | app_id="/anapp", 173 | health_check_results=[ 174 | models.task.MarathonHealthCheckResult( 175 | alive=True, 176 | consecutive_failures=0, 177 | first_success="2014-10-03T22:57:02.246Z", 178 | last_failure=None, 179 | last_success="2014-10-03T22:57:41.643Z", 180 | task_id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" 181 | ) 182 | ], 183 | host="10.141.141.10", 184 | id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", 185 | ports=[ 186 | 31000 187 | ], 188 | service_ports=[ 189 | 9000 190 | ], 191 | staged_at="2014-10-03T22:16:27.811Z", 192 | started_at="2014-10-03T22:57:41.587Z", 193 | version="2014-10-03T22:16:23.634Z" 194 | ), 195 | models.task.MarathonTask( 196 | app_id="/anotherapp", 197 | health_check_results=[ 198 | models.task.MarathonHealthCheckResult( 199 | alive=True, 200 | consecutive_failures=0, 201 | first_success="2014-10-03T22:57:02.246Z", 202 | last_failure=None, 203 | last_success="2014-10-03T22:57:41.649Z", 204 | task_id="bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799" 205 | ) 206 | ], 207 | host="10.141.141.10", 208 | id="bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799", 209 | ports=[31001], 210 | service_ports=[9000], 211 | staged_at="2014-10-03T22:16:33.814Z", 212 | started_at="2014-10-03T22:57:41.593Z", 213 | version="2014-10-03T22:16:23.634Z" 214 | )] 215 | assert actual_deployments == expected_deployments 216 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | 5 | from marathon.exceptions import MarathonHttpError, InternalServerError 6 | 7 | 8 | def test_400_error(): 9 | fake_response = requests.Response() 10 | fake_message = "Invalid JSON" 11 | fake_details = [{"path": "/taskKillGracePeriodSeconds", "errors": ["error.expected.jsnumber"]}] 12 | fake_response._content = json.dumps({"message": fake_message, "details": fake_details}).encode() 13 | fake_response.status_code = 400 14 | fake_response.headers['Content-Type'] = 'application/json' 15 | 16 | exc = MarathonHttpError(fake_response) 17 | assert exc.status_code == 400 18 | assert exc.error_message == fake_message 19 | assert exc.error_details == fake_details 20 | 21 | 22 | def test_503_error(): 23 | fake_response = requests.Response() 24 | fake_response._content = """ 25 | 26 | Error 503 27 | 28 | 29 |

HTTP ERROR: 503

30 | 31 | """ 32 | fake_response.reason = "reason" 33 | fake_response.status_code = 503 34 | 35 | exc = InternalServerError(fake_response) 36 | assert exc.status_code == 503 37 | assert exc.error_message == "reason" 38 | assert not hasattr(exc, 'error_details') 39 | -------------------------------------------------------------------------------- /tests/test_model_app.py: -------------------------------------------------------------------------------- 1 | from marathon.models.app import MarathonApp 2 | import unittest 3 | 4 | 5 | class MarathonAppTest(unittest.TestCase): 6 | 7 | def test_env_defaults_to_empty_dict(self): 8 | """ 9 | é testé 10 | """ 11 | app = MarathonApp() 12 | self.assertEquals(app.env, {}) 13 | 14 | def test_add_env_empty_dict(self): 15 | app = MarathonApp() 16 | app.add_env("MY_ENV", "my-value") 17 | self.assertDictEqual({"MY_ENV": "my-value"}, app.env) 18 | 19 | def test_add_env_non_empty_dict(self): 20 | env_data = {"OTHER_ENV": "other-value"} 21 | app = MarathonApp(env=env_data) 22 | 23 | app.add_env("MY_ENV", "my-value") 24 | self.assertDictEqual({"MY_ENV": "my-value", "OTHER_ENV": "other-value"}, app.env) 25 | -------------------------------------------------------------------------------- /tests/test_model_constraint.py: -------------------------------------------------------------------------------- 1 | from marathon.models.app import MarathonConstraint 2 | import unittest 3 | 4 | 5 | class MarathonConstraintTests(unittest.TestCase): 6 | def test_repr_with_value(self): 7 | constraint = MarathonConstraint('a_field', 'OPERATOR', 'a_value') 8 | representation = repr(constraint) 9 | self.assertEqual(representation, 10 | "MarathonConstraint::a_field:OPERATOR:a_value") 11 | 12 | def test_repr_without_value(self): 13 | constraint = MarathonConstraint('a_field', 'OPERATOR') 14 | representation = repr(constraint) 15 | self.assertEqual(representation, 16 | "MarathonConstraint::a_field:OPERATOR") 17 | 18 | def test_json_repr_with_value(self): 19 | constraint = MarathonConstraint('a_field', 'OPERATOR', 'a_value') 20 | json_repr = constraint.json_repr() 21 | self.assertEqual(json_repr, ['a_field', 'OPERATOR', 'a_value']) 22 | 23 | def test_json_repr_without_value(self): 24 | constraint = MarathonConstraint('a_field', 'OPERATOR') 25 | json_repr = constraint.json_repr() 26 | self.assertEqual(json_repr, ['a_field', 'OPERATOR']) 27 | 28 | def test_from_json_with_value(self): 29 | constraint = MarathonConstraint.from_json(['a_field', 'OPERATOR', 'a_value']) 30 | self.assertEqual(constraint, 31 | MarathonConstraint('a_field', 'OPERATOR', 'a_value')) 32 | 33 | def test_from_json_without_value(self): 34 | constraint = MarathonConstraint.from_json(['a_field', 'OPERATOR']) 35 | self.assertEqual(constraint, MarathonConstraint('a_field', 'OPERATOR')) 36 | 37 | def test_from_string_with_value(self): 38 | constraint = MarathonConstraint.from_string('a_field:OPERATOR:a_value') 39 | self.assertEqual(constraint, 40 | MarathonConstraint('a_field', 'OPERATOR', 'a_value')) 41 | 42 | def test_from_string_without_value(self): 43 | constraint = MarathonConstraint.from_string('a_field:OPERATOR') 44 | self.assertEqual(constraint, MarathonConstraint('a_field', 'OPERATOR')) 45 | 46 | def test_from_string_raises_an_error_for_invalid_format(self): 47 | with self.assertRaises(ValueError): 48 | MarathonConstraint.from_string('a_field:OPERATOR:a_value:') 49 | 50 | with self.assertRaises(ValueError): 51 | MarathonConstraint.from_string('a_field') 52 | 53 | with self.assertRaises(ValueError): 54 | MarathonConstraint.from_string('a_field:OPERATOR:a_value:something') 55 | -------------------------------------------------------------------------------- /tests/test_model_deployment.py: -------------------------------------------------------------------------------- 1 | from marathon.models.deployment import MarathonDeployment 2 | import unittest 3 | 4 | 5 | class MarathonDeploymentTest(unittest.TestCase): 6 | 7 | def test_env_defaults_to_empty_dict(self): 8 | """ 9 | é testé 10 | """ 11 | deployment_json = { 12 | "id": "ID", 13 | "version": "2020-05-30T07:35:04.695Z", 14 | "affectedApps": ["/app"], 15 | "affectedPods": [], 16 | "steps": [{ 17 | "actions": [{ 18 | "action": "RestartApplication", 19 | "app": "/app" 20 | }] 21 | }], 22 | "currentActions": [{ 23 | "action": "RestartApplication", 24 | "app": "/app", 25 | "readinessCheckResults": [] 26 | }], 27 | "currentStep": 1, 28 | "totalSteps": 1 29 | } 30 | 31 | deployment = MarathonDeployment.from_json(deployment_json) 32 | self.assertEquals(deployment.id, "ID") 33 | self.assertEquals(deployment.current_actions[0].app, "/app") 34 | -------------------------------------------------------------------------------- /tests/test_model_event.py: -------------------------------------------------------------------------------- 1 | from marathon.models.events import EventFactory, MarathonStatusUpdateEvent 2 | from marathon.models.task import MarathonIpAddress 3 | import unittest 4 | 5 | 6 | class MarathonEventTest(unittest.TestCase): 7 | 8 | def test_event_factory(self): 9 | self.assertEqual( 10 | set(EventFactory.event_to_class.keys()), 11 | set(EventFactory.class_to_event.values()), 12 | ) 13 | 14 | def test_marathon_event(self): 15 | """Test that we can process at least one kind of event.""" 16 | payload = { 17 | "eventType": "status_update_event", 18 | "slaveId": "slave-01", 19 | "taskId": "task-01", 20 | "taskStatus": "TASK_RUNNING", 21 | "message": "Some message", 22 | "appId": "/foo/bar", 23 | "host": "host-01", 24 | "ipAddresses": [ 25 | {"ip_address": "127.0.0.1", "protocol": "tcp"}, 26 | {"ip_address": "127.0.0.1", "protocol": "udp"}, 27 | ], 28 | "ports": [0, 1], 29 | "version": "1234", 30 | "timestamp": 12345, 31 | } 32 | factory = EventFactory() 33 | event = factory.process(payload) 34 | 35 | expected_event = MarathonStatusUpdateEvent( 36 | event_type="status_update_event", 37 | timestamp=12345, 38 | slave_id="slave-01", 39 | task_id="task-01", 40 | task_status="TASK_RUNNING", 41 | message="Some message", 42 | app_id="/foo/bar", 43 | host="host-01", 44 | ports=[0, 1], 45 | version="1234", 46 | ) 47 | expected_event.ip_addresses = [ 48 | MarathonIpAddress(ip_address="127.0.0.1", protocol="tcp"), 49 | MarathonIpAddress(ip_address="127.0.0.1", protocol="udp"), 50 | ] 51 | 52 | self.assertEqual(event.to_json(), expected_event.to_json()) 53 | -------------------------------------------------------------------------------- /tests/test_model_group.py: -------------------------------------------------------------------------------- 1 | from marathon.models.group import MarathonGroup 2 | import unittest 3 | 4 | 5 | class MarathonGroupTest(unittest.TestCase): 6 | 7 | def test_from_json_parses_root_group(self): 8 | data = { 9 | "id": "/", 10 | "groups": [ 11 | {"id": "/foo", "apps": []}, 12 | {"id": "/bla", "apps": []}, 13 | ], 14 | "apps": [] 15 | } 16 | group = MarathonGroup().from_json(data) 17 | self.assertEqual("/", group.id) 18 | 19 | def test_from_json_parses_group_with_enforce_role(self): 20 | data = { 21 | "id": "/mygroup/works", 22 | "groups": [ 23 | {"id": "/foo", "apps": []}, 24 | ], 25 | "apps": [], 26 | "enforceRole": False, 27 | 28 | } 29 | group = MarathonGroup().from_json(data) 30 | self.assertEqual("/mygroup/works", group.id) 31 | -------------------------------------------------------------------------------- /tests/test_model_object.py: -------------------------------------------------------------------------------- 1 | from marathon.models.base import MarathonObject 2 | from marathon.models.base import MarathonResource 3 | import unittest 4 | 5 | 6 | class MarathonObjectTest(unittest.TestCase): 7 | 8 | def test_hashable(self): 9 | """ 10 | Regression test for issue #203 11 | 12 | MarathonObject defined __eq__ but not __hash__, meaning that in 13 | in Python2.7 MarathonObjects are hashable, but in Python3 they're not, 14 | 15 | This test ensures that we are hashable in all versions of python 16 | """ 17 | obj = MarathonObject() 18 | collection = {} 19 | collection[obj] = True 20 | assert collection[obj] 21 | 22 | 23 | class MarathonResourceHashable(unittest.TestCase): 24 | 25 | def test_hashable(self): 26 | """ 27 | Regression test for issue #203 28 | 29 | MarathonResource defined __eq__ but not __hash__, meaning that in 30 | in Python2.7 MarathonResources are hashable, but in Python3 they're 31 | not 32 | 33 | This test ensures that we are hashable in all versions of python 34 | """ 35 | obj = MarathonResource() 36 | collection = {} 37 | collection[obj] = True 38 | assert collection[obj] 39 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from marathon.util import to_camel_case, to_snake_case, to_datetime 3 | 4 | 5 | def _apply_on_pairs(f): 6 | # this strategy is used to have the assertion stack trace 7 | # point to the right pair of strings in case of test failure 8 | f('foo', 'foo') 9 | f('foo42', 'foo42') 10 | f('fooBar', 'foo_bar') 11 | f('f0o42Bar', 'f0o42_bar') 12 | f('fooBarBaz', 'foo_bar_baz') 13 | f('ignoreHttp1xx', 'ignore_http1xx') 14 | f('whereAmI', 'where_am_i') 15 | f('iSee', 'i_see') 16 | f('doISee', 'do_i_see') 17 | 18 | 19 | def test_to_camel_case(): 20 | def test(camel, snake): 21 | assert to_camel_case(snake) == camel 22 | 23 | _apply_on_pairs(test) 24 | 25 | 26 | def test_to_snake_case(): 27 | def test(camel, snake): 28 | assert to_snake_case(camel) == snake 29 | 30 | _apply_on_pairs(test) 31 | 32 | 33 | def test_version_info_datetime(): 34 | assert to_datetime("2017-09-28T00:31:55Z") == datetime(2017, 9, 28, 0, 31, 55, tzinfo=timezone.utc) 35 | assert to_datetime("2017-09-28T00:31:55.4Z") == datetime(2017, 9, 28, 0, 31, 55, 400000, tzinfo=timezone.utc) 36 | assert to_datetime("2017-09-28T00:31:55.004Z") == datetime(2017, 9, 28, 0, 31, 55, 4000, tzinfo=timezone.utc) 37 | assert to_datetime("2017-09-28T00:31:55.00042Z") == datetime(2017, 9, 28, 0, 31, 55, 420, tzinfo=timezone.utc) 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | passenv = TRAVIS 3 | usedevelop=True 4 | envlist={test,itest}-{py36,py37},pep8 5 | 6 | [testenv] 7 | passenv = TRAVIS MARATHONVERSION DOCKER_HOST DOCKER_TLS_VERIFY DOCKER_CERT_PATH DOCKER_MACHINE_NAME 8 | basepython = 9 | py36: python3.6 10 | py37: python3.7 11 | whitelist_externals=/bin/bash 12 | skipsdist=True 13 | changedir = 14 | test: {toxinidir} 15 | itest: {toxinidir}/itests/ 16 | deps = 17 | -rrequirements.txt 18 | requests-mock==1.0.0 19 | docker-compose 20 | behave 21 | pytest 22 | mock 23 | commands = 24 | test: py.test -s -vv {posargs:tests} 25 | itest: ./itest.sh {posargs} 26 | 27 | [testenv:pep8] 28 | basepython = python3.6 29 | deps = flake8 30 | commands = flake8 . 31 | 32 | [flake8] 33 | exclude = .tox,*.egg,docs,build,__init__.py 34 | max-line-length = 160 35 | 36 | [testenv:pre-commit] 37 | basepython = python3.7 38 | deps = 39 | pre-commit>=1.20.0 40 | commands = 41 | pre-commit install -f --install-hooks 42 | pre-commit run --all-files --------------------------------------------------------------------------------