diff --git a/.gitignore b/.gitignore index 486901b..2947fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ docs/_build/ packer_cache /gh-pages +itests/marathon-version +.pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..80e93db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +- repo: https://github.com/asottile/pyupgrade + rev: v1.25.1 + hooks: + - id: pyupgrade + args: [--py36-plus] \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..542dd88 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,39 @@ +env: + - MARATHONVERSION: v1.10.19 + - MARATHONVERSION: v1.9.109 + - MARATHONVERSION: v1.6.322 + - MARATHONVERSION: v1.4.11 + - MARATHONVERSION: v1.3.0 + - MARATHONVERSION: v1.1.2 + +language: python +services: + - docker +python: + - 3.6 + - 3.7 +before_install: + - docker pull "missingcharacter/marathon-python:${MARATHONVERSION}" + - docker run --name marathon-python -d -p 8080:8080 -p 5050:5050 "missingcharacter/marathon-python:${MARATHONVERSION}" +install: + - pip install tox +script: + - make test-py${TRAVIS_PYTHON_VERSION/./} + - make itests-py${TRAVIS_PYTHON_VERSION/./} + +# Work around travis-ci/travis-ci#5227 +addons: + hostname: localhost + +os: linux +dist: xenial + +deploy: + - provider: pypi + user: yelplabs + password: + secure: "Wl8GWxsfPy4KoORYH26N3FllvMeWrifzeCbEx2Af4corcBQl43heeiFRRTlUOcSX0TIasER21PUvQ0R0cAgCjfknDb3SOROcRtcSBe16+cMmvwysfxcAx2OcF1UYBPY8e/qOsGge2Zyzx2PAPNEmJoWKbIT3vUJ4WvlLVeGYdJ0=" + on: + tags: true + condition: $MARATHONVERSION == "v1.6.322" + repo: thefactory/marathon-python diff --git a/CHANGELOG.md b/CHANGELOG.md index 757a5be..dc2946f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,93 +1,678 @@ -## 0.6.10 (2014-12-17) +# Changelog -Changes: -* Added `force` option to `delete_deployment()` +## [0.13.0](https://github.com/thefactory/marathon-python/tree/0.13.0) (2020-08-21) -## 0.6.9 (2014-12-03) +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.12.0...0.13.0) -Changes: -* Added lastFailureCause field to app.lastTaskFailure -* Added `parameters` and `privileged` fields to `MarathonContainer` +**Closed issues:** -## 0.6.8 (2014-11-18) +- about view one instance app logs real time [\#277](https://github.com/thefactory/marathon-python/issues/277) -Changes: -* (Temporarily) Added `apps` field to `DeploymentAction` (https://github.com/mesosphere/marathon/pull/802) +**Merged pull requests:** -## 0.6.7 (2014-11-18) +- Fix deserialization for Deploment model [\#276](https://github.com/thefactory/marathon-python/pull/276) ([missingcharacter](https://github.com/missingcharacter)) +- Local tests no longer need docker-compose and rely on "mini-marathon" [\#274](https://github.com/thefactory/marathon-python/pull/274) ([missingcharacter](https://github.com/missingcharacter)) +- Adding support for v1.10.19 [\#273](https://github.com/thefactory/marathon-python/pull/273) ([missingcharacter](https://github.com/missingcharacter)) +- Moving marathon away from ubuntu and starting from mesosphere dockerhub [\#272](https://github.com/thefactory/marathon-python/pull/272) ([missingcharacter](https://github.com/missingcharacter)) +- Updates for Marathon 1.9.109 [\#270](https://github.com/thefactory/marathon-python/pull/270) ([missingcharacter](https://github.com/missingcharacter)) +- Fix return value for kill\_given\_tasks. [\#268](https://github.com/thefactory/marathon-python/pull/268) ([Tilian](https://github.com/Tilian)) +- Bump requests from 2.11.1 to 2.20.0 [\#266](https://github.com/thefactory/marathon-python/pull/266) ([dependabot[bot]](https://github.com/apps/dependabot)) -Changes: -* Updated `list_tasks()` and `get_info()` to match latest Marathon response signature -* Fixed `__repr__` for `MarathonInfo()` and other `MarathonResources` without `id +## [0.12.0](https://github.com/thefactory/marathon-python/tree/0.12.0) (2019-11-14) -## 0.6.6 (2014-11-17) +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.11.0...0.12.0) -Changes: -* Improved behavior of `MarathonClient.update_app()` to strip `version` from the passed `app` +**Closed issues:** -## 0.6.5 (2014-11-14) +- Downloading Log For App [\#265](https://github.com/thefactory/marathon-python/issues/265) -Changes: -* Fixed bug with `MarathonClient.scale_app()` and add `force` support +**Merged pull requests:** -## 0.6.4 (2014-11-13) +- Always create TZ-aware datetime objects. \(also drop support for python 2\) [\#267](https://github.com/thefactory/marathon-python/pull/267) ([EvanKrall](https://github.com/EvanKrall)) +- Compatible with event stream redirect behavior. [\#262](https://github.com/thefactory/marathon-python/pull/262) ([fengyehong](https://github.com/fengyehong)) -Support for Marathon 0.7.5 +## [0.11.0](https://github.com/thefactory/marathon-python/tree/0.11.0) (2019-01-15) -Changes: -* Added support for app.lastTaskFailure -* Added support for task.healthCheckResult -* Fixed support for app.upgradeStrategy +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.10.0...0.11.0) -## 0.6.3 (2014-10-10) +**Merged pull requests:** -Changes: -* Added support for embedding tasks in get/list app results -* Switched to patch-style updates for apps and groups (send partial object) +- Added region and zone members to task model. [\#260](https://github.com/thefactory/marathon-python/pull/260) ([gisjedi](https://github.com/gisjedi)) +- Exception [\#259](https://github.com/thefactory/marathon-python/pull/259) ([fengyehong](https://github.com/fengyehong)) +- New release 0.10.0 [\#256](https://github.com/thefactory/marathon-python/pull/256) ([solarkennedy](https://github.com/solarkennedy)) -## 0.6.2 (2014-10-09) +## [0.10.0](https://github.com/thefactory/marathon-python/tree/0.10.0) (2018-08-08) -Changes: -* Added support for service port in container port mappings (Marathon 0.7.3) +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.3...0.10.0) -## 0.6.2 (2014-10-09) +**Closed issues:** -Changes: -* Added support for LIKE and UNLIKE constraint operators -* Added support for patch-style app and group updates +- Travis tests are broken [\#249](https://github.com/thefactory/marathon-python/issues/249) +- SSE SSL authentication not supported [\#247](https://github.com/thefactory/marathon-python/issues/247) +- Lack of support for container.networks [\#243](https://github.com/thefactory/marathon-python/issues/243) +- \_\_init\_\_\(\) got an unexpected keyword argument 'port\_mappings' [\#237](https://github.com/thefactory/marathon-python/issues/237) +- Wrong health check object generated for COMMAND protocol [\#222](https://github.com/thefactory/marathon-python/issues/222) -## 0.6.1 (2014-09-29) +**Merged pull requests:** -Changes: -* Fixed broken exception import +- Add support for mesos 1.6 [\#255](https://github.com/thefactory/marathon-python/pull/255) ([jdewinne](https://github.com/jdewinne)) +- Possibility for send the full json object on create [\#252](https://github.com/thefactory/marathon-python/pull/252) ([kkorekk](https://github.com/kkorekk)) +- events: add a few attributes [\#251](https://github.com/thefactory/marathon-python/pull/251) ([iksaif](https://github.com/iksaif)) +- install-marathon.sh: do not remove oracle-java7-installer [\#250](https://github.com/thefactory/marathon-python/pull/250) ([iksaif](https://github.com/iksaif)) +- MarathonClient: set verify when using sse\_session [\#248](https://github.com/thefactory/marathon-python/pull/248) ([iksaif](https://github.com/iksaif)) +- add reset delay api [\#246](https://github.com/thefactory/marathon-python/pull/246) ([iandyh](https://github.com/iandyh)) +- fixes for issue 244 [\#245](https://github.com/thefactory/marathon-python/pull/245) ([mikekatica](https://github.com/mikekatica)) +- Test against 1.4.11 [\#240](https://github.com/thefactory/marathon-python/pull/240) ([nhandler](https://github.com/nhandler)) +- fix isuuse-238 [\#239](https://github.com/thefactory/marathon-python/pull/239) ([yudong2015](https://github.com/yudong2015)) +- Test against 1.4.10 instead of 1.4.9 [\#236](https://github.com/thefactory/marathon-python/pull/236) ([nhandler](https://github.com/nhandler)) +- make models.info compatible with 1.4.9 [\#233](https://github.com/thefactory/marathon-python/pull/233) ([iandyh](https://github.com/iandyh)) +- Fix health check 'command' [\#231](https://github.com/thefactory/marathon-python/pull/231) ([protetore](https://github.com/protetore)) +- Feature/marathon constraint model improvements [\#229](https://github.com/thefactory/marathon-python/pull/229) ([diogommartins](https://github.com/diogommartins)) +- Removes id validation from MarathonGroup\(\) [\#228](https://github.com/thefactory/marathon-python/pull/228) ([daltonmatos](https://github.com/daltonmatos)) -## 0.6.0 (2014-09-29) +## [0.9.3](https://github.com/thefactory/marathon-python/tree/0.9.3) (2017-10-16) -Mesos 0.20.1- and Marathon 0.7.1-compatible release. +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.2...0.9.3) -Changes: -* Added bridge networking support -* Use common exception for all pick-from-a-list options (`InvalidChoiceError`). `InvalidOperatorError` has been removed -* Updated `MarathonObject.__repr__` to be more useful -* Changed default HTTP request timeout from 5s to 10s +**Closed issues:** -## 0.5.1 (2014-09-18) +- `list\_queue` doesn't like the `embed\_last\_unused\_offers` option [\#220](https://github.com/thefactory/marathon-python/issues/220) -Changes: -* Added support for multiple Marathon servers (if a request to one fails for network reasons, try the next) -* Fixed a bug with HTTP 4xx response handling +**Merged pull requests:** -## 0.5.0 (2014-09-15) +- Release 0.9.3 [\#224](https://github.com/thefactory/marathon-python/pull/224) ([solarkennedy](https://github.com/solarkennedy)) +- Make travis automatically upload to pypi on new tags [\#223](https://github.com/thefactory/marathon-python/pull/223) ([solarkennedy](https://github.com/solarkennedy)) +- Fix MarathonQueueItem to know about the possible last\_unused\_offers arg [\#221](https://github.com/thefactory/marathon-python/pull/221) ([matthewbentley](https://github.com/matthewbentley)) +- support more datetime formats in MarathonAppVersionInfo [\#219](https://github.com/thefactory/marathon-python/pull/219) ([somic](https://github.com/somic)) +- Remove default container.docker.network [\#218](https://github.com/thefactory/marathon-python/pull/218) ([protetore](https://github.com/protetore)) +- Make MarathonZooKeeperConfig compatible with maraton 1.5 [\#216](https://github.com/thefactory/marathon-python/pull/216) ([fengyehong](https://github.com/fengyehong)) -Initial release compatible with Marathon 0.7.0 and Mesos 0.20.0. +## [0.9.2](https://github.com/thefactory/marathon-python/tree/0.9.2) (2017-09-13) -_Warning: this includes multiple breaking changes, both from Marathon and from this library_ +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.1...0.9.2) -Changes: -* Added support for Deployments, Groups, native Docker containers, Queue, Server Info, Server Metrics, and Ping -* Updated object attributes to be at parity with Marathon 0.7.0 (RC2) -* Updated return types to be at parity with Marathon 0.7.0 (RC2) -* Updated method signatures to be more consistent -* HTTP 4xx errors other than 404 now throw `MarathonHttpError` instead of `NotFoundError` -* `json_encode()` and `json_decode()` on MarathonResource have been renamed to `to_json()` and `from_json()`, respectively -* Refactored serialization/deserialization to reduce boilerplate +**Closed issues:** + +- Failed to import marathon in python3 [\#217](https://github.com/thefactory/marathon-python/issues/217) +- No support for "USER" network mode. [\#173](https://github.com/thefactory/marathon-python/issues/173) +- YAML support for marathon-cli [\#74](https://github.com/thefactory/marathon-python/issues/74) + +**Merged pull requests:** + +- Test against the latest marathon 1.4 point release \(1.4.7\) [\#215](https://github.com/thefactory/marathon-python/pull/215) ([nhandler](https://github.com/nhandler)) +- Fix events [\#214](https://github.com/thefactory/marathon-python/pull/214) ([fengyehong](https://github.com/fengyehong)) + +## [0.9.1](https://github.com/thefactory/marathon-python/tree/0.9.1) (2017-09-06) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.9.0...0.9.1) + +**Closed issues:** + +- \_do\_request can raise JSONDecodeError when it means to raise InternalServerError [\#202](https://github.com/thefactory/marathon-python/issues/202) +- marathon.exceptions.InvalidChoiceError: Invalid choice "tcp,udp" for param "protocol". Must be one of \['tcp', 'udp'\] [\#150](https://github.com/thefactory/marathon-python/issues/150) + +**Merged pull requests:** + +- Fix for Marathon 1.5 breaking the /v2/apps API moving portMappings [\#213](https://github.com/thefactory/marathon-python/pull/213) ([gisjedi](https://github.com/gisjedi)) +- Update container.py [\#212](https://github.com/thefactory/marathon-python/pull/212) ([DavidZisky](https://github.com/DavidZisky)) +- Support filtering applications by labels [\#211](https://github.com/thefactory/marathon-python/pull/211) ([iandyh](https://github.com/iandyh)) +- add embed option for /v2/queue [\#210](https://github.com/thefactory/marathon-python/pull/210) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- Enable TCP keepalive for sse requests [\#209](https://github.com/thefactory/marathon-python/pull/209) ([fengyehong](https://github.com/fengyehong)) +- Add "udp,tcp" to authorized protocols for containers [\#208](https://github.com/thefactory/marathon-python/pull/208) ([fuegoio](https://github.com/fuegoio)) +- Allow event type filter on event stream [\#207](https://github.com/thefactory/marathon-python/pull/207) ([fengyehong](https://github.com/fengyehong)) +- Fix MarathonResource hash as well [\#205](https://github.com/thefactory/marathon-python/pull/205) ([jolynch](https://github.com/jolynch)) + +## [0.9.0](https://github.com/thefactory/marathon-python/tree/0.9.0) (2017-06-21) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.14...0.9.0) + +**Closed issues:** + +- MarathonObject is not hashable in Python3 [\#203](https://github.com/thefactory/marathon-python/issues/203) +- Missing pods attribute in MarathonDeploymentOriginalState [\#196](https://github.com/thefactory/marathon-python/issues/196) +- Travis tests have stopped automatically running [\#194](https://github.com/thefactory/marathon-python/issues/194) +- Adding the key "networks" in the JSON received of marathon \( list apps \) [\#192](https://github.com/thefactory/marathon-python/issues/192) +- Unknown event\_type: instance\_changed\_event [\#191](https://github.com/thefactory/marathon-python/issues/191) +- logs cause exception with non-ascii characters [\#187](https://github.com/thefactory/marathon-python/issues/187) +- Add new Yelp Contributors [\#181](https://github.com/thefactory/marathon-python/issues/181) + +**Merged pull requests:** + +- Add hash to marathon object [\#204](https://github.com/thefactory/marathon-python/pull/204) ([jolynch](https://github.com/jolynch)) +- Add requests session param tip. [\#201](https://github.com/thefactory/marathon-python/pull/201) ([Colstuwjx](https://github.com/Colstuwjx)) +- Fix variable [\#199](https://github.com/thefactory/marathon-python/pull/199) ([fengyehong](https://github.com/fengyehong)) +- add new marathon event\_stream events [\#198](https://github.com/thefactory/marathon-python/pull/198) ([bergerx](https://github.com/bergerx)) +- There are cases where this check stack traces [\#197](https://github.com/thefactory/marathon-python/pull/197) ([thekad](https://github.com/thekad)) +- Updated changelog [\#195](https://github.com/thefactory/marathon-python/pull/195) ([solarkennedy](https://github.com/solarkennedy)) +- Adding the key "networks" in the JSON received of marathon [\#193](https://github.com/thefactory/marathon-python/pull/193) ([joaoleite](https://github.com/joaoleite)) +- Remove out of date constraint validation of operator. [\#190](https://github.com/thefactory/marathon-python/pull/190) ([akatrevorjay](https://github.com/akatrevorjay)) +- Add raw\_data option for event\_stream method [\#189](https://github.com/thefactory/marathon-python/pull/189) ([fengyehong](https://github.com/fengyehong)) +- handle case when non-ascii char are logged [\#188](https://github.com/thefactory/marathon-python/pull/188) ([tgermain](https://github.com/tgermain)) +- \[fix\] util.to\_camel\_case doesn't handle digits [\#184](https://github.com/thefactory/marathon-python/pull/184) ([hlerebours](https://github.com/hlerebours)) +- \[fix\] broken build: glibc++ not found [\#183](https://github.com/thefactory/marathon-python/pull/183) ([hlerebours](https://github.com/hlerebours)) +- Support for "disabled" unreachableStrategy. [\#182](https://github.com/thefactory/marathon-python/pull/182) ([nihn](https://github.com/nihn)) +- \[fix\] Handle non-JSON errors from Marathon [\#178](https://github.com/thefactory/marathon-python/pull/178) ([hlerebours](https://github.com/hlerebours)) + +## [0.8.14](https://github.com/thefactory/marathon-python/tree/0.8.14) (2017-03-24) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.13...0.8.14) + +**Closed issues:** + +- Pypi need update to marathon 0.8.13 [\#185](https://github.com/thefactory/marathon-python/issues/185) +- Tests fail on master branch [\#180](https://github.com/thefactory/marathon-python/issues/180) +- ignoreHttp1xx or ignoreHttp1Xx [\#125](https://github.com/thefactory/marathon-python/issues/125) +- ValueError when 401 Unauthorized is received [\#22](https://github.com/thefactory/marathon-python/issues/22) + +## [0.8.13](https://github.com/thefactory/marathon-python/tree/0.8.13) (2017-03-17) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.12...0.8.13) + +**Merged pull requests:** + +- Support processed\_offers\_summary attribute [\#177](https://github.com/thefactory/marathon-python/pull/177) ([nhandler](https://github.com/nhandler)) + +## [0.8.12](https://github.com/thefactory/marathon-python/tree/0.8.12) (2017-03-17) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.11...0.8.12) + +**Closed issues:** + +- Unknown event\_type app\_terminated\_event [\#151](https://github.com/thefactory/marathon-python/issues/151) + +**Merged pull requests:** + +- Add support for 'since' in /v2/queue [\#176](https://github.com/thefactory/marathon-python/pull/176) ([nhandler](https://github.com/nhandler)) +- Use Marathon /v2/apps/\/tasks endpoint to get tasks by id. [\#175](https://github.com/thefactory/marathon-python/pull/175) ([nihn](https://github.com/nihn)) +- Update list\_apps docs for param app\_id [\#172](https://github.com/thefactory/marathon-python/pull/172) ([baopham](https://github.com/baopham)) +- Updated event.py to handle app\_terminated\_event. [\#171](https://github.com/thefactory/marathon-python/pull/171) ([Jbrownstone](https://github.com/Jbrownstone)) + +## [0.8.11](https://github.com/thefactory/marathon-python/tree/0.8.11) (2017-02-22) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.10...0.8.11) + +**Merged pull requests:** + +- Update to work with Marathon 1.4.0 [\#169](https://github.com/thefactory/marathon-python/pull/169) ([nhandler](https://github.com/nhandler)) +- Change location [\#168](https://github.com/thefactory/marathon-python/pull/168) ([tsukaby](https://github.com/tsukaby)) +- Adds MarathonApp.add\_env\(\) method [\#166](https://github.com/thefactory/marathon-python/pull/166) ([daltonmatos](https://github.com/daltonmatos)) + +## [0.8.10](https://github.com/thefactory/marathon-python/tree/0.8.10) (2017-01-07) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.9...0.8.10) + +**Closed issues:** + +- InvalidChoiceError when container type is "MESOS" [\#153](https://github.com/thefactory/marathon-python/issues/153) + +**Merged pull requests:** + +- Marathon 1.4 instance [\#165](https://github.com/thefactory/marathon-python/pull/165) ([solarkennedy](https://github.com/solarkennedy)) +- Removed unused sseclient depencency [\#164](https://github.com/thefactory/marathon-python/pull/164) ([migueleliasweb](https://github.com/migueleliasweb)) +- Add new Marathon 1.4 API keywords [\#162](https://github.com/thefactory/marathon-python/pull/162) ([stj](https://github.com/stj)) + +## [0.8.9](https://github.com/thefactory/marathon-python/tree/0.8.9) (2016-12-15) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.8...0.8.9) + +**Closed issues:** + +- 0.8.8 on PyPi [\#160](https://github.com/thefactory/marathon-python/issues/160) + +**Merged pull requests:** + +- Added more unimplemented Marathon 1.4 API keywords [\#161](https://github.com/thefactory/marathon-python/pull/161) ([solarkennedy](https://github.com/solarkennedy)) + +## [0.8.8](https://github.com/thefactory/marathon-python/tree/0.8.8) (2016-12-09) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.7...0.8.8) + +**Closed issues:** + +- Can we include the license file in the source release tarball ? [\#156](https://github.com/thefactory/marathon-python/issues/156) + +**Merged pull requests:** + +- Allow to disable SSL certificate validation [\#159](https://github.com/thefactory/marathon-python/pull/159) ([Djailla](https://github.com/Djailla)) +- Expose error details from response object MarathonHttpError [\#157](https://github.com/thefactory/marathon-python/pull/157) ([moonkev](https://github.com/moonkev)) + +## [0.8.7](https://github.com/thefactory/marathon-python/tree/0.8.7) (2016-10-24) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.6...0.8.7) + +**Closed issues:** + +- Need to support Oauth tokens for use with DCOS + adminrouter [\#148](https://github.com/thefactory/marathon-python/issues/148) +- Not yet compatible with marathon 1.1.1 external volumes [\#98](https://github.com/thefactory/marathon-python/issues/98) + +**Merged pull requests:** + +- Preliminary Marathon 1.4 Support [\#155](https://github.com/thefactory/marathon-python/pull/155) ([solarkennedy](https://github.com/solarkennedy)) +- Mesos container support [\#154](https://github.com/thefactory/marathon-python/pull/154) ([tanderegg](https://github.com/tanderegg)) +- Add missing 'timeout\_seconds' parameter in ReadinessCheck class [\#152](https://github.com/thefactory/marathon-python/pull/152) ([mmelcot](https://github.com/mmelcot)) +- Add support for token-based Auth [\#149](https://github.com/thefactory/marathon-python/pull/149) ([jimbobhickville](https://github.com/jimbobhickville)) +- \[WIP\] Add support for marathon 1.3.0 [\#147](https://github.com/thefactory/marathon-python/pull/147) ([nhandler](https://github.com/nhandler)) +- Add external volume support [\#146](https://github.com/thefactory/marathon-python/pull/146) ([drewrobb](https://github.com/drewrobb)) + +## [0.8.6](https://github.com/thefactory/marathon-python/tree/0.8.6) (2016-08-29) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.5...0.8.6) + +**Closed issues:** + +- Unexpected keyword argument: gpus [\#140](https://github.com/thefactory/marathon-python/issues/140) +- \[Profiling\] Humongous CPU with event\_stream [\#139](https://github.com/thefactory/marathon-python/issues/139) +- Python 3 test not running [\#80](https://github.com/thefactory/marathon-python/issues/80) + +**Merged pull requests:** + +- Add NONE as valid docker network mode [\#144](https://github.com/thefactory/marathon-python/pull/144) ([fengyehong](https://github.com/fengyehong)) +- Removed sseclient dependency + major enhancements on event\_stream\(\) [\#143](https://github.com/thefactory/marathon-python/pull/143) ([migueleliasweb](https://github.com/migueleliasweb)) +- run tox against multiple python versions [\#142](https://github.com/thefactory/marathon-python/pull/142) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- Fix \#140 - Resolve gpus TypeError [\#141](https://github.com/thefactory/marathon-python/pull/141) ([mbeacom](https://github.com/mbeacom)) +- Add support for unhealthy\_task\_kill\_event [\#137](https://github.com/thefactory/marathon-python/pull/137) ([nuclon](https://github.com/nuclon)) + +## [0.8.5](https://github.com/thefactory/marathon-python/tree/0.8.5) (2016-08-10) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.4...0.8.5) + +**Closed issues:** + +- HTTP 400 returned with message, "Invalid JSON" [\#133](https://github.com/thefactory/marathon-python/issues/133) +- \[Question\] Passing parameters to request.get [\#132](https://github.com/thefactory/marathon-python/issues/132) + +**Merged pull requests:** + +- Add update\_apps method to client [\#136](https://github.com/thefactory/marathon-python/pull/136) ([moonkev](https://github.com/moonkev)) +- Allow setting of a custom requests session [\#135](https://github.com/thefactory/marathon-python/pull/135) ([ammaraskar](https://github.com/ammaraskar)) +- Marathon 1.1.2 and Mesos 1.0.\* [\#134](https://github.com/thefactory/marathon-python/pull/134) ([nhandler](https://github.com/nhandler)) + +## [0.8.4](https://github.com/thefactory/marathon-python/tree/0.8.4) (2016-07-20) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.3...0.8.4) + +**Closed issues:** + +- Can we get another Pypi release? [\#130](https://github.com/thefactory/marathon-python/issues/130) + +**Merged pull requests:** + +- Fix py3k regression in 0.8.3 [\#131](https://github.com/thefactory/marathon-python/pull/131) ([jimbobhickville](https://github.com/jimbobhickville)) +- Expose id query param in list\_apps [\#129](https://github.com/thefactory/marathon-python/pull/129) ([moonkev](https://github.com/moonkev)) + +## [0.8.3](https://github.com/thefactory/marathon-python/tree/0.8.3) (2016-07-19) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.2...0.8.3) + +**Closed issues:** + +- New marathon application structure field [\#126](https://github.com/thefactory/marathon-python/issues/126) +- MarathonReadinessCheck class is absent [\#122](https://github.com/thefactory/marathon-python/issues/122) +- Supporting creating applications with a json file \(or json-formatted string, or json object\) [\#112](https://github.com/thefactory/marathon-python/issues/112) +- Task.ip\_addresses are not set properly [\#110](https://github.com/thefactory/marathon-python/issues/110) +- RuntimeError: maximum recursion depth exceeded in cmp when calling create\_app [\#60](https://github.com/thefactory/marathon-python/issues/60) + +**Merged pull requests:** + +- Try to fix java hostname issues [\#128](https://github.com/thefactory/marathon-python/pull/128) ([solarkennedy](https://github.com/solarkennedy)) +- Issue126: secrets and taskKillGracePeriodSeconds Marathon.App fields … [\#127](https://github.com/thefactory/marathon-python/pull/127) ([dmajere](https://github.com/dmajere)) +- Issue \#70: Remove resource\_name from get\_group [\#124](https://github.com/thefactory/marathon-python/pull/124) ([stj](https://github.com/stj)) +- Type assertion for ReadinessCheck in MarathonApp.\_\_init\_\_ method added [\#123](https://github.com/thefactory/marathon-python/pull/123) ([dmajere](https://github.com/dmajere)) +- Use requests.Session while communicating with Marathon. [\#121](https://github.com/thefactory/marathon-python/pull/121) ([nihn](https://github.com/nihn)) +- Fix `Client.get\_version` method. [\#120](https://github.com/thefactory/marathon-python/pull/120) ([nihn](https://github.com/nihn)) +- Handle HTTP errors without content graceful [\#119](https://github.com/thefactory/marathon-python/pull/119) ([stj](https://github.com/stj)) +- Add local\_volumes to MarathonTask [\#118](https://github.com/thefactory/marathon-python/pull/118) ([usmanm](https://github.com/usmanm)) +- Fix pep8 issues and strict flake8 check. [\#117](https://github.com/thefactory/marathon-python/pull/117) ([oilbeater](https://github.com/oilbeater)) +- Don't die if no JSON found in response [\#116](https://github.com/thefactory/marathon-python/pull/116) ([usmanm](https://github.com/usmanm)) +- Set fetch in MarathonApp. [\#115](https://github.com/thefactory/marathon-python/pull/115) ([oilbeater](https://github.com/oilbeater)) +- Add in state to the task model [\#114](https://github.com/thefactory/marathon-python/pull/114) ([chuckwired](https://github.com/chuckwired)) +- Add 'persistent' volume option [\#113](https://github.com/thefactory/marathon-python/pull/113) ([jimbobhickville](https://github.com/jimbobhickville)) +- Issue110: MarathonTask.ip\_addresses attribute is set properly [\#111](https://github.com/thefactory/marathon-python/pull/111) ([dmajere](https://github.com/dmajere)) +- Add message field for MarathonStatusUpdateEvent. [\#109](https://github.com/thefactory/marathon-python/pull/109) ([oilbeater](https://github.com/oilbeater)) + +## [0.8.2](https://github.com/thefactory/marathon-python/tree/0.8.2) (2016-06-14) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.1...0.8.2) + +**Closed issues:** + +- AttributeError on connection issues [\#106](https://github.com/thefactory/marathon-python/issues/106) +- Wrongly use version\_info as task\_stats [\#104](https://github.com/thefactory/marathon-python/issues/104) +- c.scale\_app\('myapp3', 2\) [\#102](https://github.com/thefactory/marathon-python/issues/102) +- AttributeError: module 'marathon' has no attribute 'MarathonClient' [\#100](https://github.com/thefactory/marathon-python/issues/100) +- when run list\_apps ,has errors [\#99](https://github.com/thefactory/marathon-python/issues/99) + +**Merged pull requests:** + +- Make exception handling work with py3k [\#108](https://github.com/thefactory/marathon-python/pull/108) ([jimbobhickville](https://github.com/jimbobhickville)) +- Add 'wipe' option to kill\_task methods [\#107](https://github.com/thefactory/marathon-python/pull/107) ([jimbobhickville](https://github.com/jimbobhickville)) +- task\_stats bugfix and query improvements. [\#105](https://github.com/thefactory/marathon-python/pull/105) ([oilbeater](https://github.com/oilbeater)) +- Change to\_json\(\) datatime format [\#103](https://github.com/thefactory/marathon-python/pull/103) ([oilbeater](https://github.com/oilbeater)) +- add name attribute to port mapping [\#101](https://github.com/thefactory/marathon-python/pull/101) ([Rob-Johnson](https://github.com/Rob-Johnson)) + +## [0.8.1](https://github.com/thefactory/marathon-python/tree/0.8.1) (2016-04-21) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.8.0...0.8.1) + +**Closed issues:** + +- Does not understand readiness\_check\_results [\#94](https://github.com/thefactory/marathon-python/issues/94) +- Generate marathon-python from raml [\#86](https://github.com/thefactory/marathon-python/issues/86) +- Support Marathon 0.15.1 [\#84](https://github.com/thefactory/marathon-python/issues/84) + +**Merged pull requests:** + +- Added force option for kill\_given\_tasks [\#96](https://github.com/thefactory/marathon-python/pull/96) ([solarkennedy](https://github.com/solarkennedy)) +- Support the deployments endpoint correctly in marathon 1.1.1 [\#95](https://github.com/thefactory/marathon-python/pull/95) ([solarkennedy](https://github.com/solarkennedy)) + +## [0.8.0](https://github.com/thefactory/marathon-python/tree/0.8.0) (2016-04-18) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.7...0.8.0) + +**Closed issues:** + +- 0.7.7 release [\#90](https://github.com/thefactory/marathon-python/issues/90) + +**Merged pull requests:** + +- Comply with pep8 betterer [\#93](https://github.com/thefactory/marathon-python/pull/93) ([solarkennedy](https://github.com/solarkennedy)) +- Support marathon .15 and 1.1 [\#92](https://github.com/thefactory/marathon-python/pull/92) ([solarkennedy](https://github.com/solarkennedy)) +- Add support for /v2/events stream [\#91](https://github.com/thefactory/marathon-python/pull/91) ([nuclon](https://github.com/nuclon)) +- update for v2/queue and v2/apps?embed=apps.taskStats [\#89](https://github.com/thefactory/marathon-python/pull/89) ([bergerx](https://github.com/bergerx)) + +## [0.7.7](https://github.com/thefactory/marathon-python/tree/0.7.7) (2016-02-29) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.6...0.7.7) + +**Merged pull requests:** + +- restart endpoint [\#88](https://github.com/thefactory/marathon-python/pull/88) ([burakbostancioglu](https://github.com/burakbostancioglu)) +- a small fix for fetching apps for marathon v0.15 [\#87](https://github.com/thefactory/marathon-python/pull/87) ([burakbostancioglu](https://github.com/burakbostancioglu)) + +## [0.7.6](https://github.com/thefactory/marathon-python/tree/0.7.6) (2016-02-12) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.5...0.7.6) + +**Closed issues:** + +- MarathonClient is not compatible with marathon 0.14.1-1.0.455.ubuntu1404 [\#81](https://github.com/thefactory/marathon-python/issues/81) +- Zero values in apps/groups doesnt work [\#75](https://github.com/thefactory/marathon-python/issues/75) +- does marathon-python supports marathon Version 0.11.1? [\#66](https://github.com/thefactory/marathon-python/issues/66) +- Why MarathonDockerContainer.parameters is defined as dict? [\#47](https://github.com/thefactory/marathon-python/issues/47) + +**Merged pull requests:** + +- 0.14 support [\#85](https://github.com/thefactory/marathon-python/pull/85) ([itamaro](https://github.com/itamaro)) +- Change MarathonDockerContainer.parameters to type list [\#83](https://github.com/thefactory/marathon-python/pull/83) ([fengyehong](https://github.com/fengyehong)) +- Don't drop 0s when transforming to JSON [\#79](https://github.com/thefactory/marathon-python/pull/79) ([iksaif](https://github.com/iksaif)) +- Itest fixes and stick to legacy travis infrastructure for now. [\#78](https://github.com/thefactory/marathon-python/pull/78) ([solarkennedy](https://github.com/solarkennedy)) +- Modify list\_apps so user can input app\_id without the starting slash [\#71](https://github.com/thefactory/marathon-python/pull/71) ([fengyehong](https://github.com/fengyehong)) +- Use the /v2/tasks/delete endpoint for taskkill [\#67](https://github.com/thefactory/marathon-python/pull/67) ([fengyehong](https://github.com/fengyehong)) + +## [0.7.5](https://github.com/thefactory/marathon-python/tree/0.7.5) (2015-12-09) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.4...0.7.5) + +**Merged pull requests:** + +- Release 0.7.5 for official Marathon 11 support [\#73](https://github.com/thefactory/marathon-python/pull/73) ([solarkennedy](https://github.com/solarkennedy)) +- Added tests for killing tasks on an app [\#72](https://github.com/thefactory/marathon-python/pull/72) ([solarkennedy](https://github.com/solarkennedy)) +- Use automatic changelog generation [\#69](https://github.com/thefactory/marathon-python/pull/69) ([solarkennedy](https://github.com/solarkennedy)) +- Provide proper compatability support for str/unicode in py3 [\#57](https://github.com/thefactory/marathon-python/pull/57) ([mattrobenolt](https://github.com/mattrobenolt)) + +## [0.7.4](https://github.com/thefactory/marathon-python/tree/0.7.4) (2015-11-20) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.3...0.7.4) + +**Merged pull requests:** + +- Marathon 11 Support [\#68](https://github.com/thefactory/marathon-python/pull/68) ([solarkennedy](https://github.com/solarkennedy)) + +## [0.7.3](https://github.com/thefactory/marathon-python/tree/0.7.3) (2015-11-12) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.2...0.7.3) + +**Closed issues:** + +- When will you guys release 0.7.2 [\#62](https://github.com/thefactory/marathon-python/issues/62) +- 0.7.1 tag missing [\#61](https://github.com/thefactory/marathon-python/issues/61) + +**Merged pull requests:** + +- use the /v2/tasks endpoint for list\_tasks [\#65](https://github.com/thefactory/marathon-python/pull/65) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- Remove call to logging.basicConfig [\#64](https://github.com/thefactory/marathon-python/pull/64) ([itamaro](https://github.com/itamaro)) + +## [0.7.2](https://github.com/thefactory/marathon-python/tree/0.7.2) (2015-09-18) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.7.0...0.7.2) + +**Closed issues:** + +- Marathon Json encoder can't handle unicode strings [\#50](https://github.com/thefactory/marathon-python/issues/50) +- Marathon app name validation is broken [\#45](https://github.com/thefactory/marathon-python/issues/45) +- New release for 8.2 compatibility [\#38](https://github.com/thefactory/marathon-python/issues/38) +- Task.app\_id is None when using c.get\_app\("xxx"\).tasks [\#9](https://github.com/thefactory/marathon-python/issues/9) + +**Merged pull requests:** + +- Updated to support Marathon 0.9.1 with get\_info\(\) calls [\#59](https://github.com/thefactory/marathon-python/pull/59) ([grampelberg](https://github.com/grampelberg)) +- Add support for building with a wheel and cleanup setup.py [\#58](https://github.com/thefactory/marathon-python/pull/58) ([mattrobenolt](https://github.com/mattrobenolt)) +- travis should run unit tests [\#55](https://github.com/thefactory/marathon-python/pull/55) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- implement \_\_eq\_\_ on base models + fix tests to be useful [\#54](https://github.com/thefactory/marathon-python/pull/54) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- Fix deployments parsing [\#53](https://github.com/thefactory/marathon-python/pull/53) ([Rob-Johnson](https://github.com/Rob-Johnson)) +- Added failing test and fix for unicode handling [\#52](https://github.com/thefactory/marathon-python/pull/52) ([solarkennedy](https://github.com/solarkennedy)) +- Removed previously unused test framework in favor of tox + docker-compose version [\#49](https://github.com/thefactory/marathon-python/pull/49) ([solarkennedy](https://github.com/solarkennedy)) +- Add accepted\_resource\_role kwarg for Marathon 0.9.0 support [\#48](https://github.com/thefactory/marathon-python/pull/48) ([keshavdv](https://github.com/keshavdv)) +- Upstream merge - Add itest framework and fix regex [\#46](https://github.com/thefactory/marathon-python/pull/46) ([solarkennedy](https://github.com/solarkennedy)) +- First pass at adding an itest framework [\#42](https://github.com/thefactory/marathon-python/pull/42) ([solarkennedy](https://github.com/solarkennedy)) + +## [0.7.0](https://github.com/thefactory/marathon-python/tree/0.7.0) (2015-07-06) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.15...0.7.0) + +**Closed issues:** + +- MarathonHealthCheck class doesn't support 0.8.2 [\#34](https://github.com/thefactory/marathon-python/issues/34) + +**Merged pull requests:** + +- Update endpoint docstring. [\#41](https://github.com/thefactory/marathon-python/pull/41) ([Poogles](https://github.com/Poogles)) +- fixed variable name in list comprehension [\#39](https://github.com/thefactory/marathon-python/pull/39) ([AFriemann](https://github.com/AFriemann)) +- Add validation to marathon app/group ids [\#37](https://github.com/thefactory/marathon-python/pull/37) ([mattrobenolt](https://github.com/mattrobenolt)) +- adds ignore\_http1xx and forward compat kwargs to MarathonHealthCheck [\#36](https://github.com/thefactory/marathon-python/pull/36) ([mrtheb](https://github.com/mrtheb)) +- Feature/event factory [\#32](https://github.com/thefactory/marathon-python/pull/32) ([kevinschoon](https://github.com/kevinschoon)) + +## [0.6.15](https://github.com/thefactory/marathon-python/tree/0.6.15) (2015-06-05) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.14...0.6.15) + +**Merged pull requests:** + +- Make `force\_pull\_image` actually work [\#33](https://github.com/thefactory/marathon-python/pull/33) ([mattrobenolt](https://github.com/mattrobenolt)) + +## [0.6.14](https://github.com/thefactory/marathon-python/tree/0.6.14) (2015-05-28) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.13...0.6.14) + +**Closed issues:** + +- forcePullImage not honored by marathon-python \(marathon 0.8.2 RC2\) [\#29](https://github.com/thefactory/marathon-python/issues/29) +- create\_app\(\) not working for docker container [\#28](https://github.com/thefactory/marathon-python/issues/28) +- Urgent BUG: to\_json\(\) is returning unexpected result under python3 version [\#26](https://github.com/thefactory/marathon-python/issues/26) +- portMapping isn't iterable [\#25](https://github.com/thefactory/marathon-python/issues/25) + +**Merged pull requests:** + +- Added forcePullImage parameter for the container model [\#31](https://github.com/thefactory/marathon-python/pull/31) ([solarkennedy](https://github.com/solarkennedy)) +- Quick fix \#29 - add kwargs to MarathonDockerContainer.\_\_init\_\_ [\#30](https://github.com/thefactory/marathon-python/pull/30) ([ghost](https://github.com/ghost)) +- Fixed \#26:Using try/except to get rid of use\_2to3 failing [\#27](https://github.com/thefactory/marathon-python/pull/27) ([vitan](https://github.com/vitan)) + +## [0.6.13](https://github.com/thefactory/marathon-python/tree/0.6.13) (2015-03-24) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.12...0.6.13) + +**Merged pull requests:** + +- Added get\_leader and delete\_leader functions [\#24](https://github.com/thefactory/marathon-python/pull/24) ([pradeepchhetri](https://github.com/pradeepchhetri)) +- Fixed get\_info for marathon-0.8.1-RC2 [\#23](https://github.com/thefactory/marathon-python/pull/23) ([pradeepchhetri](https://github.com/pradeepchhetri)) +- Added two app parameters - tasks\_healthy, tasks\_unhealthy \(marathon-0.8.1-RC2\) [\#21](https://github.com/thefactory/marathon-python/pull/21) ([pradeepchhetri](https://github.com/pradeepchhetri)) +- Possibility to send the full object to Marathon on update [\#20](https://github.com/thefactory/marathon-python/pull/20) ([wndhydrnt](https://github.com/wndhydrnt)) + +## [0.6.12](https://github.com/thefactory/marathon-python/tree/0.6.12) (2015-03-07) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.11...0.6.12) + +## [0.6.11](https://github.com/thefactory/marathon-python/tree/0.6.11) (2015-03-06) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.10...0.6.11) + +**Merged pull requests:** + +- Small changes to fix compatibility issues with Marathon 0.8.0 [\#19](https://github.com/thefactory/marathon-python/pull/19) ([cloudify](https://github.com/cloudify)) + +## [0.6.10](https://github.com/thefactory/marathon-python/tree/0.6.10) (2014-12-17) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.8...0.6.10) + +**Merged pull requests:** + +- Added optional ?force=true param to MarathonClient.delete\_deployment\(\) [\#18](https://github.com/thefactory/marathon-python/pull/18) ([mattcallanan](https://github.com/mattcallanan)) +- Add parameters and privileged fields to Container model [\#17](https://github.com/thefactory/marathon-python/pull/17) ([gabrtv](https://github.com/gabrtv)) +- apparently undocumented API in Marathon [\#16](https://github.com/thefactory/marathon-python/pull/16) ([elyast](https://github.com/elyast)) + +## [0.6.8](https://github.com/thefactory/marathon-python/tree/0.6.8) (2014-11-19) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.7...0.6.8) + +## [0.6.7](https://github.com/thefactory/marathon-python/tree/0.6.7) (2014-11-18) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.6...0.6.7) + +**Closed issues:** + +- update\_app\(\) no-ops if Version is passed [\#14](https://github.com/thefactory/marathon-python/issues/14) + +**Merged pull requests:** + +- fixing issues with resources /v2/tasks, v2/info [\#15](https://github.com/thefactory/marathon-python/pull/15) ([elyast](https://github.com/elyast)) + +## [0.6.6](https://github.com/thefactory/marathon-python/tree/0.6.6) (2014-11-17) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.5...0.6.6) + +**Closed issues:** + +- scale\_app\(...\) calls update\_app\(...\) with only 1 argument [\#13](https://github.com/thefactory/marathon-python/issues/13) + +## [0.6.5](https://github.com/thefactory/marathon-python/tree/0.6.5) (2014-11-14) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.4...0.6.5) + +## [0.6.4](https://github.com/thefactory/marathon-python/tree/0.6.4) (2014-11-14) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.3...0.6.4) + +**Merged pull requests:** + +- Add MarathonHealthCheckResult Class to tasks File and Include it in MarathonTask [\#12](https://github.com/thefactory/marathon-python/pull/12) ([JTCunning](https://github.com/JTCunning)) + +## [0.6.3](https://github.com/thefactory/marathon-python/tree/0.6.3) (2014-10-10) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.2...0.6.3) + +**Merged pull requests:** + +- add service\_port argument [\#11](https://github.com/thefactory/marathon-python/pull/11) ([danielfrg](https://github.com/danielfrg)) + +## [0.6.2](https://github.com/thefactory/marathon-python/tree/0.6.2) (2014-10-09) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.1...0.6.2) + +**Merged pull requests:** + +- Add `LIKE` and `UNLIKE` constraint [\#10](https://github.com/thefactory/marathon-python/pull/10) ([iven](https://github.com/iven)) + +## [0.6.1](https://github.com/thefactory/marathon-python/tree/0.6.1) (2014-09-29) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.6.0...0.6.1) + +## [0.6.0](https://github.com/thefactory/marathon-python/tree/0.6.0) (2014-09-29) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.5.1...0.6.0) + +**Closed issues:** + +- Support for HA nodes [\#8](https://github.com/thefactory/marathon-python/issues/8) + +## [0.5.1](https://github.com/thefactory/marathon-python/tree/0.5.1) (2014-09-18) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.5.0...0.5.1) + +## [0.5.0](https://github.com/thefactory/marathon-python/tree/0.5.0) (2014-09-18) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.4.0...0.5.0) + +**Merged pull requests:** + +- Bug Fix: Cannot define constraints with a tuple of strings [\#6](https://github.com/thefactory/marathon-python/pull/6) ([adgaudio](https://github.com/adgaudio)) + +## [0.4.0](https://github.com/thefactory/marathon-python/tree/0.4.0) (2014-08-19) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.3.1...0.4.0) + +**Merged pull requests:** + +- Throwing exceptions on 400s and 500s in \_do\_request [\#5](https://github.com/thefactory/marathon-python/pull/5) ([Codeacious](https://github.com/Codeacious)) +- Fix container options not being sent to marathon [\#4](https://github.com/thefactory/marathon-python/pull/4) ([boffbowsh](https://github.com/boffbowsh)) + +## [0.3.1](https://github.com/thefactory/marathon-python/tree/0.3.1) (2014-08-05) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.9...0.3.1) + +**Merged pull requests:** + +- Raise exceptions instead of swallowing them silently [\#3](https://github.com/thefactory/marathon-python/pull/3) ([StephanErb](https://github.com/StephanErb)) + +## [0.2.9](https://github.com/thefactory/marathon-python/tree/0.2.9) (2014-08-04) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.7...0.2.9) + +## [0.2.7](https://github.com/thefactory/marathon-python/tree/0.2.7) (2014-07-24) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.6...0.2.7) + +## [0.2.6](https://github.com/thefactory/marathon-python/tree/0.2.6) (2014-07-24) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.5...0.2.6) + +**Merged pull requests:** + +- Updated README.md with correction to create\_app args [\#2](https://github.com/thefactory/marathon-python/pull/2) ([rasathus](https://github.com/rasathus)) + +## [0.2.5](https://github.com/thefactory/marathon-python/tree/0.2.5) (2014-07-02) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.3...0.2.5) + +**Merged pull requests:** + +- allowing stagedAt and startedAt keys to be null [\#1](https://github.com/thefactory/marathon-python/pull/1) ([Codeacious](https://github.com/Codeacious)) + +## [0.2.3](https://github.com/thefactory/marathon-python/tree/0.2.3) (2014-06-02) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.2.0...0.2.3) + +## [0.2.0](https://github.com/thefactory/marathon-python/tree/0.2.0) (2014-04-28) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.1.1...0.2.0) + +## [0.1.1](https://github.com/thefactory/marathon-python/tree/0.1.1) (2014-04-23) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/0.1.0...0.1.1) + +## [0.1.0](https://github.com/thefactory/marathon-python/tree/0.1.0) (2014-04-23) + +[Full Changelog](https://github.com/thefactory/marathon-python/compare/8060b138250686d1fe2f79d4d5118fef39aa553e...0.1.0) + + + +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1aba38f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d94d86 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +itests: itests-py36 itests-py37 + +itests-py36: + tox -e itest-py36 + +itests-py37: + tox -e itest-py37 + +test: test-py36 test-py37 + +test-py36: + tox -e pep8 + tox -e test-py36 + +test-py37: + tox -e test-py37 + +clean: + rm -rf dist/ build/ + +package: clean + github_changelog_generator --user=thefactory --project=marathon-python --future-release=0.13.0 + pip install wheel + python setup.py sdist bdist_wheel + +publish: package + pip install twine + twine upload dist/* + +.PHONY: itests test clean package publish diff --git a/README.md b/README.md index 17f7380..d4e38cf 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,24 @@ # marathon-python -This is a Python library for interfacing with [Marathon](https://github.com/mesosphere/marathon) servers via Marathon's [REST API](https://github.com/mesosphere/marathon/blob/master/REST.md). +[![Build Status](https://travis-ci.org/thefactory/marathon-python.svg?branch=master)](https://travis-ci.org/thefactory/marathon-python) + +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). #### Compatibility -* marathon-python is primarily developed against Marathon 0.7.5 (see [Marathon docs](https://mesosphere.github.io/marathon/docs/rest-api.html)) +* For Marathon 1.9.x and 1.10.x, use at least 0.13.0 +* For Marathon 1.6.x, use at least 0.10.0 +* For Marathon 1.4.1, use at least 0.8.13 +* For Marathon 1.1.1, use at least 0.8.1 +* For all version changes, please see `CHANGELOG.md` + +If you find a feature that is broken, please submit a PR that adds a test for +it so it will be fixed and will continue to stay fixed as Marathon changes over +time. + +Just because this library is tested against a specific version of Marathon, +doesn't necessarily mean that it supports every feature and API Marathon +provides. ## Installation @@ -24,6 +38,23 @@ git clone git@github.com:thefactory/marathon-python python marathon-python/setup.py install ``` +## Testing + +`marathon-python` uses Travis to test the code against different versions of Marathon. +You can run the tests locally on a Linux machine that has docker on it: + +### Running The Tests + +```bash +make itests +``` + +### Running The Tests Against a Specific Version of Marathon + +```bash +MARATHONVERSION=v1.6.322 make itests +``` + ## Documentation API documentation is [here](http://thefactory.github.io/marathon-python). @@ -78,7 +109,7 @@ MarathonApp::myapp3 ```python >>> c.get_app('myapp3').instances 1 ->>> c.scale_app('myapp3', 2) +>>> c.scale_app('myapp3', instances=3) {'deploymentId': '611b89e3-99f2-4d8a-afe1-ec0b83fdbb88', 'version': '2014-08-26T07:40:20.121Z'} >>> c.get_app('myapp3').instances 3 diff --git a/docs/conf.py b/docs/conf.py index e3a0e64..b5611ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # marathon-python documentation build configuration file, created by # sphinx-quickstart on Tue Apr 22 11:36:23 2014. @@ -46,8 +45,8 @@ master_doc = 'index' # General information about the project. -project = u'marathon-python' -copyright = u'2014, The Factory' +project = 'marathon-python' +copyright = '2014, The Factory' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -203,8 +202,8 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'marathon-python.tex', u'marathon-python Documentation', - u'Mike Babineau', 'manual'), + ('index', 'marathon-python.tex', 'marathon-python Documentation', + 'Mike Babineau', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -233,8 +232,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'marathon-python', u'marathon-python Documentation', - [u'Mike Babineau'], 1) + ('index', 'marathon-python', 'marathon-python Documentation', + ['Mike Babineau'], 1) ] # If true, show URL addresses after external links. @@ -247,8 +246,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'marathon-python', u'marathon-python Documentation', - u'Mike Babineau', 'marathon-python', 'One line description of project.', + ('index', 'marathon-python', 'marathon-python Documentation', + 'Mike Babineau', 'marathon-python', 'One line description of project.', 'Miscellaneous'), ] @@ -268,10 +267,10 @@ # -- Options for Epub output ---------------------------------------------- # Bibliographic Dublin Core info. -epub_title = u'marathon-python' -epub_author = u'Mike Babineau' -epub_publisher = u'Mike Babineau' -epub_copyright = u'2014, The Factory' +epub_title = 'marathon-python' +epub_author = 'Mike Babineau' +epub_publisher = 'Mike Babineau' +epub_copyright = '2014, The Factory' # The basename for the epub file. It defaults to the project name. #epub_basename = u'marathon-python' diff --git a/docs/index.rst b/docs/index.rst index 408cf6e..934fbf9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ marathon-python documentation Python library for interfacing with `Marathon`_ servers via Marathon's `REST API`_. .. _Marathon: https://github.com/mesosphere/marathon -.. _REST API: https://github.com/mesosphere/marathon/blob/master/REST.md +.. _REST API: https://github.com/mesosphere/marathon/blob/master/docs/docs/rest-api.md Project home: https://github.com/thefactory/marathon-python diff --git a/itests/docker/.dockerignore b/itests/docker/.dockerignore new file mode 100644 index 0000000..2947fe4 --- /dev/null +++ b/itests/docker/.dockerignore @@ -0,0 +1,66 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +# Sphinx documentation +docs/_build/ + +.DS_Store + +# IntelliJ +.idea +*.iml + +# Packer http://packer.io +packer_cache + +/gh-pages +itests/marathon-version +.pytest_cache/ diff --git a/itests/docker/Dockerfile b/itests/docker/Dockerfile new file mode 100644 index 0000000..511fbbf --- /dev/null +++ b/itests/docker/Dockerfile @@ -0,0 +1,14 @@ +ARG MARATHONVERSION=v1.6.322 +FROM mesosphere/marathon:$MARATHONVERSION +ARG MARATHONVERSION +USER root + +# Setup +ADD ./install-marathon.sh /root/install-marathon.sh +RUN echo "MARATHONVERSION=${MARATHONVERSION}" > /root/marathon-version \ + && /root/install-marathon.sh + +EXPOSE 8080 5050 +ADD ./start-marathon.sh /root/start-marathon.sh +ENTRYPOINT [] +CMD ["/root/start-marathon.sh"] diff --git a/itests/docker/README.md b/itests/docker/README.md new file mode 100644 index 0000000..807726e --- /dev/null +++ b/itests/docker/README.md @@ -0,0 +1,9 @@ +# mini-marathon + +**Note:** We currently only support the marathon versions listed in [.travis.yml](https://github.com/thefactory/marathon-python/blob/acffecd307c38c3512b77487e2e83806963c7a8d/.travis.yml#L2-L7) + +## How to build + +``` +docker build --build-arg "MARATHONVERSION=v1.6.322" . +``` diff --git a/itests/docker/install-marathon.sh b/itests/docker/install-marathon.sh new file mode 100755 index 0000000..5a4ff52 --- /dev/null +++ b/itests/docker/install-marathon.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Default version of marathon to test against if not set by the user +[[ -f /root/marathon-version ]] && source /root/marathon-version +MARATHONVERSION="${MARATHONVERSION:-v1.6.322}" + +export DEBIAN_FRONTEND=noninteractive + +shopt -s extglob + +case "${MARATHONVERSION}" in + @(v1.10.19|v1.9.109)) + echo "Marathon version ${MARATHONVERSION} needs no specific changes" + apt update + ;; + v1.6.322) + sed -i 's!deb http://ftp.debian.org/debian jessie-backports main!!g' /etc/apt/sources.list + apt update + apt install -y mesos=1.6.* + ;; + v1.4.11) + sed -i 's!deb http://ftp.debian.org/debian jessie-backports main!!g' /etc/apt/sources.list + apt update + ;; + @(v1.3.0|v1.1.2)) + rm /etc/apt/sources.list.d/jessie-backports.list + apt update + ;; + *) + echo "Marathon version ${MARATHONVERSION} is not supported" + exit 1 + ;; +esac + +apt install -y --force-yes zookeeperd curl lsof +rm -rf /var/log/apt/* /var/log/alternatives.log /var/log/bootstrap.log /var/log/dpkg.log diff --git a/itests/docker/start-marathon.sh b/itests/docker/start-marathon.sh new file mode 100755 index 0000000..116e15c --- /dev/null +++ b/itests/docker/start-marathon.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +LOGGER="--logging_level info" +# Default version of marathon to test against if not set by the user +[[ -f /root/marathon-version ]] && source /root/marathon-version +MARATHONVERSION="${MARATHONVERSION:-v1.6.322}" + +shopt -s extglob + +case "${MARATHONVERSION}" in + @(v1.4.11|v1.3.0|v1.1.2)) + ln -sf /marathon/bin/start /marathon/bin/marathon + ;; + *) + echo "Marathon version ${MARATHONVERSION} needs no specific changes" + ;; +esac + +java -version +export MESOS_WORK_DIR='/tmp/mesos' +export ZK_HOST=$(cat /etc/mesos/zk) + +mkdir -p "${MESOS_WORK_DIR}" +/etc/init.d/zookeeper start +nohup mesos-master --work_dir=/tmp/mesosmaster --zk=${ZK_HOST} --quorum=1 &> mesos-master.log & +nohup /usr/bin/env MESOS_SYSTEMD_ENABLE_SUPPORT=false mesos-slave --master=${ZK_HOST} --work_dir=/tmp/mesosagent --launcher=posix &> mesos-agent.log & +eval "bin/marathon --master ${ZK_HOST} ${LOGGER}" diff --git a/itests/environment.py b/itests/environment.py new file mode 100644 index 0000000..b1198f2 --- /dev/null +++ b/itests/environment.py @@ -0,0 +1,21 @@ +import time + +from itest_utils import wait_for_marathon + + +def before_all(context): + wait_for_marathon() + + +def after_scenario(context, scenario): + """If a marathon client object exists in our context, delete any apps in Marathon and wait until they die.""" + if context.client: + while True: + apps = context.client.list_apps() + if not apps: + break + for app in apps: + context.client.delete_app(app.id, force=True) + time.sleep(0.5) + while context.client.list_deployments(): + time.sleep(0.5) diff --git a/itests/itest.sh b/itests/itest.sh new file mode 100755 index 0000000..6867ef6 --- /dev/null +++ b/itests/itest.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +[[ -n $TRAVIS ]] || docker pull "missingcharacter/marathon-python:${MARATHONVERSION}" +[[ -n $TRAVIS ]] || docker run --rm --name marathon-python -d -p 18080:8080 -p 15050:5050 "missingcharacter/marathon-python:${MARATHONVERSION}" +behave "$@" +[[ -n $TRAVIS ]] || docker kill marathon-python diff --git a/itests/itest_utils.py b/itests/itest_utils.py new file mode 100644 index 0000000..0ec865e --- /dev/null +++ b/itests/itest_utils.py @@ -0,0 +1,58 @@ +import errno +from functools import wraps +import os +import signal +import time + +import requests + + +class TimeoutError(Exception): + pass + + +def timeout(seconds=10, error_message=os.strerror(errno.ETIME)): + def decorator(func): + def _handle_timeout(signum, frame): + raise TimeoutError(error_message) + + def wrapper(*args, **kwargs): + signal.signal(signal.SIGALRM, _handle_timeout) + signal.alarm(seconds) + try: + result = func(*args, **kwargs) + finally: + signal.alarm(0) + return result + + return wraps(func)(wrapper) + + return decorator + + +@timeout(30) +def wait_for_marathon(): + """Blocks until marathon is up""" + marathon_service = get_marathon_connection_string() + while True: + print('Connecting to marathon on %s' % marathon_service) + try: + response = requests.get( + 'http://%s/ping' % marathon_service, timeout=2) + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ): + time.sleep(2) + continue + if response.status_code == 200: + print("Marathon is up and running!") + break + + +def get_marathon_connection_string(): + # only reliable way I can detect travis.. + if '/travis/' in os.environ.get('PATH'): + return 'localhost:8080' + else: + return "localhost:18080" diff --git a/itests/marathon_apps.feature b/itests/marathon_apps.feature new file mode 100644 index 0000000..7871be9 --- /dev/null +++ b/itests/marathon_apps.feature @@ -0,0 +1,15 @@ +Feature: marathon-python can create and list marathon apps + + Scenario: Metadata can be fetched + Given a working marathon instance + Then we get the marathon instance's info + + Scenario: Trivial apps can be deployed + Given a working marathon instance + When we create a trivial new app + Then we should see the trivial app running via the marathon api + + Scenario: Complex apps can be deployed + Given a working marathon instance + When we create a complex new app + Then we should see the complex app running via the marathon api diff --git a/itests/marathon_deployments.feature b/itests/marathon_deployments.feature new file mode 100644 index 0000000..cfacb2f --- /dev/null +++ b/itests/marathon_deployments.feature @@ -0,0 +1,6 @@ +Feature: marathon-python read deployments + + Scenario: deployments can be read + Given a working marathon instance + When we create a trivial new app + Then we should be able to see a deployment diff --git a/itests/marathon_tasks.feature b/itests/marathon_tasks.feature new file mode 100644 index 0000000..c3e53fc --- /dev/null +++ b/itests/marathon_tasks.feature @@ -0,0 +1,28 @@ +Feature: marathon-python can operate marathon app tasks + + Scenario: App tasks can be listed + Given a working marathon instance + When we create a trivial new app + And we wait the trivial app deployment finish + Then we should be able to list tasks of the trivial app + + Scenario: App tasks can be killed + Given a working marathon instance + When we create a trivial new app + And we wait the trivial app deployment finish + Then we should be able to kill the tasks + + Scenario: A list of app tasks can be killed + Given a working marathon instance + When we create a trivial new app + And we wait the trivial app deployment finish + Then we should be able to kill the #0,1,2 tasks of the trivial app + + Scenario: Events can be listened in stream + Given a working marathon instance + When marathon version is greater than 0.11.0 + And we start listening for events + And we create a trivial new app + And we wait the trivial app deployment finish + Then we should be able to kill the tasks + And we should see list of events diff --git a/itests/steps/marathon_steps.py b/itests/steps/marathon_steps.py new file mode 100644 index 0000000..131c199 --- /dev/null +++ b/itests/steps/marathon_steps.py @@ -0,0 +1,154 @@ +import sys +import time +import multiprocessing +from distutils.version import LooseVersion + +import marathon +from behave import given, when, then + +from itest_utils import get_marathon_connection_string +sys.path.append('../') + + +@given('a working marathon instance') +def working_marathon(context): + """Adds a working marathon client as context.client for the purposes of + interacting with it in the test.""" + if not hasattr(context, 'client'): + marathon_connection_string = "http://%s" % \ + get_marathon_connection_string() + context.client = marathon.MarathonClient(marathon_connection_string) + + +@then('we get the marathon instance\'s info') +def get_marathon_info(context): + assert context.client.get_info() + + +@when('we create a trivial new app') +def create_trivial_new_app(context): + context.client.create_app('test-trivial-app', marathon.MarathonApp( + cmd='sleep 3600', mem=16, cpus=0.1, instances=5)) + + +@then('we should be able to kill the tasks') +def kill_a_task(context): + time.sleep(5) + app = context.client.get_app('test-trivial-app') + tasks = app.tasks + context.client.kill_task( + app_id='test-trivial-app', task_id=tasks[0].id, scale=True) + + +@when('we create a complex new app') +def create_complex_new_app_with_unicode(context): + app_config = { + 'container': { + 'type': 'DOCKER', + 'docker': { + 'portMappings': + [{'protocol': 'tcp', + 'name': 'myport', + 'containerPort': 8888, + 'hostPort': 0}], + 'image': 'localhost/fake_docker_url', + 'network': 'BRIDGE', + 'parameters': [{'key': 'add-host', 'value': 'google-public-dns-a.google.com:8.8.8.8'}], + }, + 'volumes': + [{'hostPath': '/etc/stuff', + 'containerPath': '/etc/stuff', + 'mode': 'RO'}], + }, + 'instances': 1, + 'mem': 30, + 'args': [], + 'backoff_factor': 2, + 'cpus': 0.25, + 'uris': ['file:///root/.dockercfg'], + 'backoff_seconds': 1, + 'constraints': None, + 'cmd': '/bin/true', + 'health_checks': [ + { + 'protocol': 'HTTP', + 'path': '/health', + 'gracePeriodSeconds': 3, + 'intervalSeconds': 10, + 'portIndex': 0, + 'timeoutSeconds': 10, + 'maxConsecutiveFailures': 3 + }, + ], + } + context.client.create_app( + 'test-complex-app', marathon.MarathonApp(**app_config)) + + +@then('we should see the {which} app running via the marathon api') +def see_complext_app_running(context, which): + print(context.client.list_apps()) + assert context.client.get_app('test-%s-app' % which) + + +@when('we wait the {which} app deployment finish') +def wait_deployment_finish(context, which): + while True: + time.sleep(1) + app = context.client.get_app('test-%s-app' % which, embed_tasks=True) + if not app.deployments: + break + + +@then('we should be able to kill the #{to_kill} tasks of the {which} app') +def kill_tasks(context, to_kill, which): + app_tasks = context.client.get_app( + 'test-%s-app' % which, embed_tasks=True).tasks + + index_to_kill = eval("[" + to_kill + "]") + task_to_kill = [app_tasks[index].id for index in index_to_kill] + + context.client.kill_given_tasks(task_to_kill) + + +@then('we should be able to list tasks of the {which} app') +def list_tasks(context, which): + app = context.client.get_app('test-%s-app' % which) + tasks = context.client.list_tasks('test-%s-app' % which) + assert len(tasks) == app.instances, "we defined {} tasks, got {} tasks".format(app.instances, len(tasks)) + + +def listen_for_events(client, events): + for msg in client.event_stream(): + events.append(msg) + + +@when('marathon version is greater than {version}') +def marathon_version_chech(context, version): + info = context.client.get_info() + if LooseVersion(info.version) < LooseVersion(version): + context.scenario.skip(reason='Marathon version is too low for this scenario') + + +@when('we start listening for events') +def start_listening_stream(context): + manager = multiprocessing.Manager() + mlist = manager.list() + context.manager = manager + context.events = mlist + p = multiprocessing.Process(target=listen_for_events, args=(context.client, mlist)) + p.start() + context.p = p + + +@then('we should see list of events') +def stop_listening_stream(context): + time.sleep(10) + context.p.terminate() + assert len(context.events) >= 1, "We had %d events: %s" % (len(context.events), context.events) + + +@then('we should be able to see a deployment') +def see_a_deployment(context): + deployments = context.client.list_deployments() + assert len(deployments) == 1, "We had %d deployments: %s" % (len(deployments), deployments) diff --git a/marathon/__init__.py b/marathon/__init__.py index ea21d0a..fdb6e47 100644 --- a/marathon/__init__.py +++ b/marathon/__init__.py @@ -1,8 +1,6 @@ -import logging - from .client import MarathonClient from .models import MarathonResource, MarathonApp, MarathonTask, MarathonConstraint from .exceptions import MarathonError, MarathonHttpError, NotFoundError, InvalidChoiceError +from .util import get_log -log = logging.getLogger(__name__) -logging.basicConfig() \ No newline at end of file +log = get_log() diff --git a/marathon/client.py b/marathon/client.py index d05cb15..26c7914 100644 --- a/marathon/client.py +++ b/marathon/client.py @@ -1,28 +1,29 @@ import itertools import time -import urlparse try: import json except ImportError: import simplejson as json -try: - from urllib2 import HTTPError -except ImportError: - from urllib.error import HTTPError import requests import requests.exceptions +from requests_toolbelt.adapters import socket_options import marathon -from .models import MarathonApp, MarathonDeployment, MarathonGroup, MarathonInfo, MarathonTask, MarathonEndpoint -from .exceptions import InternalServerError, NotFoundError, MarathonHttpError, MarathonError +from .models import MarathonApp, MarathonDeployment, MarathonGroup, MarathonInfo, MarathonTask, MarathonEndpoint, MarathonQueueItem +from .exceptions import ConflictError, InternalServerError, NotFoundError, MarathonHttpError, MarathonError, NoResponseError +from .models.base import assert_valid_path +from .models.events import EventFactory, MarathonEvent +from .util import MarathonJsonEncoder, MarathonMinimalJsonEncoder + +class MarathonClient: -class MarathonClient(object): """Client interface for the Marathon REST API.""" - def __init__(self, servers, username=None, password=None, timeout=10): + def __init__(self, servers, username=None, password=None, timeout=10, session=None, + auth_token=None, verify=True, sse_session=None): """Create a MarathonClient instance. If multiple servers are specified, each will be tried in succession until a non-"Connection Error"-type @@ -33,19 +34,41 @@ def __init__(self, servers, username=None, password=None, timeout=10): :type servers: str or list[str] :param str username: Basic auth username :param str password: Basic auth password + :param requests.session session: requests.session for reusing the connections :param int timeout: Timeout (in seconds) for requests to Marathon + :param str auth_token: Token-based auth token, used with DCOS + Oauth + :param bool verify: Enable SSL certificate verification + :param requests.session sse_session: requests.session for event stream connections, which by default enables tcp keepalive """ + if session is None: + self.session = requests.Session() + else: + self.session = session + if sse_session is None: + self.sse_session = requests.Session() + keep_alive = socket_options.TCPKeepAliveAdapter() + self.sse_session.mount('http://', keep_alive) + self.sse_session.mount('https://', keep_alive) + else: + self.sse_session = sse_session self.servers = servers if isinstance(servers, list) else [servers] self.auth = (username, password) if username and password else None + self.verify = verify self.timeout = timeout + self.auth_token = auth_token + if self.auth and self.auth_token: + raise ValueError("Can't specify both auth token and username/password. Must select " + "one type of authentication.") + def __repr__(self): return 'Connection:%s' % self.servers @staticmethod def _parse_response(response, clazz, is_list=False, resource_name=None): """Parse a Marathon response into an object or list of objects.""" - target = response.json()[resource_name] if resource_name else response.json() + target = response.json()[ + resource_name] if resource_name else response.json() if is_list: return [clazz.from_json(resource) for resource in target] else: @@ -53,73 +76,123 @@ def _parse_response(response, clazz, is_list=False, resource_name=None): def _do_request(self, method, path, params=None, data=None): """Query Marathon server.""" - headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} + headers = { + 'Content-Type': 'application/json', 'Accept': 'application/json'} + + if self.auth_token: + headers['Authorization'] = f"token={self.auth_token}" + response = None servers = list(self.servers) while servers and response is None: server = servers.pop(0) url = ''.join([server.rstrip('/'), path]) try: - response = requests.request(method, url, params=params, data=data, headers=headers, - auth=self.auth, timeout=self.timeout) + response = self.session.request( + method, url, params=params, data=data, headers=headers, + auth=self.auth, timeout=self.timeout, verify=self.verify) marathon.log.info('Got response from %s', server) - except requests.exceptions.RequestException, e: - marathon.log.error('Error while calling %s: %s', url, e.message) + except requests.exceptions.RequestException as e: + marathon.log.error( + 'Error while calling %s: %s', url, str(e)) if response is None: - raise MarathonError('No remaining Marathon servers to try') + raise NoResponseError('No remaining Marathon servers to try') if response.status_code >= 500: - marathon.log.error('Got HTTP {code}: {body}'.format(code=response.status_code, body=response.text)) + marathon.log.error('Got HTTP {code}: {body}'.format( + code=response.status_code, body=response.text.encode('utf-8'))) raise InternalServerError(response) elif response.status_code >= 400: - marathon.log.error('Got HTTP {code}: {body}'.format(code=response.status_code, body=response.text)) + marathon.log.error('Got HTTP {code}: {body}'.format( + code=response.status_code, body=response.text.encode('utf-8'))) if response.status_code == 404: raise NotFoundError(response) + elif response.status_code == 409: + raise ConflictError(response) else: raise MarathonHttpError(response) elif response.status_code >= 300: - marathon.log.warn('Got HTTP {code}: {body}'.format(code=response.status_code, body=response.text)) + marathon.log.warn('Got HTTP {code}: {body}'.format( + code=response.status_code, body=response.text.encode('utf-8'))) else: - marathon.log.debug('Got HTTP {code}: {body}'.format(code=response.status_code, body=response.text)) + marathon.log.debug('Got HTTP {code}: {body}'.format( + code=response.status_code, body=response.text.encode('utf-8'))) return response + def _do_sse_request(self, path, params=None): + """Query Marathon server for events.""" + urls = [''.join([server.rstrip('/'), path]) for server in self.servers] + while urls: + url = urls.pop() + try: + # Requests does not set the original Authorization header on cross origin + # redirects. If set allow_redirects=True we may get a 401 response. + response = self.sse_session.get( + url, + params=params, + stream=True, + headers={'Accept': 'text/event-stream'}, + auth=self.auth, + verify=self.verify, + allow_redirects=False + ) + except Exception as e: + marathon.log.error( + 'Error while calling %s: %s', url, e.message) + else: + if response.is_redirect and response.next: + urls.append(response.next.url) + marathon.log.debug(f"Got redirect to {response.next.url}") + elif response.ok: + return response.iter_lines() + + raise MarathonError('No remaining Marathon servers to try') + def list_endpoints(self): """List the current endpoints for all applications :returns: list of endpoints :rtype: list[`MarathonEndpoint`] """ - response = self._do_request('GET', '/v1/endpoints') - endpoints = [MarathonEndpoint.from_json(app) for app in response.json()] - # Flatten result - return [item for sublist in endpoints for item in sublist] + return MarathonEndpoint.from_tasks(self.list_tasks()) - def create_app(self, app_id, app): + def create_app(self, app_id, app, minimal=True): """Create and start an app. :param str app_id: application ID :param :class:`marathon.models.app.MarathonApp` app: the application to create + :param bool minimal: ignore nulls and empty collections :returns: the created app (on success) :rtype: :class:`marathon.models.app.MarathonApp` or False """ - app.id = app_id - data = app.to_json() + app.id = assert_valid_path(app_id) + data = app.to_json(minimal=minimal) + marathon.log.debug('create app JSON sent: {}'.format(data)) response = self._do_request('POST', '/v2/apps', data=data) if response.status_code == 201: return self._parse_response(response, MarathonApp) else: return False - def list_apps(self, cmd=None, embed_tasks=False, embed_failures=False, **kwargs): + def list_apps(self, cmd=None, embed_tasks=False, embed_counts=False, + embed_deployments=False, embed_readiness=False, + embed_last_task_failure=False, embed_failures=False, + embed_task_stats=False, app_id=None, label=None, **kwargs): """List all apps. - :param str app_id: application ID :param str cmd: if passed, only show apps with a matching `cmd` :param bool embed_tasks: embed tasks in result - :param bool embed_failures: embed tasks and last task failure in result + :param bool embed_counts: embed all task counts + :param bool embed_deployments: embed all deployment identifier + :param bool embed_readiness: embed all readiness check results + :param bool embed_last_task_failure: embeds the last task failure + :param bool embed_failures: shorthand for embed_last_task_failure + :param bool embed_task_stats: embed task stats in result + :param str app_id: if passed, only show apps with an 'id' that matches or contains this value + :param str label: if passed, only show apps with the selected labels :param kwargs: arbitrary search filters :returns: list of applications @@ -128,32 +201,81 @@ def list_apps(self, cmd=None, embed_tasks=False, embed_failures=False, **kwargs) params = {} if cmd: params['cmd'] = cmd - - if embed_failures: - params['embed'] = 'apps.failures' - elif embed_tasks: - params['embed'] = 'apps.tasks' + if app_id: + params['id'] = app_id + if label: + params['label'] = label + + embed_params = { + 'app.tasks': embed_tasks, + 'app.counts': embed_counts, + 'app.deployments': embed_deployments, + 'app.readiness': embed_readiness, + 'app.lastTaskFailure': embed_last_task_failure, + 'app.failures': embed_failures, + 'app.taskStats': embed_task_stats + } + filtered_embed_params = [k for (k, v) in embed_params.items() if v] + if filtered_embed_params: + params['embed'] = filtered_embed_params response = self._do_request('GET', '/v2/apps', params=params) - apps = self._parse_response(response, MarathonApp, is_list=True, resource_name='apps') - for k, v in kwargs.iteritems(): - apps = filter(lambda o: getattr(o, k) == v, apps) + apps = self._parse_response( + response, MarathonApp, is_list=True, resource_name='apps') + for k, v in kwargs.items(): + apps = [o for o in apps if getattr(o, k) == v] return apps - def get_app(self, app_id, embed_tasks=False): + def get_app(self, app_id, embed_tasks=False, embed_counts=False, + embed_deployments=False, embed_readiness=False, + embed_last_task_failure=False, embed_failures=False, + embed_task_stats=False): """Get a single app. :param str app_id: application ID :param bool embed_tasks: embed tasks in result + :param bool embed_counts: embed all task counts + :param bool embed_deployments: embed all deployment identifier + :param bool embed_readiness: embed all readiness check results + :param bool embed_last_task_failure: embeds the last task failure + :param bool embed_failures: shorthand for embed_last_task_failure + :param bool embed_task_stats: embed task stats in result :returns: application :rtype: :class:`marathon.models.app.MarathonApp` """ - params = {'embed': 'apps.tasks'} if embed_tasks else {} - response = self._do_request('GET', '/v2/apps/{app_id}'.format(app_id=app_id), params=params) + params = {} + embed_params = { + 'app.tasks': embed_tasks, + 'app.counts': embed_counts, + 'app.deployments': embed_deployments, + 'app.readiness': embed_readiness, + 'app.lastTaskFailure': embed_last_task_failure, + 'app.failures': embed_failures, + 'app.taskStats': embed_task_stats + } + filtered_embed_params = [k for (k, v) in embed_params.items() if v] + if filtered_embed_params: + params['embed'] = filtered_embed_params + + response = self._do_request( + 'GET', f'/v2/apps/{app_id}', params=params) return self._parse_response(response, MarathonApp, resource_name='app') - def update_app(self, app_id, app, force=False): + def restart_app(self, app_id, force=False): + """ + Restarts given application by app_id + :param str app_id: application ID + :param bool force: apply even if a deployment is in progress + :returns: a dict containing the deployment id and version + :rtype: dict + """ + params = {'force': force} + response = self._do_request( + 'POST', f'/v2/apps/{app_id}/restart', params=params) + return response.json() + + def update_app(self, app_id, app, force=False, minimal=True): """Update an app. Applies writable settings in `app` to `app_id` @@ -163,6 +285,7 @@ def update_app(self, app_id, app, force=False): :param app: application settings :type app: :class:`marathon.models.app.MarathonApp` :param bool force: apply even if a deployment is in progress + :param bool minimal: ignore nulls and empty collections :returns: a dict containing the deployment id and version :rtype: dict @@ -171,9 +294,36 @@ def update_app(self, app_id, app, force=False): app.version = None params = {'force': force} - data = app.to_json(minimal=True) + data = app.to_json(minimal=minimal) - response = self._do_request('PUT', '/v2/apps/{app_id}'.format(app_id=app_id), params=params, data=data) + response = self._do_request( + 'PUT', f'/v2/apps/{app_id}', params=params, data=data) + return response.json() + + def update_apps(self, apps, force=False, minimal=True): + """Update multiple apps. + + Applies writable settings in elements of apps either by upgrading existing ones or creating new ones + + :param apps: sequence of application settings + :param bool force: apply even if a deployment is in progress + :param bool minimal: ignore nulls and empty collections + + :returns: a dict containing the deployment id and version + :rtype: dict + """ + json_repr_apps = [] + for app in apps: + # Changes won't take if version is set - blank it for convenience + app.version = None + json_repr_apps.append(app.json_repr(minimal=minimal)) + + params = {'force': force} + encoder = MarathonMinimalJsonEncoder if minimal else MarathonJsonEncoder + data = json.dumps(json_repr_apps, cls=encoder, sort_keys=True) + + response = self._do_request( + 'PUT', '/v2/apps', params=params, data=data) return response.json() def rollback_app(self, app_id, version, force=False): @@ -188,7 +338,8 @@ def rollback_app(self, app_id, version, force=False): """ params = {'force': force} data = json.dumps({'version': version}) - response = self._do_request('PUT', '/v2/apps/{app_id}'.format(app_id=app_id), params=params, data=data) + response = self._do_request( + 'PUT', f'/v2/apps/{app_id}', params=params, data=data) return response.json() def delete_app(self, app_id, force=False): @@ -201,7 +352,8 @@ def delete_app(self, app_id, force=False): :rtype: dict """ params = {'force': force} - response = self._do_request('DELETE', '/v2/apps/{app_id}'.format(app_id=app_id), params=params) + response = self._do_request( + 'DELETE', f'/v2/apps/{app_id}', params=params) return response.json() def scale_app(self, app_id, instances=None, delta=None, force=False): @@ -228,10 +380,11 @@ def scale_app(self, app_id, instances=None, delta=None, force=False): try: app = self.get_app(app_id) except NotFoundError: - marathon.log.error('App "{app}" not found'.format(app=app_id)) + marathon.log.error(f'App "{app_id}" not found') return - desired = instances if instances is not None else (app.instances + delta) + desired = instances if instances is not None else ( + app.instances + delta) return self.update_app(app.id, MarathonApp(instances=desired), force=force) def create_group(self, group): @@ -255,9 +408,10 @@ def list_groups(self, **kwargs): :rtype: list[:class:`marathon.models.group.MarathonGroup`] """ response = self._do_request('GET', '/v2/groups') - groups = self._parse_response(response, MarathonGroup, is_list=True, resource_name='groups') - for k, v in kwargs.iteritems(): - groups = filter(lambda o: getattr(o, k) == v, groups) + groups = self._parse_response( + response, MarathonGroup, is_list=True, resource_name='groups') + for k, v in kwargs.items(): + groups = [o for o in groups if getattr(o, k) == v] return groups def get_group(self, group_id): @@ -268,10 +422,11 @@ def get_group(self, group_id): :returns: group :rtype: :class:`marathon.models.group.MarathonGroup` """ - response = self._do_request('GET', '/v2/groups/{group_id}'.format(group_id=group_id)) - return self._parse_response(response, MarathonGroup, resource_name='group') + response = self._do_request( + 'GET', f'/v2/groups/{group_id}') + return self._parse_response(response, MarathonGroup) - def update_group(self, group_id, group, force=False): + def update_group(self, group_id, group, force=False, minimal=True): """Update a group. Applies writable settings in `group` to `group_id` @@ -281,6 +436,7 @@ def update_group(self, group_id, group, force=False): :param group: group settings :type group: :class:`marathon.models.group.MarathonGroup` :param bool force: apply even if a deployment is in progress + :param bool minimal: ignore nulls and empty collections :returns: a dict containing the deployment id and version :rtype: dict @@ -289,9 +445,10 @@ def update_group(self, group_id, group, force=False): group.version = None params = {'force': force} - data = group.to_json(minimal=True) + data = group.to_json(minimal=minimal) - response = self._do_request('PUT', '/v2/groups/{group_id}'.format(group_id=group_id), data=data, params=params) + response = self._do_request( + 'PUT', f'/v2/groups/{group_id}', data=data, params=params) return response.json() def rollback_group(self, group_id, version, force=False): @@ -305,9 +462,11 @@ def rollback_group(self, group_id, version, force=False): :rtype: dict """ params = {'force': force} - response = self._do_request('PUT', '/v2/groups/{group_id}/versions/{version}'.format(group_id=group_id, - version=version), - params=params) + response = self._do_request( + 'PUT', + '/v2/groups/{group_id}/versions/{version}'.format( + group_id=group_id, version=version), + params=params) return response.json() def delete_group(self, group_id, force=False): @@ -320,7 +479,8 @@ def delete_group(self, group_id, force=False): :rtype: dict """ params = {'force': force} - response = self._do_request('DELETE', '/v2/groups/{group_id}'.format(group_id=group_id), params=params) + response = self._do_request( + 'DELETE', f'/v2/groups/{group_id}', params=params) return response.json() def scale_group(self, group_id, scale_by): @@ -332,8 +492,9 @@ def scale_group(self, group_id, scale_by): :returns: a dict containing the deployment id and version :rtype: dict """ - params = {'scaleBy': scale_by} - response = self._do_request('PUT', '/v2/groups/{group_id}'.format(group_id=group_id), params=params) + data = {'scaleBy': scale_by} + response = self._do_request( + 'PUT', f'/v2/groups/{group_id}', data=json.dumps(data)) return response.json() def list_tasks(self, app_id=None, **kwargs): @@ -345,19 +506,37 @@ def list_tasks(self, app_id=None, **kwargs): :returns: list of tasks :rtype: list[:class:`marathon.models.task.MarathonTask`] """ - if app_id: - response = self._do_request('GET', '/v2/apps/{app_id}/tasks'.format(app_id=app_id)) - else: - response = self._do_request('GET', '/v2/tasks') - - tasks = self._parse_response(response, MarathonTask, is_list=True, resource_name='tasks') - [setattr(t, 'app_id', app_id) for t in tasks if app_id and t.app_id is None] - for k, v in kwargs.iteritems(): - tasks = filter(lambda o: getattr(o, k) == v, tasks) + response = self._do_request( + 'GET', '/v2/apps/%s/tasks' % app_id if app_id else '/v2/tasks') + tasks = self._parse_response( + response, MarathonTask, is_list=True, resource_name='tasks') + [setattr(t, 'app_id', app_id) + for t in tasks if app_id and t.app_id is None] + for k, v in kwargs.items(): + tasks = [o for o in tasks if getattr(o, k) == v] return tasks - def kill_tasks(self, app_id, scale=False, host=None, batch_size=0, batch_delay=0): + def kill_given_tasks(self, task_ids, scale=False, force=None): + """Kill a list of given tasks. + + :param list[str] task_ids: tasks to kill + :param bool scale: if true, scale down the app by the number of tasks killed + :param bool force: if true, ignore any current running deployments + + :return: True on success + :rtype: bool + """ + params = {'scale': scale} + if force is not None: + params['force'] = force + data = json.dumps({"ids": task_ids}) + response = self._do_request( + 'POST', '/v2/tasks/delete', params=params, data=data) + return response.status_code == 200 + + def kill_tasks(self, app_id, scale=False, wipe=False, + host=None, batch_size=0, batch_delay=0): """Kill all tasks belonging to app. :param str app_id: application ID @@ -373,26 +552,38 @@ def batch(iterable, size): sourceiter = iter(iterable) while True: batchiter = itertools.islice(sourceiter, size) - yield itertools.chain([batchiter.next()], batchiter) + yield itertools.chain([next(batchiter)], batchiter) if batch_size == 0: # Terminate all at once - params = {'scale': scale} - if host: params['host'] = host - response = self._do_request('DELETE', '/v2/apps/{app_id}/tasks'.format(app_id=app_id), params) - return self._parse_response(response, MarathonTask, is_list=True, resource_name='tasks') + params = {'scale': scale, 'wipe': wipe} + if host: + params['host'] = host + response = self._do_request( + 'DELETE', f'/v2/apps/{app_id}/tasks', params) + # Marathon is inconsistent about what type of object it returns on the multi + # task deletion endpoint, depending on the version of Marathon. See: + # https://github.com/mesosphere/marathon/blob/06a6f763a75fb6d652b4f1660685ae234bd15387/src/main/scala/mesosphere/marathon/api/v2/AppTasksResource.scala#L88-L95 + if "tasks" in response.json(): + return self._parse_response(response, MarathonTask, is_list=True, resource_name='tasks') + else: + return response.json() else: # Terminate in batches - tasks = self.list_tasks(app_id, host=host) if host else self.list_tasks(app_id) + tasks = self.list_tasks( + app_id, host=host) if host else self.list_tasks(app_id) for tbatch in batch(tasks, batch_size): - killed_tasks = [self.kill_task(app_id, t.id, scale=scale) for t in tbatch] + killed_tasks = [self.kill_task(app_id, t.id, scale=scale, wipe=wipe) + for t in tbatch] - # Pause until the tasks have been killed to avoid race conditions - killed_task_ids = set(t.id for t in killed_tasks) + # Pause until the tasks have been killed to avoid race + # conditions + killed_task_ids = {t.id for t in killed_tasks} running_task_ids = killed_task_ids while killed_task_ids.intersection(running_task_ids): time.sleep(1) - running_task_ids = set(t.id for t in self.get_app(app_id).tasks) + running_task_ids = { + t.id for t in self.get_app(app_id).tasks} if batch_delay == 0: # Pause until the replacement tasks are healthy @@ -400,13 +591,14 @@ def batch(iterable, size): running_instances = 0 while running_instances < desired_instances: time.sleep(1) - running_instances = sum(t.started_at is None for t in self.get_app(app_id).tasks) + running_instances = sum( + t.started_at is None for t in self.get_app(app_id).tasks) else: time.sleep(batch_delay) return tasks - def kill_task(self, app_id, task_id, scale=False): + def kill_task(self, app_id, task_id, scale=False, wipe=False): """Kill a task. :param str app_id: application ID @@ -416,10 +608,16 @@ def kill_task(self, app_id, task_id, scale=False): :returns: the killed task :rtype: :class:`marathon.models.task.MarathonTask` """ - params = {'scale': scale} + params = {'scale': scale, 'wipe': wipe} response = self._do_request('DELETE', '/v2/apps/{app_id}/tasks/{task_id}' .format(app_id=app_id, task_id=task_id), params) - return self._parse_response(response, MarathonTask, resource_name='task') + # Marathon is inconsistent about what type of object it returns on the multi + # task deletion endpoint, depending on the version of Marathon. See: + # https://github.com/mesosphere/marathon/blob/06a6f763a75fb6d652b4f1660685ae234bd15387/src/main/scala/mesosphere/marathon/api/v2/AppTasksResource.scala#L88-L95 + if "task" in response.json(): + return self._parse_response(response, MarathonTask, is_list=False, resource_name='task') + else: + return response.json() def list_versions(self, app_id): """List the versions of an app. @@ -429,7 +627,8 @@ def list_versions(self, app_id): :returns: list of versions :rtype: list[str] """ - response = self._do_request('GET', '/v2/apps/{app_id}/versions'.format(app_id=app_id)) + response = self._do_request( + 'GET', f'/v2/apps/{app_id}/versions') return [version for version in response.json()['versions']] def get_version(self, app_id, version): @@ -443,7 +642,7 @@ def get_version(self, app_id, version): """ response = self._do_request('GET', '/v2/apps/{app_id}/versions/{version}' .format(app_id=app_id, version=version)) - return MarathonApp(response.json()) + return MarathonApp.from_json(response.json()) def list_event_subscriptions(self): """List the event subscriber callback URLs. @@ -487,6 +686,19 @@ def list_deployments(self): response = self._do_request('GET', '/v2/deployments') return self._parse_response(response, MarathonDeployment, is_list=True) + def list_queue(self, embed_last_unused_offers=False): + """List all the tasks queued up or waiting to be scheduled. + + :returns: list of queue items + :rtype: list[:class:`marathon.models.queue.MarathonQueueItem`] + """ + if embed_last_unused_offers: + params = {'embed': 'lastUnusedOffers'} + else: + params = {} + response = self._do_request('GET', '/v2/queue', params=params) + return self._parse_response(response, MarathonQueueItem, is_list=True, resource_name='queue') + def delete_deployment(self, deployment_id, force=False): """Cancel a deployment. @@ -498,13 +710,21 @@ def delete_deployment(self, deployment_id, force=False): """ if force: params = {'force': True} - self._do_request('DELETE', '/v2/deployments/{deployment}'.format(deployment=deployment_id), params=params) - # Successful DELETE with ?force=true returns empty text (and status code 202). Client code should poll until deployment is removed. + self._do_request('DELETE', '/v2/deployments/{deployment}'.format( + deployment=deployment_id), params=params) + # Successful DELETE with ?force=true returns empty text (and status + # code 202). Client code should poll until deployment is removed. return {} else: - response = self._do_request('DELETE', '/v2/deployments/{deployment}'.format(deployment=deployment_id)) + response = self._do_request( + 'DELETE', f'/v2/deployments/{deployment_id}') return response.json() + def reset_delay(self, app_id): + self._do_request( + "DELETE", f'/v2/queue/{app_id}/delay' + ) + def get_info(self): """Get server configuration information. @@ -514,6 +734,24 @@ def get_info(self): response = self._do_request('GET', '/v2/info') return self._parse_response(response, MarathonInfo) + def get_leader(self): + """Get the current marathon leader. + + :returns: leader endpoint + :rtype: dict + """ + response = self._do_request('GET', '/v2/leader') + return response.json() + + def delete_leader(self): + """Causes the current leader to abdicate, triggers a new election. + + :returns: message saying leader abdicated + :rtype: dict + """ + response = self._do_request('DELETE', '/v2/leader') + return response.json() + def ping(self): """Ping the Marathon server. @@ -521,7 +759,7 @@ def ping(self): :rtype: str """ response = self._do_request('GET', '/ping') - return response.text + return response.text.encode('utf-8') def get_metrics(self): """Get server metrics @@ -531,3 +769,38 @@ def get_metrics(self): """ response = self._do_request('GET', '/metrics') return response.json() + + def event_stream(self, raw=False, event_types=None): + """Polls event bus using /v2/events + + :param bool raw: if true, yield raw event text, else yield MarathonEvent object + :param event_types: a list of event types to consume + :type event_types: list[type] or list[str] + :returns: iterator with events + :rtype: iterator + """ + + ef = EventFactory() + + params = { + 'event_type': [ + EventFactory.class_to_event[et] if isinstance( + et, type) and issubclass(et, MarathonEvent) else et + for et in event_types or [] + ] + } + + for raw_message in self._do_sse_request('/v2/events', params=params): + try: + _data = raw_message.decode('utf8').split(':', 1) + + if _data[0] == 'data': + if raw: + yield _data[1] + else: + event_data = json.loads(_data[1].strip()) + if 'eventType' not in event_data: + raise MarathonError('Invalid event data received.') + yield ef.process(event_data) + except ValueError: + raise MarathonError('Invalid event data received.') diff --git a/marathon/exceptions.py b/marathon/exceptions.py index 666935b..0889cb9 100644 --- a/marathon/exceptions.py +++ b/marathon/exceptions.py @@ -8,10 +8,13 @@ def __init__(self, response): """ :param :class:`requests.Response` response: HTTP response """ - content = response.json() + self.error_message = response.reason or '' + if response.content and 'application/json' in response.headers.get('content-type', ''): + content = response.json() + self.error_message = content.get('message', self.error_message) + self.error_details = content.get('details') self.status_code = response.status_code - self.error_message = content['message'] - super(MarathonHttpError, self).__init__(self.__str__() ) + super().__init__(self.__str__()) def __repr__(self): return 'MarathonHttpError: HTTP %s returned with message, "%s"' % \ @@ -29,11 +32,19 @@ class InternalServerError(MarathonHttpError): pass +class ConflictError(MarathonHttpError): + pass + + class InvalidChoiceError(MarathonError): def __init__(self, param, value, options): - super(InvalidChoiceError, self).__init__( + super().__init__( 'Invalid choice "{value}" for param "{param}". Must be one of {options}'.format( param=param, value=value, options=options ) - ) \ No newline at end of file + ) + + +class NoResponseError(MarathonError): + pass diff --git a/marathon/models/__init__.py b/marathon/models/__init__.py index 00a0286..1591d3f 100644 --- a/marathon/models/__init__.py +++ b/marathon/models/__init__.py @@ -1,7 +1,7 @@ from .app import MarathonApp, MarathonHealthCheck from .base import MarathonResource, MarathonObject from .constraint import MarathonConstraint -from .deployment import MarathonDeployment, MarathonDeploymentAction +from .deployment import MarathonDeployment, MarathonDeploymentAction, MarathonDeploymentStep from .endpoint import MarathonEndpoint from .group import MarathonGroup from .info import MarathonInfo, MarathonConfig, MarathonZooKeeperConfig diff --git a/marathon/models/app.py b/marathon/models/app.py index c9327f8..e290fd9 100644 --- a/marathon/models/app.py +++ b/marathon/models/app.py @@ -1,17 +1,24 @@ -from datetime import datetime - -from .base import MarathonResource, MarathonObject +from ..exceptions import InvalidChoiceError +from .base import MarathonResource, MarathonObject, assert_valid_path from .constraint import MarathonConstraint from .container import MarathonContainer from .deployment import MarathonDeployment from .task import MarathonTask +from ..util import get_log +from ..util import to_datetime + +log = get_log() class MarathonApp(MarathonResource): + """Marathon Application resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#post-/v2/apps + :param list[str] accepted_resource_roles: a list of resource roles (the resource offer + must contain at least one of these for the app + to be launched on that host) :param list[str] args: args form of the command to run :param int backoff_factor: multiplier for subsequent backoff :param int backoff_seconds: base time, in seconds, for exponential backoff @@ -27,13 +34,17 @@ class MarathonApp(MarathonResource): :type deployments: list[:class:`marathon.models.deployment.MarathonDeployment`] :param dict env: env vars :param str executor: executor + :param int gpus: gpus required per instance :param health_checks: health checks :type health_checks: list[:class:`marathon.models.MarathonHealthCheck`] or list[dict] :param str id: app id + :param str role: mesos role :param int instances: instances :param last_task_failure: last task failure :type last_task_failure: :class:`marathon.models.app.MarathonTaskFailure` or dict :param float mem: memory (in MB) required per instance + :param dict secrets: A map with named secret declarations. + :type port_definitions: list[:class:`marathon.models.app.PortDefinitions`] or list[dict] :param list[int] ports: ports :param bool require_ports: require the specified `ports` to be available in the resource offer :param list[str] store_urls: store URLs @@ -42,34 +53,54 @@ class MarathonApp(MarathonResource): :type tasks: list[:class:`marathon.models.task.MarathonTask`] :param int tasks_running: (read-only) the number of running tasks :param int tasks_staged: (read-only) the number of staged tasks + :param int tasks_healthy: (read-only) the number of healthy tasks + :param int tasks_unhealthy: (read-only) the number of unhealthy tasks :param upgrade_strategy: strategy by which app instances are replaced during a deployment :type upgrade_strategy: :class:`marathon.models.app.MarathonUpgradeStrategy` or dict :param list[str] uris: uris :param str user: user :param str version: version id + :param version_info: time of last scaling, last config change + :type version_info: :class:`marathon.models.app.MarathonAppVersionInfo` or dict + :param task_stats: task statistics + :type task_stats: :class:`marathon.models.app.MarathonTaskStats` or dict :param dict labels + :type readiness_checks: list[:class:`marathon.models.app.ReadinessCheck`] or list[dict] + :type residency: :class:`marathon.models.app.Residency` or dict + :param int task_kill_grace_period_seconds: Configures the termination signal escalation behavior of executors when stopping tasks. + :param list[dict] unreachable_strategy: Handling for unreachable instances. + :param str kill_selection: Defines which instance should be killed first in case of e.g. rescaling. """ UPDATE_OK_ATTRIBUTES = [ 'args', 'backoff_factor', 'backoff_seconds', 'cmd', 'constraints', 'container', 'cpus', 'dependencies', 'disk', - 'env', 'executor', 'health_checks', 'instances', 'labels', 'max_launch_delay_seconds', 'mem', 'ports', 'require_ports', - 'store_urls', 'task_rate_limit', 'upgrade_strategy', 'uris', 'user', 'version' + 'env', 'executor', 'gpus', 'health_checks', 'instances', 'kill_selection', 'labels', 'max_launch_delay_seconds', + 'mem', 'ports', 'require_ports', 'store_urls', 'task_rate_limit', 'upgrade_strategy', 'unreachable_strategy', + 'uris', 'user', 'version', 'role' ] """List of attributes which may be updated/changed after app creation""" - CREATE_ONLY_ATTRIBUTES = ['id'] + CREATE_ONLY_ATTRIBUTES = ['id', 'accepted_resource_roles'] """List of attributes that should only be passed on creation""" - READ_ONLY_ATTRIBUTES = ['deployments', 'tasks', 'tasks_running', 'tasks_staging'] + READ_ONLY_ATTRIBUTES = [ + 'deployments', 'tasks', 'tasks_running', 'tasks_staged', 'tasks_healthy', 'tasks_unhealthy'] """List of read-only attributes""" - def __init__(self, args=None, backoff_factor=None, backoff_seconds=None, cmd=None, constraints=None, container=None, - cpus=None, dependencies=None, deployments=None, disk=None, env=None, executor=None, health_checks=None, - id=None, instances=None, labels=None, last_task_failure=None, max_launch_delay_seconds=None, mem=None, - ports=None, require_ports=None, store_urls=None, task_rate_limit=None, tasks=None, tasks_running=None, - tasks_staged=None, upgrade_strategy=None, uris=None, user=None, version=None): + KILL_SELECTIONS = ["YOUNGEST_FIRST", "OLDEST_FIRST"] + + def __init__(self, accepted_resource_roles=None, args=None, backoff_factor=None, backoff_seconds=None, cmd=None, + constraints=None, container=None, cpus=None, dependencies=None, deployments=None, disk=None, env=None, + executor=None, health_checks=None, id=None, role=None, instances=None, kill_selection=None, labels=None, + last_task_failure=None, max_launch_delay_seconds=None, mem=None, ports=None, require_ports=None, + store_urls=None, task_rate_limit=None, tasks=None, tasks_running=None, tasks_staged=None, + tasks_healthy=None, task_kill_grace_period_seconds=None, tasks_unhealthy=None, upgrade_strategy=None, + unreachable_strategy=None, uris=None, user=None, version=None, version_info=None, + ip_address=None, fetch=None, task_stats=None, readiness_checks=None, + readiness_check_results=None, secrets=None, port_definitions=None, residency=None, gpus=None, networks=None): # self.args = args or [] + self.accepted_resource_roles = accepted_resource_roles self.args = args # Marathon 0.7.0-RC1 throws a validation error if this is [] and cmd is passed: # "error": "AppDefinition must either contain a 'cmd' or a 'container'." @@ -86,26 +117,52 @@ def __init__(self, args=None, backoff_factor=None, backoff_seconds=None, cmd=Non self.cpus = cpus self.dependencies = dependencies or [] self.deployments = [ - d if isinstance(d, MarathonDeployment) else MarathonDeployment().from_json(d) + d if isinstance( + d, MarathonDeployment) else MarathonDeployment().from_json(d) for d in (deployments or []) ] self.disk = disk - self.env = env + self.env = env or dict() self.executor = executor + self.gpus = gpus self.health_checks = health_checks or [] self.health_checks = [ - hc if isinstance(hc, MarathonHealthCheck) else MarathonHealthCheck().from_json(hc) + hc if isinstance( + hc, MarathonHealthCheck) else MarathonHealthCheck().from_json(hc) for hc in (health_checks or []) ] - self.id = id + self.id = assert_valid_path(id) + self.role = role self.instances = instances + if kill_selection and kill_selection not in self.KILL_SELECTIONS: + raise InvalidChoiceError( + 'kill_selection', kill_selection, self.KILL_SELECTIONS) + self.kill_selection = kill_selection self.labels = labels or {} self.last_task_failure = last_task_failure if (isinstance(last_task_failure, MarathonTaskFailure) or last_task_failure is None) \ else MarathonTaskFailure.from_json(last_task_failure) self.max_launch_delay_seconds = max_launch_delay_seconds self.mem = mem self.ports = ports or [] + self.port_definitions = [ + pd if isinstance( + pd, PortDefinition) else PortDefinition.from_json(pd) + for pd in (port_definitions or []) + ] + self.readiness_checks = [ + rc if isinstance( + rc, ReadinessCheck) else ReadinessCheck().from_json(rc) + for rc in (readiness_checks or []) + ] + self.readiness_check_results = readiness_check_results or [] + self.residency = residency self.require_ports = require_ports + + self.secrets = secrets or {} + for k, s in self.secrets.items(): + if not isinstance(s, Secret): + self.secrets[k] = Secret().from_json(s) + self.store_urls = store_urls or [] self.task_rate_limit = task_rate_limit self.tasks = [ @@ -114,14 +171,31 @@ def __init__(self, args=None, backoff_factor=None, backoff_seconds=None, cmd=Non ] self.tasks_running = tasks_running self.tasks_staged = tasks_staged + self.tasks_healthy = tasks_healthy + self.task_kill_grace_period_seconds = task_kill_grace_period_seconds + self.tasks_unhealthy = tasks_unhealthy self.upgrade_strategy = upgrade_strategy if (isinstance(upgrade_strategy, MarathonUpgradeStrategy) or upgrade_strategy is None) \ else MarathonUpgradeStrategy.from_json(upgrade_strategy) + self.unreachable_strategy = unreachable_strategy \ + if (isinstance(unreachable_strategy, MarathonUnreachableStrategy) + or unreachable_strategy is None) \ + else MarathonUnreachableStrategy.from_json(unreachable_strategy) self.uris = uris or [] + self.fetch = fetch or [] self.user = user self.version = version + self.version_info = version_info if (isinstance(version_info, MarathonAppVersionInfo) or version_info is None) \ + else MarathonAppVersionInfo.from_json(version_info) + self.task_stats = task_stats if (isinstance(task_stats, MarathonTaskStats) or task_stats is None) \ + else MarathonTaskStats.from_json(task_stats) + self.networks = networks + + def add_env(self, key, value): + self.env[key] = value class MarathonHealthCheck(MarathonObject): + """Marathon health check. See https://mesosphere.github.io/marathon/docs/health-checks.html @@ -134,11 +208,27 @@ class MarathonHealthCheck(MarathonObject): :param int port_index: target port as indexed in app's `ports` array :param str protocol: health check protocol ('HTTP', 'TCP', or 'COMMAND') :param int timeout_seconds: how long before a waiting health check is considered failed + :param bool ignore_http1xx: Ignore HTTP informational status codes 100 to 199. + :param dict kwargs: additional arguments for forward compatibility """ def __init__(self, command=None, grace_period_seconds=None, interval_seconds=None, max_consecutive_failures=None, - path=None, port_index=None, protocol=None, timeout_seconds=None): - self.command = command + path=None, port_index=None, protocol=None, timeout_seconds=None, ignore_http1xx=None, **kwargs): + + if command is None: + self.command = None + elif isinstance(command, str): + self.command = { + "value": command + } + elif type(command) is dict and 'value' in command: + log.warn('Deprecated: Using command as dict instead of string is deprecated') + self.command = { + "value": command['value'] + } + else: + raise ValueError(f'Invalid command format: {command}') + self.grace_period_seconds = grace_period_seconds self.interval_seconds = interval_seconds self.max_consecutive_failures = max_consecutive_failures @@ -146,35 +236,42 @@ def __init__(self, command=None, grace_period_seconds=None, interval_seconds=Non self.port_index = port_index self.protocol = protocol self.timeout_seconds = timeout_seconds + self.ignore_http1xx = ignore_http1xx + # additional not previously known healthcheck attributes + for k, v in kwargs.items(): + setattr(self, k, v) class MarathonTaskFailure(MarathonObject): + """Marathon Task Failure. :param str app_id: application id :param str host: mesos slave running the task :param str message: error message :param str task_id: task id + :param str instance_id: instance id :param str state: task state :param timestamp: when this task failed :type timestamp: datetime or str :param str version: app version with which this task was started """ - DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' - - def __init__(self, app_id=None, host=None, message=None, task_id=None, state=None, timestamp=None, version=None): + def __init__(self, app_id=None, host=None, message=None, task_id=None, instance_id=None, + slave_id=None, state=None, timestamp=None, version=None): self.app_id = app_id self.host = host self.message = message self.task_id = task_id + self.instance_id = instance_id + self.slave_id = slave_id self.state = state - self.timestamp = timestamp if (timestamp is None or isinstance(timestamp, datetime)) \ - else datetime.strptime(timestamp, self.DATETIME_FORMAT) + self.timestamp = to_datetime(timestamp) self.version = version class MarathonUpgradeStrategy(MarathonObject): + """Marathon health check. See https://mesosphere.github.io/marathon/docs/health-checks.html @@ -182,6 +279,226 @@ class MarathonUpgradeStrategy(MarathonObject): :param float minimum_health_capacity: minimum % of instances kept healthy on deploy """ - def __init__(self, maximum_over_capacity=None, minimum_health_capacity=None): + def __init__(self, maximum_over_capacity=None, + minimum_health_capacity=None): self.maximum_over_capacity = maximum_over_capacity - self.minimum_health_capacity = minimum_health_capacity \ No newline at end of file + self.minimum_health_capacity = minimum_health_capacity + + +class MarathonUnreachableStrategy(MarathonObject): + + """Marathon unreachable Strategy. + + Define handling for unreachable instances. Given + `unreachable_inactive_after_seconds = 60` and + `unreachable_expunge_after = 120`, an instance will be expunged if it has + been unreachable for more than 120 seconds or a second instance is started + if it has been unreachable for more than 60 seconds.", + + See https://mesosphere.github.io/marathon/docs/? + + :param int unreachable_inactive_after_seconds: time an instance is + unreachable for in seconds before marked as inactive. + :param int unreachable_expunge_after_seconds: time an instance is + unreachable for in seconds before expunged. + :param int inactive_after_seconds + :param int expunge_after_seconds + """ + DISABLED = 'disabled' + + def __init__(self, unreachable_inactive_after_seconds=None, + unreachable_expunge_after_seconds=None, + inactive_after_seconds=None, expunge_after_seconds=None): + self.unreachable_inactive_after_seconds = unreachable_inactive_after_seconds + self.unreachable_expunge_after_seconds = unreachable_expunge_after_seconds + self.inactive_after_seconds = inactive_after_seconds + self.expunge_after_seconds = expunge_after_seconds + + @classmethod + def from_json(cls, attributes): + if attributes == cls.DISABLED: + return cls.DISABLED + return super().from_json(attributes) + + +class MarathonAppVersionInfo(MarathonObject): + + """Marathon App version info. + + See release notes for Marathon v0.11.0 + https://github.com/mesosphere/marathon/releases/tag/v0.11.0 + + :param str app_id: application id + :param str host: mesos slave running the task + """ + + def __init__(self, last_scaling_at=None, last_config_change_at=None): + self.last_scaling_at = to_datetime(last_scaling_at) + self.last_config_change_at = to_datetime(last_config_change_at) + + +class MarathonTaskStats(MarathonObject): + + """Marathon task statistics + + See https://mesosphere.github.io/marathon/docs/rest-api.html#taskstats-object-v0-11 + + :param started_after_last_scaling: contains statistics about all tasks that were started after the last scaling or restart operation. + :type started_after_last_scaling: :class:`marathon.models.app.MarathonTaskStatsType` or dict + :param with_latest_config: contains statistics about all tasks that run with the same config as the latest app version. + :type with_latest_config: :class:`marathon.models.app.MarathonTaskStatsType` or dict + :param with_outdated_config: contains statistics about all tasks that were started before the last config change + which was not simply a restart or scaling operation. + :type with_outdated_config: :class:`marathon.models.app.MarathonTaskStatsType` or dict + :param total_summary: contains statistics about all tasks. + :type total_summary: :class:`marathon.models.app.MarathonTaskStatsType` or dict + """ + + def __init__(self, started_after_last_scaling=None, + with_latest_config=None, with_outdated_config=None, total_summary=None): + self.started_after_last_scaling = started_after_last_scaling if \ + (isinstance(started_after_last_scaling, MarathonTaskStatsType) or started_after_last_scaling is None) \ + else MarathonTaskStatsType.from_json(started_after_last_scaling) + self.with_latest_config = with_latest_config if \ + (isinstance(with_latest_config, MarathonTaskStatsType) or with_latest_config is None) \ + else MarathonTaskStatsType.from_json(with_latest_config) + self.with_outdated_config = with_outdated_config if \ + (isinstance(with_outdated_config, MarathonTaskStatsType) or with_outdated_config is None) \ + else MarathonTaskStatsType.from_json(with_outdated_config) + self.total_summary = total_summary if \ + (isinstance(total_summary, MarathonTaskStatsType) or total_summary is None) \ + else MarathonTaskStatsType.from_json(total_summary) + + +class MarathonTaskStatsType(MarathonObject): + + """Marathon app task stats + + :param stats: stast about app tasks + :type stats: :class:`marathon.models.app.MarathonTaskStatsStats` or dict + """ + + def __init__(self, stats=None): + self.stats = stats if (isinstance(stats, MarathonTaskStatsStats) or stats is None)\ + else MarathonTaskStatsStats.from_json(stats) + + +class MarathonTaskStatsStats(MarathonObject): + + """Marathon app task stats + + :param counts: app task count breakdown + :type counts: :class:`marathon.models.app.MarathonTaskStatsCounts` or dict + :param life_time: app task life time stats + :type life_time: :class:`marathon.models.app.MarathonTaskStatsLifeTime` or dict + """ + + def __init__(self, counts=None, life_time=None): + self.counts = counts if (isinstance(counts, MarathonTaskStatsCounts) or counts is None)\ + else MarathonTaskStatsCounts.from_json(counts) + self.life_time = life_time if (isinstance(life_time, MarathonTaskStatsLifeTime) or life_time is None)\ + else MarathonTaskStatsLifeTime.from_json(life_time) + + +class MarathonTaskStatsCounts(MarathonObject): + + """Marathon app task counts + + Equivalent to tasksStaged, tasksRunning, tasksHealthy, tasksUnhealthy. + + :param int staged: Staged task count + :param int running: Running task count + :param int healthy: Healthy task count + :param int unhealthy: unhealthy task count + """ + + def __init__(self, staged=None, + running=None, healthy=None, unhealthy=None): + self.staged = staged + self.running = running + self.healthy = healthy + self.unhealthy = unhealthy + + +class MarathonTaskStatsLifeTime(MarathonObject): + + """Marathon app life time statistics + + Measured from `"startedAt"` (timestamp of the Mesos TASK_RUNNING status update) of each running task until now + + :param float average_seconds: Average seconds + :param float median_seconds: Median seconds + """ + + def __init__(self, average_seconds=None, median_seconds=None): + self.average_seconds = average_seconds + self.median_seconds = median_seconds + + +class ReadinessCheck(MarathonObject): + """Marathon readiness check: https://mesosphere.github.io/marathon/docs/readiness-checks.html + + :param string name (Optional. Default: "readinessCheck"): The name used to identify this readiness check. + :param string protocol (Optional. Default: "HTTP"): Protocol of the requests to be performed. Either HTTP or HTTPS. + :param string path (Optional. Default: "/"): Path to the endpoint the task exposes to provide readiness status. + Example: /path/to/readiness. + :param string port_name (Optional. Default: "http-api"): Name of the port to query as described in the + portDefinitions. Example: http-api. + :param int interval_seconds (Optional. Default: 30 seconds): Number of seconds to wait between readiness checks. + :param int timeout_seconds (Optional. Default: 10 seconds): Number of seconds after which a readiness check + times out, regardless of the response. This value must be smaller than interval_seconds. + :param list http_status_codes_for_ready (Optional. Default: [200]): The HTTP/HTTPS status code to treat as ready. + :param bool preserve_last_response (Optional. Default: false): If true, the last readiness check response will be + preserved and exposed in the API as part of a deployment. + + """ + + def __init__(self, name=None, protocol=None, path=None, port_name=None, interval_seconds=None, + http_status_codes_for_ready=None, preserve_last_response=None, timeout_seconds=None): + self.name = name + self.protocol = protocol + self.path = path + self.port_name = port_name + self.interval_seconds = interval_seconds + self.http_status_codes_for_ready = http_status_codes_for_ready + self.preserve_last_response = preserve_last_response + self.timeout_seconds = timeout_seconds + + +class PortDefinition(MarathonObject): + """Marathon port definitions: https://mesosphere.github.io/marathon/docs/ports.html + + :param int port: The port + :param string protocol: tcp or udp + :param string name: (optional) the name of the port + :param dict labels: undocumented + """ + + def __init__(self, port=None, protocol=None, name=None, labels=None): + self.port = port + self.protocol = protocol + self.name = name + self.labels = labels + + +class Residency(MarathonObject): + """Declares how "resident" an app is: https://mesosphere.github.io/marathon/docs/persistent-volumes.html + + :param int relaunch_escalation_timeout_seconds: How long marathon will try to relaunch where the volumes is, defaults to 3600 + :param string task_lost_behavior: What to do after a TASK_LOST. See the official Marathon docs for options + + """ + + def __init__(self, relaunch_escalation_timeout_seconds=None, task_lost_behavior=None): + self.relaunch_escalation_timeout_seconds = relaunch_escalation_timeout_seconds + self.task_lost_behavior = task_lost_behavior + + +class Secret(MarathonObject): + """Declares marathon secret object. + :param str source: The source of the secret's value. The format depends on the secret store used by Mesos. + + """ + + def __init__(self, source=None): + self.source = source diff --git a/marathon/models/base.py b/marathon/models/base.py index bd0a2d0..39aba17 100644 --- a/marathon/models/base.py +++ b/marathon/models/base.py @@ -1,13 +1,26 @@ import json +import re from marathon.util import to_camel_case, to_snake_case, MarathonJsonEncoder, MarathonMinimalJsonEncoder -class MarathonObject(object): +class MarathonObject: """Base Marathon object.""" def __repr__(self): - return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json()) + return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json(minimal=False)) + + def __eq__(self, other): + try: + return self.__dict__ == other.__dict__ + except Exception: + return False + + def __hash__(self): + # Technically this class shouldn't be hashable because it often + # contains mutable fields, but in practice this class is used more + # like a record or namedtuple. + return hash(self.to_json()) def json_repr(self, minimal=False): """Construct a JSON-friendly representation of the object. @@ -17,9 +30,9 @@ def json_repr(self, minimal=False): :rtype: dict """ if minimal: - return {to_camel_case(k):v for k,v in vars(self).iteritems() if (v or v == False)} + return {to_camel_case(k): v for k, v in vars(self).items() if (v or v is False or v == 0)} else: - return {to_camel_case(k):v for k,v in vars(self).iteritems()} + return {to_camel_case(k): v for k, v in vars(self).items()} @classmethod def from_json(cls, attributes): @@ -27,7 +40,7 @@ def from_json(cls, attributes): :param dict attributes: object attributes from parsed response """ - return cls(**{to_snake_case(k): v for k,v in attributes.iteritems()}) + return cls(**{to_snake_case(k): v for k, v in attributes.items()}) def to_json(self, minimal=True): """Encode an object as a JSON string. @@ -42,15 +55,66 @@ def to_json(self, minimal=True): return json.dumps(self.json_repr(), cls=MarathonJsonEncoder, sort_keys=True) - class MarathonResource(MarathonObject): + """Base Marathon resource.""" def __repr__(self): - if 'id' in vars(self).keys(): - return "{clazz}::{id}".format(clazz=self.__class__.__name__, id=self.id) + if 'id' in list(vars(self).keys()): + return f"{self.__class__.__name__}::{self.id}" else: return "{clazz}::{obj}".format(clazz=self.__class__.__name__, obj=self.to_json()) + def __eq__(self, other): + try: + return self.__dict__ == other.__dict__ + except Exception: + return False + + def __hash__(self): + # Technically this class shouldn't be hashable because it often + # contains mutable fields, but in practice this class is used more + # like a record or namedtuple. + return hash(self.to_json()) + + def __str__(self): + return f"{self.__class__.__name__}::" + str(self.__dict__) + + +# See: +# https://github.com/mesosphere/marathon/blob/2a9d1d20ec2f1cfcc49fbb1c0e7348b26418ef38/src/main/scala/mesosphere/marathon/api/ModelValidation.scala#L224 +ID_PATTERN = re.compile( + '^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])|(\\.|\\.\\.)$') + + +def assert_valid_path(path): + """Checks if a path is a correct format that Marathon expects. Raises ValueError if not valid. + + :param str path: The app id. + + :rtype: str + """ + if path is None: + return + # As seen in: + # https://github.com/mesosphere/marathon/blob/0c11661ca2f259f8a903d114ef79023649a6f04b/src/main/scala/mesosphere/marathon/state/PathId.scala#L71 + for id in filter(None, path.strip('/').split('/')): + if not ID_PATTERN.match(id): + raise ValueError( + 'invalid path (allowed: lowercase letters, digits, hyphen, "/", ".", ".."): %r' % path) + return path + + +def assert_valid_id(id): + """Checks if an id is the correct format that Marathon expects. Raises ValueError if not valid. + :param str id: App or group id. + :rtype: str + """ + if id is None: + return + if not ID_PATTERN.match(id.strip('/')): + raise ValueError( + 'invalid id (allowed: lowercase letters, digits, hyphen, ".", ".."): %r' % id) + return id diff --git a/marathon/models/constraint.py b/marathon/models/constraint.py index 9d87663..cbbde87 100644 --- a/marathon/models/constraint.py +++ b/marathon/models/constraint.py @@ -1,8 +1,8 @@ -from ..exceptions import InvalidChoiceError from .base import MarathonObject class MarathonConstraint(MarathonObject): + """Marathon placement constraint. See https://mesosphere.github.io/marathon/docs/constraints.html @@ -15,12 +15,9 @@ class MarathonConstraint(MarathonObject): :type value: str, int, or None """ - OPERATORS = ['UNIQUE', 'CLUSTER', 'GROUP_BY', 'LIKE', 'UNLIKE'] """Valid operators""" def __init__(self, field, operator, value=None): - if not operator in self.OPERATORS: - raise InvalidChoiceError('operator', operator, self.OPERATORS) self.field = field self.operator = operator self.value = value @@ -58,3 +55,19 @@ def from_json(cls, obj): if len(obj) > 2: (field, operator, value) = obj return cls(field, operator, value) + + @classmethod + def from_string(cls, constraint): + """ + :param str constraint: The string representation of a constraint + + :rtype: :class:`MarathonConstraint` + """ + obj = constraint.split(':') + marathon_constraint = cls.from_json(obj) + + if marathon_constraint: + return marathon_constraint + + raise ValueError("Invalid string format. " + "Expected `field:operator:value`") diff --git a/marathon/models/container.py b/marathon/models/container.py index 1fea5c9..0ec267a 100644 --- a/marathon/models/container.py +++ b/marathon/models/container.py @@ -3,6 +3,7 @@ class MarathonContainer(MarathonObject): + """Marathon health check. See https://mesosphere.github.io/marathon/docs/native-docker.html @@ -10,26 +11,42 @@ class MarathonContainer(MarathonObject): :param docker: docker field (e.g., {"image": "mygroup/myimage"})' :type docker: :class:`marathon.models.container.MarathonDockerContainer` or dict :param str type: + :param port_mappings: New in Marathon v1.5. container.docker.port_mappings moved here. + :type port_mappings: list[:class:`marathon.models.container.MarathonContainerPortMapping`] or list[dict] :param volumes: :type volumes: list[:class:`marathon.models.container.MarathonContainerVolume`] or list[dict] """ - TYPES = ['DOCKER'] + TYPES = ['DOCKER', 'MESOS'] """Valid container types""" - def __init__(self, docker=None, type='DOCKER', volumes=None): - if not type in self.TYPES: + def __init__(self, docker=None, type='DOCKER', port_mappings=None, volumes=None): + if type not in self.TYPES: raise InvalidChoiceError('type', type, self.TYPES) self.type = type - self.docker = docker if isinstance(docker, MarathonDockerContainer) \ - else MarathonDockerContainer().from_json(docker) + + # Marathon v1.5 moved portMappings from within container.docker object directly + # under the container object + if port_mappings: + self.port_mappings = [ + pm if isinstance( + pm, MarathonContainerPortMapping) else MarathonContainerPortMapping().from_json(pm) + for pm in (port_mappings or []) + ] + + if docker: + self.docker = docker if isinstance(docker, MarathonDockerContainer) \ + else MarathonDockerContainer().from_json(docker) + self.volumes = [ - v if isinstance(v, MarathonContainerVolume) else MarathonContainerVolume().from_json(v) + v if isinstance( + v, MarathonContainerVolume) else MarathonContainerVolume().from_json(v) for v in (volumes or []) ] class MarathonDockerContainer(MarathonObject): + """Docker options. See https://mesosphere.github.io/marathon/docs/native-docker.html @@ -38,50 +55,61 @@ class MarathonDockerContainer(MarathonObject): :param str network: :param port_mappings: :type port_mappings: list[:class:`marathon.models.container.MarathonContainerPortMapping`] or list[dict] - :param dict parameters: + :param list[dict] parameters: :param bool privileged: run container in privileged mode + :param bool force_pull_image: Force a docker pull before launching """ - NETWORK_MODES=['BRIDGE', 'HOST'] + NETWORK_MODES = ['BRIDGE', 'HOST', 'USER', 'NONE'] """Valid network modes""" - def __init__(self, image=None, network='HOST', port_mappings=None, parameters=None, privileged=None): + def __init__(self, image=None, network=None, port_mappings=None, parameters=None, privileged=None, + force_pull_image=None, **kwargs): self.image = image if network: - if not network in self.NETWORK_MODES: - raise InvalidChoiceError('network', network, self.NETWORK_MODES) + if network not in self.NETWORK_MODES: + raise InvalidChoiceError( + 'network', network, self.NETWORK_MODES) self.network = network self.port_mappings = [ - pm if isinstance(pm, MarathonContainerPortMapping) else MarathonContainerPortMapping().from_json(pm) + pm if isinstance( + pm, MarathonContainerPortMapping) else MarathonContainerPortMapping().from_json(pm) for pm in (port_mappings or []) ] - self.parameters = parameters or {} + self.parameters = parameters or [] self.privileged = privileged or False + self.force_pull_image = force_pull_image or False class MarathonContainerPortMapping(MarathonObject): + """Container port mapping. See https://mesosphere.github.io/marathon/docs/native-docker.html + :param str name: :param int container_port: :param int host_port: :param str protocol: + :param object labels: """ - PROTOCOLS=['tcp', 'udp'] + PROTOCOLS = ['tcp', 'udp', 'udp,tcp'] """Valid protocols""" - def __init__(self, container_port=None, host_port=0, service_port=None, protocol='tcp'): + def __init__(self, name=None, container_port=None, host_port=None, service_port=None, protocol='tcp', labels=None): + self.name = name self.container_port = container_port self.host_port = host_port self.service_port = service_port - if not protocol in self.PROTOCOLS: + if protocol not in self.PROTOCOLS: raise InvalidChoiceError('protocol', protocol, self.PROTOCOLS) self.protocol = protocol + self.labels = labels class MarathonContainerVolume(MarathonObject): + """Volume options. See https://mesosphere.github.io/marathon/docs/native-docker.html @@ -89,13 +117,17 @@ class MarathonContainerVolume(MarathonObject): :param str container_path: container path :param str host_path: host path :param str mode: one of ['RO', 'RW'] + :param object persistent: persistent volume options, should be of the form {'size': 1000} + :param object external: external volume options """ - MODES=['RO', 'RW'] + MODES = ['RO', 'RW'] - def __init__(self, container_path=None, host_path=None, mode='RW'): + def __init__(self, container_path=None, host_path=None, mode='RW', persistent=None, external=None): self.container_path = container_path self.host_path = host_path - if not mode in self.MODES: + if mode not in self.MODES: raise InvalidChoiceError('mode', mode, self.MODES) self.mode = mode + self.persistent = persistent + self.external = external diff --git a/marathon/models/deployment.py b/marathon/models/deployment.py index 956541c..7a98c85 100644 --- a/marathon/models/deployment.py +++ b/marathon/models/deployment.py @@ -1,10 +1,12 @@ -from .base import MarathonObject, MarathonResource +from .base import MarathonObject, MarathonResource, assert_valid_path class MarathonDeployment(MarathonResource): + """Marathon Application resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#deployments + https://mesosphere.github.io/marathon/docs/generated/api.html#v2_deployments_get :param list[str] affected_apps: list of affected app ids :param current_actions: current actions @@ -15,26 +17,37 @@ class MarathonDeployment(MarathonResource): :type steps: list[:class:`marathon.models.deployment.MarathonDeploymentAction`] or list[dict] :param int total_steps: total number of steps :param str version: version id + :param str affected_pods: list of strings """ def __init__(self, affected_apps=None, current_actions=None, current_step=None, id=None, steps=None, - total_steps=None, version=None): + total_steps=None, version=None, affected_pods=None): self.affected_apps = affected_apps self.current_actions = [ - a if isinstance(a, MarathonDeploymentAction) else MarathonDeploymentAction().from_json(a) + a if isinstance( + a, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(a) for a in (current_actions or []) ] self.current_step = current_step self.id = id - self.steps = [ - s if isinstance(a, MarathonDeploymentAction) else MarathonDeploymentAction().from_json(a) - for s in (steps or []) - ] + self.steps = [self.parse_deployment_step(step) for step in (steps or [])] self.total_steps = total_steps self.version = version + self.affected_pods = affected_pods + + def parse_deployment_step(self, step): + if step.__class__ == dict: + # This is what Marathon 1.0.0 returns: steps + return MarathonDeploymentStep().from_json(step) + elif step.__class__ == list: + # This is Marathon < 1.0.0 style, a list of actions + return [s if isinstance(s, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(s) for s in step] + else: + return step class MarathonDeploymentAction(MarathonObject): + """Marathon Application resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#deployments @@ -42,9 +55,54 @@ class MarathonDeploymentAction(MarathonObject): :param str action: action :param str app: app id :param str apps: app id (see https://github.com/mesosphere/marathon/pull/802) + :param type readiness_check_results: Undocumented """ - def __init__(self, action=None, app=None, apps=None): + def __init__(self, action=None, app=None, apps=None, type=None, readiness_check_results=None, pod=None): self.action = action - self.app = app + self.app = assert_valid_path(app) + self.apps = assert_valid_path(apps) + self.pod = pod + self.type = type # TODO: Remove builtin shadow + self.readiness_check_results = readiness_check_results # TODO: The docs say this is called just "readinessChecks?" + + +class MarathonDeploymentPlan(MarathonObject): + + def __init__(self, original=None, target=None, + steps=None, id=None, version=None): + self.original = MarathonDeploymentOriginalState.from_json(original) + self.target = MarathonDeploymentTargetState.from_json(target) + self.steps = [MarathonDeploymentStep.from_json(x) for x in steps] + self.id = id + self.version = version + + +class MarathonDeploymentStep(MarathonObject): + + def __init__(self, actions=None): + self.actions = [a if isinstance(a, MarathonDeploymentAction) else MarathonDeploymentAction.from_json(a) for a in (actions or [])] + + +class MarathonDeploymentOriginalState(MarathonObject): + + def __init__(self, dependencies=None, + apps=None, id=None, version=None, groups=None, pods=None): self.apps = apps + self.groups = groups + self.id = id + self.version = version + self.dependencies = dependencies + self.pods = pods + + +class MarathonDeploymentTargetState(MarathonObject): + + def __init__(self, groups=None, apps=None, + dependencies=None, id=None, version=None, pods=None): + self.apps = apps + self.groups = groups + self.id = id + self.version = version + self.dependencies = dependencies + self.pods = pods diff --git a/marathon/models/endpoint.py b/marathon/models/endpoint.py index 06b21b7..4e9c45c 100644 --- a/marathon/models/endpoint.py +++ b/marathon/models/endpoint.py @@ -1,56 +1,50 @@ -try: - import json -except ImportError: - import simplejson as json +from .base import MarathonObject -from .base import MarathonResource -from .task import MarathonTask +class MarathonEndpoint(MarathonObject): -class MarathonEndpoint(MarathonResource): - """(DEPRECATED) Marathon Endpoint resource. - - This was removed in Marathon 0.7.0. + """Marathon Endpoint helper object for service discovery. It describes a single port mapping for a running task. :param str app_id: application id :param str host: mesos slave running the task - :param str id: task id - :param int app_port: port seen by the application + :param str task_id: task id + :param int service_port: application service port :param int task_port: port allocated on the slave """ def __repr__(self): - return "{clazz}::{app_id}::{task_id}".format(clazz=self.__class__.__name__, app_id=self.app_id, task_id=self.task_id) - - def __init__(self, app_id=None, app_port=None, host=None, task_id=None, task_port=None): + return "{clazz}::{app_id}::{service_port}::{task_id}::{task_port}".format( + clazz=self.__class__.__name__, + app_id=self.app_id, + service_port=self.service_port, + task_id=self.task_id, + task_port=self.task_port + ) + + def __init__(self, app_id=None, service_port=None, + host=None, task_id=None, task_port=None): self.app_id = app_id - self.app_port = app_port + self.service_port = service_port self.host = host self.task_id = task_id self.task_port = task_port @classmethod - def from_json(cls, obj): - """Construct a list of MarathonEndpoints from a parsed endpoints response. + def from_tasks(cls, tasks): + """Construct a list of MarathonEndpoints from a list of tasks. - :param dict obj: object obj from parsed response + :param list[:class:`marathon.models.MarathonTask`] tasks: list of tasks to parse :rtype: list[:class:`MarathonEndpoint`] """ - app_id = obj.get('id') - app_ports = obj.get('ports') - f = MarathonTask() - endpoints = [] - tasks = [MarathonTask.from_json(i) for i in obj['instances']] - - for task in tasks: - # If this fails, fundamental assumptions around port mappings are incorrect and we need to bail - assert len(task.ports) == len(app_ports), "Mismatch between app and task ports. Something's wrong." - - for task_port in task.ports: - index = task.ports.index(task_port) - app_port = app_ports[index] - endpoints.append(cls(app_id=app_id, app_port=app_port, host=task.host, - task_id=task.id, task_port=task_port)) - - return endpoints \ No newline at end of file + + endpoints = [ + [ + MarathonEndpoint(task.app_id, task.service_ports[ + port_index], task.host, task.id, port) + for port_index, port in enumerate(task.ports) + ] + for task in tasks + ] + # Flatten result + return [item for sublist in endpoints for item in sublist] diff --git a/marathon/models/events.py b/marathon/models/events.py new file mode 100644 index 0000000..a0b3d4d --- /dev/null +++ b/marathon/models/events.py @@ -0,0 +1,217 @@ +""" +This module is used to translate Events from Marathon's EventBus system. +See: +* https://mesosphere.github.io/marathon/docs/event-bus.html +* https://github.com/mesosphere/marathon/blob/master/src/main/scala/mesosphere/marathon/core/event/Events.scala +""" + +from marathon.models.base import MarathonObject +from marathon.models.app import MarathonHealthCheck +from marathon.models.task import MarathonIpAddress +from marathon.models.deployment import MarathonDeploymentPlan +from marathon.exceptions import MarathonError + + +class MarathonEvent(MarathonObject): + + """ + The MarathonEvent base class handles the translation of Event objects sent by the + Marathon server into library MarathonObjects. + """ + + KNOWN_ATTRIBUTES = [] + attribute_name_to_marathon_object = { # Allows embedding of MarathonObjects inside events. + 'health_check': MarathonHealthCheck, + 'plan': MarathonDeploymentPlan, + 'ip_address': MarathonIpAddress, + } + seq_name_to_singular = { + 'ip_addresses': 'ip_address', + } + + def __init__(self, event_type, timestamp, **kwargs): + self.event_type = event_type # All events have these two attributes + self.timestamp = timestamp + for attribute in self.KNOWN_ATTRIBUTES: + self._set(attribute, kwargs.get(attribute)) + + def __to_marathon_object(self, attribute_name, attribute): + if attribute_name in self.attribute_name_to_marathon_object: + clazz = self.attribute_name_to_marathon_object[attribute_name] + # If this attribute already has a Marathon object instantiate it. + attribute = clazz.from_json(attribute) + return attribute + + def _set(self, attribute_name, attribute): + if not attribute: + return + # Special handling for lists... + if isinstance(attribute, list): + name = self.seq_name_to_singular.get(attribute_name) + attribute = [ + self.__to_marathon_object(name, v) + for v in attribute + ] + else: + attribute = self.__to_marathon_object(attribute_name, attribute) + setattr(self, attribute_name, attribute) + + +class MarathonApiPostEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'app_definition', 'uri'] + + +class MarathonStatusUpdateEvent(MarathonEvent): + KNOWN_ATTRIBUTES = [ + 'slave_id', 'task_id', 'task_status', 'app_id', 'host', 'ports', 'version', 'message', 'ip_addresses'] + + +class MarathonFrameworkMessageEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['slave_id', 'executor_id', 'message'] + + +class MarathonSubscribeEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'callback_url'] + + +class MarathonUnsubscribeEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'callback_url'] + + +class MarathonAddHealthCheckEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'version'] + + +class MarathonRemoveHealthCheckEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'health_check'] + + +class MarathonFailedHealthCheckEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id'] + + +class MarathonHealthStatusChangedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'health_check', 'task_id', 'instance_id', 'alive'] + + +class MarathonGroupChangeSuccess(MarathonEvent): + KNOWN_ATTRIBUTES = ['group_id', 'version'] + + +class MarathonGroupChangeFailed(MarathonEvent): + KNOWN_ATTRIBUTES = ['group_id', 'version', 'reason'] + + +class MarathonDeploymentSuccess(MarathonEvent): + KNOWN_ATTRIBUTES = ['id'] + + +class MarathonDeploymentFailed(MarathonEvent): + KNOWN_ATTRIBUTES = ['id'] + + +class MarathonDeploymentInfo(MarathonEvent): + KNOWN_ATTRIBUTES = ['plan', 'current_step'] + + +class MarathonDeploymentStepSuccess(MarathonEvent): + KNOWN_ATTRIBUTES = ['plan'] + + +class MarathonDeploymentStepFailure(MarathonEvent): + KNOWN_ATTRIBUTES = ['plan'] + + +class MarathonEventStreamAttached(MarathonEvent): + KNOWN_ATTRIBUTES = ['remote_address'] + + +class MarathonEventStreamDetached(MarathonEvent): + KNOWN_ATTRIBUTES = ['remote_address'] + + +class MarathonUnhealthyTaskKillEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'task_id', 'instance_id', 'version', 'reason'] + + +class MarathonAppTerminatedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id'] + + +class MarathonInstanceChangedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['instance_id', 'slave_id', 'condition', 'host', 'run_spec_id', 'run_spec_version'] + + +class MarathonUnknownInstanceTerminated(MarathonEvent): + KNOWN_ATTRIBUTES = ['instance_id', 'run_spec_id', 'condition'] + + +class MarathonInstanceHealthChangedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['instance_id', 'run_spec_id', 'run_spec_version', 'healthy'] + + +class MarathonPodCreatedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'uri'] + + +class MarathonPodUpdatedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'uri'] + + +class MarathonPodDeletedEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['client_ip', 'uri'] + + +class MarathonUnhealthyInstanceKillEvent(MarathonEvent): + KNOWN_ATTRIBUTES = ['app_id', 'task_id', 'instance_id', 'version', 'reason', 'host', 'slave_id'] + + +class EventFactory: + + """ + Handle an event emitted from the Marathon EventBus + See: https://mesosphere.github.io/marathon/docs/event-bus.html + """ + + def __init__(self): + pass + + event_to_class = { + 'api_post_event': MarathonApiPostEvent, + 'status_update_event': MarathonStatusUpdateEvent, + 'framework_message_event': MarathonFrameworkMessageEvent, + 'subscribe_event': MarathonSubscribeEvent, + 'unsubscribe_event': MarathonUnsubscribeEvent, + 'add_health_check_event': MarathonAddHealthCheckEvent, + 'remove_health_check_event': MarathonRemoveHealthCheckEvent, + 'failed_health_check_event': MarathonFailedHealthCheckEvent, + 'health_status_changed_event': MarathonHealthStatusChangedEvent, + 'unhealthy_task_kill_event': MarathonUnhealthyTaskKillEvent, + 'group_change_success': MarathonGroupChangeSuccess, + 'group_change_failed': MarathonGroupChangeFailed, + 'deployment_success': MarathonDeploymentSuccess, + 'deployment_failed': MarathonDeploymentFailed, + 'deployment_info': MarathonDeploymentInfo, + 'deployment_step_success': MarathonDeploymentStepSuccess, + 'deployment_step_failure': MarathonDeploymentStepFailure, + 'event_stream_attached': MarathonEventStreamAttached, + 'event_stream_detached': MarathonEventStreamDetached, + 'app_terminated_event': MarathonAppTerminatedEvent, + 'instance_changed_event': MarathonInstanceChangedEvent, + 'unknown_instance_terminated_event': MarathonUnknownInstanceTerminated, + 'unhealthy_instance_kill_event': MarathonUnhealthyInstanceKillEvent, + 'instance_health_changed_event': MarathonInstanceHealthChangedEvent, + 'pod_created_event': MarathonPodCreatedEvent, + 'pod_updated_event': MarathonPodUpdatedEvent, + 'pod_deleted_event': MarathonPodDeletedEvent, + } + + class_to_event = {v: k for k, v in event_to_class.items()} + + def process(self, event): + event_type = event['eventType'] + if event_type in self.event_to_class: + clazz = self.event_to_class[event_type] + return clazz.from_json(event) + else: + raise MarathonError(f'Unknown event_type: {event_type}, data: {event}') diff --git a/marathon/models/group.py b/marathon/models/group.py index b4f2efe..f68e230 100644 --- a/marathon/models/group.py +++ b/marathon/models/group.py @@ -3,6 +3,7 @@ class MarathonGroup(MarathonResource): + """Marathon group resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#groups @@ -13,10 +14,13 @@ class MarathonGroup(MarathonResource): :param groups: :type groups: list[:class:`marathon.models.group.MarathonGroup`] or list[dict] :param str id: + :param pods: + :type pods: list[:class:`marathon.models.pod.MarathonPod`] or list[dict] :param str version: """ - def __init__(self, apps=None, dependencies=None, groups=None, id=None, version=None): + def __init__(self, apps=None, dependencies=None, + groups=None, id=None, pods=None, version=None, enforce_role=None): self.apps = [ a if isinstance(a, MarathonApp) else MarathonApp().from_json(a) for a in (apps or []) @@ -26,5 +30,12 @@ def __init__(self, apps=None, dependencies=None, groups=None, id=None, version=N g if isinstance(g, MarathonGroup) else MarathonGroup().from_json(g) for g in (groups or []) ] + self.pods = [] + # ToDo: Create class MarathonPod + # self.pods = [ + # p if isinstance(p, MarathonPod) else MarathonPod().from_json(p) + # for p in (pods or []) + # ] self.id = id self.version = version + self.enforce_role = enforce_role diff --git a/marathon/models/info.py b/marathon/models/info.py index 329693d..68622a3 100644 --- a/marathon/models/info.py +++ b/marathon/models/info.py @@ -2,9 +2,11 @@ class MarathonInfo(MarathonResource): + """Marathon Info. - See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info + See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-v2-info + Also: https://mesosphere.github.io/marathon/docs/generated/api.html#v2_info_get :param str framework_id: :param str leader: @@ -18,12 +20,19 @@ class MarathonInfo(MarathonResource): :type http_config: :class:`marathon.models.info.MarathonHttpConfig` or dict :param event_subscriber: :type event_subscriber: :class`marathon.models.info.MarathonEventSubscriber` or dict + :param bool elected: + :param str buildref: """ def __init__(self, event_subscriber=None, framework_id=None, http_config=None, leader=None, marathon_config=None, - name=None, version=None, zookeeper_config=None): - self.event_subscriber = event_subscriber if isinstance(event_subscriber, MarathonEventSubscriber) \ - else MarathonEventSubscriber().from_json(event_subscriber) + name=None, version=None, elected=None, zookeeper_config=None, buildref=None): + if isinstance(event_subscriber, MarathonEventSubscriber): + self.event_subscriber = event_subscriber + elif event_subscriber is not None: + self.event_subscriber = MarathonEventSubscriber().from_json( + event_subscriber) + else: + self.event_subscriber = None self.framework_id = framework_id self.http_config = http_config if isinstance(http_config, MarathonHttpConfig) \ else MarathonHttpConfig().from_json(http_config) @@ -32,11 +41,14 @@ def __init__(self, event_subscriber=None, framework_id=None, http_config=None, l else MarathonConfig().from_json(marathon_config) self.name = name self.version = version + self.elected = elected self.zookeeper_config = zookeeper_config if isinstance(zookeeper_config, MarathonZooKeeperConfig) \ else MarathonZooKeeperConfig().from_json(zookeeper_config) + self.buildref = buildref class MarathonConfig(MarathonObject): + """Marathon config resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info @@ -44,37 +56,113 @@ class MarathonConfig(MarathonObject): :param bool checkpoint: :param str executor: :param int failover_timeout: + :param type features: Undocumented object + :param str framework_name: :param bool ha: :param str hostname: + :param int leader_proxy_connection_timeout_ms: + :param int leader_proxy_read_timeout_ms: :param int local_port_min: :param int local_port_max: + :param bool maintenance_mode: :param str master: + :param str mesos_leader_ui_url: :param str mesos_role: :param str mesos_user: + :param str new_group_enforce_role: + :param str webui_url: :param int reconciliation_initial_delay: :param int reconciliation_interval: :param int task_launch_timeout: + :param int task_reservation_timeout: + :param int marathon_store_timeout: + :param str access_control_allow_origin: + :param int decline_offer_duration: + :param str default_network_name: + :param str env_vars_prefix: + :param int launch_token: + :param int launch_token_refresh_interval: + :param int max_instances_per_offer: + :param str mesos_bridge_name: + :param int mesos_heartbeat_failure_threshold: + :param int mesos_heartbeat_interval: + :param int min_revive_offers_interval: + :param int offer_matching_timeout: + :param int on_elected_prepare_timeout: + :param bool revive_offers_for_new_apps: + :param int revive_offers_repetitions: + :param int scale_apps_initial_delay: + :param int scale_apps_interval: + :param bool store_cache: + :param int task_launch_confirm_timeout: + :param int task_lost_expunge_initial_delay: + :param int task_lost_expunge_interval: """ - def __init__(self, checkpoint=None, executor=None, failover_timeout=None, ha=None, hostname=None, - local_port_min=None, local_port_max=None, master=None, mesos_role=None, mesos_user=None, - reconciliation_initial_delay=None, reconciliation_interval=None, task_launch_timeout=None): + def __init__(self, checkpoint=None, executor=None, failover_timeout=None, framework_name=None, ha=None, + hostname=None, leader_proxy_connection_timeout_ms=None, leader_proxy_read_timeout_ms=None, + local_port_min=None, local_port_max=None, maintenance_mode=None, master=None, mesos_leader_ui_url=None, + mesos_role=None, mesos_user=None, new_group_enforce_role=None, webui_url=None, + reconciliation_initial_delay=None, reconciliation_interval=None, task_launch_timeout=None, + marathon_store_timeout=None, task_reservation_timeout=None, features=None, + access_control_allow_origin=None, decline_offer_duration=None, default_network_name=None, + env_vars_prefix=None, launch_token=None, launch_token_refresh_interval=None, + max_instances_per_offer=None, mesos_bridge_name=None, + mesos_heartbeat_failure_threshold=None, + mesos_heartbeat_interval=None, min_revive_offers_interval=None, + offer_matching_timeout=None, on_elected_prepare_timeout=None, + revive_offers_for_new_apps=None, + revive_offers_repetitions=None, scale_apps_initial_delay=None, + scale_apps_interval=None, store_cache=None, + task_launch_confirm_timeout=None, + task_lost_expunge_initial_delay=None, + task_lost_expunge_interval=None + ): self.checkpoint = checkpoint self.executor = executor self.failover_timeout = failover_timeout + self.features = features self.ha = ha self.hostname = hostname self.local_port_min = local_port_min self.local_port_max = local_port_max + self.maintenance_mode = maintenance_mode self.master = master + self.mesos_leader_ui_url = mesos_leader_ui_url self.mesos_role = mesos_role self.mesos_user = mesos_user + self.new_group_enforce_role = new_group_enforce_role + self.webui_url = webui_url self.reconciliation_initial_delay = reconciliation_initial_delay self.reconciliation_interval = reconciliation_interval self.task_launch_timeout = task_launch_timeout + self.task_reservation_timeout = task_reservation_timeout + self.marathon_store_timeout = marathon_store_timeout + self.access_control_allow_origin = access_control_allow_origin + self.decline_offer_duration = decline_offer_duration + self.default_network_name = default_network_name + self.env_vars_prefix = env_vars_prefix + self.launch_token = launch_token + self.launch_token_refresh_interval = launch_token_refresh_interval + self.max_instances_per_offer = max_instances_per_offer + self.mesos_bridge_name = mesos_bridge_name + self.mesos_heartbeat_failure_threshold = mesos_heartbeat_failure_threshold + self.mesos_heartbeat_interval = mesos_heartbeat_interval + self.min_revive_offers_interval = min_revive_offers_interval + self.offer_matching_timeout = offer_matching_timeout + self.on_elected_prepare_timeout = on_elected_prepare_timeout + self.revive_offers_for_new_apps = revive_offers_for_new_apps + self.revive_offers_repetitions = revive_offers_repetitions + self.scale_apps_initial_delay = scale_apps_initial_delay + self.scale_apps_interval = scale_apps_interval + self.store_cache = store_cache + self.task_launch_confirm_timeout = task_launch_confirm_timeout + self.task_lost_expunge_initial_delay = task_lost_expunge_initial_delay + self.task_lost_expunge_interval = task_lost_expunge_interval class MarathonZooKeeperConfig(MarathonObject): + """Marathon zookeeper config resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info @@ -82,21 +170,37 @@ class MarathonZooKeeperConfig(MarathonObject): :param str zk: :param dict zk_future_timeout: :param str zk_hosts: + :param str zk_max_versions: :param str zk_path: + :param str zk_session_timeout: :param str zk_state: :param int zk_timeout: + :param int zk_connection_timeout: + :param bool zk_compression: + :param int zk_compression_threshold: + :param int zk_max_node_size: """ - def __init__(self, zk=None, zk_future_timeout=None, zk_hosts=None, zk_path=None, zk_state=None, zk_timeout=None): + def __init__(self, zk=None, zk_future_timeout=None, zk_hosts=None, zk_max_versions=None, zk_path=None, + zk_session_timeout=None, zk_state=None, zk_timeout=None, zk_connection_timeout=None, + zk_compression=None, zk_compression_threshold=None, + zk_max_node_size=None): self.zk = zk - self.zk_future_timeout = zk_future_timeout self.zk_hosts = zk_hosts self.zk_path = zk_path self.zk_state = zk_state + self.zk_max_versions = zk_max_versions self.zk_timeout = zk_timeout + self.zk_connection_timeout = zk_connection_timeout + self.zk_future_timeout = zk_future_timeout + self.zk_session_timeout = zk_session_timeout + self.zk_compression = zk_compression + self.zk_compression_threshold = zk_compression_threshold + self.zk_max_node_size = zk_max_node_size class MarathonHttpConfig(MarathonObject): + """Marathon http config resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info @@ -113,6 +217,7 @@ def __init__(self, assets_path=None, http_port=None, https_port=None): class MarathonEventSubscriber(MarathonObject): + """Marathon event subscriber resource. See: https://mesosphere.github.io/marathon/docs/rest-api.html#get-/v2/info diff --git a/marathon/models/queue.py b/marathon/models/queue.py index acd7c9a..d53fd70 100644 --- a/marathon/models/queue.py +++ b/marathon/models/queue.py @@ -3,15 +3,49 @@ class MarathonQueueItem(MarathonResource): + """Marathon queue item. See: https://mesosphere.github.io/marathon/docs/rest-api.html#queue + List all the tasks queued up or waiting to be scheduled. This is mainly + used for troubleshooting and occurs when scaling changes are requested and the + volume of scaling changes out paces the ability to schedule those tasks. In + addition to the application in the queue, you see also the task count that + needs to be started. + + If the task has a rate limit, then a delay to the start gets applied. You + can see this delay for every application with the seconds to wait before + the next launch will be tried. + :param app: :type app: :class:`marathon.models.app.MarathonApp` or dict + :param delay: queue item delay + :type delay: :class:`marathon.models.app.MarathonQueueItemDelay` or dict :param bool overdue: """ - def __init__(self, app=None, overdue=None): - self.app = app if isinstance(app, MarathonApp) else MarathonApp().from_json(app) + def __init__(self, app=None, overdue=None, count=None, delay=None, since=None, + processed_offers_summary=None, last_unused_offers=None): + self.app = app if isinstance( + app, MarathonApp) else MarathonApp().from_json(app) + self.overdue = overdue + self.count = count + self.delay = delay if isinstance( + delay, MarathonQueueItemDelay) else MarathonQueueItemDelay().from_json(delay) + self.since = since + self.processed_offers_summary = processed_offers_summary + self.last_unused_offers = last_unused_offers + + +class MarathonQueueItemDelay(MarathonResource): + + """Marathon queue item delay. + + :param int time_left_seconds: Seconds to wait before the next launch will be tried. + :param bool overdue: Is the queue item overdue. + """ + + def __init__(self, time_left_seconds=None, overdue=None): + self.time_left_seconds = time_left_seconds self.overdue = overdue diff --git a/marathon/models/task.py b/marathon/models/task.py index 1967fbc..0348c6a 100644 --- a/marathon/models/task.py +++ b/marathon/models/task.py @@ -1,9 +1,9 @@ -from datetime import datetime - from .base import MarathonResource, MarathonObject +from ..util import to_datetime class MarathonTask(MarathonResource): + """Marathon Task resource. :param str app_id: application id @@ -13,35 +13,62 @@ class MarathonTask(MarathonResource): :param str id: task id :param list[int] ports: allocated ports :param list[int] service_ports: ports exposed for load balancing + :param str state: State of the task e.g. TASK_RUNNING + :param str slave_id: Mesos slave id :param staged_at: when this task was staged :type staged_at: datetime or str :param started_at: when this task was started :type started_at: datetime or str :param str version: app version with which this task was started + :type region: str + :param region: fault domain region support in DCOS EE + :type zone: str + :param zone: fault domain zone support in DCOS EE + :type role: str + :param role: mesos role """ DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' def __init__(self, app_id=None, health_check_results=None, host=None, id=None, ports=None, service_ports=None, - staged_at=None, started_at=None, version=None): + slave_id=None, staged_at=None, started_at=None, version=None, ip_addresses=[], state=None, local_volumes=None, + region=None, zone=None, role=None): self.app_id = app_id self.health_check_results = health_check_results or [] self.health_check_results = [ - hcr if isinstance(hcr, MarathonHealthCheckResult) else MarathonHealthCheckResult().from_json(hcr) + hcr if isinstance( + hcr, MarathonHealthCheckResult) else MarathonHealthCheckResult().from_json(hcr) for hcr in (health_check_results or []) if any(health_check_results) ] self.host = host self.id = id self.ports = ports or [] self.service_ports = service_ports or [] - self.staged_at = staged_at if (staged_at is None or isinstance(staged_at, datetime)) \ - else datetime.strptime(staged_at, self.DATETIME_FORMAT) - self.started_at = started_at if (started_at is None or isinstance(started_at, datetime)) \ - else datetime.strptime(started_at, self.DATETIME_FORMAT) + self.slave_id = slave_id + self.staged_at = to_datetime(staged_at) + self.started_at = to_datetime(started_at) + self.state = state self.version = version + self.ip_addresses = [ + ipaddr if isinstance( + ip_addresses, MarathonIpAddress) else MarathonIpAddress().from_json(ipaddr) + for ipaddr in (ip_addresses or [])] + self.local_volumes = local_volumes or [] + self.region = region + self.zone = zone + self.role = role + + +class MarathonIpAddress(MarathonObject): + """ + """ + def __init__(self, ip_address=None, protocol=None): + self.ip_address = ip_address + self.protocol = protocol class MarathonHealthCheckResult(MarathonObject): + """Marathon health check result. See https://mesosphere.github.io/marathon/docs/health-checks.html @@ -53,19 +80,19 @@ class MarathonHealthCheckResult(MarathonObject): :param str last_failure_cause: cause for last failure :param str last_success: last time when which healthcheck succeeded :param str task_id: task id + :param str instance_id: instance id """ DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' def __init__(self, alive=None, consecutive_failures=None, first_success=None, - last_failure=None, last_success=None, task_id=None, last_failure_cause=None): + last_failure=None, last_success=None, task_id=None, + last_failure_cause=None, instance_id=None): self.alive = alive self.consecutive_failures = consecutive_failures - self.first_success = first_success if (first_success is None or isinstance(first_success, datetime)) \ - else datetime.strptime(first_success, self.DATETIME_FORMAT) - self.last_failure = last_failure if (last_failure is None or isinstance(last_failure, datetime)) \ - else datetime.strptime(last_failure, self.DATETIME_FORMAT) - self.last_success = last_success if (last_success is None or isinstance(last_success, datetime)) \ - else datetime.strptime(last_success, self.DATETIME_FORMAT) + self.first_success = to_datetime(first_success) + self.last_failure = to_datetime(last_failure) + self.last_success = to_datetime(last_success) self.task_id = task_id self.last_failure_cause = last_failure_cause + self.instance_id = instance_id diff --git a/marathon/util.py b/marathon/util.py index 09e0420..af2932e 100644 --- a/marathon/util.py +++ b/marathon/util.py @@ -1,6 +1,12 @@ -import collections +# collections.abc new as of 3.3, and collections is deprecated. collections +# will be unavailable in 3.9 +try: + import collections.abc as collections +except ImportError: + import collections + import datetime -import types +import logging try: import json @@ -9,7 +15,12 @@ import re +def get_log(): + return logging.getLogger(__name__.split('.')[0]) + + class MarathonJsonEncoder(json.JSONEncoder): + """Custom JSON encoder for Marathon object serialization.""" def default(self, obj): @@ -17,18 +28,19 @@ def default(self, obj): return self.default(obj.json_repr()) if isinstance(obj, datetime.datetime): - return obj.isoformat() + return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - if isinstance(obj, collections.Iterable) and not isinstance(obj, types.StringTypes): - if hasattr(obj, 'iteritems'): - return {k: self.default(v) for k,v in obj.iteritems()} - else: + if isinstance(obj, collections.Iterable) and not isinstance(obj, str): + try: + return {k: self.default(v) for k, v in obj.items()} + except AttributeError: return [self.default(e) for e in obj] return obj class MarathonMinimalJsonEncoder(json.JSONEncoder): + """Custom JSON encoder for Marathon object serialization.""" def default(self, obj): @@ -36,22 +48,40 @@ def default(self, obj): return self.default(obj.json_repr(minimal=True)) if isinstance(obj, datetime.datetime): - return obj.isoformat() + return obj.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - if isinstance(obj, collections.Iterable) and not isinstance(obj, types.StringTypes): - if hasattr(obj, 'iteritems'): - return {k: self.default(v) for k,v in obj.iteritems() if (v or v == False)} - else: - return [self.default(e) for e in obj if (e or e == False)] + if isinstance(obj, collections.Iterable) and not isinstance(obj, str): + try: + return {k: self.default(v) for k, v in obj.items() if (v or v in (False, 0))} + except AttributeError: + return [self.default(e) for e in obj if (e or e in (False, 0))] return obj def to_camel_case(snake_str): words = snake_str.split('_') - return words[0] + ''.join(w.title() for w in words[1:]) + return words[0] + ''.join(w.capitalize() for w in words[1:]) def to_snake_case(camel_str): s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str) - return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() \ No newline at end of file + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + +DATETIME_FORMATS = [ + '%Y-%m-%dT%H:%M:%S.%fZ', + '%Y-%m-%dT%H:%M:%SZ', # Marathon omits milliseconds when they would be .000 +] + + +def to_datetime(timestamp): + if (timestamp is None or isinstance(timestamp, datetime.datetime)): + return timestamp + else: + for fmt in DATETIME_FORMATS: + try: + return datetime.datetime.strptime(timestamp, fmt).replace(tzinfo=datetime.timezone.utc) + except ValueError: + pass + raise ValueError(f'Unrecognized datetime format: {timestamp}') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c20f36f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.20.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 32a4e6c..fa25a98 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python import sys from setuptools import setup @@ -5,22 +6,29 @@ if sys.version_info >= (3,): extra['use_2to3'] = True -setup(name='marathon', - version='0.6.10', - description='Marathon Client Library', - long_description="""Python interface to the Mesos Marathon REST API.""", - author='Mike Babineau', - author_email='michael.babineau@gmail.com', - install_requires=[ 'requests>=2.0.0' ], - url='https://github.com/thefactory/marathon-python', - packages=['marathon', 'marathon.models'], - license='MIT', - platforms='Posix; MacOS X; Windows', - classifiers=['Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Topic :: Software Development :: Libraries :: Python Modules'], - **extra -) \ No newline at end of file +setup( + name='marathon', + version='0.13.0', + description='Marathon Client Library', + long_description="""Python interface to the Mesos Marathon REST API.""", + author='Mike Babineau', + author_email='michael.babineau@gmail.com', + install_requires=['requests>=2.4.0', 'requests-toolbelt>=0.4.0'], + url='https://github.com/thefactory/marathon-python', + packages=['marathon', 'marathon.models'], + license='MIT', + platforms='Posix; MacOS X; Windows', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Python Modules' + ], + **extra +) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3b0ec7d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,215 @@ +import requests_mock +from marathon import MarathonClient +from marathon import models + + +def test_get_deployments_pre_1_0(): + fake_response = """[ + { + "affectedApps": [ + "/test" + ], + "id": "fakeid", + "steps": [ + [ + { + "action": "ScaleApplication", + "app": "/test" + } + ] + ], + "currentActions": [ + { + "action": "ScaleApplication", + "app": "/test" + } + ], + "version": "fakeversion", + "currentStep": 1, + "totalSteps": 1 + } + ]""" + with requests_mock.mock() as m: + m.get('http://fake_server/v2/deployments', text=fake_response) + mock_client = MarathonClient(servers='http://fake_server') + actual_deployments = mock_client.list_deployments() + expected_deployments = [models.MarathonDeployment( + id="fakeid", + steps=[ + [models.MarathonDeploymentAction( + action="ScaleApplication", app="/test")]], + current_actions=[models.MarathonDeploymentAction( + action="ScaleApplication", app="/test")], + current_step=1, + total_steps=1, + affected_apps=["/test"], + version="fakeversion" + )] + assert expected_deployments == actual_deployments + + +def test_get_deployments_post_1_0(): + fake_response = """[ + { + "id": "4d2ff4d8-fbe5-4239-a886-f0831ed68d20", + "version": "2016-04-20T18:00:20.084Z", + "affectedApps": [ + "/test-trivial-app" + ], + "steps": [ + { + "actions": [ + { + "type": "StartApplication", + "app": "/test-trivial-app" + } + ] + }, + { + "actions": [ + { + "type": "ScaleApplication", + "app": "/test-trivial-app" + } + ] + } + ], + "currentActions": [ + { + "action": "ScaleApplication", + "app": "/test-trivial-app", + "readinessCheckResults": [] + } + ], + "currentStep": 2, + "totalSteps": 2 + } + ]""" + with requests_mock.mock() as m: + m.get('http://fake_server/v2/deployments', text=fake_response) + mock_client = MarathonClient(servers='http://fake_server') + actual_deployments = mock_client.list_deployments() + expected_deployments = [models.MarathonDeployment( + id="4d2ff4d8-fbe5-4239-a886-f0831ed68d20", + steps=[ + models.MarathonDeploymentStep( + actions=[models.MarathonDeploymentAction( + type="StartApplication", app="/test-trivial-app")], + ), + models.MarathonDeploymentStep( + actions=[models.MarathonDeploymentAction( + type="ScaleApplication", app="/test-trivial-app")], + ), + ], + current_actions=[models.MarathonDeploymentAction( + action="ScaleApplication", app="/test-trivial-app", readiness_check_results=[]) + ], + current_step=2, + total_steps=2, + affected_apps=["/test-trivial-app"], + version="2016-04-20T18:00:20.084Z" + )] + # Helpful for tox to see the diff + assert expected_deployments[0].__dict__ == actual_deployments[0].__dict__ + assert expected_deployments == actual_deployments + + +def test_list_tasks_with_app_id(): + fake_response = '{ "tasks": [ { "appId": "/anapp", "healthCheckResults": ' \ + '[ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", ' \ + '"lastFailure": null, "lastSuccess": "2014-10-03T22:57:41.643Z", "taskId": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" } ],' \ + ' "host": "10.141.141.10", "id": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", "ports": [ 31000 ], ' \ + '"servicePorts": [ 9000 ], "stagedAt": "2014-10-03T22:16:27.811Z", "startedAt": "2014-10-03T22:57:41.587Z", ' \ + '"version": "2014-10-03T22:16:23.634Z" }]}' + with requests_mock.mock() as m: + m.get('http://fake_server/v2/apps//anapp/tasks', text=fake_response) + mock_client = MarathonClient(servers='http://fake_server') + actual_deployments = mock_client.list_tasks(app_id='/anapp') + expected_deployments = [models.task.MarathonTask( + app_id="/anapp", + health_check_results=[ + models.task.MarathonHealthCheckResult( + alive=True, + consecutive_failures=0, + first_success="2014-10-03T22:57:02.246Z", + last_failure=None, + last_success="2014-10-03T22:57:41.643Z", + task_id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" + ) + ], + host="10.141.141.10", + id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", + ports=[ + 31000 + ], + service_ports=[ + 9000 + ], + staged_at="2014-10-03T22:16:27.811Z", + started_at="2014-10-03T22:57:41.587Z", + version="2014-10-03T22:16:23.634Z" + )] + assert actual_deployments == expected_deployments + + +def test_list_tasks_without_app_id(): + fake_response = '{ "tasks": [ { "appId": "/anapp", "healthCheckResults": ' \ + '[ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", "lastFailure": null, ' \ + '"lastSuccess": "2014-10-03T22:57:41.643Z", "taskId": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" } ],' \ + ' "host": "10.141.141.10", "id": "bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", "ports": [ 31000 ], ' \ + '"servicePorts": [ 9000 ], "stagedAt": "2014-10-03T22:16:27.811Z", "startedAt": "2014-10-03T22:57:41.587Z", ' \ + '"version": "2014-10-03T22:16:23.634Z" }, { "appId": "/anotherapp", ' \ + '"healthCheckResults": [ { "alive": true, "consecutiveFailures": 0, "firstSuccess": "2014-10-03T22:57:02.246Z", ' \ + '"lastFailure": null, "lastSuccess": "2014-10-03T22:57:41.649Z", "taskId": "bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799" } ], ' \ + '"host": "10.141.141.10", "id": "bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799", "ports": [ 31001 ], "servicePorts": [ 9000 ], ' \ + '"stagedAt": "2014-10-03T22:16:33.814Z", "startedAt": "2014-10-03T22:57:41.593Z", "version": "2014-10-03T22:16:23.634Z" } ] }' + with requests_mock.mock() as m: + m.get('http://fake_server/v2/tasks', text=fake_response) + mock_client = MarathonClient(servers='http://fake_server') + actual_deployments = mock_client.list_tasks() + expected_deployments = [ + models.task.MarathonTask( + app_id="/anapp", + health_check_results=[ + models.task.MarathonHealthCheckResult( + alive=True, + consecutive_failures=0, + first_success="2014-10-03T22:57:02.246Z", + last_failure=None, + last_success="2014-10-03T22:57:41.643Z", + task_id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799" + ) + ], + host="10.141.141.10", + id="bridged-webapp.eb76c51f-4b4a-11e4-ae49-56847afe9799", + ports=[ + 31000 + ], + service_ports=[ + 9000 + ], + staged_at="2014-10-03T22:16:27.811Z", + started_at="2014-10-03T22:57:41.587Z", + version="2014-10-03T22:16:23.634Z" + ), + models.task.MarathonTask( + app_id="/anotherapp", + health_check_results=[ + models.task.MarathonHealthCheckResult( + alive=True, + consecutive_failures=0, + first_success="2014-10-03T22:57:02.246Z", + last_failure=None, + last_success="2014-10-03T22:57:41.649Z", + task_id="bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799" + ) + ], + host="10.141.141.10", + id="bridged-webapp.ef0b5d91-4b4a-11e4-ae49-56847afe9799", + ports=[31001], + service_ports=[9000], + staged_at="2014-10-03T22:16:33.814Z", + started_at="2014-10-03T22:57:41.593Z", + version="2014-10-03T22:16:23.634Z" + )] + assert actual_deployments == expected_deployments diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..906c119 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,38 @@ +import json + +import requests + +from marathon.exceptions import MarathonHttpError, InternalServerError + + +def test_400_error(): + fake_response = requests.Response() + fake_message = "Invalid JSON" + fake_details = [{"path": "/taskKillGracePeriodSeconds", "errors": ["error.expected.jsnumber"]}] + fake_response._content = json.dumps({"message": fake_message, "details": fake_details}).encode() + fake_response.status_code = 400 + fake_response.headers['Content-Type'] = 'application/json' + + exc = MarathonHttpError(fake_response) + assert exc.status_code == 400 + assert exc.error_message == fake_message + assert exc.error_details == fake_details + + +def test_503_error(): + fake_response = requests.Response() + fake_response._content = """ + +Error 503 + + +

HTTP ERROR: 503

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